@spacefast/auth 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @spacefast/auth
2
+
3
+ Auth primitives for apps running on Spacefast. Two entry points, one contract:
4
+
5
+ - `@spacefast/auth/server` — verify a visitor token offline and get back the
6
+ identity contract every Spacefast surface shares.
7
+ - `@spacefast/auth/tokens` — keep any short-lived credential fresh without
8
+ writing refresh code again.
9
+
10
+ The library verifies and manages. It never mints — identity always comes from
11
+ the platform.
12
+
13
+ ## Verify on the server
14
+
15
+ ```ts
16
+ import { createVerifier } from "@spacefast/auth/server";
17
+
18
+ // The full relying-party profile is mandatory. A signing key alone only says
19
+ // who signed a token — issuer, audience, host, purpose, and claim version are
20
+ // what make it *yours*. The JWKS is data you already have, never a URL: no
21
+ // network round-trip hides in your request path.
22
+ const verify = createVerifier({
23
+ jwks, // { keys: [...] } — Ed25519 public keys
24
+ issuer: "spacefast-api",
25
+ audience: "spc_1234", // your Space
26
+ host: "docs.example.com", // the host this token must be bound to
27
+ purpose: "handoff", // tokens minted for any other lane never pass
28
+ claimVersion: 1,
29
+ });
30
+
31
+ const auth = await verify(request); // AuthContext | null
32
+
33
+ // AuthContext — the shared contract:
34
+ // { principal: "account:usr_8m2p" | "person:per_…" | "guest:local",
35
+ // authorities: ["member:usr_8m2p"],
36
+ // capabilities: ["page.view", "comments.write"],
37
+ // isGuest, isAuthenticated, displayName, email?, emailVerified?, picture? }
38
+ ```
39
+
40
+ `verify` accepts a `Request` (it reads `Authorization: Bearer` or
41
+ `X-SF-Authorization`) or a raw token string.
42
+
43
+ ### The four states
44
+
45
+ `null` is fine for the main path — anything that isn't a verified identity is
46
+ a guest. When you need to know _why_, ask for the outcome:
47
+
48
+ ```ts
49
+ import { guestContext } from "@spacefast/auth/server";
50
+
51
+ const outcome = await verify.outcome(request);
52
+ // { state: "verified", auth } the token proves an identity
53
+ // { state: "no-token" } nothing to verify
54
+ // { state: "invalid-token", reason } provably not acceptable here
55
+ // { state: "verifier-unavailable", reason }
56
+ // this process can't render a verdict —
57
+ // e.g. a key id your JWKS doesn't know yet
58
+
59
+ const auth = outcome.state === "verified" ? outcome.auth : guestContext();
60
+ ```
61
+
62
+ Fail open to guest: every non-verified state serves the guest experience. The
63
+ states exist so your logs can tell a forged token from a stale key deployment.
64
+ An unknown `kid` is deliberately _unavailable_, not _invalid_ — during key
65
+ rotation the honest answer is "I can't know", never "forged".
66
+
67
+ Defaults worth knowing: 5 seconds of clock skew on `exp`/`nbf`; `azp` is
68
+ checked only when you pass `authorizedParties`; a malformed claim — even an
69
+ optional one — makes the whole token invalid. Bad data fails loud, not soft.
70
+
71
+ ## Keep a credential fresh
72
+
73
+ ```ts
74
+ import { createTokenManager, TokenRefusedError } from "@spacefast/auth/tokens";
75
+
76
+ const tickets = createTokenManager({
77
+ mint: async () => {
78
+ const response = await fetch("/__spacefast/collab/ticket", { method: "POST" });
79
+ if (response.status === 403) throw new TokenRefusedError("forbidden");
80
+ if (!response.ok) throw new Error(`ticket ${response.status}`); // transient
81
+ const { data } = await response.json();
82
+ return { token: data.token, expiresAt: data.expiresAt };
83
+ },
84
+ });
85
+
86
+ await tickets.current(); // mints once, then stays warm
87
+ tickets.onToken((token) => reconnect(token));
88
+ tickets.onRefusal((code) => teardown(code)); // "no" is terminal, not a retry
89
+ tickets.stop(); // cancels everything, leaks nothing
90
+ ```
91
+
92
+ What the manager does for you:
93
+
94
+ - **Proactive refresh** at 80% of each token's lifetime — callers never pay
95
+ the mint latency on the hot path.
96
+ - **Jittered exponential backoff** when minting fails transiently, while any
97
+ unexpired token keeps being served.
98
+ - **Offline ≠ signed out.** A caller with no live token during an outage gets
99
+ a `TokenOfflineError`, never a `null` that reads as "no access". Refusals
100
+ throw `TokenRefusedError` with the code the minter gave.
101
+ - **One mint at a time.** Concurrent `current()` calls share the in-flight
102
+ request — bursts don't stampede your token endpoint.
@@ -0,0 +1,76 @@
1
+ /** The one identity contract every Spacefast surface shares. */
2
+ export type AuthContext = {
3
+ /**
4
+ * Who this session is: `account:…` (a Spacefast user), `person:…` (an
5
+ * email-invited collaborator), `external:…` (vouched for by an identity
6
+ * connection), or `guest:…` (nobody in particular).
7
+ */
8
+ principal: string;
9
+ /** The credentials this session arrived with, e.g. `member:usr_8m2p`. */
10
+ authorities: string[];
11
+ /** What the session may do, e.g. `page.view`, `comments.write`. */
12
+ capabilities: string[];
13
+ isGuest: boolean;
14
+ isAuthenticated: boolean;
15
+ displayName: string;
16
+ email?: string;
17
+ emailVerified?: boolean;
18
+ picture?: string;
19
+ };
20
+ export type Jwk = {
21
+ kty?: string;
22
+ crv?: string;
23
+ alg?: string;
24
+ kid?: string;
25
+ x?: string;
26
+ [member: string]: unknown;
27
+ };
28
+ /** Key material as data. Never a URL — the verifier does not fetch. */
29
+ export type Jwks = {
30
+ keys: Jwk[];
31
+ };
32
+ export type VerifierConfig = {
33
+ /** Ed25519 public keys (OKP/EdDSA), passed as data. */
34
+ jwks: Jwks;
35
+ /** Exact `iss` the token must carry. */
36
+ issuer: string;
37
+ /** Exact `aud` the token must carry — for visitor tokens, the Space id. */
38
+ audience: string;
39
+ /** The serving host the token must be bound to (`host` claim, case-insensitive). */
40
+ host: string;
41
+ /** Exact `purpose` the token must carry. A token minted for another lane never verifies. */
42
+ purpose: string;
43
+ /** Exact claim-schema version (`v` claim) this verifier understands. */
44
+ claimVersion: number;
45
+ /** When set, a token carrying `azp` must name one of these parties. */
46
+ authorizedParties?: readonly string[];
47
+ /** exp/nbf leeway in seconds. Default 5. */
48
+ clockSkewSeconds?: number;
49
+ /** Unix-seconds clock override for tests. */
50
+ now?: () => number;
51
+ };
52
+ export type VerifyInput = Request | string | null | undefined;
53
+ export type VerifyOutcome = {
54
+ state: "verified";
55
+ auth: AuthContext;
56
+ } | {
57
+ state: "no-token";
58
+ } | {
59
+ state: "invalid-token";
60
+ reason: string;
61
+ } | {
62
+ state: "verifier-unavailable";
63
+ reason: string;
64
+ };
65
+ export type Verifier = {
66
+ /** Sugar over `outcome`: the AuthContext, or null for every other state. */
67
+ (input: VerifyInput): Promise<AuthContext | null>;
68
+ outcome(input: VerifyInput): Promise<VerifyOutcome>;
69
+ };
70
+ /**
71
+ * The guest AuthContext — what a caller serves when verification returns
72
+ * anything but `verified`. `guestContext("jane doe")` names the guest;
73
+ * with no name the guest is simply a Guest.
74
+ */
75
+ export declare function guestContext(name?: string): AuthContext;
76
+ export declare function createVerifier(config: VerifierConfig): Verifier;
package/dist/server.js ADDED
@@ -0,0 +1,371 @@
1
+ // Server-side verification of Spacefast visitor tokens (access-unification
2
+ // plan §07). The whole design is 0-hop: the JWKS arrives as data, the token is
3
+ // checked offline with WebCrypto, and no request ever leaves the process. The
4
+ // relying-party profile — issuer, audience, host, purpose, claim version — is
5
+ // mandatory in full. A JWKS alone only says who signed a token; with a shared
6
+ // cross-tenant signer that is not a tenant boundary. The profile is.
7
+ //
8
+ // Verification is four-state (Lakebed's contract):
9
+ // verified the token proves an AuthContext
10
+ // no-token the request carried nothing to verify
11
+ // invalid-token the token is provably not acceptable here
12
+ // verifier-unavailable this process cannot render a verdict (missing
13
+ // WebCrypto, or a `kid` the supplied JWKS doesn't know)
14
+ //
15
+ // The documented failure posture is fail-open-to-guest: anything but
16
+ // `verified` serves the guest experience. The four states exist so callers
17
+ // that care (logging, strict mode) can tell an attack from a stale deploy.
18
+ // jose v6 reports the fully-specified "Ed25519" for OKP keys while older
19
+ // signers stamp the umbrella "EdDSA" — both name the same signature scheme.
20
+ const TOKEN_ALGS = ["EdDSA", "Ed25519"];
21
+ const ED25519_PUBLIC_KEY_BYTES = 32;
22
+ const DEFAULT_CLOCK_SKEW_SECONDS = 5;
23
+ /**
24
+ * The guest AuthContext — what a caller serves when verification returns
25
+ * anything but `verified`. `guestContext("jane doe")` names the guest;
26
+ * with no name the guest is simply a Guest.
27
+ */
28
+ export function guestContext(name) {
29
+ const slug = name === undefined ? "local" : guestSlug(name);
30
+ return {
31
+ principal: `guest:${slug}`,
32
+ authorities: [],
33
+ capabilities: [],
34
+ isGuest: true,
35
+ isAuthenticated: false,
36
+ displayName: name === undefined ? "Guest" : guestDisplayName(slug),
37
+ };
38
+ }
39
+ export function createVerifier(config) {
40
+ requireText(config.issuer, "issuer");
41
+ requireText(config.audience, "audience");
42
+ requireText(config.host, "host");
43
+ requireText(config.purpose, "purpose");
44
+ if (!Number.isInteger(config.claimVersion)) {
45
+ throw new TypeError("createVerifier: claimVersion must be an integer");
46
+ }
47
+ const keys = usableKeys(config.jwks);
48
+ if (keys.length === 0) {
49
+ throw new TypeError("createVerifier: jwks carries no usable Ed25519 key (need kty OKP, crv Ed25519, base64url x of 32 bytes)");
50
+ }
51
+ const expectedHost = config.host.trim().toLowerCase();
52
+ const skew = config.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
53
+ const outcome = async (input) => {
54
+ const token = tokenFrom(input);
55
+ if (!token) {
56
+ return { state: "no-token" };
57
+ }
58
+ const parts = token.split(".");
59
+ const headerPart = parts[0];
60
+ const payloadPart = parts[1];
61
+ const signaturePart = parts[2];
62
+ if (parts.length !== 3 || !headerPart || !payloadPart || !signaturePart) {
63
+ return invalid("malformed");
64
+ }
65
+ let header;
66
+ let payload;
67
+ let signature;
68
+ try {
69
+ header = jsonObject(decodeText(headerPart));
70
+ payload = jsonObject(decodeText(payloadPart));
71
+ signature = base64UrlDecode(signaturePart);
72
+ }
73
+ catch {
74
+ return invalid("malformed");
75
+ }
76
+ if (!header || !payload) {
77
+ return invalid("malformed");
78
+ }
79
+ if (!TOKEN_ALGS.includes(header.alg)) {
80
+ return invalid("alg");
81
+ }
82
+ // Key selection: a token `kid` narrows to keys claiming that id (keys
83
+ // published without a kid stay candidates). A kid nobody claims is
84
+ // indistinguishable from key material this deploy hasn't received yet —
85
+ // that is unavailability, not proof of forgery.
86
+ const kid = typeof header.kid === "string" ? header.kid : null;
87
+ const candidates = kid ? keys.filter((key) => key.kid === undefined || key.kid === kid) : keys;
88
+ if (candidates.length === 0) {
89
+ return { state: "verifier-unavailable", reason: "unknown-kid" };
90
+ }
91
+ const subtle = webCrypto();
92
+ if (!subtle) {
93
+ return { state: "verifier-unavailable", reason: "no-webcrypto" };
94
+ }
95
+ const signed = new TextEncoder().encode(`${headerPart}.${payloadPart}`);
96
+ const verified = (await Promise.all(candidates.map((key) => ed25519Verify(subtle, key.bytes, signed, signature)))).some(Boolean);
97
+ if (!verified) {
98
+ return invalid("signature");
99
+ }
100
+ const claims = readClaims(payload);
101
+ if (typeof claims === "string") {
102
+ return invalid(claims);
103
+ }
104
+ if (claims.iss !== config.issuer)
105
+ return invalid("claim:iss");
106
+ if (claims.aud !== config.audience)
107
+ return invalid("claim:aud");
108
+ if (claims.host.trim().toLowerCase() !== expectedHost)
109
+ return invalid("claim:host");
110
+ if (claims.purpose !== config.purpose)
111
+ return invalid("claim:purpose");
112
+ if (claims.v !== config.claimVersion)
113
+ return invalid("claim:v");
114
+ if (config.authorizedParties !== undefined &&
115
+ claims.azp !== undefined &&
116
+ !config.authorizedParties.includes(claims.azp)) {
117
+ return invalid("claim:azp");
118
+ }
119
+ const now = config.now ? config.now() : Math.floor(Date.now() / 1000);
120
+ if (now > claims.exp + skew)
121
+ return invalid("expired");
122
+ if (claims.nbf !== undefined && now < claims.nbf - skew)
123
+ return invalid("not-yet-valid");
124
+ return { state: "verified", auth: authFromClaims(claims) };
125
+ };
126
+ const verify = async (input) => {
127
+ const result = await outcome(input);
128
+ return result.state === "verified" ? result.auth : null;
129
+ };
130
+ return Object.assign(verify, { outcome });
131
+ }
132
+ function invalid(reason) {
133
+ return { state: "invalid-token", reason };
134
+ }
135
+ // Identity comes from the `principal` claim alone — set at mint by whichever
136
+ // lane actually authenticated somebody, never inferred from authorities. A
137
+ // verified token without it is a real session (a share link, a password) that
138
+ // belongs to nobody: it keeps its authorities and capabilities as a guest.
139
+ function authFromClaims(claims) {
140
+ const identified = claims.principal !== undefined;
141
+ const displayName = claims.profile?.name ??
142
+ claims.profile?.username ??
143
+ (identified && claims.principal
144
+ ? claims.principal.slice(claims.principal.indexOf(":") + 1)
145
+ : "Guest");
146
+ return {
147
+ principal: claims.principal ?? "guest:local",
148
+ authorities: claims.authorities,
149
+ capabilities: claims.capabilities,
150
+ isGuest: !identified,
151
+ isAuthenticated: identified,
152
+ displayName,
153
+ ...(claims.email !== undefined ? { email: claims.email } : {}),
154
+ ...(claims.emailVerified !== undefined ? { emailVerified: claims.emailVerified } : {}),
155
+ ...(claims.profile?.avatar_url !== undefined ? { picture: claims.profile.avatar_url } : {}),
156
+ };
157
+ }
158
+ // Strict claim reading: a malformed claim — even an optional one — makes the
159
+ // token invalid. A signer emitting out-of-contract values is a bug we want
160
+ // loud, not a value we quietly drop. Returns the reason string on failure.
161
+ function readClaims(payload) {
162
+ const sub = text(payload.sub);
163
+ if (sub === null)
164
+ return "claim:sub";
165
+ const authorities = stringArray(payload.authorities);
166
+ if (authorities === null || authorities.length === 0)
167
+ return "claim:authorities";
168
+ const capabilities = payload.capabilities === undefined ? [] : stringArray(payload.capabilities);
169
+ if (capabilities === null)
170
+ return "claim:capabilities";
171
+ const exp = seconds(payload.exp);
172
+ if (exp === null)
173
+ return "claim:exp";
174
+ const iss = text(payload.iss);
175
+ if (iss === null)
176
+ return "claim:iss";
177
+ const aud = text(payload.aud);
178
+ if (aud === null)
179
+ return "claim:aud";
180
+ const host = text(payload.host);
181
+ if (host === null)
182
+ return "claim:host";
183
+ const purpose = text(payload.purpose);
184
+ if (purpose === null)
185
+ return "claim:purpose";
186
+ if (typeof payload.v !== "number" || !Number.isInteger(payload.v))
187
+ return "claim:v";
188
+ const claims = {
189
+ sub,
190
+ authorities,
191
+ capabilities,
192
+ exp,
193
+ iss,
194
+ aud,
195
+ host,
196
+ purpose,
197
+ v: payload.v,
198
+ };
199
+ if (payload.principal !== undefined) {
200
+ const principal = text(payload.principal);
201
+ if (principal === null || !/^(?:account|person|external):[A-Za-z0-9_.-]+$/.test(principal)) {
202
+ return "claim:principal";
203
+ }
204
+ claims.principal = principal;
205
+ }
206
+ if (payload.nbf !== undefined) {
207
+ const nbf = seconds(payload.nbf);
208
+ if (nbf === null)
209
+ return "claim:nbf";
210
+ claims.nbf = nbf;
211
+ }
212
+ if (payload.azp !== undefined) {
213
+ const azp = text(payload.azp);
214
+ if (azp === null)
215
+ return "claim:azp";
216
+ claims.azp = azp;
217
+ }
218
+ if (payload.email !== undefined) {
219
+ const email = text(payload.email);
220
+ if (email === null || !email.includes("@"))
221
+ return "claim:email";
222
+ claims.email = email;
223
+ }
224
+ if (payload.emailVerified !== undefined) {
225
+ if (typeof payload.emailVerified !== "boolean")
226
+ return "claim:emailVerified";
227
+ claims.emailVerified = payload.emailVerified;
228
+ }
229
+ if (payload.profile !== undefined) {
230
+ const raw = payload.profile;
231
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
232
+ return "claim:profile";
233
+ const record = raw;
234
+ const profile = {};
235
+ for (const field of ["name", "username", "avatar_url"]) {
236
+ if (record[field] === undefined)
237
+ continue;
238
+ const value = text(record[field]);
239
+ if (value === null)
240
+ return "claim:profile";
241
+ profile[field] = value;
242
+ }
243
+ claims.profile = profile;
244
+ }
245
+ return claims;
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // Token extraction
249
+ // ---------------------------------------------------------------------------
250
+ // `Authorization: Bearer …` wins; `X-SF-Authorization` is the integration
251
+ // header for clients whose Authorization is already spoken for.
252
+ function tokenFrom(input) {
253
+ if (input === null || input === undefined)
254
+ return null;
255
+ if (typeof input === "string") {
256
+ return input.trim() || null;
257
+ }
258
+ const authorization = input.headers.get("authorization");
259
+ if (authorization) {
260
+ const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
261
+ if (match?.[1])
262
+ return match[1].trim();
263
+ }
264
+ const dedicated = input.headers.get("x-sf-authorization");
265
+ return dedicated?.trim() || null;
266
+ }
267
+ function usableKeys(jwks) {
268
+ const keys = [];
269
+ for (const jwk of jwks.keys ?? []) {
270
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string")
271
+ continue;
272
+ if (jwk.alg !== undefined && !TOKEN_ALGS.includes(jwk.alg))
273
+ continue;
274
+ let bytes;
275
+ try {
276
+ bytes = base64UrlDecode(jwk.x);
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ if (bytes.length !== ED25519_PUBLIC_KEY_BYTES)
282
+ continue;
283
+ keys.push(typeof jwk.kid === "string" ? { kid: jwk.kid, bytes } : { bytes });
284
+ }
285
+ return keys;
286
+ }
287
+ function webCrypto() {
288
+ return globalThis.crypto?.subtle ?? null;
289
+ }
290
+ async function ed25519Verify(subtle, publicKey, data, signature) {
291
+ try {
292
+ const key = await subtle.importKey("raw", copyBytes(publicKey), { name: "Ed25519" }, false, [
293
+ "verify",
294
+ ]);
295
+ return await subtle.verify({ name: "Ed25519" }, key, copyBytes(signature), copyBytes(data));
296
+ }
297
+ catch {
298
+ return false;
299
+ }
300
+ }
301
+ // Copies onto a fresh ArrayBuffer so BufferSource-typed WebCrypto inputs never
302
+ // see a SharedArrayBuffer-backed or offset view.
303
+ function copyBytes(bytes) {
304
+ return new Uint8Array(bytes);
305
+ }
306
+ // ---------------------------------------------------------------------------
307
+ // Small strict parsers
308
+ // ---------------------------------------------------------------------------
309
+ function requireText(value, name) {
310
+ if (typeof value !== "string" || !value.trim()) {
311
+ throw new TypeError(`createVerifier: ${name} is required`);
312
+ }
313
+ }
314
+ function text(value) {
315
+ return typeof value === "string" && value.trim() ? value.trim() : null;
316
+ }
317
+ function seconds(value) {
318
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
319
+ }
320
+ function stringArray(value) {
321
+ if (!Array.isArray(value))
322
+ return null;
323
+ const items = [];
324
+ for (const item of value) {
325
+ if (typeof item !== "string" || item.length === 0)
326
+ return null;
327
+ items.push(item);
328
+ }
329
+ return items;
330
+ }
331
+ function jsonObject(value) {
332
+ const parsed = JSON.parse(value);
333
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
334
+ return null;
335
+ }
336
+ return parsed;
337
+ }
338
+ function decodeText(part) {
339
+ return new TextDecoder().decode(base64UrlDecode(part));
340
+ }
341
+ // Strict base64url: reject out-of-alphabet input instead of skipping it, so a
342
+ // tampered segment fails as malformed rather than decoding to something else.
343
+ function base64UrlDecode(value) {
344
+ if (!/^[A-Za-z0-9_-]*$/.test(value)) {
345
+ throw new TypeError("invalid base64url");
346
+ }
347
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
348
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
349
+ const binary = atob(padded);
350
+ const bytes = new Uint8Array(binary.length);
351
+ for (let index = 0; index < binary.length; index += 1) {
352
+ bytes[index] = binary.charCodeAt(index);
353
+ }
354
+ return bytes;
355
+ }
356
+ function guestSlug(name) {
357
+ return (name
358
+ .replace(/^guest:/, "")
359
+ .trim()
360
+ .replace(/[^a-zA-Z0-9_.-]+/g, "-")
361
+ .replace(/^-+|-+$/g, "")
362
+ .toLowerCase() || "local");
363
+ }
364
+ function guestDisplayName(slug) {
365
+ const pretty = slug
366
+ .split(/[-_.]+/)
367
+ .filter(Boolean)
368
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
369
+ .join(" ");
370
+ return pretty || "Guest";
371
+ }
@@ -0,0 +1,43 @@
1
+ /** What a supplier returns: the credential and when it dies. */
2
+ export type MintedToken = {
3
+ token: string;
4
+ /** ISO-8601 string, epoch milliseconds, or a Date. */
5
+ expiresAt: string | number | Date;
6
+ };
7
+ /** The minter refused — a policy decision, not an outage. Throw this from `mint`. */
8
+ export declare class TokenRefusedError extends Error {
9
+ readonly code: string;
10
+ constructor(code: string, message?: string);
11
+ }
12
+ /** Minting is failing for transient reasons. The session is not over. */
13
+ export declare class TokenOfflineError extends Error {
14
+ constructor(cause?: unknown);
15
+ }
16
+ /** Scheduling seam — inject a fake clock in tests, omit everywhere else. */
17
+ export type TokenManagerClock = {
18
+ now(): number;
19
+ random(): number;
20
+ setTimer(callback: () => void, delayMs: number): unknown;
21
+ clearTimer(timer: unknown): void;
22
+ };
23
+ export type TokenManagerOptions = {
24
+ mint: () => Promise<MintedToken>;
25
+ clock?: TokenManagerClock;
26
+ };
27
+ export type TokenManager = {
28
+ /**
29
+ * The live token, minting one first if needed. Concurrent callers share one
30
+ * in-flight mint. Rejects with `TokenRefusedError` (terminal),
31
+ * `TokenOfflineError` (transient), or a plain Error after `stop()`.
32
+ */
33
+ current(): Promise<string>;
34
+ /** Force a fresh mint now — the "identity changed" seam. */
35
+ refresh(): Promise<string>;
36
+ /** Fires with each new token, and with null when access lapses. */
37
+ onToken(listener: (token: string | null) => void): () => void;
38
+ /** Fires once, with the refusal code, when the minter terminally says no. */
39
+ onRefusal(listener: (code: string) => void): () => void;
40
+ /** Cancels all timers, settles all waiters, ends the manager for good. */
41
+ stop(): void;
42
+ };
43
+ export declare function createTokenManager(options: TokenManagerOptions): TokenManager;
package/dist/tokens.js ADDED
@@ -0,0 +1,195 @@
1
+ // One TokenManager for every short-lived credential (access-unification plan
2
+ // §07): realtime tickets, machine-exchange tokens, anything a supplier can
3
+ // mint. The manager owns the lifecycle — proactive refresh at 80% of the
4
+ // token's lifetime, jittered exponential backoff when minting fails, and an
5
+ // honest vocabulary for the two ways minting stops working:
6
+ //
7
+ // refused the minter said no. Terminal. `onRefusal` fires with the code,
8
+ // and every later `current()` rejects with the same error.
9
+ // offline the minter was unreachable. NOT signed out: an unexpired token
10
+ // keeps being served, retries continue in the background, and a
11
+ // caller with no token gets a distinct `TokenOfflineError` —
12
+ // never a null that reads as "no access".
13
+ //
14
+ // `stop()` cancels every timer and settles every waiter. Nothing leaks.
15
+ /** The minter refused — a policy decision, not an outage. Throw this from `mint`. */
16
+ export class TokenRefusedError extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(message ?? `token refused: ${code}`);
20
+ this.name = "TokenRefusedError";
21
+ this.code = code;
22
+ }
23
+ }
24
+ /** Minting is failing for transient reasons. The session is not over. */
25
+ export class TokenOfflineError extends Error {
26
+ constructor(cause) {
27
+ super("token minting is unreachable", cause === undefined ? undefined : { cause });
28
+ this.name = "TokenOfflineError";
29
+ }
30
+ }
31
+ const REFRESH_AT_LIFETIME_FRACTION = 0.8;
32
+ const RETRY_BASE_MS = 500;
33
+ const RETRY_MAX_MS = 30_000;
34
+ function stoppedError() {
35
+ return new Error("token manager stopped");
36
+ }
37
+ function expiryOf(minted) {
38
+ const raw = minted.expiresAt;
39
+ const expiresAt = raw instanceof Date ? raw.getTime() : typeof raw === "number" ? raw : Date.parse(raw);
40
+ if (!Number.isFinite(expiresAt)) {
41
+ // A supplier handing back an unreadable expiry is a bug, not weather.
42
+ throw new TypeError(`mint returned an unreadable expiresAt: ${String(raw)}`);
43
+ }
44
+ return expiresAt;
45
+ }
46
+ function systemClock() {
47
+ return {
48
+ now: () => Date.now(),
49
+ random: () => Math.random(),
50
+ setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
51
+ clearTimer: (timer) => clearTimeout(timer),
52
+ };
53
+ }
54
+ export function createTokenManager(options) {
55
+ const clock = options.clock ?? systemClock();
56
+ const tokenListeners = new Set();
57
+ const refusalListeners = new Set();
58
+ let live = null;
59
+ let terminal = null;
60
+ let inFlight = null;
61
+ let timer = null;
62
+ let retryAttempt = 0;
63
+ let generation = 0;
64
+ let emitted = null;
65
+ function clearTimer() {
66
+ if (timer !== null)
67
+ clock.clearTimer(timer);
68
+ timer = null;
69
+ }
70
+ function emit(token) {
71
+ if (token === emitted)
72
+ return;
73
+ emitted = token;
74
+ for (const listener of tokenListeners)
75
+ listener(token);
76
+ }
77
+ function unexpired() {
78
+ return live && live.expiresAt > clock.now() ? live.token : null;
79
+ }
80
+ function fail(error, refusalCode) {
81
+ clearTimer();
82
+ terminal = error;
83
+ live = null;
84
+ inFlight = null;
85
+ if (refusalCode !== null) {
86
+ for (const listener of refusalListeners)
87
+ listener(refusalCode);
88
+ }
89
+ emit(null);
90
+ return error;
91
+ }
92
+ function scheduleMint(delayMs) {
93
+ clearTimer();
94
+ timer = clock.setTimer(() => {
95
+ timer = null;
96
+ void mintOnce().catch(() => {
97
+ // A background mint failure is handled inside mintOnce (backoff or
98
+ // terminal state); the rejection here has no waiter.
99
+ });
100
+ }, Math.max(1, delayMs));
101
+ }
102
+ function scheduleRefresh(expiresAt) {
103
+ scheduleMint((expiresAt - clock.now()) * REFRESH_AT_LIFETIME_FRACTION);
104
+ }
105
+ function scheduleRetry() {
106
+ const exponential = Math.min(RETRY_BASE_MS * 2 ** Math.max(0, retryAttempt - 1), RETRY_MAX_MS);
107
+ const jitter = 0.8 + Math.min(1, Math.max(0, clock.random())) * 0.4;
108
+ scheduleMint(exponential * jitter);
109
+ }
110
+ function mintOnce() {
111
+ if (inFlight)
112
+ return inFlight;
113
+ clearTimer();
114
+ const request = generation;
115
+ const attempt = (async () => {
116
+ let minted;
117
+ try {
118
+ minted = await options.mint();
119
+ }
120
+ catch (error) {
121
+ if (generation !== request)
122
+ throw stoppedError();
123
+ inFlight = null;
124
+ if (error instanceof TokenRefusedError) {
125
+ throw fail(error, error.code);
126
+ }
127
+ // Transient: keep any unexpired token in play, back off, and tell the
128
+ // waiter the truth — offline, not signed out.
129
+ retryAttempt += 1;
130
+ if (live && live.expiresAt <= clock.now())
131
+ emit(null);
132
+ scheduleRetry();
133
+ throw new TokenOfflineError(error);
134
+ }
135
+ if (generation !== request)
136
+ throw stoppedError();
137
+ inFlight = null;
138
+ const expiresAt = expiryOf(minted); // throws on contract breakage
139
+ if (expiresAt <= clock.now()) {
140
+ // A token born dead is indistinguishable from clock trouble; treat it
141
+ // as an outage and retry rather than hot-looping on the 80% timer.
142
+ retryAttempt += 1;
143
+ scheduleRetry();
144
+ throw new TokenOfflineError(new Error("minted token was already expired"));
145
+ }
146
+ retryAttempt = 0;
147
+ live = { token: minted.token, expiresAt };
148
+ scheduleRefresh(expiresAt);
149
+ emit(minted.token);
150
+ return minted.token;
151
+ })();
152
+ // Contract failures from expiryOf land here as terminal state.
153
+ inFlight = attempt;
154
+ attempt.catch((error) => {
155
+ if (generation === request && error instanceof TypeError) {
156
+ fail(error, null);
157
+ }
158
+ });
159
+ return attempt;
160
+ }
161
+ return {
162
+ current() {
163
+ if (terminal)
164
+ return Promise.reject(terminal);
165
+ const token = unexpired();
166
+ if (token)
167
+ return Promise.resolve(token);
168
+ return mintOnce();
169
+ },
170
+ refresh() {
171
+ if (terminal)
172
+ return Promise.reject(terminal);
173
+ return mintOnce();
174
+ },
175
+ onToken(listener) {
176
+ tokenListeners.add(listener);
177
+ return () => tokenListeners.delete(listener);
178
+ },
179
+ onRefusal(listener) {
180
+ refusalListeners.add(listener);
181
+ return () => refusalListeners.delete(listener);
182
+ },
183
+ stop() {
184
+ if (terminal)
185
+ return;
186
+ generation += 1;
187
+ terminal = stoppedError();
188
+ clearTimer();
189
+ live = null;
190
+ inFlight = null;
191
+ tokenListeners.clear();
192
+ refusalListeners.clear();
193
+ },
194
+ };
195
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@spacefast/auth",
3
+ "version": "0.0.23",
4
+ "license": "MIT",
5
+ "description": "Verify Spacefast visitor tokens offline and keep short-lived credentials fresh.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/spacefast/monorepo.git",
9
+ "directory": "packages/spacefast-auth"
10
+ },
11
+ "type": "module",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "engines": {
16
+ "bun": "^1.3.11",
17
+ "node": ">=20"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "exports": {
23
+ "./package.json": "./package.json",
24
+ "./server": {
25
+ "types": "./dist/server.d.ts",
26
+ "import": "./dist/server.js",
27
+ "default": "./dist/server.js"
28
+ },
29
+ "./tokens": {
30
+ "types": "./dist/tokens.d.ts",
31
+ "import": "./dist/tokens.js",
32
+ "default": "./dist/tokens.js"
33
+ }
34
+ }
35
+ }