@techfinityedge/koolbase-react-native 1.8.0 → 1.10.0
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 +114 -60
- package/dist/auth-errors.d.ts +83 -0
- package/dist/auth-errors.js +159 -1
- package/dist/auth-storage.d.ts +26 -0
- package/dist/auth-storage.js +105 -0
- package/dist/auth.d.ts +115 -8
- package/dist/auth.js +489 -67
- package/dist/device-metadata.d.ts +36 -0
- package/dist/device-metadata.js +102 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +27 -1
- package/dist/types.d.ts +84 -0
- package/dist/types.js +16 -1
- package/package.json +11 -4
package/dist/auth.js
CHANGED
|
@@ -1,62 +1,379 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.KoolbaseAuth = void 0;
|
|
4
|
+
const types_1 = require("./types");
|
|
4
5
|
const auth_errors_1 = require("./auth-errors");
|
|
6
|
+
const auth_storage_1 = require("./auth-storage");
|
|
7
|
+
const device_metadata_1 = require("./device-metadata");
|
|
5
8
|
class KoolbaseAuth {
|
|
6
9
|
constructor(config) {
|
|
7
10
|
this.session = null;
|
|
11
|
+
this.ongoingRefresh = null;
|
|
12
|
+
this.listeners = new Set();
|
|
8
13
|
this.config = config;
|
|
14
|
+
this.metadata = new device_metadata_1.DeviceMetadata(config.appVersion);
|
|
15
|
+
this.fetchFn = config.fetch ?? ((url, init) => fetch(url, init));
|
|
16
|
+
this.timeoutMs = config.authTimeout ?? 10000;
|
|
17
|
+
if (config.authStorage) {
|
|
18
|
+
this.storage = config.authStorage;
|
|
19
|
+
}
|
|
20
|
+
else if ((0, auth_storage_1.isKeychainAvailable)()) {
|
|
21
|
+
this.storage = new auth_storage_1.SecureAuthStorage();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this.storage = null;
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.warn('[Koolbase] No persistent auth storage available. Sessions will not ' +
|
|
27
|
+
'survive app restarts. Install react-native-keychain for the ' +
|
|
28
|
+
'default secure backend, or provide KoolbaseConfig.authStorage ' +
|
|
29
|
+
'with your own implementation.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ─── Auth state listener ────────────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to authentication state changes. The listener fires:
|
|
35
|
+
* - Immediately on subscribe, with the current user (or null).
|
|
36
|
+
* - On every successful login, register, refresh, session restoration.
|
|
37
|
+
* - On logout / explicit setSession(null).
|
|
38
|
+
* - On linkPhone success (user object updated with phone fields).
|
|
39
|
+
*
|
|
40
|
+
* Returns an unsubscribe function. Call it when the consumer no longer
|
|
41
|
+
* needs updates (e.g. in a React useEffect cleanup).
|
|
42
|
+
*
|
|
43
|
+
* Listener errors are swallowed so a buggy listener can't break auth
|
|
44
|
+
* state propagation to other listeners.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const unsubscribe = auth.onAuthStateChange((user) => {
|
|
48
|
+
* setCurrentUser(user);
|
|
49
|
+
* });
|
|
50
|
+
* // later:
|
|
51
|
+
* unsubscribe();
|
|
52
|
+
*/
|
|
53
|
+
onAuthStateChange(listener) {
|
|
54
|
+
this.listeners.add(listener);
|
|
55
|
+
// Fire immediately with current state — matches RN ecosystem
|
|
56
|
+
// convention (Firebase Auth, Supabase Auth) so consumers don't
|
|
57
|
+
// need to separately read currentUser on mount.
|
|
58
|
+
try {
|
|
59
|
+
listener(this.session?.user ?? null);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// swallow
|
|
63
|
+
}
|
|
64
|
+
return () => {
|
|
65
|
+
this.listeners.delete(listener);
|
|
66
|
+
};
|
|
9
67
|
}
|
|
10
|
-
|
|
11
|
-
|
|
68
|
+
fireAuthStateChange() {
|
|
69
|
+
const user = this.session?.user ?? null;
|
|
70
|
+
for (const listener of this.listeners) {
|
|
71
|
+
try {
|
|
72
|
+
listener(user);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// swallow — one broken listener doesn't break others
|
|
76
|
+
}
|
|
77
|
+
}
|
|
12
78
|
}
|
|
13
|
-
|
|
79
|
+
// ─── Headers ────────────────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Compose the full header set for an outbound request: base headers,
|
|
82
|
+
* device metadata, and optionally the Authorization bearer token.
|
|
83
|
+
* Async because device metadata's first build may read from keychain.
|
|
84
|
+
*/
|
|
85
|
+
async prepareHeaders(includeAuth) {
|
|
86
|
+
const deviceHeaders = await this.metadata.build();
|
|
14
87
|
return {
|
|
15
88
|
'Content-Type': 'application/json',
|
|
16
|
-
|
|
89
|
+
'x-api-key': this.config.publicKey,
|
|
90
|
+
...deviceHeaders,
|
|
91
|
+
...(includeAuth && this.session
|
|
17
92
|
? { Authorization: `Bearer ${this.session.accessToken}` }
|
|
18
93
|
: {}),
|
|
19
94
|
};
|
|
20
95
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
96
|
+
// ─── Request plumbing ───────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* Low-level request helper used by every endpoint. Wires together:
|
|
99
|
+
* - The injected fetch implementation (config.fetch or global fetch)
|
|
100
|
+
* - Device metadata + x-api-key + auth header in one place
|
|
101
|
+
* - AbortController-based timeout (config.authTimeout, default 10s)
|
|
102
|
+
*
|
|
103
|
+
* On timeout, fetch rejects with an AbortError; callers see this as a
|
|
104
|
+
* non-KoolbaseAuthError exception, which restoreSession() treats as
|
|
105
|
+
* Offline (preserving optimistic state).
|
|
106
|
+
*/
|
|
107
|
+
async authRequest(path, options = {}) {
|
|
108
|
+
const headers = await this.prepareHeaders(options.includeAuth ?? false);
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
111
|
+
try {
|
|
112
|
+
return await this.fetchFn(`${this.config.baseUrl}${path}`, {
|
|
113
|
+
method: options.method ?? 'GET',
|
|
114
|
+
headers,
|
|
115
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
116
|
+
signal: controller.signal,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Authenticated request wrapper. Refreshes the access token if it's
|
|
125
|
+
* stale (within 1-min buffer of expiry) before issuing the call, then
|
|
126
|
+
* delegates to {@link authRequest} with includeAuth=true.
|
|
127
|
+
*/
|
|
128
|
+
async authedRequest(path, options = {}) {
|
|
129
|
+
await this._ensureValidToken();
|
|
130
|
+
return this.authRequest(path, { ...options, includeAuth: true });
|
|
131
|
+
}
|
|
132
|
+
// ─── Internal session lifecycle ─────────────────────────────────────────
|
|
133
|
+
async setSessionInternal(session) {
|
|
134
|
+
this.session = session;
|
|
135
|
+
if (this.storage) {
|
|
136
|
+
try {
|
|
137
|
+
await this.storage.saveSession(session);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// eslint-disable-next-line no-console
|
|
141
|
+
console.warn('[Koolbase] Failed to persist session; staying signed in for this ' +
|
|
142
|
+
'session only:', err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.fireAuthStateChange();
|
|
146
|
+
}
|
|
147
|
+
async clearSessionInternal() {
|
|
148
|
+
this.session = null;
|
|
149
|
+
if (this.storage) {
|
|
150
|
+
try {
|
|
151
|
+
await this.storage.clear();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// best effort
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.fireAuthStateChange();
|
|
158
|
+
}
|
|
159
|
+
// ─── Session restoration ────────────────────────────────────────────────
|
|
160
|
+
async restoreSession() {
|
|
161
|
+
if (!this.storage)
|
|
162
|
+
return types_1.RestoreResult.NoSession;
|
|
163
|
+
const persisted = await this.storage.readSession();
|
|
164
|
+
if (!persisted)
|
|
165
|
+
return types_1.RestoreResult.NoSession;
|
|
166
|
+
// Optimistic restore — populate state and fire listener before any
|
|
167
|
+
// network call. App can render authenticated UI immediately.
|
|
168
|
+
this.session = persisted;
|
|
169
|
+
this.fireAuthStateChange();
|
|
170
|
+
const expiresAt = persisted.expiresAt
|
|
171
|
+
? new Date(persisted.expiresAt).getTime()
|
|
172
|
+
: 0;
|
|
173
|
+
const oneMinuteMs = 60 * 1000;
|
|
174
|
+
if (expiresAt > Date.now() + oneMinuteMs) {
|
|
175
|
+
return types_1.RestoreResult.Restored;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await this.refresh(persisted.refreshToken);
|
|
179
|
+
return types_1.RestoreResult.Restored;
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
if (e instanceof auth_errors_1.SessionExpiredError ||
|
|
183
|
+
e instanceof auth_errors_1.TokenRevokedError ||
|
|
184
|
+
e instanceof auth_errors_1.InvalidCredentialsError) {
|
|
185
|
+
await this.clearSessionInternal();
|
|
186
|
+
return types_1.RestoreResult.Expired;
|
|
187
|
+
}
|
|
188
|
+
return types_1.RestoreResult.Offline;
|
|
30
189
|
}
|
|
31
|
-
return data;
|
|
32
190
|
}
|
|
191
|
+
// ─── Public auth API ────────────────────────────────────────────────────
|
|
33
192
|
async register(params) {
|
|
34
|
-
|
|
35
|
-
|
|
193
|
+
if (params.password.length < 8)
|
|
194
|
+
throw new auth_errors_1.WeakPasswordError();
|
|
195
|
+
const res = await this.authRequest('/v1/sdk/auth/register', {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
body: params,
|
|
198
|
+
});
|
|
199
|
+
const session = await this.parseSessionResponse(res, false);
|
|
200
|
+
await this.setSessionInternal(session);
|
|
201
|
+
return session.user;
|
|
36
202
|
}
|
|
37
203
|
async login(params) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
204
|
+
const res = await this.authRequest('/v1/sdk/auth/login', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
body: params,
|
|
207
|
+
});
|
|
208
|
+
const session = await this.parseSessionResponse(res, false);
|
|
209
|
+
await this.setSessionInternal(session);
|
|
210
|
+
return session;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Sign in with Apple using a credential obtained from a native Apple
|
|
214
|
+
* Sign-In SDK.
|
|
215
|
+
*
|
|
216
|
+
* The SDK is library-agnostic — use any native Apple Sign-In package
|
|
217
|
+
* (`@invertase/react-native-apple-authentication`, etc.) and pass the
|
|
218
|
+
* resulting `identityToken`, optional `nonce`, and optional `fullName`.
|
|
219
|
+
*
|
|
220
|
+
* `fullName` is meaningful only on first sign-in — Apple omits name
|
|
221
|
+
* data on subsequent sign-ins. The server persists at link time and
|
|
222
|
+
* ignores on subsequent sign-ins.
|
|
223
|
+
*
|
|
224
|
+
* On success the session is persisted via the configured storage and
|
|
225
|
+
* `onAuthStateChange` fires with the resolved user.
|
|
226
|
+
*
|
|
227
|
+
* @throws AppleSignInNotConfiguredError when Apple is not enabled in
|
|
228
|
+
* the dashboard OAuth config for this environment (400).
|
|
229
|
+
* @throws InvalidAppleTokenError when the token signature, audience,
|
|
230
|
+
* expiry, replay, or nonce check failed server-side (401).
|
|
231
|
+
* @throws UserDisabledError when the account flag is set to disabled (403).
|
|
232
|
+
* @throws AppleEmailRequiredError when Apple did not return email for
|
|
233
|
+
* a new-account sign-in (400).
|
|
234
|
+
* @throws OAuthEmailConflictError when email matches existing user
|
|
235
|
+
* but auto-link rule blocked (409).
|
|
236
|
+
*/
|
|
237
|
+
async signInWithApple(params) {
|
|
238
|
+
const body = {
|
|
239
|
+
identity_token: params.identityToken,
|
|
240
|
+
};
|
|
241
|
+
if (params.nonce && params.nonce.length > 0) {
|
|
242
|
+
body.nonce = params.nonce;
|
|
243
|
+
}
|
|
244
|
+
if (params.fullName) {
|
|
245
|
+
const nameJson = {};
|
|
246
|
+
if (params.fullName.givenName)
|
|
247
|
+
nameJson.given_name = params.fullName.givenName;
|
|
248
|
+
if (params.fullName.familyName)
|
|
249
|
+
nameJson.family_name = params.fullName.familyName;
|
|
250
|
+
if (Object.keys(nameJson).length > 0) {
|
|
251
|
+
body.full_name = nameJson;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const res = await this.authRequest('/v1/sdk/auth/oauth/apple', {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
body,
|
|
257
|
+
});
|
|
258
|
+
const session = await this.parseAppleSessionResponse(res);
|
|
259
|
+
await this.setSessionInternal(session);
|
|
260
|
+
return session;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Parses a /v1/sdk/auth/oauth/apple response. Distinct from
|
|
264
|
+
* parseSessionResponse because OAuth error semantics differ from
|
|
265
|
+
* credential auth — status codes map to a separate error set.
|
|
266
|
+
*/
|
|
267
|
+
async parseAppleSessionResponse(res) {
|
|
268
|
+
if (res.status === 200) {
|
|
269
|
+
const data = await res.json();
|
|
270
|
+
return {
|
|
271
|
+
accessToken: data.access_token,
|
|
272
|
+
refreshToken: data.refresh_token,
|
|
273
|
+
expiresAt: data.expires_at,
|
|
274
|
+
user: this.mapUser(data.user),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
let errorMessage = '';
|
|
278
|
+
try {
|
|
279
|
+
const data = await res.json();
|
|
280
|
+
errorMessage = data?.error ?? '';
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// best-effort error message extraction
|
|
284
|
+
}
|
|
285
|
+
if (res.status === 400) {
|
|
286
|
+
if (errorMessage.includes('not configured')) {
|
|
287
|
+
throw new auth_errors_1.AppleSignInNotConfiguredError();
|
|
288
|
+
}
|
|
289
|
+
if (errorMessage.includes('did not return email')) {
|
|
290
|
+
throw new auth_errors_1.AppleEmailRequiredError();
|
|
291
|
+
}
|
|
292
|
+
throw new auth_errors_1.KoolbaseAuthError(`apple sign-in failed: ${errorMessage}`, 'apple_signin_failed');
|
|
293
|
+
}
|
|
294
|
+
if (res.status === 401)
|
|
295
|
+
throw new auth_errors_1.InvalidAppleTokenError();
|
|
296
|
+
if (res.status === 403)
|
|
297
|
+
throw new auth_errors_1.UserDisabledError();
|
|
298
|
+
if (res.status === 409)
|
|
299
|
+
throw new auth_errors_1.OAuthEmailConflictError();
|
|
300
|
+
if (res.status === 429)
|
|
301
|
+
throw new auth_errors_1.RateLimitError(errorMessage);
|
|
302
|
+
throw new auth_errors_1.KoolbaseAuthError(`apple sign-in failed: ${res.status} ${errorMessage}`, `apple_signin_http_${res.status}`);
|
|
303
|
+
}
|
|
304
|
+
async refresh(refreshToken) {
|
|
305
|
+
if (this.ongoingRefresh) {
|
|
306
|
+
return this.ongoingRefresh;
|
|
307
|
+
}
|
|
308
|
+
const promise = this._doRefresh(refreshToken);
|
|
309
|
+
this.ongoingRefresh = promise;
|
|
310
|
+
promise
|
|
311
|
+
.catch(() => {
|
|
312
|
+
// swallow; original promise still rejects to awaiters
|
|
313
|
+
})
|
|
314
|
+
.finally(() => {
|
|
315
|
+
if (this.ongoingRefresh === promise) {
|
|
316
|
+
this.ongoingRefresh = null;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
return promise;
|
|
320
|
+
}
|
|
321
|
+
async _doRefresh(refreshToken) {
|
|
322
|
+
const token = refreshToken ?? this.session?.refreshToken;
|
|
323
|
+
if (!token) {
|
|
324
|
+
throw new auth_errors_1.SessionExpiredError();
|
|
325
|
+
}
|
|
326
|
+
const res = await this.authRequest('/v1/sdk/auth/refresh', {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
body: { refresh_token: token },
|
|
329
|
+
});
|
|
330
|
+
const session = await this.parseSessionResponse(res, true);
|
|
331
|
+
await this.setSessionInternal(session);
|
|
332
|
+
return session;
|
|
41
333
|
}
|
|
42
334
|
async logout() {
|
|
43
|
-
|
|
44
|
-
return;
|
|
335
|
+
let serverSucceeded = true;
|
|
45
336
|
try {
|
|
46
|
-
|
|
337
|
+
if (this.session) {
|
|
338
|
+
// Best-effort: don't auto-refresh during logout. If the token's
|
|
339
|
+
// already expired, we still want to clear local state — server
|
|
340
|
+
// will reap expired sessions itself.
|
|
341
|
+
const res = await this.authRequest('/v1/sdk/auth/logout', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
includeAuth: true,
|
|
344
|
+
});
|
|
345
|
+
if (!res.ok)
|
|
346
|
+
serverSucceeded = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
serverSucceeded = false;
|
|
47
351
|
}
|
|
48
352
|
finally {
|
|
49
|
-
this.
|
|
353
|
+
await this.clearSessionInternal();
|
|
50
354
|
}
|
|
355
|
+
return serverSucceeded;
|
|
51
356
|
}
|
|
52
357
|
async forgotPassword(email) {
|
|
53
|
-
await this.
|
|
358
|
+
const res = await this.authRequest('/v1/sdk/auth/password-reset', {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
body: { email },
|
|
361
|
+
});
|
|
362
|
+
await this.checkResponse(res);
|
|
54
363
|
}
|
|
55
364
|
async resetPassword(token, password) {
|
|
56
|
-
await this.
|
|
57
|
-
|
|
58
|
-
password,
|
|
365
|
+
const res = await this.authRequest('/v1/sdk/auth/password-reset/confirm', {
|
|
366
|
+
method: 'POST',
|
|
367
|
+
body: { token, password },
|
|
368
|
+
});
|
|
369
|
+
await this.checkResponse(res);
|
|
370
|
+
}
|
|
371
|
+
async unlock(token) {
|
|
372
|
+
const res = await this.authRequest('/v1/sdk/auth/unlock', {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
body: { token },
|
|
59
375
|
});
|
|
376
|
+
await this.checkResponse(res);
|
|
60
377
|
}
|
|
61
378
|
get currentUser() {
|
|
62
379
|
return this.session?.user ?? null;
|
|
@@ -64,61 +381,58 @@ class KoolbaseAuth {
|
|
|
64
381
|
get accessToken() {
|
|
65
382
|
return this.session?.accessToken ?? null;
|
|
66
383
|
}
|
|
67
|
-
setSession(session) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
async oauthLogin({ provider, token, email = '', name = '', avatarUrl = '', }) {
|
|
71
|
-
try {
|
|
72
|
-
const response = await fetch(`${this.config.baseUrl}/v1/auth/oauth`, {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify({ provider, token, email, name, avatar_url: avatarUrl }),
|
|
76
|
-
});
|
|
77
|
-
if (response.ok)
|
|
78
|
-
return response.json();
|
|
79
|
-
return null;
|
|
384
|
+
async setSession(session) {
|
|
385
|
+
if (session) {
|
|
386
|
+
await this.setSessionInternal(session);
|
|
80
387
|
}
|
|
81
|
-
|
|
82
|
-
|
|
388
|
+
else {
|
|
389
|
+
await this.clearSessionInternal();
|
|
83
390
|
}
|
|
84
391
|
}
|
|
392
|
+
// ─── OAuth (DEPRECATED — see v1.10.0) ───────────────────────────────────
|
|
393
|
+
/**
|
|
394
|
+
* @deprecated v1.9.0: Server endpoint /v1/sdk/auth/oauth not yet
|
|
395
|
+
* shipped. This method previously routed to /v1/auth/oauth (dashboard
|
|
396
|
+
* developer OAuth) which never created project-scoped end-user
|
|
397
|
+
* sessions. Properly implemented in v1.10.0 with provider-specific
|
|
398
|
+
* server endpoints under /v1/sdk/auth/oauth/{apple,google,github}.
|
|
399
|
+
* Use email/password sign-in for now.
|
|
400
|
+
*
|
|
401
|
+
* @throws Always throws KoolbaseAuthError('not_implemented').
|
|
402
|
+
*/
|
|
403
|
+
async oauthLogin(_params) {
|
|
404
|
+
throw new auth_errors_1.KoolbaseAuthError('OAuth sign-in is not yet implemented for the Koolbase SDK. ' +
|
|
405
|
+
'Planned for v1.10.0 (server-side endpoints under ' +
|
|
406
|
+
'/v1/sdk/auth/oauth/{provider}). Use email/password authentication ' +
|
|
407
|
+
'in the meantime.', 'not_implemented');
|
|
408
|
+
}
|
|
409
|
+
// ─── Phone OTP ──────────────────────────────────────────────────────────
|
|
85
410
|
async sendOtp(params) {
|
|
86
411
|
this.validatePhone(params.phoneNumber);
|
|
87
|
-
const res = await
|
|
412
|
+
const res = await this.authRequest('/v1/sdk/auth/phone/send-otp', {
|
|
88
413
|
method: 'POST',
|
|
89
|
-
|
|
90
|
-
body: JSON.stringify({ phone_number: params.phoneNumber }),
|
|
414
|
+
body: { phone_number: params.phoneNumber },
|
|
91
415
|
});
|
|
92
416
|
const data = await this.parsePhoneResponse(res);
|
|
93
417
|
return { expiresAt: data.expires_at };
|
|
94
418
|
}
|
|
95
419
|
async verifyOtp(params) {
|
|
96
420
|
this.validatePhone(params.phoneNumber);
|
|
97
|
-
const res = await
|
|
421
|
+
const res = await this.authRequest('/v1/sdk/auth/phone/verify-otp', {
|
|
98
422
|
method: 'POST',
|
|
99
|
-
|
|
100
|
-
body: JSON.stringify({
|
|
423
|
+
body: {
|
|
101
424
|
phone_number: params.phoneNumber,
|
|
102
425
|
code: params.code,
|
|
103
|
-
}
|
|
426
|
+
},
|
|
104
427
|
});
|
|
105
428
|
const data = await this.parsePhoneResponse(res);
|
|
106
|
-
const user = {
|
|
107
|
-
id: data.user.id,
|
|
108
|
-
email: data.user.email ?? '',
|
|
109
|
-
phoneNumber: data.user.phone_number,
|
|
110
|
-
phoneVerified: data.user.phone_verified ?? false,
|
|
111
|
-
fullName: data.user.full_name,
|
|
112
|
-
avatarUrl: data.user.avatar_url,
|
|
113
|
-
verified: data.user.verified ?? false,
|
|
114
|
-
createdAt: data.user.created_at,
|
|
115
|
-
};
|
|
116
429
|
const session = {
|
|
117
430
|
accessToken: data.access_token,
|
|
118
431
|
refreshToken: data.refresh_token,
|
|
119
|
-
|
|
432
|
+
expiresAt: data.expires_at,
|
|
433
|
+
user: this.mapUser(data.user),
|
|
120
434
|
};
|
|
121
|
-
this.session
|
|
435
|
+
await this.setSessionInternal(session);
|
|
122
436
|
return { session, isNewUser: data.is_new_user ?? false };
|
|
123
437
|
}
|
|
124
438
|
async linkPhone(params) {
|
|
@@ -126,27 +440,135 @@ class KoolbaseAuth {
|
|
|
126
440
|
throw new auth_errors_1.KoolbaseAuthError('Must be signed in to link a phone number', 'unauthenticated');
|
|
127
441
|
}
|
|
128
442
|
this.validatePhone(params.phoneNumber);
|
|
129
|
-
const res = await
|
|
443
|
+
const res = await this.authedRequest('/v1/sdk/auth/phone/link', {
|
|
130
444
|
method: 'POST',
|
|
131
|
-
|
|
132
|
-
body: JSON.stringify({
|
|
445
|
+
body: {
|
|
133
446
|
phone_number: params.phoneNumber,
|
|
134
447
|
code: params.code,
|
|
135
|
-
}
|
|
448
|
+
},
|
|
136
449
|
});
|
|
137
|
-
await this.parsePhoneResponse(res);
|
|
450
|
+
const body = await this.parsePhoneResponse(res);
|
|
451
|
+
// Update local session: prefer the canonical user from the server
|
|
452
|
+
// response if present; otherwise merge the linked phone into the
|
|
453
|
+
// existing in-memory user. Either way, setSessionInternal fires the
|
|
454
|
+
// auth state listener so consumers can react to the phone link.
|
|
455
|
+
if (this.session) {
|
|
456
|
+
const updatedUser = body.user
|
|
457
|
+
? this.mapUser(body.user)
|
|
458
|
+
: {
|
|
459
|
+
...this.session.user,
|
|
460
|
+
phoneNumber: params.phoneNumber,
|
|
461
|
+
phoneVerified: true,
|
|
462
|
+
};
|
|
463
|
+
await this.setSessionInternal({
|
|
464
|
+
...this.session,
|
|
465
|
+
user: updatedUser,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// ─── Cleanup ────────────────────────────────────────────────────────────
|
|
470
|
+
/**
|
|
471
|
+
* Release resources held by this auth client. Clears the in-memory
|
|
472
|
+
* listener set. Does not invalidate sessions or clear storage — call
|
|
473
|
+
* {@link logout} for that.
|
|
474
|
+
*/
|
|
475
|
+
dispose() {
|
|
476
|
+
this.listeners.clear();
|
|
138
477
|
}
|
|
478
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
139
479
|
validatePhone(phoneNumber) {
|
|
140
480
|
if (!/^\+[1-9]\d{6,14}$/.test(phoneNumber)) {
|
|
141
481
|
throw new auth_errors_1.InvalidPhoneNumberError();
|
|
142
482
|
}
|
|
143
483
|
}
|
|
484
|
+
async _ensureValidToken() {
|
|
485
|
+
if (this.session && this.session.expiresAt) {
|
|
486
|
+
const expiresAt = new Date(this.session.expiresAt).getTime();
|
|
487
|
+
if (Date.now() < expiresAt - 60 * 1000) {
|
|
488
|
+
return this.session.accessToken;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!this.session) {
|
|
492
|
+
throw new auth_errors_1.SessionExpiredError();
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const session = await this.refresh();
|
|
496
|
+
return session.accessToken;
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
if (e instanceof auth_errors_1.KoolbaseAuthError)
|
|
500
|
+
throw e;
|
|
501
|
+
throw new auth_errors_1.SessionExpiredError();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
mapUser(raw) {
|
|
505
|
+
return {
|
|
506
|
+
id: raw.id,
|
|
507
|
+
email: raw.email ?? '',
|
|
508
|
+
phoneNumber: raw.phone_number,
|
|
509
|
+
phoneVerified: raw.phone_verified ?? false,
|
|
510
|
+
fullName: raw.full_name,
|
|
511
|
+
avatarUrl: raw.avatar_url,
|
|
512
|
+
verified: raw.verified ?? false,
|
|
513
|
+
createdAt: raw.created_at,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
async parseSessionResponse(res, isRefresh) {
|
|
517
|
+
if (res.status === 409)
|
|
518
|
+
throw new auth_errors_1.EmailAlreadyInUseError();
|
|
519
|
+
if (res.status === 401) {
|
|
520
|
+
throw isRefresh ? new auth_errors_1.SessionExpiredError() : new auth_errors_1.InvalidCredentialsError();
|
|
521
|
+
}
|
|
522
|
+
if (res.status === 403)
|
|
523
|
+
throw new auth_errors_1.UserDisabledError();
|
|
524
|
+
if (!res.ok)
|
|
525
|
+
await this.throwTypedError(res);
|
|
526
|
+
const data = await res.json();
|
|
527
|
+
return {
|
|
528
|
+
accessToken: data.access_token,
|
|
529
|
+
refreshToken: data.refresh_token,
|
|
530
|
+
expiresAt: data.expires_at,
|
|
531
|
+
user: this.mapUser(data.user),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async checkResponse(res) {
|
|
535
|
+
if (res.ok)
|
|
536
|
+
return;
|
|
537
|
+
await this.throwTypedError(res);
|
|
538
|
+
}
|
|
539
|
+
async throwTypedError(res) {
|
|
540
|
+
let body = {};
|
|
541
|
+
try {
|
|
542
|
+
body = await res.json();
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// ignore
|
|
546
|
+
}
|
|
547
|
+
const msg = body.error ?? '';
|
|
548
|
+
if (res.status === 429) {
|
|
549
|
+
if (msg.includes('account temporarily locked')) {
|
|
550
|
+
throw new auth_errors_1.AccountLockedError();
|
|
551
|
+
}
|
|
552
|
+
throw new auth_errors_1.RateLimitError(msg || undefined);
|
|
553
|
+
}
|
|
554
|
+
if (msg.includes('invalid or expired unlock token')) {
|
|
555
|
+
throw new auth_errors_1.UnlockTokenInvalidError();
|
|
556
|
+
}
|
|
557
|
+
if (msg.includes('session revoked') ||
|
|
558
|
+
msg.includes('token revoked') ||
|
|
559
|
+
msg.includes('session has been revoked')) {
|
|
560
|
+
throw new auth_errors_1.TokenRevokedError();
|
|
561
|
+
}
|
|
562
|
+
throw new auth_errors_1.KoolbaseAuthError(msg || `Request failed: ${res.status}`, `http_${res.status}`);
|
|
563
|
+
}
|
|
144
564
|
async parsePhoneResponse(res) {
|
|
145
565
|
let body = {};
|
|
146
566
|
try {
|
|
147
567
|
body = await res.json();
|
|
148
568
|
}
|
|
149
|
-
catch {
|
|
569
|
+
catch {
|
|
570
|
+
// ignore
|
|
571
|
+
}
|
|
150
572
|
if (res.ok)
|
|
151
573
|
return body;
|
|
152
574
|
const msg = body.error ?? '';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Koolbase React Native SDK version. Sent in the `x-koolbase-sdk-version`
|
|
3
|
+
* header on every authenticated request so the server can route
|
|
4
|
+
* version-conditional logic (deprecation warnings, schema migrations,
|
|
5
|
+
* feature flags). Must match the `version` field in package.json.
|
|
6
|
+
*/
|
|
7
|
+
export declare const koolbaseSdkVersion = "1.10.0";
|
|
8
|
+
/**
|
|
9
|
+
* Builds device-identifying headers attached to every Koolbase auth
|
|
10
|
+
* request. Mirrors the Flutter SDK's `DeviceMetadata` for parity. Apps
|
|
11
|
+
* with privacy concerns can swap in a custom storage adapter to avoid
|
|
12
|
+
* persisting the device label.
|
|
13
|
+
*
|
|
14
|
+
* Headers emitted:
|
|
15
|
+
* - User-Agent: koolbase-react-native/<sdk> (<platform> <version>)
|
|
16
|
+
* - x-koolbase-sdk: react-native
|
|
17
|
+
* - x-koolbase-sdk-version: <koolbaseSdkVersion>
|
|
18
|
+
* - x-koolbase-platform: ios | android | web | etc.
|
|
19
|
+
* - x-koolbase-platform-version: numeric SDK level or OS version string
|
|
20
|
+
* - x-koolbase-app-version: from KoolbaseConfig.appVersion or 'unknown'
|
|
21
|
+
* - x-koolbase-device-label: persistent UUID per install
|
|
22
|
+
*/
|
|
23
|
+
export declare class DeviceMetadata {
|
|
24
|
+
private cached;
|
|
25
|
+
private ephemeralLabel;
|
|
26
|
+
private readonly appVersion;
|
|
27
|
+
constructor(appVersion?: string);
|
|
28
|
+
/**
|
|
29
|
+
* Build (or return cached) device headers. The first call may perform
|
|
30
|
+
* an async keychain read to look up the persisted device label;
|
|
31
|
+
* subsequent calls return the in-memory cache synchronously via the
|
|
32
|
+
* returned Promise.
|
|
33
|
+
*/
|
|
34
|
+
build(): Promise<Record<string, string>>;
|
|
35
|
+
private getOrCreateDeviceLabel;
|
|
36
|
+
}
|