diff --git a/README.md b/README.md index 399eb69..8927671 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,47 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > complete. +## Upgrading to 0.60.0 — single sign-on + +**Settings → Single sign-on (OIDC)** configures an identity provider — Authentik, +Keycloak, Authelia, Pocket ID, Zitadel, anything that publishes +`/.well-known/openid-configuration`. Fill in the issuer, client id and secret, +copy the redirect URI it shows you into your provider, and the login screen +grows a button with whatever label you chose. + +Everything is configured from the UI. No environment variables, no restart to +fix a typo in a client id, and a *Test connection* button that actually fetches +the provider's metadata and reports which endpoints and how many signing keys it +publishes. + +**The password form never goes away.** A provider outage must not lock you out +of your own Docker host, so local accounts keep working beside SSO. + +**Accounts.** The first successful sign-in creates an account (switchable off), +matched on the provider's `sub` — the only identifier a provider promises is +stable — so renaming somebody upstream keeps them the same account. Somebody who +already had a local account is *linked*, not duplicated, and keeps their role. +Set an admin claim and value (e.g. `groups` = `stackpilot-admins`) and the +provider decides who is an admin, on every sign-in, in both directions. Disabling +an account in StackPilot outranks the provider. + +**What is verified.** Authorization Code with PKCE, a single-use state, and a +nonce bound to the login. The ID token's signature is checked against the +provider's published keys, along with its issuer, audience and expiry — an +unsigned or mis-signed token is refused, because "the provider said so" is only +worth something if it really was the provider. + +**No token ever appears in a URL.** The callback sets the same httpOnly refresh +cookie a password login sets and redirects to the app, which trades it for an +access token on boot exactly as it already did. Nothing lands in a proxy log or +the browser history. + +Two fixes ride along: `verify_password` now returns false for an unparseable +stored hash instead of raising (a password attempt against an SSO account was a +500), and the bundled nginx forwards `X-Forwarded-Proto`, without which uvicorn +saw plain HTTP behind TLS — which made the derived redirect URI wrong and cost +the refresh cookie its `Secure` flag. + ## Upgrading to 0.59.0 — CVE scanning The Images page can tell you what is wrong with the images you are running. @@ -456,6 +497,11 @@ it is what your saved destination credentials are encrypted with. compose** converter. - **Dashboard** — system resource bar, stack grid with quick actions, and a recent-activity audit feed. +- **Single sign-on (OIDC)** — configured entirely from Settings, with a button on + the login screen, auto-created or linked accounts, and optional claim-based + admin mapping. Authorization Code + PKCE, verified ID tokens, and the session + handed back as the same httpOnly cookie a password login uses. The password + form stays, so a provider outage cannot lock you out. - **CVE scanning** — Trivy runs as a throwaway container against the images your stacks use; the Images page shows critical/high counts, how many findings are fixable, and the full advisory list per image. A failed scan reports the @@ -1021,6 +1067,16 @@ GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache) GET /api/dashboard/summary (containers, uptime series, ops activity) ``` +### Single sign-on endpoints + +``` +GET /api/auth/oidc/status (public: whether to show the button, and its label) +GET /api/auth/oidc/login (public: redirects to the provider) +GET /api/auth/oidc/callback (public: the provider sends the browser back here) +GET /api/auth/oidc/config PUT … (admin; the client secret is never returned) +POST /api/auth/oidc/test (fetch the provider's metadata and report it) +``` + ### CVE scanning endpoints ``` @@ -1086,6 +1142,9 @@ GET /api/stacks/icons/logo/{slug} (one catalog logo, served from our cache) (Fernet, key derived from `SECRET_KEY`). The generated Docker CLI config that carries them for `compose pull` is written 0600 inside the data volume. - Login is rate-limited (10/min/IP). +- Single sign-on verifies the ID token's signature against the provider's + published keys, plus issuer, audience, expiry and a per-login nonce; the state + is single-use. Accounts created through it hold no usable password hash. - Compose files are backed up to `*.bak` before every overwrite. - Generated YAML never includes the obsolete `version:` field and uses Compose v2 (`docker compose`) syntax. diff --git a/backend/auth.py b/backend/auth.py index 50a2304..ae05684 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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 --- diff --git a/backend/main.py b/backend/main.py index 4d0fc57..90d9a1b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 6a4dbdc..4c022ab 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -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", ] diff --git a/backend/models/oidc.py b/backend/models/oidc.py new file mode 100644 index 0000000..a8743f8 --- /dev/null +++ b/backend/models/oidc.py @@ -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 = "" diff --git a/backend/models/user.py b/backend/models/user.py index 991e65e..1f07ce1 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -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): diff --git a/backend/routers/oidc.py b/backend/routers/oidc.py new file mode 100644 index 0000000..3c89b93 --- /dev/null +++ b/backend/routers/oidc.py @@ -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), + } diff --git a/backend/services/oidc_service.py b/backend/services/oidc_service.py new file mode 100644 index 0000000..f8e9e0f --- /dev/null +++ b/backend/services/oidc_service.py @@ -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 diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py new file mode 100644 index 0000000..41bf6b6 --- /dev/null +++ b/backend/tests/test_oidc.py @@ -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 + ) diff --git a/backend/tests/test_route_authorization.py b/backend/tests/test_route_authorization.py index 252b593..e9f2c1b 100644 --- a/backend/tests/test_route_authorization.py +++ b/backend/tests/test_route_authorization.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 8470c5e..6a975c7 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.59.0" +APP_VERSION = "0.60.0" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index cd4945d..3e419b9 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -15,6 +15,10 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # Without this uvicorn sees plain http even behind TLS, which would make + # the OIDC redirect URI it derives wrong and the refresh cookie miss its + # Secure flag. + proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 600s; # Stack backups (incl. volume data) can be large in both directions. client_max_body_size 0; diff --git a/frontend/package.json b/frontend/package.json index 252bf59..727543e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.59.0", + "version": "0.60.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/oidc.ts b/frontend/src/api/oidc.ts new file mode 100644 index 0000000..c4db5b3 --- /dev/null +++ b/frontend/src/api/oidc.ts @@ -0,0 +1,46 @@ +import api from "./client"; + +const base = "/api/auth/oidc"; + +export interface OidcConfig { + enabled: boolean; + issuer: string; + client_id: string; + /** The secret itself is never sent to the browser. */ + has_client_secret: boolean; + scopes: string; + button_label: string; + username_claim: string; + auto_create: boolean; + default_role: "admin" | "user"; + admin_claim: string; + admin_value: string; + redirect_uri: string; + /** What the callback URL would be if redirect_uri is left blank. */ + suggested_redirect_uri: string; +} + +export interface OidcConfigInput extends Omit< + OidcConfig, + "has_client_secret" | "suggested_redirect_uri" +> { + /** Omit to keep the stored secret. */ + client_secret?: string; +} + +export interface OidcProbe { + ok: boolean; + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + signing_keys: number; + scopes_supported: string[]; + redirect_uri: string; +} + +export const oidcApi = { + config: () => api.get(`${base}/config`).then((r) => r.data), + save: (body: OidcConfigInput) => + api.put(`${base}/config`, body).then((r) => r.data), + test: () => api.post(`${base}/test`).then((r) => r.data), +}; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index be5d9f0..68639ca 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import axios from "axios"; -import { Ship } from "lucide-react"; +import { Ship, KeyRound } from "lucide-react"; import { Button, Card, Input } from "@/components/ui"; import { useAuthStore } from "@/store/auth"; import { apiErrorMessage } from "@/api/client"; @@ -15,6 +15,7 @@ export function Login() { const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [loading, setLoading] = useState(false); + const [sso, setSso] = useState<{ enabled: boolean; button_label: string } | null>(null); useEffect(() => { if (accessToken) navigate("/"); @@ -22,8 +23,21 @@ export function Login() { .get("/api/auth/needs-setup") .then((r) => setNeedsSetup(r.data.needs_setup)) .catch(() => {}); + axios + .get("/api/auth/oidc/status") + .then((r) => setSso(r.data)) + .catch(() => {}); }, [accessToken, navigate]); + // The callback sends failures back here rather than rendering an error page + // of its own, so the message lands next to the form you can still use. + useEffect(() => { + const message = new URLSearchParams(window.location.search).get("sso_error"); + if (!message) return; + toast.error(message); + window.history.replaceState({}, "", "/login"); + }, []); + const submit = async (e: React.FormEvent) => { e.preventDefault(); if (needsSetup && password !== confirm) { @@ -58,6 +72,24 @@ export function Login() { {needsSetup ? "Create your admin account" : "Sign in to continue"}

