History

Use Effect schema decoders for JSON parsing (#3060)

cursor[bot] committed 4 months ago

Commit c08b9681 parent d9f59be2 files · +156 −122Browse files

Unified · Split

2 files
  1. packages/shared/src/dpop.test.ts +107 −61
  2. packages/shared/src/dpop.ts +49 −61
packages/shared/src/dpop.test.ts+107−61
@@ -1,6 +1,6 @@
11 import * as NodeCrypto from "node:crypto";
22
3−import { describe, expect, it } from "@effect/vitest";
3+import { assert, describe, it } from "@effect/vitest";
44
55 import {
66 computeDpopAccessTokenHash,
@@ -56,59 +56,93 @@ describe("verifyDpopProof", () => { · 49 unmodified lines
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", () => { · 7 unmodified lines
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", () => { · 1 unmodified line
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", () => { · 6 unmodified lines
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", () => { · 9 unmodified lines
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 @@
11 import { p256 } from "@noble/curves/nist";
22 import { sha256 } from "@noble/hashes/sha2";
33 import * as Encoding from "effect/Encoding";
4+import * as Option from "effect/Option";
45 import * as Result from "effect/Result";
56 import * as Schema from "effect/Schema";
67
@@ -17,21 +18,31 @@ export const DpopPublicJwk = Schema.Struct({ · 10 unmodified lines
1718 y: Schema.String.check(Schema.isNonEmpty()),
1819 });
1920 export type DpopPublicJwk = typeof DpopPublicJwk.Type;
20−const isDpopPublicJwk = Schema.is(DpopPublicJwk);
2121
22−interface DpopJwtHeader {
23− readonly typ: string;
24− readonly alg: string;
25− readonly jwk: DpopPublicJwk;
26−}
22+const DpopJwtHeaderPublicJwk = Schema.Struct({
23+ ...DpopPublicJwk.fields,
24+ d: Schema.optionalKey(Schema.Never),
25+});
2726
28−interface DpopJwtPayload {
29− readonly htm: string;
30− readonly htu: string;
31− readonly jti: string;
32− readonly iat: number;
33− readonly ath?: string;
34−}
27+const DpopJwtHeaderJson = Schema.fromJsonString(
28+ Schema.Struct({
29+ typ: Schema.Literal(DPOP_TYP),
30+ alg: Schema.Literal(DPOP_ALG),
31+ jwk: DpopJwtHeaderPublicJwk,
32+ }),
33+);
34+const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(DpopJwtHeaderJson);
35+
36+const 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+);
45+const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(DpopJwtPayloadJson);
3546
3647 export type DpopVerificationResult =
3748 | {
@@ -49,40 +60,12 @@ function base64UrlToBytes(value: string): Uint8Array { · 11 unmodified lines
4960 return Result.getOrThrow(Encoding.decodeBase64Url(value));
5061 }
5162
52−function decodeBase64UrlJson(value: string): unknown {
53− return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown;
63+function decodeBase64UrlDpopJwtHeader(value: string) {
64+ return decodeDpopJwtHeaderJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
5465 }
5566
56−function 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−
71−function 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− );
67+function decodeBase64UrlDpopJwtPayload(value: string) {
68+ return decodeDpopJwtPayloadJson(Result.getOrThrow(Encoding.decodeBase64UrlString(value)));
8669 }
8770
8871 function dpopThumbprintInput(jwk: DpopPublicJwk): string {
@@ -145,53 +128,58 @@ export function verifyDpopProof(input: { · 56 unmodified lines
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 {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t