201 lines
5.4 KiB
TypeScript
201 lines
5.4 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
import type { AppConfig } from "../config.js";
|
|
|
|
export type AdminRole = "viewer" | "operator" | "superadmin";
|
|
|
|
type TokenKind = "admin_access" | "admin_session";
|
|
|
|
type AdminTokenClaims = {
|
|
aud: string;
|
|
email: string;
|
|
exp: number;
|
|
role: AdminRole;
|
|
sub: string;
|
|
type: TokenKind;
|
|
};
|
|
|
|
export type AdminSessionClaims = {
|
|
aud: string;
|
|
exp: number;
|
|
operatorEmail: string;
|
|
operatorId: string;
|
|
role: AdminRole;
|
|
};
|
|
|
|
type AdminTokenErrorCode =
|
|
| "ADMIN_SESSION_REQUIRED"
|
|
| "INVALID_ADMIN_ACCESS_TOKEN"
|
|
| "INVALID_ADMIN_SESSION";
|
|
|
|
type AdminTokenFailure = {
|
|
code: AdminTokenErrorCode;
|
|
error: string;
|
|
statusCode: 401;
|
|
success: false;
|
|
};
|
|
|
|
type AdminTokenSuccess<T> = {
|
|
success: true;
|
|
value: T;
|
|
};
|
|
|
|
export type AdminTokenResult<T> = AdminTokenFailure | AdminTokenSuccess<T>;
|
|
|
|
type ExchangeResult = {
|
|
expiresAt: string;
|
|
operatorEmail: string;
|
|
operatorId: string;
|
|
role: AdminRole;
|
|
sessionToken: string;
|
|
};
|
|
|
|
function encodeSegment(value: unknown) {
|
|
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
}
|
|
|
|
function decodeSegment<T>(segment: string): T | null {
|
|
try {
|
|
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function signToken(claims: AdminTokenClaims, secret: string) {
|
|
const header = encodeSegment({ alg: "HS256", typ: "JWT" });
|
|
const payload = encodeSegment(claims);
|
|
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
|
|
|
|
return `${header}.${payload}.${signature}`;
|
|
}
|
|
|
|
function invalidToken(error: string, code: AdminTokenErrorCode): AdminTokenFailure {
|
|
return {
|
|
code,
|
|
error,
|
|
statusCode: 401,
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
function isFailure<T>(result: AdminTokenResult<T>): result is AdminTokenFailure {
|
|
return !result.success;
|
|
}
|
|
|
|
function verifyToken(
|
|
token: string,
|
|
config: AppConfig,
|
|
expectedType: TokenKind
|
|
): AdminTokenResult<AdminTokenClaims> {
|
|
const segments = token.split(".");
|
|
if (segments.length !== 3) {
|
|
return invalidToken(
|
|
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
|
|
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
|
|
);
|
|
}
|
|
|
|
const [header, payload, signature] = segments;
|
|
const expectedSignature = createHmac("sha256", config.ADMIN_TOKEN_SIGNING_SECRET)
|
|
.update(`${header}.${payload}`)
|
|
.digest();
|
|
const receivedSignature = Buffer.from(signature, "base64url");
|
|
|
|
if (
|
|
expectedSignature.length !== receivedSignature.length ||
|
|
!timingSafeEqual(expectedSignature, receivedSignature)
|
|
) {
|
|
return invalidToken(
|
|
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
|
|
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
|
|
);
|
|
}
|
|
|
|
const claims = decodeSegment<AdminTokenClaims>(payload);
|
|
if (!claims) {
|
|
return invalidToken(
|
|
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
|
|
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
|
|
);
|
|
}
|
|
|
|
if (claims.type !== expectedType || claims.aud !== config.ADMIN_TOKEN_AUDIENCE) {
|
|
return invalidToken(
|
|
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
|
|
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
|
|
);
|
|
}
|
|
|
|
if (claims.exp <= Math.floor(Date.now() / 1000)) {
|
|
return invalidToken(
|
|
expectedType === "admin_access" ? "Invalid privileged access token" : "Invalid admin session",
|
|
expectedType === "admin_access" ? "INVALID_ADMIN_ACCESS_TOKEN" : "INVALID_ADMIN_SESSION"
|
|
);
|
|
}
|
|
|
|
return { success: true, value: claims };
|
|
}
|
|
|
|
export function exchangePrivilegedToken(
|
|
token: string,
|
|
config: AppConfig
|
|
): AdminTokenResult<ExchangeResult> {
|
|
const verifiedAccessToken = verifyToken(token, config, "admin_access");
|
|
if (isFailure(verifiedAccessToken)) {
|
|
return verifiedAccessToken;
|
|
}
|
|
|
|
const accessClaims = verifiedAccessToken.value;
|
|
const sessionExpiry = Math.floor(Date.now() / 1000) + 15 * 60;
|
|
const sessionToken = signToken(
|
|
{
|
|
aud: config.ADMIN_TOKEN_AUDIENCE,
|
|
email: accessClaims.email,
|
|
exp: sessionExpiry,
|
|
role: accessClaims.role,
|
|
sub: accessClaims.sub,
|
|
type: "admin_session",
|
|
},
|
|
config.ADMIN_TOKEN_SIGNING_SECRET
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
value: {
|
|
expiresAt: new Date(sessionExpiry * 1000).toISOString(),
|
|
operatorEmail: accessClaims.email,
|
|
operatorId: accessClaims.sub,
|
|
role: accessClaims.role,
|
|
sessionToken,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function verifyAdminSession(
|
|
token: string | undefined,
|
|
config: AppConfig
|
|
): AdminTokenResult<AdminSessionClaims> {
|
|
if (!token) {
|
|
return invalidToken("Admin session required", "ADMIN_SESSION_REQUIRED");
|
|
}
|
|
|
|
const verifiedSession = verifyToken(token, config, "admin_session");
|
|
if (isFailure(verifiedSession)) {
|
|
return {
|
|
...verifiedSession,
|
|
code: "ADMIN_SESSION_REQUIRED",
|
|
error: "Admin session required",
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
value: {
|
|
aud: verifiedSession.value.aud,
|
|
exp: verifiedSession.value.exp,
|
|
operatorEmail: verifiedSession.value.email,
|
|
operatorId: verifiedSession.value.sub,
|
|
role: verifiedSession.value.role,
|
|
},
|
|
};
|
|
}
|