diff --git a/.changeset/eighty-frogs-return.md b/.changeset/eighty-frogs-return.md new file mode 100644 index 00000000000..42007565ddd --- /dev/null +++ b/.changeset/eighty-frogs-return.md @@ -0,0 +1,5 @@ +--- +"@clerk/nextjs": minor +--- + +Fix `auth.protect()` unauthorized error propagation within middleware diff --git a/.changeset/weak-adults-clean.md b/.changeset/weak-adults-clean.md new file mode 100644 index 00000000000..fe60e1504f5 --- /dev/null +++ b/.changeset/weak-adults-clean.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": minor +--- + +Introduce API keys Backend SDK methods 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..52138ece85f 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 TWENTY_MINUTES = 20 * 60; + + const apiKey = await clerkClient.apiKeys.create({ + subject: userId, + name: `Integration Test - ${userId}`, + secondsUntilExpiration: TWENTY_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/machine-auth/api-keys.test.ts b/integration/tests/machine-auth/api-keys.test.ts new file mode 100644 index 00000000000..b91ea3ddad1 --- /dev/null +++ b/integration/tests/machine-auth/api-keys.test.ts @@ -0,0 +1,214 @@ +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() @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 } = 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(); + 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 () => { + 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/packages/backend/src/api/endpoints/APIKeysApi.ts b/packages/backend/src/api/endpoints/APIKeysApi.ts index 4cf973de28e..bf0767d3a16 100644 --- a/packages/backend/src/api/endpoints/APIKeysApi.ts +++ b/packages/backend/src/api/endpoints/APIKeysApi.ts @@ -4,7 +4,67 @@ import { AbstractAPI } from './AbstractApi'; const basePath = '/api_keys'; +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 RevokeAPIKeyParams = { + /** + * API key ID + */ + apiKeyId: string; + /** + * Reason for revocation + */ + revocationReason?: string | null; +}; + export class APIKeysAPI extends AbstractAPI { + async create(params: CreateAPIKeyParams) { + return this.request({ + method: 'POST', + path: basePath, + bodyParams: params, + }); + } + + 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..a23fdb96e97 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, diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index f698399410e..da58ce560c6 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -12,11 +12,10 @@ 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 +206,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(); @@ -382,7 +381,7 @@ const createMiddlewareRedirectToSignUp = ( const createMiddlewareProtect = ( clerkRequest: ClerkRequest, - authObject: AuthObject, + rawAuthObject: AuthObject, redirectToSignIn: RedirectFun, ) => { return (async (params: any, options: any) => { @@ -393,6 +392,9 @@ const createMiddlewareProtect = ( redirectUrl: url, }); + const requestedToken = params?.token || options?.token || TokenType.SessionToken; + const authObject = getAuthObjectForAcceptedToken({ authObject: rawAuthObject, acceptsToken: requestedToken }); + return createProtect({ request: clerkRequest, redirect, @@ -410,42 +412,23 @@ const createMiddlewareProtect = ( * - For machine tokens: validates token type and returns appropriate auth object */ const createMiddlewareAuthHandler = ( - requestState: RequestState, + rawAuthObject: 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 authObject = getAuthObjectForAcceptedToken({ authObject: rawAuthObject, acceptsToken }); - if (!isTokenTypeAccepted(authObject.tokenType, acceptsToken)) { - if (authObject.tokenType === TokenType.SessionToken) { - return { - ...signedOutAuthObject(), - redirectToSignIn, - redirectToSignUp, - }; - } - return unauthenticatedMachineObject(authObject.tokenType); + if (authObject.tokenType === TokenType.SessionToken && isTokenTypeAccepted(TokenType.SessionToken, acceptsToken)) { + return Object.assign(authObject, { + redirectToSignIn, + redirectToSignUp, + }); } - return authObjWithMethods; + return authObject; }; return authHandler as ClerkMiddlewareAuth; @@ -465,7 +448,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. diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index 32090af25a5..34da3ab3ab5 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -10,7 +10,6 @@ import { invalidTokenAuthObject, isMachineTokenByPrefix, isTokenTypeAccepted, - type MachineTokenType, type SignedInAuthObject, type SignedOutAuthObject, signedOutAuthObject, @@ -81,6 +80,40 @@ export const getAuthDataFromRequestSync = ( return authObject; }; +const handleMachineToken = async ( + bearerToken: string | undefined, + acceptsToken: NonNullable, + options: GetAuthDataFromRequestOptions, +): Promise => { + const hasMachineToken = bearerToken && isMachineTokenByPrefix(bearerToken); + + const acceptsOnlySessionToken = + acceptsToken === TokenType.SessionToken || + (Array.isArray(acceptsToken) && acceptsToken.length === 1 && acceptsToken[0] === TokenType.SessionToken); + + if (hasMachineToken && !acceptsOnlySessionToken) { + const machineTokenType = getMachineTokenType(bearerToken); + + // Early return if the token type is not accepted to save on the verify call + if (Array.isArray(acceptsToken) && !acceptsToken.includes(machineTokenType)) { + return invalidTokenAuthObject(); + } + // Early return for scalar acceptsToken if it does not match the machine token type + if (!Array.isArray(acceptsToken) && acceptsToken !== 'any' && machineTokenType !== acceptsToken) { + const authObject = unauthenticatedMachineObject(acceptsToken, options); + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); + } + + const { data, errors } = await verifyMachineAuthToken(bearerToken, options); + const authObject = errors + ? unauthenticatedMachineObject(machineTokenType, options) + : authenticatedMachineObject(machineTokenType, bearerToken, data); + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); + } + + return null; +}; + /** * Given a request object, builds an auth object from the request data. Used in server-side environments to get access * to auth data for a given request. @@ -103,34 +136,19 @@ export const getAuthDataFromRequestAsync = async ( authReason, }; - const hasMachineToken = bearerToken && isMachineTokenByPrefix(bearerToken); - if (hasMachineToken) { - const machineTokenType = getMachineTokenType(bearerToken); - - // Early return if the token type is not accepted to save on the verify call - if (Array.isArray(acceptsToken) && !acceptsToken.includes(machineTokenType)) { - return invalidTokenAuthObject(); - } - // Early return for scalar acceptsToken if it does not match the machine token type - if (!Array.isArray(acceptsToken) && acceptsToken !== 'any' && machineTokenType !== acceptsToken) { - const authObject = unauthenticatedMachineObject(acceptsToken as MachineTokenType, options); - return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); - } - - const { data, errors } = await verifyMachineAuthToken(bearerToken, options); - const authObject = errors - ? unauthenticatedMachineObject(machineTokenType, options) - : authenticatedMachineObject(machineTokenType, bearerToken, data); - return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); + // If the request has a machine token in header, handle it first. + const machineAuthObject = await handleMachineToken(bearerToken, acceptsToken, options); + if (machineAuthObject) { + return machineAuthObject; } // If a random token is present and acceptsToken is an array that does NOT include session_token, - // return invalidTokenAuthObject. + // return invalid token auth object. if (bearerToken && Array.isArray(acceptsToken) && !acceptsToken.includes(TokenType.SessionToken)) { return invalidTokenAuthObject(); } - // Fallback to session logic (sync version) for all other cases + // Fallback to session logic for all other cases const authObject = getAuthDataFromRequestSync(req, opts); return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); };