Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resume-oauth-transfer-after-protect-check.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/react': patch
'@clerk/shared': minor
'@clerk/ui': patch
---

Fixes an issue where OAuth account transfers that needed additional verification were returned to the beginning of sign-in.
214 changes: 214 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1850,6 +1850,220 @@ describe('Clerk singleton', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

describe('__internal_resumeAfterProtectCheck', () => {
// A verification challenge can interrupt an OAuth callback partway through routing. The
// challenge card clears it and hands control back here, from a page that is no longer
// the callback route, so the remaining routing has to run rather than start over.

const gatedTransferableSignIn = (extra: Record<string, unknown> = {}) =>
new SignIn({
status: 'needs_identifier',
first_factor_verification: {
status: 'transferable',
strategy: 'oauth_google',
external_verification_redirect_url: '',
error: {
code: 'external_account_not_found',
long_message: 'The External Account was not found.',
message: 'Invalid external account',
},
},
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
...extra,
} as any as SignInJSON);

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

it('completes the transfer as a SIGN-UP and activates the created session', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSetActive = vi.fn();
const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = mockSetActive;

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() => {
expect(mockSignUpCreate).toHaveBeenCalledTimes(1);
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined });
expect(mockSetActive).toHaveBeenCalledWith(expect.objectContaining({ session: '123' }));
});
});

it('completes the transfer even when the cleared response dropped the transferable marker', async () => {
// `SignIn.fromJSON` replaces `firstFactorVerification` wholesale on every write, so the
// caller latches the continuation before running the challenge and passes it explicitly.
// Re-reading it here would silently fall back to returning the user to sign-in.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'needs_identifier',
first_factor_verification: null,
second_factor_verification: null,
identifier: '',
user_data: null,
created_session_id: null,
created_user_id: null,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
});

