anvilsign in

collin/anvil

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. `deploy/run.sh` reads it from
55`~/.config/anvil/oidc-client-secret` on the host (or the environment, which
56wins), and `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 at `~/.config/anvil/oidc-client-secret`
96(mode 600) on the host, which is where `deploy/run.sh` looks — `deploy.sh`
97pipes that script over ssh with no environment attached, so a file is the only
98thing that survives the trip.
99
100Redirect URIs are matched exactly, so development and production need separate
101entries (pass `--redirect` twice) or separate clients. Production's client here
102carries production URIs only. Note the provider stores the *normalized* form of
103what you register (`https://host` becomes `https://host/`), and compares
104character for character — anvil normalizes its post-logout URI the same way so
105the two agree.
106
107## Local development
108
109The provider runs on `https://login.localhost` (`portless` in its checkout);
110`deploy/anvil.dev.toml` points at it.
111
112Running anvil natively (`cargo run`) needs nothing more, as long as
113`portless trust` has put its CA in the system store — anvil's HTTP client uses
114the *system* roots, not a bundled set, precisely so a locally-issued
115certificate works.
116
117Running it in Docker (`deploy/dev.sh`) needs two things the container does not
118get for free, and the script arranges both: `login.localhost` resolves to the
119container's own loopback rather than the host's proxy (fixed with
120`--add-host login.localhost:host-gateway`), and portless's CA is not in the
121image's root store (fixed by mounting the host's roots plus that CA and
122pointing `SSL_CERT_FILE` at the result).
123
124## What is checked, and why
125
126The code flow is only as good as its verification, so everything the provider
127sends back is checked before it becomes a session:
128
129- **PKCE (S256), always.** The verifier never leaves this server, so a code
130 captured in transit cannot be redeemed. Cheap, and it removes the entire
131 stolen-code class.
132- **`state`**, compared in constant time against a value held in a ten-minute,
133 `HttpOnly`, `SameSite=Lax` cookie scoped to `/-/oidc`. Strict would be
134 withheld on the redirect back, which is the one hop that matters.
135- **The id token's signature**, RS256 against the provider's published JWKS. The
136 `alg` header is not consulted for *which* algorithm to use — accepting that is
137 how `none` and algorithm-confusion attacks get in. An unknown `kid` triggers
138 one refetch, which is how a key rotation propagates.
139- **`iss`, `aud`, `exp`**, against the configured issuer and client id.
140- **`nonce`**, against this login's own — what stops a token minted for one
141 sign-in being replayed into another.
142- **The discovery document's own `issuer`**, which must equal the configured
143 one. Otherwise a hijacked discovery URL could point anvil at somebody else's
144 token endpoint while every later `iss` check still passed.
145
146A failure at any of these renders an error page and sets no session.
147
148## Implementation
149
150`crates/anvil-web/src/oidc.rs` is the whole client: discovery, the two routes,
151the id token verification, and the mapping onto a local account. RS256
152verification uses `ring` (already in the tree under rustls) rather than a JWT
153crate, because the maintained ones default to `aws-lc-rs`, which needs cmake and
154will not cross-compile to the static musl the deploy image is built from.
155
156`crates/anvil-web/tests/oidc_flow.rs` drives the whole hand-off against a
157stand-in provider that signs real RS256 tokens, covering the happy path,
158adoption of an existing account, username collisions, and the failures above.