diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 59493feb..c278bb9d 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -765,6 +765,10 @@ const migrateSchema = () => { addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "totp_backup_codes", "TEXT"); + addColumnIfNotExists("sessions", "oidc_sub", "TEXT"); + addColumnIfNotExists("sessions", "oidc_sid", "TEXT"); + addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER"); + sqlite.exec(` CREATE TABLE IF NOT EXISTS webauthn_credentials ( id TEXT PRIMARY KEY, diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index fabad70f..917c3d70 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -54,6 +54,9 @@ export const sessions = sqliteTable("sessions", { jwtToken: text("jwt_token").notNull(), deviceType: text("device_type").notNull(), deviceInfo: text("device_info").notNull(), + oidcSub: text("oidc_sub"), + oidcSid: text("oidc_sid"), + ssoProviderId: integer("sso_provider_id"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/user-oidc-utils.test.ts b/src/backend/database/routes/user-oidc-utils.test.ts index 7ce97ade..b72f13b4 100644 --- a/src/backend/database/routes/user-oidc-utils.test.ts +++ b/src/backend/database/routes/user-oidc-utils.test.ts @@ -11,8 +11,15 @@ vi.mock("../../utils/logger.js", () => ({ }, })); -const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } = - await import("./user-oidc-utils.js"); +const { + isOIDCUserAllowed, + getOIDCConfigFromEnv, + extractOidcGroups, + validateLogoutTokenClaims, +} = await import("./user-oidc-utils.js"); + +const BACKCHANNEL_LOGOUT_EVENT = + "http://schemas.openid.net/event/backchannel-logout"; describe("isOIDCUserAllowed", () => { it("allows everyone when the allow-list is empty", () => { @@ -199,3 +206,48 @@ describe("extractOidcGroups", () => { 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"); + }); +}); diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 85469e03..6bb5c520 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -6,6 +6,13 @@ import { eq } from "drizzle-orm"; import { DataCrypto } from "../../utils/data-crypto.js"; 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 = { client_id: string; client_secret: string; @@ -417,3 +424,102 @@ export async function loadProviderConfig( 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; + 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, +): LogoutTokenClaims { + if ("nonce" in payload) { + throw new Error("logout_token must not contain a nonce claim"); + } + + const event = (payload.events as Record | 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 { + const payload = await verifyOIDCToken( + logoutToken, + config.issuer_url, + config.client_id, + config.ca_cert, + ); + + return validateLogoutTokenClaims(payload); +} diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 1f18b525..068f2b50 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -26,6 +26,8 @@ import { extractOidcGroups, loadProviderConfig, buildFetchOptions, + resolveProviderByIssuer, + validateLogoutToken, } from "./user-oidc-utils.js"; import { registerUserApiKeyRoutes } from "./user-api-key-routes.js"; import { registerUserSettingsRoutes } from "./user-settings-routes.js"; @@ -1464,10 +1466,16 @@ router.get("/oidc/callback", async (req, res) => { "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, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, rememberMe: storedRememberMe, + oidcSub, + oidcSid, + ssoProviderId: callbackProviderDbId ?? null, }); authLogger.success("OIDC login successful", { @@ -1788,6 +1796,84 @@ router.post("/logout", authenticateJWT, async (req, res) => { } }); +const seenLogoutJti = new Map(); +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 | 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 * /users/me: diff --git a/src/backend/utils/auth-manager-oidc-logout.test.ts b/src/backend/utils/auth-manager-oidc-logout.test.ts new file mode 100644 index 00000000..e8db7c42 --- /dev/null +++ b/src/backend/utils/auth-manager-oidc-logout.test.ts @@ -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>; + 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"); + }); +}); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index c89e8b2b..0d5f291d 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -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 { 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 { + 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 { try { const expiredSessions = await db