@rebasepro/server 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/rebase-server.js +4 -2
- package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-BSiP1c-f.js} +57 -29
- package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
- package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-CAwFRgjV.js} +19 -7
- package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +127 -1
- package/dist/api/errors.d.ts +9 -0
- package/dist/api/live-schema-routes.d.ts +52 -8
- package/dist/api/logs-routes.d.ts +39 -1
- package/dist/api/openapi-generator.d.ts +17 -0
- package/dist/api/rest/api-generator.d.ts +158 -42
- package/dist/api/rest/auth-collection-writes.d.ts +85 -0
- package/dist/api/rest/field-access-query.d.ts +6 -2
- package/dist/api/rest/idempotency.d.ts +7 -1
- package/dist/api/rest/nested-write-access.d.ts +46 -0
- package/dist/api/rest/write-validation.d.ts +34 -2
- package/dist/api/types.d.ts +17 -1
- package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-CWqS_sLJ.js} +411 -13
- package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
- package/dist/auth/access.d.ts +105 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/address-ownership.d.ts +68 -0
- package/dist/auth/admin-roles-route.d.ts +4 -2
- package/dist/auth/admin-roles.d.ts +17 -20
- package/dist/auth/admin-user-ops.d.ts +35 -2
- package/dist/auth/admin-users-route.d.ts +1 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
- package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
- package/dist/auth/api-keys/api-key-store.d.ts +31 -8
- package/dist/auth/api-keys/api-key-types.d.ts +14 -16
- package/dist/auth/api-keys/http-operation.d.ts +19 -0
- package/dist/auth/api-keys/index.d.ts +11 -11
- package/dist/auth/api-keys/key-grant.d.ts +41 -0
- package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
- package/dist/auth/auth-hooks.d.ts +50 -7
- package/dist/auth/builtin-auth-adapter.d.ts +8 -0
- package/dist/auth/captcha.d.ts +5 -0
- package/dist/auth/cookie-utils.d.ts +7 -0
- package/dist/auth/deliverable-address.d.ts +6 -0
- package/dist/auth/email-change-routes.d.ts +41 -0
- package/dist/auth/expired-token-sweep.d.ts +67 -0
- package/dist/auth/impersonation.d.ts +110 -0
- package/dist/auth/index.d.ts +4 -2
- package/dist/auth/interfaces.d.ts +146 -65
- package/dist/auth/jwt.d.ts +66 -3
- package/dist/auth/magic-link-routes.d.ts +2 -6
- package/dist/auth/mfa-routes.d.ts +2 -9
- package/dist/auth/middleware.d.ts +17 -5
- package/dist/auth/oauth-signin-policy.d.ts +25 -8
- package/dist/auth/otp-routes.d.ts +2 -6
- package/dist/auth/passwordless-signup.d.ts +27 -0
- package/dist/auth/platform-token.d.ts +122 -0
- package/dist/auth/rate-limiter.d.ts +72 -1
- package/dist/auth/routes.d.ts +45 -0
- package/dist/auth/scope-routes.d.ts +22 -0
- package/dist/auth/session-routes.d.ts +18 -6
- package/dist/auth/token-revocation.d.ts +53 -1
- package/dist/auth/verify-credential.d.ts +28 -0
- package/dist/{auth-CCDpk2rn.js → auth-DMLngxn_.js} +2712 -711
- package/dist/auth-DMLngxn_.js.map +1 -0
- package/dist/backend-DTAOsLQc.js +30 -0
- package/dist/backend-DTAOsLQc.js.map +1 -0
- package/dist/backup/backup-common.d.ts +29 -0
- package/dist/backup/backup-routes.d.ts +24 -4
- package/dist/backup/backup-schedule.d.ts +33 -0
- package/dist/backup/backup-storage.d.ts +14 -0
- package/dist/backup/index.d.ts +2 -0
- package/dist/backup-CN0s50D2.js +444 -0
- package/dist/backup-CN0s50D2.js.map +1 -0
- package/dist/boot/bundle.d.ts +19 -0
- package/dist/boot/driver.d.ts +10 -0
- package/dist/boot/env.d.ts +51 -6
- package/dist/boot/fetch-bundle.d.ts +18 -1
- package/dist/boot/rls-audit-option.d.ts +26 -0
- package/dist/boot/security-headers.d.ts +26 -0
- package/dist/boot/sources.d.ts +1 -0
- package/dist/boot/static-routing.d.ts +56 -0
- package/dist/collection_patch-BRu-BvDv.js +472 -0
- package/dist/collection_patch-BRu-BvDv.js.map +1 -0
- package/dist/{contract-routes-eLxV0le1.js → contract-routes-fz8i4pxs.js} +17 -4
- package/dist/contract-routes-fz8i4pxs.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +7 -2
- package/dist/cron/cron-scheduler.d.ts +146 -21
- package/dist/cron/cron-store.d.ts +76 -8
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-CQjvjpEw.js → cron-loader-CwaANlOG.js} +4 -4
- package/dist/cron-loader-CwaANlOG.js.map +1 -0
- package/dist/cron-routes-Bc-SB0Se.js +96 -0
- package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
- package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-CYQgco86.js} +427 -83
- package/dist/cron-scheduler-CYQgco86.js.map +1 -0
- package/dist/{cron-store-BYGZFNWk.js → cron-store-D2Q9-Aco.js} +139 -23
- package/dist/cron-store-D2Q9-Aco.js.map +1 -0
- package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-BaqMSa4Y.js} +3 -26
- package/dist/ddl-bootstrap-BaqMSa4Y.js.map +1 -0
- package/dist/email/index.d.ts +2 -2
- package/dist/email/link-base.d.ts +5 -4
- package/dist/email/smtp-email-service.d.ts +13 -1
- package/dist/email/templates.d.ts +31 -0
- package/dist/email/types.d.ts +29 -2
- package/dist/env.d.ts +25 -7
- package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
- package/dist/errors-D6_y86c5.js.map +1 -0
- package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
- package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
- package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
- package/dist/function-routes-CaNG4waN.js.map +1 -0
- package/dist/functions/context.d.ts +17 -6
- package/dist/functions/guards.d.ts +22 -5
- package/dist/functions/index.d.ts +2 -2
- package/dist/functions/index.js +90 -36
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-BQmB0P_j.js → history-recorder-Nr8zLvoU.js} +9 -7
- package/dist/history-recorder-Nr8zLvoU.js.map +1 -0
- package/dist/{history-store-CetkrBBD.js → history-store-rcAm_xFR.js} +2 -2
- package/dist/{history-store-CetkrBBD.js.map → history-store-rcAm_xFR.js.map} +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.es.js +5733 -1551
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +5 -2
- package/dist/init/health.d.ts +17 -2
- package/dist/init/shutdown.d.ts +18 -3
- package/dist/init.d.ts +54 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/job-queue.d.ts +23 -2
- package/dist/jobs/job-store.d.ts +37 -5
- package/dist/jobs/types.d.ts +8 -6
- package/dist/{jobs-Bjr8DZAi.js → jobs-DqYNfquG.js} +306 -167
- package/dist/jobs-DqYNfquG.js.map +1 -0
- package/dist/{jwt-C4OW-DNq.js → jwt-R6bSPMjk.js} +114 -38
- package/dist/{jwt-C4OW-DNq.js.map → jwt-R6bSPMjk.js.map} +1 -1
- package/dist/{keys-Qfc4XieN.js → keys-GAVZqbqx.js} +18 -17
- package/dist/{keys-Qfc4XieN.js.map → keys-GAVZqbqx.js.map} +1 -1
- package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
- package/dist/logger-D-S-hO5e.js.map +1 -0
- package/dist/{logs-routes-3EEzPjhl.js → logs-routes-DAdv37GI.js} +54 -11
- package/dist/logs-routes-DAdv37GI.js.map +1 -0
- package/dist/mcp/consent-page.d.ts +1 -1
- package/dist/mcp/mcp-routes.d.ts +45 -2
- package/dist/mcp/mcp-tools.d.ts +22 -10
- package/dist/mcp/oauth-metadata.d.ts +21 -16
- package/dist/mcp/oauth-routes.d.ts +34 -1
- package/dist/mcp/oauth-store.d.ts +29 -13
- package/dist/metrics/history-recorder.d.ts +1 -1
- package/dist/{openapi-generator-D8uFz-LW.js → openapi-generator-DAq_XVDu.js} +135 -22
- package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
- package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
- package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
- package/dist/{query-parser-BleZmY18.js → query-parser-BgiKJKvc.js} +41 -82
- package/dist/query-parser-BgiKJKvc.js.map +1 -0
- package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
- package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
- package/dist/rls-audit/index.d.ts +4 -0
- package/dist/schema-edit/apply-schema-change.d.ts +63 -3
- package/dist/schema-edit/project-root.d.ts +3 -2
- package/dist/schema-edit/remote-source.d.ts +9 -4
- package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-oIyuWl3L.js} +12 -7
- package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
- package/dist/serve-spa.d.ts +58 -0
- package/dist/services/routed-realtime-service.d.ts +11 -0
- package/dist/soft-delete-params-BWPilMPF.js +59 -0
- package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
- package/dist/{src-Br6ARbs6.js → src-CatHFUym.js} +439 -45
- package/dist/src-CatHFUym.js.map +1 -0
- package/dist/{src-1vL-I1Po.js → src-I3aG1PcY.js} +371 -81
- package/dist/src-I3aG1PcY.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +13 -1
- package/dist/storage/LocalStorageController.d.ts +2 -0
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/keys.d.ts +12 -0
- package/dist/storage/property-limits.d.ts +41 -6
- package/dist/storage/rendition-cache.d.ts +11 -1
- package/dist/storage/request-keys.d.ts +82 -0
- package/dist/storage/requested-object.d.ts +74 -0
- package/dist/storage/routes.d.ts +36 -18
- package/dist/storage/tus-handler.d.ts +30 -5
- package/dist/storage/types.d.ts +36 -1
- package/dist/types-BfKcm9do.js.map +1 -1
- package/dist/utils/logger.d.ts +12 -0
- package/package.json +9 -9
- package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
- package/dist/S3StorageController-Dcuf8lMA.js.map +0 -1
- package/dist/admin-roles-vYdp_Pil.js +0 -36
- package/dist/admin-roles-vYdp_Pil.js.map +0 -1
- package/dist/admin_block-DxKLmdiv.js +0 -206
- package/dist/admin_block-DxKLmdiv.js.map +0 -1
- package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
- package/dist/auth-CCDpk2rn.js.map +0 -1
- package/dist/backup-DzI9jLwc.js +0 -192
- package/dist/backup-DzI9jLwc.js.map +0 -1
- package/dist/contract-routes-eLxV0le1.js.map +0 -1
- package/dist/cron-loader-CQjvjpEw.js.map +0 -1
- package/dist/cron-routes-B7CRGfiq.js +0 -72
- package/dist/cron-routes-B7CRGfiq.js.map +0 -1
- package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
- package/dist/cron-store-BYGZFNWk.js.map +0 -1
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
- package/dist/errors-DWsX4yTd.js.map +0 -1
- package/dist/function-routes-Chet4-lB.js.map +0 -1
- package/dist/history-recorder-BQmB0P_j.js.map +0 -1
- package/dist/jobs-Bjr8DZAi.js.map +0 -1
- package/dist/logger-DO2PZc4i.js.map +0 -1
- package/dist/logs-routes-3EEzPjhl.js.map +0 -1
- package/dist/openapi-generator-D8uFz-LW.js.map +0 -1
- package/dist/query-parser-BleZmY18.js.map +0 -1
- package/dist/schema-editor-routes-DdLihzp0.js.map +0 -1
- package/dist/src-1vL-I1Po.js.map +0 -1
- package/dist/src-Br6ARbs6.js.map +0 -1
|
@@ -3,23 +3,37 @@ 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 {
|
|
7
|
-
import "./src-
|
|
8
|
-
import {
|
|
6
|
+
import { P as revokeInternalTableSql, ct as isAnonymousUid, st as ANONYMOUS_USER_ID } from "./src-I3aG1PcY.js";
|
|
7
|
+
import { _ as scopesForRoles, a as KEY_MANAGEMENT_SCOPES, b as validateScopes, c as intersectScopes, d as isDataPlaneScope, f as knownScopes, g as scopesBeyond, h as scopeGrantsAny, i as EMPTY_ACCESS_MODEL, l as isAdminScope, m as scopeGrants, n as ADMIN_SCOPES, o as SCOPE_TARGETS, p as parseScope, r as BUILT_IN_SCOPES, s as hasAdminRole, t as ADMIN_ROLE, u as isBuiltInScope, v as summarizeRoles } from "./src-CatHFUym.js";
|
|
8
|
+
import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
|
|
9
9
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
10
|
-
import { r as logger } from "./logger-
|
|
11
|
-
import { r as errorHandler, t as ApiError } from "./errors-
|
|
12
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
10
|
+
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
11
|
+
import { i as isDataException, r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
|
|
12
|
+
import { a as resolveListLimitParam } from "./query-parser-BgiKJKvc.js";
|
|
13
|
+
import { D as constantTimeEqual, E as require_jsonwebtoken, S as verifyMfaPendingToken, a as generateAccessToken, b as verifyDownloadToken, c as generateMfaPendingToken, d as getJwks, f as getRefreshTokenExpiry, g as isJwtConfigured, h as hashRefreshToken, i as customClaimsOf, j as sha256Hex, k as randomInt$1, l as generateRefreshToken, m as hasAsymmetricSigningKey, n as configureJwt, o as generateDownloadToken, p as getRefreshTokenTtlMs, r as configuredJwtSecret, t as MAX_COOKIE_AGE_MS, u as getAccessTokenExpiry, y as verifyAccessToken } from "./jwt-R6bSPMjk.js";
|
|
14
|
+
import { a as canonicalStorageId, o as canonicalStorageKey, r as InvalidStorageKeyError } from "./keys-GAVZqbqx.js";
|
|
15
|
+
import { n as createDdlBootstrapper } from "./ddl-bootstrap-BaqMSa4Y.js";
|
|
16
16
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
17
17
|
import { Hono } from "hono";
|
|
18
18
|
import { promisify } from "util";
|
|
19
|
-
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID, scrypt, timingSafeEqual } from "crypto";
|
|
19
|
+
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
20
20
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
21
21
|
import { createMiddleware } from "hono/factory";
|
|
22
22
|
import { z } from "zod";
|
|
23
|
+
//#region ../types/src/types/auth_adapter.ts
|
|
24
|
+
/**
|
|
25
|
+
* The request header that asks for a data-plane request to run as another
|
|
26
|
+
* user, named by uid.
|
|
27
|
+
*
|
|
28
|
+
* The server honours it only on a signed-in administrator's own session —
|
|
29
|
+
* never on an API key or the service key — and only through an adapter that
|
|
30
|
+
* implements {@link AuthAdapter.resolveUser}. Everywhere else a request that
|
|
31
|
+
* carries it is refused, never run as its caller.
|
|
32
|
+
*
|
|
33
|
+
* @group Auth
|
|
34
|
+
*/
|
|
35
|
+
var IMPERSONATE_HEADER = "x-rebase-impersonate";
|
|
36
|
+
//#endregion
|
|
23
37
|
//#region ../types/src/controllers/storage.ts
|
|
24
38
|
/**
|
|
25
39
|
* Path prefix that marks an object as **public**. Files stored under this
|
|
@@ -31,9 +45,33 @@ import { z } from "zod";
|
|
|
31
45
|
*/
|
|
32
46
|
var PUBLIC_STORAGE_PREFIX = "public/";
|
|
33
47
|
/**
|
|
34
|
-
* True when a storage key
|
|
35
|
-
* {@link PUBLIC_STORAGE_PREFIX}
|
|
36
|
-
*
|
|
48
|
+
* True when a storage **key** is public: it lies under
|
|
49
|
+
* {@link PUBLIC_STORAGE_PREFIX}.
|
|
50
|
+
*
|
|
51
|
+
* The key is read exactly as it is — the canonical key within its bucket, the
|
|
52
|
+
* one the server stores, serves, and hands a `storageAuthorize` hook as
|
|
53
|
+
* `key` — and nothing is parsed out of it. That is the difference from
|
|
54
|
+
* {@link isPublicStoragePath}, which reads a client-side *path* and so strips
|
|
55
|
+
* a `scheme://` and a leading `default/` bucket segment first. On a key both are
|
|
56
|
+
* wrong: `default/public/x` is a key in a folder called `default`, and a
|
|
57
|
+
* request path `notes://public/x` names the key `notes:/public/x`. Neither is
|
|
58
|
+
* public, and the server decides on this function so that it never says they
|
|
59
|
+
* are.
|
|
60
|
+
*
|
|
61
|
+
* @group Models
|
|
62
|
+
*/
|
|
63
|
+
function isPublicStorageKey(key) {
|
|
64
|
+
if (!key || !key.startsWith("public/")) return false;
|
|
65
|
+
return !key.split(/[\\/]/).includes("..");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* True when a storage key or client-side path points at a public object
|
|
69
|
+
* (lives under {@link PUBLIC_STORAGE_PREFIX}).
|
|
70
|
+
*
|
|
71
|
+
* For a path as a client holds it: a `scheme://` prefix and a single leading
|
|
72
|
+
* `default/` bucket segment are stripped first. The server and a
|
|
73
|
+
* `storageAuthorize` hook, which hold the canonical key, use
|
|
74
|
+
* {@link isPublicStorageKey}.
|
|
37
75
|
*
|
|
38
76
|
* @group Models
|
|
39
77
|
*/
|
|
@@ -46,6 +84,57 @@ function isPublicStoragePath(path) {
|
|
|
46
84
|
if (p.split("/").some((seg) => seg === "..")) return false;
|
|
47
85
|
return p.startsWith("public/") || p.startsWith(`default/public/`);
|
|
48
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The path the storage routes address an object by, from a key or from the
|
|
89
|
+
* `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
|
|
90
|
+
* property with `includeBucketUrl: true` keeps in its column).
|
|
91
|
+
*
|
|
92
|
+
* The routes address a source's objects by key. On S3 and GCS the bucket in a
|
|
93
|
+
* `storageUrl` is always the source's own — the server refuses any other — so
|
|
94
|
+
* it is dropped: sent along, `s3://acme-media/products/a.png` reached the
|
|
95
|
+
* server as the key `acme-media/products/a.png`, the preview found nothing and
|
|
96
|
+
* a delete reported success while the object stayed. It worked on local disk
|
|
97
|
+
* only, because `local://default/<key>` happens to carry the one bucket
|
|
98
|
+
* segment the routes recognise — which is kept, as is any other local bucket.
|
|
99
|
+
*
|
|
100
|
+
* A bare key whose first folder is `default` is sent behind that bucket
|
|
101
|
+
* segment. The routes read one leading `default/` (in any case) as the bucket,
|
|
102
|
+
* so the key `default/photo.png` sent as it is names the root `photo.png`:
|
|
103
|
+
* {@link defaultBucketPath} is the spelling that names the key itself.
|
|
104
|
+
*
|
|
105
|
+
* Shared by every storage source that talks to those routes — the SDK's and
|
|
106
|
+
* the admin panel's `useBackendStorageSource` — so they address an object alike.
|
|
107
|
+
*
|
|
108
|
+
* @group Models
|
|
109
|
+
*/
|
|
110
|
+
function storageObjectPath(keyOrUrl, bucket) {
|
|
111
|
+
let filePath = keyOrUrl;
|
|
112
|
+
let bucketQualified = false;
|
|
113
|
+
const scheme = /^(local|s3|gs):\/\//.exec(filePath);
|
|
114
|
+
if (scheme) {
|
|
115
|
+
filePath = filePath.substring(scheme[0].length);
|
|
116
|
+
if (scheme[1] !== "local") {
|
|
117
|
+
const slash = filePath.indexOf("/");
|
|
118
|
+
if (slash > 0) filePath = filePath.substring(slash + 1);
|
|
119
|
+
} else bucketQualified = true;
|
|
120
|
+
}
|
|
121
|
+
if (bucket && filePath) {
|
|
122
|
+
if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
|
|
123
|
+
bucketQualified = true;
|
|
124
|
+
}
|
|
125
|
+
return bucketQualified ? filePath : defaultBucketPath(filePath);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The request path of a key in the default bucket: the key itself, unless the
|
|
129
|
+
* routes would read its first folder as the bucket — a first folder named
|
|
130
|
+
* `default`, matched as the routes match it (case-insensitively, and only when
|
|
131
|
+
* something follows it). Then the bucket segment goes in front, so exactly the
|
|
132
|
+
* one segment the routes strip is the one added.
|
|
133
|
+
*/
|
|
134
|
+
function defaultBucketPath(key) {
|
|
135
|
+
const segments = key.split("/");
|
|
136
|
+
return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
|
|
137
|
+
}
|
|
49
138
|
//#endregion
|
|
50
139
|
//#region ../common/src/util/email.ts
|
|
51
140
|
/**
|
|
@@ -80,79 +169,26 @@ function normalizeEmail(email) {
|
|
|
80
169
|
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
81
170
|
}
|
|
82
171
|
//#endregion
|
|
83
|
-
//#region src/auth/api-keys/
|
|
172
|
+
//#region src/auth/api-keys/http-operation.ts
|
|
84
173
|
/**
|
|
85
|
-
* Map an HTTP method
|
|
174
|
+
* Map an HTTP method to the operation it performs.
|
|
86
175
|
*
|
|
87
176
|
* - `GET`, `HEAD`, `OPTIONS` → `"read"`
|
|
88
177
|
* - `POST`, `PUT`, `PATCH` → `"write"`
|
|
89
178
|
* - `DELETE` → `"delete"`
|
|
179
|
+
*
|
|
180
|
+
* Any other method is a `"write"`: a verb this does not know is not assumed
|
|
181
|
+
* to be harmless.
|
|
90
182
|
*/
|
|
91
183
|
function httpMethodToOperation(method) {
|
|
92
184
|
switch (method.toUpperCase()) {
|
|
93
185
|
case "GET":
|
|
94
186
|
case "HEAD":
|
|
95
187
|
case "OPTIONS": return "read";
|
|
96
|
-
case "POST":
|
|
97
|
-
case "PUT":
|
|
98
|
-
case "PATCH": return "write";
|
|
99
188
|
case "DELETE": return "delete";
|
|
100
|
-
default: return "
|
|
189
|
+
default: return "write";
|
|
101
190
|
}
|
|
102
191
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Check whether the given permissions array allows `operation` on `collection`.
|
|
105
|
-
*
|
|
106
|
-
* Supports the `"*"` wildcard for the collection field, which matches any
|
|
107
|
-
* collection. Returns `true` if at least one permission entry grants access.
|
|
108
|
-
*
|
|
109
|
-
* @param permissions - The API key's permission entries.
|
|
110
|
-
* @param collection - The target collection slug.
|
|
111
|
-
* @param operation - The requested operation.
|
|
112
|
-
* @returns `true` if the operation is permitted.
|
|
113
|
-
*/
|
|
114
|
-
function isOperationAllowed(permissions, collection, operation) {
|
|
115
|
-
for (const perm of permissions) if ((perm.collection === "*" || perm.collection === collection) && perm.operations.includes(operation)) return true;
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Check whether the given permissions array allows a storage operation.
|
|
120
|
-
*
|
|
121
|
-
* Storage, like functions, lives outside the collection namespace: an entry
|
|
122
|
-
* with collection `"storage"` grants storage access for the listed
|
|
123
|
-
* operations (GET routes → `read`, upload/folder/tus → `write`,
|
|
124
|
-
* DELETE routes → `delete`), and the global `"*"` wildcard also matches.
|
|
125
|
-
* A collection-scoped key gets no storage access at all.
|
|
126
|
-
*
|
|
127
|
-
* Matching is exactly {@link isOperationAllowed} with `"storage"` as the
|
|
128
|
-
* resource name — delegated so the semantics can never drift.
|
|
129
|
-
*/
|
|
130
|
-
function isStorageAllowed(permissions, operation) {
|
|
131
|
-
return isOperationAllowed(permissions, "storage", operation);
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Check whether the given permissions array allows invoking a custom function.
|
|
135
|
-
*
|
|
136
|
-
* Functions live outside the collection namespace, so they have their own
|
|
137
|
-
* resource names in the permission list:
|
|
138
|
-
*
|
|
139
|
-
* - `{ collection: "functions", ... }` grants every function
|
|
140
|
-
* - `{ collection: "functions/<name>", ... }` grants a single function
|
|
141
|
-
* - the global `"*"` wildcard (full-access keys) also matches
|
|
142
|
-
*
|
|
143
|
-
* The operation is derived from the HTTP method as usual (GET → read,
|
|
144
|
-
* POST/PUT/PATCH → write, DELETE → delete). The functions index route is
|
|
145
|
-
* checked as `functionName === ""`, which only the `"functions"` and `"*"`
|
|
146
|
-
* entries can grant.
|
|
147
|
-
*
|
|
148
|
-
* A collection-scoped key (e.g. read-only on `events`) therefore can NOT
|
|
149
|
-
* invoke functions — before this guard existed, any valid key could call
|
|
150
|
-
* every function.
|
|
151
|
-
*/
|
|
152
|
-
function isFunctionAllowed(permissions, functionName, operation) {
|
|
153
|
-
for (const perm of permissions) if ((perm.collection === "*" || perm.collection === "functions" || functionName !== "" && perm.collection === `functions/${functionName}`) && perm.operations.includes(operation)) return true;
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
192
|
//#endregion
|
|
157
193
|
//#region src/email/html.ts
|
|
158
194
|
/**
|
|
@@ -553,6 +589,16 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
|
|
|
553
589
|
};
|
|
554
590
|
}
|
|
555
591
|
/**
|
|
592
|
+
* How long an invitation's set-password link stays valid.
|
|
593
|
+
*
|
|
594
|
+
* One number for both halves: `finalizeAdminUserCreation` mints the token for
|
|
595
|
+
* this long and the template below tells the invitee this long. They were two
|
|
596
|
+
* literals, and the email said "1 hour" for a token that lived 24 — so an
|
|
597
|
+
* invitee who opened it the next morning believed the link was dead.
|
|
598
|
+
*/
|
|
599
|
+
var USER_INVITATION_LINK_TTL_MS = 1440 * 60 * 1e3;
|
|
600
|
+
var USER_INVITATION_LINK_TTL_HOURS = USER_INVITATION_LINK_TTL_MS / (3600 * 1e3);
|
|
601
|
+
/**
|
|
556
602
|
* Default user invitation email template
|
|
557
603
|
* Sent when an admin creates a new user account
|
|
558
604
|
*/
|
|
@@ -593,7 +639,7 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = DEFAULT_APP_N
|
|
|
593
639
|
</p>
|
|
594
640
|
|
|
595
641
|
<div style="${styles.warning}">
|
|
596
|
-
⏰ This link will expire in
|
|
642
|
+
⏰ This link will expire in ${USER_INVITATION_LINK_TTL_HOURS} hours for security reasons.
|
|
597
643
|
</div>
|
|
598
644
|
|
|
599
645
|
<div style="${styles.footer}">
|
|
@@ -616,7 +662,7 @@ An account has been created for you on ${appName}.
|
|
|
616
662
|
Click this link to set your password and get started:
|
|
617
663
|
${setPasswordUrl}
|
|
618
664
|
|
|
619
|
-
This link will expire in
|
|
665
|
+
This link will expire in ${USER_INVITATION_LINK_TTL_HOURS} hours for security reasons.
|
|
620
666
|
|
|
621
667
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
622
668
|
`.trim();
|
|
@@ -837,13 +883,219 @@ No action is needed.
|
|
|
837
883
|
text
|
|
838
884
|
};
|
|
839
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Default address-change confirmation, mailed to the NEW address.
|
|
888
|
+
*
|
|
889
|
+
* Names the account being moved — its current address — because the link
|
|
890
|
+
* makes the address the account's: whoever follows it hands their inbox to
|
|
891
|
+
* that account, and must be able to tell whether it is theirs.
|
|
892
|
+
*/
|
|
893
|
+
function getEmailChangeTemplate(confirmUrl, user, newEmail, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
894
|
+
const greeting = getGreeting(user);
|
|
895
|
+
const subject = `Confirm your new ${appName} email address`;
|
|
896
|
+
const body = html`
|
|
897
|
+
<!DOCTYPE html>
|
|
898
|
+
<html>
|
|
899
|
+
<head>
|
|
900
|
+
<meta charset="utf-8">
|
|
901
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
902
|
+
<title>${subject}</title>
|
|
903
|
+
</head>
|
|
904
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
905
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
906
|
+
<div style="${styles.card}">
|
|
907
|
+
<h1 style="${styles.heading}">Confirm Your New Email</h1>
|
|
908
|
+
|
|
909
|
+
<p style="${styles.paragraph}">
|
|
910
|
+
Hi ${greeting},
|
|
911
|
+
</p>
|
|
912
|
+
|
|
913
|
+
<p style="${styles.paragraph}">
|
|
914
|
+
The ${appName} account ${user.email} asked to use ${newEmail} from now on.
|
|
915
|
+
Confirm it is yours by clicking the button below:
|
|
916
|
+
</p>
|
|
917
|
+
|
|
918
|
+
<div style="text-align: center;">
|
|
919
|
+
<a href="${confirmUrl}" style="${styles.button}">Confirm New Email</a>
|
|
920
|
+
</div>
|
|
921
|
+
|
|
922
|
+
<p style="${styles.paragraph}">
|
|
923
|
+
Or copy and paste this link into your browser:
|
|
924
|
+
</p>
|
|
925
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
926
|
+
${confirmUrl}
|
|
927
|
+
</p>
|
|
928
|
+
|
|
929
|
+
<div style="${styles.warning}">
|
|
930
|
+
⏰ This link expires in 24 hours and can only be used once.
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<div style="${styles.footer}">
|
|
934
|
+
<p style="margin: 0;">
|
|
935
|
+
If you did not ask for this, ignore this email: nothing changes until the link is followed.
|
|
936
|
+
</p>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
</body>
|
|
941
|
+
</html>
|
|
942
|
+
`;
|
|
943
|
+
const text = `
|
|
944
|
+
Confirm Your New Email
|
|
945
|
+
|
|
946
|
+
Hi ${greeting},
|
|
947
|
+
|
|
948
|
+
The ${appName} account ${user.email} asked to use ${newEmail} from now on. Confirm it is yours by following this link:
|
|
949
|
+
${confirmUrl}
|
|
950
|
+
|
|
951
|
+
This link expires in 24 hours and can only be used once.
|
|
952
|
+
|
|
953
|
+
If you did not ask for this, ignore this email: nothing changes until the link is followed.
|
|
954
|
+
`.trim();
|
|
955
|
+
return {
|
|
956
|
+
subject,
|
|
957
|
+
html: body.toString().trim(),
|
|
958
|
+
text
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Default notice to the OLD address that a change was asked for. It carries
|
|
963
|
+
* no link: the old inbox can do nothing about the change except know of it,
|
|
964
|
+
* and a link here would be a second way to make it.
|
|
965
|
+
*/
|
|
966
|
+
function getEmailChangeNoticeTemplate(user, newEmail, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
967
|
+
const greeting = getGreeting(user);
|
|
968
|
+
const subject = `Your ${appName} email address is being changed`;
|
|
969
|
+
const body = html`
|
|
970
|
+
<!DOCTYPE html>
|
|
971
|
+
<html>
|
|
972
|
+
<head>
|
|
973
|
+
<meta charset="utf-8">
|
|
974
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
975
|
+
<title>${subject}</title>
|
|
976
|
+
</head>
|
|
977
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
978
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
979
|
+
<div style="${styles.card}">
|
|
980
|
+
<h1 style="${styles.heading}">Email Change Requested</h1>
|
|
981
|
+
|
|
982
|
+
<p style="${styles.paragraph}">
|
|
983
|
+
Hi ${greeting},
|
|
984
|
+
</p>
|
|
985
|
+
|
|
986
|
+
<p style="${styles.paragraph}">
|
|
987
|
+
Someone signed in to your ${appName} account asked to change its email address
|
|
988
|
+
from ${user.email} to ${newEmail}. The change happens only when the link we sent
|
|
989
|
+
to ${newEmail} is followed.
|
|
990
|
+
</p>
|
|
991
|
+
|
|
992
|
+
<div style="${styles.footer}">
|
|
993
|
+
<p style="margin: 0;">
|
|
994
|
+
If this was you, there is nothing to do here. If it was not, sign in, change your
|
|
995
|
+
password and sign out of every other session.
|
|
996
|
+
</p>
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
</body>
|
|
1001
|
+
</html>
|
|
1002
|
+
`;
|
|
1003
|
+
const text = `
|
|
1004
|
+
Email Change Requested
|
|
1005
|
+
|
|
1006
|
+
Hi ${greeting},
|
|
1007
|
+
|
|
1008
|
+
Someone signed in to your ${appName} account asked to change its email address from ${user.email} to ${newEmail}. The change happens only when the link we sent to ${newEmail} is followed.
|
|
1009
|
+
|
|
1010
|
+
If this was you, there is nothing to do here. If it was not, sign in, change your password and sign out of every other session.
|
|
1011
|
+
`.trim();
|
|
1012
|
+
return {
|
|
1013
|
+
subject,
|
|
1014
|
+
html: body.toString().trim(),
|
|
1015
|
+
text
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
//#endregion
|
|
1019
|
+
//#region src/email/link-base.ts
|
|
1020
|
+
/**
|
|
1021
|
+
* The config fields consulted for each link kind, most specific first.
|
|
1022
|
+
*
|
|
1023
|
+
* `resetPasswordUrl` is the general fallback because it is the only one any boot
|
|
1024
|
+
* path sets, and in every real deployment all three point at the same frontend.
|
|
1025
|
+
*/
|
|
1026
|
+
var LINK_BASE_FIELDS = {
|
|
1027
|
+
resetPassword: ["resetPasswordUrl"],
|
|
1028
|
+
verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
|
|
1029
|
+
magicLink: ["magicLinkUrl", "resetPasswordUrl"]
|
|
1030
|
+
};
|
|
1031
|
+
/**
|
|
1032
|
+
* True when `value` is an absolute `http(s)` URL — the only kind a mail client
|
|
1033
|
+
* can follow. A protocol-relative or path-only base is treated as missing.
|
|
1034
|
+
*/
|
|
1035
|
+
function isAbsoluteHttpUrl(value) {
|
|
1036
|
+
try {
|
|
1037
|
+
const url = new URL(value);
|
|
1038
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1039
|
+
} catch {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/** Drop a trailing slash so callers can append `/verify-email?token=…`. */
|
|
1044
|
+
function normalizeBase(value) {
|
|
1045
|
+
return value.replace(/\/+$/, "");
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The absolute base URL for a link kind, or `""` when the config has none.
|
|
1049
|
+
*
|
|
1050
|
+
* Callers append their path to the result. `""` is only reachable on a config
|
|
1051
|
+
* that never went through {@link assertEmailLinkBases} — a hand-constructed
|
|
1052
|
+
* `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
|
|
1053
|
+
* misconfiguration cannot turn a password-reset request into a 500 that
|
|
1054
|
+
* distinguishes existing accounts from missing ones.
|
|
1055
|
+
*/
|
|
1056
|
+
function resolveEmailLinkBase(config, kind) {
|
|
1057
|
+
if (!config) return "";
|
|
1058
|
+
for (const field of LINK_BASE_FIELDS[kind]) {
|
|
1059
|
+
const value = config[field];
|
|
1060
|
+
if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
|
|
1061
|
+
}
|
|
1062
|
+
return "";
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Throw when an email configuration cannot produce a followable link of every
|
|
1066
|
+
* kind it sends.
|
|
1067
|
+
*
|
|
1068
|
+
* Called from `createEmailService`, i.e. from every boot path that wires email
|
|
1069
|
+
* up (the managed runtime, both driver bootstrappers, and any app that passes
|
|
1070
|
+
* `auth.email`). A base that is set but relative is reported separately from one
|
|
1071
|
+
* that is missing, because the two have different fixes.
|
|
1072
|
+
*/
|
|
1073
|
+
function assertEmailLinkBases(config) {
|
|
1074
|
+
const relative = [
|
|
1075
|
+
"resetPasswordUrl",
|
|
1076
|
+
"verifyEmailUrl",
|
|
1077
|
+
"magicLinkUrl"
|
|
1078
|
+
].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
|
|
1079
|
+
if (relative.length > 0) throw new Error(`Email is configured with a relative link base: ${relative.map((f) => `email.${f}`).join(", ")}. Emailed links must be absolute (e.g. "https://app.example.com") — a mail client has no base document to resolve a relative href against, so the link is dead.`);
|
|
1080
|
+
const unresolved = Object.keys(LINK_BASE_FIELDS).filter((kind) => !resolveEmailLinkBase(config, kind));
|
|
1081
|
+
if (unresolved.length === Object.keys(LINK_BASE_FIELDS).length) throw new Error("Email is configured but no base URL for emailed links is set. Password-reset, verification and magic-link emails would carry relative hrefs, which are dead links in every mail client. Set FRONTEND_URL (or `auth.email.resetPasswordUrl`) to an absolute URL such as \"https://app.example.com\".");
|
|
1082
|
+
if (unresolved.length > 0) throw new Error(`Email is configured without a base URL for ${unresolved.map((kind) => LINK_KIND_LABELS[kind]).join(" and ")} emails, so their links would be relative hrefs — dead in every mail client. Those links fall back to \`auth.email.resetPasswordUrl\` only: set FRONTEND_URL (or \`auth.email.resetPasswordUrl\`) to an absolute URL such as "https://app.example.com".`);
|
|
1083
|
+
}
|
|
1084
|
+
/** How each link kind is named in a boot error. */
|
|
1085
|
+
var LINK_KIND_LABELS = {
|
|
1086
|
+
resetPassword: "password-reset",
|
|
1087
|
+
verifyEmail: "verification",
|
|
1088
|
+
magicLink: "magic-link"
|
|
1089
|
+
};
|
|
840
1090
|
//#endregion
|
|
841
1091
|
//#region src/auth/admin-user-ops.ts
|
|
842
1092
|
/**
|
|
843
1093
|
* Admin User Operations
|
|
844
1094
|
*
|
|
845
1095
|
* Shared utilities and orchestration for admin-initiated user management
|
|
846
|
-
* (user creation via REST API, password reset via admin panel)
|
|
1096
|
+
* (user creation via REST API, password reset via admin panel), and the rules
|
|
1097
|
+
* user administration holds to whichever door it arrives by — the admin user
|
|
1098
|
+
* routes, or the auth collection through the data API.
|
|
847
1099
|
*
|
|
848
1100
|
* Hook resolution order:
|
|
849
1101
|
* 1. Collection-level hook (`auth.onCreateUser` on the collection) — closest to the data
|
|
@@ -883,7 +1135,7 @@ function generateSecureToken() {
|
|
|
883
1135
|
/**
|
|
884
1136
|
* Hash a token for database storage using SHA-256.
|
|
885
1137
|
*/
|
|
886
|
-
function hashToken
|
|
1138
|
+
function hashToken(token) {
|
|
887
1139
|
return createHash("sha256").update(token).digest("hex");
|
|
888
1140
|
}
|
|
889
1141
|
/**
|
|
@@ -964,10 +1216,10 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
|
|
|
964
1216
|
if (!clearPassword) return { invitationSent: false };
|
|
965
1217
|
if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
|
|
966
1218
|
const token = generateSecureToken();
|
|
967
|
-
const tokenHash = hashToken
|
|
968
|
-
const expiresAt = new Date(Date.now() +
|
|
1219
|
+
const tokenHash = hashToken(token);
|
|
1220
|
+
const expiresAt = new Date(Date.now() + USER_INVITATION_LINK_TTL_MS);
|
|
969
1221
|
await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
|
|
970
|
-
const setPasswordUrl = `${ctx.emailConfig
|
|
1222
|
+
const setPasswordUrl = `${resolveEmailLinkBase(ctx.emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
971
1223
|
const { appName, logoUrl } = resolveEmailBranding(ctx.emailConfig);
|
|
972
1224
|
const templateFn = ctx.emailConfig?.templates?.userInvitation;
|
|
973
1225
|
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
@@ -1029,6 +1281,88 @@ async function completeUserCreation(prepared, finalize) {
|
|
|
1029
1281
|
...result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
1030
1282
|
};
|
|
1031
1283
|
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Refuse a change that leaves no administrator: `leaving` are users losing the
|
|
1286
|
+
* role (demoted, or deleted), and the project must keep at least one.
|
|
1287
|
+
*
|
|
1288
|
+
* Judged over the whole set, so a bulk write that takes every admin at once is
|
|
1289
|
+
* refused even though each of its rows alone would leave one behind.
|
|
1290
|
+
*/
|
|
1291
|
+
async function assertAdministratorsRemain(authRepo, leaving, message) {
|
|
1292
|
+
const admins = [];
|
|
1293
|
+
for (const uid of new Set(leaving)) if ((await authRepo.getUserRoleIds(uid)).includes("admin")) admins.push(uid);
|
|
1294
|
+
if (admins.length === 0) return;
|
|
1295
|
+
const { total } = await authRepo.listUsersPaginated({
|
|
1296
|
+
roleId: "admin",
|
|
1297
|
+
limit: 1
|
|
1298
|
+
});
|
|
1299
|
+
if (total <= admins.length) throw ApiError.forbidden(message, "LAST_ADMIN");
|
|
1300
|
+
}
|
|
1301
|
+
/** Refuse deleting these users when that would leave the project with no administrator. */
|
|
1302
|
+
async function assertUserDeletionsAllowed(authRepo, uids) {
|
|
1303
|
+
await assertAdministratorsRemain(authRepo, uids, uids.length > 1 ? "Cannot delete every administrator" : "Cannot delete the last administrator");
|
|
1304
|
+
}
|
|
1305
|
+
/** Refuse setting these users' roles when that would leave the project with no administrator. */
|
|
1306
|
+
async function assertRoleChangesAllowed(authRepo, changes) {
|
|
1307
|
+
await assertAdministratorsRemain(authRepo, changes.filter((change) => !change.roles.includes("admin")).map((change) => change.uid), changes.length > 1 ? "Cannot demote every administrator" : "Cannot demote the last administrator");
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* A user's new email, in the form sign-in looks it up by — or a 409 when
|
|
1311
|
+
* another account holds it.
|
|
1312
|
+
*
|
|
1313
|
+
* Sign-in, password reset and magic links all find the account by
|
|
1314
|
+
* `normalizeEmail(input)`. An address stored as typed is one those lookups
|
|
1315
|
+
* never match: its owner is locked out, and registering the address again hits
|
|
1316
|
+
* the case-insensitive unique index.
|
|
1317
|
+
*/
|
|
1318
|
+
async function normalizeUserEmailChange(authRepo, uid, email) {
|
|
1319
|
+
const normalized = normalizeEmail(email);
|
|
1320
|
+
const holder = await authRepo.getUserByEmail(normalized);
|
|
1321
|
+
if (holder && holder.id !== uid) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
1322
|
+
return normalized;
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Run `afterUserDelete` without letting it fail the deletion it follows: the
|
|
1326
|
+
* user is already gone, so a failing listener is logged, not answered.
|
|
1327
|
+
*/
|
|
1328
|
+
function runAfterUserDelete(resolvedHooks, uid) {
|
|
1329
|
+
if (!resolvedHooks.afterUserDelete) return;
|
|
1330
|
+
resolvedHooks.afterUserDelete(uid).catch((err) => {
|
|
1331
|
+
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* The auth adapter's side of user administration through the auth collection
|
|
1336
|
+
* (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
|
|
1337
|
+
* routes' rules and hooks, for the data API's writes to the same rows.
|
|
1338
|
+
*/
|
|
1339
|
+
function authCollectionUserAdmin(ctx) {
|
|
1340
|
+
const { authRepo, resolvedHooks } = ctx;
|
|
1341
|
+
return {
|
|
1342
|
+
async prepareUserUpdates(updates) {
|
|
1343
|
+
await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
|
|
1344
|
+
uid,
|
|
1345
|
+
roles: values.roles
|
|
1346
|
+
}] : []));
|
|
1347
|
+
const prepared = [];
|
|
1348
|
+
for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
|
|
1349
|
+
...values,
|
|
1350
|
+
email: await normalizeUserEmailChange(authRepo, uid, values.email)
|
|
1351
|
+
} : values);
|
|
1352
|
+
return prepared;
|
|
1353
|
+
},
|
|
1354
|
+
async prepareUserDeletions(uids) {
|
|
1355
|
+
await assertUserDeletionsAllowed(authRepo, uids);
|
|
1356
|
+
if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
|
|
1357
|
+
},
|
|
1358
|
+
async finalizeUserDeletions(uids) {
|
|
1359
|
+
for (const uid of uids) {
|
|
1360
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1361
|
+
runAfterUserDelete(resolvedHooks, uid);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1032
1366
|
//#endregion
|
|
1033
1367
|
//#region src/auth/token-revocation.ts
|
|
1034
1368
|
/**
|
|
@@ -1080,7 +1414,75 @@ async function isAccessTokenRevoked(authRepo, payload) {
|
|
|
1080
1414
|
});
|
|
1081
1415
|
return false;
|
|
1082
1416
|
}
|
|
1083
|
-
|
|
1417
|
+
return issuedBefore(payload, validAfter);
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Is the account behind this verified access token still the one that may use
|
|
1421
|
+
* it — and with which roles?
|
|
1422
|
+
*
|
|
1423
|
+
* A verified signature says who the token was minted for, an hour ago at
|
|
1424
|
+
* most. This asks the database what has happened to that account since. Every
|
|
1425
|
+
* door that honours an access token — the data plane, the admin gates, the
|
|
1426
|
+
* realtime socket — asks it here, so they cannot disagree about it.
|
|
1427
|
+
*
|
|
1428
|
+
* They did. A deleted account read as "not revoked": the watermark lives on
|
|
1429
|
+
* the user row, so once the row was gone there was no watermark, and the
|
|
1430
|
+
* roles lookup answered `[]` rather than "nobody". A token its owner had
|
|
1431
|
+
* revoked came back to life when an administrator deleted the account, as an
|
|
1432
|
+
* authenticated principal with that uid, for the rest of its lifetime. MCP
|
|
1433
|
+
* refresh and personal API keys already treated a missing account as revoked;
|
|
1434
|
+
* the main door did not.
|
|
1435
|
+
*
|
|
1436
|
+
* Throws when the repository does. Unlike the watermark read on its own, this
|
|
1437
|
+
* one decides the roles a request runs with, so a failure is a refusal (the
|
|
1438
|
+
* callers answer 503) rather than a guess.
|
|
1439
|
+
*/
|
|
1440
|
+
async function judgeAccessToken(authRepo, payload) {
|
|
1441
|
+
let roles;
|
|
1442
|
+
let validAfter = null;
|
|
1443
|
+
if (typeof authRepo.getAccountAccessState === "function") {
|
|
1444
|
+
const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
|
|
1445
|
+
if (!state) return {
|
|
1446
|
+
live: false,
|
|
1447
|
+
refusal: "account-deleted"
|
|
1448
|
+
};
|
|
1449
|
+
if (state.disabled) return {
|
|
1450
|
+
live: false,
|
|
1451
|
+
refusal: "account-disabled"
|
|
1452
|
+
};
|
|
1453
|
+
roles = state.roles;
|
|
1454
|
+
validAfter = state.tokensValidAfter;
|
|
1455
|
+
if (payload.sid && state.sessionActive === false) return {
|
|
1456
|
+
live: false,
|
|
1457
|
+
refusal: "session-revoked"
|
|
1458
|
+
};
|
|
1459
|
+
} else {
|
|
1460
|
+
if (typeof authRepo.getUserWithRoles === "function") {
|
|
1461
|
+
const account = await authRepo.getUserWithRoles(payload.uid);
|
|
1462
|
+
if (!account) return {
|
|
1463
|
+
live: false,
|
|
1464
|
+
refusal: "account-deleted"
|
|
1465
|
+
};
|
|
1466
|
+
if (account.user.disabled) return {
|
|
1467
|
+
live: false,
|
|
1468
|
+
refusal: "account-disabled"
|
|
1469
|
+
};
|
|
1470
|
+
roles = account.roles;
|
|
1471
|
+
}
|
|
1472
|
+
if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1473
|
+
}
|
|
1474
|
+
if (issuedBefore(payload, validAfter)) return {
|
|
1475
|
+
live: false,
|
|
1476
|
+
refusal: "revoked"
|
|
1477
|
+
};
|
|
1478
|
+
return {
|
|
1479
|
+
live: true,
|
|
1480
|
+
roles
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
/** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
|
|
1484
|
+
function issuedBefore(payload, validAfter) {
|
|
1485
|
+
if (!validAfter || typeof payload.iat !== "number") return false;
|
|
1084
1486
|
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
1085
1487
|
}
|
|
1086
1488
|
/**
|
|
@@ -1124,10 +1526,14 @@ async function revokeAllSessions(authRepo, uid) {
|
|
|
1124
1526
|
* `test/password-change-revokes-sessions.test.ts` holds every route that sets a
|
|
1125
1527
|
* password to this, and fails if anything but this function calls
|
|
1126
1528
|
* `updatePassword`.
|
|
1529
|
+
*
|
|
1530
|
+
* `null` removes the password instead, for the same reason and with the same
|
|
1531
|
+
* revocation: see `confirmAddressOwnership`.
|
|
1127
1532
|
*/
|
|
1128
1533
|
async function replaceUserPassword(authRepo, uid, passwordHash) {
|
|
1129
1534
|
await authRepo.updatePassword(uid, passwordHash);
|
|
1130
1535
|
await revokeAllSessions(authRepo, uid);
|
|
1536
|
+
await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
|
|
1131
1537
|
}
|
|
1132
1538
|
//#endregion
|
|
1133
1539
|
//#region src/auth/rls-scope.ts
|
|
@@ -1255,59 +1661,221 @@ function extractBearerToken(authHeader) {
|
|
|
1255
1661
|
return authHeader.slice(separator + 1);
|
|
1256
1662
|
}
|
|
1257
1663
|
//#endregion
|
|
1258
|
-
//#region src/auth/
|
|
1664
|
+
//#region src/auth/access.ts
|
|
1665
|
+
var configured = EMPTY_ACCESS_MODEL;
|
|
1666
|
+
var keyOwners;
|
|
1667
|
+
/** The access model the running backend serves. Built-ins only until boot configures it. */
|
|
1668
|
+
function getAccessModel() {
|
|
1669
|
+
return configured;
|
|
1670
|
+
}
|
|
1259
1671
|
/**
|
|
1260
|
-
*
|
|
1672
|
+
* How a personal key finds its owner, or undefined when personal keys are
|
|
1673
|
+
* off — in which case every personal key, however valid, is refused.
|
|
1261
1674
|
*/
|
|
1262
|
-
function
|
|
1263
|
-
return
|
|
1675
|
+
function getKeyOwnerResolver() {
|
|
1676
|
+
return keyOwners;
|
|
1264
1677
|
}
|
|
1265
1678
|
/**
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
* caller was already awaiting the store lookup this feeds.
|
|
1679
|
+
* Install the access model, and — when the app enables `auth.personalKeys`
|
|
1680
|
+
* and has an account store to read owners from — how personal keys find
|
|
1681
|
+
* their owner. Called once by `initializeRebaseBackend`.
|
|
1270
1682
|
*/
|
|
1271
|
-
function
|
|
1272
|
-
|
|
1683
|
+
function configureAccess(options) {
|
|
1684
|
+
configured = options.model;
|
|
1685
|
+
keyOwners = options.resolveKeyOwner;
|
|
1273
1686
|
}
|
|
1687
|
+
/** The resource names Rebase's own scopes use; an app scope may not reuse one. */
|
|
1688
|
+
var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
|
|
1689
|
+
/** A declaration the access model cannot be built from. Fails the boot. */
|
|
1690
|
+
var AccessModelError = class extends Error {
|
|
1691
|
+
constructor(message) {
|
|
1692
|
+
super(message);
|
|
1693
|
+
this.name = "AccessModelError";
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1274
1696
|
/**
|
|
1275
|
-
*
|
|
1697
|
+
* Build the access model from the users collection's `auth` block, refusing
|
|
1698
|
+
* anything that would read as a grant it is not.
|
|
1699
|
+
*
|
|
1700
|
+
* - `admin` may not be declared: it is built in and holds everything, and a
|
|
1701
|
+
* declaration would either repeat that or quietly narrow it.
|
|
1702
|
+
* - An app scope may not reuse a built-in resource name (`logs:export`): the
|
|
1703
|
+
* built-in resources are Rebase's, and a reader of `logs:*` should not have
|
|
1704
|
+
* to know which half is whose.
|
|
1705
|
+
* - A role may not list a data-plane scope: a person's data access is the
|
|
1706
|
+
* collection's `securityRules`, so `data:write` on a role would grant nothing
|
|
1707
|
+
* and look like it granted something.
|
|
1708
|
+
*/
|
|
1709
|
+
function accessModelFromCollections(collections) {
|
|
1710
|
+
const authCollection = collections.find((collection) => {
|
|
1711
|
+
const auth = collection.auth;
|
|
1712
|
+
return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
|
|
1713
|
+
});
|
|
1714
|
+
const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
|
|
1715
|
+
const declaredScopes = { ...auth?.scopes ?? {} };
|
|
1716
|
+
const declaredRoles = { ...auth?.roles ?? {} };
|
|
1717
|
+
const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
|
|
1718
|
+
for (const [name, definition] of Object.entries(declaredScopes)) {
|
|
1719
|
+
const parsed = parseScope(name);
|
|
1720
|
+
if (!parsed || parsed.target !== void 0) throw new AccessModelError(`auth.scopes: "${name}" in ${where} is not a scope name. Name scopes resource:action, lower-case, without a target — for example "project:deploy".`);
|
|
1721
|
+
const resource = name.slice(0, name.indexOf(":"));
|
|
1722
|
+
if (BUILT_IN_RESOURCES.has(resource)) throw new AccessModelError(`auth.scopes: "${name}" in ${where} uses the built-in resource "${resource}". App scopes need their own resource name; the built-in ones are ${[...BUILT_IN_RESOURCES].join(", ")}.`);
|
|
1723
|
+
if (typeof definition?.label !== "string" || definition.label.trim() === "") throw new AccessModelError(`auth.scopes: "${name}" in ${where} needs a label — it is what a person granting it reads.`);
|
|
1724
|
+
}
|
|
1725
|
+
const model = {
|
|
1726
|
+
roles: declaredRoles,
|
|
1727
|
+
scopes: declaredScopes
|
|
1728
|
+
};
|
|
1729
|
+
for (const [role, definition] of Object.entries(declaredRoles)) {
|
|
1730
|
+
if (role === "admin") throw new AccessModelError(`auth.roles: "admin" in ${where} is built in — it holds every scope — and cannot be declared. Declare a role with another name for a narrower set.`);
|
|
1731
|
+
if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
|
|
1732
|
+
const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
|
|
1733
|
+
if (dataPlane.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${dataPlane.join(", ")}. Every signed-in person already holds the data plane; what they may do with rows is the collection's securityRules. List admin-plane and app scopes only.`);
|
|
1734
|
+
const problems = validateScopes(definition.scopes, model);
|
|
1735
|
+
if (problems.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Role scopes are ${ADMIN_SCOPES.join(", ")} and the app's own auth.scopes.`);
|
|
1736
|
+
}
|
|
1737
|
+
return model;
|
|
1738
|
+
}
|
|
1739
|
+
/** Narrow a context's user to the roles it carries. */
|
|
1740
|
+
function rolesOf(user) {
|
|
1741
|
+
if (typeof user !== "object" || user === null || !("roles" in user)) return [];
|
|
1742
|
+
const roles = user.roles;
|
|
1743
|
+
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Everything the caller of this request may do. Empty for a request with no
|
|
1747
|
+
* caller.
|
|
1748
|
+
*/
|
|
1749
|
+
function callerScopes(c) {
|
|
1750
|
+
const narrowed = c.get("scopes");
|
|
1751
|
+
if (narrowed) return narrowed;
|
|
1752
|
+
const user = c.get("user");
|
|
1753
|
+
if (!user) return [];
|
|
1754
|
+
return scopesForRoles(rolesOf(user), configured);
|
|
1755
|
+
}
|
|
1756
|
+
/** Does this request's caller hold `scope`, on `target` when one is given? */
|
|
1757
|
+
function hasScope(c, scope, target) {
|
|
1758
|
+
return scopeGrants(callerScopes(c), scope, target);
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* The refusal for a missing scope, worded for whoever is holding the
|
|
1762
|
+
* credential. `details.requiredScope` names it for clients that branch.
|
|
1763
|
+
*/
|
|
1764
|
+
function scopeMissing(c, scope, target) {
|
|
1765
|
+
const wanted = target !== void 0 ? `${scope}:${target}` : scope;
|
|
1766
|
+
return new ApiError(403, "SCOPE_MISSING", c.get("apiKey") !== void 0 ? `This API key does not hold the "${wanted}" scope. Create a key that includes it.` : c.get("scopes") ? `This token does not hold the "${wanted}" scope. Reconnect and grant it.` : `Your account does not hold the "${wanted}" scope. A role that lists it (auth.roles on the users collection) grants it; the admin role holds every scope.`, { requiredScope: wanted });
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Refuse a request whose caller does not hold `scope`.
|
|
1276
1770
|
*
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1771
|
+
* `target` narrows the check to one collection, bucket, function or app
|
|
1772
|
+
* resource, and may be read from the request. Mount after authentication: a
|
|
1773
|
+
* request with no caller is refused as unauthenticated, not as unscoped.
|
|
1279
1774
|
*
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1775
|
+
* @example
|
|
1776
|
+
* ```ts
|
|
1777
|
+
* app.post("/deploy/:project", requireScope("project:deploy", c => c.req.param("project")), handler);
|
|
1778
|
+
* ```
|
|
1282
1779
|
*/
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1780
|
+
function requireScope(scope, target) {
|
|
1781
|
+
return async (c, next) => {
|
|
1782
|
+
if (!c.get("user")) return refuse(c, ApiError.unauthenticated("Authentication required"));
|
|
1783
|
+
const resolvedTarget = typeof target === "function" ? target(c) : target;
|
|
1784
|
+
if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
|
|
1785
|
+
return next();
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Refuse a request unless its caller holds the scope for its HTTP method:
|
|
1790
|
+
* `read` for GET and HEAD, `write` for everything else. For an admin surface
|
|
1791
|
+
* whose reads and writes split exactly along the method.
|
|
1792
|
+
*/
|
|
1793
|
+
function requireScopeByMethod(scopes) {
|
|
1794
|
+
const read = requireScope(scopes.read);
|
|
1795
|
+
const write = requireScope(scopes.write);
|
|
1796
|
+
return async (c, next) => {
|
|
1797
|
+
const method = c.req.method.toUpperCase();
|
|
1798
|
+
return (method === "GET" || method === "HEAD" ? read : write)(c, next);
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* What a set of roles holds beyond this request's caller — the scopes, plus
|
|
1803
|
+
* `admin` itself when the roles include it and the caller does not hold it.
|
|
1804
|
+
* Empty when the caller covers every one.
|
|
1805
|
+
*
|
|
1806
|
+
* `admin` is named separately because it is more than its scopes: it is the
|
|
1807
|
+
* RLS role that reads every row, so a role listing every admin-plane scope
|
|
1808
|
+
* still cannot hand it out.
|
|
1809
|
+
*/
|
|
1810
|
+
function beyondCaller(c, roles) {
|
|
1811
|
+
const beyond = scopesBeyond(scopesForRoles(roles, configured), callerScopes(c));
|
|
1812
|
+
if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
|
|
1813
|
+
return beyond;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Refuse to let the caller act on an account — edit it, reset its password,
|
|
1817
|
+
* delete it — when that account holds anything the caller does not.
|
|
1818
|
+
*
|
|
1819
|
+
* `users:write` is the scope that lets a support role manage accounts, and
|
|
1820
|
+
* without this it would also let that role reset an administrator's password
|
|
1821
|
+
* and sign in as them.
|
|
1822
|
+
*/
|
|
1823
|
+
function assertMayManageAccount(c, targetRoles) {
|
|
1824
|
+
const beyond = beyondCaller(c, targetRoles);
|
|
1825
|
+
if (beyond.length > 0) throw new ApiError(403, "ACCOUNT_OUTRANKS_CALLER", `This account holds ${beyond.join(", ")}, which you do not, so you cannot change it.`, { beyond });
|
|
1826
|
+
}
|
|
1827
|
+
/** Refuse to let the caller grant roles that hold anything the caller does not. */
|
|
1828
|
+
function assertMayGrantRoles(c, roles) {
|
|
1829
|
+
const beyond = beyondCaller(c, roles);
|
|
1830
|
+
if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CALLER", `These roles hold ${beyond.join(", ")}, which you do not, so you cannot grant them.`, { beyond });
|
|
1831
|
+
}
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/auth/api-keys/api-key-middleware.ts
|
|
1834
|
+
/**
|
|
1835
|
+
* Check whether a token looks like a Rebase API key.
|
|
1836
|
+
*/
|
|
1837
|
+
function isApiKeyToken(token) {
|
|
1838
|
+
return token.startsWith("rk_");
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Verify a presented key: it exists, is live, and — for a personal key —
|
|
1842
|
+
* personal keys are on and its owner still exists. Records the use.
|
|
1843
|
+
*/
|
|
1844
|
+
async function resolveApiKey(store, token) {
|
|
1845
|
+
const apiKey = await store.findByKeyHash(await sha256Hex(token));
|
|
1846
|
+
if (!apiKey) return { message: "Invalid API key" };
|
|
1847
|
+
if (apiKey.revoked_at) return { message: "API key has been revoked" };
|
|
1848
|
+
if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
|
|
1849
|
+
let identity;
|
|
1850
|
+
if (apiKey.kind === "personal") {
|
|
1851
|
+
const resolveOwner = getKeyOwnerResolver();
|
|
1852
|
+
if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
|
|
1853
|
+
const owner = await resolveOwner(apiKey.owner_uid);
|
|
1854
|
+
if (!owner) return { message: "The account this API key acts as no longer exists" };
|
|
1855
|
+
identity = {
|
|
1856
|
+
uid: apiKey.owner_uid,
|
|
1857
|
+
roles: owner.roles,
|
|
1858
|
+
scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, getAccessModel()))
|
|
1859
|
+
};
|
|
1860
|
+
} else identity = {
|
|
1861
|
+
uid: `api-key:${apiKey.id}`,
|
|
1862
|
+
roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
|
|
1863
|
+
scopes: apiKey.scopes
|
|
1864
|
+
};
|
|
1865
|
+
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
1866
|
+
const now = Date.now();
|
|
1867
|
+
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
1868
|
+
lastUsedTouchedAt.set(apiKey.id, now);
|
|
1869
|
+
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
1870
|
+
}
|
|
1305
1871
|
const masked = {
|
|
1306
1872
|
id: apiKey.id,
|
|
1307
1873
|
name: apiKey.name,
|
|
1874
|
+
kind: apiKey.kind,
|
|
1308
1875
|
key_prefix: apiKey.key_prefix,
|
|
1309
|
-
|
|
1310
|
-
|
|
1876
|
+
scopes: apiKey.scopes,
|
|
1877
|
+
roles: apiKey.roles,
|
|
1878
|
+
owner_uid: apiKey.owner_uid,
|
|
1311
1879
|
rate_limit: apiKey.rate_limit,
|
|
1312
1880
|
created_by: apiKey.created_by,
|
|
1313
1881
|
created_at: apiKey.created_at,
|
|
@@ -1316,87 +1884,84 @@ async function validateApiKey(c, token, options) {
|
|
|
1316
1884
|
expires_at: apiKey.expires_at,
|
|
1317
1885
|
revoked_at: apiKey.revoked_at
|
|
1318
1886
|
};
|
|
1319
|
-
|
|
1887
|
+
return {
|
|
1888
|
+
...identity,
|
|
1889
|
+
apiKey: masked
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
/** Per-process debounce state for last_used_at touches. */
|
|
1893
|
+
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
1894
|
+
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
1895
|
+
/**
|
|
1896
|
+
* Validate an API key token and populate the Hono context: `user`, `apiKey`,
|
|
1897
|
+
* `scopes` and the RLS-scoped `driver`.
|
|
1898
|
+
*
|
|
1899
|
+
* Returns `true` when the context is populated, or the error Response.
|
|
1900
|
+
*/
|
|
1901
|
+
async function validateApiKey(c, token, options) {
|
|
1902
|
+
const resolved = await resolveApiKey(options.store, token);
|
|
1903
|
+
if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
|
|
1904
|
+
c.set("user", {
|
|
1905
|
+
uid: resolved.uid,
|
|
1906
|
+
roles: resolved.roles
|
|
1907
|
+
});
|
|
1908
|
+
c.set("apiKey", resolved.apiKey);
|
|
1909
|
+
c.set("scopes", resolved.scopes);
|
|
1320
1910
|
try {
|
|
1321
|
-
|
|
1322
|
-
uid,
|
|
1323
|
-
roles
|
|
1324
|
-
});
|
|
1325
|
-
c.set("driver", scopedDriver);
|
|
1911
|
+
c.set("driver", await scopeDataDriver(options.driver, {
|
|
1912
|
+
uid: resolved.uid,
|
|
1913
|
+
roles: resolved.roles
|
|
1914
|
+
}));
|
|
1326
1915
|
} catch (error) {
|
|
1327
1916
|
logger.error("[AUTH] RLS scoping failed for API key", { error });
|
|
1328
|
-
return
|
|
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(() => {});
|
|
1917
|
+
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
1338
1918
|
}
|
|
1339
1919
|
return true;
|
|
1340
1920
|
}
|
|
1341
|
-
/** Per-process debounce state for last_used_at touches. */
|
|
1342
|
-
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
1343
|
-
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
1344
1921
|
/**
|
|
1345
|
-
*
|
|
1346
|
-
* generator (storage and functions
|
|
1347
|
-
* change to the error contract happens in one place.
|
|
1922
|
+
* The 403 for a credential that lacks a data-plane scope outside the REST
|
|
1923
|
+
* generator (storage and functions). Names the scope that would grant it.
|
|
1348
1924
|
*/
|
|
1349
|
-
function
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1925
|
+
function forbidScope(c, scope, target) {
|
|
1926
|
+
const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
|
|
1927
|
+
return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
|
|
1928
|
+
}
|
|
1929
|
+
/** Whether this request's caller holds `scope` — on `target` when given. */
|
|
1930
|
+
function callerHolds(c, scope, target) {
|
|
1931
|
+
const narrowed = c.get("scopes");
|
|
1932
|
+
if (!narrowed) return true;
|
|
1933
|
+
return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
|
|
1354
1934
|
}
|
|
1355
1935
|
/**
|
|
1356
|
-
*
|
|
1936
|
+
* Scope guard for the resumable-upload routes (`/tus/:id`).
|
|
1357
1937
|
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
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.
|
|
1938
|
+
* Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
|
|
1939
|
+
* cancel (DELETE) — is part of writing it, so all of them need
|
|
1940
|
+
* `storage:write`. Which source the upload writes to was checked when it was
|
|
1941
|
+
* created; these steps only reach an upload the same caller owns.
|
|
1370
1942
|
*/
|
|
1371
|
-
function
|
|
1943
|
+
function createTusScopeGuard() {
|
|
1372
1944
|
return async (c, next) => {
|
|
1373
|
-
|
|
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");
|
|
1945
|
+
if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
|
|
1377
1946
|
return next();
|
|
1378
1947
|
};
|
|
1379
1948
|
}
|
|
1380
1949
|
/**
|
|
1381
|
-
*
|
|
1950
|
+
* Scope guard for the custom-functions router: a narrowed credential needs
|
|
1951
|
+
* `functions:invoke` on the function it calls. The functions index — the
|
|
1952
|
+
* listing at the mount point itself — needs the unqualified scope.
|
|
1382
1953
|
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
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.
|
|
1954
|
+
* People pass: what a person may do inside a function is the function's own
|
|
1955
|
+
* business, as it always was.
|
|
1391
1956
|
*
|
|
1392
1957
|
* @param mountPrefix - The path the functions router is mounted at
|
|
1393
1958
|
* (e.g. `/api/functions`), used to extract the function
|
|
1394
1959
|
* name from the request path.
|
|
1395
1960
|
*/
|
|
1396
|
-
function
|
|
1961
|
+
function createFunctionScopeGuard(mountPrefix) {
|
|
1397
1962
|
return async (c, next) => {
|
|
1398
|
-
const
|
|
1399
|
-
if (!
|
|
1963
|
+
const narrowed = c.get("scopes");
|
|
1964
|
+
if (!narrowed) return next();
|
|
1400
1965
|
const path = c.req.path;
|
|
1401
1966
|
const idx = path.indexOf(mountPrefix);
|
|
1402
1967
|
const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
@@ -1406,21 +1971,17 @@ function createFunctionApiKeyGuard(mountPrefix) {
|
|
|
1406
1971
|
} catch {
|
|
1407
1972
|
functionName = rawName;
|
|
1408
1973
|
}
|
|
1409
|
-
|
|
1410
|
-
if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
|
|
1974
|
+
if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
|
|
1411
1975
|
return next();
|
|
1412
1976
|
};
|
|
1413
1977
|
}
|
|
1414
1978
|
/**
|
|
1415
|
-
*
|
|
1979
|
+
* Pre-auth middleware for `rk_` bearer tokens.
|
|
1416
1980
|
*
|
|
1417
|
-
* Routers whose auth gate is JWT-based (`
|
|
1418
|
-
* the
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
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.
|
|
1981
|
+
* Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
|
|
1982
|
+
* surfaces, the key routes) don't know about API keys. Mounted in front of
|
|
1983
|
+
* them, this authenticates `rk_` tokens and populates the request context; the
|
|
1984
|
+
* downstream gates then see the already-resolved caller and check its scopes.
|
|
1424
1985
|
*
|
|
1425
1986
|
* Requests without an `rk_` bearer token pass through untouched. An invalid,
|
|
1426
1987
|
* revoked, or expired `rk_` token is rejected here (401) rather than falling
|
|
@@ -1437,6 +1998,257 @@ function createApiKeyPreAuth(options) {
|
|
|
1437
1998
|
};
|
|
1438
1999
|
}
|
|
1439
2000
|
//#endregion
|
|
2001
|
+
//#region src/auth/impersonation.ts
|
|
2002
|
+
var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
|
|
2003
|
+
var NOT_A_PERSON = {
|
|
2004
|
+
"api-key": {
|
|
2005
|
+
reason: "api-key",
|
|
2006
|
+
message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2007
|
+
},
|
|
2008
|
+
"service-key": {
|
|
2009
|
+
reason: "service-key",
|
|
2010
|
+
message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2011
|
+
},
|
|
2012
|
+
none: {
|
|
2013
|
+
reason: "anonymous",
|
|
2014
|
+
message: ADMIN_ONLY
|
|
2015
|
+
}
|
|
2016
|
+
};
|
|
2017
|
+
/**
|
|
2018
|
+
* Decide whether this caller may act as the user they named, and who that
|
|
2019
|
+
* user is now.
|
|
2020
|
+
*
|
|
2021
|
+
* The one decision both doors take — the HTTP data plane through
|
|
2022
|
+
* {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
|
|
2023
|
+
* every frame after it. The caller is judged before the named user is looked
|
|
2024
|
+
* up, so the answer to a caller who may not impersonate says nothing about
|
|
2025
|
+
* whether the uid exists.
|
|
2026
|
+
*
|
|
2027
|
+
* Throws when a lookup fails: that is not an answer about anyone, and each
|
|
2028
|
+
* door refuses it its own way.
|
|
2029
|
+
*/
|
|
2030
|
+
async function decideImpersonation(request) {
|
|
2031
|
+
const { credential, caller, resolveUser, audit } = request;
|
|
2032
|
+
const targetUid = request.requestedUid.trim();
|
|
2033
|
+
const refuse = (reason, error) => {
|
|
2034
|
+
if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2035
|
+
eventType: "auth.impersonation.refused",
|
|
2036
|
+
reason,
|
|
2037
|
+
callerUid: caller?.uid,
|
|
2038
|
+
requestedUid: targetUid,
|
|
2039
|
+
...audit
|
|
2040
|
+
});
|
|
2041
|
+
return { refused: error };
|
|
2042
|
+
};
|
|
2043
|
+
if (targetUid === "") return refuse("malformed", ApiError.badRequest(`The ${IMPERSONATE_HEADER} header names no user. Send the uid of the user to act as, or leave the header out.`, "IMPERSONATION_INVALID"));
|
|
2044
|
+
if (credential !== "session") {
|
|
2045
|
+
const { reason, message } = NOT_A_PERSON[credential];
|
|
2046
|
+
return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
|
|
2047
|
+
}
|
|
2048
|
+
if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2049
|
+
if (!resolveUser) return refuse("unavailable", new ApiError(501, "IMPERSONATION_UNAVAILABLE", "This backend's auth cannot run a request as another user: its auth adapter does not implement resolveUser."));
|
|
2050
|
+
const self = await resolveUser(caller.uid);
|
|
2051
|
+
if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2052
|
+
const target = await resolveUser(targetUid);
|
|
2053
|
+
if (!target) return refuse("unknown-target", ApiError.notFound(`No active user has the id "${targetUid}", so there is nobody to run this request as.`, "IMPERSONATION_TARGET_NOT_FOUND"));
|
|
2054
|
+
if (audit) logger.info("[Security Audit] Ran a request as another user", {
|
|
2055
|
+
eventType: "auth.impersonation",
|
|
2056
|
+
impersonatorUid: caller.uid,
|
|
2057
|
+
targetUid: target.uid,
|
|
2058
|
+
targetRoles: target.roles,
|
|
2059
|
+
...audit
|
|
2060
|
+
});
|
|
2061
|
+
return {
|
|
2062
|
+
granted: target,
|
|
2063
|
+
impersonator: caller.uid
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
/** The caller the auth middleware put on the context, narrowed. */
|
|
2067
|
+
function callerOf(c) {
|
|
2068
|
+
const user = c.get("user");
|
|
2069
|
+
if (typeof user !== "object" || user === null) return void 0;
|
|
2070
|
+
const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
|
|
2071
|
+
if (uid === void 0) return void 0;
|
|
2072
|
+
return {
|
|
2073
|
+
uid,
|
|
2074
|
+
roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Honour or refuse this request's {@link IMPERSONATE_HEADER}.
|
|
2079
|
+
*
|
|
2080
|
+
* Called by the data-plane auth middlewares once the caller is on the context
|
|
2081
|
+
* and its driver is scoped. When the header names a user the caller may act
|
|
2082
|
+
* as, the context's `user` and `driver` are replaced with that user's and
|
|
2083
|
+
* `impersonator` names the caller.
|
|
2084
|
+
*
|
|
2085
|
+
* @returns The refusal to send, or `undefined` to carry on — as the caller
|
|
2086
|
+
* when the header is absent, as the named user when it was granted.
|
|
2087
|
+
*/
|
|
2088
|
+
async function applyImpersonation(c, { credential, driver, resolveUser }) {
|
|
2089
|
+
const requested = c.req.header(IMPERSONATE_HEADER);
|
|
2090
|
+
if (requested === void 0) return void 0;
|
|
2091
|
+
const caller = callerOf(c);
|
|
2092
|
+
let decision;
|
|
2093
|
+
try {
|
|
2094
|
+
decision = await decideImpersonation({
|
|
2095
|
+
requestedUid: requested,
|
|
2096
|
+
credential,
|
|
2097
|
+
caller,
|
|
2098
|
+
resolveUser,
|
|
2099
|
+
audit: {
|
|
2100
|
+
door: "http",
|
|
2101
|
+
method: c.req.method,
|
|
2102
|
+
path: c.req.path,
|
|
2103
|
+
requestId: c.get("requestId")
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
|
|
2108
|
+
}
|
|
2109
|
+
if ("refused" in decision) return errorHandler(decision.refused, c);
|
|
2110
|
+
const target = decision.granted;
|
|
2111
|
+
const isAnonymous = target.isAnonymous === true;
|
|
2112
|
+
c.set("user", {
|
|
2113
|
+
uid: target.uid,
|
|
2114
|
+
email: target.email,
|
|
2115
|
+
roles: target.roles,
|
|
2116
|
+
isAnonymous
|
|
2117
|
+
});
|
|
2118
|
+
c.set("impersonator", { uid: decision.impersonator });
|
|
2119
|
+
try {
|
|
2120
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
2121
|
+
uid: target.uid,
|
|
2122
|
+
roles: target.roles,
|
|
2123
|
+
isAnonymous,
|
|
2124
|
+
...target.claims ? { claims: target.claims } : {}
|
|
2125
|
+
}));
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
|
|
2128
|
+
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
|
|
2133
|
+
*
|
|
2134
|
+
* Only the data API and custom functions run a request as another user; the
|
|
2135
|
+
* rest — storage, auth, the admin surfaces — authenticate the caller and
|
|
2136
|
+
* ignore the header. Ignored, a request asking to act as B would be answered
|
|
2137
|
+
* as the administrator who sent it, which is the one outcome impersonation
|
|
2138
|
+
* must never have. Mounted ahead of every route, so a surface added later is
|
|
2139
|
+
* refused until it is listed here as honouring the header.
|
|
2140
|
+
*
|
|
2141
|
+
* @param honouredMounts - The mount points whose auth middleware applies the
|
|
2142
|
+
* header (`/api/data`, `/api/functions`). A path is under one when it
|
|
2143
|
+
* is the mount itself or continues it with a `/`.
|
|
2144
|
+
*/
|
|
2145
|
+
function refuseUnhonouredImpersonation(honouredMounts) {
|
|
2146
|
+
return async (c, next) => {
|
|
2147
|
+
if (c.req.header("x-rebase-impersonate") === void 0) return next();
|
|
2148
|
+
const path = c.req.path;
|
|
2149
|
+
if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
|
|
2150
|
+
logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2151
|
+
eventType: "auth.impersonation.refused",
|
|
2152
|
+
reason: "unsupported-route",
|
|
2153
|
+
method: c.req.method,
|
|
2154
|
+
path,
|
|
2155
|
+
requestId: c.get("requestId")
|
|
2156
|
+
});
|
|
2157
|
+
return errorHandler(ApiError.badRequest(`This route cannot run as another user, so it refuses the ${IMPERSONATE_HEADER} header. Only the data API and custom functions honour it.`, "IMPERSONATION_UNSUPPORTED"), c);
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region src/storage/requested-object.ts
|
|
2162
|
+
/**
|
|
2163
|
+
* Which object a storage request's URL path names.
|
|
2164
|
+
*
|
|
2165
|
+
* Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
|
|
2166
|
+
* `DELETE /file/*`: the route, which serves or deletes an object;
|
|
2167
|
+
* `publicObjectAuth`, which lets an anonymous caller through when that object
|
|
2168
|
+
* is public; and `fileTokenAuth`, which compares it with the path a download
|
|
2169
|
+
* token grants. A decision is only about the object acted on if it is made on
|
|
2170
|
+
* the same key, so all three derive it here — the wildcard, the decoding, the
|
|
2171
|
+
* bucket segment and the canonical key — and none of them spells a step of its
|
|
2172
|
+
* own.
|
|
2173
|
+
*
|
|
2174
|
+
* They used to. `publicObjectAuth` judged the raw path with a check that strips
|
|
2175
|
+
* everything up to a `://`, while the route canonicalized it, folding `//` to
|
|
2176
|
+
* `/`: `GET /file/notes://public/secret.txt` was public to the one and served
|
|
2177
|
+
* the private key `notes:/public/secret.txt` from the other, with the authorize
|
|
2178
|
+
* hook never asked.
|
|
2179
|
+
*
|
|
2180
|
+
* Nothing here parses a scheme. A request path is a key, optionally behind the
|
|
2181
|
+
* one bucket segment the routes recognise; a `://` in it is a `:` followed by
|
|
2182
|
+
* an empty segment, and canonicalization folds the empty segment away.
|
|
2183
|
+
*/
|
|
2184
|
+
/**
|
|
2185
|
+
* The wildcard part of a storage route's path, still percent-encoded.
|
|
2186
|
+
*
|
|
2187
|
+
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
2188
|
+
* via `app.route(prefix, subRouter)`. Instead it is derived from the
|
|
2189
|
+
* fully-resolved `c.req.path` and `c.req.routePath`.
|
|
2190
|
+
*
|
|
2191
|
+
* For a route `/metadata/*` mounted at `/api/storage`, a request to
|
|
2192
|
+
* `/api/storage/metadata/default/file.jpg` yields routePath
|
|
2193
|
+
* `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
|
|
2194
|
+
* plus one character for the trailing `/`, to obtain `default/file.jpg`.
|
|
2195
|
+
*/
|
|
2196
|
+
function storageRequestWildcard(c) {
|
|
2197
|
+
const prefix = c.req.routePath.replace("/*", "");
|
|
2198
|
+
const fullPath = c.req.path;
|
|
2199
|
+
const idx = fullPath.indexOf(prefix);
|
|
2200
|
+
if (idx < 0) return "";
|
|
2201
|
+
return fullPath.substring(idx + prefix.length + 1);
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* The object a decoded path names: a key, behind an optional `default/` bucket
|
|
2205
|
+
* segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
|
|
2206
|
+
* when the key cannot be canonicalized.
|
|
2207
|
+
*
|
|
2208
|
+
* For a path that is already text — the folder route's JSON body. A URL path
|
|
2209
|
+
* goes through {@link requestedStorageObject}, which decodes it first.
|
|
2210
|
+
*/
|
|
2211
|
+
function storageObjectOfPath(decodedPath) {
|
|
2212
|
+
const parts = decodedPath.split("/");
|
|
2213
|
+
return {
|
|
2214
|
+
bucket: "default",
|
|
2215
|
+
key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
|
|
2220
|
+
*
|
|
2221
|
+
* The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
|
|
2222
|
+
* characters (`/`, `:`, `%25` among them) still escaped — so one
|
|
2223
|
+
* `decodeURIComponent` yields the path the client encoded. A `%` that does not
|
|
2224
|
+
* begin an escape names no key, and is refused like any other key that cannot be
|
|
2225
|
+
* canonicalized rather than thrown as a `URIError`.
|
|
2226
|
+
*/
|
|
2227
|
+
function requestedStorageObject(wildcard) {
|
|
2228
|
+
let decoded;
|
|
2229
|
+
try {
|
|
2230
|
+
decoded = decodeURIComponent(wildcard);
|
|
2231
|
+
} catch {
|
|
2232
|
+
throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
|
|
2233
|
+
}
|
|
2234
|
+
return storageObjectOfPath(decoded);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* {@link requestedStorageObject}, or `null` when the path names no object.
|
|
2238
|
+
*
|
|
2239
|
+
* For the middleware, which must fail closed without an exception: a path that
|
|
2240
|
+
* names no object is neither public nor covered by any download token, and the
|
|
2241
|
+
* route answers it with a 400 of its own.
|
|
2242
|
+
*/
|
|
2243
|
+
function tryRequestedStorageObject(wildcard) {
|
|
2244
|
+
try {
|
|
2245
|
+
return requestedStorageObject(wildcard);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
if (err instanceof InvalidStorageKeyError) return null;
|
|
2248
|
+
throw err;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
//#endregion
|
|
1440
2252
|
//#region src/auth/middleware.ts
|
|
1441
2253
|
/**
|
|
1442
2254
|
* Answer a refusal with the canonical envelope, through the same formatter
|
|
@@ -1509,10 +2321,15 @@ function createRequireAuth(options) {
|
|
|
1509
2321
|
const payload = await verifyAccessToken(token);
|
|
1510
2322
|
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
1511
2323
|
if (resolveRoles || revocationRepo) try {
|
|
1512
|
-
|
|
2324
|
+
let judgedRoles;
|
|
2325
|
+
if (revocationRepo) {
|
|
2326
|
+
const verdict = await judgeAccessToken(revocationRepo, payload);
|
|
2327
|
+
if (!verdict.live) return refuse(c, verdict.refusal === "account-disabled" ? ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED") : ApiError.unauthorized(verdict.refusal === "account-deleted" ? "This account no longer exists" : "Session has been revoked", "SESSION_REVOKED"));
|
|
2328
|
+
judgedRoles = verdict.roles;
|
|
2329
|
+
}
|
|
1513
2330
|
c.set("user", resolveRoles ? {
|
|
1514
2331
|
...payload,
|
|
1515
|
-
roles: await resolveRoles(payload.uid)
|
|
2332
|
+
roles: judgedRoles ?? await resolveRoles(payload.uid)
|
|
1516
2333
|
} : payload);
|
|
1517
2334
|
return next();
|
|
1518
2335
|
} catch (error) {
|
|
@@ -1530,13 +2347,17 @@ function createRequireAuth(options) {
|
|
|
1530
2347
|
};
|
|
1531
2348
|
}
|
|
1532
2349
|
/**
|
|
1533
|
-
* Middleware that requires the user to
|
|
1534
|
-
* Must be used AFTER requireAuth or on a route where user is
|
|
2350
|
+
* Middleware that requires the user to hold the `admin` role, which holds
|
|
2351
|
+
* every scope. Must be used AFTER requireAuth or on a route where user is
|
|
2352
|
+
* guaranteed.
|
|
2353
|
+
*
|
|
2354
|
+
* Prefer `requireScope` from `./access` for anything a scope names: a scope
|
|
2355
|
+
* can be granted to a narrower role and to a key, and `admin` cannot.
|
|
1535
2356
|
*/
|
|
1536
2357
|
var requireAdmin = async (c, next) => {
|
|
1537
2358
|
const user = c.get("user");
|
|
1538
2359
|
if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
|
|
1539
|
-
if (!
|
|
2360
|
+
if (!hasAdminRole(typeof user === "object" && user !== null && "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
|
|
1540
2361
|
return next();
|
|
1541
2362
|
};
|
|
1542
2363
|
/**
|
|
@@ -1584,12 +2405,14 @@ function createAuthMiddleware(options) {
|
|
|
1584
2405
|
const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
1585
2406
|
return async (c, next) => {
|
|
1586
2407
|
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
2408
|
+
let credential = "none";
|
|
1587
2409
|
if (validator) try {
|
|
1588
2410
|
const authResult = await validator(c);
|
|
1589
2411
|
if (authResult && typeof authResult === "object") {
|
|
1590
2412
|
const id = "uid" in authResult ? authResult.uid : void 0;
|
|
1591
2413
|
if (id) {
|
|
1592
2414
|
const roles = authResult.roles || [];
|
|
2415
|
+
credential = "session";
|
|
1593
2416
|
c.set("user", {
|
|
1594
2417
|
uid: id,
|
|
1595
2418
|
roles
|
|
@@ -1605,6 +2428,7 @@ function createAuthMiddleware(options) {
|
|
|
1605
2428
|
roles: ["anon"]
|
|
1606
2429
|
}));
|
|
1607
2430
|
} else if (authResult === true) {
|
|
2431
|
+
credential = "session";
|
|
1608
2432
|
c.set("user", {
|
|
1609
2433
|
uid: "default",
|
|
1610
2434
|
roles: []
|
|
@@ -1623,10 +2447,12 @@ function createAuthMiddleware(options) {
|
|
|
1623
2447
|
else {
|
|
1624
2448
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
1625
2449
|
if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
|
|
1626
|
-
|
|
2450
|
+
const serviceUser = {
|
|
1627
2451
|
uid: "service",
|
|
1628
2452
|
roles: ["admin"]
|
|
1629
|
-
}
|
|
2453
|
+
};
|
|
2454
|
+
credential = "service-key";
|
|
2455
|
+
c.set("user", serviceUser);
|
|
1630
2456
|
try {
|
|
1631
2457
|
c.set("driver", await scopeDataDriver(driver, {
|
|
1632
2458
|
uid: "service",
|
|
@@ -1645,9 +2471,11 @@ function createAuthMiddleware(options) {
|
|
|
1645
2471
|
driver
|
|
1646
2472
|
});
|
|
1647
2473
|
if (result !== true) return result;
|
|
2474
|
+
credential = "api-key";
|
|
1648
2475
|
} else {
|
|
1649
2476
|
const payload = await extractUserFromToken(token);
|
|
1650
2477
|
if (payload) {
|
|
2478
|
+
credential = "session";
|
|
1651
2479
|
c.set("user", payload);
|
|
1652
2480
|
try {
|
|
1653
2481
|
const user = {
|
|
@@ -1679,6 +2507,11 @@ function createAuthMiddleware(options) {
|
|
|
1679
2507
|
} }, 500);
|
|
1680
2508
|
}
|
|
1681
2509
|
}
|
|
2510
|
+
const refused = await applyImpersonation(c, {
|
|
2511
|
+
credential,
|
|
2512
|
+
driver
|
|
2513
|
+
});
|
|
2514
|
+
if (refused) return refused;
|
|
1682
2515
|
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
2516
|
return next();
|
|
1684
2517
|
};
|
|
@@ -1719,14 +2552,19 @@ var queryTokenAuth = async (c, next) => {
|
|
|
1719
2552
|
* requested object path is public, it sets a minimal "public" principal so the
|
|
1720
2553
|
* downstream `requireAuth` gate lets the read through. Private paths are left
|
|
1721
2554
|
* untouched, so they still require a valid token.
|
|
2555
|
+
*
|
|
2556
|
+
* Public means the object the route will serve is public, so the decision is
|
|
2557
|
+
* made on that object's canonical key, derived by the route's own function
|
|
2558
|
+
* (`requestedStorageObject`). It used to be made on the raw path, through a
|
|
2559
|
+
* check that strips a `scheme://`: `notes://public/secret.txt` read as
|
|
2560
|
+
* `public/secret.txt` here while the route served the private
|
|
2561
|
+
* `notes:/public/secret.txt`, and the authorize hook — not asked about this
|
|
2562
|
+
* principal — never saw it.
|
|
1722
2563
|
*/
|
|
1723
2564
|
var publicObjectAuth = async (c, next) => {
|
|
1724
2565
|
if (c.get("user")) return next();
|
|
1725
|
-
const
|
|
1726
|
-
|
|
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", {
|
|
2566
|
+
const requested = tryRequestedStorageObject(storageRequestWildcard(c));
|
|
2567
|
+
if (requested && isPublicStorageKey(requested.key)) c.set("user", {
|
|
1730
2568
|
uid: "public",
|
|
1731
2569
|
roles: ["public"]
|
|
1732
2570
|
});
|
|
@@ -1750,7 +2588,7 @@ var publicObjectAuth = async (c, next) => {
|
|
|
1750
2588
|
* Hono routes the request, so this comparison already runs on a resolved path.
|
|
1751
2589
|
* But that is a guarantee of the runtime rather than of this code, and it is
|
|
1752
2590
|
* one line to not depend on it. The same rule already guards the public-object
|
|
1753
|
-
* path — see `
|
|
2591
|
+
* path — see `isPublicStorageKey`.
|
|
1754
2592
|
*/
|
|
1755
2593
|
function isPathMatch(requested, allowed) {
|
|
1756
2594
|
if (requested.split("/").some((seg) => seg === "..")) return false;
|
|
@@ -1767,34 +2605,6 @@ var fileTokenAuth = async (c, next) => {
|
|
|
1767
2605
|
const isFileRoute = c.req.path.includes("/storage/file/");
|
|
1768
2606
|
const authHeader = c.req.header("authorization");
|
|
1769
2607
|
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("/"))
|
|
1792
|
-
};
|
|
1793
|
-
return {
|
|
1794
|
-
bucket: "default",
|
|
1795
|
-
resolvedPath: tryCanonicalStorageKey(filePath)
|
|
1796
|
-
};
|
|
1797
|
-
};
|
|
1798
2608
|
/**
|
|
1799
2609
|
* Decide what a valid download token entitles this request to.
|
|
1800
2610
|
*
|
|
@@ -1809,11 +2619,11 @@ var fileTokenAuth = async (c, next) => {
|
|
|
1809
2619
|
*/
|
|
1810
2620
|
const evaluateGrant = (payload) => {
|
|
1811
2621
|
if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
|
|
1812
|
-
const rawPath =
|
|
2622
|
+
const rawPath = storageRequestWildcard(c);
|
|
1813
2623
|
if (!rawPath) return "no-path";
|
|
1814
|
-
const
|
|
1815
|
-
if (
|
|
1816
|
-
return isPathMatch(`${bucket}/${
|
|
2624
|
+
const requested = tryRequestedStorageObject(rawPath);
|
|
2625
|
+
if (requested === null) return "deny-path";
|
|
2626
|
+
return isPathMatch(`${requested.bucket}/${requested.key}`, payload.path) ? "grant" : "deny-path";
|
|
1817
2627
|
};
|
|
1818
2628
|
/**
|
|
1819
2629
|
* Which half of the scope failed is named in the message. The holder of the
|
|
@@ -1873,15 +2683,20 @@ function createAdapterAuthMiddleware(options) {
|
|
|
1873
2683
|
store: apiKeyStore,
|
|
1874
2684
|
driver
|
|
1875
2685
|
});
|
|
1876
|
-
if (result
|
|
1877
|
-
|
|
2686
|
+
if (result !== true) return result;
|
|
2687
|
+
const refused = await applyImpersonation(c, {
|
|
2688
|
+
credential: "api-key",
|
|
2689
|
+
driver
|
|
2690
|
+
});
|
|
2691
|
+
if (refused) return refused;
|
|
2692
|
+
return next();
|
|
1878
2693
|
}
|
|
1879
2694
|
}
|
|
1880
2695
|
let authenticatedUser = null;
|
|
1881
2696
|
try {
|
|
1882
2697
|
authenticatedUser = await adapter.verifyRequest(c.req.raw);
|
|
1883
2698
|
} catch (error) {
|
|
1884
|
-
return refuse(c, ApiError.unauthenticated("Unauthorized"));
|
|
2699
|
+
return refuse(c, error instanceof ApiError && error.statusCode >= 400 ? error : ApiError.unauthenticated("Unauthorized"));
|
|
1885
2700
|
}
|
|
1886
2701
|
if (authenticatedUser) {
|
|
1887
2702
|
c.set("user", {
|
|
@@ -1894,7 +2709,8 @@ function createAdapterAuthMiddleware(options) {
|
|
|
1894
2709
|
c.set("driver", await scopeDataDriver(driver, {
|
|
1895
2710
|
uid: authenticatedUser.uid,
|
|
1896
2711
|
roles: authenticatedUser.roles,
|
|
1897
|
-
isAnonymous: authenticatedUser.isAnonymous === true
|
|
2712
|
+
isAnonymous: authenticatedUser.isAnonymous === true,
|
|
2713
|
+
...authenticatedUser.claims ? { claims: authenticatedUser.claims } : {}
|
|
1898
2714
|
}));
|
|
1899
2715
|
} catch (error) {
|
|
1900
2716
|
logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
|
|
@@ -1912,6 +2728,12 @@ function createAdapterAuthMiddleware(options) {
|
|
|
1912
2728
|
return refuse(c, ApiError.internal("Server configuration error"));
|
|
1913
2729
|
}
|
|
1914
2730
|
}
|
|
2731
|
+
const refused = await applyImpersonation(c, {
|
|
2732
|
+
credential: !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session",
|
|
2733
|
+
driver,
|
|
2734
|
+
resolveUser: adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0
|
|
2735
|
+
});
|
|
2736
|
+
if (refused) return refused;
|
|
1915
2737
|
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
2738
|
return next();
|
|
1917
2739
|
};
|
|
@@ -1962,6 +2784,7 @@ async function verifyPassword(password, storedHash) {
|
|
|
1962
2784
|
if (!saltHex || !hashHex) return false;
|
|
1963
2785
|
const salt = Buffer.from(saltHex, "hex");
|
|
1964
2786
|
const storedKey = Buffer.from(hashHex, "hex");
|
|
2787
|
+
if (storedKey.length !== KEY_LENGTH) return false;
|
|
1965
2788
|
return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS), storedKey);
|
|
1966
2789
|
}
|
|
1967
2790
|
//#endregion
|
|
@@ -2011,76 +2834,49 @@ async function verifyPassword(password, storedHash) {
|
|
|
2011
2834
|
* This is the single point where defaults are applied — all consumers
|
|
2012
2835
|
* call this once and use the resolved hooks throughout.
|
|
2013
2836
|
*/
|
|
2014
|
-
function resolveAuthHooks(hooks) {
|
|
2015
|
-
return {
|
|
2016
|
-
...hooks,
|
|
2017
|
-
hashPassword: hooks?.hashPassword ?? hashPassword,
|
|
2018
|
-
verifyPassword: hooks?.verifyPassword ?? verifyPassword,
|
|
2019
|
-
validatePasswordStrength: hooks?.validatePasswordStrength ?? validatePasswordStrength
|
|
2020
|
-
};
|
|
2021
|
-
}
|
|
2022
|
-
//#endregion
|
|
2023
|
-
//#region src/email/link-base.ts
|
|
2024
|
-
/**
|
|
2025
|
-
* The config fields consulted for each link kind, most specific first.
|
|
2026
|
-
*
|
|
2027
|
-
* `resetPasswordUrl` is the general fallback because it is the only one any boot
|
|
2028
|
-
* path sets, and in every real deployment all three point at the same frontend.
|
|
2029
|
-
*/
|
|
2030
|
-
var LINK_BASE_FIELDS = {
|
|
2031
|
-
resetPassword: ["resetPasswordUrl"],
|
|
2032
|
-
verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
|
|
2033
|
-
magicLink: ["magicLinkUrl", "resetPasswordUrl"]
|
|
2034
|
-
};
|
|
2035
2837
|
/**
|
|
2036
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
}
|
|
2066
|
-
return "";
|
|
2838
|
+
* What a hook's thrown error answers.
|
|
2839
|
+
*
|
|
2840
|
+
* The hooks that refuse (`beforeUserCreate`, `beforeLogin`,
|
|
2841
|
+
* `beforeUserDelete`, `beforeEmailChange`) are documented as "throw to reject", and a plain
|
|
2842
|
+
* `Error` is what people throw. It reached the error handler as a 500
|
|
2843
|
+
* "Internal Server Error", so a deployment that limits sign-ups to its own
|
|
2844
|
+
* domain answered an outsider with a server fault. A refusal is the caller's
|
|
2845
|
+
* answer: 400 `HOOK_REJECTED` with the hook's message — or the status an
|
|
2846
|
+
* error carries, when the hook chose one (`ApiError`, or any error with a 4xx
|
|
2847
|
+
* `status`/`statusCode`).
|
|
2848
|
+
*/
|
|
2849
|
+
function hookRefusal(error, hook) {
|
|
2850
|
+
if (error instanceof ApiError) return error;
|
|
2851
|
+
const carried = typeof error === "object" && error !== null ? "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : "status" in error && typeof error.status === "number" ? error.status : void 0 : void 0;
|
|
2852
|
+
const status = carried !== void 0 && carried >= 400 && carried < 500 ? carried : 400;
|
|
2853
|
+
const ownCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && /^[A-Z][A-Z0-9_]+$/.test(error.code) ? error.code : void 0;
|
|
2854
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : `${hook} refused the request`;
|
|
2855
|
+
if (status === 400 && !ownCode) return ApiError.badRequest(message, "HOOK_REJECTED", { hook });
|
|
2856
|
+
return new ApiError(status, ownCode ?? "HOOK_REJECTED", message, { hook });
|
|
2857
|
+
}
|
|
2858
|
+
/** A refusing hook whose throw answers as {@link hookRefusal} says. */
|
|
2859
|
+
function refusable(hook, fn) {
|
|
2860
|
+
if (!fn) return void 0;
|
|
2861
|
+
return async (...args) => {
|
|
2862
|
+
try {
|
|
2863
|
+
return await fn(...args);
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
throw hookRefusal(error, hook);
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2067
2868
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
"verifyEmailUrl",
|
|
2080
|
-
"magicLinkUrl"
|
|
2081
|
-
].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
|
|
2082
|
-
if (relative.length > 0) throw new Error(`Email is configured with a relative link base: ${relative.map((f) => `email.${f}`).join(", ")}. Emailed links must be absolute (e.g. "https://app.example.com") — a mail client has no base document to resolve a relative href against, so the link is dead.`);
|
|
2083
|
-
if (!resolveEmailLinkBase(config, "resetPassword") && !resolveEmailLinkBase(config, "verifyEmail")) throw new Error("Email is configured but no base URL for emailed links is set. Password-reset, verification and magic-link emails would carry relative hrefs, which are dead links in every mail client. Set FRONTEND_URL (or `auth.email.resetPasswordUrl`) to an absolute URL such as \"https://app.example.com\".");
|
|
2869
|
+
function resolveAuthHooks(hooks) {
|
|
2870
|
+
return {
|
|
2871
|
+
...hooks,
|
|
2872
|
+
beforeUserCreate: refusable("beforeUserCreate", hooks?.beforeUserCreate?.bind(hooks)),
|
|
2873
|
+
beforeLogin: refusable("beforeLogin", hooks?.beforeLogin?.bind(hooks)),
|
|
2874
|
+
beforeUserDelete: refusable("beforeUserDelete", hooks?.beforeUserDelete?.bind(hooks)),
|
|
2875
|
+
beforeEmailChange: refusable("beforeEmailChange", hooks?.beforeEmailChange?.bind(hooks)),
|
|
2876
|
+
hashPassword: hooks?.hashPassword ?? hashPassword,
|
|
2877
|
+
verifyPassword: hooks?.verifyPassword ?? verifyPassword,
|
|
2878
|
+
validatePasswordStrength: hooks?.validatePasswordStrength ?? validatePasswordStrength
|
|
2879
|
+
};
|
|
2084
2880
|
}
|
|
2085
2881
|
//#endregion
|
|
2086
2882
|
//#region src/email/smtp-email-service.ts
|
|
@@ -2136,16 +2932,32 @@ function normalizeMessageId(value) {
|
|
|
2136
2932
|
var SMTPEmailService = class {
|
|
2137
2933
|
transporter = null;
|
|
2138
2934
|
config;
|
|
2139
|
-
|
|
2935
|
+
/**
|
|
2936
|
+
* The transporter being built, shared by every send that arrives while it
|
|
2937
|
+
* is.
|
|
2938
|
+
*
|
|
2939
|
+
* A flag set before the `await` said "initialised" while nodemailer was
|
|
2940
|
+
* still loading, so a second send in that window found no transporter and
|
|
2941
|
+
* threw "Email service not configured" — the first burst of mail after a
|
|
2942
|
+
* boot lost every message but one. Every caller awaits this one promise
|
|
2943
|
+
* instead. Cleared on failure, so one bad moment is not remembered for the
|
|
2944
|
+
* life of the process.
|
|
2945
|
+
*/
|
|
2946
|
+
transporterReady = null;
|
|
2140
2947
|
constructor(config) {
|
|
2141
2948
|
this.config = config;
|
|
2142
2949
|
}
|
|
2143
2950
|
/**
|
|
2144
2951
|
* Lazily initialize the SMTP transporter on first use
|
|
2145
2952
|
*/
|
|
2146
|
-
|
|
2147
|
-
if (this.
|
|
2148
|
-
|
|
2953
|
+
ensureTransporter() {
|
|
2954
|
+
if (!this.transporterReady) this.transporterReady = this.buildTransporter().catch((error) => {
|
|
2955
|
+
this.transporterReady = null;
|
|
2956
|
+
throw error;
|
|
2957
|
+
});
|
|
2958
|
+
return this.transporterReady;
|
|
2959
|
+
}
|
|
2960
|
+
async buildTransporter() {
|
|
2149
2961
|
if (this.config.smtp) {
|
|
2150
2962
|
const nodemailer = await loadNodemailer();
|
|
2151
2963
|
let smtpName = this.config.smtp.name;
|
|
@@ -2422,13 +3234,42 @@ function warnIfProxiedButUntrusted(hasForwardedFor) {
|
|
|
2422
3234
|
logger.warn("[RateLimit] Requests carry X-Forwarded-For but TRUSTED_PROXY_HOPS is 0, so it is being ignored and every client behind the proxy shares one rate-limit bucket. If this server really is behind a reverse proxy, set TRUSTED_PROXY_HOPS to the number of proxies in front of it (1 for a single ingress or load balancer). If it is exposed directly, this warning means someone is sending the header themselves and 0 is the correct setting.");
|
|
2423
3235
|
}
|
|
2424
3236
|
/**
|
|
3237
|
+
* The store this deployment shares its counts in, when it has one.
|
|
3238
|
+
*
|
|
3239
|
+
* Module state, because the limiters that read it are module constants: the
|
|
3240
|
+
* auth limiters are built at import time, before `initializeRebaseBackend`
|
|
3241
|
+
* has read `REBASE_RATE_LIMIT_STORE` or `rateLimit.store`, so they cannot be
|
|
3242
|
+
* handed the store the way the data limiter is. Without this they each kept a
|
|
3243
|
+
* private memory store whatever the deployment asked for, and on N replicas
|
|
3244
|
+
* every login, reset, OTP and MFA limit was N times looser than configured.
|
|
3245
|
+
*/
|
|
3246
|
+
var sharedRateLimitStore;
|
|
3247
|
+
/**
|
|
3248
|
+
* Point every named limiter at the deployment's shared store — or, with
|
|
3249
|
+
* `undefined`, back at its own memory. Called once per boot with the store
|
|
3250
|
+
* the data limiters use, and only when that store is shared (SQL, or one the
|
|
3251
|
+
* operator supplied): the default memory store is swept on the data window,
|
|
3252
|
+
* which is not every limiter's window.
|
|
3253
|
+
*/
|
|
3254
|
+
function setSharedRateLimitStore(store) {
|
|
3255
|
+
sharedRateLimitStore = store;
|
|
3256
|
+
}
|
|
3257
|
+
/** The deployment's store under `name` when there is one, this limiter's own memory otherwise. */
|
|
3258
|
+
function namedStore(name, windowMs) {
|
|
3259
|
+
const local = new MemoryRateLimitStore(windowMs);
|
|
3260
|
+
return {
|
|
3261
|
+
hit: (key, window, limit) => sharedRateLimitStore ? sharedRateLimitStore.hit(`${name}:${key}`, window, limit) : local.hit(key, window, limit),
|
|
3262
|
+
dispose: () => local.dispose()
|
|
3263
|
+
};
|
|
3264
|
+
}
|
|
3265
|
+
/**
|
|
2425
3266
|
* Create a rate-limiting middleware.
|
|
2426
3267
|
*
|
|
2427
3268
|
* Uses a sliding window: only hits within the last `windowMs` are counted.
|
|
2428
3269
|
*/
|
|
2429
3270
|
function createRateLimiter(options = {}) {
|
|
2430
3271
|
const trustedProxyHops = resolveTrustedProxyHops(options.trustedProxyHops);
|
|
2431
|
-
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", store = new MemoryRateLimitStore(windowMs), resolveLimit } = options;
|
|
3272
|
+
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", name, store = name ? namedStore(name, windowMs) : new MemoryRateLimitStore(windowMs), resolveLimit } = options;
|
|
2432
3273
|
return async (c, next) => {
|
|
2433
3274
|
const effectiveLimit = resolveLimit ? await resolveLimit(c) : limit;
|
|
2434
3275
|
if (effectiveLimit === null) return next();
|
|
@@ -2489,27 +3330,48 @@ function socketAddress(c) {
|
|
|
2489
3330
|
* resort only, and it is a single shared bucket by design: better that
|
|
2490
3331
|
* anonymous callers throttle each other than that any of them throttles nobody.
|
|
2491
3332
|
*/
|
|
3333
|
+
/**
|
|
3334
|
+
* Where a request comes from, as the rate limiters judge it: the socket's own
|
|
3335
|
+
* address, or — only behind as many proxies as `TRUSTED_PROXY_HOPS` declares —
|
|
3336
|
+
* the address they report. What a session row records as its IP, so the
|
|
3337
|
+
* sessions list shows the same address the limiter counted, rather than the
|
|
3338
|
+
* raw `X-Forwarded-For` (whose leftmost entry the caller chooses) or
|
|
3339
|
+
* `"unknown"` for every direct connection.
|
|
3340
|
+
*/
|
|
3341
|
+
function requestClientAddress(c) {
|
|
3342
|
+
return defaultKeyGenerator(c);
|
|
3343
|
+
}
|
|
2492
3344
|
function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
3345
|
+
return clientAddress((name) => c.req.header(name), () => socketAddress(c), trustedProxyHops);
|
|
3346
|
+
}
|
|
3347
|
+
/**
|
|
3348
|
+
* {@link defaultKeyGenerator}'s rule, over a request's headers and connection
|
|
3349
|
+
* address rather than a Hono context — so a door that is not an HTTP request
|
|
3350
|
+
* (the realtime socket's upgrade request) reads the caller's address the same
|
|
3351
|
+
* way.
|
|
3352
|
+
*/
|
|
3353
|
+
function clientAddress(header, connectionAddress, trustedProxyHops) {
|
|
2493
3354
|
if (trustedProxyHops === 0) {
|
|
2494
|
-
warnIfProxiedButUntrusted(!!
|
|
2495
|
-
return
|
|
3355
|
+
warnIfProxiedButUntrusted(!!header("x-forwarded-for"));
|
|
3356
|
+
return connectionAddress() ?? "unknown";
|
|
2496
3357
|
}
|
|
2497
3358
|
{
|
|
2498
|
-
const forwardedFor =
|
|
3359
|
+
const forwardedFor = header("x-forwarded-for");
|
|
2499
3360
|
if (forwardedFor) {
|
|
2500
3361
|
const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2501
3362
|
if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
|
|
2502
3363
|
}
|
|
2503
|
-
const realIp =
|
|
3364
|
+
const realIp = header("x-real-ip");
|
|
2504
3365
|
if (realIp) return realIp;
|
|
2505
3366
|
}
|
|
2506
|
-
return
|
|
3367
|
+
return connectionAddress() ?? "unknown";
|
|
2507
3368
|
}
|
|
2508
3369
|
/**
|
|
2509
3370
|
* Pre-configured rate limiter for general auth endpoints (login, register).
|
|
2510
3371
|
* 200 requests per 15 minutes per IP.
|
|
2511
3372
|
*/
|
|
2512
3373
|
var defaultAuthLimiter = createRateLimiter({
|
|
3374
|
+
name: "auth-default",
|
|
2513
3375
|
windowMs: 900 * 1e3,
|
|
2514
3376
|
limit: 200,
|
|
2515
3377
|
message: "Too many authentication attempts, please try again later."
|
|
@@ -2519,6 +3381,7 @@ var defaultAuthLimiter = createRateLimiter({
|
|
|
2519
3381
|
* 50 requests per 15 minutes per IP.
|
|
2520
3382
|
*/
|
|
2521
3383
|
var strictAuthLimiter = createRateLimiter({
|
|
3384
|
+
name: "auth-strict",
|
|
2522
3385
|
windowMs: 900 * 1e3,
|
|
2523
3386
|
limit: 50,
|
|
2524
3387
|
message: "Too many requests to this sensitive endpoint, please try again later."
|
|
@@ -2577,6 +3440,7 @@ var captureRecipientEmail = async (c, next) => {
|
|
|
2577
3440
|
* the limiter is never silently a no-op.
|
|
2578
3441
|
*/
|
|
2579
3442
|
var recipientEmailLimiter = createRateLimiter({
|
|
3443
|
+
name: "auth-recipient-email",
|
|
2580
3444
|
windowMs: 900 * 1e3,
|
|
2581
3445
|
limit: 5,
|
|
2582
3446
|
keyGenerator: (c) => {
|
|
@@ -2592,6 +3456,7 @@ async function notBefore$1(startedAt, floorMs, result) {
|
|
|
2592
3456
|
return result;
|
|
2593
3457
|
}
|
|
2594
3458
|
var verificationEmailLimiter = createRateLimiter({
|
|
3459
|
+
name: "auth-verification-email",
|
|
2595
3460
|
windowMs: 900 * 1e3,
|
|
2596
3461
|
limit: 5,
|
|
2597
3462
|
keyGenerator: (c) => {
|
|
@@ -2632,8 +3497,9 @@ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
|
|
|
2632
3497
|
* `enabled: false` rather than pay for it twice.
|
|
2633
3498
|
*/
|
|
2634
3499
|
function createDataRateLimiter(config = {}) {
|
|
2635
|
-
const { windowMs = 900 * 1e3,
|
|
3500
|
+
const { windowMs = 900 * 1e3, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
2636
3501
|
const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
|
|
3502
|
+
const bucketFor = dataRateLimitBuckets(config);
|
|
2637
3503
|
/**
|
|
2638
3504
|
* Who this request is, for bucketing only.
|
|
2639
3505
|
*
|
|
@@ -2659,30 +3525,98 @@ function createDataRateLimiter(config = {}) {
|
|
|
2659
3525
|
if (user?.uid) return isAnonymousUid(user.uid) ? void 0 : user.uid;
|
|
2660
3526
|
if (!isJwtConfigured()) return void 0;
|
|
2661
3527
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
2662
|
-
if (token === void 0)
|
|
3528
|
+
if (token === void 0) {
|
|
3529
|
+
const queryToken = c.req.query("token");
|
|
3530
|
+
if (!queryToken) return void 0;
|
|
3531
|
+
const grant = await verifyDownloadToken(queryToken);
|
|
3532
|
+
return grant?.rl ? `file:${grant.rl}` : void 0;
|
|
3533
|
+
}
|
|
2663
3534
|
const payload = await verifyAccessToken(token);
|
|
2664
3535
|
if (!payload?.uid || isAnonymousUid(payload.uid)) return void 0;
|
|
2665
3536
|
return payload.uid;
|
|
2666
3537
|
};
|
|
3538
|
+
const isServiceKey = (c) => {
|
|
3539
|
+
if (!config.serviceKey) return false;
|
|
3540
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
3541
|
+
return token !== void 0 && safeCompare(token, config.serviceKey);
|
|
3542
|
+
};
|
|
3543
|
+
const bucketOf = async (c) => {
|
|
3544
|
+
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
3545
|
+
if (isServiceKey(c)) return null;
|
|
3546
|
+
const apiKey = c.get("apiKey");
|
|
3547
|
+
return bucketFor({
|
|
3548
|
+
apiKey,
|
|
3549
|
+
uid: apiKey ? void 0 : await identify(c),
|
|
3550
|
+
address: () => defaultKeyGenerator(c, trustedProxyHops)
|
|
3551
|
+
});
|
|
3552
|
+
};
|
|
2667
3553
|
return createRateLimiter({
|
|
2668
3554
|
windowMs,
|
|
2669
3555
|
store,
|
|
2670
3556
|
message: "Too many requests, please try again later.",
|
|
2671
|
-
keyGenerator: async (c) => {
|
|
2672
|
-
|
|
2673
|
-
if (key) return `api-key:${key.id}`;
|
|
2674
|
-
const uid = await identify(c);
|
|
2675
|
-
if (uid) return `user:${uid}`;
|
|
2676
|
-
return `ip:${defaultKeyGenerator(c, trustedProxyHops)}`;
|
|
2677
|
-
},
|
|
2678
|
-
resolveLimit: async (c) => {
|
|
2679
|
-
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
2680
|
-
const key = c.get("apiKey");
|
|
2681
|
-
if (key) return key.rate_limit ?? apiKeyLimit;
|
|
2682
|
-
return await identify(c) ? userLimit : anonLimit;
|
|
2683
|
-
}
|
|
3557
|
+
keyGenerator: async (c) => (await bucketOf(c))?.key ?? `ip:${defaultKeyGenerator(c, trustedProxyHops)}`,
|
|
3558
|
+
resolveLimit: async (c) => (await bucketOf(c))?.limit ?? null
|
|
2684
3559
|
});
|
|
2685
3560
|
}
|
|
3561
|
+
/**
|
|
3562
|
+
* The data API's per-caller limit, as a check rather than a middleware — for
|
|
3563
|
+
* the door into the same data that is not an HTTP request.
|
|
3564
|
+
*
|
|
3565
|
+
* The realtime socket serves the rows the data API serves, one frame per
|
|
3566
|
+
* request. It counted them per connection only, so a caller bought more budget
|
|
3567
|
+
* by opening more sockets, and a frame never met the per-person limit its HTTP
|
|
3568
|
+
* twin does. Built from the same config and store as
|
|
3569
|
+
* {@link createDataRateLimiter}, it puts a caller in the same bucket under the
|
|
3570
|
+
* same key: one person's allowance, spent through either door.
|
|
3571
|
+
*/
|
|
3572
|
+
function createDataRateLimitCheck(config = {}) {
|
|
3573
|
+
const { windowMs = 900 * 1e3, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
3574
|
+
const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
|
|
3575
|
+
const bucketFor = dataRateLimitBuckets(config);
|
|
3576
|
+
return async (caller) => {
|
|
3577
|
+
if (caller.uid === SERVICE_IDENTITY.uid) return null;
|
|
3578
|
+
const bucket = bucketFor({
|
|
3579
|
+
apiKey: caller.apiKey,
|
|
3580
|
+
uid: caller.uid !== void 0 && !isAnonymousUid(caller.uid) ? caller.uid : void 0,
|
|
3581
|
+
address: () => clientAddress(caller.header, () => caller.socketAddress, trustedProxyHops)
|
|
3582
|
+
});
|
|
3583
|
+
return bucket ? store.hit(bucket.key, windowMs, bucket.limit) : null;
|
|
3584
|
+
};
|
|
3585
|
+
}
|
|
3586
|
+
/**
|
|
3587
|
+
* Which bucket a data request counts in — the rule both doors share.
|
|
3588
|
+
*
|
|
3589
|
+
* Most specific first: an API key by its id, at its own `rate_limit`; a
|
|
3590
|
+
* signed-in user by their uid; anyone else by address at the anonymous
|
|
3591
|
+
* allowance, or not at all when that is `null`.
|
|
3592
|
+
*
|
|
3593
|
+
* The service key never reaches here: it is not a caller to bound. It is the
|
|
3594
|
+
* deployment's own credential and already reaches every row through
|
|
3595
|
+
* `SERVICE_IDENTITY`, so a limit on it protects nothing that is not already
|
|
3596
|
+
* open to whoever holds it — while reliably stopping the backfills,
|
|
3597
|
+
* migrations, imports and server-to-server jobs the key exists for, which
|
|
3598
|
+
* arrive as thousands of legitimate requests. A managed deployment has no
|
|
3599
|
+
* `rateLimit` surface to raise. Each door recognises it itself, where its
|
|
3600
|
+
* identity is known.
|
|
3601
|
+
*/
|
|
3602
|
+
function dataRateLimitBuckets(config) {
|
|
3603
|
+
const { apiKey: apiKeyLimit = 1e3, user: userLimit = 1e3, anonymous: anonLimit = 300 } = config;
|
|
3604
|
+
return (who) => {
|
|
3605
|
+
if (who.apiKey) return {
|
|
3606
|
+
key: `api-key:${who.apiKey.id}`,
|
|
3607
|
+
limit: who.apiKey.rate_limit ?? apiKeyLimit
|
|
3608
|
+
};
|
|
3609
|
+
if (who.uid) return {
|
|
3610
|
+
key: `user:${who.uid}`,
|
|
3611
|
+
limit: userLimit
|
|
3612
|
+
};
|
|
3613
|
+
if (anonLimit === null) return null;
|
|
3614
|
+
return {
|
|
3615
|
+
key: `ip:${who.address()}`,
|
|
3616
|
+
limit: anonLimit
|
|
3617
|
+
};
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
2686
3620
|
//#endregion
|
|
2687
3621
|
//#region src/auth/captcha.ts
|
|
2688
3622
|
/**
|
|
@@ -3041,7 +3975,8 @@ function keyCandidates() {
|
|
|
3041
3975
|
add(process.env.MFA_ENCRYPTION_KEY, "MFA_ENCRYPTION_KEY");
|
|
3042
3976
|
for (const previous of (process.env.MFA_ENCRYPTION_KEY_PREVIOUS ?? "").split(",")) add(previous, "MFA_ENCRYPTION_KEY_PREVIOUS");
|
|
3043
3977
|
add(process.env.JWT_SECRET, "JWT_SECRET");
|
|
3044
|
-
|
|
3978
|
+
add(configuredJwtSecret(), "auth.jwtSecret");
|
|
3979
|
+
if (candidates.length === 0) throw new Error("Cannot encrypt or decrypt TOTP secrets: none of MFA_ENCRYPTION_KEY, MFA_ENCRYPTION_KEY_PREVIOUS, JWT_SECRET or auth.jwtSecret is configured.");
|
|
3045
3980
|
if (!process.env.MFA_ENCRYPTION_KEY && !warnedAboutFallback) {
|
|
3046
3981
|
warnedAboutFallback = true;
|
|
3047
3982
|
logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production; see the rotation steps in mfa-crypto.ts before you do it on a live deployment.");
|
|
@@ -3145,6 +4080,13 @@ function clearRefreshCookie(c, config) {
|
|
|
3145
4080
|
}
|
|
3146
4081
|
/**
|
|
3147
4082
|
* Read the refresh token from the request — cookie first, then body fallback.
|
|
4083
|
+
*
|
|
4084
|
+
* An empty string is no token, wherever it comes from. In cookie mode every
|
|
4085
|
+
* response this server sends carries `refreshToken: ""` (see
|
|
4086
|
+
* `redactRefreshToken`), and a client that echoes it back — `@rebasepro/client`
|
|
4087
|
+
* did, on every refresh from a live tab — is presenting nothing. Taking the
|
|
4088
|
+
* body's `""` as the token, or refusing it at the schema, kept the cookie
|
|
4089
|
+
* beside it from ever being read.
|
|
3148
4090
|
*/
|
|
3149
4091
|
function readRefreshToken(c, body, config) {
|
|
3150
4092
|
if (config) {
|
|
@@ -3154,10 +4096,13 @@ function readRefreshToken(c, body, config) {
|
|
|
3154
4096
|
const cookies = cookieHeader.split(";");
|
|
3155
4097
|
for (const part of cookies) {
|
|
3156
4098
|
const trimmed = part.trim();
|
|
3157
|
-
if (trimmed.startsWith(prefix))
|
|
4099
|
+
if (trimmed.startsWith(prefix)) {
|
|
4100
|
+
const fromCookie = decodeURIComponent(trimmed.slice(prefix.length));
|
|
4101
|
+
if (fromCookie) return fromCookie;
|
|
4102
|
+
}
|
|
3158
4103
|
}
|
|
3159
4104
|
}
|
|
3160
|
-
return body.refreshToken;
|
|
4105
|
+
return body.refreshToken || void 0;
|
|
3161
4106
|
}
|
|
3162
4107
|
/**
|
|
3163
4108
|
* Redact the refresh token from the response body when cookie mode is active.
|
|
@@ -3226,7 +4171,8 @@ async function resolveStepUpPrincipal(c) {
|
|
|
3226
4171
|
if (existing?.uid) return {
|
|
3227
4172
|
uid: existing.uid,
|
|
3228
4173
|
aal: existing.aal === "aal2" ? "aal2" : "aal1",
|
|
3229
|
-
pending: false
|
|
4174
|
+
pending: false,
|
|
4175
|
+
iat: existing.iat
|
|
3230
4176
|
};
|
|
3231
4177
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
3232
4178
|
if (token === void 0) return null;
|
|
@@ -3234,13 +4180,15 @@ async function resolveStepUpPrincipal(c) {
|
|
|
3234
4180
|
if (session) return {
|
|
3235
4181
|
uid: session.uid,
|
|
3236
4182
|
aal: session.aal === "aal2" ? "aal2" : "aal1",
|
|
3237
|
-
pending: false
|
|
4183
|
+
pending: false,
|
|
4184
|
+
iat: session.iat
|
|
3238
4185
|
};
|
|
3239
4186
|
const pending = await verifyMfaPendingToken(token);
|
|
3240
4187
|
if (pending) return {
|
|
3241
4188
|
uid: pending.uid,
|
|
3242
4189
|
aal: "aal1",
|
|
3243
|
-
pending: true
|
|
4190
|
+
pending: true,
|
|
4191
|
+
iat: pending.iat
|
|
3244
4192
|
};
|
|
3245
4193
|
return null;
|
|
3246
4194
|
}
|
|
@@ -3253,6 +4201,7 @@ async function resolveStepUpPrincipal(c) {
|
|
|
3253
4201
|
* front of this one — they bound different things.
|
|
3254
4202
|
*/
|
|
3255
4203
|
var mfaVerificationLimiter = createRateLimiter({
|
|
4204
|
+
name: "auth-mfa-verify",
|
|
3256
4205
|
windowMs: 900 * 1e3,
|
|
3257
4206
|
limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
|
|
3258
4207
|
keyGenerator: async (c) => `mfa-verify:${(await resolveStepUpPrincipal(c))?.uid ?? "unidentified"}`,
|
|
@@ -3263,6 +4212,26 @@ function mountMfaRoutes(opts) {
|
|
|
3263
4212
|
const authRepo = config.authRepo;
|
|
3264
4213
|
const emailConfig = config.emailConfig;
|
|
3265
4214
|
/**
|
|
4215
|
+
* The caller of a challenge route, refused if the user has since ended
|
|
4216
|
+
* every session.
|
|
4217
|
+
*
|
|
4218
|
+
* These two routes resolve their own caller, because a sign-in that
|
|
4219
|
+
* answered `MFA_REQUIRED` has no session yet, and so they skipped the
|
|
4220
|
+
* revocation mark that `requireLiveSession` checks everywhere else. A
|
|
4221
|
+
* stolen access token that had enrolled its own factor (allowed at `aal1`
|
|
4222
|
+
* while the account had none) kept working here after the victim signed
|
|
4223
|
+
* out everywhere or changed their password, and the verify route then
|
|
4224
|
+
* minted it a new session dated after the mark, which the mark could not
|
|
4225
|
+
* reach. The pending token is a first factor accepted at its `iat`, so it
|
|
4226
|
+
* is held to the same mark.
|
|
4227
|
+
*/
|
|
4228
|
+
async function resolveLiveStepUpPrincipal(c) {
|
|
4229
|
+
const principal = await resolveStepUpPrincipal(c);
|
|
4230
|
+
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
4231
|
+
if (!(await judgeAccessToken(authRepo, principal)).live) throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
4232
|
+
return principal;
|
|
4233
|
+
}
|
|
4234
|
+
/**
|
|
3266
4235
|
* Changing which factors an account trusts is itself a privileged act.
|
|
3267
4236
|
*
|
|
3268
4237
|
* An account that already has a verified factor may only gain or confirm
|
|
@@ -3312,9 +4281,11 @@ function mountMfaRoutes(opts) {
|
|
|
3312
4281
|
const { secret, uri } = generateTotpSecret(issuer, user.email);
|
|
3313
4282
|
const encryptedSecret = encryptTotpSecret(secret);
|
|
3314
4283
|
const factor = await authRepo.createMfaFactor(user.id, "totp", encryptedSecret, friendlyName);
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
4284
|
+
let codes = null;
|
|
4285
|
+
if (!await authRepo.hasVerifiedMfaFactors(user.id)) {
|
|
4286
|
+
codes = generateRecoveryCodes(10);
|
|
4287
|
+
await authRepo.createRecoveryCodes(user.id, codes.map(hashRecoveryCode));
|
|
4288
|
+
}
|
|
3318
4289
|
return c.json({
|
|
3319
4290
|
factor: {
|
|
3320
4291
|
id: factor.id,
|
|
@@ -3330,6 +4301,24 @@ function mountMfaRoutes(opts) {
|
|
|
3330
4301
|
}, 201);
|
|
3331
4302
|
});
|
|
3332
4303
|
/**
|
|
4304
|
+
* POST /auth/mfa/recovery-codes
|
|
4305
|
+
* Replace the account's recovery codes with ten new ones, shown once.
|
|
4306
|
+
*
|
|
4307
|
+
* The deliberate way to get new codes — after using several, or after
|
|
4308
|
+
* losing the printout. Needs `aal2` once a factor is verified, as every
|
|
4309
|
+
* factor change does: whoever holds only the password must not be able to
|
|
4310
|
+
* swap the codes for ones they know.
|
|
4311
|
+
*/
|
|
4312
|
+
router.post("/mfa/recovery-codes", strictAuthLimiter, requireLiveSession, async (c) => {
|
|
4313
|
+
const userCtx = c.get("user");
|
|
4314
|
+
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4315
|
+
if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) throw ApiError.badRequest("This account has no verified second factor, so recovery codes would recover nothing.", "MFA_NOT_ENROLLED");
|
|
4316
|
+
await requireStepUpForFactorChange(userCtx);
|
|
4317
|
+
const codes = generateRecoveryCodes(10);
|
|
4318
|
+
await authRepo.createRecoveryCodes(userCtx.uid, codes.map(hashRecoveryCode));
|
|
4319
|
+
return c.json({ recoveryCodes: codes });
|
|
4320
|
+
});
|
|
4321
|
+
/**
|
|
3333
4322
|
* POST /auth/mfa/verify
|
|
3334
4323
|
* Verify TOTP code to complete MFA enrollment
|
|
3335
4324
|
*/
|
|
@@ -3359,14 +4348,13 @@ function mountMfaRoutes(opts) {
|
|
|
3359
4348
|
* `MFA_REQUIRED`, or to step an existing session up to `aal2`.
|
|
3360
4349
|
*/
|
|
3361
4350
|
router.post("/mfa/challenge", strictAuthLimiter, async (c) => {
|
|
3362
|
-
const principal = await
|
|
3363
|
-
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
4351
|
+
const principal = await resolveLiveStepUpPrincipal(c);
|
|
3364
4352
|
const challengeSchema = z.object({ factorId: z.string().min(1, "Factor ID is required") });
|
|
3365
4353
|
const { factorId } = parseBody(challengeSchema, await c.req.json());
|
|
3366
4354
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
3367
4355
|
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
3368
4356
|
if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
|
|
3369
|
-
const ipAddress = c
|
|
4357
|
+
const ipAddress = requestClientAddress(c);
|
|
3370
4358
|
const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
|
|
3371
4359
|
return c.json({
|
|
3372
4360
|
challengeId: challenge.id,
|
|
@@ -3380,8 +4368,7 @@ function mountMfaRoutes(opts) {
|
|
|
3380
4368
|
* minted, at `aal2`.
|
|
3381
4369
|
*/
|
|
3382
4370
|
router.post("/mfa/challenge/verify", strictAuthLimiter, mfaVerificationLimiter, async (c) => {
|
|
3383
|
-
const principal = await
|
|
3384
|
-
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
4371
|
+
const principal = await resolveLiveStepUpPrincipal(c);
|
|
3385
4372
|
const challengeVerifySchema = z.object({
|
|
3386
4373
|
challengeId: z.string().min(1, "Challenge ID is required"),
|
|
3387
4374
|
code: z.string().min(1, "Code is required")
|
|
@@ -3391,7 +4378,8 @@ function mountMfaRoutes(opts) {
|
|
|
3391
4378
|
if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
|
|
3392
4379
|
const factor = await authRepo.getMfaFactorById(challenge.factorId);
|
|
3393
4380
|
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
3394
|
-
|
|
4381
|
+
const attempts = (challenge.attempts ?? 0) >= MAX_CHALLENGE_ATTEMPTS ? 6 : typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
|
|
4382
|
+
if (attempts !== void 0 && attempts > MAX_CHALLENGE_ATTEMPTS) {
|
|
3395
4383
|
logger.warn("[Security Audit] MFA challenge attempt limit reached", {
|
|
3396
4384
|
eventType: "auth.mfa.challenge.exhausted",
|
|
3397
4385
|
uid: principal.uid,
|
|
@@ -3406,7 +4394,6 @@ function mountMfaRoutes(opts) {
|
|
|
3406
4394
|
isValid = await authRepo.useRecoveryCode(principal.uid, codeHash);
|
|
3407
4395
|
}
|
|
3408
4396
|
if (!isValid) {
|
|
3409
|
-
const attempts = typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
|
|
3410
4397
|
logger.warn("[Security Audit] MFA verification failed", {
|
|
3411
4398
|
eventType: "auth.mfa.verify.failure",
|
|
3412
4399
|
uid: principal.uid,
|
|
@@ -3416,10 +4403,17 @@ function mountMfaRoutes(opts) {
|
|
|
3416
4403
|
throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
3417
4404
|
}
|
|
3418
4405
|
await authRepo.verifyMfaChallenge(challengeId);
|
|
3419
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c
|
|
4406
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", requestClientAddress(c), {
|
|
3420
4407
|
skipMfaGate: true,
|
|
3421
|
-
aal: "aal2"
|
|
4408
|
+
aal: "aal2",
|
|
4409
|
+
method: "mfa"
|
|
3422
4410
|
});
|
|
4411
|
+
if (ops.onAuthenticated) {
|
|
4412
|
+
const account = await authRepo.getUserById(principal.uid);
|
|
4413
|
+
if (account) ops.onAuthenticated(account, "mfa").catch((err) => {
|
|
4414
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4415
|
+
});
|
|
4416
|
+
}
|
|
3423
4417
|
if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
|
|
3424
4418
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
3425
4419
|
});
|
|
@@ -3595,9 +4589,233 @@ function isAnonymousAuthOpen(policy) {
|
|
|
3595
4589
|
return !!policy.allowAnonymous;
|
|
3596
4590
|
}
|
|
3597
4591
|
//#endregion
|
|
4592
|
+
//#region src/auth/address-ownership.ts
|
|
4593
|
+
/**
|
|
4594
|
+
* What happens when the owner of an address proves it for the first time.
|
|
4595
|
+
*
|
|
4596
|
+
* Nothing verifies the address an account is made with: `POST /auth/register`
|
|
4597
|
+
* takes whatever it is given, and so does a sign-in through a provider that
|
|
4598
|
+
* does not vouch for the address (Spotify and Facebook never do; Discord and
|
|
4599
|
+
* multi-tenant Microsoft sometimes do not). So anyone can make an account for
|
|
4600
|
+
* someone else's address and leave a way in on it — a password, or the
|
|
4601
|
+
* provider identity they signed up with — then wait for the owner.
|
|
4602
|
+
*
|
|
4603
|
+
* The owner turns up by proving the address: a magic link, an email code or a
|
|
4604
|
+
* password reset, each read out of their inbox. That proof used to mark the
|
|
4605
|
+
* account verified and leave the attacker's way in where it was, so the owner
|
|
4606
|
+
* and the attacker shared the account from then on. Now the proof also removes
|
|
4607
|
+
* everything on the account that nobody proved: the password, every identity
|
|
4608
|
+
* whose provider did not vouch for this address, and every session.
|
|
4609
|
+
*
|
|
4610
|
+
* @module
|
|
4611
|
+
*/
|
|
4612
|
+
/**
|
|
4613
|
+
* The profile stored with a linked OAuth identity.
|
|
4614
|
+
*
|
|
4615
|
+
* `emailVerified` is recorded so that a later proof of the address can tell an
|
|
4616
|
+
* identity whose provider vouched for it from one whose provider did not.
|
|
4617
|
+
*/
|
|
4618
|
+
function identityProfileData(profile) {
|
|
4619
|
+
return {
|
|
4620
|
+
email: profile.email,
|
|
4621
|
+
emailVerified: profile.emailVerified === true
|
|
4622
|
+
};
|
|
4623
|
+
}
|
|
4624
|
+
/**
|
|
4625
|
+
* Did this identity's provider vouch for `email` when it was linked?
|
|
4626
|
+
*
|
|
4627
|
+
* Only whoever controls the inbox can hold a provider account that vouches for
|
|
4628
|
+
* the address, so such an identity is the owner's. An identity linked before
|
|
4629
|
+
* the flag was recorded reads as not vouching: it is detached, and if its
|
|
4630
|
+
* provider does vouch, the next sign-in with it links it again.
|
|
4631
|
+
*/
|
|
4632
|
+
function identityVouchesForAddress(identity, email) {
|
|
4633
|
+
const profile = identity.profileData;
|
|
4634
|
+
return profile?.emailVerified === true && typeof profile.email === "string" && normalizeEmail(profile.email) === normalizeEmail(email);
|
|
4635
|
+
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Settle an account whose address its owner has just proven, and mark it
|
|
4638
|
+
* verified.
|
|
4639
|
+
*
|
|
4640
|
+
* Call it only for an account that is not yet verified, from a route that has
|
|
4641
|
+
* just consumed something mailed to the address. It detaches every identity
|
|
4642
|
+
* whose provider did not vouch for the address, sets the password to
|
|
4643
|
+
* `passwordHash` — the one the owner just chose on a reset, or `null` to remove
|
|
4644
|
+
* the one somebody else may have registered with — and ends every session, in
|
|
4645
|
+
* that order and before the account is marked verified.
|
|
4646
|
+
*
|
|
4647
|
+
* A repository that cannot detach an identity makes this refuse with 409 when
|
|
4648
|
+
* there is one to detach. Marking the account verified with the identity still
|
|
4649
|
+
* on it is the takeover itself, and leaving the account unverified while
|
|
4650
|
+
* signing the owner in would share the account all the same.
|
|
4651
|
+
*/
|
|
4652
|
+
async function confirmAddressOwnership(authRepo, user, passwordHash, options) {
|
|
4653
|
+
if (options?.everythingProven) {
|
|
4654
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
4655
|
+
return {
|
|
4656
|
+
removedPassword: false,
|
|
4657
|
+
removedProviders: []
|
|
4658
|
+
};
|
|
4659
|
+
}
|
|
4660
|
+
const unproven = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email));
|
|
4661
|
+
if (unproven.length > 0) {
|
|
4662
|
+
if (typeof authRepo.unlinkUserIdentity !== "function") {
|
|
4663
|
+
logger.error("[Security Audit] Proof of address refused: the account has sign-in methods nobody proved, and this auth repository cannot remove them", {
|
|
4664
|
+
eventType: "auth.address_proof.refused",
|
|
4665
|
+
uid: user.id,
|
|
4666
|
+
providers: unproven.map((identity) => identity.provider)
|
|
4667
|
+
});
|
|
4668
|
+
throw ApiError.conflict("This account has sign-in methods that were added before its email address was verified, and this backend cannot remove them. Ask an administrator to review the account.", "UNVERIFIED_IDENTITIES");
|
|
4669
|
+
}
|
|
4670
|
+
for (const identity of unproven) await authRepo.unlinkUserIdentity(user.id, identity.provider, identity.providerId);
|
|
4671
|
+
}
|
|
4672
|
+
await replaceUserPassword(authRepo, user.id, passwordHash);
|
|
4673
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
4674
|
+
const outcome = {
|
|
4675
|
+
removedPassword: Boolean(user.passwordHash) && passwordHash === null,
|
|
4676
|
+
removedProviders: unproven.map((identity) => identity.provider)
|
|
4677
|
+
};
|
|
4678
|
+
if (unproven.length > 0 || user.passwordHash) logger.info("[Security Audit] First proof of address removed sign-in methods nobody proved", {
|
|
4679
|
+
eventType: "auth.address_proof.cleared",
|
|
4680
|
+
uid: user.id,
|
|
4681
|
+
removedProviders: outcome.removedProviders,
|
|
4682
|
+
replacedPassword: Boolean(user.passwordHash) && passwordHash !== user.passwordHash
|
|
4683
|
+
});
|
|
4684
|
+
return outcome;
|
|
4685
|
+
}
|
|
4686
|
+
//#endregion
|
|
4687
|
+
//#region src/auth/deliverable-address.ts
|
|
4688
|
+
/**
|
|
4689
|
+
* Addresses no mail can reach: the synthetic ones a guest and an X (Twitter)
|
|
4690
|
+
* account are given, because `email` is NOT NULL. Nothing is mailed to them,
|
|
4691
|
+
* and no account may move onto one.
|
|
4692
|
+
*/
|
|
4693
|
+
function isDeliverableAddress(email) {
|
|
4694
|
+
const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
|
|
4695
|
+
return domain !== "anonymous.local" && domain !== "twitter.placeholder.rebase";
|
|
4696
|
+
}
|
|
4697
|
+
//#endregion
|
|
4698
|
+
//#region src/auth/email-change-routes.ts
|
|
4699
|
+
/** How long an address-change link stays usable. */
|
|
4700
|
+
var EMAIL_CHANGE_TTL_MS = 1440 * 60 * 1e3;
|
|
4701
|
+
function mountEmailChangeRoutes(opts) {
|
|
4702
|
+
const { router, config, ops, parseBody, requireLiveSession } = opts;
|
|
4703
|
+
const authRepo = config.authRepo;
|
|
4704
|
+
const { emailService, emailConfig } = config;
|
|
4705
|
+
const changeSchema = z.object({ newEmail: z.string().email("Invalid email address").max(255) });
|
|
4706
|
+
const confirmSchema = z.object({ token: z.string().min(1, "Token is required") });
|
|
4707
|
+
/** The repository's address-change methods, or a 501 saying it has none. */
|
|
4708
|
+
function emailChangeStore() {
|
|
4709
|
+
const { setPendingEmailChange, findPendingEmailChange, applyPendingEmailChange } = authRepo;
|
|
4710
|
+
if (!setPendingEmailChange || !findPendingEmailChange || !applyPendingEmailChange) throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot change an account's email address.");
|
|
4711
|
+
return {
|
|
4712
|
+
setPendingEmailChange: setPendingEmailChange.bind(authRepo),
|
|
4713
|
+
findPendingEmailChange: findPendingEmailChange.bind(authRepo),
|
|
4714
|
+
applyPendingEmailChange: applyPendingEmailChange.bind(authRepo)
|
|
4715
|
+
};
|
|
4716
|
+
}
|
|
4717
|
+
/**
|
|
4718
|
+
* POST /auth/change-email { newEmail }
|
|
4719
|
+
*
|
|
4720
|
+
* Two limiters, as on `/send-verification`: per address of the caller's
|
|
4721
|
+
* machine, and per account, which is what bounds the mail one account can
|
|
4722
|
+
* have sent to addresses of its choosing.
|
|
4723
|
+
*/
|
|
4724
|
+
router.post("/change-email", strictAuthLimiter, requireLiveSession, verificationEmailLimiter, async (c) => {
|
|
4725
|
+
const userCtx = c.get("user");
|
|
4726
|
+
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4727
|
+
const { newEmail } = parseBody(changeSchema, await c.req.json());
|
|
4728
|
+
const store = emailChangeStore();
|
|
4729
|
+
if (!emailService?.isConfigured()) throw ApiError.serviceUnavailable("Email service not configured. The confirmation link cannot be sent.", "EMAIL_NOT_CONFIGURED");
|
|
4730
|
+
const user = await authRepo.getUserById(userCtx.uid);
|
|
4731
|
+
if (!user) throw ApiError.notFound("User not found");
|
|
4732
|
+
if (user.isAnonymous) throw ApiError.forbidden("A guest has no address to change. Make it an account first (POST /auth/anonymous/link).", "ANONYMOUS_USER");
|
|
4733
|
+
if (userCtx.aal !== "aal2" && await authRepo.hasVerifiedMfaFactors(user.id)) throw ApiError.forbidden("This account has a second factor. Sign in with it before changing the email address.", "AAL2_REQUIRED");
|
|
4734
|
+
const email = normalizeEmail(newEmail);
|
|
4735
|
+
if (email === normalizeEmail(user.email)) throw ApiError.badRequest("That is already this account's email address.", "EMAIL_UNCHANGED");
|
|
4736
|
+
if (!isDeliverableAddress(email)) throw ApiError.conflict("No mail reaches that address.", "UNDELIVERABLE_ADDRESS");
|
|
4737
|
+
if (await authRepo.getUserByEmail(email)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
4738
|
+
if (ops.beforeEmailChange) await ops.beforeEmailChange(user, email);
|
|
4739
|
+
const token = generateSecureToken();
|
|
4740
|
+
await store.setPendingEmailChange(user.id, {
|
|
4741
|
+
email,
|
|
4742
|
+
tokenHash: hashToken(token)
|
|
4743
|
+
});
|
|
4744
|
+
const confirmUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/confirm-email-change?token=${token}`;
|
|
4745
|
+
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4746
|
+
const recipient = {
|
|
4747
|
+
email: user.email,
|
|
4748
|
+
displayName: user.displayName
|
|
4749
|
+
};
|
|
4750
|
+
const confirmation = emailConfig?.templates?.emailChange ? emailConfig.templates.emailChange(confirmUrl, recipient, email) : getEmailChangeTemplate(confirmUrl, recipient, email, appName, logoUrl);
|
|
4751
|
+
await emailService.send({
|
|
4752
|
+
to: email,
|
|
4753
|
+
subject: confirmation.subject,
|
|
4754
|
+
html: confirmation.html,
|
|
4755
|
+
text: confirmation.text
|
|
4756
|
+
});
|
|
4757
|
+
if (isDeliverableAddress(user.email)) {
|
|
4758
|
+
const notice = emailConfig?.templates?.emailChangeNotice ? emailConfig.templates.emailChangeNotice(recipient, email) : getEmailChangeNoticeTemplate(recipient, email, appName, logoUrl);
|
|
4759
|
+
emailService.send({
|
|
4760
|
+
to: user.email,
|
|
4761
|
+
subject: notice.subject,
|
|
4762
|
+
html: notice.html,
|
|
4763
|
+
text: notice.text
|
|
4764
|
+
}).catch((err) => {
|
|
4765
|
+
logger.error("Failed to send the email change notice", { error: err instanceof Error ? err.message : err });
|
|
4766
|
+
});
|
|
4767
|
+
}
|
|
4768
|
+
logger.info("[Security Audit] Email change requested", {
|
|
4769
|
+
eventType: "auth.email_change.requested",
|
|
4770
|
+
uid: user.id
|
|
4771
|
+
});
|
|
4772
|
+
return c.json({
|
|
4773
|
+
success: true,
|
|
4774
|
+
pendingEmail: email,
|
|
4775
|
+
expiresAt: new Date(Date.now() + EMAIL_CHANGE_TTL_MS).toISOString()
|
|
4776
|
+
});
|
|
4777
|
+
});
|
|
4778
|
+
/**
|
|
4779
|
+
* POST /auth/confirm-email-change { token }
|
|
4780
|
+
*
|
|
4781
|
+
* No session: the link is often opened on another device, and it proves
|
|
4782
|
+
* the new inbox, which is all this needs.
|
|
4783
|
+
*/
|
|
4784
|
+
router.post("/confirm-email-change", strictAuthLimiter, async (c) => {
|
|
4785
|
+
const { token } = parseBody(confirmSchema, await c.req.json());
|
|
4786
|
+
const store = emailChangeStore();
|
|
4787
|
+
const tokenHash = hashToken(token);
|
|
4788
|
+
const invalid = () => ApiError.badRequest("Invalid or expired email change link", "INVALID_TOKEN");
|
|
4789
|
+
const found = await store.findPendingEmailChange(tokenHash);
|
|
4790
|
+
if (!found || Date.now() - found.change.sentAt.getTime() > 864e5) throw invalid();
|
|
4791
|
+
const { user } = found;
|
|
4792
|
+
const previousEmail = user.email;
|
|
4793
|
+
const vouchingForOld = (await authRepo.getUserIdentities(user.id)).filter((identity) => identityVouchesForAddress(identity, previousEmail));
|
|
4794
|
+
if (vouchingForOld.length > 0 && typeof authRepo.unlinkUserIdentity !== "function") throw ApiError.conflict("This account signs in through a provider that vouched for its current address, and this backend cannot detach it. Ask an administrator to change the address.", "UNVERIFIED_IDENTITIES");
|
|
4795
|
+
const updated = await store.applyPendingEmailChange(user.id, tokenHash);
|
|
4796
|
+
if (!updated) throw invalid();
|
|
4797
|
+
for (const identity of vouchingForOld) await authRepo.unlinkUserIdentity?.(user.id, identity.provider, identity.providerId);
|
|
4798
|
+
await authRepo.deleteAllPasswordResetTokensForUser(user.id);
|
|
4799
|
+
logger.info("[Security Audit] Email address changed", {
|
|
4800
|
+
eventType: "auth.email_change.confirmed",
|
|
4801
|
+
uid: user.id,
|
|
4802
|
+
detachedProviders: vouchingForOld.map((identity) => identity.provider)
|
|
4803
|
+
});
|
|
4804
|
+
return c.json({
|
|
4805
|
+
success: true,
|
|
4806
|
+
user: {
|
|
4807
|
+
uid: updated.id,
|
|
4808
|
+
email: updated.email,
|
|
4809
|
+
emailVerified: true
|
|
4810
|
+
},
|
|
4811
|
+
removedProviders: vouchingForOld.map((identity) => identity.provider)
|
|
4812
|
+
});
|
|
4813
|
+
});
|
|
4814
|
+
}
|
|
4815
|
+
//#endregion
|
|
3598
4816
|
//#region src/auth/session-routes.ts
|
|
3599
4817
|
function mountSessionRoutes(opts) {
|
|
3600
|
-
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession } = opts;
|
|
4818
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession, registerCaptcha, sendVerificationMail } = opts;
|
|
3601
4819
|
const authRepo = config.authRepo;
|
|
3602
4820
|
/**
|
|
3603
4821
|
* Refuse the anonymous routes unless the deployment opted in.
|
|
@@ -3615,6 +4833,28 @@ function mountSessionRoutes(opts) {
|
|
|
3615
4833
|
})) return;
|
|
3616
4834
|
throw ApiError.forbidden(config.disableSelfRegistration ? "Anonymous sign-in is disabled: disableSelfRegistration blocks it." : "Anonymous sign-in is disabled. Set `auth.allowAnonymous: true` to enable it.", "ANONYMOUS_AUTH_DISABLED");
|
|
3617
4835
|
}
|
|
4836
|
+
/**
|
|
4837
|
+
* How the caller's session was signed in, as `providerId` reports it.
|
|
4838
|
+
*
|
|
4839
|
+
* Read off the session's refresh tokens, which carry it from sign-in
|
|
4840
|
+
* through every rotation, found by the access token's `sid`. A token from
|
|
4841
|
+
* before `sid`, or a session whose rows say nothing, reads `"password"` —
|
|
4842
|
+
* what `/me` always answered.
|
|
4843
|
+
*/
|
|
4844
|
+
async function sessionMethod(userCtx) {
|
|
4845
|
+
if (!userCtx.sid) return "password";
|
|
4846
|
+
return (await authRepo.listRefreshTokensForUser(userCtx.uid).catch(() => [])).find((token) => (token.sessionId ?? token.id) === userCtx.sid && token.method)?.method ?? "password";
|
|
4847
|
+
}
|
|
4848
|
+
/**
|
|
4849
|
+
* The address the account is moving to, while its link is still live —
|
|
4850
|
+
* so a settings screen opened later still says a change is waiting.
|
|
4851
|
+
* `null` otherwise, and on a repository that cannot change addresses.
|
|
4852
|
+
*/
|
|
4853
|
+
async function pendingEmail(uid) {
|
|
4854
|
+
const change = await authRepo.getPendingEmailChange?.(uid).catch(() => null);
|
|
4855
|
+
if (!change || Date.now() - change.sentAt.getTime() > 864e5) return null;
|
|
4856
|
+
return change.email;
|
|
4857
|
+
}
|
|
3618
4858
|
const logoutSchema = z.object({ refreshToken: z.string().optional() });
|
|
3619
4859
|
const linkSchema = z.object({
|
|
3620
4860
|
email: z.string().email("Invalid email address").max(255),
|
|
@@ -3631,18 +4871,25 @@ function mountSessionRoutes(opts) {
|
|
|
3631
4871
|
router.post("/logout", async (c) => {
|
|
3632
4872
|
const body = await c.req.json().catch(() => ({}));
|
|
3633
4873
|
const refreshToken = readRefreshToken(c, parseBody(logoutSchema, body), config.cookieAuth);
|
|
4874
|
+
let signedOutUid;
|
|
3634
4875
|
if (refreshToken) {
|
|
3635
4876
|
const tokenHash = await hashRefreshToken(refreshToken);
|
|
3636
|
-
const
|
|
4877
|
+
const stored = await authRepo.findRefreshTokenByHash(tokenHash).catch(() => null);
|
|
4878
|
+
signedOutUid = stored?.uid;
|
|
4879
|
+
const sessionId = stored?.sessionId;
|
|
3637
4880
|
if (sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(sessionId);
|
|
3638
4881
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
3639
4882
|
}
|
|
3640
4883
|
clearRefreshCookie(c, config.cookieAuth);
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
4884
|
+
if (ops.afterLogout) {
|
|
4885
|
+
if (!signedOutUid) {
|
|
4886
|
+
const accessToken = extractBearerToken(c.req.header("authorization"));
|
|
4887
|
+
if (accessToken !== void 0) {
|
|
4888
|
+
const { verifyAccessToken } = await import("./jwt-R6bSPMjk.js").then((n) => n._);
|
|
4889
|
+
signedOutUid = (await verifyAccessToken(accessToken))?.uid;
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
if (signedOutUid) ops.afterLogout(signedOutUid).catch((err) => {
|
|
3646
4893
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
3647
4894
|
});
|
|
3648
4895
|
}
|
|
@@ -3655,7 +4902,7 @@ function mountSessionRoutes(opts) {
|
|
|
3655
4902
|
router.get("/sessions", requireLiveSession, async (c) => {
|
|
3656
4903
|
const userCtx = c.get("user");
|
|
3657
4904
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
3658
|
-
const currentRefreshToken = c.req.header("x-refresh-token");
|
|
4905
|
+
const currentRefreshToken = userCtx.sid ? void 0 : c.req.header("x-refresh-token");
|
|
3659
4906
|
const currentTokenHash = currentRefreshToken ? await hashRefreshToken(currentRefreshToken) : null;
|
|
3660
4907
|
const tokens = await authRepo.listRefreshTokensForUser(userCtx.uid);
|
|
3661
4908
|
const bySession = /* @__PURE__ */ new Map();
|
|
@@ -3670,7 +4917,7 @@ function mountSessionRoutes(opts) {
|
|
|
3670
4917
|
userAgent: s.userAgent,
|
|
3671
4918
|
ipAddress: s.ipAddress,
|
|
3672
4919
|
createdAt: s.sessionStartedAt ?? s.createdAt,
|
|
3673
|
-
isCurrentSession: currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
|
|
4920
|
+
isCurrentSession: userCtx.sid ? (s.sessionId ?? s.id) === userCtx.sid : currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
|
|
3674
4921
|
}));
|
|
3675
4922
|
return c.json({ sessions: mappedSessions });
|
|
3676
4923
|
});
|
|
@@ -3718,10 +4965,11 @@ function mountSessionRoutes(opts) {
|
|
|
3718
4965
|
email: result.user.email,
|
|
3719
4966
|
displayName: result.user.displayName,
|
|
3720
4967
|
photoURL: result.user.photoUrl,
|
|
3721
|
-
providerId:
|
|
4968
|
+
providerId: await sessionMethod(userCtx),
|
|
3722
4969
|
isAnonymous: result.user.isAnonymous ?? false,
|
|
3723
4970
|
emailVerified: result.user.emailVerified,
|
|
3724
|
-
|
|
4971
|
+
pendingEmail: await pendingEmail(userCtx.uid),
|
|
4972
|
+
roles: result.roles,
|
|
3725
4973
|
metadata: result.user.metadata ?? {}
|
|
3726
4974
|
} });
|
|
3727
4975
|
});
|
|
@@ -3762,10 +5010,11 @@ function mountSessionRoutes(opts) {
|
|
|
3762
5010
|
email: result.user.email,
|
|
3763
5011
|
displayName: result.user.displayName,
|
|
3764
5012
|
photoURL: result.user.photoUrl,
|
|
3765
|
-
providerId:
|
|
5013
|
+
providerId: await sessionMethod(userCtx),
|
|
3766
5014
|
isAnonymous: result.user.isAnonymous ?? false,
|
|
3767
5015
|
emailVerified: result.user.emailVerified,
|
|
3768
|
-
|
|
5016
|
+
pendingEmail: await pendingEmail(userCtx.uid),
|
|
5017
|
+
roles: result.roles,
|
|
3769
5018
|
metadata: result.user.metadata ?? {}
|
|
3770
5019
|
} });
|
|
3771
5020
|
});
|
|
@@ -3783,7 +5032,7 @@ function mountSessionRoutes(opts) {
|
|
|
3783
5032
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
3784
5033
|
const user = await authRepo.createUser(createData);
|
|
3785
5034
|
if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
3786
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5035
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "anonymous" });
|
|
3787
5036
|
if (ops.afterUserCreate) ops.afterUserCreate(user).catch((err) => {
|
|
3788
5037
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
3789
5038
|
});
|
|
@@ -3791,15 +5040,26 @@ function mountSessionRoutes(opts) {
|
|
|
3791
5040
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
3792
5041
|
});
|
|
3793
5042
|
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "anonymous");
|
|
3794
|
-
const
|
|
3795
|
-
return c.json(
|
|
5043
|
+
const transformedResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
5044
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth), 201);
|
|
3796
5045
|
});
|
|
3797
5046
|
/**
|
|
3798
5047
|
* POST /auth/anonymous/link
|
|
3799
5048
|
* Upgrade an anonymous user to a permanent account with email/password
|
|
5049
|
+
*
|
|
5050
|
+
* This is registration: before it the row is a guest, after it an account
|
|
5051
|
+
* that signs in with a password. So it answers to registration's controls,
|
|
5052
|
+
* the `register` captcha and `beforeUserCreate` on the credentials the
|
|
5053
|
+
* account is getting. `/anonymous` carries neither, deliberately, because
|
|
5054
|
+
* apps mint guests on page load; the two in a row used to be a way to make
|
|
5055
|
+
* an account that passed neither.
|
|
3800
5056
|
*/
|
|
3801
|
-
router.post("/anonymous/link", strictAuthLimiter, requireLiveSession, async (c) => {
|
|
5057
|
+
router.post("/anonymous/link", strictAuthLimiter, requireLiveSession, ...registerCaptcha ? [registerCaptcha] : [], async (c) => {
|
|
3802
5058
|
assertAnonymousAuthOpen();
|
|
5059
|
+
if (!isSteadyStateRegistrationOpen({
|
|
5060
|
+
disableSelfRegistration: config.disableSelfRegistration,
|
|
5061
|
+
allowRegistration: config.allowRegistration ?? false
|
|
5062
|
+
})) throw ApiError.forbidden("A guest cannot become an account here: registration is disabled on this backend.", "REGISTRATION_DISABLED");
|
|
3803
5063
|
const userCtx = c.get("user");
|
|
3804
5064
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
3805
5065
|
const user = await authRepo.getUserById(userCtx.uid);
|
|
@@ -3809,17 +5069,73 @@ function mountSessionRoutes(opts) {
|
|
|
3809
5069
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
3810
5070
|
if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
3811
5071
|
const passwordHash = await ops.hashPassword(password);
|
|
3812
|
-
|
|
5072
|
+
let linkData = {
|
|
3813
5073
|
email: normalizeEmail(email),
|
|
3814
|
-
passwordHash
|
|
5074
|
+
passwordHash
|
|
5075
|
+
};
|
|
5076
|
+
if (ops.beforeUserCreate) linkData = await ops.beforeUserCreate(linkData);
|
|
5077
|
+
const updatedUser = await authRepo.updateUser(user.id, {
|
|
5078
|
+
...linkData,
|
|
5079
|
+
email: normalizeEmail(linkData.email),
|
|
5080
|
+
passwordHash: linkData.passwordHash,
|
|
3815
5081
|
isAnonymous: false
|
|
3816
5082
|
});
|
|
3817
5083
|
if (!updatedUser) throw ApiError.notFound("User not found");
|
|
3818
|
-
|
|
5084
|
+
sendVerificationMail?.(updatedUser);
|
|
5085
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
3819
5086
|
const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken, "password");
|
|
3820
|
-
const
|
|
3821
|
-
return c.json(
|
|
5087
|
+
const transformedResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
5088
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
5091
|
+
//#endregion
|
|
5092
|
+
//#region src/auth/passwordless-signup.ts
|
|
5093
|
+
/**
|
|
5094
|
+
* Sign-up by magic link or email code (`auth.magicLinkCreatesUsers`).
|
|
5095
|
+
*
|
|
5096
|
+
* Both passwordless doors refuse an address with no account — silently, so
|
|
5097
|
+
* they do not say which addresses have one. A passwordless-only app then had
|
|
5098
|
+
* no way to create accounts at all. With the option on, and registration open,
|
|
5099
|
+
* an unknown address gets an account at request time — no password, unverified
|
|
5100
|
+
* — and the link or code mailed to it is what proves the address and signs it
|
|
5101
|
+
* in, exactly as for an existing account. Supabase's `shouldCreateUser`.
|
|
5102
|
+
*
|
|
5103
|
+
* @module
|
|
5104
|
+
*/
|
|
5105
|
+
/**
|
|
5106
|
+
* The account a passwordless request for `email` should mail, creating it
|
|
5107
|
+
* when the deployment lets passwordless requests sign people up. `null` when
|
|
5108
|
+
* there is none and none may be made — the caller answers as for any unknown
|
|
5109
|
+
* address.
|
|
5110
|
+
*
|
|
5111
|
+
* Registration's controls hold: the kill switch, `allowRegistration` (with no
|
|
5112
|
+
* first-user exception: the first admin is made by registering or by the
|
|
5113
|
+
* operator, never by whoever asks for a link first), `beforeUserCreate`,
|
|
5114
|
+
* `afterUserCreate` and the default role.
|
|
5115
|
+
*/
|
|
5116
|
+
async function accountForPasswordlessRequest(config, ops, email) {
|
|
5117
|
+
const authRepo = config.authRepo;
|
|
5118
|
+
const existing = await authRepo.getUserByEmail(email);
|
|
5119
|
+
if (existing || !config.magicLinkCreatesUsers) return existing;
|
|
5120
|
+
if (!isSteadyStateRegistrationOpen({
|
|
5121
|
+
disableSelfRegistration: config.disableSelfRegistration,
|
|
5122
|
+
allowRegistration: config.allowRegistration ?? false
|
|
5123
|
+
})) return null;
|
|
5124
|
+
let createData = { email: normalizeEmail(email) };
|
|
5125
|
+
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5126
|
+
let user;
|
|
5127
|
+
try {
|
|
5128
|
+
user = await authRepo.createUser(createData);
|
|
5129
|
+
} catch (error) {
|
|
5130
|
+
const made = await authRepo.getUserByEmail(email);
|
|
5131
|
+
if (made) return made;
|
|
5132
|
+
throw error;
|
|
5133
|
+
}
|
|
5134
|
+
if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
5135
|
+
if (ops.afterUserCreate) ops.afterUserCreate(user).catch((err) => {
|
|
5136
|
+
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
3822
5137
|
});
|
|
5138
|
+
return user;
|
|
3823
5139
|
}
|
|
3824
5140
|
//#endregion
|
|
3825
5141
|
//#region src/auth/magic-link-routes.ts
|
|
@@ -3845,15 +5161,24 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3845
5161
|
/**
|
|
3846
5162
|
* POST /auth/magic-link
|
|
3847
5163
|
* Request a magic link email
|
|
5164
|
+
*
|
|
5165
|
+
* Answers the same words whether or not the address has an account, and
|
|
5166
|
+
* is held to the same three things as its siblings `/otp` and
|
|
5167
|
+
* `/forgot-password` so that nothing else answers the question either:
|
|
5168
|
+
* the same response time, a per-address send limit, and `beforeLogin` on
|
|
5169
|
+
* every attempt. This route had none of the three — the SMTP send was
|
|
5170
|
+
* awaited only for a real account, and the only limit was per IP — so its
|
|
5171
|
+
* timing named the customers and a spread-out run could fill one inbox.
|
|
3848
5172
|
*/
|
|
3849
|
-
router.post("/magic-link", strictAuthLimiter, ...captchaMiddleware ? [captchaMiddleware] : [], async (c) => {
|
|
5173
|
+
router.post("/magic-link", strictAuthLimiter, captureRecipientEmail, recipientEmailLimiter, ...captchaMiddleware ? [captchaMiddleware] : [], async (c) => {
|
|
5174
|
+
const startedAt = Date.now();
|
|
3850
5175
|
const { email } = parseBody(magicLinkSchema, await c.req.json());
|
|
3851
5176
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Magic link login is not available.", "EMAIL_NOT_CONFIGURED");
|
|
3852
|
-
|
|
5177
|
+
if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
|
|
5178
|
+
const user = await accountForPasswordlessRequest(config, ops, email);
|
|
3853
5179
|
if (user) {
|
|
3854
|
-
if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
|
|
3855
5180
|
const token = generateSecureToken();
|
|
3856
|
-
const tokenHash = hashToken
|
|
5181
|
+
const tokenHash = hashToken(token);
|
|
3857
5182
|
const expiresAt = getMagicLinkExpiry();
|
|
3858
5183
|
await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
|
|
3859
5184
|
const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
|
|
@@ -3866,21 +5191,19 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3866
5191
|
email: user.email,
|
|
3867
5192
|
displayName: user.displayName
|
|
3868
5193
|
}, appName, logoUrl);
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
});
|
|
3876
|
-
} catch (emailError) {
|
|
5194
|
+
emailService.send({
|
|
5195
|
+
to: user.email,
|
|
5196
|
+
subject: emailContent.subject,
|
|
5197
|
+
html: emailContent.html,
|
|
5198
|
+
text: emailContent.text
|
|
5199
|
+
}).catch((emailError) => {
|
|
3877
5200
|
logger.error("Failed to send magic link email", { error: emailError instanceof Error ? emailError.message : emailError });
|
|
3878
|
-
}
|
|
5201
|
+
});
|
|
3879
5202
|
}
|
|
3880
|
-
return c.json({
|
|
5203
|
+
return notBefore$1(startedAt, 400, c.json({
|
|
3881
5204
|
success: true,
|
|
3882
5205
|
message: "If an account with that email exists, a magic link has been sent."
|
|
3883
|
-
});
|
|
5206
|
+
}));
|
|
3884
5207
|
});
|
|
3885
5208
|
/**
|
|
3886
5209
|
* POST /auth/magic-link/verify
|
|
@@ -3888,23 +5211,27 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3888
5211
|
*/
|
|
3889
5212
|
router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
|
|
3890
5213
|
const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
|
|
3891
|
-
const tokenHash = hashToken
|
|
5214
|
+
const tokenHash = hashToken(token);
|
|
3892
5215
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
3893
5216
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
3894
5217
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
3895
|
-
|
|
5218
|
+
let user = await authRepo.getUserById(storedToken.uid);
|
|
3896
5219
|
if (!user) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
3897
5220
|
if (!user.emailVerified) {
|
|
3898
|
-
await authRepo
|
|
3899
|
-
user
|
|
5221
|
+
await confirmAddressOwnership(authRepo, user, null);
|
|
5222
|
+
user = {
|
|
5223
|
+
...user,
|
|
5224
|
+
emailVerified: true,
|
|
5225
|
+
passwordHash: null
|
|
5226
|
+
};
|
|
3900
5227
|
}
|
|
3901
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5228
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "magic-link" });
|
|
3902
5229
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "magic-link").catch((err) => {
|
|
3903
5230
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
3904
5231
|
});
|
|
3905
5232
|
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "magic-link");
|
|
3906
|
-
const
|
|
3907
|
-
return c.json(
|
|
5233
|
+
const transformedResponse = await applyTransformHook(authResponse, "magic-link", c.req.raw, user.id);
|
|
5234
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
3908
5235
|
});
|
|
3909
5236
|
}
|
|
3910
5237
|
//#endregion
|
|
@@ -3949,6 +5276,7 @@ function otpTokenMaterial(email, code) {
|
|
|
3949
5276
|
* replicas with the default, the effective budget is five attempts per replica.
|
|
3950
5277
|
*/
|
|
3951
5278
|
var otpVerificationLimiter = createRateLimiter({
|
|
5279
|
+
name: "auth-otp-verify",
|
|
3952
5280
|
windowMs: 900 * 1e3,
|
|
3953
5281
|
limit: OTP_ATTEMPTS_PER_WINDOW,
|
|
3954
5282
|
keyGenerator: (c) => {
|
|
@@ -3969,6 +5297,7 @@ var otpVerificationLimiter = createRateLimiter({
|
|
|
3969
5297
|
* and useless as a mail bomb, matching `verificationEmailLimiter`.
|
|
3970
5298
|
*/
|
|
3971
5299
|
var otpSendLimiter = createRateLimiter({
|
|
5300
|
+
name: "auth-otp-send",
|
|
3972
5301
|
windowMs: 900 * 1e3,
|
|
3973
5302
|
limit: 5,
|
|
3974
5303
|
keyGenerator: (c) => {
|
|
@@ -4039,11 +5368,11 @@ function mountOtpRoutes(deps) {
|
|
|
4039
5368
|
const startedAt = Date.now();
|
|
4040
5369
|
const { email } = parseBody(requestSchema, await c.req.json());
|
|
4041
5370
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. One-time code login is not available.", "EMAIL_NOT_CONFIGURED");
|
|
4042
|
-
const user = await authRepo.getUserByEmail(email);
|
|
4043
5371
|
if (ops.beforeLogin) await ops.beforeLogin(email, "otp");
|
|
5372
|
+
const user = await accountForPasswordlessRequest(config, ops, email);
|
|
4044
5373
|
if (user) {
|
|
4045
5374
|
const code = generateOtpCode();
|
|
4046
|
-
await authRepo.createMagicLinkToken(user.id, hashToken
|
|
5375
|
+
await authRepo.createMagicLinkToken(user.id, hashToken(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
|
|
4047
5376
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4048
5377
|
const templateFn = emailConfig?.templates?.emailOtp;
|
|
4049
5378
|
const content = templateFn ? templateFn(code, {
|
|
@@ -4073,23 +5402,27 @@ function mountOtpRoutes(deps) {
|
|
|
4073
5402
|
*/
|
|
4074
5403
|
router.post("/otp/verify", strictAuthLimiter, captureOtpEmail, otpVerificationLimiter, async (c) => {
|
|
4075
5404
|
const { email, code } = parseBody(verifySchema, await c.req.json());
|
|
4076
|
-
const tokenHash = hashToken
|
|
5405
|
+
const tokenHash = hashToken(otpTokenMaterial(email, code));
|
|
4077
5406
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
4078
5407
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
|
|
4079
5408
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
4080
|
-
|
|
5409
|
+
let user = await authRepo.getUserById(storedToken.uid);
|
|
4081
5410
|
if (!user) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
|
|
4082
5411
|
if (!user.emailVerified) {
|
|
4083
|
-
await authRepo
|
|
4084
|
-
user
|
|
5412
|
+
await confirmAddressOwnership(authRepo, user, null);
|
|
5413
|
+
user = {
|
|
5414
|
+
...user,
|
|
5415
|
+
emailVerified: true,
|
|
5416
|
+
passwordHash: null
|
|
5417
|
+
};
|
|
4085
5418
|
}
|
|
4086
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5419
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "otp" });
|
|
4087
5420
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "otp").catch((err) => {
|
|
4088
5421
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4089
5422
|
});
|
|
4090
5423
|
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "otp");
|
|
4091
|
-
const
|
|
4092
|
-
return c.json(
|
|
5424
|
+
const transformedResponse = await applyTransformHook(authResponse, "otp", c.req.raw, user.id);
|
|
5425
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
4093
5426
|
});
|
|
4094
5427
|
}
|
|
4095
5428
|
//#endregion
|
|
@@ -4097,26 +5430,34 @@ function mountOtpRoutes(deps) {
|
|
|
4097
5430
|
/**
|
|
4098
5431
|
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
4099
5432
|
*
|
|
4100
|
-
* Both sides have to be trustworthy
|
|
5433
|
+
* Both sides have to be trustworthy:
|
|
4101
5434
|
*
|
|
4102
5435
|
* - the **provider** must have verified the address, or the caller has not
|
|
4103
5436
|
* shown they control it;
|
|
4104
|
-
* - the **local account** must
|
|
4105
|
-
*
|
|
4106
|
-
*
|
|
4107
|
-
*
|
|
4108
|
-
*
|
|
4109
|
-
*
|
|
4110
|
-
*
|
|
5437
|
+
* - the **local account**'s address must have been verified too. Whoever
|
|
5438
|
+
* made an unverified account never proved the address, and every way in
|
|
5439
|
+
* they left on it — a password, an identity from a provider that did not
|
|
5440
|
+
* vouch for the address — would go on opening the account the owner is
|
|
5441
|
+
* about to be signed into.
|
|
5442
|
+
*
|
|
5443
|
+
* The second rule used to exempt an account with no password, on the theory
|
|
5444
|
+
* that one made by an OAuth sign-in holds no credential an attacker could have
|
|
5445
|
+
* planted. It holds exactly one: the identity it was made with, which is an
|
|
5446
|
+
* attacker's whenever the provider did not vouch for the address.
|
|
5447
|
+
*
|
|
5448
|
+
* A refusal is not a dead end. `POST /auth/link/<provider>` attaches the
|
|
5449
|
+
* identity once the caller proves ownership by holding a session, and proving
|
|
5450
|
+
* the address (a magic link, an email code, a password reset) verifies the
|
|
5451
|
+
* account — removing what nobody proved — after which this answers yes.
|
|
4111
5452
|
*/
|
|
4112
5453
|
function decideOAuthAutoLink(args) {
|
|
4113
5454
|
if (args.providerEmailVerified !== true) return {
|
|
4114
5455
|
allowed: false,
|
|
4115
5456
|
reason: "provider-email-unverified"
|
|
4116
5457
|
};
|
|
4117
|
-
if (
|
|
5458
|
+
if (!args.existingUser.emailVerified) return {
|
|
4118
5459
|
allowed: false,
|
|
4119
|
-
reason: "local-account-unverified"
|
|
5460
|
+
reason: args.existingUser.passwordHash ? "local-account-unverified" : "local-account-unverified-passwordless"
|
|
4120
5461
|
};
|
|
4121
5462
|
return { allowed: true };
|
|
4122
5463
|
}
|
|
@@ -4152,6 +5493,7 @@ function canonicalRedirectUri(uri) {
|
|
|
4152
5493
|
}
|
|
4153
5494
|
//#endregion
|
|
4154
5495
|
//#region src/auth/routes.ts
|
|
5496
|
+
var REFRESH_TOKEN_REUSE_POLICIES = ["reject", "revoke-session"];
|
|
4155
5497
|
/**
|
|
4156
5498
|
* Helper to build standard auth response output
|
|
4157
5499
|
*/
|
|
@@ -4182,7 +5524,13 @@ function getPasswordResetExpiry() {
|
|
|
4182
5524
|
return new Date(Date.now() + 3600 * 1e3);
|
|
4183
5525
|
}
|
|
4184
5526
|
function createAuthRoutes(config) {
|
|
4185
|
-
if (config.
|
|
5527
|
+
if (config.refreshTokenReuse !== void 0 && !REFRESH_TOKEN_REUSE_POLICIES.includes(config.refreshTokenReuse)) throw new Error(`auth.refreshTokenReuse is "${String(config.refreshTokenReuse)}"; it must be one of ${REFRESH_TOKEN_REUSE_POLICIES.map((p) => `"${p}"`).join(", ")}.`);
|
|
5528
|
+
if (config.refreshTokenReuseIntervalSeconds !== void 0 && !(Number.isFinite(config.refreshTokenReuseIntervalSeconds) && config.refreshTokenReuseIntervalSeconds >= 0)) throw new Error(`auth.refreshTokenReuseIntervalSeconds is ${String(config.refreshTokenReuseIntervalSeconds)}; give a number of seconds, 0 or more.`);
|
|
5529
|
+
if (config.defaultRole) {
|
|
5530
|
+
const declared = getAccessModel().roles;
|
|
5531
|
+
const adminPlane = config.defaultRole === "admin" ? ["every scope"] : Object.prototype.hasOwnProperty.call(declared, config.defaultRole) ? declared[config.defaultRole].scopes.filter((scope) => isAdminScope(parseScope(scope)?.scope ?? "")) : [];
|
|
5532
|
+
if (adminPlane.length > 0) throw new Error(`CRITICAL SECURITY ERROR: defaultRole cannot be '${config.defaultRole}': every registrant would hold ${adminPlane.join(", ")}. Give the default role no admin-plane scopes, and use the POST /admin/bootstrap endpoint to promote the initial administrator.`);
|
|
5533
|
+
}
|
|
4186
5534
|
const captcha = buildCaptchaMiddlewares(config.captcha);
|
|
4187
5535
|
const router = new Hono();
|
|
4188
5536
|
router.onError(errorHandler);
|
|
@@ -4235,11 +5583,16 @@ function createAuthRoutes(config) {
|
|
|
4235
5583
|
token: z.string().min(1, "Token is required"),
|
|
4236
5584
|
password: z.string().min(1, "Password is required").max(128)
|
|
4237
5585
|
});
|
|
5586
|
+
const verifyEmailSchema = z.object({
|
|
5587
|
+
token: z.string().min(1, "Token is required"),
|
|
5588
|
+
password: z.string().min(1).max(128).optional(),
|
|
5589
|
+
removeUnproven: z.boolean().optional()
|
|
5590
|
+
});
|
|
4238
5591
|
const changePasswordSchema = z.object({
|
|
4239
5592
|
oldPassword: z.string().min(1, "Old password is required").max(128),
|
|
4240
5593
|
newPassword: z.string().min(1, "New password is required").max(128)
|
|
4241
5594
|
});
|
|
4242
|
-
const refreshSchema = z.object({ refreshToken: z.string().
|
|
5595
|
+
const refreshSchema = z.object({ refreshToken: z.string().optional() });
|
|
4243
5596
|
/** Parse a Zod schema against the request body, throwing ApiError on failure */
|
|
4244
5597
|
function parseBody(schema, body) {
|
|
4245
5598
|
const result = schema.safeParse(body);
|
|
@@ -4277,11 +5630,11 @@ function createAuthRoutes(config) {
|
|
|
4277
5630
|
* Send welcome email to a newly registered user (fire-and-forget).
|
|
4278
5631
|
*/
|
|
4279
5632
|
function sendWelcomeEmail(user) {
|
|
4280
|
-
if (!isEmailConfigured()) return;
|
|
5633
|
+
if (!isEmailConfigured() || !isDeliverableAddress(user.email)) return;
|
|
4281
5634
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4282
5635
|
const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
|
|
4283
5636
|
const templateFn = emailConfig?.templates?.welcomeEmail;
|
|
4284
|
-
const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl
|
|
5637
|
+
const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl || void 0, logoUrl);
|
|
4285
5638
|
emailService.send({
|
|
4286
5639
|
to: user.email,
|
|
4287
5640
|
subject: emailContent.subject,
|
|
@@ -4292,6 +5645,65 @@ function createAuthRoutes(config) {
|
|
|
4292
5645
|
});
|
|
4293
5646
|
}
|
|
4294
5647
|
/**
|
|
5648
|
+
* Mail a verification link for `user`'s address, and record its token.
|
|
5649
|
+
*
|
|
5650
|
+
* Sent at registration as well as on request, so a self-registered
|
|
5651
|
+
* account proves its address on its owner's terms — and keeps the
|
|
5652
|
+
* password its owner chose, which a later first proof by link or code
|
|
5653
|
+
* would otherwise have to remove (see `confirmAddressOwnership`).
|
|
5654
|
+
* Returns whether a mail was handed to the email service.
|
|
5655
|
+
*/
|
|
5656
|
+
async function sendVerificationMail(user) {
|
|
5657
|
+
if (!isEmailConfigured() || !isDeliverableAddress(user.email)) return false;
|
|
5658
|
+
const token = generateSecureToken();
|
|
5659
|
+
await authRepo.setVerificationToken(user.id, hashToken(token));
|
|
5660
|
+
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
5661
|
+
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
5662
|
+
const templateFn = emailConfig?.templates?.emailVerification;
|
|
5663
|
+
const emailContent = templateFn ? templateFn(verifyUrl, {
|
|
5664
|
+
email: user.email,
|
|
5665
|
+
displayName: user.displayName
|
|
5666
|
+
}) : getEmailVerificationTemplate(verifyUrl, {
|
|
5667
|
+
email: user.email,
|
|
5668
|
+
displayName: user.displayName
|
|
5669
|
+
}, appName, logoUrl);
|
|
5670
|
+
await emailService.send({
|
|
5671
|
+
to: user.email,
|
|
5672
|
+
subject: emailContent.subject,
|
|
5673
|
+
html: emailContent.html,
|
|
5674
|
+
text: emailContent.text
|
|
5675
|
+
});
|
|
5676
|
+
return true;
|
|
5677
|
+
}
|
|
5678
|
+
/** {@link sendVerificationMail}, not awaited: a failure is logged, never answered. */
|
|
5679
|
+
function sendVerificationMailInBackground(user) {
|
|
5680
|
+
sendVerificationMail(user).catch((err) => {
|
|
5681
|
+
logger.error("Failed to send verification email", { error: err instanceof Error ? err.message : err });
|
|
5682
|
+
});
|
|
5683
|
+
}
|
|
5684
|
+
/**
|
|
5685
|
+
* A hash of nothing anyone knows, made with this deployment's own
|
|
5686
|
+
* `hashPassword`, so verifying against it costs what verifying a real
|
|
5687
|
+
* password costs. Made once, on first use.
|
|
5688
|
+
*/
|
|
5689
|
+
let dummyHash;
|
|
5690
|
+
function dummyPasswordHash() {
|
|
5691
|
+
dummyHash ??= ops.hashPassword(randomBytes$1(24).toString("hex"));
|
|
5692
|
+
return dummyHash;
|
|
5693
|
+
}
|
|
5694
|
+
/**
|
|
5695
|
+
* Is the account just created the only one there is?
|
|
5696
|
+
*
|
|
5697
|
+
* Two rows at most: the total says whether anyone else exists, and the
|
|
5698
|
+
* one row left when nobody does says whether it is this account. It used
|
|
5699
|
+
* to be `listUsers()` — the whole users table, password hashes included,
|
|
5700
|
+
* on every sign-up, to compare its length with one.
|
|
5701
|
+
*/
|
|
5702
|
+
async function isOnlyUser(uid) {
|
|
5703
|
+
const { users, total } = await authRepo.listUsersPaginated({ limit: 2 });
|
|
5704
|
+
return total === 1 && users[0]?.id === uid;
|
|
5705
|
+
}
|
|
5706
|
+
/**
|
|
4295
5707
|
* Helper to generate and store session tokens.
|
|
4296
5708
|
*
|
|
4297
5709
|
* Every route that signs somebody in comes through here — password login,
|
|
@@ -4305,11 +5717,12 @@ function createAuthRoutes(config) {
|
|
|
4305
5717
|
* itself, which has just seen the second factor and mints at `aal2`.
|
|
4306
5718
|
*/
|
|
4307
5719
|
async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
|
|
4308
|
-
if (!options
|
|
4309
|
-
const aal = options
|
|
4310
|
-
const roleIds =
|
|
5720
|
+
if (!options.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
|
|
5721
|
+
const aal = options.aal ?? "aal1";
|
|
5722
|
+
const roleIds = await authRepo.getUserRoleIds(uid);
|
|
4311
5723
|
const sessionUser = await authRepo.getUserById(uid);
|
|
4312
5724
|
const isAnonymous = sessionUser?.isAnonymous === true;
|
|
5725
|
+
if (sessionUser?.disabled) throw ApiError.forbidden("This account has been disabled. Contact an administrator.", "ACCOUNT_DISABLED");
|
|
4313
5726
|
let customClaims;
|
|
4314
5727
|
if (ops.customizeAccessToken) {
|
|
4315
5728
|
const user = sessionUser;
|
|
@@ -4322,12 +5735,14 @@ function createAuthRoutes(config) {
|
|
|
4322
5735
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
4323
5736
|
}
|
|
4324
5737
|
}
|
|
4325
|
-
const
|
|
5738
|
+
const sessionId = randomUUID$1();
|
|
5739
|
+
const accessToken = await generateAccessToken(uid, roleIds, aal, customClaims, isAnonymous, sessionId);
|
|
4326
5740
|
const refreshToken = generateRefreshToken();
|
|
4327
5741
|
await authRepo.createRefreshToken(uid, await hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
4328
|
-
id:
|
|
5742
|
+
id: sessionId,
|
|
4329
5743
|
startedAt: /* @__PURE__ */ new Date(),
|
|
4330
|
-
aal
|
|
5744
|
+
aal,
|
|
5745
|
+
method: options.method
|
|
4331
5746
|
});
|
|
4332
5747
|
return {
|
|
4333
5748
|
roleIds,
|
|
@@ -4340,6 +5755,7 @@ function createAuthRoutes(config) {
|
|
|
4340
5755
|
* Create a new account with email/password
|
|
4341
5756
|
*/
|
|
4342
5757
|
router.post("/register", defaultAuthLimiter, ...captcha.register ? [captcha.register] : [], async (c) => {
|
|
5758
|
+
const startedAt = Date.now();
|
|
4343
5759
|
const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
|
|
4344
5760
|
if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
4345
5761
|
let bootstrapRegistration = false;
|
|
@@ -4351,7 +5767,19 @@ function createAuthRoutes(config) {
|
|
|
4351
5767
|
}
|
|
4352
5768
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
4353
5769
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4354
|
-
|
|
5770
|
+
const confirmFirst = () => notBefore$1(startedAt, 400, c.json({
|
|
5771
|
+
success: true,
|
|
5772
|
+
confirmationRequired: true,
|
|
5773
|
+
message: "Check your inbox: follow the link we sent to confirm your address, then sign in."
|
|
5774
|
+
}));
|
|
5775
|
+
const existingUser = await authRepo.getUserByEmail(email);
|
|
5776
|
+
if (existingUser) {
|
|
5777
|
+
if (config.requireEmailVerification) {
|
|
5778
|
+
if (!existingUser.emailVerified) sendVerificationMailInBackground(existingUser);
|
|
5779
|
+
return confirmFirst();
|
|
5780
|
+
}
|
|
5781
|
+
throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
5782
|
+
}
|
|
4355
5783
|
const passwordHash = await ops.hashPassword(password);
|
|
4356
5784
|
let createData = {
|
|
4357
5785
|
email: normalizeEmail(email),
|
|
@@ -4360,8 +5788,7 @@ function createAuthRoutes(config) {
|
|
|
4360
5788
|
};
|
|
4361
5789
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
4362
5790
|
const user = await authRepo.createUser(createData);
|
|
4363
|
-
const
|
|
4364
|
-
const isFirstUser = existingUsers.length === 1 && existingUsers[0].id === user.id;
|
|
5791
|
+
const isFirstUser = await isOnlyUser(user.id);
|
|
4365
5792
|
if (bootstrapRegistration && !isFirstUser) {
|
|
4366
5793
|
await authRepo.deleteUser(user.id);
|
|
4367
5794
|
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
@@ -4371,11 +5798,21 @@ function createAuthRoutes(config) {
|
|
|
4371
5798
|
if (isFirstUser) logger.warn("[Auth] First account registered in production without promotion; set REBASE_ADMIN_EMAIL/REBASE_ADMIN_PASSWORD or use the service key to assign admin.");
|
|
4372
5799
|
if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
4373
5800
|
}
|
|
4374
|
-
|
|
5801
|
+
if (config.requireEmailVerification) {
|
|
5802
|
+
sendVerificationMailInBackground(user);
|
|
5803
|
+
if (ops.afterUserCreate) try {
|
|
5804
|
+
await ops.afterUserCreate(user);
|
|
5805
|
+
} catch (err) {
|
|
5806
|
+
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
5807
|
+
}
|
|
5808
|
+
return confirmFirst();
|
|
5809
|
+
}
|
|
5810
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
4375
5811
|
sendWelcomeEmail({
|
|
4376
5812
|
email: user.email,
|
|
4377
5813
|
displayName: user.displayName
|
|
4378
5814
|
});
|
|
5815
|
+
sendVerificationMailInBackground(user);
|
|
4379
5816
|
if (ops.afterUserCreate) try {
|
|
4380
5817
|
await ops.afterUserCreate(user);
|
|
4381
5818
|
} catch (err) {
|
|
@@ -4400,8 +5837,10 @@ function createAuthRoutes(config) {
|
|
|
4400
5837
|
if (!user) throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
4401
5838
|
} else {
|
|
4402
5839
|
user = await authRepo.getUserByEmail(email);
|
|
4403
|
-
if (!user
|
|
4404
|
-
|
|
5840
|
+
if (!user || !user.passwordHash) {
|
|
5841
|
+
await ops.verifyPassword(password, await dummyPasswordHash());
|
|
5842
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
5843
|
+
}
|
|
4405
5844
|
if (!await ops.verifyPassword(password, user.passwordHash)) {
|
|
4406
5845
|
logger.warn("[Security Audit] Auth login failure", {
|
|
4407
5846
|
eventType: "auth.login.failure",
|
|
@@ -4411,7 +5850,12 @@ function createAuthRoutes(config) {
|
|
|
4411
5850
|
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
4412
5851
|
}
|
|
4413
5852
|
}
|
|
4414
|
-
|
|
5853
|
+
if (config.requireEmailVerification && !user.emailVerified) {
|
|
5854
|
+
const lastSent = user.emailVerificationSentAt?.getTime() ?? 0;
|
|
5855
|
+
if (Date.now() - lastSent > 6e4) sendVerificationMailInBackground(user);
|
|
5856
|
+
throw ApiError.forbidden("Confirm your email address first: follow the link we sent to it, then sign in.", "EMAIL_NOT_CONFIRMED");
|
|
5857
|
+
}
|
|
5858
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
4415
5859
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "login").catch((err) => {
|
|
4416
5860
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4417
5861
|
});
|
|
@@ -4424,21 +5868,47 @@ function createAuthRoutes(config) {
|
|
|
4424
5868
|
return c.json(finalResponse);
|
|
4425
5869
|
});
|
|
4426
5870
|
/**
|
|
5871
|
+
* Turn an OAuth request body into the provider's profile of the caller.
|
|
5872
|
+
*
|
|
5873
|
+
* Both OAuth doors come through here — sign-in and `/link/<provider>` —
|
|
5874
|
+
* because both end with that provider identity resolving to an account,
|
|
5875
|
+
* so both need the same two controls. The link door used to spend the
|
|
5876
|
+
* code without the first and echo the provider's error without the second:
|
|
5877
|
+
* a victim's code leaked through a stale registered URI could be linked to
|
|
5878
|
+
* the attacker's account, after which the victim's own "Sign in with …"
|
|
5879
|
+
* landed there.
|
|
5880
|
+
*
|
|
5881
|
+
* The redirect URI is allowlisted before the code is spent. The provider's
|
|
5882
|
+
* own registered-URI match authorises *every* URI on that OAuth client — a
|
|
5883
|
+
* leftover localhost entry, a staging host, a second product sharing the
|
|
5884
|
+
* client id — and any of them can mint a code this backend would otherwise
|
|
5885
|
+
* accept.
|
|
5886
|
+
*
|
|
5887
|
+
* A provider's error message is logged, never returned: its token-endpoint
|
|
5888
|
+
* error body routinely echoes the client_id, the redirect URI and
|
|
5889
|
+
* diagnostics about the credential state.
|
|
5890
|
+
*/
|
|
5891
|
+
async function verifyProviderPayload(provider, payload) {
|
|
5892
|
+
const requestedRedirectUri = typeof payload === "object" && payload !== null && "redirectUri" in payload ? payload.redirectUri : void 0;
|
|
5893
|
+
if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
|
|
5894
|
+
let externalUser;
|
|
5895
|
+
try {
|
|
5896
|
+
externalUser = await provider.verify(payload);
|
|
5897
|
+
} catch (err) {
|
|
5898
|
+
logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
|
|
5899
|
+
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
|
|
5900
|
+
}
|
|
5901
|
+
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
5902
|
+
return externalUser;
|
|
5903
|
+
}
|
|
5904
|
+
/**
|
|
4427
5905
|
* Dynamically mount OAuth provider routes
|
|
4428
5906
|
*/
|
|
4429
5907
|
if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
|
|
4430
5908
|
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
4431
5909
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
4432
|
-
const
|
|
4433
|
-
if (
|
|
4434
|
-
let externalUser;
|
|
4435
|
-
try {
|
|
4436
|
-
externalUser = await provider.verify(payload);
|
|
4437
|
-
} catch (err) {
|
|
4438
|
-
logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
|
|
4439
|
-
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
|
|
4440
|
-
}
|
|
4441
|
-
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
5910
|
+
const externalUser = await verifyProviderPayload(provider, payload);
|
|
5911
|
+
if (ops.beforeLogin) await ops.beforeLogin(normalizeEmail(externalUser.email), "oauth");
|
|
4442
5912
|
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
4443
5913
|
if (!user) {
|
|
4444
5914
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
@@ -4451,7 +5921,7 @@ function createAuthRoutes(config) {
|
|
|
4451
5921
|
reason: decision.reason,
|
|
4452
5922
|
provider: provider.id
|
|
4453
5923
|
});
|
|
4454
|
-
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId,
|
|
5924
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4455
5925
|
await authRepo.updateUser(user.id, {
|
|
4456
5926
|
displayName: user.displayName || externalUser.displayName || void 0,
|
|
4457
5927
|
photoUrl: user.photoUrl || externalUser.photoUrl || void 0
|
|
@@ -4466,20 +5936,21 @@ function createAuthRoutes(config) {
|
|
|
4466
5936
|
if (!bootstrapRegistration) throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
4467
5937
|
if (!isBootstrapWindowOpen()) throw ApiError.forbidden(SETUP_REQUIRED_MESSAGE, "SETUP_REQUIRED");
|
|
4468
5938
|
}
|
|
4469
|
-
|
|
5939
|
+
let createData = {
|
|
4470
5940
|
email: normalizeEmail(externalUser.email),
|
|
4471
5941
|
displayName: externalUser.displayName || void 0,
|
|
4472
5942
|
photoUrl: externalUser.photoUrl || void 0,
|
|
4473
5943
|
emailVerified: externalUser.emailVerified === true
|
|
4474
|
-
}
|
|
4475
|
-
|
|
5944
|
+
};
|
|
5945
|
+
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5946
|
+
user = await authRepo.createUser(createData);
|
|
5947
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4476
5948
|
if (ops.afterUserCreate) try {
|
|
4477
5949
|
await ops.afterUserCreate(user);
|
|
4478
5950
|
} catch (err) {
|
|
4479
5951
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
4480
5952
|
}
|
|
4481
|
-
const
|
|
4482
|
-
const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
|
|
5953
|
+
const isFirstUser = await isOnlyUser(user.id);
|
|
4483
5954
|
if (bootstrapRegistration && !isFirstUser) {
|
|
4484
5955
|
await authRepo.deleteUser(user.id);
|
|
4485
5956
|
throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
@@ -4495,7 +5966,13 @@ function createAuthRoutes(config) {
|
|
|
4495
5966
|
displayName: externalUser.displayName || user.displayName || void 0,
|
|
4496
5967
|
photoUrl: externalUser.photoUrl || user.photoUrl || void 0
|
|
4497
5968
|
});
|
|
4498
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5969
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: provider.id });
|
|
5970
|
+
if (ops.onAuthenticated) {
|
|
5971
|
+
const signedIn = user;
|
|
5972
|
+
ops.onAuthenticated(signedIn, "oauth").catch((err) => {
|
|
5973
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
5974
|
+
});
|
|
5975
|
+
}
|
|
4499
5976
|
const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
|
|
4500
5977
|
return c.json(finalResponse);
|
|
4501
5978
|
});
|
|
@@ -4522,14 +5999,7 @@ function createAuthRoutes(config) {
|
|
|
4522
5999
|
const userCtx = c.get("user");
|
|
4523
6000
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4524
6001
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
4525
|
-
|
|
4526
|
-
try {
|
|
4527
|
-
externalUser = await provider.verify(payload);
|
|
4528
|
-
} catch (err) {
|
|
4529
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4530
|
-
throw ApiError.unauthorized(`${provider.id} link failed: ${msg}`, "OAUTH_ERROR");
|
|
4531
|
-
}
|
|
4532
|
-
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
6002
|
+
const externalUser = await verifyProviderPayload(provider, payload);
|
|
4533
6003
|
const identityOwner = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
4534
6004
|
if (identityOwner && identityOwner.id !== userCtx.uid) throw ApiError.conflict(`That ${provider.id} account is already linked to a different user.`, "IDENTITY_ALREADY_LINKED");
|
|
4535
6005
|
if (identityOwner) return c.json({
|
|
@@ -4538,7 +6008,34 @@ function createAuthRoutes(config) {
|
|
|
4538
6008
|
alreadyLinked: true,
|
|
4539
6009
|
photoURL: externalUser.photoUrl ?? null
|
|
4540
6010
|
});
|
|
4541
|
-
await authRepo.
|
|
6011
|
+
const linking = await authRepo.getUserById(userCtx.uid);
|
|
6012
|
+
if (linking?.isAnonymous) {
|
|
6013
|
+
if (!isRegistrationAllowed()) throw ApiError.forbidden("A guest cannot become an account here: registration is disabled on this backend.", "REGISTRATION_DISABLED");
|
|
6014
|
+
const vouchedEmail = externalUser.emailVerified === true ? normalizeEmail(externalUser.email) : void 0;
|
|
6015
|
+
if (vouchedEmail && await authRepo.getUserByEmail(vouchedEmail)) throw ApiError.conflict(`An account with this ${provider.id} address already exists. Sign in to it instead.`, "EMAIL_EXISTS");
|
|
6016
|
+
let upgrade = {
|
|
6017
|
+
email: vouchedEmail ?? linking.email,
|
|
6018
|
+
...vouchedEmail ? { emailVerified: true } : {}
|
|
6019
|
+
};
|
|
6020
|
+
if (ops.beforeUserCreate) upgrade = await ops.beforeUserCreate(upgrade);
|
|
6021
|
+
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
6022
|
+
const upgraded = await authRepo.updateUser(userCtx.uid, {
|
|
6023
|
+
email: normalizeEmail(upgrade.email),
|
|
6024
|
+
...upgrade.emailVerified !== void 0 ? { emailVerified: upgrade.emailVerified } : {},
|
|
6025
|
+
isAnonymous: false
|
|
6026
|
+
});
|
|
6027
|
+
if (!upgraded) throw ApiError.notFound("User not found");
|
|
6028
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(upgraded.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: provider.id });
|
|
6029
|
+
const authResponse = buildAuthResponse(upgraded, roleIds, accessToken, refreshToken, provider.id);
|
|
6030
|
+
return c.json({
|
|
6031
|
+
...redactRefreshToken(authResponse, c, refreshToken, config.cookieAuth),
|
|
6032
|
+
success: true,
|
|
6033
|
+
provider: provider.id,
|
|
6034
|
+
alreadyLinked: false,
|
|
6035
|
+
photoURL: externalUser.photoUrl ?? null
|
|
6036
|
+
});
|
|
6037
|
+
}
|
|
6038
|
+
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4542
6039
|
return c.json({
|
|
4543
6040
|
success: true,
|
|
4544
6041
|
provider: provider.id,
|
|
@@ -4558,7 +6055,7 @@ function createAuthRoutes(config) {
|
|
|
4558
6055
|
const user = await authRepo.getUserByEmail(email);
|
|
4559
6056
|
if (user) {
|
|
4560
6057
|
const token = generateSecureToken();
|
|
4561
|
-
const tokenHash = hashToken
|
|
6058
|
+
const tokenHash = hashToken(token);
|
|
4562
6059
|
const expiresAt = getPasswordResetExpiry();
|
|
4563
6060
|
await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
|
4564
6061
|
const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
@@ -4593,12 +6090,17 @@ function createAuthRoutes(config) {
|
|
|
4593
6090
|
const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
|
|
4594
6091
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
4595
6092
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4596
|
-
const tokenHash = hashToken
|
|
6093
|
+
const tokenHash = hashToken(token);
|
|
4597
6094
|
const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
|
|
4598
6095
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
|
|
4599
6096
|
const passwordHash = await ops.hashPassword(password);
|
|
4600
|
-
await
|
|
6097
|
+
const account = await authRepo.getUserById(storedToken.uid);
|
|
6098
|
+
if (account && !account.emailVerified) await confirmAddressOwnership(authRepo, account, passwordHash);
|
|
6099
|
+
else await replaceUserPassword(authRepo, storedToken.uid, passwordHash);
|
|
4601
6100
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
6101
|
+
if (ops.onAuthenticated && account) ops.onAuthenticated(account, "password-reset").catch((err) => {
|
|
6102
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
6103
|
+
});
|
|
4602
6104
|
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
4603
6105
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
4604
6106
|
});
|
|
@@ -4622,7 +6124,14 @@ function createAuthRoutes(config) {
|
|
|
4622
6124
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4623
6125
|
const passwordHash = await ops.hashPassword(newPassword);
|
|
4624
6126
|
await replaceUserPassword(authRepo, user.id, passwordHash);
|
|
6127
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), {
|
|
6128
|
+
skipMfaGate: true,
|
|
6129
|
+
aal: userCtx.aal === "aal2" ? "aal2" : "aal1",
|
|
6130
|
+
method: "password"
|
|
6131
|
+
});
|
|
6132
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "password");
|
|
4625
6133
|
return c.json({
|
|
6134
|
+
...redactRefreshToken(authResponse, c, refreshToken, config.cookieAuth),
|
|
4626
6135
|
success: true,
|
|
4627
6136
|
message: "Password has been changed successfully"
|
|
4628
6137
|
});
|
|
@@ -4645,42 +6154,109 @@ function createAuthRoutes(config) {
|
|
|
4645
6154
|
const user = await authRepo.getUserById(userCtx.uid);
|
|
4646
6155
|
if (!user) throw ApiError.notFound("User not found");
|
|
4647
6156
|
if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
4648
|
-
|
|
4649
|
-
await
|
|
4650
|
-
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
4651
|
-
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4652
|
-
const templateFn = emailConfig?.templates?.emailVerification;
|
|
4653
|
-
const emailContent = templateFn ? templateFn(verifyUrl, {
|
|
4654
|
-
email: user.email,
|
|
4655
|
-
displayName: user.displayName
|
|
4656
|
-
}) : getEmailVerificationTemplate(verifyUrl, {
|
|
4657
|
-
email: user.email,
|
|
4658
|
-
displayName: user.displayName
|
|
4659
|
-
}, appName, logoUrl);
|
|
4660
|
-
await emailService.send({
|
|
4661
|
-
to: user.email,
|
|
4662
|
-
subject: emailContent.subject,
|
|
4663
|
-
html: emailContent.html,
|
|
4664
|
-
text: emailContent.text
|
|
4665
|
-
});
|
|
6157
|
+
if (!isDeliverableAddress(user.email)) throw ApiError.badRequest("This account has no address mail can reach. Set a real email address first.", "UNDELIVERABLE_ADDRESS");
|
|
6158
|
+
await sendVerificationMail(user);
|
|
4666
6159
|
return c.json({
|
|
4667
6160
|
success: true,
|
|
4668
6161
|
message: "Verification email sent"
|
|
4669
6162
|
});
|
|
4670
6163
|
});
|
|
4671
6164
|
/**
|
|
4672
|
-
*
|
|
4673
|
-
*
|
|
6165
|
+
* Who proved what, when a verification link is followed.
|
|
6166
|
+
*
|
|
6167
|
+
* The link proves the inbox. It does not prove whoever registered the
|
|
6168
|
+
* address is that inbox's owner: anyone can register someone else's
|
|
6169
|
+
* address, leave a password on it, and ask for the link to be mailed. So a
|
|
6170
|
+
* first proof of address keeps only what the person following the link
|
|
6171
|
+
* also proved — and takes the rest off, as a magic link, an email code or
|
|
6172
|
+
* a reset does (`confirmAddressOwnership`):
|
|
6173
|
+
*
|
|
6174
|
+
* - `session`: the request carries a live session of this very account.
|
|
6175
|
+
* Only whoever put the credentials on it can hold one, and they now hold
|
|
6176
|
+
* the inbox too — nothing on the account is a stranger's.
|
|
6177
|
+
* - `password`: the request carries the account's password.
|
|
6178
|
+
* - `none`: the password and every identity whose provider did not vouch
|
|
6179
|
+
* for the address are removed, and every session ends.
|
|
6180
|
+
*/
|
|
6181
|
+
async function verificationProof(c, user, password) {
|
|
6182
|
+
const bearer = extractBearerToken(c.req.header("authorization"));
|
|
6183
|
+
if (bearer !== void 0) {
|
|
6184
|
+
const payload = await verifyAccessToken(bearer);
|
|
6185
|
+
if (payload?.uid === user.id && (await judgeAccessToken(authRepo, payload)).live) return "session";
|
|
6186
|
+
}
|
|
6187
|
+
if (password !== void 0) {
|
|
6188
|
+
if (!user.passwordHash || !await ops.verifyPassword(password, user.passwordHash)) throw ApiError.unauthorized("That is not this account's password.", "INVALID_CREDENTIALS");
|
|
6189
|
+
return "password";
|
|
6190
|
+
}
|
|
6191
|
+
return "none";
|
|
6192
|
+
}
|
|
6193
|
+
/** The account a verification token names, if the token is live. */
|
|
6194
|
+
async function accountForVerificationToken(token) {
|
|
6195
|
+
const user = await authRepo.getUserByVerificationToken(hashToken(token));
|
|
6196
|
+
const sentAt = user?.emailVerificationSentAt?.getTime();
|
|
6197
|
+
if (!user || sentAt !== void 0 && Date.now() - sentAt > 864e5) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
|
|
6198
|
+
return user;
|
|
6199
|
+
}
|
|
6200
|
+
/**
|
|
6201
|
+
* GET /auth/verify-email?token=
|
|
6202
|
+
* Verify an address by its link. Keeps what the request proves — a live
|
|
6203
|
+
* session of the account — and removes what nobody proved. See
|
|
6204
|
+
* `verificationProof`; `POST` is the same with a password and a choice.
|
|
4674
6205
|
*/
|
|
4675
|
-
router.get("/verify-email", async (c) => {
|
|
6206
|
+
router.get("/verify-email", strictAuthLimiter, async (c) => {
|
|
4676
6207
|
const token = c.req.query("token");
|
|
4677
6208
|
if (!token) throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
|
|
4678
|
-
const user = await
|
|
4679
|
-
|
|
4680
|
-
await authRepo
|
|
6209
|
+
const user = await accountForVerificationToken(token);
|
|
6210
|
+
const proof = await verificationProof(c, user, void 0);
|
|
6211
|
+
const outcome = await confirmAddressOwnership(authRepo, user, null, { everythingProven: proof === "session" });
|
|
6212
|
+
return c.json({
|
|
6213
|
+
success: true,
|
|
6214
|
+
message: "Email verified successfully",
|
|
6215
|
+
passwordRemoved: outcome.removedPassword,
|
|
6216
|
+
removedProviders: outcome.removedProviders
|
|
6217
|
+
});
|
|
6218
|
+
});
|
|
6219
|
+
/**
|
|
6220
|
+
* POST /auth/verify-email { token, password?, removeUnproven? }
|
|
6221
|
+
*
|
|
6222
|
+
* The same proof as `GET`, with the two things a link alone cannot say:
|
|
6223
|
+
*
|
|
6224
|
+
* - `password` proves the account's password, which is then kept, and
|
|
6225
|
+
* signs the caller in — they have just proven both the address and the
|
|
6226
|
+
* password. This is how a confirm-first registration completes.
|
|
6227
|
+
* - With neither a session nor a password, an account carrying a password
|
|
6228
|
+
* or an identity nobody proved is answered `409 PROOF_REQUIRED` and the
|
|
6229
|
+
* token is left unspent, so the person holding the link can choose:
|
|
6230
|
+
* prove the password, or `removeUnproven: true` to verify without it.
|
|
6231
|
+
*/
|
|
6232
|
+
router.post("/verify-email", strictAuthLimiter, async (c) => {
|
|
6233
|
+
const { token, password, removeUnproven } = parseBody(verifyEmailSchema, await c.req.json());
|
|
6234
|
+
const user = await accountForVerificationToken(token);
|
|
6235
|
+
const proof = await verificationProof(c, user, password);
|
|
6236
|
+
if (proof === "none" && !removeUnproven) {
|
|
6237
|
+
const unprovenProviders = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email)).map((identity) => identity.provider);
|
|
6238
|
+
if (user.passwordHash || unprovenProviders.length > 0) throw new ApiError(409, "PROOF_REQUIRED", "This account has a way to sign in that was set before its address was verified. Enter its password to keep it, or verify without it and it is removed.", {
|
|
6239
|
+
password: Boolean(user.passwordHash),
|
|
6240
|
+
providers: unprovenProviders
|
|
6241
|
+
});
|
|
6242
|
+
}
|
|
6243
|
+
const outcome = await confirmAddressOwnership(authRepo, user, proof === "password" ? user.passwordHash ?? null : null, { everythingProven: proof === "session" });
|
|
6244
|
+
if (proof !== "password") return c.json({
|
|
6245
|
+
success: true,
|
|
6246
|
+
message: "Email verified successfully",
|
|
6247
|
+
passwordRemoved: outcome.removedPassword,
|
|
6248
|
+
removedProviders: outcome.removedProviders
|
|
6249
|
+
});
|
|
6250
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
6251
|
+
const transformedResponse = await applyTransformHook(buildAuthResponse({
|
|
6252
|
+
...user,
|
|
6253
|
+
emailVerified: true
|
|
6254
|
+
}, roleIds, accessToken, refreshToken, "password"), "login", c.req.raw, user.id);
|
|
4681
6255
|
return c.json({
|
|
6256
|
+
...redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth),
|
|
4682
6257
|
success: true,
|
|
4683
|
-
|
|
6258
|
+
passwordRemoved: false,
|
|
6259
|
+
removedProviders: outcome.removedProviders
|
|
4684
6260
|
});
|
|
4685
6261
|
});
|
|
4686
6262
|
/**
|
|
@@ -4713,15 +6289,24 @@ function createAuthRoutes(config) {
|
|
|
4713
6289
|
const reuseWindowMs = Math.max(0, config.refreshTokenReuseIntervalSeconds ?? 10) * 1e3;
|
|
4714
6290
|
const supersededAt = storedToken.rotatedAt ? new Date(storedToken.rotatedAt) : null;
|
|
4715
6291
|
if (supersededAt && Date.now() - supersededAt.getTime() > reuseWindowMs) {
|
|
4716
|
-
|
|
6292
|
+
const revoke = config.refreshTokenReuse === "revoke-session";
|
|
6293
|
+
logger.warn("[Security Audit] Refresh token replayed after the reuse window", {
|
|
6294
|
+
eventType: "auth.refresh.reuse",
|
|
4717
6295
|
uid: storedToken.uid,
|
|
4718
6296
|
sessionId: storedToken.sessionId,
|
|
4719
6297
|
supersededSecondsAgo: Math.round((Date.now() - supersededAt.getTime()) / 1e3),
|
|
4720
|
-
userAgent: c.req.header("user-agent") || "unknown"
|
|
6298
|
+
userAgent: c.req.header("user-agent") || "unknown",
|
|
6299
|
+
sessionRevoked: revoke
|
|
4721
6300
|
});
|
|
6301
|
+
if (revoke) {
|
|
6302
|
+
if (storedToken.sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(storedToken.sessionId);
|
|
6303
|
+
else await authRepo.deleteRefreshToken(tokenHash);
|
|
6304
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
6305
|
+
throw ApiError.unauthorized("Refresh token already used; this session has been ended", "SESSION_REVOKED");
|
|
6306
|
+
}
|
|
4722
6307
|
throw ApiError.unauthorized("Refresh token already used", "TOKEN_ALREADY_USED");
|
|
4723
6308
|
}
|
|
4724
|
-
const roleIds =
|
|
6309
|
+
const roleIds = await authRepo.getUserRoleIds(storedToken.uid);
|
|
4725
6310
|
const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
|
|
4726
6311
|
logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
|
|
4727
6312
|
uid: storedToken.uid,
|
|
@@ -4729,7 +6314,13 @@ function createAuthRoutes(config) {
|
|
|
4729
6314
|
});
|
|
4730
6315
|
return null;
|
|
4731
6316
|
});
|
|
6317
|
+
if (user?.disabled) {
|
|
6318
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
6319
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
6320
|
+
throw ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
|
|
6321
|
+
}
|
|
4732
6322
|
const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
|
|
6323
|
+
const sessionMethod = storedToken.method ?? "password";
|
|
4733
6324
|
let customClaims;
|
|
4734
6325
|
if (ops.customizeAccessToken && user) {
|
|
4735
6326
|
const defaultClaims = {
|
|
@@ -4739,18 +6330,27 @@ function createAuthRoutes(config) {
|
|
|
4739
6330
|
};
|
|
4740
6331
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
4741
6332
|
}
|
|
4742
|
-
const
|
|
6333
|
+
const sessionId = storedToken.sessionId ?? storedToken.id;
|
|
6334
|
+
const newAccessToken = await generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims, user?.isAnonymous === true, sessionId);
|
|
4743
6335
|
const newRefreshToken = generateRefreshToken();
|
|
4744
6336
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
4745
|
-
const ipAddress = c
|
|
6337
|
+
const ipAddress = requestClientAddress(c);
|
|
4746
6338
|
const session = {
|
|
4747
|
-
id:
|
|
6339
|
+
id: sessionId,
|
|
4748
6340
|
startedAt: sessionStartedAt,
|
|
4749
|
-
aal: sessionAal
|
|
6341
|
+
aal: sessionAal,
|
|
6342
|
+
method: sessionMethod
|
|
4750
6343
|
};
|
|
4751
6344
|
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
4752
6345
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
4753
|
-
|
|
6346
|
+
const presentedKept = Boolean(supersededAt) || typeof authRepo.markRefreshTokenRotated === "function";
|
|
6347
|
+
if (await authRepo.createRefreshToken(storedToken.uid, await hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, presentedKept ? {
|
|
6348
|
+
...session,
|
|
6349
|
+
rotatedFrom: tokenHash
|
|
6350
|
+
} : session) === false) {
|
|
6351
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
6352
|
+
throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
6353
|
+
}
|
|
4754
6354
|
authRepo.pruneRefreshTokens?.(storedToken.uid, session.id, new Date(Date.now() - reuseWindowMs)).catch((err) => {
|
|
4755
6355
|
logger.warn("[Auth] Refresh token prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
4756
6356
|
});
|
|
@@ -4761,14 +6361,24 @@ function createAuthRoutes(config) {
|
|
|
4761
6361
|
} };
|
|
4762
6362
|
let refreshResponse = tokensOnlyResponse;
|
|
4763
6363
|
if (user) try {
|
|
4764
|
-
refreshResponse = buildAuthResponse(user, roleIds, newAccessToken, newRefreshToken,
|
|
6364
|
+
refreshResponse = buildAuthResponse(user, roleIds, newAccessToken, newRefreshToken, sessionMethod);
|
|
4765
6365
|
} catch (err) {
|
|
4766
6366
|
logger.warn("[Auth] Could not build enriched refresh response; returning tokens only", { error: err instanceof Error ? err.message : String(err) });
|
|
4767
6367
|
refreshResponse = tokensOnlyResponse;
|
|
4768
6368
|
}
|
|
6369
|
+
if (ops.onAuthenticated && user) ops.onAuthenticated(user, "refresh").catch((err) => {
|
|
6370
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
6371
|
+
});
|
|
4769
6372
|
const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.uid), c, newRefreshToken, config.cookieAuth);
|
|
4770
6373
|
return c.json(finalResponse);
|
|
4771
6374
|
});
|
|
6375
|
+
mountEmailChangeRoutes({
|
|
6376
|
+
router,
|
|
6377
|
+
config,
|
|
6378
|
+
ops,
|
|
6379
|
+
parseBody,
|
|
6380
|
+
requireLiveSession
|
|
6381
|
+
});
|
|
4772
6382
|
mountSessionRoutes({
|
|
4773
6383
|
router,
|
|
4774
6384
|
config,
|
|
@@ -4777,7 +6387,9 @@ function createAuthRoutes(config) {
|
|
|
4777
6387
|
buildAuthResponse,
|
|
4778
6388
|
createSessionAndTokens,
|
|
4779
6389
|
applyTransformHook,
|
|
4780
|
-
requireLiveSession
|
|
6390
|
+
requireLiveSession,
|
|
6391
|
+
registerCaptcha: captcha.register,
|
|
6392
|
+
sendVerificationMail: sendVerificationMailInBackground
|
|
4781
6393
|
});
|
|
4782
6394
|
mountMfaRoutes({
|
|
4783
6395
|
router,
|
|
@@ -4875,10 +6487,11 @@ function createResetPasswordRoute(config) {
|
|
|
4875
6487
|
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
4876
6488
|
revocationRepo: authRepo
|
|
4877
6489
|
}));
|
|
4878
|
-
router.post("/users/:uid/reset-password",
|
|
6490
|
+
router.post("/users/:uid/reset-password", requireScope("users:write"), async (c) => {
|
|
4879
6491
|
const uid = c.req.param("uid");
|
|
4880
6492
|
const existing = await authRepo.getUserById(uid);
|
|
4881
6493
|
if (!existing) throw ApiError.notFound("User not found");
|
|
6494
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
4882
6495
|
let invitationSent = false;
|
|
4883
6496
|
let temporaryPassword;
|
|
4884
6497
|
let emailDeliveryFailed = false;
|
|
@@ -4908,11 +6521,11 @@ function createResetPasswordRoute(config) {
|
|
|
4908
6521
|
invitationSent = hookResult.invitationSent ?? false;
|
|
4909
6522
|
} else if (!!(emailService && emailService.isConfigured())) {
|
|
4910
6523
|
const token = generateSecureToken();
|
|
4911
|
-
const tokenHash = hashToken
|
|
6524
|
+
const tokenHash = hashToken(token);
|
|
4912
6525
|
const expiresAt = new Date(Date.now() + 3600 * 1e3);
|
|
4913
6526
|
await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
|
|
4914
6527
|
try {
|
|
4915
|
-
const setPasswordUrl = `${emailConfig
|
|
6528
|
+
const setPasswordUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
4916
6529
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4917
6530
|
const templateFn = emailConfig?.templates?.passwordReset;
|
|
4918
6531
|
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
@@ -4956,9 +6569,11 @@ function createResetPasswordRoute(config) {
|
|
|
4956
6569
|
//#endregion
|
|
4957
6570
|
//#region src/auth/admin-roles-route.ts
|
|
4958
6571
|
/**
|
|
4959
|
-
* Admin endpoint for listing
|
|
6572
|
+
* Admin endpoint for listing roles.
|
|
4960
6573
|
*
|
|
4961
|
-
* Mounts: GET /roles
|
|
6574
|
+
* Mounts: GET /roles — the built-in `admin` role and the roles the app
|
|
6575
|
+
* declares under `auth.roles` on the users collection, with the scopes each
|
|
6576
|
+
* holds. Requires `users:read`.
|
|
4962
6577
|
*/
|
|
4963
6578
|
/**
|
|
4964
6579
|
* Create a standalone admin route for listing roles.
|
|
@@ -4974,9 +6589,8 @@ function createAdminRolesRoute(config) {
|
|
|
4974
6589
|
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
4975
6590
|
revocationRepo: authRepo
|
|
4976
6591
|
}));
|
|
4977
|
-
router.get("/roles",
|
|
4978
|
-
|
|
4979
|
-
return c.json({ roles });
|
|
6592
|
+
router.get("/roles", requireScope("users:read"), (c) => {
|
|
6593
|
+
return c.json({ roles: summarizeRoles(getAccessModel()) });
|
|
4980
6594
|
});
|
|
4981
6595
|
return router;
|
|
4982
6596
|
}
|
|
@@ -4991,6 +6605,7 @@ function createAdminRolesRoute(config) {
|
|
|
4991
6605
|
* POST /users
|
|
4992
6606
|
* PUT /users/:uid
|
|
4993
6607
|
* DELETE /users/:uid
|
|
6608
|
+
* DELETE /users/:uid/mfa
|
|
4994
6609
|
* POST /bootstrap
|
|
4995
6610
|
*/
|
|
4996
6611
|
/** Upper bound for `GET /users?ids=…`, so one request can't fan out unbounded. */
|
|
@@ -5008,6 +6623,7 @@ function createAdminUsersRoute(config) {
|
|
|
5008
6623
|
photoURL: u.photoUrl ?? null,
|
|
5009
6624
|
providerId: "custom",
|
|
5010
6625
|
roles,
|
|
6626
|
+
disabled: u.disabled === true,
|
|
5011
6627
|
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
5012
6628
|
updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : u.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
5013
6629
|
};
|
|
@@ -5034,15 +6650,18 @@ function createAdminUsersRoute(config) {
|
|
|
5034
6650
|
}
|
|
5035
6651
|
const uid = "uid" in user ? user.uid : void 0;
|
|
5036
6652
|
if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
|
|
5037
|
-
|
|
6653
|
+
const refuseAnonymousCaller = () => {
|
|
5038
6654
|
logger.warn("[Security Audit] Bootstrap denied: anonymous caller", {
|
|
5039
6655
|
eventType: "auth.bootstrap.denied.anonymous",
|
|
5040
6656
|
callerId: uid
|
|
5041
6657
|
});
|
|
5042
6658
|
throw ApiError.forbidden("An anonymous session cannot claim the initial admin role. Register a real account and bootstrap from it, or assign the admin role using the service key.", "BOOTSTRAP_ANONYMOUS");
|
|
5043
|
-
}
|
|
5044
|
-
if (
|
|
5045
|
-
const
|
|
6659
|
+
};
|
|
6660
|
+
if (isAnonymousUid(uid)) refuseAnonymousCaller();
|
|
6661
|
+
const caller = await authRepo.getUserById(uid);
|
|
6662
|
+
if (!caller) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
|
|
6663
|
+
if (caller.isAnonymous === true) refuseAnonymousCaller();
|
|
6664
|
+
const registeredUsers = users.filter((u) => u.isAnonymous !== true && !isAnonymousUid(u.id));
|
|
5046
6665
|
if (registeredUsers.length > 0) {
|
|
5047
6666
|
const earliest = registeredUsers.reduce((a, b) => {
|
|
5048
6667
|
const at = new Date(a.createdAt).getTime();
|
|
@@ -5073,13 +6692,13 @@ function createAdminUsersRoute(config) {
|
|
|
5073
6692
|
}
|
|
5074
6693
|
});
|
|
5075
6694
|
});
|
|
5076
|
-
router.get("/users",
|
|
6695
|
+
router.get("/users", requireScope("users:read"), async (c) => {
|
|
5077
6696
|
const idsParam = c.req.query("ids");
|
|
5078
6697
|
if (idsParam !== void 0) {
|
|
5079
6698
|
const ids = [...new Set(idsParam.split(",").map((id) => id.trim()).filter(Boolean))].slice(0, MAX_USER_IDS_PER_LOOKUP);
|
|
5080
6699
|
const users = (await Promise.all(ids.map(async (id) => {
|
|
5081
6700
|
const result = await authRepo.getUserWithRoles(id);
|
|
5082
|
-
return result ? toAdminUser(result.user, result.roles
|
|
6701
|
+
return result ? toAdminUser(result.user, result.roles) : void 0;
|
|
5083
6702
|
}))).filter((u) => u !== void 0);
|
|
5084
6703
|
return c.json({
|
|
5085
6704
|
users,
|
|
@@ -5113,17 +6732,18 @@ function createAdminUsersRoute(config) {
|
|
|
5113
6732
|
offset: result.offset
|
|
5114
6733
|
});
|
|
5115
6734
|
});
|
|
5116
|
-
router.get("/users/:uid",
|
|
6735
|
+
router.get("/users/:uid", requireScope("users:read"), async (c) => {
|
|
5117
6736
|
const uid = c.req.param("uid");
|
|
5118
6737
|
const result = await authRepo.getUserWithRoles(uid);
|
|
5119
6738
|
if (!result) throw ApiError.notFound("User not found");
|
|
5120
|
-
const adminUser = toAdminUser(result.user, result.roles
|
|
6739
|
+
const adminUser = toAdminUser(result.user, result.roles);
|
|
5121
6740
|
return c.json({ user: adminUser });
|
|
5122
6741
|
});
|
|
5123
|
-
router.post("/users",
|
|
6742
|
+
router.post("/users", requireScope("users:write"), async (c) => {
|
|
5124
6743
|
const body = await c.req.json();
|
|
5125
6744
|
const { email, roles } = body;
|
|
5126
6745
|
if (!email) throw ApiError.badRequest("Email is required");
|
|
6746
|
+
if (roles !== void 0 && Array.isArray(roles)) assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
|
|
5127
6747
|
if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
|
|
5128
6748
|
const prepResult = await prepareAdminUserValues(body, {
|
|
5129
6749
|
authRepo,
|
|
@@ -5137,7 +6757,8 @@ function createAdminUsersRoute(config) {
|
|
|
5137
6757
|
passwordHash: prepResult.values.passwordHash,
|
|
5138
6758
|
displayName: prepResult.values.displayName,
|
|
5139
6759
|
photoUrl: prepResult.values.photoUrl,
|
|
5140
|
-
metadata: prepResult.values.metadata
|
|
6760
|
+
metadata: prepResult.values.metadata,
|
|
6761
|
+
emailVerified: prepResult.values.emailVerified === true
|
|
5141
6762
|
};
|
|
5142
6763
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5143
6764
|
const user = await authRepo.createUser(createData);
|
|
@@ -5158,12 +6779,26 @@ function createAdminUsersRoute(config) {
|
|
|
5158
6779
|
...delivery
|
|
5159
6780
|
}, 201);
|
|
5160
6781
|
});
|
|
5161
|
-
router.put("/users/:uid",
|
|
6782
|
+
router.put("/users/:uid", requireScope("users:write"), async (c) => {
|
|
5162
6783
|
const uid = c.req.param("uid");
|
|
5163
|
-
const { password, email, displayName, roles } = await c.req.json();
|
|
6784
|
+
const { password, email, displayName, roles, disabled } = await c.req.json();
|
|
5164
6785
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
6786
|
+
if (disabled !== void 0) {
|
|
6787
|
+
if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
|
|
6788
|
+
const caller = c.get("user");
|
|
6789
|
+
if (disabled && caller?.uid === uid) throw ApiError.badRequest("Cannot disable your own account", "SELF_DISABLE");
|
|
6790
|
+
if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
|
|
6791
|
+
}
|
|
6792
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
6793
|
+
if (roles !== void 0 && Array.isArray(roles)) {
|
|
6794
|
+
assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
|
|
6795
|
+
await assertRoleChangesAllowed(authRepo, [{
|
|
6796
|
+
uid,
|
|
6797
|
+
roles
|
|
6798
|
+
}]);
|
|
6799
|
+
}
|
|
5165
6800
|
const updates = {};
|
|
5166
|
-
if (email !== void 0) updates.email =
|
|
6801
|
+
if (email !== void 0) updates.email = await normalizeUserEmailChange(authRepo, uid, email);
|
|
5167
6802
|
if (displayName !== void 0) updates.displayName = displayName;
|
|
5168
6803
|
let passwordHash;
|
|
5169
6804
|
if (password) {
|
|
@@ -5173,36 +6808,60 @@ function createAdminUsersRoute(config) {
|
|
|
5173
6808
|
}
|
|
5174
6809
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
5175
6810
|
if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
|
|
5176
|
-
if (roles !== void 0 && Array.isArray(roles))
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
if (
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
}
|
|
5185
|
-
await authRepo.setUserRoles(uid, roles);
|
|
6811
|
+
if (roles !== void 0 && Array.isArray(roles)) await authRepo.setUserRoles(uid, roles);
|
|
6812
|
+
if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
|
|
6813
|
+
await authRepo.setUserDisabled(uid, disabled);
|
|
6814
|
+
if (disabled) await revokeAllSessions(authRepo, uid);
|
|
6815
|
+
logger.info("[Security Audit] Account " + (disabled ? "disabled" : "re-enabled") + " by an administrator", {
|
|
6816
|
+
eventType: disabled ? "auth.account.disabled" : "auth.account.enabled",
|
|
6817
|
+
uid,
|
|
6818
|
+
by: c.get("user")?.uid
|
|
6819
|
+
});
|
|
5186
6820
|
}
|
|
5187
6821
|
const result = await authRepo.getUserWithRoles(uid);
|
|
5188
|
-
const adminUser = toAdminUser(result.user, result.roles
|
|
6822
|
+
const adminUser = toAdminUser(result.user, result.roles);
|
|
5189
6823
|
return c.json({ user: adminUser });
|
|
5190
6824
|
});
|
|
5191
|
-
|
|
6825
|
+
/**
|
|
6826
|
+
* DELETE /users/:uid/mfa — remove every second factor and recovery code
|
|
6827
|
+
* an account has, and end its sessions.
|
|
6828
|
+
*
|
|
6829
|
+
* The way back in for someone who lost their authenticator and their
|
|
6830
|
+
* codes, which used to be SQL. It lowers the account's protection, so it
|
|
6831
|
+
* is held to what a password reset is held to: `users:write`, never on an
|
|
6832
|
+
* account that outranks the caller, and every session ends — a session
|
|
6833
|
+
* that passed the old factor must not outlive its removal.
|
|
6834
|
+
*/
|
|
6835
|
+
router.delete("/users/:uid/mfa", requireScope("users:write"), async (c) => {
|
|
6836
|
+
const uid = c.req.param("uid");
|
|
6837
|
+
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
6838
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
6839
|
+
const factors = await authRepo.getMfaFactors(uid);
|
|
6840
|
+
for (const factor of factors) await authRepo.deleteMfaFactor(factor.id, uid);
|
|
6841
|
+
await authRepo.deleteAllRecoveryCodes(uid);
|
|
6842
|
+
await revokeAllSessions(authRepo, uid);
|
|
6843
|
+
const caller = c.get("user");
|
|
6844
|
+
logger.info("[Security Audit] Second factors reset by an administrator", {
|
|
6845
|
+
eventType: "auth.mfa.admin_reset",
|
|
6846
|
+
uid,
|
|
6847
|
+
by: caller?.uid,
|
|
6848
|
+
removedFactors: factors.length
|
|
6849
|
+
});
|
|
6850
|
+
return c.json({
|
|
6851
|
+
success: true,
|
|
6852
|
+
removedFactors: factors.length
|
|
6853
|
+
});
|
|
6854
|
+
});
|
|
6855
|
+
router.delete("/users/:uid", requireScope("users:write"), async (c) => {
|
|
5192
6856
|
const uid = c.req.param("uid");
|
|
5193
6857
|
if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
|
|
5194
6858
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
roleId: "admin",
|
|
5198
|
-
limit: 1
|
|
5199
|
-
})).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
|
|
5200
|
-
}
|
|
6859
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
6860
|
+
await assertUserDeletionsAllowed(authRepo, [uid]);
|
|
5201
6861
|
if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
|
|
6862
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
5202
6863
|
await authRepo.deleteUser(uid);
|
|
5203
|
-
|
|
5204
|
-
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
5205
|
-
});
|
|
6864
|
+
runAfterUserDelete(ops, uid);
|
|
5206
6865
|
return c.json({ success: true });
|
|
5207
6866
|
});
|
|
5208
6867
|
return router;
|
|
@@ -5217,8 +6876,58 @@ function createAdminUsersRoute(config) {
|
|
|
5217
6876
|
* when the user passes a plain `RebaseAuthConfig` object.
|
|
5218
6877
|
*/
|
|
5219
6878
|
function createBuiltinAuthAdapter(config) {
|
|
5220
|
-
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, captcha, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, enableEmailOtp = false, cookieAuth } = config;
|
|
6879
|
+
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, captcha, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, enableEmailOtp = false, cookieAuth, requireEmailVerification = false, refreshTokenReuseIntervalSeconds, refreshTokenReuse, magicLinkCreatesUsers = false } = config;
|
|
5221
6880
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
6881
|
+
/**
|
|
6882
|
+
* The caller's roles as the database has them now.
|
|
6883
|
+
*
|
|
6884
|
+
* Read per request because the token's `roles` claim can be a whole
|
|
6885
|
+
* access-token lifetime stale — a demoted admin still holds a token that
|
|
6886
|
+
* says `admin`. So when the read fails, falling back to the claim hands
|
|
6887
|
+
* that token exactly the access the lookup exists to take away; during a
|
|
6888
|
+
* database blip it scoped a demoted admin with admin RLS. This refuses
|
|
6889
|
+
* instead, with the 503 `createRequireAuth` already gives the admin
|
|
6890
|
+
* routes: the adapter middleware answers with it, and the socket treats
|
|
6891
|
+
* a throwing adapter as an invalid token.
|
|
6892
|
+
*/
|
|
6893
|
+
async function resolveLiveRoles(uid) {
|
|
6894
|
+
try {
|
|
6895
|
+
return await authRepository.getUserRoleIds(uid);
|
|
6896
|
+
} catch (error) {
|
|
6897
|
+
logger.warn("[Auth] Could not resolve roles for a request; refusing it", {
|
|
6898
|
+
uid,
|
|
6899
|
+
error
|
|
6900
|
+
});
|
|
6901
|
+
throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
|
|
6902
|
+
}
|
|
6903
|
+
}
|
|
6904
|
+
/**
|
|
6905
|
+
* The roles a verified access token runs with now, or `null` when it must
|
|
6906
|
+
* not be honoured at all: revoked, or its account deleted. See
|
|
6907
|
+
* `judgeAccessToken`, which both verify paths ask. A repository that
|
|
6908
|
+
* cannot answer refuses the request (503), as `resolveLiveRoles` does.
|
|
6909
|
+
*/
|
|
6910
|
+
async function liveRoles(payload) {
|
|
6911
|
+
let verdict;
|
|
6912
|
+
try {
|
|
6913
|
+
verdict = await judgeAccessToken(authRepository, payload);
|
|
6914
|
+
} catch (error) {
|
|
6915
|
+
logger.warn("[Auth] Could not judge an access token; refusing it", {
|
|
6916
|
+
uid: payload.uid,
|
|
6917
|
+
error
|
|
6918
|
+
});
|
|
6919
|
+
throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
|
|
6920
|
+
}
|
|
6921
|
+
if (!verdict.live) {
|
|
6922
|
+
logger.warn("[Security Audit] Refused an access token", {
|
|
6923
|
+
eventType: verdict.refusal === "revoked" ? "auth.token.revoked" : "auth.token.account_gone",
|
|
6924
|
+
refusal: verdict.refusal,
|
|
6925
|
+
uid: payload.uid
|
|
6926
|
+
});
|
|
6927
|
+
return null;
|
|
6928
|
+
}
|
|
6929
|
+
return verdict.roles ?? await resolveLiveRoles(payload.uid);
|
|
6930
|
+
}
|
|
5222
6931
|
return {
|
|
5223
6932
|
id: "rebase-builtin",
|
|
5224
6933
|
serviceKey,
|
|
@@ -5234,23 +6943,9 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5234
6943
|
};
|
|
5235
6944
|
const payload = await verifyAccessToken(token);
|
|
5236
6945
|
if (!payload) return null;
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
uid: payload.uid
|
|
5241
|
-
});
|
|
5242
|
-
return null;
|
|
5243
|
-
}
|
|
5244
|
-
let roles = payload.roles || [];
|
|
5245
|
-
try {
|
|
5246
|
-
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
5247
|
-
} catch (err) {
|
|
5248
|
-
logger.warn("Role lookup from repository failed, using token roles as fallback", {
|
|
5249
|
-
uid: payload.uid,
|
|
5250
|
-
error: err
|
|
5251
|
-
});
|
|
5252
|
-
}
|
|
5253
|
-
const isAdmin = hasAdministrativeRole(roles);
|
|
6946
|
+
const roles = await liveRoles(payload);
|
|
6947
|
+
if (!roles) return null;
|
|
6948
|
+
const isAdmin = hasAdminRole(roles);
|
|
5254
6949
|
return {
|
|
5255
6950
|
uid: payload.uid,
|
|
5256
6951
|
email: payload.email ?? "",
|
|
@@ -5258,6 +6953,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5258
6953
|
roles,
|
|
5259
6954
|
isAdmin,
|
|
5260
6955
|
isAnonymous: payload.isAnonymous === true,
|
|
6956
|
+
...payload.claims ? { claims: payload.claims } : {},
|
|
5261
6957
|
rawToken: token
|
|
5262
6958
|
};
|
|
5263
6959
|
},
|
|
@@ -5271,23 +6967,9 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5271
6967
|
};
|
|
5272
6968
|
const payload = await verifyAccessToken(token);
|
|
5273
6969
|
if (!payload) return null;
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
uid: payload.uid
|
|
5278
|
-
});
|
|
5279
|
-
return null;
|
|
5280
|
-
}
|
|
5281
|
-
let roles = payload.roles || [];
|
|
5282
|
-
try {
|
|
5283
|
-
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
5284
|
-
} catch (err) {
|
|
5285
|
-
logger.warn("Role lookup from repository failed, using token roles as fallback", {
|
|
5286
|
-
uid: payload.uid,
|
|
5287
|
-
error: err
|
|
5288
|
-
});
|
|
5289
|
-
}
|
|
5290
|
-
const isAdmin = hasAdministrativeRole(roles);
|
|
6970
|
+
const roles = await liveRoles(payload);
|
|
6971
|
+
if (!roles) return null;
|
|
6972
|
+
const isAdmin = hasAdminRole(roles);
|
|
5291
6973
|
return {
|
|
5292
6974
|
uid: payload.uid,
|
|
5293
6975
|
email: payload.email ?? "",
|
|
@@ -5295,9 +6977,38 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5295
6977
|
roles,
|
|
5296
6978
|
isAdmin,
|
|
5297
6979
|
isAnonymous: payload.isAnonymous === true,
|
|
6980
|
+
...payload.claims ? { claims: payload.claims } : {},
|
|
5298
6981
|
rawToken: token
|
|
5299
6982
|
};
|
|
5300
6983
|
},
|
|
6984
|
+
async resolveUser(uid) {
|
|
6985
|
+
let user;
|
|
6986
|
+
try {
|
|
6987
|
+
user = await authRepository.getUserById(uid);
|
|
6988
|
+
} catch (error) {
|
|
6989
|
+
if (isDataException(error)) return null;
|
|
6990
|
+
throw error;
|
|
6991
|
+
}
|
|
6992
|
+
if (!user || user.disabled) return null;
|
|
6993
|
+
const roles = await resolveLiveRoles(uid);
|
|
6994
|
+
const isAnonymous = user.isAnonymous === true;
|
|
6995
|
+
let claims;
|
|
6996
|
+
if (resolvedOps.customizeAccessToken) claims = customClaimsOf(await resolvedOps.customizeAccessToken({
|
|
6997
|
+
uid,
|
|
6998
|
+
roles,
|
|
6999
|
+
aal: "aal1"
|
|
7000
|
+
}, user));
|
|
7001
|
+
return {
|
|
7002
|
+
uid,
|
|
7003
|
+
email: user.email,
|
|
7004
|
+
displayName: user.displayName ?? null,
|
|
7005
|
+
photoUrl: user.photoUrl ?? null,
|
|
7006
|
+
roles,
|
|
7007
|
+
isAdmin: hasAdminRole(roles),
|
|
7008
|
+
isAnonymous,
|
|
7009
|
+
...claims ? { claims } : {}
|
|
7010
|
+
};
|
|
7011
|
+
},
|
|
5301
7012
|
userManagement: createUserManagementFromRepo(authRepository, resolvedOps),
|
|
5302
7013
|
createAuthRoutes() {
|
|
5303
7014
|
return createAuthRoutes({
|
|
@@ -5315,7 +7026,11 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5315
7026
|
enableMagicLink,
|
|
5316
7027
|
enableEmailOtp,
|
|
5317
7028
|
cookieAuth,
|
|
5318
|
-
captcha
|
|
7029
|
+
captcha,
|
|
7030
|
+
requireEmailVerification,
|
|
7031
|
+
refreshTokenReuseIntervalSeconds,
|
|
7032
|
+
refreshTokenReuse,
|
|
7033
|
+
magicLinkCreatesUsers
|
|
5319
7034
|
});
|
|
5320
7035
|
},
|
|
5321
7036
|
createAdminRoutes() {
|
|
@@ -5373,6 +7088,10 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5373
7088
|
collectionAuthConfig
|
|
5374
7089
|
});
|
|
5375
7090
|
},
|
|
7091
|
+
...authCollectionUserAdmin({
|
|
7092
|
+
authRepo: authRepository,
|
|
7093
|
+
resolvedHooks: resolvedOps
|
|
7094
|
+
}),
|
|
5376
7095
|
async getCapabilities() {
|
|
5377
7096
|
let needsSetup = false;
|
|
5378
7097
|
try {
|
|
@@ -5422,7 +7141,8 @@ function createUserManagementFromRepo(repo, resolvedOps) {
|
|
|
5422
7141
|
passwordHash,
|
|
5423
7142
|
displayName: data.displayName,
|
|
5424
7143
|
photoUrl: data.photoUrl,
|
|
5425
|
-
metadata: data.metadata
|
|
7144
|
+
metadata: data.metadata,
|
|
7145
|
+
...data.emailVerified !== void 0 ? { emailVerified: data.emailVerified } : {}
|
|
5426
7146
|
};
|
|
5427
7147
|
if (resolvedOps.beforeUserCreate) createData = await resolvedOps.beforeUserCreate(createData);
|
|
5428
7148
|
const user = await repo.createUser(createData);
|
|
@@ -5446,6 +7166,7 @@ function createUserManagementFromRepo(repo, resolvedOps) {
|
|
|
5446
7166
|
},
|
|
5447
7167
|
async deleteUser(id) {
|
|
5448
7168
|
if (resolvedOps.beforeUserDelete) await resolvedOps.beforeUserDelete(id);
|
|
7169
|
+
await repo.deleteAllRefreshTokensForUser(id);
|
|
5449
7170
|
await repo.deleteUser(id);
|
|
5450
7171
|
if (resolvedOps.afterUserDelete) resolvedOps.afterUserDelete(id).catch((err) => {
|
|
5451
7172
|
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
@@ -6700,9 +8421,76 @@ function resolveRateLimitStoreKind(env) {
|
|
|
6700
8421
|
throw new RateLimitStoreConfigurationError(`REBASE_RATE_LIMIT_STORE="${raw}" is not a rate-limit store.`, "Use \"sql\" to share counts across processes, or \"memory\" (the default) to keep them in this process. A deployment running more than one replica or more than one role wants \"sql\" — otherwise each process enforces the whole limit by itself.");
|
|
6701
8422
|
}
|
|
6702
8423
|
//#endregion
|
|
8424
|
+
//#region src/auth/api-keys/legacy-permissions.ts
|
|
8425
|
+
var OPERATIONS = [
|
|
8426
|
+
"read",
|
|
8427
|
+
"write",
|
|
8428
|
+
"delete"
|
|
8429
|
+
];
|
|
8430
|
+
function isOperation(value) {
|
|
8431
|
+
return OPERATIONS.includes(value);
|
|
8432
|
+
}
|
|
8433
|
+
/** The admin surfaces an `admin: true` key reached. */
|
|
8434
|
+
var ADMIN_KEY_SCOPES = [
|
|
8435
|
+
"users:read",
|
|
8436
|
+
"users:write",
|
|
8437
|
+
"schema:read",
|
|
8438
|
+
"schema:write",
|
|
8439
|
+
"backups:read",
|
|
8440
|
+
"cron:read",
|
|
8441
|
+
"cron:write",
|
|
8442
|
+
"logs:read"
|
|
8443
|
+
];
|
|
8444
|
+
/** Read a stored `permissions` value — JSON text or already-parsed — into entries. */
|
|
8445
|
+
function parseStoredPermissions(value) {
|
|
8446
|
+
let raw = value;
|
|
8447
|
+
if (typeof raw === "string") try {
|
|
8448
|
+
raw = JSON.parse(raw);
|
|
8449
|
+
} catch {
|
|
8450
|
+
return [];
|
|
8451
|
+
}
|
|
8452
|
+
if (!Array.isArray(raw)) return [];
|
|
8453
|
+
const entries = [];
|
|
8454
|
+
for (const item of raw) {
|
|
8455
|
+
if (typeof item !== "object" || item === null) continue;
|
|
8456
|
+
if (!("collection" in item) || typeof item.collection !== "string") continue;
|
|
8457
|
+
const operations = "operations" in item && Array.isArray(item.operations) ? item.operations.filter((op) => typeof op === "string") : [];
|
|
8458
|
+
entries.push({
|
|
8459
|
+
collection: item.collection,
|
|
8460
|
+
operations
|
|
8461
|
+
});
|
|
8462
|
+
}
|
|
8463
|
+
return entries;
|
|
8464
|
+
}
|
|
8465
|
+
/** The scopes and RLS roles a stored key now holds. Never more than it did. */
|
|
8466
|
+
function scopesFromStoredPermissions(permissions, admin) {
|
|
8467
|
+
const scopes = /* @__PURE__ */ new Set();
|
|
8468
|
+
for (const { collection, operations } of permissions) {
|
|
8469
|
+
const ops = operations.filter(isOperation);
|
|
8470
|
+
if (collection === "*") {
|
|
8471
|
+
for (const op of ops) {
|
|
8472
|
+
scopes.add(`data:${op}`);
|
|
8473
|
+
scopes.add(`storage:${op}`);
|
|
8474
|
+
}
|
|
8475
|
+
if (ops.includes("write")) scopes.add("functions:invoke");
|
|
8476
|
+
} else if (collection === "storage") for (const op of ops) scopes.add(`storage:${op}`);
|
|
8477
|
+
else if (collection === "functions") {
|
|
8478
|
+
if (ops.includes("write")) scopes.add("functions:invoke");
|
|
8479
|
+
} else if (collection.startsWith("functions/")) {
|
|
8480
|
+
const name = collection.slice(10);
|
|
8481
|
+
if (name && ops.includes("write")) scopes.add(`functions:invoke:${name}`);
|
|
8482
|
+
} else if (collection) for (const op of ops) scopes.add(`data:${op}:${collection}`);
|
|
8483
|
+
}
|
|
8484
|
+
if (admin) for (const scope of ADMIN_KEY_SCOPES) scopes.add(scope);
|
|
8485
|
+
return {
|
|
8486
|
+
scopes: [...scopes],
|
|
8487
|
+
roles: admin ? ["admin"] : []
|
|
8488
|
+
};
|
|
8489
|
+
}
|
|
8490
|
+
//#endregion
|
|
6703
8491
|
//#region src/auth/api-keys/api-key-store.ts
|
|
6704
8492
|
/**
|
|
6705
|
-
* Database operations for
|
|
8493
|
+
* Database operations for API keys.
|
|
6706
8494
|
*
|
|
6707
8495
|
* Uses the DataDriver's `admin.executeSql` capability (same pattern as
|
|
6708
8496
|
* the cron-store and ensure-tables modules). All data lives in the
|
|
@@ -6738,9 +8526,11 @@ function toMasked(row) {
|
|
|
6738
8526
|
return {
|
|
6739
8527
|
id: row.id,
|
|
6740
8528
|
name: row.name,
|
|
8529
|
+
kind: row.kind,
|
|
6741
8530
|
key_prefix: row.key_prefix,
|
|
6742
|
-
|
|
6743
|
-
|
|
8531
|
+
scopes: row.scopes,
|
|
8532
|
+
roles: row.roles,
|
|
8533
|
+
owner_uid: row.owner_uid,
|
|
6744
8534
|
rate_limit: row.rate_limit,
|
|
6745
8535
|
created_by: row.created_by,
|
|
6746
8536
|
created_at: row.created_at,
|
|
@@ -6750,30 +8540,40 @@ function toMasked(row) {
|
|
|
6750
8540
|
revoked_at: row.revoked_at
|
|
6751
8541
|
};
|
|
6752
8542
|
}
|
|
8543
|
+
/** A JSONB string array as the driver hands it back: parsed, or JSON text. */
|
|
8544
|
+
function stringList(value) {
|
|
8545
|
+
let raw = value;
|
|
8546
|
+
if (typeof raw === "string") try {
|
|
8547
|
+
raw = JSON.parse(raw);
|
|
8548
|
+
} catch {
|
|
8549
|
+
return [];
|
|
8550
|
+
}
|
|
8551
|
+
return Array.isArray(raw) ? raw.filter((entry) => typeof entry === "string") : [];
|
|
8552
|
+
}
|
|
6753
8553
|
/**
|
|
6754
8554
|
* Parse a raw DB row into the typed `ApiKey` shape.
|
|
6755
8555
|
*/
|
|
6756
8556
|
function rowToApiKey(row) {
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
}
|
|
6761
|
-
permissions = [];
|
|
6762
|
-
}
|
|
8557
|
+
const grant = row.scopes === null || row.scopes === void 0 ? scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true) : {
|
|
8558
|
+
scopes: stringList(row.scopes),
|
|
8559
|
+
roles: stringList(row.roles)
|
|
8560
|
+
};
|
|
6763
8561
|
return {
|
|
6764
|
-
id: row.id,
|
|
6765
|
-
name: row.name,
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
8562
|
+
id: String(row.id),
|
|
8563
|
+
name: String(row.name),
|
|
8564
|
+
kind: row.kind === "personal" ? "personal" : "service",
|
|
8565
|
+
key_prefix: String(row.key_prefix),
|
|
8566
|
+
key_hash: String(row.key_hash),
|
|
8567
|
+
scopes: grant.scopes,
|
|
8568
|
+
roles: grant.roles,
|
|
8569
|
+
owner_uid: typeof row.owner_uid === "string" ? row.owner_uid : null,
|
|
6770
8570
|
rate_limit: row.rate_limit !== null && row.rate_limit !== void 0 ? Number(row.rate_limit) : null,
|
|
6771
|
-
created_by: row.created_by,
|
|
6772
|
-
created_at: new Date(row.created_at).toISOString(),
|
|
6773
|
-
updated_at: new Date(row.updated_at).toISOString(),
|
|
6774
|
-
last_used_at: row.last_used_at ? new Date(row.last_used_at).toISOString() : null,
|
|
6775
|
-
expires_at: row.expires_at ? new Date(row.expires_at).toISOString() : null,
|
|
6776
|
-
revoked_at: row.revoked_at ? new Date(row.revoked_at).toISOString() : null
|
|
8571
|
+
created_by: String(row.created_by),
|
|
8572
|
+
created_at: new Date(String(row.created_at)).toISOString(),
|
|
8573
|
+
updated_at: new Date(String(row.updated_at)).toISOString(),
|
|
8574
|
+
last_used_at: row.last_used_at ? new Date(String(row.last_used_at)).toISOString() : null,
|
|
8575
|
+
expires_at: row.expires_at ? new Date(String(row.expires_at)).toISOString() : null,
|
|
8576
|
+
revoked_at: row.revoked_at ? new Date(String(row.revoked_at)).toISOString() : null
|
|
6777
8577
|
};
|
|
6778
8578
|
}
|
|
6779
8579
|
/**
|
|
@@ -6789,6 +8589,26 @@ function createApiKeyStore(driver) {
|
|
|
6789
8589
|
}
|
|
6790
8590
|
const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
|
|
6791
8591
|
const ddl = createDdlBootstrapper(exec, "api-key-store");
|
|
8592
|
+
/**
|
|
8593
|
+
* Give every row stored before scopes existed the scopes it now holds.
|
|
8594
|
+
*
|
|
8595
|
+
* Idempotent and race-safe: a row is only written while its `scopes` is
|
|
8596
|
+
* still NULL, so two instances backfilling together write the same value
|
|
8597
|
+
* once. The `permissions` and `admin` columns are left as they are, so a
|
|
8598
|
+
* runtime that predates scopes still reads its own keys after a rollback.
|
|
8599
|
+
*/
|
|
8600
|
+
async function backfillScopes() {
|
|
8601
|
+
const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE} WHERE scopes IS NULL`);
|
|
8602
|
+
for (const row of rows) {
|
|
8603
|
+
const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
|
|
8604
|
+
await exec(`UPDATE ${TABLE} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
|
|
8605
|
+
JSON.stringify(scopes),
|
|
8606
|
+
JSON.stringify(roles),
|
|
8607
|
+
row.id
|
|
8608
|
+
] });
|
|
8609
|
+
}
|
|
8610
|
+
if (rows.length > 0) logger.info(`[api-key-store] Gave ${rows.length} stored API key(s) their scopes.`);
|
|
8611
|
+
}
|
|
6792
8612
|
return {
|
|
6793
8613
|
async ensureTable() {
|
|
6794
8614
|
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
@@ -6796,10 +8616,12 @@ function createApiKeyStore(driver) {
|
|
|
6796
8616
|
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
6797
8617
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
6798
8618
|
name TEXT NOT NULL,
|
|
8619
|
+
kind TEXT NOT NULL DEFAULT 'service',
|
|
6799
8620
|
key_prefix TEXT NOT NULL,
|
|
6800
8621
|
key_hash TEXT NOT NULL UNIQUE,
|
|
6801
|
-
|
|
6802
|
-
|
|
8622
|
+
scopes JSONB,
|
|
8623
|
+
roles JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
8624
|
+
owner_uid TEXT,
|
|
6803
8625
|
rate_limit INTEGER,
|
|
6804
8626
|
created_by TEXT NOT NULL,
|
|
6805
8627
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
@@ -6817,34 +8639,49 @@ function createApiKeyStore(driver) {
|
|
|
6817
8639
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
|
6818
8640
|
ON ${TABLE}(key_prefix)
|
|
6819
8641
|
`);
|
|
6820
|
-
await ddl.ensureObject(`Adding ${TABLE}.
|
|
6821
|
-
ALTER TABLE ${TABLE}
|
|
6822
|
-
|
|
8642
|
+
await ddl.ensureObject(`Adding ${TABLE}.kind`, `
|
|
8643
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
|
|
8644
|
+
`);
|
|
8645
|
+
await ddl.ensureObject(`Adding ${TABLE}.scopes`, `
|
|
8646
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS scopes JSONB
|
|
8647
|
+
`);
|
|
8648
|
+
await ddl.ensureObject(`Adding ${TABLE}.roles`, `
|
|
8649
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
8650
|
+
`);
|
|
8651
|
+
await ddl.ensureObject(`Adding ${TABLE}.owner_uid`, `
|
|
8652
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS owner_uid TEXT
|
|
8653
|
+
`);
|
|
8654
|
+
await ddl.ensureObject("Creating idx_api_keys_owner", `
|
|
8655
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_owner
|
|
8656
|
+
ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
|
|
6823
8657
|
`);
|
|
6824
8658
|
if (!await ddl.isReadable(TABLE)) {
|
|
6825
8659
|
logger.error(`❌ [api-key-store] ${TABLE} is unavailable — every API-key authenticated request will be rejected on this instance, and the table could not be taken back off the end-user role.`);
|
|
6826
8660
|
return;
|
|
6827
8661
|
}
|
|
6828
8662
|
await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
|
|
8663
|
+
if ((await exec(`SELECT 1 FROM information_schema.columns
|
|
8664
|
+
WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`)).length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
|
|
6829
8665
|
logger.debug("✅ API keys table ready");
|
|
6830
8666
|
},
|
|
6831
|
-
async createApiKey(
|
|
8667
|
+
async createApiKey(key, createdBy) {
|
|
6832
8668
|
const plaintext = generateApiKey();
|
|
6833
8669
|
const hash = hashKey(plaintext);
|
|
6834
8670
|
const prefix = keyPrefix(plaintext);
|
|
6835
|
-
const permissionsJson = JSON.stringify(request.permissions);
|
|
6836
8671
|
return {
|
|
6837
|
-
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, key_prefix, key_hash,
|
|
6838
|
-
VALUES ($1, $2, $3, $4
|
|
8672
|
+
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
|
|
8673
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
|
|
6839
8674
|
RETURNING *`, { params: [
|
|
6840
|
-
|
|
8675
|
+
key.name,
|
|
8676
|
+
key.kind,
|
|
6841
8677
|
prefix,
|
|
6842
8678
|
hash,
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
8679
|
+
JSON.stringify(key.scopes),
|
|
8680
|
+
JSON.stringify(key.kind === "personal" ? [] : key.roles),
|
|
8681
|
+
key.owner_uid,
|
|
8682
|
+
key.rate_limit,
|
|
6846
8683
|
createdBy,
|
|
6847
|
-
|
|
8684
|
+
key.expires_at
|
|
6848
8685
|
] }))[0])),
|
|
6849
8686
|
key: plaintext
|
|
6850
8687
|
};
|
|
@@ -6856,11 +8693,14 @@ function createApiKeyStore(driver) {
|
|
|
6856
8693
|
if (rows.length === 0) return null;
|
|
6857
8694
|
return rowToApiKey(rows[0]);
|
|
6858
8695
|
},
|
|
6859
|
-
async listApiKeys() {
|
|
6860
|
-
return (await exec(`
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
8696
|
+
async listApiKeys(filter) {
|
|
8697
|
+
return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
|
|
8698
|
+
WHERE kind = 'personal' AND owner_uid = $1
|
|
8699
|
+
ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
|
|
8700
|
+
SELECT * FROM ${TABLE}
|
|
8701
|
+
WHERE kind = 'service'
|
|
8702
|
+
ORDER BY created_at DESC
|
|
8703
|
+
`)).map((r) => toMasked(rowToApiKey(r)));
|
|
6864
8704
|
},
|
|
6865
8705
|
async getApiKeyById(id) {
|
|
6866
8706
|
const rows = await exec(`SELECT * FROM ${TABLE}
|
|
@@ -6877,13 +8717,13 @@ function createApiKeyStore(driver) {
|
|
|
6877
8717
|
setClauses.push(`name = $${paramIdx++}`);
|
|
6878
8718
|
params.push(updates.name);
|
|
6879
8719
|
}
|
|
6880
|
-
if (updates.
|
|
6881
|
-
setClauses.push(`
|
|
6882
|
-
params.push(JSON.stringify(updates.
|
|
8720
|
+
if (updates.scopes !== void 0) {
|
|
8721
|
+
setClauses.push(`scopes = $${paramIdx++}::jsonb`);
|
|
8722
|
+
params.push(JSON.stringify(updates.scopes));
|
|
6883
8723
|
}
|
|
6884
|
-
if (updates.
|
|
6885
|
-
setClauses.push(`
|
|
6886
|
-
params.push(updates.
|
|
8724
|
+
if (updates.roles !== void 0) {
|
|
8725
|
+
setClauses.push(`roles = $${paramIdx++}::jsonb`);
|
|
8726
|
+
params.push(JSON.stringify(updates.roles));
|
|
6887
8727
|
}
|
|
6888
8728
|
if (updates.rate_limit !== void 0) if (updates.rate_limit !== null) {
|
|
6889
8729
|
setClauses.push(`rate_limit = $${paramIdx++}`);
|
|
@@ -6898,16 +8738,19 @@ function createApiKeyStore(driver) {
|
|
|
6898
8738
|
params.push(id);
|
|
6899
8739
|
const rows = await exec(`UPDATE ${TABLE}
|
|
6900
8740
|
SET ${setClauses.join(", ")}
|
|
6901
|
-
WHERE id = $${paramIdx}
|
|
8741
|
+
WHERE id = $${paramIdx} AND kind = 'service'
|
|
6902
8742
|
RETURNING *`, { params });
|
|
6903
8743
|
if (rows.length === 0) return null;
|
|
6904
8744
|
return toMasked(rowToApiKey(rows[0]));
|
|
6905
8745
|
},
|
|
6906
|
-
async revokeApiKey(id) {
|
|
6907
|
-
return (await exec(`UPDATE ${TABLE}
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
8746
|
+
async revokeApiKey(id, owner_uid) {
|
|
8747
|
+
return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
|
|
8748
|
+
SET revoked_at = NOW(), updated_at = NOW()
|
|
8749
|
+
WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
|
|
8750
|
+
RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
|
|
8751
|
+
SET revoked_at = NOW(), updated_at = NOW()
|
|
8752
|
+
WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
|
|
8753
|
+
RETURNING id`, { params: [id, owner_uid] })).length > 0;
|
|
6911
8754
|
},
|
|
6912
8755
|
async updateLastUsed(id) {
|
|
6913
8756
|
try {
|
|
@@ -6921,62 +8764,156 @@ function createApiKeyStore(driver) {
|
|
|
6921
8764
|
};
|
|
6922
8765
|
}
|
|
6923
8766
|
//#endregion
|
|
6924
|
-
//#region src/auth/api-keys/
|
|
8767
|
+
//#region src/auth/api-keys/key-grant.ts
|
|
6925
8768
|
/**
|
|
6926
|
-
*
|
|
8769
|
+
* What a new or changed key may hold, decided against whoever asked for it.
|
|
6927
8770
|
*
|
|
6928
|
-
*
|
|
6929
|
-
*
|
|
6930
|
-
*
|
|
8771
|
+
* One rule for every door that mints a credential: **nothing is minted with
|
|
8772
|
+
* more than its minter holds.** A key's scopes must be within the creator's
|
|
8773
|
+
* own; a service key's RLS roles must be roles the creator holds, unless the
|
|
8774
|
+
* creator is an admin; and `keys:*` never goes on a key at all.
|
|
6931
8775
|
*
|
|
6932
8776
|
* @module
|
|
6933
8777
|
*/
|
|
8778
|
+
var TARGET_LISTS = {
|
|
8779
|
+
collection: "collections",
|
|
8780
|
+
bucket: "buckets",
|
|
8781
|
+
function: "functions"
|
|
8782
|
+
};
|
|
8783
|
+
/** Read a `scopes` body field: a non-empty array of strings, deduplicated. */
|
|
8784
|
+
function readScopesField(value) {
|
|
8785
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw ApiError.badRequest("scopes must be a non-empty array of scope strings, e.g. [\"data:read\", \"data:write:posts\"]", "INVALID_INPUT");
|
|
8786
|
+
return [...new Set(value.map((entry) => entry.trim()))];
|
|
8787
|
+
}
|
|
8788
|
+
/** Read a `roles` body field: an array of non-empty strings, deduplicated, without `service`. */
|
|
8789
|
+
function readRolesField(value) {
|
|
8790
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) throw ApiError.badRequest("roles must be an array of role names", "INVALID_INPUT");
|
|
8791
|
+
return [...new Set(value.map((entry) => entry.trim()))].filter((role) => role !== "service");
|
|
8792
|
+
}
|
|
6934
8793
|
/**
|
|
6935
|
-
*
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
]
|
|
6948
|
-
for (const
|
|
8794
|
+
* Refuse scopes that are malformed, unknown, aimed at a target that does not
|
|
8795
|
+
* exist, for key management, or beyond what `minterScopes` covers.
|
|
8796
|
+
*/
|
|
8797
|
+
function assertScopesGrantable(requested, minterScopes, model, targets) {
|
|
8798
|
+
const problems = validateScopes(requested, model);
|
|
8799
|
+
if (problems.length > 0) throw new ApiError(400, "INVALID_SCOPES", `Not valid scopes: ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Valid scopes are ${knownScopes(model).join(", ")}; data, storage and function scopes take a target after a second colon, e.g. "data:read:posts".`, {
|
|
8800
|
+
problems,
|
|
8801
|
+
validScopes: knownScopes(model)
|
|
8802
|
+
});
|
|
8803
|
+
const management = requested.filter((entry) => KEY_MANAGEMENT_SCOPES.includes(parseScope(entry)?.scope ?? ""));
|
|
8804
|
+
if (management.length > 0) throw new ApiError(400, "KEY_MANAGEMENT_SCOPE", `${management.join(", ")} cannot go on a key: a key that manages keys could mint its own successor. Manage keys as a person, or with the service key.`, { scopes: management });
|
|
8805
|
+
if (targets) {
|
|
8806
|
+
const unknown = [];
|
|
8807
|
+
for (const entry of requested) {
|
|
8808
|
+
const parsed = parseScope(entry);
|
|
8809
|
+
if (!parsed?.target || !isBuiltInScope(parsed.scope)) continue;
|
|
8810
|
+
const kind = SCOPE_TARGETS[parsed.scope];
|
|
8811
|
+
if (!kind) continue;
|
|
8812
|
+
if (!targets[TARGET_LISTS[kind]]().includes(parsed.target)) unknown.push(entry);
|
|
8813
|
+
}
|
|
8814
|
+
if (unknown.length > 0) throw new ApiError(400, "UNKNOWN_SCOPE_TARGET", `These scopes name something this backend does not serve: ${unknown.join(", ")}. A target is a collection slug, a storage source id or a function name.`, {
|
|
8815
|
+
scopes: unknown,
|
|
8816
|
+
collections: targets.collections(),
|
|
8817
|
+
buckets: targets.buckets(),
|
|
8818
|
+
functions: targets.functions()
|
|
8819
|
+
});
|
|
6949
8820
|
}
|
|
6950
|
-
|
|
8821
|
+
const beyond = scopesBeyond(requested, minterScopes);
|
|
8822
|
+
if (beyond.length > 0) throw new ApiError(403, "SCOPE_EXCEEDS_CREATOR", `A key cannot hold more than the account creating it, and you do not hold ${beyond.join(", ")}.`, { scopes: beyond });
|
|
8823
|
+
}
|
|
8824
|
+
/**
|
|
8825
|
+
* Refuse RLS roles the minter does not hold. An admin may give any role — they
|
|
8826
|
+
* already read every row — and anyone else only their own.
|
|
8827
|
+
*/
|
|
8828
|
+
function assertRolesGrantable(requested, minterRoles) {
|
|
8829
|
+
if (hasAdminRole(minterRoles)) return;
|
|
8830
|
+
const beyond = requested.filter((role) => !minterRoles.includes(role));
|
|
8831
|
+
if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CREATOR", `A key cannot run as a role the account creating it does not hold: ${beyond.join(", ")}.`, { roles: beyond });
|
|
8832
|
+
}
|
|
8833
|
+
/** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
|
|
8834
|
+
function readExpiresAt(value, requireFuture) {
|
|
8835
|
+
if (value === void 0) return void 0;
|
|
8836
|
+
if (value === null) return null;
|
|
8837
|
+
const parsed = typeof value === "string" ? new Date(value) : /* @__PURE__ */ new Date(NaN);
|
|
8838
|
+
if (isNaN(parsed.getTime())) throw ApiError.badRequest("expires_at must be a valid ISO-8601 date", "INVALID_INPUT");
|
|
8839
|
+
if (requireFuture && parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
|
|
8840
|
+
return parsed.toISOString();
|
|
8841
|
+
}
|
|
8842
|
+
/** Read an optional `rate_limit`: absent, null, or a positive integer. */
|
|
8843
|
+
function readRateLimit(value) {
|
|
8844
|
+
if (value === void 0) return void 0;
|
|
8845
|
+
if (value === null) return null;
|
|
8846
|
+
if (typeof value !== "number" || value < 1 || !Number.isInteger(value)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
|
|
8847
|
+
return value;
|
|
8848
|
+
}
|
|
8849
|
+
/** Read a key name: a non-empty string, trimmed. */
|
|
8850
|
+
function readName(value) {
|
|
8851
|
+
if (typeof value !== "string" || value.trim().length === 0) throw ApiError.badRequest("Name is required", "INVALID_INPUT");
|
|
8852
|
+
return value.trim();
|
|
6951
8853
|
}
|
|
8854
|
+
//#endregion
|
|
8855
|
+
//#region src/auth/api-keys/api-key-routes.ts
|
|
6952
8856
|
/**
|
|
6953
|
-
*
|
|
8857
|
+
* Routes for managing API keys.
|
|
8858
|
+
*
|
|
8859
|
+
* - {@link createApiKeyRoutes} — the project's service keys, under
|
|
8860
|
+
* `/api/admin/api-keys`, for holders of `keys:read` / `keys:write`.
|
|
8861
|
+
* - {@link createPersonalKeyRoutes} — the caller's own personal keys, under
|
|
8862
|
+
* `/api/auth/keys`, when the app enables `auth.personalKeys`.
|
|
6954
8863
|
*
|
|
6955
|
-
*
|
|
6956
|
-
*
|
|
6957
|
-
*
|
|
6958
|
-
*
|
|
6959
|
-
*
|
|
6960
|
-
|
|
6961
|
-
|
|
8864
|
+
* All routes return masked keys (never the hash). The full plaintext key is
|
|
8865
|
+
* returned exactly once, in the response that creates it. No API key may call
|
|
8866
|
+
* either router.
|
|
8867
|
+
*
|
|
8868
|
+
* @module
|
|
8869
|
+
*/
|
|
8870
|
+
/**
|
|
8871
|
+
* Refuse API-key-authenticated requests to the key-management routes.
|
|
6962
8872
|
*
|
|
6963
|
-
*
|
|
6964
|
-
*
|
|
8873
|
+
* A key that could manage keys could mint its own successor, widen itself, or
|
|
8874
|
+
* revoke the keys other integrations run on — and revoking it would undo none
|
|
8875
|
+
* of that, because the keys it minted are ordinary rows with no link back to
|
|
8876
|
+
* it. So key management is for a person, or the service key. Reads are
|
|
8877
|
+
* refused alongside writes: a listing of every key's name, prefix and scopes
|
|
8878
|
+
* is reconnaissance for exactly that.
|
|
6965
8879
|
*
|
|
6966
|
-
*
|
|
6967
|
-
*
|
|
6968
|
-
*
|
|
6969
|
-
* edges left to get wrong.
|
|
8880
|
+
* `keys:*` can never be granted to a key either (`key-grant.ts`); this guard is
|
|
8881
|
+
* what makes the rule hold for a key minted before that, and for personal
|
|
8882
|
+
* keys, whose routes need no scope.
|
|
6970
8883
|
*/
|
|
6971
8884
|
var rejectApiKeyAuth = async (c, next) => {
|
|
6972
|
-
if (c.get("apiKey")) throw ApiError.forbidden("API keys cannot manage API keys. Authenticate as
|
|
8885
|
+
if (c.get("apiKey")) throw ApiError.forbidden("API keys cannot manage API keys. Authenticate as a person, or use the service key — REBASE_SERVICE_KEY in this server's environment, or `rebase cloud env reveal REBASE_SERVICE_KEY` on Rebase Cloud.", "API_KEY_SELF_MANAGEMENT_FORBIDDEN");
|
|
6973
8886
|
return next();
|
|
6974
8887
|
};
|
|
8888
|
+
/** The caller's uid and roles, as the auth gate resolved them. */
|
|
8889
|
+
function caller(c) {
|
|
8890
|
+
const user = c.get("user");
|
|
8891
|
+
if (!user || typeof user !== "object" || !("uid" in user) || typeof user.uid !== "string") throw ApiError.unauthenticated("Authentication required");
|
|
8892
|
+
const roles = "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [];
|
|
8893
|
+
const isAnonymous = "isAnonymous" in user && user.isAnonymous === true;
|
|
8894
|
+
return {
|
|
8895
|
+
uid: user.uid,
|
|
8896
|
+
roles,
|
|
8897
|
+
isAnonymous
|
|
8898
|
+
};
|
|
8899
|
+
}
|
|
8900
|
+
async function readBody(c) {
|
|
8901
|
+
let body;
|
|
8902
|
+
try {
|
|
8903
|
+
body = await c.req.json();
|
|
8904
|
+
} catch {
|
|
8905
|
+
throw ApiError.badRequest("Request body must be JSON", "INVALID_INPUT");
|
|
8906
|
+
}
|
|
8907
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw ApiError.badRequest("Request body must be a JSON object", "INVALID_INPUT");
|
|
8908
|
+
return Object.fromEntries(Object.entries(body));
|
|
8909
|
+
}
|
|
6975
8910
|
/**
|
|
6976
|
-
*
|
|
8911
|
+
* The project's service keys: `GET` needs `keys:read`, everything else
|
|
8912
|
+
* `keys:write`.
|
|
6977
8913
|
*/
|
|
6978
8914
|
function createApiKeyRoutes(options) {
|
|
6979
|
-
const { store, serviceKey, resolveRoles, revocationRepo } = options;
|
|
8915
|
+
const { store, serviceKey, resolveRoles, revocationRepo, targets } = options;
|
|
8916
|
+
const model = options.accessModel ?? getAccessModel;
|
|
6980
8917
|
const router = new Hono();
|
|
6981
8918
|
router.onError(errorHandler);
|
|
6982
8919
|
router.use("/*", createRequireAuth({
|
|
@@ -6985,72 +8922,127 @@ function createApiKeyRoutes(options) {
|
|
|
6985
8922
|
revocationRepo
|
|
6986
8923
|
}));
|
|
6987
8924
|
router.use("/*", rejectApiKeyAuth);
|
|
6988
|
-
router.use("/*",
|
|
8925
|
+
router.use("/*", requireScopeByMethod({
|
|
8926
|
+
read: "keys:read",
|
|
8927
|
+
write: "keys:write"
|
|
8928
|
+
}));
|
|
6989
8929
|
router.get("/", async (c) => {
|
|
6990
|
-
const keys = await store.listApiKeys();
|
|
8930
|
+
const keys = await store.listApiKeys({ kind: "service" });
|
|
6991
8931
|
return c.json({ keys });
|
|
6992
8932
|
});
|
|
6993
8933
|
router.post("/", async (c) => {
|
|
6994
|
-
const
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
expires_at: expires_at ?? null
|
|
7014
|
-
};
|
|
7015
|
-
const keyWithSecret = await store.createApiKey(request, createdBy);
|
|
7016
|
-
return c.json({ key: keyWithSecret }, 201);
|
|
8934
|
+
const body = await readBody(c);
|
|
8935
|
+
const name = readName(body.name);
|
|
8936
|
+
const scopes = readScopesField(body.scopes);
|
|
8937
|
+
const roles = body.roles === void 0 ? [] : readRolesField(body.roles);
|
|
8938
|
+
const rate_limit = readRateLimit(body.rate_limit) ?? null;
|
|
8939
|
+
const expires_at = readExpiresAt(body.expires_at, true) ?? null;
|
|
8940
|
+
const minter = caller(c);
|
|
8941
|
+
assertScopesGrantable(scopes, callerScopes(c), model(), targets);
|
|
8942
|
+
assertRolesGrantable(roles, minter.roles);
|
|
8943
|
+
const key = await store.createApiKey({
|
|
8944
|
+
name,
|
|
8945
|
+
kind: "service",
|
|
8946
|
+
scopes,
|
|
8947
|
+
roles,
|
|
8948
|
+
owner_uid: null,
|
|
8949
|
+
rate_limit,
|
|
8950
|
+
expires_at
|
|
8951
|
+
}, minter.uid);
|
|
8952
|
+
return c.json({ key }, 201);
|
|
7017
8953
|
});
|
|
7018
8954
|
router.get("/:id", async (c) => {
|
|
7019
|
-
const
|
|
7020
|
-
|
|
7021
|
-
if (!key) throw ApiError.notFound("API key not found");
|
|
8955
|
+
const key = await store.getApiKeyById(c.req.param("id"));
|
|
8956
|
+
if (!key || key.kind !== "service") throw ApiError.notFound("API key not found");
|
|
7022
8957
|
return c.json({ key });
|
|
7023
8958
|
});
|
|
7024
8959
|
router.put("/:id", async (c) => {
|
|
7025
8960
|
const id = c.req.param("id");
|
|
7026
|
-
const
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
if (
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
if (admin !== void 0 && typeof admin !== "boolean") throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
|
|
7034
|
-
if (rate_limit !== void 0 && rate_limit !== null) {
|
|
7035
|
-
if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
|
|
8961
|
+
const body = await readBody(c);
|
|
8962
|
+
const minter = caller(c);
|
|
8963
|
+
const updates = {};
|
|
8964
|
+
if (body.name !== void 0) updates.name = readName(body.name);
|
|
8965
|
+
if (body.scopes !== void 0) {
|
|
8966
|
+
updates.scopes = readScopesField(body.scopes);
|
|
8967
|
+
assertScopesGrantable(updates.scopes, callerScopes(c), model(), targets);
|
|
7036
8968
|
}
|
|
7037
|
-
if (
|
|
7038
|
-
|
|
7039
|
-
|
|
8969
|
+
if (body.roles !== void 0) {
|
|
8970
|
+
updates.roles = readRolesField(body.roles);
|
|
8971
|
+
assertRolesGrantable(updates.roles, minter.roles);
|
|
7040
8972
|
}
|
|
7041
|
-
const
|
|
7042
|
-
if (
|
|
7043
|
-
|
|
7044
|
-
if (
|
|
7045
|
-
if (rate_limit !== void 0) updates.rate_limit = rate_limit;
|
|
7046
|
-
if (expires_at !== void 0) updates.expires_at = expires_at;
|
|
8973
|
+
const rateLimit = readRateLimit(body.rate_limit);
|
|
8974
|
+
if (rateLimit !== void 0) updates.rate_limit = rateLimit;
|
|
8975
|
+
const expiresAt = readExpiresAt(body.expires_at, false);
|
|
8976
|
+
if (expiresAt !== void 0) updates.expires_at = expiresAt;
|
|
7047
8977
|
const key = await store.updateApiKey(id, updates);
|
|
7048
8978
|
if (!key) throw ApiError.notFound("API key not found");
|
|
7049
8979
|
return c.json({ key });
|
|
7050
8980
|
});
|
|
7051
8981
|
router.delete("/:id", async (c) => {
|
|
7052
|
-
|
|
7053
|
-
|
|
8982
|
+
if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
|
|
8983
|
+
return c.json({ success: true });
|
|
8984
|
+
});
|
|
8985
|
+
return router;
|
|
8986
|
+
}
|
|
8987
|
+
/**
|
|
8988
|
+
* The caller's own keys. Each acts as the caller — their account, their roles
|
|
8989
|
+
* as they are when the key is used — and holds no scope the caller does not.
|
|
8990
|
+
*
|
|
8991
|
+
* For a signed-in account only: not a key, not the service key (it has no
|
|
8992
|
+
* account to act as) and not a guest, whose account is one sign-out from gone.
|
|
8993
|
+
*/
|
|
8994
|
+
function createPersonalKeyRoutes(options) {
|
|
8995
|
+
const { store, enabled, serviceKey, resolveRoles, revocationRepo, targets } = options;
|
|
8996
|
+
const model = options.accessModel ?? getAccessModel;
|
|
8997
|
+
const router = new Hono();
|
|
8998
|
+
router.onError(errorHandler);
|
|
8999
|
+
if (!enabled) {
|
|
9000
|
+
router.all("/*", () => {
|
|
9001
|
+
throw ApiError.forbidden("Personal API keys are switched off on this backend. Set `personalKeys: true` in the auth block of the users collection to let accounts create them.", "PERSONAL_KEYS_DISABLED");
|
|
9002
|
+
});
|
|
9003
|
+
return router;
|
|
9004
|
+
}
|
|
9005
|
+
router.use("/*", createRequireAuth({
|
|
9006
|
+
serviceKey,
|
|
9007
|
+
resolveRoles,
|
|
9008
|
+
revocationRepo
|
|
9009
|
+
}));
|
|
9010
|
+
router.use("/*", rejectApiKeyAuth);
|
|
9011
|
+
router.use("/*", async (c, next) => {
|
|
9012
|
+
const me = caller(c);
|
|
9013
|
+
if (me.uid === "service") throw ApiError.forbidden("The service key has no account for a personal key to act as. Create a service key under /api/admin/api-keys instead.", "PERSONAL_KEY_NEEDS_ACCOUNT");
|
|
9014
|
+
if (me.isAnonymous) throw ApiError.forbidden("A guest session cannot create API keys. Sign in to an account first.", "PERSONAL_KEY_NEEDS_ACCOUNT");
|
|
9015
|
+
return next();
|
|
9016
|
+
});
|
|
9017
|
+
router.get("/", async (c) => {
|
|
9018
|
+
const keys = await store.listApiKeys({
|
|
9019
|
+
kind: "personal",
|
|
9020
|
+
owner_uid: caller(c).uid
|
|
9021
|
+
});
|
|
9022
|
+
return c.json({ keys });
|
|
9023
|
+
});
|
|
9024
|
+
router.post("/", async (c) => {
|
|
9025
|
+
const body = await readBody(c);
|
|
9026
|
+
if (body.roles !== void 0) throw ApiError.badRequest("A personal key runs as your account's roles and takes none of its own.", "INVALID_INPUT");
|
|
9027
|
+
if (body.rate_limit !== void 0) throw ApiError.badRequest("A personal key uses the server's API-key rate limit and cannot set its own.", "INVALID_INPUT");
|
|
9028
|
+
const name = readName(body.name);
|
|
9029
|
+
const scopes = readScopesField(body.scopes);
|
|
9030
|
+
const expires_at = readExpiresAt(body.expires_at, true) ?? null;
|
|
9031
|
+
const me = caller(c);
|
|
9032
|
+
assertScopesGrantable(scopes, callerScopes(c), model(), targets);
|
|
9033
|
+
const key = await store.createApiKey({
|
|
9034
|
+
name,
|
|
9035
|
+
kind: "personal",
|
|
9036
|
+
scopes,
|
|
9037
|
+
roles: [],
|
|
9038
|
+
owner_uid: me.uid,
|
|
9039
|
+
rate_limit: null,
|
|
9040
|
+
expires_at
|
|
9041
|
+
}, me.uid);
|
|
9042
|
+
return c.json({ key }, 201);
|
|
9043
|
+
});
|
|
9044
|
+
router.delete("/:id", async (c) => {
|
|
9045
|
+
if (!await store.revokeApiKey(c.req.param("id"), caller(c).uid)) throw ApiError.notFound("API key not found or already revoked");
|
|
7054
9046
|
return c.json({ success: true });
|
|
7055
9047
|
});
|
|
7056
9048
|
return router;
|
|
@@ -7101,12 +9093,16 @@ function createCustomAuthAdapter(options) {
|
|
|
7101
9093
|
//#endregion
|
|
7102
9094
|
//#region src/auth/index.ts
|
|
7103
9095
|
var auth_exports = /* @__PURE__ */ __exportAll({
|
|
9096
|
+
AccessModelError: () => AccessModelError,
|
|
7104
9097
|
DEFAULT_CAPTCHA_ROUTES: () => DEFAULT_CAPTCHA_ROUTES,
|
|
7105
9098
|
MemoryRateLimitStore: () => MemoryRateLimitStore,
|
|
7106
9099
|
RateLimitStoreConfigurationError: () => RateLimitStoreConfigurationError,
|
|
9100
|
+
accessModelFromCollections: () => accessModelFromCollections,
|
|
7107
9101
|
apiKeyKeyGenerator: () => apiKeyKeyGenerator,
|
|
7108
9102
|
buildBuiltinAuthCapabilities: () => buildBuiltinAuthCapabilities,
|
|
7109
9103
|
buildCaptchaMiddlewares: () => buildCaptchaMiddlewares,
|
|
9104
|
+
callerScopes: () => callerScopes,
|
|
9105
|
+
configureAccess: () => configureAccess,
|
|
7110
9106
|
configureJwt: () => configureJwt,
|
|
7111
9107
|
createAdapterAuthMiddleware: () => createAdapterAuthMiddleware,
|
|
7112
9108
|
createApiKeyRoutes: () => createApiKeyRoutes,
|
|
@@ -7128,6 +9124,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7128
9124
|
createJwksRoutes: () => createJwksRoutes,
|
|
7129
9125
|
createLinkedinProvider: () => createLinkedinProvider,
|
|
7130
9126
|
createMicrosoftProvider: () => createMicrosoftProvider,
|
|
9127
|
+
createPersonalKeyRoutes: () => createPersonalKeyRoutes,
|
|
7131
9128
|
createRateLimiter: () => createRateLimiter,
|
|
7132
9129
|
createResetPasswordRoute: () => createResetPasswordRoute,
|
|
7133
9130
|
createSlackProvider: () => createSlackProvider,
|
|
@@ -7144,17 +9141,18 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7144
9141
|
generateRefreshToken: () => generateRefreshToken,
|
|
7145
9142
|
generateSecurePassword: () => generateSecurePassword,
|
|
7146
9143
|
generateSecureToken: () => generateSecureToken,
|
|
9144
|
+
getAccessModel: () => getAccessModel,
|
|
7147
9145
|
getAccessTokenExpiry: () => getAccessTokenExpiry,
|
|
7148
9146
|
getJwks: () => getJwks,
|
|
7149
9147
|
getRefreshTokenExpiry: () => getRefreshTokenExpiry,
|
|
7150
9148
|
hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
|
|
9149
|
+
hasScope: () => hasScope,
|
|
7151
9150
|
hashPassword: () => hashPassword,
|
|
7152
9151
|
hashRefreshToken: () => hashRefreshToken,
|
|
7153
|
-
hashToken: () => hashToken
|
|
9152
|
+
hashToken: () => hashToken,
|
|
7154
9153
|
httpMethodToOperation: () => httpMethodToOperation,
|
|
7155
9154
|
isApiKeyToken: () => isApiKeyToken,
|
|
7156
9155
|
isJwtConfigured: () => isJwtConfigured,
|
|
7157
|
-
isOperationAllowed: () => isOperationAllowed,
|
|
7158
9156
|
isRedirectUriAllowed: () => isRedirectUriAllowed,
|
|
7159
9157
|
mountMagicLinkRoutes: () => mountMagicLinkRoutes,
|
|
7160
9158
|
oauthCodeFlowSchema: () => oauthCodeFlowSchema,
|
|
@@ -7165,6 +9163,9 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7165
9163
|
queryTokenAuth: () => queryTokenAuth,
|
|
7166
9164
|
requireAdmin: () => requireAdmin,
|
|
7167
9165
|
requireAuth: () => requireAuth,
|
|
9166
|
+
requireScope: () => requireScope,
|
|
9167
|
+
requireScopeByMethod: () => requireScopeByMethod,
|
|
9168
|
+
resolveApiKey: () => resolveApiKey,
|
|
7168
9169
|
resolveAuthHooks: () => resolveAuthHooks,
|
|
7169
9170
|
resolveCaptchaVerifier: () => resolveCaptchaVerifier,
|
|
7170
9171
|
resolveRateLimitStoreKind: () => resolveRateLimitStoreKind,
|
|
@@ -7179,6 +9180,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7179
9180
|
verifyPassword: () => verifyPassword
|
|
7180
9181
|
});
|
|
7181
9182
|
//#endregion
|
|
7182
|
-
export {
|
|
9183
|
+
export { queryTokenAuth as $, createDataRateLimiter as A, getEmailChangeNoticeTemplate as At, SMTPEmailService as B, escapeHtml as Bt, pkceTokenParams as C, scopeDataDriver as Ct, isBootstrapWindowOpen as D, generateSecurePassword as Dt, createBuiltinAuthAdapter as E, completeUserCreation as Et, activeDevEmailSink as F, getPasswordResetTemplate as Ft, verifyPassword as G, isPublicStorageKey as Gt, resolveAuthHooks as H, raw as Ht, clearActiveDevEmailSink as I, getUserInvitationTemplate as It, createRequireAuth as J, IMPERSONATE_HEADER as Jt, createAdapterAuthMiddleware as K, isPublicStoragePath as Kt, createDevEmailSink as L, getWelcomeEmailTemplate as Lt, defaultAuthLimiter as M, getEmailOtpTemplate as Mt, setSharedRateLimitStore as N, getEmailVerificationTemplate as Nt, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as O, assertEmailLinkBases as Ot, MemoryRateLimitStore as P, getMagicLinkTemplate as Pt, publicObjectAuth as Q, extractLinks as R, resolveEmailBranding as Rt, oauthCodeFlowSchema as S, SERVICE_IDENTITY as St, createJwksRoutes as T, isAccessTokenRevoked as Tt, hashPassword as U, httpMethodToOperation as Ut, createEmailService as V, html as Vt, validatePasswordStrength as W, PUBLIC_STORAGE_PREFIX as Wt, fileTokenAuth as X, extractUserFromToken as Y, optionalAuth as Z, tryVerifyOidcIdToken as _, hasScope as _t, createApiKeyStore as a, decideImpersonation as at, createLinkedinProvider as b, extractBearerToken as bt, createSpotifyProvider as c, createFunctionScopeGuard as ct, createGitLabProvider as d, resolveApiKey as dt, requireAdmin as et, createDiscordProvider as f, validateApiKey as ft, createMicrosoftProvider as g, getAccessModel as gt, createAppleProvider as h, configureAccess as ht, createPersonalKeyRoutes as i, storageRequestWildcard as it, createRateLimiter as j, getEmailChangeTemplate as jt, createDataRateLimitCheck as k, resolveEmailLinkBase as kt, createSlackProvider as l, createTusScopeGuard as lt, createFacebookProvider as m, callerScopes as mt, createCustomAuthAdapter as n, requestedStorageObject as nt, resolveRateLimitStoreKind as o, refuseUnhonouredImpersonation as ot, createTwitterProvider as p, accessModelFromCollections as pt, createAuthMiddleware as q, storageObjectPath as qt, createApiKeyRoutes as r, storageObjectOfPath as rt, createSqlRateLimitStore as s, createApiKeyPreAuth as st, auth_exports as t, requireAuth as tt, createBitbucketProvider as u, isApiKeyToken as ut, verifyOidcIdToken as v, requireScope as vt, providerVerifiedEmail as w, supportsRlsScoping as wt, createGoogleProvider as x, safeCompare as xt, createGitHubProvider as y, requireScopeByMethod as yt, registerDevEmailSink as z, RawHtml as zt };
|
|
7183
9184
|
|
|
7184
|
-
//# sourceMappingURL=auth-
|
|
9185
|
+
//# sourceMappingURL=auth-DMLngxn_.js.map
|