Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-opensignup-combined-flow-redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fixed OAuth `redirect_url` for `openSignUp` modal in combined flow when `CLERK_SIGN_UP_URL` is unset.
33 changes: 33 additions & 0 deletions integration/tests/oauth-flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,39 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('oauth
expect(parsed.pathname).toBe('/sign-in');
expect(parsed.hash).toMatch(/^#\/create\/sso-callback/);
});

test('openSignUp OAuth in combined flow targets /sign-in#/create/sso-callback', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.page.goToRelative('/buttons');
await u.page.waitForClerkJsLoaded();
await u.po.expect.toBeSignedOut();

await u.page.evaluate(() => {
(window as any).Clerk.openSignUp({ forceRedirectUrl: '/protected' });
});
await u.po.signUp.waitForModal();

const signUpPostPromise = page.waitForRequest(
req => req.method() === 'POST' && /\/v1\/client\/sign_ups(\?|$)/.test(req.url()),
);

await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click();

const signUpPost = await signUpPostPromise;
const body = new URLSearchParams(signUpPost.postData() || '');
const redirectUrl = body.get('redirect_url');
expect(redirectUrl).toBeTruthy();

// With CLERK_SIGN_UP_URL unset, signUpUrl would fall back to displayConfig.signUpUrl
// (the accounts portal). Combined-flow modal should anchor to ClerkProvider.signInUrl
// instead, since the create/sso-callback route is mounted under the SignIn tree.
const parsed = new URL(redirectUrl!);
const appOrigin = new URL(app.serverUrl).origin;
expect(parsed.origin).toBe(appOrigin);
expect(parsed.pathname).toBe('/sign-in');
expect(parsed.hash).toMatch(/^#\/create\/sso-callback/);
});
});

testAgainstRunningApps({ withPattern: ['react.vite.withLegalConsent'] })(
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/contexts/components/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,22 @@ export const useSignUpContext = (): SignUpContextType => {

const authQueryString = redirectUrls.toSearchParams().toString();

// In a combined-flow modal (openSignUp with CLERK_SIGN_IN_URL set, CLERK_SIGN_UP_URL unset,
// public signup mode) signUpUrl falls back to displayConfig.signUpUrl, which points to the
// accounts portal. Customers that dont use the accounts portal end up with a broken OAuth
// redirect_url. Anchor to options.signInUrl in that case: the create/sso-callback and
// create/verify routes are mounted under the SignIn tree (components/SignIn/index.tsx),
// so the callback resolves against the app origin and hits LazySignUpSSOCallback.
// isCombinedFlow guarantees options.signInUrl is set and relative, but we keep the guard
// for type narrowing.
const modalCallbackBaseUrl =
isCombinedFlow && ctx.routing === 'virtual' && options.signInUrl ? options.signInUrl : signUpUrl;

const emailLinkRedirectUrl =
ctx.emailLinkRedirectUrl ??
buildRedirectUrl({
routing: ctx.routing,
baseUrl: signUpUrl,
baseUrl: modalCallbackBaseUrl,
authQueryString,
path: ctx.path,
endpoint: isCombinedFlow ? '/create' + MAGIC_LINK_VERIFY_PATH_ROUTE : MAGIC_LINK_VERIFY_PATH_ROUTE,
Expand All @@ -110,7 +121,7 @@ export const useSignUpContext = (): SignUpContextType => {
ctx.ssoCallbackUrl ??
buildRedirectUrl({
routing: ctx.routing,
baseUrl: signUpUrl,
baseUrl: modalCallbackBaseUrl,
authQueryString,
path: ctx.path,
endpoint: isCombinedFlow ? '/create' + SSO_CALLBACK_PATH_ROUTE : SSO_CALLBACK_PATH_ROUTE,
Expand Down
Loading