arizuko

arizuko › concepts › auth

auth

Auth answers who is calling. authd, the only token authority, turns a provider login into one canonical user id and signs a JWT that carries it. Every later permission check reads that id.

Each provider login gives a sub

You can log in with any provider. Each provider returns a string that the auth code calls a sub, short for “subject”: the id of this user at this provider. A sub always starts with its provider:

github:48291744
google:114019583462810573829
discord:837412095138652180

One person usually has several subs. The first sub you log in with becomes the canonical sub of your account, and it never changes. Every grant row keys on it, so a canonical sub that changed would remove all your rights without a warning. To add another provider, click “Link…” at /dash/profile/ and complete the OAuth flow of that provider. authd records the new login as one more identity of the same account. After that, a login with any linked provider gives a token with the canonical sub. Each provider runs OAuth2 with a signed CSRF state and PKCE. reference / env lists the optional allow-lists for each provider.

The browser holds two tokens

After login, the browser holds two things:

An access JWT
Valid for 15 minutes and kept in localStorage. authd signs it with ES256. It carries the canonical sub, the typ, a snapshot of the scopes at login and an expiry. The browser sends it as Authorization: Bearer … on API calls. Any daemon can verify it offline against the JWKS of authd.
A refresh token
32 random bytes, stored on the server as a SHA-256 hash. The browser keeps it in an HttpOnly; SameSite=Lax; Path=/ cookie, Secure on HTTPS, valid for 30 days. Each refresh replaces the cookie, and each cookie works once, so a stolen cookie gives one exchange and then stops working.

authd resolves the canonical sub once

authd decides the canonical sub exactly once, just before it creates the JWT. Whatever provider you used, the token carries your canonical sub, and auth.Authorize resolves nothing itself: the sub it sees is the answer. authd writes the provider you used to its audit log as a login event. It never writes the provider into the token, because a claim would disappear on the first refresh.

A dashboard request passes proxyd before it reaches dashd

You click a button in the operator dashboard. The browser sends a request to https://your-instance/dash/groups. The request takes these steps:

  1. The request reaches proxyd on the port PROXYD_LISTEN sets, 8080 by default. proxyd serves plain HTTP, so a proxy in front of it ends HTTPS. proxyd looks for the Authorization: Bearer header, or the refresh-token cookie on web pages.
  2. proxyd.tryAuth verifies the JWT: an ES256 signature, checked offline against the JWKS of authd. If the JWT is valid, proxyd reads sub, name and the groups of the user. A plain /auth/* login request instead gets a 302 redirect to authd, which owns the OAuth flow.
  3. proxyd.setUserHeaders copies the request, removes any identity headers from the client and sets three: X-User-Sub, X-User-Name and X-User-Groups as JSON. It then attaches an ES256 bearer from authd whose subject is service:proxyd. This bearer is the transit proof.
  4. proxyd sends the copied request to dashd over the docker network.
  5. dashd wraps every /dash/* handler in the middleware from auth/middleware.go. The middleware calls auth.ProxydTransit. It verifies the signature, expiry and issuer of the bearer against the JWKS of authd, and checks that the subject is service:proxyd. Only then does it trust the three headers. If the bearer is missing or wrong, the middleware removes the headers and redirects to /auth/login.
  6. The handler reads r.Header.Get("X-User-Sub") and trusts it. auth.Authorize uses that sub to decide what the user can do.
One key signs at both boundaries. authd holds the only ES256 private key. It signs the browser JWT and the service:proxyd transit bearer, and both verify against its public JWKS. AUTH_SECRET is an HMAC key for the OAuth state cookie and verifies no bearer. The X-User-* headers carry no signature, so the transit bearer makes them trustworthy.

A refresh token used twice ends the whole login

A refresh token works once. When you present it, authd uses it up and returns a successor. authd keeps the used row, marks it and never deletes it. That mark is how authd detects reuse. If the same token arrives a second time, one of the two holders is not you, and authd cannot tell which. So it revokes the whole chain, every token that came from that login, and logs out both holders.

authd uses up the token and creates its successor in one database write. With two writes, a revoke from the second holder could land between them. It would revoke every token that existed at that moment, and then the successor would appear unrevoked, valid for another thirty days. With one write, no such gap exists.

Logout works in the same way. A logout revokes the chain, and a refresh already in progress cannot get ahead of it, because the exchange step refuses a revoked chain. It does not only check whether the token itself was used.

An operator can list sessions and end one

An operator can ask authd two questions over HTTP, with no database shell:

Which key signs?
GET /v1/signing_keys lists each key with its id, creation time and retirement time. It also shows when a retired key stops verifying the tokens it signed. The public JWKS at /v1/keys holds only what a verifier needs. /v1/signing_keys answers whether a key rotation took effect. It returns metadata only, and no argument can reach a private key through it.
Who is logged in?
GET /v1/sessions lists one row per login: the account, the granted scope, the start time and the number of rotations. It also shows whether the login is active, revoked or expired. Token values never appear. authd stores only a hash and does not serve the hash either.

DELETE /v1/sessions/{family_id} ends one session. Use it when a session must end and its holder cannot end it, for example after a lost laptop or when a person leaves. authd writes the revoke to its audit log in the same transaction as the revoke. So the revoke appears in the audit trail with everything else.

Reading the list and ending a session are separate permissions. So a dashboard that shows who is logged in cannot log anyone out because of that. Both permissions are operator-level, and no ordinary user token can reach them, whatever its grants. The permissions of a user are folder paths, and a folder path can never satisfy a permission of this shape.

In a browser, the page is /dash/authd/: both tables, and a sign-out button on each live login. A sign-out stops the renewal. A page that the person already has open keeps working until its token expires, after about fifteen minutes, and then stops. To sign everyone out at once, you must retire the active signing key. No endpoint, CLI command or button does that.

A login that another account owns cannot be linked

You are logged in as google:114alice and click “Link GitHub”. The callback gets github:48291744. If that GitHub login is new, it joins your account. If it already belongs to you, nothing changes. If it belongs to another account, the link fails. authd never merges accounts, and it never links two logins because they share a verified email. Each of these would let one person take over the account of another. No unlink exists either.

authd/README.md has the full detail

[authd/README.md] describes the keys, the mint modes, the login snapshot and the cookie flags in full. grants explains what your canonical sub can do when it reaches a backend.