95 lines
4.9 KiB
TypeScript
95 lines
4.9 KiB
TypeScript
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);
|
|
}
|
|
}
|