mirror of
https://github.com/tale/headplane.git
synced 2026-08-19 17:36:19 +00:00
feat: replace openid-client with clean-room oidc system
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
|
||||
import { SignJWT, exportJWK, generateKeyPair } from "jose";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createOidcService, type OidcConfig } from "~/server/oidc/provider";
|
||||
|
||||
vi.mock("~/utils/log", () => ({
|
||||
default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let privateKey: CryptoKey;
|
||||
let publicJwk: Record<string, unknown>;
|
||||
|
||||
const CLIENT_ID = "test-client";
|
||||
const CLIENT_SECRET = "test-secret";
|
||||
|
||||
let tokenHandler: (req: IncomingMessage, res: ServerResponse) => void;
|
||||
let userinfoHandler: ((req: IncomingMessage, res: ServerResponse) => void) | undefined;
|
||||
|
||||
async function signIdToken(claims: Record<string, unknown>, nonce?: string) {
|
||||
const jwt = new SignJWT({ nonce, ...claims })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(baseUrl)
|
||||
.setAudience(CLIENT_ID)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m");
|
||||
|
||||
return jwt.sign(privateKey);
|
||||
}
|
||||
|
||||
// You would think this is a lot better in 2026, but no
|
||||
function readBody(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("end", () => resolve(body));
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const keyPair = await generateKeyPair("RS256");
|
||||
privateKey = keyPair.privateKey as CryptoKey;
|
||||
const exported = await exportJWK(keyPair.publicKey);
|
||||
publicJwk = { ...exported, kid: "test-key", use: "sig", alg: "RS256" };
|
||||
|
||||
server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
if (url.pathname === "/.well-known/openid-configuration") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/authorize`,
|
||||
token_endpoint: `${baseUrl}/token`,
|
||||
userinfo_endpoint: `${baseUrl}/userinfo`,
|
||||
jwks_uri: `${baseUrl}/jwks`,
|
||||
end_session_endpoint: `${baseUrl}/logout`,
|
||||
}),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/jwks") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ keys: [publicJwk] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/token") {
|
||||
tokenHandler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/userinfo" && userinfoHandler) {
|
||||
userinfoHandler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
if (typeof addr === "object" && addr) {
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.close();
|
||||
});
|
||||
|
||||
function testConfig(overrides?: Partial<OidcConfig>): OidcConfig {
|
||||
return {
|
||||
issuer: baseUrl,
|
||||
clientId: CLIENT_ID,
|
||||
clientSecret: CLIENT_SECRET,
|
||||
baseUrl: "https://headplane.example.com",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("status", () => {
|
||||
test("returns pending before discovery", () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
expect(svc.status().state).toBe("pending");
|
||||
});
|
||||
|
||||
test("returns ready after successful discovery", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
await svc.discover();
|
||||
expect(svc.status().state).toBe("ready");
|
||||
});
|
||||
|
||||
test("returns error after failed discovery", async () => {
|
||||
const svc = createOidcService(testConfig({ issuer: "http://127.0.0.1:1" }));
|
||||
await svc.discover();
|
||||
const status = svc.status();
|
||||
|
||||
expect(status.state).toBe("error");
|
||||
if (status.state === "error") {
|
||||
expect(status.error.code).toBe("discovery_failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("discover", () => {
|
||||
test("resolves endpoints from discovery document", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
const result = await svc.discover();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.value.authorizationEndpoint).toBe(`${baseUrl}/authorize`);
|
||||
expect(result.value.tokenEndpoint).toBe(`${baseUrl}/token`);
|
||||
expect(result.value.jwksUri).toBe(`${baseUrl}/jwks`);
|
||||
expect(result.value.userinfoEndpoint).toBe(`${baseUrl}/userinfo`);
|
||||
expect(result.value.endSessionEndpoint).toBe(`${baseUrl}/logout`);
|
||||
}
|
||||
});
|
||||
|
||||
test("caches successful discovery", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
const first = await svc.discover();
|
||||
const second = await svc.discover();
|
||||
expect(first).toStrictEqual(second);
|
||||
});
|
||||
|
||||
test("skips discovery when all endpoints are manual", async () => {
|
||||
const svc = createOidcService(
|
||||
testConfig({
|
||||
issuer: "http://127.0.0.1:1",
|
||||
authorizationEndpoint: "http://example.com/auth",
|
||||
tokenEndpoint: "http://example.com/token",
|
||||
jwksUri: "http://example.com/jwks",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await svc.discover();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.authorizationEndpoint).toBe("http://example.com/auth");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns missing_endpoints when discovery is incomplete", async () => {
|
||||
const incomplete = createServer((_, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
issuer: "http://localhost",
|
||||
authorization_endpoint: "http://localhost/auth",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => incomplete.listen(0, "127.0.0.1", resolve));
|
||||
const addr = incomplete.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
|
||||
const svc = createOidcService(testConfig({ issuer: `http://127.0.0.1:${port}` }));
|
||||
const result = await svc.discover();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("missing_endpoints");
|
||||
}
|
||||
|
||||
incomplete.close();
|
||||
});
|
||||
|
||||
test("retries after failure on next call", async () => {
|
||||
const svc = createOidcService(testConfig({ issuer: "http://127.0.0.1:1" }));
|
||||
const r1 = await svc.discover();
|
||||
expect(r1.ok).toBe(false);
|
||||
|
||||
svc.reload(testConfig());
|
||||
const r2 = await svc.discover();
|
||||
expect(r2.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("config overrides take precedence over discovery", async () => {
|
||||
const svc = createOidcService(
|
||||
testConfig({
|
||||
authorizationEndpoint: "http://override.example.com/auth",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await svc.discover();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.authorizationEndpoint).toBe("http://override.example.com/auth");
|
||||
expect(result.value.tokenEndpoint).toBe(`${baseUrl}/token`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidate and reload", () => {
|
||||
test("invalidate resets to pending", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
await svc.discover();
|
||||
expect(svc.status().state).toBe("ready");
|
||||
|
||||
svc.invalidate();
|
||||
expect(svc.status().state).toBe("pending");
|
||||
});
|
||||
|
||||
test("reload clears state and applies new config", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
await svc.discover();
|
||||
|
||||
svc.reload(testConfig({ issuer: "http://127.0.0.1:1" }));
|
||||
expect(svc.status().state).toBe("pending");
|
||||
|
||||
const result = await svc.discover();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startFlow", () => {
|
||||
test("builds authorization URL with required params", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
const result = await svc.startFlow();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(result.value.url);
|
||||
expect(`${url.origin}${url.pathname}`).toBe(`${baseUrl}/authorize`);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
|
||||
expect(url.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(url.searchParams.get("state")).toBe(result.value.flowState.state);
|
||||
expect(url.searchParams.get("nonce")).toBe(result.value.flowState.nonce);
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://headplane.example.com/admin/oidc/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("includes PKCE challenge by default", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
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();
|
||||
expect(result.value.flowState.codeVerifier).toBeTruthy();
|
||||
});
|
||||
|
||||
test("omits PKCE when disabled", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const result = await svc.startFlow();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const url = new URL(result.value.url);
|
||||
expect(url.searchParams.has("code_challenge")).toBe(false);
|
||||
expect(url.searchParams.has("code_challenge_method")).toBe(false);
|
||||
});
|
||||
|
||||
test("uses custom scope", async () => {
|
||||
const svc = createOidcService(testConfig({ scope: "openid email" }));
|
||||
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("scope")).toBe("openid email");
|
||||
});
|
||||
|
||||
test("passes extra_params", async () => {
|
||||
const svc = createOidcService(
|
||||
testConfig({
|
||||
extraParams: { prompt: "select_account", hd: "example.com" },
|
||||
}),
|
||||
);
|
||||
|
||||
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("prompt")).toBe("select_account");
|
||||
expect(url.searchParams.get("hd")).toBe("example.com");
|
||||
});
|
||||
|
||||
test("generates unique state and nonce per call", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
const r1 = await svc.startFlow();
|
||||
const r2 = await svc.startFlow();
|
||||
|
||||
expect(r1.ok && r2.ok).toBe(true);
|
||||
if (!r1.ok || !r2.ok) return;
|
||||
|
||||
expect(r1.value.flowState.state).not.toBe(r2.value.flowState.state);
|
||||
expect(r1.value.flowState.nonce).not.toBe(r2.value.flowState.nonce);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCallback", () => {
|
||||
test("successful flow returns identity", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) throw new Error("startFlow failed");
|
||||
const { flowState } = flowResult.value;
|
||||
|
||||
const idToken = await signIdToken(
|
||||
{
|
||||
sub: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
preferred_username: "testuser",
|
||||
},
|
||||
flowState.nonce,
|
||||
);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.issuer).toBe(baseUrl);
|
||||
expect(result.value.subject).toBe("user-123");
|
||||
expect(result.value.name).toBe("Test User");
|
||||
expect(result.value.email).toBe("test@example.com");
|
||||
expect(result.value.username).toBe("testuser");
|
||||
});
|
||||
|
||||
test("state mismatch returns error", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) throw new Error("startFlow failed");
|
||||
const { flowState } = flowResult.value;
|
||||
|
||||
const params = new URLSearchParams({ code: "test-code", state: "wrong-state" });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("state_mismatch");
|
||||
});
|
||||
|
||||
test("provider error in callback params", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) throw new Error("startFlow failed");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
error: "access_denied",
|
||||
error_description: "User denied",
|
||||
});
|
||||
|
||||
const result = await svc.handleCallback(params, flowResult.value.flowState);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("token_exchange_failed");
|
||||
});
|
||||
|
||||
test("missing authorization code", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
|
||||
const params = new URLSearchParams({ state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("token_exchange_failed");
|
||||
});
|
||||
|
||||
test("nonce mismatch returns error", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ sub: "user-123" }, "wrong-nonce");
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("nonce_mismatch");
|
||||
});
|
||||
|
||||
test("missing sub claim returns error", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const jwt = new SignJWT({ nonce: flowState.nonce })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
|
||||
.setIssuer(baseUrl)
|
||||
.setAudience(CLIENT_ID)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m");
|
||||
|
||||
const idToken = await jwt.sign(privateKey);
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("missing_sub");
|
||||
});
|
||||
|
||||
test("invalid_client triggers auth method retry", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
|
||||
|
||||
let callCount = 0;
|
||||
tokenHandler = async (req, res) => {
|
||||
callCount++;
|
||||
const body = await readBody(req);
|
||||
const bodyParams = new URLSearchParams(body);
|
||||
|
||||
if (callCount === 1 && bodyParams.has("client_secret")) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "invalid_client", error_description: "Use basic auth" }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: "mock-access-token",
|
||||
id_token: idToken,
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
test("token exchange uses client_secret_post by default", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
|
||||
|
||||
let receivedAuth: string | undefined;
|
||||
let receivedBody = "";
|
||||
tokenHandler = async (req, res) => {
|
||||
receivedAuth = req.headers.authorization;
|
||||
receivedBody = await readBody(req);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(receivedAuth).toBeUndefined();
|
||||
const bodyParams = new URLSearchParams(receivedBody);
|
||||
expect(bodyParams.get("client_id")).toBe(CLIENT_ID);
|
||||
expect(bodyParams.get("client_secret")).toBe(CLIENT_SECRET);
|
||||
});
|
||||
|
||||
test("explicit client_secret_basic sends Authorization header", async () => {
|
||||
const svc = createOidcService(
|
||||
testConfig({
|
||||
usePkce: false,
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
}),
|
||||
);
|
||||
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
|
||||
|
||||
let receivedAuth: string | undefined;
|
||||
tokenHandler = async (req, res) => {
|
||||
receivedAuth = req.headers.authorization;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
|
||||
await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(receivedAuth).toBeDefined();
|
||||
expect(receivedAuth!.startsWith("Basic ")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity resolution", () => {
|
||||
async function flowWithClaims(
|
||||
claims: Record<string, unknown>,
|
||||
configOverrides?: Partial<OidcConfig>,
|
||||
) {
|
||||
const svc = createOidcService(testConfig({ usePkce: false, ...configOverrides }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ ...claims }, flowState.nonce);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
userinfoHandler = undefined;
|
||||
const params = new URLSearchParams({ code: "c", state: flowState.state });
|
||||
return svc.handleCallback(params, flowState);
|
||||
}
|
||||
|
||||
test("uses name claim directly", async () => {
|
||||
const result = await flowWithClaims({ sub: "u1", name: "Alice Smith" });
|
||||
expect(result.ok && result.value.name).toBe("Alice Smith");
|
||||
});
|
||||
|
||||
test("falls back to given_name + family_name", async () => {
|
||||
const result = await flowWithClaims({
|
||||
sub: "u1",
|
||||
given_name: "Alice",
|
||||
family_name: "Smith",
|
||||
});
|
||||
|
||||
expect(result.ok && result.value.name).toBe("Alice Smith");
|
||||
});
|
||||
|
||||
test("falls back to preferred_username for name", async () => {
|
||||
const result = await flowWithClaims({ sub: "u1", preferred_username: "asmith" });
|
||||
expect(result.ok && result.value.name).toBe("asmith");
|
||||
});
|
||||
|
||||
test("falls back to SSO User", async () => {
|
||||
const result = await flowWithClaims({ sub: "u1" });
|
||||
expect(result.ok && result.value.name).toBe("SSO User");
|
||||
});
|
||||
|
||||
test("username from preferred_username", async () => {
|
||||
const result = await flowWithClaims({ sub: "u1", preferred_username: "alice" });
|
||||
expect(result.ok && result.value.username).toBe("alice");
|
||||
});
|
||||
|
||||
test("username falls back to email local part", async () => {
|
||||
const result = await flowWithClaims({ sub: "u1", email: "alice@example.com" });
|
||||
expect(result.ok && result.value.username).toBe("alice");
|
||||
});
|
||||
|
||||
test("username falls back to 'user'", async () => {
|
||||
const result = await flowWithClaims({ sub: "u1" });
|
||||
expect(result.ok && result.value.username).toBe("user");
|
||||
});
|
||||
|
||||
test("gravatar picture from email", async () => {
|
||||
const result = await flowWithClaims(
|
||||
{ sub: "u1", email: "test@example.com" },
|
||||
{ profilePictureSource: "gravatar" },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.picture).toMatch(/gravatar\.com\/avatar\//);
|
||||
});
|
||||
|
||||
test("oidc picture from claims", async () => {
|
||||
const result = await flowWithClaims({
|
||||
sub: "u1",
|
||||
picture: "https://example.com/photo.jpg",
|
||||
});
|
||||
|
||||
expect(result.ok && result.value.picture).toBe("https://example.com/photo.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("userinfo enrichment", () => {
|
||||
test("enriches missing claims from userinfo", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ sub: "user-123" }, flowState.nonce);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
userinfoHandler = (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
sub: "user-123",
|
||||
name: "From UserInfo",
|
||||
email: "userinfo@example.com",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({ code: "c", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.name).toBe("From UserInfo");
|
||||
expect(result.value.email).toBe("userinfo@example.com");
|
||||
});
|
||||
|
||||
test("skips userinfo when id token has all claims", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken(
|
||||
{
|
||||
sub: "user-123",
|
||||
name: "From Token",
|
||||
email: "token@example.com",
|
||||
picture: "https://example.com/pic.jpg",
|
||||
},
|
||||
flowState.nonce,
|
||||
);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
let userinfoCalledCount = 0;
|
||||
userinfoHandler = (_req, res) => {
|
||||
userinfoCalledCount++;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ name: "Should Not Use" }));
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({ code: "c", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.name).toBe("From Token");
|
||||
expect(userinfoCalledCount).toBe(0);
|
||||
});
|
||||
|
||||
test("userinfo failure does not block login", async () => {
|
||||
const svc = createOidcService(testConfig({ usePkce: false }));
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
const idToken = await signIdToken({ sub: "user-123" }, flowState.nonce);
|
||||
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
|
||||
};
|
||||
|
||||
userinfoHandler = (_req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end("Internal Server Error");
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({ code: "c", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.value.subject).toBe("user-123");
|
||||
expect(result.value.name).toBe("SSO User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pkce detection", () => {
|
||||
test("detects pkce error from provider response", async () => {
|
||||
const svc = createOidcService(testConfig());
|
||||
const flowResult = await svc.startFlow();
|
||||
if (!flowResult.ok) {
|
||||
throw new Error("startFlow failed");
|
||||
}
|
||||
|
||||
const { flowState } = flowResult.value;
|
||||
tokenHandler = async (_req, res) => {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "invalid_request",
|
||||
error_description: "code_verifier is required",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const params = new URLSearchParams({ code: "c", state: flowState.state });
|
||||
const result = await svc.handleCallback(params, flowState);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.error.code).toBe("pkce_error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("path-based issuers", () => {
|
||||
test("handles issuer with path correctly", async () => {
|
||||
const pathServer = createServer((_req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
issuer: "http://localhost/realms/test",
|
||||
authorization_endpoint: "http://localhost/realms/test/auth",
|
||||
token_endpoint: "http://localhost/realms/test/token",
|
||||
jwks_uri: "http://localhost/realms/test/jwks",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => pathServer.listen(0, "127.0.0.1", resolve));
|
||||
const addr = pathServer.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
|
||||
const svc = createOidcService(
|
||||
testConfig({
|
||||
issuer: `http://127.0.0.1:${port}/realms/test`,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await svc.discover();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.authorizationEndpoint).toBe("http://localhost/realms/test/auth");
|
||||
}
|
||||
|
||||
pathServer.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user