mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 11:36:31 +00:00
feat: add support for OIDC logouts
Closes HP-407.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user