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>
107 lines
3.7 KiB
TypeScript
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" });
|
|
}
|
|
}
|
|
}
|