mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
293 lines
8.8 KiB
TypeScript
293 lines
8.8 KiB
TypeScript
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,
|
|
validateLogoutTokenClaims,
|
|
verifyOIDCToken,
|
|
} = await import("../../../database/routes/user-oidc-utils.js");
|
|
|
|
const BACKCHANNEL_LOGOUT_EVENT =
|
|
"http://schemas.openid.net/event/backchannel-logout";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("verifyOIDCToken", () => {
|
|
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",
|
|
];
|
|
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");
|
|
});
|
|
});
|
|
|
|
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("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");
|
|
});
|
|
});
|