collin/anvil
RenderedSource
| 1 | # Single sign-on (OIDC) |
| 2 | |
| 3 | Sign in to anvil with an account at an OpenID Connect provider — |
| 4 | [login.richardscollin.com](https://login.richardscollin.com) for this instance, |
| 5 | where one passkey covers every app on the domain. |
| 6 | |
| 7 | It is **additive**. Password sign-in keeps working, remains the way in if the |
| 8 | provider is down, and is the only way in on an instance with no `[oidc] issuer` |
| 9 | configured. The two are reconciled on the `sub` claim, which the provider |
| 10 | promises never changes, rather than on email, which does. |
| 11 | |
| 12 | ## Using it |
| 13 | |
| 14 | The login page grows a *Sign in with …* button. Pressing it hands you to the |
| 15 | provider and back; anvil then finds your account, or makes one. |
| 16 | |
| 17 | Which account you land on: |
| 18 | |
| 19 | 1. **Linked already** — the `sub` claim matches an account. Its email and admin |
| 20 | flag are refreshed from the token, and you are in. |
| 21 | 2. **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. |
| 25 | 3. **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 | |
| 31 | The 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 |
| 33 | who administers anvil: `admin` grants the flag, anything else removes it, and a |
| 34 | token carrying no role at all leaves the local flag alone rather than quietly |
| 35 | demoting somebody. |
| 36 | |
| 37 | Signing out ends the provider's session too (`[oidc] sso_logout`, on by |
| 38 | default), so "sign out" means everywhere rather than just here. |
| 39 | |
| 40 | ## Configuring it |
| 41 | |
| 42 | ```toml |
| 43 | [oidc] |
| 44 | issuer = "https://login.richardscollin.com" # empty disables SSO entirely |
| 45 | client_id = "anvil" |
| 46 | client_secret = "" # prefer ANVIL_OIDC_CLIENT_SECRET; see below |
| 47 | redirect_uri = "" # default: base_url + /-/oidc/callback |
| 48 | label = "" # default: the issuer's host |
| 49 | sso_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 |
| 54 | config file**: those get committed. In production it comes from `~/anvil/.env` |
| 55 | on 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. |
| 57 | A client registered as public needs no secret at all — PKCE protects the code |
| 58 | either 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 |
| 61 | are 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 |
| 63 | usually all it takes. |
| 64 | |
| 65 | ## Registering anvil at the provider |
| 66 | |
| 67 | Admin panel → Apps → Register, or from a checkout of the provider |
| 68 | ([../login-richardscollin](https://github.com/richardscollin)): |
| 69 | |
| 70 | ```sh |
| 71 | npm 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 |
| 79 | to 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 |
| 81 | is no grant to keep in step either. |
| 82 | |
| 83 | Production is the opposite on both counts — a confidential client, and access |
| 84 | by grant. The same script runs inside the deployed container, which is where |
| 85 | that database lives: |
| 86 | |
| 87 | ```sh |
| 88 | ssh 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 | |
| 95 | It prints the secret once. Put it in `~/anvil/.env` on the host (mode 600), |
| 96 | which is the compose project directory `hag` deploys into: |
| 97 | |
| 98 | ```sh |
| 99 | ssh hagrid 'printf "ANVIL_OIDC_CLIENT_SECRET=%s\n" "<secret>" > ~/anvil/.env \ |
| 100 | && chmod 600 ~/anvil/.env' |
| 101 | ``` |
| 102 | |
| 103 | It 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 |
| 105 | host keep separate config. Omit the variable entirely rather than setting it |
| 106 | empty: empty overrides the baked config and turns a confidential client into a |
| 107 | public one. |
| 108 | |
| 109 | Redirect URIs are matched exactly, so development and production need separate |
| 110 | entries (pass `--redirect` twice) or separate clients. Production's client here |
| 111 | carries production URIs only. Note the provider stores the *normalized* form of |
| 112 | what you register (`https://host` becomes `https://host/`), and compares |
| 113 | character for character — anvil normalizes its post-logout URI the same way so |
| 114 | the two agree. |
| 115 | |
| 116 | ## Local development |
| 117 | |
| 118 | The provider runs on `https://login.localhost` (`portless` in its checkout); |
| 119 | `deploy/anvil.dev.toml` points at it. |
| 120 | |
| 121 | Running 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 |
| 123 | the *system* roots, not a bundled set, precisely so a locally-issued |
| 124 | certificate works. |
| 125 | |
| 126 | Running it in Docker (`deploy/dev.sh`) needs two things the container does not |
| 127 | get for free, and the script arranges both: `login.localhost` resolves to the |
| 128 | container'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 |
| 130 | image's root store (fixed by mounting the host's roots plus that CA and |
| 131 | pointing `SSL_CERT_FILE` at the result). |
| 132 | |
| 133 | ## What is checked, and why |
| 134 | |
| 135 | The code flow is only as good as its verification, so everything the provider |
| 136 | sends 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 | |
| 155 | A 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, |
| 160 | the id token verification, and the mapping onto a local account. RS256 |
| 161 | verification uses `ring` (already in the tree under rustls) rather than a JWT |
| 162 | crate, because the maintained ones default to `aws-lc-rs`, which needs cmake and |
| 163 | will 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 |
| 166 | stand-in provider that signs real RS256 tokens, covering the happy path, |
| 167 | adoption of an existing account, username collisions, and the failures above. |