it('does not divert to the sign-up card when a stale gate is on the sign-up resource', async () => {
// The sign-in variant below covers the first short-circuit; `resuming` skips a second one
// keyed on the SIGN-UP resource, and that is the arm that sends the user to a different
// card entirely rather than back to this one.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp({
protect_check: { status: 'pending', token: 'stale-signup-token', sdk_url: 'https://example.com/sdk.js' },
} as any),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('does not bounce back into the challenge when a stale gate is still on the resource', async () => {
// This is the test that proves `resuming` is load-bearing rather than decorative. The
// caller IS the challenge card; re-checking the gate here would hand control straight
// back to it, or — through the sign-up arm — to the wrong card entirely.
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn({
protect_check: { status: 'pending', token: 'stale-token', sdk_url: 'https://example.com/sdk.js' },
}),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi
.fn()
.mockReturnValue(Promise.resolve({ status: 'complete', createdSessionId: '123' }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' });

await waitFor(() =>
expect(mockSignUpCreate).toHaveBeenCalledWith({ transfer: true, unsafeMetadata: undefined }),
);
expect(mockNavigate.mock.calls.some(([to]) => typeof to === 'string' && to.includes('protect-check'))).toBe(
false,
);
});

it('still honours transferable: false', async () => {
loadEnvironment();
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: gatedTransferableSignIn(),
signUp: new SignUp(null),
}),
);

const mockSignUpCreate = vi.fn();

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
if (!sut.client) {
fail('we should always have a client');
}
sut.client.signUp.create = mockSignUpCreate;
sut.setActive = vi.fn();

await sut.__internal_resumeAfterProtectCheck({
continuation: 'transfer_to_sign_up',
transferable: false,
});

await waitFor(() => expect(mockSignUpCreate).not.toHaveBeenCalled());
});
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
33 changes: 29 additions & 4 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ import type {
PublicKeyCredentialWithAuthenticatorAttestationResponse,
RedirectOptions,
Resources,
ResumeAfterProtectCheckParams,
SDKMetadata,
SessionResource,
SessionTouchParams,
Expand DownExpand Up@@ -2450,15 +2451,17 @@ export class Clerk implements ClerkInterface {
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
params: ResumeAfterProtectCheckParams,
{
signIn,
signUp,
navigate,
resuming = false,
}: {
signIn: SignInResource;
signUp: SignUpResource;
navigate: (to: string) => Promise<unknown>;
resuming?: boolean;
},
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
Expand DownExpand Up@@ -2602,14 +2605,14 @@ export class Clerk implements ClerkInterface {
// sign-in's challenge. We only consult `si` here unless this is explicitly a sign-up callback.
// Transfers are unaffected: the `signIn.create({ transfer })` path below checks its own fresh
// response for the gate.
if (params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
if (!resuming && params.reloadResource !== 'signUp' && (si.protectCheck || si.status === 'needs_protect_check')) {
return navigateToSignInProtectCheck();
}

// The sign-up resource can be gated the same way (e.g. a callback that resolves straight into a
// gated sign-up). Scope to the sign-up intent for the symmetric reason — a stale sign-up's gate
// shouldn't hijack a sign-in callback.
if (params.reloadResource !== 'signIn' && su.protectCheck) {
if (!resuming && params.reloadResource !== 'signIn' && su.protectCheck) {
return navigateToSignUpProtectCheck();
}

Expand DownExpand Up@@ -2669,7 +2672,8 @@ export class Clerk implements ClerkInterface {
return navigateToResetPassword();
}

const userNeedsToBeCreated = si.firstFactorVerificationStatus === 'transferable';
const userNeedsToBeCreated =
si.firstFactorVerificationStatus === 'transferable' || params.continuation === 'transfer_to_sign_up';

if (userNeedsToBeCreated) {
if (params.transferable === false) {
Expand DownExpand Up@@ -2772,6 +2776,27 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
};

public __internal_resumeAfterProtectCheck = async (
params: ResumeAfterProtectCheckParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}
const { signIn, signUp } = this.client;

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

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
resuming: true,
});
};

public handleRedirectCallback = async (
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
64 changes: 64 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -178,6 +178,70 @@ describe('isomorphicClerk', () => {
expect(handleResourceCallback).toHaveBeenCalledWith(signInOrUp, params, customNavigate);
});

// Regression: a call queued before clerk-js loads is replayed by
// `replayInterceptedInvocations`, whose loop discards whatever its callbacks
// return. The queued copy therefore has no caller left to reject to -- the
// original `await` resolved the moment the call was queued -- so without its
// own rejection handler a failed resume becomes an unhandled rejection in the
// host app. Asserting that `catch` is attached, rather than waiting for the
// symptom, keeps the check deterministic under fake timers.
it('attaches a rejection handler to __internal_resumeAfterProtectCheck when it is queued until clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const clerkjs = {
addListener: vi.fn(),
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

expect(resumeAfterProtectCheck).not.toHaveBeenCalled();

(isomorphicClerk as any).replayInterceptedInvocations(clerkjs);

expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

it('attaches a rejection handler to __internal_resumeAfterProtectCheck after clerk-js has loaded', async () => {
const params = { continuation: 'transfer_to_sign_up' } as const;
const catchSpy = vi.fn();
const resumeAfterProtectCheck = vi.fn().mockReturnValue({ catch: catchSpy });
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = {
loaded: true,
__internal_resumeAfterProtectCheck: resumeAfterProtectCheck,
} as unknown as BrowserClerk;

const customNavigate = vi.fn();

await isomorphicClerk.__internal_resumeAfterProtectCheck(params, customNavigate);

// The navigator is the component router's; dropping it falls back to Clerk.navigate,
// which resolves component-relative destinations against the origin instead.
expect(resumeAfterProtectCheck).toHaveBeenCalledWith(params, customNavigate);
expect(catchSpy).toHaveBeenCalledTimes(1);
});

// This wrapper is always defined on IsomorphicClerk, so it cannot itself signal whether the
// loaded runtime supports the call. An older clerk-js has no such method, and calling straight
// through would throw a TypeError at a host caller instead of doing nothing.
it('does nothing when the loaded clerk-js predates __internal_resumeAfterProtectCheck', async () => {
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });

(isomorphicClerk as any).clerkjs = { loaded: true } as unknown as BrowserClerk;

await expect(
isomorphicClerk.__internal_resumeAfterProtectCheck({ continuation: 'transfer_to_sign_up' }),
).resolves.toBeUndefined();
});

it('calls __internal_handleResourceCallback immediately after clerk-js has loaded', async () => {
const signInOrUp = {} as unknown as SignInResource;
const params: HandleOAuthCallbackParams = { signInUrl: '/sign-in' };
Expand Down
Loading
Loading