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
+ The password form stays on the login screen either way, so a provider + outage cannot lock you out of your own Docker host. +
+ +