feat(auth): verify machine tokens for admin read APIs
CI / validate (push) Successful in 6m49s
Release / release (push) Successful in 11m24s

This commit is contained in:
dmg
2026-09-10 14:53:47 -04:00
parent 2402e9e42d
commit c2ac2ad16b
12 changed files with 524 additions and 12 deletions
@@ -0,0 +1,57 @@
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
const issuer = "https://sso.example/realms/operators";
let signed: string;
const fetcher = vi.fn();
beforeAll(async () => {
const keys = await generateKeyPair("RS256");
const jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "whoami-key", alg: "RS256" }] };
fetcher.mockImplementation(async () => Response.json(jwks));
signed = await new SignJWT({ resource_access: { portal: { roles: ["ops"] } }, name: "Not exposed", email: "private@example.test" })
.setProtectedHeader({ alg: "RS256", kid: "whoami-key" }).setIssuer(issuer).setAudience("portal")
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
});
beforeEach(() => {
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
vi.stubGlobal("fetch", fetcher);
});
afterEach(() => { auth.session.mockReset(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
import { GET as get } from "./route";
function request(authorization?: string) {
return new Request("https://portal.example/api/admin/whoami", { headers: authorization === undefined ? {} : { authorization } });
}
it("returns only a safe machine identity through real bearer verification", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const response = await get(request(`Bearer ${signed}`));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null });
expect(auth.session).not.toHaveBeenCalled();
});
it("returns the existing browser identity without roles or session internals", async () => {
auth.session.mockResolvedValue({ user: { name: "Admin", email: "admin@example.test", roles: ["ops"], image: "private-image" }, expires: "private-expiry" });
const response = await get(request());
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ authenticationMethod: "session", subject: null, name: "Admin", email: "admin@example.test" });
});
it.each([401, 403, 503])("returns a safe %s problem for browser auth failures", async (status) => {
if (status === 503) auth.session.mockRejectedValue(new Error("private-session-error"));
else auth.session.mockResolvedValue(status === 401 ? null : { user: { roles: [] } });
const response = await get(request());
expect(response.status).toBe(status);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(response.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
expect(await response.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
});
it("never falls back to browser auth for a supplied invalid token", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const response = await get(request("Bearer invalid"));
expect(response.status).toBe(401);
expect(auth.session).not.toHaveBeenCalled();
});
@@ -0,0 +1,10 @@
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const authorization = await authorizeAdminApi(request);
if (authorization.response) return authorization.response;
return Response.json(authorization.identity, { headers: { "cache-control": "no-store" } });
}
@@ -0,0 +1,70 @@
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
const auth = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
import { GET as list, POST } from "./route";
import { GET as detail } from "./[id]/route";
import { GET as messages } from "./[id]/messages/route";
const issuer = "https://sso.example/realms/operators";
const guildId = "100000000000000001";
const forumId = "100000000000000002";
const threadId = "100000000000000009";
const context = { params: Promise.resolve({ id: threadId }) };
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "Suggestion", owner_id: "100000000000000003", applied_tags: [], message_count: 0, thread_metadata: { archived: false, locked: false } };
let signed: string;
let roleless: string;
let jwks: unknown;
const fetcher = vi.fn();
beforeAll(async () => {
const keys = await generateKeyPair("RS256");
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "suggestions-key", alg: "RS256" }] };
const sign = (roles: string[]) => new SignJWT({ resource_access: { portal: { roles } }, realm_access: { roles: ["ops"] } })
.setProtectedHeader({ alg: "RS256", kid: "suggestions-key" }).setIssuer(issuer).setAudience("portal")
.setSubject("machine-subject").setExpirationTime("5m").sign(keys.privateKey);
signed = await sign(["ops"]);
roleless = await sign([]);
});
beforeEach(() => {
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
vi.stubEnv("KEYCLOAK_CLIENT_ID", "portal");
vi.stubEnv("DISCORD_BOT_TOKEN", "test-only-bot-token");
vi.stubEnv("DISCORD_GUILD_ID", guildId);
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", forumId);
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
fetcher.mockImplementation(async (input: string) => {
if (input === `${issuer}/protocol/openid-connect/certs`) return Response.json(jwks);
const path = String(input).replace("https://discord.com/api/v10", "");
if (path === `/channels/${forumId}`) return Response.json({ id: forumId, guild_id: guildId, type: 15, available_tags: [] });
if (path === `/guilds/${guildId}/threads/active`) return Response.json({ threads: [] });
if (path === `/channels/${threadId}`) return Response.json(thread);
if (path === `/channels/${threadId}/messages/${threadId}`) return new Response(null, { status: 404 });
if (path === `/channels/${threadId}/messages?limit=25`) return Response.json([]);
throw new Error("Unexpected transport request");
});
vi.stubGlobal("fetch", fetcher);
});
afterEach(() => { vi.resetAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
function request(token: string) {
return new Request("https://portal.example/api/suggestions", { headers: { authorization: `Bearer ${token}` } });
}
it.each([list, detail, messages])("accepts signed machine credentials on each read route", async (handler) => {
const response = await handler(request(signed), context);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(auth.session).not.toHaveBeenCalled();
});
it.each([list, detail, messages])("denies invalid or role-less bearer credentials even with an admin session and cached data", async (handler) => {
expect((await handler(request(signed), context)).status).toBe(200);
fetcher.mockClear();
expect((await handler(request("invalid"), context)).status).toBe(401);
expect((await handler(request(roleless), context)).status).toBe(403);
expect(fetcher).not.toHaveBeenCalled();
expect(auth.session).not.toHaveBeenCalled();
});
it("does not enable writes for machine identities", async () => {
const response = await POST(request(signed));
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
expect(auth.session).not.toHaveBeenCalled();
});
@@ -8,6 +8,19 @@ import { GET as messages } from "./[id]/messages/route";
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
const request = () => new Request("https://portal.example/api/suggestions");
it.each(["Bearer invalid", "Basic invalid", "", "Bearer", "Bearer a, Bearer b"])("rejects supplied authorization %j without session fallback", async (authorization) => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
const response = await GET(new Request(request(), { headers: { authorization } }));
expect(response.status).toBe(401);
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toMatchObject({ type: "urn:error:unauthorized", status: 401, instance: "/api/suggestions" });
expect(auth.session).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
@@ -68,4 +81,5 @@ it("rejects unauthenticated readers with a JSON problem instead of a redirect",
expect(response.status).toBe(401);
expect(response.headers.get("content-type")).toBe("application/problem+json");
expect(response.headers.get("location")).toBeNull();
expect(response.headers.get("www-authenticate")).toBe('Bearer realm="admin-api"');
});