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.
- package/dist/auth/handler.d.ts +67 -9
- package/dist/auth/handler.js +172 -28
- package/dist/auth/handler.js.map +1 -1
- package/dist/auth/oauth.d.ts +5 -1
- package/dist/auth/oauth.js +9 -2
- package/dist/auth/oauth.js.map +1 -1
- package/dist/auth/rate-limit.js +4 -0
- package/dist/auth/rate-limit.js.map +1 -1
- package/dist/cli.js +39 -6
- package/dist/cli.js.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/node/load-config.d.ts +3 -1
- package/dist/node/load-config.js +3 -0
- package/dist/node/load-config.js.map +1 -1
- package/dist/types.d.ts +8 -2
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/auth/handler.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
98
|
-
*
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* for an address
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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;
|
package/dist/auth/handler.js
CHANGED
|
@@ -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
|
|
98
|
-
*
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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 (!
|
|
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(
|
|
165
|
+
const retryAfter = this.rateLimiter.check(`email_frequency:${column}`, normalized, Date.now(), {
|
|
112
166
|
limit: 1,
|
|
113
167
|
windowMs: seconds * 1000,
|
|
114
168
|
});
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1238
|
+
iss: `${this.apiUrl}/auth/v1`,
|
|
1095
1239
|
sub: user.id,
|
|
1096
1240
|
aud: user.aud ?? 'authenticated',
|
|
1097
1241
|
exp: expiresAt,
|