@jasperoosthoek/zustand-auth-registry 0.0.1 → 0.0.3
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/README.md +107 -293
- package/dist/authConfig.d.ts +26 -20
- package/dist/authStore.d.ts +3 -3
- package/dist/errors.d.ts +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/useAuth.d.ts +3 -2
- package/package.json +2 -1
- package/src/authConfig.ts +132 -111
- package/src/authStore.ts +71 -57
- package/src/createAuthRegistry.ts +1 -1
- package/src/errors.ts +104 -0
- package/src/index.ts +2 -1
- package/src/useAuth.ts +169 -101
- package/dist/setupTests.d.ts +0 -1
- package/src/__tests__/authConfig.test.ts +0 -463
- package/src/__tests__/authStore.test.ts +0 -608
- package/src/__tests__/createAuthRegistry.test.ts +0 -202
- package/src/__tests__/testHelpers.ts +0 -92
- package/src/__tests__/testUtils.ts +0 -142
- package/src/__tests__/useAuth.test.ts +0 -975
- package/src/setupTests.ts +0 -46
package/src/authStore.ts
CHANGED
|
@@ -2,43 +2,72 @@ import { create, StoreApi, UseBoundStore } from 'zustand';
|
|
|
2
2
|
import { ValidatedAuthConfig, TokenData } from './authConfig';
|
|
3
3
|
|
|
4
4
|
export type AuthState<U> = {
|
|
5
|
-
isAuthenticated: boolean;
|
|
5
|
+
isAuthenticated: boolean | null; // null = not checked yet (cookie mode)
|
|
6
6
|
user: U | null;
|
|
7
|
-
tokens: TokenData | null;
|
|
8
|
-
|
|
9
|
-
//
|
|
7
|
+
tokens: TokenData | null; // null in cookie mode or when logged out
|
|
8
|
+
|
|
9
|
+
// Methods
|
|
10
10
|
setTokens: (tokens: TokenData) => void;
|
|
11
|
+
setBearerToken: (token: string) => void; // Convenience for simple Bearer token auth
|
|
12
|
+
setAuthenticated: (authenticated: boolean) => void; // For cookie mode
|
|
11
13
|
setUser: (user: U) => void;
|
|
12
14
|
unsetUser: () => void;
|
|
13
15
|
isTokenExpired: () => boolean;
|
|
14
|
-
|
|
15
|
-
// Backward compatibility
|
|
16
|
-
token: string;
|
|
17
|
-
setToken: (token: string) => void;
|
|
18
16
|
};
|
|
19
17
|
|
|
20
18
|
export type AuthStore<U> = UseBoundStore<StoreApi<AuthState<U>>> & {
|
|
21
19
|
config: ValidatedAuthConfig<U>;
|
|
22
20
|
};
|
|
23
21
|
|
|
22
|
+
// Methods that modify state (require CSRF protection)
|
|
23
|
+
const CSRF_METHODS = ['post', 'put', 'patch', 'delete'];
|
|
24
|
+
|
|
25
|
+
const setupCsrfInterceptor = <U>(config: ValidatedAuthConfig<U>): number | null => {
|
|
26
|
+
if (!config.cookieAuth?.csrf.enabled) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { headerName, getToken } = config.cookieAuth.csrf;
|
|
31
|
+
|
|
32
|
+
return config.axios.interceptors.request.use((requestConfig) => {
|
|
33
|
+
const method = requestConfig.method?.toLowerCase();
|
|
34
|
+
if (method && CSRF_METHODS.includes(method)) {
|
|
35
|
+
const csrfToken = getToken();
|
|
36
|
+
if (csrfToken) {
|
|
37
|
+
requestConfig.headers[headerName] = csrfToken;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return requestConfig;
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
24
44
|
export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U> => {
|
|
25
|
-
const { persistence } = config;
|
|
26
|
-
|
|
45
|
+
const { persistence, cookieAuth } = config;
|
|
46
|
+
|
|
47
|
+
// Set up CSRF interceptor if enabled
|
|
48
|
+
setupCsrfInterceptor(config);
|
|
49
|
+
|
|
27
50
|
const getStoredTokens = (): TokenData | null => {
|
|
51
|
+
// Cookie mode: No client-side tokens
|
|
52
|
+
if (cookieAuth?.enabled) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Token mode: Read from storage
|
|
28
57
|
if (!persistence.enabled) return null;
|
|
29
58
|
try {
|
|
30
59
|
const accessToken = persistence.storage.getItem(persistence.tokenKey);
|
|
31
60
|
if (!accessToken) return null;
|
|
32
|
-
|
|
61
|
+
|
|
33
62
|
const refreshToken = persistence.storage.getItem(persistence.refreshTokenKey);
|
|
34
63
|
const expiryString = persistence.storage.getItem(persistence.expiryKey);
|
|
35
64
|
const expiresAt = expiryString ? parseInt(expiryString, 10) : undefined;
|
|
36
|
-
|
|
65
|
+
|
|
37
66
|
return {
|
|
38
67
|
accessToken,
|
|
39
68
|
refreshToken: refreshToken || undefined,
|
|
40
69
|
expiresAt: expiresAt && !isNaN(expiresAt) ? expiresAt : undefined,
|
|
41
|
-
tokenType: 'Bearer',
|
|
70
|
+
tokenType: 'Bearer',
|
|
42
71
|
};
|
|
43
72
|
} catch {
|
|
44
73
|
return null;
|
|
@@ -57,30 +86,37 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
57
86
|
|
|
58
87
|
const initialTokens = getStoredTokens();
|
|
59
88
|
const initialUser = getStoredUser();
|
|
60
|
-
|
|
89
|
+
|
|
90
|
+
// Cookie mode: null (unknown until checkAuth)
|
|
91
|
+
// Token mode: true/false based on token presence
|
|
92
|
+
const initialIsAuthenticated = cookieAuth?.enabled
|
|
93
|
+
? null
|
|
94
|
+
: !!initialTokens?.accessToken;
|
|
61
95
|
|
|
62
96
|
const store = create<AuthState<U>>((set, get) => ({
|
|
63
97
|
tokens: initialTokens,
|
|
64
98
|
user: initialUser,
|
|
65
99
|
isAuthenticated: initialIsAuthenticated,
|
|
66
100
|
|
|
67
|
-
// OAuth 2.0 methods
|
|
68
101
|
setTokens: (tokens: TokenData) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
set({ tokens, isAuthenticated: true });
|
|
103
|
+
|
|
104
|
+
// Cookie mode: No localStorage persistence for tokens
|
|
105
|
+
if (cookieAuth?.enabled) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Token mode: Persist to storage
|
|
74
110
|
if (persistence.enabled) {
|
|
75
111
|
try {
|
|
76
112
|
persistence.storage.setItem(persistence.tokenKey, tokens.accessToken);
|
|
77
|
-
|
|
113
|
+
|
|
78
114
|
if (tokens.refreshToken) {
|
|
79
115
|
persistence.storage.setItem(persistence.refreshTokenKey, tokens.refreshToken);
|
|
80
116
|
} else {
|
|
81
117
|
persistence.storage.removeItem(persistence.refreshTokenKey);
|
|
82
118
|
}
|
|
83
|
-
|
|
119
|
+
|
|
84
120
|
if (tokens.expiresAt) {
|
|
85
121
|
persistence.storage.setItem(persistence.expiryKey, tokens.expiresAt.toString());
|
|
86
122
|
} else {
|
|
@@ -92,12 +128,17 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
92
128
|
}
|
|
93
129
|
},
|
|
94
130
|
|
|
131
|
+
setBearerToken: (token: string) => {
|
|
132
|
+
get().setTokens({ accessToken: token, tokenType: 'Bearer' });
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
setAuthenticated: (authenticated: boolean) => {
|
|
136
|
+
set({ isAuthenticated: authenticated });
|
|
137
|
+
},
|
|
138
|
+
|
|
95
139
|
setUser: (user: U) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
set({ user, isAuthenticated });
|
|
100
|
-
|
|
140
|
+
set({ user });
|
|
141
|
+
|
|
101
142
|
if (persistence.enabled) {
|
|
102
143
|
try {
|
|
103
144
|
persistence.storage.setItem(persistence.userKey, JSON.stringify(user));
|
|
@@ -108,8 +149,8 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
108
149
|
},
|
|
109
150
|
|
|
110
151
|
unsetUser: () => {
|
|
111
|
-
set({ user: null, tokens: null, isAuthenticated: false
|
|
112
|
-
|
|
152
|
+
set({ user: null, tokens: null, isAuthenticated: false });
|
|
153
|
+
|
|
113
154
|
if (persistence.enabled) {
|
|
114
155
|
try {
|
|
115
156
|
persistence.storage.removeItem(persistence.tokenKey);
|
|
@@ -124,36 +165,9 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
124
165
|
|
|
125
166
|
isTokenExpired: () => {
|
|
126
167
|
const tokens = get().tokens;
|
|
127
|
-
if (!tokens?.expiresAt) return false;
|
|
168
|
+
if (!tokens?.expiresAt) return false;
|
|
128
169
|
return Date.now() >= tokens.expiresAt;
|
|
129
170
|
},
|
|
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
171
|
}));
|
|
158
172
|
|
|
159
173
|
return Object.assign(store, { config });
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication error codes
|
|
3
|
+
*/
|
|
4
|
+
export enum AuthErrorCode {
|
|
5
|
+
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
|
|
6
|
+
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
|
|
7
|
+
TOKEN_INVALID = 'TOKEN_INVALID',
|
|
8
|
+
REFRESH_FAILED = 'REFRESH_FAILED',
|
|
9
|
+
NETWORK_ERROR = 'NETWORK_ERROR',
|
|
10
|
+
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
|
11
|
+
UNAUTHORIZED = 'UNAUTHORIZED',
|
|
12
|
+
CSRF_TOKEN_MISSING = 'CSRF_TOKEN_MISSING',
|
|
13
|
+
FORBIDDEN = 'FORBIDDEN',
|
|
14
|
+
UNKNOWN = 'UNKNOWN',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Typed authentication error
|
|
19
|
+
*/
|
|
20
|
+
export class AuthError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
public code: AuthErrorCode,
|
|
23
|
+
public originalError?: any,
|
|
24
|
+
message?: string
|
|
25
|
+
) {
|
|
26
|
+
super(message || code);
|
|
27
|
+
this.name = 'AuthError';
|
|
28
|
+
|
|
29
|
+
// Maintain proper stack trace for where our error was thrown (only available on V8)
|
|
30
|
+
if (Error.captureStackTrace) {
|
|
31
|
+
Error.captureStackTrace(this, AuthError);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convert error to JSON (excludes originalError in production)
|
|
37
|
+
*/
|
|
38
|
+
toJSON() {
|
|
39
|
+
return {
|
|
40
|
+
code: this.code,
|
|
41
|
+
message: this.message,
|
|
42
|
+
name: this.name,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if error is an AuthError
|
|
48
|
+
*/
|
|
49
|
+
static isAuthError(error: any): error is AuthError {
|
|
50
|
+
return error instanceof AuthError;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create typed AuthError from any error
|
|
56
|
+
*/
|
|
57
|
+
export function createAuthError(error: any): AuthError {
|
|
58
|
+
if (AuthError.isAuthError(error)) {
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Axios error
|
|
63
|
+
if (error.response) {
|
|
64
|
+
const status = error.response.status;
|
|
65
|
+
const data = error.response.data;
|
|
66
|
+
|
|
67
|
+
switch (status) {
|
|
68
|
+
case 401:
|
|
69
|
+
// Check if it's an expired token
|
|
70
|
+
if (data?.detail?.toLowerCase().includes('expired') ||
|
|
71
|
+
data?.message?.toLowerCase().includes('expired')) {
|
|
72
|
+
return new AuthError(AuthErrorCode.TOKEN_EXPIRED, error, 'Token has expired');
|
|
73
|
+
}
|
|
74
|
+
if (data?.detail?.toLowerCase().includes('invalid') ||
|
|
75
|
+
data?.detail?.toLowerCase().includes('credentials')) {
|
|
76
|
+
return new AuthError(AuthErrorCode.INVALID_CREDENTIALS, error, 'Invalid credentials');
|
|
77
|
+
}
|
|
78
|
+
return new AuthError(AuthErrorCode.UNAUTHORIZED, error, 'Unauthorized');
|
|
79
|
+
|
|
80
|
+
case 403:
|
|
81
|
+
if (data?.detail?.toLowerCase().includes('csrf')) {
|
|
82
|
+
return new AuthError(AuthErrorCode.CSRF_TOKEN_MISSING, error, 'CSRF token missing or invalid');
|
|
83
|
+
}
|
|
84
|
+
return new AuthError(AuthErrorCode.FORBIDDEN, error, 'Access forbidden');
|
|
85
|
+
|
|
86
|
+
case 404:
|
|
87
|
+
if (data?.detail?.toLowerCase().includes('user')) {
|
|
88
|
+
return new AuthError(AuthErrorCode.USER_NOT_FOUND, error, 'User not found');
|
|
89
|
+
}
|
|
90
|
+
return new AuthError(AuthErrorCode.UNKNOWN, error, 'Resource not found');
|
|
91
|
+
|
|
92
|
+
default:
|
|
93
|
+
return new AuthError(AuthErrorCode.UNKNOWN, error, `HTTP ${status} error`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Network error (no response received)
|
|
98
|
+
if (error.request) {
|
|
99
|
+
return new AuthError(AuthErrorCode.NETWORK_ERROR, error, 'Network error - no response received');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Other errors
|
|
103
|
+
return new AuthError(AuthErrorCode.UNKNOWN, error, error.message || 'Unknown error');
|
|
104
|
+
}
|
package/src/index.ts
CHANGED
package/src/useAuth.ts
CHANGED
|
@@ -1,151 +1,167 @@
|
|
|
1
1
|
import { useEffect, useCallback } from 'react';
|
|
2
|
-
import axios from 'axios';
|
|
3
2
|
import { AuthStore } from './authStore';
|
|
4
|
-
|
|
3
|
+
|
|
4
|
+
// Promise deduplication: prevents multiple concurrent checkAuth calls per store
|
|
5
|
+
const pendingCheckAuth = new WeakMap<AuthStore<any>, Promise<boolean>>();
|
|
5
6
|
|
|
6
7
|
export function useAuth<U>(store: AuthStore<U>) {
|
|
7
|
-
const { setTokens, setUser, unsetUser, tokens, user, isTokenExpired } = store();
|
|
8
|
+
const { setTokens, setAuthenticated, setUser, unsetUser, tokens, user, isAuthenticated, isTokenExpired } = store();
|
|
8
9
|
const config = store.config;
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
|
|
11
|
+
// Set axios Authorization header (token mode only)
|
|
12
|
+
// Note: CSRF is handled by interceptor in authStore.ts
|
|
13
|
+
const setAxiosAuth = useCallback((token?: string, tokenType?: string) => {
|
|
14
|
+
if (config.cookieAuth?.enabled) {
|
|
15
|
+
// Cookie mode: CSRF handled by interceptor, no Authorization header needed
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
// Token mode: Set Authorization header
|
|
20
|
+
if (token) {
|
|
21
|
+
config.axios.defaults.headers.common['Authorization'] = config.formatAuthHeader(token, tokenType);
|
|
22
|
+
} else {
|
|
23
|
+
delete config.axios.defaults.headers.common['Authorization'];
|
|
17
24
|
}
|
|
25
|
+
}, [config]);
|
|
26
|
+
|
|
27
|
+
// Refresh tokens
|
|
28
|
+
const refresh = useCallback(async (): Promise<boolean> => {
|
|
29
|
+
if (!config.refreshUrl) return false;
|
|
18
30
|
|
|
19
31
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
// Cookie mode: Just call refresh endpoint, server handles cookie
|
|
33
|
+
if (config.cookieAuth?.enabled) {
|
|
34
|
+
const headers = getCsrfHeaders(config);
|
|
35
|
+
await config.axios.post(config.refreshUrl, {}, { headers });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Token mode: Send refresh token, get new tokens
|
|
40
|
+
if (!tokens?.refreshToken) return false;
|
|
41
|
+
|
|
42
|
+
const response = await config.axios.post(config.refreshUrl, {
|
|
22
43
|
refresh_token: tokens.refreshToken,
|
|
23
44
|
});
|
|
24
45
|
|
|
25
46
|
const newTokens = config.extractTokens(response.data);
|
|
26
47
|
setTokens(newTokens);
|
|
27
48
|
setAxiosAuth(newTokens.accessToken, newTokens.tokenType);
|
|
28
|
-
|
|
29
|
-
config.onTokenRefresh?.(newTokens);
|
|
30
49
|
return true;
|
|
31
50
|
} catch (error) {
|
|
32
|
-
// Refresh failed, clear tokens
|
|
33
51
|
unsetUser();
|
|
34
52
|
setAxiosAuth();
|
|
35
53
|
config.onError?.(error);
|
|
36
54
|
return false;
|
|
37
55
|
}
|
|
38
|
-
}, [tokens
|
|
56
|
+
}, [tokens, config, setTokens, unsetUser, setAxiosAuth]);
|
|
39
57
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
// Check authentication (cookie mode) - with promise deduplication
|
|
59
|
+
const checkAuth = useCallback(async (): Promise<boolean> => {
|
|
60
|
+
const authCheckUrl = config.authCheckUrl;
|
|
61
|
+
if (!config.cookieAuth?.enabled || !authCheckUrl) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} else {
|
|
53
|
-
unsetUser();
|
|
54
|
-
}
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
65
|
+
// Return existing promise if check is already in progress
|
|
66
|
+
const pending = pendingCheckAuth.get(store);
|
|
67
|
+
if (pending) {
|
|
68
|
+
return pending;
|
|
69
|
+
}
|
|
57
70
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
71
|
+
const doCheck = async (): Promise<boolean> => {
|
|
72
|
+
try {
|
|
73
|
+
const headers = getCsrfHeaders(config);
|
|
74
|
+
const response = await config.axios.get(authCheckUrl, { headers });
|
|
62
75
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}, refreshTime);
|
|
76
|
+
if (response.data.authenticated) {
|
|
77
|
+
setAuthenticated(true);
|
|
66
78
|
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
const extractedUser = config.extractUser?.(response.data);
|
|
80
|
+
if (extractedUser) {
|
|
81
|
+
setUser(extractedUser);
|
|
82
|
+
} else if (config.getUserUrl) {
|
|
83
|
+
await getCurrentUser();
|
|
84
|
+
}
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
if (!user && (config.userInfoUrl || config.getUserUrl)) {
|
|
73
|
-
getCurrentUser();
|
|
86
|
+
return true;
|
|
74
87
|
}
|
|
88
|
+
|
|
89
|
+
setAuthenticated(false);
|
|
90
|
+
return false;
|
|
75
91
|
} catch (error) {
|
|
76
|
-
|
|
77
|
-
unsetUser();
|
|
78
|
-
setAxiosAuth();
|
|
79
|
-
}
|
|
92
|
+
setAuthenticated(false);
|
|
80
93
|
config.onError?.(error);
|
|
94
|
+
return false;
|
|
95
|
+
} finally {
|
|
96
|
+
pendingCheckAuth.delete(store);
|
|
81
97
|
}
|
|
82
|
-
}
|
|
83
|
-
}, [tokens, user, config.autoRefresh, config.refreshThreshold, config.userInfoUrl, config.getUserUrl, isTokenExpired, refreshTokens, unsetUser]);
|
|
98
|
+
};
|
|
84
99
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
const promise = doCheck();
|
|
101
|
+
pendingCheckAuth.set(store, promise);
|
|
102
|
+
return promise;
|
|
103
|
+
}, [store, config, setAuthenticated, setUser]);
|
|
104
|
+
|
|
105
|
+
// Get current user
|
|
106
|
+
const getCurrentUser = useCallback(async () => {
|
|
107
|
+
if (!config.getUserUrl) return;
|
|
92
108
|
|
|
93
|
-
const login = async (
|
|
94
|
-
credentials: Record<string, string>,
|
|
95
|
-
callback?: () => void
|
|
96
|
-
) => {
|
|
97
109
|
try {
|
|
98
|
-
const res = await config.axios.
|
|
99
|
-
|
|
100
|
-
|
|
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) {
|
|
110
|
+
const res = await config.axios.get<U>(config.getUserUrl);
|
|
111
|
+
setUser(res.data);
|
|
112
|
+
} catch (error) {
|
|
112
113
|
unsetUser();
|
|
113
114
|
setAxiosAuth();
|
|
114
|
-
config.onError?.(
|
|
115
|
+
config.onError?.(error);
|
|
116
|
+
throw error;
|
|
115
117
|
}
|
|
116
|
-
};
|
|
118
|
+
}, [config, setUser, unsetUser, setAxiosAuth]);
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!userUrl) return;
|
|
120
|
+
// Login
|
|
121
|
+
const login = async (credentials: Record<string, string>, callback?: () => void) => {
|
|
121
122
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
const headers = config.cookieAuth?.enabled ? getCsrfHeaders(config) : {};
|
|
124
|
+
const res = await config.axios.post(config.loginUrl, credentials, { headers });
|
|
125
|
+
|
|
126
|
+
if (config.cookieAuth?.enabled) {
|
|
127
|
+
// Cookie mode: Server sets httpOnly cookie, just mark as authenticated
|
|
128
|
+
setAuthenticated(true);
|
|
129
|
+
} else {
|
|
130
|
+
// Token mode: Extract and store tokens
|
|
131
|
+
const newTokens = config.extractTokens(res.data);
|
|
132
|
+
setTokens(newTokens);
|
|
133
|
+
setAxiosAuth(newTokens.accessToken, newTokens.tokenType);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Extract user from response
|
|
137
|
+
const extractedUser = config.extractUser?.(res.data);
|
|
138
|
+
if (extractedUser) {
|
|
139
|
+
setUser(extractedUser);
|
|
140
|
+
config.onLogin?.(extractedUser);
|
|
141
|
+
} else if (config.getUserUrl) {
|
|
142
|
+
await getCurrentUser();
|
|
143
|
+
const currentUser = store.getState().user;
|
|
144
|
+
if (currentUser) config.onLogin?.(currentUser);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
callback?.();
|
|
148
|
+
} catch (error) {
|
|
125
149
|
unsetUser();
|
|
126
150
|
setAxiosAuth();
|
|
127
|
-
config.onError?.(
|
|
151
|
+
config.onError?.(error);
|
|
152
|
+
throw error;
|
|
128
153
|
}
|
|
129
154
|
};
|
|
130
155
|
|
|
156
|
+
// Logout
|
|
131
157
|
const logout = async () => {
|
|
132
158
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
}
|
|
159
|
+
if (config.logoutUrl) {
|
|
160
|
+
const headers = config.cookieAuth?.enabled ? getCsrfHeaders(config) : {};
|
|
161
|
+
await config.axios.post(config.logoutUrl, {}, { headers });
|
|
146
162
|
}
|
|
147
|
-
} catch (
|
|
148
|
-
config.onError?.(
|
|
163
|
+
} catch (error) {
|
|
164
|
+
config.onError?.(error);
|
|
149
165
|
} finally {
|
|
150
166
|
unsetUser();
|
|
151
167
|
setAxiosAuth();
|
|
@@ -153,5 +169,57 @@ export function useAuth<U>(store: AuthStore<U>) {
|
|
|
153
169
|
}
|
|
154
170
|
};
|
|
155
171
|
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
// Auto-setup on mount
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
// Cookie mode: Check authentication if not yet determined
|
|
175
|
+
if (config.cookieAuth?.enabled) {
|
|
176
|
+
if (isAuthenticated === null) {
|
|
177
|
+
checkAuth();
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Token mode: Setup headers and auto-refresh
|
|
183
|
+
if (tokens?.accessToken) {
|
|
184
|
+
setAxiosAuth(tokens.accessToken, tokens.tokenType);
|
|
185
|
+
|
|
186
|
+
// Check if expired
|
|
187
|
+
if (isTokenExpired()) {
|
|
188
|
+
if (tokens.refreshToken && config.autoRefresh) {
|
|
189
|
+
refresh();
|
|
190
|
+
} else {
|
|
191
|
+
unsetUser();
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Setup auto-refresh timer
|
|
197
|
+
if (tokens.expiresAt && tokens.refreshToken && config.autoRefresh) {
|
|
198
|
+
const timeUntilExpiry = tokens.expiresAt - Date.now();
|
|
199
|
+
const refreshTime = Math.max(timeUntilExpiry - config.refreshThreshold, 0);
|
|
200
|
+
|
|
201
|
+
const timer = setTimeout(refresh, refreshTime);
|
|
202
|
+
return () => clearTimeout(timer);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fetch user if missing
|
|
206
|
+
if (!user && config.getUserUrl) {
|
|
207
|
+
getCurrentUser().catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}, [tokens, user, isAuthenticated, config, isTokenExpired, refresh, checkAuth, getCurrentUser, setAxiosAuth, unsetUser]);
|
|
211
|
+
|
|
212
|
+
return { login, logout, refresh, checkAuth, getCurrentUser };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Helper: Get CSRF headers if enabled (uses getToken from config)
|
|
216
|
+
function getCsrfHeaders(config: any): Record<string, string> {
|
|
217
|
+
const headers: Record<string, string> = {};
|
|
218
|
+
if (config.cookieAuth?.csrf?.enabled) {
|
|
219
|
+
const csrfToken = config.cookieAuth.csrf.getToken();
|
|
220
|
+
if (csrfToken) {
|
|
221
|
+
headers[config.cookieAuth.csrf.headerName] = csrfToken;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return headers;
|
|
225
|
+
}
|
package/dist/setupTests.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import '@testing-library/react';
|