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." });
    }
  })();
}