@payez/next-mvp 4.1.10 → 4.1.11

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.
@@ -327,8 +327,20 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
327
327
  return false;
328
328
  }
329
329
  // Build IDP token data
330
- // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
331
- // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
330
+ // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
331
+ // !hasRefreshToken was a reliable "2FA required" signal.
332
+ //
333
+ // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
334
+ // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
335
+ // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
336
+ // bypassing the 2FA gate entirely.
337
+ //
338
+ // FIX: Always set mfaVerified=false on the initial OAuth exchange.
339
+ // The tenant's require_2fa setting is enforced by the viability endpoint
340
+ // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
341
+ // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
342
+ // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
343
+ // IDP correctly withholds refresh_token until post-2FA.
332
344
  const hasRefreshToken = !!result.refresh_token;
333
345
  const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
334
346
  const idpTokenData = {
@@ -342,7 +354,7 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
342
354
  name: result.user?.full_name || result.user?.name || result.name || name,
343
355
  image: image,
344
356
  roles: result.user?.roles || result.roles || [],
345
- mfaVerified: !requiresTwoFactor,
357
+ mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
346
358
  idpClientId: result.client_id ? String(result.client_id) : undefined,
347
359
  merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
348
360
  };
@@ -158,8 +158,16 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
158
158
  if (isOAuthSession && session.mfaVerified && acrValue === '1') {
159
159
  acrValue = '2';
160
160
  }
161
+ // WO-2: send the POST-lock current refresh token, not the pre-lock snapshot.
162
+ // `session` was read before we acquired the lock; a concurrent refresh that
163
+ // completed while we waited rotates the token, so the pre-lock value is stale
164
+ // and would draw an avoidable 401 (the CAS now catches the resulting wipe, but
165
+ // the round-trip is wasted). `latest` is the authoritative post-lock session.
166
+ // Auth-context (amr/acr/2FA) intentionally still derives from `session` — it is
167
+ // the identity context of THIS request, unchanged by a token rotation.
168
+ const sentRefreshToken = latest?.idpRefreshToken ?? session.idpRefreshToken;
161
169
  const body = {
162
- refresh_token: session.idpRefreshToken,
170
+ refresh_token: sentRefreshToken,
163
171
  amr: authMethods,
164
172
  acr: acrValue,
165
173
  };
