diff --git a/apps/api/src/auth/oidc.ts b/apps/api/src/auth/oidc.ts index f523b26..36282b2 100644 --- a/apps/api/src/auth/oidc.ts +++ b/apps/api/src/auth/oidc.ts @@ -49,7 +49,7 @@ export async function handleCallback( currentUrl: URL, expectedState: string, pkceCodeVerifier: string -): Promise<{ username: string }> { +): Promise<{ username: string; idToken: string | undefined }> { const tokens = await client.authorizationCodeGrant(config, currentUrl, { expectedState, pkceCodeVerifier, @@ -57,5 +57,25 @@ export async function handleCallback( 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 }; + // Retained in the session so logout can pass it as id_token_hint to + // Authentik's end_session_endpoint (RP-Initiated Logout) -- see + // buildLogoutRedirect below. + return { username, idToken: tokens.id_token }; +} + +// Authentik has no dedicated post_logout_redirect_uri allowlist field (unlike +// redirect_uris) as of the version this was built against -- confirmed by +// checking the provider's DB schema before implementing, so this isn't +// guesswork. Builds on openid-client's own helper rather than hand-rolling +// the end_session_endpoint URL. +export function buildLogoutRedirect( + config: client.Configuration, + idToken: string | undefined, + postLogoutRedirectUri: string +): string { + const url = client.buildEndSessionUrl(config, { + post_logout_redirect_uri: postLogoutRedirectUri, + ...(idToken ? { id_token_hint: idToken } : {}), + }); + return url.href; } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index c298f39..8217cc1 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -5,6 +5,12 @@ import { verifyCredentials } from "../auth/local.js"; declare module "@fastify/session" { interface FastifySessionObject { username?: string; + // Which flow established this session -- decides whether sign-out only + // needs to clear our own session (local) or also needs to redirect + // through Authentik's RP-Initiated Logout (oidc). A locally-authenticated + // session has no Authentik session to end. + authMethod?: "local" | "oidc"; + oidcIdToken?: string; } } @@ -21,6 +27,7 @@ export function registerAuthRoutes( return reply.code(401).send({ error: "invalid credentials" }); } req.session.username = username; + req.session.authMethod = "local"; return { username }; }); @@ -31,6 +38,6 @@ export function registerAuthRoutes( app.get("/api/auth/me", async (req, reply) => { if (!req.session.username) return reply.code(401).send({ error: "not authenticated" }); - return { username: req.session.username }; + return { username: req.session.username, authMethod: req.session.authMethod ?? "local" }; }); } diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts index 9594adb..661adfd 100644 --- a/apps/api/src/routes/oidc.ts +++ b/apps/api/src/routes/oidc.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import type * as client from "openid-client"; -import { buildLoginRedirect, handleCallback } from "../auth/oidc.js"; +import { buildLoginRedirect, buildLogoutRedirect, handleCallback } from "../auth/oidc.js"; declare module "@fastify/session" { interface FastifySessionObject { @@ -14,6 +14,10 @@ export function registerOidcRoutes( oidcConfig: client.Configuration, redirectUri: string ): void { + // Same guaranteed-correct-origin trick as the callback's currentUrl below -- + // avoids re-deriving scheme/host from headers Fastify can't see accurately. + const postLogoutRedirectUri = new URL("/", redirectUri).href; + app.get("/api/auth/oidc/login", async (req, reply) => { const { url, state, codeVerifier } = await buildLoginRedirect(oidcConfig, redirectUri); req.session.oidcState = state; @@ -37,8 +41,10 @@ export function registerOidcRoutes( // the actual incoming request. const currentUrl = new URL(redirectUri); currentUrl.search = new URL(req.url, "http://placeholder").search; - const { username } = await handleCallback(oidcConfig, currentUrl, oidcState, oidcCodeVerifier); + const { username, idToken } = await handleCallback(oidcConfig, currentUrl, oidcState, oidcCodeVerifier); req.session.username = username; + req.session.authMethod = "oidc"; + req.session.oidcIdToken = idToken; req.session.oidcState = undefined; req.session.oidcCodeVerifier = undefined; return reply.redirect("/"); @@ -47,4 +53,18 @@ export function registerOidcRoutes( return reply.code(401).send({ error: "OIDC login failed" }); } }); + + // Full-page navigation, not a fetch -- Authentik needs to see this as a real + // browser request to clear its own session cookie on auth.jerodrigged.com + // before redirecting back. Only meaningful for sessions actually + // established via OIDC; local sessions fall back to plain local logout. + app.get("/api/auth/oidc/logout", async (req, reply) => { + if (req.session.authMethod !== "oidc") { + await req.session.destroy(); + return reply.redirect("/"); + } + const logoutUrl = buildLogoutRedirect(oidcConfig, req.session.oidcIdToken, postLogoutRedirectUri); + await req.session.destroy(); + return reply.redirect(logoutUrl); + }); } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 85cf1f4..d5e9786 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,24 +1,24 @@ import { useEffect, useState } from "react"; -import { me } from "./api"; +import { me, type Me } from "./api"; import { Login } from "./pages/Login"; import { Dashboard } from "./pages/Dashboard"; export default function App() { - const [username, setUsername] = useState(null); + const [session, setSession] = useState(null); const [checked, setChecked] = useState(false); useEffect(() => { me() - .then((r) => setUsername(r.username)) - .catch(() => setUsername(null)) + .then(setSession) + .catch(() => setSession(null)) .finally(() => setChecked(true)); }, []); if (!checked) return null; - if (!username) { - return me().then((r) => setUsername(r.username))} />; + if (!session) { + return me().then(setSession)} />; } - return setUsername(null)} />; + return setSession(null)} />; } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 15290f7..626e6cd 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -45,7 +45,12 @@ export function logout(): Promise<{ ok: true }> { return request("/api/auth/logout", { method: "POST" }); } -export function me(): Promise<{ username: string }> { +export interface Me { + username: string; + authMethod: "local" | "oidc"; +} + +export function me(): Promise { return request("/api/auth/me"); } diff --git a/apps/web/src/pages/Dashboard.tsx b/apps/web/src/pages/Dashboard.tsx index f343451..aa16082 100644 --- a/apps/web/src/pages/Dashboard.tsx +++ b/apps/web/src/pages/Dashboard.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { getHosts, getDevices, logout, type HostSummary, type Device } from "../api"; +import { getHosts, getDevices, logout, type HostSummary, type Device, type Me } from "../api"; import { HostCard } from "../components/HostCard"; import { DeviceTable } from "../components/DeviceTable"; const POLL_MS = 15000; -export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) { +export function Dashboard({ session, onLoggedOut }: { session: Me; onLoggedOut: () => void }) { const [hosts, setHosts] = useState([]); const [devices, setDevices] = useState([]); const [error, setError] = useState(null); @@ -41,9 +41,16 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge

Homelab Monitor

- {username} + {session.username}