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 @@...@@ -1,6 +1,6 @@
1import * as NodeCrypto from "node:crypto";1import * as NodeCrypto from "node:crypto";
22
3import { describe, expect, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
44
5import {5import {
6 computeDpopAccessTokenHash,6 computeDpopAccessTokenHash,
...@@ -56,59 +56,93 @@ describe("verifyDpopProof", () => {...@@ -56,59 +56,93 @@ describe("verifyDpopProof", () => {
5656
57 it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {57 it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {
58 const thumbprint = computeDpopJwkThumbprint(publicJwk);58 const thumbprint = computeDpopJwkThumbprint(publicJwk);
59 expect(59 const result = verifyDpopProof({
60 verifyDpopProof({60 proof,
61 proof,61 method: "POST",
62 method: "POST",62 url: "https://example.com/oauth/token",
63 url: "https://example.com/oauth/token",63 nowEpochSeconds: 101,
64 nowEpochSeconds: 101,64 expectedThumbprint: thumbprint,
65 expectedThumbprint: thumbprint,65 });
66 }),66
67 ).toMatchObject({67 if (!result.ok) {
68 ok: true,68 assert.fail(result.reason);
69 thumbprint,69 }
70 jti: "proof-1",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,
71 });86 });
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.");
72 });102 });
73103
74 it("rejects method, URL, thumbprint, and time-window mismatches", () => {104 it("rejects method, URL, thumbprint, and time-window mismatches", () => {
75 const thumbprint = computeDpopJwkThumbprint(publicJwk);105 const thumbprint = computeDpopJwkThumbprint(publicJwk);
76 expect(106 assert.equal(
77 verifyDpopProof({107 verifyDpopProof({
78 proof,108 proof,
79 method: "GET",109 method: "GET",
80 url: "https://example.com/oauth/token",110 url: "https://example.com/oauth/token",
81 nowEpochSeconds: 101,111 nowEpochSeconds: 101,
82 expectedThumbprint: thumbprint,112 expectedThumbprint: thumbprint,
83 }),113 }).ok,
84 ).toMatchObject({ ok: false });114 false,
85 expect(115 );
116 assert.equal(
86 verifyDpopProof({117 verifyDpopProof({
87 proof,118 proof,
88 method: "POST",119 method: "POST",
89 url: "https://example.com/other",120 url: "https://example.com/other",
90 nowEpochSeconds: 101,121 nowEpochSeconds: 101,
91 expectedThumbprint: thumbprint,122 expectedThumbprint: thumbprint,
92 }),123 }).ok,
93 ).toMatchObject({ ok: false });124 false,
94 expect(125 );
126 assert.equal(
95 verifyDpopProof({127 verifyDpopProof({
96 proof,128 proof,
97 method: "POST",129 method: "POST",
98 url: "https://example.com/oauth/token",130 url: "https://example.com/oauth/token",
99 nowEpochSeconds: 101,131 nowEpochSeconds: 101,
100 expectedThumbprint: "other-thumbprint",132 expectedThumbprint: "other-thumbprint",
101 }),133 }).ok,
102 ).toMatchObject({ ok: false });134 false,
103 expect(135 );
136 assert.equal(
104 verifyDpopProof({137 verifyDpopProof({
105 proof,138 proof,
106 method: "POST",139 method: "POST",
107 url: "https://example.com/oauth/token",140 url: "https://example.com/oauth/token",
108 nowEpochSeconds: 1_000,141 nowEpochSeconds: 1_000,
109 expectedThumbprint: thumbprint,142 expectedThumbprint: thumbprint,
110 }),143 }).ok,
111 ).toMatchObject({ ok: false });144 false,
145 );
112 });146 });
113147
114 it("requires the RFC 9449 access token hash when an access token is expected", () => {148 it("requires the RFC 9449 access token hash when an access token is expected", () => {
...@@ -122,7 +156,7 @@ describe("verifyDpopProof", () => {...@@ -122,7 +156,7 @@ describe("verifyDpopProof", () => {
122 accessToken: "clerk-access-token",156 accessToken: "clerk-access-token",
123 });157 });
124158
125 expect(159 assert.equal(
126 verifyDpopProof({160 verifyDpopProof({
127 proof: accessTokenProof,161 proof: accessTokenProof,
128 method: "POST",162 method: "POST",
...@@ -130,32 +164,40 @@ describe("verifyDpopProof", () => {...@@ -130,32 +164,40 @@ describe("verifyDpopProof", () => {
130 nowEpochSeconds: 101,164 nowEpochSeconds: 101,
131 expectedThumbprint: thumbprint,165 expectedThumbprint: thumbprint,
132 expectedAccessToken: "clerk-access-token",166 expectedAccessToken: "clerk-access-token",
133 }),167 }).ok,
134 ).toMatchObject({ ok: true });168 true,
135 expect(169 );
136 verifyDpopProof({170
137 proof,171 const missingHash = verifyDpopProof({
138 method: "POST",172 proof,
139 url: "https://example.com/oauth/token",173 method: "POST",
140 nowEpochSeconds: 101,174 url: "https://example.com/oauth/token",
141 expectedThumbprint: thumbprint,175 nowEpochSeconds: 101,
142 expectedAccessToken: "clerk-access-token",176 expectedThumbprint: thumbprint,
143 }),177 expectedAccessToken: "clerk-access-token",
144 ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });178 });
145 expect(179 if (missingHash.ok) {
146 verifyDpopProof({180 assert.fail("Expected DPoP proof without an access token hash to fail.");
147 proof: accessTokenProof,181 }
148 method: "POST",182 assert.equal(missingHash.reason, "DPoP access token hash mismatch.");
149 url: "https://example.com/v1/environments/env/connect",183
150 nowEpochSeconds: 101,184 const mismatchedHash = verifyDpopProof({
151 expectedThumbprint: thumbprint,185 proof: accessTokenProof,
152 expectedAccessToken: "other-access-token",186 method: "POST",
153 }),187 url: "https://example.com/v1/environments/env/connect",
154 ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });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.");
155 });196 });
156197
157 it("normalizes htu by excluding query and fragment components per RFC 9449", () => {198 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"),
159 "https://example.com/v1/environments/env/connect",201 "https://example.com/v1/environments/env/connect",
160 );202 );
161203
...@@ -168,15 +210,16 @@ describe("verifyDpopProof", () => {...@@ -168,15 +210,16 @@ describe("verifyDpopProof", () => {
168 publicJwk,210 publicJwk,
169 });211 });
170212
171 expect(213 assert.equal(
172 verifyDpopProof({214 verifyDpopProof({
173 proof: queryProof,215 proof: queryProof,
174 method: "POST",216 method: "POST",
175 url: "https://example.com/v1/environments/env/connect?foo=bar#frag",217 url: "https://example.com/v1/environments/env/connect?foo=bar#frag",
176 nowEpochSeconds: 101,218 nowEpochSeconds: 101,
177 expectedThumbprint: thumbprint,219 expectedThumbprint: thumbprint,
178 }),220 }).ok,
179 ).toMatchObject({ ok: true });221 true,
222 );
180 });223 });
181224
182 it("rejects DPoP public JWK headers that expose private key material", () => {225 it("rejects DPoP public JWK headers that expose private key material", () => {
...@@ -192,14 +235,17 @@ describe("verifyDpopProof", () => {...@@ -192,14 +235,17 @@ describe("verifyDpopProof", () => {
192 publicJwk: privateJwk,235 publicJwk: privateJwk,
193 });236 });
194237
195 expect(238 const result = verifyDpopProof({
196 verifyDpopProof({239 proof: proofWithPrivateJwk,
197 proof: proofWithPrivateJwk,240 method: "POST",
198 method: "POST",241 url: "https://example.com/oauth/token",
199 url: "https://example.com/oauth/token",242 nowEpochSeconds: 101,
200 nowEpochSeconds: 101,243 expectedThumbprint: thumbprint,
201 expectedThumbprint: thumbprint,244 });
202 }),245
203 ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." });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.");
204 });250 });
205});251});
packages/shared/src/dpop.ts+49-61
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { p256 } from "@noble/curves/nist";1import { p256 } from "@noble/curves/nist";
2import { sha256 } from "@noble/hashes/sha2";2import { sha256 } from "@noble/hashes/sha2";
3import * as Encoding from "effect/Encoding";3import * as Encoding from "effect/Encoding";
4import * as Option from "effect/Option";
4import * as Result from "effect/Result";5import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
67
...@@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({...@@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({
17 y: Schema.String.check(Schema.isNonEmpty()),18 y: Schema.String.check(Schema.isNonEmpty()),
18});19});
19export type DpopPublicJwk = typeof DpopPublicJwk.Type;20export type DpopPublicJwk = typeof DpopPublicJwk.Type;
20const isDpopPublicJwk = Schema.is(DpopPublicJwk);
2121
22interface DpopJwtHeader {22const DpopJwtHeaderPublicJwk = Schema.Struct({
23 readonly typ: string;23 ...DpopPublicJwk.fields,
24 readonly alg: string;24 d: Schema.optionalKey(Schema.Never),
25 readonly jwk: DpopPublicJwk;25});
26}
2726
28interface DpopJwtPayload {27const DpopJwtHeaderJson = Schema.fromJsonString(
29 readonly htm: string;28 Schema.Struct({
30 readonly htu: string;29 typ: Schema.Literal(DPOP_TYP),
31 readonly jti: string;30 alg: Schema.Literal(DPOP_ALG),
32 readonly iat: number;31 jwk: DpopJwtHeaderPublicJwk,
33 readonly ath?: string;32 }),
34}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
36export type DpopVerificationResult =47export type DpopVerificationResult =
37 | {48 | {
...@@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array {...@@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array {
49 return Result.getOrThrow(Encoding.decodeBase64Url(value));60 return Result.getOrThrow(Encoding.decodeBase64Url(value));
50}61}
5162
52function decodeBase64UrlJson(value: string): unknown {63function decodeBase64UrlDpopJwtHeader(value: string) {
53 return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown;64 return decodeDpopJwtHeaderJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
54}65}
5566
56function isDpopJwtHeader(value: unknown): value is DpopJwtHeader {67function decodeBase64UrlDpopJwtPayload(value: string) {
57 if (typeof value !== "object" || value === null) {68 return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
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 );
86}69}
8770
88function dpopThumbprintInput(jwk: DpopPublicJwk): string {71function dpopThumbprintInput(jwk: DpopPublicJwk): string {
...@@ -145,53 +128,58 @@ export function verifyDpopProof(input: {...@@ -145,53 +128,58 @@ export function verifyDpopProof(input: {
145 }128 }
146129
147 try {130 try {
148 const header = decodeBase64UrlJson(parts[0]);131 const header = decodeBase64UrlDpopJwtHeader(parts[0]);
149 const payload = decodeBase64UrlJson(parts[1]);132 const payload = decodeBase64UrlDpopJwtPayload(parts[1]);
150 if (!isDpopJwtHeader(header)) {133 if (Option.isNone(header)) {
151 return { ok: false, reason: "Invalid DPoP JWT header." };134 return { ok: false, reason: "Invalid DPoP JWT header." };
152 }135 }
153 if (!isDpopJwtPayload(payload)) {136 if (Option.isNone(payload)) {
154 return { ok: false, reason: "Invalid DPoP JWT payload." };137 return { ok: false, reason: "Invalid DPoP JWT payload." };
155 }138 }
156139
157 const thumbprint = computeDpopJwkThumbprint(header.jwk);140 const thumbprint = computeDpopJwkThumbprint(header.value.jwk);
158 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {141 if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
159 return { ok: false, reason: "DPoP key thumbprint mismatch." };142 return { ok: false, reason: "DPoP key thumbprint mismatch." };
160 }143 }
161 if (payload.htm.toUpperCase() !== input.method.toUpperCase()) {144 if (payload.value.htm.toUpperCase() !== input.method.toUpperCase()) {
162 return { ok: false, reason: "DPoP method mismatch." };145 return { ok: false, reason: "DPoP method mismatch." };
163 }146 }
164 const normalizedHtu = normalizeDpopHtu(input.url);147 const normalizedHtu = normalizeDpopHtu(input.url);
165 if (normalizedHtu === null || payload.htu !== normalizedHtu) {148 if (normalizedHtu === null || payload.value.htu !== normalizedHtu) {
166 return { ok: false, reason: "DPoP URL mismatch." };149 return { ok: false, reason: "DPoP URL mismatch." };
167 }150 }
168 if (input.expectedAccessToken) {151 if (input.expectedAccessToken) {
169 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);152 const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken);
170 if (payload.ath !== expectedAth) {153 if (payload.value.ath !== expectedAth) {
171 return { ok: false, reason: "DPoP access token hash mismatch." };154 return { ok: false, reason: "DPoP access token hash mismatch." };
172 }155 }
173 }156 }
174157
175 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;158 const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
176 if (159 if (
177 payload.iat > input.nowEpochSeconds + 5 ||160 payload.value.iat > input.nowEpochSeconds + 5 ||
178 input.nowEpochSeconds - payload.iat > maxAgeSeconds161 input.nowEpochSeconds - payload.value.iat > maxAgeSeconds
179 ) {162 ) {
180 return { ok: false, reason: "DPoP proof is outside the allowed time window." };163 return { ok: false, reason: "DPoP proof is outside the allowed time window." };
181 }164 }
182165
183 const signature = base64UrlToBytes(parts[2]);166 const signature = base64UrlToBytes(parts[2]);
184 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));167 const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
185 const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), {168 const verified = p256.verify(
186 prehash: false,169 signature,
187 format: "compact",170 signatureInputHash,
188 });171 publicKeyBytesFromJwk(header.value.jwk),
172 {
173 prehash: false,
174 format: "compact",
175 },
176 );
189 return verified177 return verified
190 ? {178 ? {
191 ok: true,179 ok: true,
192 thumbprint,180 thumbprint,
193 jti: payload.jti,181 jti: payload.value.jti,
194 iat: payload.iat,182 iat: payload.value.iat,
195 }183 }
196 : { ok: false, reason: "Invalid DPoP signature." };184 : { ok: false, reason: "Invalid DPoP signature." };
197 } catch {185 } catch {