tinbase 0.16.1 → 0.17.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.
@@ -13,8 +13,29 @@ import { RateLimiter } from './rate-limit.js';
13
13
  export interface AuthConfig {
14
14
  /** HS256 secret used to sign and verify access tokens */
15
15
  jwtSecret: string;
16
- /** public base URL of this instance; used as issuer and default redirect target */
16
+ /**
17
+ * Where the *application* lives. The default redirect when a link carries no
18
+ * `redirect_to` or one the allowlist refuses, and what `{{ .SiteURL }}`
19
+ * interpolates to in an email template.
20
+ *
21
+ * This is GoTrue's `SITE_URL`, and it is not where this server answers - see
22
+ * {@link apiExternalUrl}.
23
+ */
17
24
  siteUrl: string;
25
+ /**
26
+ * Where *this server* answers, as a mail client can reach it. Emailed links
27
+ * are built on it and it is the token issuer.
28
+ *
29
+ * Separate from {@link siteUrl} because the two are different machines: a
30
+ * link has to come back here to be verified, while a user who finishes the
31
+ * flow belongs on the app. Collapsing them forces a choice between links
32
+ * that resolve and a redirect that lands somewhere useful - set to the API,
33
+ * every fallback dumps the user on this server's root; set to the app, no
34
+ * emailed link works at all.
35
+ *
36
+ * Defaults to {@link siteUrl}, which is the pre-0.17 behaviour.
37
+ */
38
+ apiExternalUrl?: string;
18
39
  /** Access-token lifetime in seconds. */
19
40
  jwtExpiry: number;
20
41
  /** Force sign-out after this many seconds (config.toml auth.sessions.timebox). Caps session lifetime. */
@@ -88,23 +109,45 @@ export declare class AuthHandler {
88
109
  private settings;
89
110
  private rateLimiter;
90
111
  constructor(db: Database, config: AuthConfig);
112
+ /**
113
+ * Where this server answers: emailed links come back here, and tokens are
114
+ * issued by it. Falls back to the site URL, which is what a deployment that
115
+ * has not separated the two is already using for both.
116
+ */
117
+ private get apiUrl();
91
118
  /**
92
119
  * Enforce the rate limit for `action`, keyed by client address. Returns a 429
93
120
  * (GoTrue's `over_request_rate_limit`) when exceeded, else null to proceed.
94
121
  */
95
122
  private limit;
123
+ /** GoTrue's 429 for `max_frequency`, with the seconds the caller must wait. */
124
+ private static tooSoon;
96
125
  /**
97
- * Enforce `max_frequency`: refuse a second auth email to the same address
98
- * within the configured window (GoTrue's `over_email_send_rate_limit`).
126
+ * Enforce `max_frequency` the way GoTrue does: against the timestamp that
127
+ * flow last wrote on the user row, not against one budget per address.
128
+ *
129
+ * Which column a flow measures itself by is GoTrue's mapping, quirks
130
+ * included - a magic link is measured by `recovery_sent_at`, the same column
131
+ * password recovery uses, because GoTrue mints both from the recovery token.
132
+ * Sharing that budget is therefore correct rather than an oversight; a
133
+ * confirmation, by contrast, has its own. An app that sets `max_frequency`
134
+ * in config.toml gets the pacing Supabase's docs describe, which is the
135
+ * point of spelling it the same way.
99
136
  *
100
- * Keyed by recipient, not by caller, and checked before the account is looked
101
- * up. Both matter: keying by caller lets someone rotate addresses and keep
102
- * mailing one victim, and checking after the lookup would answer differently
103
- * for an address that exists than for one that does not - handing back the
104
- * account enumeration that answering 200 for unknown addresses exists to
105
- * prevent.
137
+ * An address with no account has no row to measure, so those fall back to an
138
+ * in-memory window keyed the same way. GoTrue simply lets them through,
139
+ * which makes a 429 proof that an account exists - `/recover` answers 200
140
+ * for an address it has never seen precisely so the response cannot be used
141
+ * to enumerate accounts, and a frequency check that only ever fires for real
142
+ * users hands that back. The fallback keeps the two indistinguishable.
106
143
  */
107
144
  private limitEmailFrequency;
145
+ /**
146
+ * Record that this flow has just mailed the address, so the next request is
147
+ * measured from now. Written only after the mail was accepted - a transport
148
+ * that refused it has not spent the window.
149
+ */
150
+ private markEmailSent;
108
151
  /** Stop background timers (rate-limiter sweep). Called on backend close. */
109
152
  stop(): void;
110
153
  /** Dispatch one `/auth/v1/*` request. Any thrown error becomes a 500 `unexpected_failure`. */
@@ -153,6 +196,21 @@ export declare class AuthHandler {
153
196
  */
154
197
  private issueToken;
155
198
  private sendOtp;
199
+ /**
200
+ * POST /auth/v1/resend - send the confirmation for `type` again.
201
+ *
202
+ * This used to be an alias for /magiclink, which is a different email
203
+ * entirely: it mails a login link measured by `recovery_sent_at`, creates an
204
+ * account for an address it has never seen, and pays no attention to `type`.
205
+ * So a resend for an address with no account signed that person up and sent
206
+ * them a way in, and a resend for a signup was paced by the recovery window.
207
+ *
208
+ * GoTrue answers 200 with an empty body both for an address it does not know
209
+ * and for one already confirmed - the two look identical from outside, which
210
+ * is what stops the endpoint from reporting who has an account. No mail goes
211
+ * out in either case.
212
+ */
213
+ private resend;
156
214
  private sendRecovery;
157
215
  /** Max wrong guesses for a one-time code before its tokens are invalidated. */
158
216
  private static readonly MAX_OTP_ATTEMPTS;
@@ -45,6 +45,21 @@ function authEmailHtml(o) {
45
45
  ].join('');
46
46
  }
47
47
  /** A cryptographically-random numeric OTP of `length` digits (6-10). */
48
+ /**
49
+ * Whether knowing `token` proves nothing on its own.
50
+ *
51
+ * An OTP is six to ten digits - a few hundred thousand guesses, which is
52
+ * nothing without the address it was sent to. The link token is 24 random
53
+ * bytes in base64url, which is not guessable and is all an emailed link can
54
+ * carry, so it stays redeemable unscoped.
55
+ *
56
+ * Decided from the token itself rather than from `token_type`, because both
57
+ * rows a recovery mints share one type - the shape is the only honest
58
+ * discriminator.
59
+ */
60
+ function isGuessableToken(token) {
61
+ return token.length < 20 || /^\d+$/.test(token);
62
+ }
48
63
  function randomOtp(length) {
49
64
  const n = Math.max(6, Math.min(10, Math.floor(length)));
50
65
  const buf = new Uint32Array(n);
@@ -76,10 +91,18 @@ export class AuthHandler {
76
91
  constructor(db, config) {
77
92
  this.db = db;
78
93
  this.config = config;
79
- this.oauth = new OAuthService(db, config.siteUrl, config.oauthProviders ?? {}, config.oauthFetch ?? fetch, config.uriAllowList, config.enforceRedirectAllowList);
94
+ this.oauth = new OAuthService(db, config.siteUrl, config.apiExternalUrl || config.siteUrl, config.oauthProviders ?? {}, config.oauthFetch ?? fetch, config.uriAllowList, config.enforceRedirectAllowList);
80
95
  this.settings = config.settings ?? { ...DEFAULT_AUTH_SETTINGS };
81
96
  this.rateLimiter = config.rateLimiter === undefined ? new RateLimiter() : config.rateLimiter;
82
97
  }
98
+ /**
99
+ * Where this server answers: emailed links come back here, and tokens are
100
+ * issued by it. Falls back to the site URL, which is what a deployment that
101
+ * has not separated the two is already using for both.
102
+ */
103
+ get apiUrl() {
104
+ return this.config.apiExternalUrl || this.config.siteUrl;
105
+ }
83
106
  /**
84
107
  * Enforce the rate limit for `action`, keyed by client address. Returns a 429
85
108
  * (GoTrue's `over_request_rate_limit`) when exceeded, else null to proceed.
@@ -93,32 +116,65 @@ export class AuthHandler {
93
116
  return null;
94
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) } });
95
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
+ }
96
127
  /**
97
- * Enforce `max_frequency`: refuse a second auth email to the same address
98
- * 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.
99
130
  *
100
- * Keyed by recipient, not by caller, and checked before the account is looked
101
- * up. Both matter: keying by caller lets someone rotate addresses and keep
102
- * mailing one victim, and checking after the lookup would answer differently
103
- * for an address that exists than for one that does not - handing back the
104
- * account enumeration that answering 200 for unknown addresses exists to
105
- * 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.
106
145
  */
107
- limitEmailFrequency(email) {
146
+ async limitEmailFrequency(email, column) {
108
147
  const seconds = this.settings.maxEmailFrequencySeconds;
109
- 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)
110
164
  return null;
111
- const retryAfter = this.rateLimiter.check('email_frequency', email.toLowerCase().trim(), Date.now(), {
165
+ const retryAfter = this.rateLimiter.check(`email_frequency:${column}`, normalized, Date.now(), {
112
166
  limit: 1,
113
167
  windowMs: seconds * 1000,
114
168
  });
115
- if (retryAfter === null)
116
- return null;
117
- return new Response(JSON.stringify({
118
- code: 429,
119
- error_code: 'over_email_send_rate_limit',
120
- msg: `For security purposes, you can only request this after ${retryAfter} seconds.`,
121
- }), { 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]);
122
178
  }
123
179
  /** Stop background timers (rate-limiter sweep). Called on backend close. */
124
180
  stop() {
@@ -160,10 +216,15 @@ export class AuthHandler {
160
216
  return this.limit('otp', req) ?? (await this.sendOtp(req, url));
161
217
  if (path === 'recover' && method === 'POST')
162
218
  return this.limit('recover', req) ?? (await this.sendRecovery(req, url));
163
- if (['magiclink', 'resend'].includes(path) && method === 'POST')
219
+ if (path === 'magiclink' && method === 'POST')
164
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));
165
223
  if (path === 'verify' && method === 'POST')
166
- return await this.verifyToken(req);
224
+ return this.limit('verify', req) ?? (await this.verifyToken(req));
225
+ // Deliberately unlimited, unlike the POST above: a link carries no email,
226
+ // so after the scoping guard in redeem() this path can only redeem the
227
+ // 24-byte link token. There is nothing here left to guess.
167
228
  if (path === 'verify' && method === 'GET')
168
229
  return await this.verifyLink(url);
169
230
  if (path === 'factors' && method === 'POST')
@@ -220,8 +281,25 @@ export class AuthHandler {
220
281
  if (existing.rows.length > 0) {
221
282
  return authError(422, 'user_already_exists', 'User already registered');
222
283
  }
223
- 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.
224
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);
225
303
  const res = await this.db.query(`insert into auth.users
226
304
  (aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
227
305
  raw_app_meta_data, raw_user_meta_data)
@@ -452,7 +530,10 @@ export class AuthHandler {
452
530
  const { code, linkToken } = minted;
453
531
  const normalized = email.toLowerCase().trim();
454
532
  const kind = tokenType === 'otp' ? 'magiclink' : tokenType;
455
- let link = `${this.config.siteUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
533
+ // GoTrue's mapping: a confirmation has its own budget, while a magic link
534
+ // and a recovery share `recovery_sent_at`.
535
+ const sentAtColumn = flavor === 'confirm' ? 'confirmation_sent_at' : 'recovery_sent_at';
536
+ let link = `${this.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
456
537
  const redirectTo = resolveRedirect(opts.redirectTo, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
457
538
  if (opts.redirectTo)
458
539
  link += `&redirect_to=${encodeURIComponent(redirectTo)}`;
@@ -490,6 +571,7 @@ export class AuthHandler {
490
571
  token_hash_new: '',
491
572
  },
492
573
  }, this.config.hookFetch);
574
+ await this.markEmailSent(minted.user.id, sentAtColumn);
493
575
  return json(200, {});
494
576
  }
495
577
  const templateName = tokenType === 'recovery' ? 'recovery' : flavor === 'confirm' ? 'confirmation' : 'magic_link';
@@ -519,13 +601,14 @@ export class AuthHandler {
519
601
  text: template?.content ? htmlToText(html) : defaultText,
520
602
  html,
521
603
  });
604
+ await this.markEmailSent(minted.user.id, sentAtColumn);
522
605
  return json(200, {});
523
606
  }
524
607
  async sendOtp(req, url) {
525
608
  const body = (await req.json().catch(() => ({})));
526
609
  if (!body.email)
527
610
  return authError(400, 'validation_failed', 'email is required');
528
- const tooSoon = this.limitEmailFrequency(body.email);
611
+ const tooSoon = await this.limitEmailFrequency(body.email, 'recovery_sent_at');
529
612
  if (tooSoon)
530
613
  return tooSoon;
531
614
  return this.issueToken(body.email, 'otp', body.create_user !== false, {
@@ -533,11 +616,61 @@ export class AuthHandler {
533
616
  ...AuthHandler.pkceFrom(body),
534
617
  });
535
618
  }
619
+ /**
620
+ * POST /auth/v1/resend - send the confirmation for `type` again.
621
+ *
622
+ * This used to be an alias for /magiclink, which is a different email
623
+ * entirely: it mails a login link measured by `recovery_sent_at`, creates an
624
+ * account for an address it has never seen, and pays no attention to `type`.
625
+ * So a resend for an address with no account signed that person up and sent
626
+ * them a way in, and a resend for a signup was paced by the recovery window.
627
+ *
628
+ * GoTrue answers 200 with an empty body both for an address it does not know
629
+ * and for one already confirmed - the two look identical from outside, which
630
+ * is what stops the endpoint from reporting who has an account. No mail goes
631
+ * out in either case.
632
+ */
633
+ async resend(req, url) {
634
+ const body = (await req.json().catch(() => ({})));
635
+ if (!body.type)
636
+ return authError(400, 'validation_failed', 'Missing one of these arguments: type');
637
+ if (body.type === 'sms' || body.type === 'phone_change') {
638
+ return authError(400, 'validation_failed', `Resend with type "${body.type}" requires phone auth, which is not supported`);
639
+ }
640
+ if (body.type === 'email_change') {
641
+ // The email-change flow itself does not exist yet, so there is no
642
+ // pending address to confirm. Say that, rather than resending something
643
+ // else and calling it an email change.
644
+ return authError(400, 'validation_failed', 'Resend type "email_change" is not supported yet');
645
+ }
646
+ if (body.type !== 'signup') {
647
+ return authError(400, 'validation_failed', `Resend requires a valid type: ${body.type}`);
648
+ }
649
+ if (!body.email)
650
+ return authError(400, 'validation_failed', 'email is required');
651
+ const normalized = body.email.toLowerCase().trim();
652
+ const res = await this.db.query(`select id, email_confirmed_at from auth.users where email = $1`, [normalized]);
653
+ const user = res.rows[0];
654
+ // Nothing to confirm, either because there is no account or because it is
655
+ // already confirmed. Answered the same way, and without creating anything:
656
+ // a resend is a repeat of a mail the address has already been sent, never
657
+ // the thing that signs someone up.
658
+ if (!user || user.email_confirmed_at)
659
+ return json(200, {});
660
+ const tooSoon = await this.limitEmailFrequency(normalized, 'confirmation_sent_at');
661
+ if (tooSoon)
662
+ return tooSoon;
663
+ return this.issueToken(normalized, 'otp', false, {
664
+ flavor: 'confirm',
665
+ redirectTo: url.searchParams.get('redirect_to'),
666
+ ...AuthHandler.pkceFrom(body),
667
+ });
668
+ }
536
669
  async sendRecovery(req, url) {
537
670
  const body = (await req.json().catch(() => ({})));
538
671
  if (!body.email)
539
672
  return authError(400, 'validation_failed', 'email is required');
540
- const tooSoon = this.limitEmailFrequency(body.email);
673
+ const tooSoon = await this.limitEmailFrequency(body.email, 'recovery_sent_at');
541
674
  if (tooSoon)
542
675
  return tooSoon;
543
676
  // GoTrue answers 200 for an address it has never seen, so the response
@@ -555,7 +688,18 @@ export class AuthHandler {
555
688
  /** Max wrong guesses for a one-time code before its tokens are invalidated. */
556
689
  static MAX_OTP_ATTEMPTS = 5;
557
690
  async redeem(token, types, email) {
558
- const normalizedEmail = email?.toLowerCase().trim() ?? null;
691
+ // `|| null`, not `??`: an empty string is an absent address, not one to
692
+ // match on. Left as '' it reaches the query as `email = ''`, which matches
693
+ // nothing, and a client that sends `email: ''` beside a link token has a
694
+ // good token refused.
695
+ const normalizedEmail = email?.toLowerCase().trim() || null;
696
+ // SECURITY: a guessable code is only a credential together with the address
697
+ // it was sent to. Unscoped, `token = $1` matches whoever happens to hold a
698
+ // live code - so one guess is tried against every account at once - and the
699
+ // attempt counter below needs the email to know what to count, so the
700
+ // lockout never fires either. Refuse rather than widen the match.
701
+ if (!normalizedEmail && isGuessableToken(token))
702
+ return null;
559
703
  const res = await this.db.query(`delete from auth.one_time_tokens
560
704
  where token = $1 and token_type = any($2::text[])
561
705
  and ($3::text is null or email = $3) and expires_at > now()
@@ -772,7 +916,7 @@ export class AuthHandler {
772
916
  user = res.rows[0] ?? user;
773
917
  }
774
918
  const redirectTo = resolveRedirect(body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
775
- const actionLink = `${this.config.siteUrl}/auth/v1/verify?token=${linkToken}&type=${type}` +
919
+ const actionLink = `${this.apiUrl}/auth/v1/verify?token=${linkToken}&type=${type}` +
776
920
  `&redirect_to=${encodeURIComponent(redirectTo)}`;
777
921
  await this.audit('generate_link', { actorId: user.id, actorEmail: user.email, traits: { type } });
778
922
  return json(200, {
@@ -1091,7 +1235,7 @@ export class AuthHandler {
1091
1235
  // moment the client refreshed.
1092
1236
  const sessionId = opts?.sessionId ?? crypto.randomUUID();
1093
1237
  const claims = {
1094
- iss: `${this.config.siteUrl}/auth/v1`,
1238
+ iss: `${this.apiUrl}/auth/v1`,
1095
1239
  sub: user.id,
1096
1240
  aud: user.aud ?? 'authenticated',
1097
1241
  exp: expiresAt,