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