diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index 58bd161e2a3..c4ba298f66c 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "node:crypto"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { computeDpopAccessTokenHash, @@ -56,59 +56,93 @@ describe("verifyDpopProof", () => { it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( - verifyDpopProof({ - proof, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - }), - ).toMatchObject({ - ok: true, - thumbprint, - jti: "proof-1", + const result = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }); + + if (!result.ok) { + assert.fail(result.reason); + } + assert.equal(result.thumbprint, thumbprint); + assert.equal(result.jti, "proof-1"); + }); + + it("rejects malformed DPoP header and payload JSON", () => { + const [header, payload, signature] = proof.split("."); + if (!header || !payload || !signature) { + assert.fail("Expected the test DPoP proof to use compact JWT format."); + } + const malformedJson = Buffer.from("{").toString("base64url"); + + const malformedHeader = verifyDpopProof({ + proof: `${malformedJson}.${payload}.${signature}`, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, }); + if (malformedHeader.ok) { + assert.fail("Expected malformed DPoP header JSON to fail."); + } + assert.equal(malformedHeader.reason, "Invalid DPoP JWT header."); + + const malformedPayload = verifyDpopProof({ + proof: `${header}.${malformedJson}.${signature}`, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + }); + if (malformedPayload.ok) { + assert.fail("Expected malformed DPoP payload JSON to fail."); + } + assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload."); }); it("rejects method, URL, thumbprint, and time-window mismatches", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( + assert.equal( verifyDpopProof({ proof, method: "GET", url: "https://example.com/oauth/token", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/other", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/oauth/token", nowEpochSeconds: 101, expectedThumbprint: "other-thumbprint", - }), - ).toMatchObject({ ok: false }); - expect( + }).ok, + false, + ); + assert.equal( verifyDpopProof({ proof, method: "POST", url: "https://example.com/oauth/token", nowEpochSeconds: 1_000, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false }); + }).ok, + false, + ); }); it("requires the RFC 9449 access token hash when an access token is expected", () => { @@ -122,7 +156,7 @@ describe("verifyDpopProof", () => { accessToken: "clerk-access-token", }); - expect( + assert.equal( verifyDpopProof({ proof: accessTokenProof, method: "POST", @@ -130,32 +164,40 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, expectedAccessToken: "clerk-access-token", - }), - ).toMatchObject({ ok: true }); - expect( - verifyDpopProof({ - proof, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - expectedAccessToken: "clerk-access-token", - }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); - expect( - verifyDpopProof({ - proof: accessTokenProof, - method: "POST", - url: "https://example.com/v1/environments/env/connect", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - expectedAccessToken: "other-access-token", - }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); + }).ok, + true, + ); + + const missingHash = verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "clerk-access-token", + }); + if (missingHash.ok) { + assert.fail("Expected DPoP proof without an access token hash to fail."); + } + assert.equal(missingHash.reason, "DPoP access token hash mismatch."); + + const mismatchedHash = verifyDpopProof({ + proof: accessTokenProof, + method: "POST", + url: "https://example.com/v1/environments/env/connect", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "other-access-token", + }); + if (mismatchedHash.ok) { + assert.fail("Expected DPoP proof with a mismatched access token hash to fail."); + } + assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch."); }); it("normalizes htu by excluding query and fragment components per RFC 9449", () => { - expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe( + assert.equal( + normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"), "https://example.com/v1/environments/env/connect", ); @@ -168,15 +210,16 @@ describe("verifyDpopProof", () => { publicJwk, }); - expect( + assert.equal( verifyDpopProof({ proof: queryProof, method: "POST", url: "https://example.com/v1/environments/env/connect?foo=bar#frag", nowEpochSeconds: 101, expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: true }); + }).ok, + true, + ); }); it("rejects DPoP public JWK headers that expose private key material", () => { @@ -192,14 +235,17 @@ describe("verifyDpopProof", () => { publicJwk: privateJwk, }); - expect( - verifyDpopProof({ - proof: proofWithPrivateJwk, - method: "POST", - url: "https://example.com/oauth/token", - nowEpochSeconds: 101, - expectedThumbprint: thumbprint, - }), - ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); + const result = verifyDpopProof({ + proof: proofWithPrivateJwk, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }); + + if (result.ok) { + assert.fail("Expected DPoP proof with private JWK material to fail."); + } + assert.equal(result.reason, "Invalid DPoP JWT header."); }); }); diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 34210679007..88dcf8e3090 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -1,6 +1,7 @@ import { p256 } from "@noble/curves/nist"; import { sha256 } from "@noble/hashes/sha2"; import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({ y: Schema.String.check(Schema.isNonEmpty()), }); export type DpopPublicJwk = typeof DpopPublicJwk.Type; -const isDpopPublicJwk = Schema.is(DpopPublicJwk); -interface DpopJwtHeader { - readonly typ: string; - readonly alg: string; - readonly jwk: DpopPublicJwk; -} +const DpopJwtHeaderPublicJwk = Schema.Struct({ + ...DpopPublicJwk.fields, + d: Schema.optionalKey(Schema.Never), +}); -interface DpopJwtPayload { - readonly htm: string; - readonly htu: string; - readonly jti: string; - readonly iat: number; - readonly ath?: string; -} +const DpopJwtHeaderJson = Schema.fromJsonString( + Schema.Struct({ + typ: Schema.Literal(DPOP_TYP), + alg: Schema.Literal(DPOP_ALG), + jwk: DpopJwtHeaderPublicJwk, + }), +); +const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson); + +const DpopJwtPayloadJson = Schema.fromJsonString( + Schema.Struct({ + htm: Schema.String.check(Schema.isNonEmpty()), + htu: Schema.String.check(Schema.isNonEmpty()), + jti: Schema.String.check(Schema.isNonEmpty()), + iat: Schema.Int, + ath: Schema.optionalKey(Schema.String), + }), +); +const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson); export type DpopVerificationResult = | { @@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array { return Result.getOrThrow(Encoding.decodeBase64Url(value)); } -function decodeBase64UrlJson(value: string): unknown { - return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown; +function decodeBase64UrlDpopJwtHeader(value: string) { + return decodeDpopJwtHeaderJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } -function isDpopJwtHeader(value: unknown): value is DpopJwtHeader { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - return ( - record.typ === DPOP_TYP && - record.alg === DPOP_ALG && - typeof record.jwk === "object" && - record.jwk !== null && - !("d" in record.jwk) && - isDpopPublicJwk(record.jwk) - ); -} - -function isDpopJwtPayload(value: unknown): value is DpopJwtPayload { - if (typeof value !== "object" || value === null) { - return false; - } - const record = value as Record; - return ( - typeof record.htm === "string" && - record.htm.length > 0 && - typeof record.htu === "string" && - record.htu.length > 0 && - typeof record.jti === "string" && - record.jti.length > 0 && - typeof record.iat === "number" && - Number.isInteger(record.iat) - ); +function decodeBase64UrlDpopJwtPayload(value: string) { + return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value))); } function dpopThumbprintInput(jwk: DpopPublicJwk): string { @@ -145,53 +128,58 @@ export function verifyDpopProof(input: { } try { - const header = decodeBase64UrlJson(parts[0]); - const payload = decodeBase64UrlJson(parts[1]); - if (!isDpopJwtHeader(header)) { + const header = decodeBase64UrlDpopJwtHeader(parts[0]); + const payload = decodeBase64UrlDpopJwtPayload(parts[1]); + if (Option.isNone(header)) { return { ok: false, reason: "Invalid DPoP JWT header." }; } - if (!isDpopJwtPayload(payload)) { + if (Option.isNone(payload)) { return { ok: false, reason: "Invalid DPoP JWT payload." }; } - const thumbprint = computeDpopJwkThumbprint(header.jwk); + const thumbprint = computeDpopJwkThumbprint(header.value.jwk); if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { return { ok: false, reason: "DPoP key thumbprint mismatch." }; } - if (payload.htm.toUpperCase() !== input.method.toUpperCase()) { + if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { return { ok: false, reason: "DPoP method mismatch." }; } const normalizedHtu = normalizeDpopHtu(input.url); - if (normalizedHtu === null || payload.htu !== normalizedHtu) { + if (normalizedHtu === null || payload.value.htu !== normalizedHtu) { return { ok: false, reason: "DPoP URL mismatch." }; } if (input.expectedAccessToken) { const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); - if (payload.ath !== expectedAth) { + if (payload.value.ath !== expectedAth) { return { ok: false, reason: "DPoP access token hash mismatch." }; } } const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; if ( - payload.iat > input.nowEpochSeconds + 5 || - input.nowEpochSeconds - payload.iat > maxAgeSeconds + payload.value.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.value.iat > maxAgeSeconds ) { return { ok: false, reason: "DPoP proof is outside the allowed time window." }; } const signature = base64UrlToBytes(parts[2]); const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); - const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), { - prehash: false, - format: "compact", - }); + const verified = p256.verify( + signature, + signatureInputHash, + publicKeyBytesFromJwk(header.value.jwk), + { + prehash: false, + format: "compact", + }, + ); return verified ? { ok: true, thumbprint, - jti: payload.jti, - iat: payload.iat, + jti: payload.value.jti, + iat: payload.value.iat, } : { ok: false, reason: "Invalid DPoP signature." }; } catch {