@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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Server-side types for @imtbl/auth-next-server
3
+ */
4
+ import type { DefaultSession } from 'next-auth';
5
+ /**
6
+ * zkEVM wallet information for module augmentation
7
+ */
8
+ interface ZkEvmInfo {
9
+ ethAddress: string;
10
+ userAdminAddress: string;
11
+ }
12
+ /**
13
+ * Auth.js v5 module augmentation to add Immutable-specific fields
14
+ * This extends the Session type to include our custom fields
15
+ */
16
+ declare module 'next-auth' {
17
+ interface Session extends DefaultSession {
18
+ user: {
19
+ sub: string;
20
+ email?: string;
21
+ nickname?: string;
22
+ } & DefaultSession['user'];
23
+ accessToken: string;
24
+ refreshToken?: string;
25
+ idToken?: string;
26
+ accessTokenExpires: number;
27
+ zkEvm?: ZkEvmInfo;
28
+ error?: string;
29
+ }
30
+ interface User {
31
+ id: string;
32
+ sub: string;
33
+ email?: string | null;
34
+ nickname?: string;
35
+ accessToken: string;
36
+ refreshToken?: string;
37
+ idToken?: string;
38
+ accessTokenExpires: number;
39
+ zkEvm?: ZkEvmInfo;
40
+ }
41
+ }
42
+ /**
43
+ * Configuration options for Immutable authentication
44
+ */
45
+ export interface ImmutableAuthConfig {
46
+ /**
47
+ * Your Immutable application client ID
48
+ */
49
+ clientId: string;
50
+ /**
51
+ * The OAuth redirect URI configured in your Immutable Hub project
52
+ */
53
+ redirectUri: string;
54
+ /**
55
+ * OAuth audience (default: "platform_api")
56
+ */
57
+ audience?: string;
58
+ /**
59
+ * OAuth scopes (default: "openid profile email offline_access transact")
60
+ */
61
+ scope?: string;
62
+ /**
63
+ * The Immutable authentication domain (default: "https://auth.immutable.com")
64
+ */
65
+ authenticationDomain?: string;
66
+ }
67
+ /**
68
+ * Token data passed from client to server during authentication
69
+ */
70
+ export interface ImmutableTokenData {
71
+ accessToken: string;
72
+ refreshToken?: string;
73
+ idToken?: string;
74
+ accessTokenExpires: number;
75
+ profile: {
76
+ sub: string;
77
+ email?: string;
78
+ nickname?: string;
79
+ };
80
+ zkEvm?: {
81
+ ethAddress: string;
82
+ userAdminAddress: string;
83
+ };
84
+ }
85
+ /**
86
+ * Response from the userinfo endpoint
87
+ */
88
+ export interface UserInfoResponse {
89
+ sub: string;
90
+ email?: string;
91
+ email_verified?: boolean;
92
+ nickname?: string;
93
+ [key: string]: unknown;
94
+ }
95
+ /**
96
+ * zkEVM user data stored in session
97
+ */
98
+ export interface ZkEvmUser {
99
+ ethAddress: string;
100
+ userAdminAddress: string;
101
+ }
102
+ /**
103
+ * Immutable user data structure
104
+ */
105
+ export interface ImmutableUser {
106
+ sub: string;
107
+ email?: string;
108
+ nickname?: string;
109
+ zkEvm?: ZkEvmUser;
110
+ }
111
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Check if pathname matches a string pattern as a path prefix.
3
+ * Ensures proper path boundary checking: '/api' matches '/api' and '/api/users'
4
+ * but NOT '/apiversion' or '/api-docs'.
5
+ *
6
+ * @param pathname - The URL pathname to check
7
+ * @param pattern - The string pattern to match against
8
+ * @returns true if pathname matches the pattern with proper path boundaries
9
+ */
10
+ export declare function matchPathPrefix(pathname: string, pattern: string): boolean;
package/jest.config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { Config } from 'jest';
2
+
3
+ const config: Config = {
4
+ clearMocks: true,
5
+ coverageProvider: 'v8',
6
+ moduleDirectories: ['node_modules', 'src'],
7
+ testEnvironment: 'node',
8
+ transform: {
9
+ '^.+\\.(t|j)sx?$': '@swc/jest',
10
+ },
11
+ transformIgnorePatterns: [],
12
+ restoreMocks: true,
13
+ roots: ['<rootDir>/src'],
14
+ };
15
+
16
+ export default config;
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@imtbl/auth-next-server",
3
+ "version": "2.12.5-alpha.13",
4
+ "description": "Immutable Auth.js v5 integration for Next.js - Server-side utilities",
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
+ "peerDependencies": {
30
+ "next": "^14.2.0 || ^15.0.0",
31
+ "next-auth": "^5.0.0-beta.25"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "next": {
35
+ "optional": true
36
+ },
37
+ "next-auth": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "devDependencies": {
42
+ "@swc/core": "^1.4.2",
43
+ "@swc/jest": "^0.2.37",
44
+ "@types/jest": "^29.5.12",
45
+ "@types/node": "^22.10.7",
46
+ "eslint": "^8.56.0",
47
+ "jest": "^29.7.0",
48
+ "next": "^15.1.6",
49
+ "next-auth": "^5.0.0-beta.30",
50
+ "tsup": "^8.3.0",
51
+ "typescript": "^5.6.2"
52
+ },
53
+ "scripts": {
54
+ "build": "tsup && pnpm build:types",
55
+ "build:types": "tsc --project tsconfig.types.json",
56
+ "clean": "rm -rf dist",
57
+ "lint": "eslint src/**/*.ts --max-warnings=0",
58
+ "test": "jest --passWithNoTests"
59
+ }
60
+ }
package/src/config.ts ADDED
@@ -0,0 +1,243 @@
1
+ // NextAuthConfig type from next-auth v5
2
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
3
+ // @ts-ignore - Type exists in next-auth v5 but TS resolver may use stale types
4
+ import type { NextAuthConfig } from 'next-auth';
5
+ import CredentialsImport from 'next-auth/providers/credentials';
6
+ import type { ImmutableAuthConfig, ImmutableTokenData, UserInfoResponse } from './types';
7
+ import { isTokenExpired } from './refresh';
8
+ import {
9
+ DEFAULT_AUTH_DOMAIN,
10
+ IMMUTABLE_PROVIDER_ID,
11
+ DEFAULT_SESSION_MAX_AGE_SECONDS,
12
+ } from './constants';
13
+
14
+ // Handle ESM/CJS interop - in some bundler configurations, the default export
15
+ // may be nested under a 'default' property
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ const Credentials = ((CredentialsImport as any).default || CredentialsImport) as typeof CredentialsImport;
18
+
19
+ /**
20
+ * Validate tokens by calling the userinfo endpoint.
21
+ * This is the standard OAuth 2.0 way to validate access tokens server-side.
22
+ * The auth server validates signature, issuer, audience, and expiry.
23
+ *
24
+ * @param accessToken - The access token to validate
25
+ * @param authDomain - The authentication domain
26
+ * @returns The user info if valid, null otherwise
27
+ */
28
+ async function validateTokens(
29
+ accessToken: string,
30
+ authDomain: string,
31
+ ): Promise<UserInfoResponse | null> {
32
+ try {
33
+ const response = await fetch(`${authDomain}/userinfo`, {
34
+ method: 'GET',
35
+ headers: {
36
+ Authorization: `Bearer ${accessToken}`,
37
+ },
38
+ });
39
+
40
+ if (!response.ok) {
41
+ // eslint-disable-next-line no-console
42
+ console.error('[auth-next-server] Token validation failed:', response.status, response.statusText);
43
+ return null;
44
+ }
45
+
46
+ return await response.json();
47
+ } catch (error) {
48
+ // eslint-disable-next-line no-console
49
+ console.error('[auth-next-server] Token validation error:', error);
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Create Auth.js v5 configuration for Immutable authentication
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * // lib/auth.ts
60
+ * import NextAuth from "next-auth";
61
+ * import { createAuthConfig } from "@imtbl/auth-next-server";
62
+ *
63
+ * const config = {
64
+ * clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
65
+ * redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
66
+ * };
67
+ *
68
+ * export const { handlers, auth, signIn, signOut } = NextAuth(createAuthConfig(config));
69
+ * ```
70
+ */
71
+ export function createAuthConfig(config: ImmutableAuthConfig): NextAuthConfig {
72
+ const authDomain = config.authenticationDomain || DEFAULT_AUTH_DOMAIN;
73
+
74
+ return {
75
+ providers: [
76
+ Credentials({
77
+ id: IMMUTABLE_PROVIDER_ID,
78
+ name: 'Immutable',
79
+ credentials: {
80
+ tokens: { label: 'Tokens', type: 'text' },
81
+ },
82
+ async authorize(credentials) {
83
+ if (!credentials?.tokens || typeof credentials.tokens !== 'string') {
84
+ return null;
85
+ }
86
+
87
+ let tokenData: ImmutableTokenData;
88
+ try {
89
+ tokenData = JSON.parse(credentials.tokens);
90
+ } catch (error) {
91
+ // eslint-disable-next-line no-console
92
+ console.error('[auth-next-server] Failed to parse token data:', error);
93
+ return null;
94
+ }
95
+
96
+ // Validate required fields exist to prevent TypeError on malformed requests
97
+ // accessTokenExpires must be a valid number to ensure isTokenExpired() works correctly
98
+ // (NaN comparisons always return false, which would prevent token refresh)
99
+ if (
100
+ !tokenData.accessToken
101
+ || typeof tokenData.accessToken !== 'string'
102
+ || !tokenData.profile
103
+ || typeof tokenData.profile !== 'object'
104
+ || !tokenData.profile.sub
105
+ || typeof tokenData.profile.sub !== 'string'
106
+ || typeof tokenData.accessTokenExpires !== 'number'
107
+ || Number.isNaN(tokenData.accessTokenExpires)
108
+ ) {
109
+ // eslint-disable-next-line no-console
110
+ console.error('[auth-next-server] Invalid token data structure - missing required fields');
111
+ return null;
112
+ }
113
+
114
+ // Validate tokens server-side via userinfo endpoint.
115
+ // This is the standard OAuth 2.0 way - the auth server validates the token.
116
+ const userInfo = await validateTokens(tokenData.accessToken, authDomain);
117
+ if (!userInfo) {
118
+ // eslint-disable-next-line no-console
119
+ console.error('[auth-next-server] Token validation failed - rejecting authentication');
120
+ return null;
121
+ }
122
+
123
+ // Verify the user ID (sub) from userinfo matches the client-provided profile.
124
+ // This prevents spoofing a different user ID with a valid token.
125
+ if (userInfo.sub !== tokenData.profile.sub) {
126
+ // eslint-disable-next-line no-console
127
+ console.error(
128
+ '[auth-next-server] User ID mismatch - userinfo sub:',
129
+ userInfo.sub,
130
+ 'provided sub:',
131
+ tokenData.profile.sub,
132
+ );
133
+ return null;
134
+ }
135
+
136
+ // Return user object with validated data
137
+ return {
138
+ id: userInfo.sub,
139
+ sub: userInfo.sub,
140
+ email: userInfo.email ?? tokenData.profile.email,
141
+ nickname: userInfo.nickname ?? tokenData.profile.nickname,
142
+ accessToken: tokenData.accessToken,
143
+ refreshToken: tokenData.refreshToken,
144
+ idToken: tokenData.idToken,
145
+ accessTokenExpires: tokenData.accessTokenExpires,
146
+ zkEvm: tokenData.zkEvm,
147
+ };
148
+ },
149
+ }),
150
+ ],
151
+
152
+ callbacks: {
153
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
+ async jwt({
155
+ token, user, trigger, session: sessionUpdate,
156
+ }: any) {
157
+ // Initial sign in - store all token data
158
+ if (user) {
159
+ return {
160
+ ...token,
161
+ sub: user.sub,
162
+ email: user.email,
163
+ nickname: user.nickname,
164
+ accessToken: user.accessToken,
165
+ refreshToken: user.refreshToken,
166
+ idToken: user.idToken,
167
+ accessTokenExpires: user.accessTokenExpires,
168
+ zkEvm: user.zkEvm,
169
+ };
170
+ }
171
+
172
+ // Handle session update (for client-side token sync)
173
+ // When client-side Auth refreshes tokens via TOKEN_REFRESHED event,
174
+ // it calls updateSession() which triggers this callback with the new tokens.
175
+ // We clear any stale error (e.g., TokenExpired) on successful update.
176
+ if (trigger === 'update' && sessionUpdate) {
177
+ const update = sessionUpdate as Record<string, unknown>;
178
+ return {
179
+ ...token,
180
+ ...(update.accessToken ? { accessToken: update.accessToken } : {}),
181
+ ...(update.refreshToken ? { refreshToken: update.refreshToken } : {}),
182
+ ...(update.idToken ? { idToken: update.idToken } : {}),
183
+ ...(update.accessTokenExpires ? { accessTokenExpires: update.accessTokenExpires } : {}),
184
+ ...(update.zkEvm ? { zkEvm: update.zkEvm } : {}),
185
+ // Clear any stale error when valid tokens are synced from client-side
186
+ error: undefined,
187
+ };
188
+ }
189
+
190
+ // Return token if not expired
191
+ if (!isTokenExpired(token.accessTokenExpires as number)) {
192
+ return token;
193
+ }
194
+
195
+ // Token expired - DON'T refresh server-side!
196
+ // Server-side refresh causes race conditions with 403 errors when multiple
197
+ // concurrent SSR requests detect expired tokens. The pendingRefreshes Map
198
+ // mutex doesn't work across serverless isolates/processes.
199
+ //
200
+ // Instead, mark the token as expired and let the client handle refresh:
201
+ // 1. SSR completes with session.error = "TokenExpired"
202
+ // 2. Client hydrates, calls getAccessToken() for data fetches
203
+ // 3. getAccessToken() calls auth.getAccessToken() which auto-refreshes
204
+ // with proper mutex protection (refreshingPromise in @imtbl/auth)
205
+ // 4. TOKEN_REFRESHED event fires → updateSession() syncs fresh tokens
206
+ // 5. NextAuth receives update trigger → clears error, stores fresh tokens
207
+ return {
208
+ ...token,
209
+ error: 'TokenExpired',
210
+ };
211
+ },
212
+
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
+ async session({ session, token }: any) {
215
+ // Expose token data to the session
216
+ return {
217
+ ...session,
218
+ user: {
219
+ ...session.user,
220
+ sub: token.sub as string,
221
+ email: token.email as string | undefined,
222
+ nickname: token.nickname as string | undefined,
223
+ },
224
+ accessToken: token.accessToken as string,
225
+ refreshToken: token.refreshToken as string | undefined,
226
+ idToken: token.idToken as string | undefined,
227
+ accessTokenExpires: token.accessTokenExpires as number,
228
+ zkEvm: token.zkEvm,
229
+ ...(token.error && { error: token.error as string }),
230
+ };
231
+ },
232
+ },
233
+
234
+ session: {
235
+ strategy: 'jwt',
236
+ // Session max age in seconds (365 days default)
237
+ maxAge: DEFAULT_SESSION_MAX_AGE_SECONDS,
238
+ },
239
+ };
240
+ }
241
+
242
+ // Keep backwards compatibility alias
243
+ export const createAuthOptions = createAuthConfig;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared constants for @imtbl/auth-next-server
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;
40
+
41
+ /**
42
+ * Buffer time in seconds before token expiry to trigger refresh
43
+ * Tokens will be refreshed when they expire within this window
44
+ */
45
+ export const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
46
+
47
+ /**
48
+ * Default session max age in seconds (365 days)
49
+ * This is how long the NextAuth session cookie will be valid
50
+ */
51
+ export const DEFAULT_SESSION_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;