mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
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:
@@ -765,6 +765,10 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||||
|
|
||||||
|
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
jwtToken: text("jwt_token").notNull(),
|
jwtToken: text("jwt_token").notNull(),
|
||||||
deviceType: text("device_type").notNull(),
|
deviceType: text("device_type").notNull(),
|
||||||
deviceInfo: text("device_info").notNull(),
|
deviceInfo: text("device_info").notNull(),
|
||||||
|
oidcSub: text("oidc_sub"),
|
||||||
|
oidcSid: text("oidc_sid"),
|
||||||
|
ssoProviderId: integer("sso_provider_id"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ vi.mock("../../utils/logger.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
|
const {
|
||||||
await import("./user-oidc-utils.js");
|
isOIDCUserAllowed,
|
||||||
|
getOIDCConfigFromEnv,
|
||||||
|
extractOidcGroups,
|
||||||
|
validateLogoutTokenClaims,
|
||||||
|
} = await import("./user-oidc-utils.js");
|
||||||
|
|
||||||
|
const BACKCHANNEL_LOGOUT_EVENT =
|
||||||
|
"http://schemas.openid.net/event/backchannel-logout";
|
||||||
|
|
||||||
describe("isOIDCUserAllowed", () => {
|
describe("isOIDCUserAllowed", () => {
|
||||||
it("allows everyone when the allow-list is empty", () => {
|
it("allows everyone when the allow-list is empty", () => {
|
||||||
@@ -199,3 +206,48 @@ describe("extractOidcGroups", () => {
|
|||||||
expect(extractOidcGroups({})).toEqual([]);
|
expect(extractOidcGroups({})).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("validateLogoutTokenClaims", () => {
|
||||||
|
const validClaims = {
|
||||||
|
sub: "subject-1",
|
||||||
|
sid: "session-1",
|
||||||
|
iat: 1_783_641_600,
|
||||||
|
jti: "logout-1",
|
||||||
|
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts a spec-compliant back-channel logout payload", () => {
|
||||||
|
expect(validateLogoutTokenClaims(validClaims)).toEqual({
|
||||||
|
sub: "subject-1",
|
||||||
|
sid: "session-1",
|
||||||
|
jti: "logout-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the logout event to contain an object", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateLogoutTokenClaims({
|
||||||
|
...validClaims,
|
||||||
|
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
|
||||||
|
}),
|
||||||
|
).toThrow("missing back-channel logout event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires iat and jti claims", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
|
||||||
|
).toThrow("missing iat claim");
|
||||||
|
expect(() =>
|
||||||
|
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
|
||||||
|
).toThrow("missing jti claim");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects nonce and requires sub or sid", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
|
||||||
|
).toThrow("must not contain a nonce");
|
||||||
|
expect(() =>
|
||||||
|
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
|
||||||
|
).toThrow("must contain sub and/or sid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { eq } from "drizzle-orm";
|
|||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { Agent } from "undici";
|
import { Agent } from "undici";
|
||||||
|
|
||||||
|
const BACKCHANNEL_LOGOUT_EVENT =
|
||||||
|
"http://schemas.openid.net/event/backchannel-logout";
|
||||||
|
|
||||||
|
function normalizeIssuer(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
export type OIDCConfig = {
|
export type OIDCConfig = {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
client_secret: string;
|
client_secret: string;
|
||||||
@@ -417,3 +424,102 @@ export async function loadProviderConfig(
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||||
|
config: OIDCConfig;
|
||||||
|
providerType: SSOProviderType;
|
||||||
|
providerDbId: number | null;
|
||||||
|
} | null> {
|
||||||
|
const target = normalizeIssuer(issuer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(ssoProviders)
|
||||||
|
.where(eq(ssoProviders.enabled, true));
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!["oidc", "github", "google"].includes(row.type)) continue;
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(row.config);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parsed = decryptConfigSecret(parsed);
|
||||||
|
const providerType = row.type as SSOProviderType;
|
||||||
|
const config = applyProviderDefaults(
|
||||||
|
parsed as unknown as OIDCConfig,
|
||||||
|
providerType,
|
||||||
|
);
|
||||||
|
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
|
||||||
|
return { config, providerType, providerDbId: row.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to resolve SSO provider by issuer", err, {
|
||||||
|
issuer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envConfig = getOIDCConfigFromEnv();
|
||||||
|
if (
|
||||||
|
envConfig?.issuer_url &&
|
||||||
|
normalizeIssuer(envConfig.issuer_url) === target
|
||||||
|
) {
|
||||||
|
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogoutTokenClaims = {
|
||||||
|
sub: string | null;
|
||||||
|
sid: string | null;
|
||||||
|
jti: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateLogoutTokenClaims(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): LogoutTokenClaims {
|
||||||
|
if ("nonce" in payload) {
|
||||||
|
throw new Error("logout_token must not contain a nonce claim");
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = (payload.events as Record<string, unknown> | undefined)?.[
|
||||||
|
BACKCHANNEL_LOGOUT_EVENT
|
||||||
|
];
|
||||||
|
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||||
|
throw new Error("logout_token missing back-channel logout event");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.iat)) {
|
||||||
|
throw new Error("logout_token missing iat claim");
|
||||||
|
}
|
||||||
|
|
||||||
|
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
|
||||||
|
if (!jti) {
|
||||||
|
throw new Error("logout_token missing jti claim");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = typeof payload.sub === "string" ? payload.sub : null;
|
||||||
|
const sid = typeof payload.sid === "string" ? payload.sid : null;
|
||||||
|
if (!sub && !sid) {
|
||||||
|
throw new Error("logout_token must contain sub and/or sid");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sub, sid, jti };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateLogoutToken(
|
||||||
|
logoutToken: string,
|
||||||
|
config: OIDCConfig,
|
||||||
|
): Promise<LogoutTokenClaims> {
|
||||||
|
const payload = await verifyOIDCToken(
|
||||||
|
logoutToken,
|
||||||
|
config.issuer_url,
|
||||||
|
config.client_id,
|
||||||
|
config.ca_cert,
|
||||||
|
);
|
||||||
|
|
||||||
|
return validateLogoutTokenClaims(payload);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
extractOidcGroups,
|
extractOidcGroups,
|
||||||
loadProviderConfig,
|
loadProviderConfig,
|
||||||
buildFetchOptions,
|
buildFetchOptions,
|
||||||
|
resolveProviderByIssuer,
|
||||||
|
validateLogoutToken,
|
||||||
} from "./user-oidc-utils.js";
|
} from "./user-oidc-utils.js";
|
||||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||||
@@ -1464,10 +1466,16 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
"oidc_role_shared_credentials",
|
"oidc_role_shared_credentials",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const oidcSub = typeof userInfo.sub === "string" ? userInfo.sub : null;
|
||||||
|
const oidcSid = typeof userInfo.sid === "string" ? userInfo.sid : null;
|
||||||
|
|
||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
rememberMe: storedRememberMe,
|
rememberMe: storedRememberMe,
|
||||||
|
oidcSub,
|
||||||
|
oidcSid,
|
||||||
|
ssoProviderId: callbackProviderDbId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
authLogger.success("OIDC login successful", {
|
authLogger.success("OIDC login successful", {
|
||||||
@@ -1788,6 +1796,84 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const seenLogoutJti = new Map<string, number>();
|
||||||
|
const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function pruneLogoutJti(now: number): void {
|
||||||
|
for (const [key, expiry] of seenLogoutJti) {
|
||||||
|
if (expiry <= now) seenLogoutJti.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReplayedJti(jti: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
pruneLogoutJti(now);
|
||||||
|
return seenLogoutJti.has(jti);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLogoutJti(jti: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
pruneLogoutJti(now);
|
||||||
|
seenLogoutJti.set(jti, now + LOGOUT_JTI_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/oidc/backchannel-logout", async (req, res) => {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logoutToken = (req.body as Record<string, unknown> | undefined)
|
||||||
|
?.logout_token;
|
||||||
|
if (typeof logoutToken !== "string" || !logoutToken) {
|
||||||
|
return res.status(400).json({ error: "missing logout_token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let issuer: string | null = null;
|
||||||
|
try {
|
||||||
|
const parts = logoutToken.split(".");
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const claims = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
||||||
|
issuer = typeof claims.iss === "string" ? claims.iss : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
issuer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issuer) {
|
||||||
|
return res.status(400).json({ error: "invalid logout_token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await resolveProviderByIssuer(issuer);
|
||||||
|
if (!provider) {
|
||||||
|
authLogger.warn("Back-channel logout for unknown issuer", { issuer });
|
||||||
|
return res.status(400).json({ error: "unknown issuer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await validateLogoutToken(logoutToken, provider.config);
|
||||||
|
|
||||||
|
if (claims.jti && isReplayedJti(claims.jti)) {
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authManager.revokeSessionsByOidc({
|
||||||
|
ssoProviderId: provider.providerDbId,
|
||||||
|
sub: claims.sub,
|
||||||
|
sid: claims.sid,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("OIDC back-channel session revocation failed", err);
|
||||||
|
return res.status(500).json({ error: "logout processing failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
markLogoutJti(claims.jti);
|
||||||
|
|
||||||
|
return res.status(200).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("OIDC back-channel logout failed", err);
|
||||||
|
return res.status(400).json({ error: "invalid logout_token" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/me:
|
* /users/me:
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import type { Request, Response, NextFunction } from "express";
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { db } from "../database/db/index.js";
|
import { db } from "../database/db/index.js";
|
||||||
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.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 { nanoid } from "nanoid";
|
||||||
import type { DeviceType } from "./user-agent-parser.js";
|
import type { DeviceType } from "./user-agent-parser.js";
|
||||||
|
|
||||||
@@ -345,6 +345,9 @@ class AuthManager {
|
|||||||
rememberMe?: boolean;
|
rememberMe?: boolean;
|
||||||
deviceType?: DeviceType;
|
deviceType?: DeviceType;
|
||||||
deviceInfo?: string;
|
deviceInfo?: string;
|
||||||
|
oidcSub?: string | null;
|
||||||
|
oidcSid?: string | null;
|
||||||
|
ssoProviderId?: number | null;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
@@ -391,6 +394,9 @@ class AuthManager {
|
|||||||
jwtToken: token,
|
jwtToken: token,
|
||||||
deviceType: options.deviceType,
|
deviceType: options.deviceType,
|
||||||
deviceInfo: options.deviceInfo,
|
deviceInfo: options.deviceInfo,
|
||||||
|
oidcSub: options.oidcSub ?? null,
|
||||||
|
oidcSid: options.oidcSid ?? null,
|
||||||
|
ssoProviderId: options.ssoProviderId ?? null,
|
||||||
createdAt,
|
createdAt,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
lastActiveAt: createdAt,
|
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> {
|
async cleanupExpiredSessions(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const expiredSessions = await db
|
const expiredSessions = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user