feat(admin): add privileged admin session exchange
This commit is contained in:
parent
ce462b4d65
commit
1c97113c67
15 changed files with 520 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
24
apps/api/src/__tests__/helpers/adminTokenTestUtils.ts
Normal file
24
apps/api/src/__tests__/helpers/adminTokenTestUtils.ts
Normal 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}`;
|
||||
}
|
||||
55
apps/api/src/__tests__/middlewares/adminAuth.test.ts
Normal file
55
apps/api/src/__tests__/middlewares/adminAuth.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/__tests__/routes/adminAuth.test.ts
Normal file
64
apps/api/src/__tests__/routes/adminAuth.test.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
201
apps/api/src/helpers/adminTokens.ts
Normal file
201
apps/api/src/helpers/adminTokens.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
18
apps/api/src/routers/admin.ts
Normal file
18
apps/api/src/routers/admin.ts
Normal 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;
|
||||
};
|
||||
55
apps/api/src/routers/adminAuth.ts
Normal file
55
apps/api/src/routers/adminAuth.ts
Normal 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;
|
||||
};
|
||||
19
apps/api/src/routers/adminTables.ts
Normal file
19
apps/api/src/routers/adminTables.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
17
packages/shared-types/src/admin.types.ts
Normal file
17
packages/shared-types/src/admin.types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue