ADR-0004: Lucas Account — one shared login service
- Status: accepted (owner decisions 2026-08-02); implemented 2026-08-02 in the separate
lucas-accountrepo — never deployed, no cloud resource created - Scope: authentication for every Lucas Academy product, starting with the Inception Space editor
- Related:
docs/INCEPTION-SPACE-EXECUTION-PLAN.zh-en.md§5 (authorization layers),docs/SPACE-MUSEUM-DIRECTION.md§10 (multiplayer)
Context
Three products already ship their own OTP login (Painterly Chameleon, art-lab, lucas-academy), and the llm-server docs describe a fourth: a first-party token endpoint issuing ES256 JWTs with a JWKS. That is four implementations of the same thing.
Two facts constrain the design:
- Email sending is tied to Cloudflare. Painterly's OTP mail goes through the Workers
send_emailbinding (wrangler.jsonc:"send_email": [{"name": "EMAIL"}]), which exists only inside a Worker. A third-party API (Resend, Postmark, SES) would free us from that, but there is no reason to throw away a working sender. - The apps are moving to Render. snake-lab already deploys there via
render.yaml, and Inception Space will follow. Render hosts have no Cloudflare bindings.
Decision
- One Cloudflare Worker, its own repository, its own domain (owner 2026-08-02). It owns OTP delivery, code storage, rate limiting, and JWT signing. Working name: Lucas Account; suggested host
auth.lucasacademy.org. - Authentication is central; authorization is per app. The service only proves "this person controls this mailbox", and it does so for anyone — there is deliberately no global email allowlist, because real sign-ups must stay possible (owner 2026-08-02). Each app decides who may do what, from its own config file: the Inception Space editor's allowlist is a committed config containing
[email removed], with no database. Abuse of the service itself is held off by the origin allowlist and the rate limits, not by restricting who may receive a code. - Apps stay stateless. A client posts an email, the user receives a code, the client posts the code, and gets a signed JWT. Clients verify the signature against the cached JWKS. No session store, no user table in the museum.
- Inception Space is the only client for now (owner 2026-08-02). Painterly / art-lab / lucas-academy migrate later, one at a time.
- Long-lived tokens with a kill switch — see the note below.
Token lifetime (owner asked for "permanent")
The owner asked for permanent JWTs, reasoning that the repos are private and the token can live in a Render secret. One correction matters: the Render secret holds the signing key, not the user's token. The JWT is a browser credential — it lands in the editor's localStorage on a laptop. A truly permanent token can never be withdrawn if that laptop is lost or a machine is shared.
Recorded decision: issue tokens with a one-year expiry plus a kid (key id) in the header. Rotating the signing key invalidates every token ever issued, instantly and without a database. That is "permanent enough" in daily use — the owner will not be asked for a code again for a year — while keeping one lever to pull if anything leaks. Revisit if the owner prefers literal exp-less tokens.
Interface sketch
POST /v1/otp/request { email } -> 202 always (no probing)
POST /v1/otp/verify { email, code } -> { token, expiresAt, email }
GET /v1/jwks -> { keys: [...] } (cacheable)
GET /v1/me Authorization: Bearer ... -> { email, sub, expiresAt }
GET /health -> liveness
Every call except /v1/jwks and /health carries `X-Lucas-App`.
JWT claims: iss (the service URL), sub (stable account id), email, iat, exp, kid in the header. Nothing app-specific — no roles, no entitlements; those live in each app's config.
Guards the service owns: per-email, per-IP, and service-wide hourly send caps (the last one keeps a distributed flood from running up a mail bill), a 60 s resend cooldown, a 10-minute code TTL, attempt caps with lockout, single-use codes, and constant-time comparison. Registered origins are the only callers: a browser app cannot hold a secret, so the declared Origin is its credential.
Guards the client owns: the app's own allowlist check must run server-side wherever data is written. A config allowlist in the browser is UI, not security. While the editor only exports files locally, there is nothing to protect; the check becomes mandatory the moment a save endpoint exists.
Consequences
- The museum can deploy to Render as a static site while login and email stay on Cloudflare.
- Multiplayer presence gets an identity for free once the service exists.
- Four OTP implementations collapse into one over time.
- New dependency: if the account service is down, editors cannot log in. Public viewing and student play must never require it.