Skip to content
Source
Sign inSign up

PairingGrantStore.ts

655 lines21.3 KB

1import {
2 AuthStandardClientScopes,
3 type AuthEnvironmentScope,
4 type AuthMcpClientAccess,
5 type AuthPairingLink,
6 mcpApprovalCode,
7 PAIRING_CODE_ALPHABET,
8 PAIRING_CODE_LENGTH,
9 parseMcpApprovalCode,
10 type ServerAuthBootstrapMethod,
11} from "@t3tools/contracts";
12import {
13 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
14 isValidDesktopBootstrapToken,
15} from "@t3tools/shared/desktopBootstrapToken";
16import * as Context from "effect/Context";
17import * as SecureRandom from "effect/crypto/SecureRandom";
18import * as DateTime from "effect/DateTime";
19import * as Duration from "effect/Duration";
20import * as Mac from "effect/crypto/Mac";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";
24import * as PubSub from "effect/PubSub";
25import * as Ref from "effect/Ref";
26import * as Schema from "effect/Schema";
27import * as Stream from "effect/Stream";
28​
29import * as ServerConfig from "../config.ts";
30import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
31​
32export interface BootstrapGrant {
33 readonly method: ServerAuthBootstrapMethod;
34 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
35 readonly subject: string;
36 readonly label?: string;
37 readonly proofKeyThumbprint?: string;
38 readonly expiresAt: DateTime.DateTime;
40​
41export class UnknownBootstrapCredentialError extends Schema.TaggedError<UnknownBootstrapCredentialError>()(
42 "UnknownBootstrapCredentialError",
43 {},
44) {
45 override get message(): string {
46 return "Unknown bootstrap credential.";
47 }
49​
50export class ExpiredBootstrapCredentialError extends Schema.TaggedError<ExpiredBootstrapCredentialError>()(
51 "ExpiredBootstrapCredentialError",
52 {},
53) {
54 override get message(): string {
55 return "Bootstrap credential expired.";
56 }
58​
59export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedError<BootstrapCredentialProofKeyMismatchError>()(
60 "BootstrapCredentialProofKeyMismatchError",
61 {},
62) {
63 override get message(): string {
64 return "Bootstrap credential proof key mismatch.";
65 }
67​
68export class UnavailableBootstrapCredentialError extends Schema.TaggedError<UnavailableBootstrapCredentialError>()(
69 "UnavailableBootstrapCredentialError",
70 {},
71) {
72 override get message(): string {
73 return "Bootstrap credential is no longer available.";
74 }
76​
77export class BootstrapCredentialScopeNotGrantedError extends Schema.TaggedError<BootstrapCredentialScopeNotGrantedError>()(
78 "BootstrapCredentialScopeNotGrantedError",
79 {},
80) {
81 override get message(): string {
82 return "The requested authentication scope was not granted.";
83 }
85​
86export const BootstrapCredentialInvalidError = Schema.Union([
87 UnknownBootstrapCredentialError,
88 ExpiredBootstrapCredentialError,
89 BootstrapCredentialProofKeyMismatchError,
90 UnavailableBootstrapCredentialError,
91]);
92export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type;
93​
94export class ActivePairingLinksLoadError extends Schema.TaggedError<ActivePairingLinksLoadError>()(
95 "ActivePairingLinksLoadError",
96 {
97 cause: Schema.Defect(),
98 },
99) {
100 override get message(): string {
101 return "Failed to load active pairing links.";
102 }
104​
105export class PairingLinkRevokeError extends Schema.TaggedError<PairingLinkRevokeError>()(
106 "PairingLinkRevokeError",
107 {
108 pairingLinkId: Schema.String,
109 cause: Schema.Defect(),
110 },
111) {
112 override get message(): string {
113 return `Failed to revoke pairing link '${this.pairingLinkId}'.`;
114 }
116​
117export class PairingCredentialIssueError extends Schema.TaggedError<PairingCredentialIssueError>()(
118 "PairingCredentialIssueError",
119 {
120 pairingLinkId: Schema.String,
121 subject: Schema.String,
122 label: Schema.optional(Schema.String),
123 cause: Schema.Defect(),
124 },
125) {
126 override get message(): string {
127 return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`;
128 }
130​
131export class PairingCredentialRandomGenerationError extends Schema.TaggedError<PairingCredentialRandomGenerationError>()(
132 "PairingCredentialRandomGenerationError",
133 {
134 operation: Schema.Literals(["generate-id", "generate-token"]),
135 cause: Schema.Defect(),
136 },
137) {
138 override get message(): string {
139 return `Failed to generate pairing credential data during '${this.operation}'.`;
140 }
142​
143export class BootstrapCredentialConsumeError extends Schema.TaggedError<BootstrapCredentialConsumeError>()(
144 "BootstrapCredentialConsumeError",
145 {
146 cause: Schema.Defect(),
147 },
148) {
149 override get message(): string {
150 return "Failed to consume bootstrap credential.";
151 }
153​
154export class BootstrapCredentialConsumeAvailableError extends Schema.TaggedError<BootstrapCredentialConsumeAvailableError>()(
155 "BootstrapCredentialConsumeAvailableError",
156 {
157 cause: Schema.Defect(),
158 },
159) {
160 override get message(): string {
161 return "Failed to atomically consume an available bootstrap credential.";
162 }
164​
165export class BootstrapCredentialLookupError extends Schema.TaggedError<BootstrapCredentialLookupError>()(
166 "BootstrapCredentialLookupError",
167 {
168 cause: Schema.Defect(),
169 },
170) {
171 override get message(): string {
172 return "Failed to look up bootstrap credential state.";
173 }
175​
176export const BootstrapCredentialInternalError = Schema.Union([
177 ActivePairingLinksLoadError,
178 PairingLinkRevokeError,
179 PairingCredentialIssueError,
180 PairingCredentialRandomGenerationError,
181 BootstrapCredentialConsumeError,
182 BootstrapCredentialConsumeAvailableError,
183 BootstrapCredentialLookupError,
184]);
185export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;
186export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);
187​
188export const BootstrapCredentialError = Schema.Union([
189 BootstrapCredentialInvalidError,
190 BootstrapCredentialInternalError,
191 BootstrapCredentialScopeNotGrantedError,
192]);
193export type BootstrapCredentialError = typeof BootstrapCredentialError.Type;
194​
195export interface IssuedBootstrapCredential {
196 readonly id: string;
197 readonly credential: string;
198 readonly label?: string;
199 readonly proofKeyThumbprint?: string;
200 readonly expiresAt: DateTime.Utc;
202​
203export type BootstrapCredentialChange =
204 | {
205 readonly type: "pairingLinkUpserted";
206 readonly pairingLink: AuthPairingLink;
207 }
208 | {
209 readonly type: "pairingLinkRemoved";
210 readonly id: string;
211 };
212​
213export class PairingGrantStore extends Context.Service<
214 PairingGrantStore,
215 {
216 readonly issueOneTimeToken: (input?: {
217 readonly ttl?: Duration.Duration;
218 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
219 readonly subject?: string;
220 readonly label?: string;
221 readonly proofKeyThumbprint?: string;
222 readonly purpose?: "startup";
223 /** Issues a hidden `<LEVEL>-` code that only an agent sign-in at this level can spend. */
224 readonly mcpApproval?: AuthMcpClientAccess;
225 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
226 readonly listActive: () => Effect.Effect<
227 ReadonlyArray<AuthPairingLink>,
228 BootstrapCredentialInternalError
229 >;
230 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
231 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
232 readonly consume: (
233 credential: string,
234 input?: {
235 readonly proofKeyThumbprint?: string;
236 readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
237 /** All scopes must be granted; requestedScopes only requires an overlap for OAuth. */
238 readonly requiredScopes?: ReadonlyArray<AuthEnvironmentScope>;
239 /** Also spends a code issued for an agent sign-in at this level; others refuse them. */
240 readonly mcpApproval?: AuthMcpClientAccess;
241 },
242 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
243 }
244>()("t3/auth/PairingGrantStore") {}
245​
246interface StoredBootstrapGrant extends BootstrapGrant {
247 readonly remainingUses: number | "unbounded";
249​
250type ConsumeResult =
251 | {
252 readonly _tag: "error";
253 readonly reason: "not-found" | "expired" | "scope-not-granted";
254 readonly error: BootstrapCredentialError;
255 }
256 | {
257 readonly _tag: "success";
258 readonly grant: BootstrapGrant;
259 };
260​
261const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
262const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);
263const DEV_STARTUP_TTL_HOURS = Duration.hours(24);
264const PAIRING_TOKEN_REJECTION_LIMIT =
265 Math.floor(256 / PAIRING_CODE_ALPHABET.length) * PAIRING_CODE_ALPHABET.length;
266​
267const PAIRING_TOKEN_PATTERN = new RegExp(
268 `^[${PAIRING_CODE_ALPHABET}]{${PAIRING_CODE_LENGTH}}$`,
269 "i",
270);
271​
272const make = Effect.gen(function* () {
273 const random = yield* SecureRandom.SecureRandom;
274 const config = yield* ServerConfig.ServerConfig;
275 const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository;
276 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
277 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
278 const generatePairingToken = Effect.gen(function* () {
279 let credential = "";
280 while (credential.length < PAIRING_CODE_LENGTH) {
281 const bytes = yield* random
282 .randomBytes(PAIRING_CODE_LENGTH)
283 .pipe(
284 Effect.mapError(
285 (cause) =>
286 new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }),
287 ),
288 );
289 for (const byte of bytes) {
290 if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
291 continue;
292 }
293 credential += PAIRING_CODE_ALPHABET[byte % PAIRING_CODE_ALPHABET.length]!;
294 if (credential.length === PAIRING_CODE_LENGTH) {
295 return credential;
296 }
297 }
298 }
299 return credential;
300 });
301​
302 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
303 Ref.update(seededGrantsRef, (current) => {
304 const next = new Map(current);
305 next.set(credential, grant);
306 return next;
307 });
308​
309 const emitUpsert = (pairingLink: AuthPairingLink) =>
310 PubSub.publish(changesPubSub, {
311 type: "pairingLinkUpserted",
312 pairingLink,
313 }).pipe(Effect.asVoid);
314​
315 const emitRemoved = (id: string) =>
316 PubSub.publish(changesPubSub, {
317 type: "pairingLinkRemoved",
318 id,
319 }).pipe(Effect.asVoid);
320​
321 // Accept tokens derived from the rotating desktop secret; older desktops send only a token.
322 const desktopBootstrapSecret = config.desktopBootstrapSecret;
323 const mac = yield* Mac.Mac;
324 const consumeRotatingDesktopToken = (credential: string, nowMs: number) =>
325 desktopBootstrapSecret === undefined
326 ? Effect.succeed(false)
327 : isValidDesktopBootstrapToken(desktopBootstrapSecret, credential, nowMs).pipe(
328 Effect.provideService(Mac.Mac, mac),
329 );
330​
331 if (config.desktopBootstrapToken && desktopBootstrapSecret === undefined) {
332 const now = yield* DateTime.now;
333 yield* seedGrant(config.desktopBootstrapToken, {
334 method: "desktop-bootstrap",
335 scopes: AuthStandardClientScopes,
336 subject: "desktop-bootstrap",
337 expiresAt: DateTime.add(now, {
338 milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS),
339 }),
340 // Reloads and expired bearer sessions must be able to re-exchange the desktop seed.
341 remainingUses: "unbounded",
342 });
343 }
344​
345 const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn(
346 "PairingGrantStore.listActive",
347 )(
348 function* () {
349 const now = yield* DateTime.now;
350 const rows = yield* pairingLinks.listActive({ now });
351​
352 return rows.map((row) =>
353 row.label
354 ? ({
355 id: row.id,
356 scopes: row.scopes,
357 subject: row.subject,
358 label: row.label,
359 createdAt: row.createdAt,
360 expiresAt: row.expiresAt,
361 } satisfies AuthPairingLink)
362 : ({
363 id: row.id,
364 scopes: row.scopes,
365 subject: row.subject,
366 createdAt: row.createdAt,
367 expiresAt: row.expiresAt,
368 } satisfies AuthPairingLink),
369 );
370 },
371 Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })),
372 );
373​
374 const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
375 function* (id) {
376 const revokedAt = yield* DateTime.now;
377 const revoked = yield* pairingLinks
378 .revoke({
379 id,
380 revokedAt,
381 })
382 .pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause })));
383 if (revoked) {
384 yield* emitRemoved(id);
385 }
386 return revoked;
387 },
388 );
389​
390 const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
391 "PairingGrantStore.issueOneTimeToken",
392 )(function* (input) {
393 const id = yield* random
394 .uuidV4()
395 .pipe(
396 Effect.mapError(
397 (cause) =>
398 new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }),
399 ),
400 );
401 const token = yield* generatePairingToken;
402 const mcpApproval = input?.mcpApproval;
403 const credential = mcpApproval ? mcpApprovalCode(mcpApproval, token) : token;
404 const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup";
405 const ttl =
406 input?.ttl ??
407 (isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES);
408 const now = yield* DateTime.now;
409 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
410 const issued: IssuedBootstrapCredential = {
411 id,
412 credential,
413 ...(input?.label ? { label: input.label } : {}),
414 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
415 expiresAt,
416 };
417 const subject = mcpApproval
418 ? `${AuthPairingLinks.MCP_APPROVAL_SUBJECT_PREFIX}${mcpApproval}`
419 : (input?.subject ?? "one-time-token");
420 yield* pairingLinks
421 .create({
422 id,
423 credential,
424 method: "one-time-token",
425 scopes: input?.scopes ?? AuthStandardClientScopes,
426 subject,
427 label: input?.label ?? null,
428 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
429 createdAt: now,
430 expiresAt: expiresAt,
431 })
432 .pipe(
433 Effect.mapError(
434 (cause) =>
435 new PairingCredentialIssueError({
436 pairingLinkId: id,
437 subject,
438 ...(input?.label ? { label: input.label } : {}),
439 cause,
440 }),
441 ),
442 );
443 // Approval codes stay out of the pairing links and QR codes that clients show.
444 if (!mcpApproval) {
445 yield* emitUpsert({
446 id,
447 scopes: input?.scopes ?? AuthStandardClientScopes,
448 subject,
449 ...(input?.label ? { label: input.label } : {}),
450 createdAt: now,
451 expiresAt,
452 });
453 }
454 return issued;
455 });
456​
457 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
458 function* (credential, input) {
459 const now = yield* DateTime.now;
460 if (yield* consumeRotatingDesktopToken(credential, now.epochMilliseconds)) {
461 return {
462 method: "desktop-bootstrap",
463 scopes: AuthStandardClientScopes,
464 subject: "desktop-bootstrap",
465 expiresAt: DateTime.add(now, {
466 milliseconds: DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
467 }),
468 } satisfies BootstrapGrant;
469 }
470 const seededResult: ConsumeResult = yield* Ref.modify(
471 seededGrantsRef,
472 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
473 const grant = current.get(credential);
474 if (!grant) {
475 return [
476 {
477 _tag: "error",
478 reason: "not-found",
479 error: new UnknownBootstrapCredentialError({}),
480 },
481 current,
482 ];
483 }
484​
485 const next = new Map(current);
486 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
487 next.delete(credential);
488 return [
489 {
490 _tag: "error",
491 reason: "expired",
492 error: new ExpiredBootstrapCredentialError({}),
493 },
494 next,
495 ];
496 }
497​
498 if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) {
499 return [
500 {
501 _tag: "error",
502 reason: "not-found",
503 error: new BootstrapCredentialProofKeyMismatchError({}),
504 },
505 next,
506 ];
507 }
508​
509 if (
510 (input?.requestedScopes !== undefined &&
511 !input.requestedScopes.some((scope) => grant.scopes.includes(scope))) ||
512 (input?.requiredScopes !== undefined &&
513 !input.requiredScopes.every((scope) => grant.scopes.includes(scope)))
514 ) {
515 return [
516 {
517 _tag: "error",
518 reason: "scope-not-granted",
519 error: new BootstrapCredentialScopeNotGrantedError({}),
520 },
521 current,
522 ];
523 }
524​
525 const remainingUses = grant.remainingUses;
526 if (typeof remainingUses === "number") {
527 if (remainingUses <= 1) {
528 next.delete(credential);
529 } else {
530 next.set(credential, {
531 ...grant,
532 remainingUses: remainingUses - 1,
533 });
534 }
535 }
536​
537 return [
538 {
539 _tag: "success",
540 grant: {
541 method: grant.method,
542 scopes: grant.scopes,
543 subject: grant.subject,
544 ...(grant.label ? { label: grant.label } : {}),
545 ...(grant.proofKeyThumbprint
546 ? { proofKeyThumbprint: grant.proofKeyThumbprint }
547 : {}),
548 expiresAt: grant.expiresAt,
549 } satisfies BootstrapGrant,
550 },
551 next,
552 ];
553 },
554 );
555​
556 if (seededResult._tag === "success") {
557 return seededResult.grant;
558 }
559 if (seededResult.reason !== "not-found") {
560 return yield* seededResult.error;
561 }
562​
563 // Only credentials in the issued pairing-code formats are case-insensitive.
564 const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential)
565 ? credential.toUpperCase()
566 : (parseMcpApprovalCode(credential)?.code ?? credential);
567​
568 // Check scopes in the UPDATE so rejected requests cannot spend one-time links.
569 const consumed = yield* pairingLinks
570 .consumeAvailable({
571 credential: pairingCredential,
572 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
573 ...(input?.requestedScopes !== undefined
574 ? { requestedScopes: input.requestedScopes }
575 : {}),
576 ...(input?.requiredScopes !== undefined ? { requiredScopes: input.requiredScopes } : {}),
577 ...(input?.mcpApproval
578 ? {
579 mcpApprovalSubject: `${AuthPairingLinks.MCP_APPROVAL_SUBJECT_PREFIX}${input.mcpApproval}`,
580 }
581 : {}),
582 consumedAt: now,
583 now,
584 })
585 .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause })));
586​
587 if (Option.isSome(consumed)) {
588 if (!consumed.value.subject.startsWith(AuthPairingLinks.MCP_APPROVAL_SUBJECT_PREFIX)) {
589 yield* emitRemoved(consumed.value.id);
590 }
591 return {
592 method: consumed.value.method,
593 scopes: consumed.value.scopes,
594 subject: consumed.value.subject,
595 ...(consumed.value.label ? { label: consumed.value.label } : {}),
596 ...(consumed.value.proofKeyThumbprint
597 ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint }
598 : {}),
599 expiresAt: consumed.value.expiresAt,
600 } satisfies BootstrapGrant;
601 }
602​
603 const matching = yield* pairingLinks
604 .getByCredential({ credential: pairingCredential })
605 .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause })));
606 if (Option.isNone(matching)) {
607 return yield* new UnknownBootstrapCredentialError({});
608 }
609​
610 if (matching.value.revokedAt !== null) {
611 return yield* new UnavailableBootstrapCredentialError({});
612 }
613​
614 if (matching.value.consumedAt !== null) {
615 return yield* new UnknownBootstrapCredentialError({});
616 }
617​
618 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
619 return yield* new ExpiredBootstrapCredentialError({});
620 }
621​
622 if (
623 matching.value.proofKeyThumbprint !== null &&
624 matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint
625 ) {
626 return yield* new BootstrapCredentialProofKeyMismatchError({});
627 }
628​
629 if (
630 (input?.requestedScopes !== undefined &&
631 !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))) ||
632 (input?.requiredScopes !== undefined &&
633 !input.requiredScopes.every((scope) => matching.value.scopes.includes(scope)))
634 ) {
635 return yield* new BootstrapCredentialScopeNotGrantedError({});
636 }
637​
638 return yield* new UnavailableBootstrapCredentialError({});
639 },
640 );
641​
642 return PairingGrantStore.of({
643 issueOneTimeToken,
644 listActive,
645 get streamChanges() {
646 return Stream.fromPubSub(changesPubSub);
647 },
648 revoke,
649 consume,
650 });
651});
652​
653export const layer = Layer.effect(PairingGrantStore, make).pipe(
654 Layer.provideMerge(AuthPairingLinks.layer),
655);