@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 +102 -0
- package/dist/server.d.ts +76 -0
- package/dist/server.js +371 -0
- package/dist/tokens.d.ts +43 -0
- package/dist/tokens.js +195 -0
- package/package.json +35 -0
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.
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -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
|
+
}
|