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. `deploy/run.sh` reads it from |
| 55 | `~/.config/anvil/oidc-client-secret` on the host (or the environment, which |
| 56 | wins), and `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 at `~/.config/anvil/oidc-client-secret` |
| 96 | (mode 600) on the host, which is where `deploy/run.sh` looks — `deploy.sh` |
| 97 | pipes that script over ssh with no environment attached, so a file is the only |
| 98 | thing that survives the trip. |
| 99 | |
| 100 | Redirect URIs are matched exactly, so development and production need separate |
| 101 | entries (pass `--redirect` twice) or separate clients. Production's client here |
| 102 | carries production URIs only. Note the provider stores the *normalized* form of |
| 103 | what you register (`https://host` becomes `https://host/`), and compares |
| 104 | character for character — anvil normalizes its post-logout URI the same way so |
| 105 | the two agree. |
| 106 | |
| 107 | ## Local development |
| 108 | |
| 109 | The provider runs on `https://login.localhost` (`portless` in its checkout); |
| 110 | `deploy/anvil.dev.toml` points at it. |
| 111 | |
| 112 | Running 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 |
| 114 | the *system* roots, not a bundled set, precisely so a locally-issued |
| 115 | certificate works. |
| 116 | |
| 117 | Running it in Docker (`deploy/dev.sh`) needs two things the container does not |
| 118 | get for free, and the script arranges both: `login.localhost` resolves to the |
| 119 | container'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 |
| 121 | image's root store (fixed by mounting the host's roots plus that CA and |
| 122 | pointing `SSL_CERT_FILE` at the result). |
| 123 | |
| 124 | ## What is checked, and why |
| 125 | |
| 126 | The code flow is only as good as its verification, so everything the provider |
| 127 | sends 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 | |
| 146 | A 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, |
| 151 | the id token verification, and the mapping onto a local account. RS256 |
| 152 | verification uses `ring` (already in the tree under rustls) rather than a JWT |
| 153 | crate, because the maintained ones default to `aws-lc-rs`, which needs cmake and |
| 154 | will 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 |
| 157 | stand-in provider that signs real RS256 tokens, covering the happy path, |
| 158 | adoption of an existing account, username collisions, and the failures above. |