diff --git a/.changeset/clean-deers-mate.md b/.changeset/clean-deers-mate.md new file mode 100644 index 00000000000..21dbeb21219 --- /dev/null +++ b/.changeset/clean-deers-mate.md @@ -0,0 +1,6 @@ +--- +'@clerk/backend': minor +'@clerk/nextjs': minor +--- + +Implement token signature verification when passing verified token from Next.js middleware to the application origin. diff --git a/package-lock.json b/package-lock.json index 058d292a7b6..6862a8909bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10617,6 +10617,12 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "node_modules/@types/debug": { "version": "0.0.30", "dev": true, @@ -15780,6 +15786,11 @@ "node": "*" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/crypto-random-string": { "version": "2.0.0", "dev": true, @@ -38736,11 +38747,13 @@ "@clerk/backend": "1.0.0-beta.33", "@clerk/clerk-react": "5.0.0-beta.36", "@clerk/shared": "2.0.0-beta.21", + "crypto-js": "4.2.0", "path-to-regexp": "6.2.1", "tslib": "2.4.1" }, "devDependencies": { "@clerk/types": "4.0.0-beta.25", + "@types/crypto-js": "4.2.2", "@types/node": "^18.17.0", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 35decd42c1c..fa7ac5c5680 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -7,6 +7,7 @@ export const JWKS_CACHE_TTL_MS = 1000 * 60 * 60; const Attributes = { AuthToken: '__clerkAuthToken', + AuthSignature: '__clerkAuthSignature', AuthStatus: '__clerkAuthStatus', AuthReason: '__clerkAuthReason', AuthMessage: '__clerkAuthMessage', @@ -32,6 +33,7 @@ const QueryParameters = { const Headers = { AuthToken: 'x-clerk-auth-token', + AuthSignature: 'x-clerk-auth-signature', AuthStatus: 'x-clerk-auth-status', AuthReason: 'x-clerk-auth-reason', AuthMessage: 'x-clerk-auth-message', diff --git a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap index a8f791e45f4..023875d97ba 100644 --- a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap @@ -14,6 +14,7 @@ exports[`constants from environment variables 1`] = ` "Accept": "accept", "AuthMessage": "x-clerk-auth-message", "AuthReason": "x-clerk-auth-reason", + "AuthSignature": "x-clerk-auth-signature", "AuthStatus": "x-clerk-auth-status", "AuthToken": "x-clerk-auth-token", "Authorization": "authorization", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index fd587b06788..951d1e63f01 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -70,11 +70,13 @@ "@clerk/backend": "1.0.0-beta.33", "@clerk/clerk-react": "5.0.0-beta.36", "@clerk/shared": "2.0.0-beta.21", + "crypto-js": "4.2.0", "path-to-regexp": "6.2.1", "tslib": "2.4.1" }, "devDependencies": { "@clerk/types": "4.0.0-beta.25", + "@types/crypto-js": "4.2.2", "@types/node": "^18.17.0", "@types/react": "*", "@types/react-dom": "*", diff --git a/packages/nextjs/src/server/__tests__/createGetAuth.test.ts b/packages/nextjs/src/server/__tests__/createGetAuth.test.ts index 5a2c7cb91ed..cad0c51bcce 100644 --- a/packages/nextjs/src/server/__tests__/createGetAuth.test.ts +++ b/packages/nextjs/src/server/__tests__/createGetAuth.test.ts @@ -1,13 +1,18 @@ import { AuthStatus, constants } from '@clerk/backend/internal'; +import hmacSHA1 from 'crypto-js/hmac-sha1'; import { NextRequest } from 'next/server'; import { createGetAuth, getAuth } from '../createGetAuth'; +const mockSecretKey = 'sk_test_mock'; + // { alg: 'HS256' }.{ sub: 'user-id' }.sig const mockToken = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLWlkIn0.0u5CllULtDVD9DUUmUMdJLbBCSNcnv4j3hCaPz4dNr8'; // { alg: 'HS256' }.{ sub: 'user-id-2' }.sig const mockToken2 = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLWlkLTIifQ.K-mhz0Ber1Hfh2xCwmvsLwhZO_IKLtKt78KTHsecEas'; +const mockTokenSignature = hmacSHA1(mockToken, 'sk_test_mock').toString(); + describe('createGetAuth(opts)', () => { it('returns a getAuth function', () => { expect(createGetAuth({ debugLoggerName: 'test', noAuthStatusMessage: 'test' })).toBeInstanceOf(Function); @@ -20,12 +25,13 @@ describe('getAuth(req)', () => { headers: new Headers({ [constants.Headers.AuthStatus]: AuthStatus.SignedIn, [constants.Headers.AuthToken]: mockToken, + [constants.Headers.AuthSignature]: mockTokenSignature, [constants.Headers.AuthMessage]: 'message', [constants.Headers.AuthReason]: 'reason', }), }); - expect(getAuth(req).userId).toEqual('user-id'); + expect(getAuth(req, { secretKey: mockSecretKey }).userId).toEqual('user-id'); }); it('parses and returns the token claims when signed out', () => { @@ -45,13 +51,14 @@ describe('getAuth(req)', () => { headers: new Headers({ [constants.Headers.AuthStatus]: AuthStatus.SignedIn, [constants.Headers.AuthToken]: mockToken, + [constants.Headers.AuthSignature]: mockTokenSignature, [constants.Headers.AuthMessage]: 'message', [constants.Headers.AuthReason]: 'reason', Cookie: `__session=${mockToken2};`, }), }); - expect(getAuth(req).userId).toEqual('user-id'); + expect(getAuth(req, { secretKey: mockSecretKey }).userId).toEqual('user-id'); }); it('throws if auth status is not found', () => { @@ -63,4 +70,18 @@ describe('getAuth(req)', () => { expect(() => getAuth(req)).toThrowError(); }); + + it('throws if signature does not match token', () => { + const req = new NextRequest('https://www.clerk.com', { + headers: new Headers({ + [constants.Headers.AuthStatus]: AuthStatus.SignedIn, + [constants.Headers.AuthToken]: mockToken2, + [constants.Headers.AuthSignature]: mockTokenSignature, + [constants.Headers.AuthMessage]: 'message', + [constants.Headers.AuthReason]: 'reason', + }), + }); + + expect(() => getAuth(req, { secretKey: mockSecretKey })).toThrowError(); + }); }); diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 6d66ca75620..a971b7a3a03 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -219,7 +219,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { logger.debug(`Added ${constants.Headers.EnableDebug} on request`); } - const result = decorateRequest(clerkRequest, finalRes, requestState) || NextResponse.next(); + const result = decorateRequest(clerkRequest, finalRes, requestState, secretKey) || NextResponse.next(); if (requestState.headers) { requestState.headers.forEach((value, key) => { diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index a478c29c2e7..7ecc810568c 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -130,7 +130,7 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); } - decorateRequest(clerkRequest, handlerResult, requestState); + decorateRequest(clerkRequest, handlerResult, requestState, options.secretKey); // TODO @nikos: we need to make this more generic // and move the logic in clerk/backend diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts index 61756ea574c..207c2e9cb85 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -6,7 +6,7 @@ import { withLogger } from '../utils/debugLogger'; import { API_URL, API_VERSION, SECRET_KEY } from './constants'; import { getAuthAuthHeaderMissing } from './errors'; import type { RequestLike } from './types'; -import { getAuthKeyFromRequest, getCookie, getHeader } from './utils'; +import { assertTokenSignature, getAuthKeyFromRequest, getCookie, getHeader } from './utils'; export const createGetAuth = ({ noAuthStatusMessage, @@ -25,6 +25,7 @@ export const createGetAuth = ({ // Then, we don't have to re-verify the JWT here, // we can just strip out the claims manually. const authToken = getAuthKeyFromRequest(req, 'AuthToken'); + const authSignature = getAuthKeyFromRequest(req, 'AuthSignature'); const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); const authReason = getAuthKeyFromRequest(req, 'AuthReason'); const authStatus = getAuthKeyFromRequest(req, 'AuthStatus') as AuthStatus; @@ -46,6 +47,8 @@ export const createGetAuth = ({ logger.debug('Options debug', options); if (authStatus === AuthStatus.SignedIn) { + assertTokenSignature(authToken as string, options.secretKey, authSignature); + const jwt = decodeJwt(authToken as string); logger.debug('JWT debug', jwt.raw.text); diff --git a/packages/nextjs/src/server/errors.ts b/packages/nextjs/src/server/errors.ts index 0f1600675cf..8e23e9d5dd4 100644 --- a/packages/nextjs/src/server/errors.ts +++ b/packages/nextjs/src/server/errors.ts @@ -95,3 +95,5 @@ ${[apiRoutesText, publicRoutesText, ignoredRoutesText, afterAuthText] For additional information about middleware, please visit https://clerk.com/docs/nextjs/middleware (This log only appears in development mode, or if \`debug: true\` is passed to authMiddleware)`; }; + +export const authSignatureInvalid = `Clerk: Unable to verify request, this usually means the Clerk middleware did not run. Ensure Clerk's middleware is properly integrated and matches the current route. For more information, see: https://clerk.com/docs/nextjs/middleware. (code=auth_signature_invalid)`; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index ada36bd6186..7d6eb6b7795 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -3,12 +3,13 @@ import { constants } from '@clerk/backend/internal'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps } from '@clerk/shared/proxy'; +import hmacSHA1 from 'crypto-js/hmac-sha1'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { constants as nextConstants } from '../constants'; import { DOMAIN, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants'; -import { missingDomainAndProxy, missingSignInUrlInDev } from './errors'; +import { authSignatureInvalid, missingDomainAndProxy, missingSignInUrlInDev } from './errors'; import type { RequestLike } from './types'; export function setCustomAttributeOnRequest(req: RequestLike, key: string, value: string): void { @@ -108,7 +109,12 @@ export const injectSSRStateIntoObject = (obj: O, authObject: T) => { }; // Auth result will be set as both a query param & header when applicable -export function decorateRequest(req: ClerkRequest, res: Response, requestState: RequestState): Response { +export function decorateRequest( + req: ClerkRequest, + res: Response, + requestState: RequestState, + secretKey: string, +): Response { const { reason, message, status, token } = requestState; // pass-through case, convert to next() if (!res) { @@ -145,6 +151,7 @@ export function decorateRequest(req: ClerkRequest, res: Response, requestState: setRequestHeadersOnNextResponse(res, req, { [constants.Headers.AuthStatus]: status, [constants.Headers.AuthToken]: token || '', + [constants.Headers.AuthSignature]: token ? createTokenSignature(token, secretKey) : '', [constants.Headers.AuthMessage]: message || '', [constants.Headers.AuthReason]: reason || '', [constants.Headers.ClerkUrl]: req.clerkUrl.toString(), @@ -206,3 +213,24 @@ export function assertKey(key: string, onError: () => never): string { return key; } + +/** + * Compute a cryptographic signature from a session token and provided secret key. Used to validate that the token has not been modified when transferring between middleware and the Next.js origin. + */ +function createTokenSignature(token: string, key: string): string { + return hmacSHA1(token, key).toString(); +} + +/** + * Assert that the provided token generates a matching signature. + */ +export function assertTokenSignature(token: string, key: string, signature?: string | null) { + if (!signature) { + throw new Error(authSignatureInvalid); + } + + const expectedSignature = createTokenSignature(token, key); + if (expectedSignature !== signature) { + throw new Error(authSignatureInvalid); + } +}