@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.
- package/README.md +191 -377
- package/package.json +25 -12
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +141 -0
- package/src/client/__tests__/secure-session-storage.test.ts +75 -0
- package/src/client/__tests__/secure-token-storage.test.ts +69 -0
- package/src/client/__tests__/session-storage.test.ts +118 -0
- package/src/client/__tests__/token-auth-core.test.ts +190 -0
- package/src/client/auth-client.ts +71 -11
- package/src/client/auth-context.tsx +94 -17
- package/src/client/backend.ts +67 -0
- package/src/client/functions.ts +38 -57
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +255 -0
- package/src/client/optional-secure-store.ts +21 -0
- package/src/client/secure-session-storage.native.ts +53 -0
- package/src/client/secure-session-storage.ts +20 -0
- package/src/client/secure-token-storage.native.ts +55 -0
- package/src/client/secure-token-storage.ts +32 -0
- package/src/client/session-storage.ts +142 -0
- package/src/client/token-auth-core.ts +190 -0
- package/src/client/token.ts +18 -0
- package/src/client/use-auth.ts +6 -1
- package/src/components/forgot-password-form.tsx +97 -0
- package/src/components/index.ts +5 -1
- package/src/components/oauth-callback.tsx +5 -2
- package/src/components/reset-password-form.tsx +124 -0
- package/src/components/sign-in-form.tsx +125 -0
- package/src/components/signup-form.tsx +161 -0
- package/src/components/use-oauth-callback.ts +14 -2
- package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
- package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
- package/src/hooks/__tests__/use-membership.test.tsx +136 -0
- package/src/hooks/index.ts +34 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +43 -1
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +5 -1
- package/src/utils/api-error.ts +54 -0
- package/src/utils/central-auth.ts +91 -0
- package/src/utils/index.ts +1 -0
- 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
|
+
}
|
package/src/client/functions.ts
CHANGED
|
@@ -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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
`${
|
|
458
|
+
`${callbackPath}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
|
478
459
|
),
|
|
479
460
|
{ credentials: 'include' }
|
|
480
461
|
);
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
}
|