Skip to content
Source
Sign inSign up

PairingGrantStore.test.ts

240 lines9.7 KB

1import { AuthStandardClientScopes } from "@t3tools/contracts";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { expect, it } from "@effect/vitest";
5import * as SecureRandom from "effect/crypto/SecureRandom";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Stream from "effect/Stream";
10import * as Queue from "effect/Queue";
11import * as TestClock from "effect/testing/TestClock";
12import {
13 DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS,
14 currentDesktopBootstrapToken,
15} from "@t3tools/shared/desktopBootstrapToken";
16​
17import * as ServerConfig from "../config.ts";
18import * as SqlitePersistence from "../persistence/Sqlite.ts";
19import * as PairingGrantStore from "./PairingGrantStore.ts";
20​
21const layerServerConfig = (
22 overrides?: Partial<
23 Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret">
24 >,
25) =>
26 Layer.effect(
27 ServerConfig.ServerConfig,
28 Effect.gen(function* () {
29 const config = yield* ServerConfig.ServerConfig;
30 return {
31 ...config,
32 ...overrides,
33 } satisfies ServerConfig.ServerConfig["Service"];
34 }),
35 ).pipe(
36 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
37 );
38​
39const layerPairingGrantStore = (
40 overrides?: Partial<
41 Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken" | "desktopBootstrapSecret">
42 >,
43) =>
44 PairingGrantStore.layer.pipe(
45 Layer.provide(SqlitePersistence.layerMemory),
46 Layer.provide(layerServerConfig(overrides)),
47 );
48​
49it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PairingGrantStore.layer", (it) => {
50 it.effect("accepts lowercase one-time tokens exactly once", () =>
51 Effect.gen(function* () {
52 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
53 const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
54 const first = yield* bootstrapCredentials.consume(issued.credential.toLowerCase());
55 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
56​
57 expect(first.method).toBe("one-time-token");
58 expect(first.scopes).toEqual(AuthStandardClientScopes);
59 expect(first.subject).toBe("one-time-token");
60 expect(first.label).toBe("Julius iPhone");
61 expect(issued.label).toBe("Julius iPhone");
62 expect(second._tag).toBe("UnknownBootstrapCredentialError");
63 expect(second.message).toContain("Unknown bootstrap credential");
64 }).pipe(
65 Effect.provide(layerPairingGrantStore()),
66 Effect.provideService(
67 SecureRandom.SecureRandom,
68 SecureRandom.make((size) => new Uint8Array(size).fill(8)),
69 ),
70 ),
71 );
72​
73 it.effect("atomically consumes a one-time token when multiple requests race", () =>
74 Effect.gen(function* () {
75 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
76 const token = yield* bootstrapCredentials.issueOneTimeToken();
77 const results = yield* Effect.all(
78 Array.from({ length: 8 }, () =>
79 Effect.result(
80 bootstrapCredentials.consume(token.credential, {
81 requestedScopes: ["orchestration:read"],
82 }),
83 ),
84 ),
85 {
86 concurrency: "unbounded",
87 },
88 );
89​
90 const successes = results.filter((result) => result._tag === "Success");
91 const failures = results.filter((result) => result._tag === "Failure");
92​
93 expect(successes).toHaveLength(1);
94 expect(failures).toHaveLength(7);
95 for (const failure of failures) {
96 expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError");
97 expect(failure.failure.message).toContain("Unknown bootstrap credential");
98 }
99 }).pipe(Effect.provide(layerPairingGrantStore())),
100 );
101​
102 it.effect("requires the bound proof key thumbprint when present", () =>
103 Effect.gen(function* () {
104 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
105 const token = yield* bootstrapCredentials.issueOneTimeToken({
106 proofKeyThumbprint: "client-proof-key-thumbprint",
107 scopes: ["orchestration:read"],
108 });
109​
110 const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential));
111 const wrong = yield* Effect.flip(
112 bootstrapCredentials.consume(token.credential, {
113 proofKeyThumbprint: "other-proof-key-thumbprint",
114 requestedScopes: ["access:write"],
115 }),
116 );
117 const forbiddenScope = yield* Effect.flip(
118 bootstrapCredentials.consume(token.credential, {
119 proofKeyThumbprint: "client-proof-key-thumbprint",
120 requestedScopes: ["access:write"],
121 }),
122 );
123 const consumed = yield* bootstrapCredentials.consume(token.credential, {
124 proofKeyThumbprint: "client-proof-key-thumbprint",
125 requestedScopes: ["orchestration:read"],
126 });
127​
128 expect(missing.message).toContain("proof key mismatch");
129 expect(wrong.message).toContain("proof key mismatch");
130 expect(forbiddenScope._tag).toBe("BootstrapCredentialScopeNotGrantedError");
131 expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint");
132 }).pipe(Effect.provide(layerPairingGrantStore())),
133 );
134​
135 it.effect("matches a seeded desktop bootstrap credential exactly", () =>
136 Effect.gen(function* () {
137 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
138 const exact = yield* bootstrapCredentials.consume("abcdefghjkmn");
139 const otherCase = yield* Effect.flip(bootstrapCredentials.consume("ABCDEFGHJKMN"));
140​
141 expect(exact.method).toBe("desktop-bootstrap");
142 expect(otherCase._tag).toBe("UnknownBootstrapCredentialError");
143 }).pipe(
144 Effect.provide(
145 layerPairingGrantStore({
146 desktopBootstrapToken: "abcdefghjkmn",
147 }),
148 ),
149 ),
150 );
151​
152 it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
153 Effect.gen(function* () {
154 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
155​
156 yield* TestClock.adjust(Duration.hours(12));
157 const stillValid = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
158 expect(stillValid.method).toBe("desktop-bootstrap");
159​
160 yield* TestClock.adjust(Duration.hours(13));
161 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
162​
163 expect(expired._tag).toBe("ExpiredBootstrapCredentialError");
164 expect(expired.message).toContain("Bootstrap credential expired");
165 }).pipe(
166 Effect.provide(
167 Layer.merge(
168 layerPairingGrantStore({
169 desktopBootstrapToken: "desktop-bootstrap-token",
170 }),
171 TestClock.layer(),
172 ),
173 ),
174 ),
175 );
176​
177 it.effect("accepts rotating desktop bootstrap tokens derived from the desktop secret", () =>
178 Effect.gen(function* () {
179 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
180 const window = DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS;
181​
182 yield* TestClock.adjust(Duration.days(5));
183 const now = 5 * 24 * 60 * 60 * 1000;
184 const current = yield* bootstrapCredentials.consume(
185 yield* currentDesktopBootstrapToken("desktop-secret", now).pipe(
186 Effect.provide(NodeCrypto.layerMac),
187 ),
188 );
189 expect(current.method).toBe("desktop-bootstrap");
190​
191 const staleToken = yield* currentDesktopBootstrapToken(
192 "desktop-secret",
193 now - 2 * window,
194 ).pipe(Effect.provide(NodeCrypto.layerMac));
195 const stale = yield* Effect.flip(bootstrapCredentials.consume(staleToken));
196 expect(stale._tag).toBe("UnknownBootstrapCredentialError");
197​
198 const launch = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
199 expect(launch._tag).toBe("UnknownBootstrapCredentialError");
200 }).pipe(
201 Effect.provide(
202 Layer.merge(
203 layerPairingGrantStore({
204 desktopBootstrapToken: "desktop-bootstrap-token",
205 desktopBootstrapSecret: "desktop-secret",
206 }),
207 TestClock.layer(),
208 ),
209 ),
210 ),
211 );
212​
213 it.effect("keeps credentials out of pairing lists and change events", () =>
214 Effect.gen(function* () {
215 const grants = yield* PairingGrantStore.PairingGrantStore;
216 const changes = yield* Queue.unbounded<PairingGrantStore.BootstrapCredentialChange>();
217 yield* grants.streamChanges.pipe(
218 Stream.runForEach((change) => Queue.offer(changes, change)),
219 Effect.forkScoped({ startImmediately: true }),
220 );
221 // An app approval code sends no update, issued or spent; the next belongs to a plain code.
222 const approval = yield* grants.issueOneTimeToken({ mcpApproval: "auto" });
223 yield* grants.consume(approval.credential, { mcpApproval: "auto" });
224 for (const input of [{}, { label: "Synthetic phone" }]) {
225 const issued = yield* grants.issueOneTimeToken(input);
226 const change = yield* Queue.take(changes);
227 expect(change?.type).toBe("pairingLinkUpserted");
228 if (change?.type !== "pairingLinkUpserted")
229 throw new Error("Expected a pairing link update");
230 expect(change.pairingLink.id).toBe(issued.id);
231 expect(change.pairingLink).not.toHaveProperty("credential");
232 const listed = (yield* grants.listActive()).find((link) => link.id === issued.id);
233 expect(listed).toEqual(change.pairingLink);
234 const consumed = yield* grants.consume(issued.credential);
235 expect(consumed.scopes).toEqual(change.pairingLink.scopes);
236 expect(yield* Queue.take(changes)).toEqual({ type: "pairingLinkRemoved", id: issued.id });
237 }
238 }).pipe(Effect.scoped, Effect.provide(layerPairingGrantStore())),
239 );
240});