Files
stackpilot/backend/routers/oidc.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

248 lines
9.5 KiB
Python

"""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),
}