History

[codex] Structure browser DPoP failures (#3277)

Julius Marminge committed 4 months ago

Commit a14abc61 parent 77a8eb22 files · +210 −29Browse files

Unified · Split

2 files
  1. apps/web/src/cloud/dpop.test.ts +85 −2
  2. apps/web/src/cloud/dpop.ts +125 −27
apps/web/src/cloud/dpop.test.ts+85−2
@@ -1,10 +1,17 @@
11 import { verifyDpopProof } from "@t3tools/shared/dpop";
22 import { describe, expect, it } from "@effect/vitest";
33 import * as Effect from "effect/Effect";
4−import { decodeJwt } from "jose";
4+import { decodeJwt, SignJWT } from "jose";
55 import { vi } from "vite-plus/test";
66
7−import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";
7+import {
8+ browserCryptoLayer,
9+ BrowserDpopKeyError,
10+ BrowserDpopProofError,
11+ createBrowserDpopProof,
12+ generateBrowserDpopKey,
13+ isBrowserDpopError,
14+} from "./dpop";
815
916 describe("browser DPoP proofs", () => {
1017 it.effect("signs relay resource proofs with an access-token hash", () =>
@@ -32,4 +39,80 @@ describe("browser DPoP proofs", () => { · 21 unmodified lines
3239 ).toMatchObject({ ok: true });
3340 }),
3441 );
42+
43+ it.effect("preserves safe invalid URL context and the parser cause", () =>
44+ Effect.gen(function* () {
45+ const proofKey = yield* generateBrowserDpopKey;
46+ const url = "http://";
47+ const error = yield* createBrowserDpopProof({
48+ method: "POST",
49+ url,
50+ proofKey,
51+ }).pipe(Effect.provide(browserCryptoLayer), Effect.flip);
52+
53+ expect(error).toBeInstanceOf(BrowserDpopProofError);
54+ expect(error).toMatchObject({
55+ operation: "normalize-url",
56+ method: "POST",
57+ requestTarget: "<invalid-url>",
58+ urlLength: url.length,
59+ thumbprint: proofKey.thumbprint,
60+ });
61+ expect(error).not.toHaveProperty("url");
62+ expect(error).not.toHaveProperty("normalizedUrl");
63+ expect(error.cause).toBeInstanceOf(Error);
64+ expect(error.message).not.toContain((error.cause as Error).message);
65+ expect(isBrowserDpopError(error)).toBe(true);
66+ }),
67+ );
68+
69+ it.effect("redacts URL credentials, query, and fragment from proof errors", () =>
70+ Effect.gen(function* () {
71+ const proofKey = yield* generateBrowserDpopKey;
72+ const cause = new Error("signing failed");
73+ const sign = vi.spyOn(SignJWT.prototype, "sign").mockRejectedValueOnce(cause);
74+ const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment";
75+
76+ const error = yield* createBrowserDpopProof({
77+ method: "POST",
78+ url,
79+ proofKey,
80+ }).pipe(Effect.provide(browserCryptoLayer), Effect.flip);
81+
82+ expect(error).toBeInstanceOf(BrowserDpopProofError);
83+ expect(error).toMatchObject({
84+ operation: "sign",
85+ method: "POST",
86+ requestTarget: "https://example.com/oauth/token",
87+ urlLength: url.length,
88+ thumbprint: proofKey.thumbprint,
89+ cause,
90+ });
91+ expect(error).not.toHaveProperty("url");
92+ expect(error).not.toHaveProperty("normalizedUrl");
93+ expect(error.message).not.toContain("user");
94+ expect(error.message).not.toContain("password");
95+ expect(error.message).not.toContain("access_token");
96+ expect(error.message).not.toContain("secret");
97+ expect(error.message).not.toContain("fragment");
98+ sign.mockRestore();
99+ }),
100+ );
101+
102+ it.effect("preserves the browser crypto cause when key generation fails", () =>
103+ Effect.gen(function* () {
104+ const cause = new Error("browser crypto unavailable");
105+ const generateKey = vi
106+ .spyOn(globalThis.crypto.subtle, "generateKey")
107+ .mockRejectedValueOnce(cause);
108+
109+ const error = yield* generateBrowserDpopKey.pipe(Effect.flip);
110+
111+ expect(error).toBeInstanceOf(BrowserDpopKeyError);
112+ expect(error.operation).toBe("generate");
113+ expect(error.cause).toBe(cause);
114+ expect(error.message).not.toContain(cause.message);
115+ generateKey.mockRestore();
116+ }),
117+ );
35118 });
apps/web/src/cloud/dpop.ts+125−27
@@ -2,9 +2,9 @@ import { · 1 unmodified line
22 computeDpopAccessTokenHash,
33 computeDpopJwkThumbprint,
44 DpopPublicJwk,
5+ redactDpopRequestTarget,
56 } from "@t3tools/shared/dpop";
67 import * as Crypto from "effect/Crypto";
7−import * as Data from "effect/Data";
88 import * as Effect from "effect/Effect";
99 import * as Layer from "effect/Layer";
1010 import * as Schema from "effect/Schema";
@@ -16,10 +16,62 @@ export interface BrowserDpopKey { · 5 unmodified lines
1616 readonly thumbprint: string;
1717 }
1818
19−export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{
20− readonly message: string;
21− readonly cause?: unknown;
22−}> {}
19+export class BrowserDpopStorageError extends Schema.TaggedErrorClass<BrowserDpopStorageError>()(
20+ "BrowserDpopStorageError",
21+ {
22+ operation: Schema.Literals(["open", "read", "write"]),
23+ databaseName: Schema.String,
24+ storeName: Schema.String,
25+ keyId: Schema.String,
26+ cause: Schema.optional(Schema.Defect()),
27+ },
28+) {
29+ override get message(): string {
30+ return `Browser DPoP key storage operation "${this.operation}" failed for database "${this.databaseName}".`;
31+ }
32+}
33+
34+export class BrowserDpopKeyError extends Schema.TaggedErrorClass<BrowserDpopKeyError>()(
35+ "BrowserDpopKeyError",
36+ {
37+ operation: Schema.Literals([
38+ "generate",
39+ "export-private",
40+ "export-public",
41+ "validate-public",
42+ "import-private",
43+ ]),
44+ cause: Schema.Defect(),
45+ },
46+) {
47+ override get message(): string {
48+ return `Browser DPoP key operation "${this.operation}" failed.`;
49+ }
50+}
51+
52+export class BrowserDpopProofError extends Schema.TaggedErrorClass<BrowserDpopProofError>()(
53+ "BrowserDpopProofError",
54+ {
55+ operation: Schema.Literals(["normalize-url", "generate-id", "sign"]),
56+ method: Schema.String,
57+ requestTarget: Schema.String,
58+ urlLength: Schema.Number,
59+ thumbprint: Schema.String,
60+ cause: Schema.Defect(),
61+ },
62+) {
63+ override get message(): string {
64+ return `Browser DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.requestTarget}.`;
65+ }
66+}
67+
68+export const BrowserDpopError = Schema.Union([
69+ BrowserDpopStorageError,
70+ BrowserDpopKeyError,
71+ BrowserDpopProofError,
72+]);
73+export type BrowserDpopError = typeof BrowserDpopError.Type;
74+export const isBrowserDpopError = Schema.is(BrowserDpopError);
2375
2476 const DPOP_DATABASE_NAME = "t3code:cloud-auth";
2577 const DPOP_DATABASE_VERSION = 1;
@@ -40,16 +92,20 @@ export const browserCryptoLayer = Layer.succeed( · 14 unmodified lines
4092 }),
4193 );
4294
43−function dpopError(message: string, cause?: unknown) {
44− return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
45−}
46−
4795 function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {
4896 return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {
4997 const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);
5098 request.addEventListener("error", () =>
5199 resume(
52− Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)),
100+ Effect.fail(
101+ new BrowserDpopStorageError({
102+ operation: "open",
103+ databaseName: DPOP_DATABASE_NAME,
104+ storeName: DPOP_KEY_STORE_NAME,
105+ keyId: DPOP_KEY_ID,
106+ ...(request.error === null ? {} : { cause: request.error }),
107+ }),
108+ ),
53109 ),
54110 );
55111 request.addEventListener("upgradeneeded", () => {
@@ -74,7 +130,17 @@ export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, · 18 unmodified lines
74130 .objectStore(DPOP_KEY_STORE_NAME)
75131 .get(DPOP_KEY_ID);
76132 request.addEventListener("error", () =>
77− resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))),
133+ resume(
134+ Effect.fail(
135+ new BrowserDpopStorageError({
136+ operation: "read",
137+ databaseName: DPOP_DATABASE_NAME,
138+ storeName: DPOP_KEY_STORE_NAME,
139+ keyId: DPOP_KEY_ID,
140+ ...(request.error === null ? {} : { cause: request.error }),
141+ }),
142+ ),
143+ ),
78144 );
79145 request.addEventListener("success", () =>
80146 resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),
@@ -97,7 +163,15 @@ export function writeStoredBrowserDpopKey( · 16 unmodified lines
97163 const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");
98164 transaction.addEventListener("error", () =>
99165 resume(
100− Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)),
166+ Effect.fail(
167+ new BrowserDpopStorageError({
168+ operation: "write",
169+ databaseName: DPOP_DATABASE_NAME,
170+ storeName: DPOP_KEY_STORE_NAME,
171+ keyId: DPOP_KEY_ID,
172+ ...(transaction.error === null ? {} : { cause: transaction.error }),
173+ }),
174+ ),
101175 ),
102176 );
103177 transaction.addEventListener("complete", () => resume(Effect.void));
@@ -114,26 +188,22 @@ export const generateBrowserDpopKey = Effect.gen(function* () { · 10 unmodified lines
114188 "sign",
115189 "verify",
116190 ]) as Promise<CryptoKeyPair>,
117− catch: (cause) => dpopError("Could not generate DPoP proof key.", cause),
191+ catch: (cause) => new BrowserDpopKeyError({ operation: "generate", cause }),
118192 });
119193 const privateJwk = yield* Effect.tryPromise({
120194 try: () => crypto.subtle.exportKey("jwk", generated.privateKey),
121− catch: (cause) => dpopError("Could not export DPoP private key.", cause),
195+ catch: (cause) => new BrowserDpopKeyError({ operation: "export-private", cause }),
122196 });
123− const publicJwk = yield* Effect.tryPromise({
197+ const encodedPublicJwk = yield* Effect.tryPromise({
124198 try: () => crypto.subtle.exportKey("jwk", generated.publicKey),
125− catch: (cause) => dpopError("Could not export DPoP public key.", cause),
126− }).pipe(
127− Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),
128− Effect.mapError((cause) =>
129− cause instanceof BrowserDpopError
130− ? cause
131− : dpopError("Generated DPoP public key is invalid.", cause),
132− ),
199+ catch: (cause) => new BrowserDpopKeyError({ operation: "export-public", cause }),
200+ });
201+ const publicJwk = yield* decodeDpopPublicJwk(encodedPublicJwk).pipe(
202+ Effect.mapError((cause) => new BrowserDpopKeyError({ operation: "validate-public", cause })),
133203 );
134204 const privateKey = yield* Effect.tryPromise({
135205 try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>,
136− catch: (cause) => dpopError("Could not import DPoP private key.", cause),
206+ catch: (cause) => new BrowserDpopKeyError({ operation: "import-private", cause }),
137207 });
138208 return {
139209 privateKey,
@@ -153,15 +223,35 @@ export function createBrowserDpopProof(input: { · 13 unmodified lines
153223 Crypto.Crypto
154224 > {
155225 return Effect.gen(function* () {
226+ const requestTarget = redactDpopRequestTarget(input.url);
227+ const urlLength = input.url.length;
156228 const normalizedUrl = yield* Effect.try({
157229 try: () => new URL(input.url),
158− catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause),
230+ catch: (cause) =>
231+ new BrowserDpopProofError({
232+ operation: "normalize-url",
233+ method: input.method,
234+ requestTarget,
235+ urlLength,
236+ thumbprint: input.proofKey.thumbprint,
237+ cause,
238+ }),
159239 });
160240 normalizedUrl.search = "";
161241 normalizedUrl.hash = "";
162242 const jti = yield* Crypto.Crypto.pipe(
163243 Effect.flatMap((crypto) => crypto.randomUUIDv4),
164− Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)),
244+ Effect.mapError(
245+ (cause) =>
246+ new BrowserDpopProofError({
247+ operation: "generate-id",
248+ method: input.method,
249+ requestTarget,
250+ urlLength,
251+ thumbprint: input.proofKey.thumbprint,
252+ cause,
253+ }),
254+ ),
165255 );
166256 const proof = yield* Effect.tryPromise({
167257 try: () =>
@@ -178,7 +268,15 @@ export function createBrowserDpopProof(input: { · 10 unmodified lines
178268 })
179269 .setIssuedAt()
180270 .sign(input.proofKey.privateKey),
181− catch: (cause) => dpopError("Could not sign DPoP proof.", cause),
271+ catch: (cause) =>
272+ new BrowserDpopProofError({
273+ operation: "sign",
274+ method: input.method,
275+ requestTarget,
276+ urlLength,
277+ thumbprint: input.proofKey.thumbprint,
278+ cause,
279+ }),
182280 });
183281 return { proof, thumbprint: input.proofKey.thumbprint };
184282 });