diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts index 58bd161e2a3..6c7c438ed1e 100644 --- a/packages/shared/src/dpop.test.ts +++ b/packages/shared/src/dpop.test.ts @@ -1,12 +1,15 @@ import * as NodeCrypto from "node:crypto"; -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; +import * as Option from "effect/Option"; import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, normalizeDpopHtu, + normalizeDpopHtuOption, type DpopPublicJwk, + type DpopVerificationResult, verifyDpopProof, } from "./dpop.ts"; @@ -41,6 +44,24 @@ function signDpopProof(input: { return `${header}.${payload}.${signature}`; } +type DpopRejectedResult = Extract; + +function assertAccepted(result: DpopVerificationResult) { + if (!result.ok) { + assert.fail(`Expected accepted DPoP proof, got ${result.error._tag}`); + } + return result; +} + +function assertRejected(result: DpopVerificationResult, tag: DpopRejectedResult["error"]["_tag"]) { + if (result.ok) { + assert.fail("Expected rejected DPoP proof."); + } + assert.equal(result.error._tag, tag); + assert.equal(result.reason, result.error.message); + return result; +} + describe("verifyDpopProof", () => { const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256", @@ -56,7 +77,7 @@ describe("verifyDpopProof", () => { it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( + const result = assertAccepted( verifyDpopProof({ proof, method: "POST", @@ -64,16 +85,14 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, }), - ).toMatchObject({ - ok: true, - thumbprint, - jti: "proof-1", - }); + ); + assert.equal(result.thumbprint, thumbprint); + assert.equal(result.jti, "proof-1"); }); it("rejects method, URL, thumbprint, and time-window mismatches", () => { const thumbprint = computeDpopJwkThumbprint(publicJwk); - expect( + assertRejected( verifyDpopProof({ proof, method: "GET", @@ -81,8 +100,9 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, }), - ).toMatchObject({ ok: false }); - expect( + "DpopMethodMismatchError", + ); + assertRejected( verifyDpopProof({ proof, method: "POST", @@ -90,8 +110,9 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, }), - ).toMatchObject({ ok: false }); - expect( + "DpopUrlMismatchError", + ); + assertRejected( verifyDpopProof({ proof, method: "POST", @@ -99,8 +120,9 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: "other-thumbprint", }), - ).toMatchObject({ ok: false }); - expect( + "DpopThumbprintMismatchError", + ); + assertRejected( verifyDpopProof({ proof, method: "POST", @@ -108,7 +130,8 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 1_000, expectedThumbprint: thumbprint, }), - ).toMatchObject({ ok: false }); + "DpopTimeWindowError", + ); }); it("requires the RFC 9449 access token hash when an access token is expected", () => { @@ -122,7 +145,7 @@ describe("verifyDpopProof", () => { accessToken: "clerk-access-token", }); - expect( + assertAccepted( verifyDpopProof({ proof: accessTokenProof, method: "POST", @@ -131,8 +154,8 @@ describe("verifyDpopProof", () => { expectedThumbprint: thumbprint, expectedAccessToken: "clerk-access-token", }), - ).toMatchObject({ ok: true }); - expect( + ); + assertRejected( verifyDpopProof({ proof, method: "POST", @@ -141,8 +164,9 @@ describe("verifyDpopProof", () => { expectedThumbprint: thumbprint, expectedAccessToken: "clerk-access-token", }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); - expect( + "DpopAccessTokenHashMismatchError", + ); + assertRejected( verifyDpopProof({ proof: accessTokenProof, method: "POST", @@ -151,13 +175,20 @@ describe("verifyDpopProof", () => { expectedThumbprint: thumbprint, expectedAccessToken: "other-access-token", }), - ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); + "DpopAccessTokenHashMismatchError", + ); }); 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", ); + assert.deepStrictEqual( + normalizeDpopHtuOption("https://example.com/v1/environments/env/connect?foo=bar#frag"), + Option.some("https://example.com/v1/environments/env/connect"), + ); + assert.deepStrictEqual(normalizeDpopHtuOption("not a url"), Option.none()); const thumbprint = computeDpopJwkThumbprint(publicJwk); const queryProof = signDpopProof({ @@ -168,7 +199,7 @@ describe("verifyDpopProof", () => { publicJwk, }); - expect( + assertAccepted( verifyDpopProof({ proof: queryProof, method: "POST", @@ -176,7 +207,7 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, }), - ).toMatchObject({ ok: true }); + ); }); it("rejects DPoP public JWK headers that expose private key material", () => { @@ -192,7 +223,7 @@ describe("verifyDpopProof", () => { publicJwk: privateJwk, }); - expect( + assertRejected( verifyDpopProof({ proof: proofWithPrivateJwk, method: "POST", @@ -200,6 +231,7 @@ describe("verifyDpopProof", () => { nowEpochSeconds: 101, expectedThumbprint: thumbprint, }), - ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); + "InvalidDpopJwtHeaderError", + ); }); }); diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 34210679007..393a42ad612 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -1,38 +1,160 @@ 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"; +import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtuOption } from "./dpopCommon.ts"; +import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts"; import { stableStringify } from "./relaySigning.ts"; +export const DpopPublicJwk = DpopPublicJwkSchema; +export type DpopPublicJwk = DpopPublicJwkType; +export { normalizeDpopHtu, normalizeDpopHtuOption } from "./dpopCommon.ts"; + const DPOP_TYP = "dpop+jwt"; const DPOP_ALG = "ES256"; const DEFAULT_MAX_AGE_SECONDS = 300; -export const DpopPublicJwk = Schema.Struct({ - kty: Schema.Literal("EC"), - crv: Schema.Literal("P-256"), - x: Schema.String.check(Schema.isNonEmpty()), - y: Schema.String.check(Schema.isNonEmpty()), +const DpopJwtHeader = Schema.Struct({ + typ: Schema.Literal(DPOP_TYP), + alg: Schema.Literal(DPOP_ALG), + jwk: DpopPublicJwk, +}); +type DpopJwtHeader = typeof DpopJwtHeader.Type; + +const DpopJwtPayload = 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), +}); +type DpopJwtPayload = typeof DpopJwtPayload.Type; + +const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(Schema.fromJsonString(DpopJwtHeader), { + onExcessProperty: "preserve", }); -export type DpopPublicJwk = typeof DpopPublicJwk.Type; -const isDpopPublicJwk = Schema.is(DpopPublicJwk); +const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(Schema.fromJsonString(DpopJwtPayload)); + +export class MissingDpopProofError extends Schema.TaggedErrorClass()( + "MissingDpopProofError", + {}, +) { + override get message(): string { + return "Missing DPoP proof."; + } +} + +export class MalformedDpopCompactJwtError extends Schema.TaggedErrorClass()( + "MalformedDpopCompactJwtError", + {}, +) { + override get message(): string { + return "Invalid DPoP compact JWT."; + } +} + +export class InvalidDpopJwtHeaderError extends Schema.TaggedErrorClass()( + "InvalidDpopJwtHeaderError", + {}, +) { + override get message(): string { + return "Invalid DPoP JWT header."; + } +} + +export class InvalidDpopJwtPayloadError extends Schema.TaggedErrorClass()( + "InvalidDpopJwtPayloadError", + {}, +) { + override get message(): string { + return "Invalid DPoP JWT payload."; + } +} + +export class DpopThumbprintMismatchError extends Schema.TaggedErrorClass()( + "DpopThumbprintMismatchError", + { + expectedThumbprint: Schema.String, + actualThumbprint: Schema.String, + }, +) { + override get message(): string { + return "DPoP key thumbprint mismatch."; + } +} + +export class DpopMethodMismatchError extends Schema.TaggedErrorClass()( + "DpopMethodMismatchError", + { + expectedMethod: Schema.String, + actualMethod: Schema.String, + }, +) { + override get message(): string { + return "DPoP method mismatch."; + } +} + +export class DpopUrlMismatchError extends Schema.TaggedErrorClass()( + "DpopUrlMismatchError", + { + requestUrl: Schema.String, + proofUrl: Schema.String, + }, +) { + override get message(): string { + return "DPoP URL mismatch."; + } +} + +export class DpopAccessTokenHashMismatchError extends Schema.TaggedErrorClass()( + "DpopAccessTokenHashMismatchError", + {}, +) { + override get message(): string { + return "DPoP access token hash mismatch."; + } +} -interface DpopJwtHeader { - readonly typ: string; - readonly alg: string; - readonly jwk: DpopPublicJwk; +export class DpopTimeWindowError extends Schema.TaggedErrorClass()( + "DpopTimeWindowError", + { + iat: Schema.Int, + nowEpochSeconds: Schema.Int, + maxAgeSeconds: Schema.Int, + }, +) { + override get message(): string { + return "DPoP proof is outside the allowed time window."; + } } -interface DpopJwtPayload { - readonly htm: string; - readonly htu: string; - readonly jti: string; - readonly iat: number; - readonly ath?: string; +export class InvalidDpopSignatureError extends Schema.TaggedErrorClass()( + "InvalidDpopSignatureError", + {}, +) { + override get message(): string { + return "Invalid DPoP signature."; + } } +export const DpopVerificationError = Schema.Union([ + MissingDpopProofError, + MalformedDpopCompactJwtError, + InvalidDpopJwtHeaderError, + InvalidDpopJwtPayloadError, + DpopThumbprintMismatchError, + DpopMethodMismatchError, + DpopUrlMismatchError, + DpopAccessTokenHashMismatchError, + DpopTimeWindowError, + InvalidDpopSignatureError, +]); +export type DpopVerificationError = typeof DpopVerificationError.Type; + export type DpopVerificationResult = | { readonly ok: true; @@ -43,45 +165,45 @@ export type DpopVerificationResult = | { readonly ok: false; readonly reason: string; + readonly error: DpopVerificationError; }; -function base64UrlToBytes(value: string): Uint8Array { - return Result.getOrThrow(Encoding.decodeBase64Url(value)); +function rejectDpopProof(error: DpopVerificationError): DpopVerificationResult { + return { ok: false, reason: error.message, error }; } -function decodeBase64UrlJson(value: string): unknown { - return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown; +function compactJwtParts(proof: string): Option.Option { + const parts = proof.split("."); + return parts.length === 3 && parts[0] && parts[1] && parts[2] + ? Option.some([parts[0], parts[1], parts[2]] as const) + : Option.none(); } -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 base64UrlToBytesOption(value: string): Option.Option { + const result = Encoding.decodeBase64Url(value); + return Result.isSuccess(result) ? Option.some(result.success) : Option.none(); +} + +function base64UrlToStringOption(value: string): Option.Option { + const result = Encoding.decodeBase64UrlString(value); + return Result.isSuccess(result) ? Option.some(result.success) : Option.none(); } -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 decodeBase64UrlJsonOption( + value: string, + decodeJson: (input: unknown) => Option.Option, +): Option.Option { + return Option.flatMap(base64UrlToStringOption(value), decodeJson); +} + +function hasPrivateJwkMaterial(header: DpopJwtHeader): boolean { + return "d" in header.jwk; +} + +function proofInput(input: { readonly proof: string | null | undefined }): Option.Option { + return Option.fromNullishOr(input.proof).pipe( + Option.map((proof) => proof.trim()), + Option.filter((proof) => proof.length > 0), ); } @@ -94,17 +216,6 @@ function dpopThumbprintInput(jwk: DpopPublicJwk): string { }); } -export function normalizeDpopHtu(url: string): string | null { - try { - const parsed = new URL(url); - parsed.hash = ""; - parsed.search = ""; - return parsed.toString(); - } catch { - return null; - } -} - export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); } @@ -113,17 +224,20 @@ export function computeDpopAccessTokenHash(accessToken: string): string { return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); } -function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array { - const x = base64UrlToBytes(jwk.x); - const y = base64UrlToBytes(jwk.y); - if (x.length !== 32 || y.length !== 32) { - throw new Error("Invalid P-256 public key coordinate length."); +function publicKeyBytesFromJwkOption(jwk: DpopPublicJwk): Option.Option { + const x = base64UrlToBytesOption(jwk.x); + const y = base64UrlToBytesOption(jwk.y); + if (Option.isNone(x) || Option.isNone(y)) { + return Option.none(); + } + if (x.value.length !== 32 || y.value.length !== 32) { + return Option.none(); } const publicKey = new Uint8Array(65); publicKey[0] = 0x04; - publicKey.set(x, 1); - publicKey.set(y, 33); - return publicKey; + publicKey.set(x.value, 1); + publicKey.set(y.value, 33); + return Option.some(publicKey); } export function verifyDpopProof(input: { @@ -135,54 +249,103 @@ export function verifyDpopProof(input: { readonly expectedAccessToken?: string; readonly maxAgeSeconds?: number; }): DpopVerificationResult { - if (!input.proof?.trim()) { - return { ok: false, reason: "Missing DPoP proof." }; + const proof = proofInput(input); + if (Option.isNone(proof)) { + return rejectDpopProof(new MissingDpopProofError({})); } - const parts = input.proof.split("."); - if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { - return { ok: false, reason: "Invalid DPoP compact JWT." }; + const parts = compactJwtParts(proof.value); + if (Option.isNone(parts)) { + return rejectDpopProof(new MalformedDpopCompactJwtError({})); } - try { - const header = decodeBase64UrlJson(parts[0]); - const payload = decodeBase64UrlJson(parts[1]); - if (!isDpopJwtHeader(header)) { - return { ok: false, reason: "Invalid DPoP JWT header." }; - } - if (!isDpopJwtPayload(payload)) { - return { ok: false, reason: "Invalid DPoP JWT payload." }; - } + const [headerPart, payloadPart, signaturePart] = parts.value; + const header = decodeBase64UrlJsonOption(headerPart, decodeDpopJwtHeaderJson); + if (Option.isNone(header)) { + return rejectDpopProof(new InvalidDpopJwtHeaderError({})); + } + if (hasPrivateJwkMaterial(header.value)) { + return rejectDpopProof(new InvalidDpopJwtHeaderError({})); + } - const thumbprint = computeDpopJwkThumbprint(header.jwk); - if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { - return { ok: false, reason: "DPoP key thumbprint mismatch." }; - } - if (payload.htm.toUpperCase() !== input.method.toUpperCase()) { - return { ok: false, reason: "DPoP method mismatch." }; - } - const normalizedHtu = normalizeDpopHtu(input.url); - if (normalizedHtu === null || payload.htu !== normalizedHtu) { - return { ok: false, reason: "DPoP URL mismatch." }; - } - if (input.expectedAccessToken) { - const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); - if (payload.ath !== expectedAth) { - return { ok: false, reason: "DPoP access token hash mismatch." }; - } - } + const payload = decodeBase64UrlJsonOption(payloadPart, decodeDpopJwtPayloadJson); + if (Option.isNone(payload)) { + return rejectDpopProof(new InvalidDpopJwtPayloadError({})); + } - const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; - if ( - payload.iat > input.nowEpochSeconds + 5 || - input.nowEpochSeconds - payload.iat > maxAgeSeconds - ) { - return { ok: false, reason: "DPoP proof is outside the allowed time window." }; + const publicKey = publicKeyBytesFromJwkOption(header.value.jwk); + if (Option.isNone(publicKey)) { + return rejectDpopProof(new InvalidDpopJwtHeaderError({})); + } + + const thumbprint = computeDpopJwkThumbprint(header.value.jwk); + const expectedThumbprint = Option.fromUndefinedOr(input.expectedThumbprint); + if (Option.isSome(expectedThumbprint) && thumbprint !== expectedThumbprint.value) { + return rejectDpopProof( + new DpopThumbprintMismatchError({ + expectedThumbprint: expectedThumbprint.value, + actualThumbprint: thumbprint, + }), + ); + } + + if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) { + return rejectDpopProof( + new DpopMethodMismatchError({ + expectedMethod: input.method, + actualMethod: payload.value.htm, + }), + ); + } + + const normalizedHtu = normalizeDpopHtuOption(input.url); + if (Option.isNone(normalizedHtu)) { + return rejectDpopProof( + new DpopUrlMismatchError({ + requestUrl: input.url, + proofUrl: payload.value.htu, + }), + ); + } + if (payload.value.htu !== normalizedHtu.value) { + return rejectDpopProof( + new DpopUrlMismatchError({ + requestUrl: input.url, + proofUrl: payload.value.htu, + }), + ); + } + + const expectedAccessToken = Option.fromUndefinedOr(input.expectedAccessToken); + if (Option.isSome(expectedAccessToken)) { + const expectedAth = computeDpopAccessTokenHash(expectedAccessToken.value); + if (payload.value.ath !== expectedAth) { + return rejectDpopProof(new DpopAccessTokenHashMismatchError({})); } + } + + const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; + if ( + payload.value.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.value.iat > maxAgeSeconds + ) { + return rejectDpopProof( + new DpopTimeWindowError({ + iat: payload.value.iat, + nowEpochSeconds: input.nowEpochSeconds, + maxAgeSeconds, + }), + ); + } + + const signature = base64UrlToBytesOption(signaturePart); + if (Option.isNone(signature)) { + return rejectDpopProof(new InvalidDpopSignatureError({})); + } - const signature = base64UrlToBytes(parts[2]); - const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); - const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), { + try { + const signatureInputHash = sha256(new TextEncoder().encode(`${headerPart}.${payloadPart}`)); + const verified = p256.verify(signature.value, signatureInputHash, publicKey.value, { prehash: false, format: "compact", }); @@ -190,11 +353,11 @@ export function verifyDpopProof(input: { ? { ok: true, thumbprint, - jti: payload.jti, - iat: payload.iat, + jti: payload.value.jti, + iat: payload.value.iat, } - : { ok: false, reason: "Invalid DPoP signature." }; + : rejectDpopProof(new InvalidDpopSignatureError({})); } catch { - return { ok: false, reason: "Invalid DPoP proof." }; + return rejectDpopProof(new InvalidDpopSignatureError({})); } } diff --git a/packages/shared/src/dpopCommon.ts b/packages/shared/src/dpopCommon.ts index ba57b016c33..feb771cf5c6 100644 --- a/packages/shared/src/dpopCommon.ts +++ b/packages/shared/src/dpopCommon.ts @@ -1,3 +1,4 @@ +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; export const DpopPublicJwk = Schema.Struct({ @@ -8,13 +9,17 @@ export const DpopPublicJwk = Schema.Struct({ }); export type DpopPublicJwk = typeof DpopPublicJwk.Type; -export function normalizeDpopHtu(url: string): string | null { +export function normalizeDpopHtuOption(url: string): Option.Option { try { const parsed = new URL(url); parsed.hash = ""; parsed.search = ""; - return parsed.toString(); + return Option.some(parsed.toString()); } catch { - return null; + return Option.none(); } } + +export function normalizeDpopHtu(url: string): string | null { + return Option.getOrNull(normalizeDpopHtuOption(url)); +}