@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/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
+ }