collin/anvil
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:
- Linked already — the
subclaim matches an account. Its email and admin flag are refreshed from the token, and you are in. - 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.
- 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 passwordsets 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. deploy/run.sh reads it from
~/.config/anvil/oidc-client-secret on the host (or the environment, which
wins), 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 at ~/.config/anvil/oidc-client-secret
(mode 600) on the host, which is where deploy/run.sh looks — deploy.sh
pipes that script over ssh with no environment attached, so a file is the only
thing that survives the trip.
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=Laxcookie 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
algheader is not consulted for which algorithm to use — accepting that is hownoneand algorithm-confusion attacks get in. An unknownkidtriggers 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 laterisscheck 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.