diff --git a/README.md b/README.md index 9f1da61..7845f1e 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,97 @@ the one place membership is edited from the person's side — the Team tab answe "who is in this team", and answering "which teams is this person in" there means visiting each team in turn. +### Single sign-on (OIDC) + +terdut can sign people in through any OpenID Connect provider; the examples use +[Authentik](https://goauthentik.io/). Groups at the provider decide who may sign +in, which teams they belong to and whether they administer the install, much as +Grafana's OAuth role and org mapping does. Password login keeps working alongside +it unless you turn it off. + +**At the provider**, create an OAuth2/OpenID provider and an application for it: +a *confidential* client, redirect URI `/api/oidc/callback`, and +the `openid`, `profile` and `email` scopes. The issuer is the application's, e.g. +`https://auth.example.com/application/o/terdut/`. Then set: + +```sh +TERDUT_PUBLIC_URL=https://terdut.example.com +TERDUT_OIDC_ISSUER=https://auth.example.com/application/o/terdut/ +TERDUT_OIDC_CLIENT_ID=terdut +TERDUT_OIDC_CLIENT_SECRET=... +TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins +TERDUT_OIDC_ADMIN_GROUP=terdut-admins +TERDUT_OIDC_GROUP_MAPPINGS='[{"group":"sre","team":"SRE","role":"member"},{"group":"sre-leads","team":"SRE","role":"owner"}]' +``` + +The web UI's sign-in page shows a "Sign in with " button (a plain link to +`/api/oidc/login`) above the password form, or instead of it when +`TERDUT_PASSWORD_LOGIN=false`; it asks `GET /api/auth/config` what the server offers +(`password_login`, `oidc.enabled`, `oidc.name`). A refused sign-in comes back to that +page with the reason spelled out. Access the groups grant is badged **SSO** on the +Team, Admin and per-user pages, with its edit and remove controls disabled, and the +Account page does not offer to set a password nobody could use. + +**What a sign-in does** + +1. *Who.* The provider's `(issuer, subject)` is the identity. The first time, a + user is found by email — only when the provider marks it verified, or + `TERDUT_OIDC_TRUST_EMAIL` is set — or created with no password. A username taken + by somebody else gets a numeric suffix (`alice-2`). Username and email follow the + provider at each sign-in. Authentik reports `email_verified` as false unless + configured otherwise, so linking existing users usually needs + `TERDUT_OIDC_TRUST_EMAIL=true`. +2. *Whether.* With `TERDUT_OIDC_ALLOWED_GROUPS` set, somebody in none of them is + refused and nothing is created. +3. *What.* The administrator flag follows `TERDUT_OIDC_ADMIN_GROUP`. Team roles + follow the mappings; where several groups grant the same team the highest role + wins. + +**Managed access.** What the sync grants is marked as managed by single sign-on, +and only that is ever changed by it. It is added at sign-in, and removed at the +next sign-in after the group is gone, even if that leaves a team without an owner +(an administrator can always repair a team) — the provider is the source of truth +for what it grants, so the last-owner and last-administrator guards do not apply. +Memberships and administrators added by hand are left alone; the exception is a +hand-added member whose mapping grants a *higher* role, who is raised and from then +on managed. Editing managed access by hand (`POST` or `DELETE` on a team's +members, revoking an SSO-granted administrator) is refused with `409`, since the +next sign-in would undo it. + +**How fast changes arrive.** Groups are read only at sign-in. A session made by an +SSO sign-in has a hard ceiling (`TERDUT_OIDC_SESSION_MAX_AGE`, default 12h) that +sliding never extends, so a change at the provider reaches terdut within that time. +Password sessions are unaffected. + +> **API keys are not revoked when somebody is removed at the provider.** terdut +> holds no refresh token and never asks the provider again, so a person removed +> from every allowed group loses their sessions within `TERDUT_OIDC_SESSION_MAX_AGE` +> and cannot sign in again, but keeps any API key they made (the TUI and scripts use +> them) until an administrator disables the user in terdut. + +**Signing in from a terminal.** A client with no browser of its own, such as the +TUI over SSH, signs in with a device code, run by terdut itself so the terminal +never talks to the provider: + +1. The terminal calls `POST /api/oidc/device` and shows the person a link + (`/device?code=XXXX-XXXX`) and the code. +2. On any device the person opens the link, signs in (by the provider or by + password, whatever the login page offers), sees the code and the account, and + presses **Approve**. Only a browser session can approve; an API key cannot. +3. The terminal polls `POST /api/oidc/device/token` every 5 seconds and is given the + ordinary `terdut_session` cookie once. A person who signs in through the provider + gets the same `TERDUT_OIDC_SESSION_MAX_AGE` ceiling on the terminal's session as + on their browser's. + +A login expires after 10 minutes. `GET /api/auth/config` reports `device_login`. + +**If the provider is down**, terdut still starts (discovery is fetched on first +use) and password login is the way in. With `TERDUT_PASSWORD_LOGIN=false` that way +is closed: set it back to `true`. The first administrator comes from the bootstrap +endpoint, and stays a manual administrator that no group can revoke; on an SSO-only +install set `bootstrap.enabled: false` in the chart if you don't want that account, +or keep it and never give it a password. + ### Docker ```bash @@ -229,6 +320,17 @@ over an administrator's edit. | `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call | | `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` | | `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats | +| `TERDUT_PASSWORD_LOGIN` | `true` | `false` refuses password login and password sign-up (`403`), leaving single sign-on the only way in. Refused at startup unless SSO is configured | +| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `/.well-known/openid-configuration`. See [Single sign-on](#single-sign-on-oidc) | +| `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values | +| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider | +| `TERDUT_OIDC_SCOPES` | `openid profile email` | Scopes requested, comma or space separated. Authentik puts `groups` behind `profile` | +| `TERDUT_OIDC_USERNAME_CLAIM` / `_EMAIL_CLAIM` / `_GROUPS_CLAIM` | `preferred_username` / `email` / `groups` | ID token claims read for the username, email and groups | +| `TERDUT_OIDC_TRUST_EMAIL` | `false` | Link a first sign-in to an existing local user by email even if the provider does not mark the address verified | +| `TERDUT_OIDC_ALLOWED_GROUPS` | — | Comma-separated. Only people in one of these may sign in. Empty admits everybody the provider authenticates | +| `TERDUT_OIDC_ADMIN_GROUP` | — | Members are system administrators | +| `TERDUT_OIDC_GROUP_MAPPINGS` | — | JSON list of `{"group","team","role"}` (`role` is `owner` or `member`). Teams that do not exist are created | +| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in | Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default. @@ -236,7 +338,7 @@ Note that `TERDUT_STALE_AFTER` and `TERDUT_DEADMAN_TIMEOUT` point in opposite di is a generous grace period around a `repeat_interval` you do not control; a dead man's switch is a deadline you set deliberately, and the heartbeat's route is configured to beat faster than it. -In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, and notifications via the `notify.*` values. +In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, and single sign-on via `oidc.*` and `passwordLogin`. --- @@ -590,7 +692,9 @@ of the last heartbeat, and the heartbeat's labels are on the incident's ### Authentication All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`, -`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key: +`/api/notify/ack/{token}`, `/api/login`, `/api/logout`, `/api/auth/config`, +`/api/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and `/api/oidc/device/token` +require either an API key: ``` Authorization: Bearer @@ -650,7 +754,14 @@ incident exists is itself something only its team should learn. | Method | Path | Description | |---|---|---| -| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures | +| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login"}`. No session needed | +| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures; `403` when `TERDUT_PASSWORD_LOGIN=false` | +| `GET` | `/api/oidc/login` | Starts a single sign-on sign-in: redirects the browser to the provider. `?next=/path` is where to land afterwards; only a path on this server is honoured. Only exists when SSO is configured | +| `POST` | `/api/oidc/device` | Starts a device login: returns `{device_code, user_code, verification_url, interval, expires_in}`. Only exists when SSO is configured | +| `POST` | `/api/oidc/device/token` | `{"device_code"}` → `202 {"status":"pending"}`, then `200` with the session cookie once approved (once only). `410` with `{"error":"expired"}` or `{"error":"denied"}`; `429 {"error":"slow_down"}` if polled faster than `interval` | +| `POST` | `/api/oidc/device/approve` | **session** — `{"user_code"}`. Approves a pending device login as the caller. `403` for an API key; `404` for an unknown, expired or already decided code | +| `POST` | `/api/oidc/device/deny` | **session** — `{"user_code"}`. Refuses it | +| `GET` | `/api/oidc/callback` | Where the provider sends the browser back. Sets the session cookie and redirects to `/`, or to `/?sso_error=` — one of `denied`, `expired`, `failed`, `unavailable`, `not_allowed`, `no_email`, `email_conflict`, `disabled` | | `POST` | `/api/logout` | Ends the session and clears the cookie | | `GET` | `/api/me` | The caller: `{user, has_password}` | @@ -671,7 +782,7 @@ on anybody's. | `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable | | `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator | | `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator | -| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator | +| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself, the last administrator, or an administrator granted by single sign-on | | `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator | | `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it | | `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions | @@ -716,8 +827,8 @@ administrator who is not in the team gets the same `404` as anybody else. | `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken | | `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents | | `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list | -| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner | -| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner | +| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner, or the membership is managed by single sign-on | +| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner, or a membership managed by single sign-on | | `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them | | `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change | | `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once | diff --git a/charts/terdut-server/templates/deployment.yaml b/charts/terdut-server/templates/deployment.yaml index 1d3d785..03015bc 100644 --- a/charts/terdut-server/templates/deployment.yaml +++ b/charts/terdut-server/templates/deployment.yaml @@ -61,8 +61,6 @@ spec: value: "{{ .Values.notify.fallbackTopic }}" - name: TERDUT_NOTIFY_REPEAT value: "{{ .Values.notify.repeatEvery }}" - - name: TERDUT_PUBLIC_URL - value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}" {{- if .Values.notify.tokenSecret.name }} - name: TERDUT_NTFY_TOKEN valueFrom: @@ -71,6 +69,49 @@ spec: key: {{ .Values.notify.tokenSecret.key }} {{- end }} {{- end }} + # Set whether or not ntfy is: single sign-on builds its redirect URI + # from it, and sessions use it to decide the cookie's Secure flag. + - name: TERDUT_PUBLIC_URL + value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}" + - name: TERDUT_PASSWORD_LOGIN + value: {{ .Values.passwordLogin | quote }} + {{- if .Values.oidc.enabled }} + - name: TERDUT_OIDC_ISSUER + value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }} + - name: TERDUT_OIDC_CLIENT_ID + value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }} + - name: TERDUT_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }} + key: {{ .Values.oidc.clientSecret.key }} + - name: TERDUT_OIDC_NAME + value: {{ .Values.oidc.name | quote }} + - name: TERDUT_OIDC_SCOPES + value: {{ .Values.oidc.scopes | quote }} + - name: TERDUT_OIDC_USERNAME_CLAIM + value: {{ .Values.oidc.usernameClaim | quote }} + - name: TERDUT_OIDC_EMAIL_CLAIM + value: {{ .Values.oidc.emailClaim | quote }} + - name: TERDUT_OIDC_GROUPS_CLAIM + value: {{ .Values.oidc.groupsClaim | quote }} + - name: TERDUT_OIDC_TRUST_EMAIL + value: {{ .Values.oidc.trustEmail | quote }} + - name: TERDUT_OIDC_SESSION_MAX_AGE + value: {{ .Values.oidc.sessionMaxAge | quote }} + {{- if .Values.oidc.allowedGroups }} + - name: TERDUT_OIDC_ALLOWED_GROUPS + value: {{ join "," .Values.oidc.allowedGroups | quote }} + {{- end }} + {{- if .Values.oidc.adminGroup }} + - name: TERDUT_OIDC_ADMIN_GROUP + value: {{ .Values.oidc.adminGroup | quote }} + {{- end }} + {{- if .Values.oidc.groupMappings }} + - name: TERDUT_OIDC_GROUP_MAPPINGS + value: {{ .Values.oidc.groupMappings | toJson | quote }} + {{- end }} + {{- end }} livenessProbe: httpGet: path: /healthz diff --git a/charts/terdut-server/values.yaml b/charts/terdut-server/values.yaml index d5f51df..d2c1ddb 100644 --- a/charts/terdut-server/values.yaml +++ b/charts/terdut-server/values.yaml @@ -107,6 +107,56 @@ notify: name: "" key: token +# Whether a user may sign in, or sign up, with a password. Turn it off once +# single sign-on works, to make it the only way in; turn it back on (and +# redeploy) if the identity provider is down and somebody has to get in. +passwordLogin: true + +# Single sign-on through an OpenID Connect provider such as Authentik. +# +# At the provider, create an OAuth2/OpenID application whose redirect URI is +# /api/oidc/callback +# (publicUrl defaults to https://), a confidential client, and +# put the client secret in an existing Secret named by clientSecret below. +# +# Groups from the provider decide what a person can do. Access it grants is +# marked as managed by single sign-on and is re-read at every sign-in; anything +# added by hand in terdut is left alone. Changes in the provider take effect at +# the person's next sign-in, at most sessionMaxAge later. API keys are NOT +# revoked when somebody is removed at the provider: disable the user in terdut too. +oidc: + enabled: false + # Issuer URL. For Authentik: https:///application/o// + issuer: "" + clientId: "" + clientSecret: + name: "" + key: client-secret + # What the sign-in button calls the provider. + name: SSO + # Authentik puts the groups claim behind the profile scope. + scopes: "openid profile email" + usernameClaim: preferred_username + emailClaim: email + groupsClaim: groups + # Link a first sign-in to an existing local user with the same email even when + # the provider does not mark the address verified. Authentik reports + # email_verified as false unless configured otherwise. + trustEmail: false + # Only people in one of these groups may sign in. Empty admits everybody the + # provider authenticates, and access control is left to the provider. + allowedGroups: [] + # Members of this group are system administrators. + adminGroup: "" + # Team roles from groups. A team that does not exist is created. Where several + # groups grant the same team the highest role wins. + # - group: sre + # team: SRE + # role: member # member or owner + groupMappings: [] + # Hard ceiling on a session made by a single sign-on login. + sessionMaxAge: 12h + # Backups are no longer this chart's business. The SQLite database lived on a PVC # beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a # dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand diff --git a/cmd/terdut/main.go b/cmd/terdut/main.go index 064fda8..3430b6a 100644 --- a/cmd/terdut/main.go +++ b/cmd/terdut/main.go @@ -17,6 +17,9 @@ var version = "dev" func main() { cfg := config.Load() + if err := cfg.Validate(); err != nil { + log.Fatalf("config: %v", err) + } database, err := db.Open(cfg.DSN) if err != nil { diff --git a/go.mod b/go.mod index 827ad09..6212f79 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,16 @@ module git.ryuvia.com/niklas/terdut-server go 1.25.9 require ( + github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.2.5 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.11.0 golang.org/x/crypto v0.55.0 + golang.org/x/oauth2 v0.36.0 ) require ( + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/go.sum b/go.sum index d2d1bac..c4f1e05 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,12 @@ +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -22,6 +26,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0e22904..0eb4274 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -15,6 +15,7 @@ import ( "time" "git.ryuvia.com/niklas/terdut-server/internal/api" + "git.ryuvia.com/niklas/terdut-server/internal/config" ) // ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so @@ -50,9 +51,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC if len(notify) > 0 { cfg = notify[0] } + return newTSWith(t, deadman, cfg, testConfig()) +} + +// newTSWith is newDeadmanTS with the server's own configuration supplied, for +// tests of behaviour that config switches on, such as single sign-on. +func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, conf config.Config) *ts { + t.Helper() database := newTestDB(t) - srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig())) + srv := httptest.NewServer(api.NewRouter(database, cfg, conf)) t.Cleanup(srv.Close) body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"}) diff --git a/internal/api/auth.go b/internal/api/auth.go index 1935e8a..bb3b840 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -143,15 +143,31 @@ func hashPassword(pw string) (string, error) { // sign-up: somebody who has just chosen a password is signed in, rather than // being sent to a form to type the same credential again. func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error { + return startSessionCapped(w, r, db, userID, publicURL, 0) +} + +// startSessionCapped is startSession with a hard ceiling on the session's life, +// which sliding never extends. maxAge zero means no ceiling. A single sign-on +// login uses it: the login is the only moment the provider's groups are read, so +// a session that could outlive it indefinitely would keep access the provider +// has since taken away. +func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error { raw, tokenHash, err := randomToken() if err != nil { return err } now := time.Now() + life := sessionTTL + var ceiling *int64 + if maxAge > 0 { + c := now.Add(maxAge).Unix() + ceiling = &c + life = min(life, maxAge) + } if _, err := db.ExecContext(r.Context(), ` - INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent) - VALUES ($1, $2, $3, $4, $5, $6)`, - tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil { + INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil { return err } @@ -159,7 +175,7 @@ func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int Name: sessionCookie, Value: raw, Path: "/", - MaxAge: int(sessionTTL.Seconds()), + MaxAge: int(life.Seconds()), HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode, diff --git a/internal/api/device.go b/internal/api/device.go new file mode 100644 index 0000000..80e148c --- /dev/null +++ b/internal/api/device.go @@ -0,0 +1,282 @@ +package api + +import ( + "crypto/rand" + "database/sql" + "errors" + "log" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// The device login flow lets a client that cannot open a browser sign in: it +// shows a code, the person approves it in a browser they are signed in to, and +// the client is handed an ordinary session. See migration 012. + +const ( + // deviceTTL is how long a person has to get from the terminal's prompt to an + // approval. + deviceTTL = 10 * time.Minute + + // deviceInterval is how often the client is told to poll. The server holds it + // to that, with a second of slack for clocks and scheduling. + deviceInterval = 5 * time.Second + + // deviceStartMaxPerAddr bounds unauthenticated device logins started per + // address, since each writes a row. + deviceStartMaxPerAddr = 30 + + // userCodeAlphabet has no vowels, so a code cannot spell a word, and none of + // the characters that read alike (0/O, 1/I/L). + userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789" + userCodeLen = 8 +) + +// newUserCode returns a code for a person to read, as XXXX-XXXX. +func newUserCode() (string, error) { + max := big.NewInt(int64(len(userCodeAlphabet))) + b := make([]byte, userCodeLen) + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b[i] = userCodeAlphabet[n.Int64()] + } + return string(b[:4]) + "-" + string(b[4:]), nil +} + +// normalizeUserCode reduces whatever a person typed or pasted to the stored +// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for +// anything that cannot be a code. +func normalizeUserCode(s string) string { + var b strings.Builder + for _, r := range strings.ToUpper(s) { + if strings.ContainsRune(userCodeAlphabet, r) { + b.WriteRune(r) + } + } + code := b.String() + if len(code) != userCodeLen { + return "" + } + return code[:4] + "-" + code[4:] +} + +// handleDeviceStart begins a device login: it returns the device code the +// client polls with, and the user code and URL the person is shown. +func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + addrKey := "device:" + clientAddr(r) + if limiter.blocked(addrKey, deviceStartMaxPerAddr) { + w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) + respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later")) + return + } + limiter.fail(addrKey) + + deviceCode, deviceHash, err := randomToken() + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + now := time.Now() + db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix()) + + // A collision on the user code is one in 27^8; retrying a few times makes + // it a non-event rather than a 500. + var userCode string + for range 5 { + userCode, err = newUserCode() + if err != nil { + break + } + _, err = db.ExecContext(r.Context(), ` + INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`, + deviceHash, userCode, now.Add(deviceTTL).Unix()) + if err == nil || !isUniqueViolation(err) { + break + } + } + if err != nil { + log.Printf("device login: start: %v", err) + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + respond(w, http.StatusOK, map[string]any{ + "device_code": deviceCode, + "user_code": userCode, + // The code is in the URL so nobody has to type it; it is shown anyway, + // for the person to check against the terminal before approving. + "verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode), + "interval": int(deviceInterval.Seconds()), + "expires_in": int(deviceTTL.Seconds()), + }) + } +} + +// handleDeviceDecision approves or denies a pending device login on behalf of +// the signed-in caller. +// +// It takes a session, not an API key. Approving hands a terminal the caller's +// identity, and the approval must come from a browser the person is looking at: +// the page shows the code and asks. A script with a key has no business +// approving one, and the check keeps it from being a way to mint sessions out of +// keys. +func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc { + status := "denied" + if approve { + status = "approved" + } + return func(w http.ResponseWriter, r *http.Request) { + if _, viaSession := sessionFromContext(r.Context()); !viaSession { + respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device")) + return + } + var req struct { + UserCode string `json:"user_code"` + } + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + code := normalizeUserCode(req.UserCode) + if code == "" { + respond(w, http.StatusBadRequest, errResp("that is not a sign-in code")) + return + } + + caller, _ := userFromContext(r.Context()) + // Only a pending login can be decided, and only once: an approval cannot + // be overwritten, so a second browser cannot take a login over. + res, err := db.ExecContext(r.Context(), ` + UPDATE device_logins SET status = $1, user_id = $2 + WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`, + status, caller.ID, code, time.Now().Unix()) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if n, _ := res.RowsAffected(); n == 0 { + respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used")) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// handleDeviceToken is what the client polls. Pending answers 202; an approval +// answers 200 with the session cookie, once; anything else is 410. +func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc { + gone := func(w http.ResponseWriter, why string) { + respond(w, http.StatusGone, map[string]string{"error": why}) + } + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceCode string `json:"device_code"` + } + if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" { + respond(w, http.StatusBadRequest, errResp("device_code is required")) + return + } + hash := hashToken(req.DeviceCode) + now := time.Now() + + tx, err := db.BeginTx(r.Context(), nil) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer tx.Rollback() //nolint:errcheck + + var status string + var userID sql.NullInt64 + var expires, lastPolled int64 + err = tx.QueryRowContext(r.Context(), ` + SELECT status, user_id, expires_at, last_polled_at FROM device_logins + WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled) + if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) { + gone(w, "expired") + return + } + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + switch status { + case "denied": + tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash) + tx.Commit() //nolint:errcheck + gone(w, "denied") + return + + case "pending": + // Held to the interval it was given, less a second of slack. + if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 { + w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds()))) + respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"}) + return + } + if _, err := tx.ExecContext(r.Context(), + "UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if err := tx.Commit(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusAccepted, map[string]string{"status": "pending"}) + return + } + + // Approved. Single use: the row goes before the session is made, so two + // racing polls cannot both be given one. + if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + var disabled, sso bool + if err := tx.QueryRowContext(r.Context(), ` + SELECT disabled_at IS NOT NULL, + EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1) + FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil { + gone(w, "denied") + return + } + if err := tx.Commit(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if disabled { + gone(w, "denied") + return + } + + // A session for somebody who signs in through the provider carries the + // same ceiling as their browser's would, so the terminal is not a way + // round it. Password users have none. + var maxAge time.Duration + if sso { + maxAge = ssoMaxAge + } + if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil { + log.Printf("device login: start session: %v", err) + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + user, err := fetchUser(r.Context(), db, userID.Int64) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, meResponse{User: user, HasPassword: false}) + } +} diff --git a/internal/api/device_test.go b/internal/api/device_test.go new file mode 100644 index 0000000..fb29b24 --- /dev/null +++ b/internal/api/device_test.go @@ -0,0 +1,347 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" +) + +type deviceStart struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURL string `json:"verification_url"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` +} + +// startDevice is the terminal asking for a login. +func startDevice(t *testing.T, s *ts) deviceStart { + t.Helper() + resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil) + var d deviceStart + decode(t, resp, &d) + if d.DeviceCode == "" || d.UserCode == "" { + t.Fatalf("device start returned %+v", d) + } + return d +} + +// pollDevice is the terminal polling. It returns the status, and the session +// cookie the response set, if any. +func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) { + t.Helper() + resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code}) + defer resp.Body.Close() + var body map[string]any + json.NewDecoder(resp.Body).Decode(&body) + var cookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "terdut_session" { + cookie = c + } + } + msg, _ := body["error"].(string) + if msg == "" { + msg, _ = body["status"].(string) + } + return resp.StatusCode, cookie, msg +} + +// readyToPoll lets the next poll through: the server holds a client to the +// interval it was given, which a test has no wish to wait out. +func (s *ts) readyToPoll(t *testing.T) { + t.Helper() + s.exec(t, "UPDATE device_logins SET last_polled_at = 0") +} + +func decide(t *testing.T, b *browser, what, code string) int { + t.Helper() + resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code}) + resp.Body.Close() + return resp.StatusCode +} + +func TestDevice_FullFlow(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + d := startDevice(t, s) + if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") || + !strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) { + t.Errorf("verification url %q", d.VerificationURL) + } + if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 { + t.Errorf("start: %+v", d) + } + + if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" { + t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg) + } + + // The person signs in through the provider in some browser and approves. + person := ssoBrowser(t, s) + signInSSO(t, idp, person, alice) + if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent { + t.Fatalf("approve: %d", got) + } + + s.readyToPoll(t) + status, cookie, _ := pollDevice(t, s, d.DeviceCode) + if status != http.StatusOK || cookie == nil { + t.Fatalf("poll after approval: %d, cookie %v", status, cookie) + } + // The cookie is a working session for the person who approved. + term := newBrowser(t, s.URL) + req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil) + req.AddCookie(cookie) + resp, err := term.Do(req) + if err != nil { + t.Fatal(err) + } + var me struct { + User struct { + Username string `json:"username"` + } `json:"user"` + } + decode(t, resp, &me) + if me.User.Username != "alice" { + t.Errorf("session belongs to %q, want alice", me.User.Username) + } + + // Single use. + if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" { + t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg) + } + // The session was made for an SSO user, so it carries the ceiling. + var ceiling *int64 + s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling) + if ceiling == nil { + t.Error("a device session for an SSO user must carry the SSO session ceiling") + } +} + +func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + admin := signedIn(t, s) // password sign-in as the bootstrap admin + + d := startDevice(t, s) + if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent { + t.Fatalf("approve: %d", got) + } + s.readyToPoll(t) + if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil { + t.Fatalf("poll: %d %v", status, cookie) + } + var ceiling *int64 + s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling) + if ceiling != nil { + t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling) + } +} + +func TestDevice_Denied(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + person := ssoBrowser(t, s) + signInSSO(t, idp, person, alice) + + d := startDevice(t, s) + if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent { + t.Fatalf("deny: %d", got) + } + s.readyToPoll(t) + if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" { + t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg) + } +} + +func TestDevice_DecisionNeedsABrowserSession(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + d := startDevice(t, s) + + // Nobody signed in. + if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized { + t.Errorf("anonymous approve: %d, want 401", got) + } + // An API key is a credential for scripts, not for approving a terminal. + resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("approve with an API key: %d, want 403", resp.StatusCode) + } + if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" { + t.Errorf("the login must still be pending: %d %q", status, msg) + } +} + +func TestDevice_ApprovalIsFinal(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + first, second := ssoBrowser(t, s), ssoBrowser(t, s) + signInSSO(t, idp, first, alice) + signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}}) + + d := startDevice(t, s) + if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent { + t.Fatalf("approve: %d", got) + } + // A second browser cannot take the login over, nor refuse it. + for _, what := range []string{"approve", "deny"} { + if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound { + t.Errorf("%s after approval: %d, want 404", what, got) + } + } + s.readyToPoll(t) + _, cookie, _ := pollDevice(t, s, d.DeviceCode) + if cookie == nil { + t.Fatal("no session") + } + var name string + s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name) + if name != "alice" { + t.Errorf("session for %q, want alice", name) + } +} + +func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + person := ssoBrowser(t, s) + signInSSO(t, idp, person, alice) + + d := startDevice(t, s) + typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " ")) + if got := decide(t, person, "approve", typed); got != http.StatusNoContent { + t.Errorf("approve %q: %d, want 204", typed, got) + } + if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest { + t.Errorf("approve nonsense: %d, want 400", got) + } +} + +func TestDevice_ExpiredAndUnknown(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + person := ssoBrowser(t, s) + signInSSO(t, idp, person, alice) + + d := startDevice(t, s) + s.exec(t, "UPDATE device_logins SET expires_at = 1") + if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound { + t.Errorf("approve expired: %d, want 404", got) + } + if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" { + t.Errorf("poll expired: %d %q, want 410 expired", status, msg) + } + if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" { + t.Errorf("poll unknown: %d %q, want 410 expired", status, msg) + } +} + +func TestDevice_PollingTooFastIsRefused(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + d := startDevice(t, s) + if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted { + t.Fatalf("first poll: %d", status) + } + if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" { + t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg) + } +} + +func TestDevice_DisabledUserGetsNoSession(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + person := ssoBrowser(t, s) + signInSSO(t, idp, person, alice) + + d := startDevice(t, s) + decide(t, person, "approve", d.UserCode) + s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'") + s.readyToPoll(t) + var before int + s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before) + if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil { + t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie) + } + var after int + s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after) + if after != before { + t.Error("a session was created for a disabled user") + } +} + +func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) { + s := newTS(t) // no SSO + resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode) + } + + idp := newFakeIdP(t) + for _, c := range []struct { + name string + s *ts + want bool + }{{"off", s, false}, {"on", newSSOTS(t, idp), true}} { + var cfg struct { + DeviceLogin bool `json:"device_login"` + } + decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg) + if cfg.DeviceLogin != c.want { + t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want) + } + } +} + +func TestDevice_StartIsRateLimited(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + b := newBrowser(t, s.URL) + var last int + for range 32 { + resp := b.do(t, http.MethodPost, "/api/oidc/device", nil) + resp.Body.Close() + last = resp.StatusCode + } + if last != http.StatusTooManyRequests { + t.Errorf("32nd start: %d, want 429", last) + } +} + +// After signing in the browser is sent on to where the person was going, which +// is how somebody without a session gets from /device?code=... through the +// provider and back to it. Only paths on this server are honoured. +func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + for _, c := range []struct{ next, want string }{ + {"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"}, + {"/team/members", "/team/members"}, + {"", "/"}, + {"//evil.example/x", "/"}, + {"/\\evil.example", "/"}, + {"https://evil.example/", "/"}, + {"evil.example", "/"}, + {"/api/users", "/"}, + {"/ok\r\nSet-Cookie: x=y", "/"}, + {"/" + strings.Repeat("a", 600), "/"}, + } { + b := ssoBrowser(t, s) + resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil) + resp.Body.Close() + loc, _ := url.Parse(resp.Header.Get("Location")) + q := loc.Query() + got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state")) + if got != c.want { + t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want) + } + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 64a7b59..fe53310 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -132,8 +132,13 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user } if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery { - db.ExecContext(ctx, - "UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3", + // LEAST keeps a capped session (a single sign-on login) from sliding + // past its ceiling; with no ceiling COALESCE makes it the plain slide. + db.ExecContext(ctx, ` + UPDATE sessions + SET last_seen_at = $1, + expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint)) + WHERE id = $3`, now.Unix(), now.Add(sessionTTL).Unix(), sessionID) } return sessionID, userID, true diff --git a/internal/api/oidc.go b/internal/api/oidc.go new file mode 100644 index 0000000..45deb5a --- /dev/null +++ b/internal/api/oidc.go @@ -0,0 +1,458 @@ +package api + +import ( + "context" + "database/sql" + "errors" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "git.ryuvia.com/niklas/terdut-server/internal/config" + "git.ryuvia.com/niklas/terdut-server/internal/models" + "git.ryuvia.com/niklas/terdut-server/internal/oidc" +) + +const ( + // oidcStateCookie ties an in-flight login to the browser that started it. + // Without it anybody could start a login, and send the callback URL that + // results to somebody else, who would be signed in as the attacker. + oidcStateCookie = "terdut_oidc_state" + + // oidcLoginTTL is how long a login may take between the redirect to the + // provider and the callback, which includes the person typing a password + // and a second factor. + oidcLoginTTL = 10 * time.Minute + + // oidcStartMaxPerAddr bounds unauthenticated logins started per address. + // Each writes a row, so an unbounded endpoint is a way to grow the table. + oidcStartMaxPerAddr = 30 +) + +// ssoError is a sign-in refusal the person can be told about. Its value is the +// code the web UI is sent back with, as ?sso_error=; the detail stays in +// the server log, since it can name accounts. +type ssoError string + +func (e ssoError) Error() string { return "sso: " + string(e) } + +const ( + ssoDenied ssoError = "denied" // the provider reported an error, or the person declined + ssoExpired ssoError = "expired" // unknown, used or expired state; start again + ssoFailed ssoError = "failed" // the token exchange or its verification failed + ssoUnavailable ssoError = "unavailable" // the provider could not be reached + ssoNotAllowed ssoError = "not_allowed" // authenticated, but in none of the allowed groups + ssoNoEmail ssoError = "no_email" // the provider sent no email address + ssoEmailConflict ssoError = "email_conflict" // a local account has this email and cannot be linked + ssoDisabled ssoError = "disabled" // the linked account is disabled +) + +// handleAuthConfig says how this server can be signed in to, so the login form +// and the TUI can offer the right choices before anybody types anything. It is +// unauthenticated by necessity, and reveals nothing beyond what the login page +// shows anyway. +func handleAuthConfig(cfg config.Config) http.HandlerFunc { + type oidcInfo struct { + Enabled bool `json:"enabled"` + Name string `json:"name,omitempty"` + } + type response struct { + PasswordLogin bool `json:"password_login"` + OIDC oidcInfo `json:"oidc"` + + // DeviceLogin is whether a client that cannot open a browser (the TUI) + // can sign in by showing a code, through /api/oidc/device. + DeviceLogin bool `json:"device_login"` + } + return func(w http.ResponseWriter, r *http.Request) { + resp := response{PasswordLogin: !cfg.DisablePasswordLogin} + if cfg.OIDC.Enabled() { + resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name} + resp.DeviceLogin = true + } + respond(w, http.StatusOK, resp) + } +} + +// passwordLoginOnly refuses a route when password login is switched off. +func passwordLoginOnly(enabled bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if enabled { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respond(w, http.StatusForbidden, errResp("password login is disabled on this server")) + }) + } +} + +// ssoRedirect sends the browser back to the web UI with the reason a sign-in +// failed. It is a redirect and not a JSON error because the browser arrived +// here by navigating from the provider: there is no page script to read one. +func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) { + http.Redirect(w, r, "/?sso_error="+url.QueryEscape(string(code)), http.StatusFound) +} + +// handleOIDCLogin starts a sign-in: it records the state, nonce and PKCE +// verifier the callback will need and sends the browser to the provider. +func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + addrKey := "oidc:" + clientAddr(r) + if limiter.blocked(addrKey, oidcStartMaxPerAddr) { + w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) + respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later")) + return + } + limiter.fail(addrKey) + + state, stateHash, err := randomToken() + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + nonce, _, err := randomToken() + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + verifier := oidc.NewVerifier() + next := safeNext(r.URL.Query().Get("next")) + + // Abandoned logins are swept here rather than by the sweeper: this is + // the only place they are made, so the table cannot outgrow its writers. + now := time.Now() + db.ExecContext(r.Context(), "DELETE FROM oidc_logins WHERE expires_at < $1", now.Unix()) + if _, err := db.ExecContext(r.Context(), ` + INSERT INTO oidc_logins (state_hash, nonce, pkce_verifier, next, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + stateHash, nonce, verifier, next, now.Add(oidcLoginTTL).Unix()); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + authURL, err := prov.AuthURL(r.Context(), state, nonce, verifier) + if err != nil { + log.Printf("oidc: start login: %v", err) + ssoRedirect(w, r, ssoUnavailable) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: oidcStateCookie, + Value: state, + Path: "/api/oidc", + MaxAge: int(oidcLoginTTL.Seconds()), + HttpOnly: true, + Secure: cookieSecure(publicURL, r), + // Lax, not Strict: the callback is a top-level navigation from the + // provider's site, which Strict would not send the cookie on. + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, authURL, http.StatusFound) + } +} + +// handleOIDCCallback finishes a sign-in: it verifies the provider's answer, +// finds or creates the user, applies their groups and starts a session. +func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.HandlerFunc { + cfg := prov.Config() + return func(w http.ResponseWriter, r *http.Request) { + // The state cookie has done its job once the callback arrives, whatever + // the outcome. + http.SetCookie(w, &http.Cookie{ + Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1, + HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode, + }) + + q := r.URL.Query() + if e := q.Get("error"); e != "" { + log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description")) + ssoRedirect(w, r, ssoDenied) + return + } + state := q.Get("state") + cookie, err := r.Cookie(oidcStateCookie) + if state == "" || q.Get("code") == "" || err != nil || cookie.Value != state { + ssoRedirect(w, r, ssoExpired) + return + } + + // DELETE ... RETURNING makes the state single-use: a replayed callback + // finds nothing. + var nonce, verifier, next string + err = db.QueryRowContext(r.Context(), ` + DELETE FROM oidc_logins WHERE state_hash = $1 AND expires_at > $2 + RETURNING nonce, pkce_verifier, next`, + hashToken(state), time.Now().Unix()).Scan(&nonce, &verifier, &next) + if errors.Is(err, sql.ErrNoRows) { + ssoRedirect(w, r, ssoExpired) + return + } + if err != nil { + log.Printf("oidc: load login state: %v", err) + ssoRedirect(w, r, ssoFailed) + return + } + + identity, err := prov.Exchange(r.Context(), q.Get("code"), verifier, nonce) + if err != nil { + log.Printf("oidc: %v", err) + ssoRedirect(w, r, ssoFailed) + return + } + + grants := oidc.ComputeGrants(cfg, identity.Groups) + if !grants.Admitted { + log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject) + ssoRedirect(w, r, ssoNotAllowed) + return + } + + userID, err := signInSSO(r.Context(), db, cfg, identity, grants) + if err != nil { + var se ssoError + if errors.As(err, &se) { + log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se) + ssoRedirect(w, r, se) + return + } + log.Printf("oidc: sign in %q: %v", identity.Username, err) + ssoRedirect(w, r, ssoFailed) + return + } + + if err := startSessionCapped(w, r, db, userID, publicURL, cfg.SessionMaxAge); err != nil { + log.Printf("oidc: start session: %v", err) + ssoRedirect(w, r, ssoFailed) + return + } + http.Redirect(w, r, safeNext(next), http.StatusFound) + } +} + +// safeNext returns where to send the browser after a sign-in: the path asked +// for, if it is one on this server, and the front page otherwise. It is the +// only thing standing between a login link and an open redirect, so it accepts +// a single leading slash and nothing that a browser could read as another host +// ("//evil.example", "/\evil.example"), and never an API path, which would +// land somebody on raw JSON. +func safeNext(next string) string { + switch { + case next == "", len(next) > 512, + !strings.HasPrefix(next, "/"), + strings.HasPrefix(next, "//"), + strings.HasPrefix(next, "/api/"), + strings.ContainsAny(next, "\\\r\n"): + return "/" + } + return next +} + +// signInSSO resolves the identity to a user and applies its grants, in one +// transaction: a login that fails half way must not leave memberships changed. +func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants) (int64, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() //nolint:errcheck + + userID, err := resolveSSOUser(ctx, tx, cfg, id) + if err != nil { + return 0, err + } + var disabled bool + if err := tx.QueryRowContext(ctx, + "SELECT disabled_at IS NOT NULL FROM users WHERE id = $1", userID).Scan(&disabled); err != nil { + return 0, err + } + if disabled { + return 0, ssoDisabled + } + if err := syncGrants(ctx, tx, userID, g); err != nil { + return 0, err + } + return userID, tx.Commit() +} + +// resolveSSOUser finds the user an identity belongs to, linking or creating one +// when this is its first sign-in. +// +// The order matters. The (issuer, subject) pair is the identity; email is only +// a way to recognise an existing local account the first time. Once linked, a +// changed email at the provider must not move the account to somebody else. +func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.Identity) (int64, error) { + now := time.Now().Unix() + + var userID int64 + err := tx.QueryRowContext(ctx, + "SELECT user_id FROM user_identities WHERE issuer = $1 AND subject = $2", + id.Issuer, id.Subject).Scan(&userID) + if err == nil { + if _, err := tx.ExecContext(ctx, + "UPDATE user_identities SET last_login_at = $1 WHERE issuer = $2 AND subject = $3", + now, id.Issuer, id.Subject); err != nil { + return 0, err + } + return userID, refreshProfile(ctx, tx, userID, id) + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + + // First sign-in with this identity. + if id.Email == "" { + return 0, ssoNoEmail + } + err = tx.QueryRowContext(ctx, + "SELECT id FROM users WHERE lower(email) = lower($1)", id.Email).Scan(&userID) + switch { + case err == nil: + if !id.EmailVerified && !cfg.TrustEmail { + return 0, ssoEmailConflict + } + // A local account that already has an identity from this issuer is a + // different person at the provider using a recycled address. Linking + // them would hand one person's account to another. + var linked bool + if err := tx.QueryRowContext(ctx, + "SELECT EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1 AND issuer = $2)", + userID, id.Issuer).Scan(&linked); err != nil { + return 0, err + } + if linked { + return 0, ssoEmailConflict + } + case errors.Is(err, sql.ErrNoRows): + userID, err = createSSOUser(ctx, tx, id) + if err != nil { + return 0, err + } + default: + return 0, err + } + + if _, err := tx.ExecContext(ctx, + "INSERT INTO user_identities (user_id, issuer, subject) VALUES ($1, $2, $3)", + userID, id.Issuer, id.Subject); err != nil { + return 0, err + } + return userID, nil +} + +// createSSOUser inserts a user with no password. The username is the provider's, +// made unique with a numeric suffix when somebody local already has it. +func createSSOUser(ctx context.Context, tx *sql.Tx, id *oidc.Identity) (int64, error) { + base := strings.TrimSpace(id.Username) + if base == "" { + base, _, _ = strings.Cut(id.Email, "@") + } + if base == "" { + base = "user" + } + for n := 1; n <= 100; n++ { + name := base + if n > 1 { + name = base + "-" + strconv.Itoa(n) + } + var userID int64 + err := tx.QueryRowContext(ctx, ` + INSERT INTO users (username, email) VALUES ($1, $2) + ON CONFLICT (username) DO NOTHING RETURNING id`, + name, id.Email).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + continue // taken; try the next suffix + } + return userID, err + } + return 0, errors.New("no free username for " + base) +} + +// refreshProfile brings a linked user's username and email in line with the +// provider. Each update is skipped, not failed, when another user already holds +// the value: both columns are unique, and a sign-in must not break over a name. +func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Identity) error { + if id.Username != "" { + if _, err := tx.ExecContext(ctx, ` + UPDATE users SET username = $1 + WHERE id = $2 AND username <> $1 + AND NOT EXISTS (SELECT 1 FROM users WHERE username = $1)`, + id.Username, userID); err != nil { + return err + } + } + if id.Email != "" { + if _, err := tx.ExecContext(ctx, ` + UPDATE users SET email = $1 + WHERE id = $2 AND email <> $1 + AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))`, + id.Email, userID); err != nil { + return err + } + } + return nil +} + +// syncGrants makes the user's OIDC-sourced access match what their groups grant +// now, and touches nothing else. +// +// Rows the sync owns are marked source 'oidc'. It adds them, changes their role +// and removes them. The last-owner and last-administrator guards do not apply: +// they exist to stop a person's mistake, and the provider is the source of truth +// for the access it grants, so a team or an install can be left without an +// SSO-granted owner. Administrators can always repair a team, and the bootstrap +// administrator is a manual one. Rows added by hand are 'manual', and the sync +// only ever raises them (turning them into 'oidc' rows), never lowers or removes +// them. +func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) error { + // Administrator. A manual administrator stays one whatever the groups say. + if g.Admin { + if _, err := tx.ExecContext(ctx, + "UPDATE users SET is_admin = true, admin_source = 'oidc' WHERE id = $1 AND NOT is_admin", + userID); err != nil { + return err + } + } else if _, err := tx.ExecContext(ctx, + "UPDATE users SET is_admin = false, admin_source = 'manual' WHERE id = $1 AND admin_source = 'oidc'", + userID); err != nil { + return err + } + + // Teams. The result of the loop is the set of teams the groups grant. + granted := make([]int64, 0, len(g.Teams)) + for name, role := range g.Teams { + if _, err := tx.ExecContext(ctx, + "INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil { + return err + } + var teamID int64 + if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil { + return err + } + granted = append(granted, teamID) + + // A row the sync owns follows the groups in both directions. One added by + // hand is only raised: a member the owner made an owner by hand is not + // demoted because the mapping says member. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO team_members (team_id, user_id, role, source) + VALUES ($1, $2, $3, 'oidc') + ON CONFLICT (team_id, user_id) DO UPDATE + SET role = excluded.role, source = 'oidc' + WHERE team_members.source = 'oidc' + OR (excluded.role = $4 AND team_members.role = $5)`, + teamID, userID, role, models.RoleOwner, models.RoleMember); err != nil { + return err + } + } + + // Access the groups no longer grant. granted is never nil, or the ALL + // comparison would be against NULL and delete nothing. + _, err := tx.ExecContext(ctx, + "DELETE FROM team_members WHERE user_id = $1 AND source = 'oidc' AND team_id <> ALL($2)", + userID, granted) + return err +} diff --git a/internal/api/oidc_test.go b/internal/api/oidc_test.go new file mode 100644 index 0000000..243dc00 --- /dev/null +++ b/internal/api/oidc_test.go @@ -0,0 +1,775 @@ +package api_test + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "git.ryuvia.com/niklas/terdut-server/internal/api" + "git.ryuvia.com/niklas/terdut-server/internal/config" +) + +// fakeIdP is just enough of an OpenID Connect provider for terdut to sign +// somebody in against: discovery, a key set and a token endpoint that checks the +// PKCE verifier. There is no authorize endpoint; the tests read the URL terdut +// redirects to and play the part of the browser and the person themselves. +type fakeIdP struct { + *httptest.Server + key *rsa.PrivateKey + + mu sync.Mutex + codes map[string]pendingCode +} + +type pendingCode struct { + claims map[string]any + challenge string +} + +const ( + idpClientID = "terdut" + idpClientSecret = "s3cret" +) + +func newFakeIdP(t *testing.T) *fakeIdP { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f := &fakeIdP{key: key, codes: map[string]pendingCode{}} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "issuer": f.URL, + "authorization_endpoint": f.URL + "/authorize", + "token_endpoint": f.URL + "/token", + "jwks_uri": f.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + b64 := base64.RawURLEncoding.EncodeToString + json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ + "kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256", + "n": b64(key.N.Bytes()), + "e": b64(big.NewInt(int64(key.E)).Bytes()), + }}}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + user, pass, basic := r.BasicAuth() + if !basic { + user, pass = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret") + } + if user != idpClientID || pass != idpClientSecret { + http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized) + return + } + f.mu.Lock() + p, ok := f.codes[r.PostForm.Get("code")] + delete(f.codes, r.PostForm.Get("code")) // single use, like a real provider + f.mu.Unlock() + sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier"))) + if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != p.challenge { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + // oauth2 picks the parser from the content type; without this it reads + // the body as a form, finds no token and retries, spending the code. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "unused", "token_type": "Bearer", "expires_in": 300, + "id_token": f.sign(t, p.claims), + }) + }) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + return f +} + +// sign returns claims as an RS256 JWT. +func (f *fakeIdP) sign(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + signing := enc(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + "." + enc(claims) + sum := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:]) + if err != nil { + t.Fatal(err) + } + return signing + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// idpUser is who signs in, as the provider describes them. +type idpUser struct { + sub, username, email string + unverified bool + groups []string + badNonce bool +} + +// ssoConfig is a terdut configuration wired to idp, with the mapping the tests +// share: terdut-users may sign in, terdut-admins administer, and the sre groups +// grant roles in the SRE team. +func ssoConfig(idp *fakeIdP) config.Config { + c := testConfig() + c.OIDC = config.OIDC{ + Issuer: idp.URL, + ClientID: idpClientID, + ClientSecret: idpClientSecret, + Name: "Authentik", + Scopes: []string{"openid", "profile", "email"}, + UsernameClaim: "preferred_username", + EmailClaim: "email", + GroupsClaim: "groups", + AllowedGroups: []string{"terdut-users"}, + AdminGroup: "terdut-admins", + GroupMappings: []config.GroupMapping{ + {Group: "sre", Team: "SRE", Role: "member"}, + {Group: "sre-leads", Team: "SRE", Role: "owner"}, + {Group: "platform", Team: "Platform", Role: "member"}, + }, + SessionMaxAge: 12 * time.Hour, + } + return c +} + +func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts { + t.Helper() + c := ssoConfig(idp) + for _, f := range tweak { + f(&c) + } + return newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{PublicURL: "http://terdut.test"}, c) +} + +// ssoBrowser is a browser that does not follow redirects, so a test can read +// where each step sends it. +func ssoBrowser(t *testing.T, s *ts) *browser { + t.Helper() + b := newBrowser(t, s.URL) + b.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return b +} + +// startLogin visits /api/oidc/login and returns what terdut asked the provider +// for: the state, nonce and PKCE challenge. +func startLogin(t *testing.T, idp *fakeIdP, b *browser) (state, nonce, challenge string) { + t.Helper() + resp := b.do(t, http.MethodGet, "/api/oidc/login", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("login start: %d", resp.StatusCode) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil || !strings.HasPrefix(loc.String(), idp.URL+"/authorize") { + t.Fatalf("login redirected to %q, want the provider", resp.Header.Get("Location")) + } + q := loc.Query() + if q.Get("code_challenge_method") != "S256" || q.Get("client_id") != idpClientID || + q.Get("redirect_uri") != "http://terdut.test/api/oidc/callback" || q.Get("response_type") != "code" { + t.Fatalf("unexpected authorization request: %v", q) + } + return q.Get("state"), q.Get("nonce"), q.Get("code_challenge") +} + +// issueCode has the provider authenticate u and hand back an authorization code. +func (f *fakeIdP) issueCode(u idpUser, nonce, challenge string) string { + if u.badNonce { + nonce = "not-the-nonce" + } + claims := map[string]any{ + "iss": f.URL, "sub": u.sub, "aud": idpClientID, + "iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(), + "nonce": nonce, + "preferred_username": u.username, + "email": u.email, + "email_verified": !u.unverified, + "groups": u.groups, + } + f.mu.Lock() + defer f.mu.Unlock() + code := fmt.Sprintf("code-%d", len(f.codes)+int(time.Now().UnixNano()%1e6)) + f.codes[code] = pendingCode{claims: claims, challenge: challenge} + return code +} + +// callback delivers the provider's answer to terdut and returns where terdut +// sends the browser next. +func callback(t *testing.T, b *browser, code, state string) string { + t.Helper() + resp := b.do(t, http.MethodGet, "/api/oidc/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil) + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("callback: %d", resp.StatusCode) + } + return resp.Header.Get("Location") +} + +// signInSSO runs a whole sign-in and returns the Location the callback ended on. +func signInSSO(t *testing.T, idp *fakeIdP, b *browser, u idpUser) string { + t.Helper() + state, nonce, challenge := startLogin(t, idp, b) + return callback(t, b, idp.issueCode(u, nonce, challenge), state) +} + +var alice = idpUser{sub: "sub-alice", username: "alice", email: "alice@example.com", groups: []string{"terdut-users", "sre"}} + +func withGroups(u idpUser, groups ...string) idpUser { + u.groups = groups + return u +} + +// meOf reads /api/me over the browser's session. +func meOf(t *testing.T, b *browser) (status int, username string, isAdmin, hasPassword bool) { + t.Helper() + resp := b.do(t, http.MethodGet, "/api/me", nil) + defer resp.Body.Close() + var me struct { + User struct { + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + } `json:"user"` + HasPassword bool `json:"has_password"` + } + json.NewDecoder(resp.Body).Decode(&me) + return resp.StatusCode, me.User.Username, me.User.IsAdmin, me.HasPassword +} + +// memberships lists a user's teams as name -> "role/source". +func (s *ts) memberships(t *testing.T, username string) map[string]string { + t.Helper() + rows, err := s.db.Query(` + SELECT t.name, m.role, m.source FROM team_members m + JOIN teams t ON t.id = m.team_id JOIN users u ON u.id = m.user_id + WHERE u.username = $1`, username) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var name, role, source string + rows.Scan(&name, &role, &source) + out[name] = role + "/" + source + } + return out +} + +func sameMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + b := ssoBrowser(t, s) + + if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" { + t.Fatalf("signed in and was sent to %q, want /", loc) + } + status, name, isAdmin, hasPassword := meOf(t, b) + if status != http.StatusOK || name != "alice" || isAdmin || hasPassword { + t.Fatalf("me: status %d user %q admin %v has_password %v", status, name, isAdmin, hasPassword) + } + want := map[string]string{"SRE": "member/oidc", "Platform": "member/oidc"} + if got := s.memberships(t, "alice"); !sameMap(got, want) { + t.Errorf("memberships %v, want %v", got, want) + } +} + +func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + b := ssoBrowser(t, s) + + loc := signInSSO(t, idp, b, withGroups(alice, "sre", "terdut-admins")) + if loc != "/?sso_error=not_allowed" { + t.Fatalf("sent to %q, want the not_allowed error", loc) + } + if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized { + t.Errorf("a refused sign-in must not leave a session: /api/me %d", status) + } + var n int + s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'alice'").Scan(&n) + if n != 0 { + t.Error("a refused sign-in must not create the user") + } +} + +func TestSSO_AdminFollowsTheAdminGroup(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "terdut-admins")) + var isAdmin bool + var source string + read := func() { + s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'alice'").Scan(&isAdmin, &source) + } + if read(); !isAdmin || source != "oidc" { + t.Fatalf("after admin sign-in: admin %v source %q", isAdmin, source) + } + + signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users")) + if read(); isAdmin || source != "manual" { + t.Errorf("after losing the group: admin %v source %q, want revoked and manual", isAdmin, source) + } +} + +func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true }) + + // The bootstrap administrator is a manual one. Signing in through the + // provider without the admin group must not take that away. + signInSSO(t, idp, ssoBrowser(t, s), idpUser{sub: "sub-admin", username: "admin", email: "admin@test.com", groups: []string{"terdut-users"}}) + var isAdmin bool + var source string + s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'admin'").Scan(&isAdmin, &source) + if !isAdmin || source != "manual" { + t.Errorf("admin %v source %q, want still a manual admin", isAdmin, source) + } +} + +func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + signInSSO(t, idp, ssoBrowser(t, s), alice) + // Somebody adds alice to another team by hand. + s.exec(t, "INSERT INTO teams (name) VALUES ('Hand')") + s.exec(t, `INSERT INTO team_members (team_id, user_id, role) + SELECT (SELECT id FROM teams WHERE name = 'Hand'), id, 'member' FROM users WHERE username = 'alice'`) + + signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users")) + want := map[string]string{"Hand": "member/manual"} + if got := s.memberships(t, "alice"); !sameMap(got, want) { + t.Errorf("memberships %v, want %v: the SRE row is the sync's to remove, Hand is not", got, want) + } +} + +func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads")) + if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) { + t.Errorf("both groups: %v, want owner", got) + } + signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre")) + if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) { + t.Errorf("lead group dropped: %v, want member", got) + } +} + +func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + // alice exists locally, is a manual owner of SRE, and is linked by email. + s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')") + s.exec(t, "INSERT INTO teams (name) VALUES ('SRE')") + s.exec(t, `INSERT INTO team_members (team_id, user_id, role) + VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`) + + signInSSO(t, idp, ssoBrowser(t, s), alice) // the mapping only says member + if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) { + t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got) + } +} + +func TestSSO_LinksExistingUserByVerifiedEmail(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'Alice@Example.com')") + + b := ssoBrowser(t, s) + signInSSO(t, idp, b, alice) + if _, name, _, _ := meOf(t, b); name != "alice-local" { + t.Errorf("signed in as %q, want the existing local user", name) + } + var users, identities int + s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users) + s.db.QueryRow("SELECT COUNT(*) FROM user_identities").Scan(&identities) + if users != 2 || identities != 1 { // admin + alice-local + t.Errorf("%d users, %d identities: linking must not create a second user", users, identities) + } +} + +func TestSSO_UnverifiedEmailIsNotLinkedUnlessTrusted(t *testing.T) { + idp := newFakeIdP(t) + unverified := alice + unverified.unverified = true + + s := newSSOTS(t, idp) + s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')") + if loc := signInSSO(t, idp, ssoBrowser(t, s), unverified); loc != "/?sso_error=email_conflict" { + t.Errorf("unverified email: sent to %q, want email_conflict", loc) + } + + trusting := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true }) + trusting.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')") + b := ssoBrowser(t, trusting) + if loc := signInSSO(t, idp, b, unverified); loc != "/" { + t.Fatalf("trusted email: sent to %q, want /", loc) + } + if _, name, _, _ := meOf(t, b); name != "alice-local" { + t.Errorf("signed in as %q, want the existing local user", name) + } +} + +func TestSSO_RecycledEmailDoesNotTakeOverALinkedAccount(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + signInSSO(t, idp, ssoBrowser(t, s), alice) + + // A different person at the provider, same address. + other := alice + other.sub = "sub-someone-else" + if loc := signInSSO(t, idp, ssoBrowser(t, s), other); loc != "/?sso_error=email_conflict" { + t.Errorf("sent to %q, want email_conflict", loc) + } +} + +func TestSSO_UsernameCollisionGetsASuffix(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'someone-else@example.com')") + + b := ssoBrowser(t, s) + signInSSO(t, idp, b, alice) + if _, name, _, _ := meOf(t, b); name != "alice-2" { + t.Errorf("username %q, want alice-2", name) + } +} + +func TestSSO_ProfileFollowsTheProvider(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + signInSSO(t, idp, ssoBrowser(t, s), alice) + + renamed := alice + renamed.username, renamed.email = "alice.smith", "alice.smith@example.com" + b := ssoBrowser(t, s) + signInSSO(t, idp, b, renamed) + if _, name, _, _ := meOf(t, b); name != "alice.smith" { + t.Errorf("username %q, want the provider's new one", name) + } + var email string + s.db.QueryRow("SELECT email FROM users WHERE username = 'alice.smith'").Scan(&email) + if email != "alice.smith@example.com" { + t.Errorf("email %q", email) + } +} + +func TestSSO_DisabledUserIsRefused(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + signInSSO(t, idp, ssoBrowser(t, s), alice) + s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'") + + b := ssoBrowser(t, s) + if loc := signInSSO(t, idp, b, alice); loc != "/?sso_error=disabled" { + t.Errorf("sent to %q, want disabled", loc) + } + if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized { + t.Errorf("/api/me %d, want 401", status) + } +} + +func TestSSO_NoEmailIsRefused(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + noEmail := alice + noEmail.email = "" + if loc := signInSSO(t, idp, ssoBrowser(t, s), noEmail); loc != "/?sso_error=no_email" { + t.Errorf("sent to %q, want no_email", loc) + } +} + +func TestSSO_SessionIsCappedAndDoesNotSlidePastTheCap(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + b := ssoBrowser(t, s) + signInSSO(t, idp, b, alice) + + var expires, ceiling int64 + s.db.QueryRow(`SELECT expires_at, max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&expires, &ceiling) + inTwelveHours := time.Now().Add(12 * time.Hour).Unix() + if ceiling < inTwelveHours-60 || ceiling > inTwelveHours+60 || expires != ceiling { + t.Fatalf("expires %d ceiling %d, want both about %d", expires, ceiling, inTwelveHours) + } + + // Age the session so the next request would slide it, with a ceiling well + // inside the ordinary 30 days. + s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200") + if status, _, _, _ := meOf(t, b); status != http.StatusOK { + t.Fatalf("/api/me %d", status) + } + var after int64 + s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after) + if after > ceiling { + t.Errorf("expiry slid to %d, past the ceiling %d", after, ceiling) + } +} + +func TestSSO_PasswordSessionsStillSlideWithoutACeiling(t *testing.T) { + s := newTS(t) + b := signedIn(t, s) + var ceiling *int64 + s.db.QueryRow(`SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&ceiling) + if ceiling != nil { + t.Errorf("a password session has a ceiling %d, want none", *ceiling) + } + s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200, expires_at = expires_at - 7200") + var before, after int64 + s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&before) + meOf(t, b) + s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after) + if after <= before { + t.Errorf("expiry %d -> %d, want it to slide forward", before, after) + } +} + +func TestSSO_StateIsSingleUseAndBoundToTheBrowser(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + + // Replaying a callback finds no state. + b := ssoBrowser(t, s) + state, nonce, challenge := startLogin(t, idp, b) + code := idp.issueCode(alice, nonce, challenge) + if loc := callback(t, b, code, state); loc != "/" { + t.Fatalf("first callback sent to %q", loc) + } + if loc := callback(t, b, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" { + t.Errorf("replayed state: sent to %q, want expired", loc) + } + + // A callback from a browser that did not start the login is refused, which + // is what stops a login being planted on somebody else. + victim := ssoBrowser(t, s) + state, nonce, challenge = startLogin(t, idp, ssoBrowser(t, s)) // the attacker's + if loc := callback(t, victim, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" { + t.Errorf("foreign browser: sent to %q, want expired", loc) + } + if status, _, _, _ := meOf(t, victim); status != http.StatusUnauthorized { + t.Errorf("the victim has a session: /api/me %d", status) + } +} + +func TestSSO_WrongNonceIsRefused(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + bad := alice + bad.badNonce = true + b := ssoBrowser(t, s) + if loc := signInSSO(t, idp, b, bad); loc != "/?sso_error=failed" { + t.Errorf("sent to %q, want failed", loc) + } + if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized { + t.Errorf("/api/me %d, want 401", status) + } +} + +func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + b := ssoBrowser(t, s) + resp := b.do(t, http.MethodGet, "/api/oidc/callback?error=access_denied", nil) + resp.Body.Close() + if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=denied" { + t.Errorf("%d to %q, want a redirect to denied", resp.StatusCode, loc) + } +} + +func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins")) + + var aliceID, sreID int64 + s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID) + s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID) + teamPath := fmt.Sprintf("/api/teams/%d/members", sreID) + + // The bootstrap admin is a system administrator, so may manage SRE. + for _, c := range []struct { + name, method, path string + body any + }{ + {"role change", http.MethodPost, teamPath, map[string]any{"user_id": aliceID, "role": "owner"}}, + {"removal", http.MethodDelete, fmt.Sprintf("%s/%d", teamPath, aliceID), nil}, + {"admin revoke", http.MethodPut, fmt.Sprintf("/api/users/%d/admin", aliceID), map[string]any{"is_admin": false}}, + } { + resp := s.req(t, c.method, c.path, c.body) + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Errorf("%s: %d, want 409", c.name, resp.StatusCode) + } + } + if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) { + t.Errorf("memberships changed by a refused edit: %v", got) + } +} + +func TestSSO_PasswordLoginCanBeSwitchedOff(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp, func(c *config.Config) { c.DisablePasswordLogin = true }) + b := newBrowser(t, s.URL) + + resp := b.login(t, "admin", "whatever-password") + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("login: %d, want 403", resp.StatusCode) + } + resp = b.do(t, http.MethodPost, "/api/signup", map[string]string{"username": "x", "email": "x@example.com", "password": "correct horse battery"}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("signup: %d, want 403", resp.StatusCode) + } + + var cfg struct { + PasswordLogin bool `json:"password_login"` + OIDC struct { + Enabled bool `json:"enabled"` + Name string `json:"name"` + } `json:"oidc"` + } + resp = b.do(t, http.MethodGet, "/api/auth/config", nil) + defer resp.Body.Close() + json.NewDecoder(resp.Body).Decode(&cfg) + if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" { + t.Errorf("auth config: %+v", cfg) + } +} + +func TestAuthConfig_DefaultsToPasswordOnly(t *testing.T) { + s := newTS(t) + var cfg struct { + PasswordLogin bool `json:"password_login"` + OIDC struct { + Enabled bool `json:"enabled"` + } `json:"oidc"` + } + resp := newBrowser(t, s.URL).do(t, http.MethodGet, "/api/auth/config", nil) + defer resp.Body.Close() + json.NewDecoder(resp.Body).Decode(&cfg) + if !cfg.PasswordLogin || cfg.OIDC.Enabled { + t.Errorf("auth config: %+v", cfg) + } + + // With SSO off the routes do not exist, rather than answering with an error + // page a person could land on. + resp = newBrowser(t, s.URL).do(t, http.MethodGet, "/api/oidc/login", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("/api/oidc/login with SSO off: %d, want 404", resp.StatusCode) + } +} + +func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + idp.Close() // the provider goes down after terdut has started + + b := ssoBrowser(t, s) + resp := b.do(t, http.MethodGet, "/api/oidc/login", nil) + resp.Body.Close() + if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=unavailable" { + t.Errorf("%d to %q, want a redirect to unavailable", resp.StatusCode, loc) + } +} + +func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) { + idp := newFakeIdP(t) + s := newSSOTS(t, idp) + b := ssoBrowser(t, s) + signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins")) + + var aliceID, sreID int64 + s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID) + s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID) + + // Users: alice's administrator flag is the groups', the bootstrap admin's is not. + var users []struct { + Username string `json:"username"` + AdminSource string `json:"admin_source"` + } + decode(t, s.req(t, http.MethodGet, "/api/users", nil), &users) + got := map[string]string{} + for _, u := range users { + got[u.Username] = u.AdminSource + } + if got["alice"] != "oidc" || got["admin"] != "manual" { + t.Errorf("admin_source by user: %v", got) + } + + // The team's own member list, as a member sees it. + var members []struct { + Username string `json:"username"` + Source string `json:"source"` + } + resp := b.do(t, http.MethodGet, fmt.Sprintf("/api/teams/%d/members", sreID), nil) + decode(t, resp, &members) + if len(members) != 1 || members[0].Username != "alice" || members[0].Source != "oidc" { + t.Errorf("team members: %+v", members) + } + + // The administrator's view of the same team, and of alice's teams. + var adminTeam struct { + Members []struct { + Username string `json:"username"` + Source string `json:"source"` + } `json:"members"` + } + decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/admin/teams/%d", sreID), nil), &adminTeam) + if len(adminTeam.Members) != 1 || adminTeam.Members[0].Source != "oidc" { + t.Errorf("admin team members: %+v", adminTeam.Members) + } + var teams []struct { + Name string `json:"name"` + Source string `json:"source"` + } + decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/users/%d/teams", aliceID), nil), &teams) + if len(teams) != 1 || teams[0].Name != "SRE" || teams[0].Source != "oidc" { + t.Errorf("user teams: %+v", teams) + } + + // The bootstrap admin's own membership is manual. + var mine []struct { + Source string `json:"source"` + } + decode(t, s.req(t, http.MethodGet, "/api/users/1/teams", nil), &mine) + if len(mine) == 0 || mine[0].Source != "manual" { + t.Errorf("bootstrap admin's teams: %+v", mine) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index b1a0ff3..dbd1f49 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -5,6 +5,7 @@ import ( "net/http" "git.ryuvia.com/niklas/terdut-server/internal/config" + "git.ryuvia.com/niklas/terdut-server/internal/oidc" "git.ryuvia.com/niklas/terdut-server/internal/web" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -20,6 +21,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler // two would let a burst of sign-ups lock somebody out of logging in. loginLimit := newLoginLimiter() signupLimiter := newLoginLimiter() + oidcLimit := newLoginLimiter() r := chi.NewRouter() r.Use(middleware.Logger) @@ -50,18 +52,43 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler // an invite link is good, so the form can say so before somebody picks a // password. r.Get("/api/signup", handleSignupInfo(db)) - r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL)) + r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)). + Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL)) + + // How to sign in: what the login form and the TUI offer before anybody types. + r.Get("/api/auth/config", handleAuthConfig(cfg)) // Signing in to the web UI. Login trades a password for a session cookie, // which AuthMiddleware accepts in place of an API key. - r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL)) + r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)). + Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL)) r.Post("/api/logout", handleLogout(db, notify.PublicURL)) + // Single sign-on. Both routes are navigations the browser makes, to and from + // the provider, so they answer with redirects rather than JSON. + if cfg.OIDC.Enabled() { + prov := oidc.New(cfg.OIDC, notify.PublicURL) + r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL)) + r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL)) + + // Device login, for a client with no browser of its own. Both are + // unauthenticated: the device code in the body is the credential. + r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL)) + r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL)) + } + // All other /api routes require a valid API key. r.Group(func(r chi.Router) { r.Use(AuthMiddleware(db)) r.Get("/api/me", handleMe(db)) + + // Approving or refusing a device login is done by somebody signed in + // to a browser, and needs the same SSO configuration the flow does. + if cfg.OIDC.Enabled() { + r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true)) + r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false)) + } r.Put("/api/me/onboarding", handleDismissOnboarding(db)) // Proves the topic works, which is the only part of "notifications are // set up" that the person holding the phone can confirm. diff --git a/internal/api/settings.go b/internal/api/settings.go index 702e8f4..b5bb401 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -315,7 +315,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc { // Same query and same ordering as handleListTeamMembers, so the two // answers to "who is in this team" cannot disagree about the answer. rows, err := db.QueryContext(r.Context(), ` - SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at + SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source FROM team_members m JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 @@ -330,7 +330,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc { for rows.Next() { var m models.TeamMember var joined int64 - if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil { + if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } diff --git a/internal/api/teams.go b/internal/api/teams.go index 66c3714..59cadba 100644 --- a/internal/api/teams.go +++ b/internal/api/teams.go @@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { caller, _ := userFromContext(r.Context()) rows, err := db.QueryContext(r.Context(), ` - SELECT t.id, t.name, t.created_at, m.role + SELECT t.id, t.name, t.created_at, m.role, m.source FROM teams t JOIN team_members m ON m.team_id = t.id WHERE m.user_id = $1 @@ -36,7 +36,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc { for rows.Next() { var t models.Team var created int64 - if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } @@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc { } rows, err := db.QueryContext(r.Context(), ` - SELECT t.id, t.name, t.created_at, m.role + SELECT t.id, t.name, t.created_at, m.role, m.source FROM teams t JOIN team_members m ON m.team_id = t.id WHERE m.user_id = $1 @@ -98,7 +98,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc { for rows.Next() { var t models.Team var created int64 - if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } @@ -255,7 +255,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc { } rows, err := db.QueryContext(r.Context(), ` - SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, + SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source, u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '', u.disabled_at IS NOT NULL, GREATEST( @@ -280,7 +280,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc { var m memberStatus var joined, lastActive int64 var hasTopic, disabled bool - if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, + if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source, &hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -343,6 +343,14 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc { return } + if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } else if managed { + respond(w, http.StatusConflict, errResp(ssoManagedMsg)) + return + } + // Demoting the last owner is removing them by another route: the team // would have nobody who can edit it. if req.Role == models.RoleMember { @@ -392,6 +400,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc { return } + if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } else if managed { + respond(w, http.StatusConflict, errResp(ssoManagedMsg)) + return + } + last, err := isLastTeamOwner(r.Context(), db, teamID, userID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) @@ -416,6 +432,20 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc { } } +// ssoManagedMsg is the refusal for editing access that single sign-on owns. +const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider" + +// isSSOManagedMember reports whether the membership comes from the group sync. +// Editing it here would be undone at the person's next sign-in, so it is refused +// instead of appearing to work. +func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) { + var managed bool + err := db.QueryRowContext(ctx, + "SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')", + teamID, userID).Scan(&managed) + return managed, err +} + func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) { var last bool err := db.QueryRowContext(ctx, ` diff --git a/internal/api/users.go b/internal/api/users.go index 10caffb..0fb0d11 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc { func handleListUsers(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rows, err := db.QueryContext(r.Context(), - "SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id") + "SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id") if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return @@ -108,7 +108,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc { var u models.User var ts int64 var disabled *int64 - if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } @@ -329,8 +329,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) { var ts int64 var disabled *int64 err := db.QueryRowContext(ctx, - "SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id). - Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled) + "SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id). + Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled) if err != nil { return u, err } @@ -361,6 +361,18 @@ func handleSetAdmin(db *sql.DB) http.HandlerFunc { } if !*req.IsAdmin { + var managed bool + if err := db.QueryRowContext(r.Context(), + "SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')", + id).Scan(&managed); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if managed { + respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider")) + return + } + caller, _ := userFromContext(r.Context()) if caller.ID == id { respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access")) diff --git a/internal/config/config.go b/internal/config/config.go index a148623..51f7415 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,12 @@ package config import ( + "encoding/json" + "errors" + "fmt" + "net/url" "os" + "strings" "time" ) @@ -59,8 +64,78 @@ type Config struct { // NotifyRepeat is how long an incident may sit unacknowledged before it is // notified again. Zero disables reminders. NotifyRepeat time.Duration + + // DisablePasswordLogin refuses signing in, or signing up, with a password. + // It is how an install moves to SSO only, and turning it back off is the way + // in when the identity provider is down. Stated negatively so that the zero + // Config, which is what a test or a new caller builds, keeps passwords working. + DisablePasswordLogin bool + + // OIDC configures single sign-on. The zero value, with no Issuer, is off. + OIDC OIDC } +// OIDC is the single sign-on configuration. Groups from the provider decide +// who may sign in, which teams they belong to, and whether they administer the +// install, in the manner of Grafana's org and role mapping. +type OIDC struct { + // Issuer is the provider's issuer URL. Discovery is fetched from + // /.well-known/openid-configuration. For Authentik this is the + // application's issuer, e.g. https://auth.example.com/application/o/terdut/. + // Empty turns single sign-on off. + Issuer string + ClientID string + ClientSecret string + + // Name is what the sign-in button calls the provider. + Name string + + // Scopes to request. The groups claim normally needs "profile" on Authentik. + Scopes []string + + // UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read. + UsernameClaim string + EmailClaim string + GroupsClaim string + + // TrustEmail links a sign-in to an existing local user by email even when the + // provider does not vouch that the address is verified. Authentik reports + // email_verified false unless told otherwise, and an install that runs its + // own provider has already decided that its addresses can be trusted. + TrustEmail bool + + // AllowedGroups gates sign-in: somebody in none of them is refused, however + // well the provider authenticated them. Empty admits everybody the provider + // authenticates, and access control is left to the provider. + AllowedGroups []string + + // AdminGroup grants the system administrator flag while the user is in it. + AdminGroup string + + // GroupMappings grants team roles. A user in Group gets Role in Team. + GroupMappings []GroupMapping + + // SessionMaxAge is the hard ceiling on a session made by an SSO login. The + // login is the only moment groups are re-read, so this is how long a change + // in the provider may take to reach terdut. + SessionMaxAge time.Duration + + // parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate: + // Load cannot fail, and a mapping that was silently dropped would grant + // less access than the operator wrote down. + parseErr error +} + +// GroupMapping grants Role in Team to members of Group. +type GroupMapping struct { + Group string `json:"group"` + Team string `json:"team"` + Role string `json:"role"` +} + +// Enabled reports whether single sign-on is configured. +func (o OIDC) Enabled() bool { return o.Issuer != "" } + func Load() Config { addr := os.Getenv("TERDUT_ADDR") if addr == "" { @@ -89,9 +164,108 @@ func Load() Config { NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"), PublicURL: os.Getenv("TERDUT_PUBLIC_URL"), NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute), + + DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true), + OIDC: loadOIDC(), } } +func loadOIDC() OIDC { + o := OIDC{ + Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")), + ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"), + ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"), + Name: str("TERDUT_OIDC_NAME", "SSO"), + Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"), + UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"), + EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"), + GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"), + TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false), + AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""), + AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"), + SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour), + } + if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" { + if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil { + o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err) + } + } + return o +} + +// Validate reports a configuration the server should refuse to start with. +// Single sign-on is the only part that can be inconsistent: a half-configured +// provider would come up and then fail every login, which is harder to notice +// than not starting. +func (c Config) Validate() error { + o := c.OIDC + if o.parseErr != nil { + return o.parseErr + } + if !o.Enabled() { + if c.DisablePasswordLogin { + return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in") + } + if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 { + return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not") + } + return nil + } + if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer) + } + if o.ClientID == "" || o.ClientSecret == "" { + return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER") + } + if c.PublicURL == "" { + return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI") + } + if o.SessionMaxAge <= 0 { + return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive") + } + for i, m := range o.GroupMappings { + if m.Group == "" || m.Team == "" { + return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i) + } + if m.Role != "owner" && m.Role != "member" { + return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role) + } + } + if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" { + return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything") + } + return nil +} + +func str(env, def string) string { + if s := strings.TrimSpace(os.Getenv(env)); s != "" { + return s + } + return def +} + +// list reads a comma- or space-separated env var. +func list(env, def string) []string { + s := os.Getenv(env) + if strings.TrimSpace(s) == "" { + s = def + } + return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) +} + +// boolean reads a true/false env var. An unrecognised value takes the default, +// so the two flags read this way (password login on, trusting email off) both +// fail towards the cautious setting. +func boolean(env string, def bool) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) { + case "true", "1", "yes": + return true + case "false", "0", "no": + return false + } + return def +} + // duration reads a time.ParseDuration-formatted env var. An unset or // unparseable value falls back to def rather than failing startup: a typo in one // tuning knob should not take the server down. diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b642053 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidate(t *testing.T) { + base := func() map[string]string { + return map[string]string{ + "TERDUT_PUBLIC_URL": "https://terdut.example.com", + "TERDUT_OIDC_ISSUER": "https://auth.example.com/application/o/terdut/", + "TERDUT_OIDC_CLIENT_ID": "id", + "TERDUT_OIDC_CLIENT_SECRET": "secret", + } + } + tests := []struct { + name string + env func(map[string]string) + wantErr string // substring; empty means valid + }{ + {"off by default", func(m map[string]string) { clear(m) }, ""}, + {"minimal sso", func(m map[string]string) {}, ""}, + {"groups without issuer", func(m map[string]string) { + clear(m) + m["TERDUT_OIDC_ADMIN_GROUP"] = "admins" + }, "ISSUER is not"}, + {"missing secret", func(m map[string]string) { delete(m, "TERDUT_OIDC_CLIENT_SECRET") }, "CLIENT_SECRET"}, + {"missing public url", func(m map[string]string) { delete(m, "TERDUT_PUBLIC_URL") }, "PUBLIC_URL"}, + {"bad issuer", func(m map[string]string) { m["TERDUT_OIDC_ISSUER"] = "not a url" }, "not a URL"}, + {"bad mapping json", func(m map[string]string) { m["TERDUT_OIDC_GROUP_MAPPINGS"] = "{nope" }, "GROUP_MAPPINGS"}, + {"bad mapping role", func(m map[string]string) { + m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"admin"}]` + }, "role must be"}, + {"mapping needs team", func(m map[string]string) { + m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","role":"member"}]` + }, "group and team"}, + {"good mapping", func(m map[string]string) { + m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"owner"}]` + }, ""}, + {"password off without sso", func(m map[string]string) { + clear(m) + m["TERDUT_PASSWORD_LOGIN"] = "false" + }, "no way to sign in"}, + {"password off with sso but no grants", func(m map[string]string) { + m["TERDUT_PASSWORD_LOGIN"] = "false" + }, "nobody able"}, + {"password off with admin group", func(m map[string]string) { + m["TERDUT_PASSWORD_LOGIN"] = "false" + m["TERDUT_OIDC_ADMIN_GROUP"] = "admins" + }, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := base() + tt.env(env) + for _, k := range []string{ + "TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID", + "TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP", "TERDUT_OIDC_GROUP_MAPPINGS", + } { + t.Setenv(k, env[k]) + } + err := Load().Validate() + switch { + case tt.wantErr == "" && err != nil: + t.Errorf("unexpected error: %v", err) + case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)): + t.Errorf("error %v, want one containing %q", err, tt.wantErr) + } + }) + } +} + +func TestLoad_OIDCDefaults(t *testing.T) { + t.Setenv("TERDUT_OIDC_ISSUER", "https://auth.example.com/") + o := Load().OIDC + if o.UsernameClaim != "preferred_username" || o.EmailClaim != "email" || o.GroupsClaim != "groups" { + t.Errorf("claim defaults: %+v", o) + } + if strings.Join(o.Scopes, " ") != "openid profile email" { + t.Errorf("scopes: %v", o.Scopes) + } + if o.SessionMaxAge.Hours() != 12 { + t.Errorf("max age: %v", o.SessionMaxAge) + } + if Load().DisablePasswordLogin { + t.Error("password login should be on by default") + } +} diff --git a/internal/db/migrations/011_oidc.sql b/internal/db/migrations/011_oidc.sql new file mode 100644 index 0000000..557d72c --- /dev/null +++ b/internal/db/migrations/011_oidc.sql @@ -0,0 +1,60 @@ +-- Single sign-on through an OpenID Connect provider (Authentik, and anything +-- else that speaks OIDC). +-- +-- Four things change, and none of them touches a password user: every new column +-- has a default that says "this is how it has always worked". +-- +-- 1. user_identities says which provider account a user is. It is keyed on +-- (issuer, subject), never on email or username: those are mutable at the +-- provider, and a recycled address must not inherit somebody's account. A +-- user can have several identities (a second provider later), and none at all +-- (a local, password-only user), which is why this is a table and not two +-- columns on users. +-- +-- 2. team_members.source and users.admin_source record who granted a role. 'oidc' +-- rows are owned by the group sync: it adds them when a group grants access +-- and removes them when it stops, and nothing else may edit them. 'manual' rows +-- are everything that existed before this migration, and are never touched by +-- the sync. Without the marker the sync could not tell a membership it created +-- from one an owner added by hand, and would have to either leave stale access +-- behind or delete people it had no business deleting. +-- +-- 3. sessions.max_expires_at is a hard ceiling on a session's life. Ordinary +-- sessions slide for as long as they are used; a session made by an SSO login +-- must not, because the login is the only moment the groups are re-read. +-- Capping the session is what makes "removed from the group in the provider" +-- take effect within a bounded time. NULL means no ceiling. +-- +-- 4. oidc_logins holds a login that has been started and not yet finished: the +-- state, nonce and PKCE verifier the callback must see again. A row rather +-- than a signed cookie, so it survives a restart and needs no signing key. +-- Only the hash of the state is stored, like every other token here; the +-- nonce and verifier are useless without the state that names the row. +CREATE TABLE user_identities ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint, + last_login_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint, + UNIQUE (issuer, subject) +); + +CREATE INDEX user_identities_user_idx ON user_identities (user_id); + +ALTER TABLE team_members + ADD COLUMN source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc')); + +ALTER TABLE users + ADD COLUMN admin_source TEXT NOT NULL DEFAULT 'manual' CHECK (admin_source IN ('manual', 'oidc')); + +ALTER TABLE sessions ADD COLUMN max_expires_at BIGINT; + +CREATE TABLE oidc_logins ( + state_hash TEXT PRIMARY KEY, + nonce TEXT NOT NULL, + pkce_verifier TEXT NOT NULL, + expires_at BIGINT NOT NULL +); + +CREATE INDEX oidc_logins_expires_idx ON oidc_logins (expires_at); diff --git a/internal/db/migrations/012_device_login.sql b/internal/db/migrations/012_device_login.sql new file mode 100644 index 0000000..1764e62 --- /dev/null +++ b/internal/db/migrations/012_device_login.sql @@ -0,0 +1,40 @@ +-- Signing in from a terminal, for clients that cannot open a browser on the +-- machine they run on (the TUI over SSH is the reason). +-- +-- The flow is the OAuth device authorization grant, run by terdut itself rather +-- than the identity provider, so the terminal never talks to the provider and +-- the server issues its ordinary session at the end: +-- +-- 1. The terminal asks for a login and gets two secrets: a device code it +-- keeps and polls with, and a short user code it shows the person. +-- 2. The person opens the verification URL on any device, signs in by whatever +-- means the server offers, sees the user code, and approves it. +-- 3. The terminal's next poll finds the row approved and is given a session. +-- +-- Only the hash of the device code is stored, like every other token here: the +-- device code is what earns a session, so a database read must not yield one. +-- The user code is shown on screens and typed by people, so it is stored as is; +-- on its own it can only be approved, never redeemed. +-- +-- user_id is the person who approved. It is empty until then, and the session +-- is minted at redemption, not at approval: an approval nobody collects must not +-- leave a live session lying about. +-- +-- last_polled_at lets the server refuse a client that polls faster than the +-- interval it was told. +CREATE TABLE device_logins ( + device_hash TEXT PRIMARY KEY, + user_code TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')), + user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + expires_at BIGINT NOT NULL, + last_polled_at BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX device_logins_expires_idx ON device_logins (expires_at); + +-- Where to send the browser once a single sign-on login completes. A person who +-- opens /device?code=... without a session has to sign in first and then come +-- back to it, and the same is true of any other deep link. Validated when it is +-- stored: only a path on this server is ever kept. +ALTER TABLE oidc_logins ADD COLUMN next TEXT NOT NULL DEFAULT '/'; diff --git a/internal/models/team.go b/internal/models/team.go index 8695b8d..8bedf25 100644 --- a/internal/models/team.go +++ b/internal/models/team.go @@ -13,6 +13,10 @@ type Team struct { // listed for a particular person. Empty when nobody in particular is // asking, as in the admin listing. Role string `json:"role,omitempty"` + + // Source says who granted Role, on the endpoint that lists one user's teams: + // "manual", or "oidc" when the identity provider's groups did. + Source string `json:"source,omitempty"` } // Team roles. An owner configures the team — its schedule, its integrations and @@ -29,6 +33,10 @@ type TeamMember struct { Username string `json:"username"` Role string `json:"role"` JoinedAt time.Time `json:"joined_at"` + + // Source is who granted the membership: "manual", or "oidc" when the + // identity provider's groups did and only they can change it. + Source string `json:"source"` } // Integration is how alerts get in, and the only thing that says which team an diff --git a/internal/models/user.go b/internal/models/user.go index c75ef95..7a93ffa 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -23,6 +23,12 @@ type User struct { // is too old to have the field", and the web UI decides what to show from // it. IsAdmin bool `json:"is_admin"` + + // AdminSource is who granted the flag: "manual" or "oidc". An "oidc" + // administrator follows the identity provider's groups, so the UI shows it as + // managed there and the API refuses to revoke it by hand. Only set on the + // user endpoints that show it. + AdminSource string `json:"admin_source,omitempty"` } type APIKey struct { diff --git a/internal/oidc/grants.go b/internal/oidc/grants.go new file mode 100644 index 0000000..5ac5459 --- /dev/null +++ b/internal/oidc/grants.go @@ -0,0 +1,81 @@ +// Package oidc signs users in through an OpenID Connect provider and turns the +// groups it reports into the access terdut grants. +// +// The package knows nothing about the database or HTTP handlers: Grants is a +// pure function of configuration and groups, and Provider is the protocol. The +// api package joins them to users, teams and sessions. +package oidc + +import ( + "git.ryuvia.com/niklas/terdut-server/internal/config" +) + +// Role names match models.RoleOwner and RoleMember. They are restated here so +// the package stays free of the models import; config.Validate has already +// refused anything else. +const ( + roleOwner = "owner" + roleMember = "member" +) + +// Grants is the access a set of groups confers. +type Grants struct { + // Admitted is false when AllowedGroups is set and the user is in none of + // them. Nothing else in the struct means anything then. + Admitted bool + + // Admin is whether the user is in the admin group. + Admin bool + + // Teams maps team name to role. Where several groups grant the same team the + // highest role wins, so belonging to both a members group and an owners + // group makes somebody an owner rather than whichever mapping came last. + Teams map[string]string +} + +// ComputeGrants evaluates the configured mappings against groups. +func ComputeGrants(cfg config.OIDC, groups []string) Grants { + in := make(map[string]bool, len(groups)) + for _, g := range groups { + in[g] = true + } + + g := Grants{Teams: map[string]string{}} + + g.Admitted = len(cfg.AllowedGroups) == 0 + for _, allowed := range cfg.AllowedGroups { + if in[allowed] { + g.Admitted = true + break + } + } + if !g.Admitted { + return g + } + + g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup] + + for _, m := range cfg.GroupMappings { + if !in[m.Group] { + continue + } + if rank(m.Role) > rank(g.Teams[m.Team]) { + g.Teams[m.Team] = m.Role + } + } + return g +} + +// rank orders roles; an unknown or absent role ranks lowest. +func rank(role string) int { + switch role { + case roleOwner: + return 2 + case roleMember: + return 1 + } + return 0 +} + +// HigherRole reports whether role a outranks role b. +func HigherRole(a, b string) bool { return rank(a) > rank(b) } diff --git a/internal/oidc/grants_test.go b/internal/oidc/grants_test.go new file mode 100644 index 0000000..c5dffa5 --- /dev/null +++ b/internal/oidc/grants_test.go @@ -0,0 +1,82 @@ +package oidc + +import ( + "reflect" + "testing" + + "git.ryuvia.com/niklas/terdut-server/internal/config" +) + +func testCfg() config.OIDC { + return config.OIDC{ + AllowedGroups: []string{"terdut-users"}, + AdminGroup: "terdut-admins", + GroupMappings: []config.GroupMapping{ + {Group: "sre", Team: "SRE", Role: "member"}, + {Group: "sre-leads", Team: "SRE", Role: "owner"}, + {Group: "platform", Team: "Platform", Role: "member"}, + }, + } +} + +func TestComputeGrants(t *testing.T) { + tests := []struct { + name string + groups []string + want Grants + }{ + { + name: "not in an allowed group is refused", + groups: []string{"sre", "terdut-admins"}, + want: Grants{Admitted: false, Teams: map[string]string{}}, + }, + { + name: "allowed but no grants", + groups: []string{"terdut-users"}, + want: Grants{Admitted: true, Teams: map[string]string{}}, + }, + { + name: "admin group grants admin", + groups: []string{"terdut-users", "terdut-admins"}, + want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}}, + }, + { + name: "team roles from several groups", + groups: []string{"terdut-users", "sre", "platform"}, + want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}}, + }, + { + name: "highest role wins whatever the order", + groups: []string{"sre-leads", "terdut-users", "sre"}, + want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeGrants(testCfg(), tt.groups) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) { + cfg := testCfg() + cfg.AllowedGroups = nil + if g := ComputeGrants(cfg, nil); !g.Admitted { + t.Error("with no allowed groups configured, everybody the provider authenticates is admitted") + } +} + +func TestStringList(t *testing.T) { + if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Errorf("list: %v", got) + } + if got := stringList("solo"); !reflect.DeepEqual(got, []string{"solo"}) { + t.Errorf("single string: %v", got) + } + if got := stringList(nil); got != nil { + t.Errorf("nil: %v", got) + } +} diff --git a/internal/oidc/provider.go b/internal/oidc/provider.go new file mode 100644 index 0000000..c5ff7cc --- /dev/null +++ b/internal/oidc/provider.go @@ -0,0 +1,173 @@ +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "git.ryuvia.com/niklas/terdut-server/internal/config" +) + +// CallbackPath is where the provider sends the browser back to. Register +// /api/oidc/callback as the redirect URI at the provider. +const CallbackPath = "/api/oidc/callback" + +// Identity is what the provider says about somebody who has just signed in. +type Identity struct { + Issuer string + Subject string + Username string + Email string + + // EmailVerified is the provider's own claim. Whether to believe it is + // config.OIDC.TrustEmail's business, not this package's. + EmailVerified bool + + Groups []string +} + +// Provider runs the authorization-code flow with PKCE against one issuer. +type Provider struct { + cfg config.OIDC + redirectURL string + http *http.Client + + // Discovery is fetched on first use, not at startup. A provider that is + // down when terdut starts must not stop terdut starting: password login is + // the way in while it is down, and it can only be that if the server is up. + mu sync.Mutex + provider *gooidc.Provider +} + +// New returns a Provider for cfg. publicURL is the base of the redirect URI. +func New(cfg config.OIDC, publicURL string) *Provider { + return &Provider{ + cfg: cfg, + redirectURL: trimSlash(publicURL) + CallbackPath, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +func trimSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +// Name is what the sign-in button calls the provider. +func (p *Provider) Name() string { return p.cfg.Name } + +// Config is the configuration this provider was built from. +func (p *Provider) Config() config.OIDC { return p.cfg } + +// discover returns the provider's metadata, fetching it if need be. A failure is +// not cached, so the next login tries again. +func (p *Provider) discover(ctx context.Context) (*gooidc.Provider, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.provider != nil { + return p.provider, nil + } + ctx = gooidc.ClientContext(ctx, p.http) + prov, err := gooidc.NewProvider(ctx, p.cfg.Issuer) + if err != nil { + return nil, fmt.Errorf("oidc discovery: %w", err) + } + p.provider = prov + return prov, nil +} + +func (p *Provider) oauth(prov *gooidc.Provider) *oauth2.Config { + return &oauth2.Config{ + ClientID: p.cfg.ClientID, + ClientSecret: p.cfg.ClientSecret, + Endpoint: prov.Endpoint(), + RedirectURL: p.redirectURL, + Scopes: p.cfg.Scopes, + } +} + +// NewVerifier returns a fresh PKCE code verifier. +func NewVerifier() string { return oauth2.GenerateVerifier() } + +// AuthURL is where to send the browser to sign in. +func (p *Provider) AuthURL(ctx context.Context, state, nonce, verifier string) (string, error) { + prov, err := p.discover(ctx) + if err != nil { + return "", err + } + return p.oauth(prov).AuthCodeURL(state, + oauth2.S256ChallengeOption(verifier), + gooidc.Nonce(nonce), + ), nil +} + +// Exchange trades the authorization code for tokens, verifies the ID token +// (signature, issuer, audience, expiry and nonce) and returns who it names. +func (p *Provider) Exchange(ctx context.Context, code, verifier, nonce string) (*Identity, error) { + prov, err := p.discover(ctx) + if err != nil { + return nil, err + } + ctx = gooidc.ClientContext(ctx, p.http) + + tok, err := p.oauth(prov).Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + return nil, fmt.Errorf("oidc token exchange: %w", err) + } + raw, _ := tok.Extra("id_token").(string) + if raw == "" { + return nil, errors.New("oidc: token response has no id_token") + } + idToken, err := prov.Verifier(&gooidc.Config{ClientID: p.cfg.ClientID}).Verify(ctx, raw) + if err != nil { + return nil, fmt.Errorf("oidc: verify id_token: %w", err) + } + if idToken.Nonce != nonce { + return nil, errors.New("oidc: id_token nonce mismatch") + } + + var claims map[string]any + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("oidc: read claims: %w", err) + } + return p.identity(idToken.Issuer, idToken.Subject, claims), nil +} + +// identity maps raw claims onto an Identity using the configured claim names. +func (p *Provider) identity(issuer, subject string, claims map[string]any) *Identity { + id := &Identity{Issuer: issuer, Subject: subject} + id.Username, _ = claims[p.cfg.UsernameClaim].(string) + id.Email, _ = claims[p.cfg.EmailClaim].(string) + id.EmailVerified, _ = claims["email_verified"].(bool) + id.Groups = stringList(claims[p.cfg.GroupsClaim]) + return id +} + +// stringList reads a claim that is a list of strings, or a single string, which +// some providers send for a one-element list. +func stringList(v any) []string { + switch t := v.(type) { + case string: + if t == "" { + return nil + } + return []string{t} + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/internal/web/login_test.go b/internal/web/login_test.go new file mode 100644 index 0000000..ccf0aff --- /dev/null +++ b/internal/web/login_test.go @@ -0,0 +1,86 @@ +package web + +import ( + "io/fs" + "regexp" + "strings" + "testing" +) + +func read(t *testing.T, name string) string { + t.Helper() + sub, err := fs.Sub(files, "static") + if err != nil { + t.Fatal(err) + } + b, err := fs.ReadFile(sub, name) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// The sign-in button has to be a link the browser navigates, not script: the +// CSP's connect-src is 'self', so a fetch to the identity provider is blocked, +// and it is a redirect to the provider that the server answers. +func TestLoginPageOffersSSOAsAPlainLink(t *testing.T) { + html := read(t, "index.html") + if !regexp.MustCompile(`]*id="sso-link"[^>]*href="/api/oidc/login"|]*href="/api/oidc/login"[^>]*id="sso-link"`).MatchString(html) { + t.Error("index.html has no ") + } + if !strings.Contains(html, `id="password-login"`) { + t.Error("the password fields must sit in #password-login so a server can hide them") + } +} + +// Every code the server can put in ?sso_error= must have a message, or a +// refused person sees a generic failure and cannot tell what to ask for. +func TestLoginExplainsEverySSOError(t *testing.T) { + js := read(t, "js/app.js") + for _, code := range []string{ + "denied", "expired", "failed", "unavailable", + "not_allowed", "no_email", "email_conflict", "disabled", + } { + if !regexp.MustCompile(`\b` + code + `:`).MatchString(js) { + t.Errorf("app.js has no message for sso_error=%s", code) + } + } +} + +// The page a terminal's prompt links to has to be reachable as a route, or the +// link 404s into the queue and the code is never seen. +func TestDevicePageIsRoutedAndCallsTheApprovalAPI(t *testing.T) { + if !strings.Contains(read(t, "index.html"), `id="view-device"`) { + t.Error("index.html has no #view-device section") + } + app := read(t, "js/app.js") + if !strings.Contains(app, "name === 'device'") || !strings.Contains(app, "device: {") { + t.Error("app.js does not route /device") + } + // The SSO button must carry the page asked for through the provider. + if !strings.Contains(app, "/api/oidc/login?next=") { + t.Error("the SSO link does not carry next=") + } + dev := read(t, "js/device.js") + for _, want := range []string{"approveDevice", "denyDevice"} { + if !strings.Contains(dev, want) { + t.Errorf("device.js never calls %s", want) + } + } + api := read(t, "js/api.js") + for _, want := range []string{"/oidc/device/approve", "/oidc/device/deny"} { + if !strings.Contains(api, want) { + t.Errorf("api.js has no call to %s", want) + } + } +} + +// SSO-managed access must be marked in every view that edits it. +func TestManagedAccessIsMarkedWhereItIsEdited(t *testing.T) { + for _, file := range []string{"js/team.js", "js/adminteam.js", "js/adminuser.js", "js/admin.js"} { + js := read(t, file) + if !strings.Contains(js, "ssoBadge") || !strings.Contains(js, "SSO_MANAGED") { + t.Errorf("%s does not mark or explain SSO-managed access", file) + } + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index b9f5ea1..45ce6a5 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -137,6 +137,13 @@ h1, h2, h3 { margin: 0; line-height: 1.25; } .login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } .login-brand h1 { font-size: 24px; letter-spacing: -0.01em; } .login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; } +.login-password { display: grid; gap: 14px; } +/* "or" between the single sign-on button and the password form. */ +.login-divider { + display: flex; align-items: center; gap: 10px; margin: 0; + color: var(--faint); font-size: 13px; +} +.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); } label { display: grid; gap: 6px; } label > span { font-size: 13px; font-weight: 600; color: var(--muted); } @@ -327,6 +334,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); font-size: 12px; white-space: nowrap; } +/* The page a terminal's sign-in prompt links to. */ +.device-card { max-width: 420px; margin: 24px auto; padding: 20px; display: grid; gap: 14px; } +.device-code { + margin: 0; padding: 14px; text-align: center; + font: 700 30px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.12em; + background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); +} +/* Access the identity provider's groups grant. The tint says "not yours to edit here". */ +.row-team.sso { margin-left: 6px; background: var(--accent-soft); border-color: transparent; color: var(--accent); } .row.resolved .row-title { color: var(--muted); } .sev-critical { --sev: var(--crit); } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 284493d..492cd41 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -25,20 +25,29 @@

terdut

- - - - - -
+ + + + + + + + diff --git a/internal/web/static/js/account.js b/internal/web/static/js/account.js index 1e4bbbe..0708b80 100644 --- a/internal/web/static/js/account.js +++ b/internal/web/static/js/account.js @@ -26,8 +26,7 @@ function render() { h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })), notifyForm(user), - h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), - passwordForm(user, hasPassword), + ...passwordSection(user, hasPassword), h('div', { class: 'only-desktop' }, h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })), @@ -119,6 +118,24 @@ function notifyForm(user) { return form; } +// With password login switched off a password opens nothing, so somebody who +// has none is not asked to make one. Somebody who does keeps the form: it is +// how they change or get rid of a credential the server still remembers. +function passwordSection(user, hasPassword) { + if (!hasPassword && state.auth.password_login === false) { + const name = state.auth.oidc?.name || 'single sign-on'; + return [ + h('div', { class: 'page-head' }, h('h2', { text: 'Password' })), + h('div', { class: 'card' }, + h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })), + ]; + } + return [ + h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), + passwordForm(user, hasPassword), + ]; +} + function passwordForm(user, hasPassword) { const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const ok = h('p', { class: 'form-ok', role: 'status', hidden: true }); diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js index 1f5af30..a1c9693 100644 --- a/internal/web/static/js/admin.js +++ b/internal/web/static/js/admin.js @@ -13,7 +13,7 @@ // gate. import * as api from './api.js'; -import { h, clear, spinner, confirm, menuCard } from './ui.js'; +import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js'; import { state, myID } from './state.js'; const view = () => document.getElementById('view-admin'); @@ -197,7 +197,9 @@ function usersCard() { u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), self && h('span', { class: 'you', text: 'you' })), h('td', { class: 'muted', text: u.email }), - h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null), + h('td', {}, + u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null, + u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : null), h('td', {}, // Neither action is offered for your own account: the server refuses // both, and an enabled-looking button that always fails is worse than @@ -206,6 +208,9 @@ function usersCard() { class: 'btn-sm', type: 'button', text: u.is_admin ? 'Revoke admin' : 'Make admin', + // The server refuses to revoke what the groups grant. + disabled: ssoAdmin(u), + title: ssoAdmin(u) ? SSO_MANAGED : null, onclick: () => setAdmin(u, !u.is_admin), }), !self && h('button', { @@ -255,6 +260,8 @@ function invitePointer() { ); } +const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc'; + async function setAdmin(user, next) { if (next && !(await confirm({ title: `Make ${user.username} an administrator?`, diff --git a/internal/web/static/js/adminteam.js b/internal/web/static/js/adminteam.js index ae72cf2..6336a88 100644 --- a/internal/web/static/js/adminteam.js +++ b/internal/web/static/js/adminteam.js @@ -11,7 +11,7 @@ // gate. import * as api from './api.js'; -import { h, clear, spinner, confirm, toast, icon } from './ui.js'; +import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js'; import { state } from './state.js'; import { navigate } from './app.js'; import { when } from './format.js'; @@ -166,17 +166,22 @@ function membersCard() { // Unlike the Team tab's own member list, the name is a link: that // person's page is where the rest of them lives. h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })), - h('td', { class: 'muted small', text: m.role }), + h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()), h('td', { class: 'row-actions' }, h('button', { class: 'btn-sm', type: 'button', text: m.role === 'owner' ? 'Make member' : 'Make owner', + // The server refuses to edit a membership the groups grant. + disabled: m.source === 'oidc', + title: m.source === 'oidc' ? SSO_MANAGED : null, // The same endpoint both ways: adding is an upsert on the role. onclick: () => act(() => api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')), }), h('button', { class: 'btn-sm danger', type: 'button', text: 'Remove', + disabled: m.source === 'oidc', + title: m.source === 'oidc' ? SSO_MANAGED : null, // The server refuses the last owner with a 409, which act() shows. onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)), }), diff --git a/internal/web/static/js/adminuser.js b/internal/web/static/js/adminuser.js index d73b98c..b04888d 100644 --- a/internal/web/static/js/adminuser.js +++ b/internal/web/static/js/adminuser.js @@ -11,7 +11,7 @@ // enforcement of them. import * as api from './api.js'; -import { h, clear, spinner, confirm, toast, icon } from './ui.js'; +import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js'; import { state, myID } from './state.js'; import { navigate } from './app.js'; import { when } from './format.js'; @@ -96,6 +96,7 @@ function identityCard() { h('div', { class: 'user-head' }, h('h2', { text: u.username }), u.is_admin && h('span', { class: 'row-team', text: 'admin' }), + u.is_admin && u.admin_source === 'oidc' && ssoBadge(), u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), self && h('span', { class: 'you', text: 'you' })), h('dl', { class: 'user-facts' }, @@ -110,6 +111,9 @@ function identityCard() { !self && h('button', { class: 'btn', type: 'button', text: u.is_admin ? 'Revoke admin' : 'Make admin', + // The server refuses to revoke what the groups grant. + disabled: u.is_admin && u.admin_source === 'oidc', + title: u.is_admin && u.admin_source === 'oidc' ? SSO_MANAGED : null, onclick: () => setAdmin(!u.is_admin), }), !self && h('button', { @@ -162,16 +166,21 @@ function teamsCard() { // Not a link: the Team tab always shows the viewer's own team, so // sending them there from somebody else's membership would be a lie. h('td', {}, h('strong', { text: t.name })), - h('td', { class: 'muted small', text: t.role }), + h('td', { class: 'muted small' }, t.role, t.source === 'oidc' && ssoBadge()), h('td', { class: 'row-actions' }, h('button', { class: 'btn-sm', type: 'button', text: t.role === 'owner' ? 'Make member' : 'Make owner', + // The server refuses to edit a membership the groups grant. + disabled: t.source === 'oidc', + title: t.source === 'oidc' ? SSO_MANAGED : null, onclick: () => act(() => api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')), }), h('button', { class: 'btn-sm danger', type: 'button', text: 'Remove', + disabled: t.source === 'oidc', + title: t.source === 'oidc' ? SSO_MANAGED : null, // The server refuses the last owner with a 409, which act() shows. onclick: () => act(() => api.removeTeamMember(t.id, userID)), }), diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 133526b..ed5f349 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -59,8 +59,13 @@ async function call(method, path, { query, body, signal } = {}) { // session export const me = () => call('GET', '/me'); +// How this server can be signed in to: { password_login, oidc: { enabled, name } }. +export const authConfig = () => call('GET', '/auth/config'); export const login = (username, password) => call('POST', '/login', { body: { username, password } }); export const logout = () => call('POST', '/logout'); +// Approve or refuse a sign-in a terminal started; code is what it is showing. +export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } }); +export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } }); export const setPassword = (userID, password, currentPassword) => call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } }); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index a1c63e8..5e2cfa4 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -14,6 +14,7 @@ import * as team from './team.js'; import * as admin from './admin.js'; import * as adminuser from './adminuser.js'; import * as adminteam from './adminteam.js'; +import * as device from './device.js'; const $ = (id) => document.getElementById(id); @@ -30,6 +31,8 @@ const SECTIONS = { adminuser: { title: 'User', view: adminuser, nav: 'admin' }, adminteam: { title: 'Team', view: adminteam, nav: 'admin' }, more: { title: 'Account', view: account }, + // Reached by link from a terminal's sign-in prompt, not from the nav. + device: { title: 'Sign in a terminal', view: device }, }; // The mobile hamburger menu's contents — the same sections the desktop @@ -61,7 +64,7 @@ function parseRoute(pathname) { if (t) return { section: 'admin', tab: t.tab }; const tt = team.TABS.find((x) => x.path === `/${name}`); if (tt) return { section: 'team', tab: tt.tab }; - if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name }; + if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name }; return { section: 'queue', incident: null }; } @@ -227,6 +230,7 @@ async function boot() { $('login-form').addEventListener('submit', onLogin); $('signup-form').addEventListener('submit', onSignup); $('menu-btn').addEventListener('click', openNavMenu); + ssoErrorCode = takeSSOError(); // /signup is the one route that works without a session. if (location.pathname.replace(/\/$/, '') === '/signup') { @@ -236,6 +240,9 @@ async function boot() { } try { + // Before anything renders: the account view decides from it whether a + // password is worth offering to set. + await loadAuthConfig(); state.me = await api.me(); await loadTeams(); // The Admin tab exists only for an administrator. Somebody who types /admin @@ -331,17 +338,87 @@ async function onSignup(e) { } } -function showLogin() { +// Why single sign-on sent the browser back, by the code the server puts in +// ?sso_error=. The provider's name is the one the administrator configured. +function ssoErrorText(code, name) { + const sso = name || 'single sign-on'; + return { + denied: `Signing in with ${sso} was cancelled or refused.`, + expired: 'That sign-in expired or was already used. Try again.', + failed: `Signing in with ${sso} failed. Try again, and tell an administrator if it keeps happening.`, + unavailable: `${sso} could not be reached. Try again in a moment.`, + not_allowed: 'Your account is not allowed to use terdut. Ask an administrator to add you to the right group.', + no_email: `${sso} did not send an email address for you, which terdut needs.`, + email_conflict: 'An account with your email address already exists and could not be linked to this sign-in. Ask an administrator.', + disabled: 'Your account is disabled. Ask an administrator.', + }[code] || `Signing in with ${sso} failed.`; +} + +// Reads how the server can be signed in to. An older server has no such +// endpoint, and one that cannot be asked is treated as offering passwords only: +// the form that always existed is better than a blank page. +async function loadAuthConfig() { + try { + state.auth = await api.authConfig(); + } catch { + /* keep the last answer, or the defaults */ + } + return state.auth; +} + +// The reason the last single sign-on attempt failed, read once at boot. It is +// held here rather than re-read from the address because showLogin runs more +// than once on the way to the form (the 401 from /api/me reaches it through the +// API layer and again through boot's own catch), and only the first would see it. +let ssoErrorCode = null; + +// Takes ?sso_error= off the address, so a reload does not repeat the message. +function takeSSOError() { + const params = new URLSearchParams(location.search); + const code = params.get('sso_error'); + if (code === null) return null; + params.delete('sso_error'); + const query = params.toString(); + history.replaceState(null, '', location.pathname + (query ? `?${query}` : '') + location.hash); + return code; +} + +async function showLogin() { poll.stop(); ui.closeSheet(null); reset(); - $('boot').hidden = true; $('app').hidden = true; - $('login').hidden = false; $('signup-form').hidden = true; + + // Ask before showing anything, so the form does not flash the password + // fields at somebody whose server has turned them off. + const auth = await loadAuthConfig(); + const sso = auth.oidc?.enabled ? auth.oidc : null; + const passwords = auth.password_login !== false; + + const link = $('sso-link'); + link.hidden = !sso; + if (sso) { + link.textContent = `Sign in with ${sso.name || 'SSO'}`; + // Come back to the page that was asked for: a link to /device?code=... has + // to survive the trip through the provider. The server only honours paths + // on this server, and ignores the front page. + const here = location.pathname + location.search; + link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`; + } + $('login-or').hidden = !(sso && passwords); + $('password-login').hidden = !passwords; + + const ssoErr = $('sso-error'); + ssoErr.hidden = ssoErrorCode === null; + if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name); + + $('boot').hidden = true; + $('login').hidden = false; $('login-form').hidden = false; const form = $('login-form'); - form.querySelector('.form-error').hidden = true; + form.querySelector('#password-login .form-error').hidden = true; + if (!passwords) return; // Only offer the door that is open. Somebody without an invite on an // invite-only server should be told, not sent to a form that refuses them. api.signupInfo().then((info) => { @@ -354,9 +431,13 @@ function showLogin() { async function onLogin(e) { e.preventDefault(); const form = e.currentTarget; - const err = form.querySelector('.form-error'); + // Not the first .form-error: that one is the single sign-on message above. + const err = form.querySelector('#password-login .form-error'); const btn = form.querySelector('button[type=submit]'); err.hidden = true; + // Whatever single sign-on said is about the last attempt, not this one. + ssoErrorCode = null; + $('sso-error').hidden = true; btn.disabled = true; try { state.me = await api.login(form.username.value.trim(), form.password.value); @@ -380,6 +461,7 @@ export async function signOut() { } function showApp() { + ssoErrorCode = null; $('boot').hidden = true; $('login').hidden = true; $('app').hidden = false; diff --git a/internal/web/static/js/device.js b/internal/web/static/js/device.js new file mode 100644 index 0000000..9d6a638 --- /dev/null +++ b/internal/web/static/js/device.js @@ -0,0 +1,88 @@ +// Approving a sign-in that a terminal started, at /device?code=XXXX-XXXX. +// +// The terminal (the TUI) shows a code and a link to this page. Whoever opens it +// is already signed in — by the provider or by password, whichever the login +// page offered — and is asked to approve. Approving hands that terminal a +// session for *this* account, so the page names the account and the code, and +// tells anybody who did not start this to refuse. + +import * as api from './api.js'; +import { h, clear } from './ui.js'; +import { state } from './state.js'; +import { navigate } from './app.js'; + +const view = () => document.getElementById('view-device'); + +// What has been decided for the code on screen, so a re-render does not offer +// to approve it a second time. +let outcome = null; // { code, approved } + +export function show() { + render(); +} + +function render() { + const code = new URLSearchParams(location.search).get('code') || ''; + if (!code) return clear(view(), enterCode()); + if (outcome && outcome.code === code) return clear(view(), decided(outcome.approved)); + return clear(view(), confirmCard(code)); +} + +// Reached without a code, for somebody who typed the address by hand. +function enterCode() { + const input = h('input', { + name: 'code', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false', + placeholder: 'XXXX-XXXX', required: true, + }); + const form = h('form', { class: 'card device-card' }, + h('h2', { text: 'Sign in a terminal' }), + h('p', { class: 'muted', text: 'Enter the code the terminal is showing.' }), + h('label', {}, h('span', { text: 'Code' }), input), + h('button', { class: 'btn btn-primary', type: 'submit', text: 'Continue' })); + form.addEventListener('submit', (e) => { + e.preventDefault(); + navigate(`/device?code=${encodeURIComponent(input.value.trim())}`); + }); + return form; +} + +function confirmCard(code) { + const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); + const approve = h('button', { class: 'btn btn-primary', type: 'button', text: 'Approve' }); + const refuse = h('button', { class: 'btn', type: 'button', text: 'Refuse' }); + + const decide = (approved) => async () => { + err.hidden = true; + approve.disabled = refuse.disabled = true; + try { + await (approved ? api.approveDevice(code) : api.denyDevice(code)); + outcome = { code, approved }; + render(); + } catch (ex) { + err.textContent = ex.message; + err.hidden = false; + approve.disabled = refuse.disabled = false; + } + }; + approve.addEventListener('click', decide(true)); + refuse.addEventListener('click', decide(false)); + + return h('div', { class: 'card device-card' }, + h('h2', { text: 'Sign in a terminal?' }), + h('p', {}, 'A terminal is asking to sign in as ', h('strong', { text: state.me.user.username }), + '. Check that this code matches the one it is showing:'), + h('p', { class: 'device-code', text: code }), + h('p', { class: 'muted small', + text: 'Only approve a sign-in you started yourself. Whoever is approved here acts as you.' }), + err, + h('div', { class: 'row-actions' }, approve, refuse)); +} + +function decided(approved) { + return h('div', { class: 'card device-card' }, + h('h2', { text: approved ? 'Approved' : 'Refused' }), + h('p', { class: 'muted', text: approved + ? 'You can go back to your terminal. It signs in within a few seconds.' + : 'That terminal will not be signed in.' }), + h('a', { class: 'btn', href: '/', text: 'Go to the queue' })); +} diff --git a/internal/web/static/js/state.js b/internal/web/static/js/state.js index f14addc..522792a 100644 --- a/internal/web/static/js/state.js +++ b/internal/web/static/js/state.js @@ -5,6 +5,9 @@ import * as api from './api.js'; export const state = { me: null, // { user, has_password } + // How the server can be signed in to. The defaults are an older server's + // answer: passwords, no single sign-on. + auth: { password_login: true, oidc: { enabled: false, name: '' } }, open: [], // the default queue: open, not snoozed teams: [], // the teams the viewer belongs to, each with their role }; diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index e8bf3aa..d6e6edc 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -18,7 +18,7 @@ // than no form, but it is not the thing enforcing anything. import * as api from './api.js'; -import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js'; +import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js'; import { state, currentTeam, users as allUsers, myID } from './state.js'; import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js'; @@ -1058,19 +1058,24 @@ function membersCard() { h('strong', { text: m.username }), m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }), m.problem && h('div', { class: 'target-problem', text: m.problem })), - h('td', { class: 'muted small', text: m.role }), + h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()), h('td', { class: 'muted small' }, shiftCell(m)), h('td', { class: 'muted small' }, timeCell(m.last_active_at)), h('td', { class: 'muted small' }, h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), h('td', {}, isOwner() && h('div', { class: 'row-actions' }, h('button', { - class: 'btn-sm', type: 'button', text: 'Edit', onclick: () => openEditMember(m), + class: 'btn-sm', type: 'button', text: 'Edit', + // The server refuses to edit a membership the groups grant. + disabled: m.source === 'oidc', + title: m.source === 'oidc' ? SSO_MANAGED : null, + onclick: () => openEditMember(m), }), h('button', { class: 'btn-sm danger', type: 'button', text: 'Remove', - disabled: lastOwner, - title: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null, + disabled: lastOwner || m.source === 'oidc', + title: m.source === 'oidc' ? SSO_MANAGED + : lastOwner ? 'A team needs an owner. Make somebody else one first.' : null, onclick: async () => { if (!(await confirm({ title: `Remove ${m.username}?`, diff --git a/internal/web/static/js/ui.js b/internal/web/static/js/ui.js index 00c8f3e..7981e24 100644 --- a/internal/web/static/js/ui.js +++ b/internal/web/static/js/ui.js @@ -170,6 +170,15 @@ export function badge(text, cls = '') { return h('span', { class: `badge ${cls}`, text }); } +// Access granted by the identity provider's groups. The server refuses to edit +// it by hand (it would be undone at the next sign-in), so the controls that +// would try are disabled with this as their reason. +export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.'; + +export function ssoBadge(text = 'SSO') { + return h('span', { class: 'row-team sso', title: SSO_MANAGED, text }); +} + export function labelChip(k, v) { return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v })); }