@rebasepro/server 0.21.0 → 0.21.1
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/README.md +29 -11
- package/dist/api/errors.d.ts +41 -0
- package/dist/auth/admin-user-ops.d.ts +23 -1
- package/dist/auth/auth-hooks.d.ts +4 -0
- package/dist/auth/reset-password-admin.d.ts +4 -0
- package/dist/auth/token-revocation.d.ts +39 -5
- package/dist/{auth-gzoRjnuh.js → auth-BS4WBA10.js} +1636 -1565
- package/dist/auth-BS4WBA10.js.map +1 -0
- package/dist/backup/backup-common.d.ts +2 -1
- package/dist/{backup-DGu0v9Ku.js → backup-DzI9jLwc.js} +38 -10
- package/dist/backup-DzI9jLwc.js.map +1 -0
- package/dist/collections/validate-config.d.ts +4 -2
- package/dist/{cron-routes-BamgO0Xr.js → cron-routes-B_wlLybo.js} +3 -3
- package/dist/{cron-routes-BamgO0Xr.js.map → cron-routes-B_wlLybo.js.map} +1 -1
- package/dist/{cron-scheduler-D47tdB9T.js → cron-scheduler-COPQxlEq.js} +59 -10
- package/dist/cron-scheduler-COPQxlEq.js.map +1 -0
- package/dist/{cron-store-yXOyIEmn.js → cron-store-Dp0X7zRB.js} +2 -2
- package/dist/{cron-store-yXOyIEmn.js.map → cron-store-Dp0X7zRB.js.map} +1 -1
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
- package/dist/deploy/pod-contract.d.ts +14 -0
- package/dist/{errors-DMImyqyR.js → errors-DWsX4yTd.js} +55 -18
- package/dist/errors-DWsX4yTd.js.map +1 -0
- package/dist/{function-routes-C4nB2h0z.js → function-routes-Chet4-lB.js} +2 -2
- package/dist/{function-routes-C4nB2h0z.js.map → function-routes-Chet4-lB.js.map} +1 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-hBGuXcZc.js → history-recorder-5VKT7nss.js} +2 -2
- package/dist/{history-recorder-hBGuXcZc.js.map → history-recorder-5VKT7nss.js.map} +1 -1
- package/dist/{history-store-C9EtGtoL.js → history-store-By2R_G1b.js} +2 -2
- package/dist/{history-store-C9EtGtoL.js.map → history-store-By2R_G1b.js.map} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +286 -245
- package/dist/index.es.js.map +1 -1
- package/dist/init/middlewares.d.ts +19 -1
- package/dist/init/storage.d.ts +17 -0
- package/dist/init.d.ts +6 -2
- package/dist/{jobs-DZ8DLLcU.js → jobs-1IkI4Lle.js} +2 -2
- package/dist/{jobs-DZ8DLLcU.js.map → jobs-1IkI4Lle.js.map} +1 -1
- package/dist/{logs-routes-DnJINsMu.js → logs-routes-3EEzPjhl.js} +2 -2
- package/dist/{logs-routes-DnJINsMu.js.map → logs-routes-3EEzPjhl.js.map} +1 -1
- package/dist/{openapi-generator-CAFKF36E.js → openapi-generator-BDpoKZQ9.js} +2 -2
- package/dist/{openapi-generator-CAFKF36E.js.map → openapi-generator-BDpoKZQ9.js.map} +1 -1
- package/dist/{query-parser-uqzkZ82i.js → query-parser-C-rl30ce.js} +3 -3
- package/dist/{query-parser-uqzkZ82i.js.map → query-parser-C-rl30ce.js.map} +1 -1
- package/dist/{request-timeout-BR-OBwES.js → request-timeout-C_4C2BeR.js} +2 -2
- package/dist/{request-timeout-BR-OBwES.js.map → request-timeout-C_4C2BeR.js.map} +1 -1
- package/dist/{schema-editor-routes-BKOmdf4M.js → schema-editor-routes-DdLihzp0.js} +2 -2
- package/dist/{schema-editor-routes-BKOmdf4M.js.map → schema-editor-routes-DdLihzp0.js.map} +1 -1
- package/dist/src-Br6ARbs6.js.map +1 -1
- package/dist/{src-DW1cQ_28.js → src-Caz7nREj.js} +2 -3
- package/dist/src-Caz7nREj.js.map +1 -0
- package/package.json +5 -5
- package/dist/auth-gzoRjnuh.js.map +0 -1
- package/dist/backup-DGu0v9Ku.js.map +0 -1
- package/dist/cron-scheduler-D47tdB9T.js.map +0 -1
- package/dist/errors-DMImyqyR.js.map +0 -1
- package/dist/src-DW1cQ_28.js.map +0 -1
|
@@ -3,13 +3,13 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import { P as revokeInternalTableSql, at as isAnonymousUid, it as ANONYMOUS_USER_ID } from "./src-
|
|
6
|
+
import { P as revokeInternalTableSql, at as isAnonymousUid, it as ANONYMOUS_USER_ID } from "./src-Caz7nREj.js";
|
|
7
7
|
import "./src-Br6ARbs6.js";
|
|
8
8
|
import { n as createDdlBootstrapper, o as isSQLAdmin } from "./ddl-bootstrap-CfNvxMuK.js";
|
|
9
9
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
10
10
|
import { r as logger } from "./logger-DO2PZc4i.js";
|
|
11
|
-
import {
|
|
12
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
11
|
+
import { r as errorHandler, t as ApiError } from "./errors-DWsX4yTd.js";
|
|
12
|
+
import { a as resolveListLimitParam } from "./query-parser-C-rl30ce.js";
|
|
13
13
|
import { C as require_jsonwebtoken, E as randomInt$1, O as sha256Hex, _ as verifyAccessToken, b as verifyMfaPendingToken, c as getAccessTokenExpiry, d as getRefreshTokenTtlMs, f as hasAsymmetricSigningKey, i as generateDownloadToken, l as getJwks, m as isJwtConfigured, n as configureJwt, o as generateMfaPendingToken, p as hashRefreshToken, r as generateAccessToken, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as constantTimeEqual } from "./jwt-C4OW-DNq.js";
|
|
14
14
|
import { n as hasAdministrativeRole, r as isAdministrativeRole, t as ADMINISTRATIVE_ROLES } from "./admin-roles-vYdp_Pil.js";
|
|
15
15
|
import { c as tryCanonicalStorageKey, i as canonicalStorageId } from "./keys-Qfc4XieN.js";
|
|
@@ -154,1687 +154,1766 @@ function isFunctionAllowed(permissions, functionName, operation) {
|
|
|
154
154
|
return false;
|
|
155
155
|
}
|
|
156
156
|
//#endregion
|
|
157
|
-
//#region src/
|
|
157
|
+
//#region src/email/html.ts
|
|
158
158
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* `logout`, `change-password`, `reset-password` and `DELETE /auth/sessions` all
|
|
162
|
-
* stamp a `tokensValidAfter` watermark on the user. Every one of them also
|
|
163
|
-
* deletes refresh-token rows, which is what made the gap easy to miss: the
|
|
164
|
-
* session really is gone, and the *refresh* path really does check the
|
|
165
|
-
* watermark — so signing out looked like it worked.
|
|
166
|
-
*
|
|
167
|
-
* The access token was untouched. It is a bearer credential that nothing
|
|
168
|
-
* consulted a database about, so it stayed valid for its full lifetime after
|
|
169
|
-
* every one of those actions. "Sign out everywhere" left the stolen token
|
|
170
|
-
* working; so did changing your password because you thought it had leaked.
|
|
171
|
-
*
|
|
172
|
-
* The watermark had exactly one read in the repository, on refresh. This adds
|
|
173
|
-
* the other one.
|
|
174
|
-
*
|
|
175
|
-
* ## Cost
|
|
176
|
-
*
|
|
177
|
-
* One indexed lookup per request, on the paths that already make one. The
|
|
178
|
-
* adapter re-reads roles for every authenticated request; the admin routes now
|
|
179
|
-
* do too. Both call this with the row they were already fetching in mind — a
|
|
180
|
-
* repository that wants to serve both from one query is free to cache
|
|
181
|
-
* internally, but nothing here assumes it.
|
|
159
|
+
* HTML escaping for email bodies.
|
|
182
160
|
*
|
|
183
|
-
*
|
|
161
|
+
* Every default template interpolates values the server does not control — a
|
|
162
|
+
* `displayName` chosen at registration, an `appName` from config, a link base a
|
|
163
|
+
* host set — into markup that is then mailed, signed by the sending domain, to
|
|
164
|
+
* an address the same request chose. Unescaped, that turns `POST /auth/register`
|
|
165
|
+
* into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
|
|
166
|
+
* whose SPF and DKIM check out.
|
|
184
167
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
* failure is logged at warn so it is visible rather than silent.
|
|
168
|
+
* The fix is deliberately not five escaping calls at five interpolation sites:
|
|
169
|
+
* a sixth template would simply forget. Templates are built with the {@link html}
|
|
170
|
+
* tag, which escapes *every* interpolated value by default, and markup that is
|
|
171
|
+
* meant to pass through verbatim — the static style strings, a nested fragment —
|
|
172
|
+
* must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
|
|
173
|
+
* in a test; forgetting to escape is invisible until someone exploits it.
|
|
192
174
|
*/
|
|
193
|
-
async function isAccessTokenRevoked(authRepo, payload) {
|
|
194
|
-
if (typeof authRepo.getTokensValidAfter !== "function") return false;
|
|
195
|
-
if (typeof payload.iat !== "number") return false;
|
|
196
|
-
let validAfter;
|
|
197
|
-
try {
|
|
198
|
-
validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
199
|
-
} catch (error) {
|
|
200
|
-
logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
|
|
201
|
-
uid: payload.uid,
|
|
202
|
-
error
|
|
203
|
-
});
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
if (!validAfter) return false;
|
|
207
|
-
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
208
|
-
}
|
|
209
|
-
//#endregion
|
|
210
|
-
//#region src/auth/rls-scope.ts
|
|
211
|
-
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
212
175
|
/**
|
|
213
|
-
*
|
|
214
|
-
* caller presenting the service key.
|
|
215
|
-
*
|
|
216
|
-
* Naming it makes the privilege legible, because it is narrower than it reads.
|
|
217
|
-
* This is **not** an RLS bypass — a driver scoped with it is an
|
|
218
|
-
* `AuthenticatedPostgresBackendDriver`, so every statement runs in a
|
|
219
|
-
* transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
220
|
-
* `app.uid = 'service'`, and policies are evaluated. Two consequences worth
|
|
221
|
-
* knowing before you write a policy:
|
|
222
|
-
*
|
|
223
|
-
* - It passes the default policies through their `rolesOverlap(['admin'])`
|
|
224
|
-
* arm — the same arm an application user holding the `admin` role passes.
|
|
225
|
-
* - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
|
|
226
|
-
* **false** for it. A collection with `disableDefaultPolicies: true` whose
|
|
227
|
-
* only rule is `serverContext()` denies these writes (42501) and returns
|
|
228
|
-
* zero rows for these reads.
|
|
176
|
+
* Markup that has already been vetted and must be interpolated verbatim.
|
|
229
177
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
178
|
+
* Only ever construct this from a string literal in this package's own source
|
|
179
|
+
* (or from the {@link html} tag, which produces one). Wrapping user input in
|
|
180
|
+
* `raw()` defeats the entire mechanism.
|
|
232
181
|
*/
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
182
|
+
var RawHtml = class {
|
|
183
|
+
value;
|
|
184
|
+
constructor(value) {
|
|
185
|
+
this.value = value;
|
|
186
|
+
}
|
|
187
|
+
toString() {
|
|
188
|
+
return this.value;
|
|
189
|
+
}
|
|
236
190
|
};
|
|
237
191
|
/**
|
|
238
|
-
*
|
|
192
|
+
* Mark a static, author-controlled string as safe to interpolate unescaped.
|
|
239
193
|
*/
|
|
240
|
-
function
|
|
241
|
-
return
|
|
194
|
+
function raw(markup) {
|
|
195
|
+
return new RawHtml(markup);
|
|
242
196
|
}
|
|
243
197
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* The same question `scopeDataDriver` asks internally, exported because one
|
|
247
|
-
* caller must act on the answer BEFORE it has a request to scope.
|
|
198
|
+
* Escape the five characters that can change the meaning of HTML text or of a
|
|
199
|
+
* quoted attribute value.
|
|
248
200
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* behaviour for a surface whose entire promise is "this runs as you": there,
|
|
253
|
-
* an unscoped driver silently means "runs as the database owner". So the MCP
|
|
254
|
-
* surface asks this at boot and declines to mount rather than degrade.
|
|
201
|
+
* `&` goes first: escaping it after the others would double-escape the entities
|
|
202
|
+
* they just produced. `'` is included because it is a legal attribute delimiter,
|
|
203
|
+
* and `"` because it is the one this file uses.
|
|
255
204
|
*/
|
|
256
|
-
function
|
|
257
|
-
return
|
|
205
|
+
function escapeHtml(value) {
|
|
206
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
258
207
|
}
|
|
259
208
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* SECURITY: If `withAuth()` is available but fails, the error is re-thrown
|
|
263
|
-
* so the request is **denied** rather than proceeding with unscoped access
|
|
264
|
-
* (fail-closed behavior).
|
|
265
|
-
*
|
|
266
|
-
* If the driver does not support RLS, the original driver is returned.
|
|
267
|
-
*
|
|
268
|
-
* @param driver - The DataDriver to scope.
|
|
269
|
-
* @param user - The authenticated user identity for RLS.
|
|
270
|
-
* @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
|
|
209
|
+
* Render one interpolated value: pass {@link RawHtml} through, drop nullish,
|
|
210
|
+
* escape everything else.
|
|
271
211
|
*/
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
return
|
|
212
|
+
function interpolate(value) {
|
|
213
|
+
if (value instanceof RawHtml) return value.value;
|
|
214
|
+
if (value === null || value === void 0) return "";
|
|
215
|
+
return escapeHtml(String(value));
|
|
275
216
|
}
|
|
276
|
-
//#endregion
|
|
277
|
-
//#region src/auth/crypto-utils.ts
|
|
278
|
-
/**
|
|
279
|
-
* Cryptographic utility functions for auth.
|
|
280
|
-
*
|
|
281
|
-
* @module
|
|
282
|
-
*/
|
|
283
217
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* Used for comparing service keys, tokens, and other secrets where
|
|
287
|
-
* timing side-channels could leak information about the expected value.
|
|
218
|
+
* Tagged template for email markup. Every `${...}` is escaped unless it is
|
|
219
|
+
* {@link RawHtml}.
|
|
288
220
|
*
|
|
289
|
-
*
|
|
290
|
-
* UTF-8 bytes without `node:crypto` — this function is on the request path, and
|
|
291
|
-
* the byte-level reasoning it depends on is documented there.
|
|
221
|
+
* Returns `RawHtml` so fragments nest without being escaped a second time:
|
|
292
222
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
223
|
+
* ```ts
|
|
224
|
+
* const button = url ? html`<a href="${url}">Open</a>` : raw("");
|
|
225
|
+
* const body = html`<div>${button}</div>`;
|
|
226
|
+
* ```
|
|
296
227
|
*/
|
|
297
|
-
function
|
|
298
|
-
|
|
228
|
+
function html(strings, ...values) {
|
|
229
|
+
let out = strings[0];
|
|
230
|
+
for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
|
|
231
|
+
return new RawHtml(out);
|
|
299
232
|
}
|
|
300
233
|
//#endregion
|
|
301
|
-
//#region src/
|
|
302
|
-
/**
|
|
303
|
-
* Parsing of the `Authorization: Bearer <token>` header.
|
|
304
|
-
*
|
|
305
|
-
* Its own module rather than a helper inside `middleware.ts`, because the
|
|
306
|
-
* API-key middleware needs it too and `middleware.ts` already imports from
|
|
307
|
-
* there — putting it in either one would close an import cycle.
|
|
308
|
-
*
|
|
309
|
-
* @module
|
|
310
|
-
*/
|
|
234
|
+
//#region src/email/templates.ts
|
|
311
235
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
|
|
315
|
-
* clients send `bearer` — HTTP libraries that lower-case what they emit,
|
|
316
|
-
* hand-written curl, proxies that normalise headers. Matching `"Bearer "`
|
|
317
|
-
* exactly turned those into a 401 that, from the caller's side, is
|
|
318
|
-
* indistinguishable from a rejected token; people spend an afternoon
|
|
319
|
-
* regenerating credentials that were fine. Only the scheme is folded — the
|
|
320
|
-
* token itself is returned byte-for-byte.
|
|
236
|
+
* Default email templates for authentication emails.
|
|
321
237
|
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const separator = authHeader.indexOf(" ");
|
|
330
|
-
if (separator < 0) return void 0;
|
|
331
|
-
if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
|
|
332
|
-
return authHeader.slice(separator + 1);
|
|
333
|
-
}
|
|
334
|
-
//#endregion
|
|
335
|
-
//#region src/auth/api-keys/api-key-middleware.ts
|
|
336
|
-
/**
|
|
337
|
-
* Check whether a token looks like a Rebase API key.
|
|
238
|
+
* Every HTML body is built with the `html` tag from `./html`, which escapes each
|
|
239
|
+
* interpolated value unless it is explicitly marked `raw`. `displayName` reaches
|
|
240
|
+
* these templates straight from the registration body, and the resulting mail is
|
|
241
|
+
* sent — signed by the sending domain — to an address the same anonymous request
|
|
242
|
+
* chose, so an unescaped interpolation here is a phishing primitive, not a
|
|
243
|
+
* rendering glitch. Escaping lives in the tag rather than at the call sites so a
|
|
244
|
+
* sixth template inherits it instead of having to remember it.
|
|
338
245
|
*/
|
|
339
|
-
function isApiKeyToken(token) {
|
|
340
|
-
return token.startsWith("rk_");
|
|
341
|
-
}
|
|
342
246
|
/**
|
|
343
|
-
*
|
|
247
|
+
* Get a greeting name for the user.
|
|
344
248
|
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
249
|
+
* Returns the raw value — `displayName` is whatever the account was registered
|
|
250
|
+
* with, including markup. Escaping is the `html` tag's job, so that a template
|
|
251
|
+
* that interpolates the greeting into text (the plain-text bodies below) and one
|
|
252
|
+
* that interpolates it into markup can share this.
|
|
347
253
|
*/
|
|
348
|
-
function
|
|
349
|
-
return
|
|
254
|
+
function getGreeting(user) {
|
|
255
|
+
return user.displayName || user.email.split("@")[0];
|
|
350
256
|
}
|
|
351
257
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* Returns `true` if the key is valid and context has been populated,
|
|
355
|
-
* or returns an error Response if the key is invalid.
|
|
258
|
+
* Common email styles.
|
|
356
259
|
*
|
|
357
|
-
*
|
|
358
|
-
* `
|
|
260
|
+
* `raw` because these are static, author-written CSS — the one category of
|
|
261
|
+
* interpolation the `html` tag lets through unescaped. (Escaping them would
|
|
262
|
+
* survive an HTML parser but not every mail client's CSS parser: the
|
|
263
|
+
* `'Segoe UI'` quotes would arrive as entities.)
|
|
359
264
|
*/
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
265
|
+
var styles = {
|
|
266
|
+
container: raw(`
|
|
267
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
268
|
+
max-width: 600px;
|
|
269
|
+
margin: 0 auto;
|
|
270
|
+
padding: 40px 20px;
|
|
271
|
+
background-color: #f8fafc;
|
|
272
|
+
`),
|
|
273
|
+
card: raw(`
|
|
274
|
+
background-color: #ffffff;
|
|
275
|
+
border-radius: 12px;
|
|
276
|
+
padding: 40px;
|
|
277
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
278
|
+
`),
|
|
279
|
+
heading: raw(`
|
|
280
|
+
color: #1e293b;
|
|
281
|
+
font-size: 24px;
|
|
282
|
+
font-weight: 600;
|
|
283
|
+
margin: 0 0 20px 0;
|
|
284
|
+
`),
|
|
285
|
+
paragraph: raw(`
|
|
286
|
+
color: #475569;
|
|
287
|
+
font-size: 16px;
|
|
288
|
+
line-height: 1.6;
|
|
289
|
+
margin: 0 0 20px 0;
|
|
290
|
+
`),
|
|
291
|
+
button: raw(`
|
|
292
|
+
display: inline-block;
|
|
293
|
+
background-color: #3b82f6;
|
|
294
|
+
color: #ffffff;
|
|
295
|
+
font-size: 16px;
|
|
296
|
+
font-weight: 600;
|
|
297
|
+
text-decoration: none;
|
|
298
|
+
padding: 14px 28px;
|
|
299
|
+
border-radius: 8px;
|
|
300
|
+
margin: 20px 0;
|
|
301
|
+
`),
|
|
302
|
+
footer: raw(`
|
|
303
|
+
color: #94a3b8;
|
|
304
|
+
font-size: 14px;
|
|
305
|
+
margin-top: 30px;
|
|
306
|
+
padding-top: 20px;
|
|
307
|
+
border-top: 1px solid #e2e8f0;
|
|
308
|
+
`),
|
|
309
|
+
warning: raw(`
|
|
310
|
+
color: #64748b;
|
|
311
|
+
font-size: 14px;
|
|
312
|
+
background-color: #fef3c7;
|
|
313
|
+
padding: 12px 16px;
|
|
314
|
+
border-radius: 6px;
|
|
315
|
+
margin-top: 20px;
|
|
316
|
+
`),
|
|
317
|
+
/**
|
|
318
|
+
* The one-time code itself. Monospace so the digits line up, spaced so a
|
|
319
|
+
* `1` and a `7` are not read as one glyph, and large enough to be copied by
|
|
320
|
+
* eye from a phone onto another device — which is the entire flow.
|
|
321
|
+
*/
|
|
322
|
+
code: raw(`
|
|
323
|
+
display: inline-block;
|
|
324
|
+
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
|
325
|
+
font-size: 34px;
|
|
326
|
+
font-weight: 700;
|
|
327
|
+
letter-spacing: 10px;
|
|
328
|
+
color: #1e293b;
|
|
329
|
+
background-color: #f1f5f9;
|
|
330
|
+
border-radius: 10px;
|
|
331
|
+
padding: 18px 20px 18px 30px;
|
|
332
|
+
`)
|
|
333
|
+
};
|
|
421
334
|
/**
|
|
422
|
-
*
|
|
423
|
-
* generator (storage and functions guards). One envelope, one code — a
|
|
424
|
-
* change to the error contract happens in one place.
|
|
335
|
+
* The app name every template falls back to when `email.appName` is unset.
|
|
425
336
|
*/
|
|
426
|
-
|
|
427
|
-
return c.json({ error: {
|
|
428
|
-
message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
|
|
429
|
-
code: "API_KEY_FORBIDDEN"
|
|
430
|
-
} }, 403);
|
|
431
|
-
}
|
|
337
|
+
var DEFAULT_APP_NAME = "Rebase";
|
|
432
338
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
*
|
|
440
|
-
* API key pass through to the storage router's own auth gates.
|
|
339
|
+
* The Rebase mark, hosted. Mail clients do not render SVG (Gmail strips it
|
|
340
|
+
* outright), so this is the PNG rather than `logo.svg`, and it is an absolute
|
|
341
|
+
* URL rather than a data URI because Gmail blocks those in `<img src>` too.
|
|
342
|
+
*/
|
|
343
|
+
var REBASE_LOGO_URL = "https://rebase.pro/img/logo_small.png";
|
|
344
|
+
/**
|
|
345
|
+
* Whether a configured logo can actually be fetched by a mail client.
|
|
441
346
|
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
347
|
+
* A relative path, a `data:` URI or a `file:` URL all produce a broken image in
|
|
348
|
+
* the recipient's inbox rather than an error anyone would see, so an unusable
|
|
349
|
+
* value renders no logo at all instead. This is deliberately not a boot-time
|
|
350
|
+
* assert like `assertEmailLinkBases`: a dead link makes the mail useless, a
|
|
351
|
+
* missing logo only makes it plain.
|
|
447
352
|
*/
|
|
448
|
-
function
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
return
|
|
455
|
-
}
|
|
353
|
+
function isMailableImageUrl(url) {
|
|
354
|
+
if (!url) return false;
|
|
355
|
+
try {
|
|
356
|
+
const { protocol } = new URL(url);
|
|
357
|
+
return protocol === "https:" || protocol === "http:";
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
456
361
|
}
|
|
457
362
|
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* The collection permission guard lives in the REST generator and never sees
|
|
461
|
-
* function routes, so before this middleware existed ANY valid API key —
|
|
462
|
-
* however narrowly scoped — could invoke every custom function. This guard
|
|
463
|
-
* closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
|
|
464
|
-
* permission entry (or the global `"*"` wildcard) for the derived operation.
|
|
465
|
-
*
|
|
466
|
-
* Non-API-key requests (JWT, service key, anonymous) pass through untouched —
|
|
467
|
-
* functions decide their own auth for those, as before.
|
|
363
|
+
* Whether this config still carries the stock app name — i.e. nobody has
|
|
364
|
+
* branded this install.
|
|
468
365
|
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
366
|
+
* Testing against the *value* rather than against `undefined` is deliberate and
|
|
367
|
+
* load-bearing: `APP_NAME` is declared `z.string().default("Rebase")` in the
|
|
368
|
+
* cloud, in the demo app and in the eject template, so `appName` arrives here as
|
|
369
|
+
* the literal string "Rebase" even when the operator set nothing. Keying the
|
|
370
|
+
* fallback on `appName === undefined` would mean the logo never appeared in any
|
|
371
|
+
* Rebase-run product.
|
|
472
372
|
*/
|
|
473
|
-
function
|
|
474
|
-
return
|
|
475
|
-
const apiKey = c.get("apiKey");
|
|
476
|
-
if (!apiKey) return next();
|
|
477
|
-
const path = c.req.path;
|
|
478
|
-
const idx = path.indexOf(mountPrefix);
|
|
479
|
-
const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
480
|
-
let functionName;
|
|
481
|
-
try {
|
|
482
|
-
functionName = decodeURIComponent(rawName);
|
|
483
|
-
} catch {
|
|
484
|
-
functionName = rawName;
|
|
485
|
-
}
|
|
486
|
-
const operation = httpMethodToOperation(c.req.method);
|
|
487
|
-
if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
|
|
488
|
-
return next();
|
|
489
|
-
};
|
|
373
|
+
function isUnbranded(appName) {
|
|
374
|
+
return !appName || appName === DEFAULT_APP_NAME;
|
|
490
375
|
}
|
|
491
376
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
* Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
|
|
495
|
-
* the admin surfaces: admin users/roles, api-keys management, cron, backups,
|
|
496
|
-
* logs, schema editor) don't know about API keys. Mounting this middleware in
|
|
497
|
-
* front of them authenticates `rk_` tokens and populates the request context;
|
|
498
|
-
* the downstream gates then see the already-resolved user and apply their
|
|
499
|
-
* role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
|
|
500
|
-
* a non-admin key is rejected with 403.
|
|
377
|
+
* Resolve the branding a template should render from the email config.
|
|
501
378
|
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
379
|
+
* The fallback is asymmetric on purpose. `appName` falls back to "Rebase"
|
|
380
|
+
* because an unconfigured app has no better name to show. The *logo* is stricter:
|
|
381
|
+
* an app that named itself "Acme" but set no `logoUrl` gets no logo at all,
|
|
382
|
+
* because the alternative is mailing Acme's users a Rebase mark from Acme's
|
|
383
|
+
* domain. Only an install that has not renamed itself is treated as Rebase's own.
|
|
505
384
|
*/
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const result = await validateApiKey(c, token, options);
|
|
512
|
-
if (result !== true) return result;
|
|
513
|
-
return next();
|
|
385
|
+
function resolveEmailBranding(config) {
|
|
386
|
+
const logoUrl = config?.logoUrl ?? (isUnbranded(config?.appName) ? REBASE_LOGO_URL : void 0);
|
|
387
|
+
return {
|
|
388
|
+
appName: config?.appName || DEFAULT_APP_NAME,
|
|
389
|
+
logoUrl: isMailableImageUrl(logoUrl) ? logoUrl : void 0
|
|
514
390
|
};
|
|
515
391
|
}
|
|
516
|
-
//#endregion
|
|
517
|
-
//#region src/auth/middleware.ts
|
|
518
392
|
/**
|
|
519
|
-
*
|
|
520
|
-
* every route uses.
|
|
393
|
+
* The logo block that sits above the card in every default template.
|
|
521
394
|
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* a routine 401 (a client refreshing before it knows whether a session exists)
|
|
527
|
-
* logged like an incident, because `ApiError.expected` never reached the
|
|
528
|
-
* logger.
|
|
395
|
+
* `width`/`height` are attributes as well as CSS because Outlook's Word renderer
|
|
396
|
+
* ignores the style block. `alt` carries the app name, for screen readers and
|
|
397
|
+
* for the very common case of a client that blocks remote images on a first
|
|
398
|
+
* email from an unknown sender.
|
|
529
399
|
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* those callers. Calling the formatter directly gets the envelope with none of
|
|
536
|
-
* that.
|
|
400
|
+
* The type styling on the `<img>` is what that blocked case falls back to: alt
|
|
401
|
+
* text inherits it, and at the body's 16px "Rebase" overflows a 48px box and
|
|
402
|
+
* renders as "Rebas". 12px plus `height: auto` — CSS the clients that block
|
|
403
|
+
* images do read, while Outlook still sizes from the attributes — lets the name
|
|
404
|
+
* through intact.
|
|
537
405
|
*/
|
|
538
|
-
function
|
|
539
|
-
return
|
|
406
|
+
function renderHeader(appName, logoUrl) {
|
|
407
|
+
if (!logoUrl) return raw("");
|
|
408
|
+
return html`
|
|
409
|
+
<div style="text-align: center; margin-bottom: 24px;">
|
|
410
|
+
<img src="${logoUrl}" width="48" height="48" alt="${appName}"
|
|
411
|
+
style="display: inline-block; width: 48px; height: auto; border: 0; outline: none; text-decoration: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 12px; color: #64748b;">
|
|
412
|
+
</div>
|
|
413
|
+
`;
|
|
540
414
|
}
|
|
541
415
|
/**
|
|
542
|
-
*
|
|
543
|
-
* Returns 401 if token is missing or invalid.
|
|
544
|
-
*
|
|
545
|
-
* **Security:** Tokens are only accepted via the `Authorization: Bearer`
|
|
546
|
-
* header. Query-string tokens (`?token=`) are intentionally NOT accepted
|
|
547
|
-
* here because URLs leak into access logs, proxies, Referer headers, and
|
|
548
|
-
* browser history. Use {@link queryTokenAuth} on routes that legitimately
|
|
549
|
-
* need query-string tokens (e.g. storage file serving for `<img src>`).
|
|
550
|
-
*/
|
|
551
|
-
var requireAuth = async (c, next) => {
|
|
552
|
-
if (c.get("user")) return next();
|
|
553
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
554
|
-
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
555
|
-
const payload = await verifyAccessToken(token);
|
|
556
|
-
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
557
|
-
c.set("user", payload);
|
|
558
|
-
return next();
|
|
559
|
-
};
|
|
560
|
-
/**
|
|
561
|
-
* Factory that creates a requireAuth middleware with optional service key support.
|
|
562
|
-
*
|
|
563
|
-
* When `serviceKey` is provided, the middleware will check if the Bearer token
|
|
564
|
-
* matches the service key using constant-time comparison. If it matches, the
|
|
565
|
-
* request is authenticated as a service user with admin privileges.
|
|
566
|
-
*
|
|
567
|
-
* This allows admin routes (which use standalone requireAuth + requireAdmin)
|
|
568
|
-
* to be accessed via service keys for scripts and server-to-server calls.
|
|
416
|
+
* Default password reset email template
|
|
569
417
|
*/
|
|
570
|
-
function
|
|
571
|
-
|
|
572
|
-
const
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
418
|
+
function getPasswordResetTemplate(resetUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
419
|
+
const greeting = getGreeting(user);
|
|
420
|
+
const subject = `Reset your ${appName} password`;
|
|
421
|
+
const body = html`
|
|
422
|
+
<!DOCTYPE html>
|
|
423
|
+
<html>
|
|
424
|
+
<head>
|
|
425
|
+
<meta charset="utf-8">
|
|
426
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
427
|
+
<title>${subject}</title>
|
|
428
|
+
</head>
|
|
429
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
430
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
431
|
+
<div style="${styles.card}">
|
|
432
|
+
<h1 style="${styles.heading}">Reset Your Password</h1>
|
|
433
|
+
|
|
434
|
+
<p style="${styles.paragraph}">
|
|
435
|
+
Hi ${greeting},
|
|
436
|
+
</p>
|
|
437
|
+
|
|
438
|
+
<p style="${styles.paragraph}">
|
|
439
|
+
We received a request to reset your password for your ${appName} account.
|
|
440
|
+
Click the button below to create a new password:
|
|
441
|
+
</p>
|
|
442
|
+
|
|
443
|
+
<div style="text-align: center;">
|
|
444
|
+
<a href="${resetUrl}" style="${styles.button}">Reset Password</a>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<p style="${styles.paragraph}">
|
|
448
|
+
Or copy and paste this link into your browser:
|
|
449
|
+
</p>
|
|
450
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
451
|
+
${resetUrl}
|
|
452
|
+
</p>
|
|
453
|
+
|
|
454
|
+
<div style="${styles.warning}">
|
|
455
|
+
⏰ This link will expire in 1 hour for security reasons.
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div style="${styles.footer}">
|
|
459
|
+
<p style="margin: 0;">
|
|
460
|
+
If you didn't request a password reset, you can safely ignore this email.
|
|
461
|
+
Your password will remain unchanged.
|
|
462
|
+
</p>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
</body>
|
|
467
|
+
</html>
|
|
468
|
+
`;
|
|
469
|
+
const text = `
|
|
470
|
+
Reset Your Password
|
|
471
|
+
|
|
472
|
+
Hi ${greeting},
|
|
473
|
+
|
|
474
|
+
We received a request to reset your password for your ${appName} account.
|
|
475
|
+
|
|
476
|
+
Click this link to create a new password:
|
|
477
|
+
${resetUrl}
|
|
478
|
+
|
|
479
|
+
This link will expire in 1 hour for security reasons.
|
|
480
|
+
|
|
481
|
+
If you didn't request a password reset, you can safely ignore this email.
|
|
482
|
+
Your password will remain unchanged.
|
|
483
|
+
`.trim();
|
|
484
|
+
return {
|
|
485
|
+
subject,
|
|
486
|
+
html: body.toString().trim(),
|
|
487
|
+
text
|
|
607
488
|
};
|
|
608
489
|
}
|
|
609
490
|
/**
|
|
610
|
-
*
|
|
611
|
-
* Must be used AFTER requireAuth or on a route where user is guaranteed.
|
|
612
|
-
*/
|
|
613
|
-
var requireAdmin = async (c, next) => {
|
|
614
|
-
const user = c.get("user");
|
|
615
|
-
if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
|
|
616
|
-
if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
|
|
617
|
-
return next();
|
|
618
|
-
};
|
|
619
|
-
/**
|
|
620
|
-
* Middleware that optionally extracts user from JWT via Authorization header.
|
|
621
|
-
* Does not return 401 if token is missing — allows anonymous access.
|
|
622
|
-
*
|
|
623
|
-
* Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
|
|
624
|
-
* on routes that need them.
|
|
491
|
+
* Default email verification template
|
|
625
492
|
*/
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
493
|
+
function getEmailVerificationTemplate(verifyUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
494
|
+
const greeting = getGreeting(user);
|
|
495
|
+
const subject = `Verify your ${appName} email address`;
|
|
496
|
+
const body = html`
|
|
497
|
+
<!DOCTYPE html>
|
|
498
|
+
<html>
|
|
499
|
+
<head>
|
|
500
|
+
<meta charset="utf-8">
|
|
501
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
502
|
+
<title>${subject}</title>
|
|
503
|
+
</head>
|
|
504
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
505
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
506
|
+
<div style="${styles.card}">
|
|
507
|
+
<h1 style="${styles.heading}">Verify Your Email</h1>
|
|
508
|
+
|
|
509
|
+
<p style="${styles.paragraph}">
|
|
510
|
+
Hi ${greeting},
|
|
511
|
+
</p>
|
|
512
|
+
|
|
513
|
+
<p style="${styles.paragraph}">
|
|
514
|
+
Thanks for signing up for ${appName}! Please verify your email address
|
|
515
|
+
by clicking the button below:
|
|
516
|
+
</p>
|
|
517
|
+
|
|
518
|
+
<div style="text-align: center;">
|
|
519
|
+
<a href="${verifyUrl}" style="${styles.button}">Verify Email Address</a>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<p style="${styles.paragraph}">
|
|
523
|
+
Or copy and paste this link into your browser:
|
|
524
|
+
</p>
|
|
525
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
526
|
+
${verifyUrl}
|
|
527
|
+
</p>
|
|
528
|
+
|
|
529
|
+
<div style="${styles.footer}">
|
|
530
|
+
<p style="margin: 0;">
|
|
531
|
+
If you didn't create an account with ${appName}, you can safely ignore this email.
|
|
532
|
+
</p>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
</body>
|
|
537
|
+
</html>
|
|
538
|
+
`;
|
|
539
|
+
const text = `
|
|
540
|
+
Verify Your Email
|
|
541
|
+
|
|
542
|
+
Hi ${greeting},
|
|
543
|
+
|
|
544
|
+
Thanks for signing up for ${appName}! Please verify your email address by clicking this link:
|
|
545
|
+
${verifyUrl}
|
|
546
|
+
|
|
547
|
+
If you didn't create an account with ${appName}, you can safely ignore this email.
|
|
548
|
+
`.trim();
|
|
549
|
+
return {
|
|
550
|
+
subject,
|
|
551
|
+
html: body.toString().trim(),
|
|
552
|
+
text
|
|
553
|
+
};
|
|
554
|
+
}
|
|
635
555
|
/**
|
|
636
|
-
*
|
|
556
|
+
* Default user invitation email template
|
|
557
|
+
* Sent when an admin creates a new user account
|
|
637
558
|
*/
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
559
|
+
function getUserInvitationTemplate(setPasswordUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
560
|
+
const greeting = getGreeting(user);
|
|
561
|
+
const subject = `You've been invited to ${appName}`;
|
|
562
|
+
const body = html`
|
|
563
|
+
<!DOCTYPE html>
|
|
564
|
+
<html>
|
|
565
|
+
<head>
|
|
566
|
+
<meta charset="utf-8">
|
|
567
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
568
|
+
<title>${subject}</title>
|
|
569
|
+
</head>
|
|
570
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
571
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
572
|
+
<div style="${styles.card}">
|
|
573
|
+
<h1 style="${styles.heading}">Welcome to ${appName}!</h1>
|
|
574
|
+
|
|
575
|
+
<p style="${styles.paragraph}">
|
|
576
|
+
Hi ${greeting},
|
|
577
|
+
</p>
|
|
578
|
+
|
|
579
|
+
<p style="${styles.paragraph}">
|
|
580
|
+
An account has been created for you on ${appName}.
|
|
581
|
+
Click the button below to set your password and get started:
|
|
582
|
+
</p>
|
|
583
|
+
|
|
584
|
+
<div style="text-align: center;">
|
|
585
|
+
<a href="${setPasswordUrl}" style="${styles.button}">Set Your Password</a>
|
|
586
|
+
</div>
|
|
587
|
+
|
|
588
|
+
<p style="${styles.paragraph}">
|
|
589
|
+
Or copy and paste this link into your browser:
|
|
590
|
+
</p>
|
|
591
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
592
|
+
${setPasswordUrl}
|
|
593
|
+
</p>
|
|
594
|
+
|
|
595
|
+
<div style="${styles.warning}">
|
|
596
|
+
⏰ This link will expire in 1 hour for security reasons.
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
<div style="${styles.footer}">
|
|
600
|
+
<p style="margin: 0;">
|
|
601
|
+
If you weren't expecting this invitation, you can safely ignore this email.
|
|
602
|
+
</p>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
</body>
|
|
607
|
+
</html>
|
|
608
|
+
`;
|
|
609
|
+
const text = `
|
|
610
|
+
Welcome to ${appName}!
|
|
611
|
+
|
|
612
|
+
Hi ${greeting},
|
|
613
|
+
|
|
614
|
+
An account has been created for you on ${appName}.
|
|
615
|
+
|
|
616
|
+
Click this link to set your password and get started:
|
|
617
|
+
${setPasswordUrl}
|
|
618
|
+
|
|
619
|
+
This link will expire in 1 hour for security reasons.
|
|
620
|
+
|
|
621
|
+
If you weren't expecting this invitation, you can safely ignore this email.
|
|
622
|
+
`.trim();
|
|
623
|
+
return {
|
|
624
|
+
subject,
|
|
625
|
+
html: body.toString().trim(),
|
|
626
|
+
text
|
|
627
|
+
};
|
|
641
628
|
}
|
|
642
629
|
/**
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* 2. RLS-scoped DataDriver via withAuth()
|
|
646
|
-
* 3. Enforcement (401 when requireAuth is true and no user)
|
|
647
|
-
*
|
|
648
|
-
* **Secure by default:** `requireAuth` defaults to `true`. Anonymous
|
|
649
|
-
* access is only allowed when the developer explicitly opts out by
|
|
650
|
-
* setting `requireAuth: false`, indicating that Postgres RLS policies
|
|
651
|
-
* fully control access.
|
|
652
|
-
*
|
|
653
|
-
* **Fail-closed:** The raw unscoped driver is never placed in the
|
|
654
|
-
* request context. Every code path either scopes via `withAuth()` or
|
|
655
|
-
* rejects the request. This prevents silent RLS bypass.
|
|
656
|
-
*
|
|
657
|
-
* This is the single source of truth for HTTP auth in Rebase.
|
|
658
|
-
* Use this instead of manually parsing tokens in route handlers.
|
|
630
|
+
* Default welcome email template
|
|
631
|
+
* Sent automatically when a new user registers
|
|
659
632
|
*/
|
|
660
|
-
function
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const result = await validateApiKey(c, token, {
|
|
721
|
-
store: apiKeyStore,
|
|
722
|
-
driver
|
|
723
|
-
});
|
|
724
|
-
if (result !== true) return result;
|
|
725
|
-
} else {
|
|
726
|
-
const payload = await extractUserFromToken(token);
|
|
727
|
-
if (payload) {
|
|
728
|
-
c.set("user", payload);
|
|
729
|
-
try {
|
|
730
|
-
const user = {
|
|
731
|
-
uid: payload.uid,
|
|
732
|
-
roles: payload.roles,
|
|
733
|
-
isAnonymous: payload.isAnonymous === true,
|
|
734
|
-
claims: payload.claims
|
|
735
|
-
};
|
|
736
|
-
c.set("driver", await scopeDataDriver(driver, user));
|
|
737
|
-
} catch (error) {
|
|
738
|
-
logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
|
|
739
|
-
return c.json({ error: {
|
|
740
|
-
message: "Internal authentication error",
|
|
741
|
-
code: "INTERNAL_ERROR"
|
|
742
|
-
} }, 500);
|
|
743
|
-
}
|
|
744
|
-
} else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
745
|
-
}
|
|
746
|
-
else try {
|
|
747
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
748
|
-
uid: ANONYMOUS_USER_ID,
|
|
749
|
-
roles: ["anon"]
|
|
750
|
-
}));
|
|
751
|
-
} catch (error) {
|
|
752
|
-
logger.error("[AUTH] Failed to create anon-scoped driver", { error });
|
|
753
|
-
return c.json({ error: {
|
|
754
|
-
message: "Server configuration error",
|
|
755
|
-
code: "INTERNAL_ERROR"
|
|
756
|
-
} }, 500);
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
760
|
-
return next();
|
|
633
|
+
function getWelcomeEmailTemplate(user, appName = DEFAULT_APP_NAME, loginUrl, logoUrl) {
|
|
634
|
+
const greeting = getGreeting(user);
|
|
635
|
+
const url = loginUrl || "";
|
|
636
|
+
const subject = `Welcome to ${appName}`;
|
|
637
|
+
const body = html`
|
|
638
|
+
<!DOCTYPE html>
|
|
639
|
+
<html>
|
|
640
|
+
<head>
|
|
641
|
+
<meta charset="utf-8">
|
|
642
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
643
|
+
<title>${subject}</title>
|
|
644
|
+
</head>
|
|
645
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
646
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
647
|
+
<div style="${styles.card}">
|
|
648
|
+
<h1 style="${styles.heading}">Welcome to ${appName}</h1>
|
|
649
|
+
|
|
650
|
+
<p style="${styles.paragraph}">
|
|
651
|
+
Hi ${greeting},
|
|
652
|
+
</p>
|
|
653
|
+
|
|
654
|
+
<p style="${styles.paragraph}">
|
|
655
|
+
Your ${appName} account is ready.
|
|
656
|
+
</p>
|
|
657
|
+
|
|
658
|
+
${url ? html`
|
|
659
|
+
<div style="text-align: center;">
|
|
660
|
+
<a href="${url}" style="${styles.button}">Open ${appName}</a>
|
|
661
|
+
</div>
|
|
662
|
+
` : raw("")}
|
|
663
|
+
|
|
664
|
+
<p style="${styles.paragraph}">
|
|
665
|
+
If you have any questions, just reply to this email.
|
|
666
|
+
</p>
|
|
667
|
+
|
|
668
|
+
<div style="${styles.footer}">
|
|
669
|
+
<p style="margin: 0;">
|
|
670
|
+
You received this because an account was created with this email address at ${appName}.
|
|
671
|
+
</p>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
</body>
|
|
676
|
+
</html>
|
|
677
|
+
`;
|
|
678
|
+
const text = `
|
|
679
|
+
Welcome to ${appName}
|
|
680
|
+
|
|
681
|
+
Hi ${greeting},
|
|
682
|
+
|
|
683
|
+
Your ${appName} account is ready.
|
|
684
|
+
|
|
685
|
+
${url ? `Open ${appName}: ${url}` : ""}
|
|
686
|
+
|
|
687
|
+
If you have any questions, just reply to this email.
|
|
688
|
+
`.trim();
|
|
689
|
+
return {
|
|
690
|
+
subject,
|
|
691
|
+
html: body.toString().trim(),
|
|
692
|
+
text
|
|
761
693
|
};
|
|
762
694
|
}
|
|
763
695
|
/**
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
|
|
767
|
-
* Referer headers, and browser history. This middleware exists solely for
|
|
768
|
-
* routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
|
|
769
|
-
* `<a href>` for file downloads, or similar browser-native requests.
|
|
770
|
-
*
|
|
771
|
-
* Apply it **before** `requireAuth` or `optionalAuth` on the specific
|
|
772
|
-
* route that needs it. Those middlewares will see the user context this
|
|
773
|
-
* middleware sets and skip their own 401 check.
|
|
696
|
+
* Default one-time sign-in code email.
|
|
774
697
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
* ```
|
|
698
|
+
* The code is the entire content, so it is set large and repeated in the
|
|
699
|
+
* plain-text part: somebody is about to copy it by eye onto another device,
|
|
700
|
+
* which is the whole reason this flow exists rather than a link.
|
|
779
701
|
*/
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
702
|
+
function getEmailOtpTemplate(code, user, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
703
|
+
const greeting = getGreeting(user);
|
|
704
|
+
const subject = `${code} is your ${appName} sign-in code`;
|
|
705
|
+
const body = html`
|
|
706
|
+
<!DOCTYPE html>
|
|
707
|
+
<html>
|
|
708
|
+
<head>
|
|
709
|
+
<meta charset="utf-8">
|
|
710
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
711
|
+
<title>${subject}</title>
|
|
712
|
+
</head>
|
|
713
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
714
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
715
|
+
<div style="${styles.card}">
|
|
716
|
+
<h1 style="${styles.heading}">Your sign-in code</h1>
|
|
717
|
+
|
|
718
|
+
<p style="${styles.paragraph}">
|
|
719
|
+
Hi ${greeting},
|
|
720
|
+
</p>
|
|
721
|
+
|
|
722
|
+
<p style="${styles.paragraph}">
|
|
723
|
+
Enter this code to finish signing in to ${appName}:
|
|
724
|
+
</p>
|
|
725
|
+
|
|
726
|
+
<div style="text-align: center; margin: 32px 0;">
|
|
727
|
+
<span style="${styles.code}">${code}</span>
|
|
728
|
+
</div>
|
|
729
|
+
|
|
730
|
+
<div style="${styles.warning}">
|
|
731
|
+
⏰ This code expires in 10 minutes and can only be used once.
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
<div style="${styles.footer}">
|
|
735
|
+
<p style="margin: 0;">
|
|
736
|
+
If you didn't try to sign in, you can safely ignore this email.
|
|
737
|
+
We will never ask you for this code.
|
|
738
|
+
</p>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
</body>
|
|
743
|
+
</html>
|
|
744
|
+
`;
|
|
745
|
+
const text = `
|
|
746
|
+
Your ${appName} sign-in code
|
|
747
|
+
|
|
748
|
+
Hi ${greeting},
|
|
749
|
+
|
|
750
|
+
Enter this code to finish signing in:
|
|
751
|
+
|
|
752
|
+
${code}
|
|
753
|
+
|
|
754
|
+
This code expires in 10 minutes and can only be used once.
|
|
755
|
+
|
|
756
|
+
If you didn't try to sign in, you can safely ignore this email. We will never
|
|
757
|
+
ask you for this code.
|
|
758
|
+
`.trim();
|
|
759
|
+
return {
|
|
760
|
+
subject,
|
|
761
|
+
html: body.toString().trim(),
|
|
762
|
+
text
|
|
763
|
+
};
|
|
764
|
+
}
|
|
789
765
|
/**
|
|
790
|
-
*
|
|
791
|
-
* public prefix), which are served token-less via stable, permanent URLs.
|
|
792
|
-
*
|
|
793
|
-
* Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
|
|
794
|
-
* middleware and *before* the read-auth gate. If the request is already
|
|
795
|
-
* authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
|
|
796
|
-
* requested object path is public, it sets a minimal "public" principal so the
|
|
797
|
-
* downstream `requireAuth` gate lets the read through. Private paths are left
|
|
798
|
-
* untouched, so they still require a valid token.
|
|
766
|
+
* Default magic link email template
|
|
799
767
|
*/
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
768
|
+
function getMagicLinkTemplate(magicLinkUrl, user, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
769
|
+
const greeting = getGreeting(user);
|
|
770
|
+
const subject = `Sign in to ${appName}`;
|
|
771
|
+
const body = html`
|
|
772
|
+
<!DOCTYPE html>
|
|
773
|
+
<html>
|
|
774
|
+
<head>
|
|
775
|
+
<meta charset="utf-8">
|
|
776
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
777
|
+
<title>${subject}</title>
|
|
778
|
+
</head>
|
|
779
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
780
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
781
|
+
<div style="${styles.card}">
|
|
782
|
+
<h1 style="${styles.heading}">Sign In to ${appName}</h1>
|
|
783
|
+
|
|
784
|
+
<p style="${styles.paragraph}">
|
|
785
|
+
Hi ${greeting},
|
|
786
|
+
</p>
|
|
787
|
+
|
|
788
|
+
<p style="${styles.paragraph}">
|
|
789
|
+
We received a request to sign in to your ${appName} account.
|
|
790
|
+
Click the button below to log in:
|
|
791
|
+
</p>
|
|
792
|
+
|
|
793
|
+
<div style="text-align: center;">
|
|
794
|
+
<a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
|
|
795
|
+
</div>
|
|
796
|
+
|
|
797
|
+
<p style="${styles.paragraph}">
|
|
798
|
+
Or copy and paste this link into your browser:
|
|
799
|
+
</p>
|
|
800
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
801
|
+
${magicLinkUrl}
|
|
802
|
+
</p>
|
|
803
|
+
|
|
804
|
+
<div style="${styles.warning}">
|
|
805
|
+
⏰ This link will expire in 15 minutes for security reasons and can only be used once.
|
|
806
|
+
</div>
|
|
807
|
+
|
|
808
|
+
<div style="${styles.footer}">
|
|
809
|
+
<p style="margin: 0;">
|
|
810
|
+
If you didn't request this sign-in link, you can safely ignore this email.
|
|
811
|
+
No action is needed.
|
|
812
|
+
</p>
|
|
813
|
+
</div>
|
|
814
|
+
</div>
|
|
815
|
+
</div>
|
|
816
|
+
</body>
|
|
817
|
+
</html>
|
|
818
|
+
`;
|
|
819
|
+
const text = `
|
|
820
|
+
Sign In to ${appName}
|
|
821
|
+
|
|
822
|
+
Hi ${greeting},
|
|
823
|
+
|
|
824
|
+
We received a request to sign in to your ${appName} account.
|
|
825
|
+
|
|
826
|
+
Click this link to log in:
|
|
827
|
+
${magicLinkUrl}
|
|
828
|
+
|
|
829
|
+
This link will expire in 15 minutes for security reasons and can only be used once.
|
|
830
|
+
|
|
831
|
+
If you didn't request this sign-in link, you can safely ignore this email.
|
|
832
|
+
No action is needed.
|
|
833
|
+
`.trim();
|
|
834
|
+
return {
|
|
835
|
+
subject,
|
|
836
|
+
html: body.toString().trim(),
|
|
837
|
+
text
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region src/auth/admin-user-ops.ts
|
|
812
842
|
/**
|
|
813
|
-
*
|
|
814
|
-
* Matches exact paths or prefixes (exact folder prefixes or ending with /).
|
|
843
|
+
* Admin User Operations
|
|
815
844
|
*
|
|
816
|
-
*
|
|
845
|
+
* Shared utilities and orchestration for admin-initiated user management
|
|
846
|
+
* (user creation via REST API, password reset via admin panel).
|
|
817
847
|
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
|
|
823
|
-
|
|
848
|
+
* Hook resolution order:
|
|
849
|
+
* 1. Collection-level hook (`auth.onCreateUser` on the collection) — closest to the data
|
|
850
|
+
* 2. Backend-level hook (`AuthHooks.onAdminCreateUser`) — global override
|
|
851
|
+
* 3. Built-in default — framework fallback
|
|
852
|
+
*/
|
|
853
|
+
/**
|
|
854
|
+
* Generate a cryptographically secure random password that meets strength requirements.
|
|
824
855
|
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
856
|
+
* 16 characters, guaranteed at least one uppercase, one lowercase, one digit.
|
|
857
|
+
* Ambiguous characters (0, O, 1, l, I) are excluded.
|
|
858
|
+
*/
|
|
859
|
+
function generateSecurePassword() {
|
|
860
|
+
const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
861
|
+
const lower = "abcdefghjkmnpqrstuvwxyz";
|
|
862
|
+
const digits = "23456789";
|
|
863
|
+
const all = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
|
864
|
+
const pick = (chars) => chars[randomInt(chars.length)];
|
|
865
|
+
const parts = [
|
|
866
|
+
pick(upper),
|
|
867
|
+
pick(lower),
|
|
868
|
+
pick(digits)
|
|
869
|
+
];
|
|
870
|
+
for (let i = parts.length; i < 16; i++) parts.push(pick(all));
|
|
871
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
872
|
+
const j = randomInt(i + 1);
|
|
873
|
+
[parts[i], parts[j]] = [parts[j], parts[i]];
|
|
874
|
+
}
|
|
875
|
+
return parts.join("");
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Generate a cryptographically secure random token (80 hex characters).
|
|
831
879
|
*/
|
|
832
|
-
function
|
|
833
|
-
|
|
834
|
-
if (requested === allowed) return true;
|
|
835
|
-
if (allowed.endsWith("/")) return requested.startsWith(allowed);
|
|
836
|
-
return requested.startsWith(allowed + "/");
|
|
880
|
+
function generateSecureToken() {
|
|
881
|
+
return randomBytes(40).toString("hex");
|
|
837
882
|
}
|
|
838
883
|
/**
|
|
839
|
-
*
|
|
840
|
-
* It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
|
|
841
|
-
* Full access JWTs are explicitly rejected on "/file/*" routes, and in the "?token=" query param.
|
|
884
|
+
* Hash a token for database storage using SHA-256.
|
|
842
885
|
*/
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
886
|
+
function hashToken$1(token) {
|
|
887
|
+
return createHash("sha256").update(token).digest("hex");
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Build the `AuthCollectionContext` facade from server internals.
|
|
891
|
+
*
|
|
892
|
+
* This is the simplified context exposed to collection-level auth hooks,
|
|
893
|
+
* keeping them decoupled from internal interfaces like `AuthRepository`.
|
|
894
|
+
*/
|
|
895
|
+
function buildCollectionContext(ctx) {
|
|
896
|
+
const isEmailConfigured = !!(ctx.emailService && ctx.emailService.isConfigured());
|
|
897
|
+
return {
|
|
898
|
+
hashPassword: (password) => ctx.resolvedHooks.hashPassword(password),
|
|
899
|
+
sendEmail: isEmailConfigured ? (options) => ctx.emailService.send(options) : void 0,
|
|
900
|
+
emailConfigured: isEmailConfigured,
|
|
901
|
+
appName: ctx.emailConfig?.appName || "Rebase",
|
|
902
|
+
resetPasswordUrl: ctx.emailConfig?.resetPasswordUrl || ""
|
|
853
903
|
};
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Prepare user values for an admin-initiated user creation.
|
|
907
|
+
*
|
|
908
|
+
* Resolution order:
|
|
909
|
+
* 1. Collection-level `auth.onCreateUser` — closest to the data
|
|
910
|
+
* 2. Backend-level `AuthHooks.onAdminCreateUser` — global override
|
|
911
|
+
* 3. Built-in default — generate password → hash → normalize email
|
|
912
|
+
*
|
|
913
|
+
* The caller is responsible for persisting (via `driver.save()`).
|
|
914
|
+
*/
|
|
915
|
+
async function prepareAdminUserValues(body, ctx) {
|
|
916
|
+
const { resolvedHooks, collectionAuthConfig } = ctx;
|
|
917
|
+
if (collectionAuthConfig?.onCreateUser) {
|
|
918
|
+
const collectionCtx = buildCollectionContext(ctx);
|
|
919
|
+
const hookResult = await collectionAuthConfig.onCreateUser(body, collectionCtx);
|
|
870
920
|
return {
|
|
871
|
-
|
|
872
|
-
|
|
921
|
+
values: hookResult.values,
|
|
922
|
+
clearPassword: hookResult.temporaryPassword,
|
|
923
|
+
hookHandledEmail: true,
|
|
924
|
+
invitationSent: hookResult.invitationSent ?? false
|
|
873
925
|
};
|
|
874
|
-
};
|
|
875
|
-
/**
|
|
876
|
-
* Decide what a valid download token entitles this request to.
|
|
877
|
-
*
|
|
878
|
-
* One function rather than a copy per token location: a Bearer token and a
|
|
879
|
-
* `?token=` are the same grant presented two ways, and the whole failure
|
|
880
|
-
* this guards against is two places disagreeing about what was granted.
|
|
881
|
-
*
|
|
882
|
-
* `"no-path"` is distinct from the two denials because the caller treats it
|
|
883
|
-
* differently — a request with no object path at all is not a denied read,
|
|
884
|
-
* it is a request this middleware has nothing to say about, and on
|
|
885
|
-
* `/metadata/*` it must still reach the downstream auth gate.
|
|
886
|
-
*/
|
|
887
|
-
const evaluateGrant = (payload) => {
|
|
888
|
-
if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
|
|
889
|
-
const rawPath = extractWildcard(c);
|
|
890
|
-
if (!rawPath) return "no-path";
|
|
891
|
-
const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
|
|
892
|
-
if (resolvedPath === null) return "deny-path";
|
|
893
|
-
return isPathMatch(`${bucket}/${resolvedPath}`, payload.path) ? "grant" : "deny-path";
|
|
894
|
-
};
|
|
895
|
-
/**
|
|
896
|
-
* Which half of the scope failed is named in the message. The holder of the
|
|
897
|
-
* token already knows both values, so this reveals nothing to an attacker —
|
|
898
|
-
* and without it, a client that forgets to forward `?storageId=` on the file
|
|
899
|
-
* URL reports a "path mismatch" for a path that matches perfectly.
|
|
900
|
-
*/
|
|
901
|
-
const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
|
|
902
|
-
const bearerToken = extractBearerToken(authHeader);
|
|
903
|
-
if (bearerToken !== void 0) {
|
|
904
|
-
const payload = await verifyDownloadToken(bearerToken);
|
|
905
|
-
if (payload) {
|
|
906
|
-
const outcome = evaluateGrant(payload);
|
|
907
|
-
if (outcome === "grant") {
|
|
908
|
-
c.set("user", {
|
|
909
|
-
uid: "download-token",
|
|
910
|
-
roles: ["reader"]
|
|
911
|
-
});
|
|
912
|
-
return next();
|
|
913
|
-
}
|
|
914
|
-
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
915
|
-
}
|
|
916
|
-
if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
|
|
917
|
-
return next();
|
|
918
926
|
}
|
|
919
|
-
if (
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
|
|
927
|
+
if (resolvedHooks.onAdminCreateUser) {
|
|
928
|
+
const hookResult = await resolvedHooks.onAdminCreateUser(body, {
|
|
929
|
+
authRepo: ctx.authRepo,
|
|
930
|
+
emailService: ctx.emailService,
|
|
931
|
+
emailConfig: ctx.emailConfig,
|
|
932
|
+
hashPassword: (password) => resolvedHooks.hashPassword(password)
|
|
933
|
+
});
|
|
934
|
+
return {
|
|
935
|
+
values: hookResult.values,
|
|
936
|
+
clearPassword: hookResult.temporaryPassword,
|
|
937
|
+
hookHandledEmail: true,
|
|
938
|
+
invitationSent: hookResult.invitationSent ?? false
|
|
939
|
+
};
|
|
933
940
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
941
|
+
const password = body.password;
|
|
942
|
+
const clearPassword = password || generateSecurePassword();
|
|
943
|
+
const passwordHash = await resolvedHooks.hashPassword(clearPassword);
|
|
944
|
+
const values = { ...body };
|
|
945
|
+
values.passwordHash = passwordHash;
|
|
946
|
+
if (values.email) values.email = normalizeEmail(values.email);
|
|
947
|
+
values.emailVerified = true;
|
|
948
|
+
delete values.password;
|
|
949
|
+
return {
|
|
950
|
+
values,
|
|
951
|
+
clearPassword: password ? void 0 : clearPassword,
|
|
952
|
+
hookHandledEmail: false,
|
|
953
|
+
invitationSent: false
|
|
954
|
+
};
|
|
955
|
+
}
|
|
938
956
|
/**
|
|
939
|
-
*
|
|
957
|
+
* Handle post-creation work for admin-created users.
|
|
958
|
+
*
|
|
959
|
+
* Sends an invitation email (password-reset link) if email is configured
|
|
960
|
+
* and no explicit password was provided. Falls back to returning the
|
|
961
|
+
* temporary password if email fails or is not configured.
|
|
940
962
|
*/
|
|
941
|
-
function
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
const
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
} catch (error) {
|
|
977
|
-
logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
|
|
978
|
-
return refuse(c, ApiError.internal("Internal authentication error"));
|
|
979
|
-
}
|
|
980
|
-
} else {
|
|
981
|
-
if (presentedToken !== void 0) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
982
|
-
try {
|
|
983
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
984
|
-
uid: ANONYMOUS_USER_ID,
|
|
985
|
-
roles: ["anon"]
|
|
986
|
-
}));
|
|
987
|
-
} catch (error) {
|
|
988
|
-
logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
|
|
989
|
-
return refuse(c, ApiError.internal("Server configuration error"));
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
993
|
-
return next();
|
|
963
|
+
async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
|
|
964
|
+
if (!clearPassword) return { invitationSent: false };
|
|
965
|
+
if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
|
|
966
|
+
const token = generateSecureToken();
|
|
967
|
+
const tokenHash = hashToken$1(token);
|
|
968
|
+
const expiresAt = new Date(Date.now() + 1440 * 60 * 1e3);
|
|
969
|
+
await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
|
|
970
|
+
const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
|
|
971
|
+
const { appName, logoUrl } = resolveEmailBranding(ctx.emailConfig);
|
|
972
|
+
const templateFn = ctx.emailConfig?.templates?.userInvitation;
|
|
973
|
+
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
974
|
+
email: entity.values.email,
|
|
975
|
+
displayName: entity.values.displayName
|
|
976
|
+
}) : getUserInvitationTemplate(setPasswordUrl, {
|
|
977
|
+
email: entity.values.email,
|
|
978
|
+
displayName: entity.values.displayName
|
|
979
|
+
}, appName, logoUrl);
|
|
980
|
+
await ctx.emailService.send({
|
|
981
|
+
to: entity.values.email,
|
|
982
|
+
subject: emailContent.subject,
|
|
983
|
+
html: emailContent.html,
|
|
984
|
+
text: emailContent.text
|
|
985
|
+
});
|
|
986
|
+
return { invitationSent: true };
|
|
987
|
+
} catch (emailError) {
|
|
988
|
+
logger.error("Failed to send reset email", { error: emailError instanceof Error ? emailError.message : emailError });
|
|
989
|
+
return {
|
|
990
|
+
temporaryPassword: clearPassword,
|
|
991
|
+
invitationSent: false,
|
|
992
|
+
emailDeliveryFailed: true
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
temporaryPassword: clearPassword,
|
|
997
|
+
invitationSent: false
|
|
994
998
|
};
|
|
995
999
|
}
|
|
996
|
-
//#endregion
|
|
997
|
-
//#region src/email/html.ts
|
|
998
1000
|
/**
|
|
999
|
-
*
|
|
1001
|
+
* The step after a new user's row is written: who delivers the credentials,
|
|
1002
|
+
* and what the create response says about it.
|
|
1000
1003
|
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
* whose SPF and DKIM check out.
|
|
1004
|
+
* A create hook that ran owns delivery. What it reported is the answer: the
|
|
1005
|
+
* invitation it says it sent, and the temporary password it chose, which the
|
|
1006
|
+
* admin has to see because it is the one the user will be told. The framework
|
|
1007
|
+
* sends nothing of its own. Without a hook, `finalize` runs: the built-in
|
|
1008
|
+
* invitation, or the generated password when email is not configured or fails.
|
|
1007
1009
|
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
|
|
1015
|
-
/**
|
|
1016
|
-
* Markup that has already been vetted and must be interpolated verbatim.
|
|
1010
|
+
* Both doors that create a user call this, the auth collection's REST route
|
|
1011
|
+
* and `POST /admin/users`. The second used to call `finalizeAdminUserCreation`
|
|
1012
|
+
* directly and read neither `hookHandledEmail` nor `invitationSent`, so it
|
|
1013
|
+
* handled a hook's password as if the framework had generated it. With email
|
|
1014
|
+
* configured it sent its own invitation, a second one when the hook had sent
|
|
1015
|
+
* its own, and left the password out of the response. The decision lives here
|
|
1016
|
+
* so that the two doors cannot disagree about it again.
|
|
1017
1017
|
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
* `raw()` defeats the entire mechanism.
|
|
1021
|
-
*/
|
|
1022
|
-
var RawHtml = class {
|
|
1023
|
-
value;
|
|
1024
|
-
constructor(value) {
|
|
1025
|
-
this.value = value;
|
|
1026
|
-
}
|
|
1027
|
-
toString() {
|
|
1028
|
-
return this.value;
|
|
1029
|
-
}
|
|
1030
|
-
};
|
|
1031
|
-
/**
|
|
1032
|
-
* Mark a static, author-controlled string as safe to interpolate unescaped.
|
|
1018
|
+
* The result has exactly the fields a create response carries, with absent
|
|
1019
|
+
* ones left out.
|
|
1033
1020
|
*/
|
|
1034
|
-
function
|
|
1035
|
-
|
|
1021
|
+
async function completeUserCreation(prepared, finalize) {
|
|
1022
|
+
const result = prepared.hookHandledEmail ? {
|
|
1023
|
+
temporaryPassword: prepared.clearPassword,
|
|
1024
|
+
invitationSent: prepared.invitationSent
|
|
1025
|
+
} : finalize ? await finalize(prepared.clearPassword) : { invitationSent: false };
|
|
1026
|
+
return {
|
|
1027
|
+
invitationSent: result.invitationSent,
|
|
1028
|
+
...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
|
|
1029
|
+
...result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
1030
|
+
};
|
|
1036
1031
|
}
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/auth/token-revocation.ts
|
|
1037
1034
|
/**
|
|
1038
|
-
*
|
|
1039
|
-
* quoted attribute value.
|
|
1035
|
+
* Has this access token been revoked?
|
|
1040
1036
|
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1037
|
+
* Everything that ends every session a user holds — a password change or reset
|
|
1038
|
+
* of any kind, and `DELETE /auth/sessions` — goes through
|
|
1039
|
+
* {@link revokeAllSessions}, which stamps a `tokensValidAfter` watermark on the
|
|
1040
|
+
* user. It also deletes refresh-token rows, which is what made the gap easy to
|
|
1041
|
+
* miss: the session really is gone, and the *refresh* path really does check
|
|
1042
|
+
* the watermark — so signing out looked like it worked.
|
|
1043
|
+
*
|
|
1044
|
+
* The access token was untouched. It is a bearer credential that nothing
|
|
1045
|
+
* consulted a database about, so it stayed valid for its full lifetime after
|
|
1046
|
+
* every one of those actions. "Sign out everywhere" left the stolen token
|
|
1047
|
+
* working; so did changing your password because you thought it had leaked.
|
|
1048
|
+
*
|
|
1049
|
+
* The watermark had exactly one read in the repository, on refresh. This adds
|
|
1050
|
+
* the other one.
|
|
1051
|
+
*
|
|
1052
|
+
* ## Cost
|
|
1053
|
+
*
|
|
1054
|
+
* One indexed lookup per request, on the paths that already make one. The
|
|
1055
|
+
* adapter re-reads roles for every authenticated request; the admin routes now
|
|
1056
|
+
* do too. Both call this with the row they were already fetching in mind — a
|
|
1057
|
+
* repository that wants to serve both from one query is free to cache
|
|
1058
|
+
* internally, but nothing here assumes it.
|
|
1059
|
+
*
|
|
1060
|
+
* ## Failure
|
|
1061
|
+
*
|
|
1062
|
+
* Fails **open**, deliberately, and this is the one place in the auth stack
|
|
1063
|
+
* where that is right. The watermark is an extra revocation signal layered over
|
|
1064
|
+
* a token that has already been verified — signature, expiry and purpose all
|
|
1065
|
+
* checked. Refusing every request when the database is unreachable would turn a
|
|
1066
|
+
* transient outage into a total sign-out of an entire deployment, and the
|
|
1067
|
+
* attacker this protects against needs to have already stolen a live token. The
|
|
1068
|
+
* failure is logged at warn so it is visible rather than silent.
|
|
1044
1069
|
*/
|
|
1045
|
-
function
|
|
1046
|
-
|
|
1070
|
+
async function isAccessTokenRevoked(authRepo, payload) {
|
|
1071
|
+
if (typeof authRepo.getTokensValidAfter !== "function") return false;
|
|
1072
|
+
if (typeof payload.iat !== "number") return false;
|
|
1073
|
+
let validAfter;
|
|
1074
|
+
try {
|
|
1075
|
+
validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
|
|
1078
|
+
uid: payload.uid,
|
|
1079
|
+
error
|
|
1080
|
+
});
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
if (!validAfter) return false;
|
|
1084
|
+
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
1047
1085
|
}
|
|
1048
1086
|
/**
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1087
|
+
* End every session this user holds, on every device.
|
|
1088
|
+
*
|
|
1089
|
+
* Two writes, because each covers what the other cannot. Deleting the refresh
|
|
1090
|
+
* rows ends the sessions that exist at this instant, and on a repository
|
|
1091
|
+
* without the watermark it is the only revocation there is. The watermark
|
|
1092
|
+
* voids what the delete cannot see: a refresh already in flight that inserts
|
|
1093
|
+
* its rotated token a moment after the delete ran, and every access token
|
|
1094
|
+
* already handed out, which {@link isAccessTokenRevoked} refuses from here on.
|
|
1095
|
+
*
|
|
1096
|
+
* The watermark write does not fail the caller. By the time it runs the
|
|
1097
|
+
* credential has usually already changed and the rows are gone, so a 500 would
|
|
1098
|
+
* tell someone their password did not change when it did. It is logged, because
|
|
1099
|
+
* a failure here leaves access tokens working until they expire.
|
|
1051
1100
|
*/
|
|
1052
|
-
function
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1101
|
+
async function revokeAllSessions(authRepo, uid) {
|
|
1102
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1103
|
+
try {
|
|
1104
|
+
await authRepo.setTokensValidAfter?.(uid, /* @__PURE__ */ new Date());
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
logger.warn("[Auth] Could not write the token revocation watermark; access tokens issued before now stay valid until they expire", {
|
|
1107
|
+
uid,
|
|
1108
|
+
error
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1056
1111
|
}
|
|
1057
1112
|
/**
|
|
1058
|
-
*
|
|
1059
|
-
* {@link RawHtml}.
|
|
1113
|
+
* Replace a user's password and end every session they hold.
|
|
1060
1114
|
*
|
|
1061
|
-
*
|
|
1115
|
+
* The one way a password hash is written over an existing account. A new
|
|
1116
|
+
* password is what someone sets when they believe the old one — or a session
|
|
1117
|
+
* signed in with it — is in someone else's hands, so a password change that
|
|
1118
|
+
* leaves those sessions alive does not do the thing it was done for.
|
|
1062
1119
|
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1120
|
+
* It used to be a pair of lines repeated after each `updatePassword`, and it
|
|
1121
|
+
* reached the two self-service routes and none of the admin ones: an
|
|
1122
|
+
* administrator resetting a phished account left the attacker's refresh token
|
|
1123
|
+
* minting access tokens for the rest of its lifetime.
|
|
1124
|
+
* `test/password-change-revokes-sessions.test.ts` holds every route that sets a
|
|
1125
|
+
* password to this, and fails if anything but this function calls
|
|
1126
|
+
* `updatePassword`.
|
|
1067
1127
|
*/
|
|
1068
|
-
function
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
return new RawHtml(out);
|
|
1128
|
+
async function replaceUserPassword(authRepo, uid, passwordHash) {
|
|
1129
|
+
await authRepo.updatePassword(uid, passwordHash);
|
|
1130
|
+
await revokeAllSessions(authRepo, uid);
|
|
1072
1131
|
}
|
|
1073
1132
|
//#endregion
|
|
1074
|
-
//#region src/
|
|
1133
|
+
//#region src/auth/rls-scope.ts
|
|
1134
|
+
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
1075
1135
|
/**
|
|
1076
|
-
*
|
|
1136
|
+
* The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
|
|
1137
|
+
* caller presenting the service key.
|
|
1077
1138
|
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
*
|
|
1139
|
+
* Naming it makes the privilege legible, because it is narrower than it reads.
|
|
1140
|
+
* This is **not** an RLS bypass — a driver scoped with it is an
|
|
1141
|
+
* `AuthenticatedPostgresBackendDriver`, so every statement runs in a
|
|
1142
|
+
* transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
1143
|
+
* `app.uid = 'service'`, and policies are evaluated. Two consequences worth
|
|
1144
|
+
* knowing before you write a policy:
|
|
1145
|
+
*
|
|
1146
|
+
* - It passes the default policies through their `rolesOverlap(['admin'])`
|
|
1147
|
+
* arm — the same arm an application user holding the `admin` role passes.
|
|
1148
|
+
* - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
|
|
1149
|
+
* **false** for it. A collection with `disableDefaultPolicies: true` whose
|
|
1150
|
+
* only rule is `serverContext()` denies these writes (42501) and returns
|
|
1151
|
+
* zero rows for these reads.
|
|
1152
|
+
*
|
|
1153
|
+
* The true bypass is `rebase.sql()`, which runs on the owner connection and
|
|
1154
|
+
* never goes near `withAuth`.
|
|
1085
1155
|
*/
|
|
1156
|
+
var SERVICE_IDENTITY = {
|
|
1157
|
+
uid: "service",
|
|
1158
|
+
roles: ["admin"]
|
|
1159
|
+
};
|
|
1086
1160
|
/**
|
|
1087
|
-
*
|
|
1088
|
-
*
|
|
1089
|
-
* Returns the raw value — `displayName` is whatever the account was registered
|
|
1090
|
-
* with, including markup. Escaping is the `html` tag's job, so that a template
|
|
1091
|
-
* that interpolates the greeting into text (the plain-text bodies below) and one
|
|
1092
|
-
* that interpolates it into markup can share this.
|
|
1161
|
+
* Returns true if the driver supports RLS scoping via `withAuth()`.
|
|
1093
1162
|
*/
|
|
1094
|
-
function
|
|
1095
|
-
return
|
|
1163
|
+
function isRLSScopedDriver(driver) {
|
|
1164
|
+
return "withAuth" in driver && typeof driver.withAuth === "function";
|
|
1096
1165
|
}
|
|
1097
1166
|
/**
|
|
1098
|
-
*
|
|
1167
|
+
* Can this driver run a request as a specific user?
|
|
1099
1168
|
*
|
|
1100
|
-
* `
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
* `
|
|
1169
|
+
* The same question `scopeDataDriver` asks internally, exported because one
|
|
1170
|
+
* caller must act on the answer BEFORE it has a request to scope.
|
|
1171
|
+
*
|
|
1172
|
+
* `scopeDataDriver` returns the driver unscoped when the answer is no, which is
|
|
1173
|
+
* the right behaviour for `/api/data` — Mongo has no row-level security and
|
|
1174
|
+
* never claimed to, and the route's own checks still apply. It is the wrong
|
|
1175
|
+
* behaviour for a surface whose entire promise is "this runs as you": there,
|
|
1176
|
+
* an unscoped driver silently means "runs as the database owner". So the MCP
|
|
1177
|
+
* surface asks this at boot and declines to mount rather than degrade.
|
|
1104
1178
|
*/
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
max-width: 600px;
|
|
1109
|
-
margin: 0 auto;
|
|
1110
|
-
padding: 40px 20px;
|
|
1111
|
-
background-color: #f8fafc;
|
|
1112
|
-
`),
|
|
1113
|
-
card: raw(`
|
|
1114
|
-
background-color: #ffffff;
|
|
1115
|
-
border-radius: 12px;
|
|
1116
|
-
padding: 40px;
|
|
1117
|
-
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
1118
|
-
`),
|
|
1119
|
-
heading: raw(`
|
|
1120
|
-
color: #1e293b;
|
|
1121
|
-
font-size: 24px;
|
|
1122
|
-
font-weight: 600;
|
|
1123
|
-
margin: 0 0 20px 0;
|
|
1124
|
-
`),
|
|
1125
|
-
paragraph: raw(`
|
|
1126
|
-
color: #475569;
|
|
1127
|
-
font-size: 16px;
|
|
1128
|
-
line-height: 1.6;
|
|
1129
|
-
margin: 0 0 20px 0;
|
|
1130
|
-
`),
|
|
1131
|
-
button: raw(`
|
|
1132
|
-
display: inline-block;
|
|
1133
|
-
background-color: #3b82f6;
|
|
1134
|
-
color: #ffffff;
|
|
1135
|
-
font-size: 16px;
|
|
1136
|
-
font-weight: 600;
|
|
1137
|
-
text-decoration: none;
|
|
1138
|
-
padding: 14px 28px;
|
|
1139
|
-
border-radius: 8px;
|
|
1140
|
-
margin: 20px 0;
|
|
1141
|
-
`),
|
|
1142
|
-
footer: raw(`
|
|
1143
|
-
color: #94a3b8;
|
|
1144
|
-
font-size: 14px;
|
|
1145
|
-
margin-top: 30px;
|
|
1146
|
-
padding-top: 20px;
|
|
1147
|
-
border-top: 1px solid #e2e8f0;
|
|
1148
|
-
`),
|
|
1149
|
-
warning: raw(`
|
|
1150
|
-
color: #64748b;
|
|
1151
|
-
font-size: 14px;
|
|
1152
|
-
background-color: #fef3c7;
|
|
1153
|
-
padding: 12px 16px;
|
|
1154
|
-
border-radius: 6px;
|
|
1155
|
-
margin-top: 20px;
|
|
1156
|
-
`),
|
|
1157
|
-
/**
|
|
1158
|
-
* The one-time code itself. Monospace so the digits line up, spaced so a
|
|
1159
|
-
* `1` and a `7` are not read as one glyph, and large enough to be copied by
|
|
1160
|
-
* eye from a phone onto another device — which is the entire flow.
|
|
1161
|
-
*/
|
|
1162
|
-
code: raw(`
|
|
1163
|
-
display: inline-block;
|
|
1164
|
-
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
|
1165
|
-
font-size: 34px;
|
|
1166
|
-
font-weight: 700;
|
|
1167
|
-
letter-spacing: 10px;
|
|
1168
|
-
color: #1e293b;
|
|
1169
|
-
background-color: #f1f5f9;
|
|
1170
|
-
border-radius: 10px;
|
|
1171
|
-
padding: 18px 20px 18px 30px;
|
|
1172
|
-
`)
|
|
1173
|
-
};
|
|
1179
|
+
function supportsRlsScoping(driver) {
|
|
1180
|
+
return isRLSScopedDriver(driver);
|
|
1181
|
+
}
|
|
1174
1182
|
/**
|
|
1175
|
-
*
|
|
1183
|
+
* Scope a DataDriver via `withAuth()` for RLS.
|
|
1184
|
+
*
|
|
1185
|
+
* SECURITY: If `withAuth()` is available but fails, the error is re-thrown
|
|
1186
|
+
* so the request is **denied** rather than proceeding with unscoped access
|
|
1187
|
+
* (fail-closed behavior).
|
|
1188
|
+
*
|
|
1189
|
+
* If the driver does not support RLS, the original driver is returned.
|
|
1190
|
+
*
|
|
1191
|
+
* @param driver - The DataDriver to scope.
|
|
1192
|
+
* @param user - The authenticated user identity for RLS.
|
|
1193
|
+
* @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
|
|
1176
1194
|
*/
|
|
1177
|
-
|
|
1195
|
+
async function scopeDataDriver(driver, user) {
|
|
1196
|
+
if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
|
|
1197
|
+
return driver;
|
|
1198
|
+
}
|
|
1199
|
+
//#endregion
|
|
1200
|
+
//#region src/auth/crypto-utils.ts
|
|
1178
1201
|
/**
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1202
|
+
* Cryptographic utility functions for auth.
|
|
1203
|
+
*
|
|
1204
|
+
* @module
|
|
1182
1205
|
*/
|
|
1183
|
-
var REBASE_LOGO_URL = "https://rebase.pro/img/logo_small.png";
|
|
1184
1206
|
/**
|
|
1185
|
-
*
|
|
1207
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
1186
1208
|
*
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1209
|
+
* Used for comparing service keys, tokens, and other secrets where
|
|
1210
|
+
* timing side-channels could leak information about the expected value.
|
|
1211
|
+
*
|
|
1212
|
+
* The implementation lives in `utils/portable-crypto.ts`, which does this over
|
|
1213
|
+
* UTF-8 bytes without `node:crypto` — this function is on the request path, and
|
|
1214
|
+
* the byte-level reasoning it depends on is documented there.
|
|
1215
|
+
*
|
|
1216
|
+
* @param a - First string to compare.
|
|
1217
|
+
* @param b - Second string to compare.
|
|
1218
|
+
* @returns `true` if the strings are identical, `false` otherwise.
|
|
1192
1219
|
*/
|
|
1193
|
-
function
|
|
1194
|
-
|
|
1195
|
-
try {
|
|
1196
|
-
const { protocol } = new URL(url);
|
|
1197
|
-
return protocol === "https:" || protocol === "http:";
|
|
1198
|
-
} catch {
|
|
1199
|
-
return false;
|
|
1200
|
-
}
|
|
1220
|
+
function safeCompare(a, b) {
|
|
1221
|
+
return constantTimeEqual(a, b);
|
|
1201
1222
|
}
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/auth/bearer-token.ts
|
|
1202
1225
|
/**
|
|
1203
|
-
*
|
|
1204
|
-
* branded this install.
|
|
1226
|
+
* Parsing of the `Authorization: Bearer <token>` header.
|
|
1205
1227
|
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
* Rebase-run product.
|
|
1228
|
+
* Its own module rather than a helper inside `middleware.ts`, because the
|
|
1229
|
+
* API-key middleware needs it too and `middleware.ts` already imports from
|
|
1230
|
+
* there — putting it in either one would close an import cycle.
|
|
1231
|
+
*
|
|
1232
|
+
* @module
|
|
1212
1233
|
*/
|
|
1213
|
-
function isUnbranded(appName) {
|
|
1214
|
-
return !appName || appName === DEFAULT_APP_NAME;
|
|
1215
|
-
}
|
|
1216
1234
|
/**
|
|
1217
|
-
*
|
|
1235
|
+
* Pull the token out of an `Authorization: Bearer <token>` header.
|
|
1218
1236
|
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1237
|
+
* RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
|
|
1238
|
+
* clients send `bearer` — HTTP libraries that lower-case what they emit,
|
|
1239
|
+
* hand-written curl, proxies that normalise headers. Matching `"Bearer "`
|
|
1240
|
+
* exactly turned those into a 401 that, from the caller's side, is
|
|
1241
|
+
* indistinguishable from a rejected token; people spend an afternoon
|
|
1242
|
+
* regenerating credentials that were fine. Only the scheme is folded — the
|
|
1243
|
+
* token itself is returned byte-for-byte.
|
|
1244
|
+
*
|
|
1245
|
+
* Returns `undefined` when there is no header or it names another scheme.
|
|
1246
|
+
* That is deliberately distinct from an empty token (`Bearer ` with nothing
|
|
1247
|
+
* after it), which callers still route into verification so it fails as a bad
|
|
1248
|
+
* token rather than as a missing header.
|
|
1224
1249
|
*/
|
|
1225
|
-
function
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1250
|
+
function extractBearerToken(authHeader) {
|
|
1251
|
+
if (!authHeader) return void 0;
|
|
1252
|
+
const separator = authHeader.indexOf(" ");
|
|
1253
|
+
if (separator < 0) return void 0;
|
|
1254
|
+
if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
|
|
1255
|
+
return authHeader.slice(separator + 1);
|
|
1231
1256
|
}
|
|
1257
|
+
//#endregion
|
|
1258
|
+
//#region src/auth/api-keys/api-key-middleware.ts
|
|
1232
1259
|
/**
|
|
1233
|
-
*
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1260
|
+
* Check whether a token looks like a Rebase API key.
|
|
1261
|
+
*/
|
|
1262
|
+
function isApiKeyToken(token) {
|
|
1263
|
+
return token.startsWith("rk_");
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Hash a plaintext API key token for database lookup.
|
|
1239
1267
|
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
* renders as "Rebas". 12px plus `height: auto` — CSS the clients that block
|
|
1243
|
-
* images do read, while Outlook still sizes from the attributes — lets the name
|
|
1244
|
-
* through intact.
|
|
1268
|
+
* Async because it hashes with WebCrypto rather than `node:crypto` — the sole
|
|
1269
|
+
* caller was already awaiting the store lookup this feeds.
|
|
1245
1270
|
*/
|
|
1246
|
-
function
|
|
1247
|
-
|
|
1248
|
-
return html`
|
|
1249
|
-
<div style="text-align: center; margin-bottom: 24px;">
|
|
1250
|
-
<img src="${logoUrl}" width="48" height="48" alt="${appName}"
|
|
1251
|
-
style="display: inline-block; width: 48px; height: auto; border: 0; outline: none; text-decoration: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 12px; color: #64748b;">
|
|
1252
|
-
</div>
|
|
1253
|
-
`;
|
|
1271
|
+
function hashToken(token) {
|
|
1272
|
+
return sha256Hex(token);
|
|
1254
1273
|
}
|
|
1255
1274
|
/**
|
|
1256
|
-
*
|
|
1275
|
+
* Validate an API key token and populate the Hono context.
|
|
1276
|
+
*
|
|
1277
|
+
* Returns `true` if the key is valid and context has been populated,
|
|
1278
|
+
* or returns an error Response if the key is invalid.
|
|
1279
|
+
*
|
|
1280
|
+
* This is NOT a standalone middleware — it's called from within
|
|
1281
|
+
* `createAuthMiddleware()` when a `rk_` prefixed token is detected.
|
|
1257
1282
|
*/
|
|
1258
|
-
function
|
|
1259
|
-
const
|
|
1260
|
-
const
|
|
1261
|
-
const
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
<div style="${styles.warning}">
|
|
1295
|
-
⏰ This link will expire in 1 hour for security reasons.
|
|
1296
|
-
</div>
|
|
1297
|
-
|
|
1298
|
-
<div style="${styles.footer}">
|
|
1299
|
-
<p style="margin: 0;">
|
|
1300
|
-
If you didn't request a password reset, you can safely ignore this email.
|
|
1301
|
-
Your password will remain unchanged.
|
|
1302
|
-
</p>
|
|
1303
|
-
</div>
|
|
1304
|
-
</div>
|
|
1305
|
-
</div>
|
|
1306
|
-
</body>
|
|
1307
|
-
</html>
|
|
1308
|
-
`;
|
|
1309
|
-
const text = `
|
|
1310
|
-
Reset Your Password
|
|
1311
|
-
|
|
1312
|
-
Hi ${greeting},
|
|
1313
|
-
|
|
1314
|
-
We received a request to reset your password for your ${appName} account.
|
|
1315
|
-
|
|
1316
|
-
Click this link to create a new password:
|
|
1317
|
-
${resetUrl}
|
|
1318
|
-
|
|
1319
|
-
This link will expire in 1 hour for security reasons.
|
|
1320
|
-
|
|
1321
|
-
If you didn't request a password reset, you can safely ignore this email.
|
|
1322
|
-
Your password will remain unchanged.
|
|
1323
|
-
`.trim();
|
|
1324
|
-
return {
|
|
1325
|
-
subject,
|
|
1326
|
-
html: body.toString().trim(),
|
|
1327
|
-
text
|
|
1283
|
+
async function validateApiKey(c, token, options) {
|
|
1284
|
+
const { store, driver } = options;
|
|
1285
|
+
const hash = await hashToken(token);
|
|
1286
|
+
const apiKey = await store.findByKeyHash(hash);
|
|
1287
|
+
if (!apiKey) return c.json({ error: {
|
|
1288
|
+
message: "Invalid API key",
|
|
1289
|
+
code: "UNAUTHORIZED"
|
|
1290
|
+
} }, 401);
|
|
1291
|
+
if (apiKey.revoked_at) return c.json({ error: {
|
|
1292
|
+
message: "API key has been revoked",
|
|
1293
|
+
code: "UNAUTHORIZED"
|
|
1294
|
+
} }, 401);
|
|
1295
|
+
if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return c.json({ error: {
|
|
1296
|
+
message: "API key has expired",
|
|
1297
|
+
code: "UNAUTHORIZED"
|
|
1298
|
+
} }, 401);
|
|
1299
|
+
const uid = `api-key:${apiKey.id}`;
|
|
1300
|
+
const roles = apiKey.admin ? ["admin", "service"] : ["service"];
|
|
1301
|
+
c.set("user", {
|
|
1302
|
+
uid,
|
|
1303
|
+
roles
|
|
1304
|
+
});
|
|
1305
|
+
const masked = {
|
|
1306
|
+
id: apiKey.id,
|
|
1307
|
+
name: apiKey.name,
|
|
1308
|
+
key_prefix: apiKey.key_prefix,
|
|
1309
|
+
permissions: apiKey.permissions,
|
|
1310
|
+
admin: apiKey.admin,
|
|
1311
|
+
rate_limit: apiKey.rate_limit,
|
|
1312
|
+
created_by: apiKey.created_by,
|
|
1313
|
+
created_at: apiKey.created_at,
|
|
1314
|
+
updated_at: apiKey.updated_at,
|
|
1315
|
+
last_used_at: apiKey.last_used_at,
|
|
1316
|
+
expires_at: apiKey.expires_at,
|
|
1317
|
+
revoked_at: apiKey.revoked_at
|
|
1328
1318
|
};
|
|
1319
|
+
c.set("apiKey", masked);
|
|
1320
|
+
try {
|
|
1321
|
+
const scopedDriver = await scopeDataDriver(driver, {
|
|
1322
|
+
uid,
|
|
1323
|
+
roles
|
|
1324
|
+
});
|
|
1325
|
+
c.set("driver", scopedDriver);
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
logger.error("[AUTH] RLS scoping failed for API key", { error });
|
|
1328
|
+
return c.json({ error: {
|
|
1329
|
+
message: "Internal authentication error",
|
|
1330
|
+
code: "INTERNAL_ERROR"
|
|
1331
|
+
} }, 500);
|
|
1332
|
+
}
|
|
1333
|
+
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
1334
|
+
const now = Date.now();
|
|
1335
|
+
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
1336
|
+
lastUsedTouchedAt.set(apiKey.id, now);
|
|
1337
|
+
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
1338
|
+
}
|
|
1339
|
+
return true;
|
|
1329
1340
|
}
|
|
1341
|
+
/** Per-process debounce state for last_used_at touches. */
|
|
1342
|
+
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
1343
|
+
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
1330
1344
|
/**
|
|
1331
|
-
*
|
|
1345
|
+
* Shared 403 response for API-key permission denials outside the REST
|
|
1346
|
+
* generator (storage and functions guards). One envelope, one code — a
|
|
1347
|
+
* change to the error contract happens in one place.
|
|
1332
1348
|
*/
|
|
1333
|
-
function
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
<html>
|
|
1339
|
-
<head>
|
|
1340
|
-
<meta charset="utf-8">
|
|
1341
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1342
|
-
<title>${subject}</title>
|
|
1343
|
-
</head>
|
|
1344
|
-
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
1345
|
-
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
1346
|
-
<div style="${styles.card}">
|
|
1347
|
-
<h1 style="${styles.heading}">Verify Your Email</h1>
|
|
1348
|
-
|
|
1349
|
-
<p style="${styles.paragraph}">
|
|
1350
|
-
Hi ${greeting},
|
|
1351
|
-
</p>
|
|
1352
|
-
|
|
1353
|
-
<p style="${styles.paragraph}">
|
|
1354
|
-
Thanks for signing up for ${appName}! Please verify your email address
|
|
1355
|
-
by clicking the button below:
|
|
1356
|
-
</p>
|
|
1357
|
-
|
|
1358
|
-
<div style="text-align: center;">
|
|
1359
|
-
<a href="${verifyUrl}" style="${styles.button}">Verify Email Address</a>
|
|
1360
|
-
</div>
|
|
1361
|
-
|
|
1362
|
-
<p style="${styles.paragraph}">
|
|
1363
|
-
Or copy and paste this link into your browser:
|
|
1364
|
-
</p>
|
|
1365
|
-
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
1366
|
-
${verifyUrl}
|
|
1367
|
-
</p>
|
|
1368
|
-
|
|
1369
|
-
<div style="${styles.footer}">
|
|
1370
|
-
<p style="margin: 0;">
|
|
1371
|
-
If you didn't create an account with ${appName}, you can safely ignore this email.
|
|
1372
|
-
</p>
|
|
1373
|
-
</div>
|
|
1374
|
-
</div>
|
|
1375
|
-
</div>
|
|
1376
|
-
</body>
|
|
1377
|
-
</html>
|
|
1378
|
-
`;
|
|
1379
|
-
const text = `
|
|
1380
|
-
Verify Your Email
|
|
1381
|
-
|
|
1382
|
-
Hi ${greeting},
|
|
1383
|
-
|
|
1384
|
-
Thanks for signing up for ${appName}! Please verify your email address by clicking this link:
|
|
1385
|
-
${verifyUrl}
|
|
1386
|
-
|
|
1387
|
-
If you didn't create an account with ${appName}, you can safely ignore this email.
|
|
1388
|
-
`.trim();
|
|
1389
|
-
return {
|
|
1390
|
-
subject,
|
|
1391
|
-
html: body.toString().trim(),
|
|
1392
|
-
text
|
|
1393
|
-
};
|
|
1349
|
+
function forbidApiKey(c, operation, resource, grantHint) {
|
|
1350
|
+
return c.json({ error: {
|
|
1351
|
+
message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
|
|
1352
|
+
code: "API_KEY_FORBIDDEN"
|
|
1353
|
+
} }, 403);
|
|
1394
1354
|
}
|
|
1395
1355
|
/**
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1356
|
+
* Permission guard for API-key requests to the storage router.
|
|
1357
|
+
*
|
|
1358
|
+
* Storage previously did not accept API keys at all (`rk_` tokens were
|
|
1359
|
+
* misparsed as JWTs and 401'd). Now that the pre-auth middleware
|
|
1360
|
+
* authenticates them, this guard decides what they may do: the key needs a
|
|
1361
|
+
* `"storage"` permission entry (or the global `"*"` wildcard) covering the
|
|
1362
|
+
* operation derived from the HTTP method. Requests not authenticated via an
|
|
1363
|
+
* API key pass through to the storage router's own auth gates.
|
|
1364
|
+
*
|
|
1365
|
+
* TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
|
|
1366
|
+
* for EVERY method: the protocol's offset check is a GET and its cancel is a
|
|
1367
|
+
* DELETE, but both are steps of an upload — a write-scoped key must be able
|
|
1368
|
+
* to complete (and abort) its own resumable upload without also holding
|
|
1369
|
+
* `read`/`delete` on stored objects.
|
|
1398
1370
|
*/
|
|
1399
|
-
function
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
<meta charset="utf-8">
|
|
1407
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1408
|
-
<title>${subject}</title>
|
|
1409
|
-
</head>
|
|
1410
|
-
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
1411
|
-
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
1412
|
-
<div style="${styles.card}">
|
|
1413
|
-
<h1 style="${styles.heading}">Welcome to ${appName}!</h1>
|
|
1414
|
-
|
|
1415
|
-
<p style="${styles.paragraph}">
|
|
1416
|
-
Hi ${greeting},
|
|
1417
|
-
</p>
|
|
1418
|
-
|
|
1419
|
-
<p style="${styles.paragraph}">
|
|
1420
|
-
An account has been created for you on ${appName}.
|
|
1421
|
-
Click the button below to set your password and get started:
|
|
1422
|
-
</p>
|
|
1423
|
-
|
|
1424
|
-
<div style="text-align: center;">
|
|
1425
|
-
<a href="${setPasswordUrl}" style="${styles.button}">Set Your Password</a>
|
|
1426
|
-
</div>
|
|
1427
|
-
|
|
1428
|
-
<p style="${styles.paragraph}">
|
|
1429
|
-
Or copy and paste this link into your browser:
|
|
1430
|
-
</p>
|
|
1431
|
-
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
1432
|
-
${setPasswordUrl}
|
|
1433
|
-
</p>
|
|
1434
|
-
|
|
1435
|
-
<div style="${styles.warning}">
|
|
1436
|
-
⏰ This link will expire in 1 hour for security reasons.
|
|
1437
|
-
</div>
|
|
1438
|
-
|
|
1439
|
-
<div style="${styles.footer}">
|
|
1440
|
-
<p style="margin: 0;">
|
|
1441
|
-
If you weren't expecting this invitation, you can safely ignore this email.
|
|
1442
|
-
</p>
|
|
1443
|
-
</div>
|
|
1444
|
-
</div>
|
|
1445
|
-
</div>
|
|
1446
|
-
</body>
|
|
1447
|
-
</html>
|
|
1448
|
-
`;
|
|
1449
|
-
const text = `
|
|
1450
|
-
Welcome to ${appName}!
|
|
1451
|
-
|
|
1452
|
-
Hi ${greeting},
|
|
1453
|
-
|
|
1454
|
-
An account has been created for you on ${appName}.
|
|
1455
|
-
|
|
1456
|
-
Click this link to set your password and get started:
|
|
1457
|
-
${setPasswordUrl}
|
|
1458
|
-
|
|
1459
|
-
This link will expire in 1 hour for security reasons.
|
|
1460
|
-
|
|
1461
|
-
If you weren't expecting this invitation, you can safely ignore this email.
|
|
1462
|
-
`.trim();
|
|
1463
|
-
return {
|
|
1464
|
-
subject,
|
|
1465
|
-
html: body.toString().trim(),
|
|
1466
|
-
text
|
|
1371
|
+
function createStorageApiKeyGuard() {
|
|
1372
|
+
return async (c, next) => {
|
|
1373
|
+
const apiKey = c.get("apiKey");
|
|
1374
|
+
if (!apiKey) return next();
|
|
1375
|
+
const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
|
|
1376
|
+
if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
|
|
1377
|
+
return next();
|
|
1467
1378
|
};
|
|
1468
1379
|
}
|
|
1469
1380
|
/**
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
1381
|
+
* Permission guard for API-key requests to the custom-functions router.
|
|
1382
|
+
*
|
|
1383
|
+
* The collection permission guard lives in the REST generator and never sees
|
|
1384
|
+
* function routes, so before this middleware existed ANY valid API key —
|
|
1385
|
+
* however narrowly scoped — could invoke every custom function. This guard
|
|
1386
|
+
* closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
|
|
1387
|
+
* permission entry (or the global `"*"` wildcard) for the derived operation.
|
|
1388
|
+
*
|
|
1389
|
+
* Non-API-key requests (JWT, service key, anonymous) pass through untouched —
|
|
1390
|
+
* functions decide their own auth for those, as before.
|
|
1391
|
+
*
|
|
1392
|
+
* @param mountPrefix - The path the functions router is mounted at
|
|
1393
|
+
* (e.g. `/api/functions`), used to extract the function
|
|
1394
|
+
* name from the request path.
|
|
1472
1395
|
*/
|
|
1473
|
-
function
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
<
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
<p style="${styles.paragraph}">
|
|
1491
|
-
Hi ${greeting},
|
|
1492
|
-
</p>
|
|
1493
|
-
|
|
1494
|
-
<p style="${styles.paragraph}">
|
|
1495
|
-
Your ${appName} account is ready.
|
|
1496
|
-
</p>
|
|
1497
|
-
|
|
1498
|
-
${url ? html`
|
|
1499
|
-
<div style="text-align: center;">
|
|
1500
|
-
<a href="${url}" style="${styles.button}">Open ${appName}</a>
|
|
1501
|
-
</div>
|
|
1502
|
-
` : raw("")}
|
|
1503
|
-
|
|
1504
|
-
<p style="${styles.paragraph}">
|
|
1505
|
-
If you have any questions, just reply to this email.
|
|
1506
|
-
</p>
|
|
1507
|
-
|
|
1508
|
-
<div style="${styles.footer}">
|
|
1509
|
-
<p style="margin: 0;">
|
|
1510
|
-
You received this because an account was created with this email address at ${appName}.
|
|
1511
|
-
</p>
|
|
1512
|
-
</div>
|
|
1513
|
-
</div>
|
|
1514
|
-
</div>
|
|
1515
|
-
</body>
|
|
1516
|
-
</html>
|
|
1517
|
-
`;
|
|
1518
|
-
const text = `
|
|
1519
|
-
Welcome to ${appName}
|
|
1520
|
-
|
|
1521
|
-
Hi ${greeting},
|
|
1522
|
-
|
|
1523
|
-
Your ${appName} account is ready.
|
|
1524
|
-
|
|
1525
|
-
${url ? `Open ${appName}: ${url}` : ""}
|
|
1526
|
-
|
|
1527
|
-
If you have any questions, just reply to this email.
|
|
1528
|
-
`.trim();
|
|
1529
|
-
return {
|
|
1530
|
-
subject,
|
|
1531
|
-
html: body.toString().trim(),
|
|
1532
|
-
text
|
|
1396
|
+
function createFunctionApiKeyGuard(mountPrefix) {
|
|
1397
|
+
return async (c, next) => {
|
|
1398
|
+
const apiKey = c.get("apiKey");
|
|
1399
|
+
if (!apiKey) return next();
|
|
1400
|
+
const path = c.req.path;
|
|
1401
|
+
const idx = path.indexOf(mountPrefix);
|
|
1402
|
+
const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
1403
|
+
let functionName;
|
|
1404
|
+
try {
|
|
1405
|
+
functionName = decodeURIComponent(rawName);
|
|
1406
|
+
} catch {
|
|
1407
|
+
functionName = rawName;
|
|
1408
|
+
}
|
|
1409
|
+
const operation = httpMethodToOperation(c.req.method);
|
|
1410
|
+
if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
|
|
1411
|
+
return next();
|
|
1533
1412
|
};
|
|
1534
1413
|
}
|
|
1535
1414
|
/**
|
|
1536
|
-
*
|
|
1415
|
+
* Standalone pre-auth middleware for `rk_` bearer tokens.
|
|
1537
1416
|
*
|
|
1538
|
-
*
|
|
1539
|
-
*
|
|
1540
|
-
*
|
|
1417
|
+
* Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
|
|
1418
|
+
* the admin surfaces: admin users/roles, api-keys management, cron, backups,
|
|
1419
|
+
* logs, schema editor) don't know about API keys. Mounting this middleware in
|
|
1420
|
+
* front of them authenticates `rk_` tokens and populates the request context;
|
|
1421
|
+
* the downstream gates then see the already-resolved user and apply their
|
|
1422
|
+
* role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
|
|
1423
|
+
* a non-admin key is rejected with 403.
|
|
1424
|
+
*
|
|
1425
|
+
* Requests without an `rk_` bearer token pass through untouched. An invalid,
|
|
1426
|
+
* revoked, or expired `rk_` token is rejected here (401) rather than falling
|
|
1427
|
+
* through to be misparsed as a JWT.
|
|
1541
1428
|
*/
|
|
1542
|
-
function
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1551
|
-
<title>${subject}</title>
|
|
1552
|
-
</head>
|
|
1553
|
-
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
1554
|
-
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
1555
|
-
<div style="${styles.card}">
|
|
1556
|
-
<h1 style="${styles.heading}">Your sign-in code</h1>
|
|
1557
|
-
|
|
1558
|
-
<p style="${styles.paragraph}">
|
|
1559
|
-
Hi ${greeting},
|
|
1560
|
-
</p>
|
|
1561
|
-
|
|
1562
|
-
<p style="${styles.paragraph}">
|
|
1563
|
-
Enter this code to finish signing in to ${appName}:
|
|
1564
|
-
</p>
|
|
1565
|
-
|
|
1566
|
-
<div style="text-align: center; margin: 32px 0;">
|
|
1567
|
-
<span style="${styles.code}">${code}</span>
|
|
1568
|
-
</div>
|
|
1569
|
-
|
|
1570
|
-
<div style="${styles.warning}">
|
|
1571
|
-
⏰ This code expires in 10 minutes and can only be used once.
|
|
1572
|
-
</div>
|
|
1573
|
-
|
|
1574
|
-
<div style="${styles.footer}">
|
|
1575
|
-
<p style="margin: 0;">
|
|
1576
|
-
If you didn't try to sign in, you can safely ignore this email.
|
|
1577
|
-
We will never ask you for this code.
|
|
1578
|
-
</p>
|
|
1579
|
-
</div>
|
|
1580
|
-
</div>
|
|
1581
|
-
</div>
|
|
1582
|
-
</body>
|
|
1583
|
-
</html>
|
|
1584
|
-
`;
|
|
1585
|
-
const text = `
|
|
1586
|
-
Your ${appName} sign-in code
|
|
1587
|
-
|
|
1588
|
-
Hi ${greeting},
|
|
1589
|
-
|
|
1590
|
-
Enter this code to finish signing in:
|
|
1591
|
-
|
|
1592
|
-
${code}
|
|
1593
|
-
|
|
1594
|
-
This code expires in 10 minutes and can only be used once.
|
|
1595
|
-
|
|
1596
|
-
If you didn't try to sign in, you can safely ignore this email. We will never
|
|
1597
|
-
ask you for this code.
|
|
1598
|
-
`.trim();
|
|
1599
|
-
return {
|
|
1600
|
-
subject,
|
|
1601
|
-
html: body.toString().trim(),
|
|
1602
|
-
text
|
|
1429
|
+
function createApiKeyPreAuth(options) {
|
|
1430
|
+
return async (c, next) => {
|
|
1431
|
+
if (c.get("apiKey")) return next();
|
|
1432
|
+
const token = extractBearerToken(c.req.header("authorization")) ?? "";
|
|
1433
|
+
if (!token || !isApiKeyToken(token)) return next();
|
|
1434
|
+
const result = await validateApiKey(c, token, options);
|
|
1435
|
+
if (result !== true) return result;
|
|
1436
|
+
return next();
|
|
1603
1437
|
};
|
|
1604
1438
|
}
|
|
1439
|
+
//#endregion
|
|
1440
|
+
//#region src/auth/middleware.ts
|
|
1605
1441
|
/**
|
|
1606
|
-
*
|
|
1442
|
+
* Answer a refusal with the canonical envelope, through the same formatter
|
|
1443
|
+
* every route uses.
|
|
1444
|
+
*
|
|
1445
|
+
* These middlewares used to hand-build `c.json({ error: { message, code } },
|
|
1446
|
+
* 401)` — seven near-copies of it, which is how they came to omit `requestId`
|
|
1447
|
+
* while every other error on the server carried it: the one string a bug report
|
|
1448
|
+
* quotes was missing from precisely the errors an app hits most. It also meant
|
|
1449
|
+
* a routine 401 (a client refreshing before it knows whether a session exists)
|
|
1450
|
+
* logged like an incident, because `ApiError.expected` never reached the
|
|
1451
|
+
* logger.
|
|
1452
|
+
*
|
|
1453
|
+
* `errorHandler` is *called* rather than thrown to. A throw only becomes an
|
|
1454
|
+
* envelope where an `onError` is registered, and these middlewares are mounted
|
|
1455
|
+
* by callers this package does not control — `defineFunction` hands users a
|
|
1456
|
+
* Hono app and invites them to put `requireAuth` on a route of it. Making the
|
|
1457
|
+
* status depend on the mount point would turn a 401 into a 500 for exactly
|
|
1458
|
+
* those callers. Calling the formatter directly gets the envelope with none of
|
|
1459
|
+
* that.
|
|
1607
1460
|
*/
|
|
1608
|
-
function
|
|
1609
|
-
|
|
1610
|
-
const subject = `Sign in to ${appName}`;
|
|
1611
|
-
const body = html`
|
|
1612
|
-
<!DOCTYPE html>
|
|
1613
|
-
<html>
|
|
1614
|
-
<head>
|
|
1615
|
-
<meta charset="utf-8">
|
|
1616
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1617
|
-
<title>${subject}</title>
|
|
1618
|
-
</head>
|
|
1619
|
-
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
1620
|
-
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
1621
|
-
<div style="${styles.card}">
|
|
1622
|
-
<h1 style="${styles.heading}">Sign In to ${appName}</h1>
|
|
1623
|
-
|
|
1624
|
-
<p style="${styles.paragraph}">
|
|
1625
|
-
Hi ${greeting},
|
|
1626
|
-
</p>
|
|
1627
|
-
|
|
1628
|
-
<p style="${styles.paragraph}">
|
|
1629
|
-
We received a request to sign in to your ${appName} account.
|
|
1630
|
-
Click the button below to log in:
|
|
1631
|
-
</p>
|
|
1632
|
-
|
|
1633
|
-
<div style="text-align: center;">
|
|
1634
|
-
<a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
|
|
1635
|
-
</div>
|
|
1636
|
-
|
|
1637
|
-
<p style="${styles.paragraph}">
|
|
1638
|
-
Or copy and paste this link into your browser:
|
|
1639
|
-
</p>
|
|
1640
|
-
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
1641
|
-
${magicLinkUrl}
|
|
1642
|
-
</p>
|
|
1643
|
-
|
|
1644
|
-
<div style="${styles.warning}">
|
|
1645
|
-
⏰ This link will expire in 15 minutes for security reasons and can only be used once.
|
|
1646
|
-
</div>
|
|
1647
|
-
|
|
1648
|
-
<div style="${styles.footer}">
|
|
1649
|
-
<p style="margin: 0;">
|
|
1650
|
-
If you didn't request this sign-in link, you can safely ignore this email.
|
|
1651
|
-
No action is needed.
|
|
1652
|
-
</p>
|
|
1653
|
-
</div>
|
|
1654
|
-
</div>
|
|
1655
|
-
</div>
|
|
1656
|
-
</body>
|
|
1657
|
-
</html>
|
|
1658
|
-
`;
|
|
1659
|
-
const text = `
|
|
1660
|
-
Sign In to ${appName}
|
|
1661
|
-
|
|
1662
|
-
Hi ${greeting},
|
|
1663
|
-
|
|
1664
|
-
We received a request to sign in to your ${appName} account.
|
|
1665
|
-
|
|
1666
|
-
Click this link to log in:
|
|
1667
|
-
${magicLinkUrl}
|
|
1668
|
-
|
|
1669
|
-
This link will expire in 15 minutes for security reasons and can only be used once.
|
|
1670
|
-
|
|
1671
|
-
If you didn't request this sign-in link, you can safely ignore this email.
|
|
1672
|
-
No action is needed.
|
|
1673
|
-
`.trim();
|
|
1674
|
-
return {
|
|
1675
|
-
subject,
|
|
1676
|
-
html: body.toString().trim(),
|
|
1677
|
-
text
|
|
1678
|
-
};
|
|
1461
|
+
function refuse(c, error) {
|
|
1462
|
+
return errorHandler(error, c);
|
|
1679
1463
|
}
|
|
1680
|
-
//#endregion
|
|
1681
|
-
//#region src/auth/admin-user-ops.ts
|
|
1682
1464
|
/**
|
|
1683
|
-
*
|
|
1465
|
+
* Hono middleware that requires a valid JWT token via Authorization header.
|
|
1466
|
+
* Returns 401 if token is missing or invalid.
|
|
1684
1467
|
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1468
|
+
* **Security:** Tokens are only accepted via the `Authorization: Bearer`
|
|
1469
|
+
* header. Query-string tokens (`?token=`) are intentionally NOT accepted
|
|
1470
|
+
* here because URLs leak into access logs, proxies, Referer headers, and
|
|
1471
|
+
* browser history. Use {@link queryTokenAuth} on routes that legitimately
|
|
1472
|
+
* need query-string tokens (e.g. storage file serving for `<img src>`).
|
|
1473
|
+
*/
|
|
1474
|
+
var requireAuth = async (c, next) => {
|
|
1475
|
+
if (c.get("user")) return next();
|
|
1476
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
1477
|
+
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
1478
|
+
const payload = await verifyAccessToken(token);
|
|
1479
|
+
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
1480
|
+
c.set("user", payload);
|
|
1481
|
+
return next();
|
|
1482
|
+
};
|
|
1483
|
+
/**
|
|
1484
|
+
* Factory that creates a requireAuth middleware with optional service key support.
|
|
1687
1485
|
*
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1486
|
+
* When `serviceKey` is provided, the middleware will check if the Bearer token
|
|
1487
|
+
* matches the service key using constant-time comparison. If it matches, the
|
|
1488
|
+
* request is authenticated as a service user with admin privileges.
|
|
1489
|
+
*
|
|
1490
|
+
* This allows admin routes (which use standalone requireAuth + requireAdmin)
|
|
1491
|
+
* to be accessed via service keys for scripts and server-to-server calls.
|
|
1492
|
+
*/
|
|
1493
|
+
function createRequireAuth(options) {
|
|
1494
|
+
if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
|
|
1495
|
+
const key = options.serviceKey;
|
|
1496
|
+
const resolveRoles = options.resolveRoles;
|
|
1497
|
+
const revocationRepo = options.revocationRepo;
|
|
1498
|
+
return async (c, next) => {
|
|
1499
|
+
if (c.get("user")) return next();
|
|
1500
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
1501
|
+
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
1502
|
+
if (key !== void 0 && safeCompare(token, key)) {
|
|
1503
|
+
c.set("user", {
|
|
1504
|
+
uid: "service",
|
|
1505
|
+
roles: ["admin"]
|
|
1506
|
+
});
|
|
1507
|
+
return next();
|
|
1508
|
+
}
|
|
1509
|
+
const payload = await verifyAccessToken(token);
|
|
1510
|
+
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
1511
|
+
if (resolveRoles || revocationRepo) try {
|
|
1512
|
+
if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return refuse(c, ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED"));
|
|
1513
|
+
c.set("user", resolveRoles ? {
|
|
1514
|
+
...payload,
|
|
1515
|
+
roles: await resolveRoles(payload.uid)
|
|
1516
|
+
} : payload);
|
|
1517
|
+
return next();
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
|
|
1520
|
+
uid: payload.uid,
|
|
1521
|
+
error
|
|
1522
|
+
});
|
|
1523
|
+
return c.json({ error: {
|
|
1524
|
+
message: "Could not verify your permissions. Please try again.",
|
|
1525
|
+
code: "ROLE_LOOKUP_FAILED"
|
|
1526
|
+
} }, 503);
|
|
1527
|
+
}
|
|
1528
|
+
c.set("user", payload);
|
|
1529
|
+
return next();
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Middleware that requires the user to have an admin or schema-admin role.
|
|
1534
|
+
* Must be used AFTER requireAuth or on a route where user is guaranteed.
|
|
1692
1535
|
*/
|
|
1536
|
+
var requireAdmin = async (c, next) => {
|
|
1537
|
+
const user = c.get("user");
|
|
1538
|
+
if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
|
|
1539
|
+
if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
|
|
1540
|
+
return next();
|
|
1541
|
+
};
|
|
1693
1542
|
/**
|
|
1694
|
-
*
|
|
1543
|
+
* Middleware that optionally extracts user from JWT via Authorization header.
|
|
1544
|
+
* Does not return 401 if token is missing — allows anonymous access.
|
|
1695
1545
|
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1546
|
+
* Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
|
|
1547
|
+
* on routes that need them.
|
|
1698
1548
|
*/
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
const parts = [
|
|
1706
|
-
pick(upper),
|
|
1707
|
-
pick(lower),
|
|
1708
|
-
pick(digits)
|
|
1709
|
-
];
|
|
1710
|
-
for (let i = parts.length; i < 16; i++) parts.push(pick(all));
|
|
1711
|
-
for (let i = parts.length - 1; i > 0; i--) {
|
|
1712
|
-
const j = randomInt(i + 1);
|
|
1713
|
-
[parts[i], parts[j]] = [parts[j], parts[i]];
|
|
1549
|
+
var optionalAuth = async (c, next) => {
|
|
1550
|
+
if (c.get("user")) return next();
|
|
1551
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
1552
|
+
if (token !== void 0 && isJwtConfigured()) {
|
|
1553
|
+
const payload = await verifyAccessToken(token);
|
|
1554
|
+
if (payload) c.set("user", payload);
|
|
1714
1555
|
}
|
|
1715
|
-
return
|
|
1716
|
-
}
|
|
1556
|
+
return next();
|
|
1557
|
+
};
|
|
1717
1558
|
/**
|
|
1718
|
-
*
|
|
1559
|
+
* Extract user from token - for WebSocket authentication
|
|
1719
1560
|
*/
|
|
1720
|
-
function
|
|
1721
|
-
|
|
1561
|
+
async function extractUserFromToken(token) {
|
|
1562
|
+
if (!isJwtConfigured()) return null;
|
|
1563
|
+
return verifyAccessToken(token);
|
|
1722
1564
|
}
|
|
1723
1565
|
/**
|
|
1724
|
-
*
|
|
1566
|
+
* Create a configurable auth middleware that handles:
|
|
1567
|
+
* 1. Token extraction (via custom validator or JWT Bearer token)
|
|
1568
|
+
* 2. RLS-scoped DataDriver via withAuth()
|
|
1569
|
+
* 3. Enforcement (401 when requireAuth is true and no user)
|
|
1570
|
+
*
|
|
1571
|
+
* **Secure by default:** `requireAuth` defaults to `true`. Anonymous
|
|
1572
|
+
* access is only allowed when the developer explicitly opts out by
|
|
1573
|
+
* setting `requireAuth: false`, indicating that Postgres RLS policies
|
|
1574
|
+
* fully control access.
|
|
1575
|
+
*
|
|
1576
|
+
* **Fail-closed:** The raw unscoped driver is never placed in the
|
|
1577
|
+
* request context. Every code path either scopes via `withAuth()` or
|
|
1578
|
+
* rejects the request. This prevents silent RLS bypass.
|
|
1579
|
+
*
|
|
1580
|
+
* This is the single source of truth for HTTP auth in Rebase.
|
|
1581
|
+
* Use this instead of manually parsing tokens in route handlers.
|
|
1725
1582
|
*/
|
|
1726
|
-
function
|
|
1727
|
-
|
|
1583
|
+
function createAuthMiddleware(options) {
|
|
1584
|
+
const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
1585
|
+
return async (c, next) => {
|
|
1586
|
+
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
1587
|
+
if (validator) try {
|
|
1588
|
+
const authResult = await validator(c);
|
|
1589
|
+
if (authResult && typeof authResult === "object") {
|
|
1590
|
+
const id = "uid" in authResult ? authResult.uid : void 0;
|
|
1591
|
+
if (id) {
|
|
1592
|
+
const roles = authResult.roles || [];
|
|
1593
|
+
c.set("user", {
|
|
1594
|
+
uid: id,
|
|
1595
|
+
roles
|
|
1596
|
+
});
|
|
1597
|
+
const user = {
|
|
1598
|
+
uid: id,
|
|
1599
|
+
roles,
|
|
1600
|
+
...authResult
|
|
1601
|
+
};
|
|
1602
|
+
c.set("driver", await scopeDataDriver(driver, user));
|
|
1603
|
+
} else c.set("driver", await scopeDataDriver(driver, {
|
|
1604
|
+
uid: ANONYMOUS_USER_ID,
|
|
1605
|
+
roles: ["anon"]
|
|
1606
|
+
}));
|
|
1607
|
+
} else if (authResult === true) {
|
|
1608
|
+
c.set("user", {
|
|
1609
|
+
uid: "default",
|
|
1610
|
+
roles: []
|
|
1611
|
+
});
|
|
1612
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1613
|
+
uid: "default",
|
|
1614
|
+
roles: []
|
|
1615
|
+
}));
|
|
1616
|
+
} else c.set("driver", await scopeDataDriver(driver, {
|
|
1617
|
+
uid: ANONYMOUS_USER_ID,
|
|
1618
|
+
roles: ["anon"]
|
|
1619
|
+
}));
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
return refuse(c, ApiError.unauthenticated("Unauthorized"));
|
|
1622
|
+
}
|
|
1623
|
+
else {
|
|
1624
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
1625
|
+
if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
|
|
1626
|
+
c.set("user", {
|
|
1627
|
+
uid: "service",
|
|
1628
|
+
roles: ["admin"]
|
|
1629
|
+
});
|
|
1630
|
+
try {
|
|
1631
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1632
|
+
uid: "service",
|
|
1633
|
+
roles: ["admin"]
|
|
1634
|
+
}));
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
logger.error("[AUTH] RLS scoping failed for service key", { error });
|
|
1637
|
+
return c.json({ error: {
|
|
1638
|
+
message: "Internal authentication error",
|
|
1639
|
+
code: "INTERNAL_ERROR"
|
|
1640
|
+
} }, 500);
|
|
1641
|
+
}
|
|
1642
|
+
} else if (apiKeyStore && isApiKeyToken(token)) {
|
|
1643
|
+
const result = await validateApiKey(c, token, {
|
|
1644
|
+
store: apiKeyStore,
|
|
1645
|
+
driver
|
|
1646
|
+
});
|
|
1647
|
+
if (result !== true) return result;
|
|
1648
|
+
} else {
|
|
1649
|
+
const payload = await extractUserFromToken(token);
|
|
1650
|
+
if (payload) {
|
|
1651
|
+
c.set("user", payload);
|
|
1652
|
+
try {
|
|
1653
|
+
const user = {
|
|
1654
|
+
uid: payload.uid,
|
|
1655
|
+
roles: payload.roles,
|
|
1656
|
+
isAnonymous: payload.isAnonymous === true,
|
|
1657
|
+
claims: payload.claims
|
|
1658
|
+
};
|
|
1659
|
+
c.set("driver", await scopeDataDriver(driver, user));
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
|
|
1662
|
+
return c.json({ error: {
|
|
1663
|
+
message: "Internal authentication error",
|
|
1664
|
+
code: "INTERNAL_ERROR"
|
|
1665
|
+
} }, 500);
|
|
1666
|
+
}
|
|
1667
|
+
} else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
1668
|
+
}
|
|
1669
|
+
else try {
|
|
1670
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1671
|
+
uid: ANONYMOUS_USER_ID,
|
|
1672
|
+
roles: ["anon"]
|
|
1673
|
+
}));
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
logger.error("[AUTH] Failed to create anon-scoped driver", { error });
|
|
1676
|
+
return c.json({ error: {
|
|
1677
|
+
message: "Server configuration error",
|
|
1678
|
+
code: "INTERNAL_ERROR"
|
|
1679
|
+
} }, 500);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
1683
|
+
return next();
|
|
1684
|
+
};
|
|
1728
1685
|
}
|
|
1729
1686
|
/**
|
|
1730
|
-
*
|
|
1687
|
+
* Middleware that authenticates via a `?token=` query parameter.
|
|
1731
1688
|
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1689
|
+
* **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
|
|
1690
|
+
* Referer headers, and browser history. This middleware exists solely for
|
|
1691
|
+
* routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
|
|
1692
|
+
* `<a href>` for file downloads, or similar browser-native requests.
|
|
1693
|
+
*
|
|
1694
|
+
* Apply it **before** `requireAuth` or `optionalAuth` on the specific
|
|
1695
|
+
* route that needs it. Those middlewares will see the user context this
|
|
1696
|
+
* middleware sets and skip their own 401 check.
|
|
1697
|
+
*
|
|
1698
|
+
* @example
|
|
1699
|
+
* ```ts
|
|
1700
|
+
* router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
|
|
1701
|
+
* ```
|
|
1734
1702
|
*/
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
}
|
|
1703
|
+
var queryTokenAuth = async (c, next) => {
|
|
1704
|
+
if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
|
|
1705
|
+
const queryToken = c.req.query("token");
|
|
1706
|
+
if (queryToken && isJwtConfigured()) {
|
|
1707
|
+
const payload = await verifyAccessToken(queryToken);
|
|
1708
|
+
if (payload) c.set("user", payload);
|
|
1709
|
+
}
|
|
1710
|
+
return next();
|
|
1711
|
+
};
|
|
1745
1712
|
/**
|
|
1746
|
-
*
|
|
1713
|
+
* Authorizes anonymous access to **public** storage objects (those under the
|
|
1714
|
+
* public prefix), which are served token-less via stable, permanent URLs.
|
|
1747
1715
|
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1716
|
+
* Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
|
|
1717
|
+
* middleware and *before* the read-auth gate. If the request is already
|
|
1718
|
+
* authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
|
|
1719
|
+
* requested object path is public, it sets a minimal "public" principal so the
|
|
1720
|
+
* downstream `requireAuth` gate lets the read through. Private paths are left
|
|
1721
|
+
* untouched, so they still require a valid token.
|
|
1722
|
+
*/
|
|
1723
|
+
var publicObjectAuth = async (c, next) => {
|
|
1724
|
+
if (c.get("user")) return next();
|
|
1725
|
+
const prefix = c.req.routePath.replace("/*", "");
|
|
1726
|
+
const fullPath = c.req.path;
|
|
1727
|
+
const idx = fullPath.indexOf(prefix);
|
|
1728
|
+
const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
|
|
1729
|
+
if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
|
|
1730
|
+
uid: "public",
|
|
1731
|
+
roles: ["public"]
|
|
1732
|
+
});
|
|
1733
|
+
return next();
|
|
1734
|
+
};
|
|
1735
|
+
/**
|
|
1736
|
+
* Helper to match paths for scoped file tokens.
|
|
1737
|
+
* Matches exact paths or prefixes (exact folder prefixes or ending with /).
|
|
1752
1738
|
*
|
|
1753
|
-
* The
|
|
1739
|
+
* The traversal check is defence in depth, not the load-bearing defence.
|
|
1740
|
+
*
|
|
1741
|
+
* A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
|
|
1742
|
+
* starts with `user1/`, so a token scoped to `user1/` would authorize reading
|
|
1743
|
+
* user2's file — and the storage controller would not object, because its own
|
|
1744
|
+
* guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
|
|
1745
|
+
* comfortably inside it. The grant is what would be escaped, and this is the
|
|
1746
|
+
* only layer that knows what was granted.
|
|
1747
|
+
*
|
|
1748
|
+
* Nothing arrives here in that shape today: the URL parser resolves `..` (and
|
|
1749
|
+
* `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
|
|
1750
|
+
* Hono routes the request, so this comparison already runs on a resolved path.
|
|
1751
|
+
* But that is a guarantee of the runtime rather than of this code, and it is
|
|
1752
|
+
* one line to not depend on it. The same rule already guards the public-object
|
|
1753
|
+
* path — see `isPublicStoragePath`.
|
|
1754
|
+
*/
|
|
1755
|
+
function isPathMatch(requested, allowed) {
|
|
1756
|
+
if (requested.split("/").some((seg) => seg === "..")) return false;
|
|
1757
|
+
if (requested === allowed) return true;
|
|
1758
|
+
if (allowed.endsWith("/")) return requested.startsWith(allowed);
|
|
1759
|
+
return requested.startsWith(allowed + "/");
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Middleware that authenticates file-serving routes using scoped download tokens.
|
|
1763
|
+
* It enforces that only scoped "file-read" tokens can access "/file/*" and "?token=" query params.
|
|
1764
|
+
* Full access JWTs are explicitly rejected on "/file/*" routes, and in the "?token=" query param.
|
|
1754
1765
|
*/
|
|
1755
|
-
async
|
|
1756
|
-
const
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1766
|
+
var fileTokenAuth = async (c, next) => {
|
|
1767
|
+
const isFileRoute = c.req.path.includes("/storage/file/");
|
|
1768
|
+
const authHeader = c.req.header("authorization");
|
|
1769
|
+
const queryToken = c.req.query("token");
|
|
1770
|
+
const extractWildcard = (ctx) => {
|
|
1771
|
+
const prefix = ctx.req.routePath.replace("/*", "");
|
|
1772
|
+
const fullPath = ctx.req.path;
|
|
1773
|
+
const idx = fullPath.indexOf(prefix);
|
|
1774
|
+
if (idx < 0) return "";
|
|
1775
|
+
return fullPath.substring(idx + prefix.length + 1);
|
|
1776
|
+
};
|
|
1777
|
+
/**
|
|
1778
|
+
* Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
|
|
1779
|
+
* path a token GRANTS is minted there and the path a request ASKS FOR is
|
|
1780
|
+
* derived here. `/metadata` canonicalizes before signing, so a raw
|
|
1781
|
+
* comparison here would 403 an otherwise-valid request for any key whose
|
|
1782
|
+
* URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
|
|
1783
|
+
*
|
|
1784
|
+
* A key that cannot be canonicalized yields null, which matches no grant —
|
|
1785
|
+
* the same fail-closed answer `isPathMatch` gives a `..` segment.
|
|
1786
|
+
*/
|
|
1787
|
+
const parseBucketPath = (filePath) => {
|
|
1788
|
+
const parts = filePath.split("/");
|
|
1789
|
+
if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
|
|
1790
|
+
bucket: "default",
|
|
1791
|
+
resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
|
|
1765
1792
|
};
|
|
1766
|
-
}
|
|
1767
|
-
if (resolvedHooks.onAdminCreateUser) {
|
|
1768
|
-
const hookResult = await resolvedHooks.onAdminCreateUser(body, {
|
|
1769
|
-
authRepo: ctx.authRepo,
|
|
1770
|
-
emailService: ctx.emailService,
|
|
1771
|
-
emailConfig: ctx.emailConfig,
|
|
1772
|
-
hashPassword: (password) => resolvedHooks.hashPassword(password)
|
|
1773
|
-
});
|
|
1774
1793
|
return {
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
hookHandledEmail: true,
|
|
1778
|
-
invitationSent: hookResult.invitationSent ?? false
|
|
1794
|
+
bucket: "default",
|
|
1795
|
+
resolvedPath: tryCanonicalStorageKey(filePath)
|
|
1779
1796
|
};
|
|
1780
|
-
}
|
|
1781
|
-
const password = body.password;
|
|
1782
|
-
const clearPassword = password || generateSecurePassword();
|
|
1783
|
-
const passwordHash = await resolvedHooks.hashPassword(clearPassword);
|
|
1784
|
-
const values = { ...body };
|
|
1785
|
-
values.passwordHash = passwordHash;
|
|
1786
|
-
if (values.email) values.email = normalizeEmail(values.email);
|
|
1787
|
-
values.emailVerified = true;
|
|
1788
|
-
delete values.password;
|
|
1789
|
-
return {
|
|
1790
|
-
values,
|
|
1791
|
-
clearPassword: password ? void 0 : clearPassword,
|
|
1792
|
-
hookHandledEmail: false,
|
|
1793
|
-
invitationSent: false
|
|
1794
1797
|
};
|
|
1795
|
-
|
|
1798
|
+
/**
|
|
1799
|
+
* Decide what a valid download token entitles this request to.
|
|
1800
|
+
*
|
|
1801
|
+
* One function rather than a copy per token location: a Bearer token and a
|
|
1802
|
+
* `?token=` are the same grant presented two ways, and the whole failure
|
|
1803
|
+
* this guards against is two places disagreeing about what was granted.
|
|
1804
|
+
*
|
|
1805
|
+
* `"no-path"` is distinct from the two denials because the caller treats it
|
|
1806
|
+
* differently — a request with no object path at all is not a denied read,
|
|
1807
|
+
* it is a request this middleware has nothing to say about, and on
|
|
1808
|
+
* `/metadata/*` it must still reach the downstream auth gate.
|
|
1809
|
+
*/
|
|
1810
|
+
const evaluateGrant = (payload) => {
|
|
1811
|
+
if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
|
|
1812
|
+
const rawPath = extractWildcard(c);
|
|
1813
|
+
if (!rawPath) return "no-path";
|
|
1814
|
+
const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
|
|
1815
|
+
if (resolvedPath === null) return "deny-path";
|
|
1816
|
+
return isPathMatch(`${bucket}/${resolvedPath}`, payload.path) ? "grant" : "deny-path";
|
|
1817
|
+
};
|
|
1818
|
+
/**
|
|
1819
|
+
* Which half of the scope failed is named in the message. The holder of the
|
|
1820
|
+
* token already knows both values, so this reveals nothing to an attacker —
|
|
1821
|
+
* and without it, a client that forgets to forward `?storageId=` on the file
|
|
1822
|
+
* URL reports a "path mismatch" for a path that matches perfectly.
|
|
1823
|
+
*/
|
|
1824
|
+
const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
|
|
1825
|
+
const bearerToken = extractBearerToken(authHeader);
|
|
1826
|
+
if (bearerToken !== void 0) {
|
|
1827
|
+
const payload = await verifyDownloadToken(bearerToken);
|
|
1828
|
+
if (payload) {
|
|
1829
|
+
const outcome = evaluateGrant(payload);
|
|
1830
|
+
if (outcome === "grant") {
|
|
1831
|
+
c.set("user", {
|
|
1832
|
+
uid: "download-token",
|
|
1833
|
+
roles: ["reader"]
|
|
1834
|
+
});
|
|
1835
|
+
return next();
|
|
1836
|
+
}
|
|
1837
|
+
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
1838
|
+
}
|
|
1839
|
+
if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
|
|
1840
|
+
return next();
|
|
1841
|
+
}
|
|
1842
|
+
if (queryToken) {
|
|
1843
|
+
const payload = await verifyDownloadToken(queryToken);
|
|
1844
|
+
if (payload) {
|
|
1845
|
+
const outcome = evaluateGrant(payload);
|
|
1846
|
+
if (outcome === "grant") {
|
|
1847
|
+
c.set("user", {
|
|
1848
|
+
uid: "download-token",
|
|
1849
|
+
roles: ["reader"]
|
|
1850
|
+
});
|
|
1851
|
+
return next();
|
|
1852
|
+
}
|
|
1853
|
+
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
1854
|
+
}
|
|
1855
|
+
return refuse(c, ApiError.unauthenticated("Unauthorized: Invalid or unauthorized token"));
|
|
1856
|
+
}
|
|
1857
|
+
return next();
|
|
1858
|
+
};
|
|
1859
|
+
//#endregion
|
|
1860
|
+
//#region src/auth/adapter-middleware.ts
|
|
1796
1861
|
/**
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
* Sends an invitation email (password-reset link) if email is configured
|
|
1800
|
-
* and no explicit password was provided. Falls back to returning the
|
|
1801
|
-
* temporary password if email fails or is not configured.
|
|
1862
|
+
* Create a Hono middleware that uses an `AuthAdapter` for request verification.
|
|
1802
1863
|
*/
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
const
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1864
|
+
function createAdapterAuthMiddleware(options) {
|
|
1865
|
+
const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
|
|
1866
|
+
return async (c, next) => {
|
|
1867
|
+
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
1868
|
+
const presentedToken = extractBearerToken(c.req.header("authorization"));
|
|
1869
|
+
if (apiKeyStore) {
|
|
1870
|
+
const token = presentedToken ?? "";
|
|
1871
|
+
if (token.startsWith("rk_")) {
|
|
1872
|
+
const result = await validateApiKey(c, token, {
|
|
1873
|
+
store: apiKeyStore,
|
|
1874
|
+
driver
|
|
1875
|
+
});
|
|
1876
|
+
if (result === true) return next();
|
|
1877
|
+
return result;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
let authenticatedUser = null;
|
|
1881
|
+
try {
|
|
1882
|
+
authenticatedUser = await adapter.verifyRequest(c.req.raw);
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
return refuse(c, ApiError.unauthenticated("Unauthorized"));
|
|
1885
|
+
}
|
|
1886
|
+
if (authenticatedUser) {
|
|
1887
|
+
c.set("user", {
|
|
1888
|
+
uid: authenticatedUser.uid,
|
|
1889
|
+
email: authenticatedUser.email,
|
|
1890
|
+
roles: authenticatedUser.roles,
|
|
1891
|
+
isAnonymous: authenticatedUser.isAnonymous === true
|
|
1892
|
+
});
|
|
1893
|
+
try {
|
|
1894
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1895
|
+
uid: authenticatedUser.uid,
|
|
1896
|
+
roles: authenticatedUser.roles,
|
|
1897
|
+
isAnonymous: authenticatedUser.isAnonymous === true
|
|
1898
|
+
}));
|
|
1899
|
+
} catch (error) {
|
|
1900
|
+
logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
|
|
1901
|
+
return refuse(c, ApiError.internal("Internal authentication error"));
|
|
1902
|
+
}
|
|
1903
|
+
} else {
|
|
1904
|
+
if (presentedToken !== void 0) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
1905
|
+
try {
|
|
1906
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1907
|
+
uid: ANONYMOUS_USER_ID,
|
|
1908
|
+
roles: ["anon"]
|
|
1909
|
+
}));
|
|
1910
|
+
} catch (error) {
|
|
1911
|
+
logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
|
|
1912
|
+
return refuse(c, ApiError.internal("Server configuration error"));
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
1916
|
+
return next();
|
|
1838
1917
|
};
|
|
1839
1918
|
}
|
|
1840
1919
|
//#endregion
|
|
@@ -3601,8 +3680,7 @@ function mountSessionRoutes(opts) {
|
|
|
3601
3680
|
router.delete("/sessions", requireLiveSession, async (c) => {
|
|
3602
3681
|
const userCtx = c.get("user");
|
|
3603
3682
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
3604
|
-
await authRepo
|
|
3605
|
-
await authRepo.setTokensValidAfter?.(userCtx.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
3683
|
+
await revokeAllSessions(authRepo, userCtx.uid);
|
|
3606
3684
|
return c.json({
|
|
3607
3685
|
success: true,
|
|
3608
3686
|
message: "All sessions revoked successfully"
|
|
@@ -3774,7 +3852,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3774
3852
|
if (user) {
|
|
3775
3853
|
if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
|
|
3776
3854
|
const token = generateSecureToken();
|
|
3777
|
-
const tokenHash = hashToken(token);
|
|
3855
|
+
const tokenHash = hashToken$1(token);
|
|
3778
3856
|
const expiresAt = getMagicLinkExpiry();
|
|
3779
3857
|
await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
|
|
3780
3858
|
const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
|
|
@@ -3809,7 +3887,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3809
3887
|
*/
|
|
3810
3888
|
router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
|
|
3811
3889
|
const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
|
|
3812
|
-
const tokenHash = hashToken(token);
|
|
3890
|
+
const tokenHash = hashToken$1(token);
|
|
3813
3891
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
3814
3892
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
3815
3893
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
@@ -3964,7 +4042,7 @@ function mountOtpRoutes(deps) {
|
|
|
3964
4042
|
if (ops.beforeLogin) await ops.beforeLogin(email, "otp");
|
|
3965
4043
|
if (user) {
|
|
3966
4044
|
const code = generateOtpCode();
|
|
3967
|
-
await authRepo.createMagicLinkToken(user.id, hashToken(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
|
|
4045
|
+
await authRepo.createMagicLinkToken(user.id, hashToken$1(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
|
|
3968
4046
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
3969
4047
|
const templateFn = emailConfig?.templates?.emailOtp;
|
|
3970
4048
|
const content = templateFn ? templateFn(code, {
|
|
@@ -3994,7 +4072,7 @@ function mountOtpRoutes(deps) {
|
|
|
3994
4072
|
*/
|
|
3995
4073
|
router.post("/otp/verify", strictAuthLimiter, captureOtpEmail, otpVerificationLimiter, async (c) => {
|
|
3996
4074
|
const { email, code } = parseBody(verifySchema, await c.req.json());
|
|
3997
|
-
const tokenHash = hashToken(otpTokenMaterial(email, code));
|
|
4075
|
+
const tokenHash = hashToken$1(otpTokenMaterial(email, code));
|
|
3998
4076
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
3999
4077
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
|
|
4000
4078
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
@@ -4477,7 +4555,7 @@ function createAuthRoutes(config) {
|
|
|
4477
4555
|
const user = await authRepo.getUserByEmail(email);
|
|
4478
4556
|
if (user) {
|
|
4479
4557
|
const token = generateSecureToken();
|
|
4480
|
-
const tokenHash = hashToken(token);
|
|
4558
|
+
const tokenHash = hashToken$1(token);
|
|
4481
4559
|
const expiresAt = getPasswordResetExpiry();
|
|
4482
4560
|
await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
|
4483
4561
|
const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
@@ -4512,14 +4590,12 @@ function createAuthRoutes(config) {
|
|
|
4512
4590
|
const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
|
|
4513
4591
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
4514
4592
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4515
|
-
const tokenHash = hashToken(token);
|
|
4593
|
+
const tokenHash = hashToken$1(token);
|
|
4516
4594
|
const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
|
|
4517
4595
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
|
|
4518
4596
|
const passwordHash = await ops.hashPassword(password);
|
|
4519
|
-
await authRepo
|
|
4597
|
+
await replaceUserPassword(authRepo, storedToken.uid, passwordHash);
|
|
4520
4598
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
4521
|
-
await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
|
|
4522
|
-
await authRepo.setTokensValidAfter?.(storedToken.uid, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
4523
4599
|
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
4524
4600
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
4525
4601
|
});
|
|
@@ -4542,9 +4618,7 @@ function createAuthRoutes(config) {
|
|
|
4542
4618
|
const passwordValidation = ops.validatePasswordStrength(newPassword);
|
|
4543
4619
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4544
4620
|
const passwordHash = await ops.hashPassword(newPassword);
|
|
4545
|
-
await authRepo
|
|
4546
|
-
await authRepo.deleteAllRefreshTokensForUser(user.id);
|
|
4547
|
-
await authRepo.setTokensValidAfter?.(user.id, /* @__PURE__ */ new Date()).catch(() => void 0);
|
|
4621
|
+
await replaceUserPassword(authRepo, user.id, passwordHash);
|
|
4548
4622
|
return c.json({
|
|
4549
4623
|
success: true,
|
|
4550
4624
|
message: "Password has been changed successfully"
|
|
@@ -4569,7 +4643,7 @@ function createAuthRoutes(config) {
|
|
|
4569
4643
|
if (!user) throw ApiError.notFound("User not found");
|
|
4570
4644
|
if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
4571
4645
|
const token = generateSecureToken();
|
|
4572
|
-
await authRepo.setVerificationToken(user.id, hashToken(token));
|
|
4646
|
+
await authRepo.setVerificationToken(user.id, hashToken$1(token));
|
|
4573
4647
|
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
4574
4648
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4575
4649
|
const templateFn = emailConfig?.templates?.emailVerification;
|
|
@@ -4598,7 +4672,7 @@ function createAuthRoutes(config) {
|
|
|
4598
4672
|
router.get("/verify-email", async (c) => {
|
|
4599
4673
|
const token = c.req.query("token");
|
|
4600
4674
|
if (!token) throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
|
|
4601
|
-
const user = await authRepo.getUserByVerificationToken(hashToken(token));
|
|
4675
|
+
const user = await authRepo.getUserByVerificationToken(hashToken$1(token));
|
|
4602
4676
|
if (!user) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
|
|
4603
4677
|
await authRepo.setEmailVerified(user.id, true);
|
|
4604
4678
|
return c.json({
|
|
@@ -4777,6 +4851,10 @@ function buildBuiltinAuthCapabilities(inputs) {
|
|
|
4777
4851
|
* 1. Collection-level hook (`auth.onResetPassword` on the collection)
|
|
4778
4852
|
* 2. Backend-level hook (`AuthHooks.onAdminResetPassword`)
|
|
4779
4853
|
* 3. Built-in default (send reset email, or generate temp password)
|
|
4854
|
+
*
|
|
4855
|
+
* Whichever of them runs, the account's existing sessions end, and a
|
|
4856
|
+
* `temporaryPassword` in its result becomes the account's password before the
|
|
4857
|
+
* response shows it to the admin.
|
|
4780
4858
|
*/
|
|
4781
4859
|
/**
|
|
4782
4860
|
* Create a standalone admin route for resetting user passwords.
|
|
@@ -4802,14 +4880,10 @@ function createResetPasswordRoute(config) {
|
|
|
4802
4880
|
let temporaryPassword;
|
|
4803
4881
|
let emailDeliveryFailed = false;
|
|
4804
4882
|
const body = await c.req.json().catch(() => ({}));
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
const validation = ops.validatePasswordStrength(
|
|
4883
|
+
const chosenPassword = body.password ? body.password : void 0;
|
|
4884
|
+
if (chosenPassword) {
|
|
4885
|
+
const validation = ops.validatePasswordStrength(chosenPassword);
|
|
4808
4886
|
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(", ")}`);
|
|
4809
|
-
const passwordHash = await ops.hashPassword(password);
|
|
4810
|
-
await authRepo.updatePassword(existing.id, passwordHash);
|
|
4811
|
-
temporaryPassword = void 0;
|
|
4812
|
-
invitationSent = false;
|
|
4813
4887
|
} else if (collectionAuthConfig?.onResetPassword) {
|
|
4814
4888
|
const isEmailConfigured = !!(emailService && emailService.isConfigured());
|
|
4815
4889
|
const hookResult = await collectionAuthConfig.onResetPassword(existing.id, {
|
|
@@ -4829,42 +4903,38 @@ function createResetPasswordRoute(config) {
|
|
|
4829
4903
|
});
|
|
4830
4904
|
temporaryPassword = hookResult.temporaryPassword;
|
|
4831
4905
|
invitationSent = hookResult.invitationSent ?? false;
|
|
4832
|
-
} else if (!!(emailService && emailService.isConfigured()))
|
|
4906
|
+
} else if (!!(emailService && emailService.isConfigured())) {
|
|
4833
4907
|
const token = generateSecureToken();
|
|
4834
|
-
const tokenHash = hashToken(token);
|
|
4908
|
+
const tokenHash = hashToken$1(token);
|
|
4835
4909
|
const expiresAt = new Date(Date.now() + 3600 * 1e3);
|
|
4836
4910
|
await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
const passwordHash = await ops.hashPassword(clearPassword);
|
|
4865
|
-
await authRepo.updatePassword(existing.id, passwordHash);
|
|
4866
|
-
temporaryPassword = clearPassword;
|
|
4867
|
-
}
|
|
4911
|
+
try {
|
|
4912
|
+
const setPasswordUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
|
|
4913
|
+
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4914
|
+
const templateFn = emailConfig?.templates?.passwordReset;
|
|
4915
|
+
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
4916
|
+
email: existing.email,
|
|
4917
|
+
displayName: existing.displayName
|
|
4918
|
+
}) : getPasswordResetTemplate(setPasswordUrl, {
|
|
4919
|
+
email: existing.email,
|
|
4920
|
+
displayName: existing.displayName
|
|
4921
|
+
}, appName, logoUrl);
|
|
4922
|
+
await emailService.send({
|
|
4923
|
+
to: existing.email,
|
|
4924
|
+
subject: emailContent.subject,
|
|
4925
|
+
html: emailContent.html,
|
|
4926
|
+
text: emailContent.text
|
|
4927
|
+
});
|
|
4928
|
+
invitationSent = true;
|
|
4929
|
+
} catch (emailError) {
|
|
4930
|
+
logger.error("Failed to send reset email", { error: emailError instanceof Error ? emailError.message : emailError });
|
|
4931
|
+
temporaryPassword = generateSecurePassword();
|
|
4932
|
+
emailDeliveryFailed = true;
|
|
4933
|
+
}
|
|
4934
|
+
} else temporaryPassword = generateSecurePassword();
|
|
4935
|
+
const newPassword = chosenPassword ?? temporaryPassword;
|
|
4936
|
+
if (newPassword) await replaceUserPassword(authRepo, existing.id, await ops.hashPassword(newPassword));
|
|
4937
|
+
else await revokeAllSessions(authRepo, existing.id);
|
|
4868
4938
|
const userRoles = await authRepo.getUserRoleIds(existing.id);
|
|
4869
4939
|
return c.json({
|
|
4870
4940
|
user: {
|
|
@@ -5069,22 +5139,20 @@ function createAdminUsersRoute(config) {
|
|
|
5069
5139
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5070
5140
|
const user = await authRepo.createUser(createData);
|
|
5071
5141
|
if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
|
|
5072
|
-
const
|
|
5142
|
+
const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
|
|
5073
5143
|
id: user.id,
|
|
5074
5144
|
values: prepResult.values
|
|
5075
|
-
},
|
|
5145
|
+
}, clearPassword, {
|
|
5076
5146
|
authRepo,
|
|
5077
5147
|
emailService,
|
|
5078
5148
|
emailConfig,
|
|
5079
5149
|
resolvedHooks: ops,
|
|
5080
5150
|
collectionAuthConfig
|
|
5081
|
-
});
|
|
5151
|
+
}));
|
|
5082
5152
|
const adminUser = toAdminUser(user, await authRepo.getUserRoleIds(user.id));
|
|
5083
5153
|
return c.json({
|
|
5084
5154
|
user: adminUser,
|
|
5085
|
-
|
|
5086
|
-
...finalizeResult.temporaryPassword ? { temporaryPassword: finalizeResult.temporaryPassword } : {},
|
|
5087
|
-
...finalizeResult.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
5155
|
+
...delivery
|
|
5088
5156
|
}, 201);
|
|
5089
5157
|
});
|
|
5090
5158
|
router.put("/users/:uid", requireAdmin, async (c) => {
|
|
@@ -5094,12 +5162,14 @@ function createAdminUsersRoute(config) {
|
|
|
5094
5162
|
const updates = {};
|
|
5095
5163
|
if (email !== void 0) updates.email = normalizeEmail(email);
|
|
5096
5164
|
if (displayName !== void 0) updates.displayName = displayName;
|
|
5165
|
+
let passwordHash;
|
|
5097
5166
|
if (password) {
|
|
5098
5167
|
const validation = ops.validatePasswordStrength(password);
|
|
5099
5168
|
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
|
|
5100
|
-
|
|
5169
|
+
passwordHash = await ops.hashPassword(password);
|
|
5101
5170
|
}
|
|
5102
5171
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
5172
|
+
if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
|
|
5103
5173
|
if (roles !== void 0 && Array.isArray(roles)) {
|
|
5104
5174
|
const wasAdmin = (await authRepo.getUserRoleIds(uid)).includes("admin");
|
|
5105
5175
|
const willBeAdmin = roles.includes("admin");
|
|
@@ -5366,8 +5436,9 @@ function createUserManagementFromRepo(repo, resolvedOps) {
|
|
|
5366
5436
|
if (data.displayName !== void 0) updateData.displayName = data.displayName;
|
|
5367
5437
|
if (data.photoUrl !== void 0) updateData.photoUrl = data.photoUrl;
|
|
5368
5438
|
if (data.metadata !== void 0) updateData.metadata = data.metadata;
|
|
5369
|
-
|
|
5439
|
+
const passwordHash = data.password ? await resolvedOps.hashPassword(data.password) : void 0;
|
|
5370
5440
|
const user = await repo.updateUser(id, updateData);
|
|
5441
|
+
if (user && passwordHash) await replaceUserPassword(repo, id, passwordHash);
|
|
5371
5442
|
return user ? toAuthUserData(user) : null;
|
|
5372
5443
|
},
|
|
5373
5444
|
async deleteUser(id) {
|
|
@@ -7076,7 +7147,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7076
7147
|
hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
|
|
7077
7148
|
hashPassword: () => hashPassword,
|
|
7078
7149
|
hashRefreshToken: () => hashRefreshToken,
|
|
7079
|
-
hashToken: () => hashToken,
|
|
7150
|
+
hashToken: () => hashToken$1,
|
|
7080
7151
|
httpMethodToOperation: () => httpMethodToOperation,
|
|
7081
7152
|
isApiKeyToken: () => isApiKeyToken,
|
|
7082
7153
|
isJwtConfigured: () => isJwtConfigured,
|
|
@@ -7105,6 +7176,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7105
7176
|
verifyPassword: () => verifyPassword
|
|
7106
7177
|
});
|
|
7107
7178
|
//#endregion
|
|
7108
|
-
export {
|
|
7179
|
+
export { requireAdmin as $, defaultAuthLimiter as A, resolveEmailLinkBase as B, providerVerifiedEmail as C, raw as Ct, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, isPublicStoragePath as Dt, isBootstrapWindowOpen as E, PUBLIC_STORAGE_PREFIX as Et, extractLinks as F, createAdapterAuthMiddleware as G, hashPassword as H, registerDevEmailSink as I, extractUserFromToken as J, createAuthMiddleware as K, SMTPEmailService as L, activeDevEmailSink as M, clearActiveDevEmailSink as N, createDataRateLimiter as O, createDevEmailSink as P, queryTokenAuth as Q, createEmailService as R, pkceTokenParams as S, html as St, createBuiltinAuthAdapter as T, isOperationAllowed as Tt, validatePasswordStrength as U, resolveAuthHooks as V, verifyPassword as W, optionalAuth as X, fileTokenAuth as Y, publicObjectAuth as Z, verifyOidcIdToken as _, getUserInvitationTemplate as _t, resolveRateLimitStoreKind as a, validateApiKey as at, createGoogleProvider as b, RawHtml as bt, createSlackProvider as c, SERVICE_IDENTITY as ct, createDiscordProvider as d, completeUserCreation as dt, requireAuth as et, createTwitterProvider as f, generateSecurePassword as ft, tryVerifyOidcIdToken as g, getPasswordResetTemplate as gt, createMicrosoftProvider as h, getMagicLinkTemplate as ht, createApiKeyStore as i, isApiKeyToken as it, MemoryRateLimitStore as j, createRateLimiter as k, createBitbucketProvider as l, scopeDataDriver as lt, createAppleProvider as m, getEmailVerificationTemplate as mt, createCustomAuthAdapter as n, createFunctionApiKeyGuard as nt, createSqlRateLimitStore as o, extractBearerToken as ot, createFacebookProvider as p, getEmailOtpTemplate as pt, createRequireAuth as q, createApiKeyRoutes as r, createStorageApiKeyGuard as rt, createSpotifyProvider as s, safeCompare as st, auth_exports as t, createApiKeyPreAuth as tt, createGitLabProvider as u, supportsRlsScoping as ut, createGitHubProvider as v, getWelcomeEmailTemplate as vt, createJwksRoutes as w, httpMethodToOperation as wt, oauthCodeFlowSchema as x, escapeHtml as xt, createLinkedinProvider as y, resolveEmailBranding as yt, assertEmailLinkBases as z };
|
|
7109
7180
|
|
|
7110
|
-
//# sourceMappingURL=auth-
|
|
7181
|
+
//# sourceMappingURL=auth-BS4WBA10.js.map
|