feat(auth): verify machine tokens for admin read APIs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user