@@ -209,7 +217,10 @@ async function ensureFreshAccessToken(sessionToken, config, options = {}) {
209
217
  // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
210
218
  // a concurrent rotation is NOT a terminal condition.
211
219
  const postRefresh = await (0, session_store_1.getSession)(sessionToken);
212
- const tokenUnchanged = !!session.idpRefreshToken && postRefresh?.idpRefreshToken === session.idpRefreshToken;
220
+ // CAS compares against the token we ACTUALLY SENT (WO-2: sentRefreshToken,
221
+ // the post-lock value), not the pre-lock snapshot — otherwise a token rotated
222
+ // between pre-lock read and our send would be misjudged.
223
+ const tokenUnchanged = !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
213
224
  if (tokenUnchanged) {
214
225
  await (0, session_store_1.updateSession)(sessionToken, {
215
226
  idpRefreshToken: '',
@@ -31,6 +31,8 @@ export interface TokenError {
31
31
  error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
32
32
  message: string;
33
33
  terminal?: boolean;
34
+ code?: string;
35
+ resolution?: string;
34
36
  }
35
37
  export type EnsureFreshTokenResult = TokenResult | TokenError;
36
38
  /**
@@ -150,7 +150,16 @@ async function triggerRefresh(request, sessionToken, retryCount = 0) {
150
150
  // Don't retry - user must re-authenticate
151
151
  if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
152
152
  console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
153
- return { success: false, terminal: true, code: responseData.code };
153
+ // WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
154
+ // message in `error` and the code in `code`; carry both up so the consumer
155
+ // can tell the user WHY (not just "session expired") and stop a futile loop.
156
+ return {
157
+ success: false,
158
+ terminal: true,
159
+ code: responseData.code,
160
+ message: responseData.error,
161
+ resolution: responseData.resolution,
162
+ };
154
163
  }
155
164
  // For other 401s, check if maybe session was refreshed by another request
156
165
  if (response.status === 401 && retryCount < maxRetries) {
@@ -318,12 +327,21 @@ async function ensureFreshToken(request) {
318
327
  if (!refreshResult.success) {
319
328
  // Check for terminal state - session cannot be recovered
320
329
  if (refreshResult.terminal) {
321
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
330
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
331
+ code: refreshResult.code,
332
+ message: refreshResult.message,
333
+ });
334
+ // WO-1: keep `error` as the stable terminal category (existing consumers
335
+ // branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
336
+ // reason via code/message/resolution so the user learns WHY and a genuinely
337
+ // dead session (e.g. unapproved account) doesn't loop login→bounce→login.
322
338
  return {
323
339
  success: false,
324
340
  error: 'SESSION_EXPIRED_NO_REFRESH',
325
- message: 'Session expired. Please sign in again.',
341
+ message: refreshResult.message || 'Session expired. Please sign in again.',
326
342
  terminal: true,
343
+ code: refreshResult.code,
344
+ resolution: refreshResult.resolution,
327
345
  };
328
346
  }
329
347
  console.warn('[TOKEN_LIFECYCLE] Refresh failed');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.10",
3
+ "version": "4.1.11",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,455 +1,467 @@
1
- /**
2
- * Better Auth Configuration
3
- *
4
- * Primary auth configuration. Replaces the former NextAuth auth-options.ts.
5
- *
6
- * Architecture: No database adapter — Better Auth runs in stateless mode
7
- * with JWE cookie cache. User management stays on IDP, sessions on Redis.
8
- *
9
- * @see BETTER-AUTH-MIGRATION-SPEC.md
10
- */
11
-
12
- import 'server-only';
13
- import { betterAuth } from 'better-auth';
14
- import { nextCookies } from 'better-auth/next-js';
15
- import { toNextJsHandler } from 'better-auth/next-js';
16
- import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
17
- import type { IDPClientConfig } from '../lib/idp-client-config';
18
- import { getIDPClientConfig } from '../lib/idp-client-config';
19
- import { getAppSlug } from '../lib/app-slug';
20
- import { getRedis } from '../lib/redis';
21
- import { SESSION_TTL } from '../lib/session-store';
22
-
23
- /**
24
- * Better Auth social provider config shape.
25
- */
26
- export interface BetterAuthSocialProvider {
27
- clientId: string;
28
- clientSecret: string;
29
- scope?: string[];
30
- prompt?: string;
31
- accessType?: 'offline' | 'online';
32
- hd?: string;
33
- }
34
-
35
- /**
36
- * Build Better Auth social providers from IDP config.
37
- */
38
- export function buildBetterAuthProviders(
39
- config: IDPClientConfig
40
- ): Record<string, BetterAuthSocialProvider> {
41
- const providers: Record<string, BetterAuthSocialProvider> = {};
42
-
43
- for (const oauth of config.oauthProviders || []) {
44
- if (!oauth.enabled) continue;
45
- const name = oauth.provider.toLowerCase();
46
- const additionalParams = oauth.additionalParams ?? {};
47
- const rawPrompt = additionalParams.prompt;
48
- const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
49
- const rawHostedDomain = additionalParams.hd;
50
-
51
- // Ensure profile scope is present for Google so avatar image is returned
52
- const scopes = oauth.scopes?.split(' ') || [];
53
- if (name === 'google' && !scopes.includes('profile')) {
54
- scopes.push('profile');
55
- }
56
-
57
- providers[name] = {
58
- clientId: oauth.clientId,
59
- clientSecret: oauth.clientSecret,
60
- scope: scopes.length > 0 ? scopes : undefined,
61
- // Google is overly eager to reuse the last account unless we
62
- // explicitly ask for account selection on each social login.
63
- prompt: typeof rawPrompt === 'string'
64
- ? rawPrompt
65
- : name === 'google'
66
- ? 'select_account'
67
- : undefined,
68
- accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
69
- hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
70
- };
71
- }
72
-
73
- return providers;
74
- }
75
-
76
- /**
77
- * Optional configuration for `createBetterAuthInstance`.
78
- *
79
- * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
80
- * The host app supplies its own `sendMagicLink` callback — typically a
81
- * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
82
- * Omit the `magicLink` key entirely to skip the plugin; the consuming
83
- * app will not have a magic-link flow.
84
- */
85
- export interface CreateBetterAuthInstanceOptions {
86
- magicLink?: MagicLinkOptions;
87
- }
88
-
89
- /**
90
- * Create Better Auth instance from IDP config.
91
- *
92
- * No database — runs in stateless mode with JWE cookie cache.
93
- * Call after getIDPClientConfig() resolves.
94
- */
95
- export function createBetterAuthInstance(
96
- idpConfig: IDPClientConfig,
97
- opts: CreateBetterAuthInstanceOptions = {}
98
- ) {
99
- const appSlug = idpConfig.clientSlug || getAppSlug();
100
-
101
- // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
102
- // Must include /api/auth since that's where the catch-all route is mounted
103
- const rawBaseURL = process.env.BETTER_AUTH_URL
104
- || idpConfig.baseClientUrl
105
- || `http://localhost:${process.env.PORT || '3000'}`;
106
- const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
107
-
108
- return betterAuth({
109
- baseURL,
110
- secret: idpConfig.authSecret as string,
111
-
112
- socialProviders: buildBetterAuthProviders(idpConfig),
113
-
114
- // Trust the app's own origin + any configured base URL
115
- trustedOrigins: [
116
- rawBaseURL,
117
- baseURL,
118
- ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
119
- 'http://localhost:3000',
120
- 'http://localhost:3400',
121
- 'http://localhost:3600',
122
- ],
123
-
124
- // Redis-backed session storage via secondaryStorage
125
- secondaryStorage: {
126
- get: async (key: string) => {
127
- try {
128
- return await getRedis().get(`ba:${appSlug}:${key}`);
129
- } catch { return null; }
130
- },
131
- set: async (key: string, value: string, ttl?: number) => {
132
- try {
133
- const redis = getRedis();
134
- if (ttl) {
135
- await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
136
- } else {
137
- await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
138
- }
139
- } catch { /* Redis unavailable — cookie cache still works */ }
140
- },
141
- delete: async (key: string) => {
142
- try {
143
- await getRedis().del(`ba:${appSlug}:${key}`);
144
- } catch { /* ignore */ }
145
- },
146
- },
147
-
148
- session: {
149
- // Cookie cache DISABLED — Redis is canonical for session liveness.
150
- //
151
- // Previously: enabled with maxAge 300 + refreshCache false. That cached
152
- // a decoded session in process memory for 5 minutes, bypassing Redis
153
- // reads during the window. Consequence: if the canonical app session
154
- // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
155
- // Better Auth's in-memory copy stayed alive — and callers got
156
- // contradictory answers depending on whether they consulted Better
157
- // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
158
- // visible in production traces: viability 200 + idp-token 200 +
159
- // getFreshIdpToken NO_SESSION terminal within milliseconds.
160
- //
161
- // Trade-off: every Better Auth getSession() call now incurs one Redis
162
- // read on the secondary-storage path. Latency cost is acceptable
163
- // (sub-ms in-region) and the alternative is duplicate Layer-1
164
- // workarounds in every consumer app — already shipped in idealvibe.online
165
- // at b6a91f6.
166
- cookieCache: {
167
- enabled: false,
168
- maxAge: 300,
169
- refreshCache: false,
170
- },
171
- },
172
-
173
- // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
174
- advanced: {
175
- cookiePrefix: appSlug,
176
- cookies: {
177
- session_token: {
178
- name: `${appSlug}.session-token`,
179
- },
180
- },
181
- },
182
-
183
- plugins: [
184
- nextCookies(),
185
- ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
186
- ],
187
- });
188
- }
189
-
190
- /**
191
- * Better Auth is always enabled (NextAuth removed in 4.0).
192
- */
193
- export function isBetterAuthEnabled(): boolean {
194
- return true;
195
- }
196
-
197
- /**
198
- * Get Better Auth Next.js route handlers (GET, POST).
199
- * Initializes Better Auth from IDP config on first call, caches the instance.
200
- */
201
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
- let cachedInstance: any = null;
203
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
- let initPromise: Promise<any> | null = null;
205
- let configuredOpts: CreateBetterAuthInstanceOptions = {};
206
-
207
- // Expose for server-side session access (decode-session.ts)
208
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
- export { cachedInstance as __betterAuthInstance };
210
-
211
- /**
212
- * Configure Better Auth instance options for this process.
213
- *
214
- * Must be called before the first auth request — before
215
- * `getBetterAuthInstance()` caches an instance. Typically called once at
216
- * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
217
- * server bootstrap hook.
218
- *
219
- * Throws if called after the instance has already been resolved: options
220
- * cannot be applied retroactively.
221
- */
222
- export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
223
- if (cachedInstance) {
224
- throw new Error(
225
- '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
226
- 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
227
- );
228
- }
229
- configuredOpts = opts;
230
- }
231
-
232
- export async function getBetterAuthInstance() {
233
- if (cachedInstance) return cachedInstance;
234
-
235
- if (!initPromise) {
236
- initPromise = getIDPClientConfig(true).then(config => {
237
- const instance = createBetterAuthInstance(config, configuredOpts);
238
- cachedInstance = instance;
239
- console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
240
- return instance;
241
- });
242
- }
243
-
244
- return initPromise;
245
- }
246
-
247
- /**
248
- * Get flag-gated auth handler for Next.js route.
249
- *
250
- * When USE_BETTER_AUTH=true, returns Better Auth handlers.
251
- * Otherwise returns null (auth disabled).
252
- *
253
- * Usage in host app route:
254
- * ```ts
255
- * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
256
- *
257
- * export async function GET(req: Request) {
258
- * const ba = await getBetterAuthHandler();
259
- * if (ba) return ba.GET(req);
260
- * }
261
- * ```
262
- */
263
- export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
264
- if (!isBetterAuthEnabled()) return null;
265
-
266
- const auth = await getBetterAuthInstance();
267
- return toNextJsHandler(auth);
268
- }
269
-
270
- /**
271
- * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
272
- *
273
- * Call this from the OAuth callback route AFTER better-auth has processed the
274
- * callback and created the session. Reads the session token from the Set-Cookie
275
- * header of the response to find the BA Redis key.
276
- *
277
- * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
278
- */
279
- export async function exchangeOAuthForIdpTokens(
280
- sessionToken: string,
281
- provider: string = 'google'
282
- ): Promise<boolean> {
283
- try {
284
- const config = await getIDPClientConfig();
285
- const appSlug = config.clientSlug || getAppSlug();
286
- const baKey = `ba:${appSlug}:${sessionToken}`;
287
-
288
- // Read the BA session from Redis
289
- const baRaw = await getRedis().get(baKey).catch(() => null);
290
- if (!baRaw) {
291
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
292
- return false;
293
- }
294
-
295
- const baData = JSON.parse(baRaw);
296
- const email = baData?.user?.email;
297
- const name = baData?.user?.name;
298
- const image = baData?.user?.image;
299
- const baUserId = baData?.session?.userId || baData?.user?.id;
300
-
301
- if (!email) {
302
- console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
303
- return false;
304
- }
305
-
306
- // Call IDP oauth-callback
307
- const idpUrl = process.env.IDP_URL || '';
308
- if (!idpUrl) {
309
- console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
310
- return false;
311
- }
312
-
313
- console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
314
-
315
- const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
316
- method: 'POST',
317
- headers: { 'Content-Type': 'application/json' },
318
- body: JSON.stringify({
319
- provider,
320
- provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
321
- email,
322
- name,
323
- image,
324
- client_id: config.clientSlug || String(config.clientId),
325
- }),
326
- });
327
-
328
- const oauthResText = await oauthRes.text();
329
- console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
330
-
331
- if (!oauthRes.ok) {
332
- console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
333
- return false;
334
- }
335
-
336
- let idpData: any;
337
- try { idpData = JSON.parse(oauthResText); } catch { return false; }
338
- const result = idpData?.data?.result || idpData?.data || idpData;
339
-
340
- if (!result?.access_token) {
341
- console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
342
- return false;
343
- }
344
-
345
- // Build IDP token data
346
- // CRITICAL: If the IDP withholds refresh_token without setting requiresTwoFactor,
347
- // treat missing refresh token as "2FA required" so middleware redirects to verify-code.
348
- const hasRefreshToken = !!result.refresh_token;
349
- const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
350
- const idpTokenData = {
351
- idpAccessToken: result.access_token,
352
- idpRefreshToken: result.refresh_token,
353
- idpAccessTokenExpires: result.expires_in
354
- ? Date.now() + result.expires_in * 1000
355
- : Date.now() + 15 * 60 * 1000,
356
- userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
357
- email: result.user?.email || result.email || email,
358
- name: result.user?.full_name || result.user?.name || result.name || name,
359
- image: image,
360
- roles: result.user?.roles || result.roles || [],
361
- mfaVerified: !requiresTwoFactor,
362
- idpClientId: result.client_id ? String(result.client_id) : undefined,
363
- merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
364
- };
365
-
366
- // Store in BA Redis session (for decodeSession)
367
- baData.idpTokens = idpTokenData;
368
- await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
369
-
370
- // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
371
- // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
372
- try {
373
- const { getSessionPrefix } = await import('../lib/app-slug');
374
- const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
375
- await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
376
- ...idpTokenData,
377
- oauthProvider: provider,
378
- }));
379
- } catch (canonicalErr) {
380
- console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
381
- }
382
-
383
- console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
384
- return true;
385
- } catch (err) {
386
- console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
387
- return false;
388
- }
389
- }
390
-
391
- /**
392
- * Create a production-ready GET handler for the auth catch-all route.
393
- *
394
- * Wraps better-auth's GET handler with:
395
- * - OAuth state error recovery (redirects to login instead of error page)
396
- * - IDP token exchange after successful OAuth callback
397
- *
398
- * Usage in host app:
399
- * ```ts
400
- * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
401
- * export const GET = createAuthGetHandler('/account-auth/login');
402
- * export async function POST(req: Request) {
403
- * const ba = await getBetterAuthHandler();
404
- * return ba!.POST(req);
405
- * }
406
- * ```
407
- */
408
- export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
409
- return async function GET(request: Request): Promise<Response> {
410
- const ba = await getBetterAuthHandler();
411
- if (!ba) {
412
- return new Response('Auth handler not configured', { status: 500 });
413
- }
414
-
415
- const response = await ba.GET(request);
416
-
417
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
418
- if (response.status === 302) {
419
- const location = response.headers.get('location') || '';
420
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
421
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
422
- return Response.redirect(new URL(loginPath, request.url), 302);
423
- }
424
- }
425
-
426
- // After successful OAuth callback: exchange Google identity for IDP tokens
427
- const url = new URL(request.url);
428
- if (url.pathname.includes('/callback/') && response.status === 302) {
429
- try {
430
- const auth = await getBetterAuthInstance();
431
- if (auth?.api?.getSession) {
432
- const setCookies = response.headers.getSetCookie?.() || [];
433
- const cookieHeader = setCookies
434
- .map((c: string) => c.split(';')[0])
435
- .join('; ');
436
-
437
- const headers = new Headers();
438
- headers.set('cookie', cookieHeader);
439
-
440
- const session = await auth.api.getSession({ headers });
441
- if (session?.session?.token) {
442
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
443
- await exchangeOAuthForIdpTokens(session.session.token);
444
- } else {
445
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
446
- }
447
- }
448
- } catch (err: any) {
449
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
450
- }
451
- }
452
-
453
- return response;
454
- };
455
- }
1
+ /**
2
+ * Better Auth Configuration
3
+ *
4
+ * Primary auth configuration. Replaces the former NextAuth auth-options.ts.
5
+ *
6
+ * Architecture: No database adapter — Better Auth runs in stateless mode
7
+ * with JWE cookie cache. User management stays on IDP, sessions on Redis.
8
+ *
9
+ * @see BETTER-AUTH-MIGRATION-SPEC.md
10
+ */
11
+
12
+ import 'server-only';
13
+ import { betterAuth } from 'better-auth';
14
+ import { nextCookies } from 'better-auth/next-js';
15
+ import { toNextJsHandler } from 'better-auth/next-js';
16
+ import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
17
+ import type { IDPClientConfig } from '../lib/idp-client-config';
18
+ import { getIDPClientConfig } from '../lib/idp-client-config';
19
+ import { getAppSlug } from '../lib/app-slug';
20
+ import { getRedis } from '../lib/redis';
21
+ import { SESSION_TTL } from '../lib/session-store';
22
+
23
+ /**
24
+ * Better Auth social provider config shape.
25
+ */
26
+ export interface BetterAuthSocialProvider {
27
+ clientId: string;
28
+ clientSecret: string;
29
+ scope?: string[];
30
+ prompt?: string;
31
+ accessType?: 'offline' | 'online';
32
+ hd?: string;
33
+ }
34
+
35
+ /**
36
+ * Build Better Auth social providers from IDP config.
37
+ */
38
+ export function buildBetterAuthProviders(
39
+ config: IDPClientConfig
40
+ ): Record<string, BetterAuthSocialProvider> {
41
+ const providers: Record<string, BetterAuthSocialProvider> = {};
42
+
43
+ for (const oauth of config.oauthProviders || []) {
44
+ if (!oauth.enabled) continue;
45
+ const name = oauth.provider.toLowerCase();
46
+ const additionalParams = oauth.additionalParams ?? {};
47
+ const rawPrompt = additionalParams.prompt;
48
+ const rawAccessType = additionalParams.accessType ?? additionalParams.access_type;
49
+ const rawHostedDomain = additionalParams.hd;
50
+
51
+ // Ensure profile scope is present for Google so avatar image is returned
52
+ const scopes = oauth.scopes?.split(' ') || [];
53
+ if (name === 'google' && !scopes.includes('profile')) {
54
+ scopes.push('profile');
55
+ }
56
+
57
+ providers[name] = {
58
+ clientId: oauth.clientId,
59
+ clientSecret: oauth.clientSecret,
60
+ scope: scopes.length > 0 ? scopes : undefined,
61
+ // Google is overly eager to reuse the last account unless we
62
+ // explicitly ask for account selection on each social login.
63
+ prompt: typeof rawPrompt === 'string'
64
+ ? rawPrompt
65
+ : name === 'google'
66
+ ? 'select_account'
67
+ : undefined,
68
+ accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
69
+ hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
70
+ };
71
+ }
72
+
73
+ return providers;
74
+ }
75
+
76
+ /**
77
+ * Optional configuration for `createBetterAuthInstance`.
78
+ *
79
+ * - `magicLink`: if provided, registers Better Auth's magic-link plugin.
80
+ * The host app supplies its own `sendMagicLink` callback — typically a
81
+ * fetch to its email service (e.g. ACP's `/v1/auth/magic-link/email`).
82
+ * Omit the `magicLink` key entirely to skip the plugin; the consuming
83
+ * app will not have a magic-link flow.
84
+ */
85
+ export interface CreateBetterAuthInstanceOptions {
86
+ magicLink?: MagicLinkOptions;
87
+ }
88
+
89
+ /**
90
+ * Create Better Auth instance from IDP config.
91
+ *
92
+ * No database — runs in stateless mode with JWE cookie cache.
93
+ * Call after getIDPClientConfig() resolves.
94
+ */
95
+ export function createBetterAuthInstance(
96
+ idpConfig: IDPClientConfig,
97
+ opts: CreateBetterAuthInstanceOptions = {}
98
+ ) {
99
+ const appSlug = idpConfig.clientSlug || getAppSlug();
100
+
101
+ // Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
102
+ // Must include /api/auth since that's where the catch-all route is mounted
103
+ const rawBaseURL = process.env.BETTER_AUTH_URL
104
+ || idpConfig.baseClientUrl
105
+ || `http://localhost:${process.env.PORT || '3000'}`;
106
+ const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
107
+
108
+ return betterAuth({
109
+ baseURL,
110
+ secret: idpConfig.authSecret as string,
111
+
112
+ socialProviders: buildBetterAuthProviders(idpConfig),
113
+
114
+ // Trust the app's own origin + any configured base URL
115
+ trustedOrigins: [
116
+ rawBaseURL,
117
+ baseURL,
118
+ ...(idpConfig.baseClientUrl ? [idpConfig.baseClientUrl] : []),
119
+ 'http://localhost:3000',
120
+ 'http://localhost:3400',
121
+ 'http://localhost:3600',
122
+ ],
123
+
124
+ // Redis-backed session storage via secondaryStorage
125
+ secondaryStorage: {
126
+ get: async (key: string) => {
127
+ try {
128
+ return await getRedis().get(`ba:${appSlug}:${key}`);
129
+ } catch { return null; }
130
+ },
131
+ set: async (key: string, value: string, ttl?: number) => {
132
+ try {
133
+ const redis = getRedis();
134
+ if (ttl) {
135
+ await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
136
+ } else {
137
+ await redis.setex(`ba:${appSlug}:${key}`, SESSION_TTL, value);
138
+ }
139
+ } catch { /* Redis unavailable — cookie cache still works */ }
140
+ },
141
+ delete: async (key: string) => {
142
+ try {
143
+ await getRedis().del(`ba:${appSlug}:${key}`);
144
+ } catch { /* ignore */ }
145
+ },
146
+ },
147
+
148
+ session: {
149
+ // Cookie cache DISABLED — Redis is canonical for session liveness.
150
+ //
151
+ // Previously: enabled with maxAge 300 + refreshCache false. That cached
152
+ // a decoded session in process memory for 5 minutes, bypassing Redis
153
+ // reads during the window. Consequence: if the canonical app session
154
+ // at `{slug}:{token}` was evicted, refreshed, or rotated mid-window,
155
+ // Better Auth's in-memory copy stayed alive — and callers got
156
+ // contradictory answers depending on whether they consulted Better
157
+ // Auth (alive per cache) or Redis (dead/rotated). Documented contradiction
158
+ // visible in production traces: viability 200 + idp-token 200 +
159
+ // getFreshIdpToken NO_SESSION terminal within milliseconds.
160
+ //
161
+ // Trade-off: every Better Auth getSession() call now incurs one Redis
162
+ // read on the secondary-storage path. Latency cost is acceptable
163
+ // (sub-ms in-region) and the alternative is duplicate Layer-1
164
+ // workarounds in every consumer app — already shipped in idealvibe.online
165
+ // at b6a91f6.
166
+ cookieCache: {
167
+ enabled: false,
168
+ maxAge: 300,
169
+ refreshCache: false,
170
+ },
171
+ },
172
+
173
+ // Cookie prefix must match slim-middleware expectations ({slug}.session-token)
174
+ advanced: {
175
+ cookiePrefix: appSlug,
176
+ cookies: {
177
+ session_token: {
178
+ name: `${appSlug}.session-token`,
179
+ },
180
+ },
181
+ },
182
+
183
+ plugins: [
184
+ nextCookies(),
185
+ ...(opts.magicLink ? [magicLink(opts.magicLink)] : []),
186
+ ],
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Better Auth is always enabled (NextAuth removed in 4.0).
192
+ */
193
+ export function isBetterAuthEnabled(): boolean {
194
+ return true;
195
+ }
196
+
197
+ /**
198
+ * Get Better Auth Next.js route handlers (GET, POST).
199
+ * Initializes Better Auth from IDP config on first call, caches the instance.
200
+ */
201
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
+ let cachedInstance: any = null;
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ let initPromise: Promise<any> | null = null;
205
+ let configuredOpts: CreateBetterAuthInstanceOptions = {};
206
+
207
+ // Expose for server-side session access (decode-session.ts)
208
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
209
+ export { cachedInstance as __betterAuthInstance };
210
+
211
+ /**
212
+ * Configure Better Auth instance options for this process.
213
+ *
214
+ * Must be called before the first auth request — before
215
+ * `getBetterAuthInstance()` caches an instance. Typically called once at
216
+ * app startup, e.g. from Next.js `instrumentation.ts` or an equivalent
217
+ * server bootstrap hook.
218
+ *
219
+ * Throws if called after the instance has already been resolved: options
220
+ * cannot be applied retroactively.
221
+ */
222
+ export function configureBetterAuth(opts: CreateBetterAuthInstanceOptions): void {
223
+ if (cachedInstance) {
224
+ throw new Error(
225
+ '[BETTER_AUTH] configureBetterAuth() must run before the instance is first resolved. ' +
226
+ 'Call it in Next.js instrumentation.ts or an equivalent startup hook.'
227
+ );
228
+ }
229
+ configuredOpts = opts;
230
+ }
231
+
232
+ export async function getBetterAuthInstance() {
233
+ if (cachedInstance) return cachedInstance;
234
+
235
+ if (!initPromise) {
236
+ initPromise = getIDPClientConfig(true).then(config => {
237
+ const instance = createBetterAuthInstance(config, configuredOpts);
238
+ cachedInstance = instance;
239
+ console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
240
+ return instance;
241
+ });
242
+ }
243
+
244
+ return initPromise;
245
+ }
246
+
247
+ /**
248
+ * Get flag-gated auth handler for Next.js route.
249
+ *
250
+ * When USE_BETTER_AUTH=true, returns Better Auth handlers.
251
+ * Otherwise returns null (auth disabled).
252
+ *
253
+ * Usage in host app route:
254
+ * ```ts
255
+ * import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
256
+ *
257
+ * export async function GET(req: Request) {
258
+ * const ba = await getBetterAuthHandler();
259
+ * if (ba) return ba.GET(req);
260
+ * }
261
+ * ```
262
+ */
263
+ export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
264
+ if (!isBetterAuthEnabled()) return null;
265
+
266
+ const auth = await getBetterAuthInstance();
267
+ return toNextJsHandler(auth);
268
+ }
269
+
270
+ /**
271
+ * Exchange OAuth identity for IDP tokens and store in the BA Redis session.
272
+ *
273
+ * Call this from the OAuth callback route AFTER better-auth has processed the
274
+ * callback and created the session. Reads the session token from the Set-Cookie
275
+ * header of the response to find the BA Redis key.
276
+ *
277
+ * This replaces the old databaseHooks approach which doesn't fire in stateless mode.
278
+ */
279
+ export async function exchangeOAuthForIdpTokens(
280
+ sessionToken: string,
281
+ provider: string = 'google'
282
+ ): Promise<boolean> {
283
+ try {
284
+ const config = await getIDPClientConfig();
285
+ const appSlug = config.clientSlug || getAppSlug();
286
+ const baKey = `ba:${appSlug}:${sessionToken}`;
287
+
288
+ // Read the BA session from Redis
289
+ const baRaw = await getRedis().get(baKey).catch(() => null);
290
+ if (!baRaw) {
291
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: session not found in Redis for token', sessionToken.substring(0, 10));
292
+ return false;
293
+ }
294
+
295
+ const baData = JSON.parse(baRaw);
296
+ const email = baData?.user?.email;
297
+ const name = baData?.user?.name;
298
+ const image = baData?.user?.image;
299
+ const baUserId = baData?.session?.userId || baData?.user?.id;
300
+
301
+ if (!email) {
302
+ console.warn('[BETTER_AUTH] exchangeOAuthForIdpTokens: no email in session');
303
+ return false;
304
+ }
305
+
306
+ // Call IDP oauth-callback
307
+ const idpUrl = process.env.IDP_URL || '';
308
+ if (!idpUrl) {
309
+ console.warn('[BETTER_AUTH] No IDP_URL configured, skipping token exchange');
310
+ return false;
311
+ }
312
+
313
+ console.log('[BETTER_AUTH] Exchanging OAuth identity for IDP tokens:', email);
314
+
315
+ const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
316
+ method: 'POST',
317
+ headers: { 'Content-Type': 'application/json' },
318
+ body: JSON.stringify({
319
+ provider,
320
+ provider_account_id: email, // Cross-System Identity Standard v1.1: always use verified email, never opaque session IDs
321
+ email,
322
+ name,
323
+ image,
324
+ client_id: config.clientSlug || String(config.clientId),
325
+ }),
326
+ });
327
+
328
+ const oauthResText = await oauthRes.text();
329
+ console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
330
+
331
+ if (!oauthRes.ok) {
332
+ console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
333
+ return false;
334
+ }
335
+
336
+ let idpData: any;
337
+ try { idpData = JSON.parse(oauthResText); } catch { return false; }
338
+ const result = idpData?.data?.result || idpData?.data || idpData;
339
+
340
+ if (!result?.access_token) {
341
+ console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
342
+ return false;
343
+ }
344
+
345
+ // Build IDP token data
346
+ // ORIGINAL INTENT: IDP withholds refresh_token until 2FA is complete, so
347
+ // !hasRefreshToken was a reliable "2FA required" signal.
348
+ //
349
+ // BUG [IDP-2FA-BUG]: IDP is currently returning refresh_token on the initial
350
+ // oauth-callback even for require_2fa tenants that haven't done 2FA yet.
351
+ // This made !hasRefreshToken=false → requiresTwoFactor=false → mfaVerified=true,
352
+ // bypassing the 2FA gate entirely.
353
+ //
354
+ // FIX: Always set mfaVerified=false on the initial OAuth exchange.
355
+ // The tenant's require_2fa setting is enforced by the viability endpoint
356
+ // (tenantRequiresTwoFactor=true), which redirects to verify-code whenever
357
+ // mfaVerified=false. After real 2FA, transitionTo2FASession() sets it true.
358
+ // Revert this comment + restore `mfaVerified: !requiresTwoFactor` once the
359
+ // IDP correctly withholds refresh_token until post-2FA.
360
+ const hasRefreshToken = !!result.refresh_token;
361
+ const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? !hasRefreshToken;
362
+ const idpTokenData = {
363
+ idpAccessToken: result.access_token,
364
+ idpRefreshToken: result.refresh_token,
365
+ idpAccessTokenExpires: result.expires_in
366
+ ? Date.now() + result.expires_in * 1000
367
+ : Date.now() + 15 * 60 * 1000,
368
+ userId: String(result.user?.user_id || result.user?.id || result.user_id || baUserId),
369
+ email: result.user?.email || result.email || email,
370
+ name: result.user?.full_name || result.user?.name || result.name || name,
371
+ image: image,
372
+ roles: result.user?.roles || result.roles || [],
373
+ mfaVerified: false, // Always false post-OAuth — see IDP-2FA-BUG note above
374
+ idpClientId: result.client_id ? String(result.client_id) : undefined,
375
+ merchantId: result.merchant_id ? String(result.merchant_id) : undefined,
376
+ };
377
+
378
+ // Store in BA Redis session (for decodeSession)
379
+ baData.idpTokens = idpTokenData;
380
+ await getRedis().setex(baKey, SESSION_TTL, JSON.stringify(baData));
381
+
382
+ // Write to canonical session store so refresh handler and token lifecycle can find the tokens.
383
+ // Key format: {sessionPrefix}{token} — same key that getSession() reads from.
384
+ try {
385
+ const { getSessionPrefix } = await import('../lib/app-slug');
386
+ const canonicalKey = `${getSessionPrefix()}${sessionToken}`;
387
+ await getRedis().setex(canonicalKey, SESSION_TTL, JSON.stringify({
388
+ ...idpTokenData,
389
+ oauthProvider: provider,
390
+ }));
391
+ } catch (canonicalErr) {
392
+ console.warn('[BETTER_AUTH] Failed to write canonical session:', canonicalErr instanceof Error ? canonicalErr.message : String(canonicalErr));
393
+ }
394
+
395
+ console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
396
+ return true;
397
+ } catch (err) {
398
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err instanceof Error ? err.message : String(err));
399
+ return false;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Create a production-ready GET handler for the auth catch-all route.
405
+ *
406
+ * Wraps better-auth's GET handler with:
407
+ * - OAuth state error recovery (redirects to login instead of error page)
408
+ * - IDP token exchange after successful OAuth callback
409
+ *
410
+ * Usage in host app:
411
+ * ```ts
412
+ * import { createAuthGetHandler, getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
413
+ * export const GET = createAuthGetHandler('/account-auth/login');
414
+ * export async function POST(req: Request) {
415
+ * const ba = await getBetterAuthHandler();
416
+ * return ba!.POST(req);
417
+ * }
418
+ * ```
419
+ */
420
+ export function createAuthGetHandler(loginPath: string = '/account-auth/login') {
421
+ return async function GET(request: Request): Promise<Response> {
422
+ const ba = await getBetterAuthHandler();
423
+ if (!ba) {
424
+ return new Response('Auth handler not configured', { status: 500 });
425
+ }
426
+
427
+ const response = await ba.GET(request);
428
+
429
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
430
+ if (response.status === 302) {
431
+ const location = response.headers.get('location') || '';
432
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
433
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
434
+ return Response.redirect(new URL(loginPath, request.url), 302);
435
+ }
436
+ }
437
+
438
+ // After successful OAuth callback: exchange Google identity for IDP tokens
439
+ const url = new URL(request.url);
440
+ if (url.pathname.includes('/callback/') && response.status === 302) {
441
+ try {
442
+ const auth = await getBetterAuthInstance();
443
+ if (auth?.api?.getSession) {
444
+ const setCookies = response.headers.getSetCookie?.() || [];
445
+ const cookieHeader = setCookies
446
+ .map((c: string) => c.split(';')[0])
447
+ .join('; ');
448
+
449
+ const headers = new Headers();
450
+ headers.set('cookie', cookieHeader);
451
+
452
+ const session = await auth.api.getSession({ headers });
453
+ if (session?.session?.token) {
454
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
455
+ await exchangeOAuthForIdpTokens(session.session.token);
456
+ } else {
457
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
458
+ }
459
+ }
460
+ } catch (err: any) {
461
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
462
+ }
463
+ }
464
+
465
+ return response;
466
+ };
467
+ }
@@ -206,8 +206,16 @@ 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;
209
217
  const body: Record<string, unknown> = {
210
- refresh_token: session.idpRefreshToken,
218
+ refresh_token: sentRefreshToken,
211
219
  amr: authMethods,
212
220
  acr: acrValue,
213
221
  };
