feat(auth): verify machine tokens for admin read APIs
This commit is contained in:
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose";
|
||||
|
||||
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||
vi.mock("./admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||
const issuer = "https://sso.example/realms/operators";
|
||||
const audience = "portal-admin";
|
||||
let keys: Awaited<ReturnType<typeof generateKeyPair>>;
|
||||
let jwks: { keys: unknown[] };
|
||||
let authorize: typeof import("./admin-api-auth").authorizeAdminApi;
|
||||
const fetcher = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
keys = await generateKeyPair("RS256");
|
||||
jwks = { keys: [{ ...await exportJWK(keys.publicKey), kid: "test-key", alg: "RS256", use: "sig" }] };
|
||||
});
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", issuer);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", audience);
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_SECRET", ""); // Machine verification needs no client secret.
|
||||
auth.session.mockResolvedValue({ user: { name: "Browser Admin", email: "admin@example.test", roles: ["ops"] } });
|
||||
fetcher.mockImplementation(async () => Response.json(jwks));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
authorize = (await import("./admin-api-auth")).authorizeAdminApi;
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||
|
||||
async function token(overrides: JWTPayload = {}, header: Record<string, unknown> = {}) {
|
||||
return new SignJWT({
|
||||
iss: issuer, aud: audience, sub: "machine-subject", exp: Math.floor(Date.now() / 1000) + 300,
|
||||
resource_access: { [audience]: { roles: ["ops"] } },
|
||||
...overrides,
|
||||
}).setProtectedHeader({ alg: "RS256", kid: "test-key", ...header }).sign(keys.privateKey);
|
||||
}
|
||||
function request(bearer: string) {
|
||||
return new Request("https://portal.example/api/admin/whoami", { headers: { authorization: `Bearer ${bearer}` } });
|
||||
}
|
||||
async function rejected(bearer: string, status = 401) {
|
||||
const result = await authorize(request(bearer));
|
||||
expect(result.identity).toBeUndefined();
|
||||
expect(result.response?.status).toBe(status);
|
||||
expect(result.response?.headers.get("content-type")).toBe("application/problem+json");
|
||||
expect(result.response?.headers.get("cache-control")).toBe("no-store");
|
||||
expect(result.response?.headers.get("www-authenticate")).toBe(status === 401 ? 'Bearer realm="admin-api"' : null);
|
||||
expect(await result.response?.json()).toMatchObject({ status, instance: "/api/admin/whoami" });
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
it("verifies real RS256 signatures and caches only the configured issuer JWKS", async () => {
|
||||
const signed = await token({ email: "private@example.test", name: "Private", arbitrary: "private" }, { jku: "https://attacker.example/keys" });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(await authorize(request(signed))).toEqual({ identity: {
|
||||
authenticationMethod: "bearer", subject: "machine-subject", name: null, email: null,
|
||||
} });
|
||||
}
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`${issuer}/protocol/openid-connect/certs`);
|
||||
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual", method: "GET" });
|
||||
});
|
||||
it("accepts an audience list and case-insensitive bearer scheme", async () => {
|
||||
const signed = await token({ aud: ["other", audience] });
|
||||
const req = new Request(request(signed), { headers: { authorization: `bEaReR ${signed}` } });
|
||||
expect((await authorize(req)).identity?.subject).toBe("machine-subject");
|
||||
});
|
||||
it.each([
|
||||
["expired", { exp: 1 }], ["missing expiry", { exp: undefined }],
|
||||
["missing subject", { sub: undefined }], ["empty subject", { sub: "" }], ["blank subject", { sub: " " }],
|
||||
["future nbf", { nbf: 9999999999 }], ["wrong issuer", { iss: "https://attacker.example" }],
|
||||
["wrong audience", { aud: "another-client" }], ["missing audience", { aud: undefined }],
|
||||
] satisfies [string, JWTPayload][])("rejects %s despite an available privileged browser session", async (_name, claims) => {
|
||||
await rejected(await token(claims));
|
||||
});
|
||||
it("rejects tampering with a signed payload", async () => {
|
||||
const parts = (await token()).split(".");
|
||||
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString());
|
||||
parts[1] = Buffer.from(JSON.stringify({ ...payload, sub: "tampered" })).toString("base64url");
|
||||
await rejected(parts.join("."));
|
||||
});
|
||||
it.each([
|
||||
undefined, {}, { [audience]: { roles: [] } }, { another: { roles: ["ops"] } },
|
||||
{ [audience]: { roles: "ops" } }, { [audience]: { roles: ["player"] } },
|
||||
])("requires the configured client role, never a realm role (%j)", async (resource_access) => {
|
||||
await rejected(await token({ resource_access, realm_access: { roles: ["ops"] } }), 403);
|
||||
});
|
||||
it("rejects HS256 algorithm confusion", async () => {
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "HS256", kid: "test-key" }).sign(new TextEncoder().encode("test-only-key-with-at-least-32-bytes"));
|
||||
await rejected(signed);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an otherwise valid but non-allowlisted asymmetric algorithm", async () => {
|
||||
const ec = await generateKeyPair("ES256");
|
||||
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(ec.publicKey), kid: "ec-key" }] }));
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "ES256", kid: "ec-key" }).sign(ec.privateKey);
|
||||
await rejected(signed);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects an unknown signing key", async () => { await rejected(await token({}, { kid: "unknown" })); });
|
||||
it("rejects a signature from an untrusted key even when its kid matches", async () => {
|
||||
const other = await generateKeyPair("RS256");
|
||||
const signed = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: 9999999999 })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "test-key" }).sign(other.privateKey);
|
||||
await rejected(signed);
|
||||
});
|
||||
it("coalesces concurrent JWKS reads and refreshes rotated keys after cooldown", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const signed = await token();
|
||||
const results = await Promise.all(Array.from({ length: 8 }, () => authorize(request(signed))));
|
||||
expect(results.every((result) => result.identity?.subject === "machine-subject")).toBe(true);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const rotated = await generateKeyPair("RS256");
|
||||
fetcher.mockImplementation(async () => Response.json({ keys: [{ ...await exportJWK(rotated.publicKey), kid: "rotated-key", alg: "RS256" }] }));
|
||||
const next = await new SignJWT({ iss: issuer, aud: audience, sub: "machine", exp: Math.floor(Date.now() / 1000) + 300, resource_access: { [audience]: { roles: ["ops"] } } })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "rotated-key" }).sign(rotated.privateKey);
|
||||
await rejected(next);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
vi.setSystemTime(Date.now() + 31_000);
|
||||
expect((await authorize(request(next))).identity?.subject).toBe("machine");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each(["redirect", "non-JSON", "invalid JWKS", "HTTP failure"])("fails closed on a %s JWKS response", async (kind) => {
|
||||
fetcher.mockImplementation(async () => {
|
||||
if (kind === "redirect") return new Response(null, { status: 302, headers: { location: "https://attacker.example/keys" } });
|
||||
if (kind === "non-JSON") return new Response("private malformed response");
|
||||
if (kind === "invalid JWKS") return Response.json({ keys: "private malformed keys" });
|
||||
return new Response("private upstream failure", { status: 500 });
|
||||
});
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("fails closed and sanitizes a JWKS transport outage", async () => {
|
||||
fetcher.mockRejectedValue(new Error("private network details"));
|
||||
const result = await authorize(request(await token()));
|
||||
expect(result.response?.status).toBe(503);
|
||||
expect(await result.response?.text()).not.toContain("private");
|
||||
expect(auth.session).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["", "http://sso.example/realms/operators", "not a URL", `${issuer}?query=1`, "https://user:password@sso.example/realm"])("fails closed on unsafe/missing issuer configuration %s", async (value) => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", value);
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails closed on missing client ID", async () => {
|
||||
vi.stubEnv("KEYCLOAK_CLIENT_ID", "");
|
||||
await rejected(await token(), 503);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([null, { user: {} }, { user: { roles: ["player"] } }])("preserves missing/unauthorized browser behavior (%j)", async (session) => {
|
||||
auth.session.mockResolvedValue(session);
|
||||
const result = await authorize(new Request("https://portal.example/api/admin/whoami"));
|
||||
expect(result.response?.status).toBe(session ? 403 : 401);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves browser identity without requiring machine configuration", async () => {
|
||||
vi.stubEnv("KEYCLOAK_ISSUER_URL", "");
|
||||
expect(await authorize(new Request("https://portal.example/api/admin/whoami"))).toEqual({ identity: {
|
||||
authenticationMethod: "session", subject: null, name: "Browser Admin", email: "admin@example.test",
|
||||
} });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { adminAuthOptions, requiredAdminRole } from "./admin-auth";
|
||||
|
||||
export type AdminApiIdentity = {
|
||||
authenticationMethod: "session" | "bearer";
|
||||
subject: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
type Authorization = { identity: AdminApiIdentity; response?: never } | { response: Response; identity?: never };
|
||||
|
||||
function failure(request: Request, status: 401 | 403 | 503): Authorization {
|
||||
const problems = {
|
||||
401: ["unauthorized", "Authentication required", "Supply valid administrator credentials."],
|
||||
403: ["forbidden", "Administrator role required", "This API is restricted to administrators."],
|
||||
503: ["admin-auth-unavailable", "Authentication unavailable", "Administrator authentication is temporarily unavailable."],
|
||||
} as const;
|
||||
const [code, title, detail] = problems[status];
|
||||
const response = problemResponse(problemDetails(`urn:error:${code}`, title, status, detail, problemInstance(request)));
|
||||
if (status === 401) response.headers.set("www-authenticate", 'Bearer realm="admin-api"');
|
||||
return { response };
|
||||
}
|
||||
|
||||
// One bounded, process-local resolver. jose coalesces fetches and refreshes rotated keys.
|
||||
let remote: { issuer: string; keys: ReturnType<typeof createRemoteJWKSet> } | undefined;
|
||||
function bearerConfiguration() {
|
||||
const issuer = process.env.KEYCLOAK_ISSUER_URL?.trim() ?? "";
|
||||
const audience = process.env.KEYCLOAK_CLIENT_ID?.trim() ?? "";
|
||||
const url = new URL(issuer);
|
||||
if (!audience || url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
||||
throw new Error("Invalid administrator authentication configuration");
|
||||
}
|
||||
if (!remote || remote.issuer !== issuer) {
|
||||
// Never discover a key URL from untrusted token claims or headers (jku/x5u/iss).
|
||||
const jwksUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`);
|
||||
remote = { issuer, keys: createRemoteJWKSet(jwksUrl, {
|
||||
timeoutDuration: 5_000, cooldownDuration: 30_000, cacheMaxAge: 600_000,
|
||||
}) };
|
||||
}
|
||||
return { issuer, audience, keys: remote.keys };
|
||||
}
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
async function authorizeBearer(request: Request): Promise<Authorization> {
|
||||
const match = /^Bearer +([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/i.exec(request.headers.get("authorization") ?? "");
|
||||
if (!match) return failure(request, 401);
|
||||
let configuration: ReturnType<typeof bearerConfiguration>;
|
||||
try {
|
||||
configuration = bearerConfiguration();
|
||||
} catch {
|
||||
return failure(request, 503);
|
||||
}
|
||||
try {
|
||||
const { issuer, audience, keys } = configuration;
|
||||
const { payload } = await jwtVerify(match[1]!, keys, {
|
||||
issuer, audience, algorithms: ["RS256"], requiredClaims: ["exp", "sub"],
|
||||
});
|
||||
if (typeof payload.sub !== "string" || !payload.sub.trim()) return failure(request, 401);
|
||||
const access = payload.resource_access;
|
||||
const client = record(access) && Object.hasOwn(access, audience) ? access[audience] : undefined;
|
||||
const roles = record(client) ? client.roles : undefined;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||
return { identity: { authenticationMethod: "bearer", subject: payload.sub, name: null, email: null } };
|
||||
} catch (error) {
|
||||
// Verification failures are invalid credentials; transport/configuration failures are unavailable.
|
||||
const invalid = error instanceof errors.JWTClaimValidationFailed || error instanceof errors.JWTExpired
|
||||
|| error instanceof errors.JWSInvalid || error instanceof errors.JWTInvalid
|
||||
|| error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JOSEAlgNotAllowed
|
||||
|| error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JOSENotSupported;
|
||||
return failure(request, invalid ? 401 : 503);
|
||||
}
|
||||
}
|
||||
|
||||
/** API-only authorization; never use bearer tokens to authorize browser actions. */
|
||||
export async function authorizeAdminApi(request: Request): Promise<Authorization> {
|
||||
// Presence, including an empty/unsupported header, is authoritative. Never fall back.
|
||||
if (request.headers.has("authorization")) return authorizeBearer(request);
|
||||
try {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) return failure(request, 401);
|
||||
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) return failure(request, 403);
|
||||
return { identity: {
|
||||
authenticationMethod: "session", subject: null,
|
||||
name: session.user?.name ?? null, email: session.user?.email ?? null,
|
||||
} };
|
||||
} catch {
|
||||
return failure(request, 503);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||
import { authorizeAdminApi } from "@/lib/auth/admin-api-auth";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
||||
|
||||
@@ -17,11 +16,9 @@ function getClient() {
|
||||
}
|
||||
|
||||
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
||||
const authorization = await authorizeAdminApi(request);
|
||||
if (authorization.response) return authorization.response;
|
||||
try {
|
||||
const session = await getServerSession(adminAuthOptions);
|
||||
if (!session) throw new SuggestionsError(401, "unauthorized", "Sign in as an administrator.");
|
||||
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) throw new SuggestionsError(403, "forbidden", "This API is restricted to administrators.");
|
||||
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||
|
||||
Reference in New Issue
Block a user