diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 75951db1baf..82b49be1406 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -1,10 +1,17 @@ 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 { 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,80 @@ describe("browser DPoP proofs", () => { ).toMatchObject({ ok: true }); }), ); + + 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, + proofKey, + }).pipe(Effect.provide(browserCryptoLayer), Effect.flip); + + expect(error).toBeInstanceOf(BrowserDpopProofError); + expect(error).toMatchObject({ + operation: "normalize-url", + method: "POST", + 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"); + 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..9c4a7536048 100644 --- a/apps/web/src/cloud/dpop.ts +++ b/apps/web/src/cloud/dpop.ts @@ -2,9 +2,9 @@ import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, DpopPublicJwk, + redactDpopRequestTarget, } 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 +16,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, + requestTarget: Schema.String, + urlLength: Schema.Number, + thumbprint: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Browser DPoP proof operation "${this.operation}" failed for ${this.method.toUpperCase()} ${this.requestTarget}.`; + } +} + +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 +92,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 +130,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 +163,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 +188,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, @@ -153,15 +223,35 @@ 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) => dpopError("Could not normalize DPoP proof URL.", cause), + catch: (cause) => + new BrowserDpopProofError({ + operation: "normalize-url", + method: input.method, + requestTarget, + urlLength, + 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, + requestTarget, + urlLength, + thumbprint: input.proofKey.thumbprint, + cause, + }), + ), ); const proof = yield* Effect.tryPromise({ try: () => @@ -178,7 +268,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, + requestTarget, + urlLength, + thumbprint: input.proofKey.thumbprint, + cause, + }), }); return { proof, thumbprint: input.proofKey.thumbprint }; });