@payez/next-mvp 3.1.1 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -317,7 +317,8 @@ async function checkViability(request, endpoint, log) {
|
|
|
317
317
|
'Cache-Control': 'no-store',
|
|
318
318
|
'Cookie': request.headers.get('cookie') || ''
|
|
319
319
|
},
|
|
320
|
-
credentials: 'include'
|
|
320
|
+
credentials: 'include',
|
|
321
|
+
signal: AbortSignal.timeout(5000),
|
|
321
322
|
});
|
|
322
323
|
if (response.ok) {
|
|
323
324
|
const data = await response.json();
|
|
@@ -359,18 +360,32 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
|
|
|
359
360
|
return handleRefresh(request, safeCallback, opts);
|
|
360
361
|
}
|
|
361
362
|
}
|
|
363
|
+
/** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
|
|
364
|
+
const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
|
|
362
365
|
/** Handle 'allow' decision - run RBAC if enabled */
|
|
363
366
|
async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
|
|
364
367
|
const isPublic = (0, route_config_1.isUnauthenticatedRoute)(pathname);
|
|
365
|
-
if ((0, rbac_check_1.isRBACEnabled)() && !isPublic) {
|
|
368
|
+
if ((0, rbac_check_1.isRBACEnabled)() && !isPublic && sessionPointer.exists) {
|
|
369
|
+
// Skip RBAC for error/fallback pages to prevent redirect loops
|
|
370
|
+
if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p))) {
|
|
371
|
+
return server_1.NextResponse.next();
|
|
372
|
+
}
|
|
366
373
|
if (!sessionPointer.clientId) {
|
|
367
|
-
console.error('[MIDDLEWARE] RBAC: No clientId');
|
|
368
|
-
|
|
374
|
+
console.error('[MIDDLEWARE] RBAC: No clientId — returning 401');
|
|
375
|
+
if (pathname.startsWith('/api/')) {
|
|
376
|
+
return server_1.NextResponse.json({ error: 'Unauthorized — missing clientId for RBAC' }, { status: 401 });
|
|
377
|
+
}
|
|
378
|
+
return server_1.NextResponse.redirect(new URL('/unauthorized', request.url));
|
|
369
379
|
}
|
|
370
380
|
try {
|
|
371
381
|
const result = await (0, rbac_check_1.checkPagePermission)(pathname, sessionPointer.roles, sessionPointer.clientId);
|
|
372
382
|
if (!result.allowed) {
|
|
373
383
|
console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
|
|
384
|
+
// In development, fail open - RBAC API may not be fully configured
|
|
385
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
386
|
+
console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
|
|
387
|
+
return server_1.NextResponse.next();
|
|
388
|
+
}
|
|
374
389
|
return server_1.NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
|
|
375
390
|
}
|
|
376
391
|
if (result.requires_2fa && !sessionStatus.twoFactorComplete) {
|
|
@@ -379,6 +394,11 @@ async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
|
|
|
379
394
|
}
|
|
380
395
|
catch (error) {
|
|
381
396
|
console.error('[MIDDLEWARE] RBAC error:', error);
|
|
397
|
+
// In development, fail open
|
|
398
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
399
|
+
console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
|
|
400
|
+
return server_1.NextResponse.next();
|
|
401
|
+
}
|
|
382
402
|
return server_1.NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
|
|
383
403
|
}
|
|
384
404
|
}
|
|
@@ -403,7 +423,8 @@ async function handleRefresh(request, safeCallback, opts) {
|
|
|
403
423
|
'x-session-token': request.cookies.get((0, app_slug_1.getSessionCookieName)())?.value ||
|
|
404
424
|
request.cookies.get((0, app_slug_1.getSecureSessionCookieName)())?.value || ''
|
|
405
425
|
},
|
|
406
|
-
credentials: 'include'
|
|
426
|
+
credentials: 'include',
|
|
427
|
+
signal: AbortSignal.timeout(5000),
|
|
407
428
|
});
|
|
408
429
|
if (response.ok) {
|
|
409
430
|
const data = await response.json();
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Page RBAC Check Module
|
|
3
3
|
*
|
|
4
|
-
* Checks page-level permissions via Vibe API.
|
|
4
|
+
* Checks page-level permissions via Vibe API through the IDP Proxy.
|
|
5
5
|
* Uses in-memory cache to reduce API calls.
|
|
6
6
|
* Fails closed (DENY) on errors or timeout.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
|
|
9
|
+
* which injects proper HMAC credentials for the Vibe API.
|
|
10
|
+
*
|
|
11
|
+
* @version 2.0.0
|
|
9
12
|
* @since page-rbac-2026-01
|
|
10
13
|
*/
|
|
11
14
|
export interface RBACResult {
|
|
@@ -29,11 +32,15 @@ export declare function clearRBACCache(): void;
|
|
|
29
32
|
/**
|
|
30
33
|
* Check if user has permission to access a page.
|
|
31
34
|
*
|
|
32
|
-
*
|
|
35
|
+
* Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
|
|
36
|
+
* proper HMAC credentials. The Vibe RBAC endpoint requires client context
|
|
37
|
+
* that only the proxy can provide.
|
|
38
|
+
*
|
|
39
|
+
* FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
|
|
33
40
|
*
|
|
34
41
|
* @param path - The route path to check
|
|
35
42
|
* @param userRoles - User's roles from session
|
|
36
|
-
* @param clientId - Client
|
|
43
|
+
* @param clientId - Client slug for multi-tenancy
|
|
37
44
|
* @param userClaims - Optional claims for claim-based authorization
|
|
38
45
|
* @returns RBAC result with allowed/denied status
|
|
39
46
|
*/
|
|
@@ -2,18 +2,44 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Page RBAC Check Module
|
|
4
4
|
*
|
|
5
|
-
* Checks page-level permissions via Vibe API.
|
|
5
|
+
* Checks page-level permissions via Vibe API through the IDP Proxy.
|
|
6
6
|
* Uses in-memory cache to reduce API calls.
|
|
7
7
|
* Fails closed (DENY) on errors or timeout.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
|
|
10
|
+
* which injects proper HMAC credentials for the Vibe API.
|
|
11
|
+
*
|
|
12
|
+
* @version 2.0.0
|
|
10
13
|
* @since page-rbac-2026-01
|
|
11
14
|
*/
|
|
12
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
16
|
exports.clearRBACCache = clearRBACCache;
|
|
14
17
|
exports.checkPagePermission = checkPagePermission;
|
|
15
18
|
exports.isRBACEnabled = isRBACEnabled;
|
|
16
|
-
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// WEB CRYPTO HELPERS (Edge Runtime compatible)
|
|
21
|
+
// ============================================================================
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
async function sha256Hex(input) {
|
|
24
|
+
const data = encoder.encode(input);
|
|
25
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
26
|
+
return Array.from(new Uint8Array(hash))
|
|
27
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
28
|
+
.join('');
|
|
29
|
+
}
|
|
30
|
+
async function hmacSha256Base64(key, message) {
|
|
31
|
+
const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
32
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message));
|
|
33
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
34
|
+
}
|
|
35
|
+
function base64ToUint8Array(base64) {
|
|
36
|
+
const binary = atob(base64);
|
|
37
|
+
const bytes = new Uint8Array(binary.length);
|
|
38
|
+
for (let i = 0; i < binary.length; i++) {
|
|
39
|
+
bytes[i] = binary.charCodeAt(i);
|
|
40
|
+
}
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
17
43
|
// ============================================================================
|
|
18
44
|
// CACHE
|
|
19
45
|
// ============================================================================
|
|
@@ -25,10 +51,11 @@ const MAX_CACHE_SIZE = 1000;
|
|
|
25
51
|
* Generate cache key for RBAC result.
|
|
26
52
|
* Uses SHA-256 hash to avoid key collisions and limit key size.
|
|
27
53
|
*/
|
|
28
|
-
function getCacheKey(clientId, path, roles) {
|
|
54
|
+
async function getCacheKey(clientId, path, roles) {
|
|
29
55
|
const sortedRoles = [...roles].sort().join(',');
|
|
30
56
|
const input = JSON.stringify({ clientId, path, roles: sortedRoles });
|
|
31
|
-
|
|
57
|
+
const hash = await sha256Hex(input);
|
|
58
|
+
return hash.substring(0, 32);
|
|
32
59
|
}
|
|
33
60
|
/**
|
|
34
61
|
* Get cached RBAC result if valid.
|
|
@@ -68,99 +95,100 @@ function clearRBACCache() {
|
|
|
68
95
|
rbacCache.clear();
|
|
69
96
|
}
|
|
70
97
|
// ============================================================================
|
|
71
|
-
//
|
|
72
|
-
// ============================================================================
|
|
73
|
-
/**
|
|
74
|
-
* Generate HMAC-SHA256 signature for Vibe API request.
|
|
75
|
-
* SECURITY: Signing key is required in production.
|
|
76
|
-
*/
|
|
77
|
-
function generateSignature(path, clientId, timestamp) {
|
|
78
|
-
const signingKey = process.env.VIBE_SIGNING_KEY;
|
|
79
|
-
// SECURITY: Require signing key in production
|
|
80
|
-
if (!signingKey) {
|
|
81
|
-
if (process.env.NODE_ENV === 'production') {
|
|
82
|
-
throw new Error('[RBAC] VIBE_SIGNING_KEY is required in production');
|
|
83
|
-
}
|
|
84
|
-
return ''; // Signature optional in dev only
|
|
85
|
-
}
|
|
86
|
-
const stringToSign = `${timestamp}|GET|/api/v1/rbac/check|${path}|${clientId}`;
|
|
87
|
-
return (0, crypto_1.createHmac)('sha256', Buffer.from(signingKey, 'base64'))
|
|
88
|
-
.update(stringToSign)
|
|
89
|
-
.digest('base64');
|
|
90
|
-
}
|
|
91
|
-
// ============================================================================
|
|
92
|
-
// RBAC CHECK
|
|
98
|
+
// RBAC CHECK (via IDP Proxy)
|
|
93
99
|
// ============================================================================
|
|
94
100
|
/**
|
|
95
101
|
* Check if user has permission to access a page.
|
|
96
102
|
*
|
|
97
|
-
*
|
|
103
|
+
* Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
|
|
104
|
+
* proper HMAC credentials. The Vibe RBAC endpoint requires client context
|
|
105
|
+
* that only the proxy can provide.
|
|
106
|
+
*
|
|
107
|
+
* FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
|
|
98
108
|
*
|
|
99
109
|
* @param path - The route path to check
|
|
100
110
|
* @param userRoles - User's roles from session
|
|
101
|
-
* @param clientId - Client
|
|
111
|
+
* @param clientId - Client slug for multi-tenancy
|
|
102
112
|
* @param userClaims - Optional claims for claim-based authorization
|
|
103
113
|
* @returns RBAC result with allowed/denied status
|
|
104
114
|
*/
|
|
105
115
|
async function checkPagePermission(path, userRoles, clientId, userClaims) {
|
|
106
116
|
// Check cache first
|
|
107
|
-
const cacheKey = getCacheKey(clientId, path, userRoles);
|
|
117
|
+
const cacheKey = await getCacheKey(clientId, path, userRoles);
|
|
108
118
|
const cached = getCachedResult(cacheKey);
|
|
109
119
|
if (cached) {
|
|
110
120
|
return cached;
|
|
111
121
|
}
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
const idpUrl = process.env.NEXT_PUBLIC_IDP_URL || process.env.IDP_URL;
|
|
123
|
+
const vibeClientId = process.env.VIBE_CLIENT_ID;
|
|
124
|
+
const hmacKey = process.env.VIBE_HMAC_KEY || process.env.IDP_SIGNING_KEY;
|
|
125
|
+
if (!idpUrl) {
|
|
126
|
+
console.error('[RBAC] IDP_URL not configured');
|
|
115
127
|
return {
|
|
116
128
|
allowed: false,
|
|
117
129
|
reason: 'rbac_not_configured',
|
|
118
130
|
redirect: '/error?code=rbac_not_configured',
|
|
119
131
|
};
|
|
120
132
|
}
|
|
121
|
-
// Build
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
// Build RBAC endpoint with query params
|
|
134
|
+
// Vibe route is /v1/rbac/check (no /api/ prefix)
|
|
135
|
+
const params = new URLSearchParams();
|
|
136
|
+
params.set('path', path);
|
|
137
|
+
params.set('roles', userRoles.join(','));
|
|
126
138
|
if (userClaims && Object.keys(userClaims).length > 0) {
|
|
127
139
|
const claimsParam = Object.entries(userClaims)
|
|
128
140
|
.map(([type, value]) => `${type}:${value}`)
|
|
129
141
|
.join(',');
|
|
130
|
-
|
|
142
|
+
params.set('claims', claimsParam);
|
|
131
143
|
}
|
|
132
|
-
|
|
144
|
+
const rbacEndpoint = `/v1/rbac/check?${params.toString()}`;
|
|
145
|
+
// Build proxy request
|
|
146
|
+
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
133
147
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
134
|
-
const signature = generateSignature(path, clientId, timestamp);
|
|
135
|
-
// Build headers
|
|
136
148
|
const headers = {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
137
150
|
'Accept': 'application/json',
|
|
138
|
-
'X-Client-Id': clientId,
|
|
139
|
-
'X-Vibe-Client-Id': clientId,
|
|
140
151
|
};
|
|
141
|
-
if (
|
|
152
|
+
if (vibeClientId) {
|
|
153
|
+
headers['X-Vibe-Client-Id'] = vibeClientId;
|
|
154
|
+
}
|
|
155
|
+
// Sign with HMAC (same format as vibe-client: timestamp|method|endpoint)
|
|
156
|
+
if (hmacKey && vibeClientId) {
|
|
157
|
+
const stringToSign = `${timestamp}|GET|${rbacEndpoint}`;
|
|
158
|
+
const keyBuffer = base64ToUint8Array(hmacKey);
|
|
159
|
+
const signature = await hmacSha256Base64(keyBuffer, stringToSign);
|
|
142
160
|
headers['X-Vibe-Timestamp'] = String(timestamp);
|
|
143
161
|
headers['X-Vibe-Signature'] = signature;
|
|
144
162
|
}
|
|
163
|
+
// Proxy body format: { endpoint, method, data }
|
|
164
|
+
const proxyBody = {
|
|
165
|
+
endpoint: rbacEndpoint,
|
|
166
|
+
method: 'GET',
|
|
167
|
+
data: null,
|
|
168
|
+
};
|
|
145
169
|
try {
|
|
146
170
|
// 2 second timeout - fail closed
|
|
147
171
|
const controller = new AbortController();
|
|
148
172
|
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
149
|
-
const response = await fetch(
|
|
150
|
-
method: '
|
|
173
|
+
const response = await fetch(proxyUrl, {
|
|
174
|
+
method: 'POST',
|
|
151
175
|
headers,
|
|
176
|
+
body: JSON.stringify(proxyBody),
|
|
152
177
|
signal: controller.signal,
|
|
153
178
|
});
|
|
154
179
|
clearTimeout(timeoutId);
|
|
155
180
|
if (!response.ok) {
|
|
156
|
-
console.error('[RBAC]
|
|
181
|
+
console.error('[RBAC] Proxy error:', response.status, response.statusText);
|
|
157
182
|
return {
|
|
158
183
|
allowed: false,
|
|
159
184
|
reason: 'rbac_api_error',
|
|
160
185
|
redirect: '/error?code=rbac_error',
|
|
161
186
|
};
|
|
162
187
|
}
|
|
163
|
-
const
|
|
188
|
+
const body = await response.json();
|
|
189
|
+
// Vibe API wraps responses: { success: true, data: { allowed, reason, ... } }
|
|
190
|
+
// Unwrap the .data property if present, otherwise use body directly
|
|
191
|
+
const result = body?.data ?? body;
|
|
164
192
|
// Cache the result
|
|
165
193
|
setCachedResult(cacheKey, result);
|
|
166
194
|
return result;
|
|
@@ -168,14 +196,14 @@ async function checkPagePermission(path, userRoles, clientId, userClaims) {
|
|
|
168
196
|
catch (error) {
|
|
169
197
|
// Fail closed on any error
|
|
170
198
|
if (error.name === 'AbortError') {
|
|
171
|
-
console.error('[RBAC]
|
|
199
|
+
console.error('[RBAC] Proxy timeout (2s exceeded)');
|
|
172
200
|
return {
|
|
173
201
|
allowed: false,
|
|
174
202
|
reason: 'rbac_timeout',
|
|
175
203
|
redirect: '/error?code=rbac_timeout',
|
|
176
204
|
};
|
|
177
205
|
}
|
|
178
|
-
console.error('[RBAC]
|
|
206
|
+
console.error('[RBAC] Proxy error:', error);
|
|
179
207
|
return {
|
|
180
208
|
allowed: false,
|
|
181
209
|
reason: 'rbac_service_unavailable',
|
package/package.json
CHANGED
|
@@ -439,7 +439,8 @@ async function checkViability(
|
|
|
439
439
|
'Cache-Control': 'no-store',
|
|
440
440
|
'Cookie': request.headers.get('cookie') || ''
|
|
441
441
|
},
|
|
442
|
-
credentials: 'include'
|
|
442
|
+
credentials: 'include',
|
|
443
|
+
signal: AbortSignal.timeout(5000),
|
|
443
444
|
});
|
|
444
445
|
|
|
445
446
|
if (response.ok) {
|
|
@@ -503,6 +504,9 @@ async function executeDecision(
|
|
|
503
504
|
}
|
|
504
505
|
}
|
|
505
506
|
|
|
507
|
+
/** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
|
|
508
|
+
const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
|
|
509
|
+
|
|
506
510
|
/** Handle 'allow' decision - run RBAC if enabled */
|
|
507
511
|
async function handleAllow(
|
|
508
512
|
request: NextRequest,
|
|
@@ -512,10 +516,21 @@ async function handleAllow(
|
|
|
512
516
|
): Promise<NextResponse> {
|
|
513
517
|
const isPublic = isUnauthenticatedRoute(pathname);
|
|
514
518
|
|
|
515
|
-
if (isRBACEnabled() && !isPublic) {
|
|
519
|
+
if (isRBACEnabled() && !isPublic && sessionPointer.exists) {
|
|
520
|
+
// Skip RBAC for error/fallback pages to prevent redirect loops
|
|
521
|
+
if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p))) {
|
|
522
|
+
return NextResponse.next();
|
|
523
|
+
}
|
|
524
|
+
|
|
516
525
|
if (!sessionPointer.clientId) {
|
|
517
|
-
console.error('[MIDDLEWARE] RBAC: No clientId');
|
|
518
|
-
|
|
526
|
+
console.error('[MIDDLEWARE] RBAC: No clientId — returning 401');
|
|
527
|
+
if (pathname.startsWith('/api/')) {
|
|
528
|
+
return NextResponse.json(
|
|
529
|
+
{ error: 'Unauthorized — missing clientId for RBAC' },
|
|
530
|
+
{ status: 401 }
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return NextResponse.redirect(new URL('/unauthorized', request.url));
|
|
519
534
|
}
|
|
520
535
|
|
|
521
536
|
try {
|
|
@@ -523,6 +538,13 @@ async function handleAllow(
|
|
|
523
538
|
|
|
524
539
|
if (!result.allowed) {
|
|
525
540
|
console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
|
|
541
|
+
|
|
542
|
+
// In development, fail open - RBAC API may not be fully configured
|
|
543
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
544
|
+
console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
|
|
545
|
+
return NextResponse.next();
|
|
546
|
+
}
|
|
547
|
+
|
|
526
548
|
return NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
|
|
527
549
|
}
|
|
528
550
|
|
|
@@ -533,6 +555,13 @@ async function handleAllow(
|
|
|
533
555
|
}
|
|
534
556
|
} catch (error) {
|
|
535
557
|
console.error('[MIDDLEWARE] RBAC error:', error);
|
|
558
|
+
|
|
559
|
+
// In development, fail open
|
|
560
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
561
|
+
console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
|
|
562
|
+
return NextResponse.next();
|
|
563
|
+
}
|
|
564
|
+
|
|
536
565
|
return NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
|
|
537
566
|
}
|
|
538
567
|
}
|
|
@@ -569,7 +598,8 @@ async function handleRefresh(
|
|
|
569
598
|
'x-session-token': request.cookies.get(getSessionCookieName())?.value ||
|
|
570
599
|
request.cookies.get(getSecureSessionCookieName())?.value || ''
|
|
571
600
|
},
|
|
572
|
-
credentials: 'include'
|
|
601
|
+
credentials: 'include',
|
|
602
|
+
signal: AbortSignal.timeout(5000),
|
|
573
603
|
});
|
|
574
604
|
|
|
575
605
|
if (response.ok) {
|
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Page RBAC Check Module
|
|
3
3
|
*
|
|
4
|
-
* Checks page-level permissions via Vibe API.
|
|
4
|
+
* Checks page-level permissions via Vibe API through the IDP Proxy.
|
|
5
5
|
* Uses in-memory cache to reduce API calls.
|
|
6
6
|
* Fails closed (DENY) on errors or timeout.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* All requests route through the IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy)
|
|
9
|
+
* which injects proper HMAC credentials for the Vibe API.
|
|
10
|
+
*
|
|
11
|
+
* @version 2.0.0
|
|
9
12
|
* @since page-rbac-2026-01
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// WEB CRYPTO HELPERS (Edge Runtime compatible)
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
|
|
21
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
22
|
+
const data = encoder.encode(input);
|
|
23
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
24
|
+
return Array.from(new Uint8Array(hash))
|
|
25
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
26
|
+
.join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function hmacSha256Base64(key: Uint8Array, message: string): Promise<string> {
|
|
30
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
31
|
+
'raw',
|
|
32
|
+
key as BufferSource,
|
|
33
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
34
|
+
false,
|
|
35
|
+
['sign']
|
|
36
|
+
);
|
|
37
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message));
|
|
38
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function base64ToUint8Array(base64: string): Uint8Array {
|
|
42
|
+
const binary = atob(base64);
|
|
43
|
+
const bytes = new Uint8Array(binary.length);
|
|
44
|
+
for (let i = 0; i < binary.length; i++) {
|
|
45
|
+
bytes[i] = binary.charCodeAt(i);
|
|
46
|
+
}
|
|
47
|
+
return bytes;
|
|
48
|
+
}
|
|
13
49
|
|
|
14
50
|
// ============================================================================
|
|
15
51
|
// TYPES
|
|
@@ -45,10 +81,11 @@ const MAX_CACHE_SIZE = 1000;
|
|
|
45
81
|
* Generate cache key for RBAC result.
|
|
46
82
|
* Uses SHA-256 hash to avoid key collisions and limit key size.
|
|
47
83
|
*/
|
|
48
|
-
function getCacheKey(clientId: string, path: string, roles: string[]): string {
|
|
84
|
+
async function getCacheKey(clientId: string, path: string, roles: string[]): Promise<string> {
|
|
49
85
|
const sortedRoles = [...roles].sort().join(',');
|
|
50
86
|
const input = JSON.stringify({ clientId, path, roles: sortedRoles });
|
|
51
|
-
|
|
87
|
+
const hash = await sha256Hex(input);
|
|
88
|
+
return hash.substring(0, 32);
|
|
52
89
|
}
|
|
53
90
|
|
|
54
91
|
/**
|
|
@@ -93,46 +130,21 @@ export function clearRBACCache(): void {
|
|
|
93
130
|
}
|
|
94
131
|
|
|
95
132
|
// ============================================================================
|
|
96
|
-
//
|
|
97
|
-
// ============================================================================
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Generate HMAC-SHA256 signature for Vibe API request.
|
|
101
|
-
* SECURITY: Signing key is required in production.
|
|
102
|
-
*/
|
|
103
|
-
function generateSignature(
|
|
104
|
-
path: string,
|
|
105
|
-
clientId: string,
|
|
106
|
-
timestamp: number
|
|
107
|
-
): string {
|
|
108
|
-
const signingKey = process.env.VIBE_SIGNING_KEY;
|
|
109
|
-
|
|
110
|
-
// SECURITY: Require signing key in production
|
|
111
|
-
if (!signingKey) {
|
|
112
|
-
if (process.env.NODE_ENV === 'production') {
|
|
113
|
-
throw new Error('[RBAC] VIBE_SIGNING_KEY is required in production');
|
|
114
|
-
}
|
|
115
|
-
return ''; // Signature optional in dev only
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const stringToSign = `${timestamp}|GET|/api/v1/rbac/check|${path}|${clientId}`;
|
|
119
|
-
return createHmac('sha256', Buffer.from(signingKey, 'base64'))
|
|
120
|
-
.update(stringToSign)
|
|
121
|
-
.digest('base64');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ============================================================================
|
|
125
|
-
// RBAC CHECK
|
|
133
|
+
// RBAC CHECK (via IDP Proxy)
|
|
126
134
|
// ============================================================================
|
|
127
135
|
|
|
128
136
|
/**
|
|
129
137
|
* Check if user has permission to access a page.
|
|
130
138
|
*
|
|
131
|
-
*
|
|
139
|
+
* Routes through IDP Vibe Proxy ({IDP_URL}/api/vibe/proxy) which injects
|
|
140
|
+
* proper HMAC credentials. The Vibe RBAC endpoint requires client context
|
|
141
|
+
* that only the proxy can provide.
|
|
142
|
+
*
|
|
143
|
+
* FAIL CLOSED: If proxy is unreachable or times out, access is DENIED.
|
|
132
144
|
*
|
|
133
145
|
* @param path - The route path to check
|
|
134
146
|
* @param userRoles - User's roles from session
|
|
135
|
-
* @param clientId - Client
|
|
147
|
+
* @param clientId - Client slug for multi-tenancy
|
|
136
148
|
* @param userClaims - Optional claims for claim-based authorization
|
|
137
149
|
* @returns RBAC result with allowed/denied status
|
|
138
150
|
*/
|
|
@@ -143,15 +155,18 @@ export async function checkPagePermission(
|
|
|
143
155
|
userClaims?: Record<string, string>
|
|
144
156
|
): Promise<RBACResult> {
|
|
145
157
|
// Check cache first
|
|
146
|
-
const cacheKey = getCacheKey(clientId, path, userRoles);
|
|
158
|
+
const cacheKey = await getCacheKey(clientId, path, userRoles);
|
|
147
159
|
const cached = getCachedResult(cacheKey);
|
|
148
160
|
if (cached) {
|
|
149
161
|
return cached;
|
|
150
162
|
}
|
|
151
163
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
const idpUrl = process.env.NEXT_PUBLIC_IDP_URL || process.env.IDP_URL;
|
|
165
|
+
const vibeClientId = process.env.VIBE_CLIENT_ID;
|
|
166
|
+
const hmacKey = process.env.VIBE_HMAC_KEY || process.env.IDP_SIGNING_KEY;
|
|
167
|
+
|
|
168
|
+
if (!idpUrl) {
|
|
169
|
+
console.error('[RBAC] IDP_URL not configured');
|
|
155
170
|
return {
|
|
156
171
|
allowed: false,
|
|
157
172
|
reason: 'rbac_not_configured',
|
|
@@ -159,50 +174,66 @@ export async function checkPagePermission(
|
|
|
159
174
|
};
|
|
160
175
|
}
|
|
161
176
|
|
|
162
|
-
// Build
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
// Build RBAC endpoint with query params
|
|
178
|
+
// Vibe route is /v1/rbac/check (no /api/ prefix)
|
|
179
|
+
const params = new URLSearchParams();
|
|
180
|
+
params.set('path', path);
|
|
181
|
+
params.set('roles', userRoles.join(','));
|
|
166
182
|
|
|
167
|
-
// Add claims if provided
|
|
168
183
|
if (userClaims && Object.keys(userClaims).length > 0) {
|
|
169
184
|
const claimsParam = Object.entries(userClaims)
|
|
170
185
|
.map(([type, value]) => `${type}:${value}`)
|
|
171
186
|
.join(',');
|
|
172
|
-
|
|
187
|
+
params.set('claims', claimsParam);
|
|
173
188
|
}
|
|
174
189
|
|
|
175
|
-
|
|
190
|
+
const rbacEndpoint = `/v1/rbac/check?${params.toString()}`;
|
|
191
|
+
|
|
192
|
+
// Build proxy request
|
|
193
|
+
const proxyUrl = `${idpUrl}/api/vibe/proxy`;
|
|
176
194
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
177
|
-
const signature = generateSignature(path, clientId, timestamp);
|
|
178
195
|
|
|
179
|
-
// Build headers
|
|
180
196
|
const headers: Record<string, string> = {
|
|
197
|
+
'Content-Type': 'application/json',
|
|
181
198
|
'Accept': 'application/json',
|
|
182
|
-
'X-Client-Id': clientId,
|
|
183
|
-
'X-Vibe-Client-Id': clientId,
|
|
184
199
|
};
|
|
185
200
|
|
|
186
|
-
if (
|
|
201
|
+
if (vibeClientId) {
|
|
202
|
+
headers['X-Vibe-Client-Id'] = vibeClientId;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Sign with HMAC (same format as vibe-client: timestamp|method|endpoint)
|
|
206
|
+
if (hmacKey && vibeClientId) {
|
|
207
|
+
const stringToSign = `${timestamp}|GET|${rbacEndpoint}`;
|
|
208
|
+
const keyBuffer = base64ToUint8Array(hmacKey);
|
|
209
|
+
const signature = await hmacSha256Base64(keyBuffer, stringToSign);
|
|
187
210
|
headers['X-Vibe-Timestamp'] = String(timestamp);
|
|
188
211
|
headers['X-Vibe-Signature'] = signature;
|
|
189
212
|
}
|
|
190
213
|
|
|
214
|
+
// Proxy body format: { endpoint, method, data }
|
|
215
|
+
const proxyBody = {
|
|
216
|
+
endpoint: rbacEndpoint,
|
|
217
|
+
method: 'GET',
|
|
218
|
+
data: null,
|
|
219
|
+
};
|
|
220
|
+
|
|
191
221
|
try {
|
|
192
222
|
// 2 second timeout - fail closed
|
|
193
223
|
const controller = new AbortController();
|
|
194
224
|
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
195
225
|
|
|
196
|
-
const response = await fetch(
|
|
197
|
-
method: '
|
|
226
|
+
const response = await fetch(proxyUrl, {
|
|
227
|
+
method: 'POST',
|
|
198
228
|
headers,
|
|
229
|
+
body: JSON.stringify(proxyBody),
|
|
199
230
|
signal: controller.signal,
|
|
200
231
|
});
|
|
201
232
|
|
|
202
233
|
clearTimeout(timeoutId);
|
|
203
234
|
|
|
204
235
|
if (!response.ok) {
|
|
205
|
-
console.error('[RBAC]
|
|
236
|
+
console.error('[RBAC] Proxy error:', response.status, response.statusText);
|
|
206
237
|
return {
|
|
207
238
|
allowed: false,
|
|
208
239
|
reason: 'rbac_api_error',
|
|
@@ -210,7 +241,11 @@ export async function checkPagePermission(
|
|
|
210
241
|
};
|
|
211
242
|
}
|
|
212
243
|
|
|
213
|
-
const
|
|
244
|
+
const body = await response.json();
|
|
245
|
+
|
|
246
|
+
// Vibe API wraps responses: { success: true, data: { allowed, reason, ... } }
|
|
247
|
+
// Unwrap the .data property if present, otherwise use body directly
|
|
248
|
+
const result: RBACResult = body?.data ?? body;
|
|
214
249
|
|
|
215
250
|
// Cache the result
|
|
216
251
|
setCachedResult(cacheKey, result);
|
|
@@ -219,7 +254,7 @@ export async function checkPagePermission(
|
|
|
219
254
|
} catch (error: any) {
|
|
220
255
|
// Fail closed on any error
|
|
221
256
|
if (error.name === 'AbortError') {
|
|
222
|
-
console.error('[RBAC]
|
|
257
|
+
console.error('[RBAC] Proxy timeout (2s exceeded)');
|
|
223
258
|
return {
|
|
224
259
|
allowed: false,
|
|
225
260
|
reason: 'rbac_timeout',
|
|
@@ -227,7 +262,7 @@ export async function checkPagePermission(
|
|
|
227
262
|
};
|
|
228
263
|
}
|
|
229
264
|
|
|
230
|
-
console.error('[RBAC]
|
|
265
|
+
console.error('[RBAC] Proxy error:', error);
|
|
231
266
|
return {
|
|
232
267
|
allowed: false,
|
|
233
268
|
reason: 'rbac_service_unavailable',
|