@imtbl/auth-next-server 2.12.5-alpha.13
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/.eslintrc.cjs +17 -0
- package/LICENSE.md +176 -0
- package/dist/node/config.d.ts +21 -0
- package/dist/node/constants.d.ts +42 -0
- package/dist/node/index.cjs +436 -0
- package/dist/node/index.d.ts +301 -0
- package/dist/node/index.js +390 -0
- package/dist/node/refresh.d.ts +9 -0
- package/dist/node/types.d.ts +111 -0
- package/dist/node/utils/pathMatch.d.ts +10 -0
- package/jest.config.ts +16 -0
- package/package.json +60 -0
- package/src/config.ts +243 -0
- package/src/constants.ts +51 -0
- package/src/index.ts +662 -0
- package/src/refresh.ts +21 -0
- package/src/types.ts +124 -0
- package/src/utils/pathMatch.ts +16 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +16 -0
- package/tsconfig.types.json +8 -0
- package/tsup.config.ts +29 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @imtbl/auth-next-server
|
|
3
|
+
*
|
|
4
|
+
* Server-side utilities for Immutable Auth.js v5 integration with Next.js.
|
|
5
|
+
* This package has NO dependency on @imtbl/auth and is safe to use in
|
|
6
|
+
* Next.js middleware and Edge Runtime environments.
|
|
7
|
+
*
|
|
8
|
+
* For client-side components (provider, hooks, callback), use @imtbl/auth-next-client.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import NextAuthImport from 'next-auth';
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
13
|
+
// @ts-ignore - Type exists in next-auth v5 but TS resolver may use stale types
|
|
14
|
+
import type { NextAuthConfig, Session } from 'next-auth';
|
|
15
|
+
import { type NextRequest, NextResponse } from 'next/server';
|
|
16
|
+
import { createAuthConfig } from './config';
|
|
17
|
+
import type { ImmutableAuthConfig } from './types';
|
|
18
|
+
import { matchPathPrefix } from './utils/pathMatch';
|
|
19
|
+
|
|
20
|
+
// Handle ESM/CJS interop - in some bundler configurations, the default export
|
|
21
|
+
// may be nested under a 'default' property
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
+
const NextAuth = ((NextAuthImport as any).default || NextAuthImport) as typeof NextAuthImport;
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// createImmutableAuth
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Auth.js v5 config options that can be overridden.
|
|
31
|
+
* Excludes 'providers' as that's managed internally.
|
|
32
|
+
*/
|
|
33
|
+
export type ImmutableAuthOverrides = Omit<NextAuthConfig, 'providers'>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return type of createImmutableAuth - the NextAuth instance with handlers
|
|
37
|
+
*/
|
|
38
|
+
export type ImmutableAuthResult = ReturnType<typeof NextAuth>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create an Auth.js v5 instance with Immutable authentication
|
|
42
|
+
*
|
|
43
|
+
* @param config - Immutable auth configuration
|
|
44
|
+
* @param overrides - Optional Auth.js options to override defaults
|
|
45
|
+
* @returns NextAuth instance with { handlers, auth, signIn, signOut }
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* Callback composition: The `jwt` and `session` callbacks are composed rather than
|
|
49
|
+
* replaced. Internal callbacks run first (handling token storage and refresh), then
|
|
50
|
+
* your custom callbacks receive the result. Other callbacks (`signIn`, `redirect`)
|
|
51
|
+
* are replaced entirely if provided.
|
|
52
|
+
*
|
|
53
|
+
* @example Basic usage (App Router)
|
|
54
|
+
* ```typescript
|
|
55
|
+
* // lib/auth.ts
|
|
56
|
+
* import { createImmutableAuth } from "@imtbl/auth-next-server";
|
|
57
|
+
*
|
|
58
|
+
* export const { handlers, auth, signIn, signOut } = createImmutableAuth({
|
|
59
|
+
* clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
|
|
60
|
+
* redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // app/api/auth/[...nextauth]/route.ts
|
|
64
|
+
* import { handlers } from "@/lib/auth";
|
|
65
|
+
* export const { GET, POST } = handlers;
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function createImmutableAuth(
|
|
69
|
+
config: ImmutableAuthConfig,
|
|
70
|
+
overrides?: ImmutableAuthOverrides,
|
|
71
|
+
): ImmutableAuthResult {
|
|
72
|
+
const baseConfig = createAuthConfig(config);
|
|
73
|
+
|
|
74
|
+
// If no overrides, use base config directly
|
|
75
|
+
if (!overrides) {
|
|
76
|
+
return NextAuth(baseConfig);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Merge configs with callback composition
|
|
80
|
+
const { callbacks: overrideCallbacks, ...otherOverrides } = overrides;
|
|
81
|
+
|
|
82
|
+
// Compose callbacks - our callbacks run first, then user's callbacks
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
const composedCallbacks: any = { ...baseConfig.callbacks };
|
|
85
|
+
|
|
86
|
+
if (overrideCallbacks) {
|
|
87
|
+
// For jwt and session callbacks, compose them (ours first, then user's)
|
|
88
|
+
if (overrideCallbacks.jwt) {
|
|
89
|
+
const baseJwt = baseConfig.callbacks?.jwt;
|
|
90
|
+
const userJwt = overrideCallbacks.jwt;
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
composedCallbacks.jwt = async (params: any) => {
|
|
93
|
+
const result = baseJwt ? await baseJwt(params) : params.token;
|
|
94
|
+
return userJwt({ ...params, token: result });
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (overrideCallbacks.session) {
|
|
99
|
+
const baseSession = baseConfig.callbacks?.session;
|
|
100
|
+
const userSession = overrideCallbacks.session;
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
composedCallbacks.session = async (params: any) => {
|
|
103
|
+
const result = baseSession ? await baseSession(params) : params.session;
|
|
104
|
+
return userSession({ ...params, session: result });
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// For other callbacks, user's callbacks replace ours entirely
|
|
109
|
+
if (overrideCallbacks.signIn) {
|
|
110
|
+
composedCallbacks.signIn = overrideCallbacks.signIn;
|
|
111
|
+
}
|
|
112
|
+
if (overrideCallbacks.redirect) {
|
|
113
|
+
composedCallbacks.redirect = overrideCallbacks.redirect;
|
|
114
|
+
}
|
|
115
|
+
if (overrideCallbacks.authorized) {
|
|
116
|
+
composedCallbacks.authorized = overrideCallbacks.authorized;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mergedConfig: NextAuthConfig = {
|
|
121
|
+
...baseConfig,
|
|
122
|
+
...otherOverrides,
|
|
123
|
+
callbacks: composedCallbacks,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return NextAuth(mergedConfig);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// Re-export config utilities
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
export { createAuthConfig, createAuthOptions } from './config';
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// Type exports
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
export type {
|
|
140
|
+
ImmutableAuthConfig,
|
|
141
|
+
ImmutableTokenData,
|
|
142
|
+
UserInfoResponse,
|
|
143
|
+
ZkEvmUser,
|
|
144
|
+
ImmutableUser,
|
|
145
|
+
} from './types';
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Server utilities
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Result from getValidSession indicating auth state
|
|
153
|
+
*/
|
|
154
|
+
export type ValidSessionResult =
|
|
155
|
+
| { status: 'authenticated'; session: Session }
|
|
156
|
+
| { status: 'token_expired'; session: Session }
|
|
157
|
+
| { status: 'unauthenticated'; session: null }
|
|
158
|
+
| { status: 'error'; session: Session; error: string };
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Auth props to pass to components - enables automatic SSR/CSR switching.
|
|
162
|
+
* When token is valid, session contains accessToken for immediate use.
|
|
163
|
+
* When token is expired, ssr is false and component should fetch client-side.
|
|
164
|
+
*/
|
|
165
|
+
export interface AuthProps {
|
|
166
|
+
/** Session with valid tokens, or null if token expired/unauthenticated */
|
|
167
|
+
session: Session | null;
|
|
168
|
+
/** If true, SSR data fetching occurred with valid token */
|
|
169
|
+
ssr: boolean;
|
|
170
|
+
/** Auth error that requires user action (not TokenExpired) */
|
|
171
|
+
authError?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Auth props with pre-fetched data for SSR hydration.
|
|
176
|
+
* Extends AuthProps with optional data that was fetched server-side.
|
|
177
|
+
*/
|
|
178
|
+
export interface AuthPropsWithData<T> extends AuthProps {
|
|
179
|
+
/** Pre-fetched data from server (null if SSR was skipped or fetch failed) */
|
|
180
|
+
data: T | null;
|
|
181
|
+
/** Error message if server-side fetch failed */
|
|
182
|
+
fetchError?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Auth props without the authError field.
|
|
187
|
+
* Used when auth error handling is automatic via onAuthError callback.
|
|
188
|
+
*/
|
|
189
|
+
export interface ProtectedAuthProps {
|
|
190
|
+
/** Session with valid tokens, or null if token expired/unauthenticated */
|
|
191
|
+
session: Session | null;
|
|
192
|
+
/** If true, SSR data fetching occurred with valid token */
|
|
193
|
+
ssr: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Protected auth props with pre-fetched data.
|
|
198
|
+
* Used when auth error handling is automatic via onAuthError callback.
|
|
199
|
+
*/
|
|
200
|
+
export interface ProtectedAuthPropsWithData<T> extends ProtectedAuthProps {
|
|
201
|
+
/** Pre-fetched data from server (null if SSR was skipped or fetch failed) */
|
|
202
|
+
data: T | null;
|
|
203
|
+
/** Error message if server-side fetch failed */
|
|
204
|
+
fetchError?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Type for the auth function returned by createImmutableAuth
|
|
209
|
+
*/
|
|
210
|
+
export type AuthFunction = () => Promise<Session | null>;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get auth props for passing to Client Components (without data fetching).
|
|
214
|
+
* Use this when you want to handle data fetching separately or client-side only.
|
|
215
|
+
*
|
|
216
|
+
* For SSR data fetching, use `getAuthenticatedData` instead.
|
|
217
|
+
*
|
|
218
|
+
* @param auth - The auth function from createImmutableAuth
|
|
219
|
+
* @returns AuthProps with session and ssr flag
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* const authProps = await getAuthProps(auth);
|
|
224
|
+
* if (authProps.authError) redirect("/login");
|
|
225
|
+
* return <MyComponent {...authProps} />;
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export async function getAuthProps(auth: AuthFunction): Promise<AuthProps> {
|
|
229
|
+
const session = await auth();
|
|
230
|
+
|
|
231
|
+
// No session - unauthenticated
|
|
232
|
+
if (!session) {
|
|
233
|
+
return { session: null, ssr: false };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Token expired - skip SSR, let client refresh
|
|
237
|
+
if (session.error === 'TokenExpired') {
|
|
238
|
+
return { session: null, ssr: false };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Other error (e.g., RefreshTokenError) - needs user action
|
|
242
|
+
if (session.error) {
|
|
243
|
+
return { session: null, ssr: false, authError: session.error };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Valid session - enable SSR
|
|
247
|
+
return { session, ssr: true };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Fetch authenticated data on the server with automatic SSR/CSR switching.
|
|
252
|
+
*
|
|
253
|
+
* This is the recommended pattern for Server Components that need authenticated data:
|
|
254
|
+
* - When token is valid: Fetches data server-side, returns with `ssr: true`
|
|
255
|
+
* - When token is expired: Skips fetch, returns `ssr: false` for client-side handling
|
|
256
|
+
*
|
|
257
|
+
* @param auth - The auth function from createImmutableAuth
|
|
258
|
+
* @param fetcher - Async function that receives access token and returns data
|
|
259
|
+
* @returns AuthPropsWithData containing session, ssr flag, and pre-fetched data
|
|
260
|
+
*/
|
|
261
|
+
export async function getAuthenticatedData<T>(
|
|
262
|
+
auth: AuthFunction,
|
|
263
|
+
fetcher: (accessToken: string) => Promise<T>,
|
|
264
|
+
): Promise<AuthPropsWithData<T>> {
|
|
265
|
+
const session = await auth();
|
|
266
|
+
|
|
267
|
+
// No session - unauthenticated
|
|
268
|
+
if (!session) {
|
|
269
|
+
return { session: null, ssr: false, data: null };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Token expired - skip SSR, let client refresh and fetch
|
|
273
|
+
if (session.error === 'TokenExpired') {
|
|
274
|
+
return { session: null, ssr: false, data: null };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Other error (e.g., RefreshTokenError) - needs user action
|
|
278
|
+
if (session.error) {
|
|
279
|
+
return {
|
|
280
|
+
session: null,
|
|
281
|
+
ssr: false,
|
|
282
|
+
data: null,
|
|
283
|
+
authError: session.error,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Valid session - fetch data server-side
|
|
288
|
+
try {
|
|
289
|
+
const data = await fetcher(session.accessToken!);
|
|
290
|
+
return { session, ssr: true, data };
|
|
291
|
+
} catch (err) {
|
|
292
|
+
// Fetch failed but auth is valid - return error for client to handle
|
|
293
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
294
|
+
return {
|
|
295
|
+
session,
|
|
296
|
+
ssr: true,
|
|
297
|
+
data: null,
|
|
298
|
+
fetchError: errorMessage,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Get session with detailed status for Server Components.
|
|
305
|
+
* Use this when you need fine-grained control over different auth states.
|
|
306
|
+
*
|
|
307
|
+
* @param auth - The auth function from createImmutableAuth
|
|
308
|
+
* @returns Object with status and session
|
|
309
|
+
*/
|
|
310
|
+
export async function getValidSession(auth: AuthFunction): Promise<ValidSessionResult> {
|
|
311
|
+
const session = await auth();
|
|
312
|
+
|
|
313
|
+
if (!session) {
|
|
314
|
+
return { status: 'unauthenticated', session: null };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!session.error) {
|
|
318
|
+
return { status: 'authenticated', session };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (session.error === 'TokenExpired') {
|
|
322
|
+
return { status: 'token_expired', session };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return { status: 'error', session, error: session.error };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Auth error handler signature.
|
|
330
|
+
* The handler should either redirect (using Next.js redirect()) or throw an error.
|
|
331
|
+
* It must never return normally - hence the `never` return type.
|
|
332
|
+
*
|
|
333
|
+
* @param error - The auth error (e.g., "RefreshTokenError")
|
|
334
|
+
*/
|
|
335
|
+
export type AuthErrorHandler = (error: string) => never;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Create a protected data fetcher with automatic auth error handling.
|
|
339
|
+
*
|
|
340
|
+
* This eliminates the need to check `authError` on every page. Define the error
|
|
341
|
+
* handling once, and all pages using this fetcher will automatically redirect
|
|
342
|
+
* on auth errors.
|
|
343
|
+
*
|
|
344
|
+
* @param auth - The auth function from createImmutableAuth
|
|
345
|
+
* @param onAuthError - Handler called when there's an auth error (should redirect or throw)
|
|
346
|
+
* @returns A function to fetch protected data without needing authError checks
|
|
347
|
+
*/
|
|
348
|
+
export function createProtectedDataFetcher(
|
|
349
|
+
auth: AuthFunction,
|
|
350
|
+
onAuthError: AuthErrorHandler,
|
|
351
|
+
): <T>(fetcher: (accessToken: string) => Promise<T>) => Promise<ProtectedAuthPropsWithData<T>> {
|
|
352
|
+
return async function getProtectedData<T>(
|
|
353
|
+
fetcher: (accessToken: string) => Promise<T>,
|
|
354
|
+
): Promise<ProtectedAuthPropsWithData<T>> {
|
|
355
|
+
const result = await getAuthenticatedData(auth, fetcher);
|
|
356
|
+
|
|
357
|
+
// If there's an auth error, call the handler (which should redirect/throw)
|
|
358
|
+
if (result.authError) {
|
|
359
|
+
onAuthError(result.authError);
|
|
360
|
+
// TypeScript knows this is unreachable due to `never` return type
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Remove authError from the result since it's handled
|
|
364
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
365
|
+
const { authError: handledAuthError, ...props } = result;
|
|
366
|
+
return props;
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Create auth props getter with automatic auth error handling.
|
|
372
|
+
*
|
|
373
|
+
* Similar to createProtectedDataFetcher but for cases where you don't need
|
|
374
|
+
* server-side data fetching.
|
|
375
|
+
*
|
|
376
|
+
* @param auth - The auth function from createImmutableAuth
|
|
377
|
+
* @param onAuthError - Handler called when there's an auth error (should redirect or throw)
|
|
378
|
+
* @returns A function to get auth props without needing authError checks
|
|
379
|
+
*/
|
|
380
|
+
export function createProtectedAuthProps(
|
|
381
|
+
auth: AuthFunction,
|
|
382
|
+
onAuthError: AuthErrorHandler,
|
|
383
|
+
): () => Promise<ProtectedAuthProps> {
|
|
384
|
+
return async function getProtectedAuth(): Promise<ProtectedAuthProps> {
|
|
385
|
+
const result = await getAuthProps(auth);
|
|
386
|
+
|
|
387
|
+
if (result.authError) {
|
|
388
|
+
onAuthError(result.authError);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
392
|
+
const { authError: handledAuthError, ...props } = result;
|
|
393
|
+
return props;
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Result of createProtectedFetchers
|
|
399
|
+
*/
|
|
400
|
+
export interface ProtectedFetchers {
|
|
401
|
+
/**
|
|
402
|
+
* Get auth props with automatic auth error handling.
|
|
403
|
+
* No data fetching - use when you only need session/auth state.
|
|
404
|
+
*/
|
|
405
|
+
getAuthProps: () => Promise<ProtectedAuthProps>;
|
|
406
|
+
/**
|
|
407
|
+
* Fetch authenticated data with automatic auth error handling.
|
|
408
|
+
* Use for SSR data fetching with automatic fallback.
|
|
409
|
+
*/
|
|
410
|
+
getData: <T>(fetcher: (accessToken: string) => Promise<T>) => Promise<ProtectedAuthPropsWithData<T>>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Create protected fetchers with centralized auth error handling.
|
|
415
|
+
*
|
|
416
|
+
* This is the recommended way to set up auth error handling once and use it
|
|
417
|
+
* across all protected pages. Define your error handler once, then use the
|
|
418
|
+
* returned functions without needing to check authError on each page.
|
|
419
|
+
*
|
|
420
|
+
* @param auth - The auth function from createImmutableAuth
|
|
421
|
+
* @param onAuthError - Handler called when there's an auth error (should redirect or throw)
|
|
422
|
+
* @returns Object with getAuthProps and getData functions
|
|
423
|
+
*/
|
|
424
|
+
export function createProtectedFetchers(
|
|
425
|
+
auth: AuthFunction,
|
|
426
|
+
onAuthError: AuthErrorHandler,
|
|
427
|
+
): ProtectedFetchers {
|
|
428
|
+
return {
|
|
429
|
+
getAuthProps: createProtectedAuthProps(auth, onAuthError),
|
|
430
|
+
getData: createProtectedDataFetcher(auth, onAuthError),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Options for withServerAuth
|
|
436
|
+
*/
|
|
437
|
+
export interface WithServerAuthOptions<TFallback> {
|
|
438
|
+
/**
|
|
439
|
+
* Content to render when token is expired.
|
|
440
|
+
* This should typically be a Client Component that will refresh tokens and fetch data.
|
|
441
|
+
* If not provided, the serverRender function will still be called with the expired session.
|
|
442
|
+
*/
|
|
443
|
+
onTokenExpired?: TFallback | (() => TFallback);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Content to render when user is not authenticated at all.
|
|
447
|
+
* If not provided, throws an error.
|
|
448
|
+
*/
|
|
449
|
+
onUnauthenticated?: TFallback | (() => TFallback);
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Content to render when there's an auth error (e.g., refresh token invalid).
|
|
453
|
+
* If not provided, throws an error.
|
|
454
|
+
*/
|
|
455
|
+
onError?: TFallback | ((error: string) => TFallback);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Helper for Server Components that need authenticated data.
|
|
460
|
+
* Automatically handles token expiration by rendering a client fallback.
|
|
461
|
+
*
|
|
462
|
+
* @param auth - The auth function from createImmutableAuth
|
|
463
|
+
* @param serverRender - Async function that receives valid session and returns JSX
|
|
464
|
+
* @param options - Fallback options for different auth states
|
|
465
|
+
* @returns The rendered content based on auth state
|
|
466
|
+
*/
|
|
467
|
+
export async function withServerAuth<TResult, TFallback = TResult>(
|
|
468
|
+
auth: AuthFunction,
|
|
469
|
+
serverRender: (session: Session) => Promise<TResult>,
|
|
470
|
+
options: WithServerAuthOptions<TFallback> = {},
|
|
471
|
+
): Promise<TResult | TFallback> {
|
|
472
|
+
const result = await getValidSession(auth);
|
|
473
|
+
|
|
474
|
+
switch (result.status) {
|
|
475
|
+
case 'authenticated':
|
|
476
|
+
return serverRender(result.session);
|
|
477
|
+
|
|
478
|
+
case 'token_expired':
|
|
479
|
+
if (options.onTokenExpired !== undefined) {
|
|
480
|
+
return typeof options.onTokenExpired === 'function'
|
|
481
|
+
? (options.onTokenExpired as () => TFallback)()
|
|
482
|
+
: options.onTokenExpired;
|
|
483
|
+
}
|
|
484
|
+
// If no fallback provided, still call serverRender - handler can check session.error
|
|
485
|
+
return serverRender(result.session);
|
|
486
|
+
|
|
487
|
+
case 'unauthenticated':
|
|
488
|
+
if (options.onUnauthenticated !== undefined) {
|
|
489
|
+
return typeof options.onUnauthenticated === 'function'
|
|
490
|
+
? (options.onUnauthenticated as () => TFallback)()
|
|
491
|
+
: options.onUnauthenticated;
|
|
492
|
+
}
|
|
493
|
+
throw new Error('Unauthorized: No active session');
|
|
494
|
+
|
|
495
|
+
case 'error':
|
|
496
|
+
if (options.onError !== undefined) {
|
|
497
|
+
return typeof options.onError === 'function'
|
|
498
|
+
? (options.onError as (error: string) => TFallback)(result.error)
|
|
499
|
+
: options.onError;
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`Unauthorized: ${result.error}`);
|
|
502
|
+
|
|
503
|
+
default:
|
|
504
|
+
throw new Error('Unknown auth state');
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ============================================================================
|
|
509
|
+
// Middleware
|
|
510
|
+
// ============================================================================
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Options for createAuthMiddleware
|
|
514
|
+
*/
|
|
515
|
+
export interface AuthMiddlewareOptions {
|
|
516
|
+
/**
|
|
517
|
+
* URL to redirect to when not authenticated
|
|
518
|
+
* @default "/login"
|
|
519
|
+
*/
|
|
520
|
+
loginUrl?: string;
|
|
521
|
+
/**
|
|
522
|
+
* Paths that should be protected (regex patterns)
|
|
523
|
+
* If not provided, middleware should be configured via Next.js matcher
|
|
524
|
+
*/
|
|
525
|
+
protectedPaths?: (string | RegExp)[];
|
|
526
|
+
/**
|
|
527
|
+
* Paths that should be excluded from protection (regex patterns)
|
|
528
|
+
* Takes precedence over protectedPaths
|
|
529
|
+
*/
|
|
530
|
+
publicPaths?: (string | RegExp)[];
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Create a Next.js middleware for protecting routes with Immutable authentication.
|
|
535
|
+
*
|
|
536
|
+
* This is the App Router replacement for `withPageAuthRequired`.
|
|
537
|
+
*
|
|
538
|
+
* @param auth - The auth function from createImmutableAuth
|
|
539
|
+
* @param options - Middleware options
|
|
540
|
+
* @returns A Next.js middleware function
|
|
541
|
+
*
|
|
542
|
+
* @example Basic usage with Next.js middleware:
|
|
543
|
+
* ```typescript
|
|
544
|
+
* // middleware.ts
|
|
545
|
+
* import { createAuthMiddleware } from "@imtbl/auth-next-server";
|
|
546
|
+
* import { auth } from "@/lib/auth";
|
|
547
|
+
*
|
|
548
|
+
* export default createAuthMiddleware(auth, {
|
|
549
|
+
* loginUrl: "/login",
|
|
550
|
+
* });
|
|
551
|
+
*
|
|
552
|
+
* export const config = {
|
|
553
|
+
* matcher: ["/dashboard/:path*", "/profile/:path*"],
|
|
554
|
+
* };
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
export function createAuthMiddleware(
|
|
558
|
+
auth: AuthFunction,
|
|
559
|
+
options: AuthMiddlewareOptions = {},
|
|
560
|
+
) {
|
|
561
|
+
const { loginUrl = '/login', protectedPaths, publicPaths } = options;
|
|
562
|
+
|
|
563
|
+
return async function middleware(request: NextRequest) {
|
|
564
|
+
const { pathname } = request.nextUrl;
|
|
565
|
+
|
|
566
|
+
// Check if path is public (skip auth)
|
|
567
|
+
if (publicPaths) {
|
|
568
|
+
const isPublic = publicPaths.some((pattern) => {
|
|
569
|
+
if (typeof pattern === 'string') {
|
|
570
|
+
return matchPathPrefix(pathname, pattern);
|
|
571
|
+
}
|
|
572
|
+
return pattern.test(pathname);
|
|
573
|
+
});
|
|
574
|
+
if (isPublic) {
|
|
575
|
+
return NextResponse.next();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Check if path is protected
|
|
580
|
+
if (protectedPaths) {
|
|
581
|
+
const isProtected = protectedPaths.some((pattern) => {
|
|
582
|
+
if (typeof pattern === 'string') {
|
|
583
|
+
return matchPathPrefix(pathname, pattern);
|
|
584
|
+
}
|
|
585
|
+
return pattern.test(pathname);
|
|
586
|
+
});
|
|
587
|
+
if (!isProtected) {
|
|
588
|
+
return NextResponse.next();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Check authentication
|
|
593
|
+
const session = await auth();
|
|
594
|
+
|
|
595
|
+
// No session at all - user is not authenticated, redirect to login
|
|
596
|
+
if (!session) {
|
|
597
|
+
const url = new URL(loginUrl, request.url);
|
|
598
|
+
const returnTo = request.nextUrl.search
|
|
599
|
+
? `${pathname}${request.nextUrl.search}`
|
|
600
|
+
: pathname;
|
|
601
|
+
url.searchParams.set('returnTo', returnTo);
|
|
602
|
+
return NextResponse.redirect(url);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Session exists but has error - distinguish between error types:
|
|
606
|
+
// - "TokenExpired": Access token expired but user may have valid refresh token.
|
|
607
|
+
// Let the page load - client-side Auth will refresh tokens silently.
|
|
608
|
+
// - Other errors (e.g., "RefreshTokenError"): Refresh token is invalid/expired.
|
|
609
|
+
// User must re-authenticate, redirect to login.
|
|
610
|
+
if (session.error && session.error !== 'TokenExpired') {
|
|
611
|
+
const url = new URL(loginUrl, request.url);
|
|
612
|
+
const returnTo = request.nextUrl.search
|
|
613
|
+
? `${pathname}${request.nextUrl.search}`
|
|
614
|
+
: pathname;
|
|
615
|
+
url.searchParams.set('returnTo', returnTo);
|
|
616
|
+
url.searchParams.set('error', session.error);
|
|
617
|
+
return NextResponse.redirect(url);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Session valid OR TokenExpired (client will refresh) - allow access
|
|
621
|
+
|
|
622
|
+
return NextResponse.next();
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Higher-order function to protect a Server Action or Route Handler.
|
|
628
|
+
*
|
|
629
|
+
* The returned function forwards all arguments from Next.js to your handler,
|
|
630
|
+
* allowing access to the request, context, form data, or any other arguments.
|
|
631
|
+
*
|
|
632
|
+
* @param auth - The auth function from createImmutableAuth
|
|
633
|
+
* @param handler - The handler function to protect. Receives session as first arg,
|
|
634
|
+
* followed by any arguments passed by Next.js (request, context, etc.)
|
|
635
|
+
* @returns A protected handler that checks authentication before executing
|
|
636
|
+
*/
|
|
637
|
+
export function withAuth<TArgs extends unknown[], TReturn>(
|
|
638
|
+
auth: AuthFunction,
|
|
639
|
+
handler: (session: Session, ...args: TArgs) => Promise<TReturn>,
|
|
640
|
+
): (...args: TArgs) => Promise<TReturn> {
|
|
641
|
+
return async (...args: TArgs) => {
|
|
642
|
+
const session = await auth();
|
|
643
|
+
|
|
644
|
+
if (!session) {
|
|
645
|
+
throw new Error('Unauthorized: No active session');
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Check for session error - distinguish between error types:
|
|
649
|
+
// - "TokenExpired": Access token expired. Server can't make authenticated API calls,
|
|
650
|
+
// but handler may not need to. If handler needs tokens, it should check session.error.
|
|
651
|
+
// Throwing here would break SSR for pages that could work with stale data + client refresh.
|
|
652
|
+
// - Other errors (e.g., "RefreshTokenError"): Refresh token is invalid/expired.
|
|
653
|
+
// User must re-authenticate, throw to signal unauthorized.
|
|
654
|
+
if (session.error && session.error !== 'TokenExpired') {
|
|
655
|
+
throw new Error(`Unauthorized: ${session.error}`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Pass session to handler - handler can check session.error === 'TokenExpired'
|
|
659
|
+
// if it needs to make authenticated API calls and handle accordingly
|
|
660
|
+
return handler(session, ...args);
|
|
661
|
+
};
|
|
662
|
+
}
|
package/src/refresh.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TOKEN_EXPIRY_BUFFER_SECONDS } from './constants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if the access token is expired or about to expire
|
|
5
|
+
* Returns true if token expires within the buffer time (default 60 seconds)
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* If accessTokenExpires is not a valid number (undefined, null, NaN),
|
|
9
|
+
* returns true to trigger a refresh as a safety measure.
|
|
10
|
+
*/
|
|
11
|
+
export function isTokenExpired(
|
|
12
|
+
accessTokenExpires: number,
|
|
13
|
+
bufferSeconds: number = TOKEN_EXPIRY_BUFFER_SECONDS,
|
|
14
|
+
): boolean {
|
|
15
|
+
// If accessTokenExpires is invalid (not a number or NaN), treat as expired
|
|
16
|
+
// This prevents NaN comparisons from incorrectly returning false
|
|
17
|
+
if (typeof accessTokenExpires !== 'number' || Number.isNaN(accessTokenExpires)) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return Date.now() >= accessTokenExpires - bufferSeconds * 1000;
|
|
21
|
+
}
|