Add OIDC single sign-on, configured from Settings (0.60.0)
CI / check (push) Successful in 13m35s
CI / build-and-push (push) Successful in 2m41s

Authorization Code with PKCE against any provider that publishes a discovery
document, configured entirely from the UI — no environment variables, no restart
to fix a typo in a client id, and a Test button that fetches the provider's
metadata and says what it found.

The best decision here was not writing any new session machinery. The callback
sets the same httpOnly refresh cookie a password login sets and redirects to "/",
and the SPA's existing boot-time restore() trades it for an access token. So an
SSO session *is* a normal session — same revocation, same token_version checks,
same everything — and no token is ever put in a URL fragment or query string
where a proxy log or the browser history would keep it. The alternative everyone
reaches for first, redirecting with #access_token=..., would have been a second
code path and a worse one.

What is actually verified, because "the provider said so" is worth nothing
otherwise: the ID token's signature against the provider's published JWKS
(re-fetched once if the kid is unknown, so key rotation heals itself), issuer,
audience, expiry, and a nonce minted for that specific login. The state row is
deleted when it is consumed, which is what makes a replayed callback fail, and it
lives in the database rather than a dict so it survives the worker restart that
can happen between the redirect out and the redirect back.

Accounts match on sub, not username. It is the only identifier a provider
promises is stable, so somebody renamed upstream stays the same account instead
of silently acquiring a second one. An existing local account with that username
is linked rather than duplicated, and keeps its role — linking must not quietly
demote an admin. Claim-based admin mapping works in both directions: removed from
the group upstream means read-only on the next sign-in.

Two things this turned up that were already broken. verify_password raised
passlib's UnknownHashError on a hash it could not parse, so a password attempt
against an SSO account — which stores a deliberately unusable marker — would have
been a 500 rather than a 401; it now returns false for any unparseable hash,
which is the right answer for a corrupt row too. And the bundled nginx never
forwarded X-Forwarded-Proto, so uvicorn saw plain HTTP behind TLS: the derived
redirect URI came out as http:// and the refresh cookie lost its Secure flag.
Both fixed.

The password form stays on the login screen no matter what. A provider outage
locking you out of the machine that runs your provider is a failure mode worth
designing against.

The authorization matrix made me write down why three routes are public, which
is the right question to be asked: they are the path by which an unauthenticated
person becomes an authenticated one. status deliberately returns only a boolean
and a label — no issuer, no client id — so it tells a stranger nothing the button
would not.

