Fix auth hook so it returns forbidden if the user is authenticated but not allowed to hit an endpoint.

This commit is contained in:
2025-01-29 14:49:53 -08:00
parent 5487a23c86
commit 53214644b4
23 changed files with 835 additions and 187 deletions

View File

@ -1,157 +0,0 @@
import { JWT_SECRET } from "$env/static/private";
import type { RequestEvent } from "@sveltejs/kit";
import jwt from "jsonwebtoken";
import { routeAuth, type Method, type RouteAuthRule } from "./routeAuth";
export type LocalCredentials =
| { kind: "Basic"; payload: { username: string; password: string } }
| { kind: "Bearer"; payload: jwt.JwtPayload | string }
| { kind: "None" };
export async function getRequestBody<T = unknown>(
req: Request,
validation?: (target: unknown) => target is T
): Promise<T> {
if (req.body === null) {
throw new Error("no body is present on the request");
}
const body = await req.json();
if (validation && !validation(body)) {
throw new Error("body validation failed");
}
return body;
}
export async function isAuthorized(event: RequestEvent): Promise<LocalCredentials | null> {
let path = event.url.pathname;
let tokenKind: "Basic" | "Bearer" | "None";
let tokenRole: string;
let tokenDesc: LocalCredentials;
const parts = breakupPath(path);
if (parts[0] !== "api") {
// not concerned about requests made to the frontend server
return { kind: "None" };
}
const authHeader = event.request.headers.get("authorization");
// get token kind and role from the header
if (!authHeader) {
// This is a stranger: they have no token and they will be assigned the default
// role.
tokenKind = "None";
tokenRole = "default";
tokenDesc = { kind: "None" };
} else {
const [kind, token] = authHeader.split(" ");
if (kind === "Bearer") {
tokenKind = "Bearer";
// The role can be derived from the JWT token.
const payload = await jwt.verify(token, JWT_SECRET);
if (typeof payload === "string") {
// I do not assign, and don't know what to do with, these kinds of
// tokens. Perhaps an error should be logged here, since this is a
// weird thing to have stumbled on.
return null;
// user should have a bearer token
}
tokenRole = payload.role;
tokenDesc = { kind: "Bearer", payload };
if (!tokenRole) {
// Something has gone wrong: I should not have issued a token without a
// role.
return null;
}
} else if (kind === "Basic") {
const decoded = Buffer.from(token, "base64").toString("ascii");
const [username, password] = decoded.split(":");
tokenKind = "Basic";
tokenRole = "default";
tokenDesc = { kind: "Basic", payload: { username, password } };
} else {
// Something the server doesn't recognize was passed as the token kind.
return null;
}
}
// Determine if the role has the ability to hit this endpoint with this method
// and the given token.
const rules = routeAuth[tokenRole];
let hasMatchingAllow = false;
for (const rule of rules) {
console.log("checking rule", rule);
if (matchesRequest(tokenKind, parts, event.request.method as Method, rule)) {
console.log("match!");
if (rule.action === "deny") {
// if a request matches any deny rule, then it is denied, regardless
// of whether or not it also matches an allow rule.
return null;
} else if (rule.action === "allow") {
hasMatchingAllow = true;
}
}
}
if (hasMatchingAllow) {
return tokenDesc;
}
return null;
}
function breakupPath(path: string): string[] {
// remove a leading /
if (path[0] === "/") path = path.slice(1);
return path.split("/");
}
function matchesRequest(
requestTokenKind: "Basic" | "Bearer" | "None",
requestParts: string[],
method: Method,
{ endpoint, methods, tokenKind = "Bearer" }: RouteAuthRule
): boolean {
if (tokenKind !== requestTokenKind) {
console.log("token types didn't match", tokenKind, requestTokenKind);
return false;
}
if (!methods.includes("*") && !methods.includes(method)) {
console.log("Bad method", method);
return false;
}
const ruleParts = breakupPath(endpoint);
for (let i = 0; i < ruleParts.length; i++) {
const rulePart = ruleParts[i];
const reqPart = requestParts[i];
if (rulePart[0] === "[" && rulePart[rulePart.length - 1] === "]") {
// This part of the path represents an ID.
continue;
}
if (i == ruleParts.length - 1 && rulePart === "*") {
// Rule has a wildcard, anything after it automatically matches.
return true;
}
if (rulePart !== reqPart) {
console.log("rule parts do not match", rulePart, reqPart);
return false;
}
}
return true;
}