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 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";1import { verifyDpopProof } from "@t3tools/shared/dpop";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import { decodeJwt } from "jose";4import { decodeJwt, SignJWT } from "jose";
5import { vi } from "vite-plus/test";5import { vi } from "vite-plus/test";
6 6
7import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";7import {
8 browserCryptoLayer,
9 BrowserDpopKeyError,
10 BrowserDpopProofError,
11 createBrowserDpopProof,
12 generateBrowserDpopKey,
13 isBrowserDpopError,
14} from "./dpop";
8 15
9describe("browser DPoP proofs", () => {16describe("browser DPoP proofs", () => {
10 it.effect("signs relay resource proofs with an access-token hash", () =>17 it.effect("signs relay resource proofs with an access-token hash", () =>
@@ -32,4 +39,80 @@ describe("browser DPoP proofs", () => { · 21 unmodified lines
32 ).toMatchObject({ ok: true });39 ).toMatchObject({ ok: true });
33 }),40 }),
34 );41 );
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 );
35});118});
apps/web/src/cloud/dpop.ts+125−27
@@ -2,9 +2,9 @@ import { · 1 unmodified line
2 computeDpopAccessTokenHash,2 computeDpopAccessTokenHash,
3 computeDpopJwkThumbprint,3 computeDpopJwkThumbprint,
4 DpopPublicJwk,4 DpopPublicJwk,
5 redactDpopRequestTarget,
5} from "@t3tools/shared/dpop";6} from "@t3tools/shared/dpop";
6import * as Crypto from "effect/Crypto";7import * as Crypto from "effect/Crypto";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
@@ -16,10 +16,62 @@ export interface BrowserDpopKey { · 5 unmodified lines
16 readonly thumbprint: string;16 readonly thumbprint: string;
17}17}
18 18
19export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{19export class BrowserDpopStorageError extends Schema.TaggedErrorClass<BrowserDpopStorageError>()(
20 readonly message: string;20 "BrowserDpopStorageError",
21 readonly cause?: unknown;21 {
22}> {}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
34export 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
52export 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
68export const BrowserDpopError = Schema.Union([
69 BrowserDpopStorageError,
70 BrowserDpopKeyError,
71 BrowserDpopProofError,
72]);
73export type BrowserDpopError = typeof BrowserDpopError.Type;
74export const isBrowserDpopError = Schema.is(BrowserDpopError);
23 75
24const DPOP_DATABASE_NAME = "t3code:cloud-auth";76const DPOP_DATABASE_NAME = "t3code:cloud-auth";
25const DPOP_DATABASE_VERSION = 1;77const DPOP_DATABASE_VERSION = 1;
@@ -40,16 +92,20 @@ export const browserCryptoLayer = Layer.succeed( · 14 unmodified lines
40 }),92 }),
41);93);
42 94
43function dpopError(message: string, cause?: unknown) {
44 return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
45}
46
47function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {95function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {
48 return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {96 return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {
49 const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);97 const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);
50 request.addEventListener("error", () =>98 request.addEventListener("error", () =>
51 resume(99 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 ),
53 ),109 ),
54 );110 );
55 request.addEventListener("upgradeneeded", () => {111 request.addEventListener("upgradeneeded", () => {
@@ -74,7 +130,17 @@ export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, · 18 unmodified lines
74 .objectStore(DPOP_KEY_STORE_NAME)130 .objectStore(DPOP_KEY_STORE_NAME)
75 .get(DPOP_KEY_ID);131 .get(DPOP_KEY_ID);
76 request.addEventListener("error", () =>132 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 ),
78 );144 );
79 request.addEventListener("success", () =>145 request.addEventListener("success", () =>
80 resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),146 resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),
@@ -97,7 +163,15 @@ export function writeStoredBrowserDpopKey( · 16 unmodified lines
97 const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");163 const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");
98 transaction.addEventListener("error", () =>164 transaction.addEventListener("error", () =>
99 resume(165 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 ),
101 ),175 ),
102 );176 );
103 transaction.addEventListener("complete", () => resume(Effect.void));177 transaction.addEventListener("complete", () => resume(Effect.void));
@@ -114,26 +188,22 @@ export const generateBrowserDpopKey = Effect.gen(function* () { · 10 unmodified lines
114 "sign",188 "sign",
115 "verify",189 "verify",
116 ]) as Promise<CryptoKeyPair>,190 ]) as Promise<CryptoKeyPair>,
117 catch: (cause) => dpopError("Could not generate DPoP proof key.", cause),191 catch: (cause) => new BrowserDpopKeyError({ operation: "generate", cause }),
118 });192 });
119 const privateJwk = yield* Effect.tryPromise({193 const privateJwk = yield* Effect.tryPromise({
120 try: () => crypto.subtle.exportKey("jwk", generated.privateKey),194 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 }),
122 });196 });
123 const publicJwk = yield* Effect.tryPromise({197 const encodedPublicJwk = yield* Effect.tryPromise({
124 try: () => crypto.subtle.exportKey("jwk", generated.publicKey),198 try: () => crypto.subtle.exportKey("jwk", generated.publicKey),
125 catch: (cause) => dpopError("Could not export DPoP public key.", cause),199 catch: (cause) => new BrowserDpopKeyError({ operation: "export-public", cause }),
126 }).pipe(200 });
127 Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),201 const publicJwk = yield* decodeDpopPublicJwk(encodedPublicJwk).pipe(
128 Effect.mapError((cause) =>202 Effect.mapError((cause) => new BrowserDpopKeyError({ operation: "validate-public", cause })),
129 cause instanceof BrowserDpopError
130 ? cause
131 : dpopError("Generated DPoP public key is invalid.", cause),
132 ),
133 );203 );
134 const privateKey = yield* Effect.tryPromise({204 const privateKey = yield* Effect.tryPromise({
135 try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>,205 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 }),
137 });207 });
138 return {208 return {
139 privateKey,209 privateKey,
@@ -153,15 +223,35 @@ export function createBrowserDpopProof(input: { · 13 unmodified lines
153 Crypto.Crypto223 Crypto.Crypto
154> {224> {
155 return Effect.gen(function* () {225 return Effect.gen(function* () {
226 const requestTarget = redactDpopRequestTarget(input.url);
227 const urlLength = input.url.length;
156 const normalizedUrl = yield* Effect.try({228 const normalizedUrl = yield* Effect.try({
157 try: () => new URL(input.url),229 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 }),
159 });239 });
160 normalizedUrl.search = "";240 normalizedUrl.search = "";
161 normalizedUrl.hash = "";241 normalizedUrl.hash = "";
162 const jti = yield* Crypto.Crypto.pipe(242 const jti = yield* Crypto.Crypto.pipe(
163 Effect.flatMap((crypto) => crypto.randomUUIDv4),243 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 ),
165 );255 );
166 const proof = yield* Effect.tryPromise({256 const proof = yield* Effect.tryPromise({
167 try: () =>257 try: () =>
@@ -178,7 +268,15 @@ export function createBrowserDpopProof(input: { · 10 unmodified lines
178 })268 })
179 .setIssuedAt()269 .setIssuedAt()
180 .sign(input.proofKey.privateKey),270 .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 }),
182 });280 });
183 return { proof, thumbprint: input.proofKey.thumbprint };281 return { proof, thumbprint: input.proofKey.thumbprint };
184 });282 });