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
+2 -1
View File
@@ -6,6 +6,7 @@ from models.backup_destination import BackupDestination
from models.backup_schedule import BackupSchedule
from models.git_source import GitSource
from models.image_scan import ImageScan
from models.oidc import OidcConfig, OidcState
from models.registry import Registry
from models.runtime_state import ImageStatus, LoginAttempt, StackLock
from models.setting import Setting, Webhook
@@ -15,5 +16,5 @@ from models.user import User
__all__ = [
"User", "Stack", "AuditLog", "Setting", "Webhook",
"BackupDestination", "BackupSchedule", "AutoUpdate",
"StackLock", "ImageStatus", "LoginAttempt", "Registry", "ApiToken", "GitSource", "ImageScan",
"StackLock", "ImageStatus", "LoginAttempt", "Registry", "ApiToken", "GitSource", "ImageScan", "OidcConfig", "OidcState",
]
+102
View File
@@ -0,0 +1,102 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class OidcConfig(SQLModel, table=True):
"""Single-row configuration for signing in through an identity provider.
Deliberately in the database rather than the environment: the point of the
feature request was to configure it from Settings, and a homelab admin
should not have to edit a compose file and restart to fix a typo in a
client id.
"""
#: Always 1. A second provider would need a real table and a picker on the
#: login screen; one is what installs of this size actually use.
id: Optional[int] = Field(default=1, primary_key=True)
enabled: bool = False
issuer: str = ""
client_id: str = ""
client_secret: str = "" # encrypted
scopes: str = "openid profile email"
button_label: str = "Sign in with SSO"
#: Claim to take the StackPilot username from; falls back to email, then sub.
username_claim: str = "preferred_username"
#: Create an account the first time somebody signs in successfully.
auto_create: bool = True
default_role: str = "user"
#: A claim/value pair that grants admin, e.g. groups = "stackpilot-admins".
admin_claim: str = ""
admin_value: str = ""
#: Exact redirect URI registered with the provider. Empty derives it from
#: the request, which is right until a reverse proxy rewrites the scheme.
redirect_uri: str = ""
created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now)
class OidcState(SQLModel, table=True):
"""One in-flight login.
A row rather than a dict because it has to survive a worker restart between
the redirect out and the redirect back, and because two workers must agree
that a given ``state`` may be used exactly once.
"""
state: str = Field(primary_key=True)
verifier: str
nonce: str
redirect_uri: str
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class OidcStatus(SQLModel):
"""What the login screen is allowed to know before anyone signs in."""
enabled: bool
button_label: str
class OidcConfigRead(SQLModel):
enabled: bool
issuer: str
client_id: str
has_client_secret: bool
scopes: str
button_label: str
username_claim: str
auto_create: bool
default_role: str
admin_claim: str
admin_value: str
redirect_uri: str
#: What the redirect URI would be if left empty — shown so it can be pasted
#: into the provider without guessing.
suggested_redirect_uri: str
class OidcConfigWrite(SQLModel):
enabled: bool = False
issuer: str = ""
client_id: str = ""
#: Omitted keeps the stored secret.
client_secret: Optional[str] = None
scopes: str = "openid profile email"
button_label: str = "Sign in with SSO"
username_claim: str = "preferred_username"
auto_create: bool = True
default_role: str = "user"
admin_claim: str = ""
admin_value: str = ""
redirect_uri: str = ""
+11
View File
@@ -23,6 +23,15 @@ class User(SQLModel, table=True):
#: Without it a password reset left the old tokens usable for their full
#: lifetime (up to 30 days for a refresh token).
token_version: int = Field(default=1)
#: Subject claim of the identity provider this account signs in through.
#: The only identifier a provider promises is stable, so it — not the
#: username — is what an OIDC login matches on.
oidc_subject: Optional[str] = Field(default=None, index=True)
@property
def oidc(self) -> bool:
"""Read by ``UserRead`` so the UI can label the account. Not a column."""
return bool(self.oidc_subject)
# --- API schemas ---
@@ -33,6 +42,8 @@ class UserRead(SQLModel):
username: str
role: str
is_active: bool
#: True when this account is linked to the identity provider.
oidc: bool = False
class UserCreate(SQLModel):