@startsimpli/auth 0.4.15 → 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
  */
@@ -15,6 +15,7 @@ import {
15
15
  } from 'react';
16
16
  import { AuthClient } from './auth-client';
17
17
  import { setOnSessionExpired } from './functions';
18
+ import type { AuthBackend } from './backend';
18
19
  import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
19
20
 
20
21
  interface AuthContextValue extends AuthState {
@@ -45,16 +46,33 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
45
46
 
46
47
  interface AuthProviderProps {
47
48
  children: ReactNode;
48
- 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;
49
60
  initialSession?: Session | null;
50
61
  }
51
62
 
52
63
  export function AuthProvider({
53
64
  children,
54
65
  config,
66
+ backend,
55
67
  initialSession,
56
68
  }: AuthProviderProps) {
57
- 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
+ });
58
76
  const [state, setState] = useState<AuthState>(() => ({
59
77
  session: initialSession || null,
60
78
  isLoading: !initialSession,
@@ -90,7 +108,7 @@ export function AuthProvider({
90
108
  }
91
109
 
92
110
  authClient
93
- .bootstrapFromCookies()
111
+ .restoreSession()
94
112
  .then((session) => {
95
113
  if (cancelled) return;
96
114
  setState({
@@ -111,12 +129,12 @@ export function AuthProvider({
111
129
 
112
130
  // Keep refs current on every render so the stable handleExpired closure
113
131
  // below always reads the latest config values without being in deps.
114
- const loginPathRef = useRef(config.loginPath);
115
- const callbackParamRef = useRef(config.callbackParam ?? 'callbackUrl');
116
- const onSessionExpiredRef = useRef(config.onSessionExpired);
117
- loginPathRef.current = config.loginPath;
118
- callbackParamRef.current = config.callbackParam ?? 'callbackUrl';
119
- onSessionExpiredRef.current = config.onSessionExpired;
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;
120
138
 
121
139
  // Session expiration handler — covers both AuthClient timer and authFetch 401.
122
140
  // Depends only on authClient (a useState singleton that never changes after
@@ -143,11 +161,15 @@ export function AuthProvider({
143
161
  };
144
162
 
145
163
  // Wire AuthClient's internal expiry calls and the functional API (FetchWrapper).
146
- config.onSessionExpired = handleExpired;
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);
147
168
  setOnSessionExpired(handleExpired);
148
169
 
149
170
  return () => {
150
171
  setOnSessionExpired(null);
172
+ authClient.setOnSessionExpired?.(null);
151
173
  authClient.destroy();
152
174
  };
153
175
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -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';