@payez/next-mvp 4.1.11 → 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 (91) hide show
  1. package/dist/api-handlers/auth/refresh.js +15 -38
  2. package/dist/auth/better-auth.js +12 -33
  3. package/dist/client/better-auth-client.d.ts +14 -2
  4. package/dist/lib/ensure-fresh-access-token.js +8 -42
  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/dist/lib/token-lifecycle.d.ts +0 -2
  9. package/dist/lib/token-lifecycle.js +3 -21
  10. package/package.json +4 -3
  11. package/scripts/check-internal-url-usage.sh +73 -73
  12. package/src/api/index.ts +17 -17
  13. package/src/api-handlers/account/masked-info.ts +45 -45
  14. package/src/api-handlers/account/profile.ts +80 -80
  15. package/src/api-handlers/account/recovery/initiate.ts +23 -23
  16. package/src/api-handlers/account/recovery/send-code.ts +25 -25
  17. package/src/api-handlers/account/recovery/verify-code.ts +25 -25
  18. package/src/api-handlers/account/reset-password.ts +23 -23
  19. package/src/api-handlers/account/send-code.ts +76 -76
  20. package/src/api-handlers/account/validate-password.ts +118 -118
  21. package/src/api-handlers/admin/stats.ts +249 -249
  22. package/src/api-handlers/anon/preferences.ts +123 -123
  23. package/src/api-handlers/auth/jwks.ts +20 -20
  24. package/src/api-handlers/auth/login.ts +240 -240
  25. package/src/api-handlers/auth/refresh.ts +15 -38
  26. package/src/api-handlers/auth/validate.ts +43 -43
  27. package/src/api-handlers/session/viability.ts +142 -142
  28. package/src/auth/better-auth.ts +450 -467
  29. package/src/auth/unauthenticated-routes.ts +2 -2
  30. package/src/client/better-auth-client.ts +70 -70
  31. package/src/client/fetchWithSession.ts +21 -21
  32. package/src/client/index.ts +13 -13
  33. package/src/client/useAnonSession.ts +131 -131
  34. package/src/components/admin/VibeAdminContext.tsx +87 -87
  35. package/src/config/logging-config.ts +82 -82
  36. package/src/config/unauthenticated-routes.ts +19 -19
  37. package/src/edge/internal-api-url.ts +65 -65
  38. package/src/edge/middleware.ts +42 -42
  39. package/src/hooks/useProfile.ts +75 -75
  40. package/src/lib/anon-session.ts +213 -213
  41. package/src/lib/auth-secret.ts +137 -137
  42. package/src/lib/demo-mode.ts +17 -17
  43. package/src/lib/ensure-fresh-access-token.ts +8 -42
  44. package/src/lib/idp-client-config.ts +559 -559
  45. package/src/lib/idp-fetch.ts +126 -126
  46. package/src/lib/internal-api.ts +171 -171
  47. package/src/lib/jwt-decode-client.ts +45 -45
  48. package/src/lib/rate-limit-service.ts +9 -9
  49. package/src/lib/redis.ts +26 -26
  50. package/src/lib/refresh-token-validator.ts +64 -64
  51. package/src/lib/secret-validation.ts +8 -8
  52. package/src/lib/session-store.ts +692 -695
  53. package/src/lib/session.ts +34 -34
  54. package/src/lib/startup-init.ts +251 -251
  55. package/src/lib/token-expiry.ts +40 -40
  56. package/src/lib/token-lifecycle.ts +470 -497
  57. package/src/lib/types/api-responses.ts +336 -336
  58. package/src/middleware/auth-decision.ts +43 -43
  59. package/src/models/DecodedAccessToken.ts +16 -16
  60. package/src/models/SessionModel.ts +264 -264
  61. package/src/pages/admin-roles/index.ts +9 -9
  62. package/src/pages/security/EnhancedSecurityPage.tsx +9 -9
  63. package/src/routes/account/index.ts +31 -31
  64. package/src/routes/account/masked-info.ts +36 -36
  65. package/src/routes/account/send-code.ts +39 -39
  66. package/src/routes/account/verify-email.ts +40 -40
  67. package/src/routes/account/verify-sms.ts +40 -40
  68. package/src/routes/auth/index.ts +22 -22
  69. package/src/routes/auth/refresh.ts +48 -48
  70. package/src/routes/auth/session.ts +166 -166
  71. package/src/routes/index.ts +17 -17
  72. package/src/routes/session/index.ts +7 -7
  73. package/src/routes/session/refresh-viability.ts +17 -17
  74. package/src/server/auth.ts +373 -373
  75. package/src/server/decode-session.ts +202 -202
  76. package/src/services/signalrActivityService.ts +258 -258
  77. package/src/theme/ThemeProvider.tsx +39 -39
  78. package/src/theme/default.ts +33 -33
  79. package/src/theme/index.ts +31 -31
  80. package/src/theme/types.ts +69 -69
  81. package/src/theme/useTheme.ts +57 -57
  82. package/src/theme/utils.ts +40 -40
  83. package/src/types/api.ts +12 -12
  84. package/src/types/auth.d.ts +23 -23
  85. package/src/types/auth.ts +22 -22
  86. package/src/types/logging.ts +11 -11
  87. package/src/types/recovery.ts +54 -54
  88. package/src/utils/api.ts +353 -353
  89. package/src/utils/circuitBreaker.ts +40 -40
  90. package/src/utils/error-message.ts +108 -108
  91. package/src/utils/layout/reservedSpace.ts +124 -124
