Skip to content
Source
Sign inSign up
Open History to see the last commit

dpop.ts

206 lines6.4 KB

1import { pipe } from "effect/Function";
2import { p256 } from "@noble/curves/nist";
3import { sha256 } from "@noble/hashes/sha2";
4import * as Base64Url from "effect/encoding/Base64Url";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8​
9import { DpopPublicJwk as DpopPublicJwkSchema, normalizeDpopHtu } from "./dpopCommon.ts";
10import type { DpopPublicJwk as DpopPublicJwkType } from "./dpopCommon.ts";
11import { stableStringify } from "./relaySigning.ts";
12​
13const DPOP_TYP = "dpop+jwt";
14const DPOP_ALG = "ES256";
15/**
16 * How far a proof's `iat` may sit from the verifier's clock, in either direction.
17 * Proofs carry the client's clock, so this also bounds tolerated clock skew.
18 */
19export const DPOP_MAX_CLOCK_SKEW_SECONDS = 300;
20​
21export const DpopPublicJwk = DpopPublicJwkSchema;
22export type DpopPublicJwk = DpopPublicJwkType;
23export { normalizeDpopHtu };
24​
25export const DpopVerificationFailureCode = Schema.Literals([
26 "missing_proof",
27 "malformed_proof",
28 "key_mismatch",
29 "method_mismatch",
30 "url_mismatch",
31 "access_token_hash_mismatch",
32 "time_window",
33 "invalid_signature",
34 "invalid_proof",
35]);
36export type DpopVerificationFailureCode = typeof DpopVerificationFailureCode.Type;
37​
38const DpopJwtHeaderPublicJwk = Schema.Struct({
39 ...DpopPublicJwkSchema.fields,
40 d: Schema.optionalKey(Schema.Never),
41});
42​
43const DpopJwtHeaderJson = Schema.fromJsonString(
44 Schema.Struct({
45 typ: Schema.Literal(DPOP_TYP),
46 alg: Schema.Literal(DPOP_ALG),
47 jwk: DpopJwtHeaderPublicJwk,
48 }),
49);
50const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson);
51​
52const DpopJwtPayloadJson = Schema.fromJsonString(
53 Schema.Struct({
54 htm: Schema.String.check(Schema.isNonEmpty()),
55 htu: Schema.String.check(Schema.isNonEmpty()),
56 jti: Schema.String.check(Schema.isNonEmpty()),
57 iat: Schema.Int,
58 ath: Schema.optionalKey(Schema.String),
59 }),
60);
61const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson);
62​
63export type DpopVerificationResult =
64 | {
65 readonly ok: true;
66 readonly thumbprint: string;
67 readonly jti: string;
68 readonly iat: number;
69 }
70 | {
71 readonly ok: false;
72 readonly code: DpopVerificationFailureCode;
73 readonly reason: string;
74 };
75​
76function base64UrlToBytes(value: string): Uint8Array {
77 return Result.getOrThrow(Base64Url.decode(value));
79​
80function decodeBase64UrlDpopJwtHeader(value: string) {
81 return decodeDpopJwtHeaderJson(Result.getOrThrow(Base64Url.decodeString(value)));
83​
84function decodeBase64UrlDpopJwtPayload(value: string) {
85 return decodeDpopJwtPayloadJson(Result.getOrThrow(Base64Url.decodeString(value)));
87​
88function dpopThumbprintInput(jwk: DpopPublicJwkType): string {
89 return stableStringify({
90 crv: jwk.crv,
91 kty: jwk.kty,
92 x: jwk.x,
93 y: jwk.y,
94 });
96​
97export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string {
98 return pipe(
99 dpopThumbprintInput(jwk),
100 (input) => new TextEncoder().encode(input),
101 sha256,
102 Base64Url.encode,
103 );
105​
106export function computeDpopAccessTokenHash(accessToken: string): string {
107 return Base64Url.encode(sha256(new TextEncoder().encode(accessToken)));
109​
110function publicKeyBytesFromJwk(jwk: DpopPublicJwkType): Uint8Array {
111 const x = base64UrlToBytes(jwk.x);
112 const y = base64UrlToBytes(jwk.y);
113 if (x.length !== 32 || y.length !== 32) {
114 throw new Error("Invalid P-256 public key coordinate length.");
115 }
116 const publicKey = new Uint8Array(65);
117 publicKey[0] = 0x04;
118 publicKey.set(x, 1);
119 publicKey.set(y, 33);
120 return publicKey;
122​
123export function verifyDpopProof(input: {
124 readonly proof: string | null | undefined;
125 readonly method: string;
126 readonly url: string;
127 readonly nowEpochSeconds: number;
128 readonly expectedThumbprint?: string;
129 readonly expectedAccessToken?: string;
130 readonly maxAgeSeconds?: number;
131}): DpopVerificationResult {
132 if (!input.proof?.trim()) {
133 return { ok: false, code: "missing_proof", reason: "Missing DPoP proof." };
134 }
135​
136 const parts = input.proof.split(".");
137 if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
138 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP compact JWT." };
139 }
140​
141 try {
142 const header = decodeBase64UrlDpopJwtHeader(parts[0]);
143 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
144 if (Option.isNone(header)) {
145 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT header." };
146 }
147 if (Option.isNone(payload)) {
148 return { ok: false, code: "malformed_proof", reason: "Invalid DPoP JWT payload." };
149 }
150​
151 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
152 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
153 return { ok: false, code: "key_mismatch", reason: "DPoP key thumbprint mismatch." };
154 }
155 if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {
156 return { ok: false, code: "method_mismatch", reason: "DPoP method mismatch." };
157 }
158 const normalizedHtu = normalizeDpopHtu(input.url);
159 if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {
160 return { ok: false, code: "url_mismatch", reason: "DPoP URL mismatch." };
161 }
162 if (input.expectedAccessToken) {
163 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
164 if (payload.value.ath !== expectedAth) {
165 return {
166 ok: false,
167 code: "access_token_hash_mismatch",
168 reason: "DPoP access token hash mismatch.",
169 };
170 }
171 }
172​
173 const signature = base64UrlToBytes(parts[2]);
174 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
175 const verified = p256.verify(
176 signature,
177 signatureInputHash,
178 publicKeyBytesFromJwk(header.value.jwk),
179 {
180 prehash: false,
181 format: "compact",
182 },
183 );
184 if (!verified) {
185 return { ok: false, code: "invalid_signature", reason: "Invalid DPoP signature." };
186 }
187​
188 const maxAgeSeconds = input.maxAgeSeconds ?? DPOP_MAX_CLOCK_SKEW_SECONDS;
189 if (Math.abs(input.nowEpochSeconds - payload.value.iat) > maxAgeSeconds) {
190 return {
191 ok: false,
192 code: "time_window",
193 reason: "DPoP proof is outside the allowed time window.",
194 };
195 }
196​
197 return {
198 ok: true,
199 thumbprint,
200 jti: payload.value.jti,
201 iat: payload.value.iat,
202 };
203 } catch {
204 return { ok: false, code: "invalid_proof", reason: "Invalid DPoP proof." };
205 }