diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 310191b1938..f694e9d4e2c 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -12,6 +12,13 @@ import { createUserService } from './usersService'; import { createWaitlistService } from './waitlistService'; export type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail }; +export type { FakeMachineNetwork, FakeOAuthApp } from './machineAuthService'; +export { + createFakeMachineNetwork, + createFakeOAuthApp, + createJwtM2MToken, + obtainOAuthAccessToken, +} from './machineAuthService'; const createClerkClient = (app: Application) => { return backendCreateClerkClient({ diff --git a/integration/testUtils/machineAuthService.ts b/integration/testUtils/machineAuthService.ts new file mode 100644 index 00000000000..3d0c3ca5047 --- /dev/null +++ b/integration/testUtils/machineAuthService.ts @@ -0,0 +1,186 @@ +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/machine-auth/component.test.ts b/integration/tests/api-keys-component.test.ts similarity index 99% rename from integration/tests/machine-auth/component.test.ts rename to integration/tests/api-keys-component.test.ts index 989abe8145a..ef3354bff68 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/api-keys-component.test.ts @@ -1,10 +1,10 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import type { FakeOrganization, FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import type { FakeOrganization, FakeUser } from '../testUtils'; +import { createTestUtils } from '../testUtils'; const mockAPIKeysEnvironmentSettings = async ( page: Page, diff --git a/integration/tests/astro/machine.test.ts b/integration/tests/astro/machine.test.ts index 58d0254f4b6..ca10578e3a8 100644 --- a/integration/tests/astro/machine.test.ts +++ b/integration/tests/astro/machine.test.ts @@ -1,171 +1,318 @@ import type { User } from '@clerk/backend'; +import { createClerkClient } from '@clerk/backend'; import { TokenType } from '@clerk/backend/internal'; import { expect, test } from '@playwright/test'; import type { Application } from '../../models/application'; import { appConfigs } from '../../presets'; -import type { FakeAPIKey, FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; - -test.describe('Astro machine authentication within routes @machine', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - - app = await appConfigs.astro.node - .clone() - .addFile( - 'src/pages/api/auth/me.ts', - () => ` - import type { APIRoute } from 'astro'; - - const unautorized = () => - new Response('Unauthorized', { - status: 401, - }); - - export const GET: APIRoute = ({ locals }) => { +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 unautorized(); + 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); - }); + }; + `, + ) + .commit(); - test.afterAll(async () => { - await fakeAPIKey.revoke(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); + await app.setup(); + await app.withEnv(appConfigs.envs.withAPIKeys); + await app.dev(); - test('should return 401 if no API key is provided', async ({ request }) => { - const url = new URL('/api/auth/me', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); + 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('should return 401 if API key is invalid', async ({ request }) => { - const url = new URL('/api/auth/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_key' }, + test.afterAll(async () => { + await fakeAPIKey.revoke(); + await fakeUser.deleteIfExists(); + await app.teardown(); }); - expect(res.status()).toBe(401); - }); - test('should return 200 with auth object if API key is valid', async ({ request }) => { - const url = new URL('/api/auth/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); - }); -}); + 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); + }); -test.describe('Astro machine authentication within clerkMiddleware() @machine', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - app = await appConfigs.astro.node - .clone() - .addFile( - `src/middleware.ts`, - () => ` - import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server'; - - const isProtectedRoute = createRouteMatcher(['/api(.*)']); - - export const onRequest = clerkMiddleware((auth, context) => { - const { userId } = auth({ acceptsToken: 'api_key' }) - - if (!userId && isProtectedRoute(context.request)) { - return new Response('Unauthorized', { status: 401 }); - } + 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}` }, }); - `, - ) - .addFile( - 'src/pages/api/auth/me.ts', - () => ` - import type { APIRoute } from 'astro'; - - export const GET: APIRoute = ({ locals, request }) => { - const { userId, tokenType } = locals.auth({ acceptsToken: 'api_key' }) - - 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); + expect(res.status()).toBe(401); + }); + } }); - test.afterAll(async () => { - await fakeAPIKey.revoke(); - await fakeUser.deleteIfExists(); - await app.teardown(); - }); + test.describe('M2M auth', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let network: FakeMachineNetwork; - test('should return 401 if no API key is provided', async ({ request }) => { - const url = new URL('/api/auth/me', app.serverUrl); - const res = await request.get(url.toString()); - expect(res.status()).toBe(401); - }); + 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); - test('should return 401 if API key is invalid', async ({ request }) => { - const url = new URL('/api/auth/me', app.serverUrl); - const res = await request.get(url.toString(), { - headers: { Authorization: 'Bearer invalid_key' }, + 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); }); - expect(res.status()).toBe(401); + + 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('should return 200 with auth object if API key is valid', async ({ request }) => { - const url = new URL('/api/auth/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); + 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() + .addFile( + 'src/pages/api/oauth-verify.ts', + () => ` + import type { APIRoute } from 'astro'; + + export const GET: APIRoute = ({ locals }) => { + const { userId, tokenType } = locals.auth({ acceptsToken: 'oauth_token' }); + + if (!userId) { + return new Response('Unauthorized', { status: 401 }); + } + + return Response.json({ userId, tokenType }); + }; + `, + ) + .addFile( + 'src/pages/api/oauth/callback.ts', + () => ` + import type { APIRoute } from 'astro'; + + export const GET: APIRoute = () => { + 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); + }); + } }); }); diff --git a/integration/tests/machine-auth/api-keys.test.ts b/integration/tests/machine-auth/api-keys.test.ts deleted file mode 100644 index 73912c550e5..00000000000 --- a/integration/tests/machine-auth/api-keys.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { User } from '@clerk/backend'; -import { TokenType } from '@clerk/backend/internal'; -import { expect, test } from '@playwright/test'; - -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import type { FakeAPIKey, FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; - -test.describe('Next.js API key auth within clerkMiddleware() @machine', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - app = await appConfigs.next.appRouter - .clone() - .addFile( - `src/middleware.ts`, - () => ` - import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; - - const isProtectedRoute = createRouteMatcher(['/api(.*)']); - - export default clerkMiddleware(async (auth, req) => { - if (isProtectedRoute(req)) { - await auth.protect({ token: 'api_key' }); - } - }); - - export const config = { - matcher: [ - '/((?!.*\\..*|_next).*)', // Don't run middleware on static files - '/', // Run middleware on index page - '/(api|trpc)(.*)', - ], // Run middleware on API routes - }; - `, - ) - .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' }); - - 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('Testing purposes within clerkMiddleware()'); - 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); - }); -}); - -test.describe('Next.js API key auth within routes @nextjs', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let fakeBapiUser: User; - let fakeAPIKey: FakeAPIKey; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - - 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 }); - } - - export async function POST() { - const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] }); - - if (!authObject.isAuthenticated) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - 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); - }); - - 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) - 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); - }); -}); diff --git a/integration/tests/machine-auth/m2m.test.ts b/integration/tests/machine-auth/m2m.test.ts deleted file mode 100644 index 73721cebc53..00000000000 --- a/integration/tests/machine-auth/m2m.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { createClerkClient, type M2MToken, type Machine } from '@clerk/backend'; -import { faker } from '@faker-js/faker'; -import { expect, test } from '@playwright/test'; - -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import { instanceKeys } from '../../presets/envs'; -import { createTestUtils } from '../../testUtils'; - -test.describe('machine-to-machine auth @machine', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let primaryApiServer: Machine; - let emailServer: Machine; - let analyticsServer: Machine; - let emailServerM2MToken: M2MToken; - let analyticsServerM2MToken: M2MToken; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - const fakeCompanyName = faker.company.name(); - - // Create primary machine using instance secret key - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - primaryApiServer = await client.machines.create({ - name: `${fakeCompanyName} Primary API Server`, - }); - - app = await appConfigs.express.vite - .clone() - .addFile( - 'src/server/main.ts', - () => ` - import 'dotenv/config'; - import { clerkClient } from '@clerk/express'; - import express from 'express'; - import ViteExpress from 'vite-express'; - - const app = express(); - - app.get('/api/protected', async (req, res) => { - const token = req.get('Authorization')?.split(' ')[1]; - try { - const m2mToken = await clerkClient.m2m.verify({ token }); - res.send('Protected response ' + m2mToken.subject); - } catch { - res.status(401).send('Unauthorized'); - } - }); - - const port = parseInt(process.env.PORT as string) || 3002; - ViteExpress.listen(app, port, () => console.log('Server started')); - `, - ) - .commit(); - - await app.setup(); - - // Using the created machine, set a machine secret key using the primary machine's secret key - const env = appConfigs.envs.withAPIKeys - .clone() - .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', primaryApiServer.secretKey); - await app.withEnv(env); - await app.dev(); - - // Email server can access primary API server - emailServer = await client.machines.create({ - name: `${fakeCompanyName} Email Server`, - scopedMachines: [primaryApiServer.id], - }); - emailServerM2MToken = await client.m2m.createToken({ - machineSecretKey: emailServer.secretKey, - secondsUntilExpiration: 60 * 30, - }); - - // Analytics server cannot access primary API server - analyticsServer = await client.machines.create({ - name: `${fakeCompanyName} Analytics Server`, - // No scoped machines - }); - analyticsServerM2MToken = await client.m2m.createToken({ - machineSecretKey: analyticsServer.secretKey, - secondsUntilExpiration: 60 * 30, - }); - }); - - test.afterAll(async () => { - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - - await client.m2m.revokeToken({ - m2mTokenId: emailServerM2MToken.id, - }); - await client.m2m.revokeToken({ - m2mTokenId: analyticsServerM2MToken.id, - }); - await client.machines.delete(emailServer.id); - await client.machines.delete(primaryApiServer.id); - await client.machines.delete(analyticsServer.id); - - await app.teardown(); - }); - - test('rejects requests with invalid M2M tokens', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const res = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { - Authorization: `Bearer invalid`, - }, - }); - expect(res.status()).toBe(401); - expect(await res.text()).toBe('Unauthorized'); - - const res2 = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { - Authorization: `Bearer mt_xxx`, - }, - }); - expect(res2.status()).toBe(401); - expect(await res2.text()).toBe('Unauthorized'); - }); - - test('rejects M2M requests when sender machine lacks access to receiver machine', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const res = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { - Authorization: `Bearer ${analyticsServerM2MToken.token}`, - }, - }); - expect(res.status()).toBe(401); - expect(await res.text()).toBe('Unauthorized'); - }); - - test('authorizes M2M requests when sender machine has proper access to receiver machine', async ({ - page, - context, - }) => { - const u = createTestUtils({ app, page, context }); - - // Email server can access primary API server - const res = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { - Authorization: `Bearer ${emailServerM2MToken.token}`, - }, - }); - expect(res.status()).toBe(200); - expect(await res.text()).toBe('Protected response ' + emailServer.id); - - // Analytics server can access primary API server after adding scope - await u.services.clerk.machines.createScope(analyticsServer.id, primaryApiServer.id); - const m2mToken = await u.services.clerk.m2m.createToken({ - machineSecretKey: analyticsServer.secretKey, - secondsUntilExpiration: 60 * 30, - }); - - const res2 = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { - Authorization: `Bearer ${m2mToken.token}`, - }, - }); - expect(res2.status()).toBe(200); - expect(await res2.text()).toBe('Protected response ' + analyticsServer.id); - await u.services.clerk.m2m.revokeToken({ - m2mTokenId: m2mToken.id, - }); - }); - - test('verifies JWT format M2M token via local verification', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - const client = createClerkClient({ - secretKey: instanceKeys.get('with-api-keys').sk, - }); - const jwtToken = await client.m2m.createToken({ - machineSecretKey: emailServer.secretKey, - secondsUntilExpiration: 60 * 30, - tokenFormat: 'jwt', - }); - - const res = await u.page.request.get(app.serverUrl + '/api/protected', { - headers: { Authorization: `Bearer ${jwtToken.token}` }, - }); - expect(res.status()).toBe(200); - expect(await res.text()).toBe('Protected response ' + emailServer.id); - // JWT-format tokens are self-contained and not stored in BAPI, so revocation - // is not applicable — they expire naturally via the exp claim. - }); -}); diff --git a/integration/tests/machine-auth/oauth.test.ts b/integration/tests/machine-auth/oauth.test.ts deleted file mode 100644 index a501a15bfad..00000000000 --- a/integration/tests/machine-auth/oauth.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -import type { OAuthApplication } from '@clerk/backend'; -import { createClerkClient } from '@clerk/backend'; -import { TokenType } from '@clerk/backend/internal'; -import { expect, test } from '@playwright/test'; - -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import type { FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; - -test.describe('OAuth machine authentication @machine', () => { - test.describe.configure({ mode: 'parallel' }); - let app: Application; - let fakeUser: FakeUser; - let oAuthApp: OAuthApplication; - - test.beforeAll(async () => { - test.setTimeout(120_000); - - app = await appConfigs.next.appRouter - .clone() - .addFile( - 'src/app/api/protected/route.ts', - () => ` - import { auth } from '@clerk/nextjs/server'; - - export async function GET() { - const { userId, tokenType } = await auth({ acceptsToken: 'oauth_token' }); - - if (!userId) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - return Response.json({ userId, tokenType }); - } - `, - ) - .addFile( - 'src/app/oauth/callback/route.ts', - () => ` - import { NextResponse } from 'next/server'; - - export async function GET() { - return NextResponse.json({ message: 'OAuth callback received' }); - } - `, - ) - .commit(); - - await app.setup(); - await app.withEnv(appConfigs.envs.withAPIKeys); - await app.dev(); - - // Test user that will authorize the OAuth application - 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'), - }); - - // Create an OAuth application via the BAPI - oAuthApp = await clerkClient.oauthApplications.create({ - name: `Integration Test OAuth App - ${Date.now()}`, - redirectUris: [`${app.serverUrl}/oauth/callback`], - scopes: 'profile email', - }); - }); - - test.afterAll(async () => { - const clerkClient = createClerkClient({ - secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), - publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), - }); - - if (oAuthApp.id) { - await clerkClient.oauthApplications.delete(oAuthApp.id); - } - - 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 }); - - // Build the authorization URL - const state = randomBytes(16).toString('hex'); - const redirectUri = `${app.serverUrl}/oauth/callback`; - 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); - - // Navigate to Clerk's authorization endpoint - await u.page.goto(authorizeUrl.toString()); - - // Sign in on Account Portal - await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ - email: fakeUser.email, - password: fakeUser.password, - }); - - // Accept consent screen - // Per https://clerk.com/docs/guides/configure/auth-strategies/oauth/how-clerk-implements-oauth#consent-screen-management - const consentButton = u.page.getByRole('button', { name: 'Allow' }); - await consentButton.waitFor({ timeout: 10000 }); - await consentButton.click(); - - // Wait for the redirect to complete - await u.page.waitForURL(/oauth\/callback/, { timeout: 10000 }); - - // Extract the authorization code from the callback URL - const currentUrl = u.page.url(); - const urlObj = new URL(currentUrl); - const finalAuthCode = urlObj.searchParams.get('code'); - - expect(finalAuthCode).toBeTruthy(); - - // Exchange authorization code for access token - expect(oAuthApp.clientSecret).toBeTruthy(); - - const tokenResponse = await u.page.request.post(oAuthApp.tokenFetchUrl, { - data: new URLSearchParams({ - grant_type: 'authorization_code', - code: finalAuthCode, - 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 tokenResponseBody = await tokenResponse.text(); - - const tokenData = JSON.parse(tokenResponseBody) as { access_token?: string }; - const accessToken = tokenData.access_token; - - expect(accessToken).toBeTruthy(); - - // Use the access token to authenticate a request to our protected route - const protectedRouteUrl = new URL('/api/protected', app.serverUrl); - const protectedResponse = await u.page.request.get(protectedRouteUrl.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - expect(protectedResponse.status()).toBe(200); - const authData = await protectedResponse.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); - }); -}); diff --git a/integration/tests/next-machine.test.ts b/integration/tests/next-machine.test.ts new file mode 100644 index 00000000000..31313e4d23b --- /dev/null +++ b/integration/tests/next-machine.test.ts @@ -0,0 +1,381 @@ +import type { User } from '@clerk/backend'; +import { createClerkClient } from '@clerk/backend'; +import { TokenType } from '@clerk/backend/internal'; +import { expect, 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 }); + } + + export async function POST() { + const authObject = await auth({ acceptsToken: ['api_key', 'session_token'] }); + + if (!authObject.isAuthenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + 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('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 }); + } + `, + ) + .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() + .addFile( + 'src/app/api/protected/route.ts', + () => ` + import { auth } from '@clerk/nextjs/server'; + + export async function GET() { + const { userId, tokenType } = await auth({ acceptsToken: 'oauth_token' }); + + if (!userId) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return Response.json({ userId, tokenType }); + } + `, + ) + .addFile( + 'src/app/oauth/callback/route.ts', + () => ` + import { NextResponse } from 'next/server'; + + export async function GET() { + 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); + }); + } + }); +});