From b48012c1f6858585381d1a57ca67b0d0bc61d633 Mon Sep 17 00:00:00 2001 From: igm Date: Mon, 12 Jan 2026 17:43:53 +0800 Subject: [PATCH] Move middlewares to dedicated folder with one per file - Create src/middlewares/ folder with separate files: - os.ts: base implementer - auth.ts: authentication middleware - login-request.ts: login request middleware - superuser.ts: chains authMiddleware then checks superuser - Update base.ts to re-export from middlewares - Update admin procedures to use merged superuserMiddleware (no longer need to chain authMiddleware.use(superuserMiddleware)) Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/middlewares/auth.ts | 138 +++++++++++ apps/api-server/src/middlewares/index.ts | 8 + .../src/middlewares/login-request.ts | 64 +++++ apps/api-server/src/middlewares/os.ts | 10 + apps/api-server/src/middlewares/superuser.ts | 23 ++ .../procedures/admin/auth/complete-login.ts | 3 +- .../src/procedures/admin/orgs/create.ts | 3 +- .../src/procedures/admin/orgs/delete.ts | 3 +- .../src/procedures/admin/orgs/get.ts | 3 +- .../src/procedures/admin/orgs/list.ts | 3 +- .../src/procedures/admin/orgs/sites.ts | 5 +- .../src/procedures/admin/orgs/update.ts | 3 +- .../procedures/admin/users/confirm-email.ts | 3 +- .../src/procedures/admin/users/create.ts | 3 +- .../src/procedures/admin/users/get.ts | 3 +- .../src/procedures/admin/users/list.ts | 3 +- .../src/procedures/admin/users/update.ts | 3 +- apps/api-server/src/procedures/base.ts | 225 +----------------- 18 files changed, 262 insertions(+), 244 deletions(-) create mode 100644 apps/api-server/src/middlewares/auth.ts create mode 100644 apps/api-server/src/middlewares/index.ts create mode 100644 apps/api-server/src/middlewares/login-request.ts create mode 100644 apps/api-server/src/middlewares/os.ts create mode 100644 apps/api-server/src/middlewares/superuser.ts diff --git a/apps/api-server/src/middlewares/auth.ts b/apps/api-server/src/middlewares/auth.ts new file mode 100644 index 0000000..2626bf5 --- /dev/null +++ b/apps/api-server/src/middlewares/auth.ts @@ -0,0 +1,138 @@ +/** + * Auth middleware - validates session/API token and adds user to context + */ + +import type { AuthInfo, Session, SessionUser } from "../context.js"; +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; +import { hashToken } from "../utils/crypto.js"; +import { os } from "./os.js"; + +export const authMiddleware = os.middleware(async ({ context, next }) => { + const { db, reqHeaders } = context; + + // Try session cookie first + let tokenHash: string | undefined; + const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); + if (sessionToken) { + tokenHash = await hashToken(sessionToken); + } + + // Fall back to API key header (for CLI) + const apiKey = reqHeaders.get("x-api-key"); + if (!tokenHash && apiKey) { + tokenHash = await hashToken(apiKey); + } + + if (!tokenHash) { + throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" }); + } + + // Look up session (check not expired and not revoked) + const session = await db + .selectFrom("sessions") + .where("token_hash", "=", tokenHash) + .where("expires_at", ">", new Date()) + .where("revoked_at", "is", null) + .selectAll() + .executeTakeFirst(); + + // Fall back to API token if no session found + const apiToken = !session + ? await db + .selectFrom("api_tokens") + .where("token_hash", "=", tokenHash) + .where("expires_at", ">", new Date()) + .selectAll() + .executeTakeFirst() + : undefined; + + const userId = session?.user_id ?? apiToken?.user_id; + if (!userId) { + throw new ORPCError("UNAUTHORIZED", { + message: "Invalid or expired token", + }); + } + + // Update last_used_at for API tokens + if (apiToken) { + await db + .updateTable("api_tokens") + .set({ last_used_at: new Date() }) + .where("id", "=", apiToken.id) + .execute(); + } + + // Fetch user details + const user = await db + .selectFrom("users") + .where("id", "=", userId) + .select([ + "id", + "email", + "display_name", + "email_verified_at", + "is_superuser", + ]) + .executeTakeFirst(); + + if (!user) { + throw new ORPCError("UNAUTHORIZED", { + message: "User not found", + }); + } + + const sessionUser: SessionUser = { + id: user.id, + email: user.email, + displayName: user.display_name, + emailVerifiedAt: user.email_verified_at, + isSuperuser: user.is_superuser, + }; + + // Build session and auth info based on authentication method + let sessionInfo: Session; + let authInfo: AuthInfo; + + if (session) { + sessionInfo = { + id: session.id, + trustedMode: session.trusted_mode, + createdAt: session.created_at, + }; + authInfo = { + method: "session", + sessionId: session.id, + expiresAt: session.expires_at, + createdAt: session.created_at, + }; + } else if (apiToken) { + sessionInfo = { + // For API token auth, create a synthetic session object + id: "0", + trustedMode: true, + createdAt: apiToken.created_at, + }; + authInfo = { + method: "api_token", + tokenId: apiToken.id, + tokenName: apiToken.name, + expiresAt: apiToken.expires_at, + lastUsedAt: apiToken.last_used_at, + createdAt: apiToken.created_at, + }; + } else { + // This should never happen since we checked userId above + throw new ORPCError("UNAUTHORIZED", { + message: "Invalid authentication state", + }); + } + + return next({ + context: { + user: sessionUser, + session: sessionInfo, + auth: authInfo, + }, + }); +}); diff --git a/apps/api-server/src/middlewares/index.ts b/apps/api-server/src/middlewares/index.ts new file mode 100644 index 0000000..50d6919 --- /dev/null +++ b/apps/api-server/src/middlewares/index.ts @@ -0,0 +1,8 @@ +/** + * Middleware exports + */ + +export { authMiddleware } from "./auth.js"; +export { loginRequestMiddleware } from "./login-request.js"; +export { os } from "./os.js"; +export { superuserMiddleware } from "./superuser.js"; diff --git a/apps/api-server/src/middlewares/login-request.ts b/apps/api-server/src/middlewares/login-request.ts new file mode 100644 index 0000000..2e8984f --- /dev/null +++ b/apps/api-server/src/middlewares/login-request.ts @@ -0,0 +1,64 @@ +/** + * Login request middleware - validates login request token from cookie + */ + +import type { SessionUser } from "../context.js"; +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; +import { os } from "./os.js"; + +export const loginRequestMiddleware = os.middleware( + async ({ context, next }) => { + const { db, reqHeaders } = context; + + // Read login request token from cookie + const loginRequestToken = getCookie( + reqHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + if (!loginRequestToken) { + throw new ORPCError("BAD_REQUEST", { + message: "No login request found", + }); + } + + // Fetch login request with user data by token + const result = await db + .selectFrom("login_requests") + .innerJoin("users", "users.id", "login_requests.user_id") + .select([ + "login_requests.id", + "login_requests.user_id", + "login_requests.expires_at", + "users.email", + "users.display_name", + "users.email_verified_at", + "users.is_superuser", + ]) + .where("login_requests.token", "=", loginRequestToken) + .where("login_requests.expires_at", ">", new Date()) + .executeTakeFirst(); + + if (!result) { + throw new ORPCError("BAD_REQUEST", { + message: "Login request expired or not found", + }); + } + + const sessionUser: SessionUser = { + id: result.user_id, + email: result.email, + displayName: result.display_name, + emailVerifiedAt: result.email_verified_at, + isSuperuser: result.is_superuser, + }; + + return next({ + context: { + loginRequestId: Number(result.id), + user: sessionUser, + }, + }); + }, +); diff --git a/apps/api-server/src/middlewares/os.ts b/apps/api-server/src/middlewares/os.ts new file mode 100644 index 0000000..c3ec771 --- /dev/null +++ b/apps/api-server/src/middlewares/os.ts @@ -0,0 +1,10 @@ +/** + * Base implementer with typed APIContext + * All procedures and middlewares should derive from this + */ + +import type { APIContext } from "../context.js"; +import { implement } from "@orpc/server"; +import { contract } from "@reviq/api-contract"; + +export const os = implement(contract).$context(); diff --git a/apps/api-server/src/middlewares/superuser.ts b/apps/api-server/src/middlewares/superuser.ts new file mode 100644 index 0000000..c9f84d9 --- /dev/null +++ b/apps/api-server/src/middlewares/superuser.ts @@ -0,0 +1,23 @@ +/** + * Superuser middleware - authenticates and requires superuser access + * + * This middleware chains authMiddleware first, then checks for superuser. + */ + +import type { AuthenticatedContext } from "../context.js"; +import { ORPCError } from "@orpc/server"; +import { authMiddleware } from "./auth.js"; +import { os } from "./os.js"; + +const superuserCheck = os.middleware( + async ({ context, next }: { context: AuthenticatedContext; next: () => Promise }) => { + if (!context.user.isSuperuser) { + throw new ORPCError("FORBIDDEN", { + message: "Superuser access required", + }); + } + return next(); + }, +); + +export const superuserMiddleware = authMiddleware.concat(superuserCheck); diff --git a/apps/api-server/src/procedures/admin/auth/complete-login.ts b/apps/api-server/src/procedures/admin/auth/complete-login.ts index bb25325..2da9d32 100644 --- a/apps/api-server/src/procedures/admin/auth/complete-login.ts +++ b/apps/api-server/src/procedures/admin/auth/complete-login.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminAuthCompleteLogin = os.admin.auth.completeLogin - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const email = input.email.toLowerCase(); diff --git a/apps/api-server/src/procedures/admin/orgs/create.ts b/apps/api-server/src/procedures/admin/orgs/create.ts index 3c0307b..4f0f11e 100644 --- a/apps/api-server/src/procedures/admin/orgs/create.ts +++ b/apps/api-server/src/procedures/admin/orgs/create.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminOrgsCreate = os.admin.orgs.create - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { slug, displayName, ownerEmail } = input; diff --git a/apps/api-server/src/procedures/admin/orgs/delete.ts b/apps/api-server/src/procedures/admin/orgs/delete.ts index 1cfd440..3a8941d 100644 --- a/apps/api-server/src/procedures/admin/orgs/delete.ts +++ b/apps/api-server/src/procedures/admin/orgs/delete.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminOrgsDelete = os.admin.orgs.delete - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { slug } = input; diff --git a/apps/api-server/src/procedures/admin/orgs/get.ts b/apps/api-server/src/procedures/admin/orgs/get.ts index 13117a6..ba879ac 100644 --- a/apps/api-server/src/procedures/admin/orgs/get.ts +++ b/apps/api-server/src/procedures/admin/orgs/get.ts @@ -3,11 +3,10 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; import { toOrgResponse } from "../helpers.js"; export const adminOrgsGet = os.admin.orgs.get - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const org = await context.db diff --git a/apps/api-server/src/procedures/admin/orgs/list.ts b/apps/api-server/src/procedures/admin/orgs/list.ts index 13f1ad5..d8dd29c 100644 --- a/apps/api-server/src/procedures/admin/orgs/list.ts +++ b/apps/api-server/src/procedures/admin/orgs/list.ts @@ -2,11 +2,10 @@ * admin.orgs.list - List all organizations */ -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; import { toOrgResponse } from "../helpers.js"; export const adminOrgsList = os.admin.orgs.list - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ context }) => { const orgs = await context.db.selectFrom("orgs").selectAll().execute(); diff --git a/apps/api-server/src/procedures/admin/orgs/sites.ts b/apps/api-server/src/procedures/admin/orgs/sites.ts index aa0fc7b..737799d 100644 --- a/apps/api-server/src/procedures/admin/orgs/sites.ts +++ b/apps/api-server/src/procedures/admin/orgs/sites.ts @@ -4,11 +4,10 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; import { toSiteResponse } from "../helpers.js"; export const adminOrgsListSites = os.admin.orgs.listSites - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { slug } = input; @@ -32,7 +31,6 @@ export const adminOrgsListSites = os.admin.orgs.listSites }); export const adminOrgsAddSite = os.admin.orgs.addSite - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { slug, domain } = input; @@ -73,7 +71,6 @@ export const adminOrgsAddSite = os.admin.orgs.addSite }); export const adminOrgsRemoveSite = os.admin.orgs.removeSite - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { slug, domain } = input; diff --git a/apps/api-server/src/procedures/admin/orgs/update.ts b/apps/api-server/src/procedures/admin/orgs/update.ts index 27f4aa3..4c7320c 100644 --- a/apps/api-server/src/procedures/admin/orgs/update.ts +++ b/apps/api-server/src/procedures/admin/orgs/update.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminOrgsUpdate = os.admin.orgs.update - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { slug, displayName, logoUrl } = input; diff --git a/apps/api-server/src/procedures/admin/users/confirm-email.ts b/apps/api-server/src/procedures/admin/users/confirm-email.ts index 782306f..d66bca8 100644 --- a/apps/api-server/src/procedures/admin/users/confirm-email.ts +++ b/apps/api-server/src/procedures/admin/users/confirm-email.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminUsersConfirmEmail = os.admin.users.confirmEmail - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const result = await context.db diff --git a/apps/api-server/src/procedures/admin/users/create.ts b/apps/api-server/src/procedures/admin/users/create.ts index a23029e..47d787a 100644 --- a/apps/api-server/src/procedures/admin/users/create.ts +++ b/apps/api-server/src/procedures/admin/users/create.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminUsersCreate = os.admin.users.create - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { email, name, orgSlug, orgRole } = input; diff --git a/apps/api-server/src/procedures/admin/users/get.ts b/apps/api-server/src/procedures/admin/users/get.ts index b914e30..e6c37b2 100644 --- a/apps/api-server/src/procedures/admin/users/get.ts +++ b/apps/api-server/src/procedures/admin/users/get.ts @@ -3,11 +3,10 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; import { toUserResponse } from "../helpers.js"; export const adminUsersGet = os.admin.users.get - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const user = await context.db diff --git a/apps/api-server/src/procedures/admin/users/list.ts b/apps/api-server/src/procedures/admin/users/list.ts index e1c6d27..4ef95f5 100644 --- a/apps/api-server/src/procedures/admin/users/list.ts +++ b/apps/api-server/src/procedures/admin/users/list.ts @@ -2,11 +2,10 @@ * admin.users.list - List all users */ -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; import { toUserResponse } from "../helpers.js"; export const adminUsersList = os.admin.users.list - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ context }) => { const users = await context.db.selectFrom("users").selectAll().execute(); diff --git a/apps/api-server/src/procedures/admin/users/update.ts b/apps/api-server/src/procedures/admin/users/update.ts index 6e8f3e6..964e1b3 100644 --- a/apps/api-server/src/procedures/admin/users/update.ts +++ b/apps/api-server/src/procedures/admin/users/update.ts @@ -3,10 +3,9 @@ */ import { ORPCError } from "@orpc/server"; -import { authMiddleware, os, superuserMiddleware } from "../../base.js"; +import { os, superuserMiddleware } from "../../base.js"; export const adminUsersUpdate = os.admin.users.update - .use(authMiddleware) .use(superuserMiddleware) .handler(async ({ input, context }) => { const { email, isSuperuser } = input; diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts index 1621c7f..49d450e 100644 --- a/apps/api-server/src/procedures/base.ts +++ b/apps/api-server/src/procedures/base.ts @@ -8,227 +8,16 @@ import type { APIContext, AuthenticatedContext, - AuthInfo, LoginRequestContext, - Session, - SessionUser, } from "../context.js"; -import { implement, ORPCError } from "@orpc/server"; -import { contract } from "@reviq/api-contract"; -import { COOKIE_NAMES, getCookie } from "../utils/cookies.js"; -import { hashToken } from "../utils/crypto.js"; -/** - * Base implementer with typed APIContext - * All procedures should be derived from this - */ -export const os = implement(contract).$context(); - -/** - * Auth middleware - validates session/API token and adds user to context - * Use with os.use(authMiddleware) to create authenticated procedures - */ -export const authMiddleware = os.middleware(async ({ context, next }) => { - const { db, reqHeaders } = context; - - // Try session cookie first - let tokenHash: string | undefined; - const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); - if (sessionToken) { - tokenHash = await hashToken(sessionToken); - } - - // Fall back to API key header (for CLI) - const apiKey = reqHeaders.get("x-api-key"); - if (!tokenHash && apiKey) { - tokenHash = await hashToken(apiKey); - } - - if (!tokenHash) { - throw new ORPCError("UNAUTHORIZED", { message: "No session or API key" }); - } - - // Look up session (check not expired and not revoked) - const session = await db - .selectFrom("sessions") - .where("token_hash", "=", tokenHash) - .where("expires_at", ">", new Date()) - .where("revoked_at", "is", null) - .selectAll() - .executeTakeFirst(); - - // Fall back to API token if no session found - const apiToken = !session - ? await db - .selectFrom("api_tokens") - .where("token_hash", "=", tokenHash) - .where("expires_at", ">", new Date()) - .selectAll() - .executeTakeFirst() - : undefined; - - const userId = session?.user_id ?? apiToken?.user_id; - if (!userId) { - throw new ORPCError("UNAUTHORIZED", { - message: "Invalid or expired token", - }); - } - - // Update last_used_at for API tokens - if (apiToken) { - await db - .updateTable("api_tokens") - .set({ last_used_at: new Date() }) - .where("id", "=", apiToken.id) - .execute(); - } - - // Fetch user details - const user = await db - .selectFrom("users") - .where("id", "=", userId) - .select([ - "id", - "email", - "display_name", - "email_verified_at", - "is_superuser", - ]) - .executeTakeFirst(); - - if (!user) { - throw new ORPCError("UNAUTHORIZED", { - message: "User not found", - }); - } - - const sessionUser: SessionUser = { - id: user.id, - email: user.email, - displayName: user.display_name, - emailVerifiedAt: user.email_verified_at, - isSuperuser: user.is_superuser, - }; - - // Build session and auth info based on authentication method - let sessionInfo: Session; - let authInfo: AuthInfo; - - if (session) { - sessionInfo = { - id: session.id, - trustedMode: session.trusted_mode, - createdAt: session.created_at, - }; - authInfo = { - method: "session", - sessionId: session.id, - expiresAt: session.expires_at, - createdAt: session.created_at, - }; - } else if (apiToken) { - sessionInfo = { - // For API token auth, create a synthetic session object - id: "0", - trustedMode: true, - createdAt: apiToken.created_at, - }; - authInfo = { - method: "api_token", - tokenId: apiToken.id, - tokenName: apiToken.name, - expiresAt: apiToken.expires_at, - lastUsedAt: apiToken.last_used_at, - createdAt: apiToken.created_at, - }; - } else { - // This should never happen since we checked userId above - throw new ORPCError("UNAUTHORIZED", { - message: "Invalid authentication state", - }); - } - - return next({ - context: { - user: sessionUser, - session: sessionInfo, - auth: authInfo, - }, - }); -}); - -/** - * Login request middleware - validates login request token from cookie - */ -export const loginRequestMiddleware = os.middleware( - async ({ context, next }) => { - const { db, reqHeaders } = context; - - // Read login request token from cookie - const loginRequestToken = getCookie( - reqHeaders, - COOKIE_NAMES.LOGIN_REQUEST_TOKEN, - ); - - if (!loginRequestToken) { - throw new ORPCError("BAD_REQUEST", { - message: "No login request found", - }); - } - - // Fetch login request with user data by token - const result = await db - .selectFrom("login_requests") - .innerJoin("users", "users.id", "login_requests.user_id") - .select([ - "login_requests.id", - "login_requests.user_id", - "login_requests.expires_at", - "users.email", - "users.display_name", - "users.email_verified_at", - "users.is_superuser", - ]) - .where("login_requests.token", "=", loginRequestToken) - .where("login_requests.expires_at", ">", new Date()) - .executeTakeFirst(); - - if (!result) { - throw new ORPCError("BAD_REQUEST", { - message: "Login request expired or not found", - }); - } - - const sessionUser: SessionUser = { - id: result.user_id, - email: result.email, - displayName: result.display_name, - emailVerifiedAt: result.email_verified_at, - isSuperuser: result.is_superuser, - }; - - return next({ - context: { - loginRequestId: Number(result.id), - user: sessionUser, - }, - }); - }, -); - -/** - * Superuser middleware - requires admin access (must be used after authMiddleware) - */ -export const superuserMiddleware = os.middleware(async ({ context, next }) => { - // This middleware should be used after authMiddleware - const ctx = context as AuthenticatedContext; - if (!ctx.user.isSuperuser) { - throw new ORPCError("FORBIDDEN", { - message: "Superuser access required", - }); - } - return next(); -}); +// Re-export middlewares and os from the middlewares folder +export { + authMiddleware, + loginRequestMiddleware, + os, + superuserMiddleware, +} from "../middlewares/index.js"; // Type exports for use in procedure files export type { APIContext, AuthenticatedContext, LoginRequestContext };