diff --git a/integration/.keys.json.sample b/integration/.keys.json.sample index d4dfffafd57..db64ab190ea 100644 --- a/integration/.keys.json.sample +++ b/integration/.keys.json.sample @@ -54,5 +54,9 @@ "with-whatsapp-phone-code": { "pk": "", "sk": "" + }, + "with-api-keys": { + "pk": "", + "sk": "" } } diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index eb1bbed8da6..46f768216a0 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -163,6 +163,12 @@ const withWhatsappPhoneCode = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-whatsapp-phone-code').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-whatsapp-phone-code').pk); +const withAPIKeys = base + .clone() + .setId('withAPIKeys') + .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-api-keys').sk) + .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-api-keys').pk); + export const envs = { base, withKeyless, @@ -187,4 +193,5 @@ export const envs = { withBillingStaging, withBilling, withWhatsappPhoneCode, + withAPIKeys, } as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 49ec2d7d480..0345a787b18 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -42,6 +42,7 @@ export const createLongRunningApps = () => { config: next.appRouter, env: envs.withSessionTasks, }, + { id: 'next.appRouter.withAPIKeys', config: next.appRouter, env: envs.withAPIKeys }, { id: 'withBillingStaging.next.appRouter', config: next.appRouter, env: envs.withBillingStaging }, { id: 'withBilling.next.appRouter', config: next.appRouter, env: envs.withBilling }, { id: 'withBillingStaging.vue.vite', config: vue.vite, env: envs.withBillingStaging }, diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 09dde2d7660..9593c0f7610 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -6,10 +6,10 @@ import type { Application } from '../models/application'; import { createEmailService } from './emailService'; import { createInvitationService } from './invitationsService'; import { createOrganizationsService } from './organizationsService'; -import type { FakeOrganization, FakeUser } from './usersService'; +import type { FakeAPIKey, FakeOrganization, FakeUser } from './usersService'; import { createUserService } from './usersService'; -export type { FakeUser, FakeOrganization }; +export type { FakeUser, FakeOrganization, FakeAPIKey }; const createClerkClient = (app: Application) => { return backendCreateClerkClient({ apiUrl: app.env.privateVariables.get('CLERK_API_URL'), diff --git a/integration/testUtils/usersService.ts b/integration/testUtils/usersService.ts index 2914a15f816..cb1b708f439 100644 --- a/integration/testUtils/usersService.ts +++ b/integration/testUtils/usersService.ts @@ -1,4 +1,4 @@ -import type { ClerkClient, Organization, User } from '@clerk/backend'; +import type { APIKey, ClerkClient, Organization, User } from '@clerk/backend'; import { faker } from '@faker-js/faker'; import { hash } from '../models/helpers'; @@ -57,6 +57,12 @@ export type FakeOrganization = { delete: () => Promise; }; +export type FakeAPIKey = { + apiKey: APIKey; + secret: string; + revoke: () => Promise; +}; + export type UserService = { createFakeUser: (options?: FakeUserOptions) => FakeUser; createBapiUser: (fakeUser: FakeUser) => Promise; @@ -67,6 +73,7 @@ export type UserService = { deleteIfExists: (opts: { id?: string; email?: string; phoneNumber?: string }) => Promise; createFakeOrganization: (userId: string) => Promise; getUser: (opts: { id?: string; email?: string }) => Promise; + createFakeAPIKey: (userId: string) => Promise; }; /** @@ -175,6 +182,23 @@ export const createUserService = (clerkClient: ClerkClient) => { delete: () => clerkClient.organizations.deleteOrganization(organization.id), } satisfies FakeOrganization; }, + createFakeAPIKey: async (userId: string) => { + const THIRTY_MINUTES = 30 * 60; + + const apiKey = await clerkClient.apiKeys.create({ + subject: userId, + name: faker.company.buzzPhrase(), + secondsUntilExpiration: THIRTY_MINUTES, + }); + + const { secret } = await clerkClient.apiKeys.getSecret(apiKey.id); + + return { + apiKey, + secret, + revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }), + } satisfies FakeAPIKey; + }, }; return self; diff --git a/integration/tests/api-keys/auth.test.ts b/integration/tests/api-keys/auth.test.ts new file mode 100644 index 00000000000..5f567a4ca70 --- /dev/null +++ b/integration/tests/api-keys/auth.test.ts @@ -0,0 +1,232 @@ +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('auth() with API keys @nextjs', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeBapiUser: User; + let fakeAPIKey: FakeAPIKey; + + test.beforeAll(async () => { + 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 validate API key', async ({ page, context }) => { + const url = new URL('/api/me', app.serverUrl); + const u = createTestUtils({ app, page, context }); + + // No API key provided + const noKeyRes = await u.page.request.get(url.toString()); + expect(noKeyRes.status()).toBe(401); + + // Invalid API key + const invalidKeyRes = await u.page.request.get(url.toString(), { + headers: { + Authorization: 'Bearer invalid_key', + }, + }); + expect(invalidKeyRes.status()).toBe(401); + + // Valid API key + const validKeyRes = await u.page.request.get(url.toString(), { + headers: { + Authorization: `Bearer ${fakeAPIKey.secret}`, + }, + }); + const apiKeyData = await validKeyRes.json(); + expect(validKeyRes.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); + }); +}); + +test.describe('auth.protect() with API keys @nextjs', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeBapiUser: User; + let fakeAPIKey: FakeAPIKey; + + test.beforeAll(async () => { + 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.protect({ token: 'api_key' }); + return Response.json({ userId, tokenType }); + } + + export async function POST() { + const { userId, tokenType } = await auth.protect({ token: ['api_key', 'session_token'] }); + 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.skip('should validate API key', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + const url = new URL('/api/me', app.serverUrl); + + // No API key provided + const noKeyRes = await u.page.request.get(url.toString()); + expect(noKeyRes.status()).toBe(401); + + // Invalid API key + const invalidKeyRes = await u.page.request.get(url.toString(), { + headers: { + Authorization: 'Bearer invalid_key', + }, + }); + expect(invalidKeyRes.status()).toBe(401); + + // Valid API key + const validKeyRes = await u.page.request.get(url.toString(), { + headers: { + Authorization: `Bearer ${fakeAPIKey.secret}`, + }, + }); + const apiKeyData = await validKeyRes.json(); + expect(validKeyRes.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/api-keys/middleware.test.ts b/integration/tests/api-keys/middleware.test.ts new file mode 100644 index 00000000000..154906ebc83 --- /dev/null +++ b/integration/tests/api-keys/middleware.test.ts @@ -0,0 +1,95 @@ +import type { User } from '@clerk/backend'; +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('auth() and API key within clerkMiddleware() nextjs', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + let fakeBapiUser: User; + let fakeAPIKey: FakeAPIKey; + + test.beforeAll(async () => { + 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 } = 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(); + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('should validate API key', async () => { + const url = new URL('/api/me', app.serverUrl); + + // No API key provided + const noKeyRes = await fetch(url); + expect(noKeyRes.status).toBe(401); + + // Invalid API key + const invalidKeyRes = await fetch(url, { + headers: { + Authorization: 'Bearer invalid_key', + }, + }); + expect(invalidKeyRes.status).toBe(401); + + // Valid API key + const validKeyRes = await fetch(url, { + headers: { + Authorization: `Bearer ${fakeAPIKey.secret}`, + }, + }); + const apiKeyData = await validKeyRes.json(); + expect(validKeyRes.status).toBe(200); + expect(apiKeyData.userId).toBe(fakeBapiUser.id); + }); +}); diff --git a/package.json b/package.json index 10484e8427a..d0f1d300caf 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router", "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start", "test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue", + "test:integration:xnextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @xnextjs", "test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run", "turbo:clean": "turbo daemon clean", "typedoc:generate": "pnpm build:declarations && typedoc --tsconfig tsconfig.typedoc.json", diff --git a/packages/backend/src/api/endpoints/APIKeysApi.ts b/packages/backend/src/api/endpoints/APIKeysApi.ts index 4cf973de28e..518f75131ba 100644 --- a/packages/backend/src/api/endpoints/APIKeysApi.ts +++ b/packages/backend/src/api/endpoints/APIKeysApi.ts @@ -4,7 +4,116 @@ import { AbstractAPI } from './AbstractApi'; const basePath = '/api_keys'; +type ListAPIKeysParams = { + /** + * API key type. Currently, only 'api_key' is supported. + */ + type?: 'api_key'; + /** + * user or organization ID the API key is associated with + */ + subject: string; + includeInvalid?: boolean; +}; + +type CreateAPIKeyParams = { + type?: 'api_key'; + /** + * API key name + */ + name: string; + /** + * user or organization ID the API key is associated with + */ + subject: string; + /** + * API key description + */ + description?: string | null; + claims?: Record | null; + scopes?: string[]; + createdBy?: string | null; + secondsUntilExpiration?: number | null; +}; + +type UpdateAPIKeyParams = { + /** + * API key ID + */ + apiKeyId: string; + /** + * API key description + */ + description?: string | null; + claims?: Record | null; + scopes?: string[]; +}; + +type RevokeAPIKeyParams = { + /** + * API key ID + */ + apiKeyId: string; + /** + * Reason for revocation + */ + revocationReason?: string | null; +}; + export class APIKeysAPI extends AbstractAPI { + async list(params: ListAPIKeysParams) { + return this.request({ + method: 'GET', + path: basePath, + queryParams: { + type: params.type, + subject: params.subject, + include_invalid: params.includeInvalid, + }, + }); + } + + async create(params: CreateAPIKeyParams) { + return this.request({ + method: 'POST', + path: basePath, + bodyParams: params, + }); + } + + async update(params: UpdateAPIKeyParams) { + const { apiKeyId, ...bodyParams } = params; + + this.requireId(apiKeyId); + + return this.request({ + method: 'POST', + path: basePath, + bodyParams, + }); + } + + async revoke(params: RevokeAPIKeyParams) { + const { apiKeyId, ...bodyParams } = params; + + this.requireId(apiKeyId); + + return this.request({ + method: 'POST', + path: joinPaths(basePath, apiKeyId, 'revoke'), + bodyParams, + }); + } + + async getSecret(apiKeyId: string) { + this.requireId(apiKeyId); + + return this.request<{ secret: string }>({ + method: 'GET', + path: joinPaths(basePath, apiKeyId, 'secret'), + }); + } + async verifySecret(secret: string) { return this.request({ method: 'POST', diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 78ec3aab09a..fe902146b11 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -106,6 +106,7 @@ export type { * Resources */ export type { + APIKey, ActorToken, AccountlessApplication, AllowlistIdentifier, diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index cd5ce02c81d..b31f48a2a79 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -187,7 +187,8 @@ auth.protect = async (...args: any[]) => { require('server-only'); const request = await buildRequestLike(); - const authObject = await auth(); + const requestedToken = args?.[0]?.token || args?.[1]?.token || TokenType.SessionToken; + const authObject = await auth({ acceptsToken: requestedToken }); const protect = createProtect({ request, @@ -197,6 +198,5 @@ auth.protect = async (...args: any[]) => { redirect, unauthorized, }); - return protect(...args); }; diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index f698399410e..aa9327fa421 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -12,11 +12,9 @@ import { constants, createClerkRequest, createRedirect, + getAuthObjectForAcceptedToken, isMachineTokenByPrefix, - isTokenTypeAccepted, - signedOutAuthObject, TokenType, - unauthenticatedMachineObject, } from '@clerk/backend/internal'; import { parsePublishableKey } from '@clerk/shared/keys'; import { notFound as nextjsNotFound } from 'next/navigation'; @@ -207,7 +205,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest); const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); - const authHandler = createMiddlewareAuthHandler(requestState, redirectToSignIn, redirectToSignUp); + const authHandler = createMiddlewareAuthHandler(authObject, redirectToSignIn, redirectToSignUp); authHandler.protect = protect; let handlerResult: Response = NextResponse.next(); @@ -393,12 +391,14 @@ const createMiddlewareProtect = ( redirectUrl: url, }); + const transformedAuthObject = getAuthObjectForAcceptedToken({ authObject, acceptsToken: params?.token }); + return createProtect({ request: clerkRequest, redirect, notFound, unauthorized, - authObject, + authObject: transformedAuthObject, redirectToSignIn, })(params, options); }) as unknown as Promise; @@ -410,42 +410,26 @@ const createMiddlewareProtect = ( * - For machine tokens: validates token type and returns appropriate auth object */ const createMiddlewareAuthHandler = ( - requestState: RequestState, + authObject: AuthObject, redirectToSignIn: RedirectFun, redirectToSignUp: RedirectFun, ): ClerkMiddlewareAuth => { const authHandler = async (options?: GetAuthOptions) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const authObject = requestState.toAuth(options)!; - - const authObjWithMethods = Object.assign( - authObject, - authObject.tokenType === TokenType.SessionToken - ? { - redirectToSignIn, - redirectToSignUp, - } - : {}, - ); - const acceptsToken = options?.acceptsToken ?? TokenType.SessionToken; - if (acceptsToken === 'any') { - return authObjWithMethods; - } + const parsedAuthObject = getAuthObjectForAcceptedToken({ authObject, acceptsToken }); - if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { - if (authObject.tokenType === TokenType.SessionToken) { - return { - ...signedOutAuthObject(), - redirectToSignIn, - redirectToSignUp, - }; - } - return unauthenticatedMachineObject(authObject.tokenType); + if ( + parsedAuthObject.tokenType === TokenType.SessionToken || + (Array.isArray(acceptsToken) && acceptsToken.includes(TokenType.SessionToken)) + ) { + return Object.assign(parsedAuthObject, { + redirectToSignIn, + redirectToSignUp, + }); } - return authObjWithMethods; + return parsedAuthObject; }; return authHandler as ClerkMiddlewareAuth; @@ -465,7 +449,7 @@ const handleControlFlowErrors = ( requestState: RequestState, ): Response => { if (isNextjsUnauthorizedError(e)) { - const response = NextResponse.next({ status: 401 }); + const response = new NextResponse(null, { status: 401 }); // RequestState.toAuth() returns a session_token type by default. // We need to cast it to the correct type to check for OAuth tokens.