@@ -255,8 +263,11 @@ export async function ensureFreshAccessToken(
255
263
  // (the rotation-race wipe, QA 6873 site 1/2). Compare-and-skip if it changed;
256
264
  // a concurrent rotation is NOT a terminal condition.
257
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.
258
269
  const tokenUnchanged =
259
- !!session.idpRefreshToken && postRefresh?.idpRefreshToken === session.idpRefreshToken;
270
+ !!sentRefreshToken && postRefresh?.idpRefreshToken === sentRefreshToken;
260
271
  if (tokenUnchanged) {
261
272
  await updateSession(sessionToken, {
262
273
  idpRefreshToken: '',
@@ -46,6 +46,13 @@ export interface TokenError {
46
46
  error: 'NO_SESSION' | 'NO_TOKEN' | 'EXPIRED' | 'REFRESH_FAILED' | 'SESSION_EXPIRED_NO_REFRESH';
47
47
  message: string;
48
48
  terminal?: boolean; // If true, don't retry - redirect to login
49
+ // WO-1 (genuine-dead refresh): the VERBATIM upstream reason from the IDP, preserved
50
+ // end-to-end so the consumer can surface an honest error + halt a futile re-auth loop
51
+ // (e.g. an unapproved user whose login mints a bearer but whose refresh terminally
52
+ // fails). `error` stays the stable category for existing consumers; `code`/`resolution`
53
+ // carry the real cause. Never soften — surface it.
54
+ code?: string; // Upstream IDP error code (e.g. UNAUTHORIZED, USER_NOT_APPROVED)
55
+ resolution?: string; // Upstream human-readable resolution hint, if any
49
56
  }
50
57
 
51
58
  export type EnsureFreshTokenResult = TokenResult | TokenError;
@@ -123,6 +130,8 @@ interface RefreshResult {
123
130
  success: boolean;
124
131
  terminal?: boolean; // If true, session is dead - don't retry, redirect to login
125
132
  code?: string; // Error code from refresh endpoint
133
+ message?: string; // WO-1: verbatim human message from the refresh endpoint
134
+ resolution?: string; // WO-1: verbatim resolution hint from the refresh endpoint
126
135
  }
127
136
 
128
137
  /**
@@ -201,7 +210,16 @@ async function triggerRefresh(
201
210
  // Don't retry - user must re-authenticate
202
211
  if (responseData.code === 'NO_REFRESH_TOKEN' || responseData.terminal === true) {
203
212
  console.error('[TOKEN_LIFECYCLE] TERMINAL: Session has no refresh token - user must re-login');
204
- return { success: false, terminal: true, code: responseData.code };
213
+ // WO-1: preserve the verbatim upstream reason. refresh.ts puts the human
214
+ // message in `error` and the code in `code`; carry both up so the consumer
215
+ // can tell the user WHY (not just "session expired") and stop a futile loop.
216
+ return {
217
+ success: false,
218
+ terminal: true,
219
+ code: responseData.code,
220
+ message: responseData.error,
221
+ resolution: responseData.resolution,
222
+ };
205
223
  }
206
224
 
207
225
  // For other 401s, check if maybe session was refreshed by another request
@@ -388,12 +406,21 @@ export async function ensureFreshToken(
388
406
  if (!refreshResult.success) {
389
407
  // Check for terminal state - session cannot be recovered
390
408
  if (refreshResult.terminal) {
391
- console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired with no refresh token - redirect to login');
409
+ console.error('[TOKEN_LIFECYCLE] TERMINAL: Session expired - redirect to login', {
410
+ code: refreshResult.code,
411
+ message: refreshResult.message,
412
+ });
413
+ // WO-1: keep `error` as the stable terminal category (existing consumers
414
+ // branch on SESSION_EXPIRED_NO_REFRESH) but surface the verbatim upstream
415
+ // reason via code/message/resolution so the user learns WHY and a genuinely
416
+ // dead session (e.g. unapproved account) doesn't loop login→bounce→login.
392
417
  return {
393
418
  success: false,
394
419
  error: 'SESSION_EXPIRED_NO_REFRESH',
395
- message: 'Session expired. Please sign in again.',
420
+ message: refreshResult.message || 'Session expired. Please sign in again.',
396
421
  terminal: true,
422
+ code: refreshResult.code,
423
+ resolution: refreshResult.resolution,
397
424
  };
398
425
  }
399
426