@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/apple-id-token.android.d.ts +7 -0
- package/dist/apple-id-token.android.d.ts.map +1 -0
- package/dist/apple-id-token.android.js +39 -0
- package/dist/apple-id-token.android.js.map +1 -0
- package/dist/apple-id-token.d.ts +12 -0
- package/dist/apple-id-token.ios.d.ts +7 -0
- package/dist/apple-id-token.ios.d.ts.map +1 -0
- package/dist/apple-id-token.ios.js +25 -0
- package/dist/apple-id-token.ios.js.map +1 -0
- package/dist/apple-web-sign-in.d.ts +8 -0
- package/dist/apple-web-sign-in.d.ts.map +1 -0
- package/dist/apple-web-sign-in.js +8 -0
- package/dist/apple-web-sign-in.js.map +1 -0
- package/dist/client.d.ts +130 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +48 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/session.d.ts +132 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +735 -0
- package/dist/session.js.map +1 -0
- package/package.json +45 -1
- package/src/apple-id-token.android.ts +58 -0
- package/src/apple-id-token.d.ts +12 -0
- package/src/apple-id-token.ios.ts +27 -0
- package/src/apple-web-sign-in.ts +7 -0
- package/src/client.ts +71 -0
- package/src/index.ts +18 -0
- package/src/session.ts +1006 -0
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>;
|