new-site/api/src/middleware/portalAuth.ts
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

107 lines
3.7 KiB
TypeScript

/**
* Portal authentication middleware.
*
* Customer portal pages (/portal/*) are accessed via signed JWT links that
* are emailed to customers. No password is needed — the link IS the credential.
*
* Token format (JWT, HS256):
* payload: { order_id, order_type, email, iat, exp }
* secret: CUSTOMER_JWT_SECRET env var
*
* Token is passed as:
* 1. Query param: ?token=... (email links)
* 2. Authorization header: Bearer ... (XHR/fetch from portal page)
* 3. Cookie: pw_portal_token=... (set by the portal page on first load)
*
* Helper: generatePortalToken(order_id, order_type, email) → signed JWT
*/
import { type Request, type Response, type NextFunction } from "express";
import jwt from "jsonwebtoken";
const CUSTOMER_JWT_SECRET = process.env.CUSTOMER_JWT_SECRET || "changeme_long_random_string";
const TOKEN_TTL_SECONDS = 72 * 60 * 60; // 72 hours
export interface PortalTokenPayload {
order_id: string;
order_type: string;
email: string;
}
// ─── Generate a signed portal link token ─────────────────────────────────────
export function generatePortalToken(
order_id: string,
order_type: string,
email: string,
): string {
return jwt.sign(
{ order_id, order_type, email } satisfies PortalTokenPayload,
CUSTOMER_JWT_SECRET,
{ expiresIn: TOKEN_TTL_SECONDS },
);
}
// ─── Build a signed portal URL ────────────────────────────────────────────────
export function portalUrl(
path: string, // e.g. "/portal/domain-search"
order_id: string,
order_type: string,
email: string,
): string {
const token = generatePortalToken(order_id, order_type, email);
const domain = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "http://localhost:4321";
return `${domain}${path}?token=${encodeURIComponent(token)}`;
}
// ─── Middleware: verify portal token ─────────────────────────────────────────
// Attaches req.portalAuth = { order_id, order_type, email } on success.
// Returns 401 if token is missing, 403 if invalid/expired.
declare global {
namespace Express {
interface Request {
portalAuth?: PortalTokenPayload;
}
}
}
export function requirePortalAuth(req: Request, res: Response, next: NextFunction): void {
// 1. Query param (email link on first load)
let rawToken = (req.query.token as string) || null;
// 2. Authorization header (XHR from portal page after first load)
if (!rawToken) {
const authHeader = req.headers.authorization || "";
if (authHeader.startsWith("Bearer ")) {
rawToken = authHeader.slice(7);
}
}
// 3. Cookie (set by portal page JS after extracting from URL)
if (!rawToken) {
rawToken = (req.cookies?.pw_portal_token as string) || null;
}
if (!rawToken) {
res.status(401).json({ error: "Authentication required. Please use the link from your email.", code: "AUTH_REQUIRED" });
return;
}
try {
const payload = jwt.verify(rawToken, CUSTOMER_JWT_SECRET) as PortalTokenPayload & { iat: number; exp: number };
req.portalAuth = {
order_id: payload.order_id,
order_type: payload.order_type,
email: payload.email,
};
next();
} catch (err: any) {
if (err?.name === "TokenExpiredError") {
res.status(403).json({ error: "Your portal link has expired. Please request a new one.", code: "TOKEN_EXPIRED" });
} else {
res.status(403).json({ error: "Invalid portal link.", code: "TOKEN_INVALID" });
}
}
}