@startsimpli/auth 0.4.8 → 0.4.9

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.
@@ -11,6 +11,35 @@ import type {
11
11
  AuthUser,
12
12
  } from '../types';
13
13
  import { isTokenExpired, getTokenExpiresAt, shouldRefreshToken } from '../utils';
14
+ import { extractApiError, setAccessToken as setModuleAccessToken } from './functions';
15
+ import { deleteCookie } from '../utils/cookies';
16
+
17
+ /**
18
+ * sessionStorage key set by logout to suppress any subsequent
19
+ * bootstrapFromCookies from resurrecting a session via a refresh_token
20
+ * cookie that wasn't successfully deleted on the server side. Cleared on
21
+ * successful login / register / OAuth completion.
22
+ */
23
+ const LOGGED_OUT_FLAG = '__ss_logged_out';
24
+
25
+ function setLoggedOutFlag(): void {
26
+ try {
27
+ if (typeof window !== 'undefined') window.sessionStorage.setItem(LOGGED_OUT_FLAG, '1');
28
+ } catch {}
29
+ }
30
+ function clearLoggedOutFlag(): void {
31
+ try {
32
+ if (typeof window !== 'undefined') window.sessionStorage.removeItem(LOGGED_OUT_FLAG);
33
+ } catch {}
34
+ }
35
+ function hasLoggedOutFlag(): boolean {
36
+ try {
37
+ if (typeof window === 'undefined') return false;
38
+ return window.sessionStorage.getItem(LOGGED_OUT_FLAG) === '1';
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
14
43
 
15
44
  export class AuthClient {
16
45
  private config: Required<AuthConfig>;
@@ -40,8 +69,8 @@ export class AuthClient {
40
69
  });
41
70
 
42
71
  if (!response.ok) {
43
- const error = await response.json().catch(() => ({ detail: 'Login failed' }));
44
- throw new Error(error.detail || 'Login failed');
72
+ const data = await response.json().catch(() => ({} as Record<string, unknown>));
73
+ throw new Error(extractApiError(data as Record<string, unknown>, 'Login failed'));
45
74
  }
46
75
 
47
76
  const data: any = await response.json();
@@ -59,6 +88,11 @@ export class AuthClient {
59
88
  };
60
89
 
61
90
  this.session = tempSession;
91
+ // Mirror the access token into the module-level storage so any consumer
92
+ // reading via functions.ts `getAccessToken()` (e.g. @startsimpli/api's
93
+ // FetchWrapper) sees the same value as useAuth().session.
94
+ setModuleAccessToken(data.access);
95
+ clearLoggedOutFlag();
62
96
 
63
97
  // Fetch user data if not included in login response
64
98
  if (!data.user) {
@@ -75,6 +109,177 @@ export class AuthClient {
75
109
  return this.session;
76
110
  }
77
111
 
112
+ /**
113
+ * Register a new account and open a session.
114
+ *
115
+ * POSTs to /api/v1/auth/register/ with snake_case body fields. If the
116
+ * backend returns a user in the response, use it directly; otherwise fall
117
+ * back to /me/ (same pattern as login).
118
+ */
119
+ async register(payload: {
120
+ email: string;
121
+ password: string;
122
+ passwordConfirm: string;
123
+ name?: string;
124
+ firstName?: string;
125
+ lastName?: string;
126
+ }): Promise<Session> {
127
+ // Derive first/last from `name` if the caller used it.
128
+ const rawName = payload.name?.trim() ?? '';
129
+ const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
130
+ const lastFromName = rest.length ? rest.join(' ') : undefined;
131
+
132
+ const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/register/`, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ credentials: 'include',
136
+ body: JSON.stringify({
137
+ email: payload.email,
138
+ password: payload.password,
139
+ password_confirm: payload.passwordConfirm,
140
+ name: payload.name,
141
+ first_name: payload.firstName ?? firstFromName ?? undefined,
142
+ last_name: payload.lastName ?? lastFromName ?? undefined,
143
+ }),
144
+ });
145
+
146
+ const data = await response.json().catch(() => ({} as Record<string, unknown>));
147
+
148
+ if (!response.ok) {
149
+ throw new Error(extractApiError(data as Record<string, unknown>, 'Registration failed'));
150
+ }
151
+
152
+ return this.sessionFromTokenResponse(data as Record<string, unknown>);
153
+ }
154
+
155
+ /**
156
+ * Kick off Google OAuth by asking the backend for an authorization URL.
157
+ *
158
+ * @param redirectTo optional in-app path the server-side callback should
159
+ * route to after exchanging the code. Defaults to the current origin
160
+ * + /auth/callback.
161
+ * @returns the Google authorization URL the caller should redirect to.
162
+ */
163
+ async signInWithGoogle(redirectTo?: string): Promise<string> {
164
+ const defaultRedirect =
165
+ typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : '';
166
+ const redirectUri = redirectTo ?? defaultRedirect;
167
+
168
+ const response = await fetch(
169
+ `${this.config.apiBaseUrl}/api/v1/auth/oauth/google/initiate/`,
170
+ {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ credentials: 'include',
174
+ body: JSON.stringify({ redirect_uri: redirectUri }),
175
+ }
176
+ );
177
+
178
+ const data = await response.json().catch(() => ({} as Record<string, unknown>));
179
+
180
+ if (!response.ok) {
181
+ throw new Error(
182
+ extractApiError(data as Record<string, unknown>, 'Failed to initiate Google OAuth')
183
+ );
184
+ }
185
+
186
+ const url = (data as { auth_url?: string; authUrl?: string }).auth_url
187
+ ?? (data as { authUrl?: string }).authUrl;
188
+ if (!url) {
189
+ throw new Error('OAuth initiation succeeded but no auth_url was returned');
190
+ }
191
+ return url;
192
+ }
193
+
194
+ /**
195
+ * Complete a Google OAuth callback: exchange the code + state for a session.
196
+ */
197
+ async completeGoogleCallback(code: string, state: string): Promise<Session> {
198
+ const url = new URL(
199
+ `${this.config.apiBaseUrl}/api/v1/auth/oauth/google/callback/`
200
+ );
201
+ url.searchParams.set('code', code);
202
+ url.searchParams.set('state', state);
203
+
204
+ const response = await fetch(url.toString(), {
205
+ credentials: 'include',
206
+ });
207
+
208
+ const data = await response.json().catch(() => ({} as Record<string, unknown>));
209
+
210
+ if (!response.ok) {
211
+ throw new Error(
212
+ extractApiError(data as Record<string, unknown>, 'OAuth authentication failed')
213
+ );
214
+ }
215
+
216
+ return this.sessionFromTokenResponse(data as Record<string, unknown>);
217
+ }
218
+
219
+ /**
220
+ * Shared session builder for flows that return `{ access, user? }`.
221
+ *
222
+ * - Validates the access token and derives expiresAt
223
+ * - Uses the response user if present, else fetches /me/
224
+ * - Stores the session and starts the refresh timer
225
+ */
226
+ private async sessionFromTokenResponse(data: Record<string, unknown>): Promise<Session> {
227
+ const accessToken = (data.access ?? data.accessToken) as string | undefined;
228
+ if (!accessToken) {
229
+ throw new Error('Auth response missing access token');
230
+ }
231
+
232
+ const expiresAt = getTokenExpiresAt(accessToken);
233
+ if (!expiresAt) {
234
+ throw new Error('Invalid token received');
235
+ }
236
+
237
+ // Seed session with whatever user payload the response gave us so
238
+ // getCurrentUser() (which reads this.session for its auth header) can
239
+ // run if we need it.
240
+ const rawUser = data.user as Record<string, unknown> | undefined;
241
+ let user: AuthUser = rawUser
242
+ ? {
243
+ id: (rawUser.id ?? '') as string,
244
+ email: (rawUser.email ?? '') as string,
245
+ firstName: (rawUser.first_name ?? rawUser.firstName ?? '') as string,
246
+ lastName: (rawUser.last_name ?? rawUser.lastName ?? '') as string,
247
+ isEmailVerified:
248
+ (rawUser.is_email_verified ?? rawUser.isEmailVerified ?? false) as boolean,
249
+ createdAt: (rawUser.created_at ?? rawUser.createdAt ?? '') as string,
250
+ updatedAt: (rawUser.updated_at ?? rawUser.updatedAt ?? '') as string,
251
+ isStaff: (rawUser.is_staff ?? rawUser.isStaff) as boolean | undefined,
252
+ isActive: (rawUser.is_active ?? rawUser.isActive) as boolean | undefined,
253
+ name: (rawUser.full_name ?? rawUser.name ?? null) as string | null,
254
+ }
255
+ : {
256
+ id: '',
257
+ email: '',
258
+ firstName: '',
259
+ lastName: '',
260
+ isEmailVerified: false,
261
+ createdAt: '',
262
+ updatedAt: '',
263
+ };
264
+
265
+ this.session = { user, accessToken, expiresAt };
266
+ // Keep module-level access-token storage in lockstep (see comment on login).
267
+ setModuleAccessToken(accessToken);
268
+ clearLoggedOutFlag();
269
+
270
+ if (!rawUser) {
271
+ try {
272
+ user = await this.getCurrentUser();
273
+ this.session.user = user;
274
+ } catch (error) {
275
+ console.error('Failed to fetch user data after auth flow:', error);
276
+ }
277
+ }
278
+
279
+ this.startRefreshTimer();
280
+ return this.session;
281
+ }
282
+
78
283
  /**
79
284
  * Logout and clear session
80
285
  */
@@ -88,7 +293,17 @@ export class AuthClient {
88
293
  } catch (error) {
89
294
  console.error('Logout error:', error);
90
295
  } finally {
296
+ // Clear client-readable cookies so the Next middleware
297
+ // (see packages/auth/src/server/middleware.ts) doesn't see a stale
298
+ // auth_session and bounce the post-logout /auth/signin request back to
299
+ // /dashboard. HttpOnly refresh_token is cleared by the backend.
300
+ deleteCookie('auth_session');
301
+ deleteCookie('access_token');
302
+ deleteCookie('csrftoken');
91
303
  this.clearSession();
304
+ // Prevent a stale refresh_token cookie from re-authenticating on the
305
+ // next page load. Cleared on next successful login/register/OAuth.
306
+ setLoggedOutFlag();
92
307
  }
93
308
  }
94
309
 
@@ -114,16 +329,39 @@ export class AuthClient {
114
329
  }
115
330
 
116
331
  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
- });
332
+ let response: Response;
333
+ try {
334
+ response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/token/refresh/`, {
335
+ method: 'POST',
336
+ headers: { 'Content-Type': 'application/json' },
337
+ credentials: 'include', // Send refresh token cookie
338
+ });
339
+ } catch (err) {
340
+ // Network error / unreachable backend — transient, do NOT clear session.
341
+ throw new Error(
342
+ `Token refresh failed transiently: ${err instanceof Error ? err.message : String(err)}`
343
+ );
344
+ }
345
+
346
+ // 401/403 = session dead. 5xx = backend flake. Only the former should
347
+ // log the user out; the latter should let them retry.
348
+ if (response.status === 401 || response.status === 403) {
349
+ this.clearSession();
350
+ this.config.onSessionExpired();
351
+ throw new Error('Token refresh rejected — session expired');
352
+ }
353
+
354
+ if (response.status >= 500) {
355
+ throw new Error(
356
+ `Token refresh failed transiently: backend returned ${response.status}`
357
+ );
358
+ }
122
359
 
123
360
  if (!response.ok) {
361
+ // Other non-ok (400, 404) — treat conservatively as session-dead.
124
362
  this.clearSession();
125
363
  this.config.onSessionExpired();
126
- throw new Error('Token refresh failed');
364
+ throw new Error(`Token refresh failed: ${response.status}`);
127
365
  }
128
366
 
129
367
  const data: RefreshResponse = await response.json();
@@ -137,6 +375,7 @@ export class AuthClient {
137
375
  this.session.accessToken = data.access;
138
376
  this.session.expiresAt = expiresAt;
139
377
  }
378
+ setModuleAccessToken(data.access);
140
379
 
141
380
  this.startRefreshTimer();
142
381
  return data.access;
@@ -168,6 +407,9 @@ export class AuthClient {
168
407
  isEmailVerified: raw.is_email_verified ?? raw.isEmailVerified ?? false,
169
408
  createdAt: raw.created_at || raw.createdAt || '',
170
409
  updatedAt: raw.updated_at || raw.updatedAt || '',
410
+ isStaff: raw.is_staff ?? raw.isStaff,
411
+ isActive: raw.is_active ?? raw.isActive,
412
+ name: raw.full_name ?? raw.name ?? null,
171
413
  };
172
414
 
173
415
  if (this.session) {
@@ -177,6 +419,93 @@ export class AuthClient {
177
419
  return user;
178
420
  }
179
421
 
422
+ /**
423
+ * Attempt to restore a session on page load / AuthProvider remount.
424
+ *
425
+ * New AuthClient instances start with no in-memory session, so a hard
426
+ * navigation (or a full page reload) loses auth state even when the
427
+ * refresh_token cookie is still valid. This method:
428
+ * 1. POSTs /auth/token/refresh/ using the refresh_token cookie the
429
+ * browser sends automatically (credentials: include).
430
+ * 2. If the backend returns a new access token, fetches /me/ to populate
431
+ * the user, stores the session, and starts the refresh timer.
432
+ * 3. Returns the Session, or null if no valid refresh cookie exists.
433
+ *
434
+ * Safe to call even when a session is already in memory — it becomes a
435
+ * no-op pass-through in that case.
436
+ */
437
+ async bootstrapFromCookies(): Promise<Session | null> {
438
+ if (this.session && !isTokenExpired(this.session.accessToken)) {
439
+ return this.session;
440
+ }
441
+
442
+ // Respect an explicit logout. If the user just logged out in this tab,
443
+ // don't let a stale refresh_token cookie resurrect the session — even
444
+ // if Chromium didn't honor the Set-Cookie delete from /auth/logout/.
445
+ if (hasLoggedOutFlag()) {
446
+ return null;
447
+ }
448
+
449
+ try {
450
+ const response = await fetch(
451
+ `${this.config.apiBaseUrl}/api/v1/auth/token/refresh/`,
452
+ {
453
+ method: 'POST',
454
+ headers: { 'Content-Type': 'application/json' },
455
+ credentials: 'include',
456
+ }
457
+ );
458
+
459
+ // 401/403 = no valid refresh cookie / session dead → return null so the
460
+ // provider settles in the unauthenticated state.
461
+ // 5xx = backend flake → leave the session untouched; caller should retry.
462
+ // Keeping the user in isLoading=false / isAuthenticated=false during a
463
+ // backend outage is better than looping, so fall through to null for
464
+ // transient failures too — but we never clear any in-memory state the
465
+ // app already had. (The early-return at the top of this method already
466
+ // preserved a valid in-memory session.)
467
+ if (response.status >= 500) {
468
+ return null;
469
+ }
470
+ if (!response.ok) return null;
471
+
472
+ const data = (await response.json()) as RefreshResponse;
473
+ const accessToken = data.access;
474
+ const expiresAt = getTokenExpiresAt(accessToken);
475
+ if (!expiresAt) return null;
476
+
477
+ // Seed with a placeholder user so getCurrentUser's auth-header builder works.
478
+ this.session = {
479
+ user: {
480
+ id: '',
481
+ email: '',
482
+ firstName: '',
483
+ lastName: '',
484
+ isEmailVerified: false,
485
+ createdAt: '',
486
+ updatedAt: '',
487
+ },
488
+ accessToken,
489
+ expiresAt,
490
+ };
491
+ setModuleAccessToken(accessToken);
492
+
493
+ try {
494
+ const user = await this.getCurrentUser();
495
+ this.session.user = user;
496
+ } catch (error) {
497
+ console.error('bootstrapFromCookies: /me/ failed', error);
498
+ this.clearSession();
499
+ return null;
500
+ }
501
+
502
+ this.startRefreshTimer();
503
+ return this.session;
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+
180
509
  /**
181
510
  * Get current session
182
511
  */
@@ -199,6 +528,7 @@ export class AuthClient {
199
528
  */
200
529
  setSession(session: Session): void {
201
530
  this.session = session;
531
+ setModuleAccessToken(session.accessToken);
202
532
  this.startRefreshTimer();
203
533
  }
204
534
 
@@ -260,6 +590,7 @@ export class AuthClient {
260
590
  */
261
591
  private clearSession(): void {
262
592
  this.session = null;
593
+ setModuleAccessToken(null);
263
594
  if (this.refreshTimer) {
264
595
  clearInterval(this.refreshTimer);
265
596
  this.refreshTimer = null;
@@ -21,6 +21,23 @@ interface AuthContextValue extends AuthState {
21
21
  logout: () => Promise<void>;
22
22
  refreshUser: () => Promise<void>;
23
23
  getAccessToken: () => Promise<string | null>;
24
+ register: (payload: {
25
+ email: string;
26
+ password: string;
27
+ passwordConfirm: string;
28
+ name?: string;
29
+ firstName?: string;
30
+ lastName?: string;
31
+ }) => Promise<void>;
32
+ signInWithGoogle: (redirectTo?: string) => Promise<string>;
33
+ completeGoogleCallback: (code: string, state: string) => Promise<void>;
34
+ /**
35
+ * Hydrate the provider with an externally-acquired session. Used by OAuth
36
+ * callback flows that run the token exchange via a component (OAuthCallback)
37
+ * and then need to tell AuthProvider about the result so React state and
38
+ * the refresh timer stay in sync.
39
+ */
40
+ hydrateSession: (session: Session) => void;
24
41
  }
25
42
 
26
43
  const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -43,8 +60,14 @@ export function AuthProvider({
43
60
  isAuthenticated: !!initialSession,
44
61
  }));
45
62
 
46
- // Initialize session from SSR or check for existing session
63
+ // Initialize session from SSR or restore from refresh_token cookie.
64
+ // The bootstrap path matters after a hard navigation (or full reload) —
65
+ // a brand-new AuthClient instance starts with no in-memory session, so
66
+ // without this the user bounces to /auth/signin even though their
67
+ // refresh_token cookie is still valid.
47
68
  useEffect(() => {
69
+ let cancelled = false;
70
+
48
71
  if (initialSession) {
49
72
  authClient.setSession(initialSession);
50
73
  setState({
@@ -52,15 +75,37 @@ export function AuthProvider({
52
75
  isLoading: false,
53
76
  isAuthenticated: true,
54
77
  });
55
- } else {
56
- // Try to get session from client
57
- const session = authClient.getSession();
78
+ return;
79
+ }
80
+
81
+ const existing = authClient.getSession();
82
+ if (existing) {
58
83
  setState({
59
- session,
84
+ session: existing,
60
85
  isLoading: false,
61
- isAuthenticated: !!session,
86
+ isAuthenticated: true,
62
87
  });
88
+ return;
63
89
  }
90
+
91
+ authClient
92
+ .bootstrapFromCookies()
93
+ .then((session) => {
94
+ if (cancelled) return;
95
+ setState({
96
+ session,
97
+ isLoading: false,
98
+ isAuthenticated: !!session,
99
+ });
100
+ })
101
+ .catch(() => {
102
+ if (cancelled) return;
103
+ setState({ session: null, isLoading: false, isAuthenticated: false });
104
+ });
105
+
106
+ return () => {
107
+ cancelled = true;
108
+ };
64
109
  }, [authClient, initialSession]);
65
110
 
66
111
  // Session expiration handler — covers both AuthClient timer and authFetch 401
@@ -135,12 +180,67 @@ export function AuthProvider({
135
180
  return authClient.getAccessToken();
136
181
  }, [authClient]);
137
182
 
183
+ const register = useCallback(
184
+ async (payload: {
185
+ email: string;
186
+ password: string;
187
+ passwordConfirm: string;
188
+ name?: string;
189
+ firstName?: string;
190
+ lastName?: string;
191
+ }) => {
192
+ setState((prev) => ({ ...prev, isLoading: true }));
193
+ try {
194
+ const session = await authClient.register(payload);
195
+ setState({ session, isLoading: false, isAuthenticated: true });
196
+ } catch (error) {
197
+ setState((prev) => ({ ...prev, isLoading: false }));
198
+ throw error;
199
+ }
200
+ },
201
+ [authClient]
202
+ );
203
+
204
+ const signInWithGoogle = useCallback(
205
+ async (redirectTo?: string) => {
206
+ // This just produces the URL; the caller is responsible for the redirect.
207
+ return authClient.signInWithGoogle(redirectTo);
208
+ },
209
+ [authClient]
210
+ );
211
+
212
+ const completeGoogleCallback = useCallback(
213
+ async (code: string, state: string) => {
214
+ setState((prev) => ({ ...prev, isLoading: true }));
215
+ try {
216
+ const session = await authClient.completeGoogleCallback(code, state);
217
+ setState({ session, isLoading: false, isAuthenticated: true });
218
+ } catch (error) {
219
+ setState((prev) => ({ ...prev, isLoading: false }));
220
+ throw error;
221
+ }
222
+ },
223
+ [authClient]
224
+ );
225
+
226
+ const hydrateSession = useCallback(
227
+ (session: Session) => {
228
+ authClient.setSession(session);
229
+ setState({ session, isLoading: false, isAuthenticated: true });
230
+ },
231
+ [authClient]
232
+ );
233
+
138
234
  const value: AuthContextValue = {
139
235
  ...state,
140
236
  login,
141
237
  logout,
142
238
  refreshUser,
143
239
  getAccessToken,
240
+ register,
241
+ signInWithGoogle,
242
+ completeGoogleCallback,
243
+ hydrateSession,
144
244
  };
145
245
 
146
246
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;