@jasperoosthoek/zustand-auth-registry 0.0.1 → 0.0.2
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 +25 -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 +119 -112
- package/src/authStore.ts +46 -57
- package/src/createAuthRegistry.ts +1 -1
- package/src/errors.ts +104 -0
- package/src/index.ts +2 -1
- package/src/useAuth.ts +181 -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,19 +2,17 @@ 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>>> & {
|
|
@@ -22,23 +20,29 @@ export type AuthStore<U> = UseBoundStore<StoreApi<AuthState<U>>> & {
|
|
|
22
20
|
};
|
|
23
21
|
|
|
24
22
|
export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U> => {
|
|
25
|
-
const { persistence } = config;
|
|
26
|
-
|
|
23
|
+
const { persistence, cookieAuth } = config;
|
|
24
|
+
|
|
27
25
|
const getStoredTokens = (): TokenData | null => {
|
|
26
|
+
// Cookie mode: No client-side tokens
|
|
27
|
+
if (cookieAuth?.enabled) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Token mode: Read from storage
|
|
28
32
|
if (!persistence.enabled) return null;
|
|
29
33
|
try {
|
|
30
34
|
const accessToken = persistence.storage.getItem(persistence.tokenKey);
|
|
31
35
|
if (!accessToken) return null;
|
|
32
|
-
|
|
36
|
+
|
|
33
37
|
const refreshToken = persistence.storage.getItem(persistence.refreshTokenKey);
|
|
34
38
|
const expiryString = persistence.storage.getItem(persistence.expiryKey);
|
|
35
39
|
const expiresAt = expiryString ? parseInt(expiryString, 10) : undefined;
|
|
36
|
-
|
|
40
|
+
|
|
37
41
|
return {
|
|
38
42
|
accessToken,
|
|
39
43
|
refreshToken: refreshToken || undefined,
|
|
40
44
|
expiresAt: expiresAt && !isNaN(expiresAt) ? expiresAt : undefined,
|
|
41
|
-
tokenType: 'Bearer',
|
|
45
|
+
tokenType: 'Bearer',
|
|
42
46
|
};
|
|
43
47
|
} catch {
|
|
44
48
|
return null;
|
|
@@ -57,30 +61,37 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
57
61
|
|
|
58
62
|
const initialTokens = getStoredTokens();
|
|
59
63
|
const initialUser = getStoredUser();
|
|
60
|
-
|
|
64
|
+
|
|
65
|
+
// Cookie mode: null (unknown until checkAuth)
|
|
66
|
+
// Token mode: true/false based on token presence
|
|
67
|
+
const initialIsAuthenticated = cookieAuth?.enabled
|
|
68
|
+
? null
|
|
69
|
+
: !!initialTokens?.accessToken;
|
|
61
70
|
|
|
62
71
|
const store = create<AuthState<U>>((set, get) => ({
|
|
63
72
|
tokens: initialTokens,
|
|
64
73
|
user: initialUser,
|
|
65
74
|
isAuthenticated: initialIsAuthenticated,
|
|
66
75
|
|
|
67
|
-
// OAuth 2.0 methods
|
|
68
76
|
setTokens: (tokens: TokenData) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
set({ tokens, isAuthenticated: true });
|
|
78
|
+
|
|
79
|
+
// Cookie mode: No localStorage persistence for tokens
|
|
80
|
+
if (cookieAuth?.enabled) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Token mode: Persist to storage
|
|
74
85
|
if (persistence.enabled) {
|
|
75
86
|
try {
|
|
76
87
|
persistence.storage.setItem(persistence.tokenKey, tokens.accessToken);
|
|
77
|
-
|
|
88
|
+
|
|
78
89
|
if (tokens.refreshToken) {
|
|
79
90
|
persistence.storage.setItem(persistence.refreshTokenKey, tokens.refreshToken);
|
|
80
91
|
} else {
|
|
81
92
|
persistence.storage.removeItem(persistence.refreshTokenKey);
|
|
82
93
|
}
|
|
83
|
-
|
|
94
|
+
|
|
84
95
|
if (tokens.expiresAt) {
|
|
85
96
|
persistence.storage.setItem(persistence.expiryKey, tokens.expiresAt.toString());
|
|
86
97
|
} else {
|
|
@@ -92,12 +103,17 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
92
103
|
}
|
|
93
104
|
},
|
|
94
105
|
|
|
106
|
+
setBearerToken: (token: string) => {
|
|
107
|
+
get().setTokens({ accessToken: token, tokenType: 'Bearer' });
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
setAuthenticated: (authenticated: boolean) => {
|
|
111
|
+
set({ isAuthenticated: authenticated });
|
|
112
|
+
},
|
|
113
|
+
|
|
95
114
|
setUser: (user: U) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
set({ user, isAuthenticated });
|
|
100
|
-
|
|
115
|
+
set({ user });
|
|
116
|
+
|
|
101
117
|
if (persistence.enabled) {
|
|
102
118
|
try {
|
|
103
119
|
persistence.storage.setItem(persistence.userKey, JSON.stringify(user));
|
|
@@ -108,8 +124,8 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
108
124
|
},
|
|
109
125
|
|
|
110
126
|
unsetUser: () => {
|
|
111
|
-
set({ user: null, tokens: null, isAuthenticated: false
|
|
112
|
-
|
|
127
|
+
set({ user: null, tokens: null, isAuthenticated: false });
|
|
128
|
+
|
|
113
129
|
if (persistence.enabled) {
|
|
114
130
|
try {
|
|
115
131
|
persistence.storage.removeItem(persistence.tokenKey);
|
|
@@ -124,36 +140,9 @@ export const createAuthStore = <U>(config: ValidatedAuthConfig<U>): AuthStore<U>
|
|
|
124
140
|
|
|
125
141
|
isTokenExpired: () => {
|
|
126
142
|
const tokens = get().tokens;
|
|
127
|
-
if (!tokens?.expiresAt) return false;
|
|
143
|
+
if (!tokens?.expiresAt) return false;
|
|
128
144
|
return Date.now() >= tokens.expiresAt;
|
|
129
145
|
},
|
|
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
146
|
}));
|
|
158
147
|
|
|
159
148
|
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,172 @@
|
|
|
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
|
-
const
|
|
11
|
+
// Set axios Authorization header (token mode only)
|
|
12
|
+
const setAxiosAuth = useCallback((token?: string, tokenType?: string) => {
|
|
13
|
+
if (config.cookieAuth?.enabled) {
|
|
14
|
+
// Cookie mode: CSRF header if enabled, no Authorization header
|
|
15
|
+
if (config.cookieAuth.csrf.enabled) {
|
|
16
|
+
const csrfToken = getCookie(config.cookieAuth.csrf.cookieName);
|
|
17
|
+
if (csrfToken) {
|
|
18
|
+
config.axios.defaults.headers.common[config.cookieAuth.csrf.headerName] = csrfToken;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
12
23
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
// Token mode: Set Authorization header
|
|
25
|
+
if (token) {
|
|
26
|
+
config.axios.defaults.headers.common['Authorization'] = config.formatAuthHeader(token, tokenType);
|
|
27
|
+
} else {
|
|
28
|
+
delete config.axios.defaults.headers.common['Authorization'];
|
|
17
29
|
}
|
|
30
|
+
}, [config]);
|
|
31
|
+
|
|
32
|
+
// Refresh tokens
|
|
33
|
+
const refresh = useCallback(async (): Promise<boolean> => {
|
|
34
|
+
if (!config.refreshUrl) return false;
|
|
18
35
|
|
|
19
36
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
37
|
+
// Cookie mode: Just call refresh endpoint, server handles cookie
|
|
38
|
+
if (config.cookieAuth?.enabled) {
|
|
39
|
+
const headers = getCsrfHeaders(config);
|
|
40
|
+
await config.axios.post(config.refreshUrl, {}, { headers });
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Token mode: Send refresh token, get new tokens
|
|
45
|
+
if (!tokens?.refreshToken) return false;
|
|
46
|
+
|
|
47
|
+
const response = await config.axios.post(config.refreshUrl, {
|
|
22
48
|
refresh_token: tokens.refreshToken,
|
|
23
49
|
});
|
|
24
50
|
|
|
25
51
|
const newTokens = config.extractTokens(response.data);
|
|
26
52
|
setTokens(newTokens);
|
|
27
53
|
setAxiosAuth(newTokens.accessToken, newTokens.tokenType);
|
|
28
|
-
|
|
29
|
-
config.onTokenRefresh?.(newTokens);
|
|
30
54
|
return true;
|
|
31
55
|
} catch (error) {
|
|
32
|
-
// Refresh failed, clear tokens
|
|
33
56
|
unsetUser();
|
|
34
57
|
setAxiosAuth();
|
|
35
58
|
config.onError?.(error);
|
|
36
59
|
return false;
|
|
37
60
|
}
|
|
38
|
-
}, [tokens
|
|
61
|
+
}, [tokens, config, setTokens, unsetUser, setAxiosAuth]);
|
|
39
62
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
// Check authentication (cookie mode) - with promise deduplication
|
|
64
|
+
const checkAuth = useCallback(async (): Promise<boolean> => {
|
|
65
|
+
const authCheckUrl = config.authCheckUrl;
|
|
66
|
+
if (!config.cookieAuth?.enabled || !authCheckUrl) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} else {
|
|
53
|
-
unsetUser();
|
|
54
|
-
}
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
70
|
+
// Return existing promise if check is already in progress
|
|
71
|
+
const pending = pendingCheckAuth.get(store);
|
|
72
|
+
if (pending) {
|
|
73
|
+
return pending;
|
|
74
|
+
}
|
|
57
75
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
76
|
+
const doCheck = async (): Promise<boolean> => {
|
|
77
|
+
try {
|
|
78
|
+
const headers = getCsrfHeaders(config);
|
|
79
|
+
const response = await config.axios.get(authCheckUrl, { headers });
|
|
62
80
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}, refreshTime);
|
|
81
|
+
if (response.data.authenticated) {
|
|
82
|
+
setAuthenticated(true);
|
|
66
83
|
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
const extractedUser = config.extractUser?.(response.data);
|
|
85
|
+
if (extractedUser) {
|
|
86
|
+
setUser(extractedUser);
|
|
87
|
+
} else if (config.getUserUrl) {
|
|
88
|
+
await getCurrentUser();
|
|
89
|
+
}
|
|
69
90
|
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
if (!user && (config.userInfoUrl || config.getUserUrl)) {
|
|
73
|
-
getCurrentUser();
|
|
91
|
+
return true;
|
|
74
92
|
}
|
|
93
|
+
|
|
94
|
+
setAuthenticated(false);
|
|
95
|
+
return false;
|
|
75
96
|
} catch (error) {
|
|
76
|
-
|
|
77
|
-
unsetUser();
|
|
78
|
-
setAxiosAuth();
|
|
79
|
-
}
|
|
97
|
+
setAuthenticated(false);
|
|
80
98
|
config.onError?.(error);
|
|
99
|
+
return false;
|
|
100
|
+
} finally {
|
|
101
|
+
pendingCheckAuth.delete(store);
|
|
81
102
|
}
|
|
82
|
-
}
|
|
83
|
-
}, [tokens, user, config.autoRefresh, config.refreshThreshold, config.userInfoUrl, config.getUserUrl, isTokenExpired, refreshTokens, unsetUser]);
|
|
103
|
+
};
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
const promise = doCheck();
|
|
106
|
+
pendingCheckAuth.set(store, promise);
|
|
107
|
+
return promise;
|
|
108
|
+
}, [store, config, setAuthenticated, setUser]);
|
|
109
|
+
|
|
110
|
+
// Get current user
|
|
111
|
+
const getCurrentUser = useCallback(async () => {
|
|
112
|
+
if (!config.getUserUrl) return;
|
|
92
113
|
|
|
93
|
-
const login = async (
|
|
94
|
-
credentials: Record<string, string>,
|
|
95
|
-
callback?: () => void
|
|
96
|
-
) => {
|
|
97
114
|
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) {
|
|
115
|
+
const res = await config.axios.get<U>(config.getUserUrl);
|
|
116
|
+
setUser(res.data);
|
|
117
|
+
} catch (error) {
|
|
112
118
|
unsetUser();
|
|
113
119
|
setAxiosAuth();
|
|
114
|
-
config.onError?.(
|
|
120
|
+
config.onError?.(error);
|
|
121
|
+
throw error;
|
|
115
122
|
}
|
|
116
|
-
};
|
|
123
|
+
}, [config, setUser, unsetUser, setAxiosAuth]);
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!userUrl) return;
|
|
125
|
+
// Login
|
|
126
|
+
const login = async (credentials: Record<string, string>, callback?: () => void) => {
|
|
121
127
|
try {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
const headers = config.cookieAuth?.enabled ? getCsrfHeaders(config) : {};
|
|
129
|
+
const res = await config.axios.post(config.loginUrl, credentials, { headers });
|
|
130
|
+
|
|
131
|
+
if (config.cookieAuth?.enabled) {
|
|
132
|
+
// Cookie mode: Server sets httpOnly cookie, just mark as authenticated
|
|
133
|
+
setAuthenticated(true);
|
|
134
|
+
} else {
|
|
135
|
+
// Token mode: Extract and store tokens
|
|
136
|
+
const newTokens = config.extractTokens(res.data);
|
|
137
|
+
setTokens(newTokens);
|
|
138
|
+
setAxiosAuth(newTokens.accessToken, newTokens.tokenType);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Extract user from response
|
|
142
|
+
const extractedUser = config.extractUser?.(res.data);
|
|
143
|
+
if (extractedUser) {
|
|
144
|
+
setUser(extractedUser);
|
|
145
|
+
config.onLogin?.(extractedUser);
|
|
146
|
+
} else if (config.getUserUrl) {
|
|
147
|
+
await getCurrentUser();
|
|
148
|
+
const currentUser = store.getState().user;
|
|
149
|
+
if (currentUser) config.onLogin?.(currentUser);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
callback?.();
|
|
153
|
+
} catch (error) {
|
|
125
154
|
unsetUser();
|
|
126
155
|
setAxiosAuth();
|
|
127
|
-
config.onError?.(
|
|
156
|
+
config.onError?.(error);
|
|
157
|
+
throw error;
|
|
128
158
|
}
|
|
129
159
|
};
|
|
130
160
|
|
|
161
|
+
// Logout
|
|
131
162
|
const logout = async () => {
|
|
132
163
|
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
|
-
}
|
|
164
|
+
if (config.logoutUrl) {
|
|
165
|
+
const headers = config.cookieAuth?.enabled ? getCsrfHeaders(config) : {};
|
|
166
|
+
await config.axios.post(config.logoutUrl, {}, { headers });
|
|
146
167
|
}
|
|
147
|
-
} catch (
|
|
148
|
-
config.onError?.(
|
|
168
|
+
} catch (error) {
|
|
169
|
+
config.onError?.(error);
|
|
149
170
|
} finally {
|
|
150
171
|
unsetUser();
|
|
151
172
|
setAxiosAuth();
|
|
@@ -153,5 +174,64 @@ export function useAuth<U>(store: AuthStore<U>) {
|
|
|
153
174
|
}
|
|
154
175
|
};
|
|
155
176
|
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
// Auto-setup on mount
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
// Cookie mode: Check authentication if not yet determined
|
|
180
|
+
if (config.cookieAuth?.enabled) {
|
|
181
|
+
if (isAuthenticated === null) {
|
|
182
|
+
checkAuth();
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Token mode: Setup headers and auto-refresh
|
|
188
|
+
if (tokens?.accessToken) {
|
|
189
|
+
setAxiosAuth(tokens.accessToken, tokens.tokenType);
|
|
190
|
+
|
|
191
|
+
// Check if expired
|
|
192
|
+
if (isTokenExpired()) {
|
|
193
|
+
if (tokens.refreshToken && config.autoRefresh) {
|
|
194
|
+
refresh();
|
|
195
|
+
} else {
|
|
196
|
+
unsetUser();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Setup auto-refresh timer
|
|
202
|
+
if (tokens.expiresAt && tokens.refreshToken && config.autoRefresh) {
|
|
203
|
+
const timeUntilExpiry = tokens.expiresAt - Date.now();
|
|
204
|
+
const refreshTime = Math.max(timeUntilExpiry - config.refreshThreshold, 0);
|
|
205
|
+
|
|
206
|
+
const timer = setTimeout(refresh, refreshTime);
|
|
207
|
+
return () => clearTimeout(timer);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fetch user if missing
|
|
211
|
+
if (!user && config.getUserUrl) {
|
|
212
|
+
getCurrentUser().catch(() => {});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}, [tokens, user, isAuthenticated, config, isTokenExpired, refresh, checkAuth, getCurrentUser, setAxiosAuth, unsetUser]);
|
|
216
|
+
|
|
217
|
+
return { login, logout, refresh, checkAuth, getCurrentUser };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Helper: Get cookie value
|
|
221
|
+
function getCookie(name: string): string | null {
|
|
222
|
+
if (typeof document === 'undefined') return null;
|
|
223
|
+
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
|
|
224
|
+
return match ? match[2] : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Helper: Get CSRF headers if enabled
|
|
228
|
+
function getCsrfHeaders(config: any): Record<string, string> {
|
|
229
|
+
const headers: Record<string, string> = {};
|
|
230
|
+
if (config.cookieAuth?.csrf?.enabled) {
|
|
231
|
+
const csrfToken = getCookie(config.cookieAuth.csrf.cookieName);
|
|
232
|
+
if (csrfToken) {
|
|
233
|
+
headers[config.cookieAuth.csrf.headerName] = csrfToken;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return headers;
|
|
237
|
+
}
|
package/dist/setupTests.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import '@testing-library/react';
|