Skip to content

Commit

Permalink
feat(nextjs): Add signature verification of token passed from middlew…
Browse files Browse the repository at this point in the history
…are (#3121)

* feat(nextjs): Add signature verification of token passed from middleware

* chore(repo): Adds changeset

* chore(fastify): Update snapshot

* chore(nextjs): Pin crypto-js version

* chore(nextjs): Tweak error message
  • Loading branch information
BRKalow authored Apr 10, 2024
1 parent 701d3fd commit ecb60da
Show file tree
Hide file tree
Showing 11 changed files with 85 additions and 7 deletions.
6 changes: 6 additions & 0 deletions .changeset/clean-deers-mate.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
25 changes: 23 additions & 2 deletions packages/nextjs/src/server/__tests__/createGetAuth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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();
});
});
2 changes: 1 addition & 1 deletion packages/nextjs/src/server/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/nextjs/src/server/createGetAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/server/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`;
32 changes: 30 additions & 2 deletions packages/nextjs/src/server/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -108,7 +109,12 @@ export const injectSSRStateIntoObject = <O, T>(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) {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
}

0 comments on commit ecb60da

Please sign in to comment.