Files
stackpilot/backend/services/oidc_service.py
T
menzeljandClaude Opus 5 8edea8f971
CI / check (push) Successful in 13m35s
CI / build-and-push (push) Successful in 2m41s
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>
2026-09-18 01:29:15 +02:00

357 lines
13 KiB
Python

"""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