diff --git a/.changeset/red-windows-train.md b/.changeset/red-windows-train.md new file mode 100644 index 00000000000..47e582ac8b1 --- /dev/null +++ b/.changeset/red-windows-train.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Fix OAuth application flows to handle redirect to `redirect_url` from `/oauth/authorize/continue` diff --git a/packages/shared/src/internal/clerk-js/__tests__/url.test.ts b/packages/shared/src/internal/clerk-js/__tests__/url.test.ts index 63927fae344..ddd8fd20bd2 100644 --- a/packages/shared/src/internal/clerk-js/__tests__/url.test.ts +++ b/packages/shared/src/internal/clerk-js/__tests__/url.test.ts @@ -497,6 +497,7 @@ describe('isRedirectForFAPIInitiatedFlow(frontendAp: string, redirectUrl: string ['clerk.foo.bar-53.lcl.dev', 'foo', false], ['clerk.foo.bar-53.lcl.dev', 'https://clerk.foo.bar-53.lcl.dev/deadbeef.', false], ['clerk.foo.bar-53.lcl.dev', 'https://clerk.foo.bar-53.lcl.dev/oauth/authorize', true], + ['clerk.foo.bar-53.lcl.dev', 'https://clerk.foo.bar-53.lcl.dev/oauth/authorize/continue', true], ['clerk.foo.bar-53.lcl.dev', 'https://clerk.foo.bar-53.lcl.dev/v1/verify', true], ['clerk.foo.bar-53.lcl.dev', 'https://clerk.foo.bar-53.lcl.dev/v1/tickets/accept', true], ['clerk.foo.bar-53.lcl.dev', 'https://clerk.foo.bar-53.lcl.dev/oauth/authorize-with-immediate-redirect', true], @@ -518,9 +519,10 @@ describe('requiresUserInput(redirectUrl: string)', () => { ['foo', false], ['https://clerk.foo.bar-53.lcl.dev/deadbeef.', false], ['https://clerk.foo.bar-53.lcl.dev/oauth/authorize', true], + ['https://clerk.foo.bar-53.lcl.dev/oauth/authorize/continue', true], ['https://clerk.foo.bar-53.lcl.dev/v1/verify', false], ['https://clerk.foo.bar-53.lcl.dev/v1/tickets/accept', false], - ['https://clerk.foo.bar-53.lcl.dev/oauth/authorize-with-immediate-redirect', false], + ['https://clerk.foo.bar-53.lcl.dev/oauth/authorize-with-immediate-redirect', true], ['https://clerk.foo.bar-53.lcl.dev/oauth/end_session', false], ['https://google.com', false], ['https://google.com/v1/verify', false], diff --git a/packages/shared/src/internal/clerk-js/redirectUrls.ts b/packages/shared/src/internal/clerk-js/redirectUrls.ts index cf9cdaf9386..1bcae1dd1cc 100644 --- a/packages/shared/src/internal/clerk-js/redirectUrls.ts +++ b/packages/shared/src/internal/clerk-js/redirectUrls.ts @@ -1,7 +1,7 @@ import { applyFunctionToObj, filterProps, removeUndefined } from '../../object'; import type { ClerkOptions, RedirectOptions } from '../../types'; import { camelToSnake } from '../../underscore'; -import { isAllowedRedirect, relativeToAbsoluteUrl } from './url'; +import { isAllowedRedirect, isFAPIInitiatedFlowPath, relativeToAbsoluteUrl } from './url'; type ComponentMode = 'modal' | 'mounted'; @@ -99,6 +99,15 @@ export class RedirectUrls { const forceKey = `${prefix}ForceRedirectUrl` as const; const fallbackKey = `${prefix}FallbackRedirectUrl` as const; + // FAPI-initiated flow redirect URLs (e.g. /oauth/authorize/continue) must + // take highest priority to ensure the IDP authorization flow completes. + // Without this, a configured signInForceRedirectUrl would override the + // redirect_url needed to resume the OAuth IDP flow after sign-in. + const fapiRedirectUrl = this.fromSearchParams.redirectUrl; + if (fapiRedirectUrl && isFAPIInitiatedFlowPath(fapiRedirectUrl)) { + return fapiRedirectUrl; + } + let result; // Prioritize forceRedirectUrl result = this.fromSearchParams[forceKey] || this.fromProps[forceKey] || this.fromOptions[forceKey]; diff --git a/packages/shared/src/internal/clerk-js/url.ts b/packages/shared/src/internal/clerk-js/url.ts index 6c524febf4a..4c411dcf8b2 100644 --- a/packages/shared/src/internal/clerk-js/url.ts +++ b/packages/shared/src/internal/clerk-js/url.ts @@ -400,12 +400,13 @@ export const pathFromFullPath = (fullPath: string) => { const frontendApiRedirectPathsWithUserInput: string[] = [ '/oauth/authorize', // OAuth2 identify provider flow + '/oauth/authorize/continue', // OAuth 2 identity provider continuation + '/oauth/authorize-with-immediate-redirect', // OAuth 2 identity provider (requires sign-in first) ]; const frontendApiRedirectPathsNoUserInput: string[] = [ '/v1/verify', // magic links '/v1/tickets/accept', // ticket flow - '/oauth/authorize-with-immediate-redirect', // OAuth 2 identity provider '/oauth/end_session', // OIDC logout ]; @@ -423,6 +424,28 @@ export function requiresUserInput(redirectUrl: string): boolean { return frontendApiRedirectPathsWithUserInput.includes(url.pathname); } +/** + * Checks if a URL points to a known FAPI-initiated flow endpoint. + * Only matches absolute URLs whose origin differs from the current window origin, + * preventing customer app routes from accidentally matching FAPI paths. + */ +export function isFAPIInitiatedFlowPath(url: string): boolean { + try { + // Only match absolute URLs — relative paths like "/oauth/authorize" should not match + // as they would be customer app routes, not FAPI endpoints. + // new URL(url) without a base will throw for relative URLs. + const parsed = new URL(url); + // Ensure the URL is not on the same origin as the current page (FAPI is always cross-origin) + if (typeof window !== 'undefined' && parsed.origin === window.location.origin) { + return false; + } + const allFAPIFlowPaths = [...frontendApiRedirectPathsWithUserInput, ...frontendApiRedirectPathsNoUserInput]; + return allFAPIFlowPaths.includes(parsed.pathname); + } catch { + return false; + } +} + export const isAllowedRedirect = (allowedRedirectOrigins: Array | undefined, currentOrigin: string) => (_url: URL | string) => { let url = _url;