31 tests, with a throwaway RSA key standing in for a provider so verification is
exercised for real rather than mocked: wrong key under the right kid, wrong
audience, wrong issuer, expired, replayed nonce, reused state. Plus an end-to-end
run of the whole flow — redirect, callback, cookie, session, group-mapped admin,
replay refused, password login against the SSO account cleanly refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-18 01:29:15 +02:00
co-authored by Claude Opus 5
parent a1cd14a1cd
commit 8edea8f971
16 changed files with 1589 additions and 7 deletions
+59
View File
@@ -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.
+13 -1
View File
@@ -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 ---
+2
View File
@@ -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)
+2 -1
View File
@@ -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",
]
+102
View File
@@ -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 = ""
+11
View File
@@ -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):
+247
View File
@@ -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),
}
+356
View File
@@ -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
+470
View File
@@ -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
)
+13
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.59.0"
APP_VERSION = "0.60.0"
+4
View File
@@ -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;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.59.0",
"version": "0.60.0",
"type": "module",
"scripts": {
"dev": "vite",
+46
View File
@@ -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<OidcConfig>(`${base}/config`).then((r) => r.data),
save: (body: OidcConfigInput) =>
api.put<OidcConfig>(`${base}/config`, body).then((r) => r.data),
test: () => api.post<OidcProbe>(`${base}/test`).then((r) => r.data),
};
+40 -3
View File
@@ -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"}
</p>
</div>
{sso?.enabled && !needsSetup && (
<div className="mb-5 space-y-4">
{/* A plain link, not a fetch: the browser has to follow the
redirect to the provider itself. */}
<a href="/api/auth/oidc/login" className="block">
<Button type="button" className="w-full">
<KeyRound className="h-4 w-4" />
{sso.button_label}
</Button>
</a>
<div className="flex items-center gap-3">
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-700" />
<span className="text-xs uppercase tracking-wide text-slate-400">or</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-700" />
</div>
</div>
)}
<form onSubmit={submit} className="space-y-3">
<Input
placeholder="Username"
@@ -79,8 +111,13 @@ export function Login() {
onChange={(e) => setConfirm(e.target.value)}
/>
)}
<Button type="submit" loading={loading} className="w-full">
{needsSetup ? "Create account" : "Sign in"}
<Button
type="submit"
loading={loading}
variant={sso?.enabled && !needsSetup ? "outline" : "primary"}
className="w-full"
>
{needsSetup ? "Create account" : "Sign in with a password"}
</Button>
</form>
</Card>
+222
View File
@@ -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() {
<SchedulesSection />
<NotificationsSection />
<ApiTokensSection />
<OidcSection />
<UsersSection />
</div>
);
@@ -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 (
<section>
<SectionTitle icon={<LogIn className="h-4 w-4" />}>Single sign-on (OIDC)</SectionTitle>
{isLoading || !data ? (
<Spinner />
) : (
<OidcForm
config={data}
onSaved={() => qc.invalidateQueries({ queryKey: ["oidc-config"] })}
/>
)}
</section>
);
}
function OidcForm({ config, onSaved }: { config: OidcConfig; onSaved: () => void }) {
const [form, setForm] = useState({ ...config });
const [secret, setSecret] = useState("");
const [copied, setCopied] = useState(false);
const set = <K extends keyof OidcConfig>(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 (
<Card className="space-y-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => set("enabled", e.target.checked)}
className="h-4 w-4"
/>
Show a single sign-on button on the login screen
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1 sm:col-span-2">
<span className="sp-label">Issuer URL</span>
<Input
placeholder="https://auth.example.com/application/o/stackpilot/"
value={form.issuer}
onChange={(e) => set("issuer", e.target.value)}
/>
<span className="block text-[11px] text-slate-400">
The base URL that serves <code>/.well-known/openid-configuration</code>.
</span>
</label>
<label className="space-y-1">
<span className="sp-label">Client ID</span>
<Input value={form.client_id} onChange={(e) => set("client_id", e.target.value)} />
</label>
<label className="space-y-1">
<span className="sp-label">Client secret</span>
<Input
type="password"
autoComplete="new-password"
placeholder={config.has_client_secret ? "Leave blank to keep the stored one" : ""}
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="sp-label">Scopes</span>
<Input value={form.scopes} onChange={(e) => set("scopes", e.target.value)} />
</label>
<label className="space-y-1">
<span className="sp-label">Button label</span>
<Input
value={form.button_label}
onChange={(e) => set("button_label", e.target.value)}
/>
</label>
</div>
<label className="block space-y-1">
<span className="sp-label">Redirect URI register this with your provider</span>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs dark:bg-slate-800">
{redirect}
</code>
<Button variant="outline" onClick={copyRedirect}>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Input
className="mt-2"
placeholder="Override — only needed if the address above is wrong"
value={form.redirect_uri}
onChange={(e) => set("redirect_uri", e.target.value)}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="sp-label">Username claim</span>
<Input
value={form.username_claim}
onChange={(e) => set("username_claim", e.target.value)}
/>
<span className="block text-[11px] text-slate-400">
Falls back to <code>email</code>, then <code>sub</code>.
</span>
</label>
<label className="space-y-1">
<span className="sp-label">Role for new accounts</span>
<select
value={form.default_role}
onChange={(e) => set("default_role", e.target.value as "admin" | "user")}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
>
<option value="user">Read-only</option>
<option value="admin">Admin</option>
</select>
</label>
<label className="space-y-1">
<span className="sp-label">Admin claim (optional)</span>
<Input
placeholder="groups"
value={form.admin_claim}
onChange={(e) => set("admin_claim", e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="sp-label">Admin claim value</span>
<Input
placeholder="stackpilot-admins"
value={form.admin_value}
onChange={(e) => set("admin_value", e.target.value)}
/>
<span className="block text-[11px] text-slate-400">
Set both and the provider decides who is an admin on every sign-in,
in both directions.
</span>
</label>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.auto_create}
onChange={(e) => set("auto_create", e.target.checked)}
className="h-4 w-4"
/>
Create a StackPilot account the first time somebody signs in
</label>
<p className="text-xs text-slate-500">
The password form stays on the login screen either way, so a provider
outage cannot lock you out of your own Docker host.
</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => probe.mutate()} loading={probe.isPending}>
<RefreshCw className="h-4 w-4" /> Test connection
</Button>
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</div>
</Card>
);
}
/* -------------------------------------------------------------------------- */
/* Users */
/* -------------------------------------------------------------------------- */
function UsersSection() {
const qc = useQueryClient();
const me = useAuthStore((s) => s.user);