Files
publisher-dashboard/apps/api-server/src/middleware/auth.ts

182 lines
4.5 KiB
TypeScript

/**
* Authentication middleware for oRPC server
*
* Handles authentication via:
* - Session cookie (rev.session_token) - for browser clients
* - API key header (x-api-key) - for CLI and programmatic access
*/
import type {
APIContext,
AuthenticatedContext,
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";
/**
* Create the auth middleware function
* This returns a middleware handler that can be used with oRPC procedures
*/
export const createAuthMiddleware = () => {
return async ({
context,
next,
}: {
context: APIContext;
next: (opts: {
context: Omit<AuthenticatedContext, keyof APIContext>;
}) => Promise<unknown>;
}) => {
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,
},
});
};
};
/**
* Middleware to require superuser access
*/
export const createSuperuserMiddleware = () => {
return async ({
context,
next,
}: {
context: AuthenticatedContext;
next: () => Promise<unknown>;
}) => {
if (!context.user.isSuperuser) {
throw new ORPCError("FORBIDDEN", {
message: "Superuser access required",
});
}
return next();
};
};