tinbase 0.16.2 → 0.18.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.
@@ -116,32 +116,65 @@ export class AuthHandler {
116
116
  return null;
117
117
  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
118
  }
119
+ /** GoTrue's 429 for `max_frequency`, with the seconds the caller must wait. */
120
+ static tooSoon(retryAfter) {
121
+ return new Response(JSON.stringify({
122
+ code: 429,
123
+ error_code: 'over_email_send_rate_limit',
124
+ msg: `For security purposes, you can only request this after ${retryAfter} seconds.`,
125
+ }), { status: 429, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(retryAfter) } });
126
+ }
119
127
  /**
120
- * Enforce `max_frequency`: refuse a second auth email to the same address
121
- * within the configured window (GoTrue's `over_email_send_rate_limit`).
128
+ * Enforce `max_frequency` the way GoTrue does: against the timestamp that
129
+ * flow last wrote on the user row, not against one budget per address.
122
130
  *
123
- * Keyed by recipient, not by caller, and checked before the account is looked
124
- * up. Both matter: keying by caller lets someone rotate addresses and keep
125
- * mailing one victim, and checking after the lookup would answer differently
126
- * for an address that exists than for one that does not - handing back the
127
- * account enumeration that answering 200 for unknown addresses exists to
128
- * prevent.
131
+ * Which column a flow measures itself by is GoTrue's mapping, quirks
132
+ * included - a magic link is measured by `recovery_sent_at`, the same column
133
+ * password recovery uses, because GoTrue mints both from the recovery token.
134
+ * Sharing that budget is therefore correct rather than an oversight; a
135
+ * confirmation, by contrast, has its own. An app that sets `max_frequency`
136
+ * in config.toml gets the pacing Supabase's docs describe, which is the
137
+ * point of spelling it the same way.
138
+ *
139
+ * An address with no account has no row to measure, so those fall back to an
140
+ * in-memory window keyed the same way. GoTrue simply lets them through,
141
+ * which makes a 429 proof that an account exists - `/recover` answers 200
142
+ * for an address it has never seen precisely so the response cannot be used
143
+ * to enumerate accounts, and a frequency check that only ever fires for real
144
+ * users hands that back. The fallback keeps the two indistinguishable.
129
145
  */
130
- limitEmailFrequency(email) {
146
+ async limitEmailFrequency(email, column) {
131
147
  const seconds = this.settings.maxEmailFrequencySeconds;
132
- if (!this.rateLimiter || !seconds || seconds <= 0)
148
+ if (!seconds || seconds <= 0)
149
+ return null;
150
+ 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]);
154
+ const row = res.rows[0];
155
+ if (row) {
156
+ // A row exists: the column is the authority, and it survives a restart,
157
+ // 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));
160
+ }
161
+ return null;
162
+ }
163
+ if (!this.rateLimiter)
133
164
  return null;
