diff --git a/packages/auth-next-client/README.md b/packages/auth-next-client/README.md index d0b58495e7..d0a36be569 100644 --- a/packages/auth-next-client/README.md +++ b/packages/auth-next-client/README.md @@ -582,7 +582,7 @@ The session type returned by `useImmutableSession`. Note that `accessToken` is i interface ImmutableSession { // accessToken is NOT exposed -- use getAccessToken() instead refreshToken?: string; - idToken?: string; + idToken?: string; // Only present transiently after sign-in or token refresh (not stored in cookie) accessTokenExpires: number; zkEvm?: { ethAddress: string; @@ -597,6 +597,8 @@ interface ImmutableSession { } ``` +> **Note:** The `idToken` is **not** stored in the session cookie (to avoid CloudFront 413 errors from oversized headers). It is only present in the session response transiently after sign-in or token refresh. `@imtbl/auth-next-client` automatically persists it in `localStorage` so that `getUser()` always returns a valid `idToken` for wallet operations. All data extracted from the idToken (`email`, `nickname`, `zkEvm`) remains in the cookie as separate fields and is always available in the session. + ### LoginConfig Configuration for the `useLogin` hook's login functions: diff --git a/packages/auth-next-client/src/callback.tsx b/packages/auth-next-client/src/callback.tsx index 87cd74711e..96f015258a 100644 --- a/packages/auth-next-client/src/callback.tsx +++ b/packages/auth-next-client/src/callback.tsx @@ -6,6 +6,7 @@ import { signIn } from 'next-auth/react'; import { handleLoginCallback as handleAuthCallback, type TokenResponse } from '@imtbl/auth'; import type { ImmutableUserClient } from './types'; import { IMMUTABLE_PROVIDER_ID } from './constants'; +import { storeIdToken } from './idTokenStorage'; /** * Config for CallbackPage - matches LoginConfig from @imtbl/auth @@ -159,6 +160,12 @@ export function CallbackPage({ // Not in a popup - sign in to NextAuth with the tokens const tokenData = mapTokensToSignInData(tokens); + // Persist idToken to localStorage before signIn so it's available + // immediately. The cookie won't contain idToken (stripped by jwt.encode). + if (tokens.idToken) { + storeIdToken(tokens.idToken); + } + const result = await signIn(IMMUTABLE_PROVIDER_ID, { tokens: JSON.stringify(tokenData), redirect: false, diff --git a/packages/auth-next-client/src/hooks.tsx b/packages/auth-next-client/src/hooks.tsx index 2dafa5533e..2aa038b79f 100644 --- a/packages/auth-next-client/src/hooks.tsx +++ b/packages/auth-next-client/src/hooks.tsx @@ -19,6 +19,7 @@ import { logoutWithRedirect as rawLogoutWithRedirect, } from '@imtbl/auth'; import { IMMUTABLE_PROVIDER_ID, TOKEN_EXPIRY_BUFFER_MS } from './constants'; +import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage'; // --------------------------------------------------------------------------- // Module-level deduplication for session refresh @@ -189,6 +190,20 @@ export function useImmutableSession(): UseImmutableSessionReturn { } }, [session?.accessTokenExpires]); + // --------------------------------------------------------------------------- + // Sync idToken to localStorage + // --------------------------------------------------------------------------- + + // The idToken is stripped from the cookie by jwt.encode on the server to avoid + // CloudFront 413 errors. It is only present in the session response transiently + // after sign-in or token refresh. When present, persist it in localStorage so + // that getUser() can always return it (used by wallet's MagicTEESigner). + useEffect(() => { + if (session?.idToken) { + storeIdToken(session.idToken); + } + }, [session?.idToken]); + /** * Get user function for wallet integration. * Returns a User object compatible with @imtbl/wallet's getUser option. @@ -213,6 +228,10 @@ export function useImmutableSession(): UseImmutableSessionReturn { // Also update the ref so subsequent calls get the fresh data if (currentSession) { sessionRef.current = currentSession; + // Immediately persist fresh idToken to localStorage (avoids race with useEffect) + if (currentSession.idToken) { + storeIdToken(currentSession.idToken); + } } } catch (error) { // eslint-disable-next-line no-console @@ -229,6 +248,10 @@ export function useImmutableSession(): UseImmutableSessionReturn { if (refreshed) { currentSession = refreshed as ImmutableSessionInternal; sessionRef.current = currentSession; + // Persist fresh idToken to localStorage immediately + if (currentSession.idToken) { + storeIdToken(currentSession.idToken); + } } else { currentSession = sessionRef.current; } @@ -252,7 +275,9 @@ export function useImmutableSession(): UseImmutableSessionReturn { return { accessToken: currentSession.accessToken, refreshToken: currentSession.refreshToken, - idToken: currentSession.idToken, + // Prefer session idToken (fresh after sign-in or refresh, before useEffect + // stores it), fall back to localStorage for normal reads (cookie has no idToken). + idToken: currentSession.idToken || getStoredIdToken(), profile: { sub: currentSession.user?.sub ?? '', email: currentSession.user?.email ?? undefined, @@ -387,6 +412,12 @@ export function useLogin(): UseLoginReturn { profile: { sub: string; email?: string; nickname?: string }; zkEvm?: ZkEvmInfo; }) => { + // Persist idToken to localStorage before signIn so it's available immediately. + // The cookie won't contain idToken (stripped by jwt.encode on the server). + if (tokens.idToken) { + storeIdToken(tokens.idToken); + } + const result = await signIn(IMMUTABLE_PROVIDER_ID, { tokens: JSON.stringify(tokens), redirect: false, @@ -550,6 +581,9 @@ export function useLogout(): UseLogoutReturn { setError(null); try { + // Clear idToken from localStorage before clearing session + clearStoredIdToken(); + // First, clear the NextAuth session (this clears the JWT cookie) // We use redirect: false to handle the redirect ourselves for federated logout await signOut({ redirect: false }); diff --git a/packages/auth-next-client/src/idTokenStorage.ts b/packages/auth-next-client/src/idTokenStorage.ts new file mode 100644 index 0000000000..9d76a8e346 --- /dev/null +++ b/packages/auth-next-client/src/idTokenStorage.ts @@ -0,0 +1,56 @@ +/** + * Utility for persisting idToken in localStorage. + * + * The idToken is stripped from the NextAuth session cookie (via a custom + * jwt.encode in @imtbl/auth-next-server) to keep cookie size under CDN header + * limits (CloudFront 20 KB). Instead, the client stores idToken in + * localStorage so that wallet operations (e.g., MagicTEESigner) can still + * access it via getUser(). + * + * All functions are safe to call during SSR or in restricted environments + * (e.g., incognito mode with localStorage disabled) -- they silently no-op. + */ + +const ID_TOKEN_STORAGE_KEY = 'imtbl_id_token'; + +/** + * Store the idToken in localStorage. + * @param idToken - The raw ID token JWT string + */ +export function storeIdToken(idToken: string): void { + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(ID_TOKEN_STORAGE_KEY, idToken); + } + } catch { + // Silently ignore -- localStorage may be unavailable (SSR, incognito, etc.) + } +} + +/** + * Retrieve the idToken from localStorage. + * @returns The stored idToken, or undefined if not available. + */ +export function getStoredIdToken(): string | undefined { + try { + if (typeof window !== 'undefined' && window.localStorage) { + return window.localStorage.getItem(ID_TOKEN_STORAGE_KEY) ?? undefined; + } + } catch { + // Silently ignore + } + return undefined; +} + +/** + * Remove the idToken from localStorage (e.g., on logout). + */ +export function clearStoredIdToken(): void { + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(ID_TOKEN_STORAGE_KEY); + } + } catch { + // Silently ignore + } +} diff --git a/packages/auth-next-server/README.md b/packages/auth-next-server/README.md index 014ee879b0..1a64b4a171 100644 --- a/packages/auth-next-server/README.md +++ b/packages/auth-next-server/README.md @@ -7,6 +7,7 @@ Server-side utilities for Immutable authentication with Auth.js v5 (NextAuth) in This package provides server-side authentication utilities for Next.js applications using the App Router. It integrates with Auth.js v5 to handle OAuth authentication with Immutable's identity provider. **Key features:** + - Auth.js v5 configuration for Immutable authentication - Route protection via middleware - Server utilities for authenticated data fetching @@ -40,10 +41,12 @@ Create a file to configure Immutable authentication: import NextAuth from "next-auth"; import { createAuthConfig } from "@imtbl/auth-next-server"; -export const { handlers, auth, signIn, signOut } = NextAuth(createAuthConfig({ - clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, - redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`, -})); +export const { handlers, auth, signIn, signOut } = NextAuth( + createAuthConfig({ + clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, + redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`, + }), +); ``` ### 2. Set Up API Route @@ -76,27 +79,29 @@ Creates an Auth.js v5 configuration object for Immutable authentication. You pas import NextAuth from "next-auth"; import { createAuthConfig } from "@imtbl/auth-next-server"; -const { handlers, auth, signIn, signOut } = NextAuth(createAuthConfig({ - // Required - clientId: "your-client-id", - redirectUri: "https://your-app.com/callback", - - // Optional - audience: "platform_api", // Default: "platform_api" - scope: "openid profile email offline_access transact", // Default scope - authenticationDomain: "https://auth.immutable.com", // Default domain -})); +const { handlers, auth, signIn, signOut } = NextAuth( + createAuthConfig({ + // Required + clientId: "your-client-id", + redirectUri: "https://your-app.com/callback", + + // Optional + audience: "platform_api", // Default: "platform_api" + scope: "openid profile email offline_access transact", // Default scope + authenticationDomain: "https://auth.immutable.com", // Default domain + }), +); ``` #### Configuration Options -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `clientId` | `string` | Yes | Your Immutable application client ID | -| `redirectUri` | `string` | Yes | OAuth redirect URI configured in Immutable Hub | -| `audience` | `string` | No | OAuth audience (default: `"platform_api"`) | -| `scope` | `string` | No | OAuth scopes (default: `"openid profile email offline_access transact"`) | -| `authenticationDomain` | `string` | No | Auth domain (default: `"https://auth.immutable.com"`) | +| Option | Type | Required | Description | +| ---------------------- | -------- | -------- | ------------------------------------------------------------------------ | +| `clientId` | `string` | Yes | Your Immutable application client ID | +| `redirectUri` | `string` | Yes | OAuth redirect URI configured in Immutable Hub | +| `audience` | `string` | No | OAuth audience (default: `"platform_api"`) | +| `scope` | `string` | No | OAuth scopes (default: `"openid profile email offline_access transact"`) | +| `authenticationDomain` | `string` | No | Auth domain (default: `"https://auth.immutable.com"`) | #### Extending the Configuration @@ -117,19 +122,20 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ secret: process.env.AUTH_SECRET, trustHost: true, basePath: "/api/auth/custom", - + // Extend callbacks (be sure to call the base callbacks first) callbacks: { ...baseConfig.callbacks, async jwt(params) { // Call base jwt callback first - const token = await baseConfig.callbacks?.jwt?.(params) ?? params.token; + const token = (await baseConfig.callbacks?.jwt?.(params)) ?? params.token; // Add your custom logic return token; }, async session(params) { // Call base session callback first - const session = await baseConfig.callbacks?.session?.(params) ?? params.session; + const session = + (await baseConfig.callbacks?.session?.(params)) ?? params.session; // Add your custom logic return session; }, @@ -141,18 +147,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ This package provides several utilities for handling authentication in Server Components. Choose the right one based on your needs: -| Utility | Use Case | Data Fetching | Error Handling | -|---------|----------|---------------|----------------| -| `getAuthProps` | Pass auth state to client, fetch data client-side | No | Manual | -| `getAuthenticatedData` | SSR data fetching with client fallback | Yes | Manual | -| `createProtectedFetchers` | Multiple pages with same error handling | Optional | Centralized | -| `getValidSession` | Custom logic for each auth state | No | Manual (detailed) | +| Utility | Use Case | Data Fetching | Error Handling | +| ------------------------- | ------------------------------------------------- | ------------- | ----------------- | +| `getAuthProps` | Pass auth state to client, fetch data client-side | No | Manual | +| `getAuthenticatedData` | SSR data fetching with client fallback | Yes | Manual | +| `createProtectedFetchers` | Multiple pages with same error handling | Optional | Centralized | +| `getValidSession` | Custom logic for each auth state | No | Manual (detailed) | ### `getAuthProps(auth)` **Use case:** You want to pass authentication state to a Client Component but handle data fetching entirely on the client side. This is the simplest approach when your page doesn't need SSR data fetching. **When to use:** + - Pages where data is fetched client-side (e.g., infinite scroll, real-time updates) - Pages that show a loading skeleton while fetching - When you want full control over loading states in the client @@ -167,11 +174,11 @@ import { DashboardClient } from "./DashboardClient"; export default async function DashboardPage() { const authProps = await getAuthProps(auth); - + if (authProps.authError) { redirect("/login"); } - + // DashboardClient will fetch its own data using useImmutableSession().getUser() return ; } @@ -182,11 +189,13 @@ export default async function DashboardPage() { **Use case:** You want to fetch data server-side for faster initial page loads (SSR), but gracefully fall back to client-side fetching when the token is expired. **When to use:** + - Pages that benefit from SSR (SEO, faster first paint) - Profile pages, settings pages, or any page showing user-specific data - When you want the best of both worlds: SSR when possible, CSR as fallback **How it works:** + 1. If token is valid → fetches data server-side, returns `ssr: true` 2. If token is expired → skips fetch, returns `ssr: false`, client refreshes and fetches 3. Pair with `useHydratedData` hook on the client for seamless handling @@ -208,11 +217,11 @@ async function fetchUserProfile(accessToken: string) { export default async function ProfilePage() { const result = await getAuthenticatedData(auth, fetchUserProfile); - + if (result.authError) { redirect("/login"); } - + // ProfileClient uses useHydratedData() to handle both SSR data and client fallback return ; } @@ -223,11 +232,13 @@ export default async function ProfilePage() { **Use case:** You have multiple protected pages and want to define auth error handling once, rather than repeating `if (authError) redirect(...)` on every page. **When to use:** + - Apps with many protected pages sharing the same error handling logic - When you want DRY (Don't Repeat Yourself) error handling - Teams that want consistent auth error behavior across the app **How it works:** + - Define error handling once in a shared file - Use the returned `getAuthProps` and `getData` functions in your pages - Auth errors automatically trigger your handler (no manual checking needed) @@ -245,7 +256,7 @@ export const { getAuthProps, getData } = createProtectedFetchers( (error) => { // This runs automatically when there's an auth error (e.g., RefreshTokenError) redirect(`/login?error=${error}`); - } + }, ); ``` @@ -259,7 +270,7 @@ export default async function DashboardPage() { const result = await getData(async (token) => { return fetchDashboardData(token); }); - + return ; } ``` @@ -281,6 +292,7 @@ export default async function SettingsPage() { **Use case:** You need fine-grained control over different authentication states and want to handle each case with custom logic. **When to use:** + - Complex pages that render completely different UI based on auth state - When you need to distinguish between "token expired" vs "not authenticated" - Analytics or logging that needs to track specific auth states @@ -294,22 +306,22 @@ import { getValidSession } from "@imtbl/auth-next-server"; export default async function AccountPage() { const result = await getValidSession(auth); - + switch (result.status) { case "authenticated": // Full access - render the complete account page with SSR data const userData = await fetchUserData(result.session.accessToken); return ; - + case "token_expired": // Token expired but user has session - show skeleton, let client refresh // This avoids a flash of "please login" for users who are actually logged in return ; - + case "unauthenticated": // No session at all - show login prompt or redirect return ; - + case "error": // Auth system error (e.g., refresh token revoked) - needs re-login return ; @@ -324,11 +336,13 @@ export default async function AccountPage() { **Use case:** Protect entire sections of your app at the routing level, before pages even render. This is the most efficient way to block unauthenticated access. **When to use:** + - You have groups of pages that all require authentication (e.g., `/dashboard/*`, `/settings/*`) - You want to redirect unauthenticated users before any page code runs - You need consistent protection across many routes without adding checks to each page **When NOT to use:** + - Pages that show different content for authenticated vs unauthenticated users (use page-level checks instead) - Public pages with optional authenticated features @@ -352,17 +366,18 @@ export const config = { #### Middleware Options -| Option | Type | Description | -|--------|------|-------------| -| `loginUrl` | `string` | URL to redirect unauthenticated users (default: `"/login"`) | -| `protectedPaths` | `(string \| RegExp)[]` | Paths that require authentication | -| `publicPaths` | `(string \| RegExp)[]` | Paths that skip authentication (takes precedence) | +| Option | Type | Description | +| ---------------- | ---------------------- | ----------------------------------------------------------- | +| `loginUrl` | `string` | URL to redirect unauthenticated users (default: `"/login"`) | +| `protectedPaths` | `(string \| RegExp)[]` | Paths that require authentication | +| `publicPaths` | `(string \| RegExp)[]` | Paths that skip authentication (takes precedence) | ### `withAuth(auth, handler)` **Use case:** Protect individual API Route Handlers or Server Actions. Ensures the handler only runs for authenticated users. **When to use:** + - API routes that should only be accessible to authenticated users - Server Actions (form submissions, mutations) that require authentication - When you need the session/user info inside the handler @@ -394,11 +409,11 @@ import { auth } from "@/lib/auth"; import { withAuth } from "@imtbl/auth-next-server"; export const transferAsset = withAuth( - auth, + auth, async (session, formData: FormData) => { const assetId = formData.get("assetId") as string; const toAddress = formData.get("toAddress") as string; - + // Use session.user.sub to identify the sender // Use session.accessToken to call Immutable APIs const result = await executeTransfer({ @@ -407,9 +422,9 @@ export const transferAsset = withAuth( assetId, accessToken: session.accessToken, }); - + return result; - } + }, ); ``` @@ -420,22 +435,24 @@ The package augments the Auth.js `Session` type with Immutable-specific fields: ```typescript interface Session { user: { - sub: string; // Immutable user ID + sub: string; // Immutable user ID email?: string; nickname?: string; }; accessToken: string; refreshToken?: string; - idToken?: string; + idToken?: string; // Only present transiently after sign-in or token refresh (not stored in cookie) accessTokenExpires: number; zkEvm?: { ethAddress: string; userAdminAddress: string; }; - error?: string; // "TokenExpired" or "RefreshTokenError" + error?: string; // "TokenExpired" or "RefreshTokenError" } ``` +> **Note:** The `idToken` is **not** stored in the session cookie. It is stripped by a custom `jwt.encode` to keep cookie size under CDN header limits. The `idToken` is only present in the session response transiently after sign-in or token refresh. On the client, `@imtbl/auth-next-client` automatically persists it in `localStorage` so that wallet operations (via `getUser()`) can always access it. All data extracted from the idToken (`email`, `nickname`, `zkEvm`) remains in the cookie as separate fields. + ## Token Refresh ### Automatic Refresh on Token Expiry @@ -453,10 +470,10 @@ import { useImmutableSession } from "@imtbl/auth-next-client"; function MyComponent() { const { getUser } = useImmutableSession(); - + const handleRegistration = async () => { // After zkEVM registration completes... - + // Force refresh to get updated zkEvm claims from IDP const freshUser = await getUser(true); console.log("Updated zkEvm:", freshUser?.zkEvm); @@ -465,6 +482,7 @@ function MyComponent() { ``` When `forceRefresh` is triggered: + 1. Client calls `update({ forceRefresh: true })` via NextAuth 2. The `jwt` callback detects `trigger === 'update'` with `forceRefresh: true` 3. Server performs a token refresh using the refresh token @@ -476,10 +494,10 @@ When `forceRefresh` is triggered: The package also exports utilities for manual token handling: ```typescript -import { - isTokenExpired, // Check if access token is expired - refreshAccessToken, // Manually refresh tokens - extractZkEvmFromIdToken // Extract zkEvm claims from ID token +import { + isTokenExpired, // Check if access token is expired + refreshAccessToken, // Manually refresh tokens + extractZkEvmFromIdToken, // Extract zkEvm claims from ID token } from "@imtbl/auth-next-server"; ``` @@ -487,10 +505,10 @@ import { The session may contain an `error` field indicating authentication issues: -| Error | Description | Recommended Action | -|-------|-------------|-------------------| -| `"TokenExpired"` | Access token expired, refresh token may be valid | Let client refresh via `@imtbl/auth-next-client` | -| `"RefreshTokenError"` | Refresh token invalid/expired | Redirect to login | +| Error | Description | Recommended Action | +| --------------------- | ------------------------------------------------ | ------------------------------------------------ | +| `"TokenExpired"` | Access token expired, refresh token may be valid | Let client refresh via `@imtbl/auth-next-client` | +| `"RefreshTokenError"` | Refresh token invalid/expired | Redirect to login | ## TypeScript diff --git a/packages/auth-next-server/src/config.ts b/packages/auth-next-server/src/config.ts index 34de64c25e..877bcda08f 100644 --- a/packages/auth-next-server/src/config.ts +++ b/packages/auth-next-server/src/config.ts @@ -3,6 +3,7 @@ // @ts-ignore - Type exists in next-auth v5 but TS resolver may use stale types import type { NextAuthConfig } from 'next-auth'; import CredentialsImport from 'next-auth/providers/credentials'; +import { encode as encodeImport } from 'next-auth/jwt'; import type { ImmutableAuthConfig, ImmutableTokenData, UserInfoResponse } from './types'; import { isTokenExpired, refreshAccessToken, extractZkEvmFromIdToken } from './refresh'; import { @@ -15,6 +16,8 @@ import { // may be nested under a 'default' property // eslint-disable-next-line @typescript-eslint/no-explicit-any const Credentials = ((CredentialsImport as any).default || CredentialsImport) as typeof CredentialsImport; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const defaultJwtEncode = ((encodeImport as any).default || encodeImport) as typeof encodeImport; /** * Validate tokens by calling the userinfo endpoint. @@ -72,6 +75,23 @@ export function createAuthConfig(config: ImmutableAuthConfig): NextAuthConfig { const authDomain = config.authenticationDomain || DEFAULT_AUTH_DOMAIN; return { + // Custom jwt.encode: strip idToken from the cookie to reduce size and avoid + // CloudFront 413 "Request Entity Too Large" errors. The idToken (~1-2 KB) is + // still available in session responses (after sign-in or token refresh) because + // the session callback runs BEFORE encode. All data extracted FROM idToken + // (email, nickname, zkEvm) remains in the cookie as separate fields. + // On the client, idToken is persisted in localStorage by @imtbl/auth-next-client. + jwt: { + async encode(params) { + const { token, ...rest } = params; + if (token) { + const { idToken, ...cookieToken } = token as Record; + return defaultJwtEncode({ ...rest, token: cookieToken }); + } + return defaultJwtEncode(params); + }, + }, + providers: [ Credentials({ id: IMMUTABLE_PROVIDER_ID,