diff --git a/libs/@guardian/identity-auth/src/@types/OAuth.ts b/libs/@guardian/identity-auth/src/@types/OAuth.ts index 6491cdc2d..279b907d8 100644 --- a/libs/@guardian/identity-auth/src/@types/OAuth.ts +++ b/libs/@guardian/identity-auth/src/@types/OAuth.ts @@ -135,6 +135,7 @@ export interface OAuthUrls { authorizeUrl: `${IdentityAuthOptions['issuer']}/v1/authorize`; tokenUrl: `${IdentityAuthOptions['issuer']}/v1/token`; keysUrl: `${IdentityAuthOptions['issuer']}/v1/keys`; + userinfoUrl: `${IdentityAuthOptions['issuer']}/v1/userinfo`; } /** diff --git a/libs/@guardian/identity-auth/src/@types/Token.ts b/libs/@guardian/identity-auth/src/@types/Token.ts index 3478d22b1..8bf6de6f7 100644 --- a/libs/@guardian/identity-auth/src/@types/Token.ts +++ b/libs/@guardian/identity-auth/src/@types/Token.ts @@ -85,7 +85,7 @@ export interface JWTHeader { /** * The payload of a JWT. * - * Set up as a partial type so that we can use it for the access token and ID token, as we don't + * Set up as a generic type so that we can use it for the access token and ID token, as we don't * know exactly which claims will be in each, and will be returned from the server, until we * validate and decode the token. */ diff --git a/libs/@guardian/identity-auth/src/lib/token.ts b/libs/@guardian/identity-auth/src/lib/token.ts new file mode 100644 index 000000000..b16d2a542 --- /dev/null +++ b/libs/@guardian/identity-auth/src/lib/token.ts @@ -0,0 +1,468 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition -- we're doing type guards on two different types */ + +import { isNonNullable } from '@guardian/libs'; +import type { + AccessToken, + AccessTokenClaims, + CustomClaims, + IDToken, + IDTokenClaims, + JWK, + JWKS, + JWTHeader, + JWTObject, + JWTPayload, +} from '../@types/Token'; +import { base64UrlToString, stringToBuffer } from '../crypto'; +import { OAuthError } from '../error'; + +/** + * @name isAccessTokenClaims + * @description Type guard for AccessTokenClaims + * + * @param claims - the claims to check + * @returns {boolean} - true if claims is an AccessTokenClaims object + */ +export const isAccessTokenClaims = ( + claims: unknown, +): claims is AccessTokenClaims => { + const maybeClaims = claims as AccessTokenClaims; + return ( + isNonNullable(maybeClaims) && + maybeClaims.aud !== undefined && + maybeClaims.auth_time !== undefined && + maybeClaims.cid !== undefined && + maybeClaims.email_validated !== undefined && + maybeClaims.exp !== undefined && + maybeClaims.iat !== undefined && + maybeClaims.identity_username !== undefined && + maybeClaims.iss !== undefined && + maybeClaims.jti !== undefined && + maybeClaims.legacy_identity_id !== undefined && + maybeClaims.scp !== undefined && + maybeClaims.sub !== undefined && + maybeClaims.uid !== undefined && + maybeClaims.ver !== undefined + ); +}; + +/** + * @name isIDTokenClaims + * @description Type guard for IDTokenClaims + * + * @param claims - the claims to check + * @returns {boolean} - true if claims is an IDTokenClaims object + */ +export const isIDTokenClaims = (claims: unknown): claims is IDTokenClaims => { + const maybeClaims = claims as IDTokenClaims; + return ( + isNonNullable(maybeClaims) && + maybeClaims.amr !== undefined && + maybeClaims.at_hash !== undefined && + maybeClaims.aud !== undefined && + maybeClaims.auth_time !== undefined && + maybeClaims.exp !== undefined && + maybeClaims.iat !== undefined && + maybeClaims.identity_username !== undefined && + maybeClaims.idp !== undefined && + maybeClaims.iss !== undefined && + maybeClaims.jti !== undefined && + maybeClaims.legacy_identity_id !== undefined && + maybeClaims.name !== undefined && + maybeClaims.nonce !== undefined && + maybeClaims.preferred_username !== undefined && + maybeClaims.sub !== undefined && + maybeClaims.user_groups !== undefined && + maybeClaims.ver !== undefined + ); +}; + +/** + * @name isAccessToken + * @description Type guard for AccessToken + * + * @param token - the token to check + * @returns {boolean} - true if token is an AccessToken object + */ +export const isAccessToken = (token: unknown): token is AccessToken => { + const maybeToken = token as AccessToken; + return ( + isNonNullable(maybeToken) && + maybeToken.accessToken !== undefined && + maybeToken.expiresAt !== undefined && + maybeToken.scopes !== undefined && + maybeToken.tokenType !== undefined && + isAccessTokenClaims(maybeToken.claims) + ); +}; + +/** + * @name isIDToken + * @description Type guard for IDToken + * + * @param token - the token to check + * @returns {boolean} - true if token is an IDToken object + */ +export const isIDToken = (token: unknown): token is IDToken => { + const maybeToken = token as IDToken; + return ( + isNonNullable(maybeToken) && + maybeToken.expiresAt !== undefined && + maybeToken.idToken !== undefined && + maybeToken.scopes !== undefined && + isIDTokenClaims(maybeToken.claims) + ); +}; + +/** + * @name decodeToken + * @description Decodes a JWT token into its parts, i.e header, payload and signature + * + * @param token `string` - JWT token + * @returns JWTObject - object containing the decoded token parts + */ +export const decodeToken = ( + token: string, +): JWTObject => { + // attempt to split the token into its parts + const [headerStr, payloadStr, signature] = token.split('.'); + + try { + // check that all parts are present + if ( + headerStr === undefined || + payloadStr === undefined || + signature === undefined + ) { + throw 'Missing token parts'; + } + + const header = JSON.parse(base64UrlToString(headerStr)) as JWTHeader; + const payload = JSON.parse(base64UrlToString(payloadStr)) as JWTPayload; + + // validate the header, all okta oauth tokens have an alg and kid claim + if (!header.alg || !header.kid) { + throw 'Missing algorithm in header'; + } + + // validate the payload, all okta oauth tokens have an jti claim + if (!payload.jti) { + throw 'Possibly malformed payload'; + } + + return { + header, + payload, + signature, + }; + } catch (error) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Malformed token', + message: error as string, + }); + } +}; + +/** + * @name verifySignature + * @description Verifies the signature of a JWT token + * + * Get's the public key from the JWKS endpoint, imports it and verifies the signature + * of a given JWT token + * + * @param issuer + * @param jwt + * @param token + */ +export const verifySignature = async ( + jwksUri: string, + jwt: JWTObject, + token: string, +): Promise => { + // fetch the JWKS, okta returns standard cache-control headers, so caching is handled by the browser + const jwksResponse = await fetch(jwksUri); + + // throw an error if the response isn't ok + if (!jwksResponse.ok) { + throw new OAuthError({ + error: 'failed_request', + error_description: 'Failed to fetch JWKS', + message: 'Failed to fetch JWKS', + }); + } + + // parse the JWKS response + const jwks = (await jwksResponse.json()) as Partial; + + // validate the JWKS, first check that there are keys + if (!jwks.keys || jwks.keys.length === 0) { + throw new OAuthError({ + error: 'invalid_jwks', + error_description: 'No keys found in JWKS', + message: 'No keys found in JWKS', + }); + } + + // next loop through the keys and check that they have the required properties + for (const key of jwks.keys) { + if (!key.kty || !key.n || !key.e || !key.alg || !key.use || !key.kid) { + throw new OAuthError({ + error: 'invalid_jwks', + error_description: 'Invalid key in JWKS', + message: 'Invalid key in JWKS', + }); + } + } + + // find the key that matches the kid in the JWT header + const key = jwks.keys.find((k: JWK) => k.kid === jwt.header.kid); + + // throw an error if no key was found + if (key === undefined) { + throw new OAuthError({ + error: 'invalid_jwks', + error_description: 'No key found for token in JWKS', + message: 'No key found for token in JWKS', + }); + } + + // create the algorithm to use for the verification + const algorithm: HmacImportParams = { + name: 'RSASSA-PKCS1-v1_5', + hash: { name: 'SHAxw-256' }, + }; + + // import the public key using the jwk format + const publicKey = await crypto.subtle.importKey( + 'jwk', + { + kty: key.kty, + n: key.n, + e: key.e, + alg: key.alg, + use: key.use, + }, + algorithm, + true, + ['verify'], + ); + + // split the token into it's parts + const [header, payload, sig] = token.split('.'); + + // throw an error if the token is malformed + if (header === undefined || payload === undefined || sig === undefined) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Malformed token', + message: 'Malformed token', + }); + } + + // create the data to verify, i.e the header and payload + const data = `${header}.${payload}`; + + // verify the signature + const isValid = await crypto.subtle.verify( + algorithm, + publicKey, + stringToBuffer(base64UrlToString(sig)), + stringToBuffer(data), + ); + + // throw an error if the signature is invalid + if (!isValid) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Invalid signature', + message: 'Invalid signature', + }); + } +}; + +interface VerifyIdTokenParams { + claims: JWTPayload; + expectedIssuer: string; + expectedClientId: string; + expectedNonce?: string; + clockSkew?: number; +} + +export const genericVerifyIdTokenClaims = ({ + claims, + expectedIssuer, + expectedClientId, + expectedNonce, + clockSkew = 0, +}: VerifyIdTokenParams): void => { + // get the local time in seconds + const localTime = Math.floor(Date.now() / 1000); + + // calculate the normalised time, which takes into account clock skew + const normalisedTime = localTime - clockSkew; + + if (!isIDTokenClaims(claims)) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token is not an ID token', + message: 'Token is not an ID token', + }); + } + + // aud claim matches clientId + if (claims.aud !== expectedClientId) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token `aud` claim does not match expected `clientId`', + message: 'Token `aud` claim does not match expected `clientId`', + }); + } + + // iss claim matches issuer + if (claims.iss !== expectedIssuer) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token issuer does not match expected issuer', + message: 'Token issuer does not match expected issuer', + }); + } + + // if the token contains a nonce claim, but no nonce was provided, throw an error + if (claims.nonce && !expectedNonce) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token contains nonce but nonce was not provided', + message: 'Token contains nonce but nonce was not provided', + }); + } + + // nonce claim matches expected nonce (if it exists) + if (expectedNonce && claims.nonce !== expectedNonce) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token nonce does not match expected nonce', + message: 'Token nonce does not match expected nonce', + }); + } + + // check that the iat (issued at) claim is present + if (!claims.iat) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Missing iat claim in ID token', + message: 'Token does not contain required claims', + }); + } + + // check that the token wasn't issued in the future + if (claims.iat > normalisedTime) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token issued in the future', + message: 'Token issued in the future', + }); + } +}; + +interface VerifyAccessTokenParams { + claims: JWTPayload; + expectedAudience: string; + expectedIssuer: string; + expectedClientId?: string; + clockSkew?: number; +} + +export const genericVerifyAccessTokenClaims = ({ + claims, + expectedAudience, + expectedIssuer, + expectedClientId, + clockSkew = 0, +}: VerifyAccessTokenParams): void => { + // get the local time in seconds + const localTime = Math.floor(Date.now() / 1000); + + // calculate the normalised time, which takes into account clock skew + const normalisedTime = localTime - clockSkew; + + if (!isAccessTokenClaims(claims)) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token is not an access token', + message: 'Token is not an access token', + }); + } + + // check that the iat (issued at) and exp (expiry) claims are present + if (!claims.iat || !claims.exp) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Missing iat or exp claim in access token', + message: 'Token does not contain required claims', + }); + } + + // check that the iat isn't after the exp + if (claims.iat > claims.exp) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'iat claim is after exp claim in access token', + message: 'Token has expired before it was issued', + }); + } + + // check the token hasn't expired + if (claims.exp < normalisedTime) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token has expired', + message: 'Token has expired', + }); + } + + // check the token wasn't issued in the future + if (claims.iat > normalisedTime) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token issued in the future', + message: 'Token issued in the future', + }); + } + + // check audience claim + if (claims.aud !== expectedAudience) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token audience does not match expected audience', + message: 'Token audience does not match expected audience', + }); + } + + // check issuer claim + if (claims.iss !== expectedIssuer) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token issuer does not match expected issuer', + message: 'Token issuer does not match expected issuer', + }); + } + + // check client ID claim (if it exists) + if (expectedClientId && claims.cid !== expectedClientId) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token client ID does not match expected client ID', + message: 'Token client ID does not match expected client ID', + }); + } + + // check that the token has at least one scope + if (claims.scp.length === 0) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token is missing scopes', + message: 'Token is missing scopes', + }); + } +}; diff --git a/libs/@guardian/identity-auth/src/token.ts b/libs/@guardian/identity-auth/src/token.ts index 1b7987983..203500da4 100644 --- a/libs/@guardian/identity-auth/src/token.ts +++ b/libs/@guardian/identity-auth/src/token.ts @@ -13,124 +13,30 @@ import type { } from './@types/OAuth'; import type { AccessToken, - AccessTokenClaims, CustomClaims, IDToken, - IDTokenClaims, - JWK, - JWKS, - JWTHeader, - JWTObject, JWTPayload, TokenResponse, Tokens, } from './@types/Token'; import { base64UrlEncode, - base64UrlToString, generateCodeChallenge, generateCodeVerifier, generateSha256Hash, getRandomString, - stringToBuffer, } from './crypto'; import { OAuthError } from './error'; - -/** - * @name isAccessTokenClaims - * @description Type guard for AccessTokenClaims - * - * @param claims - the claims to check - * @returns {boolean} - true if claims is an AccessTokenClaims object - */ -const isAccessTokenClaims = (claims: unknown): claims is AccessTokenClaims => { - const maybeClaims = claims as AccessTokenClaims; - return ( - isNonNullable(maybeClaims) && - maybeClaims.aud !== undefined && - maybeClaims.auth_time !== undefined && - maybeClaims.cid !== undefined && - maybeClaims.email_validated !== undefined && - maybeClaims.exp !== undefined && - maybeClaims.iat !== undefined && - maybeClaims.identity_username !== undefined && - maybeClaims.iss !== undefined && - maybeClaims.jti !== undefined && - maybeClaims.legacy_identity_id !== undefined && - maybeClaims.scp !== undefined && - maybeClaims.sub !== undefined && - maybeClaims.uid !== undefined && - maybeClaims.ver !== undefined - ); -}; - -/** - * @name isAccessToken - * @description Type guard for AccessToken - * - * @param token - the token to check - * @returns {boolean} - true if token is an AccessToken object - */ -export const isAccessToken = (token: unknown): token is AccessToken => { - const maybeToken = token as AccessToken; - return ( - isNonNullable(maybeToken) && - maybeToken.accessToken !== undefined && - maybeToken.expiresAt !== undefined && - maybeToken.scopes !== undefined && - maybeToken.tokenType !== undefined && - isAccessTokenClaims(maybeToken.claims) - ); -}; - -/** - * @name isIDTokenClaims - * @description Type guard for IDTokenClaims - * - * @param claims - the claims to check - * @returns {boolean} - true if claims is an IDTokenClaims object - */ -const isIDTokenClaims = (claims: unknown): claims is IDTokenClaims => { - const maybeClaims = claims as IDTokenClaims; - return ( - isNonNullable(maybeClaims) && - maybeClaims.amr !== undefined && - maybeClaims.at_hash !== undefined && - maybeClaims.aud !== undefined && - maybeClaims.auth_time !== undefined && - maybeClaims.exp !== undefined && - maybeClaims.iat !== undefined && - maybeClaims.identity_username !== undefined && - maybeClaims.idp !== undefined && - maybeClaims.iss !== undefined && - maybeClaims.jti !== undefined && - maybeClaims.legacy_identity_id !== undefined && - maybeClaims.name !== undefined && - maybeClaims.nonce !== undefined && - maybeClaims.preferred_username !== undefined && - maybeClaims.sub !== undefined && - maybeClaims.user_groups !== undefined && - maybeClaims.ver !== undefined - ); -}; - -/** - * @name isIDToken - * @description Type guard for IDToken - * - * @param token - the token to check - * @returns {boolean} - true if token is an IDToken object - */ -export const isIDToken = (token: unknown): token is IDToken => { - const maybeToken = token as IDToken; - return ( - isNonNullable(maybeToken) && - maybeToken.expiresAt !== undefined && - maybeToken.idToken !== undefined && - maybeToken.scopes !== undefined && - isIDTokenClaims(maybeToken.claims) - ); -}; +import { + decodeToken, + genericVerifyAccessTokenClaims, + genericVerifyIdTokenClaims, + isAccessToken, + isAccessTokenClaims, + isIDToken, + isIDTokenClaims, + verifySignature, +} from './lib/token'; /** * @name isOAuthAuthorizeResponseError @@ -199,57 +105,7 @@ const calculateClockSkew = (now: number, token: string): number => { return now - iat; }; -/** - * @name decodeToken - * @description Decodes a JWT token into its parts, i.e header, payload and signature - * - * @param token `string` - JWT token - * @returns JWTObject - object containing the decoded token parts - */ -const decodeToken = ( - token: string, -): JWTObject => { - // attempt to split the token into its parts - const [headerStr, payloadStr, signature] = token.split('.'); - - try { - // check that all parts are present - if ( - headerStr === undefined || - payloadStr === undefined || - signature === undefined - ) { - throw 'Missing token parts'; - } - - const header = JSON.parse(base64UrlToString(headerStr)) as JWTHeader; - const payload = JSON.parse(base64UrlToString(payloadStr)) as JWTPayload; - - // validate the header, all okta oauth tokens have an alg and kid claim - if (!header.alg || !header.kid) { - throw 'Missing algorithm in header'; - } - - // validate the payload, all okta oauth tokens have an jti claim - if (!payload.jti) { - throw 'Possibly malformed payload'; - } - - return { - header, - payload, - signature, - }; - } catch (error) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Malformed token', - message: error as string, - }); - } -}; - -interface DecodeTokenParams { +interface DecodeTokensParams { accessTokenRaw: string; accessTokenClockSkew: number; idTokenRaw: string; @@ -278,7 +134,7 @@ const decodeTokens = < idTokenClockSkew, nonce, options, -}: DecodeTokenParams): Tokens => { +}: DecodeTokensParams): Tokens => { // get the current time in seconds const now = Math.floor(Date.now() / 1000); @@ -384,74 +240,6 @@ const decodeTokens = < }; }; -/** - * @name verifyIdTokenClaims - * @description Verifies the claims of an ID token are valid and expected - * - * @param decoded - decoded ID token - * @param claims - ID token claims - * @param options - RequiredIdentityAuthOptions - * - * @throws Error - if the claims are invalid - * @returns void - */ -export const verifyIdTokenClaims = ( - decoded: IDToken, - claims: JWTPayload, - options: RequiredIdentityAuthOptions, -): void => { - // get the local time in seconds - const localTime = Math.floor(Date.now() / 1000); - - // calculate the normalised time, which takes into account clock skew - const normalisedTime = localTime - decoded.clockSkew; - - // check the nonce is valid - if (claims.nonce !== decoded.nonce) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Invalid nonce in ID token', - message: 'Invalid nonce in ID token', - }); - } - - // check the issuer is the one configured in the options - if (claims.iss !== options.issuer) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Invalid issuer in ID token', - message: 'Invalid issuer in ID token', - }); - } - - // check the audience is the one configured in the options (clientId) - if (claims.aud !== options.clientId) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Invalid audience in ID token', - message: 'Invalid audience in ID token', - }); - } - - // check that the iat (issued at) claim is present - if (!claims.iat) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Missing iat claim in ID token', - message: 'Token does not contain required claims', - }); - } - - // check the token wasn't issued in the future - if (claims.iat > normalisedTime) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Token was issued in the future', - message: 'Token was issued in the future', - }); - } -}; - /** * @name verifyAccessTokenWithAtHash * @description Verifies the access token with the id token at_hash claim @@ -488,123 +276,6 @@ export const verifyAccessTokenWithAtHash = async ( return; }; -/** - * @name verifySignature - * @description Verifies the signature of a JWT token - * - * Get's the public key from the JWKS endpoint, imports it and verifies the signature - * of a given JWT token - * - * @param issuer - * @param jwt - * @param token - */ -export const verifySignature = async ( - jwksUri: string, - jwt: JWTObject, - token: string, -): Promise => { - // fetch the JWKS, okta returns standard cache-control headers, so caching is handled by the browser - const jwksResponse = await fetch(jwksUri); - - // throw an error if the response isn't ok - if (!jwksResponse.ok) { - throw new OAuthError({ - error: 'failed_request', - error_description: 'Failed to fetch JWKS', - message: 'Failed to fetch JWKS', - }); - } - - // parse the JWKS response - const jwks = (await jwksResponse.json()) as JWKS; - - // validate the JWKS, first check that there are keys - if (!jwks.keys || jwks.keys.length === 0) { - throw new OAuthError({ - error: 'invalid_jwks', - error_description: 'No keys found in JWKS', - message: 'No keys found in JWKS', - }); - } - - // next loop through the keys and check that they have the required properties - for (const key of jwks.keys) { - if (!key.kty || !key.n || !key.e || !key.alg || !key.use || !key.kid) { - throw new OAuthError({ - error: 'invalid_jwks', - error_description: 'Invalid key in JWKS', - message: 'Invalid key in JWKS', - }); - } - } - - // find the key that matches the kid in the JWT header - const key = jwks.keys.find((k: JWK) => k.kid === jwt.header.kid); - - // throw an error if no key was found - if (key === undefined) { - throw new OAuthError({ - error: 'invalid_jwks', - error_description: 'No key found for token in JWKS', - message: 'No key found for token in JWKS', - }); - } - - // create the algorithm to use for the verification - const algorithm: HmacImportParams = { - name: 'RSASSA-PKCS1-v1_5', - hash: { name: 'SHA-256' }, - }; - - // import the public key using the jwk format - const publicKey = await window.crypto.subtle.importKey( - 'jwk', - { - kty: key.kty, - n: key.n, - e: key.e, - alg: key.alg, - use: key.use, - }, - algorithm, - true, - ['verify'], - ); - - // split the token into it's parts - const [header, payload, sig] = token.split('.'); - - // throw an error if the token is malformed - if (header === undefined || payload === undefined || sig === undefined) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Malformed token', - message: 'Malformed token', - }); - } - - // create the data to verify, i.e the header and payload - const data = `${header}.${payload}`; - - // verify the signature - const isValid = await window.crypto.subtle.verify( - algorithm, - publicKey, - stringToBuffer(base64UrlToString(sig)), - stringToBuffer(data), - ); - - // throw an error if the signature is invalid - if (!isValid) { - throw new OAuthError({ - error: 'invalid_token', - error_description: 'Invalid signature', - message: 'Invalid signature', - }); - } -}; - /** * @name verifyAccessTokenTimestamps * @description Verifies the timestamps of an access token, used to determine if both the access token and ID token are valid @@ -822,7 +493,13 @@ export const verifyTokens = async ( const idTokenJWT = decodeToken(idToken.idToken); // verify the claims - verifyIdTokenClaims(idToken, idTokenJWT.payload, options); + genericVerifyIdTokenClaims({ + claims: idTokenJWT.payload, + expectedIssuer: options.issuer, + expectedClientId: options.clientId, + expectedNonce: idToken.nonce, + clockSkew: idToken.clockSkew, + }); // verify the signature await verifySignature(oauthUrls.keysUrl, idTokenJWT, idToken.idToken); @@ -835,8 +512,14 @@ export const verifyTokens = async ( const accessTokenJWT = decodeToken(accessToken.accessToken); - // verify access token timestamps - verifyAccessTokenTimestamps(accessToken, accessTokenJWT.payload); + // verify access token claims + genericVerifyAccessTokenClaims({ + claims: accessTokenJWT.payload, + expectedAudience: 'todo', + expectedIssuer: options.issuer, + expectedClientId: options.clientId, + clockSkew: accessToken.clockSkew, + }); // if successful, memoize the token memoizedVerifyTokens.set(idToken.claims.jti, true); @@ -1104,7 +787,7 @@ export class Token< idTokenRaw, idTokenClockSkew, nonce, - }: Omit) { + }: Omit) { return decodeTokens({ accessTokenRaw, accessTokenClockSkew, diff --git a/libs/@guardian/identity-auth/src/tokenManager.ts b/libs/@guardian/identity-auth/src/tokenManager.ts index 1df079036..a2399c02c 100644 --- a/libs/@guardian/identity-auth/src/tokenManager.ts +++ b/libs/@guardian/identity-auth/src/tokenManager.ts @@ -9,8 +9,8 @@ import type { TokenType, } from './@types/Token'; import type { Emitter } from './emitter'; +import { isAccessToken, isIDToken } from './lib/token'; import type { Token } from './token'; -import { isAccessToken, isIDToken } from './token'; /** * @class TokenManager diff --git a/libs/@guardian/identity-auth/src/tokenVerifier.ts b/libs/@guardian/identity-auth/src/tokenVerifier.ts new file mode 100644 index 000000000..2491f0d4a --- /dev/null +++ b/libs/@guardian/identity-auth/src/tokenVerifier.ts @@ -0,0 +1,259 @@ +import type { OAuthUrls, ProfileUrl } from './@types/OAuth'; +import type { + AccessTokenClaims, + CustomClaims, + IDTokenClaims, + JWTObject, +} from './@types/Token'; +import { OAuthError } from './error'; +import { + decodeToken, + genericVerifyAccessTokenClaims, + genericVerifyIdTokenClaims, + verifySignature, +} from './lib/token'; + +interface TokenVerifierOptions { + issuer: `${ProfileUrl}/oauth2/${string}`; + audience: string; + clientId?: string; +} + +interface AccessTokenVerifierParams { + accessToken: string; + expectedAudience: string; + expectedScopes?: string[]; +} + +interface IdTokenVerifierParams { + idToken: string; + nonce?: string; +} + +export class TokenVerifier { + #options: TokenVerifierOptions; + #oauthUrls: Pick; + + constructor(options: TokenVerifierOptions) { + this.#options = options; + this.#oauthUrls = { + keysUrl: `${this.#options.issuer}/v1/keys`, + userinfoUrl: `${this.#options.issuer}/v1/userinfo`, + }; + } + + /** + * @name decodeAndVerifyToken + * @description First, attempts to decode the token. Then verifies the signature is valid. + * @param token - The token to decode and verify + * @returns The decoded and verified token + */ + async #decodeAndVerifyToken( + token: string, + ): Promise> { + const decodedToken = decodeToken(token); + await verifySignature(this.#oauthUrls.keysUrl, decodedToken, token); + return decodedToken; + } + + /** + * @name verifyAccessToken + * @description Verify an OAuth access token + * For any access token to be valid, the following are asserted: + * - Signature is valid (the token was signed by a private key which has a corresponding public key in the JWKS response from the authorization server). + * - Access token is not expired (requires local system time to be in sync with Okta, checks the exp claim of the access token). + * - The aud claim matches any expected aud claim passed to verifyAccessToken(). + * - The iss claim matches the issuer the verifier is constructed with. + * - The cid claim matches the client ID the verifier is constructed with. + * - The token has all the required scopes (if any are passed to verifyAccessToken()). + * - If secure scopes are passed in, a server-side check is performed. + * - Any custom claim assertions that you add are confirmed + * @param accessToken - The access token to verify + * @param expectedAudience - The expected aud claim of the access token + * @param expectedScopes - The expected scopes of the access token + * @returns The decoded and verified access token + */ + public async verifyAccessToken({ + accessToken, + expectedAudience, + expectedScopes, + }: AccessTokenVerifierParams): Promise>> { + const decodedToken = + await this.#decodeAndVerifyToken>(accessToken); + + genericVerifyAccessTokenClaims({ + claims: decodedToken.payload, + expectedAudience, + expectedIssuer: this.#options.issuer, + expectedClientId: this.#options.clientId, + }); + + // Check that all scopes in scopes array (if it exists) are present in the token + if (expectedScopes && expectedScopes.length > 0) { + const everyScopeExists = expectedScopes.every((expectedScope) => + decodedToken.payload.scp.includes(expectedScope), + ); + + if (!everyScopeExists) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Token is missing required scopes', + message: 'Token is missing required scopes', + }); + } + + // If any of the scopes passed in end with '.secure', perform the serverside check + // using the userinfo endpoint from the options + const hasSecureScopes = expectedScopes.some((expectedScope) => + expectedScope.endsWith('.secure'), + ); + + if (hasSecureScopes) { + try { + const userInfoResponse = await fetch(this.#oauthUrls.userinfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!userInfoResponse.ok) { + const wwwAuthenticateHeader = + userInfoResponse.headers.get('WWW-Authenticate'); + const errorMatch = + wwwAuthenticateHeader?.match(/error="(.*?)"/)?.[0]; + const errorDescriptionMatch = wwwAuthenticateHeader?.match( + /error_description="(.*?)"/, + )?.[0]; + throw new OAuthError({ + error: errorMatch ?? 'invalid_token', + error_description: + errorDescriptionMatch ?? 'Unexpected error validating token', + message: + errorDescriptionMatch ?? 'Unexpected error validating token', + }); + } + } catch (error) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Unexpected error validating token', + message: 'Unexpected error validating token', + }); + } + } + } + + return decodedToken; + } + + /** + * @name verifyIdToken + * @description Verify an OAuth ID token + * For any ID token to be valid, the following are asserted: + * - Signature is valid (the token was signed by a private key which has a corresponding public key in the JWKS response from the authorization server). + * - ID token is not expired (requires local system time to be in sync with Okta, checks the exp claim of the ID token). + * - The aud claim matches the client ID the verifier is constructed with. + * - The iss claim matches the issuer the verifier is constructed with. + * - The nonce claim matches the nonce passed to verifyIdToken(). + * @param idToken - The ID token to verify + * @param nonce - The nonce to verify + * @returns The decoded and verified ID token + */ + public async verifyIdToken({ + idToken, + nonce, + }: IdTokenVerifierParams): Promise>> { + if (!this.#options.clientId) { + throw new OAuthError({ + error: 'invalid_token', + error_description: 'Client ID is required to verify ID token', + message: 'Client ID is required to verify ID token', + }); + } + + const decodedToken = + await this.#decodeAndVerifyToken>(idToken); + + genericVerifyIdTokenClaims({ + claims: decodedToken.payload, + expectedNonce: nonce, + expectedClientId: this.#options.clientId, + expectedIssuer: this.#options.issuer, + }); + + return decodedToken; + } + + /** + * @name accessTokenIssuedAfterTime + * @description Check if the access token was issued after a given time. + * This is useful for checking if the access token was issued after the + * user's last sign-out time. + * @param accessToken - The access token to check, as a raw JWT string + * @param timestamp - The time to check against, as a Unix timestamp in seconds + * @returns Whether the access token was issued after the given time + */ + public async accessTokenIssuedAfterTime({ + accessToken, + timestamp, + }: { + accessToken: string; + timestamp: number; + }): Promise { + // get the local time in seconds + const localTime = Math.floor(Date.now() / 1000); + + if (!timestamp || typeof timestamp !== 'number') { + throw new Error('Timestamp must be a number'); + } + const decodedToken = await this.#decodeAndVerifyToken(accessToken); + const iat = decodedToken.payload.iat; + if (!iat || typeof iat !== 'number') { + throw new Error('iat claim must be a number'); + } + if (timestamp >= localTime) { + throw new Error('Timestamp must be in the past'); + } + return iat > timestamp; + } + + /** + * @name idTokenAuthTimeAfterTime + * @description Check if the auth_time claim of the ID token is + * after a given time. The auth_time claim is defined in the OIDC + * spec as "Time when the End-User authentication occurred", and + * should be present in the ID token if the authentication was + * performed with the "max_age" parameter. For this reason, we also + * check that the auth_time claim is present at all. + * @param idToken - The ID token to check, as a raw JWT string + * @param timestamp - The time to check against, as a Unix timestamp in seconds + * @returns Whether the auth_time claim of the ID token is after the given time + */ + public async idTokenAuthTimeAfterTime({ + idToken, + timestamp, + }: { + idToken: string; + timestamp: number; + }): Promise { + // get the local time in seconds + const localTime = Math.floor(Date.now() / 1000); + + if (!timestamp || typeof timestamp !== 'number') { + throw new Error('Timestamp must be a number'); + } + const decodedToken = await this.#decodeAndVerifyToken(idToken); + const authTime = decodedToken.payload.auth_time; + if ( + !authTime || + typeof authTime !== 'number' || + authTime < 0 || + authTime > localTime + ) { + throw new Error('auth_time claim must be set and valid'); + } + if (timestamp >= localTime) { + throw new Error('Timestamp must be in the past'); + } + return authTime > timestamp; + } +} diff --git a/libs/@guardian/identity-auth/tsconfig.spec.json b/libs/@guardian/identity-auth/tsconfig.spec.json index d42f10920..50020e22d 100644 --- a/libs/@guardian/identity-auth/tsconfig.spec.json +++ b/libs/@guardian/identity-auth/tsconfig.spec.json @@ -3,6 +3,8 @@ "compilerOptions": { "outDir": "../../../dist/out-tsc", "module": "commonjs", + "composite": true, + "noEmit": false, "types": ["jest", "node"] }, "include": [