File size: 4,772 Bytes
b13f38d 8b499f7 b13f38d 8b499f7 b13f38d 8b499f7 b13f38d 8b499f7 b13f38d 8b499f7 b13f38d 8b499f7 b13f38d 8b499f7 b13f38d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | // src/server/middleware/auth.ts
//
// Supabase JWT verification + license gating for the paid API.
//
// The central Supabase project issues the session JWT; this backend verifies
// it against the project's PUBLIC JWKS endpoint (no shared database needed).
// License rows are read from the central project via its service-role REST
// API (server-side only).
//
// Enforcement is OFF by default (LICENSE_ENFORCEMENT != "true") so existing
// open/demo deployments keep working. Turn it on at go-live:
//
// SUPABASE_URL=https://ciytvulujiqemqljablq.supabase.co
// SUPABASE_SERVICE_ROLE_KEY=<service role key, server only>
// LICENSE_ENFORCEMENT=true
//
// Frontend equivalent lives in the FortyDN website repo (src/lib/productApi.ts).
// Ported from BRIDGE Modul-X (src/server/middleware/auth.ts) — same contract.
import type { Request, Response, NextFunction } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
export const CENTRAL_SUPABASE_URL =
process.env.SUPABASE_URL || "https://ciytvulujiqemqljablq.supabase.co";
const JWKS_URL = new URL(`${CENTRAL_SUPABASE_URL}/auth/v1/.well-known/jwks.json`);
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks() {
if (!jwks) jwks = createRemoteJWKSet(JWKS_URL);
return jwks;
}
function enforcementOn(): boolean {
return process.env.LICENSE_ENFORCEMENT === "true";
}
export interface AuthClaims {
userId: string;
email?: string;
}
function bearerToken(req: Request): string | null {
const header = req.headers.authorization || "";
if (!header.startsWith("Bearer ")) return null;
const token = header.slice(7).trim();
return token.length > 0 ? token : null;
}
export async function verifySupabaseJwt(token: string): Promise<AuthClaims> {
const { payload } = await jwtVerify(token, getJwks(), {
audience: "authenticated",
});
const sub = payload.sub;
if (!sub) throw new Error("JWT has no sub claim");
return {
userId: sub,
email: typeof payload.email === "string" ? payload.email : undefined,
};
}
/** Combos that unlock Modul-S (mirrors the website catalog). */
const MODULS_GRANTS = new Set([
"modul-s",
"combo-validate-send",
"combo-full-bridge",
]);
export async function hasModulSLicense(userId: string): Promise<boolean> {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!serviceKey) {
console.warn("[auth] SUPABASE_SERVICE_ROLE_KEY not set — cannot check licenses.");
return false;
}
const params = new URLSearchParams({
user_id: `eq.${userId}`,
status: "eq.active",
select: "product",
});
const res = await fetch(`${CENTRAL_SUPABASE_URL}/rest/v1/licenses?${params}`, {
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
},
});
if (!res.ok) {
const body = await res.text().catch(() => "");
// Missing table (migrations not applied yet) => fail closed with a hint.
console.warn(`[auth] license lookup HTTP ${res.status}: ${body.slice(0, 200)}`);
return false;
}
const rows = (await res.json()) as { product?: string }[];
return (
Array.isArray(rows) && rows.some((r) => r.product && MODULS_GRANTS.has(r.product))
);
}
/**
* Requires a valid Supabase JWT. No-op (with a one-time warning) while
* LICENSE_ENFORCEMENT is off so demo deployments keep working.
*/
export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!enforcementOn()) return next();
void (async () => {
const token = bearerToken(req);
if (!token) {
return res.status(401).json({ detail: "Missing bearer token — sign in first." });
}
try {
const claims = await verifySupabaseJwt(token);
(req as Request & { auth?: AuthClaims }).auth = claims;
return next();
} catch {
return res.status(401).json({ detail: "Invalid or expired token." });
}
})();
}
/**
* Requires a valid JWT AND an active Modul-S license (direct or via combo).
* No-op while LICENSE_ENFORCEMENT is off.
*/
export function requireModulSLicense(req: Request, res: Response, next: NextFunction) {
if (!enforcementOn()) return next();
void (async () => {
const token = bearerToken(req);
if (!token) {
return res.status(401).json({ detail: "Missing bearer token — sign in first." });
}
try {
const claims = await verifySupabaseJwt(token);
(req as Request & { auth?: AuthClaims }).auth = claims;
const ok = await hasModulSLicense(claims.userId);
if (!ok) {
return res
.status(402)
.json({ detail: "No active Modul-S license — see /pricing." });
}
return next();
} catch {
return res.status(401).json({ detail: "Invalid or expired token." });
}
})();
}
|