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";...@@ -3,9 +3,12 @@ import { expect, it } from "@effect/vitest";
3import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
6import * as TestClock from "effect/testing/TestClock";7import * as TestClock from "effect/testing/TestClock";
78
8import * as ServerConfig from "../config.ts";9import * as ServerConfig from "../config.ts";
10import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
11import { PersistenceSqlError } from "../persistence/Errors.ts";
9import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";12import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
10import * as PairingGrantStore from "./PairingGrantStore.ts";13import * as PairingGrantStore from "./PairingGrantStore.ts";
1114
...@@ -33,6 +36,26 @@ const makePairingGrantStoreLayer = (...@@ -33,6 +36,26 @@ const makePairingGrantStoreLayer = (
33 Layer.provide(makeServerConfigLayer(overrides)),36 Layer.provide(makeServerConfigLayer(overrides)),
34 );37 );
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
36it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {59it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
37 it.effect("issues pairing tokens in a short manual-entry format", () =>60 it.effect("issues pairing tokens in a short manual-entry format", () =>
38 Effect.gen(function* () {61 Effect.gen(function* () {
...@@ -186,4 +209,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {...@@ -186,4 +209,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
186 expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError");209 expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError");
187 }).pipe(Effect.provide(makePairingGrantStoreLayer())),210 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
188 );211 );
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 });
189});236});
apps/server/src/auth/PairingGrantStore.ts+112-48
...@@ -88,22 +88,38 @@ export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass<ActiveP...@@ -88,22 +88,38 @@ export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass<ActiveP
88export class PairingLinkRevokeError extends Schema.TaggedErrorClass<PairingLinkRevokeError>()(88export class PairingLinkRevokeError extends Schema.TaggedErrorClass<PairingLinkRevokeError>()(
89 "PairingLinkRevokeError",89 "PairingLinkRevokeError",
90 {90 {
91 pairingLinkId: Schema.String,
91 cause: Schema.Defect(),92 cause: Schema.Defect(),
92 },93 },
93) {94) {
94 override get message(): string {95 override get message(): string {
95 return "Failed to revoke pairing link.";96 return `Failed to revoke pairing link '${this.pairingLinkId}'.`;
96 }97 }
97}98}
9899
99export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()(100export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()(
100 "PairingCredentialIssueError",101 "PairingCredentialIssueError",
101 {102 {
103 pairingLinkId: Schema.String,
104 subject: Schema.String,
105 label: Schema.optional(Schema.String),
102 cause: Schema.Defect(),106 cause: Schema.Defect(),
103 },107 },
104) {108) {
105 override get message(): string {109 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}'.`;
107 }123 }
108}124}
109125
...@@ -118,11 +134,36 @@ export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<Boo...@@ -118,11 +134,36 @@ export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<Boo
118 }134 }
119}135}
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
121export const BootstrapCredentialInternalError = Schema.Union([159export const BootstrapCredentialInternalError = Schema.Union([
122 ActivePairingLinksLoadError,160 ActivePairingLinksLoadError,
123 PairingLinkRevokeError,161 PairingLinkRevokeError,
124 PairingCredentialIssueError,162 PairingCredentialIssueError,
163 PairingCredentialRandomGenerationError,
125 BootstrapCredentialConsumeError,164 BootstrapCredentialConsumeError,
165 BootstrapCredentialConsumeAvailableError,
166 BootstrapCredentialLookupError,
126]);167]);
127export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;168export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;
128export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);169export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);
...@@ -207,7 +248,14 @@ export const make = Effect.gen(function* () {...@@ -207,7 +248,14 @@ export const make = Effect.gen(function* () {
207 const generatePairingToken = Effect.gen(function* () {248 const generatePairingToken = Effect.gen(function* () {
208 let credential = "";249 let credential = "";
209 while (credential.length < PAIRING_TOKEN_LENGTH) {250 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 );
211 for (const byte of bytes) {259 for (const byte of bytes) {
212 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {260 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
213 continue;261 continue;
...@@ -287,58 +335,73 @@ export const make = Effect.gen(function* () {...@@ -287,58 +335,73 @@ export const make = Effect.gen(function* () {
287 const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(335 const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
288 function* (id) {336 function* (id) {
289 const revokedAt = yield* DateTime.now;337 const revokedAt = yield* DateTime.now;
290 const revoked = yield* pairingLinks.revoke({338 const revoked = yield* pairingLinks
291 id,339 .revoke({
292 revokedAt,340 id,
293 });341 revokedAt,
342 })
343 .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause })));
294 if (revoked) {344 if (revoked) {
295 yield* emitRemoved(id);345 yield* emitRemoved(id);
296 }346 }
297 return revoked;347 return revoked;
298 },348 },
299 Effect.mapError((cause) => new PairingLinkRevokeError({ cause })),
300 );349 );
301350
302 const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(351 const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
303 "PairingGrantStore.issueOneTimeToken",352 "PairingGrantStore.issueOneTimeToken",
304 )(353 )(function* (input) {
305 function* (input) {354 const id = yield* crypto.randomUUIDv4.pipe(
306 const id = yield* crypto.randomUUIDv4;355 Effect.mapError(
307 const credential = yield* generatePairingToken;356 (cause) => new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }),
308 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;357 ),
309 const now = yield* DateTime.now;358 );
310 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });359 const credential = yield* generatePairingToken;
311 const issued: IssuedBootstrapCredential = {360 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
312 id,361 const now = yield* DateTime.now;
313 credential,362 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
314 ...(input?.label ? { label: input.label } : {}),363 const issued: IssuedBootstrapCredential = {
315 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),364 id,
316 expiresAt,365 credential,
317 };366 ...(input?.label ? { label: input.label } : {}),
318 yield* pairingLinks.create({367 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
368 expiresAt,
369 };
370 const subject = input?.subject ?? "one-time-token";
371 yield* pairingLinks
372 .create({
319 id,373 id,
320 credential,374 credential,
321 method: "one-time-token",375 method: "one-time-token",
322 scopes: input?.scopes ?? AuthStandardClientScopes,376 scopes: input?.scopes ?? AuthStandardClientScopes,
323 subject: input?.subject ?? "one-time-token",377 subject,
324 label: input?.label ?? null,378 label: input?.label ?? null,
325 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,379 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
326 createdAt: now,380 createdAt: now,
327 expiresAt: expiresAt,381 expiresAt: expiresAt,
328 });382 })
329 yield* emitUpsert({383 .pipe(
330 id,384 Effect.mapError(
331 credential,385 (cause) =>
332 scopes: input?.scopes ?? AuthStandardClientScopes,386 new PairingCredentialIssueError({
333 subject: input?.subject ?? "one-time-token",387 pairingLinkId: id,
334 ...(input?.label ? { label: input.label } : {}),388 subject,
335 createdAt: now,389 ...(input?.label ? { label: input.label } : {}),
336 expiresAt,390 cause,
337 });391 }),
338 return issued;392 ),
339 },393 );
340 Effect.mapError((cause) => new PairingCredentialIssueError({ cause })),394 yield* emitUpsert({
341 );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
343 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(406 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
344 function* (credential, input) {407 function* (credential, input) {
...@@ -420,12 +483,14 @@ export const make = Effect.gen(function* () {...@@ -420,12 +483,14 @@ export const make = Effect.gen(function* () {
420 return yield* seededResult.error;483 return yield* seededResult.error;
421 }484 }
422485
423 const consumed = yield* pairingLinks.consumeAvailable({486 const consumed = yield* pairingLinks
424 credential,487 .consumeAvailable({
425 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,488 credential,
426 consumedAt: now,489 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
427 now,490 consumedAt: now,
428 });491 now,
492 })
493 .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause })));
429494
430 if (Option.isSome(consumed)) {495 if (Option.isSome(consumed)) {
431 yield* emitRemoved(consumed.value.id);496 yield* emitRemoved(consumed.value.id);
...@@ -441,7 +506,9 @@ export const make = Effect.gen(function* () {...@@ -441,7 +506,9 @@ export const make = Effect.gen(function* () {
441 } satisfies BootstrapGrant;506 } satisfies BootstrapGrant;
442 }507 }
443508
444 const matching = yield* pairingLinks.getByCredential({ credential });509 const matching = yield* pairingLinks
510 .getByCredential({ credential })
511 .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause })));
445 if (Option.isNone(matching)) {512 if (Option.isNone(matching)) {
446 return yield* new UnknownBootstrapCredentialError({});513 return yield* new UnknownBootstrapCredentialError({});
447 }514 }
...@@ -467,9 +534,6 @@ export const make = Effect.gen(function* () {...@@ -467,9 +534,6 @@ export const make = Effect.gen(function* () {
467534
468 return yield* new UnavailableBootstrapCredentialError({});535 return yield* new UnavailableBootstrapCredentialError({});
469 },536 },
470 Effect.mapError((cause) =>
471 isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }),
472 ),
473 );537 );
474538
475 return PairingGrantStore.of({539 return PairingGrantStore.of({