diff --git a/.changeset/shy-tokens-sign-out.md b/.changeset/shy-tokens-sign-out.md new file mode 100644 index 00000000000..4a1958dad15 --- /dev/null +++ b/.changeset/shy-tokens-sign-out.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Fix `getAuth()` and `auth()` returning OAuth and M2M tokens as signed-in user sessions. They now return a signed-out state for machine tokens. To authenticate OAuth or M2M tokens on a route, use `auth({ acceptsToken })`. diff --git a/integration/testUtils/machineAuthHelpers.ts b/integration/testUtils/machineAuthHelpers.ts index 4b0ac197340..30d182bb84c 100644 --- a/integration/testUtils/machineAuthHelpers.ts +++ b/integration/testUtils/machineAuthHelpers.ts @@ -24,8 +24,8 @@ export type FakeMachineNetwork = { cleanup: () => Promise; }; -async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { - const fakeCompanyName = faker.company.name(); +export async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { + const fakeCompanyName = `${faker.company.name()} ${Date.now()}`; const primaryServer = await clerkClient.machines.create({ name: `${fakeCompanyName} Primary API Server`, @@ -68,7 +68,7 @@ async function createFakeMachineNetwork(clerkClient: ClerkClient): Promise { +export async function createJwtM2MToken(clerkClient: ClerkClient, senderSecretKey: string): Promise { return clerkClient.m2m.createToken({ machineSecretKey: senderSecretKey, secondsUntilExpiration: 60 * 30, @@ -181,7 +181,7 @@ export type MachineAuthTestAdapter = { const createApiKeysEnv = (): EnvironmentConfig => appConfigs.envs.withAPIKeys.clone(); -const createMachineClient = () => +export const createMachineClient = () => createClerkClient({ secretKey: instanceKeys.get('with-api-keys').sk, }); diff --git a/integration/tests/next-machine.test.ts b/integration/tests/next-machine.test.ts index 80b9cbc878d..9f81b14c73e 100644 --- a/integration/tests/next-machine.test.ts +++ b/integration/tests/next-machine.test.ts @@ -5,8 +5,15 @@ import { expect, test } from '@playwright/test'; import type { Application } from '../models/application'; import { appConfigs } from '../presets'; import { createTestUtils } from '../testUtils'; -import type { MachineAuthTestAdapter } from '../testUtils/machineAuthHelpers'; -import { registerApiKeyAuthTests, registerM2MAuthTests, registerOAuthAuthTests } from '../testUtils/machineAuthHelpers'; +import type { FakeMachineNetwork, MachineAuthTestAdapter } from '../testUtils/machineAuthHelpers'; +import { + createFakeMachineNetwork, + createJwtM2MToken, + createMachineClient, + registerApiKeyAuthTests, + registerM2MAuthTests, + registerOAuthAuthTests, +} from '../testUtils/machineAuthHelpers'; import type { FakeAPIKey, FakeUser } from '../testUtils/usersService'; const adapter: MachineAuthTestAdapter = { @@ -189,4 +196,107 @@ test.describe('Next.js machine authentication @machine', () => { expect(await res.json()).toEqual({ userId: fakeBapiUser.id, tokenType: TokenType.ApiKey }); }); }); + + test.describe('session helpers with machine tokens', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let network: FakeMachineNetwork; + let jwtToken: string; + + test.beforeAll(async () => { + test.setTimeout(120_000); + + network = await createFakeMachineNetwork(createMachineClient()); + jwtToken = (await createJwtM2MToken(createMachineClient(), network.scopedSender.secretKey)).token; + + app = await appConfigs.next.appRouter + .clone() + .addFile( + 'src/pages/api/pages-session.ts', + () => ` + import { getAuth } from '@clerk/nextjs/server'; + import type { NextApiRequest, NextApiResponse } from 'next'; + + export default function handler(req: NextApiRequest, res: NextApiResponse) { + const { userId, tokenType } = getAuth(req); + + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + return res.status(200).json({ userId, tokenType }); + } + `, + ) + .addFile( + 'src/app/api/app-session/route.ts', + () => ` + import { auth } from '@clerk/nextjs/server'; + + export async function GET() { + const { userId, tokenType } = await auth(); + + if (!userId) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return Response.json({ userId, tokenType }); + } + `, + ) + .addFile( + 'src/app/api/app-machine/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(); + await app.withEnv( + appConfigs.envs.withAPIKeys + .clone() + .setEnvVariable('private', 'CLERK_MACHINE_SECRET_KEY', network.primaryServer.secretKey), + ); + await app.dev(); + }); + + test.afterAll(async () => { + await network?.cleanup(); + await app?.teardown(); + }); + + test('should return 200 from auth() if the route accepts M2M tokens', async ({ request }) => { + const res = await request.get(new URL('/api/app-machine', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${jwtToken}` }, + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ subject: network.scopedSender.id, tokenType: TokenType.M2MToken }); + }); + + test('should return 401 from getAuth() if a JWT-format M2M token is sent', async ({ request }) => { + const res = await request.get(new URL('/api/pages-session', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${jwtToken}` }, + }); + expect(res.status()).toBe(401); + }); + + test('should return 401 from auth() if a JWT-format M2M token is sent with a padded Authorization header', async ({ + request, + }) => { + const res = await request.get(new URL('/api/app-session', app.serverUrl).toString(), { + headers: { Authorization: `Bearer ${jwtToken} x` }, + }); + expect(res.status()).toBe(401); + }); + }); }); diff --git a/packages/nextjs/src/server/__tests__/createGetAuth.test.ts b/packages/nextjs/src/server/__tests__/createGetAuth.test.ts index 5574da4b6a1..41d20308bbb 100644 --- a/packages/nextjs/src/server/__tests__/createGetAuth.test.ts +++ b/packages/nextjs/src/server/__tests__/createGetAuth.test.ts @@ -85,4 +85,32 @@ describe('getAuth(req)', () => { expect(() => getAuth(req, { secretKey: mockSecretKey })).toThrowError(); }); + + const toBase64Url = (str: string) => btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const mockOAuthJwt = [ + JSON.stringify({ alg: 'RS256', typ: 'at+jwt' }), + JSON.stringify({ sub: 'user_123' }), + 'signature', + ] + .map(toBase64Url) + .join('.'); + + it.each([ + ['a JWT-format OAuth token', mockOAuthJwt], + ['an API key', 'ak_123'], + ])('returns signed-out when the middleware verified %s', (_, token) => { + const req = new NextRequest('https://www.clerk.com', { + headers: new Headers({ + [constants.Headers.AuthStatus]: AuthStatus.SignedIn, + [constants.Headers.AuthToken]: token, + [constants.Headers.AuthSignature]: HmacSHA1(token, mockSecretKey).toString(), + }), + }); + + const auth = getAuth(req, { secretKey: mockSecretKey }); + + expect(auth.tokenType).toBe('session_token'); + expect(auth.userId).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + }); }); diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 1a49c6b6c13..49f3c6c7876 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -1,9 +1,10 @@ import type { MachineAuthObject } from '@clerk/backend'; import type { AuthenticatedMachineObject, MachineTokenType, SignedOutAuthObject } from '@clerk/backend/internal'; -import { constants } from '@clerk/backend/internal'; +import { AuthStatus, constants } from '@clerk/backend/internal'; import { NextRequest } from 'next/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HmacSHA1 } from '../../vendor/crypto-es'; import { getAuthDataFromRequest } from '../data/getAuthDataFromRequest'; import { encryptClerkRequestData } from '../utils'; @@ -306,7 +307,7 @@ describe('getAuthDataFromRequest', () => { const mockM2MJwt = [ toBase64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })), toBase64Url(JSON.stringify({ sub: 'mch_testmachine123', exp: 9999999999, iat: 1666648250 })), - 'fakesignature', + toBase64Url('signature'), ].join('.'); it('returns signed-out when JWT-format M2M token is sent to a session-only endpoint', () => { @@ -352,5 +353,24 @@ describe('getAuthDataFromRequest', () => { expect(auth.isAuthenticated).toBe(false); }); + + it('returns signed-out when a verified JWT-format M2M token is sent with a padded Authorization header', () => { + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: `Bearer ${mockM2MJwt} x`, + [constants.Headers.AuthStatus]: AuthStatus.SignedIn, + [constants.Headers.AuthToken]: mockM2MJwt, + [constants.Headers.AuthSignature]: HmacSHA1(mockM2MJwt, 'sk_test_xxxxxxxxxxxxxxxxxx').toString(), + }), + machineAuthObject: createMockMachineAuthObject({ tokenType: 'm2m_token', isAuthenticated: true }), + }); + + const auth = getAuthDataFromRequest(req); + + expect(auth.tokenType).toBe('session_token'); + expect((auth as SignedOutAuthObject).userId).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + }); }); }); diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index f62f6ab4798..348e7f277e2 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -89,6 +89,10 @@ export const getSessionAuthDataFromRequest = ( } else { assertTokenSignature(authToken as string, options.secretKey, authSignature); + if (isMachineToken(authToken as string)) { + return signedOutAuthObject(options); + } + const jwt = decodeJwt(authToken as string); opts.logger?.debug('jwt', jwt.raw);