mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-24 23:58:23 +00:00
534 lines
17 KiB
TypeScript
534 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
import {
|
|
describeFetchFailure,
|
|
extractOidcGroups,
|
|
extractOidcGroupsFromSources,
|
|
parseOidcRoleMap,
|
|
resolveOidcMappedRoles,
|
|
validateLogoutTokenClaims,
|
|
verifyOIDCToken,
|
|
} from "../../src/backend/oidc-protocol.js";
|
|
import {
|
|
getOidcConfigFromEnv as getOIDCConfigFromEnv,
|
|
isEnvOverrideEnabled as isOIDCEnvOverrideEnabled,
|
|
} from "../../src/backend/providers.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("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("../../src/backend/oidc-protocol.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();
|
|
});
|
|
});
|