@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.
- package/LICENSE +21 -0
- package/README.md +407 -0
- package/dist/authConfig.d.ts +67 -0
- package/dist/authStore.d.ts +17 -0
- package/dist/createAuthRegistry.d.ts +3 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/setupTests.d.ts +1 -0
- package/dist/useAuth.d.ts +7 -0
- package/package.json +50 -0
- package/src/__tests__/authConfig.test.ts +463 -0
- package/src/__tests__/authStore.test.ts +608 -0
- package/src/__tests__/createAuthRegistry.test.ts +202 -0
- package/src/__tests__/testHelpers.ts +92 -0
- package/src/__tests__/testUtils.ts +142 -0
- package/src/__tests__/useAuth.test.ts +975 -0
- package/src/authConfig.ts +184 -0
- package/src/authStore.ts +160 -0
- package/src/createAuthRegistry.ts +22 -0
- package/src/index.ts +4 -0
- package/src/setupTests.ts +46 -0
- package/src/useAuth.ts +157 -0
|
@@ -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
|
+
}
|
package/src/authStore.ts
ADDED
|
@@ -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,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
|
+
}
|