@motherbase/client 1.5.1 → 2.0.2
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 +399 -379
- package/dist/auth.d.ts +147 -424
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +287 -827
- package/dist/auth.js.map +1 -1
- package/dist/cookie.d.ts.map +1 -1
- package/dist/cookie.js +11 -1
- package/dist/cookie.js.map +1 -1
- package/dist/database.d.ts +2 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/devices.d.ts +13 -0
- package/dist/devices.d.ts.map +1 -0
- package/dist/devices.js +13 -0
- package/dist/devices.js.map +1 -0
- package/dist/http.d.ts +7 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +27 -24
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +12 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -12
- package/dist/index.js.map +1 -1
- package/dist/mfa.d.ts +4 -4
- package/dist/mfa.d.ts.map +1 -1
- package/dist/mfa.js +18 -7
- package/dist/mfa.js.map +1 -1
- package/dist/pkce.d.ts.map +1 -1
- package/dist/pkce.js +27 -4
- package/dist/pkce.js.map +1 -1
- package/dist/postgrest.d.ts +7 -0
- package/dist/postgrest.d.ts.map +1 -1
- package/dist/postgrest.js +11 -1
- package/dist/postgrest.js.map +1 -1
- package/dist/profile.d.ts +16 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +26 -0
- package/dist/profile.js.map +1 -0
- package/dist/realtime.d.ts +3 -0
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +20 -3
- package/dist/realtime.js.map +1 -1
- package/dist/session.d.ts +2 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +26 -10
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +76 -62
package/dist/auth.js
CHANGED
|
@@ -1,57 +1,54 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { OrganizationClient } from './organization.js';
|
|
7
|
-
import { PasskeyClient, authenticateWithPasskey } from './passkey.js';
|
|
8
|
-
import { SsoClient } from './sso.js';
|
|
1
|
+
import { createAuthClient as createBetterAuthClient } from 'better-auth/client';
|
|
2
|
+
import { anonymousClient, emailOTPClient, genericOAuthClient, jwtClient, magicLinkClient, organizationClient, phoneNumberClient, twoFactorClient, } from 'better-auth/client/plugins';
|
|
3
|
+
import { passkeyClient } from '@better-auth/passkey/client';
|
|
4
|
+
import { ssoClient } from '@better-auth/sso/client';
|
|
5
|
+
import { apiKeyClient } from '@better-auth/api-key/client';
|
|
9
6
|
import { HTTP_ROUTES } from '@motherbase/contracts';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
import { AuthAdminClient } from './admin.js';
|
|
8
|
+
import { normalizeUser } from './compat.js';
|
|
9
|
+
import { codeChallengeFor, generateCodeVerifier, withCodeChallenge } from './pkce.js';
|
|
10
|
+
const EVENT_MAP = {
|
|
11
|
+
signIn: 'SIGNED_IN',
|
|
12
|
+
signOut: 'SIGNED_OUT',
|
|
13
|
+
tokenRefreshed: 'TOKEN_REFRESHED',
|
|
14
|
+
userUpdated: 'USER_UPDATED',
|
|
15
|
+
mfaVerified: 'MFA_CHALLENGE_VERIFIED',
|
|
16
|
+
};
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Endpoints whose next step needs a cookie set on the response: the OAuth
|
|
19
|
+
* state, the WebAuthn challenge, or the pending second factor a password
|
|
20
|
+
* sign-in leaves for `twoFactor.verify*`. Cross-origin, fetch only keeps and
|
|
21
|
+
* resends a cookie with `credentials: 'include'`. Everything else stays
|
|
22
|
+
* `same-origin`, so a backend that does not allow credentialed CORS still
|
|
23
|
+
* serves it.
|
|
21
24
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return options?.captchaToken ? { 'x-captcha-response': options.captchaToken } : undefined;
|
|
43
|
-
}
|
|
44
|
-
/** Build a clean Session from the Better Auth auth payload. */
|
|
45
|
-
function toSession(payload) {
|
|
46
|
-
return { accessToken: payload.token, user: payload.user };
|
|
47
|
-
}
|
|
25
|
+
const COOKIE_PATHS = [
|
|
26
|
+
'/sign-in/email',
|
|
27
|
+
'/sign-in/phone-number',
|
|
28
|
+
'/two-factor/',
|
|
29
|
+
'/sign-in/social',
|
|
30
|
+
'/sign-in/sso',
|
|
31
|
+
'/sign-in/oauth2',
|
|
32
|
+
'/link-social',
|
|
33
|
+
'/oauth2/link',
|
|
34
|
+
'/passkey/',
|
|
35
|
+
];
|
|
36
|
+
/** Sign-ins that come back through the server's OAuth callback, where the PKCE challenge rides. */
|
|
37
|
+
const REDIRECT_SIGN_IN_PATHS = ['/sign-in/social', '/sign-in/sso', '/sign-in/oauth2'];
|
|
38
|
+
/** Calls after which the session this client holds no longer exists. */
|
|
39
|
+
const SIGN_OUT_PATHS = new Set(['/sign-out', '/revoke-sessions']);
|
|
40
|
+
/**
|
|
41
|
+
* Calls that change the user but answer without it (`{ status: true }`): the
|
|
42
|
+
* session is read again after them, so the stored user and USER_UPDATED follow.
|
|
43
|
+
*/
|
|
44
|
+
const USER_CHANGE_PATHS = new Set(['/update-user', '/change-email', '/update-user-credentials', '/two-factor/disable']);
|
|
48
45
|
/**
|
|
49
46
|
* Read the token an OAuth callback left in the URL fragment and return the URL
|
|
50
47
|
* with it removed, so a reload or a shared link cannot replay it.
|
|
51
48
|
*
|
|
52
49
|
* The provider callback cannot answer the SDK with a JSON body: it is a browser
|
|
53
|
-
* redirect. The
|
|
54
|
-
* issues
|
|
50
|
+
* redirect. The server therefore appends `#access_token=…` to the redirect it
|
|
51
|
+
* issues (auth/oauth-redirect.ts). Exported for testing.
|
|
55
52
|
*/
|
|
56
53
|
export function readAccessTokenFromUrl(href) {
|
|
57
54
|
const hash = href.indexOf('#');
|
|
@@ -66,796 +63,259 @@ export function readAccessTokenFromUrl(href) {
|
|
|
66
63
|
const rest = params.toString();
|
|
67
64
|
return { accessToken, cleanedHref: `${href.slice(0, hash)}${rest ? `#${rest}` : ''}` };
|
|
68
65
|
}
|
|
69
|
-
const EVENT_MAP = {
|
|
70
|
-
signIn: 'SIGNED_IN',
|
|
71
|
-
signOut: 'SIGNED_OUT',
|
|
72
|
-
tokenRefreshed: 'TOKEN_REFRESHED',
|
|
73
|
-
userUpdated: 'USER_UPDATED',
|
|
74
|
-
mfaVerified: 'MFA_CHALLENGE_VERIFIED',
|
|
75
|
-
};
|
|
76
66
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
67
|
+
* The session a successful auth response carries, if any.
|
|
68
|
+
*
|
|
69
|
+
* Better Auth answers a sign-in with `{ token, user }` (password, OTP, anonymous,
|
|
70
|
+
* id token, second factor) or `{ session: { token }, user }` (passkey,
|
|
71
|
+
* get-session). A `user` is required alongside: the jwt plugin's `/token`
|
|
72
|
+
* answers `{ token }` with a JWT, which is not a session.
|
|
80
73
|
*/
|
|
81
|
-
export
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
headers: captchaHeaders(credentials.options),
|
|
149
|
-
anonymous: true,
|
|
150
|
-
raw: true,
|
|
151
|
-
});
|
|
152
|
-
await this.persist(payload);
|
|
153
|
-
const data = credentials.options?.data;
|
|
154
|
-
if (data && Object.keys(data).length > 0) {
|
|
155
|
-
// Better Auth creates the guest with no profile, so the metadata is
|
|
156
|
-
// written straight after, as the guest itself.
|
|
157
|
-
const updated = await this.updateUser({ data });
|
|
158
|
-
if (updated.error)
|
|
159
|
-
return { data: { user: null, session: null }, error: updated.error };
|
|
160
|
-
}
|
|
161
|
-
const session = this.store.get();
|
|
162
|
-
return { data: { user: session?.user ?? null, session }, error: null };
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
return { data: { user: null, session: null }, error: toError(err) };
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Email the signed-in user a one-time nonce, then pass it to
|
|
170
|
-
* `updateUser({ password, nonce })`. GoTrue's "secure password change".
|
|
171
|
-
*/
|
|
172
|
-
async reauthenticate() {
|
|
173
|
-
try {
|
|
174
|
-
await this.http.request(HTTP_ROUTES.authReauthenticate, { method: 'POST', body: {}, raw: true });
|
|
175
|
-
return { data: { user: null, session: null }, error: null };
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
return { data: { user: null, session: null }, error: toError(err) };
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Request a password-reset OTP by email. Always resolves successfully
|
|
183
|
-
* (whether or not the email is registered) to avoid leaking account
|
|
184
|
-
* existence; check `error` only for transport/server failures.
|
|
185
|
-
*/
|
|
186
|
-
async resetPasswordForEmail(email, options) {
|
|
187
|
-
try {
|
|
188
|
-
await this.http.request(HTTP_ROUTES.authRequestPasswordReset, {
|
|
189
|
-
method: 'POST',
|
|
190
|
-
body: { email },
|
|
191
|
-
headers: captchaHeaders(options),
|
|
192
|
-
anonymous: true,
|
|
193
|
-
raw: true,
|
|
194
|
-
});
|
|
195
|
-
return { error: null };
|
|
196
|
-
}
|
|
197
|
-
catch (err) {
|
|
198
|
-
return { error: toError(err) };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
/** Complete a password reset using the OTP sent to the user's email. */
|
|
202
|
-
async resetPassword(args) {
|
|
203
|
-
try {
|
|
204
|
-
await this.http.request(HTTP_ROUTES.authResetPassword, {
|
|
205
|
-
method: 'POST',
|
|
206
|
-
body: { email: args.email, otp: args.otp, password: args.password },
|
|
207
|
-
anonymous: true,
|
|
208
|
-
raw: true,
|
|
209
|
-
});
|
|
210
|
-
return { error: null };
|
|
211
|
-
}
|
|
212
|
-
catch (err) {
|
|
213
|
-
return { error: toError(err) };
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Send a one-time sign-in code, by email or by SMS. Pair with `verifyOtp` to
|
|
218
|
-
* complete the sign-in.
|
|
219
|
-
*
|
|
220
|
-
* `{ phone }` routes to the phone-number plugin, which the project has to
|
|
221
|
-
* have enabled with an SMS sender configured; without one the call resolves
|
|
222
|
-
* with the backend's error rather than pretending a code was sent.
|
|
223
|
-
*/
|
|
224
|
-
async signInWithOtp(credentials) {
|
|
225
|
-
if (!credentials.email && !credentials.phone) {
|
|
226
|
-
return { data: {}, error: { message: 'signInWithOtp needs either `email` or `phone`.' } };
|
|
227
|
-
}
|
|
228
|
-
try {
|
|
229
|
-
if (credentials.phone) {
|
|
230
|
-
await this.http.request(HTTP_ROUTES.authSendPhoneOtp, {
|
|
231
|
-
method: 'POST',
|
|
232
|
-
body: { phoneNumber: credentials.phone },
|
|
233
|
-
headers: captchaHeaders(credentials.options),
|
|
234
|
-
anonymous: true,
|
|
235
|
-
raw: true,
|
|
236
|
-
});
|
|
237
|
-
return { data: {}, error: null };
|
|
238
|
-
}
|
|
239
|
-
await this.http.request(HTTP_ROUTES.authSendOtp, {
|
|
240
|
-
method: 'POST',
|
|
241
|
-
body: { email: credentials.email, type: 'sign-in' },
|
|
242
|
-
headers: captchaHeaders(credentials.options),
|
|
243
|
-
anonymous: true,
|
|
244
|
-
raw: true,
|
|
245
|
-
});
|
|
246
|
-
return { data: {}, error: null };
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
return { data: {}, error: toError(err) };
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Send the one-time code again, supabase-js style.
|
|
254
|
-
*
|
|
255
|
-
* supabase-js names the flows `signup` (confirm a new address), `email_change`
|
|
256
|
-
* and `recovery`; Better Auth names them `email-verification` and
|
|
257
|
-
* `forget-password`. The mapping lives here so ported code keeps working.
|
|
258
|
-
* `sms` and `phone_change` re-send an SMS code through the phone-number flow.
|
|
259
|
-
*/
|
|
260
|
-
async resend(params) {
|
|
261
|
-
if (params.type === 'sms' || params.type === 'phone_change') {
|
|
262
|
-
if (!params.phone)
|
|
263
|
-
return { data: {}, error: { message: '`phone` is required.' } };
|
|
264
|
-
return this.signInWithOtp({ phone: params.phone });
|
|
265
|
-
}
|
|
266
|
-
const OTP_TYPE = {
|
|
267
|
-
signup: 'email-verification',
|
|
268
|
-
email_change: 'email-verification',
|
|
269
|
-
recovery: 'forget-password',
|
|
270
|
-
};
|
|
271
|
-
const type = OTP_TYPE[params.type];
|
|
272
|
-
if (!type) {
|
|
273
|
-
return { data: {}, error: { message: `resend({ type: '${params.type}' }) is not supported.` } };
|
|
274
|
-
}
|
|
275
|
-
if (!params.email) {
|
|
276
|
-
return { data: {}, error: { message: '`email` is required.' } };
|
|
277
|
-
}
|
|
278
|
-
try {
|
|
279
|
-
await this.http.request(HTTP_ROUTES.authSendOtp, {
|
|
280
|
-
method: 'POST',
|
|
281
|
-
body: { email: params.email, type },
|
|
282
|
-
anonymous: true,
|
|
283
|
-
raw: true,
|
|
284
|
-
});
|
|
285
|
-
return { data: {}, error: null };
|
|
286
|
-
}
|
|
287
|
-
catch (err) {
|
|
288
|
-
return { data: {}, error: toError(err) };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Complete a `signInWithOtp` sign-in with the code that was sent.
|
|
293
|
-
*
|
|
294
|
-
* The channel follows the identifier, as in supabase-js: `{ phone }` verifies
|
|
295
|
-
* an SMS code, `{ email }` an email one. `type` is accepted for signature
|
|
296
|
-
* parity and only disambiguates when both are somehow present.
|
|
297
|
-
*/
|
|
298
|
-
async verifyOtp(params) {
|
|
299
|
-
if (!params.email && !params.phone) {
|
|
300
|
-
return { data: { user: null, session: null }, error: { message: 'verifyOtp needs either `email` or `phone`.' } };
|
|
301
|
-
}
|
|
302
|
-
try {
|
|
303
|
-
const bySms = Boolean(params.phone) && params.type !== 'email';
|
|
304
|
-
const payload = await this.http.request(bySms ? HTTP_ROUTES.authVerifyPhoneOtp : HTTP_ROUTES.authSignInEmailOtp, {
|
|
305
|
-
method: 'POST',
|
|
306
|
-
body: bySms
|
|
307
|
-
? { phoneNumber: params.phone, code: params.token }
|
|
308
|
-
: { email: params.email, otp: params.token },
|
|
309
|
-
anonymous: true,
|
|
310
|
-
raw: true,
|
|
311
|
-
});
|
|
312
|
-
if (!payload?.token) {
|
|
313
|
-
return { data: { user: null, session: null }, error: { message: 'The code did not produce a session.' } };
|
|
314
|
-
}
|
|
315
|
-
const session = await this.persist(payload);
|
|
316
|
-
return { data: { user: session.user, session }, error: null };
|
|
317
|
-
}
|
|
318
|
-
catch (err) {
|
|
319
|
-
return { data: { user: null, session: null }, error: toError(err) };
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Start an OAuth sign-in with a configured social provider. Resolves with the
|
|
324
|
-
* provider's authorize URL and, in a browser, redirects to it immediately
|
|
325
|
-
* unless `options.skipBrowserRedirect` is set.
|
|
326
|
-
*/
|
|
327
|
-
async signInWithOAuth(credentials) {
|
|
328
|
-
const redirectTo = await this.redirectForFlow(credentials.options?.redirectTo);
|
|
329
|
-
if (redirectTo instanceof Error)
|
|
330
|
-
return { data: { provider: credentials.provider, url: null }, error: { message: redirectTo.message } };
|
|
331
|
-
credentials = { ...credentials, options: { ...credentials.options, redirectTo } };
|
|
332
|
-
// In a browser, hand the whole handshake to a top-level navigation. Starting
|
|
333
|
-
// it with a fetch makes the OAuth state cookie a cross-site write, which
|
|
334
|
-
// Safari and Firefox drop outright and Chrome drops in incognito, and the
|
|
335
|
-
// callback then fails with state_mismatch. Navigating to the backend's own
|
|
336
|
-
// origin makes that cookie first-party, so a plain SameSite=Lax cookie
|
|
337
|
-
// survives everywhere. The backend redirects on from there.
|
|
338
|
-
if (typeof window !== 'undefined' && !credentials.options?.skipBrowserRedirect) {
|
|
339
|
-
const params = new URLSearchParams();
|
|
340
|
-
if (credentials.options?.redirectTo)
|
|
341
|
-
params.set('callbackURL', credentials.options.redirectTo);
|
|
342
|
-
const query = params.toString();
|
|
343
|
-
const url = this.http.url(`${HTTP_ROUTES.authSignInSocial}/${encodeURIComponent(credentials.provider)}${query ? `?${query}` : ''}`);
|
|
344
|
-
window.location.href = url;
|
|
345
|
-
return { data: { provider: credentials.provider, url }, error: null };
|
|
346
|
-
}
|
|
347
|
-
try {
|
|
348
|
-
const payload = await this.http.request(HTTP_ROUTES.authSignInSocial, {
|
|
349
|
-
method: 'POST',
|
|
350
|
-
body: {
|
|
351
|
-
provider: credentials.provider,
|
|
352
|
-
callbackURL: credentials.options?.redirectTo,
|
|
353
|
-
scopes: credentials.options?.scopes ? credentials.options.scopes.split(' ') : undefined,
|
|
354
|
-
disableRedirect: true,
|
|
355
|
-
},
|
|
356
|
-
anonymous: true,
|
|
357
|
-
raw: true,
|
|
358
|
-
// The server answers with a signed OAuth state cookie it checks again on
|
|
359
|
-
// the provider callback. Without 'include' the browser drops that cookie
|
|
360
|
-
// on a cross-origin call and every callback fails with state_mismatch.
|
|
361
|
-
credentials: 'include',
|
|
362
|
-
});
|
|
363
|
-
const url = payload?.url ?? null;
|
|
364
|
-
if (url && !credentials.options?.skipBrowserRedirect && typeof window !== 'undefined') {
|
|
365
|
-
window.location.href = url;
|
|
366
|
-
}
|
|
367
|
-
return { data: { provider: credentials.provider, url }, error: null };
|
|
368
|
-
}
|
|
369
|
-
catch (err) {
|
|
370
|
-
return { data: { provider: credentials.provider, url: null }, error: toError(err) };
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* Sign in with a passkey saved on this device or a synced one. Opens the
|
|
375
|
-
* browser's passkey prompt; dismissing it resolves with an error, not a throw.
|
|
376
|
-
* Needs Auth · Passkey on and a browser (see `auth.passkey`).
|
|
377
|
-
*/
|
|
378
|
-
async signInWithPasskey() {
|
|
379
|
-
const result = await authenticateWithPasskey(this.http);
|
|
380
|
-
if (result.error)
|
|
381
|
-
return { data: { user: null, session: null }, error: result.error };
|
|
382
|
-
const session = await this.persist(result.data);
|
|
383
|
-
return { data: { user: session.user, session }, error: null };
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Start an enterprise SSO sign-in. Identify the provider by the user's email
|
|
387
|
-
* domain, by an explicit `domain`, or by `providerId` when you already know
|
|
388
|
-
* which one to use.
|
|
389
|
-
*
|
|
390
|
-
* Resolves with the identity provider's URL and, in a browser, redirects to it
|
|
391
|
-
* unless `options.skipBrowserRedirect` is set. The project must have the SSO
|
|
392
|
-
* plugin enabled and at least one provider registered (Studio →
|
|
393
|
-
* Authentication → Organizations & SSO).
|
|
394
|
-
*/
|
|
395
|
-
async signInWithSSO(params) {
|
|
396
|
-
if (!params.domain && !params.email && !params.providerId && !params.organizationSlug) {
|
|
397
|
-
return { data: { url: null }, error: { message: 'signInWithSSO needs a domain, email, providerId or organizationSlug' } };
|
|
398
|
-
}
|
|
399
|
-
const redirectTo = await this.redirectForFlow(params.options?.redirectTo);
|
|
400
|
-
if (redirectTo instanceof Error)
|
|
401
|
-
return { data: { url: null }, error: { message: redirectTo.message } };
|
|
402
|
-
params = { ...params, options: { ...params.options, redirectTo } };
|
|
403
|
-
try {
|
|
404
|
-
const payload = await this.http.request('/api/auth/sign-in/sso', {
|
|
405
|
-
method: 'POST',
|
|
406
|
-
body: {
|
|
407
|
-
...(params.domain ? { domain: params.domain } : {}),
|
|
408
|
-
...(params.email ? { email: params.email } : {}),
|
|
409
|
-
...(params.providerId ? { providerId: params.providerId } : {}),
|
|
410
|
-
...(params.organizationSlug ? { organizationSlug: params.organizationSlug } : {}),
|
|
411
|
-
callbackURL: params.options?.redirectTo,
|
|
412
|
-
},
|
|
413
|
-
anonymous: true,
|
|
414
|
-
raw: true,
|
|
415
|
-
credentials: 'include', // Same OAuth state cookie as signInWithOAuth.
|
|
416
|
-
});
|
|
417
|
-
const url = payload?.url ?? null;
|
|
418
|
-
if (url && !params.options?.skipBrowserRedirect && typeof window !== 'undefined') {
|
|
419
|
-
window.location.href = url;
|
|
420
|
-
}
|
|
421
|
-
return { data: { url }, error: null };
|
|
422
|
-
}
|
|
423
|
-
catch (err) {
|
|
424
|
-
return { data: { url: null }, error: toError(err) };
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Update the signed-in user's profile fields and/or password. Password
|
|
429
|
-
* changes require `currentPassword` (a Better Auth requirement. There is no
|
|
430
|
-
* blind password overwrite from an authenticated session).
|
|
431
|
-
*/
|
|
432
|
-
async updateUser(attributes) {
|
|
433
|
-
try {
|
|
434
|
-
if (attributes.data && Object.keys(attributes.data).length > 0) {
|
|
435
|
-
await this.http.request(HTTP_ROUTES.authUpdateUser, { method: 'POST', body: attributes.data, raw: true });
|
|
436
|
-
}
|
|
437
|
-
if (attributes.password && attributes.currentPassword) {
|
|
438
|
-
await this.http.request(HTTP_ROUTES.authChangePassword, {
|
|
439
|
-
method: 'POST',
|
|
440
|
-
body: { newPassword: attributes.password, currentPassword: attributes.currentPassword },
|
|
441
|
-
raw: true,
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
else if (attributes.email || attributes.password) {
|
|
445
|
-
// Email (a guest's upgrade) and a password set by nonce, or a first
|
|
446
|
-
// password, go through GoTrue's `PUT /user` equivalent.
|
|
447
|
-
await this.http.request(HTTP_ROUTES.authUpdateUserCredentials, {
|
|
448
|
-
method: 'POST',
|
|
449
|
-
body: {
|
|
450
|
-
...(attributes.email ? { email: attributes.email } : {}),
|
|
451
|
-
...(attributes.password ? { password: attributes.password } : {}),
|
|
452
|
-
...(attributes.nonce ? { nonce: attributes.nonce } : {}),
|
|
453
|
-
},
|
|
454
|
-
raw: true,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
const result = await this.getUser();
|
|
458
|
-
// supabase-js emits USER_UPDATED here, and apps re-read the profile off
|
|
459
|
-
// that event rather than off the return value. Refresh the stored session
|
|
460
|
-
// so the user the listener receives is the updated one.
|
|
461
|
-
const current = this.store.get();
|
|
462
|
-
if (result.data?.user && current) {
|
|
463
|
-
await this.store.set({ ...current, user: result.data.user }, 'userUpdated');
|
|
74
|
+
export function sessionFromResponse(data) {
|
|
75
|
+
if (!data || typeof data !== 'object')
|
|
76
|
+
return null;
|
|
77
|
+
const body = data;
|
|
78
|
+
if (!body.user)
|
|
79
|
+
return null;
|
|
80
|
+
const token = typeof body.token === 'string' ? body.token : body.session?.token;
|
|
81
|
+
return typeof token === 'string' ? { token, user: body.user } : null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether two tokens name the same session. The bearer the server hands out
|
|
85
|
+
* (`set-auth-token`, the OAuth fragment) is the signed cookie value,
|
|
86
|
+
* `<token>.<signature>`, while response bodies carry the bare `<token>`; the
|
|
87
|
+
* bearer plugin accepts either.
|
|
88
|
+
*/
|
|
89
|
+
export function sameSession(stored, token) {
|
|
90
|
+
if (!stored || !token)
|
|
91
|
+
return false;
|
|
92
|
+
return stored === token || stored.startsWith(`${token}.`) || token.startsWith(`${stored}.`);
|
|
93
|
+
}
|
|
94
|
+
/** The auth path of a request: `/api/auth/sign-in/email` → `/sign-in/email`. */
|
|
95
|
+
function authPath(url) {
|
|
96
|
+
const pathname = typeof url === 'string' && !/^https?:/.test(url) ? url.split('?')[0] : new URL(url).pathname;
|
|
97
|
+
return pathname.startsWith(HTTP_ROUTES.auth) ? pathname.slice(HTTP_ROUTES.auth.length) : pathname;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The proxy hands out the function it finds detached from its object, so a
|
|
101
|
+
* class instance exposed as an action would lose `this`. Copy its methods out
|
|
102
|
+
* bound.
|
|
103
|
+
*/
|
|
104
|
+
function bindMethods(target) {
|
|
105
|
+
const bound = {};
|
|
106
|
+
for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(target))) {
|
|
107
|
+
const value = target[key];
|
|
108
|
+
if (key !== 'constructor' && typeof value === 'function')
|
|
109
|
+
bound[key] = value.bind(target);
|
|
110
|
+
}
|
|
111
|
+
return bound;
|
|
112
|
+
}
|
|
113
|
+
function adminActions(http) {
|
|
114
|
+
const admin = new AuthAdminClient(http);
|
|
115
|
+
return { ...bindMethods(admin), mfa: bindMethods(admin.mfa) };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Keeps the SDK's session store and Better Auth's calls in step: the bearer
|
|
119
|
+
* goes out on every request, and every response that creates, replaces or ends
|
|
120
|
+
* the session is written back, which is what fires `onAuthStateChange`.
|
|
121
|
+
*/
|
|
122
|
+
function sessionSync(store, options,
|
|
123
|
+
/** Reads the session again; its response comes back through this plugin. */
|
|
124
|
+
revalidate) {
|
|
125
|
+
const { flowType, verifiers } = options;
|
|
126
|
+
return {
|
|
127
|
+
id: 'motherbase-session',
|
|
128
|
+
name: 'Motherbase session',
|
|
129
|
+
// Edits the call's options in place rather than returning a copy: better-fetch
|
|
130
|
+
// runs its own schema plugin after this one, and it hands back the options
|
|
131
|
+
// object it was given. The object is built fresh for every call.
|
|
132
|
+
async init(url, fetchOptions = {}) {
|
|
133
|
+
const path = authPath(url);
|
|
134
|
+
fetchOptions.credentials = COOKIE_PATHS.some(prefix => path.startsWith(prefix)) ? 'include' : 'same-origin';
|
|
135
|
+
// Node does not send Origin, and Better Auth's CSRF check wants one on a
|
|
136
|
+
// mutation. Supply the project's own rather than turning the check off.
|
|
137
|
+
const headers = (fetchOptions.headers ?? {});
|
|
138
|
+
if (typeof window === 'undefined' && (fetchOptions.method ?? 'GET').toUpperCase() !== 'GET'
|
|
139
|
+
&& !Object.keys(headers).some(name => name.toLowerCase() === 'origin')) {
|
|
140
|
+
fetchOptions.headers = { ...headers, origin: new URL(options.url).origin };
|
|
464
141
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Permanently delete the signed-in user's account. Not part of the
|
|
473
|
-
* supabase-js client surface (there it's admin-only). Kept here because
|
|
474
|
-
* self-service account deletion is a real product need. Requires the
|
|
475
|
-
* backend's `delete-user` flow to be enabled; clears the local session on
|
|
476
|
-
* success.
|
|
477
|
-
*/
|
|
478
|
-
async deleteUser(attributes = {}) {
|
|
479
|
-
try {
|
|
480
|
-
await this.http.request(HTTP_ROUTES.authDeleteUser, {
|
|
481
|
-
method: 'POST',
|
|
482
|
-
body: attributes,
|
|
483
|
-
raw: true,
|
|
484
|
-
});
|
|
485
|
-
await this.store.set(null, 'signOut');
|
|
486
|
-
return { error: null };
|
|
487
|
-
}
|
|
488
|
-
catch (err) {
|
|
489
|
-
return { error: toError(err) };
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* Adopt an externally-obtained access token as the current session (e.g. one
|
|
494
|
-
* captured from an OAuth redirect). Validates it against the server before
|
|
495
|
-
* persisting. `refresh_token` is accepted for supabase-js signature parity
|
|
496
|
-
* and unused: Better Auth bearer sessions have none.
|
|
497
|
-
*/
|
|
498
|
-
async setSession(currentSession) {
|
|
499
|
-
await this.store.set({ accessToken: currentSession.access_token, user: { id: '', email: '' } }, 'signIn');
|
|
500
|
-
try {
|
|
501
|
-
const payload = await this.http.request(HTTP_ROUTES.authSession, { raw: true });
|
|
502
|
-
if (!payload?.user) {
|
|
503
|
-
await this.store.set(null, 'signOut');
|
|
504
|
-
return { data: { user: null, session: null }, error: { message: 'Invalid session' } };
|
|
142
|
+
const body = (fetchOptions.body ?? {});
|
|
143
|
+
// An id-token sign-in answers with the session directly: no redirect.
|
|
144
|
+
if (!REDIRECT_SIGN_IN_PATHS.includes(path) || body.idToken)
|
|
145
|
+
return { url, options: fetchOptions };
|
|
146
|
+
if (flowType === 'pkce') {
|
|
147
|
+
fetchOptions.body = { ...body, callbackURL: await pkceCallback(body.callbackURL, verifiers) };
|
|
505
148
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
await this.http.request(HTTP_ROUTES.authSignOut, { method: 'POST', raw: true });
|
|
518
|
-
}
|
|
519
|
-
catch {
|
|
520
|
-
// best-effort; clear locally regardless
|
|
521
|
-
}
|
|
522
|
-
await this.store.set(null, 'signOut');
|
|
523
|
-
return { error: null };
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* Finish an OAuth / SSO sign-in after the provider redirected back.
|
|
527
|
-
*
|
|
528
|
-
* `signInWithOAuth` only starts the flow; the session is created during the
|
|
529
|
-
* redirect the browser makes to the backend callback, so the SDK has to pick
|
|
530
|
-
* it up from the URL the browser landed on. Call this once on the page you
|
|
531
|
-
* pointed `redirectTo` at (or on app start, which is where supabase-js does
|
|
532
|
-
* the equivalent). Without it the user is signed in on the backend and signed
|
|
533
|
-
* out in your app.
|
|
534
|
-
*
|
|
535
|
-
* Resolves with a null session and no error when there is nothing to read, so
|
|
536
|
-
* it is safe to call on every page load.
|
|
537
|
-
*/
|
|
538
|
-
async getSessionFromUrl(href) {
|
|
539
|
-
const url = href ?? (typeof window !== 'undefined' ? window.location.href : undefined);
|
|
540
|
-
const found = url ? readAccessTokenFromUrl(url) : null;
|
|
541
|
-
if (!found)
|
|
542
|
-
return { data: { user: null, session: null }, error: null };
|
|
543
|
-
// Strip the token before the network call: a failed lookup must not leave a
|
|
544
|
-
// usable token sitting in the address bar or in the back/forward history.
|
|
545
|
-
if (!href && typeof window !== 'undefined') {
|
|
546
|
-
window.history?.replaceState?.(window.history.state, '', found.cleanedHref);
|
|
547
|
-
}
|
|
548
|
-
try {
|
|
549
|
-
// Resolve the user with the token explicitly rather than storing it first:
|
|
550
|
-
// a rejected token must not emit a SIGNED_IN event carrying a null user.
|
|
551
|
-
const payload = await this.http.request(HTTP_ROUTES.authSession, {
|
|
552
|
-
raw: true,
|
|
553
|
-
anonymous: true,
|
|
554
|
-
headers: { Authorization: `Bearer ${found.accessToken}` },
|
|
555
|
-
});
|
|
556
|
-
if (!payload?.user) {
|
|
557
|
-
return { data: { user: null, session: null }, error: { message: 'OAuth session is no longer valid' } };
|
|
149
|
+
if (path === '/sign-in/social' && typeof window !== 'undefined' && !body.disableRedirect) {
|
|
150
|
+
// Started with a fetch, the OAuth state cookie is a cross-site write
|
|
151
|
+
// that Safari and Firefox drop (Chrome too, in incognito), and the
|
|
152
|
+
// callback fails with state_mismatch. Navigating to the server's GET
|
|
153
|
+
// start route makes it first-party. The request is answered locally
|
|
154
|
+
// with Better Auth's own shape, `redirect: false` so it does not
|
|
155
|
+
// navigate a second time.
|
|
156
|
+
const href = socialStartUrl(options.url, fetchOptions.body);
|
|
157
|
+
window.location.href = href;
|
|
158
|
+
fetchOptions.customFetchImpl = async () => Response.json({ url: href, redirect: false });
|
|
558
159
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
catch (err) {
|
|
614
|
-
return { data: { user: null }, error: toError(err) };
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
/**
|
|
618
|
-
* NEVER USE FOR AN AUTHORIZATION DECISION. Use {@link getUser} instead.
|
|
619
|
-
*
|
|
620
|
-
* The locally-held session, if any (no network call). It is whatever the
|
|
621
|
-
* caller's cookie or storage said, parsed: unsigned JSON the browser sent,
|
|
622
|
-
* which anyone can set by hand. `session.user.id`, `.email` and `.role` are
|
|
623
|
-
* therefore attacker-controlled, and an admin allowlist checked against
|
|
624
|
-
* them is checked against nothing.
|
|
625
|
-
*
|
|
626
|
-
* Reads and writes through `from()` are unaffected: they carry the access
|
|
627
|
-
* token to the backend, which verifies it under RLS. It is the decisions
|
|
628
|
-
* your own app takes from `session.user` that are forgeable.
|
|
629
|
-
*
|
|
630
|
-
* `getUser()` costs one round trip and returns the user the backend
|
|
631
|
-
* confirms, in the same `{ data, error }` shape. Prefer it everywhere but a
|
|
632
|
-
* cosmetic "am I probably signed in" check.
|
|
633
|
-
*/
|
|
634
|
-
async getSession() {
|
|
635
|
-
await this.store.hydrate();
|
|
636
|
-
return { data: { session: this.store.get() }, error: null };
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* Revalidate the current session against the server. Better Auth bearer
|
|
640
|
-
* sessions have no refresh token: if the session is still valid the user is
|
|
641
|
-
* refreshed; otherwise the local session is cleared.
|
|
642
|
-
*/
|
|
643
|
-
async refreshSession() {
|
|
644
|
-
await this.store.hydrate();
|
|
645
|
-
const current = this.store.get();
|
|
646
|
-
if (!current?.accessToken) {
|
|
647
|
-
return { data: { user: null, session: null }, error: { message: 'Auth session missing!' } };
|
|
648
|
-
}
|
|
649
|
-
try {
|
|
650
|
-
const payload = await this.http.request(HTTP_ROUTES.authSession, { raw: true, _retry: true });
|
|
651
|
-
if (!payload?.user) {
|
|
652
|
-
await this.store.set(null, 'signOut');
|
|
653
|
-
return { data: { user: null, session: null }, error: { message: 'Session expired' } };
|
|
654
|
-
}
|
|
655
|
-
const session = { accessToken: current.accessToken, user: payload.user };
|
|
656
|
-
await this.store.set(session, 'tokenRefreshed');
|
|
657
|
-
return { data: { user: session.user, session }, error: null };
|
|
658
|
-
}
|
|
659
|
-
catch (err) {
|
|
660
|
-
// Only a rejected token clears the session. A network failure or a 5xx
|
|
661
|
-
// must leave it intact: middleware runs this on every navigation, so
|
|
662
|
-
// one backend hiccup would otherwise sign the user out.
|
|
663
|
-
const status = err instanceof BackendError ? err.httpStatus : 0;
|
|
664
|
-
if (status === 401 || status === 403)
|
|
665
|
-
await this.store.set(null, 'signOut');
|
|
666
|
-
return { data: { user: null, session: null }, error: toError(err) };
|
|
667
|
-
}
|
|
160
|
+
return { url, options: fetchOptions };
|
|
161
|
+
},
|
|
162
|
+
hooks: {
|
|
163
|
+
async onSuccess({ data, request }) {
|
|
164
|
+
const path = authPath(request.url);
|
|
165
|
+
const current = store.get();
|
|
166
|
+
const ended = SIGN_OUT_PATHS.has(path)
|
|
167
|
+
|| (path === '/revoke-session' && sameSession(current?.accessToken, revokedToken(request.body)))
|
|
168
|
+
|| (path === '/delete-user' && data?.message === 'User deleted')
|
|
169
|
+
// The stored token was revoked or expired elsewhere: the server answers
|
|
170
|
+
// null. Only for the stored token: getSessionFromUrl checks another one.
|
|
171
|
+
|| (path === '/get-session' && data === null
|
|
172
|
+
&& request.headers.get('authorization') === `Bearer ${current?.accessToken}`);
|
|
173
|
+
if (ended) {
|
|
174
|
+
if (current)
|
|
175
|
+
await store.set(null, 'signOut');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (USER_CHANGE_PATHS.has(path) && current) {
|
|
179
|
+
await revalidate();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const next = sessionFromResponse(data);
|
|
183
|
+
if (!next)
|
|
184
|
+
return;
|
|
185
|
+
if (!current || !sameSession(current.accessToken, next.token)) {
|
|
186
|
+
await store.set({ accessToken: next.token, user: next.user }, path.startsWith('/two-factor/') ? 'mfaVerified' : 'signIn');
|
|
187
|
+
}
|
|
188
|
+
else if (JSON.stringify(normalizeUser(next.user)) !== JSON.stringify(current.user)) {
|
|
189
|
+
// Compared as the store holds it: normalised, aliases included.
|
|
190
|
+
await store.set({ ...current, user: next.user }, 'userUpdated');
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The server's GET start route for a social sign-in (auth/social-start.ts). */
|
|
197
|
+
function socialStartUrl(projectUrl, body) {
|
|
198
|
+
const params = new URLSearchParams();
|
|
199
|
+
for (const key of ['callbackURL', 'errorCallbackURL', 'newUserCallbackURL']) {
|
|
200
|
+
if (body[key])
|
|
201
|
+
params.set(key, body[key]);
|
|
202
|
+
}
|
|
203
|
+
if (body.scopes?.length)
|
|
204
|
+
params.set('scopes', body.scopes.join(' '));
|
|
205
|
+
const query = params.toString();
|
|
206
|
+
const provider = encodeURIComponent(body.provider ?? '');
|
|
207
|
+
return `${projectUrl.replace(/\/$/, '')}${HTTP_ROUTES.authSignInSocial}/${provider}${query ? `?${query}` : ''}`;
|
|
208
|
+
}
|
|
209
|
+
/** The `token` a `/revoke-session` request names. The body reaches the hook serialized. */
|
|
210
|
+
function revokedToken(body) {
|
|
211
|
+
try {
|
|
212
|
+
return (typeof body === 'string' ? JSON.parse(body) : body)?.token;
|
|
668
213
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
*
|
|
672
|
-
* The callback fires once with `INITIAL_SESSION` as soon as the stored
|
|
673
|
-
* session has been read, before any later event. That first call is not a
|
|
674
|
-
* nicety: the standard Supabase bootstrap is
|
|
675
|
-
* `if (event === 'INITIAL_SESSION') setLoading(false)`, and without it the
|
|
676
|
-
* app renders its loading state forever for a signed-out visitor, who never
|
|
677
|
-
* produces any other event.
|
|
678
|
-
*
|
|
679
|
-
* It is delivered asynchronously (after hydration), so unsubscribing
|
|
680
|
-
* synchronously suppresses it, exactly as in supabase-js.
|
|
681
|
-
*/
|
|
682
|
-
/**
|
|
683
|
-
* The providers linked to the signed-in user, in the supabase-js identity
|
|
684
|
-
* shape. Backed by Better Auth's `list-accounts`, which is why `email` shows
|
|
685
|
-
* up as an identity for a password account: it is a real linked credential,
|
|
686
|
-
* exactly as in Supabase.
|
|
687
|
-
*/
|
|
688
|
-
async getUserIdentities() {
|
|
689
|
-
try {
|
|
690
|
-
const accounts = await this.http.request(HTTP_ROUTES.authListAccounts, { raw: true });
|
|
691
|
-
const userId = this.store.get()?.user?.id ?? '';
|
|
692
|
-
const identities = (accounts ?? []).map(account => ({
|
|
693
|
-
id: account.accountId ?? account.id,
|
|
694
|
-
identity_id: account.id,
|
|
695
|
-
user_id: userId,
|
|
696
|
-
provider: account.providerId,
|
|
697
|
-
identity_data: { sub: account.accountId, ...(account.scopes ? { scopes: account.scopes } : {}) },
|
|
698
|
-
created_at: asIsoString(account.createdAt),
|
|
699
|
-
updated_at: asIsoString(account.updatedAt),
|
|
700
|
-
last_sign_in_at: asIsoString(account.updatedAt),
|
|
701
|
-
}));
|
|
702
|
-
return { data: { identities }, error: null };
|
|
703
|
-
}
|
|
704
|
-
catch (err) {
|
|
705
|
-
return { data: null, error: toError(err) };
|
|
706
|
-
}
|
|
214
|
+
catch {
|
|
215
|
+
return undefined;
|
|
707
216
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The callback URL a PKCE sign-in should use: the given one, or this page, with
|
|
220
|
+
* a fresh challenge on it. The verifier waits in `verifiers` for
|
|
221
|
+
* `exchangeCodeForSession`.
|
|
222
|
+
*/
|
|
223
|
+
async function pkceCallback(callbackURL, verifiers) {
|
|
224
|
+
const target = callbackURL ?? (typeof window !== 'undefined' ? window.location.href.split('#')[0] : undefined);
|
|
225
|
+
if (!target)
|
|
226
|
+
throw new Error('flowType "pkce" needs a callbackURL outside a browser.');
|
|
227
|
+
const verifier = generateCodeVerifier();
|
|
228
|
+
await verifiers.set(verifier);
|
|
229
|
+
return withCodeChallenge(target, await codeChallengeFor(verifier));
|
|
230
|
+
}
|
|
231
|
+
/** Motherbase's additions, as a Better Auth client plugin. */
|
|
232
|
+
function motherbaseClient(http, store, options) {
|
|
233
|
+
// Bound when Better Auth hands over its fetch, which happens as the client
|
|
234
|
+
// is built, before any request can reach the session sync.
|
|
235
|
+
let revalidate = async () => undefined;
|
|
236
|
+
return {
|
|
237
|
+
id: 'motherbase',
|
|
238
|
+
fetchPlugins: [sessionSync(store, options, () => revalidate())],
|
|
239
|
+
getActions: $fetch => {
|
|
240
|
+
revalidate = () => $fetch('/get-session');
|
|
241
|
+
return {
|
|
242
|
+
onAuthStateChange(callback) {
|
|
243
|
+
let active = true;
|
|
244
|
+
const unsubscribe = store.subscribe((event, session) => {
|
|
245
|
+
if (active)
|
|
246
|
+
callback(EVENT_MAP[event], session);
|
|
247
|
+
});
|
|
248
|
+
void store.hydrate().then(session => {
|
|
249
|
+
if (active)
|
|
250
|
+
callback('INITIAL_SESSION', session);
|
|
251
|
+
});
|
|
252
|
+
return { data: { subscription: { unsubscribe: () => { active = false; unsubscribe(); } } } };
|
|
721
253
|
},
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
await this.http.request(HTTP_ROUTES.authUnlinkAccount, {
|
|
739
|
-
method: 'POST',
|
|
740
|
-
body: { providerId: identity.provider, accountId: identity.id },
|
|
741
|
-
raw: true,
|
|
742
|
-
});
|
|
743
|
-
return { data: {}, error: null };
|
|
744
|
-
}
|
|
745
|
-
catch (err) {
|
|
746
|
-
return { data: null, error: toError(err) };
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Native mobile sign-in with a provider id token (Sign in with Apple, Google
|
|
751
|
-
* One Tap), routed to Better Auth's social sign-in with an `idToken`.
|
|
752
|
-
* The provider must be configured on the backend.
|
|
753
|
-
*/
|
|
754
|
-
async signInWithIdToken(params) {
|
|
755
|
-
try {
|
|
756
|
-
const payload = await this.http.request(HTTP_ROUTES.authSignInSocial, {
|
|
757
|
-
method: 'POST',
|
|
758
|
-
body: {
|
|
759
|
-
provider: params.provider,
|
|
760
|
-
idToken: {
|
|
761
|
-
token: params.token,
|
|
762
|
-
...(params.access_token ? { accessToken: params.access_token } : {}),
|
|
763
|
-
...(params.nonce ? { nonce: params.nonce } : {}),
|
|
764
|
-
},
|
|
254
|
+
async getSessionFromUrl(href) {
|
|
255
|
+
const url = href ?? (typeof window !== 'undefined' ? window.location.href : undefined);
|
|
256
|
+
const found = url ? readAccessTokenFromUrl(url) : null;
|
|
257
|
+
if (!found)
|
|
258
|
+
return { data: null, error: null };
|
|
259
|
+
// Strip the token before the network call: a failed lookup must not
|
|
260
|
+
// leave a usable token in the address bar or the history.
|
|
261
|
+
if (!href && typeof window !== 'undefined') {
|
|
262
|
+
window.history?.replaceState?.(window.history.state, '', found.cleanedHref);
|
|
263
|
+
}
|
|
264
|
+
// Checked before it is stored, so a rejected token never emits
|
|
265
|
+
// SIGNED_IN. On success, sessionSync adopts it from the response.
|
|
266
|
+
const { error } = await $fetch('/get-session', { auth: { type: 'Bearer', token: found.accessToken } });
|
|
267
|
+
if (error)
|
|
268
|
+
return { data: null, error };
|
|
269
|
+
return currentSession(store) ?? { data: null, error: { message: 'The session in the URL is no longer valid.' } };
|
|
765
270
|
},
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
/**
|
|
780
|
-
* Claims for the current session, as a JWT payload.
|
|
781
|
-
*
|
|
782
|
-
* supabase-js reads them out of the access token, which is itself a JWT.
|
|
783
|
-
* A Better Auth session token is an opaque bearer, so this asks the server's
|
|
784
|
-
* jwt plugin to mint one and decodes it. The decode is NOT a verification:
|
|
785
|
-
* the token came from the authenticated endpoint over TLS, and a client
|
|
786
|
-
* verifying a token it just asked for proves nothing. Verify server side,
|
|
787
|
-
* against `/api/auth/jwks`.
|
|
788
|
-
*/
|
|
789
|
-
async getClaims() {
|
|
790
|
-
try {
|
|
791
|
-
const payload = await this.http.request(HTTP_ROUTES.authToken, { raw: true });
|
|
792
|
-
const token = payload?.token;
|
|
793
|
-
const claims = token ? decodeJwtPayload(token) : null;
|
|
794
|
-
if (!token || !claims) {
|
|
795
|
-
return { data: null, error: { message: 'No JWT is available for this session.' } };
|
|
796
|
-
}
|
|
797
|
-
return { data: { claims, token }, error: null };
|
|
798
|
-
}
|
|
799
|
-
catch (err) {
|
|
800
|
-
return { data: null, error: toError(err) };
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
/**
|
|
804
|
-
* Revalidate the session on a timer, the way supabase-js does while the tab
|
|
805
|
-
* is focused. Motherbase refreshes on a 401 already, so this is opt-in: it
|
|
806
|
-
* exists so a ported app that calls it keeps working, and so a long-lived
|
|
807
|
-
* screen notices a revoked session without waiting for its next request.
|
|
808
|
-
*/
|
|
809
|
-
startAutoRefresh(intervalMs = 60000) {
|
|
810
|
-
this.stopAutoRefresh();
|
|
811
|
-
this.autoRefreshTimer = setInterval(() => {
|
|
812
|
-
if (this.store.get())
|
|
813
|
-
void this.refreshSession();
|
|
814
|
-
}, intervalMs);
|
|
815
|
-
// Node keeps the process alive for a pending timer; a background refresh
|
|
816
|
-
// must never be the reason a script does not exit.
|
|
817
|
-
this.autoRefreshTimer.unref?.();
|
|
818
|
-
}
|
|
819
|
-
/** Stop the timer started by `startAutoRefresh`. Safe to call when none runs. */
|
|
820
|
-
stopAutoRefresh() {
|
|
821
|
-
if (this.autoRefreshTimer !== null) {
|
|
822
|
-
clearInterval(this.autoRefreshTimer);
|
|
823
|
-
this.autoRefreshTimer = null;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
onAuthStateChange(callback) {
|
|
827
|
-
let active = true;
|
|
828
|
-
const unsubscribeStore = this.store.subscribe((event, session) => {
|
|
829
|
-
if (active)
|
|
830
|
-
callback(EVENT_MAP[event], session);
|
|
831
|
-
});
|
|
832
|
-
void this.store.hydrate().then(session => {
|
|
833
|
-
if (active)
|
|
834
|
-
callback('INITIAL_SESSION', session);
|
|
835
|
-
});
|
|
836
|
-
return {
|
|
837
|
-
data: {
|
|
838
|
-
subscription: {
|
|
839
|
-
unsubscribe: () => {
|
|
840
|
-
active = false;
|
|
841
|
-
unsubscribeStore();
|
|
842
|
-
},
|
|
271
|
+
async exchangeCodeForSession(authCode) {
|
|
272
|
+
const verifier = await options.verifiers.get();
|
|
273
|
+
if (!verifier) {
|
|
274
|
+
return { data: null, error: { message: 'No PKCE code verifier found: start the sign-in with flowType: "pkce" in this browser.' } };
|
|
275
|
+
}
|
|
276
|
+
const { error } = await $fetch('/exchange-code', {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
body: { auth_code: authCode, code_verifier: verifier },
|
|
279
|
+
});
|
|
280
|
+
if (error)
|
|
281
|
+
return { data: null, error };
|
|
282
|
+
await options.verifiers.clear();
|
|
283
|
+
return currentSession(store) ?? { data: null, error: { message: 'The code did not produce a session.' } };
|
|
843
284
|
},
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
285
|
+
reauthenticate: () => $fetch('/reauthenticate', { method: 'POST' }),
|
|
286
|
+
updateUserCredentials: params => $fetch('/update-user-credentials', { method: 'POST', body: params }),
|
|
287
|
+
admin: adminActions(http),
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function currentSession(store) {
|
|
293
|
+
const session = store.get();
|
|
294
|
+
return session ? { data: { user: session.user, session }, error: null } : null;
|
|
295
|
+
}
|
|
296
|
+
export function createAuthClient(http, store, options) {
|
|
297
|
+
const plugins = [
|
|
298
|
+
anonymousClient(),
|
|
299
|
+
emailOTPClient(),
|
|
300
|
+
magicLinkClient(),
|
|
301
|
+
phoneNumberClient(),
|
|
302
|
+
twoFactorClient(),
|
|
303
|
+
organizationClient({ teams: { enabled: true } }),
|
|
304
|
+
genericOAuthClient(),
|
|
305
|
+
jwtClient(),
|
|
306
|
+
passkeyClient(),
|
|
307
|
+
ssoClient(),
|
|
308
|
+
apiKeyClient(),
|
|
309
|
+
motherbaseClient(http, store, options),
|
|
310
|
+
];
|
|
311
|
+
return createBetterAuthClient({
|
|
312
|
+
baseURL: `${options.url.replace(/\/$/, '')}${HTTP_ROUTES.auth}`,
|
|
313
|
+
fetchOptions: {
|
|
314
|
+
customFetchImpl: http.observedFetch(options.fetch),
|
|
315
|
+
headers: options.headers,
|
|
316
|
+
auth: { type: 'Bearer', token: async () => (await store.hydrate())?.accessToken },
|
|
317
|
+
},
|
|
318
|
+
plugins,
|
|
319
|
+
});
|
|
860
320
|
}
|
|
861
321
|
//# sourceMappingURL=auth.js.map
|