Add Authentik OIDC login as an additional sign-in option
CI / web (push) Successful in 19s
CI / api (push) Successful in 28s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:59:25 -06:00
parent 9891751d37
commit a431b87f1f
12 changed files with 268 additions and 8 deletions
+10 -2
View File
@@ -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
+32
View File
@@ -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",
+1
View File
@@ -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"
+61
View File
@@ -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<client.Configuration> {
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<LoginRedirect> {
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 };
}
+25 -2
View File
@@ -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<string, string>;
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,
};
}
+8 -1
View File
@@ -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();
+7 -1
View File
@@ -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)) {
+41
View File
@@ -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" });
}
});
}
+4
View File
@@ -24,6 +24,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>;
}
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 }) });
}
+21
View File
@@ -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;
}
+17 -2
View File
@@ -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<string | null>(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 }) {
<button type="submit" disabled={submitting}>
{submitting ? "Signing in…" : "Sign in"}
</button>
{oidcEnabled && (
<>
<div className="login-divider">or</div>
<a className="oidc-button" href="/api/auth/oidc/login">
Sign in with Authentik
</a>
</>
)}
</form>
</div>
);
+41
View File
@@ -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.