@payez/next-mvp 4.1.10 → 4.1.12

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.
Files changed (89) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.js +9 -18
  3. package/dist/client/better-auth-client.d.ts +14 -2
  4. package/dist/lib/ensure-fresh-access-token.js +7 -30
  5. package/dist/lib/session-store.d.ts +0 -1
  6. package/dist/lib/session-store.js +29 -33
  7. package/dist/lib/startup-init.js +19 -19
  8. package/package.json +4 -3
  9. package/scripts/check-internal-url-usage.sh +73 -73
  10. package/src/api/index.ts +17 -17
  11. package/src/api-handlers/account/masked-info.ts +45 -45
  12. package/src/api-handlers/account/profile.ts +80 -80
  13. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  14. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  15. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  16. package/src/api-handlers/account/reset-password.ts +23 -23
  17. package/src/api-handlers/account/send-code.ts +76 -76
  18. package/src/api-handlers/account/validate-password.ts +118 -118
  19. package/src/api-handlers/admin/stats.ts +249 -249
  20. package/src/api-handlers/anon/preferences.ts +123 -123
  21. package/src/api-handlers/auth/jwks.ts +20 -20
  22. package/src/api-handlers/auth/login.ts +240 -240
  23. package/src/api-handlers/auth/refresh.ts +15 -38
  24. package/src/api-handlers/auth/validate.ts +43 -43
  25. package/src/api-handlers/session/viability.ts +142 -142
  26. package/src/auth/better-auth.ts +9 -14
  27. package/src/auth/unauthenticated-routes.ts +2 -2
  28. package/src/client/better-auth-client.ts +70 -70
  29. package/src/client/fetchWithSession.ts +21 -21
  30. package/src/client/index.ts +13 -13
  31. package/src/client/useAnonSession.ts +131 -131
  32. package/src/components/admin/VibeAdminContext.tsx +87 -87
  33. package/src/config/logging-config.ts +82 -82
  34. package/src/config/unauthenticated-routes.ts +19 -19
  35. package/src/edge/internal-api-url.ts +65 -65
  36. package/src/edge/middleware.ts +42 -42
  37. package/src/hooks/useProfile.ts +75 -75
  38. package/src/lib/anon-session.ts +213 -213
  39. package/src/lib/auth-secret.ts +137 -137
  40. package/src/lib/demo-mode.ts +17 -17
  41. package/src/lib/ensure-fresh-access-token.ts +7 -30
  42. package/src/lib/idp-client-config.ts +559 -559
  43. package/src/lib/idp-fetch.ts +126 -126
  44. package/src/lib/internal-api.ts +171 -171
  45. package/src/lib/jwt-decode-client.ts +45 -45
  46. package/src/lib/rate-limit-service.ts +9 -9
  47. package/src/lib/redis.ts +26 -26
  48. package/src/lib/refresh-token-validator.ts +64 -64
  49. package/src/lib/secret-validation.ts +8 -8
  50. package/src/lib/session-store.ts +692 -695
  51. package/src/lib/session.ts +34 -34
  52. package/src/lib/startup-init.ts +251 -251
  53. package/src/lib/token-expiry.ts +40 -40
  54. package/src/lib/token-lifecycle.ts +470 -470
  55. package/src/lib/types/api-responses.ts +336 -336
  56. package/src/middleware/auth-decision.ts +43 -43
  57. package/src/models/DecodedAccessToken.ts +16 -16
  58. package/src/models/SessionModel.ts +264 -264
  59. package/src/pages/admin-roles/index.ts +9 -9
  60. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  61. package/src/routes/account/index.ts +31 -31
  62. package/src/routes/account/masked-info.ts +36 -36
  63. package/src/routes/account/send-code.ts +39 -39
  64. package/src/routes/account/verify-email.ts +40 -40
  65. package/src/routes/account/verify-sms.ts +40 -40
  66. package/src/routes/auth/index.ts +22 -22
  67. package/src/routes/auth/refresh.ts +48 -48
  68. package/src/routes/auth/session.ts +166 -166
  69. package/src/routes/index.ts +17 -17
  70. package/src/routes/session/index.ts +7 -7
  71. package/src/routes/session/refresh-viability.ts +17 -17
  72. package/src/server/auth.ts +373 -373
  73. package/src/server/decode-session.ts +202 -202
  74. package/src/services/signalrActivityService.ts +258 -258
  75. package/src/theme/ThemeProvider.tsx +39 -39
  76. package/src/theme/default.ts +33 -33
  77. package/src/theme/index.ts +31 -31
  78. package/src/theme/types.ts +69 -69
  79. package/src/theme/useTheme.ts +57 -57
  80. package/src/theme/utils.ts +40 -40
  81. package/src/types/api.ts +12 -12
  82. package/src/types/auth.d.ts +23 -23
  83. package/src/types/auth.ts +22 -22
  84. package/src/types/logging.ts +11 -11
  85. package/src/types/recovery.ts +54 -54
  86. package/src/utils/api.ts +353 -353
  87. package/src/utils/circuitBreaker.ts +40 -40
  88. package/src/utils/error-message.ts +108 -108
  89. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,240 +1,240 @@
