Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
88bdf8e
fix: support email-link session reverification
joshrowley Aug 12, 2026
3cd248e
chore(localizations): generate email-link strings
joshrowley Aug 17, 2026
2a93e14
Merge remote-tracking branch 'origin/main' into email-link-session-re…
joshrowley Aug 18, 2026
c188e0f
test(ui): add email-link reverification journey
joshrowley Aug 18, 2026
d673f1c
Merge remote-tracking branch 'origin/main' into email-link-session-re…
joshrowley Aug 18, 2026
9511445
test(ui): harden email-link reverification coverage
joshrowley Aug 19, 2026
2ff2ff1
fix(integration): handle email inbox response
joshrowley Aug 19, 2026
c0b1c0b
test(clerk-js): type reverification responses
joshrowley Aug 19, 2026
f7253cf
test(integration): validate email messages
joshrowley Aug 19, 2026
7d8369a
test(integration): use deliverable email address
joshrowley Aug 19, 2026
bb90b71
test(integration): match email links by address
joshrowley Aug 19, 2026
55e02dd
test(integration): filter shared email inbox
joshrowley Aug 19, 2026
ee44e0a
test(integration): match redirected inbox address
joshrowley Aug 19, 2026
db608df
test(integration): authenticate email inbox reads
joshrowley Aug 19, 2026
dd681c1
Merge remote-tracking branch 'origin/main' into email-link-session-re…
joshrowley Aug 19, 2026
4a1115e
test(integration): filter authenticated email inbox
joshrowley Aug 19, 2026
0ebced6
test(integration): wait for email delivery
joshrowley Aug 19, 2026
1d53bbc
test(integration): use isolated public inboxes
joshrowley Aug 19, 2026
be048b1
test(integration): tolerate delayed email delivery
joshrowley Aug 19, 2026
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.
103 changes: 59 additions & 44 deletions integration/testUtils/emailService.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,86 @@
import { runWithExponentialBackOff } from '@clerk/shared/utils';

type Message = {
_id: string;
links: string[];
subject: string;
};

