Add OIDC single sign-on, configured from Settings (0.60.0)
CI / check (push) Successful in 13m35s
CI / build-and-push (push) Successful in 2m41s

Authorization Code with PKCE against any provider that publishes a discovery
document, configured entirely from the UI — no environment variables, no restart
to fix a typo in a client id, and a Test button that fetches the provider's
metadata and says what it found.

The best decision here was not writing any new session machinery. The callback
sets the same httpOnly refresh cookie a password login sets and redirects to "/",
and the SPA's existing boot-time restore() trades it for an access token. So an
SSO session *is* a normal session — same revocation, same token_version checks,
same everything — and no token is ever put in a URL fragment or query string
where a proxy log or the browser history would keep it. The alternative everyone
reaches for first, redirecting with #access_token=..., would have been a second
code path and a worse one.

What is actually verified, because "the provider said so" is worth nothing
otherwise: the ID token's signature against the provider's published JWKS
(re-fetched once if the kid is unknown, so key rotation heals itself), issuer,
audience, expiry, and a nonce minted for that specific login. The state row is
deleted when it is consumed, which is what makes a replayed callback fail, and it
lives in the database rather than a dict so it survives the worker restart that
can happen between the redirect out and the redirect back.

Accounts match on sub, not username. It is the only identifier a provider
promises is stable, so somebody renamed upstream stays the same account instead
of silently acquiring a second one. An existing local account with that username
is linked rather than duplicated, and keeps its role — linking must not quietly
demote an admin. Claim-based admin mapping works in both directions: removed from
the group upstream means read-only on the next sign-in.

Two things this turned up that were already broken. verify_password raised
passlib's UnknownHashError on a hash it could not parse, so a password attempt
against an SSO account — which stores a deliberately unusable marker — would have
been a 500 rather than a 401; it now returns false for any unparseable hash,
which is the right answer for a corrupt row too. And the bundled nginx never
forwarded X-Forwarded-Proto, so uvicorn saw plain HTTP behind TLS: the derived
redirect URI came out as http:// and the refresh cookie lost its Secure flag.
Both fixed.

The password form stays on the login screen no matter what. A provider outage
locking you out of the machine that runs your provider is a failure mode worth
designing against.

The authorization matrix made me write down why three routes are public, which
is the right question to be asked: they are the path by which an unauthenticated
person becomes an authenticated one. status deliberately returns only a boolean
and a label — no issuer, no client id — so it tells a stranger nothing the button
would not.

31 tests, with a throwaway RSA key standing in for a provider so verification is
exercised for real rather than mocked: wrong key under the right kid, wrong
audience, wrong issuer, expired, replayed nonce, reused state. Plus an end-to-end
run of the whole flow — redirect, callback, cookie, session, group-mapped admin,
replay refused, password login against the SSO account cleanly refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-18 01:29:15 +02:00
co-authored by Claude Opus 5
parent a1cd14a1cd
commit 8edea8f971
16 changed files with 1589 additions and 7 deletions
+59
View File
@@ -13,6 +13,47 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete.
## Upgrading to 0.60.0 — single sign-on
**Settings → Single sign-on (OIDC)** configures an identity provider — Authentik,
Keycloak, Authelia, Pocket ID, Zitadel, anything that publishes
`/.well-known/openid-configuration`. Fill in the issuer, client id and secret,
copy the redirect URI it shows you into your provider, and the login screen
grows a button with whatever label you chose.
Everything is configured from the UI. No environment variables, no restart to
fix a typo in a client id, and a *Test connection* button that actually fetches
the provider's metadata and reports which endpoints and how many signing keys it
publishes.
**The password form never goes away.** A provider outage must not lock you out
of your own Docker host, so local accounts keep working beside SSO.
**Accounts.** The first successful sign-in creates an account (switchable off),
matched on the provider's `sub` — the only identifier a provider promises is
stable — so renaming somebody upstream keeps them the same account. Somebody who
already had a local account is *linked*, not duplicated, and keeps their role.
Set an admin claim and value (e.g. `groups` = `stackpilot-admins`) and the
provider decides who is an admin, on every sign-in, in both directions. Disabling
an account in StackPilot outranks the provider.
**What is verified.** Authorization Code with PKCE, a single-use state, and a
nonce bound to the login. The ID token's signature is checked against the
provider's published keys, along with its issuer, audience and expiry — an
unsigned or mis-signed token is refused, because "the provider said so" is only
worth something if it really was the provider.
**No token ever appears in a URL.** The callback sets the same httpOnly refresh
cookie a password login sets and redirects to the app, which trades it for an
access token on boot exactly as it already did. Nothing lands in a proxy log or
the browser history.
Two fixes ride along: `verify_password` now returns false for an unparseable
stored hash instead of raising (a password attempt against an SSO account was a
500), and the bundled nginx forwards `X-Forwarded-Proto`, without which uvicorn
saw plain HTTP behind TLS — which made the derived redirect URI wrong and cost
the refresh cookie its `Secure` flag.
## Upgrading to 0.59.0 — CVE scanning
The Images page can tell you what is wrong with the images you are running.
@@ -456,6 +497,11 @@ it is what your saved destination credentials are encrypted with.
compose** converter.
- **Dashboard** — system resource bar, stack grid with quick actions, and a
recent-activity audit feed.
- **Single sign-on (OIDC)** — configured entirely from Settings, with a button on
the login screen, auto-created or linked accounts, and optional claim-based
admin mapping. Authorization Code + PKCE, verified ID tokens, and the session
handed back as the same httpOnly cookie a password login uses. The password
form stays, so a provider outage cannot lock you out.
- **CVE scanning** — Trivy runs as a throwaway container against the images your
stacks use; the Images page shows critical/high counts, how many findings are
fixable, and the full advisory list per image. A failed scan reports the
@@ -1021,6 +1067,16 @@ GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache)
GET /api/dashboard/summary (containers, uptime series, ops activity)
```
### Single sign-on endpoints
```
GET /api/auth/oidc/status (public: whether to show the button, and its label)
GET /api/auth/oidc/login (public: redirects to the provider)
GET /api/auth/oidc/callback (public: the provider sends the browser back here)
GET /api/auth/oidc/config PUT … (admin; the client secret is never returned)
POST /api/auth/oidc/test (fetch the provider's metadata and report it)
```
### CVE scanning endpoints
```
@@ -1086,6 +1142,9 @@ GET /api/stacks/icons/logo/{slug} (one catalog logo, served from our cache)
(Fernet, key derived from `SECRET_KEY`). The generated Docker CLI config that
carries them for `compose pull` is written 0600 inside the data volume.
- Login is rate-limited (10/min/IP).
- Single sign-on verifies the ID token's signature against the provider's
published keys, plus issuer, audience, expiry and a per-login nonce; the state
is single-use. Accounts created through it hold no usable password hash.
- Compose files are backed up to `*.bak` before every overwrite.
- Generated YAML never includes the obsolete `version:` field and uses Compose v2
(`docker compose`) syntax.