Sign out also ends the Authentik SSO session (RP-Initiated Logout)
Previously logout only destroyed our own session -- someone who
signed in via Authentik stayed logged into Authentik itself, so
"Sign in with Authentik" again would silently re-authenticate with
no prompt.
Session now tracks authMethod ("local" | "oidc") and, for OIDC
sessions, the raw id_token (needed as id_token_hint at logout time).
New GET /api/auth/oidc/logout redirects through Authentik's
end_session_endpoint (openid-client's buildEndSessionUrl, not
hand-rolled) before landing back on /. Must be a full-page navigation
-- Authentik needs a real browser request to clear its own session
cookie, a fetch() wouldn't do that. Local sessions still use the
existing POST /api/auth/logout unchanged.
Confirmed Authentik has no dedicated post_logout_redirect_uri
allowlist field by checking the provider's DB schema directly before
implementing, rather than assuming.
Closes #19.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,7 @@ export async function handleCallback(
|
|||||||
currentUrl: URL,
|
currentUrl: URL,
|
||||||
expectedState: string,
|
expectedState: string,
|
||||||
pkceCodeVerifier: string
|
pkceCodeVerifier: string
|
||||||
): Promise<{ username: string }> {
|
): Promise<{ username: string; idToken: string | undefined }> {
|
||||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||||
expectedState,
|
expectedState,
|
||||||
pkceCodeVerifier,
|
pkceCodeVerifier,
|
||||||
@@ -57,5 +57,25 @@ export async function handleCallback(
|
|||||||
const claims = tokens.claims();
|
const claims = tokens.claims();
|
||||||
const username = (claims?.preferred_username as string) ?? (claims?.email as string) ?? claims?.sub;
|
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");
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { verifyCredentials } from "../auth/local.js";
|
|||||||
declare module "@fastify/session" {
|
declare module "@fastify/session" {
|
||||||
interface FastifySessionObject {
|
interface FastifySessionObject {
|
||||||
username?: string;
|
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" });
|
return reply.code(401).send({ error: "invalid credentials" });
|
||||||
}
|
}
|
||||||
req.session.username = username;
|
req.session.username = username;
|
||||||
|
req.session.authMethod = "local";
|
||||||
return { username };
|
return { username };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +38,6 @@ export function registerAuthRoutes(
|
|||||||
|
|
||||||
app.get("/api/auth/me", async (req, reply) => {
|
app.get("/api/auth/me", async (req, reply) => {
|
||||||
if (!req.session.username) return reply.code(401).send({ error: "not authenticated" });
|
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" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type * as client from "openid-client";
|
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" {
|
declare module "@fastify/session" {
|
||||||
interface FastifySessionObject {
|
interface FastifySessionObject {
|
||||||
@@ -14,6 +14,10 @@ export function registerOidcRoutes(
|
|||||||
oidcConfig: client.Configuration,
|
oidcConfig: client.Configuration,
|
||||||
redirectUri: string
|
redirectUri: string
|
||||||
): void {
|
): 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) => {
|
app.get("/api/auth/oidc/login", async (req, reply) => {
|
||||||
const { url, state, codeVerifier } = await buildLoginRedirect(oidcConfig, redirectUri);
|
const { url, state, codeVerifier } = await buildLoginRedirect(oidcConfig, redirectUri);
|
||||||
req.session.oidcState = state;
|
req.session.oidcState = state;
|
||||||
@@ -37,8 +41,10 @@ export function registerOidcRoutes(
|
|||||||
// the actual incoming request.
|
// the actual incoming request.
|
||||||
const currentUrl = new URL(redirectUri);
|
const currentUrl = new URL(redirectUri);
|
||||||
currentUrl.search = new URL(req.url, "http://placeholder").search;
|
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.username = username;
|
||||||
|
req.session.authMethod = "oidc";
|
||||||
|
req.session.oidcIdToken = idToken;
|
||||||
req.session.oidcState = undefined;
|
req.session.oidcState = undefined;
|
||||||
req.session.oidcCodeVerifier = undefined;
|
req.session.oidcCodeVerifier = undefined;
|
||||||
return reply.redirect("/");
|
return reply.redirect("/");
|
||||||
@@ -47,4 +53,18 @@ export function registerOidcRoutes(
|
|||||||
return reply.code(401).send({ error: "OIDC login failed" });
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { me } from "./api";
|
import { me, type Me } from "./api";
|
||||||
import { Login } from "./pages/Login";
|
import { Login } from "./pages/Login";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [username, setUsername] = useState<string | null>(null);
|
const [session, setSession] = useState<Me | null>(null);
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
me()
|
me()
|
||||||
.then((r) => setUsername(r.username))
|
.then(setSession)
|
||||||
.catch(() => setUsername(null))
|
.catch(() => setSession(null))
|
||||||
.finally(() => setChecked(true));
|
.finally(() => setChecked(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!checked) return null;
|
if (!checked) return null;
|
||||||
|
|
||||||
if (!username) {
|
if (!session) {
|
||||||
return <Login onLoggedIn={() => me().then((r) => setUsername(r.username))} />;
|
return <Login onLoggedIn={() => me().then(setSession)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Dashboard username={username} onLoggedOut={() => setUsername(null)} />;
|
return <Dashboard session={session} onLoggedOut={() => setSession(null)} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -45,7 +45,12 @@ export function logout(): Promise<{ ok: true }> {
|
|||||||
return request("/api/auth/logout", { method: "POST" });
|
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<Me> {
|
||||||
return request("/api/auth/me");
|
return request("/api/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { HostCard } from "../components/HostCard";
|
||||||
import { DeviceTable } from "../components/DeviceTable";
|
import { DeviceTable } from "../components/DeviceTable";
|
||||||
|
|
||||||
const POLL_MS = 15000;
|
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<HostSummary[]>([]);
|
const [hosts, setHosts] = useState<HostSummary[]>([]);
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -41,9 +41,16 @@ export function Dashboard({ username, onLoggedOut }: { username: string; onLogge
|
|||||||
<header className="dashboard-header">
|
<header className="dashboard-header">
|
||||||
<h1>Homelab Monitor</h1>
|
<h1>Homelab Monitor</h1>
|
||||||
<div>
|
<div>
|
||||||
<span className="username">{username}</span>
|
<span className="username">{session.username}</span>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
if (session.authMethod === "oidc") {
|
||||||
|
// Full-page navigation, not a fetch -- Authentik needs a real
|
||||||
|
// browser request to clear its own session cookie before
|
||||||
|
// redirecting back here. See docs/oidc-setup.md.
|
||||||
|
window.location.href = "/api/auth/oidc/logout";
|
||||||
|
return;
|
||||||
|
}
|
||||||
await logout();
|
await logout();
|
||||||
onLoggedOut();
|
onLoggedOut();
|
||||||
}}
|
}}
|
||||||
|
|||||||
+15
-1
@@ -53,7 +53,21 @@ ever unreachable. Routes in `apps/api/src/routes/oidc.ts`:
|
|||||||
|
|
||||||
- `GET /api/auth/oidc/login` — redirects to Authentik's authorization endpoint
|
- `GET /api/auth/oidc/login` — redirects to Authentik's authorization endpoint
|
||||||
- `GET /api/auth/oidc/callback` — exchanges the code, sets `req.session.username` from
|
- `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
|
the `preferred_username` (falls back to `email`, then `sub`) ID token claim, and stores
|
||||||
|
`authMethod: "oidc"` + the raw `id_token` in the session
|
||||||
|
- `GET /api/auth/oidc/logout` — RP-Initiated Logout (issue #19). Only takes this path if
|
||||||
|
`req.session.authMethod === "oidc"` (a locally-authenticated session has no Authentik
|
||||||
|
session to end); redirects through Authentik's `end_session_endpoint` with
|
||||||
|
`id_token_hint` + `post_logout_redirect_uri` (via `openid-client`'s `buildEndSessionUrl`,
|
||||||
|
not hand-rolled) before landing back on `/`. **Must be a full-page navigation, not a
|
||||||
|
fetch** — the frontend does `window.location.href = ...`, since Authentik needs to see a
|
||||||
|
real browser request to clear its own session cookie on `auth.jerodrigged.com`. Plain
|
||||||
|
`POST /api/auth/logout` still exists for local sessions and just clears the local one.
|
||||||
|
|
||||||
|
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 (`\d authentik_providers_oauth2_oauth2provider`) before
|
||||||
|
implementing, not assumed.
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user