export const createEmailService = () => {
const cleanEmail = (email: string) => {
return email.replace(/\+.*@/, '@');
type InboxPageData = {
props?: {
pageProps?: {
seedInboxMessages?: unknown[];
};
};
};

const fetcher = async (url: string | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {});
return fetch(url, { ...init, headers });
};
const consumedMessageIds = new Set<string>();

function isMessage(value: unknown): value is Message {
return (
typeof value === 'object' &&
value !== null &&
'_id' in value &&
typeof value._id === 'string' &&
'links' in value &&
Array.isArray(value.links) &&
value.links.every(link => typeof link === 'string') &&
'subject' in value &&
typeof value.subject === 'string'
);
}

export const createEmailService = () => {
const filterMessagesByAddress = async (email: string, sub?: string) => {
const url = new URL('https://mailsac.com/api/inbox-filter');
url.searchParams.set('andTo', email);
if (sub) {
url.searchParams.set('andSubjectIncludes', sub);
}
const url = new URL(`https://mailsac.com/inbox/${encodeURIComponent(email)}`);
// Retry in case the email delivery is delayed
await new Promise(res => setTimeout(res, 1500));
return runWithExponentialBackOff(
async () => {
const res = await fetcher(url);
const json = (await res.json()) as unknown as { messages: Message[] };
const message = json.messages[0];
for (let attempt = 0; attempt < 20; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Email inbox request failed with status ${res.status}`);
}
const html = await res.text();
const nextData = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/s)?.[1];
if (!nextData) {
throw new Error('email inbox data not found');
}
const json = JSON.parse(nextData) as InboxPageData;
const messages = json.props?.pageProps?.seedInboxMessages ?? [];
const normalizedSubject = sub?.toLowerCase();
const message = messages.find(
value =>
isMessage(value) &&
!consumedMessageIds.has(value._id) &&
(!normalizedSubject || value.subject.toLowerCase().includes(normalizedSubject)),
);
if (!message) {
throw new Error('message not found');
}
consumedMessageIds.add(message._id);
return message;
},
{
firstDelay: 750,
timeMultiple: 2,
shouldRetry: (_, iterationsCount) => iterationsCount < 5,
},
);
};

const getMessagePlaintextForAddress = async (email: string, id: string) => {
const url = new URL(`https://mailsac.com/api/text/${cleanEmail(email)}/${id}`);
const res = await fetcher(url);
return res.text();
};

const deleteMessage = async (email: string, id: string) => {
// best-effort file-and-forget delete
const url = new URL(`https://mailsac.com/api/addresses/${cleanEmail(email)}/messages/${id}`);
return fetcher(url, { method: 'DELETE' });
} catch (error) {
if (attempt === 19) {
throw error;
}
await new Promise(res => setTimeout(res, Math.min(750 * 2 ** attempt, 5_000)));
}
}
throw new Error('message not found');
};

return {
getCodeForEmailAddress: async (email: string) => {
const message = await filterMessagesByAddress(email, 'verification code');
const code = (message.subject.match(/\d{6}/)?.[0] || '').trim();
void deleteMessage(email, message._id);
return code;
},
getVerificationLinkForEmailAddress: async (email: string) => {
const message = await filterMessagesByAddress(email, 'link');
const body = await getMessagePlaintextForAddress(email, message._id);
const link = (body.match(/https:\/\/.*\/verify\?.*/) || [''])[0].trim().replace(/&amp;/g, '&');
void deleteMessage(email, message._id);
return link;
const message = await filterMessagesByAddress(email);
const verificationLink = message.links.find(link => /\/verify\?/.test(link));
if (!verificationLink) {
throw new Error('verification link not found');
}
return verificationLink;
},
};
};
2 changes: 1 addition & 1 deletion integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export const createUserService = (clerkClient: ClerkClient) => {
const markedHash = runMarker ? `${runMarker}_${randomHash}` : randomHash;
const email = fictionalEmail
? `${markedHash}+clerk_test@clerkcookie.com`
: `clerkcookie+${markedHash}@mailsac.com`;
: `clerkcookie-${markedHash}@mailsac.com`;
const phoneNumber = fakerPhoneNumber();
const { file, line, title, titlePath } = test.info();
const fakeUserEmail = withEmail ? email : undefined;
Expand Down
187 changes: 139 additions & 48 deletions integration/tests/sign-in-or-up-email-links-flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,156 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [] })('sign-in-or-up email links flow', ({ app }) => {
test.describe.configure({ mode: 'serial' });
testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpEmailLinksFlow] })(
'@nextjs sign-in-or-up email links flow',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let fakeUser: FakeUser;
let emailLinkOnlyUser: FakeUser;
let emailLinkOnlyAddress: string;

test.beforeAll(() => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test);
});
test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test, { fictionalEmail: false });
emailLinkOnlyUser = u.services.users.createFakeUser(test, {
fictionalEmail: false,
withPassword: false,
});
if (!emailLinkOnlyUser.email) {
throw new Error('Expected the email-link-only test user to have an email address');
}
emailLinkOnlyAddress = emailLinkOnlyUser.email;
await u.services.users.createBapiUser(emailLinkOnlyUser);
});

test.afterAll(async () => {
await app.teardown();
});
test.afterAll(async () => {
try {
await Promise.all([fakeUser.deleteIfExists(), emailLinkOnlyUser.deleteIfExists()]);
} finally {
await app.teardown();
}
});

