diff --git a/.changeset/dull-ants-argue.md b/.changeset/dull-ants-argue.md new file mode 100644 index 00000000000..5a65fd09620 --- /dev/null +++ b/.changeset/dull-ants-argue.md @@ -0,0 +1,14 @@ +--- +'@clerk/backend': major +--- + +Change return value of `verifyToken()` from `@clerk/backend` to `{ data, error}`. +To replicate the current behaviour use this: +```typescript +import { verifyToken } from '@clerk/backend' + +const { data, error } = await verifyToken(...); +if(error){ + throw error; +} +``` \ No newline at end of file diff --git a/.changeset/mighty-rice-marry.md b/.changeset/mighty-rice-marry.md new file mode 100644 index 00000000000..b9f461e5f62 --- /dev/null +++ b/.changeset/mighty-rice-marry.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': minor +--- + +Introduce new `ResultWithError` type in `@clerk/types` diff --git a/.changeset/proud-trees-yell.md b/.changeset/proud-trees-yell.md new file mode 100644 index 00000000000..84af419ccfd --- /dev/null +++ b/.changeset/proud-trees-yell.md @@ -0,0 +1,23 @@ +--- +'@clerk/backend': major +'@clerk/nextjs': major +'@clerk/types': major +--- + +Change return values of `signJwt`, `hasValidSignature`, `decodeJwt`, `verifyJwt` +to return `{ data, error }`. Example of keeping the same behavior using those utilities: +```typescript +import { signJwt, hasValidSignature, decodeJwt, verifyJwt } from '@clerk/backend/jwt'; + +const { data, error } = await signJwt(...) +if (error) throw error; + +const { data, error } = await hasValidSignature(...) +if (error) throw error; + +const { data, error } = decodeJwt(...) +if (error) throw error; + +const { data, error } = await verifyJwt(...) +if (error) throw error; +``` \ No newline at end of file diff --git a/packages/backend/README.md b/packages/backend/README.md index b8f7f821a5d..7e31d3d0ab0 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -103,7 +103,7 @@ Verifies a Clerk generated JWT (i.e. Clerk Session JWT and Clerk JWT templates). ```js import { verifyToken } from '@clerk/backend'; -verifyToken(token, { +const { result, error } = await verifyToken(token, { issuer: '...', authorizedParties: '...', }); @@ -114,11 +114,10 @@ verifyToken(token, { Verifies a Clerk generated JWT (i.e. Clerk Session JWT and Clerk JWT templates). The key needs to be provided in the options. ```js -import { verifyJwt } from '@clerk/backend'; +import { verifyJwt } from '@clerk/backend/jwt'; -verifyJwt(token, { +const { result, error } = verifyJwt(token, { key: JsonWebKey | string, - issuer: '...', authorizedParties: '...', }); ``` @@ -128,9 +127,9 @@ verifyJwt(token, { Decodes a JWT. ```js -import { decodeJwt } from '@clerk/backend'; +import { decodeJwt } from '@clerk/backend/jwt'; -decodeJwt(token); +const { result, error } = decodeJwt(token); ``` #### hasValidSignature(jwt: Jwt, key: JsonWebKey | string) @@ -138,9 +137,9 @@ decodeJwt(token); Verifies that the JWT has a valid signature. The key needs to be provided. ```js -import { hasValidSignature } from '@clerk/backend'; +import { hasValidSignature } from '@clerk/backend/jwt'; -hasValidSignature(token, jwk); +const { result, error } = await hasValidSignature(token, jwk); ``` #### debugRequestState(requestState) @@ -148,7 +147,7 @@ hasValidSignature(token, jwk); Generates a debug payload for the request state ```js -import { debugRequestState } from '@clerk/backend'; +import { debugRequestState } from '@clerk/backend/internal'; debugRequestState(requestState); ``` @@ -158,7 +157,7 @@ debugRequestState(requestState); Builds the AuthObject when the user is signed in. ```js -import { signedInAuthObject } from '@clerk/backend'; +import { signedInAuthObject } from '@clerk/backend/internal'; signedInAuthObject(jwtPayload, options); ``` @@ -168,7 +167,7 @@ signedInAuthObject(jwtPayload, options); Builds the empty AuthObject when the user is signed out. ```js -import { signedOutAuthObject } from '@clerk/backend'; +import { signedOutAuthObject } from '@clerk/backend/internal'; signedOutAuthObject(); ``` @@ -178,7 +177,7 @@ signedOutAuthObject(); Removes sensitive private metadata from user and organization resources in the AuthObject ```js -import { sanitizeAuthObject } from '@clerk/backend'; +import { sanitizeAuthObject } from '@clerk/backend/internal'; sanitizeAuthObject(authObject); ``` @@ -188,7 +187,7 @@ sanitizeAuthObject(authObject); Removes any `private_metadata` and `privateMetadata` attributes from the object to avoid leaking sensitive information to the browser during SSR. ```js -import { prunePrivateMetadata } from '@clerk/backend'; +import { prunePrivateMetadata } from '@clerk/backend/internal'; prunePrivateMetadata(obj); ``` diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 44aaa2935d7..b76db3f0f66 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -18,6 +18,7 @@ export default (QUnit: QUnit) => { module('subpath /errors exports', () => { test('should not include a breaking change', assert => { const exportedApiKeys = [ + 'SignJWTError', 'TokenVerificationError', 'TokenVerificationErrorAction', 'TokenVerificationErrorCode', diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 15a7580f465..32bf6fe170c 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -60,3 +60,5 @@ export class TokenVerificationError extends Error { })`; } } + +export class SignJWTError extends Error {} diff --git a/packages/backend/src/jwt/__tests__/signJwt.test.ts b/packages/backend/src/jwt/__tests__/signJwt.test.ts index 22fa071cd3b..35e31e4879a 100644 --- a/packages/backend/src/jwt/__tests__/signJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/signJwt.test.ts @@ -9,6 +9,7 @@ import { publicJwks, signingJwks, } from '../../fixtures'; +import { assertOk } from '../../util/testUtils'; import { signJwt } from '../signJwt'; import { verifyJwt } from '../verifyJwt'; @@ -26,22 +27,24 @@ export default (QUnit: QUnit) => { }); test('signs a JWT with a JWK formatted secret', async assert => { - const jwt = await signJwt(payload, signingJwks, { + const { data } = await signJwt(payload, signingJwks, { algorithm: mockJwtHeader.alg, header: mockJwtHeader, }); + assertOk(assert, data); - const verifiedPayload = await verifyJwt(jwt, { key: publicJwks }); + const { data: verifiedPayload } = await verifyJwt(data, { key: publicJwks }); assert.deepEqual(verifiedPayload, payload); }); test('signs a JWT with a pkcs8 formatted secret', async assert => { - const jwt = await signJwt(payload, pemEncodedSignKey, { + const { data } = await signJwt(payload, pemEncodedSignKey, { algorithm: mockJwtHeader.alg, header: mockJwtHeader, }); + assertOk(assert, data); - const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey }); + const { data: verifiedPayload } = await verifyJwt(data, { key: pemEncodedPublicKey }); assert.deepEqual(verifiedPayload, payload); }); }); diff --git a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts index 39c9e9007f2..29b36e7c43e 100644 --- a/packages/backend/src/jwt/__tests__/verifyJwt.test.ts +++ b/packages/backend/src/jwt/__tests__/verifyJwt.test.ts @@ -1,3 +1,4 @@ +import type { Jwt } from '@clerk/types'; import type QUnit from 'qunit'; import sinon from 'sinon'; @@ -11,66 +12,72 @@ import { signedJwt, someOtherPublicKey, } from '../../fixtures'; +import { assertOk } from '../../util/testUtils'; import { decodeJwt, hasValidSignature, verifyJwt } from '../verifyJwt'; export default (QUnit: QUnit) => { const { module, test } = QUnit; + const invalidTokenError = { + reason: 'token-invalid', + message: 'Invalid JWT form. A JWT consists of three parts separated by dots.', + }; module('hasValidSignature(jwt, key)', () => { test('verifies the signature with a JWK formatted key', async assert => { - assert.true(await hasValidSignature(decodeJwt(signedJwt), publicJwks)); + const { data: decodedResult } = decodeJwt(signedJwt); + assertOk(assert, decodedResult); + const { data: signatureResult } = await hasValidSignature(decodedResult, publicJwks); + assert.true(signatureResult); }); test('verifies the signature with a PEM formatted key', async assert => { - assert.true(await hasValidSignature(decodeJwt(signedJwt), pemEncodedPublicKey)); + const { data: decodedResult } = decodeJwt(signedJwt); + assertOk(assert, decodedResult); + const { data: signatureResult } = await hasValidSignature(decodedResult, pemEncodedPublicKey); + assert.true(signatureResult); }); test('it returns false if the key is not correct', async assert => { - assert.false(await hasValidSignature(decodeJwt(signedJwt), someOtherPublicKey)); + const { data: decodedResult } = decodeJwt(signedJwt); + assertOk(assert, decodedResult); + const { data: signatureResult } = await hasValidSignature(decodedResult, someOtherPublicKey); + assert.false(signatureResult); }); }); module('decodeJwt(jwt)', () => { test('decodes a valid JWT', assert => { - const { header, payload } = decodeJwt(mockJwt); - assert.propEqual(header, mockJwtHeader); - assert.propEqual(payload, mockJwtPayload); - // TODO: @dimkl assert signature is instance of Uint8Array + const { data } = decodeJwt(mockJwt); + assertOk(assert, data); + + assert.propEqual(data.header, mockJwtHeader); + assert.propEqual(data.payload, mockJwtPayload); + // TODO(@dimkl): assert signature is instance of Uint8Array }); - test('throws an error if null is given as jwt', assert => { - assert.throws( - () => decodeJwt('null'), - new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'), - ); + test('returns an error if null is given as jwt', assert => { + const { error } = decodeJwt('null'); + assert.propContains(error, invalidTokenError); }); - test('throws an error if undefined is given as jwt', assert => { - assert.throws( - () => decodeJwt('undefined'), - new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'), - ); + test('returns an error if undefined is given as jwt', assert => { + const { error } = decodeJwt('undefined'); + assert.propContains(error, invalidTokenError); }); - test('throws an error if empty string is given as jwt', assert => { - assert.throws( - () => decodeJwt('undefined'), - new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'), - ); + test('returns an error if empty string is given as jwt', assert => { + const { error } = decodeJwt(''); + assert.propContains(error, invalidTokenError); }); test('throws an error if invalid string is given as jwt', assert => { - assert.throws( - () => decodeJwt('undefined'), - new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'), - ); + const { error } = decodeJwt('whatever'); + assert.propContains(error, invalidTokenError); }); test('throws an error if number is given as jwt', assert => { - assert.throws( - () => decodeJwt('42'), - new Error('Invalid JWT form. A JWT consists of three parts separated by dots.'), - ); + const { error } = decodeJwt('42'); + assert.propContains(error, invalidTokenError); }); }); @@ -90,8 +97,8 @@ export default (QUnit: QUnit) => { issuer: mockJwtPayload.iss, authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], }; - const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions); - assert.propEqual(payload, mockJwtPayload); + const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + assert.propEqual(data, mockJwtPayload); }); test('returns the valid JWT payload if valid key & issuer method & azp is given', async assert => { @@ -100,8 +107,8 @@ export default (QUnit: QUnit) => { issuer: (iss: string) => iss.startsWith('https://clerk'), authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], }; - const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions); - assert.propEqual(payload, mockJwtPayload); + const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + assert.propEqual(data, mockJwtPayload); }); test('returns the valid JWT payload if valid key & issuer & list of azp (with empty string) is given', async assert => { @@ -110,12 +117,18 @@ export default (QUnit: QUnit) => { issuer: mockJwtPayload.iss, authorizedParties: ['', 'https://accounts.inspired.puma-74.lcl.dev'], }; - const payload = await verifyJwt(mockJwt, inputVerifyJwtOptions); - assert.propEqual(payload, mockJwtPayload); + const { data } = await verifyJwt(mockJwt, inputVerifyJwtOptions); + assert.propEqual(data, mockJwtPayload); }); - // todo('returns the reason of the failure when verifications fail', assert => { - // assert.true(true); - // }); + test('returns the reason of the failure when verifications fail', async assert => { + const inputVerifyJwtOptions = { + key: mockJwks.keys[0], + issuer: mockJwtPayload.iss, + authorizedParties: ['', 'https://accounts.inspired.puma-74.lcl.dev'], + }; + const { error } = await verifyJwt('invalid-jwt', inputVerifyJwtOptions); + assert.propContains(error, invalidTokenError); + }); }); }; diff --git a/packages/backend/src/jwt/signJwt.ts b/packages/backend/src/jwt/signJwt.ts index 5f3d2f8ef44..ada988a077b 100644 --- a/packages/backend/src/jwt/signJwt.ts +++ b/packages/backend/src/jwt/signJwt.ts @@ -1,7 +1,9 @@ +import { SignJWTError } from '../errors'; import runtime from '../runtime'; import { base64url } from '../util/rfc4648'; import { getCryptoAlgorithm } from './algorithms'; import { importKey } from './cryptoKeys'; +import type { JwtReturnType } from './types'; export interface SignJwtOptions { algorithm?: string; @@ -32,7 +34,7 @@ export async function signJwt( payload: Record, key: string | JsonWebKey, options: SignJwtOptions, -): Promise { +): Promise> { if (!options.algorithm) { throw new Error('No algorithm specified'); } @@ -53,7 +55,11 @@ export async function signJwt( const encodedPayload = encodeJwtData(payload); const firstPart = `${encodedHeader}.${encodedPayload}`; - const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart)); - - return `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`; + try { + const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart)); + const encodedSignature = `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`; + return { data: encodedSignature }; + } catch (error) { + return { error: new SignJWTError((error as Error)?.message) }; + } } diff --git a/packages/backend/src/jwt/types.ts b/packages/backend/src/jwt/types.ts new file mode 100644 index 00000000000..f0b34b58dc1 --- /dev/null +++ b/packages/backend/src/jwt/types.ts @@ -0,0 +1,9 @@ +export type JwtReturnType = + | { + data: R; + error?: undefined; + } + | { + data?: undefined; + error: E; + }; diff --git a/packages/backend/src/jwt/verifyJwt.ts b/packages/backend/src/jwt/verifyJwt.ts index 8e0dc3a81bb..acd48b40076 100644 --- a/packages/backend/src/jwt/verifyJwt.ts +++ b/packages/backend/src/jwt/verifyJwt.ts @@ -17,27 +17,40 @@ import { assertSubClaim, } from './assertions'; import { importKey } from './cryptoKeys'; +import type { JwtReturnType } from './types'; const DEFAULT_CLOCK_SKEW_IN_SECONDS = 5 * 1000; -export async function hasValidSignature(jwt: Jwt, key: JsonWebKey | string) { +export async function hasValidSignature(jwt: Jwt, key: JsonWebKey | string): Promise> { const { header, signature, raw } = jwt; const encoder = new TextEncoder(); const data = encoder.encode([raw.header, raw.payload].join('.')); const algorithm = getCryptoAlgorithm(header.alg); - const cryptoKey = await importKey(key, algorithm, 'verify'); - - return runtime.crypto.subtle.verify(algorithm.name, cryptoKey, signature, data); + try { + const cryptoKey = await importKey(key, algorithm, 'verify'); + + const verified = await runtime.crypto.subtle.verify(algorithm.name, cryptoKey, signature, data); + return { data: verified }; + } catch (error) { + return { + error: new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalidSignature, + message: (error as Error)?.message, + }), + }; + } } -export function decodeJwt(token: string): Jwt { +export function decodeJwt(token: string): JwtReturnType { const tokenParts = (token || '').toString().split('.'); if (tokenParts.length !== 3) { - throw new TokenVerificationError({ - reason: TokenVerificationErrorReason.TokenInvalid, - message: `Invalid JWT form. A JWT consists of three parts separated by dots.`, - }); + return { + error: new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalid, + message: `Invalid JWT form. A JWT consists of three parts separated by dots.`, + }), + }; } const [rawHeader, rawPayload, rawSignature] = tokenParts; @@ -63,7 +76,7 @@ export function decodeJwt(token: string): Jwt { const payload = JSON.parse(decoder.decode(base64url.parse(rawPayload, { loose: true }))); const signature = base64url.parse(rawSignature, { loose: true }); - return { + const data = { header, payload, signature, @@ -73,7 +86,9 @@ export function decodeJwt(token: string): Jwt { signature: rawSignature, text: token, }, - }; + } satisfies Jwt; + + return { data }; } export type VerifyJwtOptions = { @@ -86,47 +101,54 @@ export type VerifyJwtOptions = { export async function verifyJwt( token: string, { audience, authorizedParties, clockSkewInMs, key }: VerifyJwtOptions, -): Promise { +): Promise> { const clockSkew = clockSkewInMs || DEFAULT_CLOCK_SKEW_IN_SECONDS; - const decoded = decodeJwt(token); + const { data: decoded, error } = decodeJwt(token); + if (error) { + return { error }; + } const { header, payload } = decoded; + try { + // Header verifications + const { typ, alg } = header; - // Header verifications - const { typ, alg } = header; - - assertHeaderType(typ); - assertHeaderAlgorithm(alg); - - // Payload verifications - const { azp, sub, aud, iat, exp, nbf } = payload; - - assertSubClaim(sub); - assertAudienceClaim([aud], [audience]); - assertAuthorizedPartiesClaim(azp, authorizedParties); - assertExpirationClaim(exp, clockSkew); - assertActivationClaim(nbf, clockSkew); - assertIssuedAtClaim(iat, clockSkew); + assertHeaderType(typ); + assertHeaderAlgorithm(alg); - let signatureValid: boolean; + // Payload verifications + const { azp, sub, aud, iat, exp, nbf } = payload; - try { - signatureValid = await hasValidSignature(decoded, key); + assertSubClaim(sub); + assertAudienceClaim([aud], [audience]); + assertAuthorizedPartiesClaim(azp, authorizedParties); + assertExpirationClaim(exp, clockSkew); + assertActivationClaim(nbf, clockSkew); + assertIssuedAtClaim(iat, clockSkew); } catch (err) { - throw new TokenVerificationError({ - action: TokenVerificationErrorAction.EnsureClerkJWT, - reason: TokenVerificationErrorReason.TokenVerificationFailed, - message: `Error verifying JWT signature. ${err}`, - }); + return { error: err as TokenVerificationError }; + } + + const { data: signatureValid, error: signatureError } = await hasValidSignature(decoded, key); + if (signatureError) { + return { + error: new TokenVerificationError({ + action: TokenVerificationErrorAction.EnsureClerkJWT, + reason: TokenVerificationErrorReason.TokenVerificationFailed, + message: `Error verifying JWT signature. ${signatureError}`, + }), + }; } if (!signatureValid) { - throw new TokenVerificationError({ - reason: TokenVerificationErrorReason.TokenInvalidSignature, - message: 'JWT signature is invalid.', - }); + return { + error: new TokenVerificationError({ + reason: TokenVerificationErrorReason.TokenInvalidSignature, + message: 'JWT signature is invalid.', + }), + }; } - return payload; + return { data: payload }; } diff --git a/packages/backend/src/tokens/__tests__/verify.test.ts b/packages/backend/src/tokens/__tests__/verify.test.ts index bd32177b7c9..9034facec74 100644 --- a/packages/backend/src/tokens/__tests__/verify.test.ts +++ b/packages/backend/src/tokens/__tests__/verify.test.ts @@ -26,19 +26,19 @@ export default (QUnit: QUnit) => { }); test('verifies the provided session JWT', async assert => { - const payload = await verifyToken(mockJwt, { + const { data } = await verifyToken(mockJwt, { apiUrl: 'https://api.clerk.test', secretKey: 'a-valid-key', authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], skipJwksCache: true, }); - assert.propEqual(payload, mockJwtPayload); + assert.propEqual(data, mockJwtPayload); assert.ok(fakeFetch.calledOnce); }); test('verifies the token by fetching the JWKs from Backend API when secretKey is provided ', async assert => { - const payload = await verifyToken(mockJwt, { + const { data } = await verifyToken(mockJwt, { secretKey: 'a-valid-key', authorizedParties: ['https://accounts.inspired.puma-74.lcl.dev'], skipJwksCache: true, @@ -52,7 +52,7 @@ export default (QUnit: QUnit) => { 'Clerk-Backend-SDK': '@clerk/backend', }, }); - assert.propEqual(payload, mockJwtPayload); + assert.propEqual(data, mockJwtPayload); }); }); }; diff --git a/packages/backend/src/tokens/handshake.ts b/packages/backend/src/tokens/handshake.ts index 1dfd8b41bb9..4689f5ac64c 100644 --- a/packages/backend/src/tokens/handshake.ts +++ b/packages/backend/src/tokens/handshake.ts @@ -6,7 +6,10 @@ import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; import type { VerifyTokenOptions } from './verify'; async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Promise<{ handshake: string[] }> { - const decoded = decodeJwt(token); + const { data: decoded, error } = decodeJwt(token); + if (error) { + throw error; + } const { header, payload } = decoded; @@ -16,14 +19,11 @@ async function verifyHandshakeJwt(token: string, { key }: VerifyJwtOptions): Pro assertHeaderType(typ); assertHeaderAlgorithm(alg); - let signatureValid: boolean; - - try { - signatureValid = await hasValidSignature(decoded, key); - } catch (err) { + const { data: signatureValid, error: signatureError } = await hasValidSignature(decoded, key); + if (signatureError) { throw new TokenVerificationError({ reason: TokenVerificationErrorReason.TokenVerificationFailed, - message: `Error verifying handshake token. ${err}`, + message: `Error verifying handshake token. ${signatureError}`, }); } @@ -46,8 +46,12 @@ export async function verifyHandshakeToken( ): Promise<{ handshake: string[] }> { const { secretKey, apiUrl, apiVersion, jwksCacheTtlInMs, jwtKey, skipJwksCache } = options; - const { header } = decodeJwt(token); - const { kid } = header; + const { data, error } = decodeJwt(token); + if (error) { + throw error; + } + + const { kid } = data.header; let key; diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 507261ecfa7..f0df811a617 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -1,5 +1,4 @@ import { parsePublishableKey } from '@clerk/shared/keys'; -import type { JwtPayload } from '@clerk/types'; import { constants } from '../constants'; import type { TokenCarrier } from '../errors'; @@ -60,7 +59,7 @@ function assertSignInUrlFormatAndOrigin(_signInUrl: string, origin: string) { function isRequestEligibleForHandshake(authenticateContext: { secFetchDest?: string; accept?: string }) { const { accept, secFetchDest } = authenticateContext; - // NOTE: we could also check sec-fetch-mode === navigate here, but according to the spec, sec-fetch-dest: document should indicate that the request is the result of a user navigation. + // NOTE: we could also check sec-fetch-mode === navigate here, but according to the spec, sec-fetch-dest: document should indicate that the request is the data of a user navigation. if (secFetchDest === 'document') { return true; } @@ -145,37 +144,41 @@ export async function authenticateRequest( return signedOut(authenticateContext, AuthErrorReason.SessionTokenMissing, '', headers); } - let verifyResult: JwtPayload; + const { data, error } = await verifyToken(sessionToken, authenticateContext); + if (data) { + return signedIn(authenticateContext, data, headers); + } - try { - verifyResult = await verifyToken(sessionToken, authenticateContext); - } catch (err) { - if ( - err instanceof TokenVerificationError && - instanceType === 'development' && - (err.reason === TokenVerificationErrorReason.TokenExpired || - err.reason === TokenVerificationErrorReason.TokenNotActiveYet) - ) { - err.tokenCarrier = 'cookie'; - // This probably means we're dealing with clock skew - console.error( - `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. + if ( + instanceType === 'development' && + (error.reason === TokenVerificationErrorReason.TokenExpired || + error.reason === TokenVerificationErrorReason.TokenNotActiveYet) + ) { + error.tokenCarrier = 'cookie'; + // This probably means we're dealing with clock skew + console.error( + `Clerk: Clock skew detected. This usually means that your system clock is inaccurate. Clerk will attempt to account for the clock skew in development. To resolve this issue, make sure your system's clock is set to the correct time (e.g. turn off and on automatic time synchronization). --- -${err.getFullMessage()}`, - ); +${error.getFullMessage()}`, + ); - // Retry with a generous clock skew allowance (1 day) - verifyResult = await verifyToken(sessionToken, { ...authenticateContext, clockSkewInMs: 86_400_000 }); - } else { - throw err; + // Retry with a generous clock skew allowance (1 day) + const { data: retryResult, error: retryError } = await verifyToken(sessionToken, { + ...authenticateContext, + clockSkewInMs: 86_400_000, + }); + if (retryResult) { + return signedIn(authenticateContext, retryResult, headers); } + + throw retryError; } - return signedIn(authenticateContext, verifyResult!, headers); + throw error; } function handleMaybeHandshakeStatus( @@ -204,8 +207,12 @@ ${err.getFullMessage()}`, const { sessionTokenInHeader } = authenticateContext; try { - const verifyResult = await verifyToken(sessionTokenInHeader!, authenticateContext); - return await signedIn(options, verifyResult); + const { data, error } = await verifyToken(sessionTokenInHeader!, authenticateContext); + if (error) { + throw error; + } + // use `await` to force this try/catch handle the signedIn invocation + return await signedIn(options, data); } catch (err) { return handleError(err, 'header'); } @@ -294,17 +301,22 @@ ${err.getFullMessage()}`, return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.ClientUATWithoutSessionToken, ''); } - const decodeResult = decodeJwt(sessionToken!); + const { data: decodeResult, error: decodedError } = decodeJwt(sessionToken!); + if (decodedError) { + return handleError(decodedError, 'cookie'); + } if (decodeResult.payload.iat < clientUat) { return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SessionTokenOutdated, ''); } try { - const verifyResult = await verifyToken(sessionToken!, authenticateContext); - if (verifyResult) { - return signedIn(authenticateContext, verifyResult); + const { data, error } = await verifyToken(sessionToken!, authenticateContext); + if (error) { + throw error; } + // use `await` to force this try/catch handle the signedIn invocation + return await signedIn(authenticateContext, data); } catch (err) { return handleError(err, 'cookie'); } diff --git a/packages/backend/src/tokens/verify.ts b/packages/backend/src/tokens/verify.ts index 90d1d716cc0..7d76a0a2212 100644 --- a/packages/backend/src/tokens/verify.ts +++ b/packages/backend/src/tokens/verify.ts @@ -3,6 +3,7 @@ import type { JwtPayload } from '@clerk/types'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; import type { VerifyJwtOptions } from '../jwt'; import { decodeJwt, verifyJwt } from '../jwt'; +import type { JwtReturnType } from '../jwt/types'; import type { LoadClerkJWKFromRemoteOptions } from './keys'; import { loadClerkJWKFromLocal, loadClerkJWKFromRemote } from './keys'; @@ -13,7 +14,10 @@ export type VerifyTokenOptions = Pick; -export async function verifyToken(token: string, options: VerifyTokenOptions): Promise { +export async function verifyToken( + token: string, + options: VerifyTokenOptions, +): Promise> { const { secretKey, apiUrl, @@ -26,28 +30,39 @@ export async function verifyToken(token: string, options: VerifyTokenOptions): P skipJwksCache, } = options; - const { header } = decodeJwt(token); + const { data: decodedResult, error: decodedError } = decodeJwt(token); + if (decodedError) { + return { error: decodedError }; + } + + const { header } = decodedResult; const { kid } = header; - let key; - - if (jwtKey) { - key = loadClerkJWKFromLocal(jwtKey); - } else if (secretKey) { - // Fetch JWKS from Backend API using the key - key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); - } else { - throw new TokenVerificationError({ - action: TokenVerificationErrorAction.SetClerkJWTKey, - message: 'Failed to resolve JWK during verification.', - reason: TokenVerificationErrorReason.JWKFailedToResolve, + try { + let key; + + if (jwtKey) { + key = loadClerkJWKFromLocal(jwtKey); + } else if (secretKey) { + // Fetch JWKS from Backend API using the key + key = await loadClerkJWKFromRemote({ secretKey, apiUrl, apiVersion, kid, jwksCacheTtlInMs, skipJwksCache }); + } else { + return { + error: new TokenVerificationError({ + action: TokenVerificationErrorAction.SetClerkJWTKey, + message: 'Failed to resolve JWK during verification.', + reason: TokenVerificationErrorReason.JWKFailedToResolve, + }), + }; + } + + return await verifyJwt(token, { + audience, + authorizedParties, + clockSkewInMs, + key, }); + } catch (error) { + return { error: error as TokenVerificationError }; } - - return await verifyJwt(token, { - audience, - authorizedParties, - clockSkewInMs, - key, - }); } diff --git a/packages/backend/src/util/testUtils.ts b/packages/backend/src/util/testUtils.ts index b12f26fcd7a..9dabe5c75e7 100644 --- a/packages/backend/src/util/testUtils.ts +++ b/packages/backend/src/util/testUtils.ts @@ -84,3 +84,9 @@ const mockHeadersGet = (key: string) => { return null; }; + +// used instead of the explicitly invoking assert.ok to avoid +// type casting the data param +export function assertOk(assert: Assert, data: unknown): asserts data is T { + assert.ok(data); +} diff --git a/packages/nextjs/src/server/getAuth.ts b/packages/nextjs/src/server/getAuth.ts index 9f372fde0ce..075c86dc69e 100644 --- a/packages/nextjs/src/server/getAuth.ts +++ b/packages/nextjs/src/server/getAuth.ts @@ -119,5 +119,11 @@ export const buildClerkProps: BuildClerkProps = (req, initState = {}) => { const parseJwt = (req: RequestLike) => { const cookieToken = getCookie(req, constants.Cookies.Session); const headerToken = getHeader(req, 'authorization')?.replace('Bearer ', ''); - return decodeJwt(cookieToken || headerToken || ''); + const { data, error } = decodeJwt(cookieToken || headerToken || ''); + + if (error) { + throw error; + } + + return data; };