mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43: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_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,
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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,
|
||||
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<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
|
||||
* /users/me:
|
||||
|
||||
Reference in New Issue
Block a user