@spacefast/auth
v0.8.0
Published
Verify Spacefast visitor tokens offline and keep short-lived credentials fresh.
Readme
@spacefast/auth
Auth primitives for apps running on Spacefast. Two entry points, one contract:
@spacefast/auth/server— verify a visitor token offline and get back the identity contract every Spacefast surface shares.@spacefast/auth/tokens— keep any short-lived credential fresh without writing refresh code again.
The library verifies and manages. It never mints — identity always comes from the platform.
Verify on the server
import { createVerifier } from "@spacefast/auth/server";
// The full relying-party profile is mandatory. A signing key alone only says
// who signed a token — issuer, audience, host, purpose, and claim version are
// what make it *yours*. The JWKS is data you already have, never a URL: no
// network round-trip hides in your request path.
const verify = createVerifier({
jwks, // { keys: [...] } — Ed25519 public keys
issuer: "spacefast-api",
audience: "spc_1234", // your Space
host: "docs.example.com", // the host this token must be bound to
purpose: "handoff", // tokens minted for any other lane never pass
claimVersion: 1,
});
const auth = await verify(request); // AuthContext | null
// AuthContext — the shared contract:
// { principal: "account:usr_8m2p" | "person:per_…" | "guest:local",
// authorities: ["member:usr_8m2p"],
// capabilities: ["page.view", "comments.write"],
// isGuest, isAuthenticated, displayName, email?, emailVerified?, picture? }verify accepts a Request (it reads Authorization: Bearer or
X-SF-Authorization) or a raw token string.
The four states
null is fine for the main path — anything that isn't a verified identity is
a guest. When you need to know why, ask for the outcome:
import { guestContext } from "@spacefast/auth/server";
const outcome = await verify.outcome(request);
// { state: "verified", auth } the token proves an identity
// { state: "no-token" } nothing to verify
// { state: "invalid-token", reason } provably not acceptable here
// { state: "verifier-unavailable", reason }
// this process can't render a verdict —
// e.g. a key id your JWKS doesn't know yet
const auth = outcome.state === "verified" ? outcome.auth : guestContext();Fail open to guest: every non-verified state serves the guest experience. The
states exist so your logs can tell a forged token from a stale key deployment.
An unknown kid is deliberately unavailable, not invalid — during key
rotation the honest answer is "I can't know", never "forged".
Defaults worth knowing: 5 seconds of clock skew on exp/nbf; azp is
checked only when you pass authorizedParties; a malformed claim — even an
optional one — makes the whole token invalid. Bad data fails loud, not soft.
Keep a credential fresh
import { createTokenManager, TokenRefusedError } from "@spacefast/auth/tokens";
const tickets = createTokenManager({
mint: async () => {
const response = await fetch("/__spacefast/collab/ticket", { method: "POST" });
if (response.status === 403) throw new TokenRefusedError("forbidden");
if (!response.ok) throw new Error(`ticket ${response.status}`); // transient
const { data } = await response.json();
return { token: data.token, expiresAt: data.expiresAt };
},
});
await tickets.current(); // mints once, then stays warm
tickets.onToken((token) => reconnect(token));
tickets.onRefusal((code) => teardown(code)); // "no" is terminal, not a retry
tickets.stop(); // cancels everything, leaks nothingWhat the manager does for you:
- Proactive refresh at 80% of each token's lifetime — callers never pay the mint latency on the hot path.
- Jittered exponential backoff when minting fails transiently, while any unexpired token keeps being served.
- Offline ≠ signed out. A caller with no live token during an outage gets
a
TokenOfflineError, never anullthat reads as "no access". Refusals throwTokenRefusedErrorwith the code the minter gave. - One mint at a time. Concurrent
current()calls share the in-flight request — bursts don't stampede your token endpoint.