@@ -1,137 +1,137 @@
1
- import 'server-only';
2
- import { validateAuthSecret } from './secret-validation';
3
- import { randomUUID } from 'crypto';
4
-
5
- let cachedSecret: string | null = null;
6
- let lastFetchedAt = 0;
7
-
8
- /**
9
- * Resolve the Better Auth signing secret (server-only).
10
- *
11
- * Priority:
12
- * 1) Use process.env.BETTER_AUTH_SECRET (preferred) or NEXTAUTH_SECRET (legacy)
13
- * if present — allows overrides/production via env.
14
- * 2) Fetch from IDP broker endpoint — IDP handles all Key Vault/signing.
15
- * 3) Cache result in-memory and set process.env.BETTER_AUTH_SECRET for
16
- * subsequent calls.
17
- *
18
- * NOTE on naming: this secret is the cryptographic key Better Auth uses to
19
- * sign session JWTs. The IDP backend still names the broker endpoint and
20
- * response field with the legacy "next-auth" / "nextAuthSecret" names; we
21
- * read both new and legacy on the wire during the migration window.
22
- */
23
- export async function resolveAuthSecret(): Promise<string> {
24
- // Check if already in environment (prefer new name, fall back to legacy)
25
- const envSecret =
26
- (process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_SECRET.trim() !== ''
27
- ? process.env.BETTER_AUTH_SECRET
28
- : undefined) ||
29
- (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== ''
30
- ? process.env.NEXTAUTH_SECRET
31
- : undefined);
32
- if (envSecret) {
33
- // Silent - already configured
34
- return envSecret;
35
- }
36
-
37
- // Check if cached and fresh (within 5 minutes)
38
- if (cachedSecret && Date.now() - lastFetchedAt < 5 * 60 * 1000) {
39
- return cachedSecret;
40
- }
41
-
42
- // Broker mode: fetch from IDP (IDP handles all Key Vault/signing)
43
- const base = process.env.IDP_URL;
44
- if (!base) throw new Error('IDP_URL environment variable is required');
45
-
46
- const clientIdStr = process.env.CLIENT_ID;
47
- if (!clientIdStr || clientIdStr.trim() === '') throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
48
-
49
- // Step 1: Request IDP to sign a client assertion (IDP has the keys, not us)
50
-
51
- const signingUrl = new URL(`${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`);
52
-
53
- const signingPayload = {
54
- issuer: clientIdStr,
55
- subject: clientIdStr,
56
- audience: 'urn:payez:externalauth:nextauthsecret',
57
- expires_in: 60,
58
- };
59
-
60
- const signingResp = await fetch(signingUrl.toString(), {
61
- method: 'POST',
62
- headers: {
63
- 'Accept': 'application/json',
64
- 'Content-Type': 'application/json',
65
- 'X-Client-Id': clientIdStr,
66
- 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
67
- },
68
- body: JSON.stringify(signingPayload),
69
- cache: 'no-store'
70
- } as RequestInit);
71
-
72
- if (!signingResp.ok) {
73
- const txt = await signingResp.text().catch(() => 'Unknown error');
74
- throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
75
- }
76
-
77
- const signingBody: any = await signingResp.json().catch(() => ({}));
78
- const client_assertion = (
79
- signingBody?.data?.client_assertion ??
80
- signingBody?.data?.clientAssertion ??
81
- signingBody?.client_assertion ??
82
- signingBody?.clientAssertion ??
83
- signingBody?.data?.ClientAssertion ??
84
- signingBody?.ClientAssertion
85
- ) as string | undefined;
86
-
87
- if (!client_assertion || typeof client_assertion !== 'string') {
88
- throw new Error('IDP did not return a valid signed client assertion');
89
- }
90
-
91
- // Step 2: Use the signed assertion to fetch the auth secret
92
- // (Endpoint is still served at /next-auth/secret on the IDP — legacy path.)
93
-
94
- const proxyUrl = new URL(`${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`);
95
-
96
- const proxyResp = await fetch(proxyUrl.toString(), {
97
- method: 'POST',
98
- headers: {
99
- 'Accept': 'application/json',
100
- 'Content-Type': 'application/json',
101
- 'X-Client-Id': clientIdStr,
102
- 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
103
- },
104
- body: JSON.stringify({ client_assertion }),
105
- cache: 'no-store'
106
- } as RequestInit);
107
-
108
- if (!proxyResp.ok) {
109
- const txt = await proxyResp.text().catch(() => 'Unknown error');
110
- throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
111
- }
112
-
113
- const proxyBody: any = await proxyResp.json().catch(() => ({}));
114
-
115
- const secret = (proxyBody?.data?.secret ?? proxyBody?.secret) as string | undefined;
116
- // Configuration is available but we don't log it verbosely
117
-
118
- if (!secret || typeof secret !== 'string') {
119
- throw new Error('Proxy did not return a valid auth secret');
120
- }
121
-
122
- const validation = validateAuthSecret(secret);
123
- if (!validation.valid) {
124
- throw new Error(`Fetched auth secret failed validation: ${validation.reason}`);
125
- }
126
-
127
- cachedSecret = secret;
128
- lastFetchedAt = Date.now();
129
- process.env.BETTER_AUTH_SECRET = secret;
130
- // Also set legacy name during transition so any consumer still reading
131
- // process.env.NEXTAUTH_SECRET keeps working until they upgrade.
132
- process.env.NEXTAUTH_SECRET = secret;
133
-
134
- console.log('[AUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
135
-
136
- return secret;
137
- }
1
+ import 'server-only';
2
+ import { validateAuthSecret } from './secret-validation';
3
+ import { randomUUID } from 'crypto';
4
+
5
+ let cachedSecret: string | null = null;
6
+ let lastFetchedAt = 0;
7
+
8
+ /**
9
+ * Resolve the Better Auth signing secret (server-only).
10
+ *
11
+ * Priority:
12
+ * 1) Use process.env.BETTER_AUTH_SECRET (preferred) or NEXTAUTH_SECRET (legacy)
13
+ * if present — allows overrides/production via env.
14
+ * 2) Fetch from IDP broker endpoint — IDP handles all Key Vault/signing.
15
+ * 3) Cache result in-memory and set process.env.BETTER_AUTH_SECRET for
16
+ * subsequent calls.
17
+ *
18
+ * NOTE on naming: this secret is the cryptographic key Better Auth uses to
19
+ * sign session JWTs. The IDP backend still names the broker endpoint and
20
+ * response field with the legacy "next-auth" / "nextAuthSecret" names; we
21
+ * read both new and legacy on the wire during the migration window.
22
+ */
23
+ export async function resolveAuthSecret(): Promise<string> {
24
+ // Check if already in environment (prefer new name, fall back to legacy)
25
+ const envSecret =
26
+ (process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_SECRET.trim() !== ''
27
+ ? process.env.BETTER_AUTH_SECRET
28
+ : undefined) ||
29
+ (process.env.NEXTAUTH_SECRET && process.env.NEXTAUTH_SECRET.trim() !== ''
30
+ ? process.env.NEXTAUTH_SECRET
31
+ : undefined);
32
+ if (envSecret) {
33
+ // Silent - already configured
34
+ return envSecret;
35
+ }
36
+
37
+ // Check if cached and fresh (within 5 minutes)
38
+ if (cachedSecret && Date.now() - lastFetchedAt < 5 * 60 * 1000) {
39
+ return cachedSecret;
40
+ }
41
+
42
+ // Broker mode: fetch from IDP (IDP handles all Key Vault/signing)
43
+ const base = process.env.IDP_URL;
44
+ if (!base) throw new Error('IDP_URL environment variable is required');
45
+
46
+ const clientIdStr = process.env.CLIENT_ID;
47
+ if (!clientIdStr || clientIdStr.trim() === '') throw new Error('CLIENT_ID is required (e.g., "ideal_resume_website")');
48
+
49
+ // Step 1: Request IDP to sign a client assertion (IDP has the keys, not us)
50
+
51
+ const signingUrl = new URL(`${base.replace(/\/$/, '')}/api/ExternalAuth/sign-client-assertion`);
52
+
53
+ const signingPayload = {
54
+ issuer: clientIdStr,
55
+ subject: clientIdStr,
56
+ audience: 'urn:payez:externalauth:nextauthsecret',
57
+ expires_in: 60,
58
+ };
59
+
60
+ const signingResp = await fetch(signingUrl.toString(), {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Accept': 'application/json',
64
+ 'Content-Type': 'application/json',
65
+ 'X-Client-Id': clientIdStr,
66
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
67
+ },
68
+ body: JSON.stringify(signingPayload),
69
+ cache: 'no-store'
70
+ } as RequestInit);
71
+
72
+ if (!signingResp.ok) {
73
+ const txt = await signingResp.text().catch(() => 'Unknown error');
74
+ throw new Error(`Failed to sign client assertion: ${signingResp.status} ${signingResp.statusText} - ${txt}`);
75
+ }
76
+
77
+ const signingBody: any = await signingResp.json().catch(() => ({}));
78
+ const client_assertion = (
79
+ signingBody?.data?.client_assertion ??
80
+ signingBody?.data?.clientAssertion ??
81
+ signingBody?.client_assertion ??
82
+ signingBody?.clientAssertion ??
83
+ signingBody?.data?.ClientAssertion ??
84
+ signingBody?.ClientAssertion
85
+ ) as string | undefined;
86
+
87
+ if (!client_assertion || typeof client_assertion !== 'string') {
88
+ throw new Error('IDP did not return a valid signed client assertion');
89
+ }
90
+
91
+ // Step 2: Use the signed assertion to fetch the auth secret
92
+ // (Endpoint is still served at /next-auth/secret on the IDP — legacy path.)
93
+
94
+ const proxyUrl = new URL(`${base.replace(/\/$/, '')}/api/ExternalAuth/next-auth/secret`);
95
+
96
+ const proxyResp = await fetch(proxyUrl.toString(), {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Accept': 'application/json',
100
+ 'Content-Type': 'application/json',
101
+ 'X-Client-Id': clientIdStr,
102
+ 'X-Correlation-Id': randomUUID().replace(/-/g, ''),
103
+ },
104
+ body: JSON.stringify({ client_assertion }),
105
+ cache: 'no-store'
106
+ } as RequestInit);
107
+
108
+ if (!proxyResp.ok) {
109
+ const txt = await proxyResp.text().catch(() => 'Unknown error');
110
+ throw new Error(`Proxy error: ${proxyResp.status} ${proxyResp.statusText} - ${txt}`);
111
+ }
112
+
113
+ const proxyBody: any = await proxyResp.json().catch(() => ({}));
114
+
115
+ const secret = (proxyBody?.data?.secret ?? proxyBody?.secret) as string | undefined;
116
+ // Configuration is available but we don't log it verbosely
117
+
118
+ if (!secret || typeof secret !== 'string') {
119
+ throw new Error('Proxy did not return a valid auth secret');
120
+ }
121
+
122
+ const validation = validateAuthSecret(secret);
123
+ if (!validation.valid) {
124
+ throw new Error(`Fetched auth secret failed validation: ${validation.reason}`);
125
+ }
126
+
127
+ cachedSecret = secret;
128
+ lastFetchedAt = Date.now();
129
+ process.env.BETTER_AUTH_SECRET = secret;
130
+ // Also set legacy name during transition so any consumer still reading
131
+ // process.env.NEXTAUTH_SECRET keeps working until they upgrade.
132
+ process.env.NEXTAUTH_SECRET = secret;
133
+
134
+ console.log('[AUTH-SECRET] Resolved from IDP (length:', secret.length + ')');
135
+
136
+ return secret;
137
+ }
@@ -1,17 +1,17 @@
1
- /**
2
- * Demo mode utilities
3
- * When DEMO_MODE=true, auth package is installed but not enforced
4
- */
5
-
6
- export function isDemoMode(): boolean {
7
- return process.env.DEMO_MODE === 'true' || process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
8
- }
9
-
10
- export function isAuthConfigured(): boolean {
11
- if (isDemoMode()) return false;
12
- return !!(
13
- process.env.BETTER_AUTH_SECRET ||
14
- process.env.NEXTAUTH_SECRET ||
15
- (process.env.NEXT_CLIENT_ID && process.env.NEXT_CLIENT_PRIVATE_KEY_PEM)
16
- );
17
- }
1
+ /**
2
+ * Demo mode utilities
3
+ * When DEMO_MODE=true, auth package is installed but not enforced
4
+ */
5
+
6
+ export function isDemoMode(): boolean {
7
+ return process.env.DEMO_MODE === 'true' || process.env.NEXT_PUBLIC_DEMO_MODE === 'true';
8
+ }
9
+
10
+ export function isAuthConfigured(): boolean {
11
+ if (isDemoMode()) return false;
12
+ return !!(
13
+ process.env.BETTER_AUTH_SECRET ||
14
+ process.env.NEXTAUTH_SECRET ||
15
+ (process.env.NEXT_CLIENT_ID && process.env.NEXT_CLIENT_PRIVATE_KEY_PEM)
16
+ );
17
+ }
@@ -206,16 +206,8 @@ export async function ensureFreshAccessToken(
206
206
  acrValue = '2';
207
207
  }
