Sign out also ends the Authentik SSO session (RP-Initiated Logout)
CI / web (push) Successful in 22s
CI / api (push) Successful in 29s

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:
2026-07-13 08:54:21 -06:00
parent 5b2dab3203
commit 74c7bf6ef7
7 changed files with 90 additions and 17 deletions
+22 -2
View File
@@ -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;
}
+8 -1
View File
@@ -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" };
});
}
+22 -2
View File
@@ -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);
});
}