@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/.eslintrc.cjs +18 -0
- package/LICENSE.md +176 -0
- package/dist/node/callback.d.ts +56 -0
- package/dist/node/constants.d.ts +32 -0
- package/dist/node/index.cjs +501 -0
- package/dist/node/index.d.ts +15 -0
- package/dist/node/index.js +486 -0
- package/dist/node/provider.d.ts +66 -0
- package/dist/node/types.d.ts +133 -0
- package/dist/node/utils/token.d.ts +8 -0
- package/jest.config.ts +16 -0
- package/package.json +70 -0
- package/src/callback.tsx +281 -0
- package/src/constants.ts +39 -0
- package/src/index.ts +45 -0
- package/src/provider.tsx +547 -0
- package/src/types.ts +148 -0
- package/src/utils/token.ts +39 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +19 -0
- package/tsconfig.types.json +8 -0
- package/tsup.config.ts +33 -0
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
|
+
}
|
package/src/callback.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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';
|