@payez/next-mvp 4.0.21 → 4.0.23
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 +1 -1
- package/dist/client/AuthContext.d.ts +1 -2
- package/dist/client/AuthContext.js +2 -2
- package/dist/client/better-auth-client.d.ts +8 -0
- package/dist/client/better-auth-client.js +15 -0
- package/dist/components/account/MobileNavDrawer.js +1 -1
- package/dist/hooks/useAvailableProviders.d.ts +4 -5
- package/dist/hooks/useAvailableProviders.js +7 -8
- package/dist/hooks/usePublicAuthSettings.d.ts +4 -4
- package/dist/hooks/usePublicAuthSettings.js +6 -6
- package/dist/lib/idp-client-config.d.ts +4 -0
- package/dist/lib/idp-client-config.js +14 -0
- package/dist/lib/startup-init.js +21 -19
- package/dist/routes/auth/settings.d.ts +1 -1
- package/dist/routes/auth/settings.js +2 -2
- package/dist/server/auth.js +1 -1
- package/package.json +1 -1
- package/src/auth/better-auth.ts +271 -271
- package/src/client/AuthContext.tsx +3 -4
- package/src/client/better-auth-client.ts +14 -0
- package/src/components/account/MobileNavDrawer.tsx +2 -2
- package/src/hooks/useAvailableProviders.ts +5 -7
- package/src/hooks/usePublicAuthSettings.ts +6 -6
- package/src/lib/idp-client-config.ts +539 -526
- package/src/lib/startup-init.ts +246 -243
- package/src/routes/auth/settings.ts +3 -3
- package/src/server/auth.ts +81 -81
package/src/auth/better-auth.ts
CHANGED
|
@@ -1,271 +1,271 @@
|
|
|
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 type { IDPClientConfig } from '../lib/idp-client-config';
|
|
17
|
-
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
18
|
-
import { getAppSlug } from '../lib/app-slug';
|
|
19
|
-
import { getRedis } from '../lib/redis';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Better Auth social provider config shape.
|
|
23
|
-
*/
|
|
24
|
-
export interface BetterAuthSocialProvider {
|
|
25
|
-
clientId: string;
|
|
26
|
-
clientSecret: string;
|
|
27
|
-
scope?: string[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Build Better Auth social providers from IDP config.
|
|
32
|
-
*/
|
|
33
|
-
export function buildBetterAuthProviders(
|
|
34
|
-
config: IDPClientConfig
|
|
35
|
-
): Record<string, BetterAuthSocialProvider> {
|
|
36
|
-
const providers: Record<string, BetterAuthSocialProvider> = {};
|
|
37
|
-
|
|
38
|
-
for (const oauth of config.oauthProviders || []) {
|
|
39
|
-
if (!oauth.enabled) continue;
|
|
40
|
-
const name = oauth.provider.toLowerCase();
|
|
41
|
-
providers[name] = {
|
|
42
|
-
clientId: oauth.clientId,
|
|
43
|
-
clientSecret: oauth.clientSecret,
|
|
44
|
-
scope: oauth.scopes?.split(' '),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return providers;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Create Better Auth instance from IDP config.
|
|
53
|
-
*
|
|
54
|
-
* No database — runs in stateless mode with JWE cookie cache.
|
|
55
|
-
* Call after getIDPClientConfig() resolves.
|
|
56
|
-
*/
|
|
57
|
-
export function createBetterAuthInstance(idpConfig: IDPClientConfig) {
|
|
58
|
-
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
59
|
-
|
|
60
|
-
// Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
|
|
61
|
-
const baseURL = process.env.BETTER_AUTH_URL
|
|
62
|
-
|| idpConfig.baseClientUrl
|
|
63
|
-
|| `http://localhost:${process.env.PORT || '3000'}`;
|
|
64
|
-
|
|
65
|
-
return betterAuth({
|
|
66
|
-
baseURL,
|
|
67
|
-
secret: idpConfig.nextAuthSecret as string,
|
|
68
|
-
|
|
69
|
-
socialProviders: buildBetterAuthProviders(idpConfig),
|
|
70
|
-
|
|
71
|
-
// Trust the app's own origin + any configured base URL
|
|
72
|
-
trustedOrigins: [
|
|
73
|
-
baseURL,
|
|
74
|
-
...(idpConfig.baseClientUrl && idpConfig.baseClientUrl !== baseURL ? [idpConfig.baseClientUrl] : []),
|
|
75
|
-
'http://localhost:3000',
|
|
76
|
-
'http://localhost:3400',
|
|
77
|
-
'http://localhost:3600',
|
|
78
|
-
],
|
|
79
|
-
|
|
80
|
-
// Redis-backed session storage via secondaryStorage
|
|
81
|
-
secondaryStorage: {
|
|
82
|
-
get: async (key: string) => {
|
|
83
|
-
try {
|
|
84
|
-
return await getRedis().get(`ba:${appSlug}:${key}`);
|
|
85
|
-
} catch { return null; }
|
|
86
|
-
},
|
|
87
|
-
set: async (key: string, value: string, ttl?: number) => {
|
|
88
|
-
try {
|
|
89
|
-
const redis = getRedis();
|
|
90
|
-
if (ttl) {
|
|
91
|
-
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
92
|
-
} else {
|
|
93
|
-
await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
|
|
94
|
-
}
|
|
95
|
-
} catch { /* Redis unavailable — cookie cache still works */ }
|
|
96
|
-
},
|
|
97
|
-
delete: async (key: string) => {
|
|
98
|
-
try {
|
|
99
|
-
await getRedis().del(`ba:${appSlug}:${key}`);
|
|
100
|
-
} catch { /* ignore */ }
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
session: {
|
|
105
|
-
cookieCache: {
|
|
106
|
-
enabled: true,
|
|
107
|
-
maxAge: 300,
|
|
108
|
-
refreshCache: false,
|
|
109
|
-
},
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
// After social login, exchange Google identity for IDP tokens and store in Redis
|
|
113
|
-
databaseHooks: {
|
|
114
|
-
session: {
|
|
115
|
-
create: {
|
|
116
|
-
after: async (session: any) => {
|
|
117
|
-
try {
|
|
118
|
-
const userId = session.userId;
|
|
119
|
-
const token = session.token;
|
|
120
|
-
if (!userId || !token) return;
|
|
121
|
-
|
|
122
|
-
// Look up user from Better Auth's memory/DB to get email
|
|
123
|
-
// The user was just created/found by Better Auth during OAuth
|
|
124
|
-
const baKey = `ba:${appSlug}:${token}`;
|
|
125
|
-
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
126
|
-
const baData = baRaw ? JSON.parse(baRaw) : null;
|
|
127
|
-
const email = baData?.user?.email;
|
|
128
|
-
const name = baData?.user?.name;
|
|
129
|
-
const image = baData?.user?.image;
|
|
130
|
-
|
|
131
|
-
if (!email) {
|
|
132
|
-
console.warn('[BETTER_AUTH] Session created but no email found for IDP token exchange');
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Call IDP oauth-callback to get IDP tokens
|
|
137
|
-
const idpUrl = process.env.IDP_URL || '';
|
|
138
|
-
if (!idpUrl) {
|
|
139
|
-
console.warn('[BETTER_AUTH] No IDP URL configured, skipping token exchange');
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
|
|
144
|
-
method: 'POST',
|
|
145
|
-
headers: { 'Content-Type': 'application/json' },
|
|
146
|
-
body: JSON.stringify({
|
|
147
|
-
provider: 'google',
|
|
148
|
-
provider_account_id: userId,
|
|
149
|
-
email,
|
|
150
|
-
name,
|
|
151
|
-
image,
|
|
152
|
-
client_id: idpConfig.clientSlug || String(idpConfig.clientId),
|
|
153
|
-
}),
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
const oauthResText = await oauthRes.text();
|
|
157
|
-
console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
|
|
158
|
-
|
|
159
|
-
if (!oauthRes.ok) {
|
|
160
|
-
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
let idpData: any;
|
|
165
|
-
try { idpData = JSON.parse(oauthResText); } catch { return; }
|
|
166
|
-
const result = idpData?.data?.result || idpData?.data || idpData;
|
|
167
|
-
|
|
168
|
-
if (!result?.access_token) {
|
|
169
|
-
console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Store IDP tokens in the BA Redis session
|
|
174
|
-
if (baData) {
|
|
175
|
-
const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? false;
|
|
176
|
-
baData.idpTokens = {
|
|
177
|
-
idpAccessToken: result.access_token,
|
|
178
|
-
idpRefreshToken: result.refresh_token,
|
|
179
|
-
idpAccessTokenExpires: result.expires_in
|
|
180
|
-
? Date.now() + result.expires_in * 1000
|
|
181
|
-
: Date.now() + 15 * 60 * 1000,
|
|
182
|
-
userId: String(result.user?.user_id || result.user?.id || result.user_id || userId),
|
|
183
|
-
email: result.user?.email || result.email || email,
|
|
184
|
-
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
185
|
-
roles: result.user?.roles || result.roles || [],
|
|
186
|
-
mfaVerified: !requiresTwoFactor,
|
|
187
|
-
};
|
|
188
|
-
await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
|
|
189
|
-
console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
190
|
-
}
|
|
191
|
-
} catch (err) {
|
|
192
|
-
console.error('[BETTER_AUTH] Post-login IDP exchange failed:', err instanceof Error ? err.message : String(err));
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
|
|
199
|
-
// Cookie prefix must match slim-middleware expectations ({slug}.session-token)
|
|
200
|
-
advanced: {
|
|
201
|
-
cookiePrefix: appSlug,
|
|
202
|
-
cookies: {
|
|
203
|
-
session_token: {
|
|
204
|
-
name: `${appSlug}.session-token`,
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
},
|
|
208
|
-
|
|
209
|
-
plugins: [
|
|
210
|
-
nextCookies(),
|
|
211
|
-
],
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Better Auth is always enabled (NextAuth removed in 4.0).
|
|
217
|
-
*/
|
|
218
|
-
export function isBetterAuthEnabled(): boolean {
|
|
219
|
-
return true;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Get Better Auth Next.js route handlers (GET, POST).
|
|
224
|
-
* Initializes Better Auth from IDP config on first call, caches the instance.
|
|
225
|
-
*/
|
|
226
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
227
|
-
let cachedInstance: any = null;
|
|
228
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
229
|
-
let initPromise: Promise<any> | null = null;
|
|
230
|
-
|
|
231
|
-
// Expose for server-side session access (decode-session.ts)
|
|
232
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
233
|
-
export { cachedInstance as __betterAuthInstance };
|
|
234
|
-
|
|
235
|
-
export async function getBetterAuthInstance() {
|
|
236
|
-
if (cachedInstance) return cachedInstance;
|
|
237
|
-
|
|
238
|
-
if (!initPromise) {
|
|
239
|
-
initPromise = getIDPClientConfig().then(config => {
|
|
240
|
-
const instance = createBetterAuthInstance(config);
|
|
241
|
-
cachedInstance = instance;
|
|
242
|
-
console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
|
|
243
|
-
return instance;
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return initPromise;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Get flag-gated auth handler for Next.js route.
|
|
252
|
-
*
|
|
253
|
-
* When USE_BETTER_AUTH=true, returns Better Auth handlers.
|
|
254
|
-
* Otherwise returns null (auth disabled).
|
|
255
|
-
*
|
|
256
|
-
* Usage in host app route:
|
|
257
|
-
* ```ts
|
|
258
|
-
* import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
|
|
259
|
-
*
|
|
260
|
-
* export async function GET(req: Request) {
|
|
261
|
-
* const ba = await getBetterAuthHandler();
|
|
262
|
-
* if (ba) return ba.GET(req);
|
|
263
|
-
* }
|
|
264
|
-
* ```
|
|
265
|
-
*/
|
|
266
|
-
export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
|
|
267
|
-
if (!isBetterAuthEnabled()) return null;
|
|
268
|
-
|
|
269
|
-
const auth = await getBetterAuthInstance();
|
|
270
|
-
return toNextJsHandler(auth);
|
|
271
|
-
}
|
|
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 type { IDPClientConfig } from '../lib/idp-client-config';
|
|
17
|
+
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
18
|
+
import { getAppSlug } from '../lib/app-slug';
|
|
19
|
+
import { getRedis } from '../lib/redis';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Better Auth social provider config shape.
|
|
23
|
+
*/
|
|
24
|
+
export interface BetterAuthSocialProvider {
|
|
25
|
+
clientId: string;
|
|
26
|
+
clientSecret: string;
|
|
27
|
+
scope?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build Better Auth social providers from IDP config.
|
|
32
|
+
*/
|
|
33
|
+
export function buildBetterAuthProviders(
|
|
34
|
+
config: IDPClientConfig
|
|
35
|
+
): Record<string, BetterAuthSocialProvider> {
|
|
36
|
+
const providers: Record<string, BetterAuthSocialProvider> = {};
|
|
37
|
+
|
|
38
|
+
for (const oauth of config.oauthProviders || []) {
|
|
39
|
+
if (!oauth.enabled) continue;
|
|
40
|
+
const name = oauth.provider.toLowerCase();
|
|
41
|
+
providers[name] = {
|
|
42
|
+
clientId: oauth.clientId,
|
|
43
|
+
clientSecret: oauth.clientSecret,
|
|
44
|
+
scope: oauth.scopes?.split(' '),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return providers;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create Better Auth instance from IDP config.
|
|
53
|
+
*
|
|
54
|
+
* No database — runs in stateless mode with JWE cookie cache.
|
|
55
|
+
* Call after getIDPClientConfig() resolves.
|
|
56
|
+
*/
|
|
57
|
+
export function createBetterAuthInstance(idpConfig: IDPClientConfig) {
|
|
58
|
+
const appSlug = idpConfig.clientSlug || getAppSlug();
|
|
59
|
+
|
|
60
|
+
// Resolve base URL: BETTER_AUTH_URL env > IDP config > localhost fallback
|
|
61
|
+
const baseURL = process.env.BETTER_AUTH_URL
|
|
62
|
+
|| idpConfig.baseClientUrl
|
|
63
|
+
|| `http://localhost:${process.env.PORT || '3000'}`;
|
|
64
|
+
|
|
65
|
+
return betterAuth({
|
|
66
|
+
baseURL,
|
|
67
|
+
secret: idpConfig.nextAuthSecret as string,
|
|
68
|
+
|
|
69
|
+
socialProviders: buildBetterAuthProviders(idpConfig),
|
|
70
|
+
|
|
71
|
+
// Trust the app's own origin + any configured base URL
|
|
72
|
+
trustedOrigins: [
|
|
73
|
+
baseURL,
|
|
74
|
+
...(idpConfig.baseClientUrl && idpConfig.baseClientUrl !== baseURL ? [idpConfig.baseClientUrl] : []),
|
|
75
|
+
'http://localhost:3000',
|
|
76
|
+
'http://localhost:3400',
|
|
77
|
+
'http://localhost:3600',
|
|
78
|
+
],
|
|
79
|
+
|
|
80
|
+
// Redis-backed session storage via secondaryStorage
|
|
81
|
+
secondaryStorage: {
|
|
82
|
+
get: async (key: string) => {
|
|
83
|
+
try {
|
|
84
|
+
return await getRedis().get(`ba:${appSlug}:${key}`);
|
|
85
|
+
} catch { return null; }
|
|
86
|
+
},
|
|
87
|
+
set: async (key: string, value: string, ttl?: number) => {
|
|
88
|
+
try {
|
|
89
|
+
const redis = getRedis();
|
|
90
|
+
if (ttl) {
|
|
91
|
+
await redis.setex(`ba:${appSlug}:${key}`, ttl, value);
|
|
92
|
+
} else {
|
|
93
|
+
await redis.setex(`ba:${appSlug}:${key}`, 7 * 24 * 60 * 60, value);
|
|
94
|
+
}
|
|
95
|
+
} catch { /* Redis unavailable — cookie cache still works */ }
|
|
96
|
+
},
|
|
97
|
+
delete: async (key: string) => {
|
|
98
|
+
try {
|
|
99
|
+
await getRedis().del(`ba:${appSlug}:${key}`);
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
session: {
|
|
105
|
+
cookieCache: {
|
|
106
|
+
enabled: true,
|
|
107
|
+
maxAge: 300,
|
|
108
|
+
refreshCache: false,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// After social login, exchange Google identity for IDP tokens and store in Redis
|
|
113
|
+
databaseHooks: {
|
|
114
|
+
session: {
|
|
115
|
+
create: {
|
|
116
|
+
after: async (session: any) => {
|
|
117
|
+
try {
|
|
118
|
+
const userId = session.userId;
|
|
119
|
+
const token = session.token;
|
|
120
|
+
if (!userId || !token) return;
|
|
121
|
+
|
|
122
|
+
// Look up user from Better Auth's memory/DB to get email
|
|
123
|
+
// The user was just created/found by Better Auth during OAuth
|
|
124
|
+
const baKey = `ba:${appSlug}:${token}`;
|
|
125
|
+
const baRaw = await getRedis().get(baKey).catch(() => null);
|
|
126
|
+
const baData = baRaw ? JSON.parse(baRaw) : null;
|
|
127
|
+
const email = baData?.user?.email;
|
|
128
|
+
const name = baData?.user?.name;
|
|
129
|
+
const image = baData?.user?.image;
|
|
130
|
+
|
|
131
|
+
if (!email) {
|
|
132
|
+
console.warn('[BETTER_AUTH] Session created but no email found for IDP token exchange');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Call IDP oauth-callback to get IDP tokens
|
|
137
|
+
const idpUrl = process.env.IDP_URL || '';
|
|
138
|
+
if (!idpUrl) {
|
|
139
|
+
console.warn('[BETTER_AUTH] No IDP URL configured, skipping token exchange');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const oauthRes = await fetch(`${idpUrl}/api/ExternalAuth/oauth-callback`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
provider: 'google',
|
|
148
|
+
provider_account_id: userId,
|
|
149
|
+
email,
|
|
150
|
+
name,
|
|
151
|
+
image,
|
|
152
|
+
client_id: idpConfig.clientSlug || String(idpConfig.clientId),
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const oauthResText = await oauthRes.text();
|
|
157
|
+
console.log('[BETTER_AUTH] IDP oauth-callback response:', oauthRes.status, oauthResText.substring(0, 500));
|
|
158
|
+
|
|
159
|
+
if (!oauthRes.ok) {
|
|
160
|
+
console.error('[BETTER_AUTH] IDP oauth-callback failed:', oauthRes.status);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let idpData: any;
|
|
165
|
+
try { idpData = JSON.parse(oauthResText); } catch { return; }
|
|
166
|
+
const result = idpData?.data?.result || idpData?.data || idpData;
|
|
167
|
+
|
|
168
|
+
if (!result?.access_token) {
|
|
169
|
+
console.warn('[BETTER_AUTH] IDP oauth-callback returned no access_token. Keys:', Object.keys(result || {}));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Store IDP tokens in the BA Redis session
|
|
174
|
+
if (baData) {
|
|
175
|
+
const requiresTwoFactor = result.user?.requiresTwoFactor ?? result.requiresTwoFactor ?? false;
|
|
176
|
+
baData.idpTokens = {
|
|
177
|
+
idpAccessToken: result.access_token,
|
|
178
|
+
idpRefreshToken: result.refresh_token,
|
|
179
|
+
idpAccessTokenExpires: result.expires_in
|
|
180
|
+
? Date.now() + result.expires_in * 1000
|
|
181
|
+
: Date.now() + 15 * 60 * 1000,
|
|
182
|
+
userId: String(result.user?.user_id || result.user?.id || result.user_id || userId),
|
|
183
|
+
email: result.user?.email || result.email || email,
|
|
184
|
+
name: result.user?.full_name || result.user?.name || result.name || name,
|
|
185
|
+
roles: result.user?.roles || result.roles || [],
|
|
186
|
+
mfaVerified: !requiresTwoFactor,
|
|
187
|
+
};
|
|
188
|
+
await getRedis().setex(baKey, 7 * 24 * 60 * 60, JSON.stringify(baData));
|
|
189
|
+
console.log('[BETTER_AUTH] IDP tokens stored in session for', email);
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('[BETTER_AUTH] Post-login IDP exchange failed:', err instanceof Error ? err.message : String(err));
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Cookie prefix must match slim-middleware expectations ({slug}.session-token)
|
|
200
|
+
advanced: {
|
|
201
|
+
cookiePrefix: appSlug,
|
|
202
|
+
cookies: {
|
|
203
|
+
session_token: {
|
|
204
|
+
name: `${appSlug}.session-token`,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
plugins: [
|
|
210
|
+
nextCookies(),
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Better Auth is always enabled (NextAuth removed in 4.0).
|
|
217
|
+
*/
|
|
218
|
+
export function isBetterAuthEnabled(): boolean {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get Better Auth Next.js route handlers (GET, POST).
|
|
224
|
+
* Initializes Better Auth from IDP config on first call, caches the instance.
|
|
225
|
+
*/
|
|
226
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
227
|
+
let cachedInstance: any = null;
|
|
228
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
229
|
+
let initPromise: Promise<any> | null = null;
|
|
230
|
+
|
|
231
|
+
// Expose for server-side session access (decode-session.ts)
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
233
|
+
export { cachedInstance as __betterAuthInstance };
|
|
234
|
+
|
|
235
|
+
export async function getBetterAuthInstance() {
|
|
236
|
+
if (cachedInstance) return cachedInstance;
|
|
237
|
+
|
|
238
|
+
if (!initPromise) {
|
|
239
|
+
initPromise = getIDPClientConfig(true).then(config => {
|
|
240
|
+
const instance = createBetterAuthInstance(config);
|
|
241
|
+
cachedInstance = instance;
|
|
242
|
+
console.log('[BETTER_AUTH] Instance created for', config.clientSlug || config.clientId);
|
|
243
|
+
return instance;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return initPromise;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get flag-gated auth handler for Next.js route.
|
|
252
|
+
*
|
|
253
|
+
* When USE_BETTER_AUTH=true, returns Better Auth handlers.
|
|
254
|
+
* Otherwise returns null (auth disabled).
|
|
255
|
+
*
|
|
256
|
+
* Usage in host app route:
|
|
257
|
+
* ```ts
|
|
258
|
+
* import { getBetterAuthHandler } from '@payez/next-mvp/auth/better-auth';
|
|
259
|
+
*
|
|
260
|
+
* export async function GET(req: Request) {
|
|
261
|
+
* const ba = await getBetterAuthHandler();
|
|
262
|
+
* if (ba) return ba.GET(req);
|
|
263
|
+
* }
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
export async function getBetterAuthHandler(): Promise<{ GET: (req: Request) => Promise<Response>; POST: (req: Request) => Promise<Response> } | null> {
|
|
267
|
+
if (!isBetterAuthEnabled()) return null;
|
|
268
|
+
|
|
269
|
+
const auth = await getBetterAuthInstance();
|
|
270
|
+
return toNextJsHandler(auth);
|
|
271
|
+
}
|
|
@@ -15,7 +15,7 @@ const defaultConfig: AuthConfig = {
|
|
|
15
15
|
allowPasswordReset: true,
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
// Map
|
|
18
|
+
// Map provider IDs to our FederatedProvider type
|
|
19
19
|
const PROVIDER_MAP: Record<string, FederatedProvider> = {
|
|
20
20
|
'google': 'google',
|
|
21
21
|
'apple': 'apple',
|
|
@@ -32,9 +32,8 @@ interface AuthProviderProps {
|
|
|
32
32
|
children: ReactNode;
|
|
33
33
|
config?: Partial<AuthConfig>;
|
|
34
34
|
/**
|
|
35
|
-
* If true, providers will be
|
|
35
|
+
* If true, providers will be loaded dynamically from IDP config
|
|
36
36
|
* instead of using the static providers array from config.
|
|
37
|
-
* Defaults to true for dynamic provider loading from IDP.
|
|
38
37
|
*/
|
|
39
38
|
useDynamicProviders?: boolean;
|
|
40
39
|
}
|
|
@@ -54,7 +53,7 @@ export function AuthProvider({ children, config, useDynamicProviders = true }: A
|
|
|
54
53
|
},
|
|
55
54
|
}));
|
|
56
55
|
|
|
57
|
-
//
|
|
56
|
+
// Load available providers on mount
|
|
58
57
|
useEffect(() => {
|
|
59
58
|
if (!useDynamicProviders) return;
|
|
60
59
|
|
|
@@ -18,6 +18,20 @@ export const authClient = createAuthClient({
|
|
|
18
18
|
// Convenience exports
|
|
19
19
|
export const { useSession, signIn, signOut } = authClient;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Sign in with a specific OAuth provider via direct redirect.
|
|
23
|
+
*
|
|
24
|
+
* better-auth exposes per-provider endpoints at /api/auth/sign-in/{provider}
|
|
25
|
+
* (e.g. /api/auth/sign-in/google). The generic signIn.social() method POSTs
|
|
26
|
+
* to /api/auth/sign-in/social which doesn't exist — use this instead.
|
|
27
|
+
*/
|
|
28
|
+
export function signInWithProvider(provider: string, callbackURL?: string) {
|
|
29
|
+
const params = new URLSearchParams();
|
|
30
|
+
if (callbackURL) params.set('callbackURL', callbackURL);
|
|
31
|
+
const qs = params.toString();
|
|
32
|
+
window.location.href = `/api/auth/sign-in/${provider}${qs ? '?' + qs : ''}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
/**
|
|
22
36
|
* NextAuth-compatible useSession wrapper.
|
|
23
37
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useCallback } from 'react';
|
|
4
|
-
import { authClient } from '../../client/better-auth-client';
|
|
4
|
+
import { authClient, signInWithProvider } from '../../client/better-auth-client';
|
|
5
5
|
import { usePathname } from 'next/navigation';
|
|
6
6
|
import Image from 'next/image';
|
|
7
7
|
import Link from 'next/link';
|
|
@@ -90,7 +90,7 @@ export function MobileNavDrawer({
|
|
|
90
90
|
if (onSignIn) {
|
|
91
91
|
onSignIn();
|
|
92
92
|
} else {
|
|
93
|
-
|
|
93
|
+
signInWithProvider('google', signInCallbackUrl);
|
|
94
94
|
}
|
|
95
95
|
};
|
|
96
96
|
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useAvailableProviders Hook
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Returns the list of OAuth providers configured for this client.
|
|
5
|
+
* Ensures UI only shows buttons for providers that are enabled in IDP.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
'use client';
|
|
9
9
|
|
|
10
10
|
import { useState, useEffect } from 'react';
|
|
11
|
-
import { authClient } from '../client/better-auth-client';
|
|
12
11
|
import type { FederatedProvider } from '../types/auth';
|
|
13
12
|
|
|
14
|
-
// Map
|
|
13
|
+
// Map provider IDs to our FederatedProvider type
|
|
15
14
|
const PROVIDER_MAP: Record<string, FederatedProvider> = {
|
|
16
15
|
'google': 'google',
|
|
17
16
|
'apple': 'apple',
|
|
@@ -32,10 +31,9 @@ export interface UseAvailableProvidersResult {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
/**
|
|
35
|
-
* Hook to get available OAuth providers
|
|
34
|
+
* Hook to get available federated OAuth providers.
|
|
36
35
|
*
|
|
37
|
-
* Returns only the providers that are
|
|
38
|
-
* which reflects what's enabled in IDP config.
|
|
36
|
+
* Returns only the providers that are enabled in IDP config.
|
|
39
37
|
*
|
|
40
38
|
* @example
|
|
41
39
|
* ```tsx
|