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
+13 -1
View File
@@ -7,6 +7,7 @@ from typing import Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib import exc
from passlib.context import CryptContext
from sqlmodel import Session, select
@@ -27,7 +28,18 @@ def hash_password(password: str) -> str:
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
"""Does this password match the stored hash?
A hash passlib cannot parse means "no", not an exception. Accounts that
sign in through the identity provider deliberately store an unusable
marker instead of a hash (see services/oidc_service.py), and a password
attempt against one of those has to be a clean rejection rather than a 500
— which is also what any other corrupt row deserves.
"""
try:
return pwd_context.verify(plain, hashed)
except (ValueError, TypeError, exc.PasslibSecurityError, exc.UnknownHashError):
return False
# --- token helpers ---
+2
View File
@@ -26,6 +26,7 @@ from routers import (
git,
images,
networks,
oidc,
ports,
registries,
schedules,
@@ -140,6 +141,7 @@ app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(git.router)
app.include_router(git.hook_router)
app.include_router(oidc.router)
app.include_router(tokens.router)
app.include_router(registries.router)
app.include_router(secrets.router)
+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):
+247
View File
@@ -0,0 +1,247 @@
"""Single sign-on through an OpenID Connect provider.
Three of these routes are unauthenticated, and have to be: they are how somebody
who is *not* signed in gets signed in. They are the login surface, and each one
is deliberately quiet about what it knows — see the individual docstrings.
"""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session
from auth import require_admin, require_admin_session
from database import get_session
from models.oidc import OidcConfig, OidcConfigRead, OidcConfigWrite, OidcStatus
from models.user import User
from services import audit_service, crypto_service, oidc_service
router = APIRouter(prefix="/api/auth/oidc", tags=["auth"])
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _suggested_redirect(request: Request) -> str:
"""The callback URL as this request makes it look.
Correct behind a proxy only if it forwards the scheme — which is why the
field is configurable and the UI shows this as a suggestion, not a promise.
"""
return str(request.base_url).rstrip("/") + "/api/auth/oidc/callback"
def _effective_redirect(config: OidcConfig, request: Request) -> str:
return (config.redirect_uri or "").strip() or _suggested_redirect(request)
# --------------------------------------------------------------------------- #
# The login surface (unauthenticated)
# --------------------------------------------------------------------------- #
@router.get("/status", response_model=OidcStatus)
def status(session: Session = Depends(get_session)) -> OidcStatus:
"""Whether to show the button, and what to write on it.
Public because the login screen asks before anybody has signed in. It says
nothing about the provider — no issuer, no client id — so an unauthenticated
caller learns only that SSO is on, which the button would tell them anyway.
"""
config = oidc_service.get_config(session)
enabled = oidc_service.is_enabled(session)
return OidcStatus(
enabled=enabled,
button_label=(config.button_label if config else "") or "Sign in with SSO",
)
@router.get("/login")
async def start_login(request: Request, session: Session = Depends(get_session)):
"""Redirect the browser to the provider."""
config = oidc_service.get_config(session)
if not config or not oidc_service.is_enabled(session):
raise HTTPException(status_code=404, detail="Single sign-on is not configured")
state = oidc_service.begin(session, _effective_redirect(config, request))
try:
url = await oidc_service.authorize_url(config, state)
except oidc_service.OidcError as exc:
return _fail(request, str(exc))
return RedirectResponse(url, status_code=302)
@router.get("/callback")
async def callback(
request: Request,
code: str | None = Query(default=None),
state: str | None = Query(default=None),
error: str | None = Query(default=None),
error_description: str | None = Query(default=None),
session: Session = Depends(get_session),
):
"""Where the provider sends the browser back.
Ends by setting the same httpOnly refresh cookie a password login sets and
redirecting to the app — which trades it for an access token on boot. No
token is ever placed in a URL, where a proxy log or the browser's history
would keep it.
"""
from routers.auth import _issue
if error:
return _fail(request, error_description or error)
config = oidc_service.get_config(session)
if not config or not oidc_service.is_enabled(session):
raise HTTPException(status_code=404, detail="Single sign-on is not configured")
# Consumed here, so a replayed callback finds nothing.
stored = oidc_service.take_state(session, state or "")
if not stored:
return _fail(request, "This sign-in link has expired or was already used")
if not code:
return _fail(request, "The provider returned no authorization code")
try:
tokens = await oidc_service.exchange(config, code, stored)
claims = await oidc_service.verify_id_token(config, tokens, stored)
user = oidc_service.resolve_user(session, config, claims)
except oidc_service.OidcError as exc:
audit_service.record(
session, user="oidc", action="auth.oidc-failed", target=(state or "")[:12],
detail=str(exc)[:300], ip=_ip(request),
)
return _fail(request, str(exc))
response = RedirectResponse("/", status_code=302)
_issue(user, response, request)
audit_service.record(
session, user=user.username, action="auth.oidc-login", target=user.username,
detail=f"role={user.role}", ip=_ip(request),
)
return response
def _fail(request: Request, message: str) -> RedirectResponse:
"""Back to the login screen with something readable in the URL.
The message describes *our* end of the exchange — a bad nonce, an expired
state, a provider error — and never anything the caller did not already
send us.
"""
from urllib.parse import quote
return RedirectResponse(f"/login?sso_error={quote(message[:300])}", status_code=302)
# --------------------------------------------------------------------------- #
# Configuration (admin)
# --------------------------------------------------------------------------- #
def _to_read(config: OidcConfig | None, request: Request) -> OidcConfigRead:
config = config or OidcConfig()
return OidcConfigRead(
enabled=config.enabled,
issuer=config.issuer,
client_id=config.client_id,
has_client_secret=bool(config.client_secret),
scopes=config.scopes,
button_label=config.button_label,
username_claim=config.username_claim,
auto_create=config.auto_create,
default_role=config.default_role,
admin_claim=config.admin_claim,
admin_value=config.admin_value,
redirect_uri=config.redirect_uri,
suggested_redirect_uri=_suggested_redirect(request),
)
@router.get("/config", response_model=OidcConfigRead)
def read_config(
request: Request,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> OidcConfigRead:
return _to_read(oidc_service.get_config(session), request)
@router.put("/config", response_model=OidcConfigRead)
def write_config(
body: OidcConfigWrite,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin_session),
) -> OidcConfigRead:
"""Save the provider settings.
Needs a real session rather than an API token, like every other route that
changes how people authenticate: turning on SSO with a provider you control
is otherwise a way to grant yourself a permanent second door.
"""
config = oidc_service.get_config(session) or OidcConfig(id=1)
if body.enabled and not (body.issuer.strip() and body.client_id.strip()):
raise HTTPException(
status_code=400,
detail="An issuer URL and a client id are required to enable single sign-on",
)
if body.default_role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Default role must be admin or user")
config.enabled = body.enabled
config.issuer = body.issuer.strip().rstrip("/")
config.client_id = body.client_id.strip()
if body.client_secret:
config.client_secret = crypto_service.encrypt(body.client_secret)
config.scopes = body.scopes.strip() or "openid profile email"
config.button_label = body.button_label.strip() or "Sign in with SSO"
config.username_claim = body.username_claim.strip() or "preferred_username"
config.auto_create = body.auto_create
config.default_role = body.default_role
config.admin_claim = body.admin_claim.strip()
config.admin_value = body.admin_value.strip()
config.redirect_uri = body.redirect_uri.strip()
config.updated_at = datetime.now(timezone.utc)
session.add(config)
session.commit()
session.refresh(config)
audit_service.record(
session, user=user.username, action="settings.oidc", target=config.issuer or "-",
detail=f"enabled={config.enabled}", ip=_ip(request),
)
return _to_read(config, request)
@router.post("/test")
async def test_config(
request: Request,
session: Session = Depends(get_session),
_user: User = Depends(require_admin),
) -> dict:
"""Fetch the provider's metadata and report what was found.
Checks the half that can be checked without a browser: that the issuer is
reachable, that it publishes the endpoints the flow needs, and that it
publishes signing keys.
"""
config = oidc_service.get_config(session)
if not config or not config.issuer:
raise HTTPException(status_code=400, detail="Set an issuer URL first")
try:
document = await oidc_service.discover(config.issuer, force=True)
keys = await oidc_service._signing_keys(document, force=True)
except oidc_service.OidcError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"ok": True,
"issuer": document.get("issuer"),
"authorization_endpoint": document.get("authorization_endpoint"),
"token_endpoint": document.get("token_endpoint"),
"signing_keys": len(keys.get("keys") or []),
"scopes_supported": document.get("scopes_supported") or [],
"redirect_uri": _effective_redirect(config, request),
}
+356
View File
@@ -0,0 +1,356 @@
"""Signing in through an OpenID Connect provider.
Authorization Code flow with PKCE, which is the current recommendation even for
a confidential client: the code is useless to anyone who intercepts it without
the verifier that never left this process.
The part worth understanding is how the session is handed back to the browser.
The callback does *not* put a token in the URL. It sets the same httpOnly
refresh cookie a password login sets and redirects to the app, which already
trades that cookie for an access token on boot — so an OIDC session is exactly
a normal StackPilot session, and no token is ever written somewhere a proxy log
or a browser history could keep it.
What this trusts, and what it verifies: the ID token's signature against the
provider's published keys, its issuer, its audience, its expiry, and the nonce
minted for this particular login. An unsigned or mis-signed token is refused —
"the provider said so" is only worth anything if it really was the provider.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import urlencode
import httpx
from jose import jwt
from jose.exceptions import JWTError
from sqlmodel import Session, select
from models.oidc import OidcConfig, OidcState
from models.user import User
from services import crypto_service
logger = logging.getLogger("stackpilot.oidc")
TIMEOUT = httpx.Timeout(15.0)
#: A login has this long to come back from the provider. Long enough to type a
#: password and answer an MFA prompt, short enough that an abandoned state row
#: is not a lasting foothold.
STATE_TTL = timedelta(minutes=15)
#: Discovery documents and signing keys change rarely; refetching them on every
#: login would put the provider in the hot path of every sign-in.
_DISCOVERY_TTL = 3600.0
_cache: dict[str, tuple[float, dict]] = {}
class OidcError(Exception):
"""Anything that stops a sign-in, phrased for the person who has to fix it."""
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
def get_config(session: Session) -> Optional[OidcConfig]:
return session.get(OidcConfig, 1)
def is_enabled(session: Session) -> bool:
config = get_config(session)
return bool(config and config.enabled and config.issuer and config.client_id)
def client_secret(config: OidcConfig) -> str:
try:
return crypto_service.decrypt(config.client_secret or "")
except crypto_service.DecryptError as exc:
raise OidcError(str(exc)) from exc
# --------------------------------------------------------------------------- #
# Provider metadata
# --------------------------------------------------------------------------- #
async def discover(issuer: str, force: bool = False) -> dict:
"""The provider's OpenID configuration document."""
url = issuer.rstrip("/") + "/.well-known/openid-configuration"
hit = _cache.get(url)
if hit and not force and time.time() - hit[0] < _DISCOVERY_TTL:
return hit[1]
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
response = await client.get(url)
response.raise_for_status()
document = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise OidcError(f"Could not read {url}: {exc}") from exc
for required in ("authorization_endpoint", "token_endpoint", "issuer"):
if not document.get(required):
raise OidcError(f"The provider's metadata is missing '{required}'")
_cache[url] = (time.time(), document)
return document
async def _signing_keys(document: dict, force: bool = False) -> dict:
url = document.get("jwks_uri")
if not url:
raise OidcError("The provider publishes no jwks_uri, so tokens cannot be verified")
hit = _cache.get(url)
if hit and not force and time.time() - hit[0] < _DISCOVERY_TTL:
return hit[1]
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
response = await client.get(url)
response.raise_for_status()
keys = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise OidcError(f"Could not read the provider's signing keys: {exc}") from exc
_cache[url] = (time.time(), keys)
return keys
# --------------------------------------------------------------------------- #
# Starting a login
# --------------------------------------------------------------------------- #
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def begin(session: Session, redirect_uri: str) -> OidcState:
"""Mint and store the state, PKCE verifier and nonce for one login."""
prune_states(session)
row = OidcState(
state=_b64url(secrets.token_bytes(24)),
verifier=_b64url(secrets.token_bytes(48)),
nonce=_b64url(secrets.token_bytes(24)),
redirect_uri=redirect_uri,
)
session.add(row)
session.commit()
session.refresh(row)
return row
def take_state(session: Session, state: str) -> Optional[OidcState]:
"""Consume a state row. Returns None if unknown, used already, or expired.
Deleting it here is what makes a login single-use: a replayed callback finds
nothing and is refused.
"""
row = session.get(OidcState, state or "")
if not row:
return None
session.delete(row)
session.commit()
created = row.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - created > STATE_TTL:
return None
return row
def prune_states(session: Session) -> int:
cutoff = datetime.now(timezone.utc) - STATE_TTL
gone = 0
for row in session.exec(select(OidcState)).all():
created = row.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if created < cutoff:
session.delete(row)
gone += 1
if gone:
session.commit()
return gone
async def authorize_url(config: OidcConfig, state: OidcState) -> str:
document = await discover(config.issuer)
challenge = _b64url(hashlib.sha256(state.verifier.encode("ascii")).digest())
query = {
"response_type": "code",
"client_id": config.client_id,
"redirect_uri": state.redirect_uri,
"scope": config.scopes or "openid profile email",
"state": state.state,
"nonce": state.nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return f"{document['authorization_endpoint']}?{urlencode(query)}"
# --------------------------------------------------------------------------- #
# Finishing a login
# --------------------------------------------------------------------------- #
async def exchange(config: OidcConfig, code: str, state: OidcState) -> dict:
"""Trade the authorization code for tokens."""
document = await discover(config.issuer)
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": state.redirect_uri,
"client_id": config.client_id,
"code_verifier": state.verifier,
}
secret = client_secret(config)
auth = (config.client_id, secret) if secret else None
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=TIMEOUT) as client:
response = await client.post(document["token_endpoint"], data=data, auth=auth)
if response.status_code >= 400:
# The provider's own error is far more useful than a generic one.
raise OidcError(_token_error(response))
payload = response.json()
except httpx.HTTPError as exc:
raise OidcError(f"Could not reach the provider's token endpoint: {exc}") from exc
except ValueError as exc:
raise OidcError("The provider's token response was not JSON") from exc
if not payload.get("id_token"):
raise OidcError("The provider returned no ID token — is 'openid' in the scopes?")
return payload
def _token_error(response: httpx.Response) -> str:
try:
body = response.json()
detail = body.get("error_description") or body.get("error") or ""
except ValueError:
detail = response.text[:200]
return f"The provider rejected the login ({response.status_code}): {detail}".strip()
async def verify_id_token(config: OidcConfig, tokens: dict, state: OidcState) -> dict:
"""Validate the ID token and return its claims."""
document = await discover(config.issuer)
id_token = tokens["id_token"]
try:
header = jwt.get_unverified_header(id_token)
except JWTError as exc:
raise OidcError("The ID token is malformed") from exc
keys = await _signing_keys(document)
key = _pick_key(keys, header.get("kid"))
if key is None:
# A provider that has rotated its keys since we cached them.
keys = await _signing_keys(document, force=True)
key = _pick_key(keys, header.get("kid"))
if key is None:
raise OidcError("The ID token was signed with a key the provider does not publish")
try:
claims = jwt.decode(
id_token,
key,
algorithms=[header.get("alg", "RS256")],
audience=config.client_id,
issuer=document["issuer"],
access_token=tokens.get("access_token"),
options={"leeway": 60},
)
except JWTError as exc:
raise OidcError(f"The ID token failed verification: {exc}") from exc
if claims.get("nonce") != state.nonce:
# Without this an attacker could replay an ID token obtained elsewhere.
raise OidcError("The ID token's nonce does not match this login")
return claims
def _pick_key(keys: dict, kid: Optional[str]) -> Optional[dict]:
candidates = keys.get("keys") or []
if kid:
for key in candidates:
if key.get("kid") == kid:
return key
return None
return candidates[0] if len(candidates) == 1 else None
# --------------------------------------------------------------------------- #
# Turning claims into a user
# --------------------------------------------------------------------------- #
def _username_from(config: OidcConfig, claims: dict) -> str:
for claim in (config.username_claim or "preferred_username", "email", "sub"):
value = claims.get(claim)
if isinstance(value, str) and value.strip():
return value.strip()
raise OidcError("The ID token carries no usable username claim")
def _is_admin(config: OidcConfig, claims: dict) -> Optional[bool]:
"""True/False from the configured claim, or None when no mapping is set."""
if not config.admin_claim or not config.admin_value:
return None
value = claims.get(config.admin_claim)
if isinstance(value, str):
return value == config.admin_value
if isinstance(value, (list, tuple)):
return config.admin_value in value
return False
def resolve_user(session: Session, config: OidcConfig, claims: dict) -> User:
"""Find, link or create the account this login belongs to."""
subject = str(claims.get("sub") or "")
if not subject:
raise OidcError("The ID token has no subject")
username = _username_from(config, claims)
admin = _is_admin(config, claims)
# The subject is the only identifier the provider promises is stable, so it
# wins over the username — somebody renamed upstream stays the same account.
user = session.exec(select(User).where(User.oidc_subject == subject)).first()
if user is None:
existing = session.exec(select(User).where(User.username == username)).first()
if existing is not None:
# First OIDC sign-in for somebody who already had a local account:
# link them rather than creating a duplicate.
existing.oidc_subject = subject
user = existing
elif config.auto_create:
user = User(
username=username,
# No usable password: this account signs in through the provider.
# A random unusable hash, never a blank one that might verify.
hashed_password="!oidc:" + secrets.token_urlsafe(16),
role=config.default_role if config.default_role in ("admin", "user") else "user",
oidc_subject=subject,
)
session.add(user)
else:
raise OidcError(
f"No StackPilot account for '{username}', and automatic creation is off"
)
if not user.is_active:
# Disabling an account here has to outrank the provider's opinion.
raise OidcError(f"The account '{user.username}' is disabled")
if admin is not None:
role = "admin" if admin else "user"
if user.role != role:
logger.info("OIDC role mapping: %s -> %s", user.username, role)
user.role = role
session.add(user)
session.commit()
session.refresh(user)
return user
+470
View File
@@ -0,0 +1,470 @@
"""Signing in through an identity provider.
No provider is contacted: a throwaway RSA key stands in for one, so ID tokens
can be minted here and the verification exercised for real rather than mocked
away. That is the point — the whole feature rests on "the provider said so"
being worth something, which it only is if the signature is actually checked.
The cases that matter are the ones where a token looks fine and must still be
refused: signed by the wrong key, issued to somebody else, from another issuer,
replayed with an old nonce, or arriving on a state that was already used.
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
import pytest
from jose import jwt
from sqlmodel import Session, delete, select
ISSUER = "https://idp.test/realms/homelab"
CLIENT_ID = "stackpilot"
# A 2048-bit RSA key, generated once for these tests and used nowhere else.
KEY = None
WRONG_KEY = None
def _keypair():
"""Build a JWK pair lazily — generating RSA keys is slow enough to matter."""
global KEY, WRONG_KEY
if KEY is None:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def make(kid):
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = private.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
from jose import jwk
public = jwk.construct(
private.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode(),
algorithm="RS256",
).to_dict()
public = {k: (v.decode() if isinstance(v, bytes) else v) for k, v in public.items()}
public["kid"] = kid
public["alg"] = "RS256"
public["use"] = "sig"
return pem, public
KEY = make("test-key-1")
WRONG_KEY = make("test-key-1") # same kid, different key: the nasty case
return KEY, WRONG_KEY
def _id_token(nonce: str, *, key=None, claims=None, kid="test-key-1") -> str:
(good_pem, _), (bad_pem, _) = _keypair()
payload = {
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "idp-subject-1",
"preferred_username": "alice",
"email": "alice@example.test",
"nonce": nonce,
"exp": int(time.time()) + 300,
"iat": int(time.time()),
}
payload.update(claims or {})
return jwt.encode(payload, key or good_pem, algorithm="RS256", headers={"kid": kid})
@pytest.fixture
def svc(db):
from services import oidc_service
oidc_service._cache.clear()
return oidc_service
@pytest.fixture(autouse=True)
def clean(db):
from database import engine
from models.oidc import OidcConfig, OidcState
from models.user import User
def wipe():
with Session(engine) as session:
session.exec(delete(OidcState))
session.exec(delete(OidcConfig))
for user in session.exec(select(User)).all():
if user.oidc_subject or user.username in ("alice", "bob"):
session.delete(user)
session.commit()
wipe()
yield
wipe()
@pytest.fixture
def config(db, svc, monkeypatch):
"""A configured provider, with discovery and JWKS served from memory."""
from database import engine
from models.oidc import OidcConfig
from services import crypto_service
(_pem, public), _ = _keypair()
document = {
"issuer": ISSUER,
"authorization_endpoint": f"{ISSUER}/protocol/openid-connect/auth",
"token_endpoint": f"{ISSUER}/protocol/openid-connect/token",
"jwks_uri": f"{ISSUER}/protocol/openid-connect/certs",
}
async def fake_discover(issuer, force=False):
return document
async def fake_keys(doc, force=False):
return {"keys": [public]}
monkeypatch.setattr(svc, "discover", fake_discover)
monkeypatch.setattr(svc, "_signing_keys", fake_keys)
with Session(engine) as session:
row = OidcConfig(
id=1,
enabled=True,
issuer=ISSUER,
client_id=CLIENT_ID,
client_secret=crypto_service.encrypt("shhh"),
auto_create=True,
default_role="user",
)
session.add(row)
session.commit()
session.refresh(row)
yield session, row
# --------------------------------------------------------------------------- #
# State, PKCE and single use
# --------------------------------------------------------------------------- #
def test_a_login_gets_its_own_state_verifier_and_nonce(svc, config):
session, _row = config
first = svc.begin(session, "https://sp.test/cb")
second = svc.begin(session, "https://sp.test/cb")
assert first.state != second.state
assert first.verifier != second.verifier
assert first.nonce != second.nonce
# PKCE verifiers must be long enough to be worth anything.
assert len(first.verifier) >= 43
def test_the_authorize_url_carries_pkce_and_the_nonce(svc, config):
import asyncio
from urllib.parse import parse_qs, urlparse
session, row = config
state = svc.begin(session, "https://sp.test/cb")
url = asyncio.run(svc.authorize_url(row, state))
query = parse_qs(urlparse(url).query)
assert query["code_challenge_method"] == ["S256"]
# The challenge is the hash, never the verifier itself.
assert query["code_challenge"][0] != state.verifier
assert query["state"] == [state.state]
assert query["nonce"] == [state.nonce]
assert query["redirect_uri"] == ["https://sp.test/cb"]
def test_a_state_can_only_be_used_once(svc, config):
session, _row = config
state = svc.begin(session, "https://sp.test/cb")
assert svc.take_state(session, state.state) is not None
# A replayed callback finds nothing.
assert svc.take_state(session, state.state) is None
def test_an_expired_state_is_refused(svc, config):
from models.oidc import OidcState
session, _row = config
state = svc.begin(session, "https://sp.test/cb")
row = session.get(OidcState, state.state)
row.created_at = datetime.now(timezone.utc) - svc.STATE_TTL - timedelta(minutes=1)
session.add(row)
session.commit()
assert svc.take_state(session, state.state) is None
def test_an_unknown_state_is_refused(svc, config):
session, _row = config
assert svc.take_state(session, "never-issued") is None
# --------------------------------------------------------------------------- #
# ID token verification
# --------------------------------------------------------------------------- #
def _verify(svc, row, state, token, access_token=None):
import asyncio
tokens = {"id_token": token}
if access_token:
tokens["access_token"] = access_token
return asyncio.run(svc.verify_id_token(row, tokens, state))
def test_a_properly_signed_token_is_accepted(svc, config):
session, row = config
state = svc.begin(session, "https://sp.test/cb")
claims = _verify(svc, row, state, _id_token(state.nonce))
assert claims["preferred_username"] == "alice"
def test_a_token_signed_with_the_wrong_key_is_refused(svc, config):
"""The one that matters: same kid, different key."""
session, row = config
state = svc.begin(session, "https://sp.test/cb")
(_good, _), (bad_pem, _) = _keypair()
with pytest.raises(svc.OidcError):
_verify(svc, row, state, _id_token(state.nonce, key=bad_pem))
def test_a_token_for_another_audience_is_refused(svc, config):
session, row = config
state = svc.begin(session, "https://sp.test/cb")
with pytest.raises(svc.OidcError):
_verify(svc, row, state, _id_token(state.nonce, claims={"aud": "some-other-app"}))
def test_a_token_from_another_issuer_is_refused(svc, config):
session, row = config
state = svc.begin(session, "https://sp.test/cb")
with pytest.raises(svc.OidcError):
_verify(svc, row, state, _id_token(state.nonce, claims={"iss": "https://evil.test"}))
def test_an_expired_token_is_refused(svc, config):
session, row = config
state = svc.begin(session, "https://sp.test/cb")
with pytest.raises(svc.OidcError):
_verify(svc, row, state, _id_token(state.nonce, claims={"exp": int(time.time()) - 600}))
def test_a_token_with_the_wrong_nonce_is_refused(svc, config):
"""Replaying an ID token obtained during a different login."""
session, row = config
state = svc.begin(session, "https://sp.test/cb")
with pytest.raises(svc.OidcError) as caught:
_verify(svc, row, state, _id_token("a-nonce-from-somewhere-else"))
assert "nonce" in str(caught.value)
def test_a_token_signed_with_an_unknown_key_is_refused(svc, config):
session, row = config
state = svc.begin(session, "https://sp.test/cb")
with pytest.raises(svc.OidcError):
_verify(svc, row, state, _id_token(state.nonce, kid="some-other-kid"))
def test_garbage_is_refused(svc, config):
session, row = config
state = svc.begin(session, "https://sp.test/cb")
with pytest.raises(svc.OidcError):
_verify(svc, row, state, "not.a.token")
# --------------------------------------------------------------------------- #
# Claims to accounts
# --------------------------------------------------------------------------- #
def test_a_first_sign_in_creates_the_account(svc, config):
session, row = config
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
assert user.username == "alice"
assert user.role == "user"
assert user.oidc_subject == "s1"
def test_the_created_account_cannot_be_signed_into_with_a_password(svc, config):
import auth as auth_mod
session, row = config
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
# Whatever is stored must not verify against anything, empty string included
# — and must fail cleanly rather than raising on an unparseable hash.
assert not auth_mod.verify_password("", user.hashed_password)
assert not auth_mod.verify_password("hunter2", user.hashed_password)
assert auth_mod.authenticate(session, "alice", "") is None
def test_a_password_login_against_an_oidc_account_is_a_clean_401(client, svc, config):
"""Not a 500: an unparseable stored hash used to raise out of passlib."""
session, row = config
svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
response = client.post("/api/auth/login", json={"username": "alice", "password": "x"})
assert response.status_code == 401
def test_an_existing_local_account_is_linked_not_duplicated(svc, config):
import auth as auth_mod
from models.user import User
session, row = config
session.add(
User(username="alice", hashed_password=auth_mod.hash_password("pw"), role="admin")
)
session.commit()
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
assert user.oidc_subject == "s1"
# The local role survives: linking must not quietly demote an admin.
assert user.role == "admin"
assert len(session.exec(select(User).where(User.username == "alice")).all()) == 1
def test_the_subject_wins_over_a_renamed_username(svc, config):
session, row = config
first = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
again = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice-renamed"})
assert again.id == first.id
def test_auto_create_can_be_turned_off(svc, config):
session, row = config
row.auto_create = False
with pytest.raises(svc.OidcError) as caught:
svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "nobody"})
assert "automatic creation is off" in str(caught.value)
def test_a_disabled_account_cannot_sign_in_through_the_provider(svc, config):
session, row = config
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
user.is_active = False
session.add(user)
session.commit()
with pytest.raises(svc.OidcError) as caught:
svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
assert "disabled" in str(caught.value)
def test_the_username_claim_is_configurable_with_fallbacks(svc, config):
session, row = config
row.username_claim = "email"
user = svc.resolve_user(
session, row, {"sub": "s1", "email": "bob@example.test", "preferred_username": "x"}
)
assert user.username == "bob@example.test"
def test_a_group_claim_can_grant_and_remove_admin(svc, config):
session, row = config
row.admin_claim = "groups"
row.admin_value = "stackpilot-admins"
promoted = svc.resolve_user(
session, row, {"sub": "s1", "preferred_username": "alice", "groups": ["stackpilot-admins"]}
)
assert promoted.role == "admin"
# Removed from the group upstream: the next sign-in takes it away again.
demoted = svc.resolve_user(
session, row, {"sub": "s1", "preferred_username": "alice", "groups": ["other"]}
)
assert demoted.role == "user"
def test_without_a_mapping_the_local_role_is_left_alone(svc, config):
session, row = config
user = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
user.role = "admin"
session.add(user)
session.commit()
again = svc.resolve_user(session, row, {"sub": "s1", "preferred_username": "alice"})
assert again.role == "admin"
# --------------------------------------------------------------------------- #
# Through the API
# --------------------------------------------------------------------------- #
def test_the_status_endpoint_is_public_and_says_little(client, config):
response = client.get("/api/auth/oidc/status")
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
# No issuer, no client id: a stranger learns only that SSO exists.
assert set(body) == {"enabled", "button_label"}
def test_status_is_false_when_nothing_is_configured(client, db):
assert client.get("/api/auth/oidc/status").json()["enabled"] is False
def test_the_secret_never_comes_back_out(as_admin, config):
body = as_admin.get("/api/auth/oidc/config").text
assert "shhh" not in body
assert '"has_client_secret":true' in body.replace(" ", "")
def test_saving_without_a_secret_keeps_the_stored_one(as_admin, config, svc):
session, _row = config
saved = as_admin.put(
"/api/auth/oidc/config",
json={
"enabled": True,
"issuer": ISSUER,
"client_id": CLIENT_ID,
"button_label": "Sign in with Authentik",
"default_role": "user",
},
)
assert saved.status_code == 200, saved.text
session.expire_all()
assert svc.client_secret(svc.get_config(session)) == "shhh"
def test_enabling_without_an_issuer_is_refused(as_admin, db):
response = as_admin.put(
"/api/auth/oidc/config",
json={"enabled": True, "issuer": "", "client_id": "", "default_role": "user"},
)
assert response.status_code == 400
def test_a_callback_with_an_unknown_state_lands_back_on_the_login_page(client, config):
response = client.get(
"/api/auth/oidc/callback", params={"code": "x", "state": "made-up"},
follow_redirects=False,
)
assert response.status_code == 302
assert "/login?sso_error=" in response.headers["location"]
def test_a_provider_error_is_passed_through_to_the_login_page(client, config):
response = client.get(
"/api/auth/oidc/callback",
params={"error": "access_denied", "error_description": "User said no"},
follow_redirects=False,
)
assert response.status_code == 302
assert "User%20said%20no" in response.headers["location"]
def test_the_read_only_role_cannot_read_or_change_the_config(as_user, config):
assert as_user.get("/api/auth/oidc/config").status_code == 403
assert (
as_user.put(
"/api/auth/oidc/config",
json={"enabled": False, "issuer": "", "client_id": "", "default_role": "user"},
).status_code
== 403
)
+13
View File
@@ -39,6 +39,19 @@ PUBLIC = {
# Only drops the refresh cookie. Requiring a valid token would mean you
# cannot sign out once the session has already gone stale.
"POST /api/auth/logout",
# Single sign-on: these three ARE the way an unauthenticated person becomes
# an authenticated one, so none of them can sit behind a token.
# status — says only whether SSO is on and what the button reads. No
# issuer, no client id: nothing a stranger could not guess from
# seeing the button itself.
# login — mints a single-use state + PKCE verifier and redirects out.
# callback — validates state, nonce and the ID token's signature against
# the provider's published keys before it issues anything, and
# hands the session back as the same httpOnly cookie a password
# login uses rather than a token in the URL.
"GET /api/auth/oidc/status",
"GET /api/auth/oidc/login",
"GET /api/auth/oidc/callback",
# A Git forge has no StackPilot credentials to present, so this one cannot
# be behind a bearer token. It is authorized instead by an HMAC over the
# request body against a per-stack secret, and answers 404 — not 403 — to
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.59.0"
APP_VERSION = "0.60.0"