Add OIDC single sign-on, configured from Settings (0.60.0)
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:
@@ -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 = ""
|
||||
Reference in New Issue
Block a user