anvilsign in

collin/anvil

main / docs / oidc.md

RenderedSource

Single sign-on (OIDC)

Sign in to anvil with an account at an OpenID Connect provider — login.richardscollin.com for this instance, where one passkey covers every app on the domain.

It is additive. Password sign-in keeps working, remains the way in if the provider is down, and is the only way in on an instance with no [oidc] issuer configured. The two are reconciled on the sub claim, which the provider promises never changes, rather than on email, which does.

Using it

The login page grows a Sign in with … button. Pressing it hands you to the provider and back; anvil then finds your account, or makes one.

Which account you land on:

  1. Linked already — the sub claim matches an account. Its email and admin flag are refreshed from the token, and you are in.
  2. An account that predates single sign-on — same email, not yet linked. Adopted, once, and only on an address the provider says it verified. Linking on an unverified address is how one account takes over another, so anvil refuses and tells you to use your password.
  3. Nobody — a fresh account, named from preferred_username (sanitized, and suffixed -2, -3, … if taken; falling back to the email's local part when the name is reserved or unusable). It has no password: the stored hash is empty, which no password can match. anvild user password sets one if you ever want a local fallback.

The provider decides who may sign in at all — access lives in its client_grants, not in an invite list here. Its per-app role claim decides who administers anvil: admin grants the flag, anything else removes it, and a token carrying no role at all leaves the local flag alone rather than quietly demoting somebody.

Signing out ends the provider's session too ([oidc] sso_logout, on by default), so "sign out" means everywhere rather than just here.

Configuring it

[oidc]
issuer = "https://login.richardscollin.com"  # empty disables SSO entirely
client_id = "anvil"
client_secret = ""      # prefer ANVIL_OIDC_CLIENT_SECRET; see below
redirect_uri = ""       # default: base_url + /-/oidc/callback
label = ""              # default: the issuer's host
sso_logout = true

ANVIL_OIDC_ISSUER, ANVIL_OIDC_CLIENT_ID, ANVIL_OIDC_CLIENT_SECRET and ANVIL_OIDC_REDIRECT_URI override the file. Keep the secret out of the config file: those get committed. In production it comes from ~/anvil/.env on the host, which compose.yaml loads if present and skips if not, and deploy/dev.sh passes ANVIL_OIDC_CLIENT_SECRET through when set. A client registered as public needs no secret at all — PKCE protects the code either way, which is how the local dev client is set up.

redirect_uri must match what is registered at the provider exactly; there are no wildcards. It defaults to base_url + /-/oidc/callback, so getting http.base_url right (as PORTLESS_URL/ANVIL_BASE_URL do behind a proxy) is usually all it takes.

Registering anvil at the provider

Admin panel → Apps → Register, or from a checkout of the provider (../login-richardscollin):

npm run register-client -- \
  --id anvil --name anvil \
  --redirect https://anvil.localhost/-/oidc/callback \
  --post-logout https://anvil.localhost/ \
  --access-mode open --public

--public is what makes local development frictionless: PKCE only, no secret to carry into deploy/anvil.dev.toml or the container's environment. With --access-mode open, any account at the local provider can sign in, so there is no grant to keep in step either.

Production is the opposite on both counts — a confidential client, and access by grant. The same script runs inside the deployed container, which is where that database lives:

ssh hagrid 'docker exec login node --experimental-strip-types \
    scripts/register-client.ts --id anvil --name anvil \
    --redirect https://anvil.richardscollin.com/-/oidc/callback \
    --post-logout https://anvil.richardscollin.com/ \
    --grant you@example.com:admin'

It prints the secret once. Put it in ~/anvil/.env on the host (mode 600), which is the compose project directory hag deploys into:

ssh hagrid 'printf "ANVIL_OIDC_CLIENT_SECRET=%s\n" "<secret>" > ~/anvil/.env \
    && chmod 600 ~/anvil/.env'

It lives on the host rather than in the repo because hag copies only compose.yaml up — a local .env never travels, so the workstation and the host keep separate config. Omit the variable entirely rather than setting it empty: empty overrides the baked config and turns a confidential client into a public one.

Redirect URIs are matched exactly, so development and production need separate entries (pass --redirect twice) or separate clients. Production's client here carries production URIs only. Note the provider stores the normalized form of what you register (https://host becomes https://host/), and compares character for character — anvil normalizes its post-logout URI the same way so the two agree.

Local development

The provider runs on https://login.localhost (portless in its checkout); deploy/anvil.dev.toml points at it.

Running anvil natively (cargo run) needs nothing more, as long as portless trust has put its CA in the system store — anvil's HTTP client uses the system roots, not a bundled set, precisely so a locally-issued certificate works.

Running it in Docker (deploy/dev.sh) needs two things the container does not get for free, and the script arranges both: login.localhost resolves to the container's own loopback rather than the host's proxy (fixed with --add-host login.localhost:host-gateway), and portless's CA is not in the image's root store (fixed by mounting the host's roots plus that CA and pointing SSL_CERT_FILE at the result).

What is checked, and why

The code flow is only as good as its verification, so everything the provider sends back is checked before it becomes a session:

  • PKCE (S256), always. The verifier never leaves this server, so a code captured in transit cannot be redeemed. Cheap, and it removes the entire stolen-code class.
  • state, compared in constant time against a value held in a ten-minute, HttpOnly, SameSite=Lax cookie scoped to /-/oidc. Strict would be withheld on the redirect back, which is the one hop that matters.
  • The id token's signature, RS256 against the provider's published JWKS. The alg header is not consulted for which algorithm to use — accepting that is how none and algorithm-confusion attacks get in. An unknown kid triggers one refetch, which is how a key rotation propagates.
  • iss, aud, exp, against the configured issuer and client id.
  • nonce, against this login's own — what stops a token minted for one sign-in being replayed into another.
  • The discovery document's own issuer, which must equal the configured one. Otherwise a hijacked discovery URL could point anvil at somebody else's token endpoint while every later iss check still passed.

A failure at any of these renders an error page and sets no session.

Implementation

crates/anvil-web/src/oidc.rs is the whole client: discovery, the two routes, the id token verification, and the mapping onto a local account. RS256 verification uses ring (already in the tree under rustls) rather than a JWT crate, because the maintained ones default to aws-lc-rs, which needs cmake and will not cross-compile to the static musl the deploy image is built from.

crates/anvil-web/tests/oidc_flow.rs drives the whole hand-off against a stand-in provider that signs real RS256 tokens, covering the happy path, adoption of an existing account, username collisions, and the failures above.