@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.
- package/dist/auth/better-auth.js +15 -3
- package/dist/lib/ensure-fresh-access-token.js +13 -2
- package/dist/lib/token-lifecycle.d.ts +2 -0
- package/dist/lib/token-lifecycle.js +21 -3
- package/package.json +1 -1
- package/src/auth/better-auth.ts +467 -455
- package/src/lib/ensure-fresh-access-token.ts +13 -2
- package/src/lib/token-lifecycle.ts +30 -3
package/dist/auth/better-auth.js
CHANGED
|
@@ -327,8 +327,20 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
327
327
|
return false;
|
|
328
328
|
}
|
|
329
329
|
// Build IDP token data
|
|
330
|
-
//
|
|
331
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
package/src/auth/better-auth.ts
CHANGED
|
@@ -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
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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:
|
|
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
|
-
!!
|
|
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
|
-
|
|
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
|
|
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
|
|