diff --git a/.changeset/wild-cats-march.md b/.changeset/wild-cats-march.md
new file mode 100644
index 000000000000..fd275fb2b52c
--- /dev/null
+++ b/.changeset/wild-cats-march.md
@@ -0,0 +1,16 @@
+---
+"@logto/console": minor
+"@logto/core": minor
+"@logto/core-kit": minor
+"@logto/phrases": minor
+"@logto/schemas": minor
+---
+
+support wildcard patterns in redirect URIs
+
+Added support for wildcard patterns (`*`) in redirect URIs to better support dynamic environments like preview deployments.
+
+Rules (web only):
+- Wildcards are allowed for http/https redirect URIs in the hostname and/or pathname.
+- Wildcards are rejected in scheme, port, query, and hash.
+- Hostname wildcard patterns must contain at least one dot to avoid overly broad patterns.
diff --git a/package.json b/package.json
index 050733d3ef2d..befc57b907b7 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,8 @@
}
},
"patchedDependencies": {
- "tsup": "patches/tsup.patch"
+ "tsup": "patches/tsup.patch",
+ "oidc-provider": "patches/oidc-provider.patch"
}
},
"dependencies": {
diff --git a/packages/console/src/mdx-components/UriInputField/index.tsx b/packages/console/src/mdx-components/UriInputField/index.tsx
index ce864dc3fdc5..b3ca60483767 100644
--- a/packages/console/src/mdx-components/UriInputField/index.tsx
+++ b/packages/console/src/mdx-components/UriInputField/index.tsx
@@ -24,7 +24,7 @@ import type {
OidcClientMetadataKey,
} from '@/types/guide';
import { trySubmitSafe } from '@/utils/form';
-import { uriValidator } from '@/utils/validator';
+import { redirectUriValidator, uriValidator } from '@/utils/validator';
import styles from './index.module.scss';
@@ -68,6 +68,10 @@ function UriInputField(props: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useApi();
const title: AdminConsoleKey = nameToKey[name];
+ const validator =
+ name === 'redirectUris' || name === 'postLogoutRedirectUris'
+ ? redirectUriValidator
+ : uriValidator;
const onSubmit = trySubmitSafe(async (value: string[]) => {
if (!appId || !data) {
@@ -117,7 +121,7 @@ function UriInputField(props: Props) {
validate: createValidatorForRhf({
required: t('errors.required_field_missing_plural', { field: t(title) }),
pattern: {
- verify: (value) => !value || uriValidator(value),
+ verify: (value) => !value || validator(value),
message: t('errors.invalid_uri_format'),
},
}),
diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx
index dc22042d9a49..5e03aa6617d2 100644
--- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx
+++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/Settings.tsx
@@ -38,6 +38,20 @@ const hasMixedUriProtocols = (applicationType: ApplicationType, uris: string[]):
}
};
+const hasWildcardUri = (uris?: string[]) => Boolean(uris?.some((uri) => uri.includes('*')));
+
+/**
+ * Validates redirect URIs based on application type.
+ * Wildcards are only allowed for web applications (SPA and Traditional), not for native apps.
+ */
+const createRedirectUriValidator = (applicationType: ApplicationType) => (value: string) => {
+ // Native apps don't support wildcard redirect URIs
+ if (applicationType === ApplicationType.Native && value.includes('*')) {
+ return false;
+ }
+ return validateRedirectUrl(value, 'web') || validateRedirectUrl(value, 'mobile');
+};
+
function MixedUriWarning() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@@ -47,6 +61,15 @@ function MixedUriWarning() {
);
}
+function WildcardUriWarning() {
+ const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
+ return (
+
+ {t('application_details.wildcard_redirect_uri_warning')}
+
+ );
+}
+
type Props = {
readonly data: Application;
};
@@ -63,10 +86,10 @@ function Settings({ data }: Props) {
const { type: applicationType, isThirdParty } = data;
const isProtectedApp = applicationType === ApplicationType.Protected;
+ const redirectUriValidator = createRedirectUriValidator(applicationType);
const uriPatternRules: MultiTextInputRule = {
pattern: {
- verify: (value) =>
- !value || validateRedirectUrl(value, 'web') || validateRedirectUrl(value, 'mobile'),
+ verify: (value) => !value || redirectUriValidator(value),
message: t('errors.invalid_uri_format'),
},
};
@@ -77,6 +100,8 @@ function Settings({ data }: Props) {
applicationType,
postLogoutRedirectUris
);
+ const showRedirectUriWildcardWarning = hasWildcardUri(redirectUris);
+ const showPostLogoutUriWildcardWarning = hasWildcardUri(postLogoutRedirectUris);
if (isProtectedApp) {
return ;
@@ -143,6 +168,7 @@ function Settings({ data }: Props) {
)}
/>
)}
+ {showRedirectUriWildcardWarning && }
{showRedirectUriMixedWarning && }
{applicationType !== ApplicationType.MachineToMachine && (
)}
+ {showPostLogoutUriWildcardWarning && }
{showPostLogoutUriMixedWarning && }
{applicationType !== ApplicationType.MachineToMachine && (
{
return true;
};
+/**
+ * Checks if the path contains dot segments (.., ., %2e, %2e%2e) that could be used
+ * for path traversal attacks.
+ */
+const hasDotSegmentsInPath = (url: string, schemeSeparatorIndex: number): boolean => {
+ const authority = url.slice(schemeSeparatorIndex + 3).split(/[#/?]/)[0] ?? '';
+ const afterAuthorityIndex = schemeSeparatorIndex + 3 + authority.length;
+ const rest = url.slice(afterAuthorityIndex);
+ const path = rest.split(/[#?]/)[0] ?? '';
+
+ if (!path) {
+ return false;
+ }
+
+ const segments = path.split('/');
+ return segments.some((segment) => {
+ const normalized = segment.toLowerCase();
+ return segment === '.' || segment === '..' || normalized === '%2e' || normalized === '%2e%2e';
+ });
+};
+
+const hasWildcardInQueryOrHash = (url: string): boolean => {
+ const queryIndex = url.indexOf('?');
+ if (queryIndex >= 0 && url.slice(queryIndex).includes('*')) {
+ return true;
+ }
+
+ const hashIndex = url.indexOf('#');
+ return hashIndex >= 0 && url.slice(hashIndex).includes('*');
+};
+
+const isValidWildcardAuthority = (authority: string): boolean => {
+ // Disallow credentials and IPv6 literals for simplicity.
+ if (!authority || authority.includes('@') || authority.startsWith('[')) {
+ return false;
+ }
+
+ // Disallow wildcards in port segment.
+ const lastColonIndex = authority.lastIndexOf(':');
+ const hasPort = lastColonIndex > -1 && authority.indexOf(':') === lastColonIndex;
+ const hostname = hasPort ? authority.slice(0, lastColonIndex) : authority;
+
+ // When wildcard is used in hostname, require at least one dot to avoid overly broad patterns.
+ if (hostname.includes('*') && !hostname.includes('.')) {
+ return false;
+ }
+
+ if (hasPort && authority.slice(lastColonIndex + 1).includes('*')) {
+ return false;
+ }
+
+ return true;
+};
+
+const isValidWildcardUrl = (value: string): boolean => {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(value.replaceAll('*', 'wildcard'));
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const redirectUriValidator = (value: string) => {
+ if (!value.includes('*')) {
+ return uriValidator(value);
+ }
+
+ const schemeSeparatorIndex = value.indexOf('://');
+ if (schemeSeparatorIndex <= 0) {
+ return false;
+ }
+
+ // Disallow wildcards in scheme (check before scheme validation).
+ if (value.slice(0, schemeSeparatorIndex).includes('*')) {
+ return false;
+ }
+
+ const scheme = value.slice(0, schemeSeparatorIndex).toLowerCase();
+ if (scheme !== 'http' && scheme !== 'https') {
+ return false;
+ }
+
+ if (hasWildcardInQueryOrHash(value)) {
+ return false;
+ }
+
+ const authority = value.slice(schemeSeparatorIndex + 3).split(/[#/?]/)[0] ?? '';
+ if (!isValidWildcardAuthority(authority)) {
+ return false;
+ }
+
+ if (hasDotSegmentsInPath(value, schemeSeparatorIndex)) {
+ return false;
+ }
+
+ return isValidWildcardUrl(value);
+};
+
export const jsonValidator = (value: string) => {
try {
JSON.parse(value);
diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts
index 58ece730ee91..4d9b019e77ea 100644
--- a/packages/core/src/oidc/utils.test.ts
+++ b/packages/core/src/oidc/utils.test.ts
@@ -128,6 +128,107 @@ describe('isOriginAllowed', () => {
)
).toBeTruthy();
});
+
+ it('should return true if redirectUris include a wildcard pattern that matches the origin', () => {
+ expect(
+ isOriginAllowed(
+ 'https://pr-123.myapp.example.com',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['https://*.myapp.example.com/callback']
+ )
+ ).toBeTruthy();
+ });
+
+ it('should return false if wildcard pattern does not match the origin (no subdomain)', () => {
+ // *.example.com should NOT match example.com (requires at least one subdomain label)
+ expect(
+ isOriginAllowed(
+ 'https://example.com',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['https://*.example.com/callback']
+ )
+ ).toBeFalsy();
+ });
+
+ it('should handle port matching with wildcards correctly', () => {
+ // Same port should match
+ expect(
+ isOriginAllowed(
+ 'https://pr-123.example.com:8080',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['https://*.example.com:8080/callback']
+ )
+ ).toBeTruthy();
+
+ // Different port should not match
+ expect(
+ isOriginAllowed(
+ 'https://pr-123.example.com:3000',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['https://*.example.com:8080/callback']
+ )
+ ).toBeFalsy();
+
+ // Default port (443 for https) should match when not specified
+ expect(
+ isOriginAllowed(
+ 'https://pr-123.example.com',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['https://*.example.com/callback']
+ )
+ ).toBeTruthy();
+ });
+
+ it('should return false for protocol mismatch', () => {
+ expect(
+ isOriginAllowed(
+ 'http://pr-123.example.com',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['https://*.example.com/callback']
+ )
+ ).toBeFalsy();
+ });
+
+ it('should return true when only one of multiple wildcard patterns matches', () => {
+ expect(
+ isOriginAllowed(
+ 'https://pr-123.myapp.example.com',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ [
+ 'https://*.other-domain.com/callback',
+ 'https://*.myapp.example.com/callback',
+ 'https://*.another.com/callback',
+ ]
+ )
+ ).toBeTruthy();
+ });
+
+ it('should handle malformed wildcard patterns gracefully', () => {
+ // Invalid/malformed patterns should not crash and should return false
+ expect(
+ isOriginAllowed(
+ 'https://example.com',
+ {
+ [CustomClientMetadataKey.CorsAllowedOrigins]: [],
+ },
+ ['not-a-valid-url', 'https://*.example.com/callback']
+ )
+ ).toBeFalsy();
+ });
});
describe('buildLoginPromptUrl', () => {
diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts
index 5602a0e9a72d..38ed047eb0cf 100644
--- a/packages/core/src/oidc/utils.ts
+++ b/packages/core/src/oidc/utils.ts
@@ -10,7 +10,7 @@ import {
FirstScreen,
experience,
} from '@logto/schemas';
-import { conditional } from '@silverhand/essentials';
+import { conditional, trySafe } from '@silverhand/essentials';
import { type AllClientMetadata, type ClientAuthMethod, errors } from 'oidc-provider';
import { type EnvSet } from '#src/env-set/index.js';
@@ -76,9 +76,133 @@ export const isOriginAllowed = (
{ corsAllowedOrigins = [] }: CustomClientMetadata,
redirectUris: string[] = []
) => {
- const redirectUriOrigins = redirectUris.map((uri) => new URL(uri).origin);
+ if (corsAllowedOrigins.includes(origin)) {
+ return true;
+ }
+
+ for (const uri of redirectUris) {
+ if (!uri.includes('*')) {
+ try {
+ if (new URL(uri).origin === origin) {
+ return true;
+ }
+ } catch {
+ continue;
+ }
+
+ continue;
+ }
+
+ if (matchesOriginAgainstRedirectUriPattern(origin, uri)) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+const getEffectivePort = (protocol: string, port: string) => {
+ if (port) {
+ return port;
+ }
+
+ switch (protocol) {
+ case 'http:': {
+ return '80';
+ }
+
+ case 'https:': {
+ return '443';
+ }
+
+ default: {
+ return '';
+ }
+ }
+};
+
+const escapeRegExp = (value: string) => value.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
+
+const matchHostnameLabel = (pattern: string, actual: string) => {
+ if (!pattern.includes('*')) {
+ return pattern === actual;
+ }
+
+ const regex = new RegExp(
+ `^${pattern
+ .split('*')
+ .map((part) => escapeRegExp(part))
+ .join('[^.]+')}$`,
+ 'i'
+ );
+ return regex.test(actual);
+};
+
+const matchHostnamePattern = (patternHostname: string, actualHostname: string) => {
+ const patternLabels = patternHostname.toLowerCase().split('.');
+ const actualLabels = actualHostname.toLowerCase().split('.');
+
+ if (patternLabels.length !== actualLabels.length) {
+ return false;
+ }
+
+ return patternLabels.every((patternLabel, index) =>
+ matchHostnameLabel(patternLabel, actualLabels[index] ?? '')
+ );
+};
+
+const parseRedirectUriOriginPattern = (patternUrl: string) => {
+ const schemeSeparatorIndex = patternUrl.indexOf('://');
+ if (schemeSeparatorIndex <= 0) {
+ return;
+ }
+
+ // Parse a placeholder URL to validate scheme/port and other basic URL parts.
+ const parsed = trySafe(() => new URL(patternUrl.replaceAll('*', 'wildcard')));
+ if (!parsed) {
+ return;
+ }
+
+ const rest = patternUrl.slice(schemeSeparatorIndex + 3);
+ const authority = rest.split(/[#/?]/)[0] ?? '';
+ if (!authority || authority.includes('@') || authority.startsWith('[')) {
+ return;
+ }
+
+ const lastColonIndex = authority.lastIndexOf(':');
+ const hasPort = lastColonIndex > -1 && authority.indexOf(':') === lastColonIndex;
+ const hostnamePattern = hasPort ? authority.slice(0, lastColonIndex) : authority;
+
+ return {
+ protocol: parsed.protocol,
+ hostnamePattern,
+ port: parsed.port,
+ };
+};
+
+const matchesOriginAgainstRedirectUriPattern = (origin: string, redirectUriPattern: string) => {
+ const pattern = parseRedirectUriOriginPattern(redirectUriPattern);
+ if (!pattern) {
+ return false;
+ }
+
+ const parsedOrigin = trySafe(() => new URL(origin));
+ if (!parsedOrigin) {
+ return false;
+ }
+
+ if (parsedOrigin.protocol !== pattern.protocol) {
+ return false;
+ }
+
+ if (
+ getEffectivePort(parsedOrigin.protocol, parsedOrigin.port) !==
+ getEffectivePort(pattern.protocol, pattern.port)
+ ) {
+ return false;
+ }
- return [...corsAllowedOrigins, ...redirectUriOrigins].includes(origin);
+ return matchHostnamePattern(pattern.hostnamePattern, parsedOrigin.hostname);
};
export const getUtcStartOfTheDay = (date: Date) => {
diff --git a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts
index 76c474e2de16..7ae328c8553c 100644
--- a/packages/phrases/src/locales/en/translation/admin-console/application-details.ts
+++ b/packages/phrases/src/locales/en/translation/admin-console/application-details.ts
@@ -43,6 +43,8 @@ const application_details = {
'The URI redirects after a user sign-in (whether successful or not). See OpenID Connect AuthRequest for more info.',
mixed_redirect_uri_warning:
'Your application type is not compatible with at least one of the redirect URIs. It does not follow best practices and we strongly recommend keeping the redirect URIs consistent.',
+ wildcard_redirect_uri_warning:
+ 'Wildcard redirect URIs are not standard OIDC and can increase the attack surface. Use with care and prefer exact redirect URIs whenever possible.',
post_sign_out_redirect_uri: 'Post sign-out redirect URI',
post_sign_out_redirect_uris: 'Post sign-out redirect URIs',
post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home',
diff --git a/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts b/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts
index 8fa4ffa530d6..20020c0f60a1 100644
--- a/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts
+++ b/packages/phrases/src/locales/fr/translation/admin-console/application-details.ts
@@ -44,6 +44,8 @@ const application_details = {
"L'URI de redirection après la connexion d'un utilisateur (qu'elle soit réussie ou non). Voir OpenID Connect AuthRequest pour plus d'informations.",
mixed_redirect_uri_warning:
"Le type de votre application n'est pas compatible avec au moins une des URIs de redirection. Cela ne suit pas les meilleures pratiques et nous recommandons fortement de garder les URIs de redirection cohérentes.",
+ wildcard_redirect_uri_warning:
+ "Les URIs de redirection avec wildcard ne sont pas standard OIDC et peuvent augmenter la surface d'attaque. Utilisez-les avec prudence et privilégiez des URIs exactes lorsque c'est possible.",
post_sign_out_redirect_uri: 'URI de redirection post-déconnexion',
post_sign_out_redirect_uris: 'URI de redirection après la déconnexion',
post_sign_out_redirect_uri_placeholder: 'https://votre.site.com/home',
diff --git a/packages/schemas/src/foundations/jsonb-types/oidc-module.ts b/packages/schemas/src/foundations/jsonb-types/oidc-module.ts
index fa4a3d9f696d..83a2e8805e1c 100644
--- a/packages/schemas/src/foundations/jsonb-types/oidc-module.ts
+++ b/packages/schemas/src/foundations/jsonb-types/oidc-module.ts
@@ -48,10 +48,12 @@ export type OidcClientMetadata = {
export const oidcClientMetadataGuard = z.object({
redirectUris: z
.string()
- .refine((url) => validateRedirectUrl(url, 'web'))
- .or(z.string().refine((url) => validateRedirectUrl(url, 'mobile')))
+ .refine((url) => validateRedirectUrl(url, 'web') || validateRedirectUrl(url, 'mobile'))
+ .array(),
+ postLogoutRedirectUris: z
+ .string()
+ .refine((url) => validateRedirectUrl(url, 'web') || validateRedirectUrl(url, 'mobile'))
.array(),
- postLogoutRedirectUris: z.string().url().array(),
backchannelLogoutUri: z.string().url().optional(),
backchannelLogoutSessionRequired: z.boolean().optional(),
logoUri: z.string().optional(),
diff --git a/packages/toolkit/core-kit/src/utils/url.test.ts b/packages/toolkit/core-kit/src/utils/url.test.ts
index 4200c736c9e1..01e9c021ac2c 100644
--- a/packages/toolkit/core-kit/src/utils/url.test.ts
+++ b/packages/toolkit/core-kit/src/utils/url.test.ts
@@ -14,6 +14,9 @@ describe('url utilities', () => {
expect(validateRedirectUrl('https://logto.dev/callback', 'web')).toBeTruthy();
expect(validateRedirectUrl('https://my-company.com/callback?test=123', 'web')).toBeTruthy();
expect(validateRedirectUrl('https://abc.com/callback?test=123#param=hash', 'web')).toBeTruthy();
+ expect(validateRedirectUrl('https://*.example.com/callback', 'web')).toBeTruthy();
+ expect(validateRedirectUrl('https://pr-*-myapp.vercel.app/callback', 'web')).toBeTruthy();
+ expect(validateRedirectUrl('https://example.com/callback/*', 'web')).toBeTruthy();
expect(validateRedirectUrl('io.logto://my-app/callback', 'mobile')).toBeTruthy();
expect(validateRedirectUrl('com.company://myDemoApp/callback', 'mobile')).toBeTruthy();
expect(validateRedirectUrl('com.company://demo:1234', 'mobile')).toBeTruthy();
@@ -31,6 +34,12 @@ describe('url utilities', () => {
expect(validateRedirectUrl('http://localhost:3001', 'mobile')).toBeFalsy();
expect(validateRedirectUrl('https://logto.dev/callback', 'mobile')).toBeFalsy();
expect(validateRedirectUrl('demoApp/callback', 'mobile')).toBeFalsy();
+ expect(validateRedirectUrl('https://example.com:*/*', 'web')).toBeFalsy();
+ expect(validateRedirectUrl('https://example.com/callback?x=*', 'web')).toBeFalsy();
+ expect(validateRedirectUrl('ht*ps://example.com/callback', 'web')).toBeFalsy();
+ expect(validateRedirectUrl('https://*/callback', 'web')).toBeFalsy();
+ expect(validateRedirectUrl('https://example.com/callback/../../admin', 'web')).toBeFalsy();
+ expect(validateRedirectUrl('https://example.com/callback/%2e%2e/admin', 'web')).toBeFalsy();
});
it('should allow valid URIs', () => {
diff --git a/packages/toolkit/core-kit/src/utils/url.ts b/packages/toolkit/core-kit/src/utils/url.ts
index 4b7b602d0ed7..228451ca68cb 100644
--- a/packages/toolkit/core-kit/src/utils/url.ts
+++ b/packages/toolkit/core-kit/src/utils/url.ts
@@ -1,6 +1,14 @@
import { mobileUriSchemeProtocolRegEx, webRedirectUriProtocolRegEx } from '../regex.js';
export const validateRedirectUrl = (url: string, type: 'web' | 'mobile') => {
+ if (type === 'web' && url.includes('*')) {
+ return validateWildcardWebRedirectUrl(url);
+ }
+
+ if (type === 'web' && hasDotSegmentsInAbsoluteUrlPath(url)) {
+ return false;
+ }
+
try {
const { protocol } = new URL(url);
const protocolRegEx =
@@ -12,6 +20,104 @@ export const validateRedirectUrl = (url: string, type: 'web' | 'mobile') => {
}
};
+const validateWildcardWebRedirectUrl = (url: string) => {
+ const schemeSeparatorIndex = url.indexOf('://');
+ if (schemeSeparatorIndex <= 0) {
+ return false;
+ }
+
+ if (hasWildcardInScheme(url, schemeSeparatorIndex)) {
+ return false;
+ }
+
+ if (hasWildcardInQueryOrHash(url)) {
+ return false;
+ }
+
+ const authority = getAuthorityFromUrl(url, schemeSeparatorIndex);
+ if (!isAuthorityAllowedForWildcardWebRedirect(authority)) {
+ return false;
+ }
+
+ if (hasDotSegmentsInAbsoluteUrlPath(url)) {
+ return false;
+ }
+
+ return isUrlProtocolAllowedAfterWildcardReplacement(url);
+};
+
+const hasWildcardInScheme = (url: string, schemeSeparatorIndex: number) =>
+ url.slice(0, schemeSeparatorIndex).includes('*');
+
+const hasWildcardInQueryOrHash = (url: string) => {
+ // Disallow wildcards in query/hash to keep matching deterministic and safer.
+ const queryIndex = url.indexOf('?');
+ if (queryIndex >= 0 && url.slice(queryIndex).includes('*')) {
+ return true;
+ }
+
+ const hashIndex = url.indexOf('#');
+ return hashIndex >= 0 && url.slice(hashIndex).includes('*');
+};
+
+const getAuthorityFromUrl = (url: string, schemeSeparatorIndex: number) =>
+ url.slice(schemeSeparatorIndex + 3).split(/[#/?]/)[0] ?? '';
+
+const hasDotSegmentsInAbsoluteUrlPath = (url: string) => {
+ const schemeSeparatorIndex = url.indexOf('://');
+ if (schemeSeparatorIndex <= 0) {
+ return false;
+ }
+
+ const authority = getAuthorityFromUrl(url, schemeSeparatorIndex);
+ const afterAuthorityIndex = schemeSeparatorIndex + 3 + authority.length;
+ const rest = url.slice(afterAuthorityIndex);
+ const path = rest.split(/[#?]/)[0] ?? '';
+
+ if (!path) {
+ return false;
+ }
+
+ const segments = path.split('/');
+ return segments.some((segment) => {
+ const normalized = segment.toLowerCase();
+
+ return segment === '.' || segment === '..' || normalized === '%2e' || normalized === '%2e%2e';
+ });
+};
+
+const isAuthorityAllowedForWildcardWebRedirect = (authority: string) => {
+ // Disallow credentials in authority part.
+ if (authority.includes('@')) {
+ return false;
+ }
+
+ if (authority.startsWith('[')) {
+ // IPv6 literals are not a typical use-case for wildcard redirect URIs; reject for simplicity.
+ return false;
+ }
+
+ const lastColonIndex = authority.lastIndexOf(':');
+ const hasPort = lastColonIndex > -1 && authority.indexOf(':') === lastColonIndex;
+ const hostname = hasPort ? authority.slice(0, lastColonIndex) : authority;
+
+ // When wildcard is used in hostname, require at least one dot to avoid overly broad patterns.
+ if (hostname.includes('*') && !hostname.includes('.')) {
+ return false;
+ }
+
+ return !(hasPort && authority.slice(lastColonIndex + 1).includes('*'));
+};
+
+const isUrlProtocolAllowedAfterWildcardReplacement = (url: string) => {
+ try {
+ const parsed = new URL(url.replaceAll('*', 'wildcard'));
+ return webRedirectUriProtocolRegEx.test(parsed.protocol);
+ } catch {
+ return false;
+ }
+};
+
export const validateUriOrigin = (url: string) => {
try {
return new URL(url).origin === url;
diff --git a/patches/oidc-provider.patch b/patches/oidc-provider.patch
new file mode 100644
index 000000000000..c2e9a385ef42
--- /dev/null
+++ b/patches/oidc-provider.patch
@@ -0,0 +1,203 @@
+diff --git a/lib/actions/authorization/one_redirect_uri_clients.js b/lib/actions/authorization/one_redirect_uri_clients.js
+index 2f1a07182..d823c363e 100755
+--- a/lib/actions/authorization/one_redirect_uri_clients.js
++++ b/lib/actions/authorization/one_redirect_uri_clients.js
+@@ -11,7 +11,12 @@ export default function oneRedirectUriClients(ctx, next) {
+
+ const { params, client } = ctx.oidc;
+
+- if (params.redirect_uri === undefined && client.redirectUris.length === 1) {
++ if (
++ params.redirect_uri === undefined
++ && client.redirectUris.length === 1
++ // Wildcard redirect URIs must always be explicitly provided.
++ && !client.redirectUris[0].includes('*')
++ ) {
+ ctx.oidc.redirectUriCheckPerformed = true;
+ [params.redirect_uri] = client.redirectUris;
+ }
+diff --git a/lib/models/client.js b/lib/models/client.js
+index 903f909cf..6a746adf9 100755
+--- a/lib/models/client.js
++++ b/lib/models/client.js
+@@ -48,6 +48,153 @@ function URLparse(url) {
+ }
+ }
+
++const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
++
++const getEffectivePort = (protocol, port) => {
++ if (port) {
++ return port;
++ }
++
++ switch (protocol) {
++ case 'http:':
++ return '80';
++ case 'https:':
++ return '443';
++ default:
++ return '';
++ }
++};
++
++const matchHostnameWithWildcards = (patternHostname, actualHostname) => {
++ const pattern = String(patternHostname).toLowerCase();
++ const actual = String(actualHostname).toLowerCase();
++
++ if (!pattern.includes('*')) {
++ return pattern === actual;
++ }
++
++ const patternLabels = pattern.split('.');
++ const actualLabels = actual.split('.');
++
++ if (patternLabels.length !== actualLabels.length) {
++ return false;
++ }
++
++ return patternLabels.every((labelPattern, index) => {
++ const label = actualLabels[index] ?? '';
++
++ if (!labelPattern.includes('*')) {
++ return labelPattern === label;
++ }
++
++ const regex = new RegExp(
++ `^${labelPattern.split('*').map(escapeRegExp).join('[^.]+')}$`,
++ 'i',
++ );
++ return regex.test(label);
++ });
++};
++
++const matchPathWithWildcards = (patternPathname, actualPathname) => {
++ if (!patternPathname.includes('*')) {
++ return patternPathname === actualPathname;
++ }
++
++ const regex = new RegExp(`^${patternPathname.split('*').map(escapeRegExp).join('.*')}$`);
++ return regex.test(actualPathname);
++};
++
++const parseWildcardUrlPattern = (pattern) => {
++ const schemeSeparatorIndex = pattern.indexOf('://');
++ if (schemeSeparatorIndex <= 0) {
++ return null;
++ }
++
++ // Disallow wildcards in scheme.
++ if (pattern.slice(0, schemeSeparatorIndex).includes('*')) {
++ return null;
++ }
++
++ // Disallow wildcards in query/hash (matching stays deterministic and safer).
++ const queryIndex = pattern.indexOf('?');
++ if (queryIndex >= 0 && pattern.slice(queryIndex).includes('*')) {
++ return null;
++ }
++
++ const hashIndex = pattern.indexOf('#');
++ if (hashIndex >= 0 && pattern.slice(hashIndex).includes('*')) {
++ return null;
++ }
++
++ const parsed = URLparse(pattern.replace(/\*/g, 'wildcard'));
++ if (!parsed) {
++ return null;
++ }
++
++ const rest = pattern.slice(schemeSeparatorIndex + 3);
++ const authority = rest.split(/[/?#]/)[0] ?? '';
++ if (!authority || authority.includes('@') || authority.startsWith('[')) {
++ return null;
++ }
++
++ const lastColonIndex = authority.lastIndexOf(':');
++ const hasPort = lastColonIndex > -1 && authority.indexOf(':') === lastColonIndex;
++ if (hasPort && authority.slice(lastColonIndex + 1).includes('*')) {
++ return null;
++ }
++
++ const hostnamePattern = hasPort ? authority.slice(0, lastColonIndex) : authority;
++
++ // When wildcard is used in hostname, require at least one dot to avoid overly broad patterns.
++ if (hostnamePattern.includes('*') && !hostnamePattern.includes('.')) {
++ return null;
++ }
++
++ const pathStartIndex = schemeSeparatorIndex + 3 + authority.length;
++ const pathEndIndex = Math.min(
++ ...[pattern.length, queryIndex >= 0 ? queryIndex : pattern.length, hashIndex >= 0 ? hashIndex : pattern.length],
++ );
++
++ const pathnamePattern = pattern.slice(pathStartIndex, pathEndIndex) || '/';
++
++ return {
++ protocol: parsed.protocol,
++ port: parsed.port,
++ hostnamePattern,
++ pathnamePattern,
++ search: parsed.search,
++ hash: parsed.hash,
++ };
++};
++
++const wildcardUrlMatch = (pattern, actual) => {
++ const parsedPattern = parseWildcardUrlPattern(pattern);
++ if (!parsedPattern) {
++ return false;
++ }
++
++ if (actual.protocol !== parsedPattern.protocol) {
++ return false;
++ }
++
++ if (
++ getEffectivePort(actual.protocol, actual.port)
++ !== getEffectivePort(parsedPattern.protocol, parsedPattern.port)
++ ) {
++ return false;
++ }
++
++ if (!matchHostnameWithWildcards(parsedPattern.hostnamePattern, actual.hostname)) {
++ return false;
++ }
++
++ if (!matchPathWithWildcards(parsedPattern.pathnamePattern, actual.pathname)) {
++ return false;
++ }
++
++ return actual.search === parsedPattern.search && actual.hash === parsedPattern.hash;
++};
++
+ const validateJWKS = (jwks) => {
+ if (jwks !== undefined) {
+ if (!Array.isArray(jwks?.keys) || !jwks.keys.every(isPlainObject)) {
+@@ -515,7 +662,11 @@ export default function getClient(provider) {
+ const parsed = URLparse(value);
+ if (!parsed) return false;
+
+- const match = this.redirectUris.find((allowed) => URLparse(allowed)?.href === parsed.href);
++ const match = this.redirectUris.find((allowed) => (
++ allowed.includes('*')
++ ? wildcardUrlMatch(allowed, parsed)
++ : URLparse(allowed)?.href === parsed.href
++ ));
+ if (
+ !!match
+ || this.applicationType !== 'native'
+@@ -551,8 +702,11 @@ export default function getClient(provider) {
+ postLogoutRedirectUriAllowed(value) {
+ const parsed = URLparse(value);
+ if (!parsed) return false;
+- return !!this.postLogoutRedirectUris
+- .find((allowed) => URLparse(allowed)?.href === parsed.href);
++ return !!this.postLogoutRedirectUris.find((allowed) => (
++ allowed.includes('*')
++ ? wildcardUrlMatch(allowed, parsed)
++ : URLparse(allowed)?.href === parsed.href
++ ));
+ }
+
+ static async validate(metadata) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 16fc084a0677..03f6054569a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,6 +16,9 @@ overrides:
nanoid@^4.0.0 <5.0.9: ^5.0.9
patchedDependencies:
+ oidc-provider:
+ hash: 008e9a4b33eea9bf44b6bbcf678d3a8a0fb87fe50e7c6cae362226752e98901b
+ path: patches/oidc-provider.patch
tsup:
hash: 248e52ddb3640c6c06693a689978c94074cecf10117a193f0c83e9c788f468fd
path: patches/tsup.patch
@@ -3855,7 +3858,7 @@ importers:
version: 1.3.3
oidc-provider:
specifier: github:logto-io/node-oidc-provider#aa47a2b000d08e28c1d212aac1899eddd13009e9
- version: https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/aa47a2b000d08e28c1d212aac1899eddd13009e9
+ version: https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/aa47a2b000d08e28c1d212aac1899eddd13009e9(patch_hash=008e9a4b33eea9bf44b6bbcf678d3a8a0fb87fe50e7c6cae362226752e98901b)
openapi-types:
specifier: ^12.1.3
version: 12.1.3
@@ -3882,7 +3885,7 @@ importers:
version: 5.9.2
qrcode:
specifier: ^1.5.3
- version: 1.5.3
+ version: 1.5.4
raw-body:
specifier: ^3.0.0
version: 3.0.0
@@ -3970,7 +3973,7 @@ importers:
version: 0.0.33
'@types/qrcode':
specifier: ^1.5.2
- version: 1.5.2
+ version: 1.5.6
'@types/semver':
specifier: ^7.3.12
version: 7.5.8
@@ -8005,9 +8008,6 @@ packages:
'@types/psl@1.1.3':
resolution: {integrity: sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==}
- '@types/qrcode@1.5.2':
- resolution: {integrity: sha512-W4KDz75m7rJjFbyCctzCtRzZUj+PrUHV+YjqDp50sSRezTbrtEAIq2iTzC6lISARl3qw+8IlcCyljdcVJE0Wug==}
-
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -9837,9 +9837,6 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
- encode-utf8@1.0.3:
- resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
-
encodeurl@1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
@@ -13380,11 +13377,6 @@ packages:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
- qrcode@1.5.3:
- resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
- engines: {node: '>=10.13.0'}
- hasBin: true
-
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@@ -18411,10 +18403,10 @@ snapshots:
eslint-config-prettier: 9.1.0(eslint@8.57.0)
eslint-config-xo: 0.44.0(eslint@8.57.0)
eslint-config-xo-typescript: 4.0.0(@typescript-eslint/eslint-plugin@7.7.0(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3))(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)
- eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
+ eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-consistent-default-export-name: 0.0.15
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0)
- eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+ eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-n: 17.2.1(eslint@8.57.0)
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-prettier: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.5.3)
@@ -19336,10 +19328,6 @@ snapshots:
'@types/psl@1.1.3': {}
- '@types/qrcode@1.5.2':
- dependencies:
- '@types/node': 22.14.1
-
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.14.1
@@ -21534,8 +21522,6 @@ snapshots:
emoji-regex@9.2.2: {}
- encode-utf8@1.0.3: {}
-
encodeurl@1.0.2: {}
end-of-stream@1.4.4:
@@ -21796,13 +21782,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0):
+ eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 4.4.1(supports-color@5.5.0)
enhanced-resolve: 5.16.0
eslint: 8.57.0
- eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
- eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+ eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
+ eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.3
is-core-module: 2.13.1
@@ -21813,14 +21799,14 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
+ eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.7.0(eslint@8.57.0)(typescript@5.5.3)
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0)
+ eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0)
transitivePeerDependencies:
- supports-color
@@ -21842,7 +21828,7 @@ snapshots:
eslint: 8.57.0
ignore: 5.3.1
- eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
+ eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
@@ -21852,7 +21838,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+ eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3
@@ -25422,7 +25408,7 @@ snapshots:
obuf@1.1.2: {}
- oidc-provider@https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/aa47a2b000d08e28c1d212aac1899eddd13009e9:
+ oidc-provider@https://codeload.github.com/logto-io/node-oidc-provider/tar.gz/aa47a2b000d08e28c1d212aac1899eddd13009e9(patch_hash=008e9a4b33eea9bf44b6bbcf678d3a8a0fb87fe50e7c6cae362226752e98901b):
dependencies:
'@koa/cors': 5.0.0
'@koa/router': 13.1.0
@@ -26127,13 +26113,6 @@ snapshots:
pvutils@1.1.3: {}
- qrcode@1.5.3:
- dependencies:
- dijkstrajs: 1.0.3
- encode-utf8: 1.0.3
- pngjs: 5.0.0
- yargs: 15.4.1
-
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3