+ {sso?.enabled && !needsSetup && ( +
+ {/* A plain link, not a fetch: the browser has to follow the + redirect to the provider itself. */} + + + +
+ + or + +
+
+ )} +
setConfirm(e.target.value)} /> )} -
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1ea7ad0..94e05a9 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -17,6 +17,7 @@ import { Terminal, Copy, Check, + LogIn, } from "lucide-react"; import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; @@ -33,6 +34,7 @@ import { type RegistryInput, } from "@/api/registries"; import { tokensApi, type ApiToken, type ApiTokenCreated } from "@/api/tokens"; +import { oidcApi, type OidcConfig } from "@/api/oidc"; import { schedulesApi, type BackupSchedule } from "@/api/schedules"; import { stacksApi } from "@/api/stacks"; import { apiErrorMessage } from "@/api/client"; @@ -63,6 +65,7 @@ export function Settings() { + ); @@ -1206,6 +1209,225 @@ function TokenForm({ /* Users */ /* -------------------------------------------------------------------------- */ +/* -------------------------------------------------------------------------- */ +/* Single sign-on */ +/* -------------------------------------------------------------------------- */ + +function OidcSection() { + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ["oidc-config"], queryFn: oidcApi.config }); + + return ( +
+ }>Single sign-on (OIDC) + {isLoading || !data ? ( + + ) : ( + qc.invalidateQueries({ queryKey: ["oidc-config"] })} + /> + )} +
+ ); +} + +function OidcForm({ config, onSaved }: { config: OidcConfig; onSaved: () => void }) { + const [form, setForm] = useState({ ...config }); + const [secret, setSecret] = useState(""); + const [copied, setCopied] = useState(false); + const set = (key: K, value: OidcConfig[K]) => + setForm((f) => ({ ...f, [key]: value })); + + const redirect = form.redirect_uri.trim() || config.suggested_redirect_uri; + + const save = useMutation({ + mutationFn: () => + oidcApi.save({ + enabled: form.enabled, + issuer: form.issuer, + client_id: form.client_id, + client_secret: secret || undefined, + scopes: form.scopes, + button_label: form.button_label, + username_claim: form.username_claim, + auto_create: form.auto_create, + default_role: form.default_role, + admin_claim: form.admin_claim, + admin_value: form.admin_value, + redirect_uri: form.redirect_uri, + }), + onSuccess: () => { + toast.success("Saved"); + setSecret(""); + onSaved(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const probe = useMutation({ + mutationFn: oidcApi.test, + onSuccess: (r) => + toast.success( + `Reached ${r.issuer} — ${r.signing_keys} signing key(s) published` + ), + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const copyRedirect = async () => { + try { + await navigator.clipboard.writeText(redirect); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Could not copy — select the text and copy it manually"); + } + }; + + return ( + + + +
+ + + + + +
+ + + +
+ + + + +
+ + + +

+ The password form stays on the login screen either way, so a provider + outage cannot lock you out of your own Docker host. +

+ +
+ + +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Users */ +/* -------------------------------------------------------------------------- */ + function UsersSection() { const qc = useQueryClient(); const me = useAuthStore((s) => s.user);