arizuko

arizuko › components › authd

authd

authd signs every platform token, the backends verify each token offline, and routd decides what the token allows.

authd is the only daemon that signs tokens

authd checks who you are through GitHub, Google or Discord. It returns a signed token that proves your identity. Every other daemon trusts that token.

authd is the auth plane: the only signer of platform tokens and the only JWKS publisher. It holds the active ES256 signing key, on the P-256 curve, in memory. It creates every access and refresh token and acts as the OAuth provider. Backends never sign. They fetch the public JWKS of authd and verify tokens locally with the auth/ library. authd owns the authd.* tables in the store.

Keep one line straight: authd authenticates, and routd authorizes. authd answers “who are you, and what is the most you could be allowed”. It does not decide whether one action on one folder is permitted. That gate lives in routd, the owner of the ACL.

One signing key in one daemon means one secret to protect

If every daemon signed its own tokens, every daemon would hold a signing key, and a leak in one place would be a leak everywhere. With the key in one daemon, there is exactly one secret to protect and one place to rotate it. authd is that daemon.

authd runs the login flow. A user signs in with GitHub, Google or Discord. authd exchanges that login for a short-lived ES256 access token and a refresh token. A snapshot of the grants of the subject limits the scope of the access token. authd fetches that snapshot from routd when it creates the token, because routd owns the ACL. Refresh tokens rotate with reuse detection: if someone presents a used token again, authd revokes the whole family.

authd also gives the daemons their identity. At boot, authd creates service:authd for itself. It exchanges the service key of each daemon for a service:<name> token. So routd, runed, webd and proxyd all carry an identity that authd can verify. One key is active at a time: a rotation retires the old key and installs the new key in one guarded batch. authd still serves recently retired keys until their window closes.

Backends verify offline with the public keys

browser login (GitHub / Google / Discord)
        │  GET /auth/*
        ▼
      authd   snapshot grants  ◄── routd (GRANTS_URL, the ACL owner)
        │     mint ES256 access + refresh token
        ▼
      user / daemon holds a token

      every backend (routd / runed / webd / proxyd / dashd / onbod)
        │  GET /v1/keys  (public JWKS, fetched once, cached)
        ▼
      auth.VerifyToken   verifies offline, no call back to authd per request
Inputs
OAuth callbacks on /auth/*. Requests to create or narrow a token on POST /v1/tokens. Service-key exchange for daemons on POST /v1/service-token. Refresh rotation on POST /v1/refresh. The grants ceiling, fetched from routd.
Outputs
ES256 tokens. The public JWKS at GET /v1/keys, which every backend reads to verify offline.
Dependencies
Its own tables, which no other daemon reads. routd through GRANTS_URL for the scope ceiling at login. Without it, sessions have an empty scope. authd is not the grants authority: it reads the ceiling and does not set it.

One daemon creates tokens, and every backend verifies them

The split is deliberate. Creation is central, with one key in one daemon. Verification is distributed, in every backend, with no round trip. A request with a token never calls authd, because the backend already has the JWKS and checks the signature locally. If authd is down for a moment, existing tokens still verify. Only new logins and refreshes wait.

authd runs alone

authd is the most independent of the split daemons. It owns its tables and its signing key, and it needs no other daemon to create and verify tokens. Its one soft link is GRANTS_URL: without routd, authd still issues tokens, but their scope ceiling is empty.

cd /srv/data/myinstance
export DATA_DIR=/srv/data/myinstance
export DYNAMODB_URL=https://extenddb:18443       # the instance's store server
export AWS_ACCESS_KEY_ID=<key id> AWS_SECRET_ACCESS_KEY=<secret> AWS_REGION=us-east-1
export LISTEN_ADDR=:8080
export AUTHD_SERVICE_KEY=$(grep ^AUTHD_SERVICE_KEY .env | cut -d= -f2)
export AUTHD_SERVICE_KEYS='routd=<key>,runed=<key>,webd=<key>,proxyd=<key>'
export GRANTS_URL=http://routd:8080
# OAuth provider config, mount /auth/* only when present:
export GITHUB_CLIENT_ID=<id> GITHUB_CLIENT_SECRET=<secret>
export AUTH_BASE_URL=https://example.com
./authd

The /auth/* routes mount only when a provider configuration is present. GET /health returns 200 when the signer is live with at least one serving key.

Two endpoints show the keys and the sessions

Two questions come up as soon as something fails: which key signs now, and who is logged in. authd answers both, read-only, and ends a session on request:

GET /v1/signing_keys
The history of each key, not the key. It shows the id, the creation time, whether the key signs now, and for a retired key, when its signed tokens stop being accepted. /v1/keys, the JWK Set, holds only what a verifier needs and none of this. No query and no response names the private half, so no caller and no argument can reach it.
GET /v1/sessions
One row per login: who, what they can reach, and when it started. The row also shows when and how often it renewed, and whether it is active, revoked or expired. authd stores only a hash of a refresh token, never the token, so there is no token to withhold.
DELETE /v1/sessions/{family_id}
Ends one login. Use it during an incident, when a session must end and its holder cannot or does not end it. /auth/logout covers the case where the holder ends it. authd marks the row as ended and does not delete it, because that mark catches a stolen token when someone presents it again.
An ended session stops renewing, not the current token. The browser cannot get a new token and goes back to the login screen. A page that is already open keeps working for up to fifteen minutes, the lifetime of one token, and then stops. Short lifetimes replace a revocation list that every backend would have to check.

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. No person can hold either permission. They are resource:verb permissions, and the permissions of a person are folder paths, so even the all-folders ** does not match them. The only holder is the dashboard daemon, and its page is limited to operators, so two different mechanisms enforce the rule.

That page is /dash/authd/, described in components/dashd. It shows both tables and puts a sign-out button on each live login.

To invalidate existing access tokens, delete the authd.signing_key_slot item and restart authd. authd creates a new signing key. Recently retired keys still verify tokens until retired_at + maxAccessTTL, up to one hour. Offline verifiers can accept existing access tokens with cached keys until the tokens expire. Key rotation does not revoke refresh tokens.

To sign everyone out, stop authd before you change its store. Connect to DYNAMODB_URL with the authd credentials in env/authd.env and the certificate in STORE_CA. See [the store setup in INSTALL.md]. Use DynamoDB DeleteTable to delete authd.refresh_families and authd.refresh_tokens. Wait for both deletions to finish. Use DynamoDB TransactWriteItems to delete the authd.signing_key_slot item at part="authd", sort="active". Restart authd to create empty refresh tables and a new signing key. Existing access tokens can still work until their expiry.

authd does not authorize

authd creates and verifies tokens, runs the OAuth flow and rotates keys. It never decides whether a caller can touch a particular folder. routd makes that decision, every time.

Six pages cover the details