@missionmind/auth-front 0.0.0 → 0.1.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.
@@ -0,0 +1,735 @@
1
+ import { observable } from "@legendapp/state";
2
+ import { GoogleSignin } from "@react-native-google-signin/google-signin";
3
+ import { randomUUID } from "expo-crypto";
4
+ // biome-ignore lint/performance/noNamespaceImport: needed for expo-secure-store
5
+ import * as SecureStore from "expo-secure-store";
6
+ import { getAppleIdToken } from "./apple-id-token";
7
+ const MAX_ATTEMPTS = 5;
8
+ /** Creating the account at the end of onboarding: the user is waiting on it. */
9
+ const ACCOUNT_ATTEMPTS = 3;
10
+ const INITIAL_RETRY_DELAY_MS = 2000;
11
+ const HTTP_UNAUTHORIZED = 401;
12
+ const REGISTERED_ACCOUNT_KIND = "registered";
13
+ const ANONYMOUS_ACCOUNT_KIND = "anonymous";
14
+ export class AuthenticationError extends Error {
15
+ code;
16
+ constructor(status, code) {
17
+ super(status.toString());
18
+ this.name = "AuthenticationError";
19
+ this.code = code;
20
+ }
21
+ }
22
+ class TransientAuthError extends Error {
23
+ }
24
+ function wait(ms) {
25
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
27
+ function throwOnAuthError(error) {
28
+ if (error) {
29
+ console.error(error);
30
+ throw new AuthenticationError(error.status, error.code ?? "UNKNOWN_ERROR");
31
+ }
32
+ }
33
+ /**
34
+ * Read synchronously: the asynchronous keychain calls queue behind the others
35
+ * at launch and can take seconds. A keychain that cannot be read counts as
36
+ * empty.
37
+ */
38
+ function readKeychain(key) {
39
+ try {
40
+ return SecureStore.getItem(key);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * The signed-in user, when the provider answered with one.
48
+ *
49
+ * A social sign-in resolves either to a session — the id token flow every
50
+ * platform here uses — or to a URL the user still has to visit, which carries
51
+ * no user yet.
52
+ */
53
+ function getSignedInUserId(data) {
54
+ if (data && typeof data === "object" && "user" in data) {
55
+ return data.user.id;
56
+ }
57
+ return;
58
+ }
59
+ /**
60
+ * The account a device holds, and everything that signs it in and out.
61
+ *
62
+ * A device gets an anonymous account once (`ensureAccount`), and a long-lived
63
+ * device token in the keychain that brings that account back whenever its
64
+ * session dies (`ensureSession`, `recoverSession`). Signing in with an email,
65
+ * Apple or Google replaces an anonymous account (the server hands its data
66
+ * over), linking Apple or Google keeps it. A registered account that cannot be
67
+ * recovered asks the user to sign in (`isSignInRequired$`); an anonymous one
68
+ * starts over.
69
+ */
70
+ export function createAuthSession(client, options) {
71
+ const { authClient, readStoredCookie, storagePrefix } = client;
72
+ const DEVICE_TOKEN_STORAGE_KEY = `${storagePrefix}.device-token`;
73
+ const DEVICE_ID_STORAGE_KEY = `${storagePrefix}.device-id`;
74
+ const LAST_ACCOUNT_KIND_STORAGE_KEY = `${storagePrefix}.last-account-kind`;
75
+ const LAST_USER_ID_STORAGE_KEY = `${storagePrefix}.last-user-id`;
76
+ function reportError(error) {
77
+ options.reportError?.(error);
78
+ }
79
+ function track(action, props, run) {
80
+ return options.trackAction
81
+ ? options.trackAction(action, props, run)
82
+ : run();
83
+ }
84
+ /**
85
+ * Set when the device can no longer prove who it is and the account it lost
86
+ * is one the user can prove they own.
87
+ *
88
+ * A device whose anonymous account is lost starts over with onboarding, which
89
+ * is right for a visitor who never signed up and quietly destructive for
90
+ * everyone else: the account is still there on the server, and the only thing
91
+ * missing is a credential the user can supply themselves.
92
+ */
93
+ const isSignInRequired$ = observable(false);
94
+ /**
95
+ * Who the app is acting as.
96
+ *
97
+ * Straight from better-auth: the Expo client caches each `/get-session` reply
98
+ * in the keychain and seeds the session with it as the first one goes out, so
99
+ * the user is known before any reply comes back and stays known when one
100
+ * fails — the cached session is only dropped on a 401, which is the server
101
+ * actually saying the session is gone. Nothing here needs a store of its own.
102
+ */
103
+ function useAuthSession() {
104
+ const { data: session, isPending } = authClient.useSession();
105
+ return {
106
+ isAnonymous: Boolean(session?.user.isAnonymous),
107
+ isPending,
108
+ session,
109
+ };
110
+ }
111
+ /**
112
+ * Whether a sign-in actually left us signed in.
113
+ *
114
+ * Every sign-in response carries the session cookie, and the Expo client has
115
+ * written it to the keychain by the time the call resolves — better-auth's
116
+ * fetch layer awaits that hook before returning. Reading the cookie back is
117
+ * therefore a stronger check than a second `/get-session` and costs no round
118
+ * trip: the cookie is the thing every later request depends on.
119
+ */
120
+ async function hasSessionCookie() {
121
+ return Boolean(await authClient.getCookie());
122
+ }
123
+ /**
124
+ * Tells better-auth to read the session again.
125
+ *
126
+ * A sign-in gets this for free — the Expo client raises the signal when the
127
+ * session cookie changes — but linking a provider leaves the cookie alone
128
+ * while changing what the account is, and every screen showing "anonymous"
129
+ * needs to hear about it.
130
+ */
131
+ function notifySessionChanged() {
132
+ authClient.$store.notify("$sessionSignal");
133
+ }
134
+ /**
135
+ * This install's name for itself, kept for as long as the keychain survives.
136
+ *
137
+ * The server files each device token under it, so two installs of the same
138
+ * account each keep their own; issuing one used to revoke every other, which
139
+ * only surfaced weeks later on whichever device was unlucky enough to need
140
+ * its token first.
141
+ *
142
+ * @returns isNew - true when the id was created just now, which also means
143
+ * the token stored beside it, if any, predates the scheme and is still
144
+ * untagged server-side.
145
+ */
146
+ async function getDeviceId() {
147
+ const stored = await SecureStore.getItemAsync(DEVICE_ID_STORAGE_KEY);
148
+ if (stored) {
149
+ return { deviceId: stored, isNew: false };
150
+ }
151
+ const deviceId = randomUUID();
152
+ await SecureStore.setItemAsync(DEVICE_ID_STORAGE_KEY, deviceId);
153
+ return { deviceId, isNew: true };
154
+ }
155
+ /**
156
+ * The device token is a long-lived credential held in the keychain. It lets
157
+ * us re-authenticate as the same user after the server session expires, so
158
+ * session loss never orphans the workouts of someone who never created an
159
+ * account.
160
+ */
161
+ async function issueAndStoreDeviceToken(deviceId) {
162
+ const result = await authClient.$fetch("/device-token/issue", { method: "POST", body: { deviceId } });
163
+ if (result.error || !result.data?.token) {
164
+ throw new TransientAuthError(`Device token issuance failed (${result.error?.status ?? "no token returned"})`);
165
+ }
166
+ await SecureStore.setItemAsync(DEVICE_TOKEN_STORAGE_KEY, result.data.token);
167
+ }
168
+ /**
169
+ * A token issued before this device had an id is untagged on the server,
170
+ * which leaves it revocable by any other install of the same account — so it
171
+ * is traded for one bound to this device the first time we come through here.
172
+ */
173
+ async function ensureDeviceToken() {
174
+ const { deviceId, isNew } = await getDeviceId();
175
+ const stored = await SecureStore.getItemAsync(DEVICE_TOKEN_STORAGE_KEY);
176
+ if (stored && !isNew) {
177
+ return;
178
+ }
179
+ await issueAndStoreDeviceToken(deviceId).catch((error) => {
180
+ // Best effort: the next ensureSession call retries issuance.
181
+ console.error("[auth] could not issue device token", error);
182
+ reportError(error);
183
+ });
184
+ }
185
+ /** Forgets the device token, so the next launch starts from a clean slate. */
186
+ async function clearDeviceToken() {
187
+ await SecureStore.deleteItemAsync(DEVICE_TOKEN_STORAGE_KEY);
188
+ }
189
+ /**
190
+ * Remembers whether the account the device holds is one the user could sign
191
+ * back into, so a refused device token can be told apart from a visitor who
192
+ * never had anything to lose. By the time it is read there is no session left
193
+ * to ask.
194
+ */
195
+ async function rememberAccountKind(isAnonymous) {
196
+ await SecureStore.setItemAsync(LAST_ACCOUNT_KIND_STORAGE_KEY, isAnonymous ? ANONYMOUS_ACCOUNT_KIND : REGISTERED_ACCOUNT_KIND).catch((error) => {
197
+ // Losing this only costs us the sign-in prompt on a failed recovery.
198
+ console.error("[auth] could not record account kind", error);
199
+ });
200
+ }
201
+ /**
202
+ * Remembers which user the local data belongs to, so a sign-in can tell
203
+ * signing back into the same account (keep the data, sync it) from switching
204
+ * to another one (clear it).
205
+ */
206
+ async function rememberAccountUser(userId) {
207
+ await SecureStore.setItemAsync(LAST_USER_ID_STORAGE_KEY, userId).catch((error) => {
208
+ console.error("[auth] could not record account user", error);
209
+ });
210
+ }
211
+ function hadRegisteredAccount() {
212
+ return (readKeychain(LAST_ACCOUNT_KIND_STORAGE_KEY) === REGISTERED_ACCOUNT_KIND);
213
+ }
214
+ /**
215
+ * Leaves the device without an account: no credential, and no record of an
216
+ * account to sign back into. The next launch starts like a new install.
217
+ */
218
+ async function forgetAccount() {
219
+ await Promise.allSettled([
220
+ clearDeviceToken(),
221
+ SecureStore.deleteItemAsync(LAST_ACCOUNT_KIND_STORAGE_KEY),
222
+ SecureStore.deleteItemAsync(LAST_USER_ID_STORAGE_KEY),
223
+ ]);
224
+ }
225
+ /** Empties what the device kept of an account it no longer holds. */
226
+ async function startOverLocally() {
227
+ await options.onStartOver?.();
228
+ }
229
+ /**
230
+ * Rebinds the device to the user who is signed in right now.
231
+ *
232
+ * The stored token belongs to whoever held the session before — the anonymous
233
+ * user that has just been replaced by a real account, most of the time — and
234
+ * that user is usually deleted moments later, so keeping it would leave the
235
+ * device unable to recover any session at all.
236
+ */
237
+ async function refreshDeviceToken() {
238
+ const { deviceId } = await getDeviceId();
239
+ await clearDeviceToken();
240
+ await issueAndStoreDeviceToken(deviceId).catch((error) => {
241
+ // The device is now without a recovery credential: it will be issued one
242
+ // on the next launch, but until then a dead session cannot be recovered,
243
+ // which is worth hearing about rather than only reading in a console.
244
+ console.error("[auth] could not refresh device token", error);
245
+ reportError(error);
246
+ });
247
+ }
248
+ /** Resolves null when the token has been revoked, so the caller starts fresh. */
249
+ async function signInWithDeviceToken(deviceToken) {
250
+ const result = await authClient.$fetch("/sign-in/device-token", {
251
+ method: "POST",
252
+ body: { token: deviceToken },
253
+ });
254
+ if (!result.error) {
255
+ if (await hasSessionCookie()) {
256
+ return { isAnonymous: Boolean(result.data?.user.isAnonymous) };
257
+ }
258
+ throw new TransientAuthError("Session cookie missing after device token sign-in");
259
+ }
260
+ if (result.error.status === HTTP_UNAUTHORIZED) {
261
+ // The token was revoked server-side; forget it and start fresh.
262
+ await clearDeviceToken();
263
+ return null;
264
+ }
265
+ throw new TransientAuthError("Device token sign-in failed");
266
+ }
267
+ /**
268
+ * Whether the cookie in the keychain is still worth believing.
269
+ *
270
+ * Deliberately not an argument: a rejection arriving on one request says the
271
+ * credential is dead for every caller, the run already in flight included —
272
+ * and that run is the one about to answer them all. Passing the doubt in per
273
+ * call let it be dropped the moment two callers shared a run, which is
274
+ * exactly when it matters. Raised by whoever was refused, lowered by whatever
275
+ * establishes the next session.
276
+ */
277
+ let isStoredCookieDistrusted = false;
278
+ /**
279
+ * Says the credential this device holds has just been refused.
280
+ *
281
+ * The caller knows something nobody else does yet, including a session run
282
+ * already under way: the cookie it may be about to accept on sight is the one
283
+ * that was turned down.
284
+ */
285
+ function distrustStoredCookie() {
286
+ isStoredCookieDistrusted = true;
287
+ }
288
+ /**
289
+ * Brings back the session of the account this device holds, and creates an
290
+ * anonymous one only when `create` is set — at the end of onboarding, the one
291
+ * moment a device gets an account. A launch, a sync or a rejected request
292
+ * only ever recovers: a device that never finished onboarding has no
293
+ * account, and nothing on the server to sync with.
294
+ *
295
+ * At launch an unexpired session cookie is enough to consider the device
296
+ * signed in: better-auth is already fetching the session behind it, and a
297
+ * cookie the server no longer honours comes back as a rejected request, which
298
+ * lands in {@link recoverSession}. That reasoning collapses once recovery has
299
+ * spoken — the credential it is called about is this very one — so the
300
+ * shortcut is off both when the doubt was raised before we arrived and when
301
+ * it is raised while we are still inside it. Falling through costs a
302
+ * `/get-session` and answers the question for real.
303
+ */
304
+ async function establishSession(create) {
305
+ if (!isStoredCookieDistrusted && (await hasSessionCookie())) {
306
+ await ensureDeviceToken();
307
+ if (!isStoredCookieDistrusted) {
308
+ return true;
309
+ }
310
+ }
311
+ // Without a cookie there is no session for the server to find.
312
+ if (await hasSessionCookie()) {
313
+ const session = await authClient
314
+ .getSession()
315
+ .catch(() => ({ data: null, error: { status: 0 } }));
316
+ if (session.data) {
317
+ await rememberAccountKind(Boolean(session.data.user.isAnonymous));
318
+ await ensureDeviceToken();
319
+ return true;
320
+ }
321
+ if (session.error) {
322
+ // Network or server failure: the session may still exist, so giving up
323
+ // on it here would leave the account behind. Retry instead.
324
+ throw new TransientAuthError("Could not reach the auth server");
325
+ }
326
+ }
327
+ // Signed out. Recover the account this device holds via its device token.
328
+ const deviceToken = readKeychain(DEVICE_TOKEN_STORAGE_KEY);
329
+ if (deviceToken) {
330
+ const recovered = await signInWithDeviceToken(deviceToken);
331
+ if (recovered) {
332
+ await rememberAccountKind(recovered.isAnonymous);
333
+ await ensureDeviceToken();
334
+ notifySessionChanged();
335
+ return true;
336
+ }
337
+ if (!hadRegisteredAccount()) {
338
+ // The anonymous account is gone and nothing else could bring it back:
339
+ // the device starts over, with onboarding. Signing out drops the dead
340
+ // session from the keychain before the request even goes out.
341
+ await forgetAccount();
342
+ await authClient.signOut().catch(() => undefined);
343
+ await startOverLocally();
344
+ }
345
+ }
346
+ // Nothing left to prove who this device is. Someone who owns an account can
347
+ // prove it themselves, which beats starting them over with nothing.
348
+ if (hadRegisteredAccount()) {
349
+ isSignInRequired$.set(true);
350
+ return false;
351
+ }
352
+ if (!create) {
353
+ return false;
354
+ }
355
+ const created = await authClient.signIn.anonymous();
356
+ if (created.error || !created.data) {
357
+ throw new TransientAuthError("Anonymous sign-in failed");
358
+ }
359
+ await rememberAccountKind(true);
360
+ await ensureDeviceToken();
361
+ if (!(await hasSessionCookie())) {
362
+ throw new TransientAuthError("Session cookie missing after anonymous sign-in");
363
+ }
364
+ notifySessionChanged();
365
+ return true;
366
+ }
367
+ async function establishWithRetries(create, attempts) {
368
+ for (let attempt = 0; attempt < attempts; attempt++) {
369
+ if (attempt > 0) {
370
+ await wait(INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1));
371
+ }
372
+ try {
373
+ return await establishSession(create);
374
+ }
375
+ catch {
376
+ // Transient failure, retry with backoff.
377
+ }
378
+ }
379
+ return false;
380
+ }
381
+ let inFlightSession = null;
382
+ let hasEstablishedSession = false;
383
+ /**
384
+ * There is only ever one run: a second caller — a remount, a sync that found
385
+ * itself signed out — joins the one in flight rather than opening its own,
386
+ * which is what keeps two callers from each creating their own anonymous user
387
+ * and leaving one of them orphaned. A run that may create answers callers
388
+ * that only recover, and one that only recovers is waited for before
389
+ * creating. A successful run is remembered, so later callers cost nothing.
390
+ */
391
+ function runSession(create, attempts) {
392
+ if (hasEstablishedSession) {
393
+ return Promise.resolve(true);
394
+ }
395
+ // Waiting on the user rather than on the network: every caller would
396
+ // otherwise re-run a recovery that already knows how it ends.
397
+ if (isSignInRequired$.get()) {
398
+ return Promise.resolve(false);
399
+ }
400
+ if (inFlightSession) {
401
+ if (inFlightSession.create || !create) {
402
+ return inFlightSession.run;
403
+ }
404
+ return inFlightSession.run.then((established) => established || runSession(true, attempts));
405
+ }
406
+ const run = establishWithRetries(create, attempts).then((established) => {
407
+ hasEstablishedSession = established;
408
+ if (established) {
409
+ // Whatever it took to get here, the keychain now holds a cookie nobody
410
+ // has been refused on.
411
+ isStoredCookieDistrusted = false;
412
+ }
413
+ inFlightSession = null;
414
+ return established;
415
+ });
416
+ inFlightSession = { create, run };
417
+ return run;
418
+ }
419
+ /**
420
+ * Brings back the session of the account this device holds. Resolves false
421
+ * when there is none — a device that has not finished onboarding — or when
422
+ * the server cannot be reached.
423
+ */
424
+ function ensureSession() {
425
+ return runSession(false, MAX_ATTEMPTS);
426
+ }
427
+ /**
428
+ * Gives the device its account, at the end of onboarding: the one it already
429
+ * holds, or a new anonymous one. Resolves false when the server cannot be
430
+ * reached, for the caller to offer a retry.
431
+ */
432
+ function ensureAccount() {
433
+ return runSession(true, ACCOUNT_ATTEMPTS);
434
+ }
435
+ /**
436
+ * Re-establishes the session after the API has rejected our credentials.
437
+ *
438
+ * Distinct from {@link ensureSession} because that one remembers its success
439
+ * for the life of the process — right at launch, useless once the session it
440
+ * established has died. Forgetting that result is the whole difference; the
441
+ * run itself is still shared, since a dead session shows up as a rejection on
442
+ * several requests at once and they must not each start their own sign-in.
443
+ *
444
+ * The doubt is recorded before joining, so a run already in flight hears it
445
+ * too rather than answering from the cookie the caller was just refused on.
446
+ */
447
+ function recoverSession() {
448
+ hasEstablishedSession = false;
449
+ distrustStoredCookie();
450
+ return ensureSession();
451
+ }
452
+ let isInitialized = false;
453
+ /**
454
+ * Brings back the session of the account this device holds, once per launch.
455
+ *
456
+ * A device that already holds a session costs nothing here — better-auth
457
+ * reads it on its own — and only one with an expired session goes on to
458
+ * trade its device token. A device without an account stays without one
459
+ * until onboarding ends ({@link ensureAccount}). The app is rendered before
460
+ * any of this finishes: what the user writes in the meantime is stored
461
+ * locally and synced when the session lands.
462
+ */
463
+ async function initializeAuth() {
464
+ if (isInitialized) {
465
+ return;
466
+ }
467
+ isInitialized = true;
468
+ // Nothing reads them any more.
469
+ for (const key of options.legacyStorageKeys ?? []) {
470
+ void SecureStore.deleteItemAsync(key).catch(() => {
471
+ // Never written on this device.
472
+ });
473
+ }
474
+ await ensureSession();
475
+ }
476
+ /**
477
+ * Whether this device holds no account: no session, no device token and no
478
+ * record of an account it could sign back into — a new install, or a device
479
+ * signed out. Such a user has nothing on the server to download, and gets an
480
+ * account when onboarding ends. Keychain items survive a reinstall on iOS, so
481
+ * a returning user still waits for their data.
482
+ *
483
+ * Synchronous on purpose, so the root layout can ask before the first render.
484
+ */
485
+ function holdsNoAccount() {
486
+ return !(readStoredCookie() ||
487
+ readKeychain(DEVICE_TOKEN_STORAGE_KEY) ||
488
+ readKeychain(LAST_ACCOUNT_KIND_STORAGE_KEY));
489
+ }
490
+ let isGoogleConfigured = false;
491
+ function configureGoogleSignIn() {
492
+ if (isGoogleConfigured) {
493
+ return;
494
+ }
495
+ GoogleSignin.configure({
496
+ webClientId: options.google.webClientId,
497
+ iosClientId: options.google.iosClientId,
498
+ });
499
+ isGoogleConfigured = true;
500
+ }
501
+ /** Resolves null when the user backs out of the native sheet. */
502
+ async function getGoogleIdToken() {
503
+ configureGoogleSignIn();
504
+ const signInResponse = await GoogleSignin.signIn();
505
+ if (signInResponse.type !== "success" || !signInResponse.data.idToken) {
506
+ return null;
507
+ }
508
+ return signInResponse.data.idToken;
509
+ }
510
+ function getAppleToken() {
511
+ return getAppleIdToken({ webSignInUrl: options.apple.webSignInUrl });
512
+ }
513
+ /**
514
+ * The current session cookie, as a header a sign-in request can carry.
515
+ *
516
+ * better-auth's Expo client strips cookies from id-token sign-ins — only
517
+ * `/link-social` keeps them — so Apple and Google reach the server looking
518
+ * like requests from nobody, and the anonymous plugin finds no anonymous
519
+ * account to delete. The email path, which does send its cookie, gets that
520
+ * cleanup for free. The sign-in handler itself never reads the cookie, so
521
+ * passing it only restores the symmetry.
522
+ */
523
+ async function previousSessionHeader() {
524
+ const cookie = await authClient.getCookie();
525
+ return cookie ? { cookie } : {};
526
+ }
527
+ /**
528
+ * The account the device held, read before signing in: the session changes
529
+ * under us as soon as the sign-in answers. `userId` is the user the local
530
+ * data belongs to; a device that predates this record and held a registered
531
+ * account is assumed to sign back into it.
532
+ */
533
+ function previousAccount() {
534
+ return {
535
+ userId: readKeychain(LAST_USER_ID_STORAGE_KEY),
536
+ wasRegistered: hadRegisteredAccount(),
537
+ wasAnonymous: readKeychain(LAST_ACCOUNT_KIND_STORAGE_KEY) === ANONYMOUS_ACCOUNT_KIND,
538
+ };
539
+ }
540
+ /**
541
+ * Signing in replaces the user this device carries: the anonymous one is
542
+ * dropped server-side, so the token that used to bring it back has to be
543
+ * traded for one bound to the account the user just proved they own.
544
+ */
545
+ async function onSignedIn(userId, previous) {
546
+ // The user just proved they own this account, which is exactly what a
547
+ // failed recovery was waiting for, and the sign-in wrote a cookie to go
548
+ // with it.
549
+ hasEstablishedSession = true;
550
+ isStoredCookieDistrusted = false;
551
+ isSignInRequired$.set(false);
552
+ // Rows carry no user id: what the device holds belongs to the account it
553
+ // had, and would otherwise be filed under this one. Signing back into the
554
+ // same account keeps it and syncs; any other account starts from its own.
555
+ const isSameAccount = previous.userId
556
+ ? previous.userId === userId
557
+ : previous.wasRegistered;
558
+ await options.onAccountSignedIn?.({ isSameAccount, userId });
559
+ await rememberAccountKind(false);
560
+ if (userId) {
561
+ await rememberAccountUser(userId);
562
+ }
563
+ const { onAnonymousAccountReplaced } = options;
564
+ // Independent of each other, and each a round trip or more: the sign-in
565
+ // screen waits for both, so they run side by side.
566
+ await Promise.all([
567
+ refreshDeviceToken(),
568
+ userId && previous.wasAnonymous && onAnonymousAccountReplaced
569
+ ? // What an anonymous account paid for would be lost with it: the
570
+ // server deletes it on this sign-in. Any other sign-in leaves
571
+ // subscriptions where they are, or signing out of a Pro account and
572
+ // into another would hand the subscription over. A failure here
573
+ // must not take the sign-in down with it.
574
+ Promise.resolve(onAnonymousAccountReplaced(userId)).catch((error) => {
575
+ console.error("[auth] could not hand the anonymous account over", error);
576
+ reportError(error);
577
+ })
578
+ : undefined,
579
+ ]);
580
+ notifySessionChanged();
581
+ }
582
+ async function signInWithEmail(email, password, entryPoint = "sign_in") {
583
+ return await track("sign_in", { provider: "email", entry_point: entryPoint }, async () => {
584
+ const previous = previousAccount();
585
+ const { data, error } = await authClient.signIn.email({
586
+ email,
587
+ password,
588
+ });
589
+ throwOnAuthError(error);
590
+ await onSignedIn(data?.user.id, previous);
591
+ return true;
592
+ });
593
+ }
594
+ async function signInWithApple(entryPoint = "sign_in") {
595
+ return await track("sign_in", { provider: "apple", entry_point: entryPoint }, async () => {
596
+ const previous = previousAccount();
597
+ const idToken = await getAppleToken();
598
+ if (!idToken) {
599
+ return false;
600
+ }
601
+ const { data, error } = await authClient.signIn.social({
602
+ provider: "apple",
603
+ idToken,
604
+ fetchOptions: {
605
+ headers: {
606
+ ...(await previousSessionHeader()),
607
+ "expo-origin": options.signInOrigin,
608
+ },
609
+ },
610
+ });
611
+ throwOnAuthError(error);
612
+ await onSignedIn(getSignedInUserId(data), previous);
613
+ return true;
614
+ });
615
+ }
616
+ async function signInWithGoogle(entryPoint = "sign_in") {
617
+ return await track("sign_in", { provider: "google", entry_point: entryPoint }, async () => {
618
+ const previous = previousAccount();
619
+ const idToken = await getGoogleIdToken();
620
+ if (!idToken) {
621
+ return false;
622
+ }
623
+ const { data, error } = await authClient.signIn.social({
624
+ provider: "google",
625
+ idToken: { token: idToken },
626
+ fetchOptions: {
627
+ headers: {
628
+ ...(await previousSessionHeader()),
629
+ "expo-origin": options.signInOrigin,
630
+ },
631
+ },
632
+ });
633
+ throwOnAuthError(error);
634
+ await onSignedIn(getSignedInUserId(data), previous);
635
+ return true;
636
+ });
637
+ }
638
+ /**
639
+ * Attaches a provider to the account already in hand instead of signing into
640
+ * another one: the user keeps their id, and with it every workout they logged
641
+ * before they had a way to sign back in.
642
+ */
643
+ async function linkWithApple() {
644
+ return await track("link_account", { provider: "apple", entry_point: "settings" }, async () => {
645
+ const idToken = await getAppleToken();
646
+ if (!idToken) {
647
+ return false;
648
+ }
649
+ const { error } = await authClient.linkSocial({
650
+ provider: "apple",
651
+ idToken,
652
+ });
653
+ throwOnAuthError(error);
654
+ // The account keeps its id but stops being anonymous, so a device token
655
+ // refused later is now worth a sign-in prompt rather than a fresh
656
+ // identity.
657
+ await rememberAccountKind(false);
658
+ await refreshDeviceToken();
659
+ notifySessionChanged();
660
+ return true;
661
+ });
662
+ }
663
+ async function linkWithGoogle() {
664
+ return await track("link_account", { provider: "google", entry_point: "settings" }, async () => {
665
+ const idToken = await getGoogleIdToken();
666
+ if (!idToken) {
667
+ return false;
668
+ }
669
+ const { error } = await authClient.linkSocial({
670
+ provider: "google",
671
+ idToken: { token: idToken },
672
+ });
673
+ throwOnAuthError(error);
674
+ // The account keeps its id but stops being anonymous, so a device token
675
+ // refused later is now worth a sign-in prompt rather than a fresh
676
+ // identity.
677
+ await rememberAccountKind(false);
678
+ await refreshDeviceToken();
679
+ notifySessionChanged();
680
+ return true;
681
+ });
682
+ }
683
+ async function requestPasswordReset(email) {
684
+ options.onPasswordReset?.();
685
+ const { error } = await authClient.requestPasswordReset({
686
+ email,
687
+ redirectTo: options.passwordResetUrl,
688
+ });
689
+ throwOnAuthError(error);
690
+ }
691
+ async function resetPassword(newPassword, token) {
692
+ options.onPasswordReset?.();
693
+ const { error } = await authClient.resetPassword({ newPassword, token });
694
+ throwOnAuthError(error);
695
+ }
696
+ /**
697
+ * Leaves the account behind: the device holds no account any more, as on a
698
+ * new install, and onboarding gives it one again.
699
+ *
700
+ * The local data is emptied here rather than by the screen that asked for
701
+ * it: the app stays mounted across a sign-out, so nothing else would separate
702
+ * the data of the account being left from the next one.
703
+ */
704
+ async function signOut() {
705
+ options.onSignOut?.();
706
+ await forgetAccount();
707
+ isSignInRequired$.set(false);
708
+ // Signing out empties better-auth's cached session as well as the cookie,
709
+ // the latter before the request goes out, so even offline.
710
+ await Promise.allSettled([authClient.signOut(), GoogleSignin.signOut()]);
711
+ hasEstablishedSession = false;
712
+ await startOverLocally();
713
+ }
714
+ return {
715
+ ensureAccount,
716
+ ensureSession,
717
+ hasSessionCookie,
718
+ holdsNoAccount,
719
+ initializeAuth,
720
+ isSignInRequired$,
721
+ linkWithApple,
722
+ linkWithGoogle,
723
+ recoverSession,
724
+ rememberAccountKind,
725
+ rememberAccountUser,
726
+ requestPasswordReset,
727
+ resetPassword,
728
+ signInWithApple,
729
+ signInWithEmail,
730
+ signInWithGoogle,
731
+ signOut,
732
+ useAuthSession,
733
+ };
734
+ }
735
+ //# sourceMappingURL=session.js.map