mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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
@@ -0,0 +1,220 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import { vi } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
/** Core's 2.8 sso_providers, as db/index.ts created it. */
|
||||
export const LEGACY_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: {
|
||||
user?: string;
|
||||
body?: unknown;
|
||||
form?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any; location: string | null }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
// The IdP is stubbed through the global fetch; calls to the test server
|
||||
// itself still go out for real.
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
linkedUsers?: Record<string, number>;
|
||||
before?: (sqlite: TestDb["sqlite"]) => void;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir, { before: options.before });
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, 1)")
|
||||
.run("admin", "admin");
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions,
|
||||
linkedUsers: options.linkedUsers,
|
||||
baseUrl: "https://termix.test",
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") || undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(
|
||||
method,
|
||||
path,
|
||||
{ user = "admin", body, form, headers = {} } = {},
|
||||
) {
|
||||
const payload =
|
||||
form !== undefined
|
||||
? new URLSearchParams(form).toString()
|
||||
: body !== undefined && method !== "GET"
|
||||
? JSON.stringify(body)
|
||||
: undefined;
|
||||
const response = await realFetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(form !== undefined
|
||||
? { "content-type": "application/x-www-form-urlencoded" }
|
||||
: payload !== undefined
|
||||
? { "content-type": "application/json" }
|
||||
: {}),
|
||||
...headers,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
location: response.headers.get("location"),
|
||||
};
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ISSUER = "https://idp.example";
|
||||
|
||||
export interface FakeIdp {
|
||||
/** Claims the next id_token carries. */
|
||||
claims: Record<string, unknown>;
|
||||
/** Form bodies the token endpoint received. */
|
||||
tokenRequests: URLSearchParams[];
|
||||
/** Signs any payload with the IdP's key, for logout tokens. */
|
||||
sign: (payload: Record<string, unknown>) => Promise<string>;
|
||||
/** Extra handlers, checked first. */
|
||||
routes: Map<string, () => Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal OpenID provider behind the global fetch: discovery, JWKS, a
|
||||
* token endpoint that issues a signed id_token and a userinfo endpoint.
|
||||
*/
|
||||
export async function installIdp(): Promise<FakeIdp> {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||
const jwk = { ...(await exportJWK(publicKey)), kid: "k1", alg: "RS256" };
|
||||
|
||||
const idp: FakeIdp = {
|
||||
claims: {},
|
||||
tokenRequests: [],
|
||||
routes: new Map(),
|
||||
sign: (payload) =>
|
||||
new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "RS256", kid: "k1" })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience("termix")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey),
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const custom = idp.routes.get(url);
|
||||
if (custom) return custom();
|
||||
if (url.startsWith("http://127.0.0.1")) return realFetch(input, init);
|
||||
if (url === `${ISSUER}/.well-known/openid-configuration`) {
|
||||
return Response.json({
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
userinfo_endpoint: `${ISSUER}/userinfo`,
|
||||
});
|
||||
}
|
||||
if (url === `${ISSUER}/jwks`) return Response.json({ keys: [jwk] });
|
||||
if (url === `${ISSUER}/token`) {
|
||||
idp.tokenRequests.push(new URLSearchParams(String(init?.body)));
|
||||
return Response.json({
|
||||
access_token: "access",
|
||||
id_token: await idp.sign(idp.claims),
|
||||
});
|
||||
}
|
||||
if (url === `${ISSUER}/userinfo`) {
|
||||
return Response.json({ email: "alice@example.com" });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}),
|
||||
);
|
||||
return idp;
|
||||
}
|
||||
|
||||
export const OIDC_CONFIG = {
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
issuer_url: ISSUER,
|
||||
authorization_url: `${ISSUER}/authorize`,
|
||||
token_url: `${ISSUER}/token`,
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
admin_group: "termix-admins",
|
||||
role_map: "ops-team:ops",
|
||||
};
|
||||
@@ -0,0 +1,533 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LEGACY_DDL,
|
||||
OIDC_CONFIG,
|
||||
installIdp,
|
||||
startServer,
|
||||
type TestServer,
|
||||
} from "./helpers.js";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
/** A 2.8 database: one OIDC provider and one LDAP directory in one table. */
|
||||
function legacyRows(
|
||||
sqlite: Parameters<
|
||||
NonNullable<Parameters<typeof startServer>[0]>["before"]
|
||||
>[0],
|
||||
) {
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run(3, "Keycloak", "oidc", 1, JSON.stringify(OIDC_CONFIG));
|
||||
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
|
||||
}
|
||||
|
||||
async function startLegacy(options: Parameters<typeof startServer>[0] = {}) {
|
||||
server = await startServer({ before: legacyRows, ...options });
|
||||
return server;
|
||||
}
|
||||
|
||||
/** Runs /start and returns the state the provider would echo back. */
|
||||
async function beginLogin(s: TestServer, query = "provider=3") {
|
||||
const started = await s.request("GET", `/start?${query}`, {
|
||||
headers: { referer: "https://app.termix.test/login" },
|
||||
});
|
||||
expect(started.status).toBe(302);
|
||||
return new URL(started.location!);
|
||||
}
|
||||
|
||||
describe("adopting sso_providers", () => {
|
||||
it("keeps every provider and marks the old ones for the 2.8 redirect URI", async () => {
|
||||
const s = await startLegacy();
|
||||
const rows = s.db.sqlite
|
||||
.prepare(
|
||||
"SELECT id, type, legacy_callback FROM p_sso_providers ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
expect(rows).toEqual([
|
||||
{ id: 3, type: "oidc", legacy_callback: 1 },
|
||||
{ id: 4, type: "ldap", legacy_callback: 1 },
|
||||
]);
|
||||
|
||||
const listed = await s.request("GET", "/providers");
|
||||
expect(listed.status).toBe(200);
|
||||
// LDAP rows are the ldap plugin's; they are not listed here.
|
||||
expect(listed.body.providers).toHaveLength(1);
|
||||
expect(listed.body.providers[0]).toMatchObject({
|
||||
id: 3,
|
||||
name: "Keycloak",
|
||||
legacyCallback: true,
|
||||
hasClientSecret: true,
|
||||
redirectUri: "https://termix.test/users/oidc/callback",
|
||||
});
|
||||
expect(listed.body.providers[0].config.client_secret).toBeUndefined();
|
||||
expect(listed.body.newRedirectUri).toBe(
|
||||
"https://termix.test/plugin-api/sso/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a fresh table on a new install", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: { name: "Keycloak", type: "oidc", config: OIDC_CONFIG },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({
|
||||
legacyCallback: false,
|
||||
redirectUri: "https://termix.test/plugin-api/sso/callback",
|
||||
});
|
||||
const stored = server.db.sqlite
|
||||
.prepare("SELECT config FROM p_sso_providers")
|
||||
.get() as { config: string };
|
||||
expect(JSON.parse(stored.config).client_secret).toMatch(/^sealed:/);
|
||||
});
|
||||
|
||||
it("reads secrets 2.8 stored base64 encoded", async () => {
|
||||
server = await startServer({
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (id, name, type, config) VALUES (1, 'Old', 'oidc', ?)",
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({
|
||||
...OIDC_CONFIG,
|
||||
client_secret: `encoded:${Buffer.from("old-secret").toString("base64")}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const idp = await installIdp();
|
||||
idp.claims = { sub: "sub-1" };
|
||||
const url = await beginLogin(server, "provider=1");
|
||||
idp.claims.nonce = url.searchParams.get("nonce");
|
||||
await server.request(
|
||||
"GET",
|
||||
`/callback?code=c&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(idp.tokenRequests[0].get("client_secret")).toBe("old-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider admin routes", () => {
|
||||
it("need sso.manage", async () => {
|
||||
const s = await startLegacy({ permissions: [] });
|
||||
for (const [method, path] of [
|
||||
["GET", "/providers"],
|
||||
["POST", "/providers"],
|
||||
["PUT", "/providers/3"],
|
||||
["DELETE", "/providers/3"],
|
||||
]) {
|
||||
expect((await s.request(method, path, { body: {} })).status).toBe(403);
|
||||
}
|
||||
// The public routes stay reachable.
|
||||
expect((await s.request("GET", "/config")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("keeps a secret that is not sent again and can leave the old redirect URI", async () => {
|
||||
const s = await startLegacy();
|
||||
const updated = await s.request("PUT", "/providers/3", {
|
||||
body: { legacyCallback: false, config: { client_secret: "" } },
|
||||
});
|
||||
expect(updated.body).toMatchObject({
|
||||
legacyCallback: false,
|
||||
hasClientSecret: true,
|
||||
redirectUri: "https://termix.test/plugin-api/sso/callback",
|
||||
});
|
||||
});
|
||||
|
||||
it("validates a new provider", async () => {
|
||||
const s = await startLegacy();
|
||||
const missing = await s.request("POST", "/providers", {
|
||||
body: { name: "X", type: "oidc", config: { client_id: "a" } },
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
const userinfo = await s.request("POST", "/providers", {
|
||||
body: {
|
||||
name: "X",
|
||||
type: "oidc",
|
||||
config: { ...OIDC_CONFIG, issuer_url: "https://idp.example/userinfo" },
|
||||
},
|
||||
});
|
||||
expect(userinfo.status).toBe(400);
|
||||
const ldap = await s.request("POST", "/providers", {
|
||||
body: { name: "X", type: "ldap", config: {} },
|
||||
});
|
||||
expect(ldap.status).toBe(400);
|
||||
});
|
||||
|
||||
it("will not delete a provider people still sign in with", async () => {
|
||||
const s = await startLegacy({ linkedUsers: { "3": 2 } });
|
||||
expect((await s.request("DELETE", "/providers/3")).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the login method", () => {
|
||||
it("offers one button per enabled provider", async () => {
|
||||
const s = await startLegacy();
|
||||
const method = s.mock.auth.loginMethods[0];
|
||||
expect(method).toMatchObject({
|
||||
id: "oidc",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
});
|
||||
expect(await method.describe!()).toEqual([
|
||||
{ id: "3", label: "Keycloak", type: "oidc", enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the provider configured through the environment", async () => {
|
||||
server = await startServer();
|
||||
vi.stubEnv("OIDC_CLIENT_ID", "env-client");
|
||||
vi.stubEnv("OIDC_CLIENT_SECRET", "env-secret");
|
||||
vi.stubEnv("OIDC_ISSUER_URL", "https://idp.example");
|
||||
vi.stubEnv("OIDC_AUTHORIZATION_URL", "https://idp.example/authorize");
|
||||
vi.stubEnv("OIDC_TOKEN_URL", "https://idp.example/token");
|
||||
expect(await server.mock.auth.loginMethods[0].describe!()).toEqual([
|
||||
{ id: "0", label: "SSO", type: "oidc", enabled: true },
|
||||
]);
|
||||
const url = await beginLogin(server, "provider=0");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://termix.test/users/oidc/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("starts with PKCE and the redirect URI the provider was set up with", async () => {
|
||||
const s = await startLegacy();
|
||||
const url = await beginLogin(s);
|
||||
expect(url.origin + url.pathname).toBe("https://idp.example/authorize");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://termix.test/users/oidc/callback",
|
||||
);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
||||
expect([...s.mock.kv.keys()]).toEqual([
|
||||
`state:${url.searchParams.get("state")}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers 404 for a provider that does not exist", async () => {
|
||||
const s = await startLegacy();
|
||||
expect((await s.request("GET", "/start?provider=99")).status).toBe(404);
|
||||
// An LDAP row is not an SSO provider.
|
||||
expect((await s.request("GET", "/start?provider=4")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the callback", () => {
|
||||
it("verifies the id token and hands core the identity", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = {
|
||||
sub: "sub-1",
|
||||
sid: "sid-1",
|
||||
name: "Alice",
|
||||
nonce: url.searchParams.get("nonce"),
|
||||
groups: ["termix-admins", "ops-team"],
|
||||
};
|
||||
|
||||
const callback = await s.request(
|
||||
"GET",
|
||||
`/callback?code=abc&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(callback.status).toBe(200);
|
||||
expect(callback.body.identity).toMatchObject({
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
isAdmin: true,
|
||||
roles: { desired: ["ops"], managed: ["ops"] },
|
||||
logoutClaims: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
legacy: { identifier: "sub-1", providerRowId: 3 },
|
||||
returnTo: "https://app.termix.test",
|
||||
});
|
||||
|
||||
const token = idp.tokenRequests[0];
|
||||
expect(token.get("redirect_uri")).toBe(
|
||||
"https://termix.test/users/oidc/callback",
|
||||
);
|
||||
expect(token.get("code_verifier")).toBeTruthy();
|
||||
// The state is used up.
|
||||
expect(s.mock.kv.size).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts the provider's answer as a form post", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = { sub: "sub-1", nonce: url.searchParams.get("nonce") };
|
||||
const callback = await s.request("POST", "/callback", {
|
||||
form: { code: "abc", state: url.searchParams.get("state")! },
|
||||
});
|
||||
expect(callback.body.identity.subject).toBe("sub-1");
|
||||
});
|
||||
|
||||
it("sends the user back with an error on a nonce mismatch", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = { sub: "sub-1", nonce: "someone-elses" };
|
||||
const callback = await s.request(
|
||||
"GET",
|
||||
`/callback?code=abc&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(callback.status).toBe(302);
|
||||
expect(callback.location).toMatch(/^https:\/\/app\.termix\.test\/\?error=/);
|
||||
expect(s.mock.auth.completedLogins).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses an unknown or replayed state", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const url = await beginLogin(s);
|
||||
idp.claims = { sub: "sub-1", nonce: url.searchParams.get("nonce") };
|
||||
const state = url.searchParams.get("state");
|
||||
expect(
|
||||
(await s.request("GET", `/callback?code=a&state=${state}`)).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await s.request("GET", `/callback?code=a&state=${state}`)).status,
|
||||
).toBe(400);
|
||||
expect((await s.request("GET", "/callback?code=a&state=nope")).status).toBe(
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("signs GitHub users in by their numeric id", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/providers", {
|
||||
body: {
|
||||
name: "GitHub",
|
||||
type: "github",
|
||||
config: { client_id: "gh", client_secret: "gh-secret" },
|
||||
},
|
||||
});
|
||||
const idp = await installIdp();
|
||||
idp.routes.set("https://github.com/login/oauth/access_token", () =>
|
||||
Response.json({ access_token: "gh-token" }),
|
||||
);
|
||||
idp.routes.set("https://api.github.com/user", () =>
|
||||
Response.json({ id: 42, login: "octo" }),
|
||||
);
|
||||
idp.routes.set("https://api.github.com/user/emails", () =>
|
||||
Response.json([
|
||||
{ email: "octo@example.com", primary: true, verified: true },
|
||||
]),
|
||||
);
|
||||
const url = await beginLogin(server, `provider=${created.body.id}`);
|
||||
const callback = await server.request(
|
||||
"GET",
|
||||
`/callback?code=abc&state=${url.searchParams.get("state")}`,
|
||||
);
|
||||
expect(callback.body.identity).toMatchObject({
|
||||
provider: String(created.body.id),
|
||||
subject: "42",
|
||||
name: "octo",
|
||||
email: "octo@example.com",
|
||||
legacy: { identifier: `github:${created.body.id}:42` },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("back-channel logout", () => {
|
||||
const event = {
|
||||
"http://schemas.openid.net/event/backchannel-logout": {},
|
||||
};
|
||||
|
||||
it("ends the sessions from that provider login, once per token", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
const token = await idp.sign({ sid: "sid-1", jti: "j1", events: event });
|
||||
|
||||
const first = await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: token },
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
expect(s.mock.auth.revokedSessions).toEqual([
|
||||
{ providerId: 3, sub: null, sid: "sid-1" },
|
||||
]);
|
||||
|
||||
const replay = await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: token },
|
||||
});
|
||||
expect(replay.status).toBe(200);
|
||||
expect(s.mock.auth.revokedSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses a token that is missing, unsigned or from an unknown issuer", async () => {
|
||||
const s = await startLegacy();
|
||||
const idp = await installIdp();
|
||||
expect(
|
||||
(await s.request("POST", "/backchannel-logout", { form: {} })).status,
|
||||
).toBe(400);
|
||||
const withNonce = await idp.sign({
|
||||
sid: "s",
|
||||
jti: "j2",
|
||||
nonce: "n",
|
||||
events: event,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: withNonce },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
const header = Buffer.from('{"alg":"none"}').toString("base64url");
|
||||
const body = Buffer.from('{"iss":"https://elsewhere.example"}').toString(
|
||||
"base64url",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await s.request("POST", "/backchannel-logout", {
|
||||
form: { logout_token: `${header}.${body}.x` },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
expect(s.mock.auth.revokedSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it.each(["db:own", "auth:provide", "network:serve"])(
|
||||
"activate fails closed without %s",
|
||||
async (capability) => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== capability),
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("cannot start a login without kv:own", async () => {
|
||||
const { manifest } = await import("./helpers.js");
|
||||
const s = await startLegacy({
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "kv:own"),
|
||||
});
|
||||
expect((await s.request("GET", "/start?provider=3")).status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
const providers = {
|
||||
providers: [
|
||||
{
|
||||
id: 3,
|
||||
name: "Keycloak",
|
||||
type: "oidc",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
legacyCallback: true,
|
||||
config: { client_id: "termix" },
|
||||
hasClientSecret: true,
|
||||
redirectUri: "https://termix.test/users/oidc/callback",
|
||||
},
|
||||
],
|
||||
newRedirectUri: "https://termix.test/plugin-api/sso/callback",
|
||||
};
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the login buttons and the provider settings", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.loginMethods()).toEqual(["oidc"]);
|
||||
expect(rendered.registered.settingsComponents()).toEqual(["providers"]);
|
||||
});
|
||||
|
||||
it("draws a button per provider that starts the redirect", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const startRedirect = vi.fn(async () => {});
|
||||
rendered.renderLoginMethod("oidc", {
|
||||
instances: [
|
||||
{ id: "3", label: "Keycloak" },
|
||||
{ id: "5", label: "Google" },
|
||||
],
|
||||
startRedirect,
|
||||
});
|
||||
fireEvent.click(screen.getByText("Login with Google"));
|
||||
await waitFor(() => expect(startRedirect).toHaveBeenCalledWith("5"));
|
||||
expect(screen.getByText("Login with Keycloak")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists providers with the redirect URI to register", async () => {
|
||||
const get = vi.fn(async () => ({ data: providers }));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get } as never,
|
||||
});
|
||||
rendered.renderSettingsComponent("providers");
|
||||
expect(await screen.findByText("Keycloak")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("https://termix.test/users/oidc/callback"),
|
||||
).toBeTruthy();
|
||||
expect(get).toHaveBeenCalledWith("/providers");
|
||||
});
|
||||
|
||||
it("adds a provider and never sends an empty secret", async () => {
|
||||
const get = vi.fn(async () => ({
|
||||
data: { providers: [], newRedirectUri: providers.newRedirectUri },
|
||||
}));
|
||||
const post = vi.fn(async () => ({ data: {} }));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: { get, post } as never,
|
||||
});
|
||||
rendered.renderSettingsComponent("providers");
|
||||
fireEvent.click(await screen.findByText(locales.providers.add));
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(locales.providers.namePlaceholder),
|
||||
{ target: { value: "Corp" } },
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("your-client-id"), {
|
||||
target: { value: "termix" },
|
||||
});
|
||||
fireEvent.click(screen.getByText(locales.providers.save));
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
const [path, body] = post.mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ name: string; type: string; config: Record<string, string> },
|
||||
];
|
||||
expect(path).toBe("/providers");
|
||||
expect(body).toMatchObject({ name: "Corp", type: "oidc" });
|
||||
expect(body.config.client_id).toBe("termix");
|
||||
expect(body.config).not.toHaveProperty("client_secret");
|
||||
});
|
||||
|
||||
it("removes everything on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.loginMethods()).toEqual([]);
|
||||
expect(app.registered.settingsComponents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user