208
208
 
209
- // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
210
- // `session` was read before we acquired the lock; a concurrent refresh that
211
- // completed while we waited rotates the token, so the pre-lock value is stale
212
- // and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
213
- // the round-trip is wasted). `latest` is the authoritative post-lock session.
214
- // Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
215
- // the identity context of THIS request, unchanged by a token rotation.
216
- const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
217
209
  const body: Record<string, unknown> = {
218
- refresh_token: sentRefreshToken,
210
+ refresh_token: session.idpRefreshToken,
219
211
  amr: authMethods,
220
212
  acr: acrValue,
221
213
  };
@@ -254,41 +246,15 @@ export async function ensureFreshAccessToken(
254
246
  if (!idpResponse.ok) {
255
247
  const idpError = responseData?.error || {};
256
248
  const code = idpError.code || 'UNKNOWN_ERROR';
257
- let discardToken = idpResponse.status === 401 || idpError.discard_token === true;
249
+ const discardToken = idpResponse.status === 401 || idpError.discard_token === true;
258
250
  const retryable = idpResponse.status !== 401 && idpError.retryable === true;
259
251
  if (discardToken) {
260
- // Race-safe discard (CAS): only clear if the refresh token we used is STILL
261
- // current. A concurrent refresh may have already rotated it — our 401 is then
262
- // stale/expected, and wiping here would silently log out a healthy session
263
- // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
264
- // a concurrent rotation is NOT a terminal condition.
265
- const postRefresh = await getSession(sessionToken);
266
- // CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
267
- // the post-lock value), not the pre-lock snapshot — otherwise a token rotated
268
- // between pre-lock read and our send would be misjudged.
269
- const tokenUnchanged =
270
- !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
271
- if (tokenUnchanged) {
272
- await updateSession(sessionToken, {
273
- idpRefreshToken: '',
274
- idpRefreshTokenExpires: undefined,
275
- refreshTokenClearedAt: Date.now(),
276
- refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
277
- });
278
- } else {
279
- // Concurrent refresh already rotated the token — our 401 is stale. Return the
280
- // freshly-rotated token directly so the loser succeeds without a retry round-trip
281
- // (race-fix follow-up #1).
282
- discardToken = false;
283
- if (postRefresh?.idpAccessToken && postRefresh.idpAccessTokenExpires) {
284
- return {
285
- ok: true,
286
- accessToken: postRefresh.idpAccessToken,
287
- accessTokenExpires: postRefresh.idpAccessTokenExpires,
288
- refreshed: true,
289
- };
290
- }
291
- }
252
+ await updateSession(sessionToken, {
253
+ idpRefreshToken: '',
254
+ idpRefreshTokenExpires: undefined,
255
+ refreshTokenClearedAt: Date.now(),
256
+ refreshTokenClearedReason: `IDP_DISCARD_TOKEN:${code}`,
257
+ });
292
258
  }
293
259
  return {
294
260
  ok: false,