authorgravatar for 206951365+cursor[bot]@users.noreply.github.comcursor[bot] <206951365+cursor[bot]@users.noreply.github.com> 2026-06-19 11:48:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 11:48:24-07:00
logc08b968f4e8078027b35752a1bc980cb03ac8fd8
tree2c0e06950d84b5f79a0efcd5c4434d3f327791da
parentd9f59be7044a7f6d193e73ff9fa06127009e0c91
signature Signed by PGP key B5690EEEBB952194

Use Effect schema decoders for JSON parsing (#3060)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Julius Marminge <juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 156 insertions(+), 122 deletions(-)

packages/shared/src/dpop.test.ts+107-61
......@@ -1,6 +1,6 @@
11import * as NodeCrypto from "node:crypto";
22
3import { describe, expect, it } from "@effect/vitest";
3import { assert, describe, it } from "@effect/vitest";
44
55import {
66 computeDpopAccessTokenHash,
......@@ -56,59 +56,93 @@ describe("verifyDpopProof", () => {
5656
5757 it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {
5858 const thumbprint = computeDpopJwkThumbprint(publicJwk);
59 expect(
60 verifyDpopProof({
61 proof,
62 method: "POST",
63 url: "https://example.com/oauth/token",
64 nowEpochSeconds: 101,
65 expectedThumbprint: thumbprint,
66 }),
67 ).toMatchObject({
68 ok: true,
69 thumbprint,
70 jti: "proof-1",
59 const result = verifyDpopProof({
60 proof,
61 method: "POST",
62 url: "https://example.com/oauth/token",
63 nowEpochSeconds: 101,
64 expectedThumbprint: thumbprint,
65 });
66
67 if (!result.ok) {
68 assert.fail(result.reason);
69 }
70 assert.equal(result.thumbprint, thumbprint);
71 assert.equal(result.jti, "proof-1");
72 });
73
74 it("rejects malformed DPoP header and payload JSON", () => {
75 const [header, payload, signature] = proof.split(".");
76 if (!header || !payload || !signature) {
77 assert.fail("Expected the test DPoP proof to use compact JWT format.");
78 }
79 const malformedJson = Buffer.from("{").toString("base64url");
80
81 const malformedHeader = verifyDpopProof({
82 proof: `${malformedJson}.${payload}.${signature}`,
83 method: "POST",
84 url: "https://example.com/oauth/token",
85 nowEpochSeconds: 101,
7186 });
87 if (malformedHeader.ok) {
88 assert.fail("Expected malformed DPoP header JSON to fail.");
89 }
90 assert.equal(malformedHeader.reason, "Invalid DPoP JWT header.");
91
92 const malformedPayload = verifyDpopProof({
93 proof: `${header}.${malformedJson}.${signature}`,
94 method: "POST",
95 url: "https://example.com/oauth/token",
96 nowEpochSeconds: 101,
97 });
98 if (malformedPayload.ok) {
99 assert.fail("Expected malformed DPoP payload JSON to fail.");
100 }
101 assert.equal(malformedPayload.reason, "Invalid DPoP JWT payload.");
72102 });
73103
74104 it("rejects method, URL, thumbprint, and time-window mismatches", () => {
75105 const thumbprint = computeDpopJwkThumbprint(publicJwk);
76 expect(
106 assert.equal(
77107 verifyDpopProof({
78108 proof,
79109 method: "GET",
80110 url: "https://example.com/oauth/token",
81111 nowEpochSeconds: 101,
82112 expectedThumbprint: thumbprint,
83 }),
84 ).toMatchObject({ ok: false });
85 expect(
113 }).ok,
114 false,
115 );
116 assert.equal(
86117 verifyDpopProof({
87118 proof,
88119 method: "POST",
89120 url: "https://example.com/other",
90121 nowEpochSeconds: 101,
91122 expectedThumbprint: thumbprint,
92 }),
93 ).toMatchObject({ ok: false });
94 expect(
123 }).ok,
124 false,
125 );
126 assert.equal(
95127 verifyDpopProof({
96128 proof,
97129 method: "POST",
98130 url: "https://example.com/oauth/token",
99131 nowEpochSeconds: 101,
100132 expectedThumbprint: "other-thumbprint",
101 }),
102 ).toMatchObject({ ok: false });
103 expect(
133 }).ok,
134 false,
135 );
136 assert.equal(
104137 verifyDpopProof({
105138 proof,
106139 method: "POST",
107140 url: "https://example.com/oauth/token",
108141 nowEpochSeconds: 1_000,
109142 expectedThumbprint: thumbprint,
110 }),
111 ).toMatchObject({ ok: false });
143 }).ok,
144 false,
145 );
112146 });
113147
114148 it("requires the RFC 9449 access token hash when an access token is expected", () => {
......@@ -122,7 +156,7 @@ describe("verifyDpopProof", () => {
122156 accessToken: "clerk-access-token",
123157 });
124158
125 expect(
159 assert.equal(
126160 verifyDpopProof({
127161 proof: accessTokenProof,
128162 method: "POST",
......@@ -130,32 +164,40 @@ describe("verifyDpopProof", () => {
130164 nowEpochSeconds: 101,
131165 expectedThumbprint: thumbprint,
132166 expectedAccessToken: "clerk-access-token",
133 }),
134 ).toMatchObject({ ok: true });
135 expect(
136 verifyDpopProof({
137 proof,
138 method: "POST",
139 url: "https://example.com/oauth/token",
140 nowEpochSeconds: 101,
141 expectedThumbprint: thumbprint,
142 expectedAccessToken: "clerk-access-token",
143 }),
144 ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });
145 expect(
146 verifyDpopProof({
147 proof: accessTokenProof,
148 method: "POST",
149 url: "https://example.com/v1/environments/env/connect",
150 nowEpochSeconds: 101,
151 expectedThumbprint: thumbprint,
152 expectedAccessToken: "other-access-token",
153 }),
154 ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });
167 }).ok,
168 true,
169 );
170
171 const missingHash = verifyDpopProof({
172 proof,
173 method: "POST",
174 url: "https://example.com/oauth/token",
175 nowEpochSeconds: 101,
176 expectedThumbprint: thumbprint,
177 expectedAccessToken: "clerk-access-token",
178 });
179 if (missingHash.ok) {
180 assert.fail("Expected DPoP proof without an access token hash to fail.");
181 }
182 assert.equal(missingHash.reason, "DPoP access token hash mismatch.");
183
184 const mismatchedHash = verifyDpopProof({
185 proof: accessTokenProof,
186 method: "POST",
187 url: "https://example.com/v1/environments/env/connect",
188 nowEpochSeconds: 101,
189 expectedThumbprint: thumbprint,
190 expectedAccessToken: "other-access-token",
191 });
192 if (mismatchedHash.ok) {
193 assert.fail("Expected DPoP proof with a mismatched access token hash to fail.");
194 }
195 assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch.");
155196 });
156197
157198 it("normalizes htu by excluding query and fragment components per RFC 9449", () => {
158 expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe(
199 assert.equal(
200 normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"),
159201 "https://example.com/v1/environments/env/connect",
160202 );
161203
......@@ -168,15 +210,16 @@ describe("verifyDpopProof", () => {
168210 publicJwk,
169211 });
170212
171 expect(
213 assert.equal(
172214 verifyDpopProof({
173215 proof: queryProof,
174216 method: "POST",
175217 url: "https://example.com/v1/environments/env/connect?foo=bar#frag",
176218 nowEpochSeconds: 101,
177219 expectedThumbprint: thumbprint,
178 }),
179 ).toMatchObject({ ok: true });
220 }).ok,
221 true,
222 );
180223 });
181224
182225 it("rejects DPoP public JWK headers that expose private key material", () => {
......@@ -192,14 +235,17 @@ describe("verifyDpopProof", () => {
192235 publicJwk: privateJwk,
193236 });
194237
195 expect(
196 verifyDpopProof({
197 proof: proofWithPrivateJwk,
198 method: "POST",
199 url: "https://example.com/oauth/token",
200 nowEpochSeconds: 101,
201 expectedThumbprint: thumbprint,
202 }),
203 ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." });
238 const result = verifyDpopProof({
239 proof: proofWithPrivateJwk,
240 method: "POST",
241 url: "https://example.com/oauth/token",
242 nowEpochSeconds: 101,
243 expectedThumbprint: thumbprint,
244 });
245
246 if (result.ok) {
247 assert.fail("Expected DPoP proof with private JWK material to fail.");
248 }
249 assert.equal(result.reason, "Invalid DPoP JWT header.");
204250 });
205251});
packages/shared/src/dpop.ts+49-61
......@@ -1,6 +1,7 @@
11import { p256 } from "@noble/curves/nist";
22import { sha256 } from "@noble/hashes/sha2";
33import * as Encoding from "effect/Encoding";
4import * as Option from "effect/Option";
45import * as Result from "effect/Result";
56import * as Schema from "effect/Schema";
67
......@@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({
1718 y: Schema.String.check(Schema.isNonEmpty()),
1819});
1920export type DpopPublicJwk = typeof DpopPublicJwk.Type;
20const isDpopPublicJwk = Schema.is(DpopPublicJwk);
2121
22interface DpopJwtHeader {
23 readonly typ: string;
24 readonly alg: string;
25 readonly jwk: DpopPublicJwk;
26}
22const DpopJwtHeaderPublicJwk = Schema.Struct({
23 ...DpopPublicJwk.fields,
24 d: Schema.optionalKey(Schema.Never),
25});
2726
28interface DpopJwtPayload {
29 readonly htm: string;
30 readonly htu: string;
31 readonly jti: string;
32 readonly iat: number;
33 readonly ath?: string;
34}
27const DpopJwtHeaderJson = Schema.fromJsonString(
28 Schema.Struct({
29 typ: Schema.Literal(DPOP_TYP),
30 alg: Schema.Literal(DPOP_ALG),
31 jwk: DpopJwtHeaderPublicJwk,
32 }),
33);
34const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson);
35
36const DpopJwtPayloadJson = Schema.fromJsonString(
37 Schema.Struct({
38 htm: Schema.String.check(Schema.isNonEmpty()),
39 htu: Schema.String.check(Schema.isNonEmpty()),
40 jti: Schema.String.check(Schema.isNonEmpty()),
41 iat: Schema.Int,
42 ath: Schema.optionalKey(Schema.String),
43 }),
44);
45const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson);
3546
3647export type DpopVerificationResult =
3748 | {
......@@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array {
4960 return Result.getOrThrow(Encoding.decodeBase64Url(value));
5061}
5162
52function decodeBase64UrlJson(value: string): unknown {
53 return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown;
63function decodeBase64UrlDpopJwtHeader(value: string) {
64 return decodeDpopJwtHeaderJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
5465}
5566
56function isDpopJwtHeader(value: unknown): value is DpopJwtHeader {
57 if (typeof value !== "object" || value === null) {
58 return false;
59 }
60 const record = value as Record<string, unknown>;
61 return (
62 record.typ === DPOP_TYP &&
63 record.alg === DPOP_ALG &&
64 typeof record.jwk === "object" &&
65 record.jwk !== null &&
66 !("d" in record.jwk) &&
67 isDpopPublicJwk(record.jwk)
68 );
69}
70
71function isDpopJwtPayload(value: unknown): value is DpopJwtPayload {
72 if (typeof value !== "object" || value === null) {
73 return false;
74 }
75 const record = value as Record<string, unknown>;
76 return (
77 typeof record.htm === "string" &&
78 record.htm.length > 0 &&
79 typeof record.htu === "string" &&
80 record.htu.length > 0 &&
81 typeof record.jti === "string" &&
82 record.jti.length > 0 &&
83 typeof record.iat === "number" &&
84 Number.isInteger(record.iat)
85 );
67function decodeBase64UrlDpopJwtPayload(value: string) {
68 return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
8669}
8770
8871function dpopThumbprintInput(jwk: DpopPublicJwk): string {
......@@ -145,53 +128,58 @@ export function verifyDpopProof(input: {
145128 }
146129
147130 try {
148 const header = decodeBase64UrlJson(parts[0]);
149 const payload = decodeBase64UrlJson(parts[1]);
150 if (!isDpopJwtHeader(header)) {
131 const header = decodeBase64UrlDpopJwtHeader(parts[0]);
132 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
133 if (Option.isNone(header)) {
151134 return { ok: false, reason: "Invalid DPoP JWT header." };
152135 }
153 if (!isDpopJwtPayload(payload)) {
136 if (Option.isNone(payload)) {
154137 return { ok: false, reason: "Invalid DPoP JWT payload." };
155138 }
156139
157 const thumbprint = computeDpopJwkThumbprint(header.jwk);
140 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
158141 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
159142 return { ok: false, reason: "DPoP key thumbprint mismatch." };
160143 }
161 if (payload.htm.toUpperCase() !== input.method.toUpperCase()) {
144 if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {
162145 return { ok: false, reason: "DPoP method mismatch." };
163146 }
164147 const normalizedHtu = normalizeDpopHtu(input.url);
165 if (normalizedHtu === null || payload.htu !== normalizedHtu) {
148 if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {
166149 return { ok: false, reason: "DPoP URL mismatch." };
167150 }
168151 if (input.expectedAccessToken) {
169152 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
170 if (payload.ath !== expectedAth) {
153 if (payload.value.ath !== expectedAth) {
171154 return { ok: false, reason: "DPoP access token hash mismatch." };
172155 }
173156 }
174157
175158 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
176159 if (
177 payload.iat > input.nowEpochSeconds + 5 ||
178 input.nowEpochSeconds - payload.iat > maxAgeSeconds
160 payload.value.iat > input.nowEpochSeconds + 5 ||
161 input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
179162 ) {
180163 return { ok: false, reason: "DPoP proof is outside the allowed time window." };
181164 }
182165
183166 const signature = base64UrlToBytes(parts[2]);
184167 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
185 const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), {
186 prehash: false,
187 format: "compact",
188 });
168 const verified = p256.verify(
169 signature,
170 signatureInputHash,
171 publicKeyBytesFromJwk(header.value.jwk),
172 {
173 prehash: false,
174 format: "compact",
175 },
176 );
189177 return verified
190178 ? {
191179 ok: true,
192180 thumbprint,
193 jti: payload.jti,
194 iat: payload.iat,
181 jti: payload.value.jti,
182 iat: payload.value.iat,
195183 }
196184 : { ok: false, reason: "Invalid DPoP signature." };
197185 } catch {