@jasperoosthoek/zustand-auth-registry 0.0.1

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,184 @@
1
+ import { AxiosInstance } from 'axios';
2
+
3
+ // OAuth 2.0 token data structure
4
+ export type TokenData = {
5
+ accessToken: string;
6
+ refreshToken?: string;
7
+ expiresAt?: number;
8
+ tokenType: string;
9
+ scope?: string[];
10
+ };
11
+
12
+ // Backward compatibility: extract single token vs OAuth token data
13
+ export type TokenExtractor = (data: any) => string | TokenData;
14
+
15
+ export type AuthConfig<U> = {
16
+ axios: AxiosInstance;
17
+
18
+ // OAuth 2.0 compliant endpoints (new defaults)
19
+ tokenUrl?: string;
20
+ revokeUrl?: string;
21
+ userInfoUrl?: string;
22
+
23
+ // Backward compatibility: Django-style endpoints
24
+ loginUrl?: string;
25
+ logoutUrl?: string;
26
+ getUserUrl?: string;
27
+
28
+ // OAuth 2.0 compliant token extraction
29
+ extractTokens?: (data: any) => TokenData;
30
+ extractAccessToken?: (data: any) => string;
31
+ extractRefreshToken?: (data: any) => string | undefined;
32
+ extractExpiresIn?: (data: any) => number | undefined;
33
+ extractTokenType?: (data: any) => string;
34
+ extractScope?: (data: any) => string[] | undefined;
35
+
36
+ // Backward compatibility: single token extraction
37
+ extractToken?: (data: any) => string;
38
+
39
+ formatAuthHeader?: (token: string, tokenType?: string) => string;
40
+
41
+ // OAuth 2.0 features
42
+ autoRefresh?: boolean;
43
+ refreshThreshold?: number; // ms before expiry to refresh
44
+
45
+ persistence?: {
46
+ enabled?: boolean;
47
+ storage?: Storage;
48
+ tokenKey?: string;
49
+ refreshTokenKey?: string;
50
+ userKey?: string;
51
+ expiryKey?: string;
52
+ };
53
+
54
+ onError?: (error: any) => void;
55
+ onLogin?: (user: U) => void;
56
+ onLogout?: () => void;
57
+ onTokenRefresh?: (tokens: TokenData) => void;
58
+ };
59
+
60
+ export type ValidatedAuthConfig<U> = {
61
+ axios: AxiosInstance;
62
+
63
+ // Resolved endpoints (OAuth preferred, Django fallback)
64
+ tokenUrl: string;
65
+ revokeUrl?: string;
66
+ userInfoUrl?: string;
67
+
68
+ // Backward compatibility endpoints
69
+ loginUrl?: string;
70
+ logoutUrl?: string;
71
+ getUserUrl?: string;
72
+
73
+ // Token extraction functions
74
+ extractTokens: (data: any) => TokenData;
75
+ extractToken?: (data: any) => string;
76
+
77
+ formatAuthHeader: (token: string, tokenType?: string) => string;
78
+
79
+ // OAuth features
80
+ autoRefresh: boolean;
81
+ refreshThreshold: number;
82
+
83
+ persistence: {
84
+ enabled: boolean;
85
+ storage: Storage;
86
+ tokenKey: string;
87
+ refreshTokenKey: string;
88
+ userKey: string;
89
+ expiryKey: string;
90
+ };
91
+
92
+ onError?: (error: any) => void;
93
+ onLogin?: (user: U) => void;
94
+ onLogout?: () => void;
95
+ onTokenRefresh?: (tokens: TokenData) => void;
96
+ };
97
+
98
+ export const validateAuthConfig = <U>(config: AuthConfig<U>): ValidatedAuthConfig<U> => {
99
+ if (!config.axios) {
100
+ throw new Error('AuthConfig: axios instance is required');
101
+ }
102
+
103
+ // Determine token endpoint (OAuth preferred, Django fallback)
104
+ const tokenUrl = config.tokenUrl || config.loginUrl;
105
+ if (!tokenUrl) {
106
+ throw new Error('AuthConfig: tokenUrl or loginUrl is required');
107
+ }
108
+
109
+ // Keep revokeUrl and logoutUrl distinct for proper OAuth/legacy behavior
110
+ const revokeUrl = config.revokeUrl;
111
+
112
+ // Determine user info endpoint
113
+ const userInfoUrl = config.userInfoUrl || config.getUserUrl;
114
+
115
+ // Create OAuth-compliant token extraction function
116
+ const extractTokens = createTokenExtractor(config);
117
+
118
+ const defaultPersistence = {
119
+ enabled: true,
120
+ storage: typeof window !== 'undefined' && window.localStorage ? window.localStorage : ({} as Storage),
121
+ tokenKey: 'token',
122
+ refreshTokenKey: 'refresh_token',
123
+ userKey: 'user',
124
+ expiryKey: 'expires_at',
125
+ };
126
+
127
+ return {
128
+ axios: config.axios,
129
+ tokenUrl,
130
+ revokeUrl,
131
+ userInfoUrl,
132
+ // Backward compatibility
133
+ loginUrl: config.loginUrl,
134
+ logoutUrl: config.logoutUrl,
135
+ getUserUrl: config.getUserUrl,
136
+ extractTokens,
137
+ extractToken: config.extractToken,
138
+ formatAuthHeader: config.formatAuthHeader || ((token: string, tokenType: string = 'Bearer') => `${tokenType} ${token}`),
139
+ autoRefresh: config.autoRefresh ?? true,
140
+ refreshThreshold: config.refreshThreshold ?? 300000, // 5 minutes
141
+ persistence: {
142
+ ...defaultPersistence,
143
+ ...config.persistence,
144
+ },
145
+ onError: config.onError,
146
+ onLogin: config.onLogin,
147
+ onLogout: config.onLogout,
148
+ onTokenRefresh: config.onTokenRefresh,
149
+ };
150
+ };
151
+
152
+ // Helper function to create OAuth-compliant token extractor with backward compatibility
153
+ function createTokenExtractor<U>(config: AuthConfig<U>): (data: any) => TokenData {
154
+ return (data: any): TokenData => {
155
+ // If custom extractTokens function provided, use it
156
+ if (config.extractTokens) {
157
+ return config.extractTokens(data);
158
+ }
159
+
160
+ // OAuth 2.0 compliant extraction (preferred)
161
+ if (data.access_token) {
162
+ return {
163
+ accessToken: config.extractAccessToken ? config.extractAccessToken(data) : data.access_token,
164
+ refreshToken: config.extractRefreshToken ? config.extractRefreshToken(data) : data.refresh_token,
165
+ expiresAt: config.extractExpiresIn
166
+ ? (config.extractExpiresIn(data) ? Date.now() + (config.extractExpiresIn(data)! * 1000) : undefined)
167
+ : (data.expires_in ? Date.now() + (data.expires_in * 1000) : undefined),
168
+ tokenType: config.extractTokenType ? config.extractTokenType(data) : (data.token_type || 'Bearer'),
169
+ scope: config.extractScope ? config.extractScope(data) : (data.scope ? data.scope.split(' ') : undefined),
170
+ };
171
+ }
172
+
173
+ // Single token fallback (backward compatibility)
174
+ if (config.extractToken || data.auth_token || data.token) {
175
+ const token = config.extractToken ? config.extractToken(data) : (data.auth_token || data.token);
176
+ return {
177
+ accessToken: token,
178
+ tokenType: 'Bearer', // Standard default
179
+ };
180
+ }
181
+
182
+ throw new Error('No valid token found in response. Provide extractTokens, extractToken, or ensure response contains access_token/auth_token field.');
183
+ };
184
+ }
@@ -0,0 +1,160 @@
1
+ import { create, StoreApi, UseBoundStore } from 'zustand';
2
+ import { ValidatedAuthConfig, TokenData } from './authConfig';
3
+
4
+ export type AuthState<U> = {
5
+ isAuthenticated: boolean;
6
+ user: U | null;
7
+ tokens: TokenData | null;
8
+
9
+ // OAuth 2.0 methods
10
+ setTokens: (tokens: TokenData) => void;
11
+ setUser: (user: U) => void;
12
+ unsetUser: () => void;
13
+ isTokenExpired: () => boolean;
14
+
15
+ // Backward compatibility
16
+ token: string;
17
+ setToken: (token: string) => void;
18
+ };
19
+
20
+ export type AuthStore<U> = UseBoundStore<StoreApi<AuthState<U>>> & {
21
+ config: ValidatedAuthConfig<U>;
22
+ };
23
+
24
+ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U> => {
25
+ const { persistence } = config;
26
+
27
+ const getStoredTokens = (): TokenData | null => {
28
+ if (!persistence.enabled) return null;
29
+ try {
30
+ const accessToken = persistence.storage.getItem(persistence.tokenKey);
31
+ if (!accessToken) return null;
32
+
33
+ const refreshToken = persistence.storage.getItem(persistence.refreshTokenKey);
34
+ const expiryString = persistence.storage.getItem(persistence.expiryKey);
35
+ const expiresAt = expiryString ? parseInt(expiryString, 10) : undefined;
36
+
37
+ return {
38
+ accessToken,
39
+ refreshToken: refreshToken || undefined,
40
+ expiresAt: expiresAt && !isNaN(expiresAt) ? expiresAt : undefined,
41
+ tokenType: 'Bearer', // Default, will be updated on setTokens
42
+ };
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+
48
+ const getStoredUser = (): U | null => {
49
+ if (!persistence.enabled) return null;
50
+ try {
51
+ const userString = persistence.storage.getItem(persistence.userKey);
52
+ return userString ? (JSON.parse(userString) as U) : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ };
57
+
58
+ const initialTokens = getStoredTokens();
59
+ const initialUser = getStoredUser();
60
+ const initialIsAuthenticated = !!initialTokens?.accessToken;
61
+
62
+ const store = create<AuthState<U>>((set, get) => ({
63
+ tokens: initialTokens,
64
+ user: initialUser,
65
+ isAuthenticated: initialIsAuthenticated,
66
+
67
+ // OAuth 2.0 methods
68
+ setTokens: (tokens: TokenData) => {
69
+ const user = get().user;
70
+ const isAuthenticated = !!tokens.accessToken;
71
+
72
+ set({ tokens, isAuthenticated, token: tokens.accessToken });
73
+
74
+ if (persistence.enabled) {
75
+ try {
76
+ persistence.storage.setItem(persistence.tokenKey, tokens.accessToken);
77
+
78
+ if (tokens.refreshToken) {
79
+ persistence.storage.setItem(persistence.refreshTokenKey, tokens.refreshToken);
80
+ } else {
81
+ persistence.storage.removeItem(persistence.refreshTokenKey);
82
+ }
83
+
84
+ if (tokens.expiresAt) {
85
+ persistence.storage.setItem(persistence.expiryKey, tokens.expiresAt.toString());
86
+ } else {
87
+ persistence.storage.removeItem(persistence.expiryKey);
88
+ }
89
+ } catch (error) {
90
+ config.onError?.(error);
91
+ }
92
+ }
93
+ },
94
+
95
+ setUser: (user: U) => {
96
+ const tokens = get().tokens;
97
+ const isAuthenticated = !!tokens?.accessToken;
98
+
99
+ set({ user, isAuthenticated });
100
+
101
+ if (persistence.enabled) {
102
+ try {
103
+ persistence.storage.setItem(persistence.userKey, JSON.stringify(user));
104
+ } catch (error) {
105
+ config.onError?.(error);
106
+ }
107
+ }
108
+ },
109
+
110
+ unsetUser: () => {
111
+ set({ user: null, tokens: null, isAuthenticated: false, token: '' });
112
+
113
+ if (persistence.enabled) {
114
+ try {
115
+ persistence.storage.removeItem(persistence.tokenKey);
116
+ persistence.storage.removeItem(persistence.refreshTokenKey);
117
+ persistence.storage.removeItem(persistence.userKey);
118
+ persistence.storage.removeItem(persistence.expiryKey);
119
+ } catch (error) {
120
+ config.onError?.(error);
121
+ }
122
+ }
123
+ },
124
+
125
+ isTokenExpired: () => {
126
+ const tokens = get().tokens;
127
+ if (!tokens?.expiresAt) return false; // No expiry info means no expiration
128
+ return Date.now() >= tokens.expiresAt;
129
+ },
130
+
131
+ // Backward compatibility - computed property
132
+ token: initialTokens?.accessToken || '',
133
+
134
+ setToken: (token: string) => {
135
+ const currentTokens = get().tokens;
136
+ const user = get().user;
137
+ const newTokens: TokenData = {
138
+ accessToken: token,
139
+ refreshToken: currentTokens?.refreshToken,
140
+ expiresAt: currentTokens?.expiresAt,
141
+ tokenType: currentTokens?.tokenType || 'Bearer', // Standard default
142
+ scope: currentTokens?.scope,
143
+ };
144
+
145
+ const isAuthenticated = !!token;
146
+ set({ tokens: newTokens, token, isAuthenticated });
147
+
148
+ // Handle persistence
149
+ if (persistence.enabled) {
150
+ try {
151
+ persistence.storage.setItem(persistence.tokenKey, token);
152
+ } catch (error) {
153
+ config.onError?.(error);
154
+ }
155
+ }
156
+ },
157
+ }));
158
+
159
+ return Object.assign(store, { config });
160
+ };
@@ -0,0 +1,22 @@
1
+ import { AuthConfig, validateAuthConfig } from './authConfig';
2
+ import { createAuthStore, AuthStore } from './authStore';
3
+
4
+ export function createAuthRegistry<AuthModels extends Record<string, any>>() {
5
+ const registry: Record<string, AuthStore<any>> = {};
6
+
7
+ function getAuthStore<K extends keyof AuthModels>(
8
+ key: K,
9
+ config: AuthConfig<AuthModels[K]>
10
+ ): AuthStore<AuthModels[K]> {
11
+ const stringKey = String(key);
12
+
13
+ if (!registry[stringKey]) {
14
+ const validatedConfig = validateAuthConfig(config);
15
+ registry[stringKey] = createAuthStore(validatedConfig);
16
+ }
17
+
18
+ return registry[stringKey];
19
+ }
20
+
21
+ return getAuthStore;
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './authConfig';
2
+ export * from './authStore';
3
+ export * from './createAuthRegistry';
4
+ export * from './useAuth';
@@ -0,0 +1,46 @@
1
+ // Test setup with React Testing Library and jsdom
2
+ import '@testing-library/react';
3
+
4
+ // Mock console to reduce noise during tests
5
+ global.console = {
6
+ ...console,
7
+ error: jest.fn(),
8
+ warn: jest.fn(),
9
+ };
10
+
11
+ // Mock localStorage for auth persistence testing
12
+ const createStorageMock = () => ({
13
+ getItem: jest.fn(),
14
+ setItem: jest.fn(),
15
+ removeItem: jest.fn(),
16
+ clear: jest.fn(),
17
+ });
18
+
19
+ Object.defineProperty(window, 'localStorage', {
20
+ value: createStorageMock(),
21
+ });
22
+
23
+ Object.defineProperty(window, 'sessionStorage', {
24
+ value: createStorageMock(),
25
+ });
26
+
27
+ // Mock axios for all tests
28
+ jest.mock('axios', () => ({
29
+ create: jest.fn(() => ({
30
+ get: jest.fn(),
31
+ post: jest.fn(),
32
+ put: jest.fn(),
33
+ patch: jest.fn(),
34
+ delete: jest.fn(),
35
+ defaults: {
36
+ headers: {
37
+ common: {}
38
+ }
39
+ },
40
+ interceptors: {
41
+ request: { use: jest.fn(), eject: jest.fn() },
42
+ response: { use: jest.fn(), eject: jest.fn() }
43
+ }
44
+ })),
45
+ isAxiosError: jest.fn()
46
+ }));
package/src/useAuth.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { useEffect, useCallback } from 'react';
2
+ import axios from 'axios';
3
+ import { AuthStore } from './authStore';
4
+ import { TokenData } from './authConfig';
5
+
6
+ export function useAuth<U>(store: AuthStore<U>) {
7
+ const { setTokens, setUser, unsetUser, tokens, user, isTokenExpired } = store();
8
+ const config = store.config;
9
+
10
+ // Backward compatibility
11
+ const { setToken, token } = store();
12
+
13
+ // OAuth 2.0 refresh token functionality
14
+ const refreshTokens = useCallback(async (): Promise<boolean> => {
15
+ if (!tokens?.refreshToken) {
16
+ return false;
17
+ }
18
+
19
+ try {
20
+ const response = await config.axios.post(config.tokenUrl, {
21
+ grant_type: 'refresh_token',
22
+ refresh_token: tokens.refreshToken,
23
+ });
24
+
25
+ const newTokens = config.extractTokens(response.data);
26
+ setTokens(newTokens);
27
+ setAxiosAuth(newTokens.accessToken, newTokens.tokenType);
28
+
29
+ config.onTokenRefresh?.(newTokens);
30
+ return true;
31
+ } catch (error) {
32
+ // Refresh failed, clear tokens
33
+ unsetUser();
34
+ setAxiosAuth();
35
+ config.onError?.(error);
36
+ return false;
37
+ }
38
+ }, [tokens?.refreshToken, config, setTokens, unsetUser]);
39
+
40
+ // Auto-refresh and authentication setup
41
+ useEffect(() => {
42
+ // Handle current tokens
43
+ if (tokens?.accessToken) {
44
+ // Set axios headers immediately
45
+ setAxiosAuth(tokens.accessToken, tokens.tokenType);
46
+
47
+ // Check if token is expired or about to expire
48
+ if (isTokenExpired()) {
49
+ // Token is already expired, try to refresh
50
+ if (tokens.refreshToken && config.autoRefresh) {
51
+ refreshTokens();
52
+ } else {
53
+ unsetUser();
54
+ }
55
+ return;
56
+ }
57
+
58
+ // Set up auto-refresh timer if we have expiry info
59
+ if (tokens.expiresAt && tokens.refreshToken && config.autoRefresh) {
60
+ const timeUntilExpiry = tokens.expiresAt - Date.now();
61
+ const refreshTime = Math.max(timeUntilExpiry - config.refreshThreshold, 0);
62
+
63
+ const timer = setTimeout(() => {
64
+ refreshTokens();
65
+ }, refreshTime);
66
+
67
+ return () => clearTimeout(timer);
68
+ }
69
+
70
+ // Try to get user info if missing
71
+ try {
72
+ if (!user && (config.userInfoUrl || config.getUserUrl)) {
73
+ getCurrentUser();
74
+ }
75
+ } catch (error) {
76
+ if (axios.isAxiosError(error) && error.response?.status === 403) {
77
+ unsetUser();
78
+ setAxiosAuth();
79
+ }
80
+ config.onError?.(error);
81
+ }
82
+ }
83
+ }, [tokens, user, config.autoRefresh, config.refreshThreshold, config.userInfoUrl, config.getUserUrl, isTokenExpired, refreshTokens, unsetUser]);
84
+
85
+ const setAxiosAuth = (token?: string, tokenType?: string) => {
86
+ if (typeof token !== 'undefined' && token) {
87
+ config.axios.defaults.headers.common['Authorization'] = config.formatAuthHeader(token, tokenType);
88
+ } else {
89
+ delete config.axios.defaults.headers.common['Authorization'];
90
+ }
91
+ };
92
+
93
+ const login = async (
94
+ credentials: Record<string, string>,
95
+ callback?: () => void
96
+ ) => {
97
+ try {
98
+ const res = await config.axios.post(config.tokenUrl, credentials);
99
+ const tokens = config.extractTokens(res.data);
100
+ setTokens(tokens);
101
+ setAxiosAuth(tokens.accessToken, tokens.tokenType);
102
+
103
+ if (config.userInfoUrl || config.getUserUrl) {
104
+ await getCurrentUser();
105
+ }
106
+
107
+ if (user) {
108
+ config.onLogin?.(user);
109
+ }
110
+ callback?.();
111
+ } catch (err) {
112
+ unsetUser();
113
+ setAxiosAuth();
114
+ config.onError?.(err);
115
+ }
116
+ };
117
+
118
+ const getCurrentUser = async () => {
119
+ const userUrl = config.userInfoUrl || config.getUserUrl;
120
+ if (!userUrl) return;
121
+ try {
122
+ const res = await config.axios.get<U>(userUrl);
123
+ setUser(res.data);
124
+ } catch (err) {
125
+ unsetUser();
126
+ setAxiosAuth();
127
+ config.onError?.(err);
128
+ }
129
+ };
130
+
131
+ const logout = async () => {
132
+ try {
133
+ // If we have a revoke/logout URL, call it
134
+ const logoutUrl = config.revokeUrl || config.logoutUrl;
135
+ if (logoutUrl) {
136
+ if (config.revokeUrl && tokens?.refreshToken) {
137
+ // OAuth revoke
138
+ await config.axios.post(logoutUrl, {
139
+ token: tokens.refreshToken,
140
+ token_type_hint: 'refresh_token'
141
+ });
142
+ } else {
143
+ // Simple logout
144
+ await config.axios.post(logoutUrl);
145
+ }
146
+ }
147
+ } catch (err) {
148
+ config.onError?.(err);
149
+ } finally {
150
+ unsetUser();
151
+ setAxiosAuth();
152
+ config.onLogout?.();
153
+ }
154
+ };
155
+
156
+ return { login, getCurrentUser, logout, refreshTokens };
157
+ }