1import {
2 AuthAdministrativeScopes,
3 AuthStandardClientScopes,
4 type AuthEnvironmentScope,
5 type AuthPairingLink,
6 type ServerAuthBootstrapMethod,
7} from "@t3tools/contracts";
8import {
9 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
10 isValidDesktopBootstrapToken,
11} from "@t3tools/shared/desktopBootstrapToken";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as DateTime from "effect/DateTime";
15import * as Duration from "effect/Duration";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as PubSub from "effect/PubSub";
20import * as Ref from "effect/Ref";
21import * as Schema from "effect/Schema";
22import * as Stream from "effect/Stream";
23
24import * as ServerConfig from "../config.ts";
25import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
26
27export interface BootstrapGrant {
28 readonly method: ServerAuthBootstrapMethod;
29 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
30 readonly subject: string;
31 readonly label?: string;
32 readonly proofKeyThumbprint?: string;
33 readonly expiresAt: DateTime.DateTime;
34}
35
36export class UnknownBootstrapCredentialError extends Schema.TaggedError<UnknownBootstrapCredentialError>()(
37 "UnknownBootstrapCredentialError",
38 {},
39) {
40 override get message(): string {
41 return "Unknown bootstrap credential.";
42 }
43}
44
45export class ExpiredBootstrapCredentialError extends Schema.TaggedError<ExpiredBootstrapCredentialError>()(
46 "ExpiredBootstrapCredentialError",
47 {},
48) {
49 override get message(): string {
50 return "Bootstrap credential expired.";
51 }
52}
53
54export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedError<BootstrapCredentialProofKeyMismatchError>()(
55 "BootstrapCredentialProofKeyMismatchError",
56 {},
57) {
58 override get message(): string {
59 return "Bootstrap credential proof key mismatch.";
60 }
61}
62
63export class UnavailableBootstrapCredentialError extends Schema.TaggedError<UnavailableBootstrapCredentialError>()(
64 "UnavailableBootstrapCredentialError",
65 {},
66) {
67 override get message(): string {
68 return "Bootstrap credential is no longer available.";
69 }
70}
71
72export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedError<BootstrapCredentialScopeNotGrantedError>()(
73 "BootstrapCredentialScopeNotGrantedError",
74 {},
75) {
76 override get message(): string {
77 return "The requested authentication scope was not granted.";
78 }
79}
80
81export const BootstrapCredentialInvalidError = Schema.Union([
82 UnknownBootstrapCredentialError,
83 ExpiredBootstrapCredentialError,
84 BootstrapCredentialProofKeyMismatchError,
85 UnavailableBootstrapCredentialError,
86]);
87export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type;
88
89export class ActivePairingLinksLoadError extends Schema.TaggedError<ActivePairingLinksLoadError>()(
90 "ActivePairingLinksLoadError",
91 {
92 cause: Schema.Defect(),
93 },
94) {
95 override get message(): string {
96 return "Failed to load active pairing links.";
97 }
98}
99
100export class PairingLinkRevokeError extends Schema.TaggedError<PairingLinkRevokeError>()(
101 "PairingLinkRevokeError",
102 {
103 pairingLinkId: Schema.String,
104 cause: Schema.Defect(),
105 },
106) {
107 override get message(): string {
108 return `Failed to revoke pairing link '${this.pairingLinkId}'.`;
109 }
110}
111
112export class PairingCredentialIssueError extends Schema.TaggedError<PairingCredentialIssueError>()(
113 "PairingCredentialIssueError",
114 {
115 pairingLinkId: Schema.String,
116 subject: Schema.String,
117 label: Schema.optional(Schema.String),
118 cause: Schema.Defect(),
119 },
120) {
121 override get message(): string {
122 return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`;
123 }
124}
125
126export class PairingCredentialRandomGenerationError extends Schema.TaggedError<PairingCredentialRandomGenerationError>()(
127 "PairingCredentialRandomGenerationError",
128 {
129 operation: Schema.Literals(["generate-id", "generate-token"]),
130 cause: Schema.Defect(),
131 },
132) {
133 override get message(): string {
134 return `Failed to generate pairing credential data during '${this.operation}'.`;
135 }
136}
137
138export class BootstrapCredentialConsumeError extends Schema.TaggedError<BootstrapCredentialConsumeError>()(
139 "BootstrapCredentialConsumeError",
140 {
141 cause: Schema.Defect(),
142 },
143) {
144 override get message(): string {
145 return "Failed to consume bootstrap credential.";
146 }
147}
148
149export class BootstrapCredentialConsumeAvailableError extends Schema.TaggedError<BootstrapCredentialConsumeAvailableError>()(
150 "BootstrapCredentialConsumeAvailableError",
151 {
152 cause: Schema.Defect(),
153 },
154) {
155 override get message(): string {
156 return "Failed to atomically consume an available bootstrap credential.";
157 }
158}
159
160export class BootstrapCredentialLookupError extends Schema.TaggedError<BootstrapCredentialLookupError>()(
161 "BootstrapCredentialLookupError",
162 {
163 cause: Schema.Defect(),
164 },
165) {
166 override get message(): string {
167 return "Failed to look up bootstrap credential state.";
168 }
169}
170
171export const BootstrapCredentialInternalError = Schema.Union([
172 ActivePairingLinksLoadError,
173 PairingLinkRevokeError,
174 PairingCredentialIssueError,
175 PairingCredentialRandomGenerationError,
176 BootstrapCredentialConsumeError,
177 BootstrapCredentialConsumeAvailableError,
178 BootstrapCredentialLookupError,
179]);
180export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;
181export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);
182
183export const BootstrapCredentialError = Schema.Union([
184 BootstrapCredentialInvalidError,
185 BootstrapCredentialInternalError,
186 BootstrapCredentialScopeNotGrantedError,
187]);
188export type BootstrapCredentialError = typeof BootstrapCredentialError.Type;
189
190export interface IssuedBootstrapCredential {
191 readonly id: string;
192 readonly credential: string;
193 readonly label?: string;
194 readonly proofKeyThumbprint?: string;
195 readonly expiresAt: DateTime.Utc;
196}
197
198export type BootstrapCredentialChange =
199 | {
200 readonly type: "pairingLinkUpserted";
201 readonly pairingLink: AuthPairingLink;
202 }
203 | {
204 readonly type: "pairingLinkRemoved";
205 readonly id: string;
206 };
207
208export class PairingGrantStore extends Context.Service<
209 PairingGrantStore,
210 {
211 readonly issueOneTimeToken: (input?: {
212 readonly ttl?: Duration.Duration;
213 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
214 readonly subject?: string;
215 readonly label?: string;
216 readonly proofKeyThumbprint?: string;
217 /**
218 * "startup" marks the credential the server mints for itself at boot,
219 * which gets the long dev TTL when a dev URL is configured.
220 */
221 readonly purpose?: "startup";
222 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
223 readonly listActive: () => Effect.Effect<
224 ReadonlyArray<AuthPairingLink>,
225 BootstrapCredentialInternalError
226 >;
227 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
228 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
229 readonly consume: (
230 credential: string,
231 input?: {
232 readonly proofKeyThumbprint?: string;
233 readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
234 },
235 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
236 }
237>()("t3/auth/PairingGrantStore") {}
238
239interface StoredBootstrapGrant extends BootstrapGrant {
240 readonly remainingUses: number | "unbounded";
241}
242
243type ConsumeResult =
244 | {
245 readonly _tag: "error";
246 readonly reason: "not-found" | "expired" | "scope-not-granted";
247 readonly error: BootstrapCredentialError;
248 }
249 | {
250 readonly _tag: "success";
251 readonly grant: BootstrapGrant;
252 };
253
254const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
255// The desktop-bootstrap grant rides on a trusted IPC channel (fd3 or
256// stdin) at backend launch, so it doesn't have to be short-lived the
257// way a user-facing pairing link does. Letting it live for the
258// lifetime of the backend process (24h is more than long enough for
259// practical desktop use, and well under "forever" in case the seed
260// gets logged anywhere by accident) means a page reload past the 5-min
261// window can still recover by re-bootstrapping rather than locking
262// the user out of the backend.
263const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);
264// A dev server's startup token is read off a log by whoever (or whatever) is
265// driving the session, often minutes later — after a `node --watch` restart, a
266// detour into another task, or a hand-off to the person actually doing the
267// testing. Five minutes turns that into a restart-the-server loop for no
268// security benefit: the token only unlocks a local dev backend, and its holder
269// could read the log anyway. Same reasoning (and duration) as the desktop
270// bootstrap grant above. Only applies when a dev URL is configured; user-issued
271// pairing links and real servers keep the 5-minute default.
272const DEV_STARTUP_TTL_HOURS = Duration.hours(24);
273const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
274const PAIRING_TOKEN_LENGTH = 12;
275const PAIRING_TOKEN_REJECTION_LIMIT =
276 Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length;
277
278export const make = Effect.gen(function* () {
279 const crypto = yield* Crypto.Crypto;
280 const config = yield* ServerConfig.ServerConfig;
281 const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository;
282 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
283 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
284 const generatePairingToken = Effect.gen(function* () {
285 let credential = "";
286 while (credential.length < PAIRING_TOKEN_LENGTH) {
287 const bytes = yield* crypto
288 .randomBytes(PAIRING_TOKEN_LENGTH)
289 .pipe(
290 Effect.mapError(
291 (cause) =>
292 new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }),
293 ),
294 );
295 for (const byte of bytes) {
296 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
297 continue;
298 }
299 credential += PAIRING_TOKEN_ALPHABET[byte % PAIRING_TOKEN_ALPHABET.length]!;
300 if (credential.length === PAIRING_TOKEN_LENGTH) {
301 return credential;
302 }
303 }
304 }
305 return credential;
306 });
307
308 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
309 Ref.update(seededGrantsRef, (current) => {
310 const next = new Map(current);
311 next.set(credential, grant);
312 return next;
313 });
314
315 const emitUpsert = (pairingLink: AuthPairingLink) =>
316 PubSub.publish(changesPubSub, {
317 type: "pairingLinkUpserted",
318 pairingLink,
319 }).pipe(Effect.asVoid);
320
321 const emitRemoved = (id: string) =>
322 PubSub.publish(changesPubSub, {
323 type: "pairingLinkRemoved",
324 id,
325 }).pipe(Effect.asVoid);
326
327 // A desktop that sends its secret rotates the renderer's token, so accept
328 // whichever token the secret derives for the current window instead of
329 // seeding one fixed token. Older desktops only send the token.
330 const desktopBootstrapSecret = config.desktopBootstrapSecret;
331 const consumeRotatingDesktopToken = (credential: string, nowMs: number) =>
332 desktopBootstrapSecret !== undefined &&
333 isValidDesktopBootstrapToken(desktopBootstrapSecret, credential, nowMs);
334
335 if (config.desktopBootstrapToken && desktopBootstrapSecret === undefined) {
336 const now = yield* DateTime.now;
337 yield* seedGrant(config.desktopBootstrapToken, {
338 method: "desktop-bootstrap",
339 scopes: AuthAdministrativeScopes,
340 subject: "desktop-bootstrap",
341 expiresAt: DateTime.add(now, {
342 milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS),
343 }),
344 // Unbounded uses so the renderer can re-exchange the seed for a
345 // fresh bearer session after a page reload (or after the prior
346 // bearer expires). The seed itself stays inside the desktop
347 // process and the rendered page, both of which the user already
348 // implicitly trusts.
349 remainingUses: "unbounded",
350 });
351 }
352
353 const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn(
354 "PairingGrantStore.listActive",
355 )(
356 function* () {
357 const now = yield* DateTime.now;
358 const rows = yield* pairingLinks.listActive({ now });
359
360 return rows.map((row) =>
361 row.label
362 ? ({
363 id: row.id,
364 scopes: row.scopes,
365 subject: row.subject,
366 label: row.label,
367 createdAt: row.createdAt,
368 expiresAt: row.expiresAt,
369 } satisfies AuthPairingLink)
370 : ({
371 id: row.id,
372 scopes: row.scopes,
373 subject: row.subject,
374 createdAt: row.createdAt,
375 expiresAt: row.expiresAt,
376 } satisfies AuthPairingLink),
377 );
378 },
379 Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })),
380 );
381
382 const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
383 function* (id) {
384 const revokedAt = yield* DateTime.now;
385 const revoked = yield* pairingLinks
386 .revoke({
387 id,
388 revokedAt,
389 })
390 .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause })));
391 if (revoked) {
392 yield* emitRemoved(id);
393 }
394 return revoked;
395 },
396 );
397
398 const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
399 "PairingGrantStore.issueOneTimeToken",
400 )(function* (input) {
401 const id = yield* crypto
402 .randomUUIDv4()
403 .pipe(
404 Effect.mapError(
405 (cause) =>
406 new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }),
407 ),
408 );
409 const credential = yield* generatePairingToken;
410 const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup";
411 const ttl =
412 input?.ttl ??
413 (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES);
414 const now = yield* DateTime.now;
415 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
416 const issued: IssuedBootstrapCredential = {
417 id,
418 credential,
419 ...(input?.label ? { label: input.label } : {}),
420 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
421 expiresAt,
422 };
423 const subject = input?.subject ?? "one-time-token";
424 yield* pairingLinks
425 .create({
426 id,
427 credential,
428 method: "one-time-token",
429 scopes: input?.scopes ?? AuthStandardClientScopes,
430 subject,
431 label: input?.label ?? null,
432 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
433 createdAt: now,
434 expiresAt: expiresAt,
435 })
436 .pipe(
437 Effect.mapError(
438 (cause) =>
439 new PairingCredentialIssueError({
440 pairingLinkId: id,
441 subject,
442 ...(input?.label ? { label: input.label } : {}),
443 cause,
444 }),
445 ),
446 );
447 yield* emitUpsert({
448 id,
449 scopes: input?.scopes ?? AuthStandardClientScopes,
450 subject: input?.subject ?? "one-time-token",
451 ...(input?.label ? { label: input.label } : {}),
452 createdAt: now,
453 expiresAt,
454 });
455 return issued;
456 });
457
458 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
459 function* (credential, input) {
460 const now = yield* DateTime.now;
461 if (consumeRotatingDesktopToken(credential, now.epochMilliseconds)) {
462 return {
463 method: "desktop-bootstrap",
464 scopes: AuthAdministrativeScopes,
465 subject: "desktop-bootstrap",
466 expiresAt: DateTime.add(now, {
467 milliseconds: DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
468 }),
469 } satisfies BootstrapGrant;
470 }
471 const seededResult: ConsumeResult = yield* Ref.modify(
472 seededGrantsRef,
473 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
474 const grant = current.get(credential);
475 if (!grant) {
476 return [
477 {
478 _tag: "error",
479 reason: "not-found",
480 error: new UnknownBootstrapCredentialError({}),
481 },
482 current,
483 ];
484 }
485
486 const next = new Map(current);
487 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
488 next.delete(credential);
489 return [
490 {
491 _tag: "error",
492 reason: "expired",
493 error: new ExpiredBootstrapCredentialError({}),
494 },
495 next,
496 ];
497 }
498
499 if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) {
500 return [
501 {
502 _tag: "error",
503 reason: "not-found",
504 error: new BootstrapCredentialProofKeyMismatchError({}),
505 },
506 next,
507 ];
508 }
509
510 if (
511 input?.requestedScopes !== undefined &&
512 !input.requestedScopes.some((scope) => grant.scopes.includes(scope))
513 ) {
514 return [
515 {
516 _tag: "error",
517 reason: "scope-not-granted",
518 error: new BootstrapCredentialScopeNotGrantedError({}),
519 },
520 current,
521 ];
522 }
523
524 const remainingUses = grant.remainingUses;
525 if (typeof remainingUses === "number") {
526 if (remainingUses <= 1) {
527 next.delete(credential);
528 } else {
529 next.set(credential, {
530 ...grant,
531 remainingUses: remainingUses - 1,
532 });
533 }
534 }
535
536 return [
537 {
538 _tag: "success",
539 grant: {
540 method: grant.method,
541 scopes: grant.scopes,
542 subject: grant.subject,
543 ...(grant.label ? { label: grant.label } : {}),
544 ...(grant.proofKeyThumbprint
545 ? { proofKeyThumbprint: grant.proofKeyThumbprint }
546 : {}),
547 expiresAt: grant.expiresAt,
548 } satisfies BootstrapGrant,
549 },
550 next,
551 ];
552 },
553 );
554
555 if (seededResult._tag === "success") {
556 return seededResult.grant;
557 }
558 if (seededResult.reason !== "not-found") {
559 return yield* seededResult.error;
560 }
561
562 // The scope check is part of the UPDATE's WHERE clause so a rejected
563 // request cannot consume a one-time link. The re-check below only
564 // explains why nothing matched.
565 const consumed = yield* pairingLinks
566 .consumeAvailable({
567 credential,
568 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
569 ...(input?.requestedScopes !== undefined
570 ? { requestedScopes: input.requestedScopes }
571 : {}),
572 consumedAt: now,
573 now,
574 })
575 .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause })));
576
577 if (Option.isSome(consumed)) {
578 yield* emitRemoved(consumed.value.id);
579 return {
580 method: consumed.value.method,
581 scopes: consumed.value.scopes,
582 subject: consumed.value.subject,
583 ...(consumed.value.label ? { label: consumed.value.label } : {}),
584 ...(consumed.value.proofKeyThumbprint
585 ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint }
586 : {}),
587 expiresAt: consumed.value.expiresAt,
588 } satisfies BootstrapGrant;
589 }
590
591 const matching = yield* pairingLinks
592 .getByCredential({ credential })
593 .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause })));
594 if (Option.isNone(matching)) {
595 return yield* new UnknownBootstrapCredentialError({});
596 }
597
598 if (matching.value.revokedAt !== null) {
599 return yield* new UnavailableBootstrapCredentialError({});
600 }
601
602 if (matching.value.consumedAt !== null) {
603 return yield* new UnknownBootstrapCredentialError({});
604 }
605
606 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
607 return yield* new ExpiredBootstrapCredentialError({});
608 }
609
610 if (
611 matching.value.proofKeyThumbprint !== null &&
612 matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint
613 ) {
614 return yield* new BootstrapCredentialProofKeyMismatchError({});
615 }
616
617 if (
618 input?.requestedScopes !== undefined &&
619 !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))
620 ) {
621 return yield* new BootstrapCredentialScopeNotGrantedError({});
622 }
623
624 return yield* new UnavailableBootstrapCredentialError({});
625 },
626 );
627
628 return PairingGrantStore.of({
629 issueOneTimeToken,
630 listActive,
631 get streamChanges() {
632 return Stream.fromPubSub(changesPubSub);
633 },
634 revoke,
635 consume,
636 });
637});
638
639export const layer = Layer.effect(PairingGrantStore, make).pipe(
640 Layer.provideMerge(AuthPairingLinks.layer),
641);