Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions apps/web/src/cloud/dpop.test.ts
Original file line number Diff line number Diff line change
@@ -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", () =>
Expand Down Expand Up @@ -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: "<invalid-url>",
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();
}),
);
});
152 changes: 125 additions & 27 deletions apps/web/src/cloud/dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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>()(
"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>()(
"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>()(
"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;
Expand All @@ -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<IDBDatabase, BrowserDpopError> {
return Effect.callback<IDBDatabase, BrowserDpopError>((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", () => {
Expand All @@ -74,7 +130,17 @@ export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null,
.objectStore(DPOP_KEY_STORE_NAME)
.get(DPOP_KEY_ID);
request.addEventListener("error", () =>
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)),
Expand All @@ -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));
Expand All @@ -114,26 +188,22 @@ export const generateBrowserDpopKey = Effect.gen(function* () {
"sign",
"verify",
]) as Promise<CryptoKeyPair>,
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<CryptoKey>,
catch: (cause) => dpopError("Could not import DPoP private key.", cause),
catch: (cause) => new BrowserDpopKeyError({ operation: "import-private", cause }),
});
return {
privateKey,
Expand All @@ -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: () =>
Expand All @@ -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 };
});
Expand Down
Loading