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.
- package/dist/auth/handler.d.ts +94 -8
- package/dist/auth/handler.js +399 -35
- package/dist/auth/handler.js.map +1 -1
- package/dist/auth/settings.d.ts +11 -0
- package/dist/auth/settings.js +3 -0
- package/dist/auth/settings.js.map +1 -1
- package/dist/cli.js +18 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/node/load-config.js +3 -0
- package/dist/node/load-config.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/package.json +1 -1
package/dist/auth/handler.js
CHANGED
|
@@ -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
|
|
121
|
-
*
|
|
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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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 (!
|
|
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(
|
|
165
|
+
const retryAfter = this.rateLimiter.check(`email_frequency:${column}`, normalized, Date.now(), {
|
|
135
166
|
limit: 1,
|
|
136
167
|
windowMs: seconds * 1000,
|
|
137
168
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
//
|
|
779
|
-
//
|
|
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
|
|
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),
|