@payez/next-mvp 3.1.0 → 3.2.1
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.
- package/dist/auth/callbacks/jwt.js +305 -305
- package/dist/auth/providers/credentials.js +223 -223
- package/dist/lib/idp-client-config.js +4 -2
- package/dist/middleware/create-middleware.js +16 -0
- package/dist/middleware/rbac-check.d.ts +11 -4
- package/dist/middleware/rbac-check.js +79 -51
- package/package.json +1 -1
- package/src/lib/idp-client-config.ts +4 -2
- package/src/middleware/create-middleware.ts +22 -0
- package/src/middleware/rbac-check.ts +94 -59
|
@@ -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
|
@@ -177,7 +177,8 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
|
|
|
177
177
|
|
|
178
178
|
// Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config
|
|
179
179
|
// AUTH_TRUST_HOST=true tells NextAuth to derive OAuth callback URLs from headers.
|
|
180
|
-
if (
|
|
180
|
+
// Only set if not already defined (allows deployment override for beta/staging)
|
|
181
|
+
if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
|
|
181
182
|
process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = redisConfig.baseClientUrl;
|
|
182
183
|
}
|
|
183
184
|
|
|
@@ -215,7 +216,8 @@ export async function getIDPClientConfig(forceRefresh: boolean = false): Promise
|
|
|
215
216
|
|
|
216
217
|
// Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config
|
|
217
218
|
// AUTH_TRUST_HOST=true tells NextAuth to derive OAuth callback URLs from headers.
|
|
218
|
-
if (
|
|
219
|
+
// Only set if not already defined (allows deployment override for beta/staging)
|
|
220
|
+
if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
|
|
219
221
|
process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = config.baseClientUrl;
|
|
220
222
|
console.log("[IDP_CONFIG] Set IDENTITY_CLIENT_BASE_EXTERNAL_URL:", config.baseClientUrl);
|
|
221
223
|
}
|
|
@@ -503,6 +503,9 @@ async function executeDecision(
|
|
|
503
503
|
}
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
/** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
|
|
507
|
+
const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
|
|
508
|
+
|
|
506
509
|
/** Handle 'allow' decision - run RBAC if enabled */
|
|
507
510
|
async function handleAllow(
|
|
508
511
|
request: NextRequest,
|
|
@@ -513,6 +516,11 @@ async function handleAllow(
|
|
|
513
516
|
const isPublic = isUnauthenticatedRoute(pathname);
|
|
514
517
|
|
|
515
518
|
if (isRBACEnabled() && !isPublic) {
|
|
519
|
+
// Skip RBAC for error/fallback pages to prevent redirect loops
|
|
520
|
+
if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p))) {
|
|
521
|
+
return NextResponse.next();
|
|
522
|
+
}
|
|
523
|
+
|
|
516
524
|
if (!sessionPointer.clientId) {
|
|
517
525
|
console.error('[MIDDLEWARE] RBAC: No clientId');
|
|
518
526
|
return NextResponse.redirect(new URL('/error?code=no_client_id', request.url));
|
|
@@ -523,6 +531,13 @@ async function handleAllow(
|
|
|
523
531
|
|
|
524
532
|
if (!result.allowed) {
|
|
525
533
|
console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
|
|
534
|
+
|
|
535
|
+
// In development, fail open - RBAC API may not be fully configured
|
|
536
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
537
|
+
console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
|
|
538
|
+
return NextResponse.next();
|
|
539
|
+
}
|
|
540
|
+
|
|
526
541
|
return NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
|
|
527
542
|
}
|
|
528
543
|
|
|
@@ -533,6 +548,13 @@ async function handleAllow(
|
|
|
533
548
|
}
|
|
534
549
|
} catch (error) {
|
|
535
550
|
console.error('[MIDDLEWARE] RBAC error:', error);
|
|
551
|
+
|
|
552
|
+
// In development, fail open
|
|
553
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
554
|
+
console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
|
|
555
|
+
return NextResponse.next();
|
|
556
|
+
}
|
|
557
|
+
|
|
536
558
|
return NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
|
|
537
559
|
}
|
|
538
560
|
}
|
|
@@ -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',
|