@payez/next-mvp 4.1.1 → 4.1.3
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.d.ts +3 -0
- package/dist/auth/better-auth.js +22 -1
- package/dist/lib/ensure-fresh-access-token.d.ts +30 -0
- package/dist/lib/ensure-fresh-access-token.js +269 -0
- package/dist/lib/session-store.js +24 -21
- package/dist/lib/token-lifecycle.js +2 -0
- package/dist/models/SessionModel.d.ts +3 -0
- package/dist/models/SessionModel.js +3 -0
- package/dist/routes/auth/session.js +1 -1
- package/dist/routes/auth/viability.js +15 -2
- package/dist/server/auth.d.ts +59 -0
- package/dist/server/auth.js +156 -16
- package/dist/server/decode-session.js +2 -0
- package/package.json +6 -1
- package/src/auth/better-auth.ts +434 -408
- package/src/lib/ensure-fresh-access-token.ts +320 -0
- package/src/lib/session-store.ts +692 -689
- package/src/lib/token-lifecycle.ts +470 -468
- package/src/models/SessionModel.ts +264 -258
- package/src/routes/auth/session.ts +166 -166
- package/src/routes/auth/viability.ts +20 -3
- package/src/server/auth.ts +272 -78
- package/src/server/decode-session.ts +202 -200
package/src/server/auth.ts
CHANGED
|
@@ -1,78 +1,272 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side auth utilities for Better Auth.
|
|
3
|
-
*
|
|
4
|
-
* All server-side auth flows go through the Better Auth instance returned by
|
|
5
|
-
* getAuthInstance(); use getSession(req) for the request-scoped session.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import 'server-only';
|
|
9
|
-
import { createBetterAuthInstance } from '../auth/better-auth';
|
|
10
|
-
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Server-side auth utilities for Better Auth.
|
|
3
|
+
*
|
|
4
|
+
* All server-side auth flows go through the Better Auth instance returned by
|
|
5
|
+
* getAuthInstance(); use getSession(req) for the request-scoped session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import 'server-only';
|
|
9
|
+
import { createBetterAuthInstance } from '../auth/better-auth';
|
|
10
|
+
import { getIDPClientConfig } from '../lib/idp-client-config';
|
|
11
|
+
import {
|
|
12
|
+
getSession as getRedisSession,
|
|
13
|
+
getBetterAuthSession as getBetterAuthRedisSession,
|
|
14
|
+
type SessionData,
|
|
15
|
+
} from '../lib/session-store';
|
|
16
|
+
|
|
17
|
+
let authInstance: ReturnType<typeof createBetterAuthInstance> | null = null;
|
|
18
|
+
let authInitPromise: Promise<ReturnType<typeof createBetterAuthInstance>> | null = null;
|
|
19
|
+
|
|
20
|
+
export type IdpTokenResult =
|
|
21
|
+
| { success: true; accessToken: string; sessionData: SessionData }
|
|
22
|
+
| { success: false; error: 'NO_SESSION' | 'NO_TOKEN'; terminal: true };
|
|
23
|
+
|
|
24
|
+
function buildSessionDataFromAuthSession(session: any): SessionData | null {
|
|
25
|
+
const user = session?.user;
|
|
26
|
+
if (!user?.id && !user?.email) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const expiresAt = session?.session?.expiresAt
|
|
31
|
+
? new Date(session.session.expiresAt).getTime()
|
|
32
|
+
: Date.now() + 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
userId: user.userId || user.id || '',
|
|
36
|
+
email: user.email || '',
|
|
37
|
+
name: user.name || undefined,
|
|
38
|
+
image: user.image || undefined,
|
|
39
|
+
roles: Array.isArray(user.roles) ? user.roles : [],
|
|
40
|
+
idpAccessToken: user.idpAccessToken,
|
|
41
|
+
idpRefreshToken: user.idpRefreshToken,
|
|
42
|
+
idpAccessTokenExpires: user.idpAccessTokenExpires || expiresAt,
|
|
43
|
+
mfaVerified: user.mfaVerified ?? user.twoFactorSessionVerified ?? false,
|
|
44
|
+
oauthProvider: user.oauthProvider,
|
|
45
|
+
idpClientId: user.idpClientId,
|
|
46
|
+
merchantId: user.merchantId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function attachSessionData(session: any, sessionData: SessionData | null, sessionToken?: string) {
|
|
51
|
+
if (!sessionData) {
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const enrichedSessionData = {
|
|
56
|
+
...sessionData,
|
|
57
|
+
...(sessionToken ? { sessionToken } : {}),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
(session as any).sessionData = enrichedSessionData;
|
|
61
|
+
|
|
62
|
+
if (session?.user) {
|
|
63
|
+
const user = session.user as any;
|
|
64
|
+
user.userId = enrichedSessionData.userId || user.userId;
|
|
65
|
+
user.email = enrichedSessionData.email || user.email;
|
|
66
|
+
user.name = enrichedSessionData.name || user.name;
|
|
67
|
+
user.image = enrichedSessionData.image || user.image;
|
|
68
|
+
user.roles = enrichedSessionData.roles || user.roles || [];
|
|
69
|
+
user.idpAccessToken = enrichedSessionData.idpAccessToken;
|
|
70
|
+
user.idpRefreshToken = enrichedSessionData.idpRefreshToken;
|
|
71
|
+
user.idpAccessTokenExpires = enrichedSessionData.idpAccessTokenExpires;
|
|
72
|
+
user.mfaVerified = enrichedSessionData.mfaVerified;
|
|
73
|
+
user.twoFactorSessionVerified =
|
|
74
|
+
enrichedSessionData.mfaVerified ?? user.twoFactorSessionVerified;
|
|
75
|
+
user.oauthProvider = enrichedSessionData.oauthProvider || user.oauthProvider;
|
|
76
|
+
user.idpClientId = enrichedSessionData.idpClientId || user.idpClientId;
|
|
77
|
+
user.merchantId = enrichedSessionData.merchantId || user.merchantId;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return session;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the initialized Better Auth instance (singleton).
|
|
85
|
+
*/
|
|
86
|
+
export async function getAuthInstance() {
|
|
87
|
+
if (authInstance) return authInstance;
|
|
88
|
+
if (!authInitPromise) {
|
|
89
|
+
authInitPromise = getIDPClientConfig(true).then(config => {
|
|
90
|
+
authInstance = createBetterAuthInstance(config);
|
|
91
|
+
return authInstance;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return authInitPromise;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the current session from a request.
|
|
99
|
+
* Replaces getToken() and getServerSession().
|
|
100
|
+
*
|
|
101
|
+
* Returns the session object or null if not authenticated.
|
|
102
|
+
*/
|
|
103
|
+
export async function getSession(request?: Request): Promise<any> {
|
|
104
|
+
const auth = await getAuthInstance();
|
|
105
|
+
if (!request) return null;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
109
|
+
if (!session?.session?.token || !session?.user) return session;
|
|
110
|
+
|
|
111
|
+
const sessionToken = session.session.token as string;
|
|
112
|
+
let sessionData: SessionData | null = null;
|
|
113
|
+
|
|
114
|
+
// Prefer the app's normalized Redis session. Fall back to Better Auth's
|
|
115
|
+
// secondary storage record, then finally to whatever Better Auth already
|
|
116
|
+
// put on the request session object.
|
|
117
|
+
try {
|
|
118
|
+
sessionData = await getRedisSession(sessionToken);
|
|
119
|
+
if (!sessionData) {
|
|
120
|
+
sessionData = await getBetterAuthRedisSession(sessionToken);
|
|
121
|
+
}
|
|
122
|
+
} catch { /* Redis unavailable */ }
|
|
123
|
+
|
|
124
|
+
if (!sessionData) {
|
|
125
|
+
sessionData = buildSessionDataFromAuthSession(session);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return attachSessionData(session, sessionData, sessionToken);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get normalized session data for the current request.
|
|
136
|
+
*
|
|
137
|
+
* This prefers the app's Redis session because it carries the canonical
|
|
138
|
+
* IDP token, roles, and tenant-specific user identity used by app routes.
|
|
139
|
+
*/
|
|
140
|
+
export async function getSessionData(request?: Request): Promise<SessionData | null> {
|
|
141
|
+
const session = await getSession(request);
|
|
142
|
+
const sessionData =
|
|
143
|
+
((session as any)?.sessionData as SessionData | undefined) ||
|
|
144
|
+
buildSessionDataFromAuthSession(session);
|
|
145
|
+
|
|
146
|
+
if (!sessionData) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const sessionToken = session?.session?.token as string | undefined;
|
|
151
|
+
return sessionToken
|
|
152
|
+
? { ...sessionData, sessionToken }
|
|
153
|
+
: sessionData;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get the current request's IDP access token without triggering a refresh.
|
|
158
|
+
*
|
|
159
|
+
* Use this for routes that only need the currently-issued bearer token and
|
|
160
|
+
* should fail closed instead of performing token lifecycle work. For backend
|
|
161
|
+
* proxy routes that forward the token to a downstream API, prefer
|
|
162
|
+
* `getFreshIdpToken` — it preflights expiry and refreshes single-flight, so
|
|
163
|
+
* the proxy never sends a credential it already knows is invalid.
|
|
164
|
+
*/
|
|
165
|
+
export async function getIdpToken(request?: Request): Promise<IdpTokenResult> {
|
|
166
|
+
const sessionData = await getSessionData(request);
|
|
167
|
+
if (!sessionData) {
|
|
168
|
+
return { success: false, error: 'NO_SESSION', terminal: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const accessToken = sessionData.idpAccessToken || (sessionData as any).accessToken;
|
|
172
|
+
if (!accessToken) {
|
|
173
|
+
return { success: false, error: 'NO_TOKEN', terminal: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
accessToken,
|
|
179
|
+
sessionData,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type FreshIdpTokenResult =
|
|
184
|
+
| { success: true; accessToken: string; sessionData: SessionData; refreshed: boolean }
|
|
185
|
+
| {
|
|
186
|
+
success: false;
|
|
187
|
+
error: string;
|
|
188
|
+
status: number;
|
|
189
|
+
terminal?: boolean;
|
|
190
|
+
discardToken?: boolean;
|
|
191
|
+
retryable?: boolean;
|
|
192
|
+
resolution?: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export interface FreshIdpTokenConfig {
|
|
196
|
+
idpBaseUrl: string;
|
|
197
|
+
clientId: string;
|
|
198
|
+
refreshEndpoint?: string;
|
|
199
|
+
/** Refresh if the access token is within this many ms of expiry. Default 60_000. */
|
|
200
|
+
safetyWindowMs?: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the current request's IDP access token, preflight-refreshing if it is
|
|
205
|
+
* expired or within the safety window. Single-flight via Redis lock, so
|
|
206
|
+
* concurrent calls on the same session share one IDP round-trip and one
|
|
207
|
+
* single-use refresh-token consumption.
|
|
208
|
+
*
|
|
209
|
+
* Use this in proxy routes. The returned `accessToken` is safe to forward to
|
|
210
|
+
* a downstream API without expecting a 401. If `success` is false, surface a
|
|
211
|
+
* 401/redirect — there is no recoverable token for this session.
|
|
212
|
+
*/
|
|
213
|
+
export async function getFreshIdpToken(
|
|
214
|
+
request: Request | undefined,
|
|
215
|
+
config: FreshIdpTokenConfig
|
|
216
|
+
): Promise<FreshIdpTokenResult> {
|
|
217
|
+
const session = await getSession(request);
|
|
218
|
+
const sessionToken = session?.session?.token as string | undefined;
|
|
219
|
+
if (!sessionToken) {
|
|
220
|
+
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { ensureFreshAccessToken } = await import('../lib/ensure-fresh-access-token');
|
|
224
|
+
const result = await ensureFreshAccessToken(
|
|
225
|
+
sessionToken,
|
|
226
|
+
{
|
|
227
|
+
idpBaseUrl: config.idpBaseUrl,
|
|
228
|
+
clientId: config.clientId,
|
|
229
|
+
refreshEndpoint: config.refreshEndpoint,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
safetyWindowMs: config.safetyWindowMs,
|
|
233
|
+
requestId: request?.headers?.get('x-request-id') ?? undefined,
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
error: result.code,
|
|
241
|
+
status: result.status,
|
|
242
|
+
terminal: result.terminal,
|
|
243
|
+
discardToken: result.discardToken,
|
|
244
|
+
retryable: result.retryable,
|
|
245
|
+
resolution: result.resolution,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const sessionData = await getRedisSession(sessionToken);
|
|
250
|
+
if (!sessionData) {
|
|
251
|
+
return { success: false, error: 'NO_SESSION', status: 401, terminal: true };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
success: true,
|
|
256
|
+
accessToken: result.accessToken,
|
|
257
|
+
sessionData,
|
|
258
|
+
refreshed: result.refreshed,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get the current session, throwing if not authenticated.
|
|
264
|
+
* Use in API handlers that require auth.
|
|
265
|
+
*/
|
|
266
|
+
export async function requireSession(request: Request) {
|
|
267
|
+
const session = await getSession(request);
|
|
268
|
+
if (!session) {
|
|
269
|
+
throw new Error('Unauthorized');
|
|
270
|
+
}
|
|
271
|
+
return session;
|
|
272
|
+
}
|