From 6438a45e2a7631c5b456cbb87b9e40dd381f64cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:24:42 -0700 Subject: [PATCH] Structure pairing grant failures Co-authored-by: codex --- .../server/src/auth/PairingGrantStore.test.ts | 47 +++++ apps/server/src/auth/PairingGrantStore.ts | 160 ++++++++++++------ 2 files changed, 159 insertions(+), 48 deletions(-) diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index b3c9b30f643..53b1a7e7929 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -3,9 +3,12 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; @@ -33,6 +36,26 @@ const makePairingGrantStoreLayer = ( Layer.provide(makeServerConfigLayer(overrides)), ); +const makePairingGrantStoreTestLayer = ( + overrides: Partial, +) => + Layer.effect(PairingGrantStore.PairingGrantStore, PairingGrantStore.make).pipe( + Layer.provide( + Layer.succeed( + AuthPairingLinks.AuthPairingLinkRepository, + AuthPairingLinks.AuthPairingLinkRepository.of({ + create: () => Effect.void, + consumeAvailable: () => Effect.succeed(Option.none()), + listActive: () => Effect.succeed([]), + revoke: () => Effect.succeed(false), + getByCredential: () => Effect.succeed(Option.none()), + ...overrides, + }), + ), + ), + Layer.provide(makeServerConfigLayer()), + ); + it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { it.effect("issues pairing tokens in a short manual-entry format", () => Effect.gen(function* () { @@ -186,4 +209,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); + + it.effect("identifies consume-available failures and preserves their cause", () => { + const repositoryFailure = new PersistenceSqlError({ + operation: "consume-pairing-link", + detail: "Database unavailable", + cause: new Error("database unavailable"), + }); + + return Effect.gen(function* () { + const pairingGrants = yield* PairingGrantStore.PairingGrantStore; + const error = yield* Effect.flip(pairingGrants.consume("credential")); + + if (error._tag !== "BootstrapCredentialConsumeAvailableError") { + return yield* Effect.die(error); + } + expect(error.cause).toBe(repositoryFailure); + }).pipe( + Effect.provide( + makePairingGrantStoreTestLayer({ + consumeAvailable: () => Effect.fail(repositoryFailure), + }), + ), + ); + }); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 8a7a4d2e40f..7a8fb9477cf 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -88,22 +88,38 @@ export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( "PairingLinkRevokeError", { + pairingLinkId: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to revoke pairing link."; + return `Failed to revoke pairing link '${this.pairingLinkId}'.`; } } export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( "PairingCredentialIssueError", { + pairingLinkId: Schema.String, + subject: Schema.String, + label: Schema.optional(Schema.String), cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to issue pairing credential."; + return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`; + } +} + +export class PairingCredentialRandomGenerationError extends Schema.TaggedErrorClass()( + "PairingCredentialRandomGenerationError", + { + operation: Schema.Literals(["generate-id", "generate-token"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to generate pairing credential data during '${this.operation}'.`; } } @@ -118,11 +134,36 @@ export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeAvailableError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to atomically consume an available bootstrap credential."; + } +} + +export class BootstrapCredentialLookupError extends Schema.TaggedErrorClass()( + "BootstrapCredentialLookupError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to look up bootstrap credential state."; + } +} + export const BootstrapCredentialInternalError = Schema.Union([ ActivePairingLinksLoadError, PairingLinkRevokeError, PairingCredentialIssueError, + PairingCredentialRandomGenerationError, BootstrapCredentialConsumeError, + BootstrapCredentialConsumeAvailableError, + BootstrapCredentialLookupError, ]); export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); @@ -207,7 +248,14 @@ export const make = Effect.gen(function* () { const generatePairingToken = Effect.gen(function* () { let credential = ""; while (credential.length < PAIRING_TOKEN_LENGTH) { - const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH); + const bytes = yield* crypto + .randomBytes(PAIRING_TOKEN_LENGTH) + .pipe( + Effect.mapError( + (cause) => + new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }), + ), + ); for (const byte of bytes) { if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) { continue; @@ -287,58 +335,73 @@ export const make = Effect.gen(function* () { const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( function* (id) { const revokedAt = yield* DateTime.now; - const revoked = yield* pairingLinks.revoke({ - id, - revokedAt, - }); + const revoked = yield* pairingLinks + .revoke({ + id, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause }))); if (revoked) { yield* emitRemoved(id); } return revoked; }, - Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), ); const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", - )( - function* (input) { - const id = yield* crypto.randomUUIDv4; - const credential = yield* generatePairingToken; - const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; - const now = yield* DateTime.now; - const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); - const issued: IssuedBootstrapCredential = { - id, - credential, - ...(input?.label ? { label: input.label } : {}), - ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), - expiresAt, - }; - yield* pairingLinks.create({ + )(function* (input) { + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }), + ), + ); + const credential = yield* generatePairingToken; + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); + const issued: IssuedBootstrapCredential = { + id, + credential, + ...(input?.label ? { label: input.label } : {}), + ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), + expiresAt, + }; + const subject = input?.subject ?? "one-time-token"; + yield* pairingLinks + .create({ id, credential, method: "one-time-token", scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", + subject, label: input?.label ?? null, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, createdAt: now, expiresAt: expiresAt, - }); - yield* emitUpsert({ - id, - credential, - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", - ...(input?.label ? { label: input.label } : {}), - createdAt: now, - expiresAt, - }); - return issued; - }, - Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), - ); + }) + .pipe( + Effect.mapError( + (cause) => + new PairingCredentialIssueError({ + pairingLinkId: id, + subject, + ...(input?.label ? { label: input.label } : {}), + cause, + }), + ), + ); + yield* emitUpsert({ + id, + credential, + scopes: input?.scopes ?? AuthStandardClientScopes, + subject: input?.subject ?? "one-time-token", + ...(input?.label ? { label: input.label } : {}), + createdAt: now, + expiresAt, + }); + return issued; + }); const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { @@ -420,12 +483,14 @@ export const make = Effect.gen(function* () { return yield* seededResult.error; } - const consumed = yield* pairingLinks.consumeAvailable({ - credential, - proofKeyThumbprint: input?.proofKeyThumbprint ?? null, - consumedAt: now, - now, - }); + const consumed = yield* pairingLinks + .consumeAvailable({ + credential, + proofKeyThumbprint: input?.proofKeyThumbprint ?? null, + consumedAt: now, + now, + }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause }))); if (Option.isSome(consumed)) { yield* emitRemoved(consumed.value.id); @@ -441,7 +506,9 @@ export const make = Effect.gen(function* () { } satisfies BootstrapGrant; } - const matching = yield* pairingLinks.getByCredential({ credential }); + const matching = yield* pairingLinks + .getByCredential({ credential }) + .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { return yield* new UnknownBootstrapCredentialError({}); } @@ -467,9 +534,6 @@ export const make = Effect.gen(function* () { return yield* new UnavailableBootstrapCredentialError({}); }, - Effect.mapError((cause) => - isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), - ), ); return PairingGrantStore.of({