@startsimpli/auth 0.4.15 → 0.4.17

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.
Files changed (48) hide show
  1. package/README.md +191 -377
  2. package/package.json +25 -12
  3. package/src/__tests__/auth-backend-contract.test.ts +84 -0
  4. package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
  5. package/src/__tests__/auth-functions.test.ts +0 -1
  6. package/src/__tests__/session-user-groups.test.ts +45 -0
  7. package/src/__tests__/useauth-shape-contract.test.ts +0 -1
  8. package/src/client/__tests__/mock-backend.test.ts +141 -0
  9. package/src/client/__tests__/secure-session-storage.test.ts +75 -0
  10. package/src/client/__tests__/secure-token-storage.test.ts +69 -0
  11. package/src/client/__tests__/session-storage.test.ts +118 -0
  12. package/src/client/__tests__/token-auth-core.test.ts +190 -0
  13. package/src/client/auth-client.ts +71 -11
  14. package/src/client/auth-context.tsx +94 -17
  15. package/src/client/backend.ts +67 -0
  16. package/src/client/functions.ts +38 -57
  17. package/src/client/index.ts +15 -0
  18. package/src/client/mock-backend.ts +255 -0
  19. package/src/client/optional-secure-store.ts +21 -0
  20. package/src/client/secure-session-storage.native.ts +53 -0
  21. package/src/client/secure-session-storage.ts +20 -0
  22. package/src/client/secure-token-storage.native.ts +55 -0
  23. package/src/client/secure-token-storage.ts +32 -0
  24. package/src/client/session-storage.ts +142 -0
  25. package/src/client/token-auth-core.ts +190 -0
  26. package/src/client/token.ts +18 -0
  27. package/src/client/use-auth.ts +6 -1
  28. package/src/components/forgot-password-form.tsx +97 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/oauth-callback.tsx +5 -2
  31. package/src/components/reset-password-form.tsx +124 -0
  32. package/src/components/sign-in-form.tsx +125 -0
  33. package/src/components/signup-form.tsx +161 -0
  34. package/src/components/use-oauth-callback.ts +14 -2
  35. package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
  36. package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
  37. package/src/hooks/__tests__/use-membership.test.tsx +136 -0
  38. package/src/hooks/index.ts +34 -0
  39. package/src/hooks/use-domain-claims.ts +144 -0
  40. package/src/hooks/use-invitations.ts +138 -0
  41. package/src/hooks/use-membership.ts +192 -0
  42. package/src/index.ts +43 -1
  43. package/src/server/index.ts +4 -0
  44. package/src/types/index.ts +5 -1
  45. package/src/utils/api-error.ts +54 -0
  46. package/src/utils/central-auth.ts +91 -0
  47. package/src/utils/index.ts +1 -0
  48. package/src/utils/validation.ts +10 -21
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ createMemorySessionStorage,
4
+ createWebSessionStorage,
5
+ createRememberAwareSessionStorage,
6
+ SESSION_STORAGE_KEY,
7
+ } from '../session-storage';
8
+ import type { Session } from '../../types';
9
+
10
+ const session: Session = {
11
+ user: {
12
+ id: 'u1',
13
+ email: 'a@x.test',
14
+ firstName: 'A',
15
+ lastName: 'B',
16
+ isEmailVerified: true,
17
+ createdAt: '',
18
+ updatedAt: '',
19
+ },
20
+ accessToken: 'tok',
21
+ expiresAt: Date.now() + 1e6,
22
+ };
23
+
24
+ describe('createMemorySessionStorage', () => {
25
+ it('round-trips and clears', async () => {
26
+ const s = createMemorySessionStorage();
27
+ expect(await s.load()).toBeNull();
28
+ await s.save(session);
29
+ expect((await s.load())?.user.email).toBe('a@x.test');
30
+ await s.clear();
31
+ expect(await s.load()).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe('createWebSessionStorage', () => {
36
+ beforeEach(() => localStorage.clear());
37
+
38
+ it('persists to localStorage and reads it back', async () => {
39
+ const s = createWebSessionStorage();
40
+ await s.save(session);
41
+ expect(localStorage.getItem(SESSION_STORAGE_KEY)).toBeTruthy();
42
+ expect((await s.load())?.accessToken).toBe('tok');
43
+ });
44
+
45
+ it('honors a custom key and an injected Storage', async () => {
46
+ const backing = createMemoryStorage();
47
+ const s = createWebSessionStorage({ key: 'k2', storage: backing });
48
+ await s.save(session);
49
+ expect(backing.getItem('k2')).toBeTruthy();
50
+ expect((await s.load())?.user.id).toBe('u1');
51
+ await s.clear();
52
+ expect(backing.getItem('k2')).toBeNull();
53
+ });
54
+
55
+ it('returns null on malformed JSON rather than throwing', async () => {
56
+ localStorage.setItem(SESSION_STORAGE_KEY, '{not json');
57
+ const s = createWebSessionStorage();
58
+ expect(await s.load()).toBeNull();
59
+ });
60
+
61
+ it('returns null when the stored value is not a Session shape', async () => {
62
+ localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ nope: true }));
63
+ const s = createWebSessionStorage();
64
+ expect(await s.load()).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe('createRememberAwareSessionStorage', () => {
69
+ it('persists to the persistent store when remembering', async () => {
70
+ const persistent = createMemorySessionStorage();
71
+ const s = createRememberAwareSessionStorage(persistent, () => true);
72
+ await s.save(session);
73
+ expect((await persistent.load())?.accessToken).toBe('tok');
74
+ expect((await s.load())?.accessToken).toBe('tok');
75
+ });
76
+
77
+ it('does NOT persist (and clears) when not remembering', async () => {
78
+ const persistent = createMemorySessionStorage();
79
+ const s = createRememberAwareSessionStorage(persistent, () => false);
80
+ await s.save(session);
81
+ expect(await persistent.load()).toBeNull();
82
+ expect(await s.load()).toBeNull();
83
+ });
84
+
85
+ it('clears the persistent store when switching from remember to not', async () => {
86
+ const persistent = createMemorySessionStorage();
87
+ let remember = true;
88
+ const s = createRememberAwareSessionStorage(persistent, () => remember);
89
+ await s.save(session);
90
+ expect(await s.load()).not.toBeNull();
91
+ remember = false;
92
+ await s.save(session);
93
+ expect(await persistent.load()).toBeNull();
94
+ });
95
+
96
+ it('clear() wipes both stores', async () => {
97
+ const persistent = createMemorySessionStorage();
98
+ const s = createRememberAwareSessionStorage(persistent, () => true);
99
+ await s.save(session);
100
+ await s.clear();
101
+ expect(await s.load()).toBeNull();
102
+ });
103
+ });
104
+
105
+ // Minimal Storage impl for the injected-storage test.
106
+ function createMemoryStorage(): Storage {
107
+ const map = new Map<string, string>();
108
+ return {
109
+ get length() {
110
+ return map.size;
111
+ },
112
+ clear: () => map.clear(),
113
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
114
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
115
+ removeItem: (k: string) => void map.delete(k),
116
+ setItem: (k: string, v: string) => void map.set(k, String(v)),
117
+ } as Storage;
118
+ }
@@ -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+/) : [];
@@ -138,7 +132,6 @@ export class AuthClient {
138
132
  body: JSON.stringify({
139
133
  email: payload.email,
140
134
  password: payload.password,
141
- password_confirm: payload.passwordConfirm,
142
135
  name: payload.name,
143
136
  first_name: payload.firstName ?? firstFromName ?? undefined,
144
137
  last_name: payload.lastName ?? lastFromName ?? undefined,
@@ -197,8 +190,66 @@ export class AuthClient {
197
190
  * Complete a Google OAuth callback: exchange the code + state for a session.
198
191
  */
199
192
  async completeGoogleCallback(code: string, state: string): Promise<Session> {
193
+ return this.completeOAuthCallback('google', code, state);
194
+ }
195
+
196
+ /**
197
+ * Kick off Microsoft OAuth (Azure AD / Entra ID). Mirrors the Google flow;
198
+ * the backend returns `{ authorization_url }`.
199
+ */
200
+ async signInWithMicrosoft(redirectTo?: string): Promise<string> {
201
+ const defaultRedirect =
202
+ typeof window !== 'undefined'
203
+ ? `${window.location.origin}/oauth/microsoft/callback`
204
+ : '';
205
+ const redirectUri = redirectTo ?? defaultRedirect;
206
+
207
+ const response = await fetch(
208
+ `${this.config.apiBaseUrl}/api/v1/auth/oauth/microsoft/initiate/`,
209
+ {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ credentials: 'include',
213
+ body: JSON.stringify({ redirect_uri: redirectUri }),
214
+ }
215
+ );
216
+
217
+ const data = await response.json().catch(() => ({} as Record<string, unknown>));
218
+
219
+ if (!response.ok) {
220
+ throw new Error(
221
+ extractApiError(data as Record<string, unknown>, 'Failed to initiate Microsoft OAuth')
222
+ );
223
+ }
224
+
225
+ // Microsoft backend returns `authorization_url`; fall back to other casings.
226
+ const obj = data as { authorization_url?: string; auth_url?: string; authUrl?: string };
227
+ const url = obj.authorization_url ?? obj.auth_url ?? obj.authUrl;
228
+ if (!url) {
229
+ throw new Error('OAuth initiation succeeded but no authorization_url was returned');
230
+ }
231
+ return url;
232
+ }
233
+
234
+ /**
235
+ * Complete a Microsoft OAuth callback: exchange the code + state for a session.
236
+ */
237
+ async completeMicrosoftCallback(code: string, state: string): Promise<Session> {
238
+ return this.completeOAuthCallback('microsoft', code, state);
239
+ }
240
+
241
+ /**
242
+ * Provider-agnostic OAuth callback completion. Both Google and Microsoft
243
+ * speak the same `GET /api/v1/auth/oauth/<provider>/callback/?code=&state=`
244
+ * shape, so we share the implementation and just swap the path segment.
245
+ */
246
+ private async completeOAuthCallback(
247
+ provider: 'google' | 'microsoft',
248
+ code: string,
249
+ state: string,
250
+ ): Promise<Session> {
200
251
  const url = new URL(
201
- `${this.config.apiBaseUrl}/api/v1/auth/oauth/google/callback/`
252
+ `${this.config.apiBaseUrl}/api/v1/auth/oauth/${provider}/callback/`
202
253
  );
203
254
  url.searchParams.set('code', code);
204
255
  url.searchParams.set('state', state);
@@ -508,6 +559,15 @@ export class AuthClient {
508
559
  }
509
560
  }
510
561
 
562
+ /**
563
+ * AuthBackend contract: restore a persisted session on mount. For the
564
+ * Django client this is the refresh-token-cookie bootstrap; aliased so the
565
+ * provider can call a backend-neutral name.
566
+ */
567
+ restoreSession(): Promise<Session | null> {
568
+ return this.bootstrapFromCookies();
569
+ }
570
+
511
571
  /**
512
572
  * Get current session
513
573
  */
@@ -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 {
@@ -25,13 +26,14 @@ interface AuthContextValue extends AuthState {
25
26
  register: (payload: {
26
27
  email: string;
27
28
  password: string;
28
- passwordConfirm: string;
29
29
  name?: string;
30
30
  firstName?: string;
31
31
  lastName?: string;
32
32
  }) => Promise<void>;
33
33
  signInWithGoogle: (redirectTo?: string) => Promise<string>;
34
34
  completeGoogleCallback: (code: string, state: string) => Promise<void>;
35
+ signInWithMicrosoft: (redirectTo?: string) => Promise<string>;
36
+ completeMicrosoftCallback: (code: string, state: string) => Promise<void>;
35
37
  /**
36
38
  * Hydrate the provider with an externally-acquired session. Used by OAuth
37
39
  * callback flows that run the token exchange via a component (OAuthCallback)
@@ -45,16 +47,33 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
45
47
 
46
48
  interface AuthProviderProps {
47
49
  children: ReactNode;
48
- config: AuthConfig;
50
+ /**
51
+ * Django/JWT configuration. Used to construct the default {@link AuthClient}.
52
+ * Optional when an explicit `backend` is supplied instead.
53
+ */
54
+ config?: AuthConfig;
55
+ /**
56
+ * An explicit {@link AuthBackend} to drive auth state. When provided, the
57
+ * provider uses it directly and ignores `config` (no AuthClient is created).
58
+ * This is how backendless apps inject a mock/offline backend.
59
+ */
60
+ backend?: AuthBackend;
49
61
  initialSession?: Session | null;
50
62
  }
51
63
 
52
64
  export function AuthProvider({
53
65
  children,
54
66
  config,
67
+ backend,
55
68
  initialSession,
56
69
  }: AuthProviderProps) {
57
- const [authClient] = useState(() => new AuthClient(config));
70
+ const [authClient] = useState<AuthBackend>(() => {
71
+ if (backend) return backend;
72
+ if (!config) {
73
+ throw new Error('AuthProvider requires either a `config` or a `backend` prop');
74
+ }
75
+ return new AuthClient(config);
76
+ });
58
77
  const [state, setState] = useState<AuthState>(() => ({
59
78
  session: initialSession || null,
60
79
  isLoading: !initialSession,
@@ -90,7 +109,7 @@ export function AuthProvider({
90
109
  }
91
110
 
92
111
  authClient
93
- .bootstrapFromCookies()
112
+ .restoreSession()
94
113
  .then((session) => {
95
114
  if (cancelled) return;
96
115
  setState({
@@ -111,12 +130,12 @@ export function AuthProvider({
111
130
 
112
131
  // Keep refs current on every render so the stable handleExpired closure
113
132
  // 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;
133
+ const loginPathRef = useRef(config?.loginPath);
134
+ const callbackParamRef = useRef(config?.callbackParam ?? 'callbackUrl');
135
+ const onSessionExpiredRef = useRef(config?.onSessionExpired);
136
+ loginPathRef.current = config?.loginPath;
137
+ callbackParamRef.current = config?.callbackParam ?? 'callbackUrl';
138
+ onSessionExpiredRef.current = config?.onSessionExpired;
120
139
 
121
140
  // Session expiration handler — covers both AuthClient timer and authFetch 401.
122
141
  // Depends only on authClient (a useState singleton that never changes after
@@ -133,21 +152,51 @@ export function AuthProvider({
133
152
  onSessionExpiredRef.current?.();
134
153
 
135
154
  if (loginPathRef.current && typeof window !== 'undefined') {
136
- const here = window.location.pathname + window.location.search;
137
- const isOnLogin = window.location.pathname.startsWith(loginPathRef.current);
138
- if (!isOnLogin) {
139
- const callback = encodeURIComponent(here);
140
- window.location.href = `${loginPathRef.current}?${callbackParamRef.current}=${callback}`;
155
+ const loginPath = loginPathRef.current;
156
+ const callbackParam = callbackParamRef.current;
157
+ const here = window.location.href;
158
+ const isAbsolute = /^https?:\/\//i.test(loginPath);
159
+
160
+ if (isAbsolute) {
161
+ // Full-URL loginPath (e.g. central auth host at
162
+ // https://auth.startsimpli.com/signin?app=vault). Avoid bouncing if
163
+ // we're already on that host+pathname, and preserve any pre-existing
164
+ // query params on the loginPath (e.g. ?app=vault) by appending the
165
+ // callback via URL/searchParams instead of naive string concat.
166
+ try {
167
+ const target = new URL(loginPath);
168
+ const current = new URL(window.location.href);
169
+ const isOnLogin =
170
+ current.host === target.host && current.pathname === target.pathname;
171
+ if (!isOnLogin) {
172
+ target.searchParams.set(callbackParam, here);
173
+ window.location.href = target.toString();
174
+ }
175
+ } catch {
176
+ // Malformed loginPath — fall through to no-op rather than crash.
177
+ }
178
+ } else {
179
+ const path = window.location.pathname + window.location.search;
180
+ const isOnLogin = window.location.pathname.startsWith(loginPath);
181
+ if (!isOnLogin) {
182
+ const callback = encodeURIComponent(path);
183
+ const sep = loginPath.includes('?') ? '&' : '?';
184
+ window.location.href = `${loginPath}${sep}${callbackParam}=${callback}`;
185
+ }
141
186
  }
142
187
  }
143
188
  };
144
189
 
145
190
  // Wire AuthClient's internal expiry calls and the functional API (FetchWrapper).
146
- config.onSessionExpired = handleExpired;
191
+ // The Django client reads expiry via config; non-config backends (e.g. mock)
192
+ // accept the handler through the optional setOnSessionExpired method.
193
+ if (config) config.onSessionExpired = handleExpired;
194
+ authClient.setOnSessionExpired?.(handleExpired);
147
195
  setOnSessionExpired(handleExpired);
148
196
 
149
197
  return () => {
150
198
  setOnSessionExpired(null);
199
+ authClient.setOnSessionExpired?.(null);
151
200
  authClient.destroy();
152
201
  };
153
202
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -208,7 +257,6 @@ export function AuthProvider({
208
257
  async (payload: {
209
258
  email: string;
210
259
  password: string;
211
- passwordConfirm: string;
212
260
  name?: string;
213
261
  firstName?: string;
214
262
  lastName?: string;
@@ -247,6 +295,33 @@ export function AuthProvider({
247
295
  [authClient]
248
296
  );
249
297
 
298
+ const signInWithMicrosoft = useCallback(
299
+ async (redirectTo?: string) => {
300
+ if (!authClient.signInWithMicrosoft) {
301
+ throw new Error('signInWithMicrosoft is not supported by this auth backend');
302
+ }
303
+ return authClient.signInWithMicrosoft(redirectTo);
304
+ },
305
+ [authClient]
306
+ );
307
+
308
+ const completeMicrosoftCallback = useCallback(
309
+ async (code: string, state: string) => {
310
+ if (!authClient.completeMicrosoftCallback) {
311
+ throw new Error('completeMicrosoftCallback is not supported by this auth backend');
312
+ }
313
+ setState((prev) => ({ ...prev, isLoading: true }));
314
+ try {
315
+ const session = await authClient.completeMicrosoftCallback(code, state);
316
+ setState({ session, isLoading: false, isAuthenticated: true });
317
+ } catch (error) {
318
+ setState((prev) => ({ ...prev, isLoading: false }));
319
+ throw error;
320
+ }
321
+ },
322
+ [authClient]
323
+ );
324
+
250
325
  const hydrateSession = useCallback(
251
326
  (session: Session) => {
252
327
  authClient.setSession(session);
@@ -264,6 +339,8 @@ export function AuthProvider({
264
339
  register,
265
340
  signInWithGoogle,
266
341
  completeGoogleCallback,
342
+ signInWithMicrosoft,
343
+ completeMicrosoftCallback,
267
344
  hydrateSession,
268
345
  };
269
346