authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:02:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:02:16-07:00
logfdc1e8b3a26d9a2b09e9b814e1a02be77ebb68a1
tree03634332cb95f9621045204a481d3808fa135aa5
parentc8454ad88a8774034544f02d177fbebd0520f80a
signature Signed by PGP key B5690EEEBB952194

[codex] Structure pairing grant failures (#3386)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 159 insertions(+), 48 deletions(-)

apps/server/src/auth/PairingGrantStore.test.ts+47
......@@ -3,9 +3,12 @@ import { expect, it } from "@effect/vitest";
33import * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
67import * as TestClock from "effect/testing/TestClock";
78
89import * as ServerConfig from "../config.ts";
10import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
11import { PersistenceSqlError } from "../persistence/Errors.ts";
912import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
1013import * as PairingGrantStore from "./PairingGrantStore.ts";
1114
......@@ -33,6 +36,26 @@ const makePairingGrantStoreLayer = (
3336 Layer.provide(makeServerConfigLayer(overrides)),
3437 );
3538
39const makePairingGrantStoreTestLayer = (
40 overrides: Partial<AuthPairingLinks.AuthPairingLinkRepository["Service"]>,
41) =>
42 Layer.effect(PairingGrantStore.PairingGrantStore, PairingGrantStore.make).pipe(
43 Layer.provide(
44 Layer.succeed(
45 AuthPairingLinks.AuthPairingLinkRepository,
46 AuthPairingLinks.AuthPairingLinkRepository.of({
47 create: () => Effect.void,
48 consumeAvailable: () => Effect.succeed(Option.none()),
49 listActive: () => Effect.succeed([]),
50 revoke: () => Effect.succeed(false),
51 getByCredential: () => Effect.succeed(Option.none()),
52 ...overrides,
53 }),
54 ),
55 ),
56 Layer.provide(makeServerConfigLayer()),
57 );
58
3659it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
3760 it.effect("issues pairing tokens in a short manual-entry format", () =>
3861 Effect.gen(function* () {
......@@ -186,4 +209,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
186209 expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError");
187210 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
188211 );
212
213 it.effect("identifies consume-available failures and preserves their cause", () => {
214 const repositoryFailure = new PersistenceSqlError({
215 operation: "consume-pairing-link",
216 detail: "Database unavailable",
217 cause: new Error("database unavailable"),
218 });
219
220 return Effect.gen(function* () {
221 const pairingGrants = yield* PairingGrantStore.PairingGrantStore;
222 const error = yield* Effect.flip(pairingGrants.consume("credential"));
223
224 if (error._tag !== "BootstrapCredentialConsumeAvailableError") {
225 return yield* Effect.die(error);
226 }
227 expect(error.cause).toBe(repositoryFailure);
228 }).pipe(
229 Effect.provide(
230 makePairingGrantStoreTestLayer({
231 consumeAvailable: () => Effect.fail(repositoryFailure),
232 }),
233 ),
234 );
235 });
189236});
apps/server/src/auth/PairingGrantStore.ts+112-48
......@@ -88,22 +88,38 @@ export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass<ActiveP
8888export class PairingLinkRevokeError extends Schema.TaggedErrorClass<PairingLinkRevokeError>()(
8989 "PairingLinkRevokeError",
9090 {
91 pairingLinkId: Schema.String,
9192 cause: Schema.Defect(),
9293 },
9394) {
9495 override get message(): string {
95 return "Failed to revoke pairing link.";
96 return `Failed to revoke pairing link '${this.pairingLinkId}'.`;
9697 }
9798}
9899
99100export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()(
100101 "PairingCredentialIssueError",
101102 {
103 pairingLinkId: Schema.String,
104 subject: Schema.String,
105 label: Schema.optional(Schema.String),
102106 cause: Schema.Defect(),
103107 },
104108) {
105109 override get message(): string {
106 return "Failed to issue pairing credential.";
110 return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`;
111 }
112}
113
114export class PairingCredentialRandomGenerationError extends Schema.TaggedErrorClass<PairingCredentialRandomGenerationError>()(
115 "PairingCredentialRandomGenerationError",
116 {
117 operation: Schema.Literals(["generate-id", "generate-token"]),
118 cause: Schema.Defect(),
119 },
120) {
121 override get message(): string {
122 return `Failed to generate pairing credential data during '${this.operation}'.`;
107123 }
108124}
109125
......@@ -118,11 +134,36 @@ export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<Boo
118134 }
119135}
120136
137export class BootstrapCredentialConsumeAvailableError extends Schema.TaggedErrorClass<BootstrapCredentialConsumeAvailableError>()(
138 "BootstrapCredentialConsumeAvailableError",
139 {
140 cause: Schema.Defect(),
141 },
142) {
143 override get message(): string {
144 return "Failed to atomically consume an available bootstrap credential.";
145 }
146}
147
148export class BootstrapCredentialLookupError extends Schema.TaggedErrorClass<BootstrapCredentialLookupError>()(
149 "BootstrapCredentialLookupError",
150 {
151 cause: Schema.Defect(),
152 },
153) {
154 override get message(): string {
155 return "Failed to look up bootstrap credential state.";
156 }
157}
158
121159export const BootstrapCredentialInternalError = Schema.Union([
122160 ActivePairingLinksLoadError,
123161 PairingLinkRevokeError,
124162 PairingCredentialIssueError,
163 PairingCredentialRandomGenerationError,
125164 BootstrapCredentialConsumeError,
165 BootstrapCredentialConsumeAvailableError,
166 BootstrapCredentialLookupError,
126167]);
127168export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;
128169export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);
......@@ -207,7 +248,14 @@ export const make = Effect.gen(function* () {
207248 const generatePairingToken = Effect.gen(function* () {
208249 let credential = "";
209250 while (credential.length < PAIRING_TOKEN_LENGTH) {
210 const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH);
251 const bytes = yield* crypto
252 .randomBytes(PAIRING_TOKEN_LENGTH)
253 .pipe(
254 Effect.mapError(
255 (cause) =>
256 new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }),
257 ),
258 );
211259 for (const byte of bytes) {
212260 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
213261 continue;
......@@ -287,58 +335,73 @@ export const make = Effect.gen(function* () {
287335 const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
288336 function* (id) {
289337 const revokedAt = yield* DateTime.now;
290 const revoked = yield* pairingLinks.revoke({
291 id,
292 revokedAt,
293 });
338 const revoked = yield* pairingLinks
339 .revoke({
340 id,
341 revokedAt,
342 })
343 .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause })));
294344 if (revoked) {
295345 yield* emitRemoved(id);
296346 }
297347 return revoked;
298348 },
299 Effect.mapError((cause) => new PairingLinkRevokeError({ cause })),
300349 );
301350
302351 const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
303352 "PairingGrantStore.issueOneTimeToken",
304 )(
305 function* (input) {
306 const id = yield* crypto.randomUUIDv4;
307 const credential = yield* generatePairingToken;
308 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
309 const now = yield* DateTime.now;
310 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
311 const issued: IssuedBootstrapCredential = {
312 id,
313 credential,
314 ...(input?.label ? { label: input.label } : {}),
315 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
316 expiresAt,
317 };
318 yield* pairingLinks.create({
353 )(function* (input) {
354 const id = yield* crypto.randomUUIDv4.pipe(
355 Effect.mapError(
356 (cause) => new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }),
357 ),
358 );
359 const credential = yield* generatePairingToken;
360 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
361 const now = yield* DateTime.now;
362 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
363 const issued: IssuedBootstrapCredential = {
364 id,
365 credential,
366 ...(input?.label ? { label: input.label } : {}),
367 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
368 expiresAt,
369 };
370 const subject = input?.subject ?? "one-time-token";
371 yield* pairingLinks
372 .create({
319373 id,
320374 credential,
321375 method: "one-time-token",
322376 scopes: input?.scopes ?? AuthStandardClientScopes,
323 subject: input?.subject ?? "one-time-token",
377 subject,
324378 label: input?.label ?? null,
325379 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
326380 createdAt: now,
327381 expiresAt: expiresAt,
328 });
329 yield* emitUpsert({
330 id,
331 credential,
332 scopes: input?.scopes ?? AuthStandardClientScopes,
333 subject: input?.subject ?? "one-time-token",
334 ...(input?.label ? { label: input.label } : {}),
335 createdAt: now,
336 expiresAt,
337 });
338 return issued;
339 },
340 Effect.mapError((cause) => new PairingCredentialIssueError({ cause })),
341 );
382 })
383 .pipe(
384 Effect.mapError(
385 (cause) =>
386 new PairingCredentialIssueError({
387 pairingLinkId: id,
388 subject,
389 ...(input?.label ? { label: input.label } : {}),
390 cause,
391 }),
392 ),
393 );
394 yield* emitUpsert({
395 id,
396 credential,
397 scopes: input?.scopes ?? AuthStandardClientScopes,
398 subject: input?.subject ?? "one-time-token",
399 ...(input?.label ? { label: input.label } : {}),
400 createdAt: now,
401 expiresAt,
402 });
403 return issued;
404 });
342405
343406 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
344407 function* (credential, input) {
......@@ -420,12 +483,14 @@ export const make = Effect.gen(function* () {
420483 return yield* seededResult.error;
421484 }
422485
423 const consumed = yield* pairingLinks.consumeAvailable({
424 credential,
425 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
426 consumedAt: now,
427 now,
428 });
486 const consumed = yield* pairingLinks
487 .consumeAvailable({
488 credential,
489 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
490 consumedAt: now,
491 now,
492 })
493 .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause })));
429494
430495 if (Option.isSome(consumed)) {
431496 yield* emitRemoved(consumed.value.id);
......@@ -441,7 +506,9 @@ export const make = Effect.gen(function* () {
441506 } satisfies BootstrapGrant;
442507 }
443508
444 const matching = yield* pairingLinks.getByCredential({ credential });
509 const matching = yield* pairingLinks
510 .getByCredential({ credential })
511 .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause })));
445512 if (Option.isNone(matching)) {
446513 return yield* new UnknownBootstrapCredentialError({});
447514 }
......@@ -467,9 +534,6 @@ export const make = Effect.gen(function* () {
467534
468535 return yield* new UnavailableBootstrapCredentialError({});
469536 },
470 Effect.mapError((cause) =>
471 isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }),
472 ),
473537 );
474538
475539 return PairingGrantStore.of({