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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/email-link-session-reverification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/shared': patch
'@clerk/clerk-js': patch
'@clerk/ui': patch
'@clerk/localizations': patch
---

Support email-link first factors in session reverification. The original tab waits for the link callback, then resumes the protected action without changing the configured authentication strategy.
57 changes: 57 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,24 @@ import {
serializePublicKeyCredentialAssertion,
webAuthnGetCredential as webAuthnGetCredentialOnWindow,
} from '@clerk/shared/internal/clerk-js/passkeys';
import { Poller } from '@clerk/shared/poller';
import { retry } from '@clerk/shared/retry';
import type {
ActClaim,
AgentActClaim,
CheckAuthorization,
ClientResource,
CreateEmailLinkFlowReturn,
EmailCodeConfig,
EmailLinkConfig,
EnterpriseSSOConfig,
GetToken,
GetTokenOptions,
PhoneCodeConfig,
SessionJSON,
SessionJSONSnapshot,
SessionResource,
SessionStartEmailLinkFlowParams,
SessionStatus,
SessionTask,
SessionTouchParams,
Expand Down Expand Up @@ -261,6 +265,12 @@ export class Session extends BaseResource implements SessionResource {
case 'email_code':
config = { emailAddressId: factor.emailAddressId } as EmailCodeConfig;
break;
case 'email_link':
config = {
emailAddressId: factor.emailAddressId,
redirectUrl: factor.redirectUrl,
} as EmailLinkConfig;
break;
case 'phone_code':
config = {
phoneNumberId: factor.phoneNumberId,
Expand Down Expand Up @@ -295,6 +305,53 @@ export class Session extends BaseResource implements SessionResource {
return new SessionVerification(json);
};

createEmailLinkFlow = (): CreateEmailLinkFlowReturn<SessionStartEmailLinkFlowParams, SessionVerificationResource> => {
const { run, stop } = Poller();

const startEmailLinkFlow = async ({
emailAddressId,
redirectUrl,
}: SessionStartEmailLinkFlowParams): Promise<SessionVerificationResource> => {
await this.prepareFirstFactorVerification({ strategy: 'email_link', emailAddressId, redirectUrl });

return new Promise((resolve, reject) => {
void run(() => {
return this.#readVerification()
.then(res => {
const verificationStatus = res.firstFactorVerification.status;
if (
res.status === 'complete' ||
res.status === 'needs_second_factor' ||
verificationStatus === 'verified' ||
verificationStatus === 'expired' ||
verificationStatus === 'failed'
) {
stop();
resolve(res);
}
})
.catch(err => {
stop();
reject(err);
});
});
});
};

return { startEmailLinkFlow, cancelEmailLinkFlow: stop };
};

#readVerification = async (): Promise<SessionVerificationResource> => {
const json = (
await BaseResource._fetch({
method: 'GET',
path: `/client/sessions/${this.id}/verify`,
})
)?.response as unknown as SessionVerificationJSON;

return new SessionVerification(json);
};

attemptFirstFactorVerification = async (
attemptFactor: SessionVerifyAttemptFirstFactorParams,
): Promise<SessionVerificationResource> => {
Expand Down
53 changes: 52 additions & 1 deletion packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error';
import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types';
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';

import { clerkMock, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures';
import { clerkMock, createSession, createUser, mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures';
import {
restoreDocument,
setDocument,
Expand Down Expand Up @@ -2522,4 +2522,55 @@ describe('Session', () => {
});
});
});

describe('createEmailLinkFlow()', () => {
it('prepares email-link reverification and resolves after the callback completes the active step-up', async () => {
BaseResource.clerk = clerkMock();
const sessionJSON = createSession({ id: 'session_1', factor_verification_age: [99999, -1] });
const session = new Session(sessionJSON);
const fetchSpy = vi.spyOn(BaseResource, '_fetch');
const response = (status: 'needs_first_factor' | 'complete', verificationStatus: 'unverified' | 'verified') => ({
response: {
object: 'session_verification',
status,
level: 'first_factor',
session: sessionJSON,
first_factor_verification: {
object: 'verification_email_link',
strategy: 'email_link',
status: verificationStatus,
},
second_factor_verification: null,
supported_first_factors: status === 'complete' ? null : [{ strategy: 'email_link' }],
supported_second_factors: null,
},
});

fetchSpy
.mockResolvedValueOnce(response('needs_first_factor', 'unverified') as any)
.mockResolvedValueOnce(response('complete', 'verified') as any);

const { startEmailLinkFlow } = session.createEmailLinkFlow();
const result = await startEmailLinkFlow({
emailAddressId: 'idn_email',
redirectUrl: 'https://app.example.com/protected-action',
});

expect(result.status).toBe('complete');
expect(result.firstFactorVerification.strategy).toBe('email_link');
expect(fetchSpy).toHaveBeenNthCalledWith(1, {
method: 'POST',
path: '/client/sessions/session_1/verify/prepare_first_factor',
body: {
emailAddressId: 'idn_email',
redirectUrl: 'https://app.example.com/protected-action',
strategy: 'email_link',
},
});
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
method: 'GET',
path: '/client/sessions/session_1/verify',
});
});
});
});
24 changes: 24 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@ export const enUS: LocalizationResource = {
actionText: 'Don鈥檛 have any of these?',
blockButton__backupCode: 'Use a backup code',
blockButton__emailCode: 'Email code to {{identifier}}',
blockButton__emailLink: 'Email link to {{identifier}}',
blockButton__passkey: 'Use your passkey',
blockButton__password: 'Continue with your password',
blockButton__phoneCode: 'Send SMS code to {{identifier}}',
Expand All @@ -1309,6 +1310,29 @@ export const enUS: LocalizationResource = {
subtitle: 'Enter the code sent to your email to continue',
title: 'Verification required',
},
emailLink: {
clientMismatch: {
subtitle: 'Open the link in the same browser where you started verification.',
title: 'Verification link is invalid for this browser',
},
expired: {
subtitle: 'Return to the original tab and request a new link.',
title: 'This verification link has expired',
},
failed: {
subtitle: 'Return to the original tab and request a new link.',
title: 'This verification link is invalid',
},
formSubtitle: 'Use the verification link sent to your email',
formTitle: 'Verification link',
resendButton: "Didn't receive a link? Resend",
subtitle: 'We sent a verification link to your email address',
title: 'Check your email',
verified: {
subtitle: 'Return to the original tab to continue.',
title: 'Verification complete',
},
},
noAvailableMethods: {
message: 'Cannot proceed with verification. No suitable authentication factor is configured',
subtitle: 'An error occurred',
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,29 @@ export type __internal_LocalizationResource = {
formTitle: LocalizationValue;
resendButton: LocalizationValue;
};
emailLink: {
title: LocalizationValue;
subtitle: LocalizationValue;
formTitle: LocalizationValue;
formSubtitle: LocalizationValue;
resendButton: LocalizationValue;
verified: {
title: LocalizationValue;
subtitle: LocalizationValue;
};
expired: {
title: LocalizationValue;
subtitle: LocalizationValue;
};
failed: {
title: LocalizationValue;
subtitle: LocalizationValue;
};
clientMismatch: {
title: LocalizationValue;
subtitle: LocalizationValue;
};
};
phoneCode: {
title: LocalizationValue;
subtitle: LocalizationValue;
Expand Down Expand Up @@ -674,6 +697,7 @@ export type __internal_LocalizationResource = {
actionLink: LocalizationValue;
actionText: LocalizationValue;
blockButton__emailCode: LocalizationValue<'identifier'>;
blockButton__emailLink: LocalizationValue<'identifier'>;
blockButton__phoneCode: LocalizationValue<'identifier'>;
blockButton__password: LocalizationValue;
blockButton__totp: LocalizationValue;
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
BackupCodeAttempt,
EmailCodeAttempt,
EmailCodeConfig,
EmailLinkConfig,
EnterpriseSSOConfig,
PasskeyAttempt,
PassKeyConfig,
Expand All @@ -29,6 +30,7 @@ import type { SessionJSONSnapshot } from './snapshots';
import type { TokenResource } from './token';
import type { UserResource } from './user';
import type { Autocomplete } from './utils';
import type { CreateEmailLinkFlowReturn } from './verification';

/**
* @inline
Expand Down Expand Up @@ -317,6 +319,10 @@ export interface SessionResource extends ClerkResource {
prepareFirstFactorVerification: (
factor: SessionVerifyPrepareFirstFactorParams,
) => Promise<SessionVerificationResource>;
/**
* Creates an email-link reverification flow. The returned promise resolves in the original tab after the link callback completes the active session verification.
*/
createEmailLinkFlow: () => CreateEmailLinkFlowReturn<SessionStartEmailLinkFlowParams, SessionVerificationResource>;
/**
* Attempts to complete the [first factor verification](!first-factor-verification) process.
* @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors.
Expand Down Expand Up @@ -527,13 +533,19 @@ export type SessionVerifyCreateParams = {

export type SessionVerifyPrepareFirstFactorParams =
| EmailCodeConfig
| EmailLinkConfig
| PhoneCodeConfig
| PassKeyConfig
/**
* @experimental
*/
| Omit<EnterpriseSSOConfig, 'actionCompleteRedirectUrl'>;

export type SessionStartEmailLinkFlowParams = {
emailAddressId: string;
redirectUrl: string;
};

export type SessionVerifyAttemptFirstFactorParams =
| EmailCodeAttempt
| PhoneCodeAttempt
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/sessionVerification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
BackupCodeFactor,
EmailCodeFactor,
EmailLinkFactor,
EnterpriseSSOFactor,
PasskeyFactor,
PasswordFactor,
Expand Down Expand Up @@ -52,6 +53,7 @@ export type SessionVerificationAfterMinutes = number;

export type SessionVerificationFirstFactor =
| EmailCodeFactor
| EmailLinkFactor
| PhoneCodeFactor
| PasswordFactor
| PasskeyFactor
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ const componentNodes = Object.freeze({
SignUp: 'signUpModal',
SignIn: 'signInModal',
UserProfile: 'userProfileModal',
UserVerification: 'userVerificationModal',
OrganizationProfile: 'organizationProfileModal',
CreateOrganization: 'createOrganizationModal',
Waitlist: 'waitlistModal',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza
return localizationKeys('reverification.alternativeMethods.blockButton__emailCode', {
identifier: formatSafeIdentifier(factor.safeIdentifier) || '',
});
case 'email_link':
return localizationKeys('reverification.alternativeMethods.blockButton__emailLink', {
identifier: formatSafeIdentifier(factor.safeIdentifier) || '',
});
case 'phone_code':
return localizationKeys('reverification.alternativeMethods.blockButton__phoneCode', {
identifier: formatSafeIdentifier(factor.safeIdentifier) || '',
Expand All @@ -125,6 +129,7 @@ export function getButtonLabel(factor: SessionVerificationFirstFactor): Localiza
export function getButtonIcon(factor: SessionVerificationFirstFactor) {
const icons = {
email_code: Envelope,
email_link: Envelope,
phone_code: SpeechBubble,
password: Lock,
passkey: Fingerprint,
Expand Down
Loading
Loading