Add Authentik OIDC login as an additional sign-in option
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:
+10
-2
@@ -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
|
||||
|
||||
Generated
+32
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }) });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user