-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.ts
More file actions
108 lines (94 loc) · 4.3 KB
/
Copy pathproxy.ts
File metadata and controls
108 lines (94 loc) · 4.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
import { NextRequest, NextResponse } from 'next/server';
// ─────────────────────────────────────────────────────────────────────────────
// Auth proxy (Next.js 16's renamed "middleware" convention — the file MUST be
// `proxy.ts` at the project root and export a `proxy` function for Next to run
// it). It is the single gate that keeps unauthenticated traffic out of the
// Plans app:
//
// • Page routes → redirect to the Structum login (with returnTo) so the
// parent platform can authenticate and set the shared `token` cookie.
// • API routes → return 401 JSON (NOT 500) so the client can react/redirect
// instead of showing "broken" pages.
//
// It runs on the Edge runtime, so it can only cheaply check the token's
// presence + expiry (no signature verification — that happens in lib/auth.ts on
// the Node side). That's enough to convert the common "no token / expired
// token" case into a clean 401/redirect for every route at once.
// ─────────────────────────────────────────────────────────────────────────────
function decodeJwtExp(token: string): number | null {
try {
const part = token.split('.')[1];
if (!part) return null;
// JWT uses base64url; convert to base64 and pad before decoding.
const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const json = atob(padded);
const payload = JSON.parse(json) as { exp?: number };
return typeof payload.exp === 'number' ? payload.exp : null;
} catch {
return null;
}
}
/** Structurally-valid and not expired. Signature is verified later in lib/auth. */
function tokenLooksValid(token: string): boolean {
if (token.split('.').length !== 3) return false;
const exp = decodeJwtExp(token);
if (exp === null) return true; // no exp claim — let the Node verify decide
return exp * 1000 > Date.now();
}
function isDevAuthAllowed(): boolean {
return !process.env.JWT_SECRET || process.env.ALLOW_DEV_AUTH === 'true';
}
function resolveLoginUrl(): string | null {
const explicit = process.env.STRUCTUM_LOGIN_URL;
if (explicit) return explicit;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
if (appUrl) return `${appUrl.replace(/\/$/, '')}/login`;
return null;
}
export default function proxy(req: NextRequest) {
const { pathname, search } = req.nextUrl;
// Cron endpoints authenticate themselves with CRON_SECRET — never block them.
if (pathname.startsWith('/api/v1/cron/')) {
return NextResponse.next();
}
// Health check is intentionally public (no secrets) so deployment can be
// verified without a login session.
if (pathname === '/api/v1/health') {
return NextResponse.next();
}
// Local dev / opt-in: identity comes from lib/auth dev fallback, don't gate.
if (isDevAuthAllowed()) {
return NextResponse.next();
}
const token = req.cookies.get('token')?.value;
if (token && tokenLooksValid(token)) {
return NextResponse.next();
}
// Unauthenticated API call → 401 JSON (client handles it; no 500s).
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{ data: null, error: { message: 'Unauthorized' }, message: 'Unauthorized' },
{ status: 401 }
);
}
// Unauthenticated page → redirect to the platform login with returnTo.
const loginUrl = resolveLoginUrl();
if (!loginUrl) {
console.error('[proxy] No login URL configured (STRUCTUM_LOGIN_URL / NEXT_PUBLIC_APP_URL)');
return NextResponse.next();
}
const separator = loginUrl.includes('?') ? '&' : '?';
const response = NextResponse.redirect(
`${loginUrl}${separator}returnTo=${encodeURIComponent(pathname + search)}`
);
// Clear the stale cookie so the browser stops resending an expired token.
if (token) {
response.cookies.set('token', '', { maxAge: 0, path: '/' });
}
return response;
}
export const config = {
// Run on everything except Next internals and static assets.
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)'],
};