Add OIDC single sign-on, configured from Settings (0.60.0)
Authorization Code with PKCE against any provider that publishes a discovery document, configured entirely from the UI — no environment variables, no restart to fix a typo in a client id, and a Test button that fetches the provider's metadata and says what it found. The best decision here was not writing any new session machinery. The callback sets the same httpOnly refresh cookie a password login sets and redirects to "/", and the SPA's existing boot-time restore() trades it for an access token. So an SSO session *is* a normal session — same revocation, same token_version checks, same everything — and no token is ever put in a URL fragment or query string where a proxy log or the browser history would keep it. The alternative everyone reaches for first, redirecting with #access_token=..., would have been a second code path and a worse one. What is actually verified, because "the provider said so" is worth nothing otherwise: the ID token's signature against the provider's published JWKS (re-fetched once if the kid is unknown, so key rotation heals itself), issuer, audience, expiry, and a nonce minted for that specific login. The state row is deleted when it is consumed, which is what makes a replayed callback fail, and it lives in the database rather than a dict so it survives the worker restart that can happen between the redirect out and the redirect back. Accounts match on sub, not username. It is the only identifier a provider promises is stable, so somebody renamed upstream stays the same account instead of silently acquiring a second one. An existing local account with that username is linked rather than duplicated, and keeps its role — linking must not quietly demote an admin. Claim-based admin mapping works in both directions: removed from the group upstream means read-only on the next sign-in. Two things this turned up that were already broken. verify_password raised passlib's UnknownHashError on a hash it could not parse, so a password attempt against an SSO account — which stores a deliberately unusable marker — would have been a 500 rather than a 401; it now returns false for any unparseable hash, which is the right answer for a corrupt row too. And the bundled nginx never forwarded X-Forwarded-Proto, so uvicorn saw plain HTTP behind TLS: the derived redirect URI came out as http:// and the refresh cookie lost its Secure flag. Both fixed. The password form stays on the login screen no matter what. A provider outage locking you out of the machine that runs your provider is a failure mode worth designing against. The authorization matrix made me write down why three routes are public, which is the right question to be asked: they are the path by which an unauthenticated person becomes an authenticated one. status deliberately returns only a boolean and a label — no issuer, no client id — so it tells a stranger nothing the button would not. 31 tests, with a throwaway RSA key standing in for a provider so verification is exercised for real rather than mocked: wrong key under the right kid, wrong audience, wrong issuer, expired, replayed nonce, reused state. Plus an end-to-end run of the whole flow — redirect, callback, cookie, session, group-mapped admin, replay refused, password login against the SSO account cleanly refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
|
||||||
> complete.
|
> 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
|
## Upgrading to 0.59.0 — CVE scanning
|
||||||
|
|
||||||
The Images page can tell you what is wrong with the images you are running.
|
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.
|
compose** converter.
|
||||||
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
- **Dashboard** — system resource bar, stack grid with quick actions, and a
|
||||||
recent-activity audit feed.
|
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
|
- **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
|
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
|
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)
|
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
|
### 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
|
(Fernet, key derived from `SECRET_KEY`). The generated Docker CLI config that
|
||||||
carries them for `compose pull` is written 0600 inside the data volume.
|
carries them for `compose pull` is written 0600 inside the data volume.
|
||||||
- Login is rate-limited (10/min/IP).
|
- 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.
|
- Compose files are backed up to `*.bak` before every overwrite.
|
||||||
- Generated YAML never includes the obsolete `version:` field and uses Compose v2
|
- Generated YAML never includes the obsolete `version:` field and uses Compose v2
|
||||||
(`docker compose`) syntax.
|
(`docker compose`) syntax.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Optional
|
|||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
from passlib import exc
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -27,7 +28,18 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
"""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)
|
return pwd_context.verify(plain, hashed)
|
||||||
|
except (ValueError, TypeError, exc.PasslibSecurityError, exc.UnknownHashError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# --- token helpers ---
|
# --- token helpers ---
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from routers import (
|
|||||||
git,
|
git,
|
||||||
images,
|
images,
|
||||||
networks,
|
networks,
|
||||||
|
oidc,
|
||||||
ports,
|
ports,
|
||||||
registries,
|
registries,
|
||||||
schedules,
|
schedules,
|
||||||
@@ -140,6 +141,7 @@ app.include_router(auth.router)
|
|||||||
app.include_router(stacks.router)
|
app.include_router(stacks.router)
|
||||||
app.include_router(git.router)
|
app.include_router(git.router)
|
||||||
app.include_router(git.hook_router)
|
app.include_router(git.hook_router)
|
||||||
|
app.include_router(oidc.router)
|
||||||
app.include_router(tokens.router)
|
app.include_router(tokens.router)
|
||||||
app.include_router(registries.router)
|
app.include_router(registries.router)
|
||||||
app.include_router(secrets.router)
|
app.include_router(secrets.router)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from models.backup_destination import BackupDestination
|
|||||||
from models.backup_schedule import BackupSchedule
|
from models.backup_schedule import BackupSchedule
|
||||||
from models.git_source import GitSource
|
from models.git_source import GitSource
|
||||||
from models.image_scan import ImageScan
|
from models.image_scan import ImageScan
|
||||||
|
from models.oidc import OidcConfig, OidcState
|
||||||
from models.registry import Registry
|
from models.registry import Registry
|
||||||
from models.runtime_state import ImageStatus, LoginAttempt, StackLock
|
from models.runtime_state import ImageStatus, LoginAttempt, StackLock
|
||||||
from models.setting import Setting, Webhook
|
from models.setting import Setting, Webhook
|
||||||
@@ -15,5 +16,5 @@ from models.user import User
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Stack", "AuditLog", "Setting", "Webhook",
|
"User", "Stack", "AuditLog", "Setting", "Webhook",
|
||||||
"BackupDestination", "BackupSchedule", "AutoUpdate",
|
"BackupDestination", "BackupSchedule", "AutoUpdate",
|
||||||
"StackLock", "ImageStatus", "LoginAttempt", "Registry", "ApiToken", "GitSource", "ImageScan",
|
"StackLock", "ImageStatus", "LoginAttempt", "Registry", "ApiToken", "GitSource", "ImageScan", "OidcConfig", "OidcState",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
@@ -23,6 +23,15 @@ class User(SQLModel, table=True):
|
|||||||
#: Without it a password reset left the old tokens usable for their full
|
#: Without it a password reset left the old tokens usable for their full
|
||||||
#: lifetime (up to 30 days for a refresh token).
|
#: lifetime (up to 30 days for a refresh token).
|
||||||
token_version: int = Field(default=1)
|
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 ---
|
# --- API schemas ---
|
||||||
@@ -33,6 +42,8 @@ class UserRead(SQLModel):
|
|||||||
username: str
|
username: str
|
||||||
role: str
|
role: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
#: True when this account is linked to the identity provider.
|
||||||
|
oidc: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(SQLModel):
|
class UserCreate(SQLModel):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -39,6 +39,19 @@ PUBLIC = {
|
|||||||
# Only drops the refresh cookie. Requiring a valid token would mean you
|
# Only drops the refresh cookie. Requiring a valid token would mean you
|
||||||
# cannot sign out once the session has already gone stale.
|
# cannot sign out once the session has already gone stale.
|
||||||
"POST /api/auth/logout",
|
"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
|
# 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
|
# 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
|
# request body against a per-stack secret, and answers 404 — not 403 — to
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.59.0"
|
APP_VERSION = "0.60.0"
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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;
|
proxy_read_timeout 600s;
|
||||||
# Stack backups (incl. volume data) can be large in both directions.
|
# Stack backups (incl. volume data) can be large in both directions.
|
||||||
client_max_body_size 0;
|
client_max_body_size 0;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.59.0",
|
"version": "0.60.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Ship } from "lucide-react";
|
import { Ship, KeyRound } from "lucide-react";
|
||||||
import { Button, Card, Input } from "@/components/ui";
|
import { Button, Card, Input } from "@/components/ui";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -15,6 +15,7 @@ export function Login() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirm, setConfirm] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sso, setSso] = useState<{ enabled: boolean; button_label: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accessToken) navigate("/");
|
if (accessToken) navigate("/");
|
||||||
@@ -22,8 +23,21 @@ export function Login() {
|
|||||||
.get("/api/auth/needs-setup")
|
.get("/api/auth/needs-setup")
|
||||||
.then((r) => setNeedsSetup(r.data.needs_setup))
|
.then((r) => setNeedsSetup(r.data.needs_setup))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
axios
|
||||||
|
.get("/api/auth/oidc/status")
|
||||||
|
.then((r) => setSso(r.data))
|
||||||
|
.catch(() => {});
|
||||||
}, [accessToken, navigate]);
|
}, [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) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (needsSetup && password !== confirm) {
|
if (needsSetup && password !== confirm) {
|
||||||
@@ -58,6 +72,24 @@ export function Login() {
|
|||||||
{needsSetup ? "Create your admin account" : "Sign in to continue"}
|
{needsSetup ? "Create your admin account" : "Sign in to continue"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<form onSubmit={submit} className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
@@ -79,8 +111,13 @@ export function Login() {
|
|||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" loading={loading} className="w-full">
|
<Button
|
||||||
{needsSetup ? "Create account" : "Sign in"}
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
variant={sso?.enabled && !needsSetup ? "outline" : "primary"}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{needsSetup ? "Create account" : "Sign in with a password"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
LogIn,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
type RegistryInput,
|
type RegistryInput,
|
||||||
} from "@/api/registries";
|
} from "@/api/registries";
|
||||||
import { tokensApi, type ApiToken, type ApiTokenCreated } from "@/api/tokens";
|
import { tokensApi, type ApiToken, type ApiTokenCreated } from "@/api/tokens";
|
||||||
|
import { oidcApi, type OidcConfig } from "@/api/oidc";
|
||||||
import { schedulesApi, type BackupSchedule } from "@/api/schedules";
|
import { schedulesApi, type BackupSchedule } from "@/api/schedules";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -63,6 +65,7 @@ export function Settings() {
|
|||||||
<SchedulesSection />
|
<SchedulesSection />
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
<ApiTokensSection />
|
<ApiTokensSection />
|
||||||
|
<OidcSection />
|
||||||
<UsersSection />
|
<UsersSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1206,6 +1209,225 @@ function TokenForm({
|
|||||||
/* Users */
|
/* 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() {
|
function UsersSection() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const me = useAuthStore((s) => s.user);
|
const me = useAuthStore((s) => s.user);
|
||||||
|
|||||||
Reference in New Issue
Block a user