@waukeshamakerspace/auth-kit
v0.3.0
Published
Keymaster OIDC Backend-For-Frontend for Waukesha Makerspace services: discovery, PKCE, code exchange, id_token verification, and the signed session cookie. The SPA never sees a token.
Maintainers
Readme
@waukeshamakerspace/auth-kit
The Keymaster OIDC Backend-For-Frontend, as a package.
Atlas established this pattern in its ADR-0007 and Journeyman copied it. By the time this package was extracted, the two copies had drifted apart in ways that mattered, so a token-verification fix meant one pull request per service and an audit of which copies were missed. This is that code, once.
The client secret never leaves the API process, and the SPA never sees a token.
Install
npm install @waukeshamakerspace/auth-kit
# for the Fastify routes, which need these peers:
npm install fastify @fastify/cookieUse
import {
KeymasterOidcClient,
SessionCodec,
keymasterConfigFromEnv,
sessionConfigFromEnv,
} from '@waukeshamakerspace/auth-kit';
import { registerAuthRoutes, sessionGuard } from '@waukeshamakerspace/auth-kit/fastify';
const oidc = new KeymasterOidcClient(keymasterConfigFromEnv());
const session = new SessionCodec(sessionConfigFromEnv('myapp_session'));
await registerAuthRoutes(app, { oidc, session });That mounts four routes under /api/auth:
| Route | Purpose |
|---|---|
| GET /login?returnTo=/path | 302 to Keymaster with PKCE S256 + state |
| GET /callback/keymaster | Code exchange, sets the session cookie |
| GET /session | The SPA's session source of truth; data is null when signed out |
| GET /logout | Clears the cookie, then RP-initiated logout at Keymaster |
Guarding a route:
const guard = sessionGuard(session);
app.post('/api/things', async (request, reply) => {
const claims = await guard(request, reply);
if (!claims) return; // guard already sent the 401
// claims.sub is the Roster person ID
});Environment variables
The house names from shared/environment-variables.md:
KEYMASTER_URL(optional, defaults to the production deployment)KEYMASTER_CLIENT_ID,KEYMASTER_CLIENT_SECRETSESSION_SECRET, at least 32 bytes
Service-specific session claims
Some services keep an extra claim in the cookie. Atlas keeps a tier and
treats a session without one as no session at all. That is a type parameter
plus a validator rather than a fork:
type Tier = { tier: string };
const session = new SessionCodec<Tier>(sessionConfigFromEnv('atlas_session'), {
decode: (p) => (typeof p.tier === 'string' ? { tier: p.tier } : null),
});
await registerAuthRoutes(app, {
oidc,
session,
extraClaims: async (identity) => ({ tier: await deriveTier(identity) }),
onLogin: async (identity) => provisionUser(identity), // Atlas keeps user rows
});onLogin runs after a successful code exchange and before the cookie is set;
throwing there fails the sign-in. Whatever it returns is passed as the third
argument to extraClaims and overrideClaims, so a value the provisioning
step already read (Atlas's stored tier) shapes the cookie without a second
lookup:
await registerAuthRoutes(app, {
oidc,
session,
onLogin: async (identity) => upsertUser(identity), // returns the stored row
extraClaims: (identity, _request, stored) => ({ tier: effectiveTier(stored, identity.roles) }),
// Replace `name`, `email` or `roles` before the cookie is written. Atlas
// stores a public "First L." name rather than the Keymaster display name.
overrideClaims: (identity) => ({ name: publicName(identity.firstName, identity.lastName) }),
// Runs on GET /session with a valid session. Return the same object to
// leave the cookie alone, a new claims object to re-issue it (a stale-roles
// refresh), or null to sign the session out.
onSession: async (claims) =>
rolesAreStale(claims) ? { ...claims, roles: await liveRoles(claims.sub), rolesSyncedAt: Date.now() } : claims,
});A failed code exchange (Keymaster down, a replayed code) redirects to
/?auth_error=exchange_failed and logs a warning, rather than surfacing as a
500. The other auth_error values are missing_state, bad_state,
state_mismatch, and whatever error Keymaster itself sent back.
What changed during extraction
The two source copies disagreed. Rather than silently pick a winner, each difference was resolved deliberately:
| Concern | Atlas | Journeyman | Here |
|---|---|---|---|
| Signing key | SESSION_SECRET or NEXTAUTH_SECRET | SESSION_SECRET | SESSION_SECRET only |
| Short secrets | unchecked | unchecked | rejected at startup, under 32 bytes |
| Missing client id/secret | sent as '' | sent as '' | throws at construction |
| tier claim | required | absent | opt-in via ExtraClaimsCodec |
| firstName/lastName | populated | always null | populated |
| Display name | name claim only | synthesised from given/family | both, name wins |
| Discovery/JWKS cache | module-global | module-global | per instance |
| returnTo sanitising | on the query param | on the query param | query param and the pending cookie |
Two of those are behaviour changes worth calling out before you adopt this in an existing service:
NEXTAUTH_SECRET is not read. It was a leftover from Atlas's NextAuth era.
If Atlas still needs it, resolve the value in Atlas and pass it as secret, so
the shim stays visible in the service rather than hidden in shared code.
Short session secrets now fail at startup rather than at sign-in. HS256
keys below 256 bits weaken the signature and jose rejects them anyway; this
just turns a confusing runtime failure into a startup error with the fix in it.
The returnTo change closes a real gap. Both copies sanitised the query
parameter but then trusted whatever returnTo came back out of the pending
cookie, so anything able to write that cookie had an open redirector. It is
now sanitised on the way out as well.
Testing against it
registerAuthRoutes takes an OidcClientLike, not the concrete client, so a
service can inject a stub in its own route tests without standing up a provider
or reaching into jose. See src/fastify.test.ts for the shape.
