@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.
@@ -1,223 +1,223 @@
1
- "use strict";
2
- /**
3
- * Credentials Provider
4
- *
5
- * Handles email/password authentication via PayEz IDP.
6
- * Creates Redis session and returns minimal user object to NextAuth.
7
- *
8
- * FLOW:
9
- * 1. User submits email/password
10
- * 2. We call IDP /api/ExternalAuth/login
11
- * 3. IDP returns tokens if credentials valid
12
- * 4. We create Redis session with tokens
13
- * 5. Return user object with redisSessionId to NextAuth
14
- *
15
- * @version 1.0.0
16
- * @since auth-refactor-2026-01
17
- */
18
- var __importDefault = (this && this.__importDefault) || function (mod) {
19
- return (mod && mod.__esModule) ? mod : { "default": mod };
20
- };
21
- Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.createCredentialsProvider = createCredentialsProvider;
23
- const credentials_1 = __importDefault(require("next-auth/providers/credentials"));
24
- const session_store_1 = require("../../lib/session-store");
25
- const idp_client_1 = require("../utils/idp-client");
26
- const token_utils_1 = require("../utils/token-utils");
27
- const auth_types_1 = require("../types/auth-types");
28
- // NOTE: Using any for sessionData until Phase 3 normalizes types
29
- // ============================================================================
30
- // CREDENTIALS PROVIDER
31
- // ============================================================================
32
- /**
33
- * Create the CredentialsProvider for NextAuth.
34
- *
35
- * This provider handles email/password login. The authorize function
36
- * is called when a user submits the login form.
37
- */
38
- function createCredentialsProvider() {
39
- return (0, credentials_1.default)({
40
- id: 'credentials',
41
- name: 'Credentials',
42
- credentials: {
43
- email: { label: 'Email', type: 'email' },
44
- password: { label: 'Password', type: 'password' },
45
- },
46
- authorize: authorizeCredentials,
47
- });
48
- }
49
- /**
50
- * Authorize user with email/password.
51
- *
52
- * This is the core authentication function. It:
53
- * 1. Validates credentials with IDP
54
- * 2. Decodes the returned tokens
55
- * 3. Creates a Redis session
56
- * 4. Returns user info for NextAuth JWT
57
- *
58
- * @param credentials - Email and password from login form
59
- * @param req - The incoming request (for IP/UA forwarding)
60
- * @returns User object for NextAuth, or null/throws on failure
61
- */
62
- async function authorizeCredentials(credentials, req) {
63
- // -------------------------------------------------------------------------
64
- // Validate Input
65
- // -------------------------------------------------------------------------
66
- if (!credentials?.email || !credentials?.password) {
67
- throw new Error('Email and password required');
68
- }
69
- const loginCredentials = {
70
- email: credentials.email,
71
- password: credentials.password,
72
- };
73
- // Extract client info for audit logging
74
- const clientHeaders = extractClientHeaders(req);
75
- // -------------------------------------------------------------------------
76
- // Call IDP
77
- // -------------------------------------------------------------------------
78
- const loginResult = await (0, idp_client_1.idpLogin)(loginCredentials, clientHeaders);
79
- if (!loginResult.success || !loginResult.result) {
80
- // Build structured error for frontend
81
- const errorResponse = buildAuthError(loginResult.error);
82
- throw new Error(JSON.stringify(errorResponse));
83
- }
84
- const { access_token, refresh_token, user: idpUser } = loginResult.result;
85
- // -------------------------------------------------------------------------
86
- // Decode Token
87
- // -------------------------------------------------------------------------
88
- const decoded = (0, token_utils_1.decodeIdpAccessToken)(access_token);
89
- if (!decoded) {
90
- throw new Error('Failed to decode token');
91
- }
92
- // Extract kid from JWT header (CRITICAL: this is different from client_id in payload)
93
- const bearerKeyId = (0, token_utils_1.extractKidFromToken)(access_token);
94
- if (bearerKeyId) {
95
- console.log('[CREDENTIALS] Extracted bearerKeyId (kid) from JWT header:', bearerKeyId);
96
- }
97
- else {
98
- console.warn('[CREDENTIALS] No kid found in JWT header - token may be unsigned or malformed');
99
- }
100
- // Extract claims from token
101
- const email = (0, token_utils_1.extractEmailFromToken)(decoded);
102
- const roles = (0, token_utils_1.extractRolesFromToken)(decoded);
103
- const amrClaims = (0, token_utils_1.extractAmrFromToken)(decoded);
104
- const acrLevel = decoded.acr || '1';
105
- const userId = decoded.sub;
106
- // Check if 2FA is complete based on ACR level
107
- // ACR=1: Provisional token (requires 2FA)
108
- // ACR=2: Full authentication (2FA complete)
109
- const mfaVerified = acrLevel === '2';
110
- // Decode refresh token expiry if available
111
- let refreshTokenExpires;
112
- try {
113
- const refreshDecoded = (0, token_utils_1.decodeIdpAccessToken)(refresh_token);
114
- if (refreshDecoded?.exp) {
115
- refreshTokenExpires = (0, token_utils_1.expClaimToMs)(refreshDecoded.exp);
116
- }
117
- }
118
- catch {
119
- // Ignore - will use default expiry
120
- }
121
- // -------------------------------------------------------------------------
122
- // Create Redis Session
123
- // -------------------------------------------------------------------------
124
- // Using normalized field names (session-store handles backward compatibility)
125
- const sessionData = {
126
- userId,
127
- email,
128
- roles,
129
- // IDP tokens (normalized names)
130
- idpAccessToken: access_token,
131
- idpRefreshToken: refresh_token,
132
- idpAccessTokenExpires: (0, token_utils_1.expClaimToMs)(decoded.exp),
133
- idpRefreshTokenExpires: refreshTokenExpires,
134
- decodedAccessToken: decoded,
135
- // Bearer key ID from JWT header (NOT client_id from payload)
136
- bearerKeyId,
137
- // MFA state (normalized names)
138
- mfaVerified,
139
- authenticationMethods: amrClaims,
140
- authenticationLevel: acrLevel,
141
- // MFA timing info from token
142
- mfaCompletedAt: decoded.mfa_time ? (0, token_utils_1.expClaimToMs)(decoded.mfa_time) : undefined,
143
- mfaExpiresAt: decoded.mfa_expires ? (0, token_utils_1.expClaimToMs)(decoded.mfa_expires) : undefined,
144
- mfaValidityHours: decoded.mfa_validity_hours,
145
- };
146
- // Determine MFA method from IDP user info
147
- let mfaMethod;
148
- if (idpUser?.isEmailConfirmed) {
149
- mfaMethod = 'email';
150
- }
151
- else if (idpUser?.isSmsConfirmed) {
152
- mfaMethod = 'sms';
153
- }
154
- if (mfaMethod) {
155
- sessionData.mfaMethod = mfaMethod;
156
- }
157
- // Create the Redis session
158
- const redisSessionId = await (0, session_store_1.createSession)(sessionData);
159
- // -------------------------------------------------------------------------
160
- // Return User Object for NextAuth
161
- // -------------------------------------------------------------------------
162
- // NextAuth requires 'id' field - we use userId from IDP
163
- // The redisSessionId is passed through to the JWT callback
164
- return {
165
- id: userId,
166
- email,
167
- roles,
168
- redisSessionId: (0, auth_types_1.toRedisSessionId)(redisSessionId),
169
- mfaRequired: !mfaVerified,
170
- mfaMethod,
171
- };
172
- }
173
- // ============================================================================
174
- // HELPER FUNCTIONS
175
- // ============================================================================
176
- /**
177
- * Extract client headers from request for audit logging.
178
- */
179
- function extractClientHeaders(req) {
180
- const headers = {};
181
- // Extract client IP
182
- const forwardedFor = req?.headers?.['x-forwarded-for'];
183
- const realIp = req?.headers?.['x-real-ip'];
184
- if (forwardedFor) {
185
- const ip = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor.split(',')[0].trim();
186
- headers.ip = ip;
187
- }
188
- else if (realIp) {
189
- headers.ip = Array.isArray(realIp) ? realIp[0] : realIp;
190
- }
191
- // Extract User-Agent
192
- const userAgent = req?.headers?.['user-agent'];
193
- if (userAgent) {
194
- headers.userAgent = Array.isArray(userAgent) ? userAgent[0] : userAgent;
195
- }
196
- return headers;
197
- }
198
- /**
199
- * Build structured error response for frontend.
200
- *
201
- * The frontend expects a specific error structure to display
202
- * appropriate messages and handle things like lockout.
203
- */
204
- function buildAuthError(error) {
205
- if (!error) {
206
- return {
207
- success: false,
208
- error: {
209
- code: 'AUTH_ERROR',
210
- message: 'Authentication failed',
211
- details: {},
212
- },
213
- };
214
- }
215
- return {
216
- success: false,
217
- error: {
218
- code: error.code || 'AUTH_ERROR',
219
- message: error.message || 'Authentication failed',
220
- details: error.details || {},
221
- },
222
- };
223
- }
1
+ "use strict";
2
+ /**
3
+ * Credentials Provider
4
+ *
5
+ * Handles email/password authentication via PayEz IDP.
6
+ * Creates Redis session and returns minimal user object to NextAuth.
7
+ *
8
+ * FLOW:
9
+ * 1. User submits email/password
10
+ * 2. We call IDP /api/ExternalAuth/login
11
+ * 3. IDP returns tokens if credentials valid
12
+ * 4. We create Redis session with tokens
13
+ * 5. Return user object with redisSessionId to NextAuth
14
+ *
15
+ * @version 1.0.0
16
+ * @since auth-refactor-2026-01
17
+ */
18
+ var __importDefault = (this && this.__importDefault) || function (mod) {
19
+ return (mod && mod.__esModule) ? mod : { "default": mod };
20
+ };
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.createCredentialsProvider = createCredentialsProvider;
23
+ const credentials_1 = __importDefault(require("next-auth/providers/credentials"));
24
+ const session_store_1 = require("../../lib/session-store");
25
+ const idp_client_1 = require("../utils/idp-client");
26
+ const token_utils_1 = require("../utils/token-utils");
27
+ const auth_types_1 = require("../types/auth-types");
28
+ // NOTE: Using any for sessionData until Phase 3 normalizes types
29
+ // ============================================================================
30
+ // CREDENTIALS PROVIDER
31
+ // ============================================================================
32
+ /**
33
+ * Create the CredentialsProvider for NextAuth.
34
+ *
35
+ * This provider handles email/password login. The authorize function
36
+ * is called when a user submits the login form.
37
+ */
38
+ function createCredentialsProvider() {
39
+ return (0, credentials_1.default)({
40
+ id: 'credentials',
41
+ name: 'Credentials',
42
+ credentials: {
43
+ email: { label: 'Email', type: 'email' },
44
+ password: { label: 'Password', type: 'password' },
45
+ },
46
+ authorize: authorizeCredentials,
47
+ });
48
+ }
49
+ /**
50
+ * Authorize user with email/password.
51
+ *
52
+ * This is the core authentication function. It:
53
+ * 1. Validates credentials with IDP
54
+ * 2. Decodes the returned tokens
55
+ * 3. Creates a Redis session
56
+ * 4. Returns user info for NextAuth JWT
57
+ *
58
+ * @param credentials - Email and password from login form
59
+ * @param req - The incoming request (for IP/UA forwarding)
60
+ * @returns User object for NextAuth, or null/throws on failure
61
+ */
62
+ async function authorizeCredentials(credentials, req) {
63
+ // -------------------------------------------------------------------------
64
+ // Validate Input
65
+ // -------------------------------------------------------------------------
66
+ if (!credentials?.email || !credentials?.password) {
67
+ throw new Error('Email and password required');
68
+ }
69
+ const loginCredentials = {
70
+ email: credentials.email,
71
+ password: credentials.password,
72
+ };
73
+ // Extract client info for audit logging
74
+ const clientHeaders = extractClientHeaders(req);
75
+ // -------------------------------------------------------------------------
76
+ // Call IDP
77
+ // -------------------------------------------------------------------------
78
+ const loginResult = await (0, idp_client_1.idpLogin)(loginCredentials, clientHeaders);
79
+ if (!loginResult.success || !loginResult.result) {
80
+ // Build structured error for frontend
81
+ const errorResponse = buildAuthError(loginResult.error);
82
+ throw new Error(JSON.stringify(errorResponse));
83
+ }
84
+ const { access_token, refresh_token, user: idpUser } = loginResult.result;
85
+ // -------------------------------------------------------------------------
86
+ // Decode Token
87
+ // -------------------------------------------------------------------------
88
+ const decoded = (0, token_utils_1.decodeIdpAccessToken)(access_token);
89
+ if (!decoded) {
90
+ throw new Error('Failed to decode token');
91
+ }
92
+ // Extract kid from JWT header (CRITICAL: this is different from client_id in payload)
93
+ const bearerKeyId = (0, token_utils_1.extractKidFromToken)(access_token);
94
+ if (bearerKeyId) {
95
+ console.log('[CREDENTIALS] Extracted bearerKeyId (kid) from JWT header:', bearerKeyId);
96
+ }
97
+ else {
98
+ console.warn('[CREDENTIALS] No kid found in JWT header - token may be unsigned or malformed');
99
+ }
100
+ // Extract claims from token
101
+ const email = (0, token_utils_1.extractEmailFromToken)(decoded);
102
+ const roles = (0, token_utils_1.extractRolesFromToken)(decoded);
103
+ const amrClaims = (0, token_utils_1.extractAmrFromToken)(decoded);
104
+ const acrLevel = decoded.acr || '1';
105
+ const userId = decoded.sub;
106
+ // Check if 2FA is complete based on ACR level
107
+ // ACR=1: Provisional token (requires 2FA)
108
+ // ACR=2: Full authentication (2FA complete)
109
+ const mfaVerified = acrLevel === '2';
110
+ // Decode refresh token expiry if available
111
+ let refreshTokenExpires;
112
+ try {
113
+ const refreshDecoded = (0, token_utils_1.decodeIdpAccessToken)(refresh_token);
114
+ if (refreshDecoded?.exp) {
115
+ refreshTokenExpires = (0, token_utils_1.expClaimToMs)(refreshDecoded.exp);
116
+ }
117
+ }
118
+ catch {
119
+ // Ignore - will use default expiry
120
+ }
121
+ // -------------------------------------------------------------------------
122
+ // Create Redis Session
123
+ // -------------------------------------------------------------------------
124
+ // Using normalized field names (session-store handles backward compatibility)
125
+ const sessionData = {
126
+ userId,
127
+ email,
128
+ roles,
129
+ // IDP tokens (normalized names)
130
+ idpAccessToken: access_token,
131
+ idpRefreshToken: refresh_token,
132
+ idpAccessTokenExpires: (0, token_utils_1.expClaimToMs)(decoded.exp),
133
+ idpRefreshTokenExpires: refreshTokenExpires,
134
+ decodedAccessToken: decoded,
135
+ // Bearer key ID from JWT header (NOT client_id from payload)
136
+ bearerKeyId,
137
+ // MFA state (normalized names)
138
+ mfaVerified,
139
+ authenticationMethods: amrClaims,
140
+ authenticationLevel: acrLevel,
141
+ // MFA timing info from token
142
+ mfaCompletedAt: decoded.mfa_time ? (0, token_utils_1.expClaimToMs)(decoded.mfa_time) : undefined,
143
+ mfaExpiresAt: decoded.mfa_expires ? (0, token_utils_1.expClaimToMs)(decoded.mfa_expires) : undefined,
144
+ mfaValidityHours: decoded.mfa_validity_hours,
145
+ };
146
+ // Determine MFA method from IDP user info
147
+ let mfaMethod;
148
+ if (idpUser?.isEmailConfirmed) {
149
+ mfaMethod = 'email';
150
+ }
151
+ else if (idpUser?.isSmsConfirmed) {
152
+ mfaMethod = 'sms';
153
+ }
154
+ if (mfaMethod) {
155
+ sessionData.mfaMethod = mfaMethod;
156
+ }
157
+ // Create the Redis session
158
+ const redisSessionId = await (0, session_store_1.createSession)(sessionData);
159
+ // -------------------------------------------------------------------------
160
+ // Return User Object for NextAuth
161
+ // -------------------------------------------------------------------------
162
+ // NextAuth requires 'id' field - we use userId from IDP
163
+ // The redisSessionId is passed through to the JWT callback
164
+ return {
165
+ id: userId,
166
+ email,
167
+ roles,
168
+ redisSessionId: (0, auth_types_1.toRedisSessionId)(redisSessionId),
169
+ mfaRequired: !mfaVerified,
170
+ mfaMethod,
171
+ };
172
+ }
173
+ // ============================================================================
174
+ // HELPER FUNCTIONS
175
+ // ============================================================================
176
+ /**
177
+ * Extract client headers from request for audit logging.
178
+ */
179
+ function extractClientHeaders(req) {
180
+ const headers = {};
181
+ // Extract client IP
182
+ const forwardedFor = req?.headers?.['x-forwarded-for'];
183
+ const realIp = req?.headers?.['x-real-ip'];
184
+ if (forwardedFor) {
185
+ const ip = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor.split(',')[0].trim();
186
+ headers.ip = ip;
187
+ }
188
+ else if (realIp) {
189
+ headers.ip = Array.isArray(realIp) ? realIp[0] : realIp;
190
+ }
191
+ // Extract User-Agent
192
+ const userAgent = req?.headers?.['user-agent'];
193
+ if (userAgent) {
194
+ headers.userAgent = Array.isArray(userAgent) ? userAgent[0] : userAgent;
195
+ }
196
+ return headers;
197
+ }
198
+ /**
199
+ * Build structured error response for frontend.
200
+ *
201
+ * The frontend expects a specific error structure to display
202
+ * appropriate messages and handle things like lockout.
203
+ */
204
+ function buildAuthError(error) {
205
+ if (!error) {
206
+ return {
207
+ success: false,
208
+ error: {
209
+ code: 'AUTH_ERROR',
210
+ message: 'Authentication failed',
211
+ details: {},
212
+ },
213
+ };
214
+ }
215
+ return {
216
+ success: false,
217
+ error: {
218
+ code: error.code || 'AUTH_ERROR',
219
+ message: error.message || 'Authentication failed',
220
+ details: error.details || {},
221
+ },
222
+ };
223
+ }
@@ -120,7 +120,8 @@ async function getIDPClientConfig(forceRefresh = false) {
120
120
  }
