Skip to content
Closed
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/easy-plants-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Automatically include current window search params (redirect_url) for AuthenticateWithRedirectCallback to make it easier to use for sso-callback
128 changes: 128 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,134 @@ describe('Clerk singleton', () => {
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/reset-password');
});
});

it('reads redirect_url from URL search params and uses it as the redirect destination', async () => {
// Set window.location to include redirect_url in search params
const customRedirectUrl = '/custom-redirect-from-url';
Object.defineProperty(global.window, 'location', {
value: {
...mockWindowLocation,
search: `?redirect_url=${encodeURIComponent(customRedirectUrl)}`,
},
});

mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
authConfig: {},
userSettings: mockUserSettings,
displayConfig: mockDisplayConfig,
isSingleSession: () => false,
isProduction: () => false,
isDevelopmentOrStaging: () => true,
onWindowLocationHost: () => false,
}),
);

mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'complete',
first_factor_verification: {
status: 'verified',
strategy: 'oauth_google',
external_verification_redirect_url: null,
error: null,
},
second_factor_verification: null,
identifier: 'test@example.com',
user_data: null,
created_session_id: 'sess_123',
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn(async ({ navigate }) => {
// Simulate the navigate callback being called
await navigate({ session: { currentTask: null } });
});

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
sut.setActive = mockSetActive;

await sut.handleRedirectCallback();

await waitFor(() => {
expect(mockSetActive).toHaveBeenCalled();
// The redirect should include the custom redirect URL from URL search params
expect(mockNavigate.mock.calls[0][0]).toContain(customRedirectUrl);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth adding a test where both props and search params are present? Just to lock the precedence behavior (search params > props) and prevent future regressions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, good idea. Done.

});
});

it('uses redirect_url from URL search params over props', async () => {
const customRedirectUrl = '/custom-redirect-from-url';
Object.defineProperty(global.window, 'location', {
value: {
...mockWindowLocation,
search: `?redirect_url=${encodeURIComponent(customRedirectUrl)}`,
},
});

const redirectOptions = {
signInUrl: 'http://test.host/sign-in',
signUpUrl: 'http://test.host/sign-up',
afterSignInUrl: '/after-sign-in',
afterSignUpUrl: '/after-sign-up',
// TODO: it may be required to override force redirects as well. USER-3111
// signInForceRedirectUrl: '/force-after-sign-in',
// signUpForceRedirectUrl: '/force-after-sign-up',
};

mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
authConfig: {},
userSettings: mockUserSettings,
displayConfig: mockDisplayConfig,
isSingleSession: () => false,
isProduction: () => false,
isDevelopmentOrStaging: () => true,
onWindowLocationHost: () => false,
}),
);

mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'complete',
first_factor_verification: {
status: 'verified',
strategy: 'oauth_google',
external_verification_redirect_url: null,
error: null,
},
second_factor_verification: null,
identifier: 'test@example.com',
user_data: null,
created_session_id: 'sess_123',
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn(async ({ navigate }) => {
await navigate({ session: { currentTask: null } });
});

const sut = new Clerk(productionPublishableKey);
await sut.load({ ...mockedLoadOptions, ...redirectOptions });
sut.setActive = mockSetActive;

await sut.handleRedirectCallback(redirectOptions);

await waitFor(() => {
expect(mockSetActive).toHaveBeenCalled();
// redirect_url from the doc request (URL search params) takes precedence over all other redirect props
expect(mockNavigate.mock.calls[0][0]).toContain(customRedirectUrl);
});
});
});

describe('.handleEmailLinkVerification()', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2064,6 +2064,7 @@ export class Clerk implements ClerkInterface {
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
},
searchParams?: URLSearchParams,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a quick test verifying that handleGoogleOneTapCallback ignores redirect_url from the URL? Would help protect the intentional scoping here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I agree with not changing the GoogleOneTap behavior, I'm a little hesitant to add a test because I don't know what the behavior should be...

I did do some manual tests and the behavior seemed unchanged regardless of the search params. Probably in part because with GoogleOneTap, my sample app never redirects and just stays on the custom sign in page.

I suppose someone implementing both custom sign-in and GoogleOneTap and supporting OAuth integrations would figure out they need to handle that...

If you think it's merited I can easily add the test. But, that's my thinking. Let me know your perspective. :-)

): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
Expand Down Expand Up @@ -2126,7 +2127,7 @@ export class Clerk implements ClerkInterface {
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
);

const redirectUrls = new RedirectUrls(this.#options, params);
const redirectUrls = new RedirectUrls(this.#options, params, searchParams);

const navigateToContinueSignUp = makeNavigate(
params.continueSignUpUrl ||
Expand Down Expand Up @@ -2334,12 +2335,18 @@ export class Clerk implements ClerkInterface {

const navigate = (to: string) =>
customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to);

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
});
// Pass the current window location search params to preserve and prioritize
// a redirect_url from the OAuth flow. Needed by AuthenticateWithRedirectCallback.
const searchParams = new URLSearchParams(window.location.search);
return this._handleRedirectCallback(
params,
{
signUp,
signIn,
navigate,
},
searchParams,
);
};

// TODO: Deprecate this one, and mark it as internal. Is there actual benefit for external developers to use this ? Should they ever reach for it ?
Expand Down