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
+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);