From 74d109c0d9ec174752db9d0c214f4b39afdfaf15 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 31 Mar 2026 10:38:14 -0700 Subject: [PATCH 1/2] chore(repo): Clean up machine integration tests --- integration/testUtils/createTestUtils.ts | 86 ++++ integration/testUtils/index.ts | 95 +--- integration/testUtils/machineAuthHelpers.ts | 469 ++++++++++++++++++ integration/testUtils/machineAuthService.ts | 186 ------- integration/tests/astro/machine.test.ts | 357 +++---------- integration/tests/next-machine.test.ts | 404 +++------------ .../tests/react-router/machine.test.ts | 318 ++---------- .../tests/tanstack-start/machine.test.ts | 395 +++------------ 8 files changed, 807 insertions(+), 1503 deletions(-) create mode 100644 integration/testUtils/createTestUtils.ts create mode 100644 integration/testUtils/machineAuthHelpers.ts delete mode 100644 integration/testUtils/machineAuthService.ts diff --git a/integration/testUtils/createTestUtils.ts b/integration/testUtils/createTestUtils.ts new file mode 100644 index 00000000000..fa13217ab98 --- /dev/null +++ b/integration/testUtils/createTestUtils.ts @@ -0,0 +1,86 @@ +import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; +import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable'; +import type { Browser, BrowserContext, Page } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { createEmailService } from './emailService'; +import { createInvitationService } from './invitationsService'; +import { createOrganizationsService } from './organizationsService'; +import { withRetry } from './retryableClerkClient'; +import { createUserService } from './usersService'; +import { createWaitlistService } from './waitlistService'; + +const createClerkClient = (app: Application) => { + return backendCreateClerkClient({ + apiUrl: app.env.privateVariables.get('CLERK_API_URL'), + secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), + publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), + }); +}; + +export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser }; + +export const createTestUtils = < + Params extends { app: Application; useTestingToken?: boolean } & Partial, + Services = typeof services, + PO = typeof pageObjects, + BH = typeof browserHelpers, + FullReturn = { services: Services; po: PO; tabs: BH; page: EnhancedPage; nextJsVersion: string }, + OnlyAppReturn = { services: Services }, +>( + params: Params, +): Params extends Partial ? FullReturn : OnlyAppReturn => { + const { app, context, browser, useTestingToken = true } = params || {}; + + const clerkClient = withRetry(createClerkClient(app)); + const services = { + clerk: clerkClient, + email: createEmailService(), + users: createUserService(clerkClient), + invitations: createInvitationService(clerkClient), + organizations: createOrganizationsService(clerkClient), + waitlist: createWaitlistService(clerkClient), + }; + + if (!params.page) { + return { services } as any; + } + + const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl }); + + const browserHelpers = { + runInNewTab: async ( + cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, + ) => { + const u = createTestUtils({ + app, + page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), + }); + await cb(u as any, context); + return u; + }, + runInNewBrowser: async ( + cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, + ) => { + if (!browser) { + throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?'); + } + const context = await browser.newContext(); + const u = createTestUtils({ + app, + page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), + }); + await cb(u as any, context); + return u; + }, + }; + + return { + page: pageObjects.page, + services, + po: pageObjects, + tabs: browserHelpers, + // eslint-disable-next-line turbo/no-undeclared-env-vars + nextJsVersion: process.env.E2E_NEXTJS_VERSION, + } as any; +}; diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index f694e9d4e2c..e19a3db3749 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -1,98 +1,17 @@ -import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; -import { withRetry } from './retryableClerkClient'; -import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable'; -import type { Browser, BrowserContext, Page } from '@playwright/test'; - -import type { Application } from '../models/application'; -import { createEmailService } from './emailService'; -import { createInvitationService } from './invitationsService'; -import { createOrganizationsService } from './organizationsService'; import type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail } from './usersService'; -import { createUserService } from './usersService'; -import { createWaitlistService } from './waitlistService'; +export type { CreateAppPageObjectArgs } from './createTestUtils'; +export { createTestUtils } from './createTestUtils'; export type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail }; -export type { FakeMachineNetwork, FakeOAuthApp } from './machineAuthService'; +export type { FakeMachineNetwork, FakeOAuthApp, MachineAuthTestAdapter } from './machineAuthHelpers'; export { createFakeMachineNetwork, createFakeOAuthApp, createJwtM2MToken, obtainOAuthAccessToken, -} from './machineAuthService'; - -const createClerkClient = (app: Application) => { - return backendCreateClerkClient({ - apiUrl: app.env.privateVariables.get('CLERK_API_URL'), - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); -}; - -export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser }; - -export const createTestUtils = < - Params extends { app: Application; useTestingToken?: boolean } & Partial, - Services = typeof services, - PO = typeof pageObjects, - BH = typeof browserHelpers, - FullReturn = { services: Services; po: PO; tabs: BH; page: EnhancedPage; nextJsVersion: string }, - OnlyAppReturn = { services: Services }, ->( - params: Params, -): Params extends Partial ? FullReturn : OnlyAppReturn => { - const { app, context, browser, useTestingToken = true } = params || {}; - - const clerkClient = withRetry(createClerkClient(app)); - const services = { - clerk: clerkClient, - email: createEmailService(), - users: createUserService(clerkClient), - invitations: createInvitationService(clerkClient), - organizations: createOrganizationsService(clerkClient), - waitlist: createWaitlistService(clerkClient), - }; - - if (!params.page) { - return { services } as any; - } - - const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl }); - - const browserHelpers = { - runInNewTab: async ( - cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, - ) => { - const u = createTestUtils({ - app, - page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), - }); - await cb(u as any, context); - return u; - }, - runInNewBrowser: async ( - cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, - ) => { - if (!browser) { - throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?'); - } - const context = await browser.newContext(); - const u = createTestUtils({ - app, - page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), - }); - await cb(u as any, context); - return u; - }, - }; - - return { - page: pageObjects.page, - services, - po: pageObjects, - tabs: browserHelpers, - // eslint-disable-next-line turbo/no-undeclared-env-vars - nextJsVersion: process.env.E2E_NEXTJS_VERSION, - } as any; -}; + registerApiKeyAuthTests, + registerM2MAuthTests, + registerOAuthAuthTests, +} from './machineAuthHelpers'; export { testAgainstRunningApps } from './testAgainstRunningApps'; diff --git a/integration/testUtils/machineAuthHelpers.ts b/integration/testUtils/machineAuthHelpers.ts new file mode 100644 index 00000000000..0c70fc839a3 --- /dev/null +++ b/integration/testUtils/machineAuthHelpers.ts @@ -0,0 +1,469 @@ +import { randomBytes } from 'node:crypto'; + +import type { ClerkClient, M2MToken, Machine, OAuthApplication, User } from '@clerk/backend'; +import { createClerkClient } from '@clerk/backend'; +import { TokenType } from '@clerk/backend/internal'; +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import type { ApplicationConfig } from '../models/applicationConfig'; +import type { EnvironmentConfig } from '../models/environment'; +import { appConfigs } from '../presets'; +import { instanceKeys } from '../presets/envs'; +import { createTestUtils } from './createTestUtils'; +import type { FakeAPIKey, FakeUser } from './usersService'; + +export type FakeMachineNetwork = { + primaryServer: Machine; + scopedSender: Machine; + unscopedSender: Machine; + scopedSenderToken: M2MToken; + unscopedSenderToken: M2MToken; + cleanup: () => Promise; +}; + +export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { + const fakeCompanyName = faker.company.name(); + + const primaryServer = await clerkClient.machines.create({ + name: `${fakeCompanyName} Primary API Server`, + }); + + const scopedSender = await clerkClient.machines.create({ + name: `${fakeCompanyName} Scoped Sender`, + scopedMachines: [primaryServer.id], + }); + const scopedSenderToken = await clerkClient.m2m.createToken({ + machineSecretKey: scopedSender.secretKey, + secondsUntilExpiration: 60 * 30, + }); + + const unscopedSender = await clerkClient.machines.create({ + name: `${fakeCompanyName} Unscoped Sender`, + }); + const unscopedSenderToken = await clerkClient.m2m.createToken({ + machineSecretKey: unscopedSender.secretKey, + secondsUntilExpiration: 60 * 30, + }); + + return { + primaryServer, + scopedSender, + unscopedSender, + scopedSenderToken, + unscopedSenderToken, + cleanup: async () => { + await Promise.all([ + clerkClient.m2m.revokeToken({ m2mTokenId: scopedSenderToken.id }), + clerkClient.m2m.revokeToken({ m2mTokenId: unscopedSenderToken.id }), + ]); + await Promise.all([ + clerkClient.machines.delete(scopedSender.id), + clerkClient.machines.delete(unscopedSender.id), + clerkClient.machines.delete(primaryServer.id), + ]); + }, + }; +} + +export async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise { + return clerkClient.m2m.createToken({ + machineSecretKey: senderSecretKey, + secondsUntilExpiration: 60 * 30, + tokenFormat: 'jwt', + }); +} + +export type FakeOAuthApp = { + oAuthApp: OAuthApplication; + cleanup: () => Promise; +}; + +export async function createFakeOAuthApp(clerkClient: ClerkClient, callbackUrl: string): Promise { + const oAuthApp = await clerkClient.oauthApplications.create({ + name: `Integration Test OAuth App - ${Date.now()}`, + redirectUris: [callbackUrl], + scopes: 'profile email', + }); + + return { + oAuthApp, + cleanup: async () => { + await clerkClient.oauthApplications.delete(oAuthApp.id); + }, + }; +} + +export type ObtainOAuthAccessTokenParams = { + page: Page; + oAuthApp: OAuthApplication; + redirectUri: string; + fakeUser: { email?: string; password: string }; + signIn: { + waitForMounted: (...args: any[]) => Promise; + signInWithEmailAndInstantPassword: (params: { email: string; password: string }) => Promise; + }; +}; + +export async function obtainOAuthAccessToken({ + page, + oAuthApp, + redirectUri, + fakeUser, + signIn, +}: ObtainOAuthAccessTokenParams): Promise { + const state = randomBytes(16).toString('hex'); + const authorizeUrl = new URL(oAuthApp.authorizeUrl); + authorizeUrl.searchParams.set('client_id', oAuthApp.clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('scope', 'profile email'); + authorizeUrl.searchParams.set('state', state); + + await page.goto(authorizeUrl.toString()); + + await signIn.waitForMounted(); + await signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + + const consentButton = page.getByRole('button', { name: 'Allow' }); + await consentButton.waitFor({ timeout: 10000 }); + await consentButton.click(); + + await page.waitForURL(/oauth\/callback/, { timeout: 10000 }); + const callbackUrl = new URL(page.url()); + const authCode = callbackUrl.searchParams.get('code'); + expect(authCode).toBeTruthy(); + + expect(oAuthApp.clientSecret).toBeTruthy(); + const tokenResponse = await page.request.post(oAuthApp.tokenFetchUrl, { + data: new URLSearchParams({ + grant_type: 'authorization_code', + code: authCode, + redirect_uri: redirectUri, + client_id: oAuthApp.clientId, + client_secret: oAuthApp.clientSecret, + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + expect(tokenResponse.status()).toBe(200); + const tokenData = (await tokenResponse.json()) as { access_token?: string }; + expect(tokenData.access_token).toBeTruthy(); + + return tokenData.access_token; +} + +type RouteBuilder = (config: ApplicationConfig) => ApplicationConfig; + +export type MachineAuthTestAdapter = { + baseConfig: ApplicationConfig; + apiKey: { + path: string; + addRoutes: RouteBuilder; + }; + m2m: { + path: string; + addRoutes: RouteBuilder; + }; + oauth: { + verifyPath: string; + callbackPath: string; + addRoutes: RouteBuilder; + }; +}; + +const createApiKeysEnv = (): EnvironmentConfig => appConfigs.envs.withAPIKeys.clone(); + +const createMachineClient = () => + createClerkClient({ + secretKey: instanceKeys.get('with-api-keys').sk, + }); + +const buildApp = async (adapter: MachineAuthTestAdapter, addRoutes: RouteBuilder): Promise => { + const config = addRoutes(adapter.baseConfig.clone()); + return config.commit(); +}; + +const createOAuthClient = (app: Application) => + createClerkClient({ + secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), + publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), + }); + +export const registerApiKeyAuthTests = (adapter: MachineAuthTestAdapter): void => { + test.describe('API key auth', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeBapiUser: User; + let fakeAPIKey: FakeAPIKey; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + app = await buildApp(adapter, adapter.apiKey.addRoutes); + await app.setup(); + await app.withEnv(createApiKeysEnv()); + await app.dev(); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + fakeBapiUser = await u.services.users.createBapiUser(fakeUser); + fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); + }); + + test.afterAll(async () => { + await fakeAPIKey.revoke(); + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('should return 401 if no API key is provided', async ({ request }) => { + const res = await request.get(new URL(adapter.apiKey.path, app.serverUrl).toString()); + expect(res.status()).toBe(401); + }); + + test('should return 401 if API key is invalid', async ({ request }) => { + const res = await request.get(new URL(adapter.apiKey.path, app.serverUrl).toString(), { + headers: { Authorization: 'Bearer invalid_key' }, + }); + expect(res.status()).toBe(401); + }); + + test('should return 200 with auth object if API key is valid', async ({ request }) => { + const res = await request.get(new URL(adapter.apiKey.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + const apiKeyData = await res.json(); + expect(res.status()).toBe(200); + expect(apiKeyData.userId).toBe(fakeBapiUser.id); + expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); + }); + + for (const [tokenType, token] of [ + ['M2M', 'mt_test_mismatch'], + ['OAuth', 'oat_test_mismatch'], + ] as const) { + test(`rejects ${tokenType} token on API key route (token type mismatch)`, async ({ request }) => { + const res = await request.get(new URL(adapter.apiKey.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status()).toBe(401); + }); + } + + test('should handle multiple token types', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const url = new URL(adapter.apiKey.path, app.serverUrl).toString(); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + const getRes = await u.page.request.get(url); + expect(getRes.status()).toBe(401); + + const postWithSessionRes = await u.page.request.post(url); + const sessionData = await postWithSessionRes.json(); + expect(postWithSessionRes.status()).toBe(200); + expect(sessionData.userId).toBe(fakeBapiUser.id); + expect(sessionData.tokenType).toBe(TokenType.SessionToken); + + const postWithApiKeyRes = await u.page.request.post(url, { + headers: { Authorization: `Bearer ${fakeAPIKey.secret}` }, + }); + const apiKeyData = await postWithApiKeyRes.json(); + expect(postWithApiKeyRes.status()).toBe(200); + expect(apiKeyData.userId).toBe(fakeBapiUser.id); + expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); + }); + }); +}; + +export const registerM2MAuthTests = (adapter: MachineAuthTestAdapter): void => { + test.describe('M2M auth', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let network: FakeMachineNetwork; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + network = await createFakeMachineNetwork(createMachineClient()); + app = await buildApp(adapter, adapter.m2m.addRoutes); + await app.setup(); + + const env = createApiKeysEnv().setEnvVariable( + 'private', + 'CLERK_MACHINE_SECRET_KEY', + network.primaryServer.secretKey, + ); + await app.withEnv(env); + await app.dev(); + }); + + test.afterAll(async () => { + await network.cleanup(); + await app.teardown(); + }); + + test('rejects requests with invalid M2M tokens', async ({ request }) => { + const url = new URL(adapter.m2m.path, app.serverUrl).toString(); + const res = await request.get(url); + expect(res.status()).toBe(401); + + const res2 = await request.get(url, { + headers: { Authorization: 'Bearer mt_xxx' }, + }); + expect(res2.status()).toBe(401); + }); + + test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ request }) => { + const res = await request.get(new URL(adapter.m2m.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${network.unscopedSenderToken.token}` }, + }); + expect(res.status()).toBe(401); + }); + + test('authorizes M2M requests when sender machine has proper access', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const res = await u.page.request.get(new URL(adapter.m2m.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${network.scopedSenderToken.token}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.subject).toBe(network.scopedSender.id); + expect(body.tokenType).toBe(TokenType.M2MToken); + }); + + test('authorizes after dynamically granting scope', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.services.clerk.machines.createScope(network.unscopedSender.id, network.primaryServer.id); + const m2mToken = await u.services.clerk.m2m.createToken({ + machineSecretKey: network.unscopedSender.secretKey, + secondsUntilExpiration: 60 * 30, + }); + + try { + const res = await u.page.request.get(new URL(adapter.m2m.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${m2mToken.token}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.subject).toBe(network.unscopedSender.id); + expect(body.tokenType).toBe(TokenType.M2MToken); + } finally { + await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id }); + } + }); + + test('verifies JWT format M2M token via local verification', async ({ request }) => { + const jwtToken = await createJwtM2MToken(createMachineClient(), network.scopedSender.secretKey); + + const res = await request.get(new URL(adapter.m2m.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${jwtToken.token}` }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.subject).toBe(network.scopedSender.id); + expect(body.tokenType).toBe(TokenType.M2MToken); + }); + + for (const [tokenType, token] of [ + ['API key', 'ak_test_mismatch'], + ['OAuth', 'oat_test_mismatch'], + ] as const) { + test(`rejects ${tokenType} token on M2M route (token type mismatch)`, async ({ request }) => { + const res = await request.get(new URL(adapter.m2m.path, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status()).toBe(401); + }); + } + }); +}; + +export const registerOAuthAuthTests = (adapter: MachineAuthTestAdapter): void => { + test.describe('OAuth auth', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeOAuth: FakeOAuthApp; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + app = await buildApp(adapter, adapter.oauth.addRoutes); + await app.setup(); + await app.withEnv(createApiKeysEnv()); + await app.dev(); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + fakeOAuth = await createFakeOAuthApp( + createOAuthClient(app), + new URL(adapter.oauth.callbackPath, app.serverUrl).toString(), + ); + }); + + test.afterAll(async () => { + await fakeOAuth.cleanup(); + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const accessToken = await obtainOAuthAccessToken({ + page: u.page, + oAuthApp: fakeOAuth.oAuthApp, + redirectUri: new URL(adapter.oauth.callbackPath, app.serverUrl).toString(), + fakeUser, + signIn: u.po.signIn, + }); + + const res = await u.page.request.get(new URL(adapter.oauth.verifyPath, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(res.status()).toBe(200); + const authData = await res.json(); + expect(authData.userId).toBeDefined(); + expect(authData.tokenType).toBe(TokenType.OAuthToken); + }); + + test('rejects request without OAuth token', async ({ request }) => { + const res = await request.get(new URL(adapter.oauth.verifyPath, app.serverUrl).toString()); + expect(res.status()).toBe(401); + }); + + test('rejects request with invalid OAuth token', async ({ request }) => { + const res = await request.get(new URL(adapter.oauth.verifyPath, app.serverUrl).toString(), { + headers: { Authorization: 'Bearer invalid_oauth_token' }, + }); + expect(res.status()).toBe(401); + }); + + for (const [tokenType, token] of [ + ['API key', 'ak_test_mismatch'], + ['M2M', 'mt_test_mismatch'], + ] as const) { + test(`rejects ${tokenType} token on OAuth route (token type mismatch)`, async ({ request }) => { + const res = await request.get(new URL(adapter.oauth.verifyPath, app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status()).toBe(401); + }); + } + }); +}; diff --git a/integration/testUtils/machineAuthService.ts b/integration/testUtils/machineAuthService.ts deleted file mode 100644 index 3d0c3ca5047..00000000000 --- a/integration/testUtils/machineAuthService.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -import type { ClerkClient, M2MToken, Machine, OAuthApplication } from '@clerk/backend'; -import { faker } from '@faker-js/faker'; -import type { Page } from '@playwright/test'; -import { expect } from '@playwright/test'; - -// ─── M2M ──────────────────────────────────────────────────────────────────── - -export type FakeMachineNetwork = { - primaryServer: Machine; - scopedSender: Machine; - unscopedSender: Machine; - scopedSenderToken: M2MToken; - unscopedSenderToken: M2MToken; - cleanup: () => Promise; -}; - -/** - * Creates a network of three machines for M2M testing: - * - A primary API server (the "receiver") - * - A sender machine scoped to the primary (should succeed) - * - A sender machine with no scope (should fail) - * - * Each sender gets an opaque M2M token created for it. - * Call `cleanup()` to revoke tokens and delete all machines. - */ -export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { - const fakeCompanyName = faker.company.name(); - - const primaryServer = await clerkClient.machines.create({ - name: `${fakeCompanyName} Primary API Server`, - }); - - const scopedSender = await clerkClient.machines.create({ - name: `${fakeCompanyName} Scoped Sender`, - scopedMachines: [primaryServer.id], - }); - const scopedSenderToken = await clerkClient.m2m.createToken({ - machineSecretKey: scopedSender.secretKey, - secondsUntilExpiration: 60 * 30, - }); - - const unscopedSender = await clerkClient.machines.create({ - name: `${fakeCompanyName} Unscoped Sender`, - }); - const unscopedSenderToken = await clerkClient.m2m.createToken({ - machineSecretKey: unscopedSender.secretKey, - secondsUntilExpiration: 60 * 30, - }); - - return { - primaryServer, - scopedSender, - unscopedSender, - scopedSenderToken, - unscopedSenderToken, - cleanup: async () => { - await Promise.all([ - clerkClient.m2m.revokeToken({ m2mTokenId: scopedSenderToken.id }), - clerkClient.m2m.revokeToken({ m2mTokenId: unscopedSenderToken.id }), - ]); - await Promise.all([ - clerkClient.machines.delete(scopedSender.id), - clerkClient.machines.delete(unscopedSender.id), - clerkClient.machines.delete(primaryServer.id), - ]); - }, - }; -} - -/** - * Creates a JWT-format M2M token for a sender machine. - * JWT tokens are self-contained and expire via the `exp` claim (no revocation needed). - */ -export async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise { - return clerkClient.m2m.createToken({ - machineSecretKey: senderSecretKey, - secondsUntilExpiration: 60 * 30, - tokenFormat: 'jwt', - }); -} - -// ─── OAuth ────────────────────────────────────────────────────────────────── - -export type FakeOAuthApp = { - oAuthApp: OAuthApplication; - cleanup: () => Promise; -}; - -/** - * Creates an OAuth application via BAPI for testing the full authorization code flow. - * Call `cleanup()` to delete the OAuth application. - */ -export async function createFakeOAuthApp(clerkClient: ClerkClient, callbackUrl: string): Promise { - const oAuthApp = await clerkClient.oauthApplications.create({ - name: `Integration Test OAuth App - ${Date.now()}`, - redirectUris: [callbackUrl], - scopes: 'profile email', - }); - - return { - oAuthApp, - cleanup: async () => { - await clerkClient.oauthApplications.delete(oAuthApp.id); - }, - }; -} - -export type ObtainOAuthAccessTokenParams = { - page: Page; - oAuthApp: OAuthApplication; - redirectUri: string; - fakeUser: { email?: string; password: string }; - signIn: { - waitForMounted: (...args: any[]) => Promise; - signInWithEmailAndInstantPassword: (params: { email: string; password: string }) => Promise; - }; -}; - -/** - * Runs the full OAuth 2.0 authorization code flow using Playwright: - * 1. Navigates to the authorize URL - * 2. Signs in with the provided user credentials - * 3. Accepts the consent screen - * 4. Extracts the authorization code from the callback - * 5. Exchanges the code for an access token - * - * Returns the access token string. - */ -export async function obtainOAuthAccessToken({ - page, - oAuthApp, - redirectUri, - fakeUser, - signIn, -}: ObtainOAuthAccessTokenParams): Promise { - const state = randomBytes(16).toString('hex'); - const authorizeUrl = new URL(oAuthApp.authorizeUrl); - authorizeUrl.searchParams.set('client_id', oAuthApp.clientId); - authorizeUrl.searchParams.set('redirect_uri', redirectUri); - authorizeUrl.searchParams.set('response_type', 'code'); - authorizeUrl.searchParams.set('scope', 'profile email'); - authorizeUrl.searchParams.set('state', state); - - await page.goto(authorizeUrl.toString()); - - // Sign in on Account Portal - await signIn.waitForMounted(); - await signIn.signInWithEmailAndInstantPassword({ - email: fakeUser.email, - password: fakeUser.password, - }); - - // Accept consent screen - const consentButton = page.getByRole('button', { name: 'Allow' }); - await consentButton.waitFor({ timeout: 10000 }); - await consentButton.click(); - - // Wait for redirect and extract authorization code - await page.waitForURL(/oauth\/callback/, { timeout: 10000 }); - const callbackUrl = new URL(page.url()); - const authCode = callbackUrl.searchParams.get('code'); - expect(authCode).toBeTruthy(); - - // Exchange code for access token - expect(oAuthApp.clientSecret).toBeTruthy(); - const tokenResponse = await page.request.post(oAuthApp.tokenFetchUrl, { - data: new URLSearchParams({ - grant_type: 'authorization_code', - code: authCode, - redirect_uri: redirectUri, - client_id: oAuthApp.clientId, - client_secret: oAuthApp.clientSecret, - }).toString(), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - - expect(tokenResponse.status()).toBe(200); - const tokenData = (await tokenResponse.json()) as { access_token?: string }; - expect(tokenData.access_token).toBeTruthy(); - - return tokenData.access_token; -} diff --git a/integration/tests/astro/machine.test.ts b/integration/tests/astro/machine.test.ts index ca10578e3a8..b629f1eb9a3 100644 --- a/integration/tests/astro/machine.test.ts +++ b/integration/tests/astro/machine.test.ts @@ -1,223 +1,66 @@ -import type { User } from '@clerk/backend'; -import { createClerkClient } from '@clerk/backend'; -import { TokenType } from '@clerk/backend/internal'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; -import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { instanceKeys } from '../../presets/envs'; -import type { FakeAPIKey, FakeMachineNetwork, FakeOAuthApp, FakeUser } from '../../testUtils'; -import { - createFakeMachineNetwork, - createFakeOAuthApp, - createJwtM2MToken, - createTestUtils, - obtainOAuthAccessToken, -} from '../../testUtils'; - -test.describe('Astro machine authentication @machine', () => { - test.describe('API key auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.astro.node - .clone() - .addFile( - 'src/pages/api/me.ts', - () => ` - import type { APIRoute } from 'astro'; - - export const GET: APIRoute = ({ locals }) => { - const { userId, tokenType } = locals.auth({ acceptsToken: 'api_key' }); - - if (!userId) { - return new Response('Unauthorized', { status: 401 }); - } - - return Response.json({ userId, tokenType }); - }; - `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - fakeBapiUser = await u.services.users.createBapiUser(fakeUser); - fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); - }); - - test.afterAll(async () => { - await fakeAPIKey.revoke(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('should return 401 if no API key is provided', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('should return 401 if API key is invalid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_key' }, - }); - expect(res.status()).toBe(401); - }); - - test('should return 200 with auth object if API key is valid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { - Authorization: `Bearer ${fakeAPIKey.secret}`, - }, - }); - const apiKeyData = await res.json(); - expect(res.status()).toBe(200); - expect(apiKeyData.userId).toBe(fakeBapiUser.id); - expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); - }); - - for (const [tokenType, token] of [ - ['M2M', 'mt_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on API key route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('M2M auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let network: FakeMachineNetwork; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - network = await createFakeMachineNetwork(client); - - app = await appConfigs.astro.node - .clone() - .addFile( - 'src/pages/api/m2m.ts', - () => ` - import type { APIRoute } from 'astro'; - - export const GET: APIRoute = ({ locals }) => { - const { subject, tokenType, isAuthenticated } = locals.auth({ acceptsToken: 'm2m_token' }); - - if (!isAuthenticated) { - return new Response('Unauthorized', { status: 401 }); - } - - return Response.json({ subject, tokenType }); - }; - `, - ) - .commit(); - - await app.setup(); - - const env = appConfigs.envs.withAPIKeys - .clone() - .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', network.primaryServer.secretKey); - await app.withEnv(env); - await app.dev(); - }); - - test.afterAll(async () => { - await network.cleanup(); - await app.teardown(); - }); - - test('rejects requests with invalid M2M tokens', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m'); - expect(res.status()).toBe(401); - - const res2 = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: 'Bearer mt_xxx' }, - }); - expect(res2.status()).toBe(401); - }); - - test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${network.unscopedSenderToken.token}` }, - }); - expect(res.status()).toBe(401); - }); - - test('authorizes M2M requests when sender machine has proper access', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const res = await u.page.request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${network.scopedSenderToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - test('verifies JWT format M2M token via local verification', async ({ request }) => { - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - const jwtToken = await createJwtM2MToken(client, network.scopedSender.secretKey); - - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${jwtToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on M2M route (token type mismatch)`, async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('OAuth auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeOAuth: FakeOAuthApp; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.astro.node - .clone() +import type { MachineAuthTestAdapter } from '../../testUtils'; +import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../../testUtils'; + +const adapter: MachineAuthTestAdapter = { + baseConfig: appConfigs.astro.node, + apiKey: { + path: '/api/me', + addRoutes: config => + config.addFile( + 'src/pages/api/me.ts', + () => ` + import type { APIRoute } from 'astro'; + + export const GET: APIRoute = ({ locals }) => { + const { userId, tokenType } = locals.auth({ acceptsToken: 'api_key' }); + + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json({ userId, tokenType }); + }; + + export const POST: APIRoute = ({ locals }) => { + const authObject = locals.auth({ acceptsToken: ['api_key', 'session_token'] }); + + if (!authObject.isAuthenticated) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType }); + }; + `, + ), + }, + m2m: { + path: '/api/m2m', + addRoutes: config => + config.addFile( + 'src/pages/api/m2m.ts', + () => ` + import type { APIRoute } from 'astro'; + + export const GET: APIRoute = ({ locals }) => { + const { subject, tokenType, isAuthenticated } = locals.auth({ acceptsToken: 'm2m_token' }); + + if (!isAuthenticated) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json({ subject, tokenType }); + }; + `, + ), + }, + oauth: { + verifyPath: '/api/oauth-verify', + callbackPath: '/api/oauth/callback', + addRoutes: config => + config .addFile( 'src/pages/api/oauth-verify.ts', () => ` @@ -243,76 +86,12 @@ test.describe('Astro machine authentication @machine', () => { return Response.json({ message: 'OAuth callback received' }); }; `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - - const clerkClient = createClerkClient({ - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); + ), + }, +}; - fakeOAuth = await createFakeOAuthApp(clerkClient, `${app.serverUrl}/api/oauth/callback`); - }); - - test.afterAll(async () => { - await fakeOAuth.cleanup(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const accessToken = await obtainOAuthAccessToken({ - page: u.page, - oAuthApp: fakeOAuth.oAuthApp, - redirectUri: `${app.serverUrl}/api/oauth/callback`, - fakeUser, - signIn: u.po.signIn, - }); - - const res = await u.page.request.get(new URL('/api/oauth-verify', app.serverUrl).toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - expect(res.status()).toBe(200); - const authData = await res.json(); - expect(authData.userId).toBeDefined(); - expect(authData.tokenType).toBe(TokenType.OAuthToken); - }); - - test('rejects request without OAuth token', async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('rejects request with invalid OAuth token', async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_oauth_token' }, - }); - expect(res.status()).toBe(401); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['M2M', 'mt_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on OAuth route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); +test.describe('Astro machine authentication @machine', () => { + registerApiKeyAuthTests(adapter); + registerM2MAuthTests(adapter); + registerOAuthAuthTests(adapter); }); diff --git a/integration/tests/next-machine.test.ts b/integration/tests/next-machine.test.ts index 31313e4d23b..fff2f05d614 100644 --- a/integration/tests/next-machine.test.ts +++ b/integration/tests/next-machine.test.ts @@ -1,286 +1,66 @@ -import type { User } from '@clerk/backend'; -import { createClerkClient } from '@clerk/backend'; -import { TokenType } from '@clerk/backend/internal'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; -import type { Application } from '../models/application'; import { appConfigs } from '../presets'; -import { instanceKeys } from '../presets/envs'; -import type { FakeAPIKey, FakeMachineNetwork, FakeOAuthApp, FakeUser } from '../testUtils'; -import { - createFakeMachineNetwork, - createFakeOAuthApp, - createJwtM2MToken, - createTestUtils, - obtainOAuthAccessToken, -} from '../testUtils'; - -test.describe('Next.js machine authentication @machine', () => { - test.describe('API key auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.next.appRouter - .clone() - .addFile( - 'src/app/api/me/route.ts', - () => ` - import { auth } from '@clerk/nextjs/server'; - - export async function GET() { - const { userId, tokenType } = await auth({ acceptsToken: 'api_key' }); - - if (!userId) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - return Response.json({ userId, tokenType }); +import type { MachineAuthTestAdapter } from '../testUtils'; +import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils'; + +const adapter: MachineAuthTestAdapter = { + baseConfig: appConfigs.next.appRouter, + apiKey: { + path: '/api/me', + addRoutes: config => + config.addFile( + 'src/app/api/me/route.ts', + () => ` + import { auth } from '@clerk/nextjs/server'; + + export async function GET() { + const { userId, tokenType } = await auth({ acceptsToken: 'api_key' }); + + if (!userId) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - export async function POST() { - const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] }); + return Response.json({ userId, tokenType }); + } - if (!authObject.isAuthenticated) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } + export async function POST() { + const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] }); - return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType }); + if (!authObject.isAuthenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - fakeBapiUser = await u.services.users.createBapiUser(fakeUser); - fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); - }); - - test.afterAll(async () => { - await fakeAPIKey.revoke(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('should return 401 if no API key is provided', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('should return 401 if API key is invalid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_key' }, - }); - expect(res.status()).toBe(401); - }); - - test('should return 200 with auth object if API key is valid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { - Authorization: `Bearer ${fakeAPIKey.secret}`, - }, - }); - const apiKeyData = await res.json(); - expect(res.status()).toBe(200); - expect(apiKeyData.userId).toBe(fakeBapiUser.id); - expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); - }); - - for (const [tokenType, token] of [ - ['M2M', 'mt_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on API key route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - - test('should handle multiple token types', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - const url = new URL('/api/me', app.serverUrl); - - // Sign in to get a session token - await u.po.signIn.goTo(); - await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); - await u.po.expect.toBeSignedIn(); - - // GET endpoint (only accepts api_key) - session token should fail - const getRes = await u.page.request.get(url.toString()); - expect(getRes.status()).toBe(401); - - // POST endpoint (accepts both api_key and session_token) - // Test with session token - const postWithSessionRes = await u.page.request.post(url.toString()); - const sessionData = await postWithSessionRes.json(); - expect(postWithSessionRes.status()).toBe(200); - expect(sessionData.userId).toBe(fakeBapiUser.id); - expect(sessionData.tokenType).toBe(TokenType.SessionToken); - - // Test with API key - const postWithApiKeyRes = await u.page.request.post(url.toString(), { - headers: { - Authorization: `Bearer ${fakeAPIKey.secret}`, - }, - }); - const apiKeyData = await postWithApiKeyRes.json(); - expect(postWithApiKeyRes.status()).toBe(200); - expect(apiKeyData.userId).toBe(fakeBapiUser.id); - expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); - }); - }); - - test.describe('M2M auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let network: FakeMachineNetwork; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - network = await createFakeMachineNetwork(client); - - app = await appConfigs.next.appRouter - .clone() - .addFile( - 'src/app/api/protected/route.ts', - () => ` - import { auth } from '@clerk/nextjs/server'; - - export async function GET() { - const { subject, tokenType, isAuthenticated } = await auth({ acceptsToken: 'm2m_token' }); - - if (!isAuthenticated) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - return Response.json({ subject, tokenType }); + return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType }); + } + `, + ), + }, + m2m: { + path: '/api/protected', + addRoutes: config => + config.addFile( + 'src/app/api/protected/route.ts', + () => ` + import { auth } from '@clerk/nextjs/server'; + + export async function GET() { + const { subject, tokenType, isAuthenticated } = await auth({ acceptsToken: 'm2m_token' }); + + if (!isAuthenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); } - `, - ) - .commit(); - - await app.setup(); - - const env = appConfigs.envs.withAPIKeys - .clone() - .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', network.primaryServer.secretKey); - await app.withEnv(env); - await app.dev(); - }); - - test.afterAll(async () => { - await network.cleanup(); - await app.teardown(); - }); - - test('rejects requests with invalid M2M tokens', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/protected'); - expect(res.status()).toBe(401); - - const res2 = await request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: 'Bearer mt_xxx' }, - }); - expect(res2.status()).toBe(401); - }); - - test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: `Bearer ${network.unscopedSenderToken.token}` }, - }); - expect(res.status()).toBe(401); - }); - - test('authorizes M2M requests when sender machine has proper access', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const res = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: `Bearer ${network.scopedSenderToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - test('authorizes after dynamically granting scope', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.services.clerk.machines.createScope(network.unscopedSender.id, network.primaryServer.id); - const m2mToken = await u.services.clerk.m2m.createToken({ - machineSecretKey: network.unscopedSender.secretKey, - secondsUntilExpiration: 60 * 30, - }); - const res = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: `Bearer ${m2mToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.unscopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id }); - }); - - test('verifies JWT format M2M token via local verification', async ({ request }) => { - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - const jwtToken = await createJwtM2MToken(client, network.scopedSender.secretKey); - - const res = await request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: `Bearer ${jwtToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on M2M route (token type mismatch)`, async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('OAuth auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeOAuth: FakeOAuthApp; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.next.appRouter - .clone() + return Response.json({ subject, tokenType }); + } + `, + ), + }, + oauth: { + verifyPath: '/api/protected', + callbackPath: '/oauth/callback', + addRoutes: config => + config .addFile( 'src/app/api/protected/route.ts', () => ` @@ -306,76 +86,12 @@ test.describe('Next.js machine authentication @machine', () => { return NextResponse.json({ message: 'OAuth callback received' }); } `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); + ), + }, +}; - const clerkClient = createClerkClient({ - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); - - fakeOAuth = await createFakeOAuthApp(clerkClient, `${app.serverUrl}/oauth/callback`); - }); - - test.afterAll(async () => { - await fakeOAuth.cleanup(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const accessToken = await obtainOAuthAccessToken({ - page: u.page, - oAuthApp: fakeOAuth.oAuthApp, - redirectUri: `${app.serverUrl}/oauth/callback`, - fakeUser, - signIn: u.po.signIn, - }); - - const res = await u.page.request.get(new URL('/api/protected', app.serverUrl).toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - expect(res.status()).toBe(200); - const authData = await res.json(); - expect(authData.userId).toBeDefined(); - expect(authData.tokenType).toBe(TokenType.OAuthToken); - }); - - test('rejects request without OAuth token', async ({ request }) => { - const url = new URL('/api/protected', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('rejects request with invalid OAuth token', async ({ request }) => { - const url = new URL('/api/protected', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_oauth_token' }, - }); - expect(res.status()).toBe(401); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['M2M', 'mt_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on OAuth route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/protected', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); +test.describe('Next.js machine authentication @machine', () => { + registerApiKeyAuthTests(adapter); + registerM2MAuthTests(adapter); + registerOAuthAuthTests(adapter); }); diff --git a/integration/tests/react-router/machine.test.ts b/integration/tests/react-router/machine.test.ts index 0b9b095fecc..694264fe413 100644 --- a/integration/tests/react-router/machine.test.ts +++ b/integration/tests/react-router/machine.test.ts @@ -1,33 +1,15 @@ -import type { User } from '@clerk/backend'; -import { createClerkClient } from '@clerk/backend'; -import { TokenType } from '@clerk/backend/internal'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; -import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { instanceKeys } from '../../presets/envs'; -import type { FakeAPIKey, FakeMachineNetwork, FakeOAuthApp, FakeUser } from '../../testUtils'; -import { - createFakeMachineNetwork, - createFakeOAuthApp, - createJwtM2MToken, - createTestUtils, - obtainOAuthAccessToken, -} from '../../testUtils'; - -test.describe('React Router machine authentication @machine', () => { - test.describe('API key auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.reactRouter.reactRouterNode - .clone() +import type { MachineAuthTestAdapter } from '../../testUtils'; +import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../../testUtils'; + +const adapter: MachineAuthTestAdapter = { + baseConfig: appConfigs.reactRouter.reactRouterNode, + apiKey: { + path: '/api/me', + addRoutes: config => + config .addFile( 'app/routes/api/me.ts', () => ` @@ -43,6 +25,16 @@ test.describe('React Router machine authentication @machine', () => { return Response.json({ userId, tokenType }); } + + export async function action(args: Route.ActionArgs) { + const authObject = await getAuth(args, { acceptsToken: ['api_key', 'session_token'] }); + + if (!authObject.isAuthenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType }); + } `, ) .addFile( @@ -58,81 +50,12 @@ test.describe('React Router machine authentication @machine', () => { route('api/me', 'routes/api/me.ts'), ] satisfies RouteConfig; `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - fakeBapiUser = await u.services.users.createBapiUser(fakeUser); - fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); - }); - - test.afterAll(async () => { - await fakeAPIKey.revoke(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('should return 401 if no API key is provided', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('should return 401 if API key is invalid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_key' }, - }); - expect(res.status()).toBe(401); - }); - - test('should return 200 with auth object if API key is valid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { - Authorization: `Bearer ${fakeAPIKey.secret}`, - }, - }); - const apiKeyData = await res.json(); - expect(res.status()).toBe(200); - expect(apiKeyData.userId).toBe(fakeBapiUser.id); - expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); - }); - - for (const [tokenType, token] of [ - ['M2M', 'mt_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on API key route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('M2M auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let network: FakeMachineNetwork; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - network = await createFakeMachineNetwork(client); - - app = await appConfigs.reactRouter.reactRouterNode - .clone() + ), + }, + m2m: { + path: '/api/m2m', + addRoutes: config => + config .addFile( 'app/routes/api/m2m.ts', () => ` @@ -163,110 +86,13 @@ test.describe('React Router machine authentication @machine', () => { route('api/m2m', 'routes/api/m2m.ts'), ] satisfies RouteConfig; `, - ) - .commit(); - - await app.setup(); - - const env = appConfigs.envs.withAPIKeys - .clone() - .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', network.primaryServer.secretKey); - await app.withEnv(env); - await app.dev(); - }); - - test.afterAll(async () => { - await network.cleanup(); - await app.teardown(); - }); - - test('rejects requests with invalid M2M tokens', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m'); - expect(res.status()).toBe(401); - - const res2 = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: 'Bearer mt_xxx' }, - }); - expect(res2.status()).toBe(401); - }); - - test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${network.unscopedSenderToken.token}` }, - }); - expect(res.status()).toBe(401); - }); - - test('authorizes M2M requests when sender machine has proper access', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const res = await u.page.request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${network.scopedSenderToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - test('authorizes after dynamically granting scope', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.services.clerk.machines.createScope(network.unscopedSender.id, network.primaryServer.id); - const m2mToken = await u.services.clerk.m2m.createToken({ - machineSecretKey: network.unscopedSender.secretKey, - secondsUntilExpiration: 60 * 30, - }); - - const res = await u.page.request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${m2mToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.unscopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id }); - }); - - test('verifies JWT format M2M token via local verification', async ({ request }) => { - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - const jwtToken = await createJwtM2MToken(client, network.scopedSender.secretKey); - - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${jwtToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on M2M route (token type mismatch)`, async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('OAuth auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeOAuth: FakeOAuthApp; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.reactRouter.reactRouterNode - .clone() + ), + }, + oauth: { + verifyPath: '/api/oauth-verify', + callbackPath: '/api/oauth/callback', + addRoutes: config => + config .addFile( 'app/routes/api/oauth-verify.ts', () => ` @@ -306,76 +132,12 @@ test.describe('React Router machine authentication @machine', () => { route('api/oauth/callback', 'routes/api/oauth-callback.ts'), ] satisfies RouteConfig; `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - - const clerkClient = createClerkClient({ - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); - - fakeOAuth = await createFakeOAuthApp(clerkClient, `${app.serverUrl}/api/oauth/callback`); - }); - - test.afterAll(async () => { - await fakeOAuth.cleanup(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); + ), + }, +}; - test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const accessToken = await obtainOAuthAccessToken({ - page: u.page, - oAuthApp: fakeOAuth.oAuthApp, - redirectUri: `${app.serverUrl}/api/oauth/callback`, - fakeUser, - signIn: u.po.signIn, - }); - - const res = await u.page.request.get(new URL('/api/oauth-verify', app.serverUrl).toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - expect(res.status()).toBe(200); - const authData = await res.json(); - expect(authData.userId).toBeDefined(); - expect(authData.tokenType).toBe(TokenType.OAuthToken); - }); - - test('rejects request without OAuth token', async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('rejects request with invalid OAuth token', async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_oauth_token' }, - }); - expect(res.status()).toBe(401); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['M2M', 'mt_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on OAuth route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); +test.describe('React Router machine authentication @machine', () => { + registerApiKeyAuthTests(adapter); + registerM2MAuthTests(adapter); + registerOAuthAuthTests(adapter); }); diff --git a/integration/tests/tanstack-start/machine.test.ts b/integration/tests/tanstack-start/machine.test.ts index 5fc4cfaa0df..345020f47d8 100644 --- a/integration/tests/tanstack-start/machine.test.ts +++ b/integration/tests/tanstack-start/machine.test.ts @@ -1,256 +1,79 @@ -import type { User } from '@clerk/backend'; -import { createClerkClient } from '@clerk/backend'; -import { TokenType } from '@clerk/backend/internal'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; -import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import { instanceKeys } from '../../presets/envs'; -import type { FakeAPIKey, FakeMachineNetwork, FakeOAuthApp, FakeUser } from '../../testUtils'; -import { - createFakeMachineNetwork, - createFakeOAuthApp, - createJwtM2MToken, - createTestUtils, - obtainOAuthAccessToken, -} from '../../testUtils'; - -test.describe('TanStack React Start machine authentication @machine', () => { - test.describe('API key auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.tanstack.reactStart - .clone() - .addFile( - 'src/routes/api/me.ts', - () => ` - import { createFileRoute } from '@tanstack/react-router' - import { auth } from '@clerk/tanstack-react-start/server' - - export const Route = createFileRoute('/api/me')({ - server: { - handlers: { - GET: async ({ request }) => { - const { userId, tokenType } = await auth({ acceptsToken: 'api_key' }); +import type { MachineAuthTestAdapter } from '../../testUtils'; +import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../../testUtils'; + +const adapter: MachineAuthTestAdapter = { + baseConfig: appConfigs.tanstack.reactStart, + apiKey: { + path: '/api/me', + addRoutes: config => + config.addFile( + 'src/routes/api/me.ts', + () => ` + import { createFileRoute } from '@tanstack/react-router' + import { auth } from '@clerk/tanstack-react-start/server' + + export const Route = createFileRoute('/api/me')({ + server: { + handlers: { + GET: async () => { + const { userId, tokenType } = await auth({ acceptsToken: 'api_key' }); + + if (!userId) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return Response.json({ userId, tokenType }); + }, + POST: async () => { + const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] }); - if (!userId) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } + if (!authObject.isAuthenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } - return Response.json({ userId, tokenType }); - }, + return Response.json({ userId: authObject.userId, tokenType: authObject.tokenType }); }, }, - }) - `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - fakeBapiUser = await u.services.users.createBapiUser(fakeUser); - fakeAPIKey = await u.services.users.createFakeAPIKey(fakeBapiUser.id); - }); - - test.afterAll(async () => { - await fakeAPIKey.revoke(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('should return 401 if no API key is provided', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('should return 401 if API key is invalid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_key' }, - }); - expect(res.status()).toBe(401); - }); - - test('should return 200 with auth object if API key is valid', async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { - Authorization: `Bearer ${fakeAPIKey.secret}`, - }, - }); - const apiKeyData = await res.json(); - expect(res.status()).toBe(200); - expect(apiKeyData.userId).toBe(fakeBapiUser.id); - expect(apiKeyData.tokenType).toBe(TokenType.ApiKey); - }); - - for (const [tokenType, token] of [ - ['M2M', 'mt_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on API key route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('M2M auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let network: FakeMachineNetwork; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - network = await createFakeMachineNetwork(client); - - app = await appConfigs.tanstack.reactStart - .clone() - .addFile( - 'src/routes/api/m2m.ts', - () => ` - import { createFileRoute } from '@tanstack/react-router' - import { auth } from '@clerk/tanstack-react-start/server' - - export const Route = createFileRoute('/api/m2m')({ - server: { - handlers: { - GET: async ({ request }) => { - const { subject, tokenType, isAuthenticated } = await auth({ acceptsToken: 'm2m_token' }); - - if (!isAuthenticated) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - return Response.json({ subject, tokenType }); - }, + }, + }) + `, + ), + }, + m2m: { + path: '/api/m2m', + addRoutes: config => + config.addFile( + 'src/routes/api/m2m.ts', + () => ` + import { createFileRoute } from '@tanstack/react-router' + import { auth } from '@clerk/tanstack-react-start/server' + + export const Route = createFileRoute('/api/m2m')({ + server: { + handlers: { + GET: async () => { + const { subject, tokenType, isAuthenticated } = await auth({ acceptsToken: 'm2m_token' }); + + if (!isAuthenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return Response.json({ subject, tokenType }); }, }, - }) - `, - ) - .commit(); - - await app.setup(); - - const env = appConfigs.envs.withAPIKeys - .clone() - .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', network.primaryServer.secretKey); - await app.withEnv(env); - await app.dev(); - }); - - test.afterAll(async () => { - await network.cleanup(); - await app.teardown(); - }); - - test('rejects requests with invalid M2M tokens', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m'); - expect(res.status()).toBe(401); - - const res2 = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: 'Bearer mt_xxx' }, - }); - expect(res2.status()).toBe(401); - }); - - test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${network.unscopedSenderToken.token}` }, - }); - expect(res.status()).toBe(401); - }); - - test('authorizes M2M requests when sender machine has proper access', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const res = await u.page.request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${network.scopedSenderToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - test('authorizes after dynamically granting scope', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.services.clerk.machines.createScope(network.unscopedSender.id, network.primaryServer.id); - const m2mToken = await u.services.clerk.m2m.createToken({ - machineSecretKey: network.unscopedSender.secretKey, - secondsUntilExpiration: 60 * 30, - }); - - const res = await u.page.request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${m2mToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.unscopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - await u.services.clerk.m2m.revokeToken({ m2mTokenId: m2mToken.id }); - }); - - test('verifies JWT format M2M token via local verification', async ({ request }) => { - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - const jwtToken = await createJwtM2MToken(client, network.scopedSender.secretKey); - - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${jwtToken.token}` }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.subject).toBe(network.scopedSender.id); - expect(body.tokenType).toBe(TokenType.M2MToken); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['OAuth', 'oat_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on M2M route (token type mismatch)`, async ({ request }) => { - const res = await request.get(app.serverUrl + '/api/m2m', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); - - test.describe('OAuth auth', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeOAuth: FakeOAuthApp; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.tanstack.reactStart - .clone() + }, + }) + `, + ), + }, + oauth: { + verifyPath: '/api/oauth-verify', + callbackPath: '/api/oauth/callback', + addRoutes: config => + config .addFile( 'src/routes/api/oauth-verify.ts', () => ` @@ -260,7 +83,7 @@ test.describe('TanStack React Start machine authentication @machine', () => { export const Route = createFileRoute('/api/oauth-verify')({ server: { handlers: { - GET: async ({ request }) => { + GET: async () => { const { userId, tokenType } = await auth({ acceptsToken: 'oauth_token' }); if (!userId) { @@ -282,83 +105,19 @@ test.describe('TanStack React Start machine authentication @machine', () => { export const Route = createFileRoute('/api/oauth/callback')({ server: { handlers: { - GET: async ({ request }) => { + GET: async () => { return Response.json({ message: 'OAuth callback received' }); }, }, }, }) `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); + ), + }, +}; - const clerkClient = createClerkClient({ - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); - - fakeOAuth = await createFakeOAuthApp(clerkClient, `${app.serverUrl}/api/oauth/callback`); - }); - - test.afterAll(async () => { - await fakeOAuth.cleanup(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('verifies valid OAuth access token obtained through authorization flow', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const accessToken = await obtainOAuthAccessToken({ - page: u.page, - oAuthApp: fakeOAuth.oAuthApp, - redirectUri: `${app.serverUrl}/api/oauth/callback`, - fakeUser, - signIn: u.po.signIn, - }); - - const res = await u.page.request.get(new URL('/api/oauth-verify', app.serverUrl).toString(), { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - expect(res.status()).toBe(200); - const authData = await res.json(); - expect(authData.userId).toBeDefined(); - expect(authData.tokenType).toBe(TokenType.OAuthToken); - }); - - test('rejects request without OAuth token', async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); - - test('rejects request with invalid OAuth token', async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_oauth_token' }, - }); - expect(res.status()).toBe(401); - }); - - for (const [tokenType, token] of [ - ['API key', 'ak_test_mismatch'], - ['M2M', 'mt_test_mismatch'], - ] as const) { - test(`rejects ${tokenType} token on OAuth route (token type mismatch)`, async ({ request }) => { - const url = new URL('/api/oauth-verify', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.status()).toBe(401); - }); - } - }); +test.describe('TanStack React Start machine authentication @machine', () => { + registerApiKeyAuthTests(adapter); + registerM2MAuthTests(adapter); + registerOAuthAuthTests(adapter); }); From 546e62ec94b5064cb3bc1440436063b72ef10aed Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 31 Mar 2026 10:45:11 -0700 Subject: [PATCH 2/2] chore: fix barrel exports --- integration/testUtils/createTestUtils.ts | 86 ---------------- integration/testUtils/index.ts | 98 ++++++++++++++++--- integration/testUtils/machineAuthHelpers.ts | 10 +- integration/tests/astro/machine.test.ts | 8 +- integration/tests/next-machine.test.ts | 4 +- .../tests/react-router/machine.test.ts | 8 +- .../tests/tanstack-start/machine.test.ts | 8 +- 7 files changed, 111 insertions(+), 111 deletions(-) delete mode 100644 integration/testUtils/createTestUtils.ts diff --git a/integration/testUtils/createTestUtils.ts b/integration/testUtils/createTestUtils.ts deleted file mode 100644 index fa13217ab98..00000000000 --- a/integration/testUtils/createTestUtils.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; -import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable'; -import type { Browser, BrowserContext, Page } from '@playwright/test'; - -import type { Application } from '../models/application'; -import { createEmailService } from './emailService'; -import { createInvitationService } from './invitationsService'; -import { createOrganizationsService } from './organizationsService'; -import { withRetry } from './retryableClerkClient'; -import { createUserService } from './usersService'; -import { createWaitlistService } from './waitlistService'; - -const createClerkClient = (app: Application) => { - return backendCreateClerkClient({ - apiUrl: app.env.privateVariables.get('CLERK_API_URL'), - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); -}; - -export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser }; - -export const createTestUtils = < - Params extends { app: Application; useTestingToken?: boolean } & Partial, - Services = typeof services, - PO = typeof pageObjects, - BH = typeof browserHelpers, - FullReturn = { services: Services; po: PO; tabs: BH; page: EnhancedPage; nextJsVersion: string }, - OnlyAppReturn = { services: Services }, ->( - params: Params, -): Params extends Partial ? FullReturn : OnlyAppReturn => { - const { app, context, browser, useTestingToken = true } = params || {}; - - const clerkClient = withRetry(createClerkClient(app)); - const services = { - clerk: clerkClient, - email: createEmailService(), - users: createUserService(clerkClient), - invitations: createInvitationService(clerkClient), - organizations: createOrganizationsService(clerkClient), - waitlist: createWaitlistService(clerkClient), - }; - - if (!params.page) { - return { services } as any; - } - - const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl }); - - const browserHelpers = { - runInNewTab: async ( - cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, - ) => { - const u = createTestUtils({ - app, - page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), - }); - await cb(u as any, context); - return u; - }, - runInNewBrowser: async ( - cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, - ) => { - if (!browser) { - throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?'); - } - const context = await browser.newContext(); - const u = createTestUtils({ - app, - page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), - }); - await cb(u as any, context); - return u; - }, - }; - - return { - page: pageObjects.page, - services, - po: pageObjects, - tabs: browserHelpers, - // eslint-disable-next-line turbo/no-undeclared-env-vars - nextJsVersion: process.env.E2E_NEXTJS_VERSION, - } as any; -}; diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index e19a3db3749..5c757e51b00 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -1,17 +1,91 @@ +import { createClerkClient as backendCreateClerkClient } from '@clerk/backend'; +import { createAppPageObject, createPageObjects, type EnhancedPage } from '@clerk/testing/playwright/unstable'; +import type { Browser, BrowserContext, Page } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { createEmailService } from './emailService'; +import { createInvitationService } from './invitationsService'; +import { createOrganizationsService } from './organizationsService'; +import { withRetry } from './retryableClerkClient'; import type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail } from './usersService'; -export type { CreateAppPageObjectArgs } from './createTestUtils'; -export { createTestUtils } from './createTestUtils'; +import { createUserService } from './usersService'; +import { createWaitlistService } from './waitlistService'; export type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail }; -export type { FakeMachineNetwork, FakeOAuthApp, MachineAuthTestAdapter } from './machineAuthHelpers'; -export { - createFakeMachineNetwork, - createFakeOAuthApp, - createJwtM2MToken, - obtainOAuthAccessToken, - registerApiKeyAuthTests, - registerM2MAuthTests, - registerOAuthAuthTests, -} from './machineAuthHelpers'; + +const createClerkClient = (app: Application) => { + return backendCreateClerkClient({ + apiUrl: app.env.privateVariables.get('CLERK_API_URL'), + secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), + publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), + }); +}; + +export type CreateAppPageObjectArgs = { page: Page; context: BrowserContext; browser: Browser }; + +export const createTestUtils = < + Params extends { app: Application; useTestingToken?: boolean } & Partial, + Services = typeof services, + PO = typeof pageObjects, + BH = typeof browserHelpers, + FullReturn = { services: Services; po: PO; tabs: BH; page: EnhancedPage; nextJsVersion: string }, + OnlyAppReturn = { services: Services }, +>( + params: Params, +): Params extends Partial ? FullReturn : OnlyAppReturn => { + const { app, context, browser, useTestingToken = true } = params || {}; + + const clerkClient = withRetry(createClerkClient(app)); + const services = { + clerk: clerkClient, + email: createEmailService(), + users: createUserService(clerkClient), + invitations: createInvitationService(clerkClient), + organizations: createOrganizationsService(clerkClient), + waitlist: createWaitlistService(clerkClient), + }; + + if (!params.page) { + return { services } as any; + } + + const pageObjects = createPageObjects({ page: params.page, useTestingToken, baseURL: app.serverUrl }); + + const browserHelpers = { + runInNewTab: async ( + cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, + ) => { + const u = createTestUtils({ + app, + page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), + }); + await cb(u as any, context); + return u; + }, + runInNewBrowser: async ( + cb: (u: { services: Services; po: PO; page: EnhancedPage }, context: BrowserContext) => Promise, + ) => { + if (!browser) { + throw new Error('Browser is not defined. Did you forget to pass it to createPageObjects?'); + } + const context = await browser.newContext(); + const u = createTestUtils({ + app, + page: createAppPageObject({ page: await context.newPage(), useTestingToken }, { baseURL: app.serverUrl }), + }); + await cb(u as any, context); + return u; + }, + }; + + return { + page: pageObjects.page, + services, + po: pageObjects, + tabs: browserHelpers, + // eslint-disable-next-line turbo/no-undeclared-env-vars + nextJsVersion: process.env.E2E_NEXTJS_VERSION, + } as any; +}; export { testAgainstRunningApps } from './testAgainstRunningApps'; diff --git a/integration/testUtils/machineAuthHelpers.ts b/integration/testUtils/machineAuthHelpers.ts index 0c70fc839a3..5582c67f2b8 100644 --- a/integration/testUtils/machineAuthHelpers.ts +++ b/integration/testUtils/machineAuthHelpers.ts @@ -12,8 +12,8 @@ import type { ApplicationConfig } from '../models/applicationConfig'; import type { EnvironmentConfig } from '../models/environment'; import { appConfigs } from '../presets'; import { instanceKeys } from '../presets/envs'; -import { createTestUtils } from './createTestUtils'; import type { FakeAPIKey, FakeUser } from './usersService'; +import { createTestUtils } from './index'; export type FakeMachineNetwork = { primaryServer: Machine; @@ -24,7 +24,7 @@ export type FakeMachineNetwork = { cleanup: () => Promise; }; -export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { +async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { const fakeCompanyName = faker.company.name(); const primaryServer = await clerkClient.machines.create({ @@ -68,7 +68,7 @@ export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promis }; } -export async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise { +async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise { return clerkClient.m2m.createToken({ machineSecretKey: senderSecretKey, secondsUntilExpiration: 60 * 30, @@ -81,7 +81,7 @@ export type FakeOAuthApp = { cleanup: () => Promise; }; -export async function createFakeOAuthApp(clerkClient: ClerkClient, callbackUrl: string): Promise { +async function createFakeOAuthApp(clerkClient: ClerkClient, callbackUrl: string): Promise { const oAuthApp = await clerkClient.oauthApplications.create({ name: `Integration Test OAuth App - ${Date.now()}`, redirectUris: [callbackUrl], @@ -107,7 +107,7 @@ export type ObtainOAuthAccessTokenParams = { }; }; -export async function obtainOAuthAccessToken({ +async function obtainOAuthAccessToken({ page, oAuthApp, redirectUri, diff --git a/integration/tests/astro/machine.test.ts b/integration/tests/astro/machine.test.ts index b629f1eb9a3..613205986ef 100644 --- a/integration/tests/astro/machine.test.ts +++ b/integration/tests/astro/machine.test.ts @@ -1,8 +1,12 @@ import { test } from '@playwright/test'; import { appConfigs } from '../../presets'; -import type { MachineAuthTestAdapter } from '../../testUtils'; -import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../../testUtils'; +import type { MachineAuthTestAdapter } from '../../testUtils/machineAuthHelpers'; +import { + registerApiKeyAuthTests, + registerM2MAuthTests, + registerOAuthAuthTests, +} from '../../testUtils/machineAuthHelpers'; const adapter: MachineAuthTestAdapter = { baseConfig: appConfigs.astro.node, diff --git a/integration/tests/next-machine.test.ts b/integration/tests/next-machine.test.ts index fff2f05d614..0a753cce476 100644 --- a/integration/tests/next-machine.test.ts +++ b/integration/tests/next-machine.test.ts @@ -1,8 +1,8 @@ import { test } from '@playwright/test'; import { appConfigs } from '../presets'; -import type { MachineAuthTestAdapter } from '../testUtils'; -import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils'; +import type { MachineAuthTestAdapter } from '../testUtils/machineAuthHelpers'; +import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils/machineAuthHelpers'; const adapter: MachineAuthTestAdapter = { baseConfig: appConfigs.next.appRouter, diff --git a/integration/tests/react-router/machine.test.ts b/integration/tests/react-router/machine.test.ts index 694264fe413..ae909ccdf4a 100644 --- a/integration/tests/react-router/machine.test.ts +++ b/integration/tests/react-router/machine.test.ts @@ -1,8 +1,12 @@ import { test } from '@playwright/test'; import { appConfigs } from '../../presets'; -import type { MachineAuthTestAdapter } from '../../testUtils'; -import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../../testUtils'; +import type { MachineAuthTestAdapter } from '../../testUtils/machineAuthHelpers'; +import { + registerApiKeyAuthTests, + registerM2MAuthTests, + registerOAuthAuthTests, +} from '../../testUtils/machineAuthHelpers'; const adapter: MachineAuthTestAdapter = { baseConfig: appConfigs.reactRouter.reactRouterNode, diff --git a/integration/tests/tanstack-start/machine.test.ts b/integration/tests/tanstack-start/machine.test.ts index 345020f47d8..7cc577b058c 100644 --- a/integration/tests/tanstack-start/machine.test.ts +++ b/integration/tests/tanstack-start/machine.test.ts @@ -1,8 +1,12 @@ import { test } from '@playwright/test'; import { appConfigs } from '../../presets'; -import type { MachineAuthTestAdapter } from '../../testUtils'; -import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../../testUtils'; +import type { MachineAuthTestAdapter } from '../../testUtils/machineAuthHelpers'; +import { + registerApiKeyAuthTests, + registerM2MAuthTests, + registerOAuthAuthTests, +} from '../../testUtils/machineAuthHelpers'; const adapter: MachineAuthTestAdapter = { baseConfig: appConfigs.tanstack.reactStart,