@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,67 @@
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
+ name?: string;
19
+ firstName?: string;
20
+ lastName?: string;
21
+ }
22
+
23
+ export interface AuthBackend {
24
+ /** Authenticate and open a session. Rejects on bad credentials. */
25
+ login(email: string, password: string): Promise<Session>;
26
+ /** End the session and clear any persisted state. */
27
+ logout(): Promise<void>;
28
+ /** Create an account and open a session. */
29
+ register(payload: RegisterPayload): Promise<Session>;
30
+ /** Re-fetch the current user (e.g. after a profile change). */
31
+ getCurrentUser(): Promise<AuthUser>;
32
+ /** Return a valid access token, refreshing if needed; null if unauthenticated. */
33
+ getAccessToken(): Promise<string | null>;
34
+ /** Synchronous current session, or null. May clear+return null if expired. */
35
+ getSession(): Session | null;
36
+ /** Adopt an externally-acquired session (SSR/hydration/OAuth callback). */
37
+ setSession(session: Session): void;
38
+ /**
39
+ * Restore a persisted session on mount. Web/Django reads the refresh-token
40
+ * cookie; native/mock backends read secure storage. Returns null when there
41
+ * is nothing to restore.
42
+ */
43
+ restoreSession(): Promise<Session | null>;
44
+ /** Begin an OAuth flow; returns the authorization URL to redirect to. */
45
+ signInWithGoogle(redirectTo?: string): Promise<string>;
46
+ /** Complete an OAuth flow by exchanging the code+state for a session. */
47
+ completeGoogleCallback(code: string, state: string): Promise<Session>;
48
+ /**
49
+ * Begin a Microsoft OAuth flow; returns the authorization URL to redirect
50
+ * to. Optional — backends that don't speak Microsoft can omit it.
51
+ */
52
+ signInWithMicrosoft?(redirectTo?: string): Promise<string>;
53
+ /**
54
+ * Complete a Microsoft OAuth callback. Optional — backends that don't speak
55
+ * Microsoft can omit it.
56
+ */
57
+ completeMicrosoftCallback?(code: string, state: string): Promise<Session>;
58
+ /**
59
+ * Optional. Register the provider's session-expiry handler. The Django
60
+ * AuthClient wires this through AuthConfig.onSessionExpired instead, so it
61
+ * is optional — backends that don't take an AuthConfig (e.g. the mock) use
62
+ * this to notify the provider when a session goes away out-of-band.
63
+ */
64
+ setOnSessionExpired?(cb: (() => void) | null): void;
65
+ /** Release timers/listeners. Called on AuthProvider unmount. */
66
+ destroy(): void;
67
+ }
@@ -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 ---
@@ -71,6 +74,8 @@ const AUTH_PATHS = {
71
74
  RESEND_VERIFICATION: `${API_BASE}/auth/resend-verification/`,
72
75
  OAUTH_GOOGLE_INITIATE: `${API_BASE}/auth/oauth/google/initiate/`,
73
76
  OAUTH_GOOGLE_CALLBACK: `${API_BASE}/auth/oauth/google/callback/`,
77
+ OAUTH_MICROSOFT_INITIATE: `${API_BASE}/auth/oauth/microsoft/initiate/`,
78
+ OAUTH_MICROSOFT_CALLBACK: `${API_BASE}/auth/oauth/microsoft/callback/`,
74
79
  ME: `${API_BASE}/auth/me/`,
75
80
  } as const;
76
81
 
@@ -210,58 +215,10 @@ function _syncAuthCookie(token: string | null): void {
210
215
 
211
216
  const AUTH_TIMEOUT_MS = 15_000;
212
217
 
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
- }
218
+ // extractApiError moved to ../utils/api-error (DOM-free, shared with the
219
+ // platform-neutral token-auth core). Imported above for internal use and
220
+ // re-exported here to preserve this module's public surface (e.g. AuthClient).
221
+ export { extractApiError }
265
222
 
266
223
  function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
267
224
  const controller = new AbortController();
