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