@rebasepro/server 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{GCSStorageController-CRd9bFrc.js → GCSStorageController-40FyFeSx.js} +6 -2
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-D00EFa5P.js → S3StorageController-Bl5shp94.js} +6 -2
- package/dist/S3StorageController-Bl5shp94.js.map +1 -0
- package/dist/access-BFAUvsSs.js +1897 -0
- package/dist/access-BFAUvsSs.js.map +1 -0
- package/dist/api/branch-routes.d.ts +25 -0
- package/dist/api/live-schema-routes.d.ts +10 -0
- package/dist/api/rest/api-generator.d.ts +9 -1
- package/dist/api/rest/idempotency.d.ts +15 -1
- package/dist/api/root-error-handler.d.ts +6 -5
- package/dist/api/types.d.ts +12 -0
- package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
- package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
- package/dist/auth/access.d.ts +10 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/adapter-service-key.d.ts +24 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
- package/dist/auth/api-keys/api-key-store.d.ts +8 -1
- package/dist/auth/api-keys/key-grant.d.ts +18 -1
- package/dist/auth/expired-token-sweep.d.ts +5 -0
- package/dist/auth/impersonation.d.ts +3 -1
- package/dist/auth/jwt.d.ts +8 -2
- package/dist/auth/oauth-signin-policy.d.ts +21 -35
- package/dist/auth/rls-scope.d.ts +17 -7
- package/dist/auth/rls-simulator-routes.d.ts +51 -0
- package/dist/auth/simulation-read-only.d.ts +28 -0
- package/dist/auth/simulation.d.ts +141 -0
- package/dist/auth/sql-rate-limit-store.d.ts +15 -1
- package/dist/{auth-Dkvo2tTJ.js → auth-izI7AEzC.js} +370 -1880
- package/dist/auth-izI7AEzC.js.map +1 -0
- package/dist/auth_adapter-B7RUMk6e.js +89 -0
- package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
- package/dist/backend/create-backend.d.ts +6 -0
- package/dist/backend/options.d.ts +9 -4
- package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
- package/dist/backend-CaG_Jyso.js.map +1 -0
- package/dist/{backup-B9m3cb3X.js → backup-BVbHoKo8.js} +6 -6
- package/dist/{backup-B9m3cb3X.js.map → backup-BVbHoKo8.js.map} +1 -1
- package/dist/boot/boot.d.ts +2 -15
- package/dist/boot/ddl-bootstrap.d.ts +34 -0
- package/dist/boot/env.d.ts +6 -0
- package/dist/boot/fetch-bundle.d.ts +5 -1
- package/dist/boot/provision-only.d.ts +23 -0
- package/dist/boot/provision.d.ts +53 -2
- package/dist/boot/role.d.ts +2 -0
- package/dist/boot/schema-change-recorder.d.ts +45 -0
- package/dist/boot/security-headers.d.ts +22 -0
- package/dist/branch-routes-B2vvEx53.js +84 -0
- package/dist/branch-routes-B2vvEx53.js.map +1 -0
- package/dist/{contract-routes-CsFXW7j4.js → contract-routes-CU2A3JnF.js} +2 -2
- package/dist/{contract-routes-CsFXW7j4.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-Dj6lA4HQ.js → cron-loader-mMhqbVsl.js} +2 -2
- package/dist/{cron-loader-Dj6lA4HQ.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-CwbLZguO.js → cron-routes-Cs33j_Kr.js} +3 -3
- package/dist/{cron-routes-CwbLZguO.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-B04ACoUE.js → cron-scheduler-Be7CNghL.js} +2 -2
- package/dist/{cron-scheduler-B04ACoUE.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DkyfuLOH.js → cron-store-DLn11M8H.js} +10 -9
- package/dist/cron-store-DLn11M8H.js.map +1 -0
- package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
- package/dist/data_driver-D0mg1Y_x.js.map +1 -0
- package/dist/{ddl-bootstrap-Ck20GSIX.js → ddl-bootstrap-CjRrn12l.js} +91 -2
- package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/email/dev-sink.d.ts +6 -0
- package/dist/email/index.d.ts +1 -1
- package/dist/{soft-delete-params-BmlVwecC.js → field-access-DYsvjI_P.js} +27 -57
- package/dist/field-access-DYsvjI_P.js.map +1 -0
- package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
- package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
- package/dist/{function-loader-KKDqTcIr.js → function-loader-B8YzXEsQ.js} +21 -14
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +19 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +3 -3
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/history/retention-sweep.d.ts +5 -0
- package/dist/{history-recorder-B2rkuhuy.js → history-recorder-Bsau7h4X.js} +6 -6
- package/dist/history-recorder-Bsau7h4X.js.map +1 -0
- package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
- package/dist/history-store-BQz5YCkW.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.es.js +2449 -543
- package/dist/index.es.js.map +1 -1
- package/dist/init/process-safety.d.ts +4 -3
- package/dist/init/shutdown.d.ts +8 -0
- package/dist/init.d.ts +6 -1
- package/dist/jobs/job-store.d.ts +8 -1
- package/dist/{jobs-h1WRq3cp.js → jobs-Co6Ni5HP.js} +6 -6
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-D3oFauZI.js → jwt-rDS0884_.js} +4 -3
- package/dist/{jwt-D3oFauZI.js.map → jwt-rDS0884_.js.map} +1 -1
- package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
- package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
- package/dist/mcp/consent-page.d.ts +38 -1
- package/dist/mcp/mcp-routes.d.ts +9 -0
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-store.d.ts +12 -5
- package/dist/metrics/history-recorder.d.ts +4 -1
- package/dist/metrics/history-store.d.ts +8 -0
- package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
- package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
- package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
- package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
- package/dist/{openapi-generator-BjHr6upl.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-CH8Jsovg.js → proxy-BYir8tWK.js} +4 -6
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-otFZqJsC.js → query-parser-CDXWf2hH.js} +6 -5
- package/dist/{query-parser-otFZqJsC.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-59crS-9u.js → request-timeout-DVjo-c_8.js} +4 -4
- package/dist/request-timeout-DVjo-c_8.js.map +1 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
- package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
- package/dist/{schema-editor-routes-Bsut0bCC.js → schema-editor-routes-Dc3DU_jm.js} +3 -3
- package/dist/{schema-editor-routes-Bsut0bCC.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
- package/dist/soft-delete-params-BRONCOA2.js +59 -0
- package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
- package/dist/{sql-export-routes-DmB_uZ4l.js → sql-export-routes-QycX8W8S.js} +38 -4
- package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
- package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
- package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +8 -3
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/maintenance-keys.d.ts +17 -0
- package/dist/storage/maintenance-routes.d.ts +28 -5
- package/dist/storage/maintenance.d.ts +136 -4
- package/dist/storage/orphan-scans.d.ts +120 -0
- package/dist/storage/types.d.ts +11 -0
- package/dist/strings.es-B7QLnHh2.js +96 -0
- package/dist/strings.es-B7QLnHh2.js.map +1 -0
- package/dist/upload-validation-Bgo626uY.js.map +1 -1
- package/package.json +5 -5
- package/dist/GCSStorageController-CRd9bFrc.js.map +0 -1
- package/dist/S3StorageController-D00EFa5P.js.map +0 -1
- package/dist/auth-Dkvo2tTJ.js.map +0 -1
- package/dist/auth_adapter-CIItCTc8.js +0 -21
- package/dist/auth_adapter-CIItCTc8.js.map +0 -1
- package/dist/backend-DTAOsLQc.js.map +0 -1
- package/dist/cron-store-DkyfuLOH.js.map +0 -1
- package/dist/ddl-bootstrap-Ck20GSIX.js.map +0 -1
- package/dist/function-loader-KKDqTcIr.js.map +0 -1
- package/dist/history-recorder-B2rkuhuy.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-h1WRq3cp.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BjHr6upl.js.map +0 -1
- package/dist/proxy-CH8Jsovg.js.map +0 -1
- package/dist/request-timeout-59crS-9u.js.map +0 -1
- package/dist/soft-delete-params-BmlVwecC.js.map +0 -1
- package/dist/sql-export-routes-DmB_uZ4l.js.map +0 -1
- package/dist/strings.es-BeZWAodn.js.map +0 -1
|
@@ -3,20 +3,18 @@ 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 { B as rewriteLegacyRlsFunctions, _ 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-D5Y3N-S4.js";
|
|
6
|
+
import { V as rewriteLegacyRlsFunctions, _ as scopesBeyond, a as KEY_MANAGEMENT_SCOPES, f as knownScopes, l as isAdminScope, m as roleNameProblem, o as SCOPE_TARGETS, p as parseScope, s as hasAdminRole, u as isBuiltInScope, x as validateScopes, y as summarizeRoles } from "./src-Bh9LRPb9.js";
|
|
8
7
|
import { n as isAnonymousUid, r as policy, t as ANONYMOUS_USER_ID } from "./policy-PdzX-Opd.js";
|
|
9
|
-
import {
|
|
10
|
-
import { t as
|
|
8
|
+
import { n as isSQLAdmin } from "./backend-CaG_Jyso.js";
|
|
9
|
+
import { $ as accessTokenRefusalError, C as queryTokenAuth, E as requireAuth, G as isApiKeyToken, J as extractBearerToken, K as resolveApiKey, L as applySimulation, P as applyImpersonation, T as requireAdmin, X as SERVICE_IDENTITY, Y as safeCompare, Z as scopeDataDriver, _ as createAuthMiddleware, a as assertMayManageAccount, at as httpMethodToOperation, b as fileTokenAuth, c as configureAccess, f as processAccessControl, h as requireScopeByMethod, i as assertMayGrantRoles, l as getAccessModel, m as requireScope, n as accessModelFromCollections, nt as replaceUserPassword, o as callerIsAdmin, p as readRoleNames, q as validateApiKey, r as accessOf, rt as revokeAllSessions, s as callerScopes, t as AccessModelError, tt as judgeAccessToken, u as hasScope, v as createRequireAuth, w as refuse, x as optionalAuth, y as extractUserFromToken } from "./access-BFAUvsSs.js";
|
|
11
10
|
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
11
|
+
import { r as toSnakeCase } from "./strings.es-B7QLnHh2.js";
|
|
12
12
|
import { n as revokeInternalTableSql, t as REBASE_USER_ROLE } from "./internal-tables-L2t39RdT.js";
|
|
13
13
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
14
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
14
|
+
import { a as resolveListLimitParam } from "./query-parser-CDXWf2hH.js";
|
|
15
|
+
import { _ as verifyAccessToken, a as generateAccessToken, c as getAccessTokenExpiry, d as hasAsymmetricSigningKey, f as hashRefreshToken, h as processJwtService, i as customClaimsOf, l as getJwks, m as jwtOf, n as configureJwt, o as generateDownloadToken, p as isJwtConfigured, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as randomInt$1, x as require_jsonwebtoken } from "./jwt-rDS0884_.js";
|
|
16
|
+
import { c as provisioningExec, n as SchemaChangeRefusedError, r as createDdlBootstrapper } from "./ddl-bootstrap-CjRrn12l.js";
|
|
15
17
|
import { a as isDataException, c as warnIfDatabaseClosed, i as errorHandler, o as isDatabaseUnreachable, t as ApiError } from "./errors-OMWkKSH-.js";
|
|
16
|
-
import { E as sha256Hex, S as constantTimeEqual, _ as verifyAccessToken, a as generateAccessToken, c as getAccessTokenExpiry, d as hasAsymmetricSigningKey, f as hashRefreshToken, h as processJwtService, i as customClaimsOf, l as getJwks, m as jwtOf, n as configureJwt, o as generateDownloadToken, p as isJwtConfigured, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as randomInt$1, x as require_jsonwebtoken } from "./jwt-D3oFauZI.js";
|
|
17
|
-
import { a as canonicalStorageId, o as canonicalStorageKey, r as InvalidStorageKeyError } from "./keys-COzQ2aE8.js";
|
|
18
|
-
import { n as scopeGuardProblem } from "./admin-roles-DI9RsgN3.js";
|
|
19
|
-
import { n as createDdlBootstrapper } from "./ddl-bootstrap-Ck20GSIX.js";
|
|
20
18
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
21
19
|
import { Hono } from "hono";
|
|
22
20
|
import { promisify } from "util";
|
|
@@ -24,108 +22,6 @@ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHma
|
|
|
24
22
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
25
23
|
import { createMiddleware } from "hono/factory";
|
|
26
24
|
import { z } from "zod";
|
|
27
|
-
//#region ../types/src/controllers/storage.ts
|
|
28
|
-
/**
|
|
29
|
-
* Path prefix that marks an object as **public**. Files stored under this
|
|
30
|
-
* prefix are served without any auth token via a stable, permanent,
|
|
31
|
-
* CDN-cacheable URL (see {@link StorageSource.getSignedUrl}). Shared by the
|
|
32
|
-
* client SDK and the backend so both agree on which objects are public.
|
|
33
|
-
*
|
|
34
|
-
* @group Models
|
|
35
|
-
*/
|
|
36
|
-
var PUBLIC_STORAGE_PREFIX = "public/";
|
|
37
|
-
/**
|
|
38
|
-
* True when a storage **key** is public: it lies under
|
|
39
|
-
* {@link PUBLIC_STORAGE_PREFIX}.
|
|
40
|
-
*
|
|
41
|
-
* The key is read exactly as it is — the canonical key within its bucket, the
|
|
42
|
-
* one the server stores, serves, and hands a `storageAuthorize` hook as
|
|
43
|
-
* `key` — and nothing is parsed out of it. That is the difference from
|
|
44
|
-
* {@link isPublicStoragePath}, which reads a client-side *path* and so strips
|
|
45
|
-
* a `scheme://` and a leading `default/` bucket segment first. On a key both are
|
|
46
|
-
* wrong: `default/public/x` is a key in a folder called `default`, and a
|
|
47
|
-
* request path `notes://public/x` names the key `notes:/public/x`. Neither is
|
|
48
|
-
* public, and the server decides on this function so that it never says they
|
|
49
|
-
* are.
|
|
50
|
-
*
|
|
51
|
-
* @group Models
|
|
52
|
-
*/
|
|
53
|
-
function isPublicStorageKey(key) {
|
|
54
|
-
if (!key || !key.startsWith("public/")) return false;
|
|
55
|
-
return !key.split(/[\\/]/).includes("..");
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* True when a storage key or client-side path points at a public object
|
|
59
|
-
* (lives under {@link PUBLIC_STORAGE_PREFIX}).
|
|
60
|
-
*
|
|
61
|
-
* For a path as a client holds it: a `scheme://` prefix and a single leading
|
|
62
|
-
* `default/` bucket segment are stripped first. The server and a
|
|
63
|
-
* `storageAuthorize` hook, which hold the canonical key, use
|
|
64
|
-
* {@link isPublicStorageKey}.
|
|
65
|
-
*
|
|
66
|
-
* @group Models
|
|
67
|
-
*/
|
|
68
|
-
function isPublicStoragePath(path) {
|
|
69
|
-
if (!path) return false;
|
|
70
|
-
let p = path;
|
|
71
|
-
const scheme = p.indexOf("://");
|
|
72
|
-
if (scheme !== -1) p = p.substring(scheme + 3);
|
|
73
|
-
p = p.replace(/^\/+/, "");
|
|
74
|
-
if (p.split("/").some((seg) => seg === "..")) return false;
|
|
75
|
-
return p.startsWith("public/") || p.startsWith(`default/public/`);
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* The path the storage routes address an object by, from a key or from the
|
|
79
|
-
* `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
|
|
80
|
-
* property with `includeBucketUrl: true` keeps in its column).
|
|
81
|
-
*
|
|
82
|
-
* The routes address a source's objects by key. On S3 and GCS the bucket in a
|
|
83
|
-
* `storageUrl` is always the source's own — the server refuses any other — so
|
|
84
|
-
* it is dropped: sent along, `s3://acme-media/products/a.png` reached the
|
|
85
|
-
* server as the key `acme-media/products/a.png`, the preview found nothing and
|
|
86
|
-
* a delete reported success while the object stayed. It worked on local disk
|
|
87
|
-
* only, because `local://default/<key>` happens to carry the one bucket
|
|
88
|
-
* segment the routes recognise — which is kept, as is any other local bucket.
|
|
89
|
-
*
|
|
90
|
-
* A bare key whose first folder is `default` is sent behind that bucket
|
|
91
|
-
* segment. The routes read one leading `default/` (in any case) as the bucket,
|
|
92
|
-
* so the key `default/photo.png` sent as it is names the root `photo.png`:
|
|
93
|
-
* {@link defaultBucketPath} is the spelling that names the key itself.
|
|
94
|
-
*
|
|
95
|
-
* Shared by every storage source that talks to those routes, so they address
|
|
96
|
-
* an object alike.
|
|
97
|
-
*
|
|
98
|
-
* @group Models
|
|
99
|
-
*/
|
|
100
|
-
function storageObjectPath(keyOrUrl, bucket) {
|
|
101
|
-
let filePath = keyOrUrl;
|
|
102
|
-
let bucketQualified = false;
|
|
103
|
-
const scheme = /^(local|s3|gs):\/\//.exec(filePath);
|
|
104
|
-
if (scheme) {
|
|
105
|
-
filePath = filePath.substring(scheme[0].length);
|
|
106
|
-
if (scheme[1] !== "local") {
|
|
107
|
-
const slash = filePath.indexOf("/");
|
|
108
|
-
if (slash > 0) filePath = filePath.substring(slash + 1);
|
|
109
|
-
} else bucketQualified = true;
|
|
110
|
-
}
|
|
111
|
-
if (bucket && filePath) {
|
|
112
|
-
if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
|
|
113
|
-
bucketQualified = true;
|
|
114
|
-
}
|
|
115
|
-
return bucketQualified ? filePath : defaultBucketPath(filePath);
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* The request path of a key in the default bucket: the key itself, unless the
|
|
119
|
-
* routes would read its first folder as the bucket — a first folder named
|
|
120
|
-
* `default`, matched as the routes match it (case-insensitively, and only when
|
|
121
|
-
* something follows it). Then the bucket segment goes in front, so exactly the
|
|
122
|
-
* one segment the routes strip is the one added.
|
|
123
|
-
*/
|
|
124
|
-
function defaultBucketPath(key) {
|
|
125
|
-
const segments = key.split("/");
|
|
126
|
-
return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
|
|
127
|
-
}
|
|
128
|
-
//#endregion
|
|
129
25
|
//#region ../common/src/util/email.ts
|
|
130
26
|
/**
|
|
131
27
|
* Email normalization — one implementation, because the database enforces it.
|
|
@@ -637,26 +533,188 @@ function checkOperation(collection, authContext, entity, targetOperation, option
|
|
|
637
533
|
return hasPermissive ? grantedByPermissive : false;
|
|
638
534
|
}
|
|
639
535
|
//#endregion
|
|
640
|
-
//#region src/auth/
|
|
536
|
+
//#region src/auth/resolve-rate-limit-store.ts
|
|
537
|
+
/**
|
|
538
|
+
* A refusal to boot, phrased as the variable to change.
|
|
539
|
+
*
|
|
540
|
+
* Its own class so `bootFromBundle` can present it as a configuration problem
|
|
541
|
+
* rather than a crash, matching `RoleConfigurationError`.
|
|
542
|
+
*/
|
|
543
|
+
var RateLimitStoreConfigurationError = class extends Error {
|
|
544
|
+
hint;
|
|
545
|
+
constructor(message, hint) {
|
|
546
|
+
super(message);
|
|
547
|
+
this.hint = hint;
|
|
548
|
+
this.name = "RateLimitStoreConfigurationError";
|
|
549
|
+
}
|
|
550
|
+
};
|
|
641
551
|
/**
|
|
642
|
-
*
|
|
552
|
+
* Which store this process should build.
|
|
643
553
|
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
554
|
+
* A misspelled value **refuses to boot** rather than falling back to memory.
|
|
555
|
+
* Every other outcome here is silent — the whole failure mode of this setting is
|
|
556
|
+
* that nothing looks wrong — so the one moment it can be caught is the moment it
|
|
557
|
+
* is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
|
|
558
|
+
* would otherwise be a per-replica limit wearing the shape of a shared one.
|
|
647
559
|
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
560
|
+
* A blank or whitespace-only value is *unset*, not invalid:
|
|
561
|
+
* `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
|
|
562
|
+
* ordinary way to write a compose file, and it is how the platform neutralises a
|
|
563
|
+
* tenant's own variable.
|
|
650
564
|
*/
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
565
|
+
function resolveRateLimitStoreKind(env) {
|
|
566
|
+
const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
|
|
567
|
+
if (raw === "") return "memory";
|
|
568
|
+
if (raw === "memory" || raw === "sql") return raw;
|
|
569
|
+
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.");
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
572
|
+
//#region src/auth/sql-rate-limit-store.ts
|
|
573
|
+
var SCHEMA = "rebase";
|
|
574
|
+
var TABLE_NAME = "rate_limit_hits";
|
|
575
|
+
var TABLE$1 = `${SCHEMA}.${TABLE_NAME}`;
|
|
576
|
+
/** How often expired buckets are deleted. */
|
|
577
|
+
var DEFAULT_SWEEP_MS = 300 * 1e3;
|
|
578
|
+
function createSqlRateLimitStore(driver, options = {}) {
|
|
579
|
+
const admin = driver.admin;
|
|
580
|
+
if (!isSQLAdmin(admin)) {
|
|
581
|
+
logger.warn("⚠️ [rate-limit] The configured driver has no SQL admin, so rate-limit counts cannot be shared between processes. Falling back to this process's memory — each replica will enforce the limit independently.");
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const exec = provisioningExec((sqlText, execOptions) => admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
|
|
585
|
+
const ddl = createDdlBootstrapper(exec, "rate-limit-store");
|
|
586
|
+
const now = options.now ?? Date.now;
|
|
587
|
+
const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
|
|
588
|
+
let sweepTimer;
|
|
589
|
+
let ready;
|
|
590
|
+
/**
|
|
591
|
+
* Create the table once, and remember whether it worked.
|
|
592
|
+
*
|
|
593
|
+
* Lazily rather than at boot because the store is constructed while routes
|
|
594
|
+
* are being mounted, and an `await` there would put a database round trip on
|
|
595
|
+
* the boot path of every deployment — including the ones that never take a
|
|
596
|
+
* request. The first request pays for it.
|
|
597
|
+
*
|
|
598
|
+
* The result is cached even on failure: a store that cannot create its table
|
|
599
|
+
* must not retry the DDL on every request, which would turn one broken
|
|
600
|
+
* permission into a per-request query storm.
|
|
601
|
+
*
|
|
602
|
+
* Readiness is decided by reading the table, never by the DDL not throwing —
|
|
603
|
+
* `ensureObject` routes through `step`, which logs a failure and returns
|
|
604
|
+
* normally. Keying on the absence of an exception would report a table that
|
|
605
|
+
* was never created as ready, and every later request would fall into the
|
|
606
|
+
* per-request error path instead of the one-time fallback.
|
|
607
|
+
*/
|
|
608
|
+
async function ensureReady() {
|
|
609
|
+
if (!ready) ready = (async () => {
|
|
610
|
+
try {
|
|
611
|
+
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
612
|
+
await ddl.ensureObject(`Creating ${TABLE$1}`, `
|
|
613
|
+
CREATE TABLE IF NOT EXISTS ${TABLE$1} (
|
|
614
|
+
key TEXT NOT NULL,
|
|
615
|
+
bucket BIGINT NOT NULL,
|
|
616
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
617
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
618
|
+
PRIMARY KEY (key, bucket)
|
|
619
|
+
)
|
|
620
|
+
`);
|
|
621
|
+
await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
|
|
622
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
|
|
623
|
+
ON ${TABLE$1}(expires_at)
|
|
624
|
+
`);
|
|
625
|
+
const readable = await ddl.isReadable(TABLE$1);
|
|
626
|
+
if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
|
|
627
|
+
else if (options.provision === false) logger.error(`[rate-limit] ${TABLE$1} does not exist, and this backend changes nothing in its database, so counts stay per-process. Every replica will enforce the limit independently until a provisioning run creates the table.`);
|
|
628
|
+
else logger.error("[rate-limit] The shared rate-limit table could not be created, so counts stay per-process. Every replica will enforce the limit independently.");
|
|
629
|
+
return readable;
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logger.error("[rate-limit] Could not prepare the shared rate-limit table. Falling back to per-process counts for the life of this process.", { error: err instanceof Error ? err.message : String(err) });
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
})();
|
|
635
|
+
return ready;
|
|
658
636
|
}
|
|
637
|
+
function startSweeping() {
|
|
638
|
+
if (sweepTimer) return;
|
|
639
|
+
sweepTimer = setInterval(() => {
|
|
640
|
+
exec(`DELETE FROM ${TABLE$1} WHERE expires_at < now()`).catch((err) => {
|
|
641
|
+
logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
|
|
642
|
+
});
|
|
643
|
+
}, sweepMs);
|
|
644
|
+
sweepTimer.unref?.();
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
ensureTable: ensureReady,
|
|
648
|
+
async hit(key, windowMs, limit) {
|
|
649
|
+
if (!await ensureReady()) return {
|
|
650
|
+
allowed: true,
|
|
651
|
+
remaining: limit,
|
|
652
|
+
retryAfterMs: 0
|
|
653
|
+
};
|
|
654
|
+
startSweeping();
|
|
655
|
+
const at = now();
|
|
656
|
+
const bucket = Math.floor(at / windowMs);
|
|
657
|
+
const carry = 1 - (at - bucket * windowMs) / windowMs;
|
|
658
|
+
const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
|
|
659
|
+
let current;
|
|
660
|
+
let previous;
|
|
661
|
+
try {
|
|
662
|
+
const row = firstRow(await exec(`WITH bumped AS (
|
|
663
|
+
INSERT INTO ${TABLE$1} AS r (key, bucket, hits, expires_at)
|
|
664
|
+
VALUES ($1, $2, 1, $3)
|
|
665
|
+
ON CONFLICT (key, bucket)
|
|
666
|
+
DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
|
|
667
|
+
RETURNING hits
|
|
668
|
+
)
|
|
669
|
+
SELECT
|
|
670
|
+
(SELECT hits FROM bumped) AS current,
|
|
671
|
+
COALESCE((SELECT hits FROM ${TABLE$1} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
|
|
672
|
+
key,
|
|
673
|
+
bucket,
|
|
674
|
+
expiresAt,
|
|
675
|
+
bucket - 1
|
|
676
|
+
] }));
|
|
677
|
+
current = Number(row?.current ?? 1);
|
|
678
|
+
previous = Number(row?.previous ?? 0);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
|
|
681
|
+
return {
|
|
682
|
+
allowed: true,
|
|
683
|
+
remaining: limit,
|
|
684
|
+
retryAfterMs: 0
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
const estimate = current + previous * carry;
|
|
688
|
+
if (estimate > limit) {
|
|
689
|
+
exec(`UPDATE ${TABLE$1} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
|
|
690
|
+
return {
|
|
691
|
+
allowed: false,
|
|
692
|
+
remaining: 0,
|
|
693
|
+
retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
allowed: true,
|
|
698
|
+
remaining: Math.max(0, Math.floor(limit - estimate)),
|
|
699
|
+
retryAfterMs: 0
|
|
700
|
+
};
|
|
701
|
+
},
|
|
702
|
+
dispose() {
|
|
703
|
+
if (sweepTimer) clearInterval(sweepTimer);
|
|
704
|
+
sweepTimer = void 0;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
659
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* The first row of whatever shape this driver returns.
|
|
710
|
+
*
|
|
711
|
+
* `SqlExec` declares an array of rows, but drivers in this repository have also
|
|
712
|
+
* returned a `{ rows }` envelope through the same escape hatch. Reading one
|
|
713
|
+
* shape only is how a store silently counts nothing and reports every caller as
|
|
714
|
+
* being on their first request — a limiter that never limits, with no error
|
|
715
|
+
* anywhere. Tolerating both costs one line.
|
|
716
|
+
*/
|
|
717
|
+
var firstRow = firstSqlRow;
|
|
660
718
|
//#endregion
|
|
661
719
|
//#region src/email/html.ts
|
|
662
720
|
/**
|
|
@@ -1668,6 +1726,7 @@ function enforceCreationPasswordPolicy(body, resolvedHooks, options) {
|
|
|
1668
1726
|
*/
|
|
1669
1727
|
async function prepareAdminUserValues(rawBody, ctx, options) {
|
|
1670
1728
|
const { resolvedHooks, collectionAuthConfig } = ctx;
|
|
1729
|
+
if (rawBody.roles !== void 0 && rawBody.roles !== null) readRoleNames(rawBody.roles);
|
|
1671
1730
|
const body = enforceCreationPasswordPolicy(rawBody, resolvedHooks, options);
|
|
1672
1731
|
if (collectionAuthConfig?.onCreateUser) {
|
|
1673
1732
|
const collectionCtx = buildCollectionContext(ctx);
|
|
@@ -1830,1509 +1889,46 @@ async function normalizeUserEmailChange(authRepo, uid, email) {
|
|
|
1830
1889
|
*/
|
|
1831
1890
|
function runAfterUserDelete(resolvedHooks, uid) {
|
|
1832
1891
|
if (!resolvedHooks.afterUserDelete) return;
|
|
1833
|
-
resolvedHooks.afterUserDelete(uid).catch((err) => {
|
|
1834
|
-
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
1835
|
-
});
|
|
1836
|
-
}
|
|
1837
|
-
/**
|
|
1838
|
-
* The auth adapter's side of user administration through the auth collection
|
|
1839
|
-
* (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
|
|
1840
|
-
* routes' rules and hooks, for the data API's writes to the same rows.
|
|
1841
|
-
*/
|
|
1842
|
-
function authCollectionUserAdmin(ctx) {
|
|
1843
|
-
const { authRepo, resolvedHooks } = ctx;
|
|
1844
|
-
return {
|
|
1845
|
-
async prepareUserUpdates(updates) {
|
|
1846
|
-
await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
|
|
1847
|
-
uid,
|
|
1848
|
-
roles: values.roles
|
|
1849
|
-
}] : []));
|
|
1850
|
-
const prepared = [];
|
|
1851
|
-
for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
|
|
1852
|
-
...values,
|
|
1853
|
-
email: await normalizeUserEmailChange(authRepo, uid, values.email)
|
|
1854
|
-
} : values);
|
|
1855
|
-
return prepared;
|
|
1856
|
-
},
|
|
1857
|
-
async prepareUserDeletions(uids) {
|
|
1858
|
-
await assertUserDeletionsAllowed(authRepo, uids);
|
|
1859
|
-
if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
|
|
1860
|
-
},
|
|
1861
|
-
async finalizeUserDeletions(uids) {
|
|
1862
|
-
for (const uid of uids) {
|
|
1863
|
-
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1864
|
-
runAfterUserDelete(resolvedHooks, uid);
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
//#endregion
|
|
1870
|
-
//#region src/auth/token-revocation.ts
|
|
1871
|
-
/**
|
|
1872
|
-
* Has this access token been revoked?
|
|
1873
|
-
*
|
|
1874
|
-
* Everything that ends every session a user holds — a password change or reset
|
|
1875
|
-
* of any kind, and `DELETE /auth/sessions` — goes through
|
|
1876
|
-
* {@link revokeAllSessions}, which stamps a `tokensValidAfter` watermark on the
|
|
1877
|
-
* user. It also deletes refresh-token rows, which is what made the gap easy to
|
|
1878
|
-
* miss: the session really is gone, and the *refresh* path really does check
|
|
1879
|
-
* the watermark — so signing out looked like it worked.
|
|
1880
|
-
*
|
|
1881
|
-
* The access token was untouched. It is a bearer credential that nothing
|
|
1882
|
-
* consulted a database about, so it stayed valid for its full lifetime after
|
|
1883
|
-
* every one of those actions. "Sign out everywhere" left the stolen token
|
|
1884
|
-
* working; so did changing your password because you thought it had leaked.
|
|
1885
|
-
*
|
|
1886
|
-
* The watermark had exactly one read in the repository, on refresh. This adds
|
|
1887
|
-
* the other one.
|
|
1888
|
-
*
|
|
1889
|
-
* ## Cost
|
|
1890
|
-
*
|
|
1891
|
-
* One indexed lookup per request, on the paths that already make one. The
|
|
1892
|
-
* adapter re-reads roles for every authenticated request; the admin routes now
|
|
1893
|
-
* do too. Both call this with the row they were already fetching in mind — a
|
|
1894
|
-
* repository that wants to serve both from one query is free to cache
|
|
1895
|
-
* internally, but nothing here assumes it.
|
|
1896
|
-
*
|
|
1897
|
-
* ## Failure
|
|
1898
|
-
*
|
|
1899
|
-
* Fails **open**, deliberately, and this is the one place in the auth stack
|
|
1900
|
-
* where that is right. The watermark is an extra revocation signal layered over
|
|
1901
|
-
* a token that has already been verified — signature, expiry and purpose all
|
|
1902
|
-
* checked. Refusing every request when the database is unreachable would turn a
|
|
1903
|
-
* transient outage into a total sign-out of an entire deployment, and the
|
|
1904
|
-
* attacker this protects against needs to have already stolen a live token. The
|
|
1905
|
-
* failure is logged at warn so it is visible rather than silent.
|
|
1906
|
-
*/
|
|
1907
|
-
async function isAccessTokenRevoked(authRepo, payload) {
|
|
1908
|
-
if (typeof authRepo.getTokensValidAfter !== "function") return false;
|
|
1909
|
-
if (typeof payload.iat !== "number") return false;
|
|
1910
|
-
let validAfter;
|
|
1911
|
-
try {
|
|
1912
|
-
validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1913
|
-
} catch (error) {
|
|
1914
|
-
logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
|
|
1915
|
-
uid: payload.uid,
|
|
1916
|
-
error
|
|
1917
|
-
});
|
|
1918
|
-
return false;
|
|
1919
|
-
}
|
|
1920
|
-
return issuedBefore(payload, validAfter);
|
|
1921
|
-
}
|
|
1922
|
-
/**
|
|
1923
|
-
* Is the account behind this verified access token still the one that may use
|
|
1924
|
-
* it — and with which roles?
|
|
1925
|
-
*
|
|
1926
|
-
* A verified signature says who the token was minted for, an hour ago at
|
|
1927
|
-
* most. This asks the database what has happened to that account since. Every
|
|
1928
|
-
* door that honours an access token — the data plane, the admin gates, the
|
|
1929
|
-
* realtime socket — asks it here, so they cannot disagree about it.
|
|
1930
|
-
*
|
|
1931
|
-
* They did. A deleted account read as "not revoked": the watermark lives on
|
|
1932
|
-
* the user row, so once the row was gone there was no watermark, and the
|
|
1933
|
-
* roles lookup answered `[]` rather than "nobody". A token its owner had
|
|
1934
|
-
* revoked came back to life when an administrator deleted the account, as an
|
|
1935
|
-
* authenticated principal with that uid, for the rest of its lifetime. MCP
|
|
1936
|
-
* refresh and personal API keys already treated a missing account as revoked;
|
|
1937
|
-
* the main door did not.
|
|
1938
|
-
*
|
|
1939
|
-
* Throws when the repository does. Unlike the watermark read on its own, this
|
|
1940
|
-
* one decides the roles a request runs with, so a failure is a refusal (the
|
|
1941
|
-
* callers answer 503) rather than a guess.
|
|
1942
|
-
*
|
|
1943
|
-
* Except one failure that is an answer: the store refusing the uid as a value
|
|
1944
|
-
* its id column cannot hold — `u1` against a UUID key. That names no account,
|
|
1945
|
-
* exactly as a well-formed id nobody has does, so it is `account-deleted` and
|
|
1946
|
-
* the caller answers 401. Answered as a failure it was a 503 asking the
|
|
1947
|
-
* client to try again, which it would do forever.
|
|
1948
|
-
*/
|
|
1949
|
-
async function judgeAccessToken(authRepo, payload) {
|
|
1950
|
-
try {
|
|
1951
|
-
return await readAccessVerdict(authRepo, payload);
|
|
1952
|
-
} catch (error) {
|
|
1953
|
-
if (isDataException(error)) return {
|
|
1954
|
-
live: false,
|
|
1955
|
-
refusal: "account-deleted"
|
|
1956
|
-
};
|
|
1957
|
-
throw error;
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
async function readAccessVerdict(authRepo, payload) {
|
|
1961
|
-
let roles;
|
|
1962
|
-
let validAfter = null;
|
|
1963
|
-
if (typeof authRepo.getAccountAccessState === "function") {
|
|
1964
|
-
const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
|
|
1965
|
-
if (!state) return {
|
|
1966
|
-
live: false,
|
|
1967
|
-
refusal: "account-deleted"
|
|
1968
|
-
};
|
|
1969
|
-
if (state.disabled) return {
|
|
1970
|
-
live: false,
|
|
1971
|
-
refusal: "account-disabled"
|
|
1972
|
-
};
|
|
1973
|
-
roles = state.roles;
|
|
1974
|
-
validAfter = state.tokensValidAfter;
|
|
1975
|
-
if (payload.sid && state.sessionActive === false) return {
|
|
1976
|
-
live: false,
|
|
1977
|
-
refusal: "session-revoked"
|
|
1978
|
-
};
|
|
1979
|
-
} else {
|
|
1980
|
-
if (typeof authRepo.getUserWithRoles === "function") {
|
|
1981
|
-
const account = await authRepo.getUserWithRoles(payload.uid);
|
|
1982
|
-
if (!account) return {
|
|
1983
|
-
live: false,
|
|
1984
|
-
refusal: "account-deleted"
|
|
1985
|
-
};
|
|
1986
|
-
if (account.user.disabled) return {
|
|
1987
|
-
live: false,
|
|
1988
|
-
refusal: "account-disabled"
|
|
1989
|
-
};
|
|
1990
|
-
roles = account.roles;
|
|
1991
|
-
}
|
|
1992
|
-
if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1993
|
-
}
|
|
1994
|
-
if (issuedBefore(payload, validAfter)) return {
|
|
1995
|
-
live: false,
|
|
1996
|
-
refusal: "revoked"
|
|
1997
|
-
};
|
|
1998
|
-
return {
|
|
1999
|
-
live: true,
|
|
2000
|
-
roles
|
|
2001
|
-
};
|
|
2002
|
-
}
|
|
2003
|
-
/**
|
|
2004
|
-
* The 401 a door answers an access token with when {@link judgeAccessToken}
|
|
2005
|
-
* refuses it — one wording and one code for each reason, wherever the token
|
|
2006
|
-
* was presented.
|
|
2007
|
-
*
|
|
2008
|
-
* `SESSION_REVOKED` for a token whose session or account is gone: the client
|
|
2009
|
-
* SDK answers it with a refresh, which the refresh route refuses for the same
|
|
2010
|
-
* reason, and the client signs out. `ACCOUNT_DISABLED` for an account an
|
|
2011
|
-
* administrator switched off, which no refresh can mend.
|
|
2012
|
-
*/
|
|
2013
|
-
function accessTokenRefusalError(refusal) {
|
|
2014
|
-
switch (refusal) {
|
|
2015
|
-
case "account-disabled": return ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
|
|
2016
|
-
case "account-deleted": return ApiError.unauthorized("This account no longer exists", "SESSION_REVOKED");
|
|
2017
|
-
case "revoked":
|
|
2018
|
-
case "session-revoked": return ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
/** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
|
|
2022
|
-
function issuedBefore(payload, validAfter) {
|
|
2023
|
-
if (!validAfter || typeof payload.iat !== "number") return false;
|
|
2024
|
-
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
2025
|
-
}
|
|
2026
|
-
/**
|
|
2027
|
-
* End every session this user holds, on every device.
|
|
2028
|
-
*
|
|
2029
|
-
* Two writes, because each covers what the other cannot. Deleting the refresh
|
|
2030
|
-
* rows ends the sessions that exist at this instant, and on a repository
|
|
2031
|
-
* without the watermark it is the only revocation there is. The watermark
|
|
2032
|
-
* voids what the delete cannot see: a refresh already in flight that inserts
|
|
2033
|
-
* its rotated token a moment after the delete ran, and every access token
|
|
2034
|
-
* already handed out, which {@link isAccessTokenRevoked} refuses from here on.
|
|
2035
|
-
*
|
|
2036
|
-
* The watermark write does not fail the caller. By the time it runs the
|
|
2037
|
-
* credential has usually already changed and the rows are gone, so a 500 would
|
|
2038
|
-
* tell someone their password did not change when it did. It is logged, because
|
|
2039
|
-
* a failure here leaves access tokens working until they expire.
|
|
2040
|
-
*/
|
|
2041
|
-
async function revokeAllSessions(authRepo, uid) {
|
|
2042
|
-
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
2043
|
-
try {
|
|
2044
|
-
await authRepo.setTokensValidAfter?.(uid, /* @__PURE__ */ new Date());
|
|
2045
|
-
} catch (error) {
|
|
2046
|
-
logger.warn("[Auth] Could not write the token revocation watermark; access tokens issued before now stay valid until they expire", {
|
|
2047
|
-
uid,
|
|
2048
|
-
error
|
|
2049
|
-
});
|
|
2050
|
-
}
|
|
2051
|
-
}
|
|
2052
|
-
/**
|
|
2053
|
-
* Replace a user's password and end every session they hold.
|
|
2054
|
-
*
|
|
2055
|
-
* The one way a password hash is written over an existing account. A new
|
|
2056
|
-
* password is what someone sets when they believe the old one — or a session
|
|
2057
|
-
* signed in with it — is in someone else's hands, so a password change that
|
|
2058
|
-
* leaves those sessions alive does not do the thing it was done for.
|
|
2059
|
-
*
|
|
2060
|
-
* It used to be a pair of lines repeated after each `updatePassword`, and it
|
|
2061
|
-
* reached the two self-service routes and none of the admin ones: an
|
|
2062
|
-
* administrator resetting a phished account left the attacker's refresh token
|
|
2063
|
-
* minting access tokens for the rest of its lifetime.
|
|
2064
|
-
* `test/password-change-revokes-sessions.test.ts` holds every route that sets a
|
|
2065
|
-
* password to this, and fails if anything but this function calls
|
|
2066
|
-
* `updatePassword`.
|
|
2067
|
-
*
|
|
2068
|
-
* `null` removes the password instead, for the same reason and with the same
|
|
2069
|
-
* revocation: see `confirmAddressOwnership`.
|
|
2070
|
-
*/
|
|
2071
|
-
async function replaceUserPassword(authRepo, uid, passwordHash) {
|
|
2072
|
-
await authRepo.updatePassword(uid, passwordHash);
|
|
2073
|
-
await revokeAllSessions(authRepo, uid);
|
|
2074
|
-
await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
|
|
2075
|
-
}
|
|
2076
|
-
//#endregion
|
|
2077
|
-
//#region src/auth/rls-scope.ts
|
|
2078
|
-
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
2079
|
-
/**
|
|
2080
|
-
* The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
|
|
2081
|
-
* caller presenting the service key.
|
|
2082
|
-
*
|
|
2083
|
-
* Naming it makes the privilege legible, because it is narrower than it reads.
|
|
2084
|
-
* This is **not** an RLS bypass — a driver scoped with it is an
|
|
2085
|
-
* `AuthenticatedPostgresBackendDriver`, so every statement runs in a
|
|
2086
|
-
* transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
2087
|
-
* `app.uid = 'service'`, and policies are evaluated. Two consequences worth
|
|
2088
|
-
* knowing before you write a policy:
|
|
2089
|
-
*
|
|
2090
|
-
* - It passes the default policies through their `rolesOverlap(['admin'])`
|
|
2091
|
-
* arm — the same arm an application user holding the `admin` role passes.
|
|
2092
|
-
* - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
|
|
2093
|
-
* **false** for it. A collection with `disableDefaultPolicies: true` whose
|
|
2094
|
-
* only rule is `serverContext()` denies these writes (42501) and returns
|
|
2095
|
-
* zero rows for these reads.
|
|
2096
|
-
*
|
|
2097
|
-
* The true bypass is `rebase.sql()`, which runs on the owner connection and
|
|
2098
|
-
* never goes near `withAuth`.
|
|
2099
|
-
*/
|
|
2100
|
-
var SERVICE_IDENTITY = {
|
|
2101
|
-
uid: "service",
|
|
2102
|
-
roles: ["admin"]
|
|
2103
|
-
};
|
|
2104
|
-
/**
|
|
2105
|
-
* Returns true if the driver supports RLS scoping via `withAuth()`.
|
|
2106
|
-
*/
|
|
2107
|
-
function isRLSScopedDriver(driver) {
|
|
2108
|
-
return "withAuth" in driver && typeof driver.withAuth === "function";
|
|
2109
|
-
}
|
|
2110
|
-
/**
|
|
2111
|
-
* Can this driver run a request as a specific user?
|
|
2112
|
-
*
|
|
2113
|
-
* The same question `scopeDataDriver` asks internally, exported because one
|
|
2114
|
-
* caller must act on the answer BEFORE it has a request to scope.
|
|
2115
|
-
*
|
|
2116
|
-
* `scopeDataDriver` returns the driver unscoped when the answer is no, which is
|
|
2117
|
-
* the right behaviour for `/api/data` — Mongo has no row-level security and
|
|
2118
|
-
* never claimed to, and the route's own checks still apply. It is the wrong
|
|
2119
|
-
* behaviour for a surface whose entire promise is "this runs as you": there,
|
|
2120
|
-
* an unscoped driver silently means "runs as the database owner". So the MCP
|
|
2121
|
-
* surface asks this at boot and declines to mount rather than degrade.
|
|
2122
|
-
*/
|
|
2123
|
-
function supportsRlsScoping(driver) {
|
|
2124
|
-
return isRLSScopedDriver(driver);
|
|
2125
|
-
}
|
|
2126
|
-
/**
|
|
2127
|
-
* Scope a DataDriver via `withAuth()` for RLS.
|
|
2128
|
-
*
|
|
2129
|
-
* SECURITY: If `withAuth()` is available but fails, the error is re-thrown
|
|
2130
|
-
* so the request is **denied** rather than proceeding with unscoped access
|
|
2131
|
-
* (fail-closed behavior).
|
|
2132
|
-
*
|
|
2133
|
-
* If the driver does not support RLS, the original driver is returned.
|
|
2134
|
-
*
|
|
2135
|
-
* @param driver - The DataDriver to scope.
|
|
2136
|
-
* @param user - The authenticated user identity for RLS.
|
|
2137
|
-
* @returns The RLS-scoped DataDriver (or the original if RLS is unsupported).
|
|
2138
|
-
*/
|
|
2139
|
-
async function scopeDataDriver(driver, user) {
|
|
2140
|
-
if (isRLSScopedDriver(driver)) return await driver.withAuth(user);
|
|
2141
|
-
return driver;
|
|
2142
|
-
}
|
|
2143
|
-
//#endregion
|
|
2144
|
-
//#region src/auth/crypto-utils.ts
|
|
2145
|
-
/**
|
|
2146
|
-
* Cryptographic utility functions for auth.
|
|
2147
|
-
*
|
|
2148
|
-
* @module
|
|
2149
|
-
*/
|
|
2150
|
-
/**
|
|
2151
|
-
* Constant-time string comparison to prevent timing attacks.
|
|
2152
|
-
*
|
|
2153
|
-
* Used for comparing service keys, tokens, and other secrets where
|
|
2154
|
-
* timing side-channels could leak information about the expected value.
|
|
2155
|
-
*
|
|
2156
|
-
* The implementation lives in `utils/portable-crypto.ts`, which does this over
|
|
2157
|
-
* UTF-8 bytes without `node:crypto` — this function is on the request path, and
|
|
2158
|
-
* the byte-level reasoning it depends on is documented there.
|
|
2159
|
-
*
|
|
2160
|
-
* @param a - First string to compare.
|
|
2161
|
-
* @param b - Second string to compare.
|
|
2162
|
-
* @returns `true` if the strings are identical, `false` otherwise.
|
|
2163
|
-
*/
|
|
2164
|
-
function safeCompare(a, b) {
|
|
2165
|
-
return constantTimeEqual(a, b);
|
|
2166
|
-
}
|
|
2167
|
-
//#endregion
|
|
2168
|
-
//#region src/auth/bearer-token.ts
|
|
2169
|
-
/**
|
|
2170
|
-
* Parsing of the `Authorization: Bearer <token>` header.
|
|
2171
|
-
*
|
|
2172
|
-
* Its own module rather than a helper inside `middleware.ts`, because the
|
|
2173
|
-
* API-key middleware needs it too and `middleware.ts` already imports from
|
|
2174
|
-
* there — putting it in either one would close an import cycle.
|
|
2175
|
-
*
|
|
2176
|
-
* @module
|
|
2177
|
-
*/
|
|
2178
|
-
/**
|
|
2179
|
-
* Pull the token out of an `Authorization: Bearer <token>` header.
|
|
2180
|
-
*
|
|
2181
|
-
* RFC 7235 §2.1 makes the auth-scheme name case-insensitive, and plenty of
|
|
2182
|
-
* clients send `bearer` — HTTP libraries that lower-case what they emit,
|
|
2183
|
-
* hand-written curl, proxies that normalise headers. Matching `"Bearer "`
|
|
2184
|
-
* exactly turned those into a 401 that, from the caller's side, is
|
|
2185
|
-
* indistinguishable from a rejected token; people spend an afternoon
|
|
2186
|
-
* regenerating credentials that were fine. Only the scheme is folded — the
|
|
2187
|
-
* token itself is returned byte-for-byte.
|
|
2188
|
-
*
|
|
2189
|
-
* Returns `undefined` when there is no header or it names another scheme.
|
|
2190
|
-
* That is deliberately distinct from an empty token (`Bearer ` with nothing
|
|
2191
|
-
* after it), which callers still route into verification so it fails as a bad
|
|
2192
|
-
* token rather than as a missing header.
|
|
2193
|
-
*/
|
|
2194
|
-
function extractBearerToken(authHeader) {
|
|
2195
|
-
if (!authHeader) return void 0;
|
|
2196
|
-
const separator = authHeader.indexOf(" ");
|
|
2197
|
-
if (separator < 0) return void 0;
|
|
2198
|
-
if (authHeader.slice(0, separator).toLowerCase() !== "bearer") return void 0;
|
|
2199
|
-
return authHeader.slice(separator + 1);
|
|
2200
|
-
}
|
|
2201
|
-
//#endregion
|
|
2202
|
-
//#region src/auth/access.ts
|
|
2203
|
-
/**
|
|
2204
|
-
* How a personal key finds its owner in an account store: the account as it
|
|
2205
|
-
* is now — a missing or disabled one is no owner — and its roles.
|
|
2206
|
-
*
|
|
2207
|
-
* One read where the store answers both at once: `getAccountAccessState`, the
|
|
2208
|
-
* statement every access token is already judged against, which reads roles
|
|
2209
|
-
* and `disabled_at` together. Otherwise the account, then its roles — the two
|
|
2210
|
-
* reads this always made, one round trip more per request.
|
|
2211
|
-
*/
|
|
2212
|
-
function keyOwnerResolverFor(repo) {
|
|
2213
|
-
if (typeof repo.getAccountAccessState === "function") {
|
|
2214
|
-
const readAccount = repo.getAccountAccessState.bind(repo);
|
|
2215
|
-
return async (uid) => {
|
|
2216
|
-
const account = await readAccount(uid);
|
|
2217
|
-
if (!account || account.disabled) return null;
|
|
2218
|
-
return { roles: account.roles };
|
|
2219
|
-
};
|
|
2220
|
-
}
|
|
2221
|
-
return async (uid) => {
|
|
2222
|
-
const user = await repo.getUserById(uid);
|
|
2223
|
-
if (!user || user.disabled) return null;
|
|
2224
|
-
return { roles: await repo.getUserRoleIds(uid) };
|
|
2225
|
-
};
|
|
2226
|
-
}
|
|
2227
|
-
/**
|
|
2228
|
-
* What the module-level API answers for: {@link getAccessModel},
|
|
2229
|
-
* {@link getKeyOwnerResolver}, and every scope check on a route mounted
|
|
2230
|
-
* outside any backend. Built-ins only until {@link configureAccess} or a
|
|
2231
|
-
* backend installed as the process default sets it.
|
|
2232
|
-
*/
|
|
2233
|
-
var processAccess = { model: EMPTY_ACCESS_MODEL };
|
|
2234
|
-
/** The process default's access model. Built-ins only until one is configured. */
|
|
2235
|
-
function getAccessModel() {
|
|
2236
|
-
return processAccess.model;
|
|
2237
|
-
}
|
|
2238
|
-
/**
|
|
2239
|
-
* Set the process default's access model, and — when the app enables
|
|
2240
|
-
* `auth.personalKeys` and has an account store to read owners from — how
|
|
2241
|
-
* personal keys find their owner. For a test, or a server wired by hand that
|
|
2242
|
-
* uses the module-level API; a backend built by `createRebaseBackend` carries
|
|
2243
|
-
* its own.
|
|
2244
|
-
*/
|
|
2245
|
-
function configureAccess(options) {
|
|
2246
|
-
processAccess = {
|
|
2247
|
-
model: options.model,
|
|
2248
|
-
resolveKeyOwner: options.resolveKeyOwner
|
|
2249
|
-
};
|
|
2250
|
-
}
|
|
2251
|
-
/** @internal See {@link defaultAccessControl}. */
|
|
2252
|
-
function setDefaultAccessControl(access) {
|
|
2253
|
-
processAccess = access;
|
|
2254
|
-
}
|
|
2255
|
-
/**
|
|
2256
|
-
* The process default as an {@link AccessControl} that follows it — what a
|
|
2257
|
-
* route or a key check built outside any backend falls back to.
|
|
2258
|
-
*/
|
|
2259
|
-
var processAccessControl = {
|
|
2260
|
-
get model() {
|
|
2261
|
-
return processAccess.model;
|
|
2262
|
-
},
|
|
2263
|
-
get resolveKeyOwner() {
|
|
2264
|
-
return processAccess.resolveKeyOwner;
|
|
2265
|
-
}
|
|
2266
|
-
};
|
|
2267
|
-
/**
|
|
2268
|
-
* The access control serving this request: the backend's whose route group is
|
|
2269
|
-
* answering it, or the process default for a route mounted outside any.
|
|
2270
|
-
*/
|
|
2271
|
-
function accessOf(c) {
|
|
2272
|
-
return c.get("services")?.access ?? processAccessControl;
|
|
2273
|
-
}
|
|
2274
|
-
/** The resource names Rebase's own scopes use; an app scope may not reuse one. */
|
|
2275
|
-
var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
|
|
2276
|
-
/** A declaration the access model cannot be built from. Fails the boot. */
|
|
2277
|
-
var AccessModelError = class extends Error {
|
|
2278
|
-
constructor(message) {
|
|
2279
|
-
super(message);
|
|
2280
|
-
this.name = "AccessModelError";
|
|
2281
|
-
}
|
|
2282
|
-
};
|
|
2283
|
-
/**
|
|
2284
|
-
* Build the access model from the users collection's `auth` block, refusing
|
|
2285
|
-
* anything that would read as a grant it is not.
|
|
2286
|
-
*
|
|
2287
|
-
* - `admin` may not be declared: it is built in and holds everything, and a
|
|
2288
|
-
* declaration would either repeat that or quietly narrow it.
|
|
2289
|
-
* - An app scope may not reuse a built-in resource name (`logs:export`): the
|
|
2290
|
-
* built-in resources are Rebase's, and a reader of `logs:*` should not have
|
|
2291
|
-
* to know which half is whose.
|
|
2292
|
-
* - A role may not list a data-plane scope: a person's data access is the
|
|
2293
|
-
* collection's `securityRules`, so `data:write` on a role would grant nothing
|
|
2294
|
-
* and look like it granted something.
|
|
2295
|
-
*/
|
|
2296
|
-
function accessModelFromCollections(collections) {
|
|
2297
|
-
const authCollection = collections.find((collection) => {
|
|
2298
|
-
const auth = collection.auth;
|
|
2299
|
-
return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
|
|
2300
|
-
});
|
|
2301
|
-
const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
|
|
2302
|
-
const declaredScopes = { ...auth?.scopes ?? {} };
|
|
2303
|
-
const declaredRoles = { ...auth?.roles ?? {} };
|
|
2304
|
-
const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
|
|
2305
|
-
for (const [name, definition] of Object.entries(declaredScopes)) {
|
|
2306
|
-
const parsed = parseScope(name);
|
|
2307
|
-
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".`);
|
|
2308
|
-
const resource = name.slice(0, name.indexOf(":"));
|
|
2309
|
-
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(", ")}.`);
|
|
2310
|
-
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.`);
|
|
2311
|
-
}
|
|
2312
|
-
const model = {
|
|
2313
|
-
roles: declaredRoles,
|
|
2314
|
-
scopes: declaredScopes
|
|
2315
|
-
};
|
|
2316
|
-
for (const [role, definition] of Object.entries(declaredRoles)) {
|
|
2317
|
-
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.`);
|
|
2318
|
-
if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
|
|
2319
|
-
const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
|
|
2320
|
-
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.`);
|
|
2321
|
-
const problems = validateScopes(definition.scopes, model);
|
|
2322
|
-
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.`);
|
|
2323
|
-
}
|
|
2324
|
-
return model;
|
|
2325
|
-
}
|
|
2326
|
-
/** Narrow a context's user to the roles it carries. */
|
|
2327
|
-
function rolesOf(user) {
|
|
2328
|
-
if (typeof user !== "object" || user === null || !("roles" in user)) return [];
|
|
2329
|
-
const roles = user.roles;
|
|
2330
|
-
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
2331
|
-
}
|
|
2332
|
-
/**
|
|
2333
|
-
* Everything the caller of this request may do. Empty for a request with no
|
|
2334
|
-
* caller.
|
|
2335
|
-
*/
|
|
2336
|
-
function callerScopes(c) {
|
|
2337
|
-
const narrowed = c.get("scopes");
|
|
2338
|
-
if (narrowed) return narrowed;
|
|
2339
|
-
const user = c.get("user");
|
|
2340
|
-
if (!user) return [];
|
|
2341
|
-
return scopesForRoles(rolesOf(user), accessOf(c).model);
|
|
2342
|
-
}
|
|
2343
|
-
/**
|
|
2344
|
-
* Whether this request's caller holds the `admin` role. The service key signs
|
|
2345
|
-
* in as one.
|
|
2346
|
-
*/
|
|
2347
|
-
function callerIsAdmin(c) {
|
|
2348
|
-
return hasAdminRole(rolesOf(c.get("user")));
|
|
2349
|
-
}
|
|
2350
|
-
/** Does this request's caller hold `scope`, on `target` when one is given? */
|
|
2351
|
-
function hasScope(c, scope, target) {
|
|
2352
|
-
return scopeGrants(callerScopes(c), scope, target);
|
|
2353
|
-
}
|
|
2354
|
-
/**
|
|
2355
|
-
* The refusal for a missing scope, worded for whoever is holding the
|
|
2356
|
-
* credential. `details.requiredScope` names it for clients that branch.
|
|
2357
|
-
*/
|
|
2358
|
-
function scopeMissing(c, scope, target) {
|
|
2359
|
-
const wanted = target !== void 0 ? `${scope}:${target}` : scope;
|
|
2360
|
-
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 });
|
|
2361
|
-
}
|
|
2362
|
-
/**
|
|
2363
|
-
* Refuse a request whose caller does not hold `scope`.
|
|
2364
|
-
*
|
|
2365
|
-
* `scope` is a built-in scope or one this backend declares under
|
|
2366
|
-
* `auth.scopes`. A name no caller could ever hold — not a scope name, a typo
|
|
2367
|
-
* of a built-in one (`data:raed`) — throws as the route is registered; an app
|
|
2368
|
-
* scope the backend answering does not declare is answered
|
|
2369
|
-
* `500 SCOPE_NOT_DECLARED`, naming the declaration to add.
|
|
2370
|
-
*
|
|
2371
|
-
* `target` narrows the check to one collection, bucket, function or app
|
|
2372
|
-
* resource, and may be read from the request. A guard, not an authenticator:
|
|
2373
|
-
* it judges the caller an auth middleware identified, so mount it after one —
|
|
2374
|
-
* `requireAuth`, or a route group's own. A request with no caller is refused
|
|
2375
|
-
* as unauthenticated, not as unscoped.
|
|
2376
|
-
*
|
|
2377
|
-
* @example
|
|
2378
|
-
* ```ts
|
|
2379
|
-
* app.post("/deploy/:project", requireAuth, requireScope("project:deploy", c => c.req.param("project")), handler);
|
|
2380
|
-
* ```
|
|
2381
|
-
*/
|
|
2382
|
-
function requireScope(scope, target) {
|
|
2383
|
-
const problem = scopeGuardProblem(scope, typeof target === "string" ? target : void 0);
|
|
2384
|
-
if (problem) throw new Error(problem);
|
|
2385
|
-
return async (c, next) => {
|
|
2386
|
-
if (!c.get("user")) return refuse(c, noCallerRefusal(c));
|
|
2387
|
-
const undeclared = scopeGuardProblem(scope, void 0, Object.keys(accessOf(c).model.scopes));
|
|
2388
|
-
if (undeclared) return refuse(c, new ApiError(500, "SCOPE_NOT_DECLARED", undeclared));
|
|
2389
|
-
const resolvedTarget = typeof target === "function" ? target(c) : target;
|
|
2390
|
-
if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
|
|
2391
|
-
return next();
|
|
2392
|
-
};
|
|
2393
|
-
}
|
|
2394
|
-
/**
|
|
2395
|
-
* The 401 for a guard that found no caller.
|
|
2396
|
-
*
|
|
2397
|
-
* A request that sent a credential, on a route where no auth middleware ran —
|
|
2398
|
-
* it sets `driver` for every caller, anonymous included — never had that
|
|
2399
|
-
* credential read. "Authentication required" sends whoever holds a valid
|
|
2400
|
-
* session to check it, so this names the wiring instead.
|
|
2401
|
-
*/
|
|
2402
|
-
function noCallerRefusal(c) {
|
|
2403
|
-
if (c.req.header("authorization") !== void 0 && c.get("driver") === void 0) return ApiError.unauthenticated("Authentication required: this request carries a credential, but nothing on this route verified it. requireScope judges the caller an auth middleware identified; put requireAuth in front of it — app.post(path, requireAuth, requireScope(scope), handler).");
|
|
2404
|
-
return ApiError.unauthenticated("Authentication required");
|
|
2405
|
-
}
|
|
2406
|
-
/**
|
|
2407
|
-
* Refuse a request unless its caller holds the scope for its HTTP method:
|
|
2408
|
-
* `read` for GET and HEAD, `write` for everything else. For an admin surface
|
|
2409
|
-
* whose reads and writes split exactly along the method.
|
|
2410
|
-
*/
|
|
2411
|
-
function requireScopeByMethod(scopes) {
|
|
2412
|
-
const read = requireScope(scopes.read);
|
|
2413
|
-
const write = requireScope(scopes.write);
|
|
2414
|
-
return async (c, next) => {
|
|
2415
|
-
const method = c.req.method.toUpperCase();
|
|
2416
|
-
return (method === "GET" || method === "HEAD" ? read : write)(c, next);
|
|
2417
|
-
};
|
|
2418
|
-
}
|
|
2419
|
-
/**
|
|
2420
|
-
* What a set of roles holds beyond this request's caller — the scopes, plus
|
|
2421
|
-
* `admin` itself when the roles include it and the caller does not hold it.
|
|
2422
|
-
* Empty when the caller covers every one.
|
|
2423
|
-
*
|
|
2424
|
-
* `admin` is named separately because it is more than its scopes: it is the
|
|
2425
|
-
* RLS role that reads every row, so a role listing every admin-plane scope
|
|
2426
|
-
* still cannot hand it out.
|
|
2427
|
-
*/
|
|
2428
|
-
function beyondCaller(c, roles) {
|
|
2429
|
-
const beyond = scopesBeyond(scopesForRoles(roles, accessOf(c).model), callerScopes(c));
|
|
2430
|
-
if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
|
|
2431
|
-
return beyond;
|
|
2432
|
-
}
|
|
2433
|
-
/**
|
|
2434
|
-
* Refuse to let the caller act on an account — edit it, reset its password,
|
|
2435
|
-
* delete it — when that account holds anything the caller does not.
|
|
2436
|
-
*
|
|
2437
|
-
* `users:write` is the scope that lets a support role manage accounts, and
|
|
2438
|
-
* without this it would also let that role reset an administrator's password
|
|
2439
|
-
* and sign in as them.
|
|
2440
|
-
*/
|
|
2441
|
-
function assertMayManageAccount(c, targetRoles) {
|
|
2442
|
-
const beyond = beyondCaller(c, targetRoles);
|
|
2443
|
-
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 });
|
|
2444
|
-
}
|
|
2445
|
-
/** Refuse to let the caller grant roles that hold anything the caller does not. */
|
|
2446
|
-
function assertMayGrantRoles(c, roles) {
|
|
2447
|
-
const beyond = beyondCaller(c, roles);
|
|
2448
|
-
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 });
|
|
2449
|
-
}
|
|
2450
|
-
//#endregion
|
|
2451
|
-
//#region src/auth/api-keys/api-key-middleware.ts
|
|
2452
|
-
/**
|
|
2453
|
-
* Check whether a token looks like a Rebase API key.
|
|
2454
|
-
*/
|
|
2455
|
-
function isApiKeyToken(token) {
|
|
2456
|
-
return token.startsWith("rk_");
|
|
2457
|
-
}
|
|
2458
|
-
/**
|
|
2459
|
-
* Verify a presented key: it exists, is live, and — for a personal key —
|
|
2460
|
-
* personal keys are on and its owner still exists. Records the use.
|
|
2461
|
-
*
|
|
2462
|
-
* `access` is the backend the key belongs to: whether it takes personal keys,
|
|
2463
|
-
* how it finds their owners, and what their roles hold. The process default
|
|
2464
|
-
* when omitted.
|
|
2465
|
-
*/
|
|
2466
|
-
async function resolveApiKey(store, token, access = processAccessControl) {
|
|
2467
|
-
const apiKey = await store.findByKeyHash(await sha256Hex(token));
|
|
2468
|
-
if (!apiKey) return { message: "Invalid API key" };
|
|
2469
|
-
if (apiKey.revoked_at) return { message: "API key has been revoked" };
|
|
2470
|
-
if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
|
|
2471
|
-
let identity;
|
|
2472
|
-
if (apiKey.kind === "personal") {
|
|
2473
|
-
const resolveOwner = access.resolveKeyOwner;
|
|
2474
|
-
if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
|
|
2475
|
-
const owner = await resolveOwner(apiKey.owner_uid);
|
|
2476
|
-
if (!owner) return { message: "The account this API key acts as no longer exists" };
|
|
2477
|
-
identity = {
|
|
2478
|
-
uid: apiKey.owner_uid,
|
|
2479
|
-
roles: owner.roles,
|
|
2480
|
-
scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, access.model))
|
|
2481
|
-
};
|
|
2482
|
-
} else identity = {
|
|
2483
|
-
uid: `api-key:${apiKey.id}`,
|
|
2484
|
-
roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
|
|
2485
|
-
scopes: apiKey.scopes
|
|
2486
|
-
};
|
|
2487
|
-
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
2488
|
-
const now = Date.now();
|
|
2489
|
-
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
2490
|
-
lastUsedTouchedAt.set(apiKey.id, now);
|
|
2491
|
-
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
2492
|
-
}
|
|
2493
|
-
const masked = {
|
|
2494
|
-
id: apiKey.id,
|
|
2495
|
-
name: apiKey.name,
|
|
2496
|
-
kind: apiKey.kind,
|
|
2497
|
-
key_prefix: apiKey.key_prefix,
|
|
2498
|
-
scopes: apiKey.scopes,
|
|
2499
|
-
roles: apiKey.roles,
|
|
2500
|
-
owner_uid: apiKey.owner_uid,
|
|
2501
|
-
rate_limit: apiKey.rate_limit,
|
|
2502
|
-
created_by: apiKey.created_by,
|
|
2503
|
-
created_at: apiKey.created_at,
|
|
2504
|
-
updated_at: apiKey.updated_at,
|
|
2505
|
-
last_used_at: apiKey.last_used_at,
|
|
2506
|
-
expires_at: apiKey.expires_at,
|
|
2507
|
-
revoked_at: apiKey.revoked_at
|
|
2508
|
-
};
|
|
2509
|
-
return {
|
|
2510
|
-
...identity,
|
|
2511
|
-
apiKey: masked
|
|
2512
|
-
};
|
|
2513
|
-
}
|
|
2514
|
-
/** Per-process debounce state for last_used_at touches. */
|
|
2515
|
-
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
2516
|
-
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
2517
|
-
/**
|
|
2518
|
-
* Validate an API key token and populate the Hono context: `user`, `apiKey`,
|
|
2519
|
-
* `scopes` and the RLS-scoped `driver`.
|
|
2520
|
-
*
|
|
2521
|
-
* Returns `true` when the context is populated, or the error Response.
|
|
2522
|
-
*/
|
|
2523
|
-
async function validateApiKey(c, token, options) {
|
|
2524
|
-
const resolved = await resolveApiKey(options.store, token, accessOf(c));
|
|
2525
|
-
if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
|
|
2526
|
-
c.set("user", {
|
|
2527
|
-
uid: resolved.uid,
|
|
2528
|
-
roles: resolved.roles
|
|
2529
|
-
});
|
|
2530
|
-
c.set("apiKey", resolved.apiKey);
|
|
2531
|
-
c.set("scopes", resolved.scopes);
|
|
2532
|
-
try {
|
|
2533
|
-
c.set("driver", await scopeDataDriver(options.driver, {
|
|
2534
|
-
uid: resolved.uid,
|
|
2535
|
-
roles: resolved.roles
|
|
2536
|
-
}));
|
|
2537
|
-
} catch (error) {
|
|
2538
|
-
logger.error("[AUTH] RLS scoping failed for API key", { error });
|
|
2539
|
-
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
2540
|
-
}
|
|
2541
|
-
return true;
|
|
2542
|
-
}
|
|
2543
|
-
/**
|
|
2544
|
-
* The 403 for a credential that lacks a data-plane scope outside the REST
|
|
2545
|
-
* generator (storage and functions). Names the scope that would grant it.
|
|
2546
|
-
*/
|
|
2547
|
-
function forbidScope(c, scope, target) {
|
|
2548
|
-
const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
|
|
2549
|
-
return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
|
|
2550
|
-
}
|
|
2551
|
-
/** Whether this request's caller holds `scope` — on `target` when given. */
|
|
2552
|
-
function callerHolds(c, scope, target) {
|
|
2553
|
-
const narrowed = c.get("scopes");
|
|
2554
|
-
if (!narrowed) return true;
|
|
2555
|
-
return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
|
|
2556
|
-
}
|
|
2557
|
-
/**
|
|
2558
|
-
* Scope guard for the resumable-upload routes (`/tus/:id`).
|
|
2559
|
-
*
|
|
2560
|
-
* Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
|
|
2561
|
-
* cancel (DELETE) — is part of writing it, so all of them need
|
|
2562
|
-
* `storage:write`. Which source the upload writes to was checked when it was
|
|
2563
|
-
* created; these steps only reach an upload the same caller owns.
|
|
2564
|
-
*/
|
|
2565
|
-
function createTusScopeGuard() {
|
|
2566
|
-
return async (c, next) => {
|
|
2567
|
-
if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
|
|
2568
|
-
return next();
|
|
2569
|
-
};
|
|
2570
|
-
}
|
|
2571
|
-
/**
|
|
2572
|
-
* Scope guard for the custom-functions router: a narrowed credential needs
|
|
2573
|
-
* `functions:invoke` on the function it calls. The functions index — the
|
|
2574
|
-
* listing at the mount point itself — needs the unqualified scope.
|
|
2575
|
-
*
|
|
2576
|
-
* People pass: what a person may do inside a function is the function's own
|
|
2577
|
-
* business, as it always was.
|
|
2578
|
-
*
|
|
2579
|
-
* @param mountPrefix - The path the functions router is mounted at
|
|
2580
|
-
* (e.g. `/api/functions`), used to extract the function
|
|
2581
|
-
* name from the request path. Omitted, it is the
|
|
2582
|
-
* `mountPath` the functions group recorded for this
|
|
2583
|
-
* request — wherever the application mounted it.
|
|
2584
|
-
*/
|
|
2585
|
-
function createFunctionScopeGuard(mountPrefix) {
|
|
2586
|
-
return async (c, next) => {
|
|
2587
|
-
const narrowed = c.get("scopes");
|
|
2588
|
-
if (!narrowed) return next();
|
|
2589
|
-
const path = c.req.path;
|
|
2590
|
-
const prefix = mountPrefix ?? c.get("mountPath") ?? "";
|
|
2591
|
-
const idx = path.indexOf(prefix);
|
|
2592
|
-
const rawName = (idx < 0 ? "" : path.slice(idx + prefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
2593
|
-
let functionName;
|
|
2594
|
-
try {
|
|
2595
|
-
functionName = decodeURIComponent(rawName);
|
|
2596
|
-
} catch {
|
|
2597
|
-
functionName = rawName;
|
|
2598
|
-
}
|
|
2599
|
-
if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
|
|
2600
|
-
return next();
|
|
2601
|
-
};
|
|
2602
|
-
}
|
|
2603
|
-
/**
|
|
2604
|
-
* Pre-auth middleware for `rk_` bearer tokens.
|
|
2605
|
-
*
|
|
2606
|
-
* Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
|
|
2607
|
-
* surfaces, the key routes) don't know about API keys. Mounted in front of
|
|
2608
|
-
* them, this authenticates `rk_` tokens and populates the request context; the
|
|
2609
|
-
* downstream gates then see the already-resolved caller and check its scopes.
|
|
2610
|
-
*
|
|
2611
|
-
* Requests without an `rk_` bearer token pass through untouched. An invalid,
|
|
2612
|
-
* revoked, or expired `rk_` token is rejected here (401) rather than falling
|
|
2613
|
-
* through to be misparsed as a JWT.
|
|
2614
|
-
*/
|
|
2615
|
-
function createApiKeyPreAuth(options) {
|
|
2616
|
-
return async (c, next) => {
|
|
2617
|
-
if (c.get("apiKey")) return next();
|
|
2618
|
-
const token = extractBearerToken(c.req.header("authorization")) ?? "";
|
|
2619
|
-
if (!token || !isApiKeyToken(token)) return next();
|
|
2620
|
-
const result = await validateApiKey(c, token, options);
|
|
2621
|
-
if (result !== true) return result;
|
|
2622
|
-
return next();
|
|
2623
|
-
};
|
|
2624
|
-
}
|
|
2625
|
-
//#endregion
|
|
2626
|
-
//#region src/auth/impersonation.ts
|
|
2627
|
-
var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
|
|
2628
|
-
var NOT_A_PERSON = {
|
|
2629
|
-
"api-key": {
|
|
2630
|
-
reason: "api-key",
|
|
2631
|
-
message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2632
|
-
},
|
|
2633
|
-
"service-key": {
|
|
2634
|
-
reason: "service-key",
|
|
2635
|
-
message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2636
|
-
},
|
|
2637
|
-
none: {
|
|
2638
|
-
reason: "anonymous",
|
|
2639
|
-
message: ADMIN_ONLY
|
|
2640
|
-
}
|
|
2641
|
-
};
|
|
2642
|
-
/**
|
|
2643
|
-
* Decide whether this caller may act as the user they named, and who that
|
|
2644
|
-
* user is now.
|
|
2645
|
-
*
|
|
2646
|
-
* The one decision both doors take — the HTTP data plane through
|
|
2647
|
-
* {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
|
|
2648
|
-
* every frame after it. The caller is judged before the named user is looked
|
|
2649
|
-
* up, so the answer to a caller who may not impersonate says nothing about
|
|
2650
|
-
* whether the uid exists.
|
|
2651
|
-
*
|
|
2652
|
-
* Throws when a lookup fails: that is not an answer about anyone, and each
|
|
2653
|
-
* door refuses it its own way.
|
|
2654
|
-
*/
|
|
2655
|
-
async function decideImpersonation(request) {
|
|
2656
|
-
const { credential, caller, resolveUser, audit } = request;
|
|
2657
|
-
const targetUid = request.requestedUid.trim();
|
|
2658
|
-
const refuse = (reason, error) => {
|
|
2659
|
-
if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2660
|
-
eventType: "auth.impersonation.refused",
|
|
2661
|
-
reason,
|
|
2662
|
-
callerUid: caller?.uid,
|
|
2663
|
-
requestedUid: targetUid,
|
|
2664
|
-
...audit
|
|
2665
|
-
});
|
|
2666
|
-
return { refused: error };
|
|
2667
|
-
};
|
|
2668
|
-
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"));
|
|
2669
|
-
if (credential !== "session") {
|
|
2670
|
-
const { reason, message } = NOT_A_PERSON[credential];
|
|
2671
|
-
return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
|
|
2672
|
-
}
|
|
2673
|
-
if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2674
|
-
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."));
|
|
2675
|
-
const self = await resolveUser(caller.uid);
|
|
2676
|
-
if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2677
|
-
const target = await resolveUser(targetUid);
|
|
2678
|
-
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"));
|
|
2679
|
-
if (audit) logger.info("[Security Audit] Ran a request as another user", {
|
|
2680
|
-
eventType: "auth.impersonation",
|
|
2681
|
-
impersonatorUid: caller.uid,
|
|
2682
|
-
targetUid: target.uid,
|
|
2683
|
-
targetRoles: target.roles,
|
|
2684
|
-
...audit
|
|
2685
|
-
});
|
|
2686
|
-
return {
|
|
2687
|
-
granted: target,
|
|
2688
|
-
impersonator: caller.uid
|
|
2689
|
-
};
|
|
2690
|
-
}
|
|
2691
|
-
/** The caller the auth middleware put on the context, narrowed. */
|
|
2692
|
-
function callerOf(c) {
|
|
2693
|
-
const user = c.get("user");
|
|
2694
|
-
if (typeof user !== "object" || user === null) return void 0;
|
|
2695
|
-
const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
|
|
2696
|
-
if (uid === void 0) return void 0;
|
|
2697
|
-
return {
|
|
2698
|
-
uid,
|
|
2699
|
-
roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
|
|
2700
|
-
};
|
|
2701
|
-
}
|
|
2702
|
-
/**
|
|
2703
|
-
* Honour or refuse this request's {@link IMPERSONATE_HEADER}.
|
|
2704
|
-
*
|
|
2705
|
-
* Called by the data-plane auth middlewares once the caller is on the context
|
|
2706
|
-
* and its driver is scoped. When the header names a user the caller may act
|
|
2707
|
-
* as, the context's `user` and `driver` are replaced with that user's and
|
|
2708
|
-
* `impersonator` names the caller.
|
|
2709
|
-
*
|
|
2710
|
-
* @returns The refusal to send, or `undefined` to carry on — as the caller
|
|
2711
|
-
* when the header is absent, as the named user when it was granted.
|
|
2712
|
-
*/
|
|
2713
|
-
async function applyImpersonation(c, { credential, driver, resolveUser }) {
|
|
2714
|
-
const requested = c.req.header(IMPERSONATE_HEADER);
|
|
2715
|
-
if (requested === void 0) return void 0;
|
|
2716
|
-
const caller = callerOf(c);
|
|
2717
|
-
let decision;
|
|
2718
|
-
try {
|
|
2719
|
-
decision = await decideImpersonation({
|
|
2720
|
-
requestedUid: requested,
|
|
2721
|
-
credential,
|
|
2722
|
-
caller,
|
|
2723
|
-
resolveUser,
|
|
2724
|
-
audit: {
|
|
2725
|
-
door: "http",
|
|
2726
|
-
method: c.req.method,
|
|
2727
|
-
path: c.req.path,
|
|
2728
|
-
requestId: c.get("requestId")
|
|
2729
|
-
}
|
|
2730
|
-
});
|
|
2731
|
-
} catch (error) {
|
|
2732
|
-
return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
|
|
2733
|
-
}
|
|
2734
|
-
if ("refused" in decision) return errorHandler(decision.refused, c);
|
|
2735
|
-
const target = decision.granted;
|
|
2736
|
-
const isAnonymous = target.isAnonymous === true;
|
|
2737
|
-
c.set("user", {
|
|
2738
|
-
uid: target.uid,
|
|
2739
|
-
email: target.email,
|
|
2740
|
-
roles: target.roles,
|
|
2741
|
-
isAnonymous
|
|
2742
|
-
});
|
|
2743
|
-
c.set("impersonator", { uid: decision.impersonator });
|
|
2744
|
-
try {
|
|
2745
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
2746
|
-
uid: target.uid,
|
|
2747
|
-
roles: target.roles,
|
|
2748
|
-
isAnonymous,
|
|
2749
|
-
...target.claims ? { claims: target.claims } : {}
|
|
2750
|
-
}));
|
|
2751
|
-
} catch (error) {
|
|
2752
|
-
logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
|
|
2753
|
-
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
2754
|
-
}
|
|
2755
|
-
}
|
|
2756
|
-
/**
|
|
2757
|
-
* Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
|
|
2758
|
-
*
|
|
2759
|
-
* Only the data API and custom functions run a request as another user; the
|
|
2760
|
-
* rest — storage, auth, the admin surfaces — authenticate the caller and
|
|
2761
|
-
* ignore the header. Ignored, a request asking to act as B would be answered
|
|
2762
|
-
* as the administrator who sent it, which is the one outcome impersonation
|
|
2763
|
-
* must never have. Mounted ahead of every route, so a surface added later is
|
|
2764
|
-
* refused until it is listed here as honouring the header.
|
|
2765
|
-
*
|
|
2766
|
-
* @param honouredMounts - The mount points whose auth middleware applies the
|
|
2767
|
-
* header (`/api/data`, `/api/functions`). A path is under one when it
|
|
2768
|
-
* is the mount itself or continues it with a `/`.
|
|
2769
|
-
*/
|
|
2770
|
-
function refuseUnhonouredImpersonation(honouredMounts) {
|
|
2771
|
-
return async (c, next) => {
|
|
2772
|
-
if (c.req.header("x-rebase-impersonate") === void 0) return next();
|
|
2773
|
-
const path = c.req.path;
|
|
2774
|
-
if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
|
|
2775
|
-
logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2776
|
-
eventType: "auth.impersonation.refused",
|
|
2777
|
-
reason: "unsupported-route",
|
|
2778
|
-
method: c.req.method,
|
|
2779
|
-
path,
|
|
2780
|
-
requestId: c.get("requestId")
|
|
2781
|
-
});
|
|
2782
|
-
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);
|
|
2783
|
-
};
|
|
2784
|
-
}
|
|
2785
|
-
//#endregion
|
|
2786
|
-
//#region src/storage/requested-object.ts
|
|
2787
|
-
/**
|
|
2788
|
-
* Which object a storage request's URL path names.
|
|
2789
|
-
*
|
|
2790
|
-
* Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
|
|
2791
|
-
* `DELETE /file/*`: the route, which serves or deletes an object;
|
|
2792
|
-
* `publicObjectAuth`, which lets an anonymous caller through when that object
|
|
2793
|
-
* is public; and `fileTokenAuth`, which compares it with the path a download
|
|
2794
|
-
* token grants. A decision is only about the object acted on if it is made on
|
|
2795
|
-
* the same key, so all three derive it here — the wildcard, the decoding, the
|
|
2796
|
-
* bucket segment and the canonical key — and none of them spells a step of its
|
|
2797
|
-
* own.
|
|
2798
|
-
*
|
|
2799
|
-
* They used to. `publicObjectAuth` judged the raw path with a check that strips
|
|
2800
|
-
* everything up to a `://`, while the route canonicalized it, folding `//` to
|
|
2801
|
-
* `/`: `GET /file/notes://public/secret.txt` was public to the one and served
|
|
2802
|
-
* the private key `notes:/public/secret.txt` from the other, with the authorize
|
|
2803
|
-
* hook never asked.
|
|
2804
|
-
*
|
|
2805
|
-
* Nothing here parses a scheme. A request path is a key, optionally behind the
|
|
2806
|
-
* one bucket segment the routes recognise; a `://` in it is a `:` followed by
|
|
2807
|
-
* an empty segment, and canonicalization folds the empty segment away.
|
|
2808
|
-
*/
|
|
2809
|
-
/**
|
|
2810
|
-
* The wildcard part of a storage route's path, still percent-encoded.
|
|
2811
|
-
*
|
|
2812
|
-
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
2813
|
-
* via `app.route(prefix, subRouter)`. Instead it is derived from the
|
|
2814
|
-
* fully-resolved `c.req.path` and `c.req.routePath`.
|
|
2815
|
-
*
|
|
2816
|
-
* For a route `/metadata/*` mounted at `/api/storage`, a request to
|
|
2817
|
-
* `/api/storage/metadata/default/file.jpg` yields routePath
|
|
2818
|
-
* `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
|
|
2819
|
-
* plus one character for the trailing `/`, to obtain `default/file.jpg`.
|
|
2820
|
-
*/
|
|
2821
|
-
function storageRequestWildcard(c) {
|
|
2822
|
-
const prefix = c.req.routePath.replace("/*", "");
|
|
2823
|
-
const fullPath = c.req.path;
|
|
2824
|
-
const idx = fullPath.indexOf(prefix);
|
|
2825
|
-
if (idx < 0) return "";
|
|
2826
|
-
return fullPath.substring(idx + prefix.length + 1);
|
|
2827
|
-
}
|
|
2828
|
-
/**
|
|
2829
|
-
* The object a decoded path names: a key, behind an optional `default/` bucket
|
|
2830
|
-
* segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
|
|
2831
|
-
* when the key cannot be canonicalized.
|
|
2832
|
-
*
|
|
2833
|
-
* For a path that is already text — the folder route's JSON body. A URL path
|
|
2834
|
-
* goes through {@link requestedStorageObject}, which decodes it first.
|
|
2835
|
-
*/
|
|
2836
|
-
function storageObjectOfPath(decodedPath) {
|
|
2837
|
-
const parts = decodedPath.split("/");
|
|
2838
|
-
return {
|
|
2839
|
-
bucket: "default",
|
|
2840
|
-
key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
|
|
2841
|
-
};
|
|
2842
|
-
}
|
|
2843
|
-
/**
|
|
2844
|
-
* The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
|
|
2845
|
-
*
|
|
2846
|
-
* The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
|
|
2847
|
-
* characters (`/`, `:`, `%25` among them) still escaped — so one
|
|
2848
|
-
* `decodeURIComponent` yields the path the client encoded. A `%` that does not
|
|
2849
|
-
* begin an escape names no key, and is refused like any other key that cannot be
|
|
2850
|
-
* canonicalized rather than thrown as a `URIError`.
|
|
2851
|
-
*/
|
|
2852
|
-
function requestedStorageObject(wildcard) {
|
|
2853
|
-
let decoded;
|
|
2854
|
-
try {
|
|
2855
|
-
decoded = decodeURIComponent(wildcard);
|
|
2856
|
-
} catch {
|
|
2857
|
-
throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
|
|
2858
|
-
}
|
|
2859
|
-
return storageObjectOfPath(decoded);
|
|
2860
|
-
}
|
|
2861
|
-
/**
|
|
2862
|
-
* {@link requestedStorageObject}, or `null` when the path names no object.
|
|
2863
|
-
*
|
|
2864
|
-
* For the middleware, which must fail closed without an exception: a path that
|
|
2865
|
-
* names no object is neither public nor covered by any download token, and the
|
|
2866
|
-
* route answers it with a 400 of its own.
|
|
2867
|
-
*/
|
|
2868
|
-
function tryRequestedStorageObject(wildcard) {
|
|
2869
|
-
try {
|
|
2870
|
-
return requestedStorageObject(wildcard);
|
|
2871
|
-
} catch (err) {
|
|
2872
|
-
if (err instanceof InvalidStorageKeyError) return null;
|
|
2873
|
-
throw err;
|
|
2874
|
-
}
|
|
2875
|
-
}
|
|
2876
|
-
//#endregion
|
|
2877
|
-
//#region src/storage/request-principals.ts
|
|
2878
|
-
/**
|
|
2879
|
-
* The two principals a storage read can be granted without a user: the reader
|
|
2880
|
-
* of a declared-public object, and the bearer of a download token.
|
|
2881
|
-
*
|
|
2882
|
-
* `checkAuthorized` does not ask the `storageAuthorize` hook about either — the
|
|
2883
|
-
* public prefix and the token are the authorization. That skip used to be
|
|
2884
|
-
* decided on the principal's `uid` string (`"public"`, `"download-token"`), so
|
|
2885
|
-
* any identity carrying that uid skipped the hook: a custom `AuthAdapter` whose
|
|
2886
|
-
* `verifyRequest` returns `{ uid: "public" }` for a guest could then upload,
|
|
2887
|
-
* delete and list anywhere, with the hook never asked.
|
|
2888
|
-
*
|
|
2889
|
-
* Each principal is now one object, set only by the middleware that granted
|
|
2890
|
-
* it (`publicObjectAuth`, `fileTokenAuth`) and recognised by identity. An
|
|
2891
|
-
* adapter builds a new object per request, so it can name the uid but never be
|
|
2892
|
-
* the grant.
|
|
2893
|
-
*/
|
|
2894
|
-
/** The principal `publicObjectAuth` grants an anonymous read of a public object. */
|
|
2895
|
-
var PUBLIC_OBJECT_PRINCIPAL = Object.freeze({
|
|
2896
|
-
uid: "public",
|
|
2897
|
-
roles: ["public"]
|
|
2898
|
-
});
|
|
2899
|
-
/** The principal `fileTokenAuth` grants a read covered by a valid download token. */
|
|
2900
|
-
var DOWNLOAD_TOKEN_PRINCIPAL = Object.freeze({
|
|
2901
|
-
uid: "download-token",
|
|
2902
|
-
roles: ["reader"]
|
|
2903
|
-
});
|
|
2904
|
-
/** Whether `user` is the grant `publicObjectAuth` made — the object, not the uid. */
|
|
2905
|
-
function isPublicObjectGrant(user) {
|
|
2906
|
-
return user === PUBLIC_OBJECT_PRINCIPAL;
|
|
2907
|
-
}
|
|
2908
|
-
/** Whether `user` is the grant `fileTokenAuth` made — the object, not the uid. */
|
|
2909
|
-
function isDownloadTokenGrant(user) {
|
|
2910
|
-
return user === DOWNLOAD_TOKEN_PRINCIPAL;
|
|
2911
|
-
}
|
|
2912
|
-
//#endregion
|
|
2913
|
-
//#region src/auth/middleware.ts
|
|
2914
|
-
/**
|
|
2915
|
-
* Answer a refusal with the canonical envelope, through the same formatter
|
|
2916
|
-
* every route uses.
|
|
2917
|
-
*
|
|
2918
|
-
* These middlewares used to hand-build `c.json({ error: { message, code } },
|
|
2919
|
-
* 401)` — seven near-copies of it, which is how they came to omit `requestId`
|
|
2920
|
-
* while every other error on the server carried it: the one string a bug report
|
|
2921
|
-
* quotes was missing from precisely the errors an app hits most. It also meant
|
|
2922
|
-
* a routine 401 (a client refreshing before it knows whether a session exists)
|
|
2923
|
-
* logged like an incident, because `ApiError.expected` never reached the
|
|
2924
|
-
* logger.
|
|
2925
|
-
*
|
|
2926
|
-
* `errorHandler` is *called* rather than thrown to. A throw only becomes an
|
|
2927
|
-
* envelope where an `onError` is registered, and these middlewares are mounted
|
|
2928
|
-
* by callers this package does not control — `defineFunction` hands users a
|
|
2929
|
-
* Hono app and invites them to put `requireAuth` on a route of it. Making the
|
|
2930
|
-
* status depend on the mount point would turn a 401 into a 500 for exactly
|
|
2931
|
-
* those callers. Calling the formatter directly gets the envelope with none of
|
|
2932
|
-
* that.
|
|
2933
|
-
*/
|
|
2934
|
-
function refuse(c, error) {
|
|
2935
|
-
return errorHandler(error, c);
|
|
2936
|
-
}
|
|
2937
|
-
/**
|
|
2938
|
-
* Hono middleware that requires a valid JWT token via Authorization header.
|
|
2939
|
-
* Returns 401 if token is missing or invalid.
|
|
2940
|
-
*
|
|
2941
|
-
* **Security:** Tokens are only accepted via the `Authorization: Bearer`
|
|
2942
|
-
* header. Query-string tokens (`?token=`) are intentionally NOT accepted
|
|
2943
|
-
* here because URLs leak into access logs, proxies, Referer headers, and
|
|
2944
|
-
* browser history. Use {@link queryTokenAuth} on routes that legitimately
|
|
2945
|
-
* need query-string tokens (e.g. storage file serving for `<img src>`).
|
|
2946
|
-
*/
|
|
2947
|
-
var requireAuth = async (c, next) => {
|
|
2948
|
-
if (c.get("user")) return next();
|
|
2949
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
2950
|
-
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
2951
|
-
const payload = await jwtOf(c).verifyAccessToken(token);
|
|
2952
|
-
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
2953
|
-
c.set("user", payload);
|
|
2954
|
-
return next();
|
|
2955
|
-
};
|
|
2956
|
-
/**
|
|
2957
|
-
* Factory that creates a requireAuth middleware with optional service key support.
|
|
2958
|
-
*
|
|
2959
|
-
* When `serviceKey` is provided, the middleware will check if the Bearer token
|
|
2960
|
-
* matches the service key using constant-time comparison. If it matches, the
|
|
2961
|
-
* request is authenticated as a service user with admin privileges.
|
|
2962
|
-
*
|
|
2963
|
-
* This allows admin routes (which use standalone requireAuth + requireAdmin)
|
|
2964
|
-
* to be accessed via service keys for scripts and server-to-server calls.
|
|
2965
|
-
*/
|
|
2966
|
-
function createRequireAuth(options) {
|
|
2967
|
-
if (!options?.serviceKey && !options?.resolveRoles && !options?.revocationRepo) return requireAuth;
|
|
2968
|
-
const key = options.serviceKey;
|
|
2969
|
-
const resolveRoles = options.resolveRoles;
|
|
2970
|
-
const revocationRepo = options.revocationRepo;
|
|
2971
|
-
return async (c, next) => {
|
|
2972
|
-
if (c.get("user")) return next();
|
|
2973
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
2974
|
-
if (token === void 0) return refuse(c, ApiError.unauthenticated("Authorization header missing or invalid"));
|
|
2975
|
-
if (key !== void 0 && safeCompare(token, key)) {
|
|
2976
|
-
c.set("user", {
|
|
2977
|
-
uid: "service",
|
|
2978
|
-
roles: ["admin"]
|
|
2979
|
-
});
|
|
2980
|
-
return next();
|
|
2981
|
-
}
|
|
2982
|
-
const payload = await jwtOf(c).verifyAccessToken(token);
|
|
2983
|
-
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
2984
|
-
if (resolveRoles || revocationRepo) try {
|
|
2985
|
-
let judgedRoles;
|
|
2986
|
-
if (revocationRepo) {
|
|
2987
|
-
const verdict = await judgeAccessToken(revocationRepo, payload);
|
|
2988
|
-
if (!verdict.live) return refuse(c, accessTokenRefusalError(verdict.refusal));
|
|
2989
|
-
judgedRoles = verdict.roles;
|
|
2990
|
-
}
|
|
2991
|
-
c.set("user", resolveRoles ? {
|
|
2992
|
-
...payload,
|
|
2993
|
-
roles: judgedRoles ?? await resolveRoles(payload.uid)
|
|
2994
|
-
} : payload);
|
|
2995
|
-
return next();
|
|
2996
|
-
} catch (error) {
|
|
2997
|
-
if (isDatabaseUnreachable(error)) {
|
|
2998
|
-
if (!warnIfDatabaseClosed(error)) logger.warn("[Auth] Could not resolve roles for an admin-gated request: the database is unreachable", { uid: payload.uid });
|
|
2999
|
-
return refuse(c, ApiError.databaseUnavailable());
|
|
3000
|
-
}
|
|
3001
|
-
logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
|
|
3002
|
-
uid: payload.uid,
|
|
3003
|
-
error
|
|
3004
|
-
});
|
|
3005
|
-
return refuse(c, ApiError.serviceUnavailable("Could not verify your permissions. Please try again.", "ROLE_LOOKUP_FAILED"));
|
|
3006
|
-
}
|
|
3007
|
-
c.set("user", payload);
|
|
3008
|
-
return next();
|
|
3009
|
-
};
|
|
3010
|
-
}
|
|
3011
|
-
/**
|
|
3012
|
-
* Middleware that requires the user to hold the `admin` role, which holds
|
|
3013
|
-
* every scope. Must be used AFTER requireAuth or on a route where user is
|
|
3014
|
-
* guaranteed.
|
|
3015
|
-
*
|
|
3016
|
-
* Prefer `requireScope` from `./access` for anything a scope names: a scope
|
|
3017
|
-
* can be granted to a narrower role and to a key, and `admin` cannot.
|
|
3018
|
-
*/
|
|
3019
|
-
var requireAdmin = async (c, next) => {
|
|
3020
|
-
const user = c.get("user");
|
|
3021
|
-
if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
|
|
3022
|
-
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"));
|
|
3023
|
-
return next();
|
|
3024
|
-
};
|
|
3025
|
-
/**
|
|
3026
|
-
* Middleware that optionally extracts user from JWT via Authorization header.
|
|
3027
|
-
* Does not return 401 if token is missing — allows anonymous access.
|
|
3028
|
-
*
|
|
3029
|
-
* Query-string tokens are NOT accepted here. Use {@link queryTokenAuth}
|
|
3030
|
-
* on routes that need them.
|
|
3031
|
-
*/
|
|
3032
|
-
var optionalAuth = async (c, next) => {
|
|
3033
|
-
if (c.get("user")) return next();
|
|
3034
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
3035
|
-
const jwt = jwtOf(c);
|
|
3036
|
-
if (token !== void 0 && jwt.isConfigured()) {
|
|
3037
|
-
const payload = await jwt.verifyAccessToken(token);
|
|
3038
|
-
if (payload) c.set("user", payload);
|
|
3039
|
-
}
|
|
3040
|
-
return next();
|
|
3041
|
-
};
|
|
3042
|
-
/**
|
|
3043
|
-
* Who a session token names, as `jwt` verifies it — or "nobody" rather than a
|
|
3044
|
-
* throw on a backend with no JWT of its own, so a socket closes as
|
|
3045
|
-
* unauthenticated, which is the outcome this describes.
|
|
3046
|
-
*/
|
|
3047
|
-
async function userFromToken(jwt, token) {
|
|
3048
|
-
if (!jwt.isConfigured()) return null;
|
|
3049
|
-
return jwt.verifyAccessToken(token);
|
|
3050
|
-
}
|
|
3051
|
-
/**
|
|
3052
|
-
* Extract user from token - for WebSocket authentication. Answers for the
|
|
3053
|
-
* process default; a backend's own socket verifies with its own keys.
|
|
3054
|
-
*/
|
|
3055
|
-
async function extractUserFromToken(token) {
|
|
3056
|
-
return userFromToken(processJwtService, token);
|
|
3057
|
-
}
|
|
3058
|
-
/**
|
|
3059
|
-
* Create a configurable auth middleware that handles:
|
|
3060
|
-
* 1. Token extraction (via custom validator or JWT Bearer token)
|
|
3061
|
-
* 2. RLS-scoped DataDriver via withAuth()
|
|
3062
|
-
* 3. Enforcement (401 when requireAuth is true and no user)
|
|
3063
|
-
*
|
|
3064
|
-
* **Secure by default:** `requireAuth` defaults to `true`. Anonymous
|
|
3065
|
-
* access is only allowed when the developer explicitly opts out by
|
|
3066
|
-
* setting `requireAuth: false`, indicating that Postgres RLS policies
|
|
3067
|
-
* fully control access.
|
|
3068
|
-
*
|
|
3069
|
-
* **Fail-closed:** The raw unscoped driver is never placed in the
|
|
3070
|
-
* request context. Every code path either scopes via `withAuth()` or
|
|
3071
|
-
* rejects the request. This prevents silent RLS bypass.
|
|
3072
|
-
*
|
|
3073
|
-
* This is the single source of truth for HTTP auth in Rebase.
|
|
3074
|
-
* Use this instead of manually parsing tokens in route handlers.
|
|
3075
|
-
*/
|
|
3076
|
-
function createAuthMiddleware(options) {
|
|
3077
|
-
const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
3078
|
-
return async (c, next) => {
|
|
3079
|
-
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
3080
|
-
let credential = "none";
|
|
3081
|
-
if (validator) try {
|
|
3082
|
-
const authResult = await validator(c);
|
|
3083
|
-
if (authResult && typeof authResult === "object") {
|
|
3084
|
-
const id = "uid" in authResult ? authResult.uid : void 0;
|
|
3085
|
-
if (id) {
|
|
3086
|
-
const roles = authResult.roles || [];
|
|
3087
|
-
credential = "session";
|
|
3088
|
-
c.set("user", {
|
|
3089
|
-
uid: id,
|
|
3090
|
-
roles
|
|
3091
|
-
});
|
|
3092
|
-
const user = {
|
|
3093
|
-
uid: id,
|
|
3094
|
-
roles,
|
|
3095
|
-
...authResult
|
|
3096
|
-
};
|
|
3097
|
-
c.set("driver", await scopeDataDriver(driver, user));
|
|
3098
|
-
} else c.set("driver", await scopeDataDriver(driver, {
|
|
3099
|
-
uid: ANONYMOUS_USER_ID,
|
|
3100
|
-
roles: ["anon"]
|
|
3101
|
-
}));
|
|
3102
|
-
} else if (authResult === true) {
|
|
3103
|
-
credential = "session";
|
|
3104
|
-
c.set("user", {
|
|
3105
|
-
uid: "default",
|
|
3106
|
-
roles: []
|
|
3107
|
-
});
|
|
3108
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
3109
|
-
uid: "default",
|
|
3110
|
-
roles: []
|
|
3111
|
-
}));
|
|
3112
|
-
} else c.set("driver", await scopeDataDriver(driver, {
|
|
3113
|
-
uid: ANONYMOUS_USER_ID,
|
|
3114
|
-
roles: ["anon"]
|
|
3115
|
-
}));
|
|
3116
|
-
} catch (error) {
|
|
3117
|
-
return refuse(c, ApiError.unauthenticated("Unauthorized"));
|
|
3118
|
-
}
|
|
3119
|
-
else {
|
|
3120
|
-
const token = extractBearerToken(c.req.header("authorization"));
|
|
3121
|
-
if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
|
|
3122
|
-
const serviceUser = {
|
|
3123
|
-
uid: "service",
|
|
3124
|
-
roles: ["admin"]
|
|
3125
|
-
};
|
|
3126
|
-
credential = "service-key";
|
|
3127
|
-
c.set("user", serviceUser);
|
|
3128
|
-
try {
|
|
3129
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
3130
|
-
uid: "service",
|
|
3131
|
-
roles: ["admin"]
|
|
3132
|
-
}));
|
|
3133
|
-
} catch (error) {
|
|
3134
|
-
logger.error("[AUTH] RLS scoping failed for service key", { error });
|
|
3135
|
-
return c.json({ error: {
|
|
3136
|
-
message: "Internal authentication error",
|
|
3137
|
-
code: "INTERNAL_ERROR"
|
|
3138
|
-
} }, 500);
|
|
3139
|
-
}
|
|
3140
|
-
} else if (apiKeyStore && isApiKeyToken(token)) {
|
|
3141
|
-
const result = await validateApiKey(c, token, {
|
|
3142
|
-
store: apiKeyStore,
|
|
3143
|
-
driver
|
|
3144
|
-
});
|
|
3145
|
-
if (result !== true) return result;
|
|
3146
|
-
credential = "api-key";
|
|
3147
|
-
} else {
|
|
3148
|
-
const payload = await userFromToken(jwtOf(c), token);
|
|
3149
|
-
if (payload) {
|
|
3150
|
-
credential = "session";
|
|
3151
|
-
c.set("user", payload);
|
|
3152
|
-
try {
|
|
3153
|
-
const user = {
|
|
3154
|
-
uid: payload.uid,
|
|
3155
|
-
roles: payload.roles,
|
|
3156
|
-
isAnonymous: payload.isAnonymous === true,
|
|
3157
|
-
claims: payload.claims
|
|
3158
|
-
};
|
|
3159
|
-
c.set("driver", await scopeDataDriver(driver, user));
|
|
3160
|
-
} catch (error) {
|
|
3161
|
-
logger.error("[AUTH] RLS scoping failed for authenticated user", { error });
|
|
3162
|
-
return c.json({ error: {
|
|
3163
|
-
message: "Internal authentication error",
|
|
3164
|
-
code: "INTERNAL_ERROR"
|
|
3165
|
-
} }, 500);
|
|
3166
|
-
}
|
|
3167
|
-
} else return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
3168
|
-
}
|
|
3169
|
-
else try {
|
|
3170
|
-
c.set("driver", await scopeDataDriver(driver, {
|
|
3171
|
-
uid: ANONYMOUS_USER_ID,
|
|
3172
|
-
roles: ["anon"]
|
|
3173
|
-
}));
|
|
3174
|
-
} catch (error) {
|
|
3175
|
-
logger.error("[AUTH] Failed to create anon-scoped driver", { error });
|
|
3176
|
-
return c.json({ error: {
|
|
3177
|
-
message: "Server configuration error",
|
|
3178
|
-
code: "INTERNAL_ERROR"
|
|
3179
|
-
} }, 500);
|
|
3180
|
-
}
|
|
3181
|
-
}
|
|
3182
|
-
const refused = await applyImpersonation(c, {
|
|
3183
|
-
credential,
|
|
3184
|
-
driver
|
|
3185
|
-
});
|
|
3186
|
-
if (refused) return refused;
|
|
3187
|
-
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."));
|
|
3188
|
-
return next();
|
|
3189
|
-
};
|
|
3190
|
-
}
|
|
3191
|
-
/**
|
|
3192
|
-
* Middleware that authenticates via a `?token=` query parameter.
|
|
3193
|
-
*
|
|
3194
|
-
* **Use sparingly.** Tokens in URLs leak into access logs, proxy logs,
|
|
3195
|
-
* Referer headers, and browser history. This middleware exists solely for
|
|
3196
|
-
* routes where the consumer cannot set HTTP headers — e.g. `<img src>`,
|
|
3197
|
-
* `<a href>` for file downloads, or similar browser-native requests.
|
|
3198
|
-
*
|
|
3199
|
-
* Apply it **before** `requireAuth` or `optionalAuth` on the specific
|
|
3200
|
-
* route that needs it. Those middlewares will see the user context this
|
|
3201
|
-
* middleware sets and skip their own 401 check.
|
|
3202
|
-
*
|
|
3203
|
-
* @example
|
|
3204
|
-
* ```ts
|
|
3205
|
-
* router.get("/file/*", queryTokenAuth, readAuthMiddleware, handler);
|
|
3206
|
-
* ```
|
|
3207
|
-
*/
|
|
3208
|
-
var queryTokenAuth = async (c, next) => {
|
|
3209
|
-
if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
|
|
3210
|
-
const queryToken = c.req.query("token");
|
|
3211
|
-
const jwt = jwtOf(c);
|
|
3212
|
-
if (queryToken && jwt.isConfigured()) {
|
|
3213
|
-
const payload = await jwt.verifyAccessToken(queryToken);
|
|
3214
|
-
if (payload) c.set("user", payload);
|
|
3215
|
-
}
|
|
3216
|
-
return next();
|
|
3217
|
-
};
|
|
3218
|
-
/**
|
|
3219
|
-
* Authorizes anonymous access to **public** storage objects (those under the
|
|
3220
|
-
* public prefix), which are served token-less via stable, permanent URLs.
|
|
3221
|
-
*
|
|
3222
|
-
* Runs on the storage `/file/*` and `/metadata/*` routes, *after* the token
|
|
3223
|
-
* middleware and *before* the read-auth gate. If the request is already
|
|
3224
|
-
* authenticated (Bearer or scoped token), it does nothing. Otherwise, when the
|
|
3225
|
-
* requested object path is public, it sets a minimal "public" principal so the
|
|
3226
|
-
* downstream `requireAuth` gate lets the read through. Private paths are left
|
|
3227
|
-
* untouched, so they still require a valid token.
|
|
3228
|
-
*
|
|
3229
|
-
* Public means the object the route will serve is public, so the decision is
|
|
3230
|
-
* made on that object's canonical key, derived by the route's own function
|
|
3231
|
-
* (`requestedStorageObject`). It used to be made on the raw path, through a
|
|
3232
|
-
* check that strips a `scheme://`: `notes://public/secret.txt` read as
|
|
3233
|
-
* `public/secret.txt` here while the route served the private
|
|
3234
|
-
* `notes:/public/secret.txt`, and the authorize hook — not asked about this
|
|
3235
|
-
* principal — never saw it.
|
|
3236
|
-
*/
|
|
3237
|
-
var publicObjectAuth = async (c, next) => {
|
|
3238
|
-
if (c.get("user")) return next();
|
|
3239
|
-
const requested = tryRequestedStorageObject(storageRequestWildcard(c));
|
|
3240
|
-
if (requested && isPublicStorageKey(requested.key)) c.set("user", PUBLIC_OBJECT_PRINCIPAL);
|
|
3241
|
-
return next();
|
|
3242
|
-
};
|
|
3243
|
-
/**
|
|
3244
|
-
* Helper to match paths for scoped file tokens.
|
|
3245
|
-
* Matches exact paths or prefixes (exact folder prefixes or ending with /).
|
|
3246
|
-
*
|
|
3247
|
-
* The traversal check is defence in depth, not the load-bearing defence.
|
|
3248
|
-
*
|
|
3249
|
-
* A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
|
|
3250
|
-
* starts with `user1/`, so a token scoped to `user1/` would authorize reading
|
|
3251
|
-
* user2's file — and the storage controller would not object, because its own
|
|
3252
|
-
* guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
|
|
3253
|
-
* comfortably inside it. The grant is what would be escaped, and this is the
|
|
3254
|
-
* only layer that knows what was granted.
|
|
3255
|
-
*
|
|
3256
|
-
* Nothing arrives here in that shape today: the URL parser resolves `..` (and
|
|
3257
|
-
* `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
|
|
3258
|
-
* Hono routes the request, so this comparison already runs on a resolved path.
|
|
3259
|
-
* But that is a guarantee of the runtime rather than of this code, and it is
|
|
3260
|
-
* one line to not depend on it. The same rule already guards the public-object
|
|
3261
|
-
* path — see `isPublicStorageKey`.
|
|
3262
|
-
*/
|
|
3263
|
-
function isPathMatch(requested, allowed) {
|
|
3264
|
-
if (requested.split("/").some((seg) => seg === "..")) return false;
|
|
3265
|
-
if (requested === allowed) return true;
|
|
3266
|
-
if (allowed.endsWith("/")) return requested.startsWith(allowed);
|
|
3267
|
-
return requested.startsWith(allowed + "/");
|
|
1892
|
+
resolvedHooks.afterUserDelete(uid).catch((err) => {
|
|
1893
|
+
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
1894
|
+
});
|
|
3268
1895
|
}
|
|
3269
1896
|
/**
|
|
3270
|
-
*
|
|
3271
|
-
*
|
|
3272
|
-
*
|
|
1897
|
+
* The auth adapter's side of user administration through the auth collection
|
|
1898
|
+
* (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
|
|
1899
|
+
* routes' rules and hooks, for the data API's writes to the same rows.
|
|
3273
1900
|
*/
|
|
3274
|
-
|
|
3275
|
-
const
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
/**
|
|
3299
|
-
* Which half of the scope failed is named in the message. The holder of the
|
|
3300
|
-
* token already knows both values, so this reveals nothing to an attacker —
|
|
3301
|
-
* and without it, a client that forgets to forward `?storageId=` on the file
|
|
3302
|
-
* URL reports a "path mismatch" for a path that matches perfectly.
|
|
3303
|
-
*/
|
|
3304
|
-
const denyMismatch = (outcome) => refuse(c, ApiError.forbidden(outcome === "deny-storage" ? "Forbidden: Scoped token storage mismatch" : "Forbidden: Scoped token path mismatch"));
|
|
3305
|
-
const bearerToken = extractBearerToken(authHeader);
|
|
3306
|
-
if (bearerToken !== void 0) {
|
|
3307
|
-
const token = bearerToken;
|
|
3308
|
-
const payload = await jwtOf(c).verifyDownloadToken(token);
|
|
3309
|
-
if (payload) {
|
|
3310
|
-
const outcome = evaluateGrant(payload);
|
|
3311
|
-
if (outcome === "grant") {
|
|
3312
|
-
c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
|
|
3313
|
-
return next();
|
|
3314
|
-
}
|
|
3315
|
-
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
3316
|
-
}
|
|
3317
|
-
if (isFileRoute) return refuse(c, ApiError.unauthenticated("Unauthorized: Access JWT not allowed on file routes"));
|
|
3318
|
-
return next();
|
|
3319
|
-
}
|
|
3320
|
-
if (queryToken) {
|
|
3321
|
-
const payload = await jwtOf(c).verifyDownloadToken(queryToken);
|
|
3322
|
-
if (payload) {
|
|
3323
|
-
const outcome = evaluateGrant(payload);
|
|
3324
|
-
if (outcome === "grant") {
|
|
3325
|
-
c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
|
|
3326
|
-
return next();
|
|
1901
|
+
function authCollectionUserAdmin(ctx) {
|
|
1902
|
+
const { authRepo, resolvedHooks } = ctx;
|
|
1903
|
+
return {
|
|
1904
|
+
async prepareUserUpdates(updates) {
|
|
1905
|
+
for (const { values } of updates) if (values.roles !== void 0 && values.roles !== null) readRoleNames(values.roles);
|
|
1906
|
+
await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
|
|
1907
|
+
uid,
|
|
1908
|
+
roles: values.roles
|
|
1909
|
+
}] : []));
|
|
1910
|
+
const prepared = [];
|
|
1911
|
+
for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
|
|
1912
|
+
...values,
|
|
1913
|
+
email: await normalizeUserEmailChange(authRepo, uid, values.email)
|
|
1914
|
+
} : values);
|
|
1915
|
+
return prepared;
|
|
1916
|
+
},
|
|
1917
|
+
async prepareUserDeletions(uids) {
|
|
1918
|
+
await assertUserDeletionsAllowed(authRepo, uids);
|
|
1919
|
+
if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
|
|
1920
|
+
},
|
|
1921
|
+
async finalizeUserDeletions(uids) {
|
|
1922
|
+
for (const uid of uids) {
|
|
1923
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1924
|
+
runAfterUserDelete(resolvedHooks, uid);
|
|
3327
1925
|
}
|
|
3328
|
-
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
3329
1926
|
}
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
return next();
|
|
3333
|
-
};
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
3334
1929
|
//#endregion
|
|
3335
1930
|
//#region src/auth/adapter-middleware.ts
|
|
1931
|
+
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
3336
1932
|
/**
|
|
3337
1933
|
* What a request is refused with when `adapter.verifyRequest` throws: the
|
|
3338
1934
|
* adapter's own answer when it gave one, otherwise a 401 — fail closed either
|
|
@@ -3361,6 +1957,11 @@ function createAdapterAuthMiddleware(options) {
|
|
|
3361
1957
|
driver
|
|
3362
1958
|
});
|
|
3363
1959
|
if (result !== true) return result;
|
|
1960
|
+
const refusedSimulation = await applySimulation(c, {
|
|
1961
|
+
credential: "api-key",
|
|
1962
|
+
driver
|
|
1963
|
+
});
|
|
1964
|
+
if (refusedSimulation) return refusedSimulation;
|
|
3364
1965
|
const refused = await applyImpersonation(c, {
|
|
3365
1966
|
credential: "api-key",
|
|
3366
1967
|
driver
|
|
@@ -3405,13 +2006,22 @@ function createAdapterAuthMiddleware(options) {
|
|
|
3405
2006
|
return refuse(c, ApiError.internal("Server configuration error"));
|
|
3406
2007
|
}
|
|
3407
2008
|
}
|
|
2009
|
+
const credential = !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session";
|
|
2010
|
+
const resolveUser = adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0;
|
|
2011
|
+
const refusedSimulation = await applySimulation(c, {
|
|
2012
|
+
credential,
|
|
2013
|
+
driver,
|
|
2014
|
+
resolveUser,
|
|
2015
|
+
apiKeyStore
|
|
2016
|
+
});
|
|
2017
|
+
if (refusedSimulation) return refusedSimulation;
|
|
3408
2018
|
const refused = await applyImpersonation(c, {
|
|
3409
|
-
credential
|
|
2019
|
+
credential,
|
|
3410
2020
|
driver,
|
|
3411
|
-
resolveUser
|
|
2021
|
+
resolveUser
|
|
3412
2022
|
});
|
|
3413
2023
|
if (refused) return refused;
|
|
3414
|
-
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."));
|
|
2024
|
+
if (enforceAuth && !c.get("user") && !c.get("simulation")) 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."));
|
|
3415
2025
|
return next();
|
|
3416
2026
|
};
|
|
3417
2027
|
}
|
|
@@ -3855,6 +2465,13 @@ function createDevEmailSink(options = {}) {
|
|
|
3855
2465
|
}
|
|
3856
2466
|
var activeSink;
|
|
3857
2467
|
/**
|
|
2468
|
+
* Every sink registered, by the `sendEmail` it hands out: what lets a backend
|
|
2469
|
+
* serve the mailbox it was given rather than the process's last one. With two
|
|
2470
|
+
* backends in one development process, the slot above made B's admins read
|
|
2471
|
+
* A's users' reset and magic links.
|
|
2472
|
+
*/
|
|
2473
|
+
var sinksBySender = /* @__PURE__ */ new WeakMap();
|
|
2474
|
+
/**
|
|
3858
2475
|
* Make `sink` the process's development mailbox.
|
|
3859
2476
|
*
|
|
3860
2477
|
* Returns the sink, so this can wrap the construction it replaces. In
|
|
@@ -3867,8 +2484,18 @@ function registerDevEmailSink(sink) {
|
|
|
3867
2484
|
return sink;
|
|
3868
2485
|
}
|
|
3869
2486
|
activeSink = sink;
|
|
2487
|
+
sinksBySender.set(sink.sendEmail, sink);
|
|
3870
2488
|
return sink;
|
|
3871
2489
|
}
|
|
2490
|
+
/**
|
|
2491
|
+
* The registered mailbox whose `sendEmail` this is — the sink a backend's
|
|
2492
|
+
* `auth.email.sendEmail` came from — or `undefined` when it is some other
|
|
2493
|
+
* sender, or the process is production.
|
|
2494
|
+
*/
|
|
2495
|
+
function devEmailSinkFor(sendEmail) {
|
|
2496
|
+
if (process.env.NODE_ENV === "production" || !sendEmail) return void 0;
|
|
2497
|
+
return sinksBySender.get(sendEmail);
|
|
2498
|
+
}
|
|
3872
2499
|
/** The registered mailbox, if there is one. */
|
|
3873
2500
|
function activeDevEmailSink() {
|
|
3874
2501
|
return process.env.NODE_ENV === "production" ? void 0 : activeSink;
|
|
@@ -4348,6 +2975,11 @@ function createDataRateLimiter(config = {}, options = {}) {
|
|
|
4348
2975
|
const resolveBucket = async (c) => {
|
|
4349
2976
|
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
4350
2977
|
if (isServiceKey(c)) return null;
|
|
2978
|
+
const impersonator = c.get("impersonator");
|
|
2979
|
+
if (impersonator?.uid) return bucketFor({
|
|
2980
|
+
uid: impersonator.uid,
|
|
2981
|
+
address: () => defaultKeyGenerator(c, trustedProxyHops)
|
|
2982
|
+
});
|
|
4351
2983
|
const apiKey = c.get("apiKey");
|
|
4352
2984
|
return bucketFor({
|
|
4353
2985
|
apiKey,
|
|
@@ -6266,36 +4898,29 @@ function mountOtpRoutes(deps) {
|
|
|
6266
4898
|
/**
|
|
6267
4899
|
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
6268
4900
|
*
|
|
6269
|
-
*
|
|
6270
|
-
*
|
|
6271
|
-
* - the **provider** must have verified the address, or the caller has not
|
|
6272
|
-
* shown they control it;
|
|
6273
|
-
* - the **local account**'s address must have been verified too. Whoever
|
|
6274
|
-
* made an unverified account never proved the address, and every way in
|
|
6275
|
-
* they left on it — a password, an identity from a provider that did not
|
|
6276
|
-
* vouch for the address — would go on opening the account the owner is
|
|
6277
|
-
* about to be signed into.
|
|
4901
|
+
* Only when the **provider** verified the address. Otherwise the caller has
|
|
4902
|
+
* not shown they control it, and linking would hand them the account.
|
|
6278
4903
|
*
|
|
6279
|
-
*
|
|
6280
|
-
*
|
|
6281
|
-
*
|
|
6282
|
-
*
|
|
6283
|
-
*
|
|
6284
|
-
*
|
|
6285
|
-
*
|
|
6286
|
-
*
|
|
6287
|
-
*
|
|
4904
|
+
* When it did, the caller has proven the address exactly as reading a code out
|
|
4905
|
+
* of the inbox does: only the inbox's owner can hold a provider account that
|
|
4906
|
+
* vouches for it. If the **local account**'s address was never verified,
|
|
4907
|
+
* whoever made it never proved the address, and every way in they left on it —
|
|
4908
|
+
* a password, an identity from a provider that did not vouch for the address,
|
|
4909
|
+
* a live session — would go on opening the account the owner is about to be
|
|
4910
|
+
* signed into. So that case answers `provesAddress: true`, and the route
|
|
4911
|
+
* removes those ways in before it links (the pre-hijack defence). It used to
|
|
4912
|
+
* refuse instead, which left the owner of the address locked out of their own
|
|
4913
|
+
* address until they found a magic link or a reset.
|
|
6288
4914
|
*/
|
|
6289
4915
|
function decideOAuthAutoLink(args) {
|
|
6290
4916
|
if (args.providerEmailVerified !== true) return {
|
|
6291
4917
|
allowed: false,
|
|
6292
4918
|
reason: "provider-email-unverified"
|
|
6293
4919
|
};
|
|
6294
|
-
|
|
6295
|
-
allowed:
|
|
6296
|
-
|
|
4920
|
+
return {
|
|
4921
|
+
allowed: true,
|
|
4922
|
+
provesAddress: !args.existingUser.emailVerified
|
|
6297
4923
|
};
|
|
6298
|
-
return { allowed: true };
|
|
6299
4924
|
}
|
|
6300
4925
|
/**
|
|
6301
4926
|
* Is `redirectUri` one the operator authorised?
|
|
@@ -6751,10 +5376,18 @@ function createAuthRoutes(config) {
|
|
|
6751
5376
|
providerEmailVerified: externalUser.emailVerified,
|
|
6752
5377
|
existingUser: user
|
|
6753
5378
|
});
|
|
6754
|
-
if (!decision.allowed) throw new ApiError(403, "EMAIL_NOT_VERIFIED", `An account with this email already exists with a different sign-in method. ${
|
|
5379
|
+
if (!decision.allowed) throw new ApiError(403, "EMAIL_NOT_VERIFIED", `An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, {
|
|
6755
5380
|
reason: decision.reason,
|
|
6756
5381
|
provider: provider.id
|
|
6757
5382
|
});
|
|
5383
|
+
if (decision.provesAddress) {
|
|
5384
|
+
await confirmAddressOwnership(authRepo, user, null);
|
|
5385
|
+
user = {
|
|
5386
|
+
...user,
|
|
5387
|
+
emailVerified: true,
|
|
5388
|
+
passwordHash: null
|
|
5389
|
+
};
|
|
5390
|
+
}
|
|
6758
5391
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
6759
5392
|
await authRepo.updateUser(user.id, {
|
|
6760
5393
|
displayName: user.displayName || externalUser.displayName || void 0,
|
|
@@ -7718,9 +6351,10 @@ function createAdminUsersRoute(config) {
|
|
|
7718
6351
|
});
|
|
7719
6352
|
router.post("/users", requireScope("users:write"), async (c) => {
|
|
7720
6353
|
const body = await c.req.json();
|
|
7721
|
-
const { email
|
|
6354
|
+
const { email } = body;
|
|
7722
6355
|
if (!email) throw ApiError.badRequest("Email is required");
|
|
7723
|
-
|
|
6356
|
+
const roles = body.roles === void 0 || body.roles === null ? void 0 : readRoleNames(body.roles);
|
|
6357
|
+
if (roles !== void 0) assertMayGrantRoles(c, roles);
|
|
7724
6358
|
if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
|
|
7725
6359
|
const prepResult = await prepareAdminUserValues(body, {
|
|
7726
6360
|
authRepo,
|
|
@@ -7739,7 +6373,7 @@ function createAdminUsersRoute(config) {
|
|
|
7739
6373
|
};
|
|
7740
6374
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
7741
6375
|
const user = await authRepo.createUser(createData);
|
|
7742
|
-
if (roles
|
|
6376
|
+
if (roles !== void 0) await authRepo.setUserRoles(user.id, roles);
|
|
7743
6377
|
const delivery = await completeUserCreation(prepResult, (clearPassword) => finalizeAdminUserCreation({
|
|
7744
6378
|
id: user.id,
|
|
7745
6379
|
values: prepResult.values
|
|
@@ -7758,7 +6392,9 @@ function createAdminUsersRoute(config) {
|
|
|
7758
6392
|
});
|
|
7759
6393
|
router.put("/users/:uid", requireScope("users:write"), async (c) => {
|
|
7760
6394
|
const uid = c.req.param("uid");
|
|
7761
|
-
const
|
|
6395
|
+
const body = await c.req.json();
|
|
6396
|
+
const { password, email, displayName, disabled } = body;
|
|
6397
|
+
const roles = body.roles === void 0 || body.roles === null ? void 0 : readRoleNames(body.roles);
|
|
7762
6398
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
7763
6399
|
if (disabled !== void 0) {
|
|
7764
6400
|
if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
|
|
@@ -7767,8 +6403,8 @@ function createAdminUsersRoute(config) {
|
|
|
7767
6403
|
if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
|
|
7768
6404
|
}
|
|
7769
6405
|
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
7770
|
-
if (roles !== void 0
|
|
7771
|
-
assertMayGrantRoles(c, roles
|
|
6406
|
+
if (roles !== void 0) {
|
|
6407
|
+
assertMayGrantRoles(c, roles);
|
|
7772
6408
|
await assertRoleChangesAllowed(authRepo, [{
|
|
7773
6409
|
uid,
|
|
7774
6410
|
roles
|
|
@@ -7785,7 +6421,7 @@ function createAdminUsersRoute(config) {
|
|
|
7785
6421
|
}
|
|
7786
6422
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
7787
6423
|
if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
|
|
7788
|
-
if (roles !== void 0
|
|
6424
|
+
if (roles !== void 0) await authRepo.setUserRoles(uid, roles);
|
|
7789
6425
|
if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
|
|
7790
6426
|
await authRepo.setUserDisabled(uid, disabled);
|
|
7791
6427
|
if (disabled) await revokeAllSessions(authRepo, uid);
|
|
@@ -8285,7 +6921,7 @@ function scopesFromStoredPermissions(permissions, admin) {
|
|
|
8285
6921
|
*
|
|
8286
6922
|
* @module
|
|
8287
6923
|
*/
|
|
8288
|
-
var TABLE
|
|
6924
|
+
var TABLE = "rebase.api_keys";
|
|
8289
6925
|
/**
|
|
8290
6926
|
* Drops `rebase.idx_api_keys_hash` — a plain btree on `key_hash`, next to the
|
|
8291
6927
|
* UNIQUE (key_hash) constraint's own index — but only while a valid, unique,
|
|
@@ -8340,7 +6976,7 @@ var BOOT_COLUMNS = [
|
|
|
8340
6976
|
var BOOT_STATE_SQL = (() => {
|
|
8341
6977
|
const list = (names) => names.map((name) => `'${name}'`).join(", ");
|
|
8342
6978
|
const target = `(SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')`;
|
|
8343
|
-
const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE
|
|
6979
|
+
const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE}') AND a.attnum > 0 AND NOT a.attisdropped AND a.attname IN (${list(names)}))::int`;
|
|
8344
6980
|
return `
|
|
8345
6981
|
SELECT
|
|
8346
6982
|
(SELECT count(*) FROM pg_class c WHERE c.relnamespace = to_regnamespace('rebase')
|
|
@@ -8350,12 +6986,12 @@ SELECT
|
|
|
8350
6986
|
${columnCount(BOOT_COLUMNS)} AS columns,
|
|
8351
6987
|
${columnCount(["permissions"])} AS legacy,
|
|
8352
6988
|
(SELECT count(*) FROM pg_class c
|
|
8353
|
-
WHERE c.oid = to_regclass('${TABLE
|
|
6989
|
+
WHERE c.oid = to_regclass('${TABLE}') AND NOT c.relrowsecurity AND ${target} IS NOT NULL
|
|
8354
6990
|
AND (c.relacl IS NULL
|
|
8355
6991
|
OR EXISTS (SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee = ${target})
|
|
8356
6992
|
OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
|
|
8357
6993
|
WHERE att.attrelid = c.oid AND att.attacl IS NOT NULL AND a.grantee = ${target})))::int AS granted,
|
|
8358
|
-
(has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE
|
|
6994
|
+
(has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE}'), 'SELECT')) AS readable
|
|
8359
6995
|
WHERE to_regnamespace('rebase') IS NOT NULL`.trim();
|
|
8360
6996
|
})();
|
|
8361
6997
|
/**
|
|
@@ -8440,13 +7076,13 @@ function rowToApiKey(row) {
|
|
|
8440
7076
|
*
|
|
8441
7077
|
* Returns `undefined` if the driver does not support `executeSql`.
|
|
8442
7078
|
*/
|
|
8443
|
-
function createApiKeyStore(driver) {
|
|
7079
|
+
function createApiKeyStore(driver, options = {}) {
|
|
8444
7080
|
const admin = driver.admin;
|
|
8445
7081
|
if (!isSQLAdmin(admin)) {
|
|
8446
7082
|
logger.warn("⚠️ [api-key-store] DataDriver does not support SQL admin — API keys will not be available.");
|
|
8447
7083
|
return;
|
|
8448
7084
|
}
|
|
8449
|
-
const exec = (sqlText,
|
|
7085
|
+
const exec = provisioningExec((sqlText, execOptions) => admin.executeSql(sqlText, execOptions?.params ? { params: execOptions.params } : void 0), options.provision);
|
|
8450
7086
|
const ddl = createDdlBootstrapper(exec, "api-key-store");
|
|
8451
7087
|
/**
|
|
8452
7088
|
* Give every row stored before scopes existed the scopes it now holds.
|
|
@@ -8457,10 +7093,10 @@ function createApiKeyStore(driver) {
|
|
|
8457
7093
|
* runtime that predates scopes still reads its own keys after a rollback.
|
|
8458
7094
|
*/
|
|
8459
7095
|
async function backfillScopes() {
|
|
8460
|
-
const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE
|
|
7096
|
+
const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE} WHERE scopes IS NULL`);
|
|
8461
7097
|
for (const row of rows) {
|
|
8462
7098
|
const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
|
|
8463
|
-
await exec(`UPDATE ${TABLE
|
|
7099
|
+
await exec(`UPDATE ${TABLE} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
|
|
8464
7100
|
JSON.stringify(scopes),
|
|
8465
7101
|
JSON.stringify(roles),
|
|
8466
7102
|
row.id
|
|
@@ -8492,8 +7128,8 @@ function createApiKeyStore(driver) {
|
|
|
8492
7128
|
return;
|
|
8493
7129
|
}
|
|
8494
7130
|
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
8495
|
-
await ddl.ensureObject(`Creating ${TABLE
|
|
8496
|
-
CREATE TABLE IF NOT EXISTS ${TABLE
|
|
7131
|
+
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
7132
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
8497
7133
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
8498
7134
|
name TEXT NOT NULL,
|
|
8499
7135
|
kind TEXT NOT NULL DEFAULT 'service',
|
|
@@ -8514,35 +7150,42 @@ function createApiKeyStore(driver) {
|
|
|
8514
7150
|
try {
|
|
8515
7151
|
await exec(DROP_DUPLICATE_KEY_HASH_INDEX_SQL);
|
|
8516
7152
|
} catch (err) {
|
|
8517
|
-
logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
|
|
7153
|
+
if (!(err instanceof SchemaChangeRefusedError)) logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
|
|
8518
7154
|
}
|
|
8519
7155
|
await ddl.ensureObject("Creating idx_api_keys_prefix", `
|
|
8520
7156
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
|
8521
|
-
ON ${TABLE
|
|
7157
|
+
ON ${TABLE}(key_prefix)
|
|
8522
7158
|
`);
|
|
8523
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8524
|
-
ALTER TABLE ${TABLE
|
|
7159
|
+
await ddl.ensureObject(`Adding ${TABLE}.kind`, `
|
|
7160
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
|
|
8525
7161
|
`);
|
|
8526
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8527
|
-
ALTER TABLE ${TABLE
|
|
7162
|
+
await ddl.ensureObject(`Adding ${TABLE}.scopes`, `
|
|
7163
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS scopes JSONB
|
|
8528
7164
|
`);
|
|
8529
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8530
|
-
ALTER TABLE ${TABLE
|
|
7165
|
+
await ddl.ensureObject(`Adding ${TABLE}.roles`, `
|
|
7166
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
8531
7167
|
`);
|
|
8532
|
-
await ddl.ensureObject(`Adding ${TABLE
|
|
8533
|
-
ALTER TABLE ${TABLE
|
|
7168
|
+
await ddl.ensureObject(`Adding ${TABLE}.owner_uid`, `
|
|
7169
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS owner_uid TEXT
|
|
8534
7170
|
`);
|
|
8535
7171
|
await ddl.ensureObject("Creating idx_api_keys_owner", `
|
|
8536
7172
|
CREATE INDEX IF NOT EXISTS idx_api_keys_owner
|
|
8537
|
-
ON ${TABLE
|
|
7173
|
+
ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
|
|
8538
7174
|
`);
|
|
8539
|
-
if (!await ddl.isReadable(TABLE
|
|
8540
|
-
logger.error(`❌ [api-key-store] ${TABLE$
|
|
7175
|
+
if (!await ddl.isReadable(TABLE)) {
|
|
7176
|
+
logger.error(options.provision === false ? `❌ [api-key-store] ${TABLE} does not exist, and this backend changes nothing in its database — every API-key authenticated request will be rejected on this instance until a provisioning run creates it.` : `❌ [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.`);
|
|
8541
7177
|
return;
|
|
8542
7178
|
}
|
|
8543
7179
|
await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
|
|
8544
|
-
|
|
8545
|
-
WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`)
|
|
7180
|
+
const legacyColumns = await exec(`SELECT 1 FROM information_schema.columns
|
|
7181
|
+
WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`);
|
|
7182
|
+
if (options.provision === false) try {
|
|
7183
|
+
await exec(`SELECT ${BOOT_COLUMNS.join(", ")} FROM ${TABLE} WHERE false`);
|
|
7184
|
+
} catch {
|
|
7185
|
+
logger.error(`❌ [api-key-store] ${TABLE} is out of date — it lacks one of ${BOOT_COLUMNS.join(", ")}, which this release reads. This backend changes nothing in its database; a provisioning run adds them.`);
|
|
7186
|
+
}
|
|
7187
|
+
if (legacyColumns.length > 0 && options.provision === false) logger.warn(`⚠️ [api-key-store] ${TABLE} still has the pre-scopes \`permissions\` column. Keys stored before scopes are given theirs by a provisioning run; this backend changes nothing in its database.`);
|
|
7188
|
+
else if (legacyColumns.length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
|
|
8546
7189
|
logger.debug("✅ API keys table ready");
|
|
8547
7190
|
},
|
|
8548
7191
|
async createApiKey(key, createdBy) {
|
|
@@ -8550,7 +7193,7 @@ function createApiKeyStore(driver) {
|
|
|
8550
7193
|
const hash = hashKey(plaintext);
|
|
8551
7194
|
const prefix = keyPrefix(plaintext);
|
|
8552
7195
|
return {
|
|
8553
|
-
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE
|
|
7196
|
+
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
|
|
8554
7197
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
|
|
8555
7198
|
RETURNING *`, { params: [
|
|
8556
7199
|
key.name,
|
|
@@ -8568,23 +7211,23 @@ function createApiKeyStore(driver) {
|
|
|
8568
7211
|
};
|
|
8569
7212
|
},
|
|
8570
7213
|
async findByKeyHash(hash) {
|
|
8571
|
-
const rows = await exec(`SELECT * FROM ${TABLE
|
|
7214
|
+
const rows = await exec(`SELECT * FROM ${TABLE}
|
|
8572
7215
|
WHERE key_hash = $1
|
|
8573
7216
|
LIMIT 1`, { params: [hash] });
|
|
8574
7217
|
if (rows.length === 0) return null;
|
|
8575
7218
|
return rowToApiKey(rows[0]);
|
|
8576
7219
|
},
|
|
8577
7220
|
async listApiKeys(filter) {
|
|
8578
|
-
return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE
|
|
7221
|
+
return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
|
|
8579
7222
|
WHERE kind = 'personal' AND owner_uid = $1
|
|
8580
7223
|
ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
|
|
8581
|
-
SELECT * FROM ${TABLE
|
|
7224
|
+
SELECT * FROM ${TABLE}
|
|
8582
7225
|
WHERE kind = 'service'
|
|
8583
7226
|
ORDER BY created_at DESC
|
|
8584
7227
|
`)).map((r) => toMasked(rowToApiKey(r)));
|
|
8585
7228
|
},
|
|
8586
7229
|
async getApiKeyById(id) {
|
|
8587
|
-
const rows = await exec(`SELECT * FROM ${TABLE
|
|
7230
|
+
const rows = await exec(`SELECT * FROM ${TABLE}
|
|
8588
7231
|
WHERE id = $1
|
|
8589
7232
|
LIMIT 1`, { params: [id] });
|
|
8590
7233
|
if (rows.length === 0) return null;
|
|
@@ -8617,7 +7260,7 @@ function createApiKeyStore(driver) {
|
|
|
8617
7260
|
if (setClauses.length === 0) return this.getApiKeyById(id);
|
|
8618
7261
|
setClauses.push("updated_at = NOW()");
|
|
8619
7262
|
params.push(id);
|
|
8620
|
-
const rows = await exec(`UPDATE ${TABLE
|
|
7263
|
+
const rows = await exec(`UPDATE ${TABLE}
|
|
8621
7264
|
SET ${setClauses.join(", ")}
|
|
8622
7265
|
WHERE id = $${paramIdx} AND kind = 'service'
|
|
8623
7266
|
RETURNING *`, { params });
|
|
@@ -8625,17 +7268,17 @@ function createApiKeyStore(driver) {
|
|
|
8625
7268
|
return toMasked(rowToApiKey(rows[0]));
|
|
8626
7269
|
},
|
|
8627
7270
|
async revokeApiKey(id, owner_uid) {
|
|
8628
|
-
return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE
|
|
7271
|
+
return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
|
|
8629
7272
|
SET revoked_at = NOW(), updated_at = NOW()
|
|
8630
7273
|
WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
|
|
8631
|
-
RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE
|
|
7274
|
+
RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
|
|
8632
7275
|
SET revoked_at = NOW(), updated_at = NOW()
|
|
8633
7276
|
WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
|
|
8634
7277
|
RETURNING id`, { params: [id, owner_uid] })).length > 0;
|
|
8635
7278
|
},
|
|
8636
7279
|
async updateLastUsed(id) {
|
|
8637
7280
|
try {
|
|
8638
|
-
await exec(`UPDATE ${TABLE
|
|
7281
|
+
await exec(`UPDATE ${TABLE}
|
|
8639
7282
|
SET last_used_at = NOW()
|
|
8640
7283
|
WHERE id = $1`, { params: [id] });
|
|
8641
7284
|
} catch (err) {
|
|
@@ -8666,10 +7309,20 @@ function readScopesField(value) {
|
|
|
8666
7309
|
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");
|
|
8667
7310
|
return [...new Set(value.map((entry) => entry.trim()))];
|
|
8668
7311
|
}
|
|
8669
|
-
/**
|
|
7312
|
+
/**
|
|
7313
|
+
* Read a `roles` body field: an array of role names, trimmed, deduplicated,
|
|
7314
|
+
* without `service`. A key's roles reach RLS joined by `,` in `app.user_roles`,
|
|
7315
|
+
* so a name `roleNameProblem` refuses — `"editor,admin"` — is refused here too.
|
|
7316
|
+
*/
|
|
8670
7317
|
function readRolesField(value) {
|
|
8671
7318
|
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");
|
|
8672
|
-
|
|
7319
|
+
const trimmed = value.map((entry) => String(entry).trim());
|
|
7320
|
+
const problems = trimmed.flatMap((name) => {
|
|
7321
|
+
const problem = roleNameProblem(name);
|
|
7322
|
+
return problem ? [`${JSON.stringify(name)} ${problem}`] : [];
|
|
7323
|
+
});
|
|
7324
|
+
if (problems.length > 0) throw new ApiError(400, "INVALID_ROLE", `Not valid role names: ${problems.join("; ")}. A role name is a non-empty string without , { } " \\ or control characters.`, { problems });
|
|
7325
|
+
return [...new Set(trimmed)].filter((role) => role !== "service");
|
|
8673
7326
|
}
|
|
8674
7327
|
/**
|
|
8675
7328
|
* Refuse scopes that are malformed, unknown, aimed at a target that does not
|
|
@@ -8715,6 +7368,24 @@ function assertRolesGrantable(requested, minterRoles) {
|
|
|
8715
7368
|
const beyond = requested.filter((role) => !minterRoles.includes(role));
|
|
8716
7369
|
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 });
|
|
8717
7370
|
}
|
|
7371
|
+
/**
|
|
7372
|
+
* Refuse changing or revoking a key that holds more than the caller — a role
|
|
7373
|
+
* they do not hold, or a scope beyond theirs. An admin manages any key.
|
|
7374
|
+
*
|
|
7375
|
+
* The same ceiling as minting, applied to the key as it already is: a
|
|
7376
|
+
* `keys:write` role could otherwise widen an admin-role key it could never
|
|
7377
|
+
* have minted to every data scope, or revoke the key an admin's integration
|
|
7378
|
+
* runs on. `assertMayManageAccount` is the same rule for accounts.
|
|
7379
|
+
*/
|
|
7380
|
+
function assertMayManageKey(existing, managerRoles, managerScopes) {
|
|
7381
|
+
if (hasAdminRole(managerRoles)) return;
|
|
7382
|
+
const roles = existing.roles.filter((role) => !managerRoles.includes(role));
|
|
7383
|
+
const scopes = scopesBeyond(existing.scopes, managerScopes);
|
|
7384
|
+
if (roles.length > 0 || scopes.length > 0) throw new ApiError(403, "KEY_OUTRANKS_CALLER", `This key holds ${[...roles.map((role) => `the role ${role}`), ...scopes].join(", ")}, which you do not, so you cannot change or revoke it.`, {
|
|
7385
|
+
roles,
|
|
7386
|
+
scopes
|
|
7387
|
+
});
|
|
7388
|
+
}
|
|
8718
7389
|
/** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
|
|
8719
7390
|
function readExpiresAt(value, requireFuture) {
|
|
8720
7391
|
if (value === void 0) return void 0;
|
|
@@ -8811,6 +7482,11 @@ function createApiKeyRoutes(options) {
|
|
|
8811
7482
|
read: "keys:read",
|
|
8812
7483
|
write: "keys:write"
|
|
8813
7484
|
}));
|
|
7485
|
+
const existingServiceKey = async (id) => {
|
|
7486
|
+
const key = await store.getApiKeyById(id);
|
|
7487
|
+
if (!key || key.kind !== "service") throw ApiError.notFound("API key not found");
|
|
7488
|
+
return key;
|
|
7489
|
+
};
|
|
8814
7490
|
router.get("/", async (c) => {
|
|
8815
7491
|
const keys = await store.listApiKeys({ kind: "service" });
|
|
8816
7492
|
return c.json({ keys });
|
|
@@ -8859,11 +7535,13 @@ function createApiKeyRoutes(options) {
|
|
|
8859
7535
|
if (rateLimit !== void 0) updates.rate_limit = rateLimit;
|
|
8860
7536
|
const expiresAt = readExpiresAt(body.expires_at, false);
|
|
8861
7537
|
if (expiresAt !== void 0) updates.expires_at = expiresAt;
|
|
7538
|
+
assertMayManageKey(await existingServiceKey(id), minter.roles, callerScopes(c));
|
|
8862
7539
|
const key = await store.updateApiKey(id, updates);
|
|
8863
7540
|
if (!key) throw ApiError.notFound("API key not found");
|
|
8864
7541
|
return c.json({ key });
|
|
8865
7542
|
});
|
|
8866
7543
|
router.delete("/:id", async (c) => {
|
|
7544
|
+
assertMayManageKey(await existingServiceKey(c.req.param("id")), caller(c).roles, callerScopes(c));
|
|
8867
7545
|
if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
|
|
8868
7546
|
return c.json({ success: true });
|
|
8869
7547
|
});
|
|
@@ -8933,194 +7611,6 @@ function createPersonalKeyRoutes(options) {
|
|
|
8933
7611
|
return router;
|
|
8934
7612
|
}
|
|
8935
7613
|
//#endregion
|
|
8936
|
-
//#region src/auth/sql-rate-limit-store.ts
|
|
8937
|
-
var SCHEMA = "rebase";
|
|
8938
|
-
var TABLE_NAME = "rate_limit_hits";
|
|
8939
|
-
var TABLE = `${SCHEMA}.${TABLE_NAME}`;
|
|
8940
|
-
/** How often expired buckets are deleted. */
|
|
8941
|
-
var DEFAULT_SWEEP_MS = 300 * 1e3;
|
|
8942
|
-
/**
|
|
8943
|
-
* Build the store, or `undefined` if this driver cannot back it.
|
|
8944
|
-
*
|
|
8945
|
-
* Returning `undefined` rather than throwing matches `createCronStore`: a
|
|
8946
|
-
* deployment on a driver with no SQL admin should degrade to per-process counts
|
|
8947
|
-
* with a warning, not refuse to serve.
|
|
8948
|
-
*/
|
|
8949
|
-
function createSqlRateLimitStore(driver, options = {}) {
|
|
8950
|
-
const admin = driver.admin;
|
|
8951
|
-
if (!isSQLAdmin(admin)) {
|
|
8952
|
-
logger.warn("⚠️ [rate-limit] The configured driver has no SQL admin, so rate-limit counts cannot be shared between processes. Falling back to this process's memory — each replica will enforce the limit independently.");
|
|
8953
|
-
return;
|
|
8954
|
-
}
|
|
8955
|
-
const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
|
|
8956
|
-
const ddl = createDdlBootstrapper(exec, "rate-limit-store");
|
|
8957
|
-
const now = options.now ?? Date.now;
|
|
8958
|
-
const sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
|
|
8959
|
-
let sweepTimer;
|
|
8960
|
-
let ready;
|
|
8961
|
-
/**
|
|
8962
|
-
* Create the table once, and remember whether it worked.
|
|
8963
|
-
*
|
|
8964
|
-
* Lazily rather than at boot because the store is constructed while routes
|
|
8965
|
-
* are being mounted, and an `await` there would put a database round trip on
|
|
8966
|
-
* the boot path of every deployment — including the ones that never take a
|
|
8967
|
-
* request. The first request pays for it.
|
|
8968
|
-
*
|
|
8969
|
-
* The result is cached even on failure: a store that cannot create its table
|
|
8970
|
-
* must not retry the DDL on every request, which would turn one broken
|
|
8971
|
-
* permission into a per-request query storm.
|
|
8972
|
-
*
|
|
8973
|
-
* Readiness is decided by reading the table, never by the DDL not throwing —
|
|
8974
|
-
* `ensureObject` routes through `step`, which logs a failure and returns
|
|
8975
|
-
* normally. Keying on the absence of an exception would report a table that
|
|
8976
|
-
* was never created as ready, and every later request would fall into the
|
|
8977
|
-
* per-request error path instead of the one-time fallback.
|
|
8978
|
-
*/
|
|
8979
|
-
async function ensureReady() {
|
|
8980
|
-
if (!ready) ready = (async () => {
|
|
8981
|
-
try {
|
|
8982
|
-
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
8983
|
-
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
8984
|
-
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
8985
|
-
key TEXT NOT NULL,
|
|
8986
|
-
bucket BIGINT NOT NULL,
|
|
8987
|
-
hits INTEGER NOT NULL DEFAULT 0,
|
|
8988
|
-
expires_at TIMESTAMPTZ NOT NULL,
|
|
8989
|
-
PRIMARY KEY (key, bucket)
|
|
8990
|
-
)
|
|
8991
|
-
`);
|
|
8992
|
-
await ddl.ensureObject("Creating idx_rate_limit_hits_expiry", `
|
|
8993
|
-
CREATE INDEX IF NOT EXISTS idx_rate_limit_hits_expiry
|
|
8994
|
-
ON ${TABLE}(expires_at)
|
|
8995
|
-
`);
|
|
8996
|
-
const readable = await ddl.isReadable(TABLE);
|
|
8997
|
-
if (readable) await ddl.step("Revoking end-user access to rate_limit_hits", () => exec(revokeInternalTableSql(SCHEMA, TABLE_NAME)));
|
|
8998
|
-
else logger.error("[rate-limit] The shared rate-limit table could not be created, so counts stay per-process. Every replica will enforce the limit independently.");
|
|
8999
|
-
return readable;
|
|
9000
|
-
} catch (err) {
|
|
9001
|
-
logger.error("[rate-limit] Could not prepare the shared rate-limit table. Falling back to per-process counts for the life of this process.", { error: err instanceof Error ? err.message : String(err) });
|
|
9002
|
-
return false;
|
|
9003
|
-
}
|
|
9004
|
-
})();
|
|
9005
|
-
return ready;
|
|
9006
|
-
}
|
|
9007
|
-
function startSweeping() {
|
|
9008
|
-
if (sweepTimer) return;
|
|
9009
|
-
sweepTimer = setInterval(() => {
|
|
9010
|
-
exec(`DELETE FROM ${TABLE} WHERE expires_at < now()`).catch((err) => {
|
|
9011
|
-
logger.debug("[rate-limit] Bucket sweep failed", { error: err instanceof Error ? err.message : String(err) });
|
|
9012
|
-
});
|
|
9013
|
-
}, sweepMs);
|
|
9014
|
-
sweepTimer.unref?.();
|
|
9015
|
-
}
|
|
9016
|
-
return {
|
|
9017
|
-
async hit(key, windowMs, limit) {
|
|
9018
|
-
if (!await ensureReady()) return {
|
|
9019
|
-
allowed: true,
|
|
9020
|
-
remaining: limit,
|
|
9021
|
-
retryAfterMs: 0
|
|
9022
|
-
};
|
|
9023
|
-
startSweeping();
|
|
9024
|
-
const at = now();
|
|
9025
|
-
const bucket = Math.floor(at / windowMs);
|
|
9026
|
-
const carry = 1 - (at - bucket * windowMs) / windowMs;
|
|
9027
|
-
const expiresAt = new Date((bucket + 2) * windowMs).toISOString();
|
|
9028
|
-
let current;
|
|
9029
|
-
let previous;
|
|
9030
|
-
try {
|
|
9031
|
-
const row = firstRow(await exec(`WITH bumped AS (
|
|
9032
|
-
INSERT INTO ${TABLE} AS r (key, bucket, hits, expires_at)
|
|
9033
|
-
VALUES ($1, $2, 1, $3)
|
|
9034
|
-
ON CONFLICT (key, bucket)
|
|
9035
|
-
DO UPDATE SET hits = r.hits + 1, expires_at = EXCLUDED.expires_at
|
|
9036
|
-
RETURNING hits
|
|
9037
|
-
)
|
|
9038
|
-
SELECT
|
|
9039
|
-
(SELECT hits FROM bumped) AS current,
|
|
9040
|
-
COALESCE((SELECT hits FROM ${TABLE} WHERE key = $1 AND bucket = $4), 0) AS previous`, { params: [
|
|
9041
|
-
key,
|
|
9042
|
-
bucket,
|
|
9043
|
-
expiresAt,
|
|
9044
|
-
bucket - 1
|
|
9045
|
-
] }));
|
|
9046
|
-
current = Number(row?.current ?? 1);
|
|
9047
|
-
previous = Number(row?.previous ?? 0);
|
|
9048
|
-
} catch (err) {
|
|
9049
|
-
logger.debug("[rate-limit] Shared count unavailable for this request", { error: err instanceof Error ? err.message : String(err) });
|
|
9050
|
-
return {
|
|
9051
|
-
allowed: true,
|
|
9052
|
-
remaining: limit,
|
|
9053
|
-
retryAfterMs: 0
|
|
9054
|
-
};
|
|
9055
|
-
}
|
|
9056
|
-
const estimate = current + previous * carry;
|
|
9057
|
-
if (estimate > limit) {
|
|
9058
|
-
exec(`UPDATE ${TABLE} SET hits = GREATEST(hits - 1, 0) WHERE key = $1 AND bucket = $2`, { params: [key, bucket] }).catch(() => {});
|
|
9059
|
-
return {
|
|
9060
|
-
allowed: false,
|
|
9061
|
-
remaining: 0,
|
|
9062
|
-
retryAfterMs: Math.max(0, (bucket + 1) * windowMs - at)
|
|
9063
|
-
};
|
|
9064
|
-
}
|
|
9065
|
-
return {
|
|
9066
|
-
allowed: true,
|
|
9067
|
-
remaining: Math.max(0, Math.floor(limit - estimate)),
|
|
9068
|
-
retryAfterMs: 0
|
|
9069
|
-
};
|
|
9070
|
-
},
|
|
9071
|
-
dispose() {
|
|
9072
|
-
if (sweepTimer) clearInterval(sweepTimer);
|
|
9073
|
-
sweepTimer = void 0;
|
|
9074
|
-
}
|
|
9075
|
-
};
|
|
9076
|
-
}
|
|
9077
|
-
/**
|
|
9078
|
-
* The first row of whatever shape this driver returns.
|
|
9079
|
-
*
|
|
9080
|
-
* `SqlExec` declares an array of rows, but drivers in this repository have also
|
|
9081
|
-
* returned a `{ rows }` envelope through the same escape hatch. Reading one
|
|
9082
|
-
* shape only is how a store silently counts nothing and reports every caller as
|
|
9083
|
-
* being on their first request — a limiter that never limits, with no error
|
|
9084
|
-
* anywhere. Tolerating both costs one line.
|
|
9085
|
-
*/
|
|
9086
|
-
var firstRow = firstSqlRow;
|
|
9087
|
-
//#endregion
|
|
9088
|
-
//#region src/auth/resolve-rate-limit-store.ts
|
|
9089
|
-
/**
|
|
9090
|
-
* A refusal to boot, phrased as the variable to change.
|
|
9091
|
-
*
|
|
9092
|
-
* Its own class so `bootFromBundle` can present it as a configuration problem
|
|
9093
|
-
* rather than a crash, matching `RoleConfigurationError`.
|
|
9094
|
-
*/
|
|
9095
|
-
var RateLimitStoreConfigurationError = class extends Error {
|
|
9096
|
-
hint;
|
|
9097
|
-
constructor(message, hint) {
|
|
9098
|
-
super(message);
|
|
9099
|
-
this.hint = hint;
|
|
9100
|
-
this.name = "RateLimitStoreConfigurationError";
|
|
9101
|
-
}
|
|
9102
|
-
};
|
|
9103
|
-
/**
|
|
9104
|
-
* Which store this process should build.
|
|
9105
|
-
*
|
|
9106
|
-
* A misspelled value **refuses to boot** rather than falling back to memory.
|
|
9107
|
-
* Every other outcome here is silent — the whole failure mode of this setting is
|
|
9108
|
-
* that nothing looks wrong — so the one moment it can be caught is the moment it
|
|
9109
|
-
* is read. `REBASE_RATE_LIMIT_STORE=postgres` is the obvious thing to type and
|
|
9110
|
-
* would otherwise be a per-replica limit wearing the shape of a shared one.
|
|
9111
|
-
*
|
|
9112
|
-
* A blank or whitespace-only value is *unset*, not invalid:
|
|
9113
|
-
* `REBASE_RATE_LIMIT_STORE=${SOMETHING}` with `SOMETHING` undefined is the
|
|
9114
|
-
* ordinary way to write a compose file, and it is how the platform neutralises a
|
|
9115
|
-
* tenant's own variable.
|
|
9116
|
-
*/
|
|
9117
|
-
function resolveRateLimitStoreKind(env) {
|
|
9118
|
-
const raw = (env.REBASE_RATE_LIMIT_STORE ?? "").trim().toLowerCase();
|
|
9119
|
-
if (raw === "") return "memory";
|
|
9120
|
-
if (raw === "memory" || raw === "sql") return raw;
|
|
9121
|
-
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.");
|
|
9122
|
-
}
|
|
9123
|
-
//#endregion
|
|
9124
7614
|
//#region src/auth/oauth-code-flow.ts
|
|
9125
7615
|
/**
|
|
9126
7616
|
* Build the request schema for an authorization-code provider.
|
|
@@ -10268,6 +8758,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
10268
8758
|
verifyPassword: () => verifyPassword
|
|
10269
8759
|
});
|
|
10270
8760
|
//#endregion
|
|
10271
|
-
export {
|
|
8761
|
+
export { getEmailChangeTemplate as $, createDataRateLimiter as A, SMTPEmailService as B, createJwksRoutes as C, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, isBootstrapWindowOpen as E, clearActiveDevEmailSink as F, verifyPassword as G, resolveAuthHooks as H, createDevEmailSink as I, completeUserCreation as J, adapterRefusal as K, devEmailSinkFor as L, setSharedRateLimitStore as M, MemoryRateLimitStore as N, createAuthLimiters as O, activeDevEmailSink as P, getEmailChangeNoticeTemplate as Q, extractLinks as R, createApiKeyStore as S, configureUserPicking as T, hashPassword as U, createEmailService as V, validatePasswordStrength as W, assertEmailLinkBases as X, generateSecurePassword as Y, resolveEmailLinkBase as Z, oauthCodeFlowSchema as _, createBitbucketProvider as a, getWelcomeEmailTemplate as at, createApiKeyRoutes as b, createTwitterProvider as c, escapeHtml as ct, createMicrosoftProvider as d, createSqlRateLimitStore as dt, getEmailOtpTemplate as et, tryVerifyOidcIdToken as f, resolveRateLimitStoreKind as ft, createGoogleProvider as g, createLinkedinProvider as h, createSlackProvider as i, getUserInvitationTemplate as it, createRateLimiter as j, createDataRateLimitCheck as k, createFacebookProvider as l, html as lt, createGitHubProvider as m, createCustomAuthAdapter as n, getMagicLinkTemplate as nt, createGitLabProvider as o, resolveEmailBranding as ot, verifyOidcIdToken as p, securityRuleToConditions as pt, createAdapterAuthMiddleware as q, createSpotifyProvider as r, getPasswordResetTemplate as rt, createDiscordProvider as s, RawHtml as st, auth_exports as t, getEmailVerificationTemplate as tt, createAppleProvider as u, raw as ut, pkceTokenParams as v, createBuiltinAuthAdapter as w, createPersonalKeyRoutes as x, providerVerifiedEmail as y, registerDevEmailSink as z };
|
|
10272
8762
|
|
|
10273
|
-
//# sourceMappingURL=auth-
|
|
8763
|
+
//# sourceMappingURL=auth-izI7AEzC.js.map
|