@startsimpli/auth 0.1.0
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 +484 -0
- package/package.json +43 -0
- package/src/__tests__/permissions.test.ts +42 -0
- package/src/__tests__/token.test.ts +97 -0
- package/src/client/auth-client.ts +265 -0
- package/src/client/auth-context.tsx +153 -0
- package/src/client/functions.ts +424 -0
- package/src/client/index.ts +5 -0
- package/src/client/use-auth.ts +45 -0
- package/src/client/use-permissions.ts +82 -0
- package/src/email/index.ts +136 -0
- package/src/index.ts +41 -0
- package/src/server/guards.ts +113 -0
- package/src/server/index.ts +20 -0
- package/src/server/middleware.ts +106 -0
- package/src/server/session.ts +115 -0
- package/src/types/index.ts +142 -0
- package/src/utils/cookies.ts +86 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/token.ts +89 -0
- package/src/validation/index.ts +34 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side authentication manager
|
|
3
|
+
* Handles JWT tokens, login/logout, and token refresh
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
AuthConfig,
|
|
8
|
+
Session,
|
|
9
|
+
LoginResponse,
|
|
10
|
+
RefreshResponse,
|
|
11
|
+
AuthUser,
|
|
12
|
+
} from '../types';
|
|
13
|
+
import { isTokenExpired, getTokenExpiresAt, shouldRefreshToken } from '../utils';
|
|
14
|
+
|
|
15
|
+
export class AuthClient {
|
|
16
|
+
private config: Required<AuthConfig>;
|
|
17
|
+
private session: Session | null = null;
|
|
18
|
+
private refreshTimer: NodeJS.Timeout | null = null;
|
|
19
|
+
private isRefreshing = false;
|
|
20
|
+
private refreshPromise: Promise<string> | null = null;
|
|
21
|
+
|
|
22
|
+
constructor(config: AuthConfig) {
|
|
23
|
+
this.config = {
|
|
24
|
+
tokenRefreshInterval: 4 * 60 * 1000, // 4 minutes
|
|
25
|
+
onSessionExpired: () => {},
|
|
26
|
+
onUnauthorized: () => {},
|
|
27
|
+
...config,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Login with email and password
|
|
33
|
+
*/
|
|
34
|
+
async login(email: string, password: string): Promise<Session> {
|
|
35
|
+
const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/token/`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
credentials: 'include', // Include cookies for refresh token
|
|
39
|
+
body: JSON.stringify({ email, password }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
const error = await response.json().catch(() => ({ detail: 'Login failed' }));
|
|
44
|
+
throw new Error(error.detail || 'Login failed');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data: any = await response.json();
|
|
48
|
+
const expiresAt = getTokenExpiresAt(data.access);
|
|
49
|
+
|
|
50
|
+
if (!expiresAt) {
|
|
51
|
+
throw new Error('Invalid token received');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Django might not include user in token response, so fetch it separately
|
|
55
|
+
const tempSession = {
|
|
56
|
+
user: data.user || { id: '', email: '', firstName: '', lastName: '', isEmailVerified: false, createdAt: '', updatedAt: '' },
|
|
57
|
+
accessToken: data.access,
|
|
58
|
+
expiresAt,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
this.session = tempSession;
|
|
62
|
+
|
|
63
|
+
// Fetch user data if not included in login response
|
|
64
|
+
if (!data.user) {
|
|
65
|
+
try {
|
|
66
|
+
const user = await this.getCurrentUser();
|
|
67
|
+
this.session.user = user;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Failed to fetch user data after login:', error);
|
|
70
|
+
// Continue anyway - some user data is better than none
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.startRefreshTimer();
|
|
75
|
+
return this.session;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Logout and clear session
|
|
80
|
+
*/
|
|
81
|
+
async logout(): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
await fetch(`${this.config.apiBaseUrl}/api/v1/auth/logout/`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: this.getAuthHeaders(),
|
|
86
|
+
credentials: 'include',
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Logout error:', error);
|
|
90
|
+
} finally {
|
|
91
|
+
this.clearSession();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Refresh access token using refresh token cookie
|
|
97
|
+
*/
|
|
98
|
+
async refreshToken(): Promise<string> {
|
|
99
|
+
// Prevent multiple simultaneous refresh requests
|
|
100
|
+
if (this.isRefreshing && this.refreshPromise) {
|
|
101
|
+
return this.refreshPromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.isRefreshing = true;
|
|
105
|
+
this.refreshPromise = this.performTokenRefresh();
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const newToken = await this.refreshPromise;
|
|
109
|
+
return newToken;
|
|
110
|
+
} finally {
|
|
111
|
+
this.isRefreshing = false;
|
|
112
|
+
this.refreshPromise = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async performTokenRefresh(): Promise<string> {
|
|
117
|
+
const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/token/refresh/`, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
credentials: 'include', // Send refresh token cookie
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
this.clearSession();
|
|
125
|
+
this.config.onSessionExpired();
|
|
126
|
+
throw new Error('Token refresh failed');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const data: RefreshResponse = await response.json();
|
|
130
|
+
const expiresAt = getTokenExpiresAt(data.access);
|
|
131
|
+
|
|
132
|
+
if (!expiresAt) {
|
|
133
|
+
throw new Error('Invalid token received');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this.session) {
|
|
137
|
+
this.session.accessToken = data.access;
|
|
138
|
+
this.session.expiresAt = expiresAt;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.startRefreshTimer();
|
|
142
|
+
return data.access;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get current user data from backend
|
|
147
|
+
*/
|
|
148
|
+
async getCurrentUser(): Promise<AuthUser> {
|
|
149
|
+
const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/me/`, {
|
|
150
|
+
headers: this.getAuthHeaders(),
|
|
151
|
+
credentials: 'include',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
if (response.status === 401) {
|
|
156
|
+
this.config.onUnauthorized();
|
|
157
|
+
}
|
|
158
|
+
throw new Error('Failed to fetch user data');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const user: AuthUser = await response.json();
|
|
162
|
+
|
|
163
|
+
if (this.session) {
|
|
164
|
+
this.session.user = user;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return user;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get current session
|
|
172
|
+
*/
|
|
173
|
+
getSession(): Session | null {
|
|
174
|
+
if (!this.session) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isTokenExpired(this.session.accessToken)) {
|
|
179
|
+
this.clearSession();
|
|
180
|
+
this.config.onSessionExpired();
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return this.session;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Set session (for SSR/hydration)
|
|
189
|
+
*/
|
|
190
|
+
setSession(session: Session): void {
|
|
191
|
+
this.session = session;
|
|
192
|
+
this.startRefreshTimer();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get auth headers for API requests
|
|
197
|
+
*/
|
|
198
|
+
getAuthHeaders(): Record<string, string> {
|
|
199
|
+
const session = this.getSession();
|
|
200
|
+
if (!session) {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
Authorization: `Bearer ${session.accessToken}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get valid access token (refreshes if needed)
|
|
211
|
+
*/
|
|
212
|
+
async getAccessToken(): Promise<string | null> {
|
|
213
|
+
const session = this.getSession();
|
|
214
|
+
if (!session) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (shouldRefreshToken(session.accessToken)) {
|
|
219
|
+
try {
|
|
220
|
+
return await this.refreshToken();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('Token refresh failed:', error);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return session.accessToken;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Start automatic token refresh timer
|
|
232
|
+
*/
|
|
233
|
+
private startRefreshTimer(): void {
|
|
234
|
+
if (this.refreshTimer) {
|
|
235
|
+
clearInterval(this.refreshTimer);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.refreshTimer = setInterval(() => {
|
|
239
|
+
const session = this.getSession();
|
|
240
|
+
if (session && shouldRefreshToken(session.accessToken)) {
|
|
241
|
+
this.refreshToken().catch((error) => {
|
|
242
|
+
console.error('Auto-refresh failed:', error);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}, this.config.tokenRefreshInterval);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Clear session and stop refresh timer
|
|
250
|
+
*/
|
|
251
|
+
private clearSession(): void {
|
|
252
|
+
this.session = null;
|
|
253
|
+
if (this.refreshTimer) {
|
|
254
|
+
clearInterval(this.refreshTimer);
|
|
255
|
+
this.refreshTimer = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Cleanup resources
|
|
261
|
+
*/
|
|
262
|
+
destroy(): void {
|
|
263
|
+
this.clearSession();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React context provider for authentication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use client';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createContext,
|
|
9
|
+
useContext,
|
|
10
|
+
useEffect,
|
|
11
|
+
useState,
|
|
12
|
+
useCallback,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from 'react';
|
|
15
|
+
import { AuthClient } from './auth-client';
|
|
16
|
+
import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
|
|
17
|
+
|
|
18
|
+
interface AuthContextValue extends AuthState {
|
|
19
|
+
login: (email: string, password: string) => Promise<void>;
|
|
20
|
+
logout: () => Promise<void>;
|
|
21
|
+
refreshUser: () => Promise<void>;
|
|
22
|
+
getAccessToken: () => Promise<string | null>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|
26
|
+
|
|
27
|
+
interface AuthProviderProps {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
config: AuthConfig;
|
|
30
|
+
initialSession?: Session | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AuthProvider({
|
|
34
|
+
children,
|
|
35
|
+
config,
|
|
36
|
+
initialSession,
|
|
37
|
+
}: AuthProviderProps) {
|
|
38
|
+
const [authClient] = useState(() => new AuthClient(config));
|
|
39
|
+
const [state, setState] = useState<AuthState>(() => ({
|
|
40
|
+
session: initialSession || null,
|
|
41
|
+
isLoading: !initialSession,
|
|
42
|
+
isAuthenticated: !!initialSession,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Initialize session from SSR or check for existing session
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (initialSession) {
|
|
48
|
+
authClient.setSession(initialSession);
|
|
49
|
+
setState({
|
|
50
|
+
session: initialSession,
|
|
51
|
+
isLoading: false,
|
|
52
|
+
isAuthenticated: true,
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
// Try to get session from client
|
|
56
|
+
const session = authClient.getSession();
|
|
57
|
+
setState({
|
|
58
|
+
session,
|
|
59
|
+
isLoading: false,
|
|
60
|
+
isAuthenticated: !!session,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}, [authClient, initialSession]);
|
|
64
|
+
|
|
65
|
+
// Session expiration handler
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const originalOnExpired = config.onSessionExpired;
|
|
68
|
+
config.onSessionExpired = () => {
|
|
69
|
+
setState({
|
|
70
|
+
session: null,
|
|
71
|
+
isLoading: false,
|
|
72
|
+
isAuthenticated: false,
|
|
73
|
+
});
|
|
74
|
+
originalOnExpired?.();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
authClient.destroy();
|
|
79
|
+
};
|
|
80
|
+
}, [authClient, config]);
|
|
81
|
+
|
|
82
|
+
const login = useCallback(
|
|
83
|
+
async (email: string, password: string) => {
|
|
84
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const session = await authClient.login(email, password);
|
|
88
|
+
setState({
|
|
89
|
+
session,
|
|
90
|
+
isLoading: false,
|
|
91
|
+
isAuthenticated: true,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[authClient]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const logout = useCallback(async () => {
|
|
102
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await authClient.logout();
|
|
106
|
+
} finally {
|
|
107
|
+
setState({
|
|
108
|
+
session: null,
|
|
109
|
+
isLoading: false,
|
|
110
|
+
isAuthenticated: false,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}, [authClient]);
|
|
114
|
+
|
|
115
|
+
const refreshUser = useCallback(async () => {
|
|
116
|
+
try {
|
|
117
|
+
const user = await authClient.getCurrentUser();
|
|
118
|
+
setState((prev) => ({
|
|
119
|
+
...prev,
|
|
120
|
+
session: prev.session
|
|
121
|
+
? { ...prev.session, user }
|
|
122
|
+
: null,
|
|
123
|
+
}));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Failed to refresh user:', error);
|
|
126
|
+
}
|
|
127
|
+
}, [authClient]);
|
|
128
|
+
|
|
129
|
+
const getAccessToken = useCallback(async () => {
|
|
130
|
+
return authClient.getAccessToken();
|
|
131
|
+
}, [authClient]);
|
|
132
|
+
|
|
133
|
+
const value: AuthContextValue = {
|
|
134
|
+
...state,
|
|
135
|
+
login,
|
|
136
|
+
logout,
|
|
137
|
+
refreshUser,
|
|
138
|
+
getAccessToken,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hook to access auth context
|
|
146
|
+
*/
|
|
147
|
+
export function useAuthContext(): AuthContextValue {
|
|
148
|
+
const context = useContext(AuthContext);
|
|
149
|
+
if (!context) {
|
|
150
|
+
throw new Error('useAuthContext must be used within AuthProvider');
|
|
151
|
+
}
|
|
152
|
+
return context;
|
|
153
|
+
}
|