From aa922509a7d30f4b48557e293b6b6a4272fbd203 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:42:15 -0700 Subject: [PATCH 1/3] refactor(web): structure browser DPoP errors Co-authored-by: codex --- apps/web/src/cloud/dpop.test.ts | 48 ++++++++++- apps/web/src/cloud/dpop.ts | 148 ++++++++++++++++++++++++++------ 2 files changed, 168 insertions(+), 28 deletions(-) diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 75951db1baf..acf6096d61f 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -4,7 +4,14 @@ import * as Effect from "effect/Effect"; import { decodeJwt } from "jose"; import { vi } from "vite-plus/test"; -import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; +import { + browserCryptoLayer, + BrowserDpopKeyError, + BrowserDpopProofError, + createBrowserDpopProof, + generateBrowserDpopKey, + isBrowserDpopError, +} from "./dpop"; describe("browser DPoP proofs", () => { it.effect("signs relay resource proofs with an access-token hash", () => @@ -32,4 +39,43 @@ describe("browser DPoP proofs", () => { ).toMatchObject({ ok: true }); }), ); + + it.effect("preserves invalid proof URL request context and the parser cause", () => + Effect.gen(function* () { + const proofKey = yield* generateBrowserDpopKey; + const error = yield* createBrowserDpopProof({ + method: "POST", + url: "http://", + proofKey, + }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopProofError); + expect(error).toMatchObject({ + operation: "normalize-url", + method: "POST", + url: "http://", + thumbprint: proofKey.thumbprint, + }); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).not.toContain((error.cause as Error).message); + expect(isBrowserDpopError(error)).toBe(true); + }), + ); + + it.effect("preserves the browser crypto cause when key generation fails", () => + Effect.gen(function* () { + const cause = new Error("browser crypto unavailable"); + const generateKey = vi + .spyOn(globalThis.crypto.subtle, "generateKey") + .mockRejectedValueOnce(cause); + + const error = yield* generateBrowserDpopKey.pipe(Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopKeyError); + expect(error.operation).toBe("generate"); + expect(error.cause).toBe(cause); + expect(error.message).not.toContain(cause.message); + generateKey.mockRestore(); + }), + ); }); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index d0994955db1..63b8cb9b85c 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -4,7 +4,6 @@ import { DpopPublicJwk, } from "@t3tools/shared/dpop"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -16,10 +15,62 @@ export interface BrowserDpopKey { readonly thumbprint: string; } -export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class BrowserDpopStorageError extends Schema.TaggedErrorClass()( + "BrowserDpopStorageError", + { + operation: Schema.Literals(["open", "read", "write"]), + databaseName: Schema.String, + storeName: Schema.String, + keyId: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Browser DPoP key storage operation "${this.operation}" failed for database "${this.databaseName}".`; + } +} + +export class BrowserDpopKeyError extends Schema.TaggedErrorClass()( + "BrowserDpopKeyError", + { + operation: Schema.Literals([ + "generate", + "export-private", + "export-public", + "validate-public", + "import-private", + ]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser DPoP key operation "${this.operation}" failed.`; + } +} + +export class BrowserDpopProofError extends Schema.TaggedErrorClass()( + "BrowserDpopProofError", + { + operation: Schema.Literals(["normalize-url", "generate-id", "sign"]), + method: Schema.String, + url: Schema.String, + normalizedUrl: Schema.optional(Schema.String), + thumbprint: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.url}.`; + } +} + +export const BrowserDpopError = Schema.Union([ + BrowserDpopStorageError, + BrowserDpopKeyError, + BrowserDpopProofError, +]); +export type BrowserDpopError = typeof BrowserDpopError.Type; +export const isBrowserDpopError = Schema.is(BrowserDpopError); const DPOP_DATABASE_NAME = "t3code:cloud-auth"; const DPOP_DATABASE_VERSION = 1; @@ -40,16 +91,20 @@ export const browserCryptoLayer = Layer.succeed( }), ); -function dpopError(message: string, cause?: unknown) { - return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); -} - function openDpopDatabase(): Effect.Effect { return Effect.callback((resume) => { const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION); request.addEventListener("error", () => resume( - Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)), + Effect.fail( + new BrowserDpopStorageError({ + operation: "open", + databaseName: DPOP_DATABASE_NAME, + storeName: DPOP_KEY_STORE_NAME, + keyId: DPOP_KEY_ID, + ...(request.error === null ? {} : { cause: request.error }), + }), + ), ), ); request.addEventListener("upgradeneeded", () => { @@ -74,7 +129,17 @@ export function readStoredBrowserDpopKey(): Effect.Effect - resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))), + resume( + Effect.fail( + new BrowserDpopStorageError({ + operation: "read", + databaseName: DPOP_DATABASE_NAME, + storeName: DPOP_KEY_STORE_NAME, + keyId: DPOP_KEY_ID, + ...(request.error === null ? {} : { cause: request.error }), + }), + ), + ), ); request.addEventListener("success", () => resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)), @@ -97,7 +162,15 @@ export function writeStoredBrowserDpopKey( const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite"); transaction.addEventListener("error", () => resume( - Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)), + Effect.fail( + new BrowserDpopStorageError({ + operation: "write", + databaseName: DPOP_DATABASE_NAME, + storeName: DPOP_KEY_STORE_NAME, + keyId: DPOP_KEY_ID, + ...(transaction.error === null ? {} : { cause: transaction.error }), + }), + ), ), ); transaction.addEventListener("complete", () => resume(Effect.void)); @@ -114,26 +187,22 @@ export const generateBrowserDpopKey = Effect.gen(function* () { "sign", "verify", ]) as Promise, - catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), + catch: (cause) => new BrowserDpopKeyError({ operation: "generate", cause }), }); const privateJwk = yield* Effect.tryPromise({ try: () => crypto.subtle.exportKey("jwk", generated.privateKey), - catch: (cause) => dpopError("Could not export DPoP private key.", cause), + catch: (cause) => new BrowserDpopKeyError({ operation: "export-private", cause }), }); - const publicJwk = yield* Effect.tryPromise({ + const encodedPublicJwk = yield* Effect.tryPromise({ try: () => crypto.subtle.exportKey("jwk", generated.publicKey), - catch: (cause) => dpopError("Could not export DPoP public key.", cause), - }).pipe( - Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), - Effect.mapError((cause) => - cause instanceof BrowserDpopError - ? cause - : dpopError("Generated DPoP public key is invalid.", cause), - ), + catch: (cause) => new BrowserDpopKeyError({ operation: "export-public", cause }), + }); + const publicJwk = yield* decodeDpopPublicJwk(encodedPublicJwk).pipe( + Effect.mapError((cause) => new BrowserDpopKeyError({ operation: "validate-public", cause })), ); const privateKey = yield* Effect.tryPromise({ try: () => importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, - catch: (cause) => dpopError("Could not import DPoP private key.", cause), + catch: (cause) => new BrowserDpopKeyError({ operation: "import-private", cause }), }); return { privateKey, @@ -155,13 +224,30 @@ export function createBrowserDpopProof(input: { return Effect.gen(function* () { const normalizedUrl = yield* Effect.try({ try: () => new URL(input.url), - catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), + catch: (cause) => + new BrowserDpopProofError({ + operation: "normalize-url", + method: input.method, + url: input.url, + thumbprint: input.proofKey.thumbprint, + cause, + }), }); normalizedUrl.search = ""; normalizedUrl.hash = ""; const jti = yield* Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), + Effect.mapError( + (cause) => + new BrowserDpopProofError({ + operation: "generate-id", + method: input.method, + url: input.url, + normalizedUrl: normalizedUrl.toString(), + thumbprint: input.proofKey.thumbprint, + cause, + }), + ), ); const proof = yield* Effect.tryPromise({ try: () => @@ -178,7 +264,15 @@ export function createBrowserDpopProof(input: { }) .setIssuedAt() .sign(input.proofKey.privateKey), - catch: (cause) => dpopError("Could not sign DPoP proof.", cause), + catch: (cause) => + new BrowserDpopProofError({ + operation: "sign", + method: input.method, + url: input.url, + normalizedUrl: normalizedUrl.toString(), + thumbprint: input.proofKey.thumbprint, + cause, + }), }); return { proof, thumbprint: input.proofKey.thumbprint }; }); From fc83f36d4465a21b600c466d05ddae7bdf572793 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:42:36 -0700 Subject: [PATCH 2/3] Redact browser DPoP error targets Co-authored-by: codex --- apps/web/src/cloud/dpop.test.ts | 45 ++++++++++++++++++++++++++++++--- apps/web/src/cloud/dpop.ts | 28 ++++++++++++++------ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index acf6096d61f..82b49be1406 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -1,7 +1,7 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { decodeJwt } from "jose"; +import { decodeJwt, SignJWT } from "jose"; import { vi } from "vite-plus/test"; import { @@ -40,12 +40,13 @@ describe("browser DPoP proofs", () => { }), ); - it.effect("preserves invalid proof URL request context and the parser cause", () => + it.effect("preserves safe invalid URL context and the parser cause", () => Effect.gen(function* () { const proofKey = yield* generateBrowserDpopKey; + const url = "http://"; const error = yield* createBrowserDpopProof({ method: "POST", - url: "http://", + url, proofKey, }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); @@ -53,15 +54,51 @@ describe("browser DPoP proofs", () => { expect(error).toMatchObject({ operation: "normalize-url", method: "POST", - url: "http://", + requestTarget: "", + urlLength: url.length, thumbprint: proofKey.thumbprint, }); + expect(error).not.toHaveProperty("url"); + expect(error).not.toHaveProperty("normalizedUrl"); expect(error.cause).toBeInstanceOf(Error); expect(error.message).not.toContain((error.cause as Error).message); expect(isBrowserDpopError(error)).toBe(true); }), ); + it.effect("redacts URL credentials, query, and fragment from proof errors", () => + Effect.gen(function* () { + const proofKey = yield* generateBrowserDpopKey; + const cause = new Error("signing failed"); + const sign = vi.spyOn(SignJWT.prototype, "sign").mockRejectedValueOnce(cause); + const url = "https://user:password@example.com/oauth/token?access_token=secret#fragment"; + + const error = yield* createBrowserDpopProof({ + method: "POST", + url, + proofKey, + }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopProofError); + expect(error).toMatchObject({ + operation: "sign", + method: "POST", + requestTarget: "https://example.com/oauth/token", + urlLength: url.length, + thumbprint: proofKey.thumbprint, + cause, + }); + expect(error).not.toHaveProperty("url"); + expect(error).not.toHaveProperty("normalizedUrl"); + expect(error.message).not.toContain("user"); + expect(error.message).not.toContain("password"); + expect(error.message).not.toContain("access_token"); + expect(error.message).not.toContain("secret"); + expect(error.message).not.toContain("fragment"); + sign.mockRestore(); + }), + ); + it.effect("preserves the browser crypto cause when key generation fails", () => Effect.gen(function* () { const cause = new Error("browser crypto unavailable"); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 63b8cb9b85c..4d7309f309a 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -53,14 +53,14 @@ export class BrowserDpopProofError extends Schema.TaggedErrorClass"; + } +} + export const browserCryptoLayer = Layer.succeed( Crypto.Crypto, Crypto.make({ @@ -222,13 +231,16 @@ export function createBrowserDpopProof(input: { Crypto.Crypto > { return Effect.gen(function* () { + const requestTarget = redactDpopRequestTarget(input.url); + const urlLength = input.url.length; const normalizedUrl = yield* Effect.try({ try: () => new URL(input.url), catch: (cause) => new BrowserDpopProofError({ operation: "normalize-url", method: input.method, - url: input.url, + requestTarget, + urlLength, thumbprint: input.proofKey.thumbprint, cause, }), @@ -242,8 +254,8 @@ export function createBrowserDpopProof(input: { new BrowserDpopProofError({ operation: "generate-id", method: input.method, - url: input.url, - normalizedUrl: normalizedUrl.toString(), + requestTarget, + urlLength, thumbprint: input.proofKey.thumbprint, cause, }), @@ -268,8 +280,8 @@ export function createBrowserDpopProof(input: { new BrowserDpopProofError({ operation: "sign", method: input.method, - url: input.url, - normalizedUrl: normalizedUrl.toString(), + requestTarget, + urlLength, thumbprint: input.proofKey.thumbprint, cause, }), From d8a8df17eeb5e07ed2ea0947185d165c4bf02307 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 08:55:04 -0700 Subject: [PATCH 3/3] Reuse shared DPoP request redaction Co-authored-by: codex --- apps/web/src/cloud/dpop.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts index 4d7309f309a..9c4a7536048 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -2,6 +2,7 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, + redactDpopRequestTarget, } from "@t3tools/shared/dpop"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -78,15 +79,6 @@ const DPOP_KEY_STORE_NAME = "keys"; const DPOP_KEY_ID = "relay-dpop-proof-key"; const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); -function redactDpopRequestTarget(url: string): string { - try { - const parsedUrl = new URL(url); - return `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}`; - } catch { - return ""; - } -} - export const browserCryptoLayer = Layer.succeed( Crypto.Crypto, Crypto.make({