diff --git a/packages/common/lib/__tests__/dpop.spec.ts b/packages/common/lib/__tests__/dpop.spec.ts new file mode 100644 index 00000000..1a858376 --- /dev/null +++ b/packages/common/lib/__tests__/dpop.spec.ts @@ -0,0 +1,124 @@ +import { createDPoP, getCreateDPoPOptions, verifyDPoP } from '../dpop'; + +describe('dpop', () => { + const alg = 'HS256'; + const jwk = { kty: 'Ed25519', crv: 'Ed25519', x: '123', y: '123' }; + const jwtIssuer = { alg, jwk }; + const htm = 'POST'; + const htu = 'https://example.com/token'; + const nonce = 'nonce'; + const jwtPayloadProps = { htm, htu, nonce } as const; + const jwtHeaderProps = { alg, jwk, typ: 'dpop+jwt' }; + const unsignedDpop = + 'eyJhbGciOiJIUzI1NiIsImp3ayI6eyJrdHkiOiJFZDI1NTE5IiwiY3J2IjoiRWQyNTUxOSIsIngiOiIxMjMiLCJ5IjoiMTIzIn0sInR5cCI6ImRwb3Arand0In0.eyJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9leGFtcGxlLmNvbS90b2tlbiIsIm5vbmNlIjoibm9uY2UiLCJpYXQiOjE3MjIzMjcxOTQsImp0aSI6Ijk4OWNiZTc4LWI1ZTYtNDViYS1iYjMzLWQ0MGE4ZGEwZjFhYSJ9'; + + it('should create a dpop with valid options', async () => { + const dpop = await createDPoP({ + jwtIssuer, + jwtPayloadProps, + createJwtCallback: async (dpopJwtIssuerWithContext, jwt) => { + expect(dpopJwtIssuerWithContext.alg).toEqual(alg); + expect(dpopJwtIssuerWithContext.jwk).toEqual(jwk); + expect(dpopJwtIssuerWithContext.dPoPSigningAlgValuesSupported).toBeUndefined(); + expect(dpopJwtIssuerWithContext.type).toEqual('dpop'); + + expect(jwt.header).toStrictEqual(jwtHeaderProps); + expect(jwt.payload).toStrictEqual({ + ...jwtPayloadProps, + iat: expect.any(Number), + jti: expect.any(String), + }); + + return unsignedDpop; + }, + }); + + expect(unsignedDpop).toEqual(dpop); + expect.assertions(7); + }); + + it('should create a dpop with valid createDPoPOptions', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { htm, htu, ...rest } = jwtPayloadProps; + const options = getCreateDPoPOptions( + { + jwtIssuer, + jwtPayloadProps: rest, + createJwtCallback: async (dpopJwtIssuerWithContext, jwt) => { + expect(dpopJwtIssuerWithContext.alg).toEqual(alg); + expect(dpopJwtIssuerWithContext.jwk).toEqual(jwk); + expect(dpopJwtIssuerWithContext.dPoPSigningAlgValuesSupported).toBeUndefined(); + expect(dpopJwtIssuerWithContext.type).toEqual('dpop'); + + expect(jwt.header).toStrictEqual(jwtHeaderProps); + expect(jwt.payload).toStrictEqual({ + ...jwtPayloadProps, + iat: expect.any(Number), + jti: expect.any(String), + }); + + return unsignedDpop; + }, + }, + htu + '?123412341#xyaksdjfaksdjf', + ); + + const dpop = await createDPoP(options); + + expect(unsignedDpop).toEqual(dpop); + expect.assertions(7); + }); + + it('verify dpop fails if jwtVerifyCallback throws an error', async () => { + await expect( + verifyDPoP( + { + headers: { dpop: unsignedDpop }, + fullUrl: htu + '?123412341#xyaksdjfaksdjf', + method: 'POST', + }, + { + jwtVerifyCallback: async () => { + throw new Error('jwtVerifyCallback'); + }, + expectedNonce: 'nonce', + expectAccessToken: false, + now: 1722327194, + }, + ), + ).rejects.toThrow(); + }); + + it('should verify a dpop with valid options', async () => { + const dpop = await verifyDPoP( + { + headers: { dpop: unsignedDpop }, + fullUrl: htu + '?123412341#xyaksdjfaksdjf', + method: 'POST', + }, + { + jwtVerifyCallback: async (jwtVerifier, jwt) => { + expect(jwtVerifier.method).toEqual('jwk'); + expect(jwtVerifier.jwk).toEqual(jwk); + expect(jwtVerifier.type).toEqual('dpop'); + expect(jwtVerifier.alg).toEqual(alg); + + expect(jwt.header).toStrictEqual(jwtHeaderProps); + expect(jwt.payload).toStrictEqual({ + ...jwtPayloadProps, + iat: expect.any(Number), + jti: expect.any(String), + }); + expect(jwt.raw).toEqual(unsignedDpop); + + return true; + }, + expectAccessToken: false, + expectedNonce: 'nonce', + now: 1722327194, + }, + ); + expect(dpop).toStrictEqual(jwk); + expect.assertions(6); + }); +}); diff --git a/packages/common/lib/dpop/DPoP.ts b/packages/common/lib/dpop/DPoP.ts index 1186361a..1a397948 100644 --- a/packages/common/lib/dpop/DPoP.ts +++ b/packages/common/lib/dpop/DPoP.ts @@ -89,6 +89,7 @@ export interface DPoPVerifyOptions { maxIatAgeInSeconds?: number; expectAccessToken?: boolean; jwtVerifyCallback: DPoPVerifyJwtCallback; + now?: number; } export async function verifyDPoP( @@ -164,10 +165,12 @@ export async function verifyDPoP( } // Validate iat claim - const { nowSkewedPast, nowSkewedFuture } = getNowSkewed(); + const { nowSkewedPast, nowSkewedFuture } = getNowSkewed(options.now); if ( - dPoPPayload.iat > nowSkewedFuture + (options.maxIatAgeInSeconds ?? 300) || - dPoPPayload.iat < nowSkewedPast - (options.maxIatAgeInSeconds ?? 300) + // iat claim is to far in the future + nowSkewedPast - (options.maxIatAgeInSeconds ?? 300) > dPoPPayload.iat || + // iat claim is too old + nowSkewedFuture + (options.maxIatAgeInSeconds ?? 300) < dPoPPayload.iat ) { // 5 minute window throw new Error('invalid_dpop_proof. Invalid iat claim'); diff --git a/packages/common/lib/jwt/Jwt.types.ts b/packages/common/lib/jwt/Jwt.types.ts index 19fb4398..1b2f3b90 100644 --- a/packages/common/lib/jwt/Jwt.types.ts +++ b/packages/common/lib/jwt/Jwt.types.ts @@ -5,7 +5,7 @@ export type JwtHeader = jwtDecodeJwtHeader & { alg?: string; x5c?: string[]; kid?: string; - jwk?: JWK & { kty: string }; + jwk?: JWK; jwt?: string; } & Record;