mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -1,104 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SsoProviderRepository } from "../../../database/repositories/sso-provider-repository.js";
|
||||
|
||||
describe("SsoProviderRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SsoProviderRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
|
||||
return new SsoProviderRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, lists, finds, updates, and deletes providers", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const disabled = await repo.create({
|
||||
name: "Disabled",
|
||||
type: "oidc",
|
||||
enabled: false,
|
||||
displayOrder: 1,
|
||||
config: "{}",
|
||||
});
|
||||
const enabled = await repo.create({
|
||||
name: "GitHub",
|
||||
type: "github",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: '{"client_id":"id"}',
|
||||
});
|
||||
|
||||
expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([
|
||||
enabled.id,
|
||||
]);
|
||||
expect((await repo.listAll()).map((row) => row.id)).toEqual([
|
||||
enabled.id,
|
||||
disabled.id,
|
||||
]);
|
||||
expect((await repo.findById(enabled.id))?.name).toBe("GitHub");
|
||||
expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id);
|
||||
|
||||
const updated = await repo.update(enabled.id, {
|
||||
name: "GitHub SSO",
|
||||
config: '{"client_id":"updated"}',
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect(updated?.name).toBe("GitHub SSO");
|
||||
expect(updated?.config).toContain("updated");
|
||||
|
||||
expect(await repo.delete(enabled.id)).toBe(true);
|
||||
expect(await repo.findById(enabled.id)).toBeNull();
|
||||
expect(await repo.delete(enabled.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts users associated with a provider", async () => {
|
||||
const repo = await createRepository();
|
||||
const provider = await repo.create({
|
||||
name: "LDAP",
|
||||
type: "ldap",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
await adapter!.exec(`
|
||||
INSERT INTO users (id, username, password_hash, sso_provider_id)
|
||||
VALUES ('user-1', 'u1', 'hash', ${provider.id}),
|
||||
('user-2', 'u2', 'hash', ${provider.id}),
|
||||
('user-3', 'u3', 'hash', NULL);
|
||||
`);
|
||||
|
||||
expect(await repo.countUsersByProviderId(provider.id)).toBe(2);
|
||||
});
|
||||
|
||||
it("runs the write hook after provider writes", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const provider = await repo.create({
|
||||
name: "OIDC",
|
||||
type: "oidc",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: "{}",
|
||||
});
|
||||
await repo.update(provider.id, { enabled: false });
|
||||
await repo.delete(provider.id);
|
||||
await repo.delete(provider.id);
|
||||
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The 2.8 login URLs identity providers and 2.8 clients still call. They
|
||||
* forward to whatever replaced them and, with the method's plugin gone, sign
|
||||
* nobody in.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { authLogger: log, databaseLogger: log, logger: log };
|
||||
});
|
||||
vi.mock("../../../auth/core-auth.js", () => ({
|
||||
ensureCoreLoginProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../../utils/trusted-proxy-auth.js", () => ({
|
||||
isTrustedProxyAuthEnabled: () => false,
|
||||
}));
|
||||
|
||||
const { registerAuthCompatRoutes } =
|
||||
await import("../../../database/routes/auth-compat-routes.js");
|
||||
const { registerLoginMethod, resetAuthRegistryForTests } =
|
||||
await import("../../../auth/registry.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetAuthRegistryForTests();
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerAuthCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/users", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function get(path: string) {
|
||||
return fetch(`${base}${path}`, { redirect: "manual" });
|
||||
}
|
||||
|
||||
function registerSso() {
|
||||
return registerLoginMethod({
|
||||
id: "oidc",
|
||||
pluginId: "sso",
|
||||
labelKey: "loginWithSso",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "3", label: "Keycloak", enabled: true, type: "oidc" },
|
||||
],
|
||||
start: async (_request, instanceId) => ({
|
||||
redirectUrl: `https://idp.example/auth?provider=${instanceId}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("the old identity provider URLs", () => {
|
||||
it("forwards the callback to the sso plugin, query intact", async () => {
|
||||
const response = await get("/users/oidc/callback?code=abc&state=xyz");
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/sso/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
|
||||
const response = await fetch(`${base}/users/oidc/callback`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(308);
|
||||
});
|
||||
|
||||
it("forwards back-channel logout to the sso plugin", async () => {
|
||||
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/\/plugin-api\/sso\/backchannel-logout$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the public config to the sso plugin", async () => {
|
||||
const response = await get("/users/oidc-config");
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/\/plugin-api\/sso\/config$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old client routes", () => {
|
||||
it("starts an SSO login through the oidc method", async () => {
|
||||
registerSso();
|
||||
const response = await get("/users/oidc/authorize?providerId=3");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
auth_url: "https://idp.example/auth?provider=3",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists providers in the 2.8 shape", async () => {
|
||||
registerSso();
|
||||
const response = await get("/users/sso-providers");
|
||||
expect(await response.json()).toEqual([
|
||||
{ id: 3, name: "Keycloak", type: "oidc", displayOrder: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers 404 once the plugins are gone", async () => {
|
||||
const dispose = registerSso();
|
||||
dispose();
|
||||
expect((await get("/users/oidc/authorize?providerId=3")).status).toBe(404);
|
||||
expect(await (await get("/users/sso-providers")).json()).toEqual([]);
|
||||
const ldap = await fetch(`${base}/users/ldap/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ providerId: 4, username: "bob", password: "x" }),
|
||||
});
|
||||
expect(ldap.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({ createAdminMiddleware: vi.fn() }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { isValidOidcIssuer } =
|
||||
await import("../../../database/routes/sso-provider-routes.js");
|
||||
|
||||
describe("isValidOidcIssuer", () => {
|
||||
it("rejects userinfo endpoints used as issuer URLs", () => {
|
||||
expect(
|
||||
isValidOidcIssuer("https://auth.example/application/o/userinfo/"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an Authentik application issuer", () => {
|
||||
expect(isValidOidcIssuer("https://auth.example/application/o/termix")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Linking an external (SSO or LDAP) account to a password account and back.
|
||||
* The linked account keeps its sign-in identities, so it signs in through the
|
||||
* same provider afterwards; unlinking drops them.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash: string;
|
||||
oidcIdentifier?: string | null;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
users: new Map<string, User>(),
|
||||
identities: [] as Array<{
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
}>,
|
||||
deleted: [] as string[],
|
||||
revoked: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => h.users.get(id) ?? null,
|
||||
findByUsername: async (username: string) =>
|
||||
[...h.users.values()].find((user) => user.username === username) ?? null,
|
||||
update: async (id: string, changes: Partial<User>) => {
|
||||
Object.assign(h.users.get(id)!, changes);
|
||||
},
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
moveIdentities: async (from: string, to: string) => {
|
||||
for (const row of h.identities) if (row.userId === from) row.userId = to;
|
||||
},
|
||||
unlinkIdentitiesForUser: async (userId: string) => {
|
||||
h.identities = h.identities.filter((row) => row.userId !== userId);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
||||
deleteUserAndRelatedData: async (userId: string) => {
|
||||
h.deleted.push(userId);
|
||||
h.users.delete(userId);
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: {} }));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { authLogger: log };
|
||||
});
|
||||
|
||||
const { registerUserOidcAccountRoutes } =
|
||||
await import("../../../database/routes/user-oidc-account-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
h.users = new Map([
|
||||
[
|
||||
"admin",
|
||||
{
|
||||
id: "admin",
|
||||
username: "admin",
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
passwordHash: "x",
|
||||
},
|
||||
],
|
||||
[
|
||||
"sso",
|
||||
{
|
||||
id: "sso",
|
||||
username: "alice-sso",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "ldap:4:alice",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local",
|
||||
{
|
||||
id: "local",
|
||||
username: "alice",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
},
|
||||
],
|
||||
]);
|
||||
h.identities = [{ userId: "sso", providerId: "ldap:4", subject: "alice" }];
|
||||
h.deleted = [];
|
||||
h.revoked = [];
|
||||
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerUserOidcAccountRoutes(router, {
|
||||
authenticateJWT: (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId =
|
||||
req.header("x-user") ?? "";
|
||||
next();
|
||||
},
|
||||
authManager: {
|
||||
revokeAllUserSessions: async (userId: string) => {
|
||||
h.revoked.push(userId);
|
||||
},
|
||||
logoutUser: () => {},
|
||||
} as never,
|
||||
});
|
||||
const app = express();
|
||||
app.use("/users", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function post(path: string, body: unknown, user = "admin") {
|
||||
return fetch(`${base}/users${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-user": user },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("linking an external account to a password account", () => {
|
||||
it("moves the identities and the old identifier, then removes the external account", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.users.get("local")).toMatchObject({
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:alice",
|
||||
});
|
||||
expect(h.identities).toEqual([
|
||||
{ userId: "local", providerId: "ldap:4", subject: "alice" },
|
||||
]);
|
||||
expect(h.revoked).toEqual(["sso"]);
|
||||
expect(h.deleted).toEqual(["sso"]);
|
||||
});
|
||||
|
||||
it("is for admins only", async () => {
|
||||
const response = await post(
|
||||
"/link-oidc-to-password",
|
||||
{ oidcUserId: "sso", targetUsername: "alice" },
|
||||
"local",
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(h.identities[0].userId).toBe("sso");
|
||||
});
|
||||
|
||||
it("refuses a target that is not a password account", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice-sso",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinking", () => {
|
||||
it("drops the identities so the provider no longer signs in as the user", async () => {
|
||||
await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "local",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.users.get("local")).toMatchObject({
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to leave an account with no way in", async () => {
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "sso",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(h.identities).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,609 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
isOIDCUserAllowed,
|
||||
getOIDCConfigFromEnv,
|
||||
extractOidcGroups,
|
||||
extractOidcGroupsFromSources,
|
||||
validateLogoutTokenClaims,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
describeFetchFailure,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("describeFetchFailure", () => {
|
||||
it("unwraps the undici cause, which carries the reason that matters", () => {
|
||||
// Every transport failure surfaces as this same outer message.
|
||||
const error = new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
|
||||
code: "ENOTFOUND",
|
||||
}),
|
||||
});
|
||||
expect(describeFetchFailure(error)).toBe(
|
||||
"fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the outer message when there is no cause", () => {
|
||||
expect(describeFetchFailure(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("handles a non-Error throw", () => {
|
||||
expect(describeFetchFailure("nope")).toBe("nope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken JWKS diagnostics", () => {
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const token = "header.payload.signature";
|
||||
|
||||
it("reports every attempted URL and why it failed", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("not found", { status: 404 }),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/openid-configuration: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/jwks.json: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its underlying cause", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("self-signed certificate"), {
|
||||
code: "SELF_SIGNED_CERT_IN_CHAIN",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
"self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when discovery succeeds but advertises no jwks_uri", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ issuer }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValue(new Response("not found", { status: 404 }));
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain("no jwks_uri in the discovery document");
|
||||
});
|
||||
|
||||
it("says so when a JWKS response carries no keys array", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
'https://idp.example/keys: response contains no "keys" array',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken", () => {
|
||||
it("accepts a discovery document URL as the configured issuer", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "google-key";
|
||||
|
||||
const issuer = "https://accounts.google.com";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(
|
||||
token,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
clientId,
|
||||
);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "entra-key";
|
||||
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(token, issuer, clientId);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOIDCUserAllowed", () => {
|
||||
it("allows everyone when the allow-list is empty", () => {
|
||||
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
|
||||
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows everyone with the '*' wildcard", () => {
|
||||
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact identifier (case-insensitive)", () => {
|
||||
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
|
||||
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
|
||||
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against the email as well as the identifier", () => {
|
||||
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches an @domain suffix pattern", () => {
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies users not on the list", () => {
|
||||
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores blank entries and surrounding whitespace in the list", () => {
|
||||
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the email against an identifier-only pattern when email differs", () => {
|
||||
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches *@domain.com wildcard pattern against emails", () => {
|
||||
expect(
|
||||
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
|
||||
).toBe(true);
|
||||
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches glob patterns with multiple wildcards", () => {
|
||||
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
|
||||
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOIDCConfigFromEnv", () => {
|
||||
const REQUIRED = [
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_AUTHORIZATION_URL",
|
||||
"OIDC_TOKEN_URL",
|
||||
];
|
||||
const OPTIONAL = [
|
||||
"OIDC_USERINFO_URL",
|
||||
"OIDC_IDENTIFIER_PATH",
|
||||
"OIDC_NAME_PATH",
|
||||
"OIDC_SCOPES",
|
||||
"OIDC_ALLOWED_USERS",
|
||||
"OIDC_ADMIN_GROUP",
|
||||
"OIDC_ENV_OVERRIDE",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when any required variable is missing", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
// issuer/authorization/token urls intentionally missing
|
||||
expect(getOIDCConfigFromEnv()).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a config with defaults when all required vars are present", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.client_id).toBe("id");
|
||||
expect(config?.identifier_path).toBe("sub");
|
||||
expect(config?.name_path).toBe("name");
|
||||
expect(config?.scopes).toBe("openid email profile");
|
||||
expect(config?.userinfo_url).toBe("");
|
||||
});
|
||||
|
||||
it("honors overrides for optional vars", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
process.env.OIDC_IDENTIFIER_PATH = "email";
|
||||
process.env.OIDC_SCOPES = "openid";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config?.identifier_path).toBe("email");
|
||||
expect(config?.scopes).toBe("openid");
|
||||
});
|
||||
|
||||
it("only enables database recovery override when explicitly requested", () => {
|
||||
expect(isOIDCEnvOverrideEnabled()).toBe(false);
|
||||
process.env.OIDC_ENV_OVERRIDE = "true";
|
||||
expect(isOIDCEnvOverrideEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroups", () => {
|
||||
it("reads the standard groups claim as an array", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits a comma-separated string claim", () => {
|
||||
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back through groups, roles, then group", () => {
|
||||
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
|
||||
});
|
||||
|
||||
it("reads a custom claim path when provided", () => {
|
||||
const userInfo = {
|
||||
"zitadel:grants:groups:123": ["user", "admin"],
|
||||
groups: ["ignored"],
|
||||
};
|
||||
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
|
||||
"user",
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses object keys as group names (Zitadel roles object)", () => {
|
||||
const userInfo = {
|
||||
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
|
||||
};
|
||||
expect(
|
||||
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
|
||||
).toEqual(["admin", "user"]);
|
||||
});
|
||||
|
||||
it("falls back to defaults when the custom claim is absent", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when no groups are present", () => {
|
||||
expect(extractOidcGroups({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroupsFromSources", () => {
|
||||
it("preserves ID token groups when userinfo omits them", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ groups: ["admins", "users"] },
|
||||
{ sub: "user-1", name: "Example User" },
|
||||
]),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
|
||||
it("combines and deduplicates groups from both verified sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ roles: ["users", "operators"] },
|
||||
{ roles: ["operators", "admins"] },
|
||||
]),
|
||||
).toEqual(["users", "operators", "admins"]);
|
||||
});
|
||||
|
||||
it("supports a configured group claim across sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources(
|
||||
[{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
|
||||
"custom_groups",
|
||||
),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOidcRoleMap", () => {
|
||||
it("returns an empty map for blank input", () => {
|
||||
expect(parseOidcRoleMap(undefined).size).toBe(0);
|
||||
expect(parseOidcRoleMap(null).size).toBe(0);
|
||||
expect(parseOidcRoleMap(" ").size).toBe(0);
|
||||
});
|
||||
|
||||
it("parses comma-separated group:role pairs", () => {
|
||||
const map = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
expect(map.get("devops-seniors")).toBe("devops-senior");
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it("parses newline-separated pairs and trims whitespace", () => {
|
||||
const map = parseOidcRoleMap(" a : role-a \n b:role-b \n");
|
||||
expect(map.get("a")).toBe("role-a");
|
||||
expect(map.get("b")).toBe("role-b");
|
||||
});
|
||||
|
||||
it("normalizes leading slashes and case in group names", () => {
|
||||
const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
});
|
||||
|
||||
it("skips malformed entries instead of throwing", () => {
|
||||
const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get("ok")).toBe("r");
|
||||
});
|
||||
|
||||
it("splits on the last colon so group names may contain colons", () => {
|
||||
const map = parseOidcRoleMap("ns:team:role-x");
|
||||
expect(map.get("ns:team")).toBe("role-x");
|
||||
});
|
||||
|
||||
it("preserves role-name case verbatim", () => {
|
||||
// Role names must match roles.name exactly, so they are not lowercased.
|
||||
expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOidcMappedRoles", () => {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
|
||||
it("reports every mapped role as managed regardless of membership", () => {
|
||||
const { managed } = resolveOidcMappedRoles([], roleMap);
|
||||
expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires only the roles whose groups the user is in", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("matches full group paths emitted by Keycloak", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-senior"]);
|
||||
});
|
||||
|
||||
it("ignores groups that are not mapped", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["finance", "devops-interns"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("supports a user in multiple mapped groups", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["devops-interns", "devops-seniors"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires nothing when the map is empty", () => {
|
||||
const { desired, managed } = resolveOidcMappedRoles(
|
||||
["devops-interns"],
|
||||
new Map(),
|
||||
);
|
||||
expect(desired.size).toBe(0);
|
||||
expect(managed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Imported as a namespace rather than destructured into the shared block at the
|
||||
// top of the file, so this suite stays independent of what that block binds.
|
||||
const oidcUtils = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
describe("verifyOIDCToken token shape", () => {
|
||||
const issuer = "https://idp.example.com/application/o/termix";
|
||||
|
||||
// The shape check runs before any network call, so no fetch stub is needed.
|
||||
const fetchSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchSpy.mockReset();
|
||||
});
|
||||
|
||||
it("reports an encrypted (JWE) token as a format error", async () => {
|
||||
const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");
|
||||
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(/JWE \(encrypted\)/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports any other non-JWS segment count as a format error", async () => {
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 2/);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 1/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a three-segment token through to key resolution", async () => {
|
||||
fetchSpy.mockResolvedValue({ ok: false });
|
||||
|
||||
// Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
|
||||
).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user