Skip to content

Commit bd01b99

Browse files
authored
feat(passport): auto refresh tokens on the client side (#2790)
1 parent 4e0956c commit bd01b99

6 files changed

Lines changed: 849 additions & 262 deletions

File tree

packages/auth-next-client/README.md

Lines changed: 117 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This package provides minimal client-side utilities for Next.js applications usi
1010

1111
- `useLogin` - Hook for login flows with state management (loading, error)
1212
- `useLogout` - Hook for logout with federated logout support (clears both local and upstream sessions)
13-
- `useImmutableSession` - Hook that provides session state and a `getUser` function for wallet integration
13+
- `useImmutableSession` - Hook that provides session state, `getAccessToken()` for guaranteed-fresh tokens, and `getUser` for wallet integration
1414
- `CallbackPage` - OAuth callback handler component
1515

1616
For server-side utilities, use [`@imtbl/auth-next-server`](../auth-next-server).
@@ -395,7 +395,11 @@ With federated logout, the auth server's session is also cleared, so users can s
395395

396396
### `useImmutableSession()`
397397

398-
A convenience hook that wraps `next-auth/react`'s `useSession` with a `getUser` function for wallet integration.
398+
A convenience hook that wraps `next-auth/react`'s `useSession` with:
399+
400+
- `getAccessToken()` -- async function that returns a **guaranteed-fresh** access token
401+
- `getUser()` -- function for wallet integration
402+
- Automatic token refresh -- detects expired tokens and refreshes on demand
399403

400404
```tsx
401405
"use client";
@@ -404,10 +408,12 @@ import { useImmutableSession } from "@imtbl/auth-next-client";
404408

405409
function MyComponent() {
406410
const {
407-
session, // Session with tokens
411+
session, // Session metadata (user info, zkEvm, error) -- does NOT include accessToken
408412
status, // 'loading' | 'authenticated' | 'unauthenticated'
409413
isLoading, // True during initial load
410414
isAuthenticated, // True when logged in
415+
isRefreshing, // True during token refresh
416+
getAccessToken, // Async function: returns a guaranteed-fresh access token
411417
getUser, // Function for wallet integration
412418
} = useImmutableSession();
413419

@@ -420,13 +426,36 @@ function MyComponent() {
420426

421427
#### Return Value
422428

423-
| Property | Type | Description |
424-
| ----------------- | --------------------------------------------------- | ---------------------------------------------------------------- |
425-
| `session` | `ImmutableSession \| null` | Session with access/refresh tokens |
426-
| `status` | `string` | Auth status: `'loading'`, `'authenticated'`, `'unauthenticated'` |
427-
| `isLoading` | `boolean` | Whether initial auth state is loading |
428-
| `isAuthenticated` | `boolean` | Whether user is authenticated |
429-
| `getUser` | `(forceRefresh?: boolean) => Promise<User \| null>` | Get user function for wallet integration |
429+
| Property | Type | Description |
430+
| ----------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------- |
431+
| `session` | `ImmutableSession \| null` | Session metadata (user, zkEvm, error). Does **not** include `accessToken` -- see below. |
432+
| `status` | `string` | Auth status: `'loading'`, `'authenticated'`, `'unauthenticated'` |
433+
| `isLoading` | `boolean` | Whether initial auth state is loading |
434+
| `isAuthenticated` | `boolean` | Whether user is authenticated |
435+
| `isRefreshing` | `boolean` | Whether a token refresh is in progress |
436+
| `getAccessToken` | `() => Promise<string>` | Get a guaranteed-fresh access token. Throws if not authenticated or refresh fails. |
437+
| `getUser` | `(forceRefresh?: boolean) => Promise<User \| null>` | Get user function for wallet integration |
438+
439+
#### Why no `accessToken` on `session`?
440+
441+
The `session` object intentionally does **not** expose `accessToken`. This is a deliberate design choice to prevent consumers from accidentally using a stale/expired token.
442+
443+
**Always use `getAccessToken()`** to obtain a token for authenticated requests:
444+
445+
```tsx
446+
// ✅ Correct - always fresh
447+
const token = await getAccessToken();
448+
await authenticatedGet("/api/data", token);
449+
450+
// ❌ Incorrect - session.accessToken does not exist on the type
451+
const token = session?.accessToken; // TypeScript error
452+
```
453+
454+
`getAccessToken()` guarantees freshness:
455+
456+
- **Fast path**: If the current token is valid, returns immediately (no network call).
457+
- **Slow path**: If the token is expired, triggers a server-side refresh and **blocks** (awaits) until the fresh token is available.
458+
- **Deduplication**: Multiple concurrent calls share a single refresh request.
430459

431460
#### Checking Authentication Status
432461

@@ -465,6 +494,64 @@ const { status } = useImmutableSession();
465494
if (status !== "authenticated") return <div>Please log in</div>;
466495
```
467496

497+
#### Using `getAccessToken()` in Practice
498+
499+
**SWR fetcher:**
500+
501+
```tsx
502+
import useSWR from "swr";
503+
import { useImmutableSession } from "@imtbl/auth-next-client";
504+
505+
function useProfile() {
506+
const { getAccessToken, isAuthenticated } = useImmutableSession();
507+
508+
return useSWR(
509+
isAuthenticated ? "/passport-profile/v1/profile" : null,
510+
async (path) => {
511+
const token = await getAccessToken(); // blocks until fresh
512+
return authenticatedGet(path, token);
513+
},
514+
);
515+
}
516+
```
517+
518+
**Event handler:**
519+
520+
```tsx
521+
import { useImmutableSession } from "@imtbl/auth-next-client";
522+
523+
function ClaimRewardButton({ questId }: { questId: string }) {
524+
const { getAccessToken } = useImmutableSession();
525+
526+
const handleClaim = async () => {
527+
const token = await getAccessToken(); // blocks until fresh
528+
await authenticatedPost("/v1/quests/claim", token, { questId });
529+
};
530+
531+
return <button onClick={handleClaim}>Claim</button>;
532+
}
533+
```
534+
535+
**Periodic polling:**
536+
537+
```tsx
538+
import useSWR from "swr";
539+
import { useImmutableSession } from "@imtbl/auth-next-client";
540+
541+
function ActivityFeed() {
542+
const { getAccessToken, isAuthenticated } = useImmutableSession();
543+
544+
return useSWR(
545+
isAuthenticated ? "/v1/activities" : null,
546+
async (path) => {
547+
const token = await getAccessToken();
548+
return authenticatedGet(path, token);
549+
},
550+
{ refreshInterval: 10000 }, // polls every 10s, always gets a fresh token
551+
);
552+
}
553+
```
554+
468555
#### The `getUser` Function
469556

470557
The `getUser` function returns fresh tokens from the session. It accepts an optional `forceRefresh` parameter:
@@ -489,11 +576,11 @@ When `forceRefresh` is `true`:
489576

490577
### ImmutableSession
491578

492-
The session type returned by `useImmutableSession`:
579+
The session type returned by `useImmutableSession`. Note that `accessToken` is intentionally **not** included -- use `getAccessToken()` instead to obtain a guaranteed-fresh token.
493580

494581
```typescript
495582
interface ImmutableSession {
496-
accessToken: string;
583+
// accessToken is NOT exposed -- use getAccessToken() instead
497584
refreshToken?: string;
498585
idToken?: string;
499586
accessTokenExpires: number;
@@ -568,17 +655,19 @@ interface LogoutConfig {
568655

569656
The session may contain an `error` field indicating authentication issues:
570657

571-
| Error | Description | Handling |
572-
| --------------------- | --------------------- | --------------------------------------------- |
573-
| `"TokenExpired"` | Access token expired | Server-side refresh will happen automatically |
574-
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
658+
| Error | Description | Handling |
659+
| --------------------- | --------------------- | -------------------------------------------- |
660+
| `"TokenExpired"` | Access token expired | Proactive refresh handles this automatically |
661+
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
662+
663+
`getAccessToken()` throws an error if the token cannot be obtained (e.g., refresh failure). Handle it with try/catch:
575664

576665
```tsx
577666
import { useImmutableSession } from "@imtbl/auth-next-client";
578-
import { signIn, signOut } from "next-auth/react";
667+
import { signOut } from "next-auth/react";
579668

580669
function ProtectedContent() {
581-
const { session, isAuthenticated } = useImmutableSession();
670+
const { session, isAuthenticated, getAccessToken } = useImmutableSession();
582671

583672
if (session?.error === "RefreshTokenError") {
584673
return (
@@ -593,11 +682,20 @@ function ProtectedContent() {
593682
return (
594683
<div>
595684
<p>Please sign in to continue.</p>
596-
<button onClick={() => signIn()}>Sign In</button>
597685
</div>
598686
);
599687
}
600688

689+
const handleFetch = async () => {
690+
try {
691+
const token = await getAccessToken();
692+
// Use token for authenticated requests
693+
} catch (error) {
694+
// Token refresh failed -- session may be expired
695+
console.error("Failed to get access token:", error);
696+
}
697+
};
698+
601699
return <div>Protected content here</div>;
602700
}
603701
```

packages/auth-next-client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
"devDependencies": {
5959
"@swc/core": "^1.4.2",
6060
"@swc/jest": "^0.2.37",
61+
"@testing-library/jest-dom": "^5.16.5",
62+
"@testing-library/react": "^13.4.0",
6163
"@types/jest": "^29.5.12",
6264
"@types/node": "^22.10.7",
6365
"@types/react": "^18.3.5",

packages/auth-next-client/src/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,9 @@ export const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
3737
* Default token expiry in milliseconds
3838
*/
3939
export const DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1000;
40+
41+
/**
42+
* Buffer time in milliseconds before token expiry to trigger refresh.
43+
* Matches TOKEN_EXPIRY_BUFFER_SECONDS (60s) in @imtbl/auth-next-server.
44+
*/
45+
export const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;

0 commit comments

Comments
 (0)