121
121
  // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from cached config
122
122
  // AUTH_TRUST_HOST=true tells NextAuth to derive OAuth callback URLs from headers.
123
- if (redisConfig.baseClientUrl) {
123
+ // Only set if not already defined (allows deployment override for beta/staging)
124
+ if (redisConfig.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
124
125
  process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = redisConfig.baseClientUrl;
125
126
  }
126
127
  return redisConfig;
@@ -152,7 +153,8 @@ async function getIDPClientConfig(forceRefresh = false) {
152
153
  }
153
154
  // Set IDENTITY_CLIENT_BASE_EXTERNAL_URL from config
154
155
  // AUTH_TRUST_HOST=true tells NextAuth to derive OAuth callback URLs from headers.
155
- if (config.baseClientUrl) {
156
+ // Only set if not already defined (allows deployment override for beta/staging)
157
+ if (config.baseClientUrl && !process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL) {
156
158
  process.env.IDENTITY_CLIENT_BASE_EXTERNAL_URL = config.baseClientUrl;
157
159
  console.log("[IDP_CONFIG] Set IDENTITY_CLIENT_BASE_EXTERNAL_URL:", config.baseClientUrl);
158
160
  }
@@ -359,10 +359,16 @@ async function executeDecision(request, decision, pathname, sessionPointer, sess
359
359
  return handleRefresh(request, safeCallback, opts);
360
360
  }
361
361
  }
362
+ /** Paths that must never be RBAC-checked (they are RBAC redirect targets) */
363
+ const RBAC_EXEMPT_PATHS = ['/error', '/unauthorized', '/service-unavailable'];
362
364
  /** Handle 'allow' decision - run RBAC if enabled */
363
365
  async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
364
366
  const isPublic = (0, route_config_1.isUnauthenticatedRoute)(pathname);
365
367
  if ((0, rbac_check_1.isRBACEnabled)() && !isPublic) {
368
+ // Skip RBAC for error/fallback pages to prevent redirect loops
369
+ if (RBAC_EXEMPT_PATHS.some(p => pathname.startsWith(p))) {
370
+ return server_1.NextResponse.next();
371
+ }
366
372
  if (!sessionPointer.clientId) {
367
373
  console.error('[MIDDLEWARE] RBAC: No clientId');
368
374
  return server_1.NextResponse.redirect(new URL('/error?code=no_client_id', request.url));
@@ -371,6 +377,11 @@ async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
371
377
  const result = await (0, rbac_check_1.checkPagePermission)(pathname, sessionPointer.roles, sessionPointer.clientId);
372
378
  if (!result.allowed) {
373
379
  console.log('[MIDDLEWARE] RBAC denied:', { pathname, reason: result.reason });
380
+ // In development, fail open - RBAC API may not be fully configured
381
+ if (process.env.NODE_ENV !== 'production') {
382
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite denial:', result.reason);
383
+ return server_1.NextResponse.next();
384
+ }
374
385
  return server_1.NextResponse.redirect(new URL(result.redirect || '/unauthorized', request.url));
375
386
  }
376
387
  if (result.requires_2fa && !sessionStatus.twoFactorComplete) {
@@ -379,6 +390,11 @@ async function handleAllow(request, pathname, sessionPointer, sessionStatus) {
379
390
  }
380
391
  catch (error) {
381
392
  console.error('[MIDDLEWARE] RBAC error:', error);
393
+ // In development, fail open
394
+ if (process.env.NODE_ENV !== 'production') {
395
+ console.warn('[MIDDLEWARE] RBAC: Allowing in development despite error');
396
+ return server_1.NextResponse.next();
397
+ }
382
398
  return server_1.NextResponse.redirect(new URL('/error?code=rbac_error', request.url));
383
399
  }
384
400
  }
@@ -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
- * @version 1.0.0
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
- * FAIL CLOSED: If Vibe API is unreachable or times out, access is DENIED.
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 ID for multi-tenancy
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
  */