mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
168 lines
5.0 KiB
TypeScript
168 lines
5.0 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
|
|
import { createOidcService, type OidcConfig } from "~/server/oidc/provider";
|
|
|
|
import { type DexEnv, startDex } from "./start-dex";
|
|
|
|
vi.mock("~/utils/log", () => ({
|
|
default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
|
}));
|
|
|
|
let dex: DexEnv;
|
|
|
|
beforeAll(async () => {
|
|
dex = await startDex();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await dex?.container.stop({ remove: true, removeVolumes: true });
|
|
});
|
|
|
|
function dexConfig(overrides?: Partial<OidcConfig>): OidcConfig {
|
|
// Dex's issuer inside the container is http://0.0.0.0:5556 but we
|
|
// connect via the mapped port. We provide manual endpoint overrides
|
|
// pointing to the external URL so the service can actually reach them,
|
|
// while the issuer stays as configured in Dex for JWT validation.
|
|
return {
|
|
issuer: "http://0.0.0.0:5556",
|
|
clientId: "test-client",
|
|
clientSecret: "test-secret",
|
|
baseUrl: "http://localhost",
|
|
authorizationEndpoint: `${dex.issuerUrl}/auth`,
|
|
tokenEndpoint: `${dex.issuerUrl}/token`,
|
|
userinfoEndpoint: `${dex.issuerUrl}/userinfo`,
|
|
jwksUri: `${dex.issuerUrl}/keys`,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("discovery against real Dex", () => {
|
|
test("resolves endpoints via manual overrides", async () => {
|
|
const svc = createOidcService(dexConfig());
|
|
const result = await svc.discover();
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
|
|
expect(result.value.authorizationEndpoint).toContain("/auth");
|
|
expect(result.value.tokenEndpoint).toContain("/token");
|
|
expect(result.value.jwksUri).toContain("/keys");
|
|
});
|
|
|
|
test("fetches real discovery document from Dex", async () => {
|
|
const svc = createOidcService({
|
|
issuer: dex.issuerUrl,
|
|
clientId: "test-client",
|
|
clientSecret: "test-secret",
|
|
baseUrl: "http://localhost",
|
|
});
|
|
|
|
const result = await svc.discover();
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
|
|
// Dex returns endpoints with the internal issuer
|
|
expect(result.value.authorizationEndpoint).toContain("/auth");
|
|
expect(result.value.tokenEndpoint).toContain("/token");
|
|
expect(result.value.jwksUri).toContain("/keys");
|
|
});
|
|
|
|
test("status is ready after discovery", async () => {
|
|
const svc = createOidcService(dexConfig());
|
|
await svc.discover();
|
|
expect(svc.status().state).toBe("ready");
|
|
});
|
|
});
|
|
|
|
describe("startFlow against real Dex", () => {
|
|
test("builds a valid authorization URL", async () => {
|
|
const svc = createOidcService(dexConfig());
|
|
const result = await svc.startFlow();
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(result.value.url);
|
|
expect(url.pathname).toBe("/auth");
|
|
expect(url.searchParams.get("client_id")).toBe("test-client");
|
|
expect(url.searchParams.get("response_type")).toBe("code");
|
|
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/admin/oidc/callback");
|
|
expect(url.searchParams.get("scope")).toContain("openid");
|
|
});
|
|
|
|
test("PKCE challenge is included by default", async () => {
|
|
const svc = createOidcService(dexConfig());
|
|
const result = await svc.startFlow();
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(result.value.url);
|
|
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("handleCallback error handling against real Dex", () => {
|
|
test("invalid authorization code returns error", async () => {
|
|
const svc = createOidcService(dexConfig({ usePkce: false }));
|
|
const flowResult = await svc.startFlow();
|
|
if (!flowResult.ok) {
|
|
throw new Error("startFlow failed");
|
|
}
|
|
|
|
const { flowState } = flowResult.value;
|
|
const params = new URLSearchParams({
|
|
code: "invalid-code",
|
|
state: flowState.state,
|
|
});
|
|
|
|
const result = await svc.handleCallback(params, flowState);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
test("state mismatch detected before hitting Dex", async () => {
|
|
const svc = createOidcService(dexConfig());
|
|
const flowResult = await svc.startFlow();
|
|
if (!flowResult.ok) {
|
|
throw new Error("startFlow failed");
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
code: "any-code",
|
|
state: "tampered-state",
|
|
});
|
|
|
|
const result = await svc.handleCallback(params, flowResult.value.flowState);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
|
|
expect(result.error.code).toBe("state_mismatch");
|
|
});
|
|
});
|
|
|
|
describe("invalidate and rediscovery against real Dex", () => {
|
|
test("invalidate forces rediscovery", async () => {
|
|
const svc = createOidcService(dexConfig());
|
|
await svc.discover();
|
|
expect(svc.status().state).toBe("ready");
|
|
|
|
svc.invalidate();
|
|
expect(svc.status().state).toBe("pending");
|
|
|
|
const result = await svc.discover();
|
|
expect(result.ok).toBe(true);
|
|
expect(svc.status().state).toBe("ready");
|
|
});
|
|
});
|