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
@@ -0,0 +1,223 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
sqlite: null as {
exec: (sql: string) => void;
prepare: (sql: string) => {
all: () => Array<Record<string, unknown>>;
run: (...values: unknown[]) => unknown;
};
} | null,
logoutUser: vi.fn(),
saveDatabase: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../database/db/index.js", async () => {
const { default: Database } = await import("better-sqlite3");
const { drizzle } = await import("drizzle-orm/better-sqlite3");
const sqlite = new Database(":memory:");
mocks.sqlite = sqlite;
return {
db: drizzle(sqlite),
saveMemoryDatabaseToFile: mocks.saveDatabase,
};
});
vi.mock("./user-crypto.js", () => ({
UserCrypto: {
getInstance: () => ({
setSessionExpiredCallback: vi.fn(),
logoutUser: mocks.logoutUser,
}),
},
}));
vi.mock("./system-crypto.js", () => ({
SystemCrypto: { getInstance: () => ({}) },
}));
vi.mock("./data-crypto.js", () => ({
DataCrypto: { getInstance: () => ({}) },
}));
vi.mock("./logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { AuthManager } = await import("./auth-manager.js");
const authManager = AuthManager.getInstance();
type SessionInput = {
id: string;
userId: string;
sub: string;
sid: string | null;
providerId: number | null;
};
function insertSession({ id, userId, sub, sid, providerId }: SessionInput) {
mocks
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
oidc_sub, oidc_sid, sso_provider_id,
created_at, expires_at, last_active_at
) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?, ?, ?)`,
)
.run(
id,
userId,
`token-${id}`,
sub,
sid,
providerId,
"2026-07-10T00:00:00.000Z",
"2026-07-11T00:00:00.000Z",
"2026-07-10T00:00:00.000Z",
);
}
function sessionIds(): string[] {
return mocks
.sqlite!.prepare("SELECT id FROM sessions ORDER BY id")
.all()
.map((row) => String(row.id));
}
describe("AuthManager.revokeSessionsByOidc", () => {
beforeAll(() => {
mocks.sqlite!.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
oidc_sub TEXT,
oidc_sid TEXT,
sso_provider_id INTEGER,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL
)
`);
});
beforeEach(() => {
mocks.sqlite!.exec("DELETE FROM sessions");
mocks.logoutUser.mockReset();
mocks.saveDatabase.mockReset().mockResolvedValue(undefined);
});
it("revokes only the matching provider session when sid is present", async () => {
insertSession({
id: "matching",
userId: "user-1",
sub: "subject-1",
sid: "session-1",
providerId: 7,
});
insertSession({
id: "other-provider",
userId: "user-1",
sub: "subject-1",
sid: "session-1",
providerId: 8,
});
insertSession({
id: "other-session",
userId: "user-1",
sub: "subject-1",
sid: "session-2",
providerId: 7,
});
await expect(
authManager.revokeSessionsByOidc({
ssoProviderId: 7,
sub: "subject-1",
sid: "session-1",
}),
).resolves.toBe(1);
expect(sessionIds()).toEqual(["other-provider", "other-session"]);
expect(mocks.logoutUser).not.toHaveBeenCalled();
});
it("revokes all provider sessions for a subject when sid is absent", async () => {
insertSession({
id: "first",
userId: "user-1",
sub: "subject-1",
sid: "session-1",
providerId: 7,
});
insertSession({
id: "second",
userId: "user-1",
sub: "subject-1",
sid: "session-2",
providerId: 7,
});
insertSession({
id: "other-subject",
userId: "user-2",
sub: "subject-2",
sid: null,
providerId: 7,
});
await expect(
authManager.revokeSessionsByOidc({
ssoProviderId: 7,
sub: "subject-1",
}),
).resolves.toBe(2);
expect(sessionIds()).toEqual(["other-subject"]);
expect(mocks.logoutUser).toHaveBeenCalledOnce();
expect(mocks.logoutUser).toHaveBeenCalledWith("user-1");
});
it("does not persist when no session matches", async () => {
await expect(
authManager.revokeSessionsByOidc({
ssoProviderId: 7,
sid: "missing",
}),
).resolves.toBe(0);
expect(mocks.saveDatabase).not.toHaveBeenCalled();
});
it("propagates persistence failures so the provider can retry", async () => {
insertSession({
id: "matching",
userId: "user-1",
sub: "subject-1",
sid: "session-1",
providerId: 7,
});
mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full"));
await expect(
authManager.revokeSessionsByOidc({
ssoProviderId: 7,
sid: "session-1",
}),
).rejects.toThrow("disk full");
});
});
+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