495050752a
- preHandler: Bearer tokens are introspected against Zitadel (RFC 7662) - any introspection failure (unreachable, non-200, bad JSON, inactive) = 401 - asserted aud must include ZITADEL_EXPECTED_AUDIENCE (project id); absent aud accepted per RFC 7662 - config: ZITADEL_INTROSPECTION_CLIENT_ID/SECRET + ZITADEL_EXPECTED_AUDIENCE (optional; Bearer-presence fallback logs warn in prod) - 13 new tests (introspection fail-closed matrix + preHandler flow)
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { createIntrospector } from "../src/auth/introspection.js";
|
|
import type { IntrospectionDeps } from "../src/auth/introspection.js";
|
|
|
|
function mockFetch(status: number, body: unknown) {
|
|
return vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
const baseDeps: IntrospectionDeps = {
|
|
issuer: "https://id-customers.siax.io",
|
|
clientId: "cid",
|
|
clientSecret: "csec",
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("createIntrospector", () => {
|
|
it("accepts an active token", async () => {
|
|
const fetchImpl = mockFetch(200, { active: true, sub: "u1", aud: ["cid"] });
|
|
const introspect = createIntrospector({ ...baseDeps, fetchImpl });
|
|
const r = await introspect("tok");
|
|
expect(r.active).toBe(true);
|
|
expect(r.sub).toBe("u1");
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
"https://id-customers.siax.io/oauth/v2/introspect",
|
|
expect.objectContaining({ method: "POST" }),
|
|
);
|
|
});
|
|
|
|
it("rejects an inactive token (fail-closed)", async () => {
|
|
const introspect = createIntrospector({ ...baseDeps, fetchImpl: mockFetch(200, { active: false }) });
|
|
const r = await introspect("tok");
|
|
expect(r.active).toBe(false);
|
|
expect(r.reason).toBe("token_inactive");
|
|
});
|
|
|
|
it("fails closed on non-200 (Zitadel returns 400 on bad client auth)", async () => {
|
|
const introspect = createIntrospector({ ...baseDeps, fetchImpl: mockFetch(400, {}) });
|
|
const r = await introspect("tok");
|
|
expect(r.active).toBe(false);
|
|
expect(r.reason).toBe("introspection_http_400");
|
|
});
|
|
|
|
it("fails closed when introspection is unreachable", async () => {
|
|
const fetchImpl = vi.fn().mockRejectedValue(new Error("fetch failed"));
|
|
const introspect = createIntrospector({ ...baseDeps, fetchImpl });
|
|
const r = await introspect("tok");
|
|
expect(r.active).toBe(false);
|
|
expect(r.reason).toBe("introspection_unreachable");
|
|
});
|
|
|
|
it("fails closed on invalid JSON", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(
|
|
new Response("<html>not json</html>", { status: 200 }),
|
|
);
|
|
const introspect = createIntrospector({ ...baseDeps, fetchImpl });
|
|
const r = await introspect("tok");
|
|
expect(r.active).toBe(false);
|
|
expect(r.reason).toBe("introspection_invalid_response");
|
|
});
|
|
|
|
it("accepts a token without aud (RFC 7662)", async () => {
|
|
const introspect = createIntrospector({
|
|
...baseDeps,
|
|
expectedAudience: "proj-1",
|
|
fetchImpl: mockFetch(200, { active: true, sub: "u1" }),
|
|
});
|
|
const r = await introspect("tok");
|
|
expect(r.active).toBe(true);
|
|
});
|
|
|
|
it("enforces asserted aud against expected audience", async () => {
|
|
const mismatch = createIntrospector({
|
|
...baseDeps,
|
|
expectedAudience: "proj-1",
|
|
fetchImpl: mockFetch(200, { active: true, aud: ["other", "cid"] }),
|
|
});
|
|
const r = await mismatch("tok");
|
|
expect(r.active).toBe(false);
|
|
expect(r.reason).toBe("audience_mismatch");
|
|
|
|
const match = createIntrospector({
|
|
...baseDeps,
|
|
expectedAudience: "proj-1",
|
|
fetchImpl: mockFetch(200, { active: true, aud: ["cid", "proj-1"] }),
|
|
});
|
|
const r2 = await match("tok");
|
|
expect(r2.active).toBe(true);
|
|
});
|
|
});
|