Repository navigation
Expand file tree
/
Copy pathmiddleware.ts
More file actions
320 lines (295 loc) · 11.1 KB
/
Copy pathmiddleware.ts
File metadata and controls
320 lines (295 loc) · 11.1 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
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { ensureCsrfCookieInMiddleware } from '@/lib/utils/csrf';
import { isAdminRoute as isAdminPath, isMerchantRoute as isMerchantPath } from '@/lib/auth/routeAccess';
import { supportedLocales, defaultLocale, LOCALE_COOKIE } from '@/lib/i18n/locales';
import type { Locale } from '@/lib/i18n/locales';
import { ROUTES } from '@/lib/navigation/routes';
// ─── Locale helpers ──────────────────────────────────────────────────────────
/**
* Extract a supported locale prefix from the URL pathname, returning the
* locale and the pathname with the prefix stripped. Returns `null` locale
* when no supported locale prefix is found.
*/
function extractLocaleFromPathname(pathname: string): { locale: Locale; stripped: string } | null {
const segments = pathname.split('/');
// segments[0] is '' (before the leading /)
const candidate = segments[1];
if (candidate && supportedLocales.includes(candidate as Locale)) {
const stripped = '/' + segments.slice(2).join('/');
return { locale: candidate as Locale, stripped: stripped || '/' };
}
return null;
}
/**
* Detect the preferred locale from the NEXT_LOCALE cookie or the
* Accept-Language header, falling back to the configured default.
*/
function detectLocaleFromRequest(request: NextRequest): Locale {
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
if (cookieLocale && supportedLocales.includes(cookieLocale as Locale)) {
return cookieLocale as Locale;
}
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
for (const lang of acceptLanguage.split(',')) {
const base = lang.trim().split(';')[0].split('-')[0].toLowerCase();
if (supportedLocales.includes(base as Locale)) {
return base as Locale;
}
}
}
return defaultLocale;
}
/** Build a locale-prefixed pathname: `/en/dashboard`, `/fr`, etc. */
function localePath(locale: Locale, pathname: string): string {
return `/${locale}${pathname === '/' ? '' : pathname}`;
}
// ─── Middleware ───────────────────────────────────────────────────────────────
/**
* Prefix for the auth surfaces (login, magic link, ...). Authenticated
* visitors get bounced to their app shell; anonymous visitors pass through.
*/
const AUTH_PATH_PREFIX = ROUTES.AUTH_PREFIX;
/**
* Public interactive surface for payment links. Kept inside the matcher solely
* so the CSRF double-submit cookie is seeded before an unauthenticated payment
* submission POST reaches the backend (issue #738). No auth is evaluated here.
*/
const PAYMENT_PATH_PREFIX = '/pay';
export function middleware(request: NextRequest) {
// ── Locale path-based routing ─────────────────────────────────────────────
const extracted = extractLocaleFromPathname(request.nextUrl.pathname);
// If the URL already has a locale prefix, strip it for internal routing and
// persist the preference in a cookie so downstream code (server components,
// client-side i18n) can resolve the active locale.
let activeLocale: Locale;
let internalPathname: string;
if (extracted) {
activeLocale = extracted.locale;
internalPathname = extracted.stripped;
} else {
// Bare path — redirect to the locale-prefixed equivalent for SEO and
// consistency. The locale is inferred from the cookie or Accept-Language.
activeLocale = detectLocaleFromRequest(request);
internalPathname = request.nextUrl.pathname;
const prefixedUrl = request.nextUrl.clone();
prefixedUrl.pathname = localePath(activeLocale, internalPathname);
return NextResponse.redirect(prefixedUrl);
}
// ── Auth & RBAC (unchanged logic, operates on the stripped path) ──────────
const token = request.cookies.get('auth_token')?.value;
const role = request.cookies.get('user_role')?.value;
const withCsrf = (response: NextResponse): NextResponse => {
ensureCsrfCookieInMiddleware(request, response);
// Persist locale so the client and server can both resolve it.
response.cookies.set(LOCALE_COOKIE, activeLocale, {
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: 'lax',
});
return response;
};
/**
* Redirect helper that keeps the active locale prefix, so a French visitor is
* never bounced into the English tree.
*/
const redirectTo = (path: string): NextResponse =>
withCsrf(NextResponse.redirect(new URL(localePath(activeLocale, path), request.url)));
// `request.nextUrl.pathname` still carries the locale prefix — every auth
// check below runs against the locale-stripped path instead.
const pathname = internalPathname;
// Public payment links have no auth requirement — bail before the protected
// checks below so an anonymous payer is never redirected to login.
if (pathname.startsWith(PAYMENT_PATH_PREFIX)) {
return withCsrf(NextResponse.rewrite(new URL(internalPathname, request.url)));
}
// If trying to access auth pages while logged in, redirect to dashboard
// Exception: 2FA page is always accessible after partial login
if (pathname.startsWith(AUTH_PATH_PREFIX)) {
if (token) {
return redirectTo(role === 'admin' ? ROUTES.OVERVIEW : ROUTES.DASHBOARD);
}
return withCsrf(
NextResponse.rewrite(new URL(internalPathname, request.url)),
);
}
// Everything else in the matcher is a protected route — require a session.
if (!token) {
return redirectTo(ROUTES.LOGIN);
}
// Redirect onboarded merchants away from onboarding page
const isOnboarded = request.cookies.get('merchant_onboarded')?.value === 'true';
if (pathname === ROUTES.ONBOARDING && isOnboarded) {
return redirectTo(ROUTES.DASHBOARD);
}
// Role-based protection
if (isAdminPath(pathname) && role !== 'admin') {
return redirectTo(ROUTES.DASHBOARD); // redirect merchants from admin
}
// Protect merchant routes from admins
if (isMerchantPath(pathname) && role === 'admin') {
return redirectTo(ROUTES.OVERVIEW);
}
return withCsrf(
NextResponse.rewrite(new URL(internalPathname, request.url)),
);
}
/*
* The matcher below is repeated once per supported locale (`/en/dashboard`,
* `/fr/dashboard`, ...). Those entries have to be spelled out as literals:
* Next statically analyses `config.matcher` at build time and silently falls
* back to matching *every* route when it meets anything it cannot evaluate
* (a spread, a function call, an interpolated template string), which would
* undo the allow-list from issue #738. Keep them in sync with `supportedLocales`
* in `lib/i18n/locales.ts`.
*
* Why they exist at all: every allow-listed route also has a locale-prefixed
* twin, because the middleware redirects bare paths to their localized form.
* The prefixed URL must match the matcher too, otherwise that redirect target
* would skip the middleware and 404 instead of being rewritten back onto the
* prefix-less route.
*/
export const config = {
/*
* Positive allow-list of routes that need authentication evaluation. The
* middleware now only runs here instead of on every non-static request:
* static assets, API routes, the homepage and the marketing/reference pages
* bypass it entirely (issue #738).
*
* `/merchants/kyb` and the `/settings/` prefix are covered by the broader
* `/merchants/:path*` and `/settings/:path*` entries respectively.
*/
matcher: [
// Auth surfaces
'/auth/:path*',
// Merchant app shell + onboarding
'/onboarding/:path*',
'/dashboard/:path*',
'/transactions/:path*',
'/wallet/:path*',
'/fx/:path*',
'/developers/:path*',
'/settings/:path*',
'/payments/:path*',
'/settlement/:path*',
'/payment-links/:path*',
'/notifications/:path*',
// Admin app shell
'/overview/:path*',
'/merchants/:path*',
'/anchors/:path*',
'/fx-management/:path*',
'/compliance/:path*',
'/audit/:path*',
'/batches/:path*',
'/security/:path*',
'/webhooks/:path*',
'/assets/:path*',
'/alerts/:path*',
'/admin/:path*',
// Public interactive: payment links (CSRF seed, see PAYMENT_PATH_PREFIX)
'/pay/:path*',
// Locale-prefixed twins (`/en/dashboard`, `/fr/auth/login`, ...).
'/en/auth/:path*',
'/en/onboarding/:path*',
'/en/dashboard/:path*',
'/en/transactions/:path*',
'/en/wallet/:path*',
'/en/fx/:path*',
'/en/developers/:path*',
'/en/settings/:path*',
'/en/payments/:path*',
'/en/settlement/:path*',
'/en/payment-links/:path*',
'/en/notifications/:path*',
'/en/overview/:path*',
'/en/merchants/:path*',
'/en/anchors/:path*',
'/en/fx-management/:path*',
'/en/compliance/:path*',
'/en/audit/:path*',
'/en/batches/:path*',
'/en/security/:path*',
'/en/webhooks/:path*',
'/en/assets/:path*',
'/en/alerts/:path*',
'/en/admin/:path*',
'/en/pay/:path*',
'/fr/auth/:path*',
'/fr/onboarding/:path*',
'/fr/dashboard/:path*',
'/fr/transactions/:path*',
'/fr/wallet/:path*',
'/fr/fx/:path*',
'/fr/developers/:path*',
'/fr/settings/:path*',
'/fr/payments/:path*',
'/fr/settlement/:path*',
'/fr/payment-links/:path*',
'/fr/notifications/:path*',
'/fr/overview/:path*',
'/fr/merchants/:path*',
'/fr/anchors/:path*',
'/fr/fx-management/:path*',
'/fr/compliance/:path*',
'/fr/audit/:path*',
'/fr/batches/:path*',
'/fr/security/:path*',
'/fr/webhooks/:path*',
'/fr/assets/:path*',
'/fr/alerts/:path*',
'/fr/admin/:path*',
'/fr/pay/:path*',
'/pt/auth/:path*',
'/pt/onboarding/:path*',
'/pt/dashboard/:path*',
'/pt/transactions/:path*',
'/pt/wallet/:path*',
'/pt/fx/:path*',
'/pt/developers/:path*',
'/pt/settings/:path*',
'/pt/payments/:path*',
'/pt/settlement/:path*',
'/pt/payment-links/:path*',
'/pt/notifications/:path*',
'/pt/overview/:path*',
'/pt/merchants/:path*',
'/pt/anchors/:path*',
'/pt/fx-management/:path*',
'/pt/compliance/:path*',
'/pt/audit/:path*',
'/pt/batches/:path*',
'/pt/security/:path*',
'/pt/webhooks/:path*',
'/pt/assets/:path*',
'/pt/alerts/:path*',
'/pt/admin/:path*',
'/pt/pay/:path*',
'/sw/auth/:path*',
'/sw/onboarding/:path*',
'/sw/dashboard/:path*',
'/sw/transactions/:path*',
'/sw/wallet/:path*',
'/sw/fx/:path*',
'/sw/developers/:path*',
'/sw/settings/:path*',
'/sw/payments/:path*',
'/sw/settlement/:path*',
'/sw/payment-links/:path*',
'/sw/notifications/:path*',
'/sw/overview/:path*',
'/sw/merchants/:path*',
'/sw/anchors/:path*',
'/sw/fx-management/:path*',
'/sw/compliance/:path*',
'/sw/audit/:path*',
'/sw/batches/:path*',
'/sw/security/:path*',
'/sw/webhooks/:path*',
'/sw/assets/:path*',
'/sw/alerts/:path*',
'/sw/admin/:path*',
'/sw/pay/:path*',
],
};