auth
A persona is how the agent sounds; auth is how the platform knows who is actually calling. Before anything can authorize a principal, something has to mint it — and that one job belongs to authd, the sole token authority. authd turns a browser login (GitHub, Google, Discord, Telegram, or a local password) into one canonical user id and a signed login-pass — a JWT, a stamped ticket proving who you are. Backends check that stamp against authd’s public-key list, its JWKS — nobody else signs. The id every later permission check reads is the id this token carries.
why identity at all
arizuko is a bunch of small Go daemons (routd, runed, webd, dashd, proxyd, more) that talk over HTTP. None of them owns the login session by itself. When dashd serves your operator page or routd decides whether to deliver a message, it needs an answer to one question: who is this request from? If each daemon re-implemented login, every one would be a place to get it wrong. Instead exactly one daemon (authd) runs OAuth and mints the token; proxyd sits in front, verifies it, and writes the answer into headers the backends trust.
where identity comes from
You log in with any provider. Each provider gives back a string the auth code calls a sub — short for "subject," but think "this user's id with this provider." subs always carry their provider as a prefix:
github:48291744
google:114019583...
discord:837412...
telegram:user/5511234
local:alice
One human usually has several. You link them at /dash/profile: pick one provider as your canonical sub, click "Link…" on another, finish the OAuth dance, and arizuko writes auth_users.linked_to_sub = <canonical> for the new row. From then on, any linked sub resolves to the same canonical sub on login. Each provider runs standard OAuth2 (Google and Discord add PKCE; Telegram uses its HMAC-verified Login Widget; Local is username + argon2id), with optional allow-list gates per provider — the env flags are in reference / env.
two tokens
After login, the browser holds two things:
- An access JWT, 1-hour lifetime, in
localStorage. ES256-signed byauthd; carries{sub, name, provider, exp}. Sent asAuthorization: Bearer …on API calls. Any daemon verifies it offline againstauthd’s JWKS. - A refresh token, 32 random bytes, stored server-side as a SHA-256 hash. Set as an
HttpOnly; SameSite=Strict; Secure; Path=/authcookie with a 30-day TTL. Each refresh rotates the cookie single-use — a stolen one is good for one swap, then dead.
one resolve point
The canonical sub is decided exactly once, inside authd: it resolves the canonical sub right before minting the JWT. Whatever provider you logged in with, the minted token carries your canonical sub. Downstream daemons never re-resolve — the sub they see is the answer. Link chains are not allowed; LinkSubToCanonical rejects them, so the lookup is always one hop.
a request, end to end
Suppose you click a button in the operator dashboard. The browser sends an HTTPS request to https://your-instance/dash/groups. Trace it:
- The request arrives at
proxydon port 443. It looks for theAuthorization: Bearerheader (or, on web pages, the refresh-token cookie). proxyd.tryAuthverifies the JWT — an ES256 signature checked offline againstauthd’s JWKS. If valid, it extractssub,name, and the user'sgroups. A bare/auth/*login request is instead 302’d toauthd, which owns the OAuth flow.proxyd.setUserHeadersclones the request, strips any client-supplied identity headers, and stamps three:X-User-Sub,X-User-Name, andX-User-Groups(JSON). It then attaches anauthd-minted ES256 bearer whose subject is pinned toservice:proxyd— the transit proof.- The cloned request is proxied to
dashdover the docker network. dashdwraps every/dash/*handler in the middleware fromauth/middleware.go. It callsauth.ProxydTransit, which verifies the bearer's signature, expiry, and issuer againstauthd's JWKS, and checks the subject isservice:proxyd. Only then does it trust the three stamped headers; a missing or wrong bearer → strip the headers, redirect to/auth/login.- The handler reads
r.Header.Get("X-User-Sub")and trusts it. That sub is whatauth.Authorizeuses to decide what the user may do.
authd holds the only ES256 private key. It signs the JWT the browser carries (browser ↔ proxyd) and the service:proxyd transit bearer proxyd attaches for backends (proxyd ↔ backend). Both are verified the same way — ES256 against authd’s public JWKS — so there is no separate shared HMAC secret to keep in sync. The X-User-* headers carry no signature of their own; the transit bearer is what makes them trustworthy.reuse — the same refresh token presented twice
A refresh token is single-use. Present it, and authd spends it and hands back a successor; the spent row is kept, marked, never deleted. That mark is the whole detection mechanism: if the same token turns up a second time, one of the two holders is not you, and authd cannot tell which. So it kills the entire chain — every token that descended from that login — and both holders are logged out. Losing your session is the cheap outcome; leaving a stolen one alive is not.
Spending the token and creating its successor happen in one database write. That matters more than it sounds: while they were two writes, a kill triggered by the second holder could land in the gap between them, revoke everything that existed at that instant, and then watch the successor appear a moment later — unrevoked, and good for another thirty days. The alarm fired and the credential it was about survived it. One write, and there is no gap to land in.
The same applies to logging out. Clicking log out kills the chain, and a refresh already in flight cannot outrun it: the spend step refuses a chain that is already dead, rather than only checking whether the token itself was used.
seeing sessions, and ending one
An operator can ask authd two questions it used to answer only through a database shell.
- Which key is signing?
GET /v1/signing_keyslists each key's id, when it was created, when it was retired, and when a retired key stops verifying the tokens it already signed. The public JWKS at/v1/keyscarries none of that — it holds what a verifier needs and nothing more — so "did the rotation actually take" had no answer. This one is metadata only; no private key is reachable through it by any argument. - Who is logged in?
GET /v1/sessionslists one row per login: the account, the scope it was granted, when it started, how many times it has rotated since, and whether it is active, revoked or expired. Token values never appear — only a hash is stored, and not even the hash is served.
DELETE /v1/sessions/{family_id} ends one. This is the verb for when a session must die and the person holding it cannot or will not do it — a lost laptop, a departure, a phone that will not come back. Logging out was always available to the person themselves; this is the half that was missing. The revoke is written to authd's audit log inside the same transaction as the revoke itself, so it shows up in the audit trail next to everything else.
Reading the list and ending a session are separate permissions, so a dashboard that shows you who is logged in does not thereby gain the ability to log them out. Both are operator-level and neither can be reached by an ordinary user's token, whatever they are granted — a user's permissions are folder paths, and a folder path can never satisfy a permission of this shape.
In a browser this is the /dash/authd/ page: both tables, and a sign-out button on each live login. Ending one stops the renewal — a page the person already has open keeps working until its pass expires, about fifteen minutes, and then stops. Signing everyone out at once is a different thing entirely: it means retiring the active signing key, and there is no button for it, deliberately.
collision — logged in as A, OAuth as B
You are logged in as google:114alice and you click "Link GitHub." The callback gets a fresh sub github:48291744. Three cases the code handles cleanly:
- Brand-new sub. Write
linked_to_sub = google:114alice, keep your session. Done. - Sub already linked to you. No-op.
- Sub canonical for someone else. Render a collision page with a 10-minute HMAC-signed token and two buttons: "Link to current" (disabled — merging two real users is a manual operator step) and "Log out, become B."
No silent ghost-account creation, no ambiguous merges. The user always picks.
where to go next
For the full state machine, OAuth-state cookie layout, and DB schema, read specs/1/f-auth-oauth.md. For what your canonical sub is allowed to do once it lands in a backend, see grants.