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>
357 lines
13 KiB
Python
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
|