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 in the
54environment**: config files get committed, and `deploy/run.sh` (production) and
55`deploy/dev.sh` (local) both pass `ANVIL_OIDC_CLIENT_SECRET` through when it is
56set. A client registered as public needs no secret at all — PKCE protects the
57code either way.
58
59`redirect_uri` must match what is registered at the provider **exactly**; there
60are no wildcards. It defaults to `base_url` + `/-/oidc/callback`, so getting
61`http.base_url` right (as `PORTLESS_URL`/`ANVIL_BASE_URL` do behind a proxy) is
62usually all it takes.
63
64## Registering anvil at the provider
65
66Admin panel → Apps → Register, or from a checkout of the provider
67([../login-richardscollin](https://github.com/richardscollin)):
68
69```sh
70npm run register-client -- \
71 --id anvil --name anvil \
72 --redirect https://anvil.localhost/-/oidc/callback \
73 --post-logout https://anvil.localhost/ \
74 --grant you@example.com:admin
75```
76
77That prints the client secret once. Against the deployed provider the same
78script runs inside the container, which is where production's database lives:
79
80```sh
81ssh collin@hagrid 'docker exec login node --experimental-strip-types \
82 scripts/register-client.ts --id anvil --name anvil \
83 --redirect https://anvil.richardscollin.com/-/oidc/callback \
84 --post-logout https://anvil.richardscollin.com/ \
85 --grant you@example.com:admin'
86```
87
88Redirect URIs are matched exactly, so development and production need separate
89entries (pass `--redirect` twice) or separate clients. Production's client here
90carries production URIs only.
91
92## Local development
93
94The provider runs on `https://login.localhost` (`portless` in its checkout);
95`deploy/anvil.dev.toml` points at it.
96
97Running anvil natively (`cargo run`) needs nothing more, as long as
98`portless trust` has put its CA in the system store — anvil's HTTP client uses
99the *system* roots, not a bundled set, precisely so a locally-issued
100certificate works.
101
102Running it in Docker (`deploy/dev.sh`) needs two things the container does not
103get for free, and the script arranges both: `login.localhost` resolves to the
104container's own loopback rather than the host's proxy (fixed with
105`--add-host login.localhost:host-gateway`), and portless's CA is not in the
106image's root store (fixed by mounting the host's roots plus that CA and
107pointing `SSL_CERT_FILE` at the result).
108
109## What is checked, and why
110
111The code flow is only as good as its verification, so everything the provider
112sends back is checked before it becomes a session:
113
114- **PKCE (S256), always.** The verifier never leaves this server, so a code
115 captured in transit cannot be redeemed. Cheap, and it removes the entire
116 stolen-code class.
117- **`state`**, compared in constant time against a value held in a ten-minute,
118 `HttpOnly`, `SameSite=Lax` cookie scoped to `/-/oidc`. Strict would be
119 withheld on the redirect back, which is the one hop that matters.
120- **The id token's signature**, RS256 against the provider's published JWKS. The
121 `alg` header is not consulted for *which* algorithm to use — accepting that is
122 how `none` and algorithm-confusion attacks get in. An unknown `kid` triggers
123 one refetch, which is how a key rotation propagates.
124- **`iss`, `aud`, `exp`**, against the configured issuer and client id.
125- **`nonce`**, against this login's own — what stops a token minted for one
126 sign-in being replayed into another.
127- **The discovery document's own `issuer`**, which must equal the configured
128 one. Otherwise a hijacked discovery URL could point anvil at somebody else's
129 token endpoint while every later `iss` check still passed.
130
131A failure at any of these renders an error page and sets no session.
132
133## Implementation
134
135`crates/anvil-web/src/oidc.rs` is the whole client: discovery, the two routes,
136the id token verification, and the mapping onto a local account. RS256
137verification uses `ring` (already in the tree under rustls) rather than a JWT
138crate, because the maintained ones default to `aws-lc-rs`, which needs cmake and
139will not cross-compile to the static musl the deploy image is built from.
140
141`crates/anvil-web/tests/oidc_flow.rs` drives the whole hand-off against a
142stand-in provider that signs real RS256 tokens, covering the happy path,
143adoption of an existing account, username collisions, and the failures above.