134
- const retryAfter = this.rateLimiter.check('email_frequency', email.toLowerCase().trim(), Date.now(), {
165
+ const retryAfter = this.rateLimiter.check(`email_frequency:${column}`, normalized, Date.now(), {
135
166
  limit: 1,
136
167
  windowMs: seconds * 1000,
137
168
  });
138
- if (retryAfter === null)
139
- return null;
140
- return new Response(JSON.stringify({
141
- code: 429,
142
- error_code: 'over_email_send_rate_limit',
143
- msg: `For security purposes, you can only request this after ${retryAfter} seconds.`,
144
- }), { status: 429, headers: { 'content-type': 'application/json; charset=utf-8', 'retry-after': String(retryAfter) } });
169
+ return retryAfter === null ? null : AuthHandler.tooSoon(retryAfter);
170
+ }
171
+ /**
172
+ * Record that this flow has just mailed the address, so the next request is
173
+ * measured from now. Written only after the mail was accepted - a transport
174
+ * that refused it has not spent the window.
175
+ */
176
+ async markEmailSent(userId, column) {
177
+ await this.db.query(`update auth.users set ${column} = now() where id = $1`, [userId]);
145
178
  }
146
179
  /** Stop background timers (rate-limiter sweep). Called on backend close. */
147
180
  stop() {
@@ -176,15 +209,17 @@ export class AuthHandler {
176
209
  if (path === 'user' && method === 'GET')
177
210
  return await this.getUser(req);
178
211
  if (path === 'user' && method === 'PUT')
179
- return await this.updateUser(req);
212
+ return await this.updateUser(req, url);
180
213
  if (path === 'logout' && method === 'POST')
181
214
  return await this.logout(req);
182
215
  if (path === 'otp' && method === 'POST')
183
216
  return this.limit('otp', req) ?? (await this.sendOtp(req, url));
184
217
  if (path === 'recover' && method === 'POST')
185
218
  return this.limit('recover', req) ?? (await this.sendRecovery(req, url));
186
- if (['magiclink', 'resend'].includes(path) && method === 'POST')
219
+ if (path === 'magiclink' && method === 'POST')
187
220
  return this.limit('otp', req) ?? (await this.sendOtp(req, url));
221
+ if (path === 'resend' && method === 'POST')
222
+ return this.limit('otp', req) ?? (await this.resend(req, url));
188
223
  if (path === 'verify' && method === 'POST')
189
224
  return this.limit('verify', req) ?? (await this.verifyToken(req));
190
225
  // Deliberately unlimited, unlike the POST above: a link carries no email,
@@ -246,8 +281,25 @@ export class AuthHandler {
246
281
  if (existing.rows.length > 0) {
247
282
  return authError(422, 'user_already_exists', 'User already registered');
248
283
  }
249
- const hashed = await hashPassword(body.password);
284
+ // A signup that has to be confirmed sends mail, so it belongs under the
285
+ // same per-recipient budget as /otp and /recover. Repeating a signup was
286
+ // never the way to abuse that - the second is refused as
287
+ // `user_already_exists` before any mail is considered - but the
288
+ // confirmation not counting meant the three flows that can mail an address
289
+ // each spent the window separately, putting more into one inbox than
290
+ // `max_frequency` allows.
291
+ //
292
+ // Checked before the row is written, so a refusal leaves no half-made
293
+ // account behind, and only when confirmation is on: under autoconfirm
294
+ // there is no email to pace, and spending the budget would block a flow
295
+ // that would actually use it.
250
296
  const autoconfirm = this.settings.autoconfirm;
297
+ if (!autoconfirm) {
298
+ const tooSoon = await this.limitEmailFrequency(email, 'confirmation_sent_at');
299
+ if (tooSoon)
300
+ return tooSoon;
301
+ }
302
+ const hashed = await hashPassword(body.password);
251
303
  const res = await this.db.query(`insert into auth.users
252
304
  (aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
253
305
  raw_app_meta_data, raw_user_meta_data)
@@ -334,7 +386,7 @@ export class AuthHandler {
334
386
  return authError(401, 'no_authorization', 'Invalid or expired token');
335
387
  return json(200, this.userJson(user, await this.getUserFactors(user.id), await this.getUserIdentities(user.id)));
336
388
  }
337
- async updateUser(req) {
389
+ async updateUser(req, url) {
338
390
  const user = await this.userFromBearer(req);
339
391
  if (!user)
340
392
  return authError(401, 'no_authorization', 'Invalid or expired token');
@@ -345,14 +397,24 @@ export class AuthHandler {
345
397
  // usually a password) keeps the same id + data, flips is_anonymous off, and
346
398
  // records an email identity - matching supabase.auth.updateUser({ email }).
347
399
  const upgradingAnon = (user.is_anonymous ?? false) && !!body.email;
400
+ // Changing the address on an account that already has one is a different
401
+ // operation from naming one for the first time, and only the first is an
402
+ // email change. An anonymous upgrade has no address to confirm from and
403
+ // nothing to lose if it is wrong, so it keeps taking effect immediately.
404
+ let pendingEmail = null;
348
405
  if (body.email) {
349
406
  const email = body.email.toLowerCase().trim();
350
407
  const clash = await this.db.query(`select id from auth.users where email = $1 and id <> $2`, [email, user.id]);
351
408
  if (clash.rows.length > 0) {
352
409
  return authError(422, 'email_exists', 'A user with this email address has already been registered');
353
410
  }
354
- params.push(email);
355
- sets.push(`email = $${params.length}, email_confirmed_at = now()`);
411
+ if (upgradingAnon || this.settings.autoconfirm || email === (user.email ?? '').toLowerCase()) {
412
+ params.push(email);
413
+ sets.push(`email = $${params.length}, email_confirmed_at = now()`);
414
+ }
415
+ else {
416
+ pendingEmail = email;
417
+ }
356
418
  }
357
419
  if (body.password) {
358
420
  if (body.password.length < this.settings.minPasswordLength) {
@@ -369,19 +431,128 @@ export class AuthHandler {
369
431
  sets.push(`is_anonymous = false`);
370
432
  sets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
371
433
  }
372
- if (sets.length === 0)
373
- return json(200, this.userJson(user));
374
- params.push(user.id);
375
- const res = await this.db.query(`update auth.users set ${sets.join(', ')}, updated_at = now() where id = $${params.length} returning *`, params);
376
- const updated = res.rows[0];
434
+ let updated = user;
435
+ if (sets.length > 0) {
436
+ params.push(user.id);
437
+ const res = await this.db.query(`update auth.users set ${sets.join(', ')}, updated_at = now() where id = $${params.length} returning *`, params);
438
+ updated = res.rows[0];
439
+ }
377
440
  if (upgradingAnon) {
378
441
  // record the email identity, unless one somehow already exists
379
442
  await this.db.query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
380
443
  values ($1, 'email', $2, $3)
381
444
  on conflict (provider, provider_id) do nothing`, [updated.id, updated.id, JSON.stringify({ sub: updated.id, email: updated.email })]);
382
445
  }
446
+ if (pendingEmail) {
447
+ const started = await this.startEmailChange(updated, pendingEmail, url.searchParams.get('redirect_to'));
448
+ if (started)
449
+ return started;
450
+ const res = await this.db.query(`select * from auth.users where id = $1`, [updated.id]);
451
+ updated = res.rows[0] ?? updated;
452
+ }
383
453
  return json(200, this.userJson(updated));
384
454
  }
455
+ /**
456
+ * Begin an email change: park the new address, mint a token for each side,
457
+ * and mail them.
458
+ *
459
+ * The address does not move until the tokens come back. Writing it straight
460
+ * to `auth.users.email` - which is what this did - means a session is all it
461
+ * takes to walk an account to an address of the holder's choosing, with no
462
+ * confirmation from either end and nothing sent to the address losing it.
463
+ *
464
+ * With `secure_email_change_enabled` (the Supabase default) both ends have
465
+ * to click: the new address proves it exists and is reachable, and the
466
+ * current one gets a say in losing the account. The second token is what
467
+ * makes a stolen session insufficient on its own.
468
+ *
469
+ * Returns a Response only when the change could not be started.
470
+ */
471
+ async startEmailChange(user, newEmail, redirectTo) {
472
+ const tooSoon = await this.limitEmailFrequency(user.email ?? newEmail, 'email_change_sent_at');
473
+ if (tooSoon)
474
+ return tooSoon;
475
+ const { newToken, currentToken } = await this.mintEmailChangeTokens(user, newEmail);
476
+ const redirect = resolveRedirect(redirectTo, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
477
+ await this.mailEmailChange(user, newEmail, newEmail, newToken, 'email_change_new', redirect, redirectTo !== null);
478
+ if (currentToken) {
479
+ await this.mailEmailChange(user, user.email, newEmail, currentToken, 'email_change_current', redirect, redirectTo !== null);
480
+ }
481
+ return null;
482
+ }
483
+ /**
484
+ * Park the new address and mint a token per side, without sending anything.
485
+ *
486
+ * Shared with `admin/generate_link`, which needs the same tokens but hands
487
+ * them to the caller. One implementation so the two cannot disagree about
488
+ * how many sides there are or when the previous attempt is invalidated.
489
+ */
490
+ async mintEmailChangeTokens(user, newEmail) {
491
+ const secure = this.settings.secureEmailChange && !!user.email;
492
+ const expiry = `${this.settings.otpExpirySeconds} seconds`;
493
+ const newToken = randomToken(24);
494
+ const currentToken = secure ? randomToken(24) : null;
495
+ // A second change replaces the first outright: leaving the old tokens live
496
+ // would let a link for a superseded address still land.
497
+ await this.db.query(`delete from auth.one_time_tokens where user_id = $1 and token_type in ('email_change_new', 'email_change_current')`, [user.id]);
498
+ await this.db.query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
499
+ values ($1, $2, 'email_change_new', $3, now() + $4::interval)`, [user.id, newEmail, newToken, expiry]);
500
+ if (currentToken) {
501
+ await this.db.query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
502
+ values ($1, $2, 'email_change_current', $3, now() + $4::interval)`, [user.id, user.email, currentToken, expiry]);
503
+ }
504
+ await this.db.query(`update auth.users
505
+ set email_change = $2, email_change_sent_at = now(), email_change_confirm_status = 0,
506
+ updated_at = now()
507
+ where id = $1`, [user.id, newEmail]);
508
+ return { newToken, currentToken };
509
+ }
510
+ /** One side of an email change, to whichever address that side belongs to. */
511
+ async mailEmailChange(user, to, pending, token, action, redirect, carryRedirect) {
512
+ let link = `${this.apiUrl}/auth/v1/verify?token=${token}&type=email_change`;
513
+ if (carryRedirect)
514
+ link += `&redirect_to=${encodeURIComponent(redirect)}`;
515
+ if (this.config.sendEmailHook) {
516
+ // GoTrue sends one payload carrying both sides, so an endpoint can word
517
+ // them differently. token/token_hash are the side being asked, and
518
+ // token_new/token_hash_new the new address's - the fields that have been
519
+ // empty strings here since the hook was added, for want of this flow.
520
+ const isNew = action === 'email_change_new';
521
+ await callSendEmailHook(this.config.sendEmailHook, {
522
+ user: this.userJson(user),
523
+ email_data: {
524
+ token: '',
525
+ token_hash: token,
526
+ redirect_to: redirect,
527
+ email_action_type: action,
528
+ site_url: this.config.siteUrl,
529
+ token_new: '',
530
+ token_hash_new: isNew ? token : '',
531
+ },
532
+ }, this.config.hookFetch);
533
+ return;
534
+ }
535
+ const template = this.config.emailTemplates?.email_change;
536
+ const lead = action === 'email_change_new'
537
+ ? 'Confirm this address to finish moving your account to it:'
538
+ : `Confirm moving your account to ${pending}:`;
539
+ const html = template?.content
540
+ ? renderTemplate(template.content, {
541
+ ConfirmationURL: link,
542
+ Token: '',
543
+ TokenHash: token,
544
+ RedirectTo: redirect,
545
+ SiteURL: this.config.siteUrl,
546
+ Email: to,
547
+ })
548
+ : authEmailHtml({ lead, action: 'Confirm email change', link, code: null });
549
+ await this.config.mailer.send({
550
+ to,
551
+ subject: template?.subject ?? 'Confirm your email change',
552
+ text: template?.content ? htmlToText(html) : `${lead} ${link}`,
553
+ html,
554
+ });
555
+ }
385
556
  /**
386
557
  * POST /auth/v1/logout[?scope=global|local|others]
387
558
  *
@@ -478,6 +649,9 @@ export class AuthHandler {
478
649
  const { code, linkToken } = minted;
479
650
  const normalized = email.toLowerCase().trim();
480
651
  const kind = tokenType === 'otp' ? 'magiclink' : tokenType;
652
+ // GoTrue's mapping: a confirmation has its own budget, while a magic link
653
+ // and a recovery share `recovery_sent_at`.
654
+ const sentAtColumn = flavor === 'confirm' ? 'confirmation_sent_at' : 'recovery_sent_at';
481
655
  let link = `${this.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
482
656
  const redirectTo = resolveRedirect(opts.redirectTo, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
483
657
  if (opts.redirectTo)
@@ -516,6 +690,7 @@ export class AuthHandler {
516
690
  token_hash_new: '',
517
691
  },
518
692
  }, this.config.hookFetch);
693
+ await this.markEmailSent(minted.user.id, sentAtColumn);
519
694
  return json(200, {});
520
695
  }
521
696
  const templateName = tokenType === 'recovery' ? 'recovery' : flavor === 'confirm' ? 'confirmation' : 'magic_link';
@@ -545,13 +720,14 @@ export class AuthHandler {
545
720
  text: template?.content ? htmlToText(html) : defaultText,
546
721
  html,
547
722
  });
723
+ await this.markEmailSent(minted.user.id, sentAtColumn);
548
724
  return json(200, {});
549
725
  }
550
726
  async sendOtp(req, url) {
551
727
  const body = (await req.json().catch(() => ({})));
552
728
  if (!body.email)
553
729
  return authError(400, 'validation_failed', 'email is required');
554
- const tooSoon = this.limitEmailFrequency(body.email);
730
+ const tooSoon = await this.limitEmailFrequency(body.email, 'recovery_sent_at');
555
731
  if (tooSoon)
556
732
  return tooSoon;
557
733
  return this.issueToken(body.email, 'otp', body.create_user !== false, {
@@ -559,11 +735,61 @@ export class AuthHandler {
559
735
  ...AuthHandler.pkceFrom(body),
560
736
  });
561
737
  }
738
+ /**
739
+ * POST /auth/v1/resend - send the confirmation for `type` again.
740
+ *
741
+ * This used to be an alias for /magiclink, which is a different email
742
+ * entirely: it mails a login link measured by `recovery_sent_at`, creates an
743
+ * account for an address it has never seen, and pays no attention to `type`.
744
+ * So a resend for an address with no account signed that person up and sent
745
+ * them a way in, and a resend for a signup was paced by the recovery window.
746
+ *
747
+ * GoTrue answers 200 with an empty body both for an address it does not know
748
+ * and for one already confirmed - the two look identical from outside, which
749
+ * is what stops the endpoint from reporting who has an account. No mail goes
750
+ * out in either case.
751
+ */
752
+ async resend(req, url) {
753
+ const body = (await req.json().catch(() => ({})));
754
+ if (!body.type)
755
+ return authError(400, 'validation_failed', 'Missing one of these arguments: type');
756
+ if (body.type === 'sms' || body.type === 'phone_change') {
757
+ return authError(400, 'validation_failed', `Resend with type "${body.type}" requires phone auth, which is not supported`);
758
+ }
759
+ if (body.type === 'email_change') {
760
+ // The email-change flow itself does not exist yet, so there is no
761
+ // pending address to confirm. Say that, rather than resending something
762
+ // else and calling it an email change.
763
+ return authError(400, 'validation_failed', 'Resend type "email_change" is not supported yet');
764
+ }
765
+ if (body.type !== 'signup') {
766
+ return authError(400, 'validation_failed', `Resend requires a valid type: ${body.type}`);
767
+ }
768
+ if (!body.email)
769
+ return authError(400, 'validation_failed', 'email is required');
770
+ const normalized = body.email.toLowerCase().trim();
771
+ const res = await this.db.query(`select id, email_confirmed_at from auth.users where email = $1`, [normalized]);
772
+ const user = res.rows[0];
773
+ // Nothing to confirm, either because there is no account or because it is
774
+ // already confirmed. Answered the same way, and without creating anything:
775
+ // a resend is a repeat of a mail the address has already been sent, never
776
+ // the thing that signs someone up.
777
+ if (!user || user.email_confirmed_at)
778
+ return json(200, {});
779
+ const tooSoon = await this.limitEmailFrequency(normalized, 'confirmation_sent_at');
780
+ if (tooSoon)
781
+ return tooSoon;
782
+ return this.issueToken(normalized, 'otp', false, {
783
+ flavor: 'confirm',
784
+ redirectTo: url.searchParams.get('redirect_to'),
785
+ ...AuthHandler.pkceFrom(body),
786
+ });
787
+ }
562
788
  async sendRecovery(req, url) {
563
789
  const body = (await req.json().catch(() => ({})));
564
790
  if (!body.email)
565
791
  return authError(400, 'validation_failed', 'email is required');
566
- const tooSoon = this.limitEmailFrequency(body.email);
792
+ const tooSoon = await this.limitEmailFrequency(body.email, 'recovery_sent_at');
567
793
  if (tooSoon)
568
794
  return tooSoon;
569
795
  // GoTrue answers 200 for an address it has never seen, so the response
@@ -627,10 +853,68 @@ export class AuthHandler {
627
853
  static redeemTypes(type) {
628
854
  if (type === 'recovery')
629
855
  return ['recovery'];
856
+ if (type === 'email_change')
857
+ return ['email_change_new', 'email_change_current'];
630
858
  if (type === 'magiclink')
631
859
  return ['magiclink'];
632
860
  return ['otp', 'magiclink'];
633
861
  }
862
+ /** What a caller is told when one side of an email change is still outstanding. */
863
+ static EMAIL_CHANGE_PENDING_MSG = 'Confirmation link accepted. Please proceed to confirm link sent to the other email';
864
+ /**
865
+ * Redeem one side of an email change.
866
+ *
867
+ * Not routed through {@link redeem}, which exists for the login and recovery
868
+ * tokens and does things that are wrong here: it clears every live token for
869
+ * the address and stamps `last_sign_in_at`, and an email change is not a
870
+ * sign-in. It also cannot express the part that matters - that one click may
871
+ * not be enough.
872
+ *
873
+ * The address moves only once no side is left outstanding. With
874
+ * `secure_email_change_enabled` that means both tokens, in either order;
875
+ * with it off there is only ever the new address's. Deciding it by what
876
+ * remains in the table, rather than by counting clicks, is what makes the
877
+ * order irrelevant and a replay harmless - the row is gone the moment it is
878
+ * redeemed.
879
+ */
880
+ async verifyEmailChange(token) {
881
+ const res = await this.db.query(`delete from auth.one_time_tokens
882
+ where token = $1 and token_type in ('email_change_new', 'email_change_current')
883
+ and expires_at > now()
884
+ returning user_id`, [token]);
885
+ const row = res.rows[0];
886
+ if (!row)
887
+ return { status: 'invalid' };
888
+ const outstanding = await this.db.query(`select 1 from auth.one_time_tokens
889
+ where user_id = $1 and token_type in ('email_change_new', 'email_change_current')
890
+ and expires_at > now()`, [row.user_id]);
891
+ if (outstanding.rows.length > 0) {
892
+ await this.db.query(`update auth.users set email_change_confirm_status = 1, updated_at = now() where id = $1`, [row.user_id]);
893
+ return { status: 'pending' };
894
+ }
895
+ // `nullif` guards the case where the pending address was cleared in
896
+ // between: better to confirm the address already held than to blank it.
897
+ const done = await this.db.query(`update auth.users
898
+ set email = coalesce(nullif(email_change, ''), email),
899
+ email_confirmed_at = now(),
900
+ email_change = '',
901
+ email_change_token_new = '',
902
+ email_change_token_current = '',
903
+ email_change_confirm_status = 0,
904
+ updated_at = now()
905
+ where id = $1
906
+ returning *`, [row.user_id]);
907
+ const user = done.rows[0];
908
+ if (!user)
909
+ return { status: 'invalid' };
910
+ // The email identity carries the address too, and getUser reads it back
911
+ // through user.identities - left alone it would still name the old one.
912
+ await this.db.query(`update auth.identities
913
+ set identity_data = coalesce(identity_data, '{}'::jsonb) || jsonb_build_object('email', $2::text),
914
+ updated_at = now()
915
+ where user_id = $1 and provider = 'email'`, [user.id, user.email]);
916
+ return { status: 'done', user };
917
+ }
634
918
  async verifyToken(req) {
635
919
  const body = (await req.json().catch(() => ({})));
636
920
  // supabase-js sends `token_hash` for verifyOtp({ token_hash }) - the shape
@@ -640,6 +924,14 @@ export class AuthHandler {
640
924
  const token = body.token ?? body.token_hash;
641
925
  if (!token)
642
926
  return authError(400, 'validation_failed', 'token is required');
927
+ if (body.type === 'email_change') {
928
+ const outcome = await this.verifyEmailChange(token);
929
+ if (outcome.status === 'invalid')
930
+ return authError(403, 'otp_expired', 'Token has expired or is invalid');
931
+ if (outcome.status === 'pending')
932
+ return json(200, { msg: AuthHandler.EMAIL_CHANGE_PENDING_MSG });
933
+ return json(200, await this.sessionFor(outcome.user));
934
+ }
643
935
  const user = await this.redeem(token, AuthHandler.redeemTypes(body.type), body.email);
644
936
  if (!user)
645
937
  return authError(403, 'otp_expired', 'Token has expired or is invalid');
@@ -655,6 +947,33 @@ export class AuthHandler {
655
947
  // operator hasn't allowed - a crafted magic-link would otherwise exfiltrate
656
948
  // the session. Unknown targets fall back to the site URL.
657
949
  const redirectTo = resolveRedirect(url.searchParams.get('redirect_to'), this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
950
+ if (type === 'email_change') {
951
+ const outcome = await this.verifyEmailChange(token);
952
+ if (outcome.status === 'invalid') {
953
+ return new Response(null, {
954
+ status: 303,
955
+ headers: { location: `${redirectTo}#error=access_denied&error_code=otp_expired` },
956
+ });
957
+ }
958
+ if (outcome.status === 'pending') {
959
+ // Nothing to hand back yet - the account is still on its old address
960
+ // until the other side answers, so there is no session to mint.
961
+ return new Response(null, {
962
+ status: 303,
963
+ headers: {
964
+ location: `${redirectTo}#message=${encodeURIComponent(AuthHandler.EMAIL_CHANGE_PENDING_MSG)}`,
965
+ },
966
+ });
967
+ }
968
+ const changed = (await this.sessionFor(outcome.user));
969
+ return new Response(null, {
970
+ status: 303,
971
+ headers: {
972
+ location: `${redirectTo}#access_token=${changed.access_token}&refresh_token=${changed.refresh_token}` +
973
+ `&expires_in=${changed.expires_in}&token_type=bearer&type=email_change`,
974
+ },
975
+ });
976
+ }
658
977
  const user = await this.redeem(token, AuthHandler.redeemTypes(type));
659
978
  if (!user) {
660
979
  return new Response(null, { status: 303, headers: { location: `${redirectTo}#error=access_denied&error_code=otp_expired` } });
@@ -775,11 +1094,10 @@ export class AuthHandler {
775
1094
  if (!body.email)
776
1095
  return authError(400, 'validation_failed', 'email is required');
777
1096
  const type = body.type ?? 'magiclink';
778
- // email_change_* would have to mint a token against a pending new address,
779
- // which needs the email-change plumbing this handler doesn't have yet. Say
780
- // so rather than returning a link that verifies as a plain login.
1097
+ // The two sides of an email change mint against a pending address, so they
1098
+ // need `new_email` as well and cannot share the flow below.
781
1099
  if (type === 'email_change_current' || type === 'email_change_new') {
782
- return authError(400, 'validation_failed', `generate_link type "${type}" is not supported yet`);
1100
+ return await this.generateEmailChangeLink(type, body);
783
1101
  }
784
1102
  if (!['signup', 'invite', 'magiclink', 'recovery'].includes(type)) {
785
1103
  return authError(400, 'validation_failed', `unsupported generate_link type: ${type}`);
@@ -821,6 +1139,48 @@ export class AuthHandler {
821
1139
  verification_type: type,
822
1140
  });
823
1141
  }
1142
+ /**
1143
+ * `admin.generateLink({ type: 'email_change_current' | 'email_change_new' })`.
1144
+ *
1145
+ * Both sides are minted whichever is asked for - they are one change, and
1146
+ * minting only the requested half would leave a secure change that can never
1147
+ * complete. The caller gets the link for the side it named.
1148
+ */
1149
+ async generateEmailChangeLink(type, body) {
1150
+ if (!body.email)
1151
+ return authError(400, 'validation_failed', 'email is required');
1152
+ if (!body.new_email)
1153
+ return authError(400, 'validation_failed', `${type} requires new_email`);
1154
+ const current = body.email.toLowerCase().trim();
1155
+ const next = body.new_email.toLowerCase().trim();
1156
+ const res = await this.db.query(`select * from auth.users where email = $1`, [current]);
1157
+ const user = res.rows[0];
1158
+ if (!user)
1159
+ return authError(404, 'user_not_found', 'User not found');
1160
+ const clash = await this.db.query(`select id from auth.users where email = $1 and id <> $2`, [next, user.id]);
1161
+ if (clash.rows.length > 0) {
1162
+ return authError(422, 'email_exists', 'A user with this address has already been registered');
1163
+ }
1164
+ const { newToken, currentToken } = await this.mintEmailChangeTokens(user, next);
1165
+ // Asking for the current side when secure email change is off: there is no
1166
+ // such token, and returning the new one under that name would quietly hand
1167
+ // back a link that confirms the wrong half.
1168
+ if (type === 'email_change_current' && !currentToken) {
1169
+ return authError(400, 'validation_failed', 'email_change_current requires secure_email_change_enabled; only the new address is confirmed');
1170
+ }
1171
+ const token = type === 'email_change_new' ? newToken : currentToken;
1172
+ const redirectTo = resolveRedirect(body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
1173
+ await this.audit('generate_link', { actorId: user.id, actorEmail: user.email, traits: { type } });
1174
+ return json(200, {
1175
+ ...this.userJson(user),
1176
+ action_link: `${this.apiUrl}/auth/v1/verify?token=${token}&type=email_change` +
1177
+ `&redirect_to=${encodeURIComponent(redirectTo)}`,
1178
+ email_otp: '',
1179
+ hashed_token: token,
1180
+ redirect_to: redirectTo,
1181
+ verification_type: type,
1182
+ });
1183
+ }
824
1184
  // ── audit trail ───────────────────────────────────────────────────────
825
1185
  /**
826
1186
  * Append a security event to auth.audit_log_entries (GoTrue-compatible
@@ -1095,6 +1455,10 @@ export class AuthHandler {
1095
1455
  aud: u.aud ?? 'authenticated',
1096
1456
  role: u.role ?? 'authenticated',
1097
1457
  email: u.email ?? '',
1458
+ // GoTrue reports a change that has been asked for but not yet confirmed,
1459
+ // so a client can say "we've emailed <new_email>" instead of showing the
1460
+ // old address with no sign anything is pending.
1461
+ new_email: u.email_change || undefined,
1098
1462
  email_confirmed_at: iso(u.email_confirmed_at),
1099
1463
  phone: u.phone ?? '',
1100
1464
  confirmed_at: iso(u.email_confirmed_at),