feat(admin): add privileged admin session exchange

This commit is contained in:
Arthur Belleville 2026-04-24 15:31:34 +02:00
parent ce462b4d65
commit 1c97113c67
No known key found for this signature in database
15 changed files with 520 additions and 4 deletions

View file

@ -3,6 +3,7 @@ import { createConfig } from "../../config.js";
import type { Secrets } from "../../secrets.js";
const baseSecrets: Secrets = {
adminTokenSigningSecret: "admin-token-signing-secret",
supabaseServiceRoleKey: "service-role-from-secret-manager",
supabaseConnectionString: "postgres://secret-manager",
supabaseCaCert: "ca-cert",

View file

@ -0,0 +1,24 @@
import { createHmac } from "node:crypto";
type TestAdminTokenClaims = {
aud: string;
email: string;
exp: number;
role: "viewer" | "operator" | "superadmin";
sub: string;
type: "admin_access" | "admin_session";
};
function encodeSegment(value: unknown) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
export function createSignedAdminToken(claims: TestAdminTokenClaims, 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}`;
}

View file

@ -0,0 +1,55 @@
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Auth Middleware", () => {
process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
process.env.ADMIN_APP_URL = "http://localhost:5176";
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
it("rejects admin routes without an admin session", async () => {
const res = await app.request("/admin/tables/profiles");
expect(res.status).toBe(401);
await expect(res.json()).resolves.toMatchObject({
error: "Admin session required",
code: "ADMIN_SESSION_REQUIRED",
});
});
it("returns the current admin session for a valid admin session token", async () => {
const sessionToken = createSignedAdminToken(
{
aud: ADMIN_TOKEN_AUDIENCE,
email: "ops@xtablo.com",
exp: Math.floor(Date.now() / 1000) + 900,
role: "operator",
sub: "operator-1",
type: "admin_session",
},
ADMIN_TOKEN_SIGNING_SECRET
);
const res = await app.request("/admin/auth/session", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
role: "operator",
operatorEmail: "ops@xtablo.com",
operatorId: "operator-1",
});
});
});

View file

@ -0,0 +1,64 @@
import { createSignedAdminToken } from "../helpers/adminTokenTestUtils.js";
import { describe, expect, it } from "vitest";
import { createConfig } from "../../config.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
const ADMIN_TOKEN_SIGNING_SECRET = "admin-test-secret";
const ADMIN_TOKEN_AUDIENCE = "xtablo-admin";
describe("Admin Auth Router", () => {
process.env.ADMIN_TOKEN_SIGNING_SECRET = ADMIN_TOKEN_SIGNING_SECRET;
process.env.ADMIN_TOKEN_AUDIENCE = ADMIN_TOKEN_AUDIENCE;
process.env.ADMIN_APP_URL = "http://localhost:5176";
const config = createConfig();
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
it("rejects requests without a valid privileged token", async () => {
const res = await app.request("/admin/auth/exchange", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ accessToken: "bad-token" }),
});
expect(res.status).toBe(401);
await expect(res.json()).resolves.toMatchObject({
error: "Invalid privileged access token",
code: "INVALID_ADMIN_ACCESS_TOKEN",
});
});
it("exchanges a valid privileged token for an admin session", async () => {
const accessToken = createSignedAdminToken(
{
aud: ADMIN_TOKEN_AUDIENCE,
email: "ops@xtablo.com",
exp: Math.floor(Date.now() / 1000) + 3600,
role: "operator",
sub: "operator-1",
type: "admin_access",
},
ADMIN_TOKEN_SIGNING_SECRET
);
const res = await app.request("/admin/auth/exchange", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ accessToken }),
});
expect(res.status).toBe(200);
await expect(res.json()).resolves.toMatchObject({
role: "operator",
operatorEmail: "ops@xtablo.com",
sessionToken: expect.any(String),
expiresAt: expect.any(String),
});
});
});

View file

@ -23,6 +23,9 @@ export interface AppConfig {
R2_SECRET_ACCESS_KEY: string;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
TASKS_SECRET: string;
ADMIN_TOKEN_SIGNING_SECRET: string;
ADMIN_TOKEN_AUDIENCE: string;
ADMIN_APP_URL: string;
/**
* Test user
@ -85,10 +88,7 @@ export function createConfig(secrets?: Secrets): AppConfig {
? validateEnvVar("STRIPE_WEBHOOK_SECRET", process.env.STRIPE_WEBHOOK_SECRET)
: getStripeWebhookSecretFromEnv() || getStripeWebhookSecret(isStagingMode),
STRIPE_SOLO_PRICE_ID: validateEnvVar("STRIPE_SOLO_PRICE_ID", process.env.STRIPE_SOLO_PRICE_ID),
STRIPE_TEAM_PRICE_ID: validateEnvVar(
"STRIPE_TEAM_PRICE_ID",
process.env.STRIPE_TEAM_PRICE_ID
),
STRIPE_TEAM_PRICE_ID: validateEnvVar("STRIPE_TEAM_PRICE_ID", process.env.STRIPE_TEAM_PRICE_ID),
STRIPE_FOUNDER_PRICE_ID: validateEnvVar(
"STRIPE_FOUNDER_PRICE_ID",
process.env.STRIPE_FOUNDER_PRICE_ID
@ -110,6 +110,11 @@ export function createConfig(secrets?: Secrets): AppConfig {
? validateEnvVar("R2_SECRET_ACCESS_KEY", process.env.R2_SECRET_ACCESS_KEY)
: secrets!.r2SecretAccessKey,
TASKS_SECRET: process.env.TASKS_SECRET || "",
ADMIN_TOKEN_SIGNING_SECRET: isTestMode
? validateEnvVar("ADMIN_TOKEN_SIGNING_SECRET", process.env.ADMIN_TOKEN_SIGNING_SECRET)
: secrets!.adminTokenSigningSecret,
ADMIN_TOKEN_AUDIENCE: process.env.ADMIN_TOKEN_AUDIENCE || "xtablo-admin",
ADMIN_APP_URL: process.env.ADMIN_APP_URL || "http://localhost:5176",
LOG_LEVEL: "info",
TEST_USER_DATA: {
id: "test",

View file

@ -0,0 +1,201 @@
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,
},
};
}

View file

@ -6,6 +6,7 @@ import { createMiddleware } from "hono/factory";
import type { Transporter } from "nodemailer";
import { Stripe } from "stripe";
import { type AppConfig } from "../config.js";
import { type AdminTokenResult, verifyAdminSession } from "../helpers/adminTokens.js";
import { authenticateFromHeader } from "../helpers/auth.js";
import { createStripeSync } from "./stripeSync.js";
import { createTransporter } from "./transporter.js";
@ -24,6 +25,9 @@ export type Middlewares = {
Variables: { supabase: SupabaseClient; user: User };
Bindings: { user: User };
}>;
adminAuthMiddleware: MiddlewareHandler<{
Variables: { adminSession: import("../helpers/adminTokens.js").AdminSessionClaims };
}>;
r2Middleware: MiddlewareHandler<{
Variables: { s3_client: S3Client };
}>;
@ -74,6 +78,10 @@ export class MiddlewareManager {
}
private initializeMiddlewares(config: AppConfig): Middlewares {
const isAdminTokenFailure = <T>(
result: AdminTokenResult<T>
): result is Extract<AdminTokenResult<T>, { success: false }> => !result.success;
const createProfileAccessMiddleware = (allowTemporaryUsers: boolean) =>
createMiddleware<{
Variables: { supabase: SupabaseClient; user: User };
@ -141,6 +149,38 @@ export class MiddlewareManager {
await next();
});
const adminAuthMiddleware = createMiddleware<{
Variables: { adminSession: import("../helpers/adminTokens.js").AdminSessionClaims };
}>(async (c, next) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json(
{
code: "ADMIN_SESSION_REQUIRED",
error: "Admin session required",
},
401
);
}
const sessionToken = authHeader.substring(7);
const verifiedSession = verifyAdminSession(sessionToken, config);
if (isAdminTokenFailure(verifiedSession)) {
return c.json(
{
code: verifiedSession.code,
error: verifiedSession.error,
},
verifiedSession.statusCode
);
}
c.set("adminSession", verifiedSession.value);
await next();
});
const maybeAuthenticatedMiddleware = createMiddleware<{
Variables: { supabase: SupabaseClient; user: User | null };
}>(async (c, next) => {
@ -241,6 +281,7 @@ export class MiddlewareManager {
supabaseMiddleware,
basicAuthMiddleware,
authMiddleware,
adminAuthMiddleware,
maybeAuthenticatedMiddleware,
r2Middleware,
regularUserCheckMiddleware,
@ -264,6 +305,10 @@ export class MiddlewareManager {
return this.middlewares.authMiddleware;
}
get adminAuth() {
return this.middlewares.adminAuthMiddleware;
}
get maybeAuthenticated() {
return this.middlewares.maybeAuthenticatedMiddleware;
}

View file

@ -0,0 +1,18 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
import { getAdminAuthRouter } from "./adminAuth.js";
import { getAdminTablesRouter } from "./adminTables.js";
export const getAdminRouter = (config: AppConfig) => {
const adminRouter = new Hono<BaseEnv>();
const middlewareManager = MiddlewareManager.getInstance();
adminRouter.route("/auth", getAdminAuthRouter(config));
adminRouter.use("/tables/*", middlewareManager.adminAuth);
adminRouter.route("/tables", getAdminTablesRouter());
return adminRouter;
};

View file

@ -0,0 +1,55 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { type AdminTokenResult, exchangePrivilegedToken } from "../helpers/adminTokens.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { BaseEnv } from "../types/app.types.js";
export const getAdminAuthRouter = (config: AppConfig) => {
const adminAuthRouter = new Hono<BaseEnv>();
const middlewareManager = MiddlewareManager.getInstance();
const isAdminTokenFailure = <T>(
result: AdminTokenResult<T>
): result is Extract<AdminTokenResult<T>, { success: false }> => !result.success;
adminAuthRouter.post("/exchange", async (c) => {
const body = await c.req.json().catch(() => null);
const accessToken =
body && typeof body === "object" && "accessToken" in body ? body.accessToken : undefined;
if (typeof accessToken !== "string" || accessToken.length === 0) {
return c.json(
{
code: "INVALID_ADMIN_ACCESS_TOKEN",
error: "Invalid privileged access token",
},
401
);
}
const exchangeResult = exchangePrivilegedToken(accessToken, config);
if (isAdminTokenFailure(exchangeResult)) {
return c.json(
{
code: exchangeResult.code,
error: exchangeResult.error,
},
exchangeResult.statusCode
);
}
return c.json(exchangeResult.value, 200);
});
adminAuthRouter.use("/session", middlewareManager.adminAuth);
adminAuthRouter.get("/session", async (c) => {
const adminSession = c.get("adminSession");
return c.json(adminSession, 200);
});
adminAuthRouter.post("/logout", async (c) => c.json({ success: true }, 200));
return adminAuthRouter;
};

View file

@ -0,0 +1,19 @@
import { Hono } from "hono";
import type { BaseEnv } from "../types/app.types.js";
export const getAdminTablesRouter = () => {
const adminTablesRouter = new Hono<BaseEnv>();
adminTablesRouter.get("/:tableId", async (c) => {
const tableId = c.req.param("tableId");
return c.json(
{
error: `Admin table '${tableId}' is not implemented yet`,
},
501
);
});
return adminTablesRouter;
};

View file

@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import { getAdminRouter } from "./admin.js";
import type { BaseEnv } from "../types/app.types.js";
import { getAuthenticatedRouter } from "./authRouter.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
@ -31,6 +32,9 @@ export const getMainRouter = (config: AppConfig) => {
// webhooks
mainRouter.route("/stripe-webhook", getStripeWebhookRouter());
// admin routes
mainRouter.route("/admin", getAdminRouter(config));
// maybe authenticated routes (checked first to allow unauthenticated booking)
mainRouter.route("/", getMaybeAuthenticatedRouter());

View file

@ -21,6 +21,7 @@ export type Secrets = {
supabaseServiceRoleKey: string;
supabaseConnectionString: string;
supabaseCaCert: string;
adminTokenSigningSecret: string;
emailClientSecret: string;
emailRefreshToken: string;
r2AccessKeyId: string;
@ -42,6 +43,7 @@ export async function loadSecrets(): Promise<Secrets> {
supabaseServiceRoleKey: await fetchSecret("supabase-service-role-key"),
supabaseConnectionString: await fetchSecret("supabase-connection-string"),
supabaseCaCert: await fetchSecret("supabase-ca-cert"),
adminTokenSigningSecret: await fetchSecret("admin-token-signing-secret"),
emailClientSecret: await fetchSecret("email-client-secret"),
emailRefreshToken: await fetchSecret("email-refresh-token"),
r2AccessKeyId: await fetchSecret("r2-access-key-id"),

View file

@ -1,4 +1,5 @@
import type { S3Client } from "@aws-sdk/client-s3";
import type { AdminSessionClaims } from "../helpers/adminTokens.js";
import type { StripeSync } from "@supabase/stripe-sync-engine";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { Hono } from "hono";
@ -10,6 +11,7 @@ import type Stripe from "stripe";
*/
export type BaseEnv = {
Variables: {
adminSession: AdminSessionClaims;
supabase: SupabaseClient;
s3_client: S3Client;
transporter: Transporter;

View file

@ -0,0 +1,17 @@
export type AdminRole = "viewer" | "operator" | "superadmin";
export type AdminSessionResponse = {
expiresAt: string;
operatorEmail: string;
operatorId: string;
role: AdminRole;
sessionToken: string;
};
export type AdminSessionInfo = {
aud: string;
exp: number;
operatorEmail: string;
operatorId: string;
role: AdminRole;
};

View file

@ -1,4 +1,8 @@
// ============================================================================
// Admin Types
// ============================================================================
export type { AdminRole, AdminSessionInfo, AdminSessionResponse } from "./admin.types.js";
// ============================================================================
// Database Types
// ============================================================================
export type { Database, Json } from "./database.types.js";