anvilsign in

collin/anvil

main / docs / oidc.md

RenderedSource

1# Single sign-on (OIDC)
2
3Sign in to anvil with an account at an OpenID Connect provider —
4[login.richardscollin.com](https://login.richardscollin.com) for this instance,
5where one passkey covers every app on the domain.
6
7It is **additive**. Password sign-in keeps working, remains the way in if the
8provider is down, and is the only way in on an instance with no `[oidc] issuer`
9configured. The two are reconciled on the `sub` claim, which the provider
10promises never changes, rather than on email, which does.
11
12## Using it
13
14The login page grows a *Sign in with …* button. Pressing it hands you to the
15provider and back; anvil then finds your account, or makes one.
16
17Which account you land on:
18
191. **Linked already** — the `sub` claim matches an account. Its email and admin
20 flag are refreshed from the token, and you are in.
212. **An account that predates single sign-on** — same email, not yet linked.
22 Adopted, once, and *only* on an address the provider says it verified.
23 Linking on an unverified address is how one account takes over another, so
24 anvil refuses and tells you to use your password.
253. **Nobody** — a fresh account, named from `preferred_username` (sanitized,
26 and suffixed `-2`, `-3`, … if taken; falling back to the email's local part
27 when the name is reserved or unusable). It has **no password**: the stored
28 hash is empty, which no password can match. `anvild user password` sets one
29 if you ever want a local fallback.
30
31The provider decides *who may sign in at all* — access lives in its
32`client_grants`, not in an invite list here. Its per-app `role` claim decides
33who administers anvil: `admin` grants the flag, anything else removes it, and a
34token carrying no role at all leaves the local flag alone rather than quietly
35demoting somebody.
36
37Signing out ends the provider's session too (`[oidc] sso_logout`, on by
38default), so "sign out" means everywhere rather than just here.
39
40## Configuring it
41
42```toml
43[oidc]
44issuer = "https://login.richardscollin.com" # empty disables SSO entirely
45client_id = "anvil"
46client_secret = "" # prefer ANVIL_OIDC_CLIENT_SECRET; see below
47redirect_uri = "" # default: base_url + /-/oidc/callback
48label = "" # default: the issuer's host
49sso_logout = true
50```
51
52`ANVIL_OIDC_ISSUER`, `ANVIL_OIDC_CLIENT_ID`, `ANVIL_OIDC_CLIENT_SECRET` and
53`ANVIL_OIDC_REDIRECT_URI` override the file. **Keep the secret out of the
54config file**: those get committed. In production it comes from `~/anvil/.env`
55on the host, which `compose.yaml` loads if present and skips if not, and
56`deploy/dev.sh` passes `ANVIL_OIDC_CLIENT_SECRET` through when set.
57A client registered as public needs no secret at all — PKCE protects the code
58either way, which is how the local dev client is set up.
59
60`redirect_uri` must match what is registered at the provider **exactly**; there
61are no wildcards. It defaults to `base_url` + `/-/oidc/callback`, so getting
62`http.base_url` right (as `PORTLESS_URL`/`ANVIL_BASE_URL` do behind a proxy) is
63usually all it takes.
64
65## Registering anvil at the provider
66
67Admin panel → Apps → Register, or from a checkout of the provider
68([../login-richardscollin](https://github.com/richardscollin)):
69
70```sh
71npm run register-client -- \
72 --id anvil --name anvil \
73 --redirect https://anvil.localhost/-/oidc/callback \
74 --post-logout https://anvil.localhost/ \
75 --access-mode open --public
76```
77
78`--public` is what makes local development frictionless: PKCE only, no secret
79to carry into `deploy/anvil.dev.toml` or the container's environment. With
80`--access-mode open`, any account at the local provider can sign in, so there
81is no grant to keep in step either.
82
83Production is the opposite on both counts — a confidential client, and access
84by grant. The same script runs inside the deployed container, which is where
85that database lives:
86
87```sh
88ssh hagrid 'docker exec login node --experimental-strip-types \
89 scripts/register-client.ts --id anvil --name anvil \
90 --redirect https://anvil.richardscollin.com/-/oidc/callback \
91 --post-logout https://anvil.richardscollin.com/ \
92 --grant you@example.com:admin'
93```
94
95It prints the secret once. Put it in `~/anvil/.env` on the host (mode 600),
96which is the compose project directory `hag` deploys into:
97
98```sh
99ssh hagrid 'printf "ANVIL_OIDC_CLIENT_SECRET=%s\n" "<secret>" > ~/anvil/.env \
100 && chmod 600 ~/anvil/.env'
101```
102
103It lives on the host rather than in the repo because `hag` copies only
104`compose.yaml` up — a local `.env` never travels, so the workstation and the
105host keep separate config. Omit the variable entirely rather than setting it
106empty: empty overrides the baked config and turns a confidential client into a
107public one.
108
109Redirect URIs are matched exactly, so development and production need separate
110entries (pass `--redirect` twice) or separate clients. Production's client here
111carries production URIs only. Note the provider stores the *normalized* form of
112what you register (`https://host` becomes `https://host/`), and compares
113character for character — anvil normalizes its post-logout URI the same way so
114the two agree.
115
116## Local development
117
118The provider runs on `https://login.localhost` (`portless` in its checkout);
119`deploy/anvil.dev.toml` points at it.
120
121Running anvil natively (`cargo run`) needs nothing more, as long as
122`portless trust` has put its CA in the system store — anvil's HTTP client uses
123the *system* roots, not a bundled set, precisely so a locally-issued
124certificate works.
125
126Running it in Docker (`deploy/dev.sh`) needs two things the container does not
127get for free, and the script arranges both: `login.localhost` resolves to the
128container's own loopback rather than the host's proxy (fixed with
129`--add-host login.localhost:host-gateway`), and portless's CA is not in the
130image's root store (fixed by mounting the host's roots plus that CA and
131pointing `SSL_CERT_FILE` at the result).
132
133## What is checked, and why
134
135The code flow is only as good as its verification, so everything the provider
136sends back is checked before it becomes a session:
137
138- **PKCE (S256), always.** The verifier never leaves this server, so a code
139 captured in transit cannot be redeemed. Cheap, and it removes the entire
140 stolen-code class.
141- **`state`**, compared in constant time against a value held in a ten-minute,
142 `HttpOnly`, `SameSite=Lax` cookie scoped to `/-/oidc`. Strict would be
143 withheld on the redirect back, which is the one hop that matters.
144- **The id token's signature**, RS256 against the provider's published JWKS. The
145 `alg` header is not consulted for *which* algorithm to use — accepting that is
146 how `none` and algorithm-confusion attacks get in. An unknown `kid` triggers
147 one refetch, which is how a key rotation propagates.
148- **`iss`, `aud`, `exp`**, against the configured issuer and client id.
149- **`nonce`**, against this login's own — what stops a token minted for one
150 sign-in being replayed into another.
151- **The discovery document's own `issuer`**, which must equal the configured
152 one. Otherwise a hijacked discovery URL could point anvil at somebody else's
153 token endpoint while every later `iss` check still passed.
154
155A failure at any of these renders an error page and sets no session.
156
157## Implementation
158
159`crates/anvil-web/src/oidc.rs` is the whole client: discovery, the two routes,
160the id token verification, and the mapping onto a local account. RS256
161verification uses `ring` (already in the tree under rustls) rather than a JWT
162crate, because the maintained ones default to `aws-lc-rs`, which needs cmake and
163will not cross-compile to the static musl the deploy image is built from.
164
165`crates/anvil-web/tests/oidc_flow.rs` drives the whole hand-off against a
166stand-in provider that signs real RS256 tokens, covering the happy path,
167adoption of an existing account, username collisions, and the failures above.