tinbase 0.20.0 → 0.21.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.
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 four literals in SentAtColumn, never caller input.
152
- const res = await this.db.query(`select extract(epoch from (now() - ${column}))::int as elapsed
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
- if (row.elapsed !== null && row.elapsed < seconds) {
159
- return AuthHandler.tooSoon(Math.max(1, seconds - row.elapsed));
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: false,
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 this.limit('otp', req) ?? (await this.sendOtp(req, url));
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 this.limit('otp', req) ?? (await this.sendOtp(req, url));
240
+ return await this.sendOtp(req, url);
221
241
  if (path === 'resend' && method === 'POST')
222
- return this.limit('otp', req) ?? (await this.resend(req, url));
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 res = await this.db.query(`select * from auth.users where email = $1`, [email]);
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.autoconfirm && !user.email_confirmed_at) {
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 authError(400, 'validation_failed', `Resend with type "${body.type}" requires phone auth, which is not supported`);
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 normalizedEmail = email?.toLowerCase().trim() || null;
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 (!normalizedEmail && isGuessableToken(token))
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 email = $3) and expires_at > now()
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(',')}}`, normalizedEmail, AuthHandler.MAX_OTP_ATTEMPTS]);
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 (normalizedEmail) {
938
+ if (identifier) {
856
939
  await this.db.query(`update auth.one_time_tokens set attempts = attempts + 1
857
- where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [normalizedEmail, `{${types.join(',')}}`]);
858
- await this.db.query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [normalizedEmail, AuthHandler.MAX_OTP_ATTEMPTS]);
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
- await this.db.query(`delete from auth.one_time_tokens where email = $1`, [row.email]);
863
- const ures = await this.db.query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
864
- where id = $1 returning *`, [row.user_id]);
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,254 @@ 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
+ // GoTrue's shape for a provider that refused the text (bad number, over
1171
+ // the provider's limit, unreachable): the caller learns why, under the
1172
+ // code supabase-js clients already handle, rather than a bare 500.
1173
+ throw new SmsSendFailed(e instanceof Error ? e.message : String(e));
1174
+ }
1175
+ await this.markEmailSent(user.id, column);
1176
+ return messageId ?? undefined;
1177
+ }
1178
+ /** Create the account a phone OTP is for, with GoTrue's provider metadata. */
1179
+ async createPhoneUser(phone, data, extra) {
1180
+ const hashed = extra.password ? await hashPassword(extra.password) : null;
1181
+ const res = await this.db.query(`insert into auth.users
1182
+ (aud, role, phone, encrypted_password, phone_confirmed_at, last_sign_in_at,
1183
+ raw_app_meta_data, raw_user_meta_data)
1184
+ values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end, now(),
1185
+ '{"provider":"phone","providers":["phone"]}', $3)
1186
+ returning *`, [phone, hashed, JSON.stringify(data ?? {}), extra.confirmed]);
1187
+ const user = res.rows[0];
1188
+ await this.ensurePhoneIdentity(user);
1189
+ return user;
1190
+ }
1191
+ /** The `phone` identity GoTrue records, so `user.identities` names the provider. */
1192
+ async ensurePhoneIdentity(user) {
1193
+ await this.db.query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
1194
+ values ($1, 'phone', $2, $3)
1195
+ on conflict (provider, provider_id) do update
1196
+ set identity_data = excluded.identity_data, updated_at = now()`, [user.id, user.id, JSON.stringify({ sub: user.id, phone: user.phone })]);
1197
+ }
1198
+ /** `supabase.auth.signUp({ phone, password })`. */
1199
+ async signupWithPhone(req, body) {
1200
+ if (!this.settings.phoneEnabled)
1201
+ return AuthHandler.phoneDisabled();
1202
+ if (this.settings.disableSignup) {
1203
+ return authError(422, 'signup_disabled', 'Signups not allowed for this instance');
1204
+ }
1205
+ if (!body.password) {
1206
+ return authError(400, 'validation_failed', 'Signup requires a valid phone number and password');
1207
+ }
1208
+ const phone = normalizePhone(body.phone ?? '');
1209
+ if (!phone)
1210
+ return AuthHandler.invalidPhone();
1211
+ if (body.password.length < this.settings.minPasswordLength) {
1212
+ return authError(422, 'weak_password', `Password should be at least ${this.settings.minPasswordLength} characters.`);
1213
+ }
1214
+ const existing = await this.db.query(`select id from auth.users where phone = $1`, [phone]);
1215
+ if (existing.rows.length > 0) {
1216
+ return authError(422, 'user_already_exists', 'User already registered');
1217
+ }
1218
+ const autoconfirm = this.settings.smsAutoconfirm;
1219
+ if (!autoconfirm) {
1220
+ // The confirmation is a text, so it is paced like one - before the row
1221
+ // is written, so a refusal leaves no half-made account behind.
1222
+ const limited = this.limit('sms', req);
1223
+ if (limited)
1224
+ return limited;
1225
+ const tooSoon = await this.limitSmsFrequency(phone, 'confirmation_sent_at');
1226
+ if (tooSoon)
1227
+ return tooSoon;
1228
+ }
1229
+ const user = await this.createPhoneUser(phone, body.data, { password: body.password, confirmed: autoconfirm });
1230
+ await this.audit('user_signedup', { actorId: user.id, actorEmail: phone });
1231
+ if (!autoconfirm) {
1232
+ await this.sendSmsCode(user, phone, 'sms', 'confirmation_sent_at');
1233
+ return json(200, this.userJson(user));
1234
+ }
1235
+ return json(200, await this.sessionFor(user));
1236
+ }
1237
+ /** `supabase.auth.signInWithOtp({ phone })`: text a code, creating the account unless told not to. */
1238
+ async sendPhoneOtp(req, body) {
1239
+ if (!this.settings.phoneEnabled)
1240
+ return AuthHandler.phoneDisabled();
1241
+ const phone = normalizePhone(body.phone ?? '');
1242
+ if (!phone)
1243
+ return AuthHandler.invalidPhone();
1244
+ const limited = this.limit('sms', req);
1245
+ if (limited)
1246
+ return limited;
1247
+ const tooSoon = await this.limitSmsFrequency(phone, 'confirmation_sent_at');
1248
+ if (tooSoon)
1249
+ return tooSoon;
1250
+ const res = await this.db.query(`select * from auth.users where phone = $1`, [phone]);
1251
+ let user = res.rows[0];
1252
+ if (!user) {
1253
+ if (body.create_user === false)
1254
+ return authError(422, 'otp_disabled', 'Signups not allowed for otp');
1255
+ if (this.settings.disableSignup) {
1256
+ return authError(422, 'signup_disabled', 'Signups not allowed for this instance');
1257
+ }
1258
+ user = await this.createPhoneUser(phone, body.data, { confirmed: false });
1259
+ }
1260
+ const messageId = await this.sendSmsCode(user, phone, 'sms', 'confirmation_sent_at');
1261
+ return json(200, messageId ? { message_id: messageId } : {});
1262
+ }
1263
+ /**
1264
+ * `supabase.auth.resend({ phone, type: 'sms' | 'phone_change' })`.
1265
+ *
1266
+ * Like the email resend: 200 and no text for a number with nothing pending,
1267
+ * whether because it has no account, is already confirmed, or has no change
1268
+ * under way - so the endpoint cannot be used to find out which.
1269
+ */
1270
+ async resendSms(req, type, rawPhone) {
1271
+ if (!this.settings.phoneEnabled)
1272
+ return AuthHandler.phoneDisabled();
1273
+ if (!rawPhone)
1274
+ return authError(400, 'validation_failed', 'phone is required');
1275
+ const phone = normalizePhone(rawPhone);
1276
+ if (!phone)
1277
+ return AuthHandler.invalidPhone();
1278
+ const limited = this.limit('sms', req);
1279
+ if (limited)
1280
+ return limited;
1281
+ if (type === 'sms') {
1282
+ const res = await this.db.query(`select * from auth.users where phone = $1`, [phone]);
1283
+ const user = res.rows[0];
1284
+ if (!user || user.phone_confirmed_at)
1285
+ return json(200, {});
1286
+ const tooSoon = await this.limitSmsFrequency(phone, 'confirmation_sent_at');
1287
+ if (tooSoon)
1288
+ return tooSoon;
1289
+ const messageId = await this.sendSmsCode(user, phone, 'sms', 'confirmation_sent_at');
1290
+ return json(200, messageId ? { message_id: messageId } : {});
1291
+ }
1292
+ // phone_change: the number given is the new one, which is where the code goes.
1293
+ const res = await this.db.query(`select * from auth.users where phone_change = $1`, [phone]);
1294
+ const user = res.rows[0];
1295
+ if (!user)
1296
+ return json(200, {});
1297
+ const tooSoon = await this.limitPhoneChangeFrequency(user, phone);
1298
+ if (tooSoon)
1299
+ return tooSoon;
1300
+ const messageId = await this.sendSmsCode(user, phone, 'phone_change', 'phone_change_sent_at');
1301
+ return json(200, messageId ? { message_id: messageId } : {});
1302
+ }
1303
+ /**
1304
+ * `max_frequency` for a phone change: the account's own `phone_change_sent_at`
1305
+ * (the new number has no row to measure by), plus the in-memory window on
1306
+ * the new number itself so one account cannot be used to text it in a loop.
1307
+ */
1308
+ async limitPhoneChangeFrequency(user, newPhone) {
1309
+ const seconds = this.settings.maxSmsFrequencySeconds;
1310
+ if (!seconds || seconds <= 0)
1311
+ return null;
1312
+ const res = await this.db.query(`select phone_change_sent_at as sent_at from auth.users where id = $1`, [user.id]);
1313
+ const elapsed = secondsSince(res.rows[0]?.sent_at);
1314
+ if (elapsed !== null && elapsed < seconds)
1315
+ return AuthHandler.tooSoon(Math.max(1, seconds - elapsed), 'sms');
1316
+ return this.limitSmsFrequency(newPhone, 'phone_change_sent_at');
1317
+ }
1318
+ /**
1319
+ * Begin a phone change: park the new number and text it a code. The number
1320
+ * moves only when the code comes back through `/verify` type `phone_change`.
1321
+ * Returns a Response only when the change could not be started.
1322
+ */
1323
+ async startPhoneChange(user, newPhone) {
1324
+ const tooSoon = await this.limitPhoneChangeFrequency(user, newPhone);
1325
+ if (tooSoon)
1326
+ return tooSoon;
1327
+ 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]);
1328
+ // A second change replaces the first outright.
1329
+ await this.db.query(`delete from auth.one_time_tokens where user_id = $1 and token_type = 'phone_change'`, [user.id]);
1330
+ await this.sendSmsCode(user, newPhone, 'phone_change', 'phone_change_sent_at');
1331
+ return null;
1332
+ }
1333
+ /** `verifyOtp({ phone, token, type: 'sms' | 'phone_change' })` → session. */
1334
+ async verifySmsToken(type, rawPhone, token) {
1335
+ if (!rawPhone)
1336
+ return authError(400, 'validation_failed', 'phone is required');
1337
+ const phone = normalizePhone(rawPhone);
1338
+ if (!phone)
1339
+ return AuthHandler.invalidPhone();
1340
+ const user = await this.redeem(token, [type], { phone });
1341
+ if (!user)
1342
+ return authError(403, 'otp_expired', 'Token has expired or is invalid');
1343
+ if (type === 'sms') {
1344
+ await this.audit('login', { actorId: user.id, actorEmail: user.email || phone, traits: { provider: 'phone' } });
1345
+ return json(200, await this.sessionFor(user));
1346
+ }
1347
+ // phone_change: the code came back from the new number, so it moves in.
1348
+ // `nullif` guards a pending number cleared in between: better to keep the
1349
+ // number already held than to blank it.
1350
+ const done = await this.db.query(`update auth.users
1351
+ set phone = coalesce(nullif(phone_change, ''), phone),
1352
+ phone_confirmed_at = now(),
1353
+ phone_change = '',
1354
+ phone_change_token = '',
1355
+ updated_at = now()
1356
+ where id = $1
1357
+ returning *`, [user.id]);
1358
+ const moved = done.rows[0] ?? user;
1359
+ await this.ensurePhoneIdentity(moved);
1360
+ return json(200, await this.sessionFor(moved));
1361
+ }
1021
1362
  // ── admin ─────────────────────────────────────────────────────────────
1022
1363
  async admin(req, ctx, path, method) {
1023
1364
  if (ctx.role !== 'service_role') {
@@ -1039,17 +1380,27 @@ export class AuthHandler {
1039
1380
  }
1040
1381
  if (path === 'admin/users' && method === 'POST') {
1041
1382
  const body = (await req.json().catch(() => ({})));
1042
- if (!body.email)
1043
- return authError(400, 'validation_failed', 'email is required');
1383
+ if (!body.email && !body.phone)
1384
+ return authError(400, 'validation_failed', 'email or phone is required');
1385
+ const phone = body.phone ? normalizePhone(body.phone) : null;
1386
+ if (body.phone && !phone)
1387
+ return AuthHandler.invalidPhone();
1044
1388
  const hashed = body.password ? await hashPassword(body.password) : null;
1389
+ // The first provider named is the primary one, as GoTrue records it.
1390
+ const provider = body.email ? 'email' : 'phone';
1391
+ const providers = [...(body.email ? ['email'] : []), ...(phone ? ['phone'] : [])];
1045
1392
  const res = await this.db.query(`insert into auth.users
1046
- (aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
1047
- values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
1393
+ (aud, role, email, phone, encrypted_password, email_confirmed_at, phone_confirmed_at,
1394
+ raw_app_meta_data, raw_user_meta_data)
1395
+ values ('authenticated', 'authenticated', $1, $2, $3,
1396
+ case when $4 then now() else null end, case when $5 then now() else null end, $6, $7)
1048
1397
  returning *`, [
1049
- body.email.toLowerCase().trim(),
1398
+ body.email ? body.email.toLowerCase().trim() : null,
1399
+ phone,
1050
1400
  hashed,
1051
- body.email_confirm ?? true,
1052
- JSON.stringify({ provider: 'email', providers: ['email'], ...(body.app_metadata ?? {}) }),
1401
+ !!body.email && (body.email_confirm ?? true),
1402
+ !!phone && (body.phone_confirm ?? true),
1403
+ JSON.stringify({ provider, providers, ...(body.app_metadata ?? {}) }),
1053
1404
  JSON.stringify(body.user_metadata ?? {}),
1054
1405
  ]);
1055
1406
  return json(200, this.userJson(res.rows[0]));
@@ -1068,6 +1419,13 @@ export class AuthHandler {
1068
1419
  params.push(body.email.toLowerCase().trim());
1069
1420
  sets.push(`email = $${params.length}`);
1070
1421
  }
1422
+ if (typeof body.phone === 'string') {
1423
+ const phone = normalizePhone(body.phone);
1424
+ if (!phone)
1425
+ return AuthHandler.invalidPhone();
1426
+ params.push(phone);
1427
+ sets.push(`phone = $${params.length}`);
1428
+ }
1071
1429
  if (typeof body.password === 'string') {
1072
1430
  params.push(await hashPassword(body.password));
1073
1431
  sets.push(`encrypted_password = $${params.length}`);
@@ -1082,6 +1440,8 @@ export class AuthHandler {
1082
1440
  }
1083
1441
  if (body.email_confirm === true)
1084
1442
  sets.push(`email_confirmed_at = now()`);
1443
+ if (body.phone_confirm === true)
1444
+ sets.push(`phone_confirmed_at = now()`);
1085
1445
  if (sets.length === 0)
1086
1446
  return authError(400, 'validation_failed', 'nothing to update');
1087
1447
  params.push(idMatch[1]);
@@ -1547,7 +1907,11 @@ export class AuthHandler {
1547
1907
  new_email: u.email_change || undefined,
1548
1908
  email_confirmed_at: iso(u.email_confirmed_at),
1549
1909
  phone: u.phone ?? '',
1550
- confirmed_at: iso(u.email_confirmed_at),
1910
+ new_phone: u.phone_change || undefined,
1911
+ phone_confirmed_at: iso(u.phone_confirmed_at),
1912
+ // GoTrue: the earlier of the two confirmations; an account confirmed by
1913
+ // either channel is confirmed.
1914
+ confirmed_at: iso(u.email_confirmed_at ?? u.phone_confirmed_at),
1551
1915
  last_sign_in_at: iso(u.last_sign_in_at),
1552
1916
  app_metadata: u.raw_app_meta_data ?? {},
1553
1917
  user_metadata: u.raw_user_meta_data ?? {},