@startsimpli/auth 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -377
- package/package.json +12 -2
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/client/__tests__/mock-backend.test.ts +144 -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 +12 -9
- package/src/client/auth-context.tsx +48 -19
- package/src/client/backend.ts +58 -0
- package/src/client/functions.ts +7 -52
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +258 -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/index.ts +18 -1
- package/src/types/index.ts +5 -0
- package/src/utils/api-error.ts +54 -0
|
@@ -0,0 +1,258 @@
|
|
|
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
|
+
if (payload.password !== payload.passwordConfirm) {
|
|
157
|
+
throw new Error('Passwords do not match');
|
|
158
|
+
}
|
|
159
|
+
const user = createUser(payload);
|
|
160
|
+
accounts.set(key, { password: payload.password, user });
|
|
161
|
+
session = makeSession(user);
|
|
162
|
+
await storage.save(session);
|
|
163
|
+
return session;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async getCurrentUser() {
|
|
167
|
+
if (!session) throw new Error('Not authenticated');
|
|
168
|
+
return session.user;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async getAccessToken() {
|
|
172
|
+
return currentSession()?.accessToken ?? null;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
getSession() {
|
|
176
|
+
return currentSession();
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
setSession(s) {
|
|
180
|
+
session = s;
|
|
181
|
+
void storage.save(s);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async restoreSession() {
|
|
185
|
+
const loaded = await storage.load();
|
|
186
|
+
if (loaded && !isExpired(loaded)) {
|
|
187
|
+
session = loaded;
|
|
188
|
+
return session;
|
|
189
|
+
}
|
|
190
|
+
if (loaded) await storage.clear();
|
|
191
|
+
return null;
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async signInWithGoogle() {
|
|
195
|
+
throw new Error('OAuth is not supported by the mock auth backend');
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async completeGoogleCallback() {
|
|
199
|
+
throw new Error('OAuth is not supported by the mock auth backend');
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
setOnSessionExpired(cb) {
|
|
203
|
+
onSessionExpired = cb;
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
destroy() {
|
|
207
|
+
onSessionExpired = null;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
// --- mock-only flows ---
|
|
211
|
+
|
|
212
|
+
async requestPasswordReset(email) {
|
|
213
|
+
await sleep(latencyMs);
|
|
214
|
+
requireAccount(email);
|
|
215
|
+
const token = genToken('mock-reset');
|
|
216
|
+
resetTokens.set(token, normalizeEmail(email));
|
|
217
|
+
return { token };
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async resetPassword(token, newPassword) {
|
|
221
|
+
await sleep(latencyMs);
|
|
222
|
+
const email = resetTokens.get(token);
|
|
223
|
+
if (!email) throw new Error('Invalid or expired reset token');
|
|
224
|
+
requireAccount(email).password = newPassword;
|
|
225
|
+
resetTokens.delete(token);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async requestEmailVerification(email) {
|
|
229
|
+
await sleep(latencyMs);
|
|
230
|
+
requireAccount(email);
|
|
231
|
+
const token = genToken('mock-verify');
|
|
232
|
+
verifyTokens.set(token, normalizeEmail(email));
|
|
233
|
+
return { token };
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async verifyEmail(token) {
|
|
237
|
+
await sleep(latencyMs);
|
|
238
|
+
const email = verifyTokens.get(token);
|
|
239
|
+
if (!email) throw new Error('Invalid or expired verification token');
|
|
240
|
+
const account = requireAccount(email);
|
|
241
|
+
account.user = { ...account.user, isEmailVerified: true };
|
|
242
|
+
verifyTokens.delete(token);
|
|
243
|
+
if (session && normalizeEmail(session.user.email) === email) {
|
|
244
|
+
session = { ...session, user: { ...session.user, isEmailVerified: true } };
|
|
245
|
+
void storage.save(session);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
upsertAccount(account) {
|
|
250
|
+
accounts.set(normalizeEmail(account.email), {
|
|
251
|
+
password: account.password,
|
|
252
|
+
user: account.user,
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return backend;
|
|
258
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async persistence for a serialized {@link Session}.
|
|
3
|
+
*
|
|
4
|
+
* Backends that own their session (the mock backend, offline-first clients)
|
|
5
|
+
* use this to survive reloads/app-restarts. The Django web client persists via
|
|
6
|
+
* httpOnly cookies instead and does not need it.
|
|
7
|
+
*
|
|
8
|
+
* Implementations here are platform-neutral (memory, Web Storage). The
|
|
9
|
+
* secure-store (iOS Keychain / Android Keystore) implementation lives in
|
|
10
|
+
* secure-session-storage.native.ts and is resolved by Metro on native builds.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Session } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface SessionStorage {
|
|
16
|
+
/** Return the persisted session, or null if none / unreadable. */
|
|
17
|
+
load(): Promise<Session | null>;
|
|
18
|
+
/** Persist the session. */
|
|
19
|
+
save(session: Session): Promise<void>;
|
|
20
|
+
/** Remove any persisted session. */
|
|
21
|
+
clear(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Default storage key. */
|
|
25
|
+
export const SESSION_STORAGE_KEY = 'startsimpli.auth.session';
|
|
26
|
+
|
|
27
|
+
function isSession(value: unknown): value is Session {
|
|
28
|
+
if (!value || typeof value !== 'object') return false;
|
|
29
|
+
const v = value as Record<string, unknown>;
|
|
30
|
+
return (
|
|
31
|
+
typeof v.accessToken === 'string' &&
|
|
32
|
+
typeof v.expiresAt === 'number' &&
|
|
33
|
+
!!v.user &&
|
|
34
|
+
typeof v.user === 'object'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* In-memory storage. Lost on reload — the safe default for SSR and tests, and
|
|
40
|
+
* a graceful fallback when no Web Storage is available.
|
|
41
|
+
*/
|
|
42
|
+
export function createMemorySessionStorage(): SessionStorage {
|
|
43
|
+
let current: Session | null = null;
|
|
44
|
+
return {
|
|
45
|
+
async load() {
|
|
46
|
+
return current;
|
|
47
|
+
},
|
|
48
|
+
async save(session) {
|
|
49
|
+
current = session;
|
|
50
|
+
},
|
|
51
|
+
async clear() {
|
|
52
|
+
current = null;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Web Storage (localStorage by default) backed storage. Persists across
|
|
59
|
+
* reloads. Falls back to in-memory when no Storage is available (SSR).
|
|
60
|
+
*/
|
|
61
|
+
export function createWebSessionStorage(opts: {
|
|
62
|
+
key?: string;
|
|
63
|
+
storage?: Storage;
|
|
64
|
+
} = {}): SessionStorage {
|
|
65
|
+
const key = opts.key ?? SESSION_STORAGE_KEY;
|
|
66
|
+
const resolveStorage = (): Storage | null => {
|
|
67
|
+
if (opts.storage) return opts.storage;
|
|
68
|
+
try {
|
|
69
|
+
const s = (globalThis as unknown as { localStorage?: Storage }).localStorage;
|
|
70
|
+
return s && typeof s.getItem === 'function' ? s : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const memoryFallback = createMemorySessionStorage();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async load() {
|
|
80
|
+
const storage = resolveStorage();
|
|
81
|
+
if (!storage) return memoryFallback.load();
|
|
82
|
+
try {
|
|
83
|
+
const raw = storage.getItem(key);
|
|
84
|
+
if (!raw) return null;
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
return isSession(parsed) ? parsed : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async save(session) {
|
|
92
|
+
const storage = resolveStorage();
|
|
93
|
+
if (!storage) return memoryFallback.save(session);
|
|
94
|
+
try {
|
|
95
|
+
storage.setItem(key, JSON.stringify(session));
|
|
96
|
+
} catch {
|
|
97
|
+
/* quota / serialization failure — non-fatal for a demo session */
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async clear() {
|
|
101
|
+
const storage = resolveStorage();
|
|
102
|
+
if (!storage) return memoryFallback.clear();
|
|
103
|
+
try {
|
|
104
|
+
storage.removeItem(key);
|
|
105
|
+
} catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* "Remember me" wrapper. When `shouldRemember()` is true at save time, the
|
|
114
|
+
* session is written to `persistent` storage (survives restarts); otherwise it
|
|
115
|
+
* is kept only in `transient` (in-memory) storage and `persistent` is cleared,
|
|
116
|
+
* so a restart starts unauthenticated. `load` always reads `persistent`, so a
|
|
117
|
+
* session is restored only if the last save was a "remembered" one.
|
|
118
|
+
*/
|
|
119
|
+
export function createRememberAwareSessionStorage(
|
|
120
|
+
persistent: SessionStorage,
|
|
121
|
+
shouldRemember: () => boolean,
|
|
122
|
+
transient: SessionStorage = createMemorySessionStorage()
|
|
123
|
+
): SessionStorage {
|
|
124
|
+
return {
|
|
125
|
+
load() {
|
|
126
|
+
return persistent.load();
|
|
127
|
+
},
|
|
128
|
+
async save(session) {
|
|
129
|
+
if (shouldRemember()) {
|
|
130
|
+
await transient.clear();
|
|
131
|
+
await persistent.save(session);
|
|
132
|
+
} else {
|
|
133
|
+
await persistent.clear();
|
|
134
|
+
await transient.save(session);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
async clear() {
|
|
138
|
+
await persistent.clear();
|
|
139
|
+
await transient.clear();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|