feat: add support for OIDC logouts

Closes HP-407.
This commit is contained in:
Aarnav Tale
2026-04-26 20:28:36 -04:00
parent ac6f9e4f7e
commit b961b339bb
13 changed files with 512 additions and 15 deletions
+23 -3
View File
@@ -7,15 +7,35 @@ export async function loader() {
}
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
try {
await context.auth.require(request);
principal = await context.auth.require(request);
} catch {
redirect("/login");
return redirect("/login");
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
// For OIDC sessions, redirect to the provider's RP-initiated logout
// endpoint when explicitly enabled, so the upstream IdP session is also
// ended. Disabled by default because the post_logout_redirect_uri must be
// pre-registered on the IdP — turning this on without registering it would
// strand users on the IdP's error page.
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) {
const status = context.oidc.service.status();
if (status.state !== "ready") {
// Trigger discovery if it hasn't happened yet so we can find the
// end_session_endpoint without forcing a re-login.
await context.oidc.service.discover();
}
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) {
url = endSessionUrl;
}
}
return redirect(url, {
headers: {
+13 -5
View File
@@ -66,13 +66,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
// Only persist the id_token when RP-initiated logout is enabled — otherwise
// we'd be storing a credential we never use.
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name: identity.name,
email: identity.email,
username: identity.username,
}),
"Set-Cookie": await context.auth.createOidcSession(
userId,
{
name: identity.name,
email: identity.email,
username: identity.username,
},
{ idToken },
),
},
});
}
+6
View File
@@ -123,6 +123,9 @@ const oidcConfig = type({
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
end_session_endpoint: "string.url?",
post_logout_redirect_uri: "string.url?",
use_end_session: "boolean = false",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
@@ -147,6 +150,9 @@ const partialOidcConfig = type({
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
end_session_endpoint: "string.url?",
post_logout_redirect_uri: "string.url?",
use_end_session: "boolean?",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
+1
View File
@@ -36,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", {
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
oidc_id_token: text("oidc_id_token"),
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
});
+3
View File
@@ -109,6 +109,7 @@ const appLoadContext = {
authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint,
endSessionEndpoint: config.oidc.end_session_endpoint,
tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined
@@ -119,8 +120,10 @@ const appLoadContext = {
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
useEndSession: config.oidc.use_end_session,
}
: undefined,
db,
+39 -4
View File
@@ -15,6 +15,7 @@ export interface OidcConfig {
authorizationEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
@@ -25,6 +26,7 @@ export interface OidcConfig {
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
postLogoutRedirectUri?: string;
}
export interface ResolvedEndpoints {
@@ -49,6 +51,7 @@ export interface OidcIdentity {
username: string;
email?: string;
picture?: string;
idToken?: string;
}
export type OidcErrorCode =
@@ -89,6 +92,8 @@ export interface OidcService {
flowState: OidcFlowState,
): Promise<Result<OidcIdentity, OidcError>>;
buildEndSessionUrl(idToken?: string): string | undefined;
invalidate(): void;
reload(config: OidcConfig): void;
}
@@ -173,6 +178,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
tokenEndpoint: config.tokenEndpoint!,
jwksUri: config.jwksUri!,
userinfoEndpoint: config.userinfoEndpoint,
endSessionEndpoint: config.endSessionEndpoint,
};
lastError = undefined;
@@ -247,7 +253,8 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
const userinfoEndpoint =
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined);
const endSessionEndpoint = metadata.end_session_endpoint as string | undefined;
const endSessionEndpoint =
config.endSessionEndpoint ?? (metadata.end_session_endpoint as string | undefined);
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
const missing: string[] = [];
@@ -390,7 +397,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
});
}
return ok(buildIdentity(enriched));
return ok(buildIdentity(enriched, tokens.id_token));
}
async function exchangeCode(
@@ -738,7 +745,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
}
function buildIdentity(claims: OidcClaims): OidcIdentity {
function buildIdentity(claims: OidcClaims, idToken?: string): OidcIdentity {
const subject = resolveSubject(claims);
if (!subject) {
throw new Error("OIDC subject was not resolved before identity construction");
@@ -769,9 +776,29 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
username,
email: claims.email,
picture,
idToken,
};
}
function buildEndSessionUrl(idToken?: string): string | undefined {
if (!endpoints?.endSessionEndpoint) {
return undefined;
}
const params = new URLSearchParams();
if (idToken) {
params.set("id_token_hint", idToken);
}
params.set("client_id", config.clientId);
const postLogoutRedirectUri =
config.postLogoutRedirectUri ?? new URL(`${__PREFIX__}/login?s=logout`, config.baseUrl).href;
params.set("post_logout_redirect_uri", postLogoutRedirectUri);
return `${endpoints.endSessionEndpoint}?${params.toString()}`;
}
function invalidate(): void {
endpoints = undefined;
lastError = undefined;
@@ -803,7 +830,15 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return undefined;
}
return { status, discover, startFlow, handleCallback, invalidate, reload };
return {
status,
discover,
startFlow,
handleCallback,
buildEndSessionUrl,
invalidate,
reload,
};
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
if (!currentConfig.allowWeakRsaKeys) {
+6 -2
View File
@@ -20,6 +20,7 @@ export type Principal =
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
@@ -64,7 +65,7 @@ export interface AuthService {
createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge?: number,
options?: { idToken?: string; maxAge?: number },
): Promise<string>;
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
@@ -185,6 +186,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return {
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: {
id: user.id,
subject: user.sub,
@@ -249,13 +251,15 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
async function createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = opts.cookie.maxAge,
options?: { idToken?: string; maxAge?: number },
): Promise<string> {
const maxAge = options?.maxAge ?? opts.cookie.maxAge;
const sid = ulid();
await opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
oidc_id_token: options?.idToken,
expires_at: new Date(Date.now() + maxAge * 1000),
});