From a431b87f1f7d57d118a741713f3d699f3b3a02f1 Mon Sep 17 00:00:00 2001 From: jhodgkin Date: Sun, 12 Jul 2026 20:59:25 -0600 Subject: [PATCH] Add Authentik OIDC login as an additional sign-in option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local auth stays the primary/always-available login (don't want to lock out the saved admin password) — OIDC is additive, shown as a second button when OIDC_ENABLED=true. Uses openid-client v6 with PKCE. Authentik-side provider was set up via an authentik blueprint (its own declarative automation, see docs/oidc-setup.md) rather than touching any existing admin credentials. Closes #12. Co-Authored-By: Claude Sonnet 5 --- .env.example | 12 +++++-- apps/api/package-lock.json | 32 +++++++++++++++++++ apps/api/package.json | 1 + apps/api/src/auth/oidc.ts | 61 ++++++++++++++++++++++++++++++++++++ apps/api/src/config/index.ts | 27 ++++++++++++++-- apps/api/src/index.ts | 9 +++++- apps/api/src/routes/auth.ts | 8 ++++- apps/api/src/routes/oidc.ts | 41 ++++++++++++++++++++++++ apps/web/src/api.ts | 4 +++ apps/web/src/index.css | 21 +++++++++++++ apps/web/src/pages/Login.tsx | 19 +++++++++-- docs/oidc-setup.md | 41 ++++++++++++++++++++++++ 12 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/auth/oidc.ts create mode 100644 apps/api/src/routes/oidc.ts create mode 100644 docs/oidc-setup.md diff --git a/.env.example b/.env.example index d89ad82..9c1545b 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,16 @@ SESSION_SECRET= ADMIN_USERNAME=admin ADMIN_PASSWORD= -# local | oidc (oidc not implemented yet, see issue #12) -AUTH_MODE=local +# Local login (username/password above) is always available. Set this true to +# additionally show a "Sign in with Authentik" button — see docs/oidc-setup.md +# for how the Authentik provider was set up. +OIDC_ENABLED=false +OIDC_ISSUER_URL=https://192.168.1.208:9443/application/o/homelab-monitor/ +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=http://192.168.1.103:8090/api/auth/oidc/callback +# Authentik's cert is self-signed on the LAN, same situation as Proxmox's API. +OIDC_ALLOW_INSECURE_TLS=true # Set to true only once a TLS-terminating reverse proxy sits in front (see # issue #13). Leave false for plain-HTTP LAN access, otherwise the session diff --git a/apps/api/package-lock.json b/apps/api/package-lock.json index aed052a..95a786f 100644 --- a/apps/api/package-lock.json +++ b/apps/api/package-lock.json @@ -15,6 +15,7 @@ "better-sqlite3": "^11.9.1", "dotenv": "^16.4.7", "fastify": "^5.2.1", + "openid-client": "^6.8.4", "ssh2": "^1.16.0", "undici": "^7.3.0", "yaml": "^2.7.0" @@ -1483,6 +1484,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -1655,6 +1665,15 @@ "node": ">=10" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -1673,6 +1692,19 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", diff --git a/apps/api/package.json b/apps/api/package.json index 629ac18..f6106f2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "better-sqlite3": "^11.9.1", "dotenv": "^16.4.7", "fastify": "^5.2.1", + "openid-client": "^6.8.4", "ssh2": "^1.16.0", "undici": "^7.3.0", "yaml": "^2.7.0" diff --git a/apps/api/src/auth/oidc.ts b/apps/api/src/auth/oidc.ts new file mode 100644 index 0000000..f523b26 --- /dev/null +++ b/apps/api/src/auth/oidc.ts @@ -0,0 +1,61 @@ +import * as client from "openid-client"; +import { Agent } from "undici"; +import type { OidcConfig } from "../config/index.js"; + +// Authentik serves a self-signed cert on the LAN, same situation as Proxmox's +// API (see collectors/proxmox.ts) — scope the relaxed TLS check to this client. +const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } }); +const insecureFetch: client.CustomFetch = (url, options) => + fetch(url, { ...options, dispatcher: insecureAgent } as RequestInit); + +export async function initOidc(cfg: OidcConfig): Promise { + const config = await client.discovery( + new URL(cfg.issuerUrl), + cfg.clientId, + cfg.clientSecret, + undefined, + cfg.allowInsecureTls ? { [client.customFetch]: insecureFetch } : undefined + ); + return config; +} + +export interface LoginRedirect { + url: string; + state: string; + codeVerifier: string; +} + +export async function buildLoginRedirect( + config: client.Configuration, + redirectUri: string +): Promise { + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const state = client.randomState(); + + const url = client.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope: "openid profile email", + code_challenge: codeChallenge, + code_challenge_method: "S256", + state, + }); + + return { url: url.href, state, codeVerifier }; +} + +export async function handleCallback( + config: client.Configuration, + currentUrl: URL, + expectedState: string, + pkceCodeVerifier: string +): Promise<{ username: string }> { + const tokens = await client.authorizationCodeGrant(config, currentUrl, { + expectedState, + pkceCodeVerifier, + }); + const claims = tokens.claims(); + const username = (claims?.preferred_username as string) ?? (claims?.email as string) ?? claims?.sub; + if (!username) throw new Error("OIDC response had no usable identity claim"); + return { username }; +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts index 81506e7..a795b25 100644 --- a/apps/api/src/config/index.ts +++ b/apps/api/src/config/index.ts @@ -31,7 +31,6 @@ export interface AppConfig { dbPath: string; pollIntervalSeconds: number; snapshotRetentionHours: number; - authMode: "local" | "oidc"; sessionSecret: string; // Only set once a TLS-terminating reverse proxy sits in front (see issue #13) — // browsers silently drop `secure` cookies over plain HTTP. @@ -47,9 +46,19 @@ export interface AppConfig { sshHosts: SshHostConfig[]; knownDevices: Map; discoveryFilePath: string; + oidc: OidcConfig | undefined; hosts: HostsConfig; } +export interface OidcConfig { + issuerUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + // Authentik's cert is self-signed on the LAN, same as Proxmox's. + allowInsecureTls: boolean; +} + function required(name: string): string { const v = process.env[name]; if (!v) throw new Error(`Missing required env var: ${name}`); @@ -60,6 +69,20 @@ export function loadConfig(hostsConfigPath: string): AppConfig { const hosts = parse(readFileSync(hostsConfigPath, "utf8")) as HostsConfig; const sshPrivateKeyPath = process.env.SSH_PRIVATE_KEY_PATH ?? "./ssh/monitor_ed25519"; + // Local auth (bcrypt + session) is always available. OIDC is an *additional* + // sign-in option when configured, not a replacement — see issue #12 comments + // for why (don't want to lock out the already-saved local admin password). + const oidc: OidcConfig | undefined = + process.env.OIDC_ENABLED === "true" + ? { + issuerUrl: required("OIDC_ISSUER_URL"), + clientId: required("OIDC_CLIENT_ID"), + clientSecret: required("OIDC_CLIENT_SECRET"), + redirectUri: required("OIDC_REDIRECT_URI"), + allowInsecureTls: process.env.OIDC_ALLOW_INSECURE_TLS === "true", + } + : undefined; + const sshHosts: SshHostConfig[] = (hosts.sshHosts ?? []).map((h) => ({ id: h.id, displayName: h.displayName, @@ -76,7 +99,6 @@ export function loadConfig(hostsConfigPath: string): AppConfig { dbPath: process.env.DB_PATH ?? "./data/monitor.db", pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30), snapshotRetentionHours: Number(process.env.SNAPSHOT_RETENTION_HOURS ?? 24), - authMode: (process.env.AUTH_MODE as "local" | "oidc") ?? "local", sessionSecret: required("SESSION_SECRET"), cookieSecure: process.env.COOKIE_SECURE === "true", adminUsername: process.env.ADMIN_USERNAME ?? "admin", @@ -90,6 +112,7 @@ export function loadConfig(hostsConfigPath: string): AppConfig { sshHosts, knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])), discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json", + oidc, hosts, }; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e5bf78f..d74bf96 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,7 +11,9 @@ import type { Collector } from "./collectors/types.js"; import { registerAuthRoutes } from "./routes/auth.js"; import { registerHostRoutes } from "./routes/hosts.js"; import { registerDeviceRoutes } from "./routes/devices.js"; +import { registerOidcRoutes } from "./routes/oidc.js"; import { refreshDevices } from "./discovery/index.js"; +import { initOidc } from "./auth/oidc.js"; const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml"; @@ -59,10 +61,15 @@ async function main() { cookie: { secure: cfg.cookieSecure, maxAge: 1000 * 60 * 60 * 12 }, }); - registerAuthRoutes(app, db); + registerAuthRoutes(app, db, cfg.oidc !== undefined); registerHostRoutes(app, db); registerDeviceRoutes(app, db); + if (cfg.oidc) { + const oidcConfig = await initOidc(cfg.oidc); + registerOidcRoutes(app, oidcConfig, cfg.oidc.redirectUri); + } + await pollOnce(); setInterval(() => { void pollOnce(); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index b559bcf..c298f39 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -8,7 +8,13 @@ declare module "@fastify/session" { } } -export function registerAuthRoutes(app: FastifyInstance, db: Database.Database): void { +export function registerAuthRoutes( + app: FastifyInstance, + db: Database.Database, + oidcEnabled: boolean +): void { + app.get("/api/auth/config", async () => ({ oidcEnabled })); + app.post<{ Body: { username: string; password: string } }>("/api/auth/login", async (req, reply) => { const { username, password } = req.body ?? {}; if (!username || !password || !verifyCredentials(db, username, password)) { diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts new file mode 100644 index 0000000..1386165 --- /dev/null +++ b/apps/api/src/routes/oidc.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from "fastify"; +import type * as client from "openid-client"; +import { buildLoginRedirect, handleCallback } from "../auth/oidc.js"; + +declare module "@fastify/session" { + interface FastifySessionObject { + oidcState?: string; + oidcCodeVerifier?: string; + } +} + +export function registerOidcRoutes( + app: FastifyInstance, + oidcConfig: client.Configuration, + redirectUri: string +): void { + app.get("/api/auth/oidc/login", async (req, reply) => { + const { url, state, codeVerifier } = await buildLoginRedirect(oidcConfig, redirectUri); + req.session.oidcState = state; + req.session.oidcCodeVerifier = codeVerifier; + return reply.redirect(url); + }); + + app.get("/api/auth/oidc/callback", async (req, reply) => { + const { oidcState, oidcCodeVerifier } = req.session; + if (!oidcState || !oidcCodeVerifier) { + return reply.code(400).send({ error: "no OIDC login in progress" }); + } + try { + const currentUrl = new URL(req.url, `http://${req.headers.host}`); + const { username } = await handleCallback(oidcConfig, currentUrl, oidcState, oidcCodeVerifier); + req.session.username = username; + req.session.oidcState = undefined; + req.session.oidcCodeVerifier = undefined; + return reply.redirect("/"); + } catch (err) { + req.log.error({ err }, "OIDC callback failed"); + return reply.code(401).send({ error: "OIDC login failed" }); + } + }); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6e10f35..b73c034 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -24,6 +24,10 @@ async function request(path: string, init?: RequestInit): Promise { return res.json() as Promise; } +export function getAuthConfig(): Promise<{ oidcEnabled: boolean }> { + return request("/api/auth/config"); +} + export function login(username: string, password: string): Promise<{ username: string }> { return request("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d2635b1..a421dd0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -42,6 +42,27 @@ body { cursor: pointer; } +.login-divider { + text-align: center; + color: #8b949e; + font-size: 0.8rem; +} + +.oidc-button { + display: block; + text-align: center; + padding: 0.5rem; + border-radius: 4px; + border: 1px solid #30363d; + background: #0d1117; + color: #e6edf3; + text-decoration: none; +} + +.oidc-button:hover { + border-color: #58a6ff; +} + .error { color: #e5484d; } diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index fab169d..e699c39 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -1,11 +1,18 @@ -import { useState } from "react"; -import { login } from "../api"; +import { useEffect, useState } from "react"; +import { login, getAuthConfig } from "../api"; export function Login({ onLoggedIn }: { onLoggedIn: () => void }) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [oidcEnabled, setOidcEnabled] = useState(false); + + useEffect(() => { + getAuthConfig() + .then((c) => setOidcEnabled(c.oidcEnabled)) + .catch(() => setOidcEnabled(false)); + }, []); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -42,6 +49,14 @@ export function Login({ onLoggedIn }: { onLoggedIn: () => void }) { + {oidcEnabled && ( + <> +
or
+ + Sign in with Authentik + + + )} ); diff --git a/docs/oidc-setup.md b/docs/oidc-setup.md new file mode 100644 index 0000000..1cea615 --- /dev/null +++ b/docs/oidc-setup.md @@ -0,0 +1,41 @@ +# Authentik OIDC setup + +Local auth (bcrypt + session, the saved Vaultwarden password) is always available. +When `OIDC_ENABLED=true`, the login page additionally shows a "Sign in with Authentik" +button — this is additive, not a replacement, so the existing admin login keeps working. + +## How the Authentik side was provisioned + +No API token or admin credentials were needed. Authentik supports **blueprints** — +declarative YAML files it applies automatically — so the OAuth2 Provider + Application +were created via `/opt/authentik/blueprints-local/homelab-monitor-oidc.yaml` on CT121, +picked up by the `worker` container on startup. This is purely additive: it doesn't +touch any existing user, group, or admin credential. + +- Blueprint volume mount added to `/opt/authentik/docker-compose.yml` (backed up as + `docker-compose.yml.bak-homelab-monitor` before editing) for both `server` and `worker`. +- Provider: confidential client, `default-provider-authorization-implicit-consent` flow + (auto-approve — reasonable for a single-user personal dashboard), signed with + Authentik's existing self-signed cert. +- Redirect URI: `http://192.168.1.103:8090/api/auth/oidc/callback` (LAN-only for now; + will need a second redirect URI added once issue #13's public exposure lands). +- Application slug: `homelab-monitor`. + +To change anything (redirect URI, flow, scopes), edit the blueprint file on CT121 and +either wait for Authentik's file-watcher or `docker restart authentik-worker-1` — it +re-applies on any change to the file. + +## API-side implementation + +`apps/api/src/auth/oidc.ts` uses `openid-client` v6 with PKCE + state, same self-signed +TLS handling pattern as `collectors/proxmox.ts` (Authentik's cert is self-signed on the +LAN too). Routes in `apps/api/src/routes/oidc.ts`: + +- `GET /api/auth/oidc/login` — redirects to Authentik's authorization endpoint +- `GET /api/auth/oidc/callback` — exchanges the code, sets `req.session.username` from + the `preferred_username` (falls back to `email`, then `sub`) ID token claim + +## Credentials + +`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` are in CT122's `.env` and saved in Vaultwarden +alongside the local admin login.