@imtbl/auth-next-client 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/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@imtbl/auth-next-client",
3
+ "version": "2.12.5-alpha.13",
4
+ "description": "Immutable Auth.js v5 integration for Next.js - Client-side components",
5
+ "author": "Immutable",
6
+ "license": "Apache-2.0",
7
+ "repository": "immutable/ts-immutable-sdk.git",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "type": "module",
12
+ "main": "./dist/node/index.cjs",
13
+ "module": "./dist/node/index.js",
14
+ "types": "./dist/node/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "development": {
18
+ "types": "./src/index.ts",
19
+ "require": "./dist/node/index.cjs",
20
+ "default": "./dist/node/index.js"
21
+ },
22
+ "default": {
23
+ "types": "./dist/node/index.d.ts",
24
+ "require": "./dist/node/index.cjs",
25
+ "default": "./dist/node/index.js"
26
+ }
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "@imtbl/auth": "2.12.5-alpha.13",
31
+ "@imtbl/auth-next-server": "2.12.5-alpha.13"
32
+ },
33
+ "peerDependencies": {
34
+ "next": "^14.2.0 || ^15.0.0",
35
+ "next-auth": "^5.0.0-beta.25",
36
+ "react": "^18.2.0 || ^19.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "next": {
40
+ "optional": true
41
+ },
42
+ "next-auth": {
43
+ "optional": true
44
+ },
45
+ "react": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@swc/core": "^1.4.2",
51
+ "@swc/jest": "^0.2.37",
52
+ "@types/jest": "^29.5.12",
53
+ "@types/node": "^22.10.7",
54
+ "@types/react": "^18.3.5",
55
+ "eslint": "^8.56.0",
56
+ "jest": "^29.7.0",
57
+ "next": "^15.1.6",
58
+ "next-auth": "^5.0.0-beta.30",
59
+ "react": "^18.2.0",
60
+ "tsup": "^8.3.0",
61
+ "typescript": "^5.6.2"
62
+ },
63
+ "scripts": {
64
+ "build": "tsup && pnpm build:types",
65
+ "build:types": "tsc --project tsconfig.types.json",
66
+ "clean": "rm -rf dist",
67
+ "lint": "eslint src/**/*.{ts,tsx} --max-warnings=0",
68
+ "test": "jest --passWithNoTests"
69
+ }
70
+ }
@@ -0,0 +1,281 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState, useRef } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { signIn } from 'next-auth/react';
6
+ import { Auth } from '@imtbl/auth';
7
+ import type { ImmutableUserClient, ImmutableTokenDataClient } from './types';
8
+ import { getTokenExpiry } from './utils/token';
9
+ import {
10
+ DEFAULT_AUTH_DOMAIN,
11
+ DEFAULT_AUDIENCE,
12
+ DEFAULT_SCOPE,
13
+ IMMUTABLE_PROVIDER_ID,
14
+ } from './constants';
15
+
16
+ /**
17
+ * Get search params from the current URL.
18
+ * Uses window.location.search directly to avoid issues with useSearchParams()
19
+ * in Pages Router, where the hook may not be hydrated during initial render.
20
+ */
21
+ function getSearchParams(): URLSearchParams {
22
+ if (typeof window === 'undefined') {
23
+ return new URLSearchParams();
24
+ }
25
+ return new URLSearchParams(window.location.search);
26
+ }
27
+
28
+ /**
29
+ * Config for CallbackPage
30
+ */
31
+ interface CallbackConfig {
32
+ clientId: string;
33
+ redirectUri: string;
34
+ popupRedirectUri?: string;
35
+ logoutRedirectUri?: string;
36
+ audience?: string;
37
+ scope?: string;
38
+ authenticationDomain?: string;
39
+ passportDomain?: string;
40
+ }
41
+
42
+ export interface CallbackPageProps {
43
+ /**
44
+ * Immutable auth configuration
45
+ */
46
+ config: CallbackConfig;
47
+ /**
48
+ * URL to redirect to after successful authentication (when not in popup).
49
+ * Can be a string or a function that receives the authenticated user.
50
+ * If a function returns void/undefined, defaults to "/".
51
+ * @default "/"
52
+ */
53
+ redirectTo?: string | ((user: ImmutableUserClient) => string | void);
54
+ /**
55
+ * Custom loading component
56
+ */
57
+ loadingComponent?: React.ReactElement | null;
58
+ /**
59
+ * Custom error component
60
+ */
61
+ errorComponent?: (error: string) => React.ReactElement | null;
62
+ /**
63
+ * Callback fired after successful authentication.
64
+ * Receives the authenticated user as a parameter.
65
+ * Called before redirect (non-popup) or before window.close (popup).
66
+ * If this callback returns a Promise, it will be awaited before proceeding.
67
+ */
68
+ onSuccess?: (user: ImmutableUserClient) => void | Promise<void>;
69
+ /**
70
+ * Callback fired when authentication fails.
71
+ * Receives the error message as a parameter.
72
+ * Called before the error UI is displayed.
73
+ */
74
+ onError?: (error: string) => void;
75
+ }
76
+
77
+ /**
78
+ * Callback page component for handling OAuth redirects (App Router version).
79
+ *
80
+ * Use this in your callback page to process authentication responses.
81
+ */
82
+ export function CallbackPage({
83
+ config,
84
+ redirectTo = '/',
85
+ loadingComponent = null,
86
+ errorComponent,
87
+ onSuccess,
88
+ onError,
89
+ }: CallbackPageProps) {
90
+ const router = useRouter();
91
+ const [error, setError] = useState<string | null>(null);
92
+ // Track whether callback has been processed to prevent double invocation
93
+ // (React 18 StrictMode runs effects twice, and OAuth codes are single-use)
94
+ const callbackProcessedRef = useRef(false);
95
+
96
+ useEffect(() => {
97
+ // Get search params directly from window.location to ensure compatibility
98
+ // with both App Router and Pages Router. useSearchParams() from next/navigation
99
+ // has hydration issues in Pages Router where params may be empty initially.
100
+ const searchParams = getSearchParams();
101
+
102
+ const handleCallback = async () => {
103
+ try {
104
+ // Create Auth instance to handle the callback
105
+ const auth = new Auth({
106
+ clientId: config.clientId,
107
+ redirectUri: config.redirectUri,
108
+ popupRedirectUri: config.popupRedirectUri,
109
+ logoutRedirectUri: config.logoutRedirectUri,
110
+ audience: config.audience || DEFAULT_AUDIENCE,
111
+ scope: config.scope || DEFAULT_SCOPE,
112
+ authenticationDomain: config.authenticationDomain || DEFAULT_AUTH_DOMAIN,
113
+ passportDomain: config.passportDomain,
114
+ });
115
+
116
+ // Process the callback - this extracts tokens from the URL and returns the user
117
+ const authUser = await auth.loginCallback();
118
+
119
+ // Check if we're in a popup window
120
+ if (window.opener) {
121
+ // Validate authUser before closing - if loginCallback failed silently,
122
+ // we need to show an error instead of closing the popup
123
+ if (!authUser) {
124
+ throw new Error('Authentication failed: no user data received from login callback');
125
+ }
126
+ // Create user object for callbacks
127
+ const user: ImmutableUserClient = {
128
+ sub: authUser.profile.sub,
129
+ email: authUser.profile.email,
130
+ nickname: authUser.profile.nickname,
131
+ };
132
+ // Call onSuccess callback before closing popup
133
+ if (onSuccess) {
134
+ await onSuccess(user);
135
+ }
136
+ // Close the popup - the parent window will receive the tokens via Auth events
137
+ window.close();
138
+ } else if (authUser) {
139
+ // Not in a popup - create NextAuth session before redirecting
140
+ // This ensures SSR/session-based auth is authenticated
141
+ const tokenData: ImmutableTokenDataClient = {
142
+ accessToken: authUser.accessToken,
143
+ refreshToken: authUser.refreshToken,
144
+ idToken: authUser.idToken,
145
+ accessTokenExpires: getTokenExpiry(authUser.accessToken),
146
+ profile: {
147
+ sub: authUser.profile.sub,
148
+ email: authUser.profile.email,
149
+ nickname: authUser.profile.nickname,
150
+ },
151
+ zkEvm: authUser.zkEvm,
152
+ };
153
+
154
+ // Sign in to NextAuth with the tokens
155
+ // Note: signIn uses the basePath from SessionProvider context,
156
+ // so ensure CallbackPage is rendered within ImmutableAuthProvider
157
+ const result = await signIn(IMMUTABLE_PROVIDER_ID, {
158
+ tokens: JSON.stringify(tokenData),
159
+ redirect: false,
160
+ });
161
+
162
+ // signIn with redirect: false returns a result object instead of throwing
163
+ if (result?.error) {
164
+ throw new Error(`NextAuth sign-in failed: ${result.error}`);
165
+ }
166
+ if (!result?.ok) {
167
+ throw new Error('NextAuth sign-in failed: unknown error');
168
+ }
169
+
170
+ // Create user object for callbacks and dynamic redirect
171
+ const user: ImmutableUserClient = {
172
+ sub: authUser.profile.sub,
173
+ email: authUser.profile.email,
174
+ nickname: authUser.profile.nickname,
175
+ };
176
+
177
+ // Call onSuccess callback before redirect
178
+ if (onSuccess) {
179
+ await onSuccess(user);
180
+ }
181
+
182
+ // Resolve redirect path (can be string or function)
183
+ const resolvedRedirectTo = typeof redirectTo === 'function'
184
+ ? redirectTo(user) || '/'
185
+ : redirectTo;
186
+
187
+ // Only redirect after successful session creation
188
+ router.replace(resolvedRedirectTo);
189
+ } else {
190
+ // authUser is undefined - loginCallback failed silently
191
+ // This can happen if the OIDC signinCallback returns null
192
+ throw new Error('Authentication failed: no user data received from login callback');
193
+ }
194
+ } catch (err) {
195
+ const errorMessage = err instanceof Error ? err.message : 'Authentication failed';
196
+ if (onError) {
197
+ onError(errorMessage);
198
+ }
199
+ setError(errorMessage);
200
+ }
201
+ };
202
+
203
+ const handleOAuthError = () => {
204
+ // OAuth providers return error and error_description when authentication fails
205
+ // (e.g., user cancels, consent denied, invalid request)
206
+ const errorCode = searchParams.get('error');
207
+ const errorDescription = searchParams.get('error_description');
208
+
209
+ const errorMessage = errorDescription || errorCode || 'Authentication failed';
210
+ if (onError) {
211
+ onError(errorMessage);
212
+ }
213
+ setError(errorMessage);
214
+ };
215
+
216
+ // Guard against double invocation (React 18 StrictMode runs effects twice)
217
+ if (callbackProcessedRef.current) {
218
+ return;
219
+ }
220
+
221
+ const hasError = searchParams.get('error');
222
+ const hasCode = searchParams.get('code');
223
+
224
+ // Handle OAuth error responses (user cancelled, consent denied, etc.)
225
+ if (hasError) {
226
+ callbackProcessedRef.current = true;
227
+ handleOAuthError();
228
+ return;
229
+ }
230
+
231
+ // Handle successful OAuth callback with authorization code
232
+ if (hasCode) {
233
+ callbackProcessedRef.current = true;
234
+ handleCallback();
235
+ return;
236
+ }
237
+
238
+ // No OAuth parameters present - user navigated directly to callback page,
239
+ // bookmarked it, or OAuth redirect lost its parameters
240
+ callbackProcessedRef.current = true;
241
+ const errorMessage = 'Invalid callback: missing OAuth parameters. Please try logging in again.';
242
+ if (onError) {
243
+ onError(errorMessage);
244
+ }
245
+ setError(errorMessage);
246
+ }, [router, config, redirectTo, onSuccess, onError]);
247
+
248
+ if (error) {
249
+ if (errorComponent) {
250
+ return errorComponent(error);
251
+ }
252
+
253
+ return (
254
+ <div style={{ padding: '2rem', textAlign: 'center' }}>
255
+ <h2 style={{ color: '#dc3545' }}>Authentication Error</h2>
256
+ <p>{error}</p>
257
+ <button
258
+ onClick={() => router.push('/')}
259
+ type="button"
260
+ style={{
261
+ padding: '0.5rem 1rem',
262
+ marginTop: '1rem',
263
+ cursor: 'pointer',
264
+ }}
265
+ >
266
+ Return to Home
267
+ </button>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ if (loadingComponent) {
273
+ return loadingComponent;
274
+ }
275
+
276
+ return (
277
+ <div style={{ padding: '2rem', textAlign: 'center' }}>
278
+ <p>Completing authentication...</p>
279
+ </div>
280
+ );
281
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared constants for @imtbl/auth-next-client
3
+ */
4
+
5
+ /**
6
+ * Default Immutable authentication domain
7
+ */
8
+ export const DEFAULT_AUTH_DOMAIN = 'https://auth.immutable.com';
9
+
10
+ /**
11
+ * Default OAuth audience
12
+ */
13
+ export const DEFAULT_AUDIENCE = 'platform_api';
14
+
15
+ /**
16
+ * Default OAuth scopes
17
+ */
18
+ export const DEFAULT_SCOPE = 'openid profile email offline_access transact';
19
+
20
+ /**
21
+ * NextAuth credentials provider ID for Immutable
22
+ */
23
+ export const IMMUTABLE_PROVIDER_ID = 'immutable';
24
+
25
+ /**
26
+ * Default NextAuth API base path
27
+ */
28
+ export const DEFAULT_NEXTAUTH_BASE_PATH = '/api/auth';
29
+
30
+ /**
31
+ * Default token expiry in seconds (15 minutes)
32
+ * Used as fallback when exp claim cannot be extracted from JWT
33
+ */
34
+ export const DEFAULT_TOKEN_EXPIRY_SECONDS = 900;
35
+
36
+ /**
37
+ * Default token expiry in milliseconds
38
+ */
39
+ export const DEFAULT_TOKEN_EXPIRY_MS = DEFAULT_TOKEN_EXPIRY_SECONDS * 1000;
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @imtbl/auth-next-client
3
+ *
4
+ * Client-side components for Immutable Auth.js v5 integration with Next.js.
5
+ * This package provides React components and hooks for authentication.
6
+ *
7
+ * Note: This package depends on @imtbl/auth and should only be used in
8
+ * browser/client environments. For server-side utilities, use @imtbl/auth-next-server.
9
+ */
10
+
11
+ // Client-side components and hooks
12
+ export {
13
+ ImmutableAuthProvider,
14
+ useImmutableAuth,
15
+ useAccessToken,
16
+ useHydratedData,
17
+ type UseHydratedDataResult,
18
+ type HydratedDataProps,
19
+ } from './provider';
20
+
21
+ export { CallbackPage, type CallbackPageProps } from './callback';
22
+
23
+ // Re-export types
24
+ export type {
25
+ ImmutableAuthProviderProps,
26
+ UseImmutableAuthReturn,
27
+ ImmutableUserClient,
28
+ ImmutableTokenDataClient,
29
+ ZkEvmInfo,
30
+ } from './types';
31
+
32
+ // Re-export server types for convenience (commonly used together)
33
+ export type {
34
+ ImmutableAuthConfig,
35
+ ImmutableTokenData,
36
+ ImmutableUser,
37
+ AuthProps,
38
+ AuthPropsWithData,
39
+ ProtectedAuthProps,
40
+ ProtectedAuthPropsWithData,
41
+ } from '@imtbl/auth-next-server';
42
+
43
+ // Re-export login-related types from @imtbl/auth for convenience
44
+ export type { LoginOptions, DirectLoginOptions } from '@imtbl/auth';
45
+ export { MarketingConsentStatus } from '@imtbl/auth';