@startsimpli/auth 0.4.14 → 0.4.16

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.
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import {
3
+ TokenAuthClient,
4
+ InMemoryTokenStorage,
5
+ type TokenStorage,
6
+ } from '../token-auth-core';
7
+
8
+ // Build a fake (unsigned) JWT with a real `exp` so getTokenExpiresAt works.
9
+ function makeToken(secondsFromNow = 3600): string {
10
+ const exp = Math.floor(Date.now() / 1000) + secondsFromNow;
11
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
12
+ const body = btoa(JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'x', userId: '1' }));
13
+ return `${header}.${body}.sig`;
14
+ }
15
+
16
+ const ACCESS = makeToken();
17
+ const ACCESS2 = makeToken(7200);
18
+
19
+ // Minimal fetch-mock helper returning a Response-like object.
20
+ function jsonResponse(body: unknown, ok = true, status = 200) {
21
+ return { ok, status, json: async () => body } as Response;
22
+ }
23
+
24
+ function makeClient(storage: TokenStorage = new InMemoryTokenStorage()) {
25
+ const fetchMock = vi.fn<typeof fetch>();
26
+ const client = new TokenAuthClient({
27
+ apiBaseUrl: 'http://localhost:8001',
28
+ storage,
29
+ fetch: fetchMock,
30
+ });
31
+ return { client, fetchMock, storage };
32
+ }
33
+
34
+ // Pull [url, init] out of the Nth fetch call for assertions.
35
+ function call(fetchMock: ReturnType<typeof vi.fn>, n = 0): [string, RequestInit] {
36
+ const [url, init] = fetchMock.mock.calls[n];
37
+ return [String(url), (init ?? {}) as RequestInit];
38
+ }
39
+
40
+ describe('TokenAuthClient.login', () => {
41
+ beforeEach(() => vi.restoreAllMocks());
42
+
43
+ it('opts into token mode (X-Auth-Mode), stores the refresh token, returns a session', async () => {
44
+ const { client, fetchMock, storage } = makeClient();
45
+ fetchMock.mockResolvedValueOnce(
46
+ jsonResponse({ access: ACCESS, refresh: 'refresh-1', user: { id: 'u1', email: 'a@b.co' } })
47
+ );
48
+
49
+ const session = await client.login('a@b.co', 'pw');
50
+
51
+ const [url, init] = call(fetchMock);
52
+ expect(url).toBe('http://localhost:8001/api/v1/auth/token/');
53
+ expect(init.method).toBe('POST');
54
+ expect((init.headers as Record<string, string>)['X-Auth-Mode']).toBe('token');
55
+ expect(JSON.parse(init.body as string)).toEqual({ email: 'a@b.co', password: 'pw' });
56
+
57
+ expect(session.accessToken).toBe(ACCESS);
58
+ expect(session.expiresAt).toBeGreaterThan(Date.now());
59
+ expect(session.user.id).toBe('u1');
60
+ expect(client.getAccessToken()).toBe(ACCESS);
61
+ expect(await storage.getRefreshToken()).toBe('refresh-1');
62
+ });
63
+
64
+ it('fetches /me/ when the login response omits the user', async () => {
65
+ const { client, fetchMock } = makeClient();
66
+ fetchMock
67
+ .mockResolvedValueOnce(jsonResponse({ access: ACCESS, refresh: 'r' })) // login, no user
68
+ .mockResolvedValueOnce(
69
+ jsonResponse({ user: { id: 'u9', email: 'me@x.co', first_name: 'Me', last_name: 'X' } })
70
+ ); // /me/
71
+
72
+ const session = await client.login('me@x.co', 'pw');
73
+
74
+ expect(fetchMock).toHaveBeenCalledTimes(2);
75
+ expect(call(fetchMock, 1)[0]).toBe('http://localhost:8001/api/v1/auth/me/');
76
+ expect(session.user.id).toBe('u9');
77
+ expect(session.user.firstName).toBe('Me');
78
+ });
79
+
80
+ it('throws the extracted API error on failure and stores nothing', async () => {
81
+ const { client, fetchMock, storage } = makeClient();
82
+ fetchMock.mockResolvedValueOnce(
83
+ jsonResponse({ error: 'No active account found' }, false, 401)
84
+ );
85
+
86
+ await expect(client.login('a@b.co', 'bad')).rejects.toThrow('No active account found');
87
+ expect(await storage.getRefreshToken()).toBeNull();
88
+ expect(client.getAccessToken()).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe('TokenAuthClient.refreshToken', () => {
93
+ beforeEach(() => vi.restoreAllMocks());
94
+
95
+ it('sends the stored refresh token in the body and rotates it', async () => {
96
+ const storage = new InMemoryTokenStorage();
97
+ await storage.setRefreshToken('refresh-old');
98
+ const { client, fetchMock } = makeClient(storage);
99
+ fetchMock.mockResolvedValueOnce(jsonResponse({ access: ACCESS2, refresh: 'refresh-new' }));
100
+
101
+ const access = await client.refreshToken();
102
+
103
+ const [url, init] = call(fetchMock);
104
+ expect(url).toBe('http://localhost:8001/api/v1/auth/token/refresh/');
105
+ expect(JSON.parse(init.body as string)).toEqual({ refresh: 'refresh-old' });
106
+ expect(access).toBe(ACCESS2);
107
+ expect(client.getAccessToken()).toBe(ACCESS2);
108
+ expect(await storage.getRefreshToken()).toBe('refresh-new'); // rotated
109
+ });
110
+
111
+ it('throws when there is no stored refresh token', async () => {
112
+ const { client, fetchMock } = makeClient();
113
+ await expect(client.refreshToken()).rejects.toThrow();
114
+ expect(fetchMock).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('clears storage when the refresh is rejected', async () => {
118
+ const storage = new InMemoryTokenStorage();
119
+ await storage.setRefreshToken('refresh-old');
120
+ const { client, fetchMock } = makeClient(storage);
121
+ fetchMock.mockResolvedValueOnce(jsonResponse({ detail: 'expired' }, false, 401));
122
+
123
+ await expect(client.refreshToken()).rejects.toThrow();
124
+ expect(await storage.getRefreshToken()).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe('TokenAuthClient.logout', () => {
129
+ beforeEach(() => vi.restoreAllMocks());
130
+
131
+ it('sends Bearer access + refresh in the body and clears storage', async () => {
132
+ const storage = new InMemoryTokenStorage();
133
+ await storage.setRefreshToken('refresh-1');
134
+ const { client, fetchMock } = makeClient(storage);
135
+ // seed an access token via a login
136
+ fetchMock.mockResolvedValueOnce(jsonResponse({ access: ACCESS, refresh: 'refresh-1', user: { id: 'u', email: 'e@e.co' } }));
137
+ await client.login('e@e.co', 'pw');
138
+ fetchMock.mockResolvedValueOnce(jsonResponse({}, true, 205));
139
+
140
+ await client.logout();
141
+
142
+ const [url, init] = call(fetchMock, 1);
143
+ expect(url).toBe('http://localhost:8001/api/v1/auth/logout/');
144
+ expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${ACCESS}`);
145
+ expect(JSON.parse(init.body as string)).toEqual({ refresh: 'refresh-1' });
146
+ expect(await storage.getRefreshToken()).toBeNull();
147
+ expect(client.getAccessToken()).toBeNull();
148
+ });
149
+
150
+ it('clears storage even if the network call fails', async () => {
151
+ const storage = new InMemoryTokenStorage();
152
+ await storage.setRefreshToken('refresh-1');
153
+ const { client, fetchMock } = makeClient(storage);
154
+ fetchMock.mockRejectedValueOnce(new Error('network down'));
155
+
156
+ await client.logout();
157
+ expect(await storage.getRefreshToken()).toBeNull();
158
+ });
159
+ });
160
+
161
+ describe('TokenAuthClient.getCurrentUser', () => {
162
+ beforeEach(() => vi.restoreAllMocks());
163
+
164
+ it('sends Bearer and normalizes the snake_case {user} envelope', async () => {
165
+ const { client, fetchMock } = makeClient();
166
+ fetchMock.mockResolvedValueOnce(
167
+ jsonResponse({
168
+ user: {
169
+ id: 'abc',
170
+ email: 't@x.co',
171
+ first_name: 'Test',
172
+ last_name: 'User',
173
+ is_email_verified: true,
174
+ created_at: '2026-01-01T00:00:00Z',
175
+ updated_at: '2026-01-02T00:00:00Z',
176
+ },
177
+ })
178
+ );
179
+
180
+ const user = await client.getCurrentUser(ACCESS);
181
+
182
+ const [url, init] = call(fetchMock);
183
+ expect(url).toBe('http://localhost:8001/api/v1/auth/me/');
184
+ expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${ACCESS}`);
185
+ expect(user.id).toBe('abc');
186
+ expect(user.firstName).toBe('Test');
187
+ expect(user.lastName).toBe('User');
188
+ expect(user.isEmailVerified).toBe(true);
189
+ });
190
+ });
@@ -13,6 +13,7 @@ import type {
13
13
  import { isTokenExpired, getTokenExpiresAt, shouldRefreshToken } from '../utils';
14
14
  import { extractApiError, setAccessToken as setModuleAccessToken } from './functions';
15
15
  import { deleteCookie } from '../utils/cookies';
16
+ import type { AuthBackend, RegisterPayload } from './backend';
16
17
 
17
18
  /**
18
19
  * sessionStorage key set by logout to suppress any subsequent
@@ -41,7 +42,7 @@ function hasLoggedOutFlag(): boolean {
41
42
  }
42
43
  }
43
44
 
44
- export class AuthClient {
45
+ export class AuthClient implements AuthBackend {
45
46
  private config: Required<AuthConfig>;
46
47
  private session: Session | null = null;
47
48
  private refreshTimer: NodeJS.Timeout | null = null;
@@ -118,14 +119,7 @@ export class AuthClient {
118
119
  * backend returns a user in the response, use it directly; otherwise fall
119
120
  * back to /me/ (same pattern as login).
120
121
  */
121
- async register(payload: {
122
- email: string;
123
- password: string;
124
- passwordConfirm: string;
125
- name?: string;
126
- firstName?: string;
127
- lastName?: string;
128
- }): Promise<Session> {
122
+ async register(payload: RegisterPayload): Promise<Session> {
129
123
  // Derive first/last from `name` if the caller used it.
130
124
  const rawName = payload.name?.trim() ?? '';
131
125
  const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
@@ -508,6 +502,15 @@ export class AuthClient {
508
502
  }
509
503
  }
510
504
 
505
+ /**
506
+ * AuthBackend contract: restore a persisted session on mount. For the
507
+ * Django client this is the refresh-token-cookie bootstrap; aliased so the
508
+ * provider can call a backend-neutral name.
509
+ */
510
+ restoreSession(): Promise<Session | null> {
511
+ return this.bootstrapFromCookies();
512
+ }
513
+
511
514
  /**
512
515
  * Get current session
513
516
  */
@@ -8,12 +8,14 @@ import {
8
8
  createContext,
9
9
  useContext,
10
10
  useEffect,
11
+ useRef,
11
12
  useState,
12
13
  useCallback,
13
14
  type ReactNode,
14
15
  } from 'react';
15
16
  import { AuthClient } from './auth-client';
16
17
  import { setOnSessionExpired } from './functions';
18
+ import type { AuthBackend } from './backend';
17
19
  import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
18
20
 
19
21
  interface AuthContextValue extends AuthState {
@@ -44,16 +46,33 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
44
46
 
45
47
  interface AuthProviderProps {
46
48
  children: ReactNode;
47
- config: AuthConfig;
49
+ /**
50
+ * Django/JWT configuration. Used to construct the default {@link AuthClient}.
51
+ * Optional when an explicit `backend` is supplied instead.
52
+ */
53
+ config?: AuthConfig;
54
+ /**
55
+ * An explicit {@link AuthBackend} to drive auth state. When provided, the
56
+ * provider uses it directly and ignores `config` (no AuthClient is created).
57
+ * This is how backendless apps inject a mock/offline backend.
58
+ */
59
+ backend?: AuthBackend;
48
60
  initialSession?: Session | null;
49
61
  }
50
62
 
51
63
  export function AuthProvider({
52
64
  children,
53
65
  config,
66
+ backend,
54
67
  initialSession,
55
68
  }: AuthProviderProps) {
56
- const [authClient] = useState(() => new AuthClient(config));
69
+ const [authClient] = useState<AuthBackend>(() => {
70
+ if (backend) return backend;
71
+ if (!config) {
72
+ throw new Error('AuthProvider requires either a `config` or a `backend` prop');
73
+ }
74
+ return new AuthClient(config);
75
+ });
57
76
  const [state, setState] = useState<AuthState>(() => ({
58
77
  session: initialSession || null,
59
78
  isLoading: !initialSession,
@@ -89,7 +108,7 @@ export function AuthProvider({
89
108
  }
90
109
 
91
110
  authClient
92
- .bootstrapFromCookies()
111
+ .restoreSession()
93
112
  .then((session) => {
94
113
  if (cancelled) return;
95
114
  setState({
@@ -108,43 +127,53 @@ export function AuthProvider({
108
127
  };
109
128
  }, [authClient, initialSession]);
110
129
 
111
- // Session expiration handler covers both AuthClient timer and authFetch 401
112
- useEffect(() => {
113
- // Capture the consumer's onSessionExpired before we overwrite it below.
114
- const consumerCallback = config.onSessionExpired;
115
- const loginPath = config.loginPath;
116
- const callbackParam = config.callbackParam ?? 'callbackUrl';
130
+ // Keep refs current on every render so the stable handleExpired closure
131
+ // below always reads the latest config values without being in deps.
132
+ const loginPathRef = useRef(config?.loginPath);
133
+ const callbackParamRef = useRef(config?.callbackParam ?? 'callbackUrl');
134
+ const onSessionExpiredRef = useRef(config?.onSessionExpired);
135
+ loginPathRef.current = config?.loginPath;
136
+ callbackParamRef.current = config?.callbackParam ?? 'callbackUrl';
137
+ onSessionExpiredRef.current = config?.onSessionExpired;
117
138
 
139
+ // Session expiration handler — covers both AuthClient timer and authFetch 401.
140
+ // Depends only on authClient (a useState singleton that never changes after
141
+ // mount). config intentionally omitted: it's a new object literal on every
142
+ // render from the caller, so including it caused destroy() to fire and wipe
143
+ // the session on every re-render.
144
+ useEffect(() => {
118
145
  const handleExpired = () => {
119
146
  setState({
120
147
  session: null,
121
148
  isLoading: false,
122
149
  isAuthenticated: false,
123
150
  });
124
- consumerCallback?.();
151
+ onSessionExpiredRef.current?.();
125
152
 
126
- // Redirect to login if configured. Done after state reset + consumer
127
- // callback so any cleanup runs first. window.location avoids pulling
128
- // a router dep into the shared package — works in any framework.
129
- if (loginPath && typeof window !== 'undefined') {
153
+ if (loginPathRef.current && typeof window !== 'undefined') {
130
154
  const here = window.location.pathname + window.location.search;
131
- const isOnLogin = window.location.pathname.startsWith(loginPath);
155
+ const isOnLogin = window.location.pathname.startsWith(loginPathRef.current);
132
156
  if (!isOnLogin) {
133
157
  const callback = encodeURIComponent(here);
134
- window.location.href = `${loginPath}?${callbackParam}=${callback}`;
158
+ window.location.href = `${loginPathRef.current}?${callbackParamRef.current}=${callback}`;
135
159
  }
136
160
  }
137
161
  };
138
162
 
139
- config.onSessionExpired = handleExpired;
140
- // Wire up the functional API's session expiration callback
163
+ // Wire AuthClient's internal expiry calls and the functional API (FetchWrapper).
164
+ // The Django client reads expiry via config; non-config backends (e.g. mock)
165
+ // accept the handler through the optional setOnSessionExpired method.
166
+ if (config) config.onSessionExpired = handleExpired;
167
+ authClient.setOnSessionExpired?.(handleExpired);
141
168
  setOnSessionExpired(handleExpired);
142
169
 
143
170
  return () => {
144
171
  setOnSessionExpired(null);
172
+ authClient.setOnSessionExpired?.(null);
145
173
  authClient.destroy();
146
174
  };
147
- }, [authClient, config]);
175
+ // eslint-disable-next-line react-hooks/exhaustive-deps
176
+ }, [authClient]);
148
177
 
149
178
  const login = useCallback(
150
179
  async (email: string, password: string) => {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Backend-agnostic authentication contract.
3
+ *
4
+ * AuthProvider drives all of its state from one object satisfying this
5
+ * interface. The Django/JWT implementation is {@link AuthClient}; backendless
6
+ * apps (demos, offline-first, Storybook) can inject any other implementation
7
+ * — e.g. `createMockAuthBackend` — without the provider knowing the difference.
8
+ *
9
+ * The method set is exactly what AuthProvider needs; nothing here is
10
+ * Django-specific.
11
+ */
12
+
13
+ import type { Session, AuthUser } from '../types';
14
+
15
+ export interface RegisterPayload {
16
+ email: string;
17
+ password: string;
18
+ passwordConfirm: string;
19
+ name?: string;
20
+ firstName?: string;
21
+ lastName?: string;
22
+ }
23
+
24
+ export interface AuthBackend {
25
+ /** Authenticate and open a session. Rejects on bad credentials. */
26
+ login(email: string, password: string): Promise<Session>;
27
+ /** End the session and clear any persisted state. */
28
+ logout(): Promise<void>;
29
+ /** Create an account and open a session. */
30
+ register(payload: RegisterPayload): Promise<Session>;
31
+ /** Re-fetch the current user (e.g. after a profile change). */
32
+ getCurrentUser(): Promise<AuthUser>;
33
+ /** Return a valid access token, refreshing if needed; null if unauthenticated. */
34
+ getAccessToken(): Promise<string | null>;
35
+ /** Synchronous current session, or null. May clear+return null if expired. */
36
+ getSession(): Session | null;
37
+ /** Adopt an externally-acquired session (SSR/hydration/OAuth callback). */
38
+ setSession(session: Session): void;
39
+ /**
40
+ * Restore a persisted session on mount. Web/Django reads the refresh-token
41
+ * cookie; native/mock backends read secure storage. Returns null when there
42
+ * is nothing to restore.
43
+ */
44
+ restoreSession(): Promise<Session | null>;
45
+ /** Begin an OAuth flow; returns the authorization URL to redirect to. */
46
+ signInWithGoogle(redirectTo?: string): Promise<string>;
47
+ /** Complete an OAuth flow by exchanging the code+state for a session. */
48
+ completeGoogleCallback(code: string, state: string): Promise<Session>;
49
+ /**
50
+ * Optional. Register the provider's session-expiry handler. The Django
51
+ * AuthClient wires this through AuthConfig.onSessionExpired instead, so it
52
+ * is optional — backends that don't take an AuthConfig (e.g. the mock) use
53
+ * this to notify the provider when a session goes away out-of-band.
54
+ */
55
+ setOnSessionExpired?(cb: (() => void) | null): void;
56
+ /** Release timers/listeners. Called on AuthProvider unmount. */
57
+ destroy(): void;
58
+ }
@@ -12,6 +12,9 @@
12
12
  */
13
13
 
14
14
  import { deleteCookie } from '../utils/cookies';
15
+ // Local binding for internal use; also re-exported below to preserve this
16
+ // module's public surface.
17
+ import { extractApiError } from '../utils/api-error';
15
18
  import { decodeToken } from '../utils/token';
16
19
 
17
20
  // --- Types ---
@@ -210,58 +213,10 @@ function _syncAuthCookie(token: string | null): void {
210
213
 
211
214
  const AUTH_TIMEOUT_MS = 15_000;
212
215
 
213
- /**
214
- * Extract a human-readable message from a Django REST Framework error response body.
215
- *
216
- * Handles the shapes we've seen in practice:
217
- * { detail: "..." } → the string
218
- * { detail: ["...", "..."] } → first string
219
- * { detail: { token: ["Invalid..."] } } → first nested string
220
- * { email: ["already exists"] } → first field-level string
221
- * { non_field_errors: ["..."] } → first field-level string
222
- * { error: "CODE", detail: { field: ["..."] } } → first nested string
223
- *
224
- * @internal Shared with AuthClient; still considered implementation detail
225
- * of the auth package. Do not rely on from outside `@startsimpli/auth`.
226
- */
227
- export function extractApiError(d: Record<string, unknown>, fallback: string): string {
228
- const pluck = (val: unknown): string | null => {
229
- if (typeof val === 'string') return val
230
- if (Array.isArray(val) && val.length > 0) {
231
- for (const item of val) {
232
- const s = pluck(item)
233
- if (s) return s
234
- }
235
- }
236
- if (val && typeof val === 'object') {
237
- for (const v of Object.values(val as Record<string, unknown>)) {
238
- const s = pluck(v)
239
- if (s) return s
240
- }
241
- }
242
- return null
243
- }
244
-
245
- // Standard DRF: { detail: "..." }
246
- const fromDetail = pluck(d.detail)
247
- if (fromDetail) return fromDetail
248
- // Some backend shapes use `error` as the human-readable message (e.g.
249
- // our Django auth errors: { "error": "No active account...", "code": "unauthorized" }).
250
- // Prefer this over field-level probing so we don't accidentally return a
251
- // code like "unauthorized" from a sibling field.
252
- const fromError = pluck(d.error)
253
- if (fromError) return fromError
254
- // Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
255
- // Skip known meta/code fields so `{ code: "unauthorized" }` doesn't leak
256
- // an internal identifier as a user-facing message.
257
- const META_KEYS = new Set(['detail', 'error', 'code', 'statusCode', 'status', 'timestamp'])
258
- for (const [key, val] of Object.entries(d)) {
259
- if (META_KEYS.has(key)) continue
260
- const s = pluck(val)
261
- if (s) return s
262
- }
263
- return fallback
264
- }
216
+ // extractApiError moved to ../utils/api-error (DOM-free, shared with the
217
+ // platform-neutral token-auth core). Imported above for internal use and
218
+ // re-exported here to preserve this module's public surface (e.g. AuthClient).
219
+ export { extractApiError }
265
220
 
266
221
  function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
267
222
  const controller = new AbortController();
@@ -1,4 +1,19 @@
1
1
  export { AuthClient } from './auth-client';
2
+ export type { AuthBackend, RegisterPayload } from './backend';
3
+ export {
4
+ createMockAuthBackend,
5
+ type MockAuthBackend,
6
+ type MockAuthBackendOptions,
7
+ type MockAccount,
8
+ } from './mock-backend';
9
+ export {
10
+ createMemorySessionStorage,
11
+ createWebSessionStorage,
12
+ createRememberAwareSessionStorage,
13
+ SESSION_STORAGE_KEY,
14
+ type SessionStorage,
15
+ } from './session-storage';
16
+ export { createSecureSessionStorage } from './secure-session-storage';
2
17
  export { AuthProvider, useAuthContext } from './auth-context';
3
18
  export { useAuth, useRequireAuth, type UseAuthReturn, type UseRequireAuthReturn, type UseRequireAuthOptions } from './use-auth';
4
19
  export { usePermissions, type UsePermissionsReturn } from './use-permissions';