1
- /**
2
- * Authentication Login API Handler
3
- *
4
- * Handles user authentication against the external IDP service.
5
- * This handler is designed to be imported and used by Next.js applications
6
- * using the @payez/next-mvp package.
7
- *
8
- * @version 2.0
9
- * @requires No authentication (public endpoint)
10
- */
11
-
12
- import { NextRequest, NextResponse } from 'next/server';
13
-
14
- // Add protection headers to all responses
15
- function addSecurityHeaders(response: NextResponse) {
16
- // Content Security Policy - restrictive for auth endpoints
17
- response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';");
18
-
19
- // Prevent clickjacking
20
- response.headers.set('X-Frame-Options', 'DENY');
21
-
22
- // Prevent MIME type sniffing
23
- response.headers.set('X-Content-Type-Options', 'nosniff');
24
-
25
- // Enable XSS protection
26
- response.headers.set('X-XSS-Protection', '1; mode=block');
27
-
28
- // Strict transport security for HTTPS
29
- response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
30
-
31
- // Referrer policy
32
- response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
33
-
34
- // Permissions policy
35
- response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()');
36
-
37
- // Cache control for sensitive auth endpoints
38
- response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
39
- response.headers.set('Pragma', 'no-cache');
40
- response.headers.set('Expires', '0');
41
-
42
- return response;
43
- }
44
-
45
- interface LoginRequest {
46
- username_or_email: string;
47
- password: string;
48
- client_id?: string;
49
- client_type?: string;
50
- two_factor_code?: string;
51
- two_factor_recovery_code?: string;
52
- }
53
-
54
- interface LoginConfig {
55
- idpBaseUrl: string;
56
- clientId: string;
57
- loginEndpoint?: string;
58
- clientType?: string; // "partner" or "idp", defaults to "partner" for CryptAply
59
- }
60
-
61
- /**
62
- * Creates a login handler for Next.js API routes
63
- *
64
- * @param config Configuration for IDP connection
65
- * @returns Next.js POST handler function
66
- *
67
- * @example
68
- * ```typescript
69
- * // In your app's /app/api/auth/login/route.ts
70
- * import { createLoginHandler } from '@payez/next-mvp/api-handlers/auth/login';
71
- *
72
- * export const POST = createLoginHandler({
73
- * idpBaseUrl: process.env.IDP_URL!,
74
- * clientId: process.env.CLIENT_ID!,
75
- * loginEndpoint: '/api/ExternalAuth/login'
76
- * });
77
- * ```
78
- */
79
- export function createLoginHandler(config: LoginConfig) {
80
- const { idpBaseUrl, clientId, loginEndpoint = '/api/ExternalAuth/login', clientType = 'partner' } = config;
81
-
82
- return async function POST(req: NextRequest) {
83
- let body: LoginRequest;
84
-
85
- try {
86
- body = await req.json();
87
- } catch (parseError) {
88
- return NextResponse.json(
89
- { error: 'Invalid JSON format' },
90
- { status: 400 }
91
- );
92
- }
93
-
94
- const { username_or_email, password, client_type, two_factor_code, two_factor_recovery_code } = body;
95
-
96
- // Validate required fields
97
- if (!username_or_email || typeof username_or_email !== 'string' || username_or_email.trim() === '') {
98
- return NextResponse.json(
99
- { error: 'username_or_email is required' },
100
- { status: 400 }
101
- );
102
- }
103
-
104
- if (!password || typeof password !== 'string' || password.trim() === '') {
105
- return NextResponse.json(
106
- { error: 'Password is required' },
107
- { status: 400 }
108
- );
109
- }
110
-
111
- const idpUrl = `${idpBaseUrl}${loginEndpoint}`;
112
- const payload: any = {
113
- username_or_email,
114
- password,
115
- client_id: clientId,
116
- client_type: client_type || clientType // Use provided client_type or config default
117
- };
118
-
119
- // Add optional 2FA fields if provided
120
- if (two_factor_code) {
121
- payload.two_factor_code = two_factor_code;
122
- }
123
- if (two_factor_recovery_code) {
124
- payload.two_factor_recovery_code = two_factor_recovery_code;
125
- }
126
-
127
- // Extract client headers for forwarding to IDP
128
- const clientHeaders: Record<string, string> = {};
129
-
130
- // Forward client IP if available
131
- const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
132
- if (clientIp) {
133
- clientHeaders['X-Forwarded-For'] = clientIp.split(',')[0].trim();
134
- }
135
-
136
- // Forward user agent if available
137
- const userAgent = req.headers.get('user-agent');
138
- if (userAgent) {
139
- clientHeaders['User-Agent'] = userAgent;
140
- }
141
-
142
- const headers = {
143
- 'Content-Type': 'application/json',
144
- 'Accept': 'application/json',
145
- 'Cookie': 'X-Device-Id=payez-PayEz-6c8201c35c1b44c9b2e823b36b95a718',
146
- ...clientHeaders
147
- };
148
-
149
- try {
150
- const backendResponse = await fetch(idpUrl, {
151
- method: 'POST',
152
- headers,
153
- body: JSON.stringify(payload),
154
- });
155
-
156
- const rawText = await backendResponse.text();
157
- let data: any;
158
-
159
- try {
160
- data = JSON.parse(rawText);
161
- } catch (jsonError) {
162
- console.error('[LOGIN] IDP returned non-JSON response', {
163
- response: rawText,
164
- status: backendResponse.status
165
- });
166
- return NextResponse.json(
167
- { error: 'Invalid response from authentication service' },
168
- { status: 502 }
169
- );
170
- }
171
-
172
- if (!backendResponse.ok) {
173
- // Handle rate limiting specifically
174
- if (backendResponse.status === 429) {
175
- const retryAfter = backendResponse.headers.get('retry-after');
176
- const retryAfterSeconds = retryAfter ? parseInt(retryAfter) : 60;
177
-
178
- return addSecurityHeaders(NextResponse.json({
179
- success: false,
180
- error: 'RATE_LIMIT_EXCEEDED',
181
- message: 'Too many login attempts. Please try again later.',
182
- retryAfter: retryAfterSeconds
183
- }, { status: 429 }));
184
- }
185
-
186
- // Pass through error from IDP
187
- console.error('[LOGIN] IDP service error', {
188
- status: backendResponse.status,
189
- error: data
190
- });
191
- return addSecurityHeaders(NextResponse.json(data, { status: backendResponse.status }));
192
- }
193
-
194
- if (!data.success) {
195
- // IDP returned a canonical error envelope with HTTP 200
196
- console.error('[LOGIN] IDP authentication failure', { error: data });
197
- return addSecurityHeaders(NextResponse.json(data, { status: 200 }));
198
- }
199
-
200
- // Normalize response shape for NextAuth authorize()
201
- const normalized = {
202
- success: data?.success === true,
203
- result: (data?.data && data?.data?.result) ? data.data.result : (data?.result ?? data),
204
- // Preserve original payload for debugging/compatibility
205
- original: data
206
- };
207
-
208
- return addSecurityHeaders(NextResponse.json(normalized as any));
209
-
210
- } catch (error) {
211
- // Handle network and other errors
212
- console.error('[LOGIN] FETCH FAILED:', {
213
- error: error instanceof Error ? error.message : String(error),
214
- idpUrl
215
- });
216
-
217
- if (error instanceof Error && error.message.includes('fetch')) {
218
- return NextResponse.json(
219
- { error: 'Unable to connect to authentication service' },
220
- { status: 503 }
221
- );
222
- }
223
-
224
- return NextResponse.json(
225
- { error: 'An unexpected error occurred during login' },
226
- { status: 500 }
227
- );
228
- }
229
- };
230
- }
231
-
232
- /**
233
- * Default export for backward compatibility
234
- * Requires environment variables: IDP_URL, CLIENT_ID
235
- */
236
- export const POST = createLoginHandler({
237
- idpBaseUrl: process.env.IDP_URL!,
238
- clientId: process.env.CLIENT_ID || 'payez_default_client',
239
- loginEndpoint: '/api/ExternalAuth/login'
240
- });
1
+ /**
2
+ * Authentication Login API Handler
3
+ *
4
+ * Handles user authentication against the external IDP service.
5
+ * This handler is designed to be imported and used by Next.js applications
6
+ * using the @payez/next-mvp package.
7
+ *
8
+ * @version 2.0
9
+ * @requires No authentication (public endpoint)
10
+ */
11
+
12
+ import { NextRequest, NextResponse } from 'next/server';
13
+
14
+ // Add protection headers to all responses
15
+ function addSecurityHeaders(response: NextResponse) {
16
+ // Content Security Policy - restrictive for auth endpoints
17
+ response.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';");
18
+
19
+ // Prevent clickjacking
20
+ response.headers.set('X-Frame-Options', 'DENY');
21
+
22
+ // Prevent MIME type sniffing
23
+ response.headers.set('X-Content-Type-Options', 'nosniff');
24
+
25
+ // Enable XSS protection
26
+ response.headers.set('X-XSS-Protection', '1; mode=block');
27
+
28
+ // Strict transport security for HTTPS
29
+ response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
30
+
31
+ // Referrer policy
32
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
33
+
34
+ // Permissions policy
35
+ response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()');
36
+
37
+ // Cache control for sensitive auth endpoints
38
+ response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
39
+ response.headers.set('Pragma', 'no-cache');
40
+ response.headers.set('Expires', '0');
41
+
42
+ return response;
43
+ }
44
+
45
+ interface LoginRequest {
46
+ username_or_email: string;
47
+ password: string;
48
+ client_id?: string;
49
+ client_type?: string;
50
+ two_factor_code?: string;
51
+ two_factor_recovery_code?: string;
52
+ }
53
+
54
+ interface LoginConfig {
55
+ idpBaseUrl: string;
56
+ clientId: string;
57
+ loginEndpoint?: string;
58
+ clientType?: string; // "partner" or "idp", defaults to "partner" for CryptAply
59
+ }
60
+
61
+ /**
62
+ * Creates a login handler for Next.js API routes
63
+ *
64
+ * @param config Configuration for IDP connection
65
+ * @returns Next.js POST handler function
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * // In your app's /app/api/auth/login/route.ts
70
+ * import { createLoginHandler } from '@payez/next-mvp/api-handlers/auth/login';
71
+ *
72
+ * export const POST = createLoginHandler({
73
+ * idpBaseUrl: process.env.IDP_URL!,
74
+ * clientId: process.env.CLIENT_ID!,
75
+ * loginEndpoint: '/api/ExternalAuth/login'
76
+ * });
77
+ * ```
78
+ */
79
+ export function createLoginHandler(config: LoginConfig) {
80
+ const { idpBaseUrl, clientId, loginEndpoint = '/api/ExternalAuth/login', clientType = 'partner' } = config;
81
+
82
+ return async function POST(req: NextRequest) {
83
+ let body: LoginRequest;
84
+
85
+ try {
86
+ body = await req.json();
87
+ } catch (parseError) {
88
+ return NextResponse.json(
89
+ { error: 'Invalid JSON format' },
90
+ { status: 400 }
91
+ );
92
+ }
93
+
94
+ const { username_or_email, password, client_type, two_factor_code, two_factor_recovery_code } = body;
95
+
96
+ // Validate required fields
97
+ if (!username_or_email || typeof username_or_email !== 'string' || username_or_email.trim() === '') {
98
+ return NextResponse.json(
99
+ { error: 'username_or_email is required' },
100
+ { status: 400 }
101
+ );
102
+ }
103
+
104
+ if (!password || typeof password !== 'string' || password.trim() === '') {
105
+ return NextResponse.json(
106
+ { error: 'Password is required' },
107
+ { status: 400 }
108
+ );
109
+ }
110
+
111
+ const idpUrl = `${idpBaseUrl}${loginEndpoint}`;
112
+ const payload: any = {
113
+ username_or_email,
114
+ password,
115
+ client_id: clientId,
116
+ client_type: client_type || clientType // Use provided client_type or config default
117
+ };
118
+
119
+ // Add optional 2FA fields if provided
120
+ if (two_factor_code) {
121
+ payload.two_factor_code = two_factor_code;
122
+ }
123
+ if (two_factor_recovery_code) {
124
+ payload.two_factor_recovery_code = two_factor_recovery_code;
125
+ }
126
+
127
+ // Extract client headers for forwarding to IDP
128
+ const clientHeaders: Record<string, string> = {};
129
+
130
+ // Forward client IP if available
131
+ const clientIp = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip');
132
+ if (clientIp) {
133
+ clientHeaders['X-Forwarded-For'] = clientIp.split(',')[0].trim();
134
+ }
135
+
136
+ // Forward user agent if available
137
+ const userAgent = req.headers.get('user-agent');
138
+ if (userAgent) {
139
+ clientHeaders['User-Agent'] = userAgent;
140
+ }
141
+
142
+ const headers = {
143
+ 'Content-Type': 'application/json',
144
+ 'Accept': 'application/json',
145
+ 'Cookie': 'X-Device-Id=payez-PayEz-6c8201c35c1b44c9b2e823b36b95a718',
146
+ ...clientHeaders
147
+ };
148
+
149
+ try {
150
+ const backendResponse = await fetch(idpUrl, {
151
+ method: 'POST',
152
+ headers,
153
+ body: JSON.stringify(payload),
154
+ });
155
+
156
+ const rawText = await backendResponse.text();
157
+ let data: any;
158
+
159
+ try {
160
+ data = JSON.parse(rawText);
161
+ } catch (jsonError) {
162
+ console.error('[LOGIN] IDP returned non-JSON response', {
163
+ response: rawText,
164
+ status: backendResponse.status
165
+ });
166
+ return NextResponse.json(
167
+ { error: 'Invalid response from authentication service' },
168
+ { status: 502 }
169
+ );
170
+ }
171
+
172
+ if (!backendResponse.ok) {
173
+ // Handle rate limiting specifically
174
+ if (backendResponse.status === 429) {
175
+ const retryAfter = backendResponse.headers.get('retry-after');
176
+ const retryAfterSeconds = retryAfter ? parseInt(retryAfter) : 60;
177
+
178
+ return addSecurityHeaders(NextResponse.json({
179
+ success: false,
180
+ error: 'RATE_LIMIT_EXCEEDED',
181
+ message: 'Too many login attempts. Please try again later.',
182
+ retryAfter: retryAfterSeconds
183
+ }, { status: 429 }));
184
+ }
185
+
186
+ // Pass through error from IDP
187
+ console.error('[LOGIN] IDP service error', {
188
+ status: backendResponse.status,
189
+ error: data
190
+ });
191
+ return addSecurityHeaders(NextResponse.json(data, { status: backendResponse.status }));
192
+ }
193
+
194
+ if (!data.success) {
195
+ // IDP returned a canonical error envelope with HTTP 200
196
+ console.error('[LOGIN] IDP authentication failure', { error: data });
197
+ return addSecurityHeaders(NextResponse.json(data, { status: 200 }));
198
+ }
199
+
200
+ // Normalize response shape for NextAuth authorize()
201
+ const normalized = {
202
+ success: data?.success === true,
203
+ result: (data?.data && data?.data?.result) ? data.data.result : (data?.result ?? data),
204
+ // Preserve original payload for debugging/compatibility
205
+ original: data
206
+ };
207
+
208
+ return addSecurityHeaders(NextResponse.json(normalized as any));
209
+
210
+ } catch (error) {
211
+ // Handle network and other errors
212
+ console.error('[LOGIN] FETCH FAILED:', {
213
+ error: error instanceof Error ? error.message : String(error),
214
+ idpUrl
215
+ });
216
+
217
+ if (error instanceof Error && error.message.includes('fetch')) {
218
+ return NextResponse.json(
219
+ { error: 'Unable to connect to authentication service' },
220
+ { status: 503 }
221
+ );
222
+ }
223
+
224
+ return NextResponse.json(
225
+ { error: 'An unexpected error occurred during login' },
226
+ { status: 500 }
227
+ );
228
+ }
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Default export for backward compatibility
234
+ * Requires environment variables: IDP_URL, CLIENT_ID
235
+ */
236
+ export const POST = createLoginHandler({
237
+ idpBaseUrl: process.env.IDP_URL!,
238
+ clientId: process.env.CLIENT_ID || 'payez_default_client',
239
+ loginEndpoint: '/api/ExternalAuth/login'
240
+ });
@@ -400,7 +400,7 @@ export function createRefreshHandler(config: RefreshConfig) {
400
400
  const errorMessage = idpError.message || 'Token refresh failed';
401
401
  // CRITICAL: 401 means token is definitively invalid - always discard
402
402
  // This prevents redirect loops where viability check keeps returning canRefresh:true
403
- let discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
403
+ const discardToken = refreshResponse.status === 401 || idpError.discard_token === true;
404
404
  const retryable = refreshResponse.status !== 401 && idpError.retryable === true;
405
405
  const resolution = idpError.resolution || null;
406
406
 
@@ -442,43 +442,20 @@ export function createRefreshHandler(config: RefreshConfig) {
442
442
 
443
443
  // If IDP signals to discard token, clear it from Redis
444
444
  if (discardToken) {
445
- // Race-safe discard (CAS): only clear if the refresh token we sent is STILL
446
- // the current one. A concurrent refresh may have already rotated it — our 401
447
- // is then stale, and wiping here would silently log out a healthy session
448
- // (the rotation-race wipe, QA 6873 site 2). Skip the wipe + drop terminal if changed.
449
- const postRefresh = await getSession(sessionToken);
450
- const tokenUnchanged =
451
- !!originalRefreshToken && postRefresh?.idpRefreshToken === originalRefreshToken;
452
- if (tokenUnchanged) {
453
- console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
454
- reason: 'IDP_DISCARD_TOKEN',
455
- errorCode,
456
- userId,
457
- sessionToken: sessionToken.substring(0, 8) + '...',
458
- hadRefreshToken: !!currentSession.idpRefreshToken,
459
- stack: new Error().stack
460
- });
461
- await updateSession(sessionToken, {
462
- idpRefreshToken: '',
463
- idpRefreshTokenExpires: undefined,
464
- refreshTokenClearedAt: Date.now(),
465
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
466
- });
467
- } else {
468
- // Concurrent refresh already rotated the token — our 401 is stale. Return the
469
- // freshly-rotated token directly so the loser succeeds without a retry
470
- // (race-fix follow-up #1).
471
- discardToken = false;
472
- console.warn('[AUTH_REFRESH] Discard suppressed: refresh token already rotated by a concurrent refresh — returning the fresh token (race-safe).');
473
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
474
- return NextResponse.json({
475
- refreshed: true,
476
- reason: 'completed_by_concurrent_request',
477
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
478
- hasRefreshToken: !!postRefresh.idpRefreshToken,
479
- });
480
- }
481
- }
445
+ console.error('[AUTH_REFRESH] ⚠️ REFRESH_TOKEN_REMOVAL: IDP signaled discard_token', {
446
+ reason: 'IDP_DISCARD_TOKEN',
447
+ errorCode,
448
+ userId,
449
+ sessionToken: sessionToken.substring(0, 8) + '...',
450
+ hadRefreshToken: !!currentSession.idpRefreshToken,
451
+ stack: new Error().stack
452
+ });
453
+ await updateSession(sessionToken, {
454
+ idpRefreshToken: '',
455
+ idpRefreshTokenExpires: undefined,
456
+ refreshTokenClearedAt: Date.now(),
457
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${errorCode}`
458
+ });
482
459
  }
483
460
 
484
461
  return NextResponse.json({
@@ -1,44 +1,44 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- import { idpFetchJSON } from '../../lib/idp-fetch';
3
- import { ENV_CONFIG, API_ENDPOINTS } from '../../config/env';
4
- import { getTokenTestAware } from '../../lib/test-aware-get-token';
5
- import { getSession } from '../../lib/session-store';
6
-
7
- /**
8
- * Validates current access token with the IDP and returns normalized info.
9
- * Sources access token from Authorization header or Redis session via cookie.
10
- */
11
- export async function GET(req: NextRequest) {
12
- try {
13
- // 1) Prefer Authorization header if present
14
- let bearer: string | undefined = undefined;
15
- const authHeader = req.headers.get('authorization') || req.headers.get('Authorization');
16
- if (authHeader && /^Bearer\s+/i.test(authHeader)) bearer = authHeader.replace(/^Bearer\s+/i, '').trim();
17
-
18
- // 2) If not, resolve from session
19
- if (!bearer) {
20
- const tok = await getTokenTestAware(req);
21
- const sessionToken = tok?.sessionToken as string | undefined;
22
- if (sessionToken) {
23
- const sess = await getSession(sessionToken);
24
- bearer = sess?.accessToken;
25
- }
26
- }
27
-
28
- if (!bearer) {
29
- return NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: 'No access token available' } }, { status: 401 });
30
- }
31
-
32
- const url = `${ENV_CONFIG.IDP_URL}${API_ENDPOINTS.externalAuth.validate}`;
33
- const result = await idpFetchJSON(req, url, { method: 'GET', headers: { Authorization: `Bearer ${bearer}` } });
34
-
35
- if (!result.ok) {
36
- return NextResponse.json({ success: false, error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status }, data: result.json }, { status: result.status });
37
- }
38
-
39
- // Passthrough normalized
40
- return NextResponse.json(result.json ?? { success: true }, { status: 200 });
41
- } catch (e: any) {
42
- return NextResponse.json({ success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: e?.message || 'validate error' } }, { status: 500 });
43
- }
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { idpFetchJSON } from '../../lib/idp-fetch';
3
+ import { ENV_CONFIG, API_ENDPOINTS } from '../../config/env';
4
+ import { getTokenTestAware } from '../../lib/test-aware-get-token';
5
+ import { getSession } from '../../lib/session-store';
6
+
7
+ /**
8
+ * Validates current access token with the IDP and returns normalized info.
9
+ * Sources access token from Authorization header or Redis session via cookie.
10
+ */
11
+ export async function GET(req: NextRequest) {
12
+ try {
13
+ // 1) Prefer Authorization header if present
14
+ let bearer: string | undefined = undefined;
15
+ const authHeader = req.headers.get('authorization') || req.headers.get('Authorization');
16
+ if (authHeader && /^Bearer\s+/i.test(authHeader)) bearer = authHeader.replace(/^Bearer\s+/i, '').trim();
17
+
18
+ // 2) If not, resolve from session
19
+ if (!bearer) {
20
+ const tok = await getTokenTestAware(req);
21
+ const sessionToken = tok?.sessionToken as string | undefined;
22
+ if (sessionToken) {
23
+ const sess = await getSession(sessionToken);
24
+ bearer = sess?.accessToken;
25
+ }
26
+ }
27
+
28
+ if (!bearer) {
29
+ return NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: 'No access token available' } }, { status: 401 });
30
+ }
31
+
32
+ const url = `${ENV_CONFIG.IDP_URL}${API_ENDPOINTS.externalAuth.validate}`;
33
+ const result = await idpFetchJSON(req, url, { method: 'GET', headers: { Authorization: `Bearer ${bearer}` } });
34
+
35
+ if (!result.ok) {
36
+ return NextResponse.json({ success: false, error: { code: 'UPSTREAM_SERVICE_ERROR', status: result.status }, data: result.json }, { status: result.status });
37
+ }
38
+
39
+ // Passthrough normalized
40
+ return NextResponse.json(result.json ?? { success: true }, { status: 200 });
41
+ } catch (e: any) {
42
+ return NextResponse.json({ success: false, error: { code: 'INTERNAL_SERVER_ERROR', message: e?.message || 'validate error' } }, { status: 500 });
43
+ }
44
44
  }