tinbase 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/dist/admin/api.d.ts +13 -0
- package/dist/admin/api.js +20 -3
- package/dist/admin/api.js.map +1 -1
- package/dist/admin/ui.d.ts +1 -1
- package/dist/admin/ui.js +50 -50
- package/dist/auth/handler.d.ts +53 -1
- package/dist/auth/handler.js +403 -35
- package/dist/auth/handler.js.map +1 -1
- package/dist/auth/inbox.d.ts +15 -4
- package/dist/auth/inbox.js +24 -7
- package/dist/auth/inbox.js.map +1 -1
- package/dist/auth/rate-limit.js +3 -0
- package/dist/auth/rate-limit.js.map +1 -1
- package/dist/auth/settings.d.ts +48 -2
- package/dist/auth/settings.js +108 -9
- package/dist/auth/settings.js.map +1 -1
- package/dist/auth/sms.d.ts +49 -0
- package/dist/auth/sms.js +65 -0
- package/dist/auth/sms.js.map +1 -0
- package/dist/cli.js +58 -1
- package/dist/cli.js.map +1 -1
- package/dist/db/bootstrap.d.ts +2 -2
- package/dist/db/bootstrap.js +30 -3
- package/dist/db/bootstrap.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +48 -2
- package/dist/index.js.map +1 -1
- package/dist/node/load-config.d.ts +14 -0
- package/dist/node/load-config.js +56 -1
- package/dist/node/load-config.js.map +1 -1
- package/dist/rest/build.d.ts +18 -1
- package/dist/rest/build.js +40 -11
- package/dist/rest/build.js.map +1 -1
- package/dist/types.d.ts +39 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/auth/handler.js
CHANGED
|
@@ -9,9 +9,13 @@ import { htmlToText, renderTemplate } from './templates.js';
|
|
|
9
9
|
import { callSendEmailHook } from './send-email-hook.js';
|
|
10
10
|
import { RateLimiter } from './rate-limit.js';
|
|
11
11
|
import { generateTotpSecret, otpauthUri, verifyTotp } from './totp.js';
|
|
12
|
+
import { DEFAULT_SMS_TEMPLATE, e164, normalizePhone, renderSmsTemplate } from './sms.js';
|
|
12
13
|
function authError(status, errorCode, msg) {
|
|
13
14
|
return json(status, { code: status, error_code: errorCode, msg });
|
|
14
15
|
}
|
|
16
|
+
/** The SMS provider refused or could not be reached; rendered as GoTrue's `sms_send_failed`. */
|
|
17
|
+
class SmsSendFailed extends Error {
|
|
18
|
+
}
|
|
15
19
|
/** Escape a value for interpolation into HTML text or an attribute. */
|
|
16
20
|
function escapeHtml(s) {
|
|
17
21
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -80,6 +84,18 @@ function iso(v) {
|
|
|
80
84
|
return null;
|
|
81
85
|
return v instanceof Date ? v.toISOString() : new Date(v).toISOString();
|
|
82
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Whole seconds since a timestamp the database wrote, or null when it never
|
|
89
|
+
* was. Computed here rather than as `now() - column` in SQL: the subset engine
|
|
90
|
+
* (pg-mem) cannot subtract timestamps, and the server's clock is the one that
|
|
91
|
+
* wrote the column on every engine but an external Postgres.
|
|
92
|
+
*/
|
|
93
|
+
function secondsSince(v) {
|
|
94
|
+
if (v === null || v === undefined)
|
|
95
|
+
return null;
|
|
96
|
+
const at = v instanceof Date ? v.getTime() : new Date(v).getTime();
|
|
97
|
+
return Number.isFinite(at) ? Math.floor((Date.now() - at) / 1000) : null;
|
|
98
|
+
}
|
|
83
99
|
/** Routes and services the GoTrue-compatible `/auth/v1/*` endpoints. */
|
|
84
100
|
export class AuthHandler {
|
|
85
101
|
db;
|
|
@@ -117,10 +133,10 @@ export class AuthHandler {
|
|
|
117
133
|
return new Response(JSON.stringify({ code: 429, error_code: 'over_request_rate_limit', msg: 'Request rate limit reached' }), { status: 429, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(retryAfter) } });
|
|
118
134
|
}
|
|
119
135
|
/** GoTrue's 429 for `max_frequency`, with the seconds the caller must wait. */
|
|
120
|
-
static tooSoon(retryAfter) {
|
|
136
|
+
static tooSoon(retryAfter, channel = 'email') {
|
|
121
137
|
return new Response(JSON.stringify({
|
|
122
138
|
code: 429,
|
|
123
|
-
error_code: 'over_email_send_rate_limit',
|
|
139
|
+
error_code: channel === 'sms' ? 'over_sms_send_rate_limit' : 'over_email_send_rate_limit',
|
|
124
140
|
msg: `For security purposes, you can only request this after ${retryAfter} seconds.`,
|
|
125
141
|
}), { status: 429, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(retryAfter) } });
|
|
126
142
|
}
|
|
@@ -148,15 +164,15 @@ export class AuthHandler {
|
|
|
148
164
|
if (!seconds || seconds <= 0)
|
|
149
165
|
return null;
|
|
150
166
|
const normalized = email.toLowerCase().trim();
|
|
151
|
-
// `column` is one of the
|
|
152
|
-
const res = await this.db.query(`select
|
|
153
|
-
from auth.users where email = $1`, [normalized]);
|
|
167
|
+
// `column` is one of the literals in SentAtColumn, never caller input.
|
|
168
|
+
const res = await this.db.query(`select ${column} as sent_at from auth.users where email = $1`, [normalized]);
|
|
154
169
|
const row = res.rows[0];
|
|
155
170
|
if (row) {
|
|
156
171
|
// A row exists: the column is the authority, and it survives a restart,
|
|
157
172
|
// which an in-memory counter does not.
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
const elapsed = secondsSince(row.sent_at);
|
|
174
|
+
if (elapsed !== null && elapsed < seconds) {
|
|
175
|
+
return AuthHandler.tooSoon(Math.max(1, seconds - elapsed));
|
|
160
176
|
}
|
|
161
177
|
return null;
|
|
162
178
|
}
|
|
@@ -192,13 +208,15 @@ export class AuthHandler {
|
|
|
192
208
|
return json(200, {
|
|
193
209
|
external: {
|
|
194
210
|
email: true,
|
|
195
|
-
phone:
|
|
211
|
+
phone: this.settings.phoneEnabled,
|
|
196
212
|
anonymous_users: this.settings.anonymousUsers,
|
|
197
213
|
...Object.fromEntries(providers.map((p) => [p, !this.settings.disabledProviders.includes(p)])),
|
|
198
214
|
},
|
|
199
215
|
disable_signup: this.settings.disableSignup,
|
|
200
216
|
autoconfirm: this.settings.autoconfirm,
|
|
201
217
|
mailer_autoconfirm: this.settings.autoconfirm,
|
|
218
|
+
phone_autoconfirm: this.settings.smsAutoconfirm,
|
|
219
|
+
sms_provider: this.settings.phoneEnabled ? (this.config.smsProviderName ?? '') : '',
|
|
202
220
|
minimum_password_length: this.settings.minPasswordLength,
|
|
203
221
|
});
|
|
204
222
|
}
|
|
@@ -212,14 +230,16 @@ export class AuthHandler {
|
|
|
212
230
|
return await this.updateUser(req, url);
|
|
213
231
|
if (path === 'logout' && method === 'POST')
|
|
214
232
|
return await this.logout(req);
|
|
233
|
+
// /otp, /magiclink and /resend pick their limit (`otp` for mail, `sms`
|
|
234
|
+
// for a text) once the body says which, so they are rate-limited inside.
|
|
215
235
|
if (path === 'otp' && method === 'POST')
|
|
216
|
-
return
|
|
236
|
+
return await this.sendOtp(req, url);
|
|
217
237
|
if (path === 'recover' && method === 'POST')
|
|
218
238
|
return this.limit('recover', req) ?? (await this.sendRecovery(req, url));
|
|
219
239
|
if (path === 'magiclink' && method === 'POST')
|
|
220
|
-
return
|
|
240
|
+
return await this.sendOtp(req, url);
|
|
221
241
|
if (path === 'resend' && method === 'POST')
|
|
222
|
-
return
|
|
242
|
+
return await this.resend(req, url);
|
|
223
243
|
if (path === 'verify' && method === 'POST')
|
|
224
244
|
return this.limit('verify', req) ?? (await this.verifyToken(req));
|
|
225
245
|
// Deliberately unlimited, unlike the POST above: a link carries no email,
|
|
@@ -251,12 +271,16 @@ export class AuthHandler {
|
|
|
251
271
|
}
|
|
252
272
|
catch (e) {
|
|
253
273
|
const msg = e instanceof Error ? e.message : String(e);
|
|
274
|
+
if (e instanceof SmsSendFailed)
|
|
275
|
+
return authError(500, 'sms_send_failed', `Error sending sms OTP: ${msg}`);
|
|
254
276
|
return authError(500, 'unexpected_failure', msg);
|
|
255
277
|
}
|
|
256
278
|
}
|
|
257
279
|
// ── flows ─────────────────────────────────────────────────────────────
|
|
258
280
|
async signup(req, url) {
|
|
259
281
|
const body = (await req.json().catch(() => ({})));
|
|
282
|
+
if (body.phone && !body.email)
|
|
283
|
+
return await this.signupWithPhone(req, body);
|
|
260
284
|
if (!body.email && !body.password) {
|
|
261
285
|
// supabase.auth.signInAnonymously()
|
|
262
286
|
if (!this.settings.anonymousUsers) {
|
|
@@ -329,15 +353,26 @@ export class AuthHandler {
|
|
|
329
353
|
const grantType = url.searchParams.get('grant_type');
|
|
330
354
|
const body = (await req.json().catch(() => ({})));
|
|
331
355
|
if (grantType === 'password') {
|
|
356
|
+
// supabase.auth.signInWithPassword({ phone, password }): same grant, the
|
|
357
|
+
// account looked up by number instead of address.
|
|
358
|
+
const byPhone = !!body.phone && !body.email;
|
|
332
359
|
const email = (body.email ?? '').toLowerCase().trim();
|
|
333
|
-
const
|
|
360
|
+
const phone = byPhone ? normalizePhone(body.phone ?? '') : null;
|
|
361
|
+
if (byPhone && !this.settings.phoneEnabled)
|
|
362
|
+
return AuthHandler.phoneDisabled();
|
|
363
|
+
const res = byPhone
|
|
364
|
+
? await this.db.query(`select * from auth.users where phone = $1`, [phone ?? ''])
|
|
365
|
+
: await this.db.query(`select * from auth.users where email = $1`, [email]);
|
|
334
366
|
const user = res.rows[0];
|
|
335
367
|
if (!user || !user.encrypted_password || !(await verifyPassword(body.password ?? '', user.encrypted_password))) {
|
|
336
|
-
await this.audit('login_failed', { actorEmail: email, traits: { grant_type: 'password' } });
|
|
368
|
+
await this.audit('login_failed', { actorEmail: byPhone ? phone : email, traits: { grant_type: 'password' } });
|
|
337
369
|
return authError(400, 'invalid_credentials', 'Invalid login credentials');
|
|
338
370
|
}
|
|
339
371
|
// when confirmation is required, unverified accounts cannot sign in yet
|
|
340
|
-
if (!this.settings.
|
|
372
|
+
if (byPhone && !this.settings.smsAutoconfirm && !user.phone_confirmed_at) {
|
|
373
|
+
return authError(400, 'phone_not_confirmed', 'Phone not confirmed');
|
|
374
|
+
}
|
|
375
|
+
if (!byPhone && !this.settings.autoconfirm && !user.email_confirmed_at) {
|
|
341
376
|
return authError(400, 'email_not_confirmed', 'Email not confirmed');
|
|
342
377
|
}
|
|
343
378
|
await this.db.query(`update auth.users set last_sign_in_at = now() where id = $1`, [user.id]);
|
|
@@ -397,6 +432,7 @@ export class AuthHandler {
|
|
|
397
432
|
// usually a password) keeps the same id + data, flips is_anonymous off, and
|
|
398
433
|
// records an email identity - matching supabase.auth.updateUser({ email }).
|
|
399
434
|
const upgradingAnon = (user.is_anonymous ?? false) && !!body.email;
|
|
435
|
+
const upgradingAnonByPhone = (user.is_anonymous ?? false) && !body.email && !!body.phone;
|
|
400
436
|
// Changing the address on an account that already has one is a different
|
|
401
437
|
// operation from naming one for the first time, and only the first is an
|
|
402
438
|
// email change. An anonymous upgrade has no address to confirm from and
|
|
@@ -416,6 +452,28 @@ export class AuthHandler {
|
|
|
416
452
|
pendingEmail = email;
|
|
417
453
|
}
|
|
418
454
|
}
|
|
455
|
+
// A phone change mirrors the email one: a new number on an account that
|
|
456
|
+
// already has one is parked until the texted code comes back (unless SMS
|
|
457
|
+
// autoconfirm is on), while an anonymous upgrade takes effect at once.
|
|
458
|
+
let pendingPhone = null;
|
|
459
|
+
if (body.phone) {
|
|
460
|
+
if (!this.settings.phoneEnabled)
|
|
461
|
+
return AuthHandler.phoneDisabled();
|
|
462
|
+
const phone = normalizePhone(body.phone);
|
|
463
|
+
if (!phone)
|
|
464
|
+
return AuthHandler.invalidPhone();
|
|
465
|
+
const clash = await this.db.query(`select id from auth.users where phone = $1 and id <> $2`, [phone, user.id]);
|
|
466
|
+
if (clash.rows.length > 0) {
|
|
467
|
+
return authError(422, 'phone_exists', 'A user with this phone number has already been registered');
|
|
468
|
+
}
|
|
469
|
+
if (upgradingAnonByPhone || this.settings.smsAutoconfirm || phone === user.phone) {
|
|
470
|
+
params.push(phone);
|
|
471
|
+
sets.push(`phone = $${params.length}, phone_confirmed_at = now()`);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
pendingPhone = phone;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
419
477
|
if (body.password) {
|
|
420
478
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
421
479
|
return authError(422, 'weak_password', `Password should be at least ${this.settings.minPasswordLength} characters.`);
|
|
@@ -431,6 +489,10 @@ export class AuthHandler {
|
|
|
431
489
|
sets.push(`is_anonymous = false`);
|
|
432
490
|
sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
|
|
433
491
|
}
|
|
492
|
+
if (upgradingAnonByPhone) {
|
|
493
|
+
sets.push(`is_anonymous = false`);
|
|
494
|
+
sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"phone","providers":["phone"]}'::jsonb`);
|
|
495
|
+
}
|
|
434
496
|
let updated = user;
|
|
435
497
|
if (sets.length > 0) {
|
|
436
498
|
params.push(user.id);
|
|
@@ -443,6 +505,9 @@ export class AuthHandler {
|
|
|
443
505
|
values ($1, 'email', $2, $3)
|
|
444
506
|
on conflict (provider, provider_id) do nothing`, [updated.id, updated.id, JSON.stringify({ sub: updated.id, email: updated.email })]);
|
|
445
507
|
}
|
|
508
|
+
if (upgradingAnonByPhone) {
|
|
509
|
+
await this.ensurePhoneIdentity(updated);
|
|
510
|
+
}
|
|
446
511
|
if (pendingEmail) {
|
|
447
512
|
const started = await this.startEmailChange(updated, pendingEmail, url.searchParams.get('redirect_to'));
|
|
448
513
|
if (started)
|
|
@@ -450,6 +515,13 @@ export class AuthHandler {
|
|
|
450
515
|
const res = await this.db.query(`select * from auth.users where id = $1`, [updated.id]);
|
|
451
516
|
updated = res.rows[0] ?? updated;
|
|
452
517
|
}
|
|
518
|
+
if (pendingPhone) {
|
|
519
|
+
const started = await this.startPhoneChange(updated, pendingPhone);
|
|
520
|
+
if (started)
|
|
521
|
+
return started;
|
|
522
|
+
const res = await this.db.query(`select * from auth.users where id = $1`, [updated.id]);
|
|
523
|
+
updated = res.rows[0] ?? updated;
|
|
524
|
+
}
|
|
453
525
|
return json(200, this.userJson(updated));
|
|
454
526
|
}
|
|
455
527
|
/**
|
|
@@ -748,8 +820,13 @@ export class AuthHandler {
|
|
|
748
820
|
}
|
|
749
821
|
async sendOtp(req, url) {
|
|
750
822
|
const body = (await req.json().catch(() => ({})));
|
|
823
|
+
if (body.phone && !body.email)
|
|
824
|
+
return await this.sendPhoneOtp(req, body);
|
|
751
825
|
if (!body.email)
|
|
752
826
|
return authError(400, 'validation_failed', 'email is required');
|
|
827
|
+
const limited = this.limit('otp', req);
|
|
828
|
+
if (limited)
|
|
829
|
+
return limited;
|
|
753
830
|
const tooSoon = await this.limitEmailFrequency(body.email, 'recovery_sent_at');
|
|
754
831
|
if (tooSoon)
|
|
755
832
|
return tooSoon;
|
|
@@ -777,7 +854,7 @@ export class AuthHandler {
|
|
|
777
854
|
if (!body.type)
|
|
778
855
|
return authError(400, 'validation_failed', 'Missing one of these arguments: type');
|
|
779
856
|
if (body.type === 'sms' || body.type === 'phone_change') {
|
|
780
|
-
return
|
|
857
|
+
return await this.resendSms(req, body.type, body.phone);
|
|
781
858
|
}
|
|
782
859
|
if (body.type === 'email_change') {
|
|
783
860
|
// The email-change flow itself does not exist yet, so there is no
|
|
@@ -790,6 +867,9 @@ export class AuthHandler {
|
|
|
790
867
|
}
|
|
791
868
|
if (!body.email)
|
|
792
869
|
return authError(400, 'validation_failed', 'email is required');
|
|
870
|
+
const limited = this.limit('otp', req);
|
|
871
|
+
if (limited)
|
|
872
|
+
return limited;
|
|
793
873
|
const normalized = body.email.toLowerCase().trim();
|
|
794
874
|
const res = await this.db.query(`select id, email_confirmed_at from auth.users where email = $1`, [normalized]);
|
|
795
875
|
const user = res.rows[0];
|
|
@@ -829,39 +909,49 @@ export class AuthHandler {
|
|
|
829
909
|
}
|
|
830
910
|
/** Max wrong guesses for a one-time code before its tokens are invalidated. */
|
|
831
911
|
static MAX_OTP_ATTEMPTS = 5;
|
|
832
|
-
async redeem(token, types, email) {
|
|
912
|
+
async redeem(token, types, scope = { email: null }) {
|
|
833
913
|
// `|| null`, not `??`: an empty string is an absent address, not one to
|
|
834
914
|
// match on. Left as '' it reaches the query as `email = ''`, which matches
|
|
835
915
|
// nothing, and a client that sends `email: ''` beside a link token has a
|
|
836
916
|
// good token refused.
|
|
837
|
-
const
|
|
917
|
+
const byPhone = scope.phone !== undefined;
|
|
918
|
+
const column = byPhone ? 'phone' : 'email';
|
|
919
|
+
const identifier = byPhone ? scope.phone : scope.email?.toLowerCase().trim() || null;
|
|
838
920
|
// SECURITY: a guessable code is only a credential together with the address
|
|
839
921
|
// it was sent to. Unscoped, `token = $1` matches whoever happens to hold a
|
|
840
922
|
// live code - so one guess is tried against every account at once - and the
|
|
841
923
|
// attempt counter below needs the email to know what to count, so the
|
|
842
924
|
// lockout never fires either. Refuse rather than widen the match.
|
|
843
|
-
if (!
|
|
925
|
+
if (!identifier && isGuessableToken(token))
|
|
844
926
|
return null;
|
|
927
|
+
// `column` is one of two literals chosen above, never caller input.
|
|
845
928
|
const res = await this.db.query(`delete from auth.one_time_tokens
|
|
846
929
|
where token = $1 and token_type = any($2::text[])
|
|
847
|
-
and ($3::text is null or
|
|
930
|
+
and ($3::text is null or ${column} = $3) and expires_at > now()
|
|
848
931
|
and attempts < $4
|
|
849
|
-
returning user_id, email`, [token, `{${types.join(',')}}`,
|
|
932
|
+
returning user_id, email, phone`, [token, `{${types.join(',')}}`, identifier, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
850
933
|
const row = res.rows[0];
|
|
851
934
|
if (!row) {
|
|
852
935
|
// Wrong/expired code: count the failed guess against the live tokens for
|
|
853
936
|
// this email, and burn them once the attempt cap is hit (brute-force
|
|
854
937
|
// lockout for the 6-digit OTP). Requires the email to scope the counter.
|
|
855
|
-
if (
|
|
938
|
+
if (identifier) {
|
|
856
939
|
await this.db.query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
857
|
-
where
|
|
858
|
-
await this.db.query(`delete from auth.one_time_tokens where
|
|
940
|
+
where ${column} = $1 and token_type = any($2::text[]) and expires_at > now()`, [identifier, `{${types.join(',')}}`]);
|
|
941
|
+
await this.db.query(`delete from auth.one_time_tokens where ${column} = $1 and attempts >= $2`, [identifier, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
859
942
|
}
|
|
860
943
|
return null;
|
|
861
944
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
945
|
+
// Every other live token addressed the same way is spent with this one.
|
|
946
|
+
if (row.phone)
|
|
947
|
+
await this.db.query(`delete from auth.one_time_tokens where phone = $1`, [row.phone]);
|
|
948
|
+
else if (row.email)
|
|
949
|
+
await this.db.query(`delete from auth.one_time_tokens where email = $1`, [row.email]);
|
|
950
|
+
const ures = byPhone
|
|
951
|
+
? await this.db.query(`update auth.users set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now()
|
|
952
|
+
where id = $1 returning *`, [row.user_id])
|
|
953
|
+
: await this.db.query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
954
|
+
where id = $1 returning *`, [row.user_id]);
|
|
865
955
|
return ures.rows[0] ?? null;
|
|
866
956
|
}
|
|
867
957
|
/**
|
|
@@ -947,6 +1037,9 @@ export class AuthHandler {
|
|
|
947
1037
|
const token = body.token ?? body.token_hash;
|
|
948
1038
|
if (!token)
|
|
949
1039
|
return authError(400, 'validation_failed', 'token is required');
|
|
1040
|
+
if (body.type === 'sms' || body.type === 'phone_change') {
|
|
1041
|
+
return await this.verifySmsToken(body.type, body.phone, token);
|
|
1042
|
+
}
|
|
950
1043
|
if (body.type === 'email_change') {
|
|
951
1044
|
const outcome = await this.verifyEmailChange(token);
|
|
952
1045
|
if (outcome.status === 'invalid')
|
|
@@ -955,7 +1048,7 @@ export class AuthHandler {
|
|
|
955
1048
|
return json(200, { msg: AuthHandler.EMAIL_CHANGE_PENDING_MSG });
|
|
956
1049
|
return json(200, await this.sessionFor(outcome.user));
|
|
957
1050
|
}
|
|
958
|
-
const user = await this.redeem(token, AuthHandler.redeemTypes(body.type), body.email);
|
|
1051
|
+
const user = await this.redeem(token, AuthHandler.redeemTypes(body.type), { email: body.email ?? null });
|
|
959
1052
|
if (!user)
|
|
960
1053
|
return authError(403, 'otp_expired', 'Token has expired or is invalid');
|
|
961
1054
|
// A PKCE challenge parked for the link is moot once the code was typed in
|
|
@@ -1018,6 +1111,258 @@ export class AuthHandler {
|
|
|
1018
1111
|
const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&type=${type}`;
|
|
1019
1112
|
return new Response(null, { status: 303, headers: { location: `${redirectTo}${hash}` } });
|
|
1020
1113
|
}
|
|
1114
|
+
// ── phone (SMS OTP) ───────────────────────────────────────────────────
|
|
1115
|
+
//
|
|
1116
|
+
// GoTrue's phone flows, on the same token table and the same attempt and
|
|
1117
|
+
// expiry rules as email. The differences are what a text is: there is no
|
|
1118
|
+
// link, only the code, so every phone token is a single guessable row scoped
|
|
1119
|
+
// to its number; and each one costs money, so sending is paced by both the
|
|
1120
|
+
// per-caller `sms` window and the per-recipient `max_frequency`.
|
|
1121
|
+
/** GoTrue's answer to any phone endpoint while phone auth is off. */
|
|
1122
|
+
static phoneDisabled() {
|
|
1123
|
+
return authError(400, 'phone_provider_disabled', 'Unsupported phone provider');
|
|
1124
|
+
}
|
|
1125
|
+
static invalidPhone() {
|
|
1126
|
+
return authError(400, 'validation_failed', 'Invalid phone number format (E.164 required)');
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* `[auth.sms] max_frequency`, measured like {@link limitEmailFrequency}:
|
|
1130
|
+
* against the column that flow last wrote on the recipient's row, with an
|
|
1131
|
+
* in-memory window for a number that has no row yet.
|
|
1132
|
+
*/
|
|
1133
|
+
async limitSmsFrequency(phone, column) {
|
|
1134
|
+
const seconds = this.settings.maxSmsFrequencySeconds;
|
|
1135
|
+
if (!seconds || seconds <= 0)
|
|
1136
|
+
return null;
|
|
1137
|
+
const res = await this.db.query(`select ${column} as sent_at from auth.users where phone = $1`, [phone]);
|
|
1138
|
+
const row = res.rows[0];
|
|
1139
|
+
if (row) {
|
|
1140
|
+
const elapsed = secondsSince(row.sent_at);
|
|
1141
|
+
if (elapsed !== null && elapsed < seconds) {
|
|
1142
|
+
return AuthHandler.tooSoon(Math.max(1, seconds - elapsed), 'sms');
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
if (!this.rateLimiter)
|
|
1147
|
+
return null;
|
|
1148
|
+
const retryAfter = this.rateLimiter.check(`sms_frequency:${column}`, phone, Date.now(), {
|
|
1149
|
+
limit: 1,
|
|
1150
|
+
windowMs: seconds * 1000,
|
|
1151
|
+
});
|
|
1152
|
+
return retryAfter === null ? null : AuthHandler.tooSoon(retryAfter, 'sms');
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Mint a code for `phone`, replacing any live one of the same type, and
|
|
1156
|
+
* text it. The send column is written only once the provider accepted the
|
|
1157
|
+
* message, as with mail. Returns the provider's message id when it gave one.
|
|
1158
|
+
*/
|
|
1159
|
+
async sendSmsCode(user, phone, tokenType, column) {
|
|
1160
|
+
const code = randomOtp(this.settings.smsOtpLength);
|
|
1161
|
+
await this.db.query(`delete from auth.one_time_tokens where phone = $1 and token_type = $2`, [phone, tokenType]);
|
|
1162
|
+
await this.db.query(`insert into auth.one_time_tokens (user_id, phone, token_type, token, expires_at)
|
|
1163
|
+
values ($1, $2, $3, $4, now() + $5::interval)`, [user.id, phone, tokenType, code, `${this.settings.smsOtpExpirySeconds} seconds`]);
|
|
1164
|
+
const body = renderSmsTemplate(this.config.smsTemplate || DEFAULT_SMS_TEMPLATE, code);
|
|
1165
|
+
let messageId;
|
|
1166
|
+
try {
|
|
1167
|
+
messageId = await this.config.smsSender.send({ to: e164(phone), body });
|
|
1168
|
+
}
|
|
1169
|
+
catch (e) {
|
|
1170
|
+
// Nobody received the code, so nothing should be redeemable: GoTrue rolls
|
|
1171
|
+
// the token back with the failed send. Seen live (staging, 2026-10-23): a
|
|
1172
|
+
// refused send still left a row that `/verify` accepted.
|
|
1173
|
+
await this.db.query(`delete from auth.one_time_tokens where phone = $1 and token_type = $2 and token = $3`, [phone, tokenType, code]);
|
|
1174
|
+
// GoTrue's shape for a provider that refused the text (bad number, over
|
|
1175
|
+
// the provider's limit, unreachable): the caller learns why, under the
|
|
1176
|
+
// code supabase-js clients already handle, rather than a bare 500.
|
|
1177
|
+
throw new SmsSendFailed(e instanceof Error ? e.message : String(e));
|
|
1178
|
+
}
|
|
1179
|
+
await this.markEmailSent(user.id, column);
|
|
1180
|
+
return messageId ?? undefined;
|
|
1181
|
+
}
|
|
1182
|
+
/** Create the account a phone OTP is for, with GoTrue's provider metadata. */
|
|
1183
|
+
async createPhoneUser(phone, data, extra) {
|
|
1184
|
+
const hashed = extra.password ? await hashPassword(extra.password) : null;
|
|
1185
|
+
const res = await this.db.query(`insert into auth.users
|
|
1186
|
+
(aud, role, phone, encrypted_password, phone_confirmed_at, last_sign_in_at,
|
|
1187
|
+
raw_app_meta_data, raw_user_meta_data)
|
|
1188
|
+
values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end, now(),
|
|
1189
|
+
'{"provider":"phone","providers":["phone"]}', $3)
|
|
1190
|
+
returning *`, [phone, hashed, JSON.stringify(data ?? {}), extra.confirmed]);
|
|
1191
|
+
const user = res.rows[0];
|
|
1192
|
+
await this.ensurePhoneIdentity(user);
|
|
1193
|
+
return user;
|
|
1194
|
+
}
|
|
1195
|
+
/** The `phone` identity GoTrue records, so `user.identities` names the provider. */
|
|
1196
|
+
async ensurePhoneIdentity(user) {
|
|
1197
|
+
await this.db.query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1198
|
+
values ($1, 'phone', $2, $3)
|
|
1199
|
+
on conflict (provider, provider_id) do update
|
|
1200
|
+
set identity_data = excluded.identity_data, updated_at = now()`, [user.id, user.id, JSON.stringify({ sub: user.id, phone: user.phone })]);
|
|
1201
|
+
}
|
|
1202
|
+
/** `supabase.auth.signUp({ phone, password })`. */
|
|
1203
|
+
async signupWithPhone(req, body) {
|
|
1204
|
+
if (!this.settings.phoneEnabled)
|
|
1205
|
+
return AuthHandler.phoneDisabled();
|
|
1206
|
+
if (this.settings.disableSignup) {
|
|
1207
|
+
return authError(422, 'signup_disabled', 'Signups not allowed for this instance');
|
|
1208
|
+
}
|
|
1209
|
+
if (!body.password) {
|
|
1210
|
+
return authError(400, 'validation_failed', 'Signup requires a valid phone number and password');
|
|
1211
|
+
}
|
|
1212
|
+
const phone = normalizePhone(body.phone ?? '');
|
|
1213
|
+
if (!phone)
|
|
1214
|
+
return AuthHandler.invalidPhone();
|
|
1215
|
+
if (body.password.length < this.settings.minPasswordLength) {
|
|
1216
|
+
return authError(422, 'weak_password', `Password should be at least ${this.settings.minPasswordLength} characters.`);
|
|
1217
|
+
}
|
|
1218
|
+
const existing = await this.db.query(`select id from auth.users where phone = $1`, [phone]);
|
|
1219
|
+
if (existing.rows.length > 0) {
|
|
1220
|
+
return authError(422, 'user_already_exists', 'User already registered');
|
|
1221
|
+
}
|
|
1222
|
+
const autoconfirm = this.settings.smsAutoconfirm;
|
|
1223
|
+
if (!autoconfirm) {
|
|
1224
|
+
// The confirmation is a text, so it is paced like one - before the row
|
|
1225
|
+
// is written, so a refusal leaves no half-made account behind.
|
|
1226
|
+
const limited = this.limit('sms', req);
|
|
1227
|
+
if (limited)
|
|
1228
|
+
return limited;
|
|
1229
|
+
const tooSoon = await this.limitSmsFrequency(phone, 'confirmation_sent_at');
|
|
1230
|
+
if (tooSoon)
|
|
1231
|
+
return tooSoon;
|
|
1232
|
+
}
|
|
1233
|
+
const user = await this.createPhoneUser(phone, body.data, { password: body.password, confirmed: autoconfirm });
|
|
1234
|
+
await this.audit('user_signedup', { actorId: user.id, actorEmail: phone });
|
|
1235
|
+
if (!autoconfirm) {
|
|
1236
|
+
await this.sendSmsCode(user, phone, 'sms', 'confirmation_sent_at');
|
|
1237
|
+
return json(200, this.userJson(user));
|
|
1238
|
+
}
|
|
1239
|
+
return json(200, await this.sessionFor(user));
|
|
1240
|
+
}
|
|
1241
|
+
/** `supabase.auth.signInWithOtp({ phone })`: text a code, creating the account unless told not to. */
|
|
1242
|
+
async sendPhoneOtp(req, body) {
|
|
1243
|
+
if (!this.settings.phoneEnabled)
|
|
1244
|
+
return AuthHandler.phoneDisabled();
|
|
1245
|
+
const phone = normalizePhone(body.phone ?? '');
|
|
1246
|
+
if (!phone)
|
|
1247
|
+
return AuthHandler.invalidPhone();
|
|
1248
|
+
const limited = this.limit('sms', req);
|
|
1249
|
+
if (limited)
|
|
1250
|
+
return limited;
|
|
1251
|
+
const tooSoon = await this.limitSmsFrequency(phone, 'confirmation_sent_at');
|
|
1252
|
+
if (tooSoon)
|
|
1253
|
+
return tooSoon;
|
|
1254
|
+
const res = await this.db.query(`select * from auth.users where phone = $1`, [phone]);
|
|
1255
|
+
let user = res.rows[0];
|
|
1256
|
+
if (!user) {
|
|
1257
|
+
if (body.create_user === false)
|
|
1258
|
+
return authError(422, 'otp_disabled', 'Signups not allowed for otp');
|
|
1259
|
+
if (this.settings.disableSignup) {
|
|
1260
|
+
return authError(422, 'signup_disabled', 'Signups not allowed for this instance');
|
|
1261
|
+
}
|
|
1262
|
+
user = await this.createPhoneUser(phone, body.data, { confirmed: false });
|
|
1263
|
+
}
|
|
1264
|
+
const messageId = await this.sendSmsCode(user, phone, 'sms', 'confirmation_sent_at');
|
|
1265
|
+
return json(200, messageId ? { message_id: messageId } : {});
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* `supabase.auth.resend({ phone, type: 'sms' | 'phone_change' })`.
|
|
1269
|
+
*
|
|
1270
|
+
* Like the email resend: 200 and no text for a number with nothing pending,
|
|
1271
|
+
* whether because it has no account, is already confirmed, or has no change
|
|
1272
|
+
* under way - so the endpoint cannot be used to find out which.
|
|
1273
|
+
*/
|
|
1274
|
+
async resendSms(req, type, rawPhone) {
|
|
1275
|
+
if (!this.settings.phoneEnabled)
|
|
1276
|
+
return AuthHandler.phoneDisabled();
|
|
1277
|
+
if (!rawPhone)
|
|
1278
|
+
return authError(400, 'validation_failed', 'phone is required');
|
|
1279
|
+
const phone = normalizePhone(rawPhone);
|
|
1280
|
+
if (!phone)
|
|
1281
|
+
return AuthHandler.invalidPhone();
|
|
1282
|
+
const limited = this.limit('sms', req);
|
|
1283
|
+
if (limited)
|
|
1284
|
+
return limited;
|
|
1285
|
+
if (type === 'sms') {
|
|
1286
|
+
const res = await this.db.query(`select * from auth.users where phone = $1`, [phone]);
|
|
1287
|
+
const user = res.rows[0];
|
|
1288
|
+
if (!user || user.phone_confirmed_at)
|
|
1289
|
+
return json(200, {});
|
|
1290
|
+
const tooSoon = await this.limitSmsFrequency(phone, 'confirmation_sent_at');
|
|
1291
|
+
if (tooSoon)
|
|
1292
|
+
return tooSoon;
|
|
1293
|
+
const messageId = await this.sendSmsCode(user, phone, 'sms', 'confirmation_sent_at');
|
|
1294
|
+
return json(200, messageId ? { message_id: messageId } : {});
|
|
1295
|
+
}
|
|
1296
|
+
// phone_change: the number given is the new one, which is where the code goes.
|
|
1297
|
+
const res = await this.db.query(`select * from auth.users where phone_change = $1`, [phone]);
|
|
1298
|
+
const user = res.rows[0];
|
|
1299
|
+
if (!user)
|
|
1300
|
+
return json(200, {});
|
|
1301
|
+
const tooSoon = await this.limitPhoneChangeFrequency(user, phone);
|
|
1302
|
+
if (tooSoon)
|
|
1303
|
+
return tooSoon;
|
|
1304
|
+
const messageId = await this.sendSmsCode(user, phone, 'phone_change', 'phone_change_sent_at');
|
|
1305
|
+
return json(200, messageId ? { message_id: messageId } : {});
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* `max_frequency` for a phone change: the account's own `phone_change_sent_at`
|
|
1309
|
+
* (the new number has no row to measure by), plus the in-memory window on
|
|
1310
|
+
* the new number itself so one account cannot be used to text it in a loop.
|
|
1311
|
+
*/
|
|
1312
|
+
async limitPhoneChangeFrequency(user, newPhone) {
|
|
1313
|
+
const seconds = this.settings.maxSmsFrequencySeconds;
|
|
1314
|
+
if (!seconds || seconds <= 0)
|
|
1315
|
+
return null;
|
|
1316
|
+
const res = await this.db.query(`select phone_change_sent_at as sent_at from auth.users where id = $1`, [user.id]);
|
|
1317
|
+
const elapsed = secondsSince(res.rows[0]?.sent_at);
|
|
1318
|
+
if (elapsed !== null && elapsed < seconds)
|
|
1319
|
+
return AuthHandler.tooSoon(Math.max(1, seconds - elapsed), 'sms');
|
|
1320
|
+
return this.limitSmsFrequency(newPhone, 'phone_change_sent_at');
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Begin a phone change: park the new number and text it a code. The number
|
|
1324
|
+
* moves only when the code comes back through `/verify` type `phone_change`.
|
|
1325
|
+
* Returns a Response only when the change could not be started.
|
|
1326
|
+
*/
|
|
1327
|
+
async startPhoneChange(user, newPhone) {
|
|
1328
|
+
const tooSoon = await this.limitPhoneChangeFrequency(user, newPhone);
|
|
1329
|
+
if (tooSoon)
|
|
1330
|
+
return tooSoon;
|
|
1331
|
+
await this.db.query(`update auth.users set phone_change = $2, phone_change_sent_at = now(), updated_at = now() where id = $1`, [user.id, newPhone]);
|
|
1332
|
+
// A second change replaces the first outright.
|
|
1333
|
+
await this.db.query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'phone_change'`, [user.id]);
|
|
1334
|
+
await this.sendSmsCode(user, newPhone, 'phone_change', 'phone_change_sent_at');
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1337
|
+
/** `verifyOtp({ phone, token, type: 'sms' | 'phone_change' })` → session. */
|
|
1338
|
+
async verifySmsToken(type, rawPhone, token) {
|
|
1339
|
+
if (!rawPhone)
|
|
1340
|
+
return authError(400, 'validation_failed', 'phone is required');
|
|
1341
|
+
const phone = normalizePhone(rawPhone);
|
|
1342
|
+
if (!phone)
|
|
1343
|
+
return AuthHandler.invalidPhone();
|
|
1344
|
+
const user = await this.redeem(token, [type], { phone });
|
|
1345
|
+
if (!user)
|
|
1346
|
+
return authError(403, 'otp_expired', 'Token has expired or is invalid');
|
|
1347
|
+
if (type === 'sms') {
|
|
1348
|
+
await this.audit('login', { actorId: user.id, actorEmail: user.email || phone, traits: { provider: 'phone' } });
|
|
1349
|
+
return json(200, await this.sessionFor(user));
|
|
1350
|
+
}
|
|
1351
|
+
// phone_change: the code came back from the new number, so it moves in.
|
|
1352
|
+
// `nullif` guards a pending number cleared in between: better to keep the
|
|
1353
|
+
// number already held than to blank it.
|
|
1354
|
+
const done = await this.db.query(`update auth.users
|
|
1355
|
+
set phone = coalesce(nullif(phone_change, ''), phone),
|
|
1356
|
+
phone_confirmed_at = now(),
|
|
1357
|
+
phone_change = '',
|
|
1358
|
+
phone_change_token = '',
|
|
1359
|
+
updated_at = now()
|
|
1360
|
+
where id = $1
|
|
1361
|
+
returning *`, [user.id]);
|
|
1362
|
+
const moved = done.rows[0] ?? user;
|
|
1363
|
+
await this.ensurePhoneIdentity(moved);
|
|
1364
|
+
return json(200, await this.sessionFor(moved));
|
|
1365
|
+
}
|
|
1021
1366
|
// ── admin ─────────────────────────────────────────────────────────────
|
|
1022
1367
|
async admin(req, ctx, path, method) {
|
|
1023
1368
|
if (ctx.role !== 'service_role') {
|
|
@@ -1039,17 +1384,27 @@ export class AuthHandler {
|
|
|
1039
1384
|
}
|
|
1040
1385
|
if (path === 'admin/users' && method === 'POST') {
|
|
1041
1386
|
const body = (await req.json().catch(() => ({})));
|
|
1042
|
-
if (!body.email)
|
|
1043
|
-
return authError(400, 'validation_failed', 'email is required');
|
|
1387
|
+
if (!body.email && !body.phone)
|
|
1388
|
+
return authError(400, 'validation_failed', 'email or phone is required');
|
|
1389
|
+
const phone = body.phone ? normalizePhone(body.phone) : null;
|
|
1390
|
+
if (body.phone && !phone)
|
|
1391
|
+
return AuthHandler.invalidPhone();
|
|
1044
1392
|
const hashed = body.password ? await hashPassword(body.password) : null;
|
|
1393
|
+
// The first provider named is the primary one, as GoTrue records it.
|
|
1394
|
+
const provider = body.email ? 'email' : 'phone';
|
|
1395
|
+
const providers = [...(body.email ? ['email'] : []), ...(phone ? ['phone'] : [])];
|
|
1045
1396
|
const res = await this.db.query(`insert into auth.users
|
|
1046
|
-
(aud, role, email, encrypted_password, email_confirmed_at,
|
|
1047
|
-
|
|
1397
|
+
(aud, role, email, phone, encrypted_password, email_confirmed_at, phone_confirmed_at,
|
|
1398
|
+
raw_app_meta_data, raw_user_meta_data)
|
|
1399
|
+
values ('authenticated', 'authenticated', $1, $2, $3,
|
|
1400
|
+
case when $4 then now() else null end, case when $5 then now() else null end, $6, $7)
|
|
1048
1401
|
returning *`, [
|
|
1049
|
-
body.email.toLowerCase().trim(),
|
|
1402
|
+
body.email ? body.email.toLowerCase().trim() : null,
|
|
1403
|
+
phone,
|
|
1050
1404
|
hashed,
|
|
1051
|
-
body.email_confirm ?? true,
|
|
1052
|
-
|
|
1405
|
+
!!body.email && (body.email_confirm ?? true),
|
|
1406
|
+
!!phone && (body.phone_confirm ?? true),
|
|
1407
|
+
JSON.stringify({ provider, providers, ...(body.app_metadata ?? {}) }),
|
|
1053
1408
|
JSON.stringify(body.user_metadata ?? {}),
|
|
1054
1409
|
]);
|
|
1055
1410
|
return json(200, this.userJson(res.rows[0]));
|
|
@@ -1068,6 +1423,13 @@ export class AuthHandler {
|
|
|
1068
1423
|
params.push(body.email.toLowerCase().trim());
|
|
1069
1424
|
sets.push(`email = $${params.length}`);
|
|
1070
1425
|
}
|
|
1426
|
+
if (typeof body.phone === 'string') {
|
|
1427
|
+
const phone = normalizePhone(body.phone);
|
|
1428
|
+
if (!phone)
|
|
1429
|
+
return AuthHandler.invalidPhone();
|
|
1430
|
+
params.push(phone);
|
|
1431
|
+
sets.push(`phone = $${params.length}`);
|
|
1432
|
+
}
|
|
1071
1433
|
if (typeof body.password === 'string') {
|
|
1072
1434
|
params.push(await hashPassword(body.password));
|
|
1073
1435
|
sets.push(`encrypted_password = $${params.length}`);
|
|
@@ -1082,6 +1444,8 @@ export class AuthHandler {
|
|
|
1082
1444
|
}
|
|
1083
1445
|
if (body.email_confirm === true)
|
|
1084
1446
|
sets.push(`email_confirmed_at = now()`);
|
|
1447
|
+
if (body.phone_confirm === true)
|
|
1448
|
+
sets.push(`phone_confirmed_at = now()`);
|
|
1085
1449
|
if (sets.length === 0)
|
|
1086
1450
|
return authError(400, 'validation_failed', 'nothing to update');
|
|
1087
1451
|
params.push(idMatch[1]);
|
|
@@ -1547,7 +1911,11 @@ export class AuthHandler {
|
|
|
1547
1911
|
new_email: u.email_change || undefined,
|
|
1548
1912
|
email_confirmed_at: iso(u.email_confirmed_at),
|
|
1549
1913
|
phone: u.phone ?? '',
|
|
1550
|
-
|
|
1914
|
+
new_phone: u.phone_change || undefined,
|
|
1915
|
+
phone_confirmed_at: iso(u.phone_confirmed_at),
|
|
1916
|
+
// GoTrue: the earlier of the two confirmations; an account confirmed by
|
|
1917
|
+
// either channel is confirmed.
|
|
1918
|
+
confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
|
|
1551
1919
|
last_sign_in_at: iso(u.last_sign_in_at),
|
|
1552
1920
|
app_metadata: u.raw_app_meta_data ?? {},
|
|
1553
1921
|
user_metadata: u.raw_user_meta_data ?? {},
|