feat: replace openid-client with clean-room oidc system

This commit is contained in:
Aarnav Tale
2026-04-03 16:36:27 -04:00
parent 4cd0c1e206
commit 1259642f8a
15 changed files with 1860 additions and 571 deletions
+167
View File
@@ -0,0 +1,167 @@
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");
});
});
+30
View File
@@ -0,0 +1,30 @@
import tc from "testcontainers";
export interface DexEnv {
container: tc.StartedTestContainer;
issuerUrl: string;
}
export async function startDex(): Promise<DexEnv> {
const container = await new tc.GenericContainer("dexidp/dex:v2.41.1")
.withExposedPorts(5556)
.withEnvironment({
DEX_ISSUER: "http://0.0.0.0:5556",
DEX_ENABLE_PASSWORD_DB: "true",
DEX_OAUTH2_SKIP_APPROVAL_SCREEN: "true",
})
.withWaitStrategy(tc.Wait.forLogMessage("listening on", 1).withStartupTimeout(30_000))
.start();
const host = container.getHost();
const port = container.getMappedPort(5556);
// Dex's issuer is configured as http://0.0.0.0:5556 inside the
// container. The external URL uses the mapped port. Discovery
// will return endpoints with the internal issuer, but that's fine
// for testing discovery + startFlow. The issuer mismatch is
// expected and logged at debug level.
const issuerUrl = `http://${host}:${port}`;
return { container, issuerUrl };
}