-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathproxy.ts
More file actions
540 lines (487 loc) · 21.3 KB
/
Copy pathproxy.ts
File metadata and controls
540 lines (487 loc) · 21.3 KB
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
import createIntlMiddleware from 'next-intl/middleware'
import { type NextRequest, NextResponse } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
import { createServerClient } from '@supabase/ssr'
import { locales, defaultLocale } from './i18n'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!
async function checkSuperAdmin(userId: string): Promise<boolean> {
try {
const res = await fetch(
`${SUPABASE_URL}/rest/v1/super_admins?user_id=eq.${userId}&select=user_id&limit=1`,
{
headers: {
apikey: SUPABASE_SERVICE_ROLE_KEY,
Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
Accept: 'application/json',
},
}
)
if (!res.ok) return false
const rows = await res.json()
return Array.isArray(rows) && rows.length > 0
} catch {
return false
}
}
const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001'
// Short-TTL in-memory cache for tenant slug -> {id, status} lookups.
// Module scope persists for the life of the Edge isolate, so this avoids a
// DB round trip on every request for a mapping that rarely changes. TTL
// keeps a deactivated tenant from staying "active" for long after a status
// flip, without needing external cache infra.
const TENANT_LOOKUP_TTL_MS = 60_000
const tenantLookupCache = new Map<string, { tenant: { id: string; status: string } | null; expiresAt: number }>()
function getCachedTenantLookup(slug: string) {
const entry = tenantLookupCache.get(slug)
if (entry && entry.expiresAt > Date.now()) return entry.tenant
return undefined
}
function setCachedTenantLookup(slug: string, tenant: { id: string; status: string } | null) {
tenantLookupCache.set(slug, { tenant, expiresAt: Date.now() + TENANT_LOOKUP_TTL_MS })
}
// Strip port from domain for hostname comparisons
const PLATFORM_DOMAIN_RAW = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN || 'lmsplatform.com'
const PLATFORM_DOMAIN = PLATFORM_DOMAIN_RAW.split(':')[0] // e.g. "lvh.me" from "lvh.me:3000"
// Domains that are the platform itself (not tenant subdomains)
const PLATFORM_HOSTS = [
'localhost',
'127.0.0.1',
PLATFORM_DOMAIN,
]
// Create i18n middleware
const intlMiddleware = createIntlMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
})
/**
* Extract tenant slug from subdomain.
* e.g. "school.lmsplatform.com" -> "school"
* e.g. "school.lvh.me:3000" -> "school"
* Returns null if on the platform root domain or localhost without subdomain.
*/
function getTenantSlugFromHost(host: string): string | null {
const hostname = host.split(':')[0] // Remove port
// Skip if it's a platform host without subdomain
if (PLATFORM_HOSTS.some(h => hostname === h)) {
return null
}
// Check for subdomain pattern: slug.platform.com
if (hostname.endsWith(`.${PLATFORM_DOMAIN}`)) {
const slug = hostname.replace(`.${PLATFORM_DOMAIN}`, '')
if (slug && !slug.includes('.')) {
return slug
}
}
// For localhost development: check x-tenant-slug header as override
return null
}
/**
* Build an absolute redirect URL using the PUBLIC host + scheme.
*
* Behind Cloudflare → Traefik, the Next.js server receives requests on the
* internal container port (3000), so `request.url` / `request.nextUrl` carry
* `:3000`. Constructing redirects from those leaks `host:3000` into the browser
* (e.g. `acme.preciopana.com:3000/auth/login`), which then fails because port
* 3000 isn't exposed through Cloudflare. Always derive host AND port from the
* `Host` header (the authority the browser actually used: port-less in
* production, `:3005`-style in local dev) and the scheme from
* `x-forwarded-proto`.
*/
function publicRedirectUrl(request: NextRequest, path: string): URL {
const url = new URL(path, request.url)
const forwardedProto = request.headers.get('x-forwarded-proto')
const hostHeader = request.headers.get('host') || url.host
// The Host header is the exact authority the browser dialed, so it is the
// only host:port a redirect can safely send it back to. Behind
// Cloudflare → Traefik it is port-less (the public domain); in local dev it
// carries the real port (e.g. acme.lvh.me:3005). Trust it verbatim.
// `x-forwarded-proto` cannot distinguish the two — Next dev sets it on every
// request — so it is only used for the scheme, never to decide on the port.
// Set hostname/port separately: the WHATWG URL host setter keeps the old
// port when the new value has none, so a port-less Host header would
// otherwise leak request.url's internal container port into the redirect.
const [hostname, port = ''] = hostHeader.split(':')
url.hostname = hostname
url.port = port
if (forwardedProto) {
url.protocol = forwardedProto + ':'
}
return url
}
export default async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/_next')) {
return NextResponse.next()
}
// --- OAuth well-known metadata (RFC 9728) ---
// MCP clients (claude.ai custom connectors, Claude Desktop) fetch
// /.well-known/oauth-protected-resource/api/mcp to discover the auth server.
// Supabase's OAuth 2.1 server IS the authorization server (it hosts
// /authorize, /token, /register and DCR) — we only advertise it here.
// Must return JSON before intl middleware adds locale prefix.
if (pathname.startsWith('/.well-known/')) {
if (pathname.startsWith('/.well-known/oauth-protected-resource')) {
const proto = request.headers.get('x-forwarded-proto') || 'https'
const reqHost = request.headers.get('host') || 'localhost:3000'
const supabaseIssuer = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1`
return new NextResponse(JSON.stringify({
resource: `${proto}://${reqHost}/api/mcp`,
authorization_servers: [supabaseIssuer],
scopes_supported: ['openid', 'profile', 'email'],
bearer_methods_supported: ['header'],
resource_name: 'LMS MCP Server',
}), {
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=3600',
'access-control-allow-origin': '*',
},
})
}
if (pathname.startsWith('/.well-known/oauth-authorization-server') ||
pathname.startsWith('/.well-known/openid-configuration')) {
// Legacy-client fallback (pre-RFC 9728 discovery): serve the REAL
// authorization-server metadata from Supabase, verbatim.
const supabaseIssuer = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1`
try {
const upstream = await fetch(
`${supabaseIssuer}/.well-known/oauth-authorization-server`,
{ next: { revalidate: 3600 } }
)
if (!upstream.ok) throw new Error(`upstream ${upstream.status}`)
const body = await upstream.text()
return new NextResponse(body, {
status: 200,
headers: {
'content-type': 'application/json',
'cache-control': 'public, max-age=3600',
'access-control-allow-origin': '*',
},
})
} catch {
return NextResponse.json(
{ error: 'server_error', error_description: 'Failed to fetch authorization server metadata' },
{ status: 502 }
)
}
}
// Other .well-known paths — pass through without intl
return NextResponse.next()
}
// --- Tenant Resolution (runs for ALL routes including /api) ---
const host = request.headers.get('host') || ''
const tenantSlug = getTenantSlugFromHost(host)
|| request.headers.get('x-tenant-slug') // Dev override
let tenantId = DEFAULT_TENANT_ID
if (tenantSlug) {
let tenant = getCachedTenantLookup(tenantSlug)
if (tenant === undefined) {
// Look up tenant by slug using service client (no auth needed)
const supabaseLookup = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,
{
cookies: {
getAll() { return request.cookies.getAll() },
setAll() { /* not needed for lookup */ },
},
}
)
const { data } = await supabaseLookup
.from('tenants')
.select('id, status')
.eq('slug', tenantSlug)
.eq('status', 'active')
.single()
tenant = data ?? null
setCachedTenantLookup(tenantSlug, tenant)
}
if (!tenant) {
// Invalid tenant slug - redirect to platform root (skip for API routes)
if (pathname.startsWith('/api')) {
return NextResponse.json({ error: 'Invalid tenant' }, { status: 404 })
}
// Redirect to the platform ROOT domain (strip the tenant subdomain).
// publicRedirectUrl gives us the correct scheme + (dev) port from the
// current request; we only swap the hostname to the bare platform domain.
const platformUrl = publicRedirectUrl(request, '/')
platformUrl.hostname = (process.env.NEXT_PUBLIC_PLATFORM_DOMAIN || request.headers.get('host') || 'localhost:3000').split(':')[0]
return NextResponse.redirect(platformUrl)
}
tenantId = tenant.id
}
// For API routes and root SEO files (robots/sitemap live outside the locale
// tree and must not be locale-redirected): set tenant header and pass through
// (no intl/auth guards)
if (pathname.startsWith('/api') || pathname === '/robots.txt' || pathname === '/sitemap.xml') {
request.headers.set('x-tenant-id', tenantId)
const response = NextResponse.next({ request })
response.headers.set('x-tenant-id', tenantId)
return response
}
// --- Inject tenant ID into request headers so server components can read it ---
request.headers.set('x-tenant-id', tenantId)
// --- Intl Middleware ---
const intlResponse = intlMiddleware(request)
if (intlResponse.headers.get('x-middleware-rewrite')) {
// It's a rewrite, continue
} else if (intlResponse.status >= 300 && intlResponse.status < 400) {
return intlResponse
}
// --- Path normalization ---
const segments = pathname.split('/')
const locale = segments[1]
const hasValidLocale = (locales as readonly string[]).includes(locale)
// --- Guarantee a locale prefix before any auth / membership logic ---
// Server Actions and RSC navigations can reach the middleware on a locale-less
// URL — the codebase convention is `redirect('/dashboard/...')` (no locale).
// With localePrefix 'always', next-intl REWRITES those (not redirects) to keep
// the RSC payload intact, so they fall through to the guards below with
// `segments[1]` being a route segment (e.g. "dashboard"), not a locale. That made
// the membership guard run on a malformed path and build the join-school URL from
// a garbage locale — briefly bouncing valid members to /join-school (#282, #287).
// Force the canonical localized URL so every downstream check sees a well-formed
// path. /api, /.well-known and /monitoring already returned above, so this only
// touches real app routes.
if (!hasValidLocale) {
// Preserve the visitor's active locale (next-intl's NEXT_LOCALE cookie),
// falling back to the default — so a Spanish user isn't flipped to English
// after a locale-less server-action redirect.
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value
const targetLocale = cookieLocale && (locales as readonly string[]).includes(cookieLocale) ? cookieLocale : defaultLocale
const localizedUrl = publicRedirectUrl(request, `/${targetLocale}${pathname}`)
localizedUrl.search = request.nextUrl.search
return NextResponse.redirect(localizedUrl)
}
const cleanPath = hasValidLocale
? `/${segments.slice(2).join('/')}`
: pathname
const normalizedPath = cleanPath === '' ? '/' : cleanPath
// Public routes
const publicRoutes = [
'/auth/login',
'/auth/sign-up',
'/auth/sign-up-success',
'/auth/forgot-password',
'/auth/update-password',
'/auth/confirm',
'/auth/error',
'/',
'/auth/callback',
'/create-school',
'/creators',
'/join-school',
'/platform-pricing',
'/pricing',
'/verify',
'/courses',
// OAuth 2.1 consent screen (Supabase redirects here with ?authorization_id=…).
// Must be public: the page handles its own login redirect and preserves the
// authorization_id — the middleware's redirectTo drops query strings.
'/oauth/consent',
]
const isPublicRoute = publicRoutes.some(route =>
normalizedPath === route || normalizedPath.startsWith(route + '/')
)
// --- Public routes: skip auth entirely when no cookies ---
intlResponse.headers.set('x-tenant-id', tenantId)
const hasAuthCookies = request.cookies.getAll().some(c => c.name.startsWith('sb-'))
if (isPublicRoute && !hasAuthCookies) {
// Fast path: unauthenticated user on public page — zero auth API calls
return intlResponse
}
// --- Auth session validation (1 auth API call via getUser()) ---
// Only runs when auth cookies exist (skip for bots, crawlers, unauthenticated visitors)
const { response: supabaseResponse, user } = await updateSession(request)
supabaseResponse.headers.set('x-tenant-id', tenantId)
// Set user ID header so server components can read it without calling getUser() again
if (user) {
request.headers.set('x-user-id', user.id)
intlResponse.headers.set('x-user-id', user.id)
supabaseResponse.headers.set('x-user-id', user.id)
}
// Read JWT claims from cookie (no network call) — getSession() is a local read
let userRole: 'student' | 'teacher' | 'admin' = 'student'
if (user) {
try {
// Parse JWT directly from cookie to avoid creating another Supabase client
const authCookie = request.cookies.getAll().find(c => c.name.startsWith('sb-') && c.name.endsWith('-auth-token'))
if (authCookie) {
const sessionData = JSON.parse(authCookie.value)
const accessToken = sessionData?.access_token || sessionData?.[0]?.access_token
if (accessToken) {
const payload = JSON.parse(atob(accessToken.split('.')[1]))
userRole = payload.tenant_role || payload.user_role || 'student'
}
}
} catch {
// Fallback: try chunked cookies (sb-*-auth-token.0, .1, etc.)
try {
const chunks = request.cookies.getAll()
.filter(c => c.name.match(/^sb-.*-auth-token\.\d+$/))
.sort((a, b) => a.name.localeCompare(b.name))
if (chunks.length > 0) {
const combined = chunks.map(c => c.value).join('')
const sessionData = JSON.parse(combined)
const accessToken = sessionData?.access_token
if (accessToken) {
const payload = JSON.parse(atob(accessToken.split('.')[1]))
userRole = payload.tenant_role || payload.user_role || 'student'
}
}
} catch {
// ignore — default to 'student'
}
}
}
// Auth Guards — public routes
if (isPublicRoute) {
if (user && (normalizedPath.startsWith('/auth/login') || normalizedPath.startsWith('/auth/sign-up'))) {
const dashboardUrl = publicRedirectUrl(request, `/${locale}/dashboard/${userRole}`)
return NextResponse.redirect(dashboardUrl)
}
// Copy ALL Set-Cookie headers from supabaseResponse to intlResponse.
// headers.get('set-cookie') only returns the first header — use getSetCookie()
// to get all of them. This is critical when clearing multiple sb-* cookies
// (e.g., auth token + chunked tokens) to stop the client-side refresh loop.
const setCookieHeaders = supabaseResponse.headers.getSetCookie()
for (const cookie of setCookieHeaders) {
intlResponse.headers.append('set-cookie', cookie)
}
return intlResponse
}
// Protected Routes
if (!user) {
const redirectUrl = publicRedirectUrl(request, `/${locale}/auth/login`)
// Keep the query string so purchase/enroll intent survives login
// (e.g. /checkout?courseId=42). Login form validates via getSafeNextPath.
redirectUrl.searchParams.set('redirectTo', normalizedPath + request.nextUrl.search)
return NextResponse.redirect(redirectUrl)
}
// Supabase client for DB queries (tenant_users check) — no auth API calls
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY!,
{
cookies: {
getAll() { return request.cookies.getAll() },
setAll(cookiesToSet) {
const cookieDomain = (() => {
const d = process.env.NEXT_PUBLIC_PLATFORM_DOMAIN?.split(':')[0]
if (!d || d === 'localhost' || d === '127.0.0.1') return undefined
return `.${d}`
})()
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, {
...options,
...(cookieDomain ? { domain: cookieDomain } : {}),
})
)
},
},
}
)
// Check if user is a member of the current tenant and get their tenant role
if (!normalizedPath.startsWith('/join-school')) {
const { data: membership } = await supabase
.from('tenant_users')
.select('id, role')
.eq('user_id', user.id)
.eq('tenant_id', tenantId)
.eq('status', 'active')
.single()
if (!membership) {
const joinUrl = publicRedirectUrl(request, `/${locale}/join-school`)
return NextResponse.redirect(joinUrl)
}
// Use tenant_users role (authoritative) over JWT claim for routing
if (membership?.role) {
userRole = membership.role as 'student' | 'teacher' | 'admin'
}
// Sync app_metadata.tenant_id so RLS get_tenant_id() returns the correct value.
// When JWT tenant_id doesn't match the subdomain, we:
// 1. Update app_metadata via admin API (so custom_access_token_hook picks it up)
// 2. Refresh the session so the CURRENT response gets a new JWT with the right tenant_id
// This costs 2 auth API calls but only runs when there's an actual mismatch.
try {
const authCookie = request.cookies.getAll().find(c => c.name.startsWith('sb-') && c.name.endsWith('-auth-token'))
const chunks = request.cookies.getAll()
.filter(c => c.name.match(/^sb-.*-auth-token\.\d+$/))
.sort((a, b) => a.name.localeCompare(b.name))
let accessToken: string | null = null
if (authCookie) {
const sd = JSON.parse(authCookie.value)
accessToken = sd?.access_token || sd?.[0]?.access_token
} else if (chunks.length > 0) {
const sd = JSON.parse(chunks.map(c => c.value).join(''))
accessToken = sd?.access_token
}
const jwtTenantId = accessToken
? JSON.parse(atob(accessToken.split('.')[1])).tenant_id
: null
if (jwtTenantId !== tenantId) {
// Step 1: Update app_metadata so the hook includes the right tenant_id
await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${user.id}`, {
method: 'PUT',
headers: {
apikey: SUPABASE_SERVICE_ROLE_KEY,
Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ app_metadata: { tenant_id: tenantId } }),
})
// Step 2: Refresh session so the current response cookies get a JWT
// with the updated tenant_id. This makes RLS work on the FIRST page load
// after a tenant switch (not just the second).
await supabase.auth.refreshSession()
}
} catch {
// JWT parsing failed or refresh failed — page will work on next reload
}
}
// Super admin platform guard — /platform/* requires super_admins membership
if (normalizedPath.startsWith('/platform')) {
const isSA = await checkSuperAdmin(user.id)
if (!isSA) {
const loginUrl = publicRedirectUrl(request, `/${locale}/auth/login`)
return NextResponse.redirect(loginUrl)
}
// Allow super admin through — bypass tenant membership checks
const finalPlatformResponse = intlResponse
for (const cookie of supabaseResponse.headers.getSetCookie()) {
finalPlatformResponse.headers.append('set-cookie', cookie)
}
finalPlatformResponse.headers.set('x-tenant-id', tenantId)
return finalPlatformResponse
}
// Role Checks
if (normalizedPath.startsWith('/dashboard/student') && userRole !== 'student') {
return NextResponse.redirect(publicRedirectUrl(request, `/${locale}/dashboard/${userRole}`))
}
if (normalizedPath.startsWith('/dashboard/teacher') && userRole !== 'teacher' && userRole !== 'admin') {
return NextResponse.redirect(publicRedirectUrl(request, `/${locale}/dashboard/${userRole}`))
}
if (normalizedPath.startsWith('/dashboard/admin') && userRole !== 'admin') {
return NextResponse.redirect(publicRedirectUrl(request, `/${locale}/dashboard/${userRole}`))
}
if (normalizedPath === '/dashboard') {
return NextResponse.redirect(publicRedirectUrl(request, `/${locale}/dashboard/${userRole}`))
}
// Allow access — copy ALL Set-Cookie headers (not just the first one)
// so refreshed JWT tokens from tenant sync are fully propagated.
const finalResponse = intlResponse
for (const cookie of supabaseResponse.headers.getSetCookie()) {
finalResponse.headers.append('set-cookie', cookie)
}
return finalResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|monitoring|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
'/.well-known/:path*',
],
}