import { createClerkClient } from '@clerk/backend'; import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { environmentConfig } from '../models/environment'; import { appConfigs } from '../presets'; import { resolveInstanceKeys } from '../presets/envs'; import type { FakeUser } from '../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../testUtils'; import { withRetry } from '../testUtils/retryableClerkClient'; import { createUserService } from '../testUtils/usersService'; const grantOAuthConsent = async (page: Page) => { const allowAccessButton = page.getByRole('button', { name: 'Allow' }); await expect(allowAccessButton).toBeVisible(); const [callback] = await Promise.all([ page.waitForRequest(request => new URL(request.url()).pathname === '/v1/oauth_callback'), allowAccessButton.click(), ]); expect(new URL(callback.url()).searchParams.has('__clerk_testing_token')).toBe(true); }; testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('oauth flows @nextjs', ({ app }) => { test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; let modalSignUpUser: FakeUser; test.beforeAll(async () => { // Create a clerkClient for the OAuth provider instance. const client = createClerkClient({ secretKey: (await resolveInstanceKeys('oauth-provider')).sk, publishableKey: (await resolveInstanceKeys('oauth-provider')).pk, }); const users = createUserService(withRetry(client)); fakeUser = users.createFakeUser(test, { withUsername: true, }); modalSignUpUser = users.createFakeUser(test, { withUsername: true, }); // Create the user on the OAuth provider instance so we do not need to sign up twice. await users.createBapiUser(fakeUser); await users.createBapiUser(modalSignUpUser); }); test.afterAll(async () => { const u = await createTestUtils({ app }); // Delete the user on the OAuth provider instance. await fakeUser.deleteIfExists(); await modalSignUpUser.deleteIfExists(); // Delete the user on the app instance. await u.services.users.deleteIfExists({ email: fakeUser.email }); await u.services.users.deleteIfExists({ email: modalSignUpUser.email }); await app.teardown(); }); test('sign up with custom oauth provider', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); const providerKeys = await resolveInstanceKeys('oauth-provider'); await createTestUtils({ app: { ...app, env: environmentConfig() .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', providerKeys.pk) .setEnvVariable('private', 'CLERK_SECRET_KEY', providerKeys.sk), }, page, context, }); await u.po.signUp.goTo(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await grantOAuthConsent(u.page); // We can't use our `expect.toBeSignedIn` first because that would result in `true` on the OAuth provider instance. // We want to assert that we're signed in on our app instance, which will render the text 'SignedIn'. await u.page.getByText('SignedIn').waitFor(); await u.po.expect.toBeSignedIn(); }); test('sign in with custom oauth provider', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await grantOAuthConsent(u.page); // We can't use our `expect.toBeSignedIn` first because that would result in `true` on the OAuth provider instance. // We want to assert that we're signed in on our app instance, which will render the text 'SignedIn'. await u.page.getByText('SignedIn').waitFor(); await u.po.expect.toBeSignedIn(); }); test('sign in modal', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.getByText('Sign in button (force)').click(); await u.po.signIn.waitForModal(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await grantOAuthConsent(u.page); await u.page.waitForAppUrl('/protected'); await u.po.expect.toBeSignedIn(); }); test('openSignIn with withSignUp signs up a new user with custom oauth provider', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.evaluate(() => { (window as any).Clerk.openSignIn({ withSignUp: true, forceRedirectUrl: '/protected', signUpForceRedirectUrl: '/protected', }); }); await u.po.signIn.waitForModal(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(modalSignUpUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await grantOAuthConsent(u.page); await u.page.waitForAppUrl('/protected'); await u.po.expect.toBeSignedIn(); }); test('openSignIn OAuth uses ClerkProvider.signInUrl for sso-callback', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.evaluate(() => { (window as any).Clerk.openSignIn({ forceRedirectUrl: '/protected' }); }); await u.po.signIn.waitForModal(); const signInPostPromise = page.waitForRequest( req => req.method() === 'POST' && /\/v1\/client\/sign_ins(\?|$)/.test(req.url()), ); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); const signInPost = await signInPostPromise; const body = new URLSearchParams(signInPost.postData() || ''); const redirectUrl = body.get('redirect_url'); expect(redirectUrl).toBeTruthy(); // The sso-callback base must come from ClerkProvider.signInUrl (CLERK_SIGN_IN_URL=/sign-in in this fixture). // Asserting origin alone would also pass for a blanket window.location.href style fix; asserting the // pathname is /sign-in pins the redirect to ClerkProvider.signInUrl rather than displayConfig.signInUrl // (accounts portal) or the current page URL. The hash assertion guarantees the callback actually targets // the sso-callback route — without it, a regression that drops the #/sso-callback fragment would still // satisfy origin/pathname while breaking the OAuth return path at runtime. const parsed = new URL(redirectUrl!); const appOrigin = new URL(app.serverUrl).origin; expect(parsed.origin).toBe(appOrigin); expect(parsed.pathname).toBe('/sign-in'); expect(parsed.hash).toMatch(/^#\/sso-callback/); }); test('sign up modal', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); // The SignUpModal will only redirect to its provided forceRedirectUrl if the user is signing up; it will not // redirect if the sign up is transferred to a sign in. await u.services.users.deleteIfExists({ email: fakeUser.email }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.getByText('Sign up button (force)').click(); await u.po.signUp.waitForModal(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await grantOAuthConsent(u.page); await u.page.waitForAppUrl('/protected'); }); test('sign in modal to sign up modal to transfer respects original forceRedirectUrl', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); // The user already exists on the OAuth provider instance, but we want to make sure that the user is created on the // app instance as well so that their sign up is transferred to a sign in. await u.services.users.getOrCreateUser(fakeUser); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); // Call clerk.openSignIn with custom redirect URLs await u.page.evaluate(() => { window.Clerk.openSignIn({ forceRedirectUrl: '/?from=signin', signUpForceRedirectUrl: '/?from=signup', }); }); await u.po.signIn.waitForModal(); // Click the Sign Up button to switch to sign up mode await u.page.getByRole('dialog').getByRole('link', { name: 'Sign up' }).click(); // Use OAuth provider await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); // Sign in with existing account await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await grantOAuthConsent(u.page); // Should redirect to the sign in redirect URL since we already had an account await u.page.waitForAppUrl('/?from=signin'); await u.po.expect.toBeSignedIn(); }); test.describe('authenticateWithPopup', () => { test('SignIn with oauthFlow=popup opens popup', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.getByText('Sign in button (force, popup)').click(); await u.po.signIn.waitForModal(); const popupPromise = context.waitForEvent('page'); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); const popup = await popupPromise; const popupUtils = await createTestUtils({ app, page: popup, context }); await popupUtils.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await popupUtils.po.signIn.setIdentifier(fakeUser.email); await popupUtils.po.signIn.continue(); await popupUtils.po.signIn.enterTestOtpCode(); await grantOAuthConsent(popupUtils.page); await u.page.waitForAppUrl('/protected'); await u.po.expect.toBeSignedIn(); }); }); }); testAgainstRunningApps({ withEnv: [appConfigs.envs.withSignInOrUpFlow] })('oauth flows combined @nextjs', ({ app }) => { test.describe.configure({ mode: 'serial' }); test.afterAll(async () => { await app.teardown(); }); test('openSignIn OAuth in combined flow targets /sign-in#/sso-callback', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.evaluate(() => { (window as any).Clerk.openSignIn({ forceRedirectUrl: '/protected' }); }); await u.po.signIn.waitForModal(); const signInPostPromise = page.waitForRequest( req => req.method() === 'POST' && /\/v1\/client\/sign_ins(\?|$)/.test(req.url()), ); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); const signInPost = await signInPostPromise; const body = new URLSearchParams(signInPost.postData() || ''); const redirectUrl = body.get('redirect_url'); expect(redirectUrl).toBeTruthy(); // Combined flow (CLERK_SIGN_UP_URL is unset in this env): the sso-callback must anchor to // ClerkProvider.signInUrl. The modal cannot know whether the SignIn mounted at that URL has the // combined-flow `create/*` routes, so it targets `sso-callback`, which every SignIn mounts and // whose handler transfers new users into a sign-up itself. const parsed = new URL(redirectUrl!); const appOrigin = new URL(app.serverUrl).origin; expect(parsed.origin).toBe(appOrigin); expect(parsed.pathname).toBe('/sign-in'); expect(parsed.hash).toMatch(/^#\/sso-callback/); }); }); testAgainstRunningApps({ withPattern: ['react.vite.withLegalConsent'] })( 'oauth popup with path-based routing @react', ({ app }) => { test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; test.beforeAll(async () => { const client = createClerkClient({ secretKey: (await resolveInstanceKeys('oauth-provider')).sk, publishableKey: (await resolveInstanceKeys('oauth-provider')).pk, }); const users = createUserService(withRetry(client)); fakeUser = users.createFakeUser(test, { withUsername: true, }); await users.createBapiUser(fakeUser); }); test.afterAll(async () => { const u = await createTestUtils({ app }); await fakeUser.deleteIfExists(); await u.services.users.deleteIfExists({ email: fakeUser.email }); await app.teardown(); }); test('popup OAuth navigates through sso-callback with path-based routing', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/sign-in-popup'); await u.page.waitForClerkJsLoaded(); await u.po.signIn.waitForMounted(); const popupPromise = context.waitForEvent('page'); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); const popup = await popupPromise; const popupUtils = await createTestUtils({ app, page: popup, context }); await popupUtils.page.getByText(/Sign in to .*oauth-provider/).waitFor(); // Complete OAuth in the popup await popupUtils.po.signIn.setIdentifier(fakeUser.email); await popupUtils.po.signIn.continue(); await popupUtils.po.signIn.enterTestOtpCode(); // Because the user is new to the app and legal consent is required, // the sign-up can't complete in the popup. The popup sends return_url // back to the parent, which navigates to /sso-callback via pushState. await u.page.getByRole('heading', { name: 'Legal consent' }).waitFor(); await u.page.getByLabel(/I agree to the/).check(); await u.po.signIn.continue(); await u.page.waitForAppUrl('/protected'); await u.po.expect.toBeSignedIn(); }); }, ); testAgainstRunningApps({ withPattern: ['react.vite.withLegalConsent'] })( 'oauth popup with hash-based routing @react', ({ app }) => { test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; test.beforeAll(async () => { const client = createClerkClient({ secretKey: (await resolveInstanceKeys('oauth-provider')).sk, publishableKey: (await resolveInstanceKeys('oauth-provider')).pk, }); const users = createUserService(withRetry(client)); fakeUser = users.createFakeUser(test, { withUsername: true, }); await users.createBapiUser(fakeUser); }); test.afterAll(async () => { const u = await createTestUtils({ app }); await fakeUser.deleteIfExists(); await u.services.users.deleteIfExists({ email: fakeUser.email }); await app.teardown(); }); test('popup OAuth navigates through sso-callback with hash-based routing', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.page.goToRelative('/sign-in-hash-popup'); await u.page.waitForClerkJsLoaded(); await u.po.signIn.waitForMounted(); const popupPromise = context.waitForEvent('page'); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); const popup = await popupPromise; const popupUtils = await createTestUtils({ app, page: popup, context }); await popupUtils.page.getByText(/Sign in to .*oauth-provider/).waitFor(); // Complete OAuth in the popup await popupUtils.po.signIn.setIdentifier(fakeUser.email); await popupUtils.po.signIn.continue(); await popupUtils.po.signIn.enterTestOtpCode(); // Because the user is new to the app and legal consent is required, // the sign-up can't complete in the popup. The popup sends return_url // back to the parent, which navigates to /sso-callback via pushState. // With hash routing, the HashRouter must detect this pushState change // to render the sso-callback route. hashchange does not fire for // pushState, so the router needs pushstate/replacestate listeners. await u.page.getByRole('heading', { name: 'Legal consent' }).waitFor(); await u.page.getByLabel(/I agree to the/).check(); await u.po.signIn.continue(); await u.page.getByText('SignedIn').waitFor(); await u.po.expect.toBeSignedIn(); }); }, ); testAgainstRunningApps({ withEnv: [appConfigs.envs.withLegalConsent] })( 'oauth flows with legal consent @nextjs', ({ app }) => { test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; test.beforeAll(async () => { // Create a clerkClient for the OAuth provider instance. const client = createClerkClient({ secretKey: (await resolveInstanceKeys('oauth-provider')).sk, publishableKey: (await resolveInstanceKeys('oauth-provider')).pk, }); const users = createUserService(withRetry(client)); fakeUser = users.createFakeUser(test, { withUsername: true, }); // Create the user on the OAuth provider instance so we do not need to sign up twice. await users.createBapiUser(fakeUser); }); test.afterAll(async () => { const u = await createTestUtils({ app }); // Delete the user on the OAuth provider instance. await fakeUser.deleteIfExists(); // Delete the user on the app instance. await u.services.users.deleteIfExists({ email: fakeUser.email }); await app.teardown(); }); test('sign up via transfer with custom oauth provider', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await u.page.getByRole('heading', { name: 'Legal consent' }).waitFor(); await u.page.getByLabel(/I agree to the/).check(); await u.po.signIn.continue(); // We can't use our `expect.toBeSignedIn` first because that would result in `true` on the OAuth provider instance. // We want to assert that we're signed in on our app instance, which will render the text 'SignedIn'. await u.page.getByText('SignedIn').waitFor(); await u.po.expect.toBeSignedIn(); }); test('sign up via transfer modal', async ({ page, context }) => { const u = await createTestUtils({ app, page, context }); await u.services.users.deleteIfExists({ email: fakeUser.email }); await u.page.goToRelative('/buttons'); await u.page.waitForClerkJsLoaded(); await u.po.expect.toBeSignedOut(); await u.page.getByText('Sign in button (force)').click(); await u.po.signIn.waitForModal(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await u.page.getByRole('heading', { name: 'Legal consent' }).waitFor(); await u.page.getByLabel(/I agree to the/).check(); await u.po.signIn.continue(); await u.page.waitForAppUrl('/protected'); }); test('redirects when attempting OAuth sign in with existing session in another tab', async ({ page, context, browser, }) => { const u = await createTestUtils({ app, page, context, browser }); // Open sign-in page in both tabs before signing in await u.po.signIn.goTo(); let secondTabUtils: any; await u.tabs.runInNewTab(async u2 => { secondTabUtils = u2; await u2.po.signIn.goTo(); }); // Sign in via OAuth on the first tab await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(fakeUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await u.page.getByText('SignedIn').waitFor(); await u.po.expect.toBeSignedIn(); // Attempt to sign in via OAuth on the second tab (which already has sign-in mounted) await secondTabUtils.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); // Should redirect and be signed in without error await secondTabUtils.po.expect.toBeSignedIn(); }); test('sends a single sign-up transfer when SignIn remounts while the transfer is in flight', async ({ page, context, }) => { const providerKeys = await resolveInstanceKeys('oauth-provider'); const providerClient = createClerkClient({ secretKey: providerKeys.sk, publishableKey: providerKeys.pk }); const users = createUserService(withRetry(providerClient)); const newUser = users.createFakeUser(test, { withUsername: true }); await users.createBapiUser(newUser); const u = await createTestUtils({ app, page, context }); const transferStatuses: number[] = []; let callbackCalledAgain = false; let onSecondTransfer: () => void = () => {}; const secondTransfer = new Promise(resolve => { onSecondTransfer = resolve; }); let onFirstTransferResponded: () => void = () => {}; const firstTransferResponded = new Promise(resolve => { onFirstTransferResponded = resolve; }); await page.route('**/v1/client/sign_ups*', async route => { const request = route.request(); const isTransfer = request.method() === 'POST' && (new URL(request.url()).searchParams.get('transfer') === 'true' || request.postData()?.includes('transfer=true')); if (!isTransfer) { return route.continue(); } const isFirst = transferStatuses.length === 0; transferStatuses.push(0); const index = transferStatuses.length - 1; if (isFirst) { await page.evaluate(() => { const clerk = window.Clerk as any; const handleRedirectCallback = clerk.handleRedirectCallback; clerk.handleRedirectCallback = (...args: unknown[]) => { (window as any).__e2eCallbackCalledAgain = true; return handleRedirectCallback(...args); }; window.dispatchEvent(new Event('e2e:resuspend')); }); callbackCalledAgain = await page .waitForFunction(() => (window as any).__e2eCallbackCalledAgain === true, undefined, { timeout: 3000 }) .then(() => true) .catch(() => false); await Promise.race([secondTransfer, new Promise(resolve => setTimeout(resolve, 1000))]); } else { onSecondTransfer(); } await route.continue(); const response = await request.response(); transferStatuses[index] = response?.status() ?? 0; if (isFirst) { onFirstTransferResponded(); } }); try { await u.page.goToRelative('/sign-in-resuspend'); await u.po.signIn.waitForMounted(); await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); await u.page.getByText(/Sign in to .*oauth-provider/).waitFor(); await u.po.signIn.setIdentifier(newUser.email); await u.po.signIn.continue(); await u.po.signIn.enterTestOtpCode(); await firstTransferResponded; expect(callbackCalledAgain).toBe(true); expect(transferStatuses).toEqual([200]); await u.page.getByRole('heading', { name: 'Legal consent' }).waitFor(); } finally { await newUser.deleteIfExists(); await u.services.users.deleteIfExists({ email: newUser.email }); } }); }, );