feat: implement OIDC back-channel logout support with session management (#1028)

* feat: implement OIDC back-channel logout support with session management

* Fix OIDC back-channel logout handling

* Require logout token replay identifiers

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Alexander Elsner
2026-07-10 02:37:25 +02:00
committed by GitHub
parent 732255333f
commit 033dd38344
7 changed files with 539 additions and 3 deletions
+63 -1
View File
@@ -8,7 +8,7 @@ import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import { db } from "../database/db/index.js";
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { eq, and, inArray, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js";
@@ -345,6 +345,9 @@ class AuthManager {
rememberMe?: boolean;
deviceType?: DeviceType;
deviceInfo?: string;
oidcSub?: string | null;
oidcSid?: string | null;
ssoProviderId?: number | null;
} = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
@@ -391,6 +394,9 @@ class AuthManager {
jwtToken: token,
deviceType: options.deviceType,
deviceInfo: options.deviceInfo,
oidcSub: options.oidcSub ?? null,
oidcSid: options.oidcSid ?? null,
ssoProviderId: options.ssoProviderId ?? null,
createdAt,
expiresAt,
lastActiveAt: createdAt,
@@ -651,6 +657,62 @@ class AuthManager {
}
}
async revokeSessionsByOidc(params: {
ssoProviderId?: number | null;
sub?: string | null;
sid?: string | null;
}): Promise<number> {
const { ssoProviderId, sub, sid } = params;
if (!sub && !sid) return 0;
try {
const conditions = [
sid ? eq(sessions.oidcSid, sid) : eq(sessions.oidcSub, sub!),
];
if (ssoProviderId != null)
conditions.push(eq(sessions.ssoProviderId, ssoProviderId));
const matched = await db
.select()
.from(sessions)
.where(conditions.length === 1 ? conditions[0] : and(...conditions));
if (matched.length === 0) return 0;
const matchedIds = matched.map((s) => s.id);
const affectedUsers = new Set(matched.map((s) => s.userId));
await db.delete(sessions).where(inArray(sessions.id, matchedIds));
authLogger.info("Sessions revoked via OIDC back-channel logout", {
operation: "oidc_backchannel_logout",
ssoProviderId,
sessionCount: matchedIds.length,
});
for (const userId of affectedUsers) {
const remaining = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
if (remaining.length === 0) {
this.userCrypto.logoutUser(userId);
}
}
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
return matchedIds.length;
} catch (error) {
databaseLogger.error("Failed to revoke sessions via OIDC", error, {
operation: "oidc_backchannel_logout_failed",
});
throw error;
}
}
async cleanupExpiredSessions(): Promise<number> {
try {
const expiredSessions = await db