test('sign up with email link', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForAppUrl('/sign-in/create');
test('sign up with email link', async ({ page, context }) => {
test.setTimeout(150_000);
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForAppUrl('/sign-in/create');

const prefilledEmail = u.po.signUp.getEmailAddressInput();
await expect(prefilledEmail).toHaveValue(fakeUser.email);
const prefilledEmail = u.po.signUp.getEmailAddressInput();
await expect(prefilledEmail).toHaveValue(fakeUser.email);

await u.po.signUp.setPassword(fakeUser.password);
await u.po.signUp.continue();
await u.po.signUp.setPassword(fakeUser.password);
await u.po.signUp.continue();

await u.po.signUp.waitForEmailVerificationScreen();
await u.tabs.runInNewTab(async u => {
const verificationLink = await u.services.email.getVerificationLinkForEmailAddress(fakeUser.email);
await u.po.signUp.waitForEmailVerificationScreen();
await u.tabs.runInNewTab(async u => {
const verificationLink = await u.services.email.getVerificationLinkForEmailAddress(fakeUser.email);

await u.po.testingToken.setup();
await u.page.goto(verificationLink);
await u.po.testingToken.setup();
await u.page.goto(verificationLink);
await u.po.expect.toBeSignedIn();
await u.page.close();
});
await u.po.expect.toBeSignedIn();
await u.page.close();
});
await u.po.expect.toBeSignedIn();
});

test('sign in with email link', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForAppUrl('/sign-in/factor-one');
// Defaults to password, so we need to switch to email link
await u.page.getByRole('link', { name: /Use another method/i }).click();
await u.page.getByRole('button', { name: /Email link to/i }).click();
await page.getByRole('heading', { name: /Check your email/i }).waitFor();
await u.tabs.runInNewTab(async u => {
const verificationLink = await u.services.email.getVerificationLinkForEmailAddress(fakeUser.email);
await u.po.testingToken.setup();
await u.page.goto(verificationLink);

test('sign in with email link', async ({ page, context }) => {
test.setTimeout(150_000);
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.page.waitForAppUrl('/sign-in/factor-one');
// Defaults to password, so we need to switch to email link
await u.page.getByRole('link', { name: /Use another method/i }).click();
await u.page.getByRole('button', { name: /Email link to/i }).click();
await page.getByRole('heading', { name: /Check your email/i }).waitFor();
await u.tabs.runInNewTab(async u => {
const verificationLink = await u.services.email.getVerificationLinkForEmailAddress(fakeUser.email);
await u.po.testingToken.setup();
await u.page.goto(verificationLink);
await u.po.expect.toBeSignedIn();
await u.page.close();
});
await u.po.expect.toBeSignedIn();
await fakeUser.deleteIfExists();
});

test('completes an expired-freshness protected action through a same-browser email link', async ({
page,
context,
browser,
}) => {
test.setTimeout(300_000);
const u = createTestUtils({ app, page, context, browser });

await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(emailLinkOnlyAddress);
await u.po.signIn.continue();
await u.page.getByRole('heading', { name: /Check your email/i }).waitFor();

await u.tabs.runInNewTab(async callback => {
const verificationLink = await callback.services.email.getVerificationLinkForEmailAddress(emailLinkOnlyAddress);
await callback.po.testingToken.setup();
await callback.page.goto(verificationLink);
await callback.po.expect.toBeSignedIn();
await callback.page.close();
});
await u.po.expect.toBeSignedIn();
await u.page.close();

await expect
.poll(
() =>
page.evaluate(async () => {
const token = await window.Clerk.session?.getToken({ skipCache: true });
if (!token) {
return -1;
}
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(payload)).fva?.[0] ?? -1;
}),
{ intervals: [5_000], timeout: 120_000 },
)
.toBeGreaterThanOrEqual(1);

const returnPath = '/action-with-use-reverification?return=protected-action';
await u.page.goToRelative(returnPath);
await u.page.getByRole('button', { name: /LogUserId/i }).click();
await u.po.userVerification.waitForMounted();
await u.page.getByRole('heading', { name: /Check your email/i }).waitFor();

const mismatchedClientLink = await u.services.email.getVerificationLinkForEmailAddress(emailLinkOnlyAddress);
await u.tabs.runInNewBrowser(async callback => {
await callback.po.testingToken.setup();
await callback.page.goto(mismatchedClientLink);
await callback.page.getByRole('heading', { name: /Verification link is invalid for this browser/i }).waitFor();
await callback.page.close();
});

await expect(u.page).toHaveURL(new RegExp(`${returnPath.replace('?', '\\?')}$`));
await u.page.getByRole('button', { name: /Resend/i }).click();

await u.tabs.runInNewTab(async callback => {
const verificationLink = await callback.services.email.getVerificationLinkForEmailAddress(emailLinkOnlyAddress);
await callback.po.testingToken.setup();
await callback.page.goto(verificationLink);
await callback.page.getByRole('heading', { name: /Verification complete/i }).waitFor();
const callbackUrl = new URL(callback.page.url());
expect(callbackUrl.pathname).toBe('/action-with-use-reverification');
expect(callbackUrl.searchParams.get('return')).toBe('protected-action');
await callback.page.close();
});

await u.po.userVerification.waitForClosed();
await expect(u.page).toHaveURL(new RegExp(`${returnPath.replace('?', '\\?')}$`));
await expect(u.page.getByText(/\{\s*"userId"\s*:\s*"user_[^"]+"\s*\}/i)).toBeVisible();
});
await u.po.expect.toBeSignedIn();
await fakeUser.deleteIfExists();
});
});
},
);
Loading
Loading