@@ -342,7 +299,6 @@ export async function signInWithCredentials(email: string, password: string) {
342
299
  export async function registerAccount(payload: {
343
300
  email: string;
344
301
  password: string;
345
- passwordConfirm: string;
346
302
  name?: string;
347
303
  firstName?: string;
348
304
  lastName?: string;
@@ -361,7 +317,6 @@ export async function registerAccount(payload: {
361
317
  body: JSON.stringify({
362
318
  email: payload.email,
363
319
  password: payload.password,
364
- password_confirm: payload.passwordConfirm,
365
320
  first_name: payload.firstName ?? firstFromName ?? undefined,
366
321
  last_name: payload.lastName ?? lastFromName ?? undefined,
367
322
  }),
@@ -398,7 +353,6 @@ export async function requestPasswordReset(email: string): Promise<void> {
398
353
  export async function resetPassword(payload: {
399
354
  token: string;
400
355
  password: string;
401
- passwordConfirm: string;
402
356
  email?: string;
403
357
  }): Promise<void> {
404
358
  const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.RESET_PASSWORD), {
@@ -407,7 +361,6 @@ export async function resetPassword(payload: {
407
361
  body: JSON.stringify({
408
362
  token: payload.token,
409
363
  password: payload.password,
410
- password_confirm: payload.passwordConfirm,
411
364
  ...(payload.email ? { email: payload.email } : {}),
412
365
  }),
413
366
  });
@@ -472,9 +425,37 @@ export async function initiateGoogleOAuth(redirectUri: string): Promise<any> {
472
425
  }
473
426
 
474
427
  export async function completeGoogleOAuth(code: string, state: string) {
428
+ return _completeOAuthCallback(AUTH_PATHS.OAUTH_GOOGLE_CALLBACK, code, state);
429
+ }
430
+
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ export async function initiateMicrosoftOAuth(redirectUri: string): Promise<any> {
433
+ const response = await fetch(resolveAuthUrl(AUTH_PATHS.OAUTH_MICROSOFT_INITIATE), {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ credentials: 'include',
437
+ body: JSON.stringify({ redirect_uri: redirectUri }),
438
+ });
439
+
440
+ const data = await response.json().catch(() => ({}));
441
+
442
+ if (!response.ok) {
443
+ const d = data as Record<string, unknown>;
444
+ const message = (d?.detail || d?.error || 'Failed to initiate Microsoft OAuth') as string;
445
+ throw new Error(message);
446
+ }
447
+
448
+ return data;
449
+ }
450
+
451
+ export async function completeMicrosoftOAuth(code: string, state: string) {
452
+ return _completeOAuthCallback(AUTH_PATHS.OAUTH_MICROSOFT_CALLBACK, code, state);
453
+ }
454
+
455
+ async function _completeOAuthCallback(callbackPath: string, code: string, state: string) {
475
456
  const response = await fetchWithTimeout(
476
457
  resolveAuthUrl(
477
- `${AUTH_PATHS.OAUTH_GOOGLE_CALLBACK}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
458
+ `${callbackPath}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
478
459
  ),
479
460
  { credentials: 'include' }
480
461
  );
@@ -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';
@@ -0,0 +1,255 @@
1
+ /**
2
+ * In-memory AuthBackend for backendless apps (demos, offline-first, Storybook).
3
+ *
4
+ * Implements the full {@link AuthBackend} contract plus mock-only helpers for
5
+ * password-reset and email-verification flows. No network, no JWT — sessions
6
+ * carry a synthetic token and an explicit expiry the backend manages itself.
7
+ * Pair it with a {@link SessionStorage} to survive reloads/app-restarts.
8
+ */
9
+
10
+ import type { Session, AuthUser } from '../types';
11
+ import type { AuthBackend, RegisterPayload } from './backend';
12
+ import { createMemorySessionStorage, type SessionStorage } from './session-storage';
13
+
14
+ export interface MockAccount {
15
+ email: string;
16
+ password: string;
17
+ user: AuthUser;
18
+ }
19
+
20
+ export interface MockAuthBackendOptions {
21
+ /** Seed accounts that can sign in immediately. */
22
+ accounts?: MockAccount[];
23
+ /** Where to persist the session. Defaults to in-memory (lost on reload). */
24
+ storage?: SessionStorage;
25
+ /** Simulated round-trip latency in ms for auth operations. Default 0. */
26
+ latencyMs?: number;
27
+ /** Synthetic session lifetime in ms. Default 24h. */
28
+ sessionTtlMs?: number;
29
+ /** Injectable clock (tests). Default Date.now. */
30
+ now?: () => number;
31
+ /** Build the AuthUser for a brand-new registration. */
32
+ createUser?: (payload: RegisterPayload) => AuthUser;
33
+ }
34
+
35
+ export interface MockAuthBackend extends AuthBackend {
36
+ /** Issue a reset token for an existing account (no email service in mock). */
37
+ requestPasswordReset(email: string): Promise<{ token: string }>;
38
+ /** Complete a reset using a token from requestPasswordReset. */
39
+ resetPassword(token: string, newPassword: string): Promise<void>;
40
+ /** Issue an email-verification token for an account. */
41
+ requestEmailVerification(email: string): Promise<{ token: string }>;
42
+ /** Mark an account verified; updates the live session if it's the current user. */
43
+ verifyEmail(token: string): Promise<void>;
44
+ /** Add or replace an account at runtime. */
45
+ upsertAccount(account: MockAccount): void;
46
+ }
47
+
48
+ const DAY_MS = 24 * 60 * 60 * 1000;
49
+
50
+ const sleep = (ms: number) =>
51
+ ms > 0 ? new Promise<void>((r) => setTimeout(r, ms)) : Promise.resolve();
52
+
53
+ let _tokenSeq = 0;
54
+ function genToken(prefix: string): string {
55
+ _tokenSeq += 1;
56
+ const rand = Math.random().toString(36).slice(2, 10);
57
+ return `${prefix}.${Date.now().toString(36)}.${_tokenSeq}.${rand}`;
58
+ }
59
+
60
+ function normalizeEmail(email: string): string {
61
+ return email.trim().toLowerCase();
62
+ }
63
+
64
+ function defaultCreateUser(payload: RegisterPayload, now: number): AuthUser {
65
+ const fromName = payload.name?.trim();
66
+ const fromParts = [payload.firstName, payload.lastName].filter(Boolean).join(' ');
67
+ const display = fromName || fromParts || payload.email.split('@')[0];
68
+ const [first, ...rest] = display.split(/\s+/);
69
+ const iso = new Date(now).toISOString();
70
+ return {
71
+ id: `mock-${normalizeEmail(payload.email).replace(/[^a-z0-9]+/g, '-')}`,
72
+ email: payload.email,
73
+ firstName: payload.firstName ?? first ?? '',
74
+ lastName: payload.lastName ?? rest.join(' '),
75
+ isEmailVerified: false,
76
+ createdAt: iso,
77
+ updatedAt: iso,
78
+ name: display,
79
+ };
80
+ }
81
+
82
+ export function createMockAuthBackend(
83
+ options: MockAuthBackendOptions = {}
84
+ ): MockAuthBackend {
85
+ const storage = options.storage ?? createMemorySessionStorage();
86
+ const latencyMs = options.latencyMs ?? 0;
87
+ const ttl = options.sessionTtlMs ?? DAY_MS;
88
+ const now = options.now ?? (() => Date.now());
89
+ const createUser =
90
+ options.createUser ?? ((payload: RegisterPayload) => defaultCreateUser(payload, now()));
91
+
92
+ interface Record_ {
93
+ password: string;
94
+ user: AuthUser;
95
+ }
96
+ const accounts = new Map<string, Record_>();
97
+ for (const a of options.accounts ?? []) {
98
+ accounts.set(normalizeEmail(a.email), { password: a.password, user: a.user });
99
+ }
100
+
101
+ const resetTokens = new Map<string, string>(); // token -> email
102
+ const verifyTokens = new Map<string, string>(); // token -> email
103
+
104
+ let session: Session | null = null;
105
+ let onSessionExpired: (() => void) | null = null;
106
+
107
+ function makeSession(user: AuthUser): Session {
108
+ return { user, accessToken: genToken('mock-access'), expiresAt: now() + ttl };
109
+ }
110
+
111
+ function isExpired(s: Session): boolean {
112
+ return s.expiresAt <= now();
113
+ }
114
+
115
+ /** Validated current session: clears + notifies if expired. */
116
+ function currentSession(): Session | null {
117
+ if (!session) return null;
118
+ if (isExpired(session)) {
119
+ session = null;
120
+ void storage.clear();
121
+ onSessionExpired?.();
122
+ return null;
123
+ }
124
+ return session;
125
+ }
126
+
127
+ function requireAccount(email: string): Record_ {
128
+ const account = accounts.get(normalizeEmail(email));
129
+ if (!account) throw new Error('No account found for that email');
130
+ return account;
131
+ }
132
+
133
+ const backend: MockAuthBackend = {
134
+ async login(email, password) {
135
+ await sleep(latencyMs);
136
+ const account = accounts.get(normalizeEmail(email));
137
+ if (!account || account.password !== password) {
138
+ throw new Error('Invalid email or password');
139
+ }
140
+ session = makeSession(account.user);
141
+ await storage.save(session);
142
+ return session;
143
+ },
144
+
145
+ async logout() {
146
+ session = null;
147
+ await storage.clear();
148
+ },
149
+
150
+ async register(payload) {
151
+ await sleep(latencyMs);
152
+ const key = normalizeEmail(payload.email);
153
+ if (accounts.has(key)) {
154
+ throw new Error('An account with this email already exists');
155
+ }
156
+ const user = createUser(payload);
157
+ accounts.set(key, { password: payload.password, user });
158
+ session = makeSession(user);
159
+ await storage.save(session);
160
+ return session;
161
+ },
162
+
163
+ async getCurrentUser() {
164
+ if (!session) throw new Error('Not authenticated');
165
+ return session.user;
166
+ },
167
+
168
+ async getAccessToken() {
169
+ return currentSession()?.accessToken ?? null;
170
+ },
171
+
172
+ getSession() {
173
+ return currentSession();
174
+ },
175
+
176
+ setSession(s) {
177
+ session = s;
178
+ void storage.save(s);
179
+ },
180
+
181
+ async restoreSession() {
182
+ const loaded = await storage.load();
183
+ if (loaded && !isExpired(loaded)) {
184
+ session = loaded;
185
+ return session;
186
+ }
187
+ if (loaded) await storage.clear();
188
+ return null;
189
+ },
190
+
191
+ async signInWithGoogle() {
192
+ throw new Error('OAuth is not supported by the mock auth backend');
193
+ },
194
+
195
+ async completeGoogleCallback() {
196
+ throw new Error('OAuth is not supported by the mock auth backend');
197
+ },
198
+
199
+ setOnSessionExpired(cb) {
200
+ onSessionExpired = cb;
201
+ },
202
+
203
+ destroy() {
204
+ onSessionExpired = null;
205
+ },
206
+
207
+ // --- mock-only flows ---
208
+
209
+ async requestPasswordReset(email) {
210
+ await sleep(latencyMs);
211
+ requireAccount(email);
212
+ const token = genToken('mock-reset');
213
+ resetTokens.set(token, normalizeEmail(email));
214
+ return { token };
215
+ },
216
+
217
+ async resetPassword(token, newPassword) {
218
+ await sleep(latencyMs);
219
+ const email = resetTokens.get(token);
220
+ if (!email) throw new Error('Invalid or expired reset token');
221
+ requireAccount(email).password = newPassword;
222
+ resetTokens.delete(token);
223
+ },
224
+
225
+ async requestEmailVerification(email) {
226
+ await sleep(latencyMs);
227
+ requireAccount(email);
228
+ const token = genToken('mock-verify');
229
+ verifyTokens.set(token, normalizeEmail(email));
230
+ return { token };
231
+ },
232
+
233
+ async verifyEmail(token) {
234
+ await sleep(latencyMs);
235
+ const email = verifyTokens.get(token);
236
+ if (!email) throw new Error('Invalid or expired verification token');
237
+ const account = requireAccount(email);
238
+ account.user = { ...account.user, isEmailVerified: true };
239
+ verifyTokens.delete(token);
240
+ if (session && normalizeEmail(session.user.email) === email) {
241
+ session = { ...session, user: { ...session.user, isEmailVerified: true } };
242
+ void storage.save(session);
243
+ }
244
+ },
245
+
246
+ upsertAccount(account) {
247
+ accounts.set(normalizeEmail(account.email), {
248
+ password: account.password,
249
+ user: account.user,
250
+ });
251
+ },
252
+ };
253
+
254
+ return backend;
255
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Loads expo-secure-store — an OPTIONAL peer (see peerDependenciesMeta).
3
+ *
4
+ * Returns null when the native module isn't in the build (a dev client compiled
5
+ * before the dependency was added, Expo Go without it) instead of throwing
6
+ * during module evaluation, which would take down the whole auth package and
7
+ * crash the app at launch. The native secure-storage implementations share this
8
+ * one guarded load and fall back to in-memory when it returns null.
9
+ *
10
+ * The require uses a static string so Metro still bundles the module; the
11
+ * try/catch turns a missing native module into a graceful null.
12
+ */
13
+ export type SecureStoreModule = typeof import('expo-secure-store');
14
+
15
+ export function loadSecureStore(): SecureStoreModule | null {
16
+ try {
17
+ return require('expo-secure-store') as SecureStoreModule;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
@@ -0,0 +1,53 @@
1
+ import type { Session } from '../types';
2
+ import {
3
+ SESSION_STORAGE_KEY,
4
+ createMemorySessionStorage,
5
+ type SessionStorage,
6
+ } from './session-storage';
7
+ import { loadSecureStore } from './optional-secure-store';
8
+
9
+ /**
10
+ * SessionStorage backed by expo-secure-store (iOS Keychain / Android Keystore).
11
+ *
12
+ * Native counterpart to the web's localStorage session storage. Metro resolves
13
+ * this file over secure-session-storage.ts on React Native builds.
14
+ *
15
+ * expo-secure-store is an OPTIONAL peer ({@link loadSecureStore}). When its
16
+ * native module isn't present — a dev client compiled before the dependency was
17
+ * added, Expo Go without it — loadSecureStore returns null and we fall back to
18
+ * in-memory storage: the app keeps working, the session just won't survive a
19
+ * restart until the build includes the module.
20
+ */
21
+ export function createSecureSessionStorage(
22
+ key: string = SESSION_STORAGE_KEY
23
+ ): SessionStorage {
24
+ const SecureStore = loadSecureStore();
25
+ if (!SecureStore) return createMemorySessionStorage();
26
+
27
+ return {
28
+ async load() {
29
+ try {
30
+ const raw = await SecureStore.getItemAsync(key);
31
+ if (!raw) return null;
32
+ const parsed = JSON.parse(raw) as Session;
33
+ return parsed && typeof parsed === 'object' ? parsed : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ },
38
+ async save(session) {
39
+ try {
40
+ await SecureStore.setItemAsync(key, JSON.stringify(session));
41
+ } catch {
42
+ /* secure store unavailable at runtime — non-fatal for a session */
43
+ }
44
+ },
45
+ async clear() {
46
+ try {
47
+ await SecureStore.deleteItemAsync(key);
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,20 @@
1
+ import {
2
+ SESSION_STORAGE_KEY,
3
+ createWebSessionStorage,
4
+ type SessionStorage,
5
+ } from './session-storage';
6
+
7
+ /**
8
+ * Web resolution of createSecureSessionStorage.
9
+ *
10
+ * expo-secure-store is React Native-only; Metro resolves
11
+ * secure-session-storage.native.ts on native builds. On the web there is no
12
+ * Keychain, so sessions persist in localStorage instead. This keeps the import
13
+ * site identical across platforms (unlike the throwing token-storage web stub —
14
+ * sessions *should* persist on the web, where there's no httpOnly cookie).
15
+ */
16
+ export function createSecureSessionStorage(
17
+ key: string = SESSION_STORAGE_KEY
18
+ ): SessionStorage {
19
+ return createWebSessionStorage({ key });
20
+ }
@@ -0,0 +1,55 @@
1
+ import type { TokenStorage } from './token-auth-core';
2
+ import { loadSecureStore } from './optional-secure-store';
3
+
4
+ /** Keychain/Keystore key under which the refresh token is persisted. */
5
+ export const REFRESH_TOKEN_KEY = 'startsimpli.auth.refresh';
6
+
7
+ /**
8
+ * TokenStorage backed by expo-secure-store (iOS Keychain / Android Keystore).
9
+ *
10
+ * Native counterpart to the web's httpOnly refresh-token cookie. Metro resolves
11
+ * this file over secure-token-storage.ts on React Native builds.
12
+ *
13
+ * expo-secure-store is an OPTIONAL peer ({@link loadSecureStore}): if its native
14
+ * module isn't in the build, loadSecureStore returns null and we fall back to
15
+ * in-memory — tokens won't survive a restart, but nothing crashes.
16
+ */
17
+ export class SecureTokenStorage implements TokenStorage {
18
+ private readonly store = loadSecureStore();
19
+ private memory: string | null = null;
20
+
21
+ constructor(private readonly key: string = REFRESH_TOKEN_KEY) {}
22
+
23
+ async getRefreshToken(): Promise<string | null> {
24
+ if (!this.store) return this.memory;
25
+ try {
26
+ return await this.store.getItemAsync(this.key);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ async setRefreshToken(token: string): Promise<void> {
33
+ if (!this.store) {
34
+ this.memory = token;
35
+ return;
36
+ }
37
+ try {
38
+ await this.store.setItemAsync(this.key, token);
39
+ } catch {
40
+ /* secure store unavailable at runtime — non-fatal */
41
+ }
42
+ }
43
+
44
+ async clear(): Promise<void> {
45
+ if (!this.store) {
46
+ this.memory = null;
47
+ return;
48
+ }
49
+ try {
50
+ await this.store.deleteItemAsync(this.key);
51
+ } catch {
52
+ /* ignore */
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,32 @@
1
+ import type { TokenStorage } from './token-auth-core';
2
+
3
+ export const REFRESH_TOKEN_KEY = 'startsimpli.auth.refresh';
4
+
5
+ /**
6
+ * Web stub for SecureTokenStorage.
7
+ *
8
+ * expo-secure-store is React Native-only. On the web the refresh token lives in
9
+ * an httpOnly cookie managed by the browser (see the cookie-based AuthClient),
10
+ * so this throws if constructed. Metro resolves secure-token-storage.native.ts
11
+ * on native builds; this file is what web bundlers see.
12
+ */
13
+ export class SecureTokenStorage implements TokenStorage {
14
+ constructor(_key: string = REFRESH_TOKEN_KEY) {
15
+ throw new Error(
16
+ 'SecureTokenStorage is React Native-only (expo-secure-store). On the web, ' +
17
+ 'use the cookie-based AuthClient instead.'
18
+ );
19
+ }
20
+
21
+ async getRefreshToken(): Promise<string | null> {
22
+ return null;
23
+ }
24
+
25
+ async setRefreshToken(_token: string): Promise<void> {
26
+ /* no-op: never reached (constructor throws) */
27
+ }
28
+
29
+ async clear(): Promise<void> {
30
+ /* no-op: never reached (constructor throws) */
31
+ }
32
+ }