@rebasepro/server 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/rebase-server.js +4 -2
- package/dist/{GCSStorageController-CjrA4PMo.js → GCSStorageController-BSiP1c-f.js} +22 -8
- package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
- package/dist/{S3StorageController-B6pKDNVj.js → S3StorageController-CAwFRgjV.js} +19 -7
- package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +92 -1
- package/dist/api/errors.d.ts +9 -0
- package/dist/api/live-schema-routes.d.ts +38 -8
- package/dist/api/logs-routes.d.ts +39 -1
- package/dist/api/openapi-generator.d.ts +17 -0
- package/dist/api/rest/api-generator.d.ts +44 -10
- package/dist/api/rest/write-validation.d.ts +2 -2
- package/dist/api/types.d.ts +17 -1
- package/dist/{ast-schema-editor-Mvr50v_S.js → ast-schema-editor-CWqS_sLJ.js} +309 -11
- package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
- package/dist/auth/access.d.ts +105 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/address-ownership.d.ts +16 -1
- package/dist/auth/admin-roles-route.d.ts +4 -2
- package/dist/auth/admin-roles.d.ts +17 -20
- package/dist/auth/admin-users-route.d.ts +1 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
- package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
- package/dist/auth/api-keys/api-key-store.d.ts +31 -8
- package/dist/auth/api-keys/api-key-types.d.ts +14 -16
- package/dist/auth/api-keys/http-operation.d.ts +19 -0
- package/dist/auth/api-keys/index.d.ts +11 -11
- package/dist/auth/api-keys/key-grant.d.ts +41 -0
- package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
- package/dist/auth/auth-hooks.d.ts +46 -7
- package/dist/auth/builtin-auth-adapter.d.ts +8 -0
- package/dist/auth/cookie-utils.d.ts +7 -0
- package/dist/auth/deliverable-address.d.ts +6 -0
- package/dist/auth/email-change-routes.d.ts +41 -0
- package/dist/auth/expired-token-sweep.d.ts +67 -0
- package/dist/auth/impersonation.d.ts +110 -0
- package/dist/auth/index.d.ts +4 -2
- package/dist/auth/interfaces.d.ts +110 -59
- package/dist/auth/jwt.d.ts +49 -3
- package/dist/auth/magic-link-routes.d.ts +2 -6
- package/dist/auth/mfa-routes.d.ts +2 -9
- package/dist/auth/middleware.d.ts +17 -5
- package/dist/auth/otp-routes.d.ts +2 -6
- package/dist/auth/passwordless-signup.d.ts +27 -0
- package/dist/auth/platform-token.d.ts +122 -0
- package/dist/auth/rate-limiter.d.ts +41 -0
- package/dist/auth/routes.d.ts +45 -0
- package/dist/auth/scope-routes.d.ts +22 -0
- package/dist/auth/session-routes.d.ts +11 -6
- package/dist/auth/token-revocation.d.ts +50 -1
- package/dist/auth/verify-credential.d.ts +28 -0
- package/dist/{auth-B-GIMpDG.js → auth-DMLngxn_.js} +2159 -569
- package/dist/auth-DMLngxn_.js.map +1 -0
- package/dist/backend-DTAOsLQc.js.map +1 -1
- package/dist/backup/backup-common.d.ts +10 -0
- package/dist/backup/backup-routes.d.ts +24 -4
- package/dist/backup/backup-schedule.d.ts +33 -0
- package/dist/backup/backup-storage.d.ts +14 -0
- package/dist/backup/index.d.ts +2 -0
- package/dist/backup-CN0s50D2.js +444 -0
- package/dist/backup-CN0s50D2.js.map +1 -0
- package/dist/boot/bundle.d.ts +19 -0
- package/dist/boot/env.d.ts +49 -4
- package/dist/boot/security-headers.d.ts +26 -0
- package/dist/boot/static-routing.d.ts +56 -0
- package/dist/collection_patch-BRu-BvDv.js +472 -0
- package/dist/collection_patch-BRu-BvDv.js.map +1 -0
- package/dist/{contract-routes-CbFjuBwa.js → contract-routes-fz8i4pxs.js} +17 -4
- package/dist/contract-routes-fz8i4pxs.js.map +1 -0
- package/dist/cron/cron-scheduler.d.ts +25 -20
- package/dist/cron/cron-store.d.ts +6 -2
- package/dist/{cron-loader-DfTj2Hbi.js → cron-loader-CwaANlOG.js} +4 -4
- package/dist/cron-loader-CwaANlOG.js.map +1 -0
- package/dist/{cron-routes-eE8nif_b.js → cron-routes-Bc-SB0Se.js} +10 -7
- package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
- package/dist/{cron-scheduler-B0pLfAix.js → cron-scheduler-CYQgco86.js} +52 -34
- package/dist/cron-scheduler-CYQgco86.js.map +1 -0
- package/dist/{cron-store-TcoGz-xS.js → cron-store-D2Q9-Aco.js} +10 -15
- package/dist/cron-store-D2Q9-Aco.js.map +1 -0
- package/dist/{ddl-bootstrap-C6mo0Kmz.js → ddl-bootstrap-BaqMSa4Y.js} +2 -2
- package/dist/{ddl-bootstrap-C6mo0Kmz.js.map → ddl-bootstrap-BaqMSa4Y.js.map} +1 -1
- package/dist/email/index.d.ts +2 -2
- package/dist/email/templates.d.ts +22 -0
- package/dist/email/types.d.ts +26 -0
- package/dist/env.d.ts +1 -2
- package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
- package/dist/errors-D6_y86c5.js.map +1 -0
- package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
- package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
- package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
- package/dist/function-routes-CaNG4waN.js.map +1 -0
- package/dist/functions/context.d.ts +17 -6
- package/dist/functions/guards.d.ts +22 -5
- package/dist/functions/index.d.ts +2 -2
- package/dist/functions/index.js +90 -36
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-B4MpJfJK.js → history-recorder-Nr8zLvoU.js} +4 -4
- package/dist/{history-recorder-B4MpJfJK.js.map → history-recorder-Nr8zLvoU.js.map} +1 -1
- package/dist/{history-store-BhxWOuz9.js → history-store-rcAm_xFR.js} +2 -2
- package/dist/{history-store-BhxWOuz9.js.map → history-store-rcAm_xFR.js.map} +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.es.js +3084 -753
- package/dist/index.es.js.map +1 -1
- package/dist/init/health.d.ts +17 -2
- package/dist/init/shutdown.d.ts +10 -0
- package/dist/init.d.ts +54 -0
- package/dist/{jobs-CazMYhyy.js → jobs-DqYNfquG.js} +5 -5
- package/dist/{jobs-CazMYhyy.js.map → jobs-DqYNfquG.js.map} +1 -1
- package/dist/{jwt-DnQHNFCl.js → jwt-R6bSPMjk.js} +39 -15
- package/dist/{jwt-DnQHNFCl.js.map → jwt-R6bSPMjk.js.map} +1 -1
- package/dist/{keys-CogCQpxG.js → keys-GAVZqbqx.js} +3 -17
- package/dist/{keys-CogCQpxG.js.map → keys-GAVZqbqx.js.map} +1 -1
- package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
- package/dist/logger-D-S-hO5e.js.map +1 -0
- package/dist/{logs-routes-Bj4TYYUl.js → logs-routes-DAdv37GI.js} +48 -8
- package/dist/logs-routes-DAdv37GI.js.map +1 -0
- package/dist/mcp/consent-page.d.ts +1 -1
- package/dist/mcp/mcp-routes.d.ts +7 -0
- package/dist/mcp/mcp-tools.d.ts +15 -9
- package/dist/mcp/oauth-metadata.d.ts +21 -16
- package/dist/mcp/oauth-routes.d.ts +7 -1
- package/dist/{openapi-generator-O_O24MAT.js → openapi-generator-DAq_XVDu.js} +104 -13
- package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
- package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
- package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
- package/dist/{query-parser-DGRVFNM3.js → query-parser-BgiKJKvc.js} +6 -56
- package/dist/query-parser-BgiKJKvc.js.map +1 -0
- package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
- package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
- package/dist/schema-edit/apply-schema-change.d.ts +63 -3
- package/dist/schema-edit/project-root.d.ts +3 -2
- package/dist/schema-edit/remote-source.d.ts +9 -4
- package/dist/{schema-editor-routes-C5-lh_jO.js → schema-editor-routes-oIyuWl3L.js} +12 -7
- package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
- package/dist/serve-spa.d.ts +58 -0
- package/dist/services/routed-realtime-service.d.ts +11 -0
- package/dist/soft-delete-params-BWPilMPF.js +59 -0
- package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
- package/dist/{src-vkcwKXbT.js → src-CatHFUym.js} +439 -20
- package/dist/src-CatHFUym.js.map +1 -0
- package/dist/{src-pmvW7BFx.js → src-I3aG1PcY.js} +252 -70
- package/dist/src-I3aG1PcY.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +2 -0
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/property-limits.d.ts +41 -6
- package/dist/storage/request-keys.d.ts +15 -0
- package/dist/storage/requested-object.d.ts +74 -0
- package/dist/storage/routes.d.ts +36 -18
- package/dist/storage/tus-handler.d.ts +30 -5
- package/dist/storage/types.d.ts +19 -0
- package/dist/types-BfKcm9do.js.map +1 -1
- package/dist/utils/logger.d.ts +12 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-CjrA4PMo.js.map +0 -1
- package/dist/S3StorageController-B6pKDNVj.js.map +0 -1
- package/dist/admin-roles-vYdp_Pil.js +0 -36
- package/dist/admin-roles-vYdp_Pil.js.map +0 -1
- package/dist/admin_block-DxKLmdiv.js +0 -206
- package/dist/admin_block-DxKLmdiv.js.map +0 -1
- package/dist/ast-schema-editor-Mvr50v_S.js.map +0 -1
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
- package/dist/auth-B-GIMpDG.js.map +0 -1
- package/dist/backup-D7YR94N3.js +0 -253
- package/dist/backup-D7YR94N3.js.map +0 -1
- package/dist/contract-routes-CbFjuBwa.js.map +0 -1
- package/dist/cron-loader-DfTj2Hbi.js.map +0 -1
- package/dist/cron-routes-eE8nif_b.js.map +0 -1
- package/dist/cron-scheduler-B0pLfAix.js.map +0 -1
- package/dist/cron-store-TcoGz-xS.js.map +0 -1
- package/dist/errors-DWsX4yTd.js.map +0 -1
- package/dist/function-routes-Chet4-lB.js.map +0 -1
- package/dist/logger-DO2PZc4i.js.map +0 -1
- package/dist/logs-routes-Bj4TYYUl.js.map +0 -1
- package/dist/openapi-generator-O_O24MAT.js.map +0 -1
- package/dist/query-parser-DGRVFNM3.js.map +0 -1
- package/dist/schema-editor-routes-C5-lh_jO.js.map +0 -1
- package/dist/src-pmvW7BFx.js.map +0 -1
- package/dist/src-vkcwKXbT.js.map +0 -1
|
@@ -3,17 +3,16 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
7
|
-
import "./src-
|
|
6
|
+
import { P as revokeInternalTableSql, ct as isAnonymousUid, st as ANONYMOUS_USER_ID } from "./src-I3aG1PcY.js";
|
|
7
|
+
import { _ as scopesForRoles, a as KEY_MANAGEMENT_SCOPES, b as validateScopes, c as intersectScopes, d as isDataPlaneScope, f as knownScopes, g as scopesBeyond, h as scopeGrantsAny, i as EMPTY_ACCESS_MODEL, l as isAdminScope, m as scopeGrants, n as ADMIN_SCOPES, o as SCOPE_TARGETS, p as parseScope, r as BUILT_IN_SCOPES, s as hasAdminRole, t as ADMIN_ROLE, u as isBuiltInScope, v as summarizeRoles } from "./src-CatHFUym.js";
|
|
8
8
|
import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
|
|
9
9
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
10
|
-
import { r as logger } from "./logger-
|
|
11
|
-
import { r as errorHandler, t as ApiError } from "./errors-
|
|
12
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { n as createDdlBootstrapper } from "./ddl-bootstrap-C6mo0Kmz.js";
|
|
10
|
+
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
11
|
+
import { i as isDataException, r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
|
|
12
|
+
import { a as resolveListLimitParam } from "./query-parser-BgiKJKvc.js";
|
|
13
|
+
import { D as constantTimeEqual, E as require_jsonwebtoken, S as verifyMfaPendingToken, a as generateAccessToken, b as verifyDownloadToken, c as generateMfaPendingToken, d as getJwks, f as getRefreshTokenExpiry, g as isJwtConfigured, h as hashRefreshToken, i as customClaimsOf, j as sha256Hex, k as randomInt$1, l as generateRefreshToken, m as hasAsymmetricSigningKey, n as configureJwt, o as generateDownloadToken, p as getRefreshTokenTtlMs, r as configuredJwtSecret, t as MAX_COOKIE_AGE_MS, u as getAccessTokenExpiry, y as verifyAccessToken } from "./jwt-R6bSPMjk.js";
|
|
14
|
+
import { a as canonicalStorageId, o as canonicalStorageKey, r as InvalidStorageKeyError } from "./keys-GAVZqbqx.js";
|
|
15
|
+
import { n as createDdlBootstrapper } from "./ddl-bootstrap-BaqMSa4Y.js";
|
|
17
16
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
18
17
|
import { Hono } from "hono";
|
|
19
18
|
import { promisify } from "util";
|
|
@@ -21,6 +20,20 @@ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHma
|
|
|
21
20
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
22
21
|
import { createMiddleware } from "hono/factory";
|
|
23
22
|
import { z } from "zod";
|
|
23
|
+
//#region ../types/src/types/auth_adapter.ts
|
|
24
|
+
/**
|
|
25
|
+
* The request header that asks for a data-plane request to run as another
|
|
26
|
+
* user, named by uid.
|
|
27
|
+
*
|
|
28
|
+
* The server honours it only on a signed-in administrator's own session —
|
|
29
|
+
* never on an API key or the service key — and only through an adapter that
|
|
30
|
+
* implements {@link AuthAdapter.resolveUser}. Everywhere else a request that
|
|
31
|
+
* carries it is refused, never run as its caller.
|
|
32
|
+
*
|
|
33
|
+
* @group Auth
|
|
34
|
+
*/
|
|
35
|
+
var IMPERSONATE_HEADER = "x-rebase-impersonate";
|
|
36
|
+
//#endregion
|
|
24
37
|
//#region ../types/src/controllers/storage.ts
|
|
25
38
|
/**
|
|
26
39
|
* Path prefix that marks an object as **public**. Files stored under this
|
|
@@ -32,9 +45,33 @@ import { z } from "zod";
|
|
|
32
45
|
*/
|
|
33
46
|
var PUBLIC_STORAGE_PREFIX = "public/";
|
|
34
47
|
/**
|
|
35
|
-
* True when a storage key
|
|
36
|
-
* {@link PUBLIC_STORAGE_PREFIX}
|
|
37
|
-
*
|
|
48
|
+
* True when a storage **key** is public: it lies under
|
|
49
|
+
* {@link PUBLIC_STORAGE_PREFIX}.
|
|
50
|
+
*
|
|
51
|
+
* The key is read exactly as it is — the canonical key within its bucket, the
|
|
52
|
+
* one the server stores, serves, and hands a `storageAuthorize` hook as
|
|
53
|
+
* `key` — and nothing is parsed out of it. That is the difference from
|
|
54
|
+
* {@link isPublicStoragePath}, which reads a client-side *path* and so strips
|
|
55
|
+
* a `scheme://` and a leading `default/` bucket segment first. On a key both are
|
|
56
|
+
* wrong: `default/public/x` is a key in a folder called `default`, and a
|
|
57
|
+
* request path `notes://public/x` names the key `notes:/public/x`. Neither is
|
|
58
|
+
* public, and the server decides on this function so that it never says they
|
|
59
|
+
* are.
|
|
60
|
+
*
|
|
61
|
+
* @group Models
|
|
62
|
+
*/
|
|
63
|
+
function isPublicStorageKey(key) {
|
|
64
|
+
if (!key || !key.startsWith("public/")) return false;
|
|
65
|
+
return !key.split(/[\\/]/).includes("..");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* True when a storage key or client-side path points at a public object
|
|
69
|
+
* (lives under {@link PUBLIC_STORAGE_PREFIX}).
|
|
70
|
+
*
|
|
71
|
+
* For a path as a client holds it: a `scheme://` prefix and a single leading
|
|
72
|
+
* `default/` bucket segment are stripped first. The server and a
|
|
73
|
+
* `storageAuthorize` hook, which hold the canonical key, use
|
|
74
|
+
* {@link isPublicStorageKey}.
|
|
38
75
|
*
|
|
39
76
|
* @group Models
|
|
40
77
|
*/
|
|
@@ -47,6 +84,57 @@ function isPublicStoragePath(path) {
|
|
|
47
84
|
if (p.split("/").some((seg) => seg === "..")) return false;
|
|
48
85
|
return p.startsWith("public/") || p.startsWith(`default/public/`);
|
|
49
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The path the storage routes address an object by, from a key or from the
|
|
89
|
+
* `storageUrl` an upload returned (`<scheme>://<bucket>/<key>`, which is what a
|
|
90
|
+
* property with `includeBucketUrl: true` keeps in its column).
|
|
91
|
+
*
|
|
92
|
+
* The routes address a source's objects by key. On S3 and GCS the bucket in a
|
|
93
|
+
* `storageUrl` is always the source's own — the server refuses any other — so
|
|
94
|
+
* it is dropped: sent along, `s3://acme-media/products/a.png` reached the
|
|
95
|
+
* server as the key `acme-media/products/a.png`, the preview found nothing and
|
|
96
|
+
* a delete reported success while the object stayed. It worked on local disk
|
|
97
|
+
* only, because `local://default/<key>` happens to carry the one bucket
|
|
98
|
+
* segment the routes recognise — which is kept, as is any other local bucket.
|
|
99
|
+
*
|
|
100
|
+
* A bare key whose first folder is `default` is sent behind that bucket
|
|
101
|
+
* segment. The routes read one leading `default/` (in any case) as the bucket,
|
|
102
|
+
* so the key `default/photo.png` sent as it is names the root `photo.png`:
|
|
103
|
+
* {@link defaultBucketPath} is the spelling that names the key itself.
|
|
104
|
+
*
|
|
105
|
+
* Shared by every storage source that talks to those routes — the SDK's and
|
|
106
|
+
* the admin panel's `useBackendStorageSource` — so they address an object alike.
|
|
107
|
+
*
|
|
108
|
+
* @group Models
|
|
109
|
+
*/
|
|
110
|
+
function storageObjectPath(keyOrUrl, bucket) {
|
|
111
|
+
let filePath = keyOrUrl;
|
|
112
|
+
let bucketQualified = false;
|
|
113
|
+
const scheme = /^(local|s3|gs):\/\//.exec(filePath);
|
|
114
|
+
if (scheme) {
|
|
115
|
+
filePath = filePath.substring(scheme[0].length);
|
|
116
|
+
if (scheme[1] !== "local") {
|
|
117
|
+
const slash = filePath.indexOf("/");
|
|
118
|
+
if (slash > 0) filePath = filePath.substring(slash + 1);
|
|
119
|
+
} else bucketQualified = true;
|
|
120
|
+
}
|
|
121
|
+
if (bucket && filePath) {
|
|
122
|
+
if (!filePath.startsWith(`${bucket}/`)) filePath = `${bucket}/${filePath}`;
|
|
123
|
+
bucketQualified = true;
|
|
124
|
+
}
|
|
125
|
+
return bucketQualified ? filePath : defaultBucketPath(filePath);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The request path of a key in the default bucket: the key itself, unless the
|
|
129
|
+
* routes would read its first folder as the bucket — a first folder named
|
|
130
|
+
* `default`, matched as the routes match it (case-insensitively, and only when
|
|
131
|
+
* something follows it). Then the bucket segment goes in front, so exactly the
|
|
132
|
+
* one segment the routes strip is the one added.
|
|
133
|
+
*/
|
|
134
|
+
function defaultBucketPath(key) {
|
|
135
|
+
const segments = key.split("/");
|
|
136
|
+
return segments.length > 1 && segments[0].toLowerCase() === "default" ? `default/${key}` : key;
|
|
137
|
+
}
|
|
50
138
|
//#endregion
|
|
51
139
|
//#region ../common/src/util/email.ts
|
|
52
140
|
/**
|
|
@@ -81,79 +169,26 @@ function normalizeEmail(email) {
|
|
|
81
169
|
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
82
170
|
}
|
|
83
171
|
//#endregion
|
|
84
|
-
//#region src/auth/api-keys/
|
|
172
|
+
//#region src/auth/api-keys/http-operation.ts
|
|
85
173
|
/**
|
|
86
|
-
* Map an HTTP method
|
|
174
|
+
* Map an HTTP method to the operation it performs.
|
|
87
175
|
*
|
|
88
176
|
* - `GET`, `HEAD`, `OPTIONS` → `"read"`
|
|
89
177
|
* - `POST`, `PUT`, `PATCH` → `"write"`
|
|
90
178
|
* - `DELETE` → `"delete"`
|
|
179
|
+
*
|
|
180
|
+
* Any other method is a `"write"`: a verb this does not know is not assumed
|
|
181
|
+
* to be harmless.
|
|
91
182
|
*/
|
|
92
183
|
function httpMethodToOperation(method) {
|
|
93
184
|
switch (method.toUpperCase()) {
|
|
94
185
|
case "GET":
|
|
95
186
|
case "HEAD":
|
|
96
187
|
case "OPTIONS": return "read";
|
|
97
|
-
case "POST":
|
|
98
|
-
case "PUT":
|
|
99
|
-
case "PATCH": return "write";
|
|
100
188
|
case "DELETE": return "delete";
|
|
101
|
-
default: return "
|
|
189
|
+
default: return "write";
|
|
102
190
|
}
|
|
103
191
|
}
|
|
104
|
-
/**
|
|
105
|
-
* Check whether the given permissions array allows `operation` on `collection`.
|
|
106
|
-
*
|
|
107
|
-
* Supports the `"*"` wildcard for the collection field, which matches any
|
|
108
|
-
* collection. Returns `true` if at least one permission entry grants access.
|
|
109
|
-
*
|
|
110
|
-
* @param permissions - The API key's permission entries.
|
|
111
|
-
* @param collection - The target collection slug.
|
|
112
|
-
* @param operation - The requested operation.
|
|
113
|
-
* @returns `true` if the operation is permitted.
|
|
114
|
-
*/
|
|
115
|
-
function isOperationAllowed(permissions, collection, operation) {
|
|
116
|
-
for (const perm of permissions) if ((perm.collection === "*" || perm.collection === collection) && perm.operations.includes(operation)) return true;
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Check whether the given permissions array allows a storage operation.
|
|
121
|
-
*
|
|
122
|
-
* Storage, like functions, lives outside the collection namespace: an entry
|
|
123
|
-
* with collection `"storage"` grants storage access for the listed
|
|
124
|
-
* operations (GET routes → `read`, upload/folder/tus → `write`,
|
|
125
|
-
* DELETE routes → `delete`), and the global `"*"` wildcard also matches.
|
|
126
|
-
* A collection-scoped key gets no storage access at all.
|
|
127
|
-
*
|
|
128
|
-
* Matching is exactly {@link isOperationAllowed} with `"storage"` as the
|
|
129
|
-
* resource name — delegated so the semantics can never drift.
|
|
130
|
-
*/
|
|
131
|
-
function isStorageAllowed(permissions, operation) {
|
|
132
|
-
return isOperationAllowed(permissions, "storage", operation);
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Check whether the given permissions array allows invoking a custom function.
|
|
136
|
-
*
|
|
137
|
-
* Functions live outside the collection namespace, so they have their own
|
|
138
|
-
* resource names in the permission list:
|
|
139
|
-
*
|
|
140
|
-
* - `{ collection: "functions", ... }` grants every function
|
|
141
|
-
* - `{ collection: "functions/<name>", ... }` grants a single function
|
|
142
|
-
* - the global `"*"` wildcard (full-access keys) also matches
|
|
143
|
-
*
|
|
144
|
-
* The operation is derived from the HTTP method as usual (GET → read,
|
|
145
|
-
* POST/PUT/PATCH → write, DELETE → delete). The functions index route is
|
|
146
|
-
* checked as `functionName === ""`, which only the `"functions"` and `"*"`
|
|
147
|
-
* entries can grant.
|
|
148
|
-
*
|
|
149
|
-
* A collection-scoped key (e.g. read-only on `events`) therefore can NOT
|
|
150
|
-
* invoke functions — before this guard existed, any valid key could call
|
|
151
|
-
* every function.
|
|
152
|
-
*/
|
|
153
|
-
function isFunctionAllowed(permissions, functionName, operation) {
|
|
154
|
-
for (const perm of permissions) if ((perm.collection === "*" || perm.collection === "functions" || functionName !== "" && perm.collection === `functions/${functionName}`) && perm.operations.includes(operation)) return true;
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
192
|
//#endregion
|
|
158
193
|
//#region src/email/html.ts
|
|
159
194
|
/**
|
|
@@ -848,6 +883,138 @@ No action is needed.
|
|
|
848
883
|
text
|
|
849
884
|
};
|
|
850
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Default address-change confirmation, mailed to the NEW address.
|
|
888
|
+
*
|
|
889
|
+
* Names the account being moved — its current address — because the link
|
|
890
|
+
* makes the address the account's: whoever follows it hands their inbox to
|
|
891
|
+
* that account, and must be able to tell whether it is theirs.
|
|
892
|
+
*/
|
|
893
|
+
function getEmailChangeTemplate(confirmUrl, user, newEmail, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
894
|
+
const greeting = getGreeting(user);
|
|
895
|
+
const subject = `Confirm your new ${appName} email address`;
|
|
896
|
+
const body = html`
|
|
897
|
+
<!DOCTYPE html>
|
|
898
|
+
<html>
|
|
899
|
+
<head>
|
|
900
|
+
<meta charset="utf-8">
|
|
901
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
902
|
+
<title>${subject}</title>
|
|
903
|
+
</head>
|
|
904
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
905
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
906
|
+
<div style="${styles.card}">
|
|
907
|
+
<h1 style="${styles.heading}">Confirm Your New Email</h1>
|
|
908
|
+
|
|
909
|
+
<p style="${styles.paragraph}">
|
|
910
|
+
Hi ${greeting},
|
|
911
|
+
</p>
|
|
912
|
+
|
|
913
|
+
<p style="${styles.paragraph}">
|
|
914
|
+
The ${appName} account ${user.email} asked to use ${newEmail} from now on.
|
|
915
|
+
Confirm it is yours by clicking the button below:
|
|
916
|
+
</p>
|
|
917
|
+
|
|
918
|
+
<div style="text-align: center;">
|
|
919
|
+
<a href="${confirmUrl}" style="${styles.button}">Confirm New Email</a>
|
|
920
|
+
</div>
|
|
921
|
+
|
|
922
|
+
<p style="${styles.paragraph}">
|
|
923
|
+
Or copy and paste this link into your browser:
|
|
924
|
+
</p>
|
|
925
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
926
|
+
${confirmUrl}
|
|
927
|
+
</p>
|
|
928
|
+
|
|
929
|
+
<div style="${styles.warning}">
|
|
930
|
+
⏰ This link expires in 24 hours and can only be used once.
|
|
931
|
+
</div>
|
|
932
|
+
|
|
933
|
+
<div style="${styles.footer}">
|
|
934
|
+
<p style="margin: 0;">
|
|
935
|
+
If you did not ask for this, ignore this email: nothing changes until the link is followed.
|
|
936
|
+
</p>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
</body>
|
|
941
|
+
</html>
|
|
942
|
+
`;
|
|
943
|
+
const text = `
|
|
944
|
+
Confirm Your New Email
|
|
945
|
+
|
|
946
|
+
Hi ${greeting},
|
|
947
|
+
|
|
948
|
+
The ${appName} account ${user.email} asked to use ${newEmail} from now on. Confirm it is yours by following this link:
|
|
949
|
+
${confirmUrl}
|
|
950
|
+
|
|
951
|
+
This link expires in 24 hours and can only be used once.
|
|
952
|
+
|
|
953
|
+
If you did not ask for this, ignore this email: nothing changes until the link is followed.
|
|
954
|
+
`.trim();
|
|
955
|
+
return {
|
|
956
|
+
subject,
|
|
957
|
+
html: body.toString().trim(),
|
|
958
|
+
text
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Default notice to the OLD address that a change was asked for. It carries
|
|
963
|
+
* no link: the old inbox can do nothing about the change except know of it,
|
|
964
|
+
* and a link here would be a second way to make it.
|
|
965
|
+
*/
|
|
966
|
+
function getEmailChangeNoticeTemplate(user, newEmail, appName = DEFAULT_APP_NAME, logoUrl) {
|
|
967
|
+
const greeting = getGreeting(user);
|
|
968
|
+
const subject = `Your ${appName} email address is being changed`;
|
|
969
|
+
const body = html`
|
|
970
|
+
<!DOCTYPE html>
|
|
971
|
+
<html>
|
|
972
|
+
<head>
|
|
973
|
+
<meta charset="utf-8">
|
|
974
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
975
|
+
<title>${subject}</title>
|
|
976
|
+
</head>
|
|
977
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
978
|
+
<div style="${styles.container}">${renderHeader(appName, logoUrl)}
|
|
979
|
+
<div style="${styles.card}">
|
|
980
|
+
<h1 style="${styles.heading}">Email Change Requested</h1>
|
|
981
|
+
|
|
982
|
+
<p style="${styles.paragraph}">
|
|
983
|
+
Hi ${greeting},
|
|
984
|
+
</p>
|
|
985
|
+
|
|
986
|
+
<p style="${styles.paragraph}">
|
|
987
|
+
Someone signed in to your ${appName} account asked to change its email address
|
|
988
|
+
from ${user.email} to ${newEmail}. The change happens only when the link we sent
|
|
989
|
+
to ${newEmail} is followed.
|
|
990
|
+
</p>
|
|
991
|
+
|
|
992
|
+
<div style="${styles.footer}">
|
|
993
|
+
<p style="margin: 0;">
|
|
994
|
+
If this was you, there is nothing to do here. If it was not, sign in, change your
|
|
995
|
+
password and sign out of every other session.
|
|
996
|
+
</p>
|
|
997
|
+
</div>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
</body>
|
|
1001
|
+
</html>
|
|
1002
|
+
`;
|
|
1003
|
+
const text = `
|
|
1004
|
+
Email Change Requested
|
|
1005
|
+
|
|
1006
|
+
Hi ${greeting},
|
|
1007
|
+
|
|
1008
|
+
Someone signed in to your ${appName} account asked to change its email address from ${user.email} to ${newEmail}. The change happens only when the link we sent to ${newEmail} is followed.
|
|
1009
|
+
|
|
1010
|
+
If this was you, there is nothing to do here. If it was not, sign in, change your password and sign out of every other session.
|
|
1011
|
+
`.trim();
|
|
1012
|
+
return {
|
|
1013
|
+
subject,
|
|
1014
|
+
html: body.toString().trim(),
|
|
1015
|
+
text
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
851
1018
|
//#endregion
|
|
852
1019
|
//#region src/email/link-base.ts
|
|
853
1020
|
/**
|
|
@@ -968,7 +1135,7 @@ function generateSecureToken() {
|
|
|
968
1135
|
/**
|
|
969
1136
|
* Hash a token for database storage using SHA-256.
|
|
970
1137
|
*/
|
|
971
|
-
function hashToken
|
|
1138
|
+
function hashToken(token) {
|
|
972
1139
|
return createHash("sha256").update(token).digest("hex");
|
|
973
1140
|
}
|
|
974
1141
|
/**
|
|
@@ -1049,7 +1216,7 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
|
|
|
1049
1216
|
if (!clearPassword) return { invitationSent: false };
|
|
1050
1217
|
if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
|
|
1051
1218
|
const token = generateSecureToken();
|
|
1052
|
-
const tokenHash = hashToken
|
|
1219
|
+
const tokenHash = hashToken(token);
|
|
1053
1220
|
const expiresAt = new Date(Date.now() + USER_INVITATION_LINK_TTL_MS);
|
|
1054
1221
|
await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
|
|
1055
1222
|
const setPasswordUrl = `${resolveEmailLinkBase(ctx.emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
@@ -1247,7 +1414,75 @@ async function isAccessTokenRevoked(authRepo, payload) {
|
|
|
1247
1414
|
});
|
|
1248
1415
|
return false;
|
|
1249
1416
|
}
|
|
1250
|
-
|
|
1417
|
+
return issuedBefore(payload, validAfter);
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Is the account behind this verified access token still the one that may use
|
|
1421
|
+
* it — and with which roles?
|
|
1422
|
+
*
|
|
1423
|
+
* A verified signature says who the token was minted for, an hour ago at
|
|
1424
|
+
* most. This asks the database what has happened to that account since. Every
|
|
1425
|
+
* door that honours an access token — the data plane, the admin gates, the
|
|
1426
|
+
* realtime socket — asks it here, so they cannot disagree about it.
|
|
1427
|
+
*
|
|
1428
|
+
* They did. A deleted account read as "not revoked": the watermark lives on
|
|
1429
|
+
* the user row, so once the row was gone there was no watermark, and the
|
|
1430
|
+
* roles lookup answered `[]` rather than "nobody". A token its owner had
|
|
1431
|
+
* revoked came back to life when an administrator deleted the account, as an
|
|
1432
|
+
* authenticated principal with that uid, for the rest of its lifetime. MCP
|
|
1433
|
+
* refresh and personal API keys already treated a missing account as revoked;
|
|
1434
|
+
* the main door did not.
|
|
1435
|
+
*
|
|
1436
|
+
* Throws when the repository does. Unlike the watermark read on its own, this
|
|
1437
|
+
* one decides the roles a request runs with, so a failure is a refusal (the
|
|
1438
|
+
* callers answer 503) rather than a guess.
|
|
1439
|
+
*/
|
|
1440
|
+
async function judgeAccessToken(authRepo, payload) {
|
|
1441
|
+
let roles;
|
|
1442
|
+
let validAfter = null;
|
|
1443
|
+
if (typeof authRepo.getAccountAccessState === "function") {
|
|
1444
|
+
const state = await authRepo.getAccountAccessState(payload.uid, payload.sid);
|
|
1445
|
+
if (!state) return {
|
|
1446
|
+
live: false,
|
|
1447
|
+
refusal: "account-deleted"
|
|
1448
|
+
};
|
|
1449
|
+
if (state.disabled) return {
|
|
1450
|
+
live: false,
|
|
1451
|
+
refusal: "account-disabled"
|
|
1452
|
+
};
|
|
1453
|
+
roles = state.roles;
|
|
1454
|
+
validAfter = state.tokensValidAfter;
|
|
1455
|
+
if (payload.sid && state.sessionActive === false) return {
|
|
1456
|
+
live: false,
|
|
1457
|
+
refusal: "session-revoked"
|
|
1458
|
+
};
|
|
1459
|
+
} else {
|
|
1460
|
+
if (typeof authRepo.getUserWithRoles === "function") {
|
|
1461
|
+
const account = await authRepo.getUserWithRoles(payload.uid);
|
|
1462
|
+
if (!account) return {
|
|
1463
|
+
live: false,
|
|
1464
|
+
refusal: "account-deleted"
|
|
1465
|
+
};
|
|
1466
|
+
if (account.user.disabled) return {
|
|
1467
|
+
live: false,
|
|
1468
|
+
refusal: "account-disabled"
|
|
1469
|
+
};
|
|
1470
|
+
roles = account.roles;
|
|
1471
|
+
}
|
|
1472
|
+
if (typeof authRepo.getTokensValidAfter === "function") validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
1473
|
+
}
|
|
1474
|
+
if (issuedBefore(payload, validAfter)) return {
|
|
1475
|
+
live: false,
|
|
1476
|
+
refusal: "revoked"
|
|
1477
|
+
};
|
|
1478
|
+
return {
|
|
1479
|
+
live: true,
|
|
1480
|
+
roles
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
/** The watermark comparison {@link isAccessTokenRevoked} and the judge share. */
|
|
1484
|
+
function issuedBefore(payload, validAfter) {
|
|
1485
|
+
if (!validAfter || typeof payload.iat !== "number") return false;
|
|
1251
1486
|
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
1252
1487
|
}
|
|
1253
1488
|
/**
|
|
@@ -1298,6 +1533,7 @@ async function revokeAllSessions(authRepo, uid) {
|
|
|
1298
1533
|
async function replaceUserPassword(authRepo, uid, passwordHash) {
|
|
1299
1534
|
await authRepo.updatePassword(uid, passwordHash);
|
|
1300
1535
|
await revokeAllSessions(authRepo, uid);
|
|
1536
|
+
await authRepo.deleteAllPasswordResetTokensForUser?.(uid);
|
|
1301
1537
|
}
|
|
1302
1538
|
//#endregion
|
|
1303
1539
|
//#region src/auth/rls-scope.ts
|
|
@@ -1425,59 +1661,221 @@ function extractBearerToken(authHeader) {
|
|
|
1425
1661
|
return authHeader.slice(separator + 1);
|
|
1426
1662
|
}
|
|
1427
1663
|
//#endregion
|
|
1428
|
-
//#region src/auth/
|
|
1664
|
+
//#region src/auth/access.ts
|
|
1665
|
+
var configured = EMPTY_ACCESS_MODEL;
|
|
1666
|
+
var keyOwners;
|
|
1667
|
+
/** The access model the running backend serves. Built-ins only until boot configures it. */
|
|
1668
|
+
function getAccessModel() {
|
|
1669
|
+
return configured;
|
|
1670
|
+
}
|
|
1429
1671
|
/**
|
|
1430
|
-
*
|
|
1672
|
+
* How a personal key finds its owner, or undefined when personal keys are
|
|
1673
|
+
* off — in which case every personal key, however valid, is refused.
|
|
1431
1674
|
*/
|
|
1432
|
-
function
|
|
1433
|
-
return
|
|
1675
|
+
function getKeyOwnerResolver() {
|
|
1676
|
+
return keyOwners;
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Install the access model, and — when the app enables `auth.personalKeys`
|
|
1680
|
+
* and has an account store to read owners from — how personal keys find
|
|
1681
|
+
* their owner. Called once by `initializeRebaseBackend`.
|
|
1682
|
+
*/
|
|
1683
|
+
function configureAccess(options) {
|
|
1684
|
+
configured = options.model;
|
|
1685
|
+
keyOwners = options.resolveKeyOwner;
|
|
1686
|
+
}
|
|
1687
|
+
/** The resource names Rebase's own scopes use; an app scope may not reuse one. */
|
|
1688
|
+
var BUILT_IN_RESOURCES = new Set(BUILT_IN_SCOPES.map((scope) => scope.slice(0, scope.indexOf(":"))));
|
|
1689
|
+
/** A declaration the access model cannot be built from. Fails the boot. */
|
|
1690
|
+
var AccessModelError = class extends Error {
|
|
1691
|
+
constructor(message) {
|
|
1692
|
+
super(message);
|
|
1693
|
+
this.name = "AccessModelError";
|
|
1694
|
+
}
|
|
1695
|
+
};
|
|
1696
|
+
/**
|
|
1697
|
+
* Build the access model from the users collection's `auth` block, refusing
|
|
1698
|
+
* anything that would read as a grant it is not.
|
|
1699
|
+
*
|
|
1700
|
+
* - `admin` may not be declared: it is built in and holds everything, and a
|
|
1701
|
+
* declaration would either repeat that or quietly narrow it.
|
|
1702
|
+
* - An app scope may not reuse a built-in resource name (`logs:export`): the
|
|
1703
|
+
* built-in resources are Rebase's, and a reader of `logs:*` should not have
|
|
1704
|
+
* to know which half is whose.
|
|
1705
|
+
* - A role may not list a data-plane scope: a person's data access is the
|
|
1706
|
+
* collection's `securityRules`, so `data:write` on a role would grant nothing
|
|
1707
|
+
* and look like it granted something.
|
|
1708
|
+
*/
|
|
1709
|
+
function accessModelFromCollections(collections) {
|
|
1710
|
+
const authCollection = collections.find((collection) => {
|
|
1711
|
+
const auth = collection.auth;
|
|
1712
|
+
return auth === true || !!auth && typeof auth === "object" && auth.enabled === true;
|
|
1713
|
+
});
|
|
1714
|
+
const auth = authCollection && typeof authCollection.auth === "object" ? authCollection.auth : void 0;
|
|
1715
|
+
const declaredScopes = { ...auth?.scopes ?? {} };
|
|
1716
|
+
const declaredRoles = { ...auth?.roles ?? {} };
|
|
1717
|
+
const where = `the "${authCollection?.slug ?? "users"}" collection's auth block`;
|
|
1718
|
+
for (const [name, definition] of Object.entries(declaredScopes)) {
|
|
1719
|
+
const parsed = parseScope(name);
|
|
1720
|
+
if (!parsed || parsed.target !== void 0) throw new AccessModelError(`auth.scopes: "${name}" in ${where} is not a scope name. Name scopes resource:action, lower-case, without a target — for example "project:deploy".`);
|
|
1721
|
+
const resource = name.slice(0, name.indexOf(":"));
|
|
1722
|
+
if (BUILT_IN_RESOURCES.has(resource)) throw new AccessModelError(`auth.scopes: "${name}" in ${where} uses the built-in resource "${resource}". App scopes need their own resource name; the built-in ones are ${[...BUILT_IN_RESOURCES].join(", ")}.`);
|
|
1723
|
+
if (typeof definition?.label !== "string" || definition.label.trim() === "") throw new AccessModelError(`auth.scopes: "${name}" in ${where} needs a label — it is what a person granting it reads.`);
|
|
1724
|
+
}
|
|
1725
|
+
const model = {
|
|
1726
|
+
roles: declaredRoles,
|
|
1727
|
+
scopes: declaredScopes
|
|
1728
|
+
};
|
|
1729
|
+
for (const [role, definition] of Object.entries(declaredRoles)) {
|
|
1730
|
+
if (role === "admin") throw new AccessModelError(`auth.roles: "admin" in ${where} is built in — it holds every scope — and cannot be declared. Declare a role with another name for a narrower set.`);
|
|
1731
|
+
if (!Array.isArray(definition?.scopes)) throw new AccessModelError(`auth.roles: "${role}" in ${where} needs a scopes list.`);
|
|
1732
|
+
const dataPlane = definition.scopes.filter((scope) => isDataPlaneScope(parseScope(scope)?.scope ?? ""));
|
|
1733
|
+
if (dataPlane.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${dataPlane.join(", ")}. Every signed-in person already holds the data plane; what they may do with rows is the collection's securityRules. List admin-plane and app scopes only.`);
|
|
1734
|
+
const problems = validateScopes(definition.scopes, model);
|
|
1735
|
+
if (problems.length > 0) throw new AccessModelError(`auth.roles: "${role}" in ${where} lists ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Role scopes are ${ADMIN_SCOPES.join(", ")} and the app's own auth.scopes.`);
|
|
1736
|
+
}
|
|
1737
|
+
return model;
|
|
1738
|
+
}
|
|
1739
|
+
/** Narrow a context's user to the roles it carries. */
|
|
1740
|
+
function rolesOf(user) {
|
|
1741
|
+
if (typeof user !== "object" || user === null || !("roles" in user)) return [];
|
|
1742
|
+
const roles = user.roles;
|
|
1743
|
+
return Array.isArray(roles) ? roles.filter((role) => typeof role === "string") : [];
|
|
1434
1744
|
}
|
|
1435
1745
|
/**
|
|
1436
|
-
*
|
|
1746
|
+
* Everything the caller of this request may do. Empty for a request with no
|
|
1747
|
+
* caller.
|
|
1748
|
+
*/
|
|
1749
|
+
function callerScopes(c) {
|
|
1750
|
+
const narrowed = c.get("scopes");
|
|
1751
|
+
if (narrowed) return narrowed;
|
|
1752
|
+
const user = c.get("user");
|
|
1753
|
+
if (!user) return [];
|
|
1754
|
+
return scopesForRoles(rolesOf(user), configured);
|
|
1755
|
+
}
|
|
1756
|
+
/** Does this request's caller hold `scope`, on `target` when one is given? */
|
|
1757
|
+
function hasScope(c, scope, target) {
|
|
1758
|
+
return scopeGrants(callerScopes(c), scope, target);
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* The refusal for a missing scope, worded for whoever is holding the
|
|
1762
|
+
* credential. `details.requiredScope` names it for clients that branch.
|
|
1763
|
+
*/
|
|
1764
|
+
function scopeMissing(c, scope, target) {
|
|
1765
|
+
const wanted = target !== void 0 ? `${scope}:${target}` : scope;
|
|
1766
|
+
return new ApiError(403, "SCOPE_MISSING", c.get("apiKey") !== void 0 ? `This API key does not hold the "${wanted}" scope. Create a key that includes it.` : c.get("scopes") ? `This token does not hold the "${wanted}" scope. Reconnect and grant it.` : `Your account does not hold the "${wanted}" scope. A role that lists it (auth.roles on the users collection) grants it; the admin role holds every scope.`, { requiredScope: wanted });
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Refuse a request whose caller does not hold `scope`.
|
|
1770
|
+
*
|
|
1771
|
+
* `target` narrows the check to one collection, bucket, function or app
|
|
1772
|
+
* resource, and may be read from the request. Mount after authentication: a
|
|
1773
|
+
* request with no caller is refused as unauthenticated, not as unscoped.
|
|
1437
1774
|
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1775
|
+
* @example
|
|
1776
|
+
* ```ts
|
|
1777
|
+
* app.post("/deploy/:project", requireScope("project:deploy", c => c.req.param("project")), handler);
|
|
1778
|
+
* ```
|
|
1440
1779
|
*/
|
|
1441
|
-
function
|
|
1442
|
-
return
|
|
1780
|
+
function requireScope(scope, target) {
|
|
1781
|
+
return async (c, next) => {
|
|
1782
|
+
if (!c.get("user")) return refuse(c, ApiError.unauthenticated("Authentication required"));
|
|
1783
|
+
const resolvedTarget = typeof target === "function" ? target(c) : target;
|
|
1784
|
+
if (!hasScope(c, scope, resolvedTarget)) return refuse(c, scopeMissing(c, scope, resolvedTarget));
|
|
1785
|
+
return next();
|
|
1786
|
+
};
|
|
1443
1787
|
}
|
|
1444
1788
|
/**
|
|
1445
|
-
*
|
|
1789
|
+
* Refuse a request unless its caller holds the scope for its HTTP method:
|
|
1790
|
+
* `read` for GET and HEAD, `write` for everything else. For an admin surface
|
|
1791
|
+
* whose reads and writes split exactly along the method.
|
|
1792
|
+
*/
|
|
1793
|
+
function requireScopeByMethod(scopes) {
|
|
1794
|
+
const read = requireScope(scopes.read);
|
|
1795
|
+
const write = requireScope(scopes.write);
|
|
1796
|
+
return async (c, next) => {
|
|
1797
|
+
const method = c.req.method.toUpperCase();
|
|
1798
|
+
return (method === "GET" || method === "HEAD" ? read : write)(c, next);
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* What a set of roles holds beyond this request's caller — the scopes, plus
|
|
1803
|
+
* `admin` itself when the roles include it and the caller does not hold it.
|
|
1804
|
+
* Empty when the caller covers every one.
|
|
1446
1805
|
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1806
|
+
* `admin` is named separately because it is more than its scopes: it is the
|
|
1807
|
+
* RLS role that reads every row, so a role listing every admin-plane scope
|
|
1808
|
+
* still cannot hand it out.
|
|
1809
|
+
*/
|
|
1810
|
+
function beyondCaller(c, roles) {
|
|
1811
|
+
const beyond = scopesBeyond(scopesForRoles(roles, configured), callerScopes(c));
|
|
1812
|
+
if (hasAdminRole(roles) && !hasAdminRole(rolesOf(c.get("user")))) beyond.unshift(ADMIN_ROLE);
|
|
1813
|
+
return beyond;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Refuse to let the caller act on an account — edit it, reset its password,
|
|
1817
|
+
* delete it — when that account holds anything the caller does not.
|
|
1449
1818
|
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1819
|
+
* `users:write` is the scope that lets a support role manage accounts, and
|
|
1820
|
+
* without this it would also let that role reset an administrator's password
|
|
1821
|
+
* and sign in as them.
|
|
1452
1822
|
*/
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1823
|
+
function assertMayManageAccount(c, targetRoles) {
|
|
1824
|
+
const beyond = beyondCaller(c, targetRoles);
|
|
1825
|
+
if (beyond.length > 0) throw new ApiError(403, "ACCOUNT_OUTRANKS_CALLER", `This account holds ${beyond.join(", ")}, which you do not, so you cannot change it.`, { beyond });
|
|
1826
|
+
}
|
|
1827
|
+
/** Refuse to let the caller grant roles that hold anything the caller does not. */
|
|
1828
|
+
function assertMayGrantRoles(c, roles) {
|
|
1829
|
+
const beyond = beyondCaller(c, roles);
|
|
1830
|
+
if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CALLER", `These roles hold ${beyond.join(", ")}, which you do not, so you cannot grant them.`, { beyond });
|
|
1831
|
+
}
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/auth/api-keys/api-key-middleware.ts
|
|
1834
|
+
/**
|
|
1835
|
+
* Check whether a token looks like a Rebase API key.
|
|
1836
|
+
*/
|
|
1837
|
+
function isApiKeyToken(token) {
|
|
1838
|
+
return token.startsWith("rk_");
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Verify a presented key: it exists, is live, and — for a personal key —
|
|
1842
|
+
* personal keys are on and its owner still exists. Records the use.
|
|
1843
|
+
*/
|
|
1844
|
+
async function resolveApiKey(store, token) {
|
|
1845
|
+
const apiKey = await store.findByKeyHash(await sha256Hex(token));
|
|
1846
|
+
if (!apiKey) return { message: "Invalid API key" };
|
|
1847
|
+
if (apiKey.revoked_at) return { message: "API key has been revoked" };
|
|
1848
|
+
if (apiKey.expires_at && new Date(apiKey.expires_at) < /* @__PURE__ */ new Date()) return { message: "API key has expired" };
|
|
1849
|
+
let identity;
|
|
1850
|
+
if (apiKey.kind === "personal") {
|
|
1851
|
+
const resolveOwner = getKeyOwnerResolver();
|
|
1852
|
+
if (!resolveOwner || !apiKey.owner_uid) return { message: "Personal API keys are switched off on this backend" };
|
|
1853
|
+
const owner = await resolveOwner(apiKey.owner_uid);
|
|
1854
|
+
if (!owner) return { message: "The account this API key acts as no longer exists" };
|
|
1855
|
+
identity = {
|
|
1856
|
+
uid: apiKey.owner_uid,
|
|
1857
|
+
roles: owner.roles,
|
|
1858
|
+
scopes: intersectScopes(apiKey.scopes, scopesForRoles(owner.roles, getAccessModel()))
|
|
1859
|
+
};
|
|
1860
|
+
} else identity = {
|
|
1861
|
+
uid: `api-key:${apiKey.id}`,
|
|
1862
|
+
roles: ["service", ...apiKey.roles.filter((role) => role !== "service")],
|
|
1863
|
+
scopes: apiKey.scopes
|
|
1864
|
+
};
|
|
1865
|
+
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
1866
|
+
const now = Date.now();
|
|
1867
|
+
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
1868
|
+
lastUsedTouchedAt.set(apiKey.id, now);
|
|
1869
|
+
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
1870
|
+
}
|
|
1475
1871
|
const masked = {
|
|
1476
1872
|
id: apiKey.id,
|
|
1477
1873
|
name: apiKey.name,
|
|
1874
|
+
kind: apiKey.kind,
|
|
1478
1875
|
key_prefix: apiKey.key_prefix,
|
|
1479
|
-
|
|
1480
|
-
|
|
1876
|
+
scopes: apiKey.scopes,
|
|
1877
|
+
roles: apiKey.roles,
|
|
1878
|
+
owner_uid: apiKey.owner_uid,
|
|
1481
1879
|
rate_limit: apiKey.rate_limit,
|
|
1482
1880
|
created_by: apiKey.created_by,
|
|
1483
1881
|
created_at: apiKey.created_at,
|
|
@@ -1486,87 +1884,84 @@ async function validateApiKey(c, token, options) {
|
|
|
1486
1884
|
expires_at: apiKey.expires_at,
|
|
1487
1885
|
revoked_at: apiKey.revoked_at
|
|
1488
1886
|
};
|
|
1489
|
-
|
|
1887
|
+
return {
|
|
1888
|
+
...identity,
|
|
1889
|
+
apiKey: masked
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
/** Per-process debounce state for last_used_at touches. */
|
|
1893
|
+
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
1894
|
+
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
1895
|
+
/**
|
|
1896
|
+
* Validate an API key token and populate the Hono context: `user`, `apiKey`,
|
|
1897
|
+
* `scopes` and the RLS-scoped `driver`.
|
|
1898
|
+
*
|
|
1899
|
+
* Returns `true` when the context is populated, or the error Response.
|
|
1900
|
+
*/
|
|
1901
|
+
async function validateApiKey(c, token, options) {
|
|
1902
|
+
const resolved = await resolveApiKey(options.store, token);
|
|
1903
|
+
if (!("uid" in resolved)) return errorHandler(ApiError.unauthenticated(resolved.message), c);
|
|
1904
|
+
c.set("user", {
|
|
1905
|
+
uid: resolved.uid,
|
|
1906
|
+
roles: resolved.roles
|
|
1907
|
+
});
|
|
1908
|
+
c.set("apiKey", resolved.apiKey);
|
|
1909
|
+
c.set("scopes", resolved.scopes);
|
|
1490
1910
|
try {
|
|
1491
|
-
|
|
1492
|
-
uid,
|
|
1493
|
-
roles
|
|
1494
|
-
});
|
|
1495
|
-
c.set("driver", scopedDriver);
|
|
1911
|
+
c.set("driver", await scopeDataDriver(options.driver, {
|
|
1912
|
+
uid: resolved.uid,
|
|
1913
|
+
roles: resolved.roles
|
|
1914
|
+
}));
|
|
1496
1915
|
} catch (error) {
|
|
1497
1916
|
logger.error("[AUTH] RLS scoping failed for API key", { error });
|
|
1498
|
-
return
|
|
1499
|
-
message: "Internal authentication error",
|
|
1500
|
-
code: "INTERNAL_ERROR"
|
|
1501
|
-
} }, 500);
|
|
1502
|
-
}
|
|
1503
|
-
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
1504
|
-
const now = Date.now();
|
|
1505
|
-
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
1506
|
-
lastUsedTouchedAt.set(apiKey.id, now);
|
|
1507
|
-
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
1917
|
+
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
1508
1918
|
}
|
|
1509
1919
|
return true;
|
|
1510
1920
|
}
|
|
1511
|
-
/** Per-process debounce state for last_used_at touches. */
|
|
1512
|
-
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
1513
|
-
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
1514
1921
|
/**
|
|
1515
|
-
*
|
|
1516
|
-
* generator (storage and functions
|
|
1517
|
-
* change to the error contract happens in one place.
|
|
1922
|
+
* The 403 for a credential that lacks a data-plane scope outside the REST
|
|
1923
|
+
* generator (storage and functions). Names the scope that would grant it.
|
|
1518
1924
|
*/
|
|
1519
|
-
function
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1925
|
+
function forbidScope(c, scope, target) {
|
|
1926
|
+
const wanted = target !== void 0 && target !== "" ? `${scope}:${target}` : scope;
|
|
1927
|
+
return errorHandler(new ApiError(403, "SCOPE_MISSING", `This credential does not hold the "${wanted}" scope.`, { requiredScope: wanted }), c);
|
|
1928
|
+
}
|
|
1929
|
+
/** Whether this request's caller holds `scope` — on `target` when given. */
|
|
1930
|
+
function callerHolds(c, scope, target) {
|
|
1931
|
+
const narrowed = c.get("scopes");
|
|
1932
|
+
if (!narrowed) return true;
|
|
1933
|
+
return target === void 0 ? scopeGrantsAny(narrowed, scope) : scopeGrants(narrowed, scope, target);
|
|
1524
1934
|
}
|
|
1525
1935
|
/**
|
|
1526
|
-
*
|
|
1936
|
+
* Scope guard for the resumable-upload routes (`/tus/:id`).
|
|
1527
1937
|
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
1532
|
-
* operation derived from the HTTP method. Requests not authenticated via an
|
|
1533
|
-
* API key pass through to the storage router's own auth gates.
|
|
1534
|
-
*
|
|
1535
|
-
* TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
|
|
1536
|
-
* for EVERY method: the protocol's offset check is a GET and its cancel is a
|
|
1537
|
-
* DELETE, but both are steps of an upload — a write-scoped key must be able
|
|
1538
|
-
* to complete (and abort) its own resumable upload without also holding
|
|
1539
|
-
* `read`/`delete` on stored objects.
|
|
1938
|
+
* Every step of an upload — the offset check (HEAD), the chunks (PATCH), the
|
|
1939
|
+
* cancel (DELETE) — is part of writing it, so all of them need
|
|
1940
|
+
* `storage:write`. Which source the upload writes to was checked when it was
|
|
1941
|
+
* created; these steps only reach an upload the same caller owns.
|
|
1540
1942
|
*/
|
|
1541
|
-
function
|
|
1943
|
+
function createTusScopeGuard() {
|
|
1542
1944
|
return async (c, next) => {
|
|
1543
|
-
|
|
1544
|
-
if (!apiKey) return next();
|
|
1545
|
-
const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
|
|
1546
|
-
if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
|
|
1945
|
+
if (/\/tus\/[^/]+$/.test(c.req.path) && c.get("user") && !callerHolds(c, "storage:write")) return forbidScope(c, "storage:write");
|
|
1547
1946
|
return next();
|
|
1548
1947
|
};
|
|
1549
1948
|
}
|
|
1550
1949
|
/**
|
|
1551
|
-
*
|
|
1950
|
+
* Scope guard for the custom-functions router: a narrowed credential needs
|
|
1951
|
+
* `functions:invoke` on the function it calls. The functions index — the
|
|
1952
|
+
* listing at the mount point itself — needs the unqualified scope.
|
|
1552
1953
|
*
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1555
|
-
* however narrowly scoped — could invoke every custom function. This guard
|
|
1556
|
-
* closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
|
|
1557
|
-
* permission entry (or the global `"*"` wildcard) for the derived operation.
|
|
1558
|
-
*
|
|
1559
|
-
* Non-API-key requests (JWT, service key, anonymous) pass through untouched —
|
|
1560
|
-
* functions decide their own auth for those, as before.
|
|
1954
|
+
* People pass: what a person may do inside a function is the function's own
|
|
1955
|
+
* business, as it always was.
|
|
1561
1956
|
*
|
|
1562
1957
|
* @param mountPrefix - The path the functions router is mounted at
|
|
1563
1958
|
* (e.g. `/api/functions`), used to extract the function
|
|
1564
1959
|
* name from the request path.
|
|
1565
1960
|
*/
|
|
1566
|
-
function
|
|
1961
|
+
function createFunctionScopeGuard(mountPrefix) {
|
|
1567
1962
|
return async (c, next) => {
|
|
1568
|
-
const
|
|
1569
|
-
if (!
|
|
1963
|
+
const narrowed = c.get("scopes");
|
|
1964
|
+
if (!narrowed) return next();
|
|
1570
1965
|
const path = c.req.path;
|
|
1571
1966
|
const idx = path.indexOf(mountPrefix);
|
|
1572
1967
|
const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
@@ -1576,21 +1971,17 @@ function createFunctionApiKeyGuard(mountPrefix) {
|
|
|
1576
1971
|
} catch {
|
|
1577
1972
|
functionName = rawName;
|
|
1578
1973
|
}
|
|
1579
|
-
|
|
1580
|
-
if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
|
|
1974
|
+
if (!(functionName === "" ? scopeGrants(narrowed, "functions:invoke") : scopeGrants(narrowed, "functions:invoke", functionName))) return forbidScope(c, "functions:invoke", functionName);
|
|
1581
1975
|
return next();
|
|
1582
1976
|
};
|
|
1583
1977
|
}
|
|
1584
1978
|
/**
|
|
1585
|
-
*
|
|
1979
|
+
* Pre-auth middleware for `rk_` bearer tokens.
|
|
1586
1980
|
*
|
|
1587
|
-
* Routers whose auth gate is JWT-based (`
|
|
1588
|
-
* the
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
* the downstream gates then see the already-resolved user and apply their
|
|
1592
|
-
* role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
|
|
1593
|
-
* a non-admin key is rejected with 403.
|
|
1981
|
+
* Routers whose auth gate is JWT-based (`createRequireAuth` — the admin
|
|
1982
|
+
* surfaces, the key routes) don't know about API keys. Mounted in front of
|
|
1983
|
+
* them, this authenticates `rk_` tokens and populates the request context; the
|
|
1984
|
+
* downstream gates then see the already-resolved caller and check its scopes.
|
|
1594
1985
|
*
|
|
1595
1986
|
* Requests without an `rk_` bearer token pass through untouched. An invalid,
|
|
1596
1987
|
* revoked, or expired `rk_` token is rejected here (401) rather than falling
|
|
@@ -1607,6 +1998,257 @@ function createApiKeyPreAuth(options) {
|
|
|
1607
1998
|
};
|
|
1608
1999
|
}
|
|
1609
2000
|
//#endregion
|
|
2001
|
+
//#region src/auth/impersonation.ts
|
|
2002
|
+
var ADMIN_ONLY = "Only a signed-in administrator can run a request as another user.";
|
|
2003
|
+
var NOT_A_PERSON = {
|
|
2004
|
+
"api-key": {
|
|
2005
|
+
reason: "api-key",
|
|
2006
|
+
message: "An API key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2007
|
+
},
|
|
2008
|
+
"service-key": {
|
|
2009
|
+
reason: "service-key",
|
|
2010
|
+
message: "The service key cannot run a request as another user. Send it with an administrator's session instead."
|
|
2011
|
+
},
|
|
2012
|
+
none: {
|
|
2013
|
+
reason: "anonymous",
|
|
2014
|
+
message: ADMIN_ONLY
|
|
2015
|
+
}
|
|
2016
|
+
};
|
|
2017
|
+
/**
|
|
2018
|
+
* Decide whether this caller may act as the user they named, and who that
|
|
2019
|
+
* user is now.
|
|
2020
|
+
*
|
|
2021
|
+
* The one decision both doors take — the HTTP data plane through
|
|
2022
|
+
* {@link applyImpersonation}, the realtime socket at `AUTHENTICATE` and before
|
|
2023
|
+
* every frame after it. The caller is judged before the named user is looked
|
|
2024
|
+
* up, so the answer to a caller who may not impersonate says nothing about
|
|
2025
|
+
* whether the uid exists.
|
|
2026
|
+
*
|
|
2027
|
+
* Throws when a lookup fails: that is not an answer about anyone, and each
|
|
2028
|
+
* door refuses it its own way.
|
|
2029
|
+
*/
|
|
2030
|
+
async function decideImpersonation(request) {
|
|
2031
|
+
const { credential, caller, resolveUser, audit } = request;
|
|
2032
|
+
const targetUid = request.requestedUid.trim();
|
|
2033
|
+
const refuse = (reason, error) => {
|
|
2034
|
+
if (audit) logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2035
|
+
eventType: "auth.impersonation.refused",
|
|
2036
|
+
reason,
|
|
2037
|
+
callerUid: caller?.uid,
|
|
2038
|
+
requestedUid: targetUid,
|
|
2039
|
+
...audit
|
|
2040
|
+
});
|
|
2041
|
+
return { refused: error };
|
|
2042
|
+
};
|
|
2043
|
+
if (targetUid === "") return refuse("malformed", ApiError.badRequest(`The ${IMPERSONATE_HEADER} header names no user. Send the uid of the user to act as, or leave the header out.`, "IMPERSONATION_INVALID"));
|
|
2044
|
+
if (credential !== "session") {
|
|
2045
|
+
const { reason, message } = NOT_A_PERSON[credential];
|
|
2046
|
+
return refuse(reason, ApiError.forbidden(message, "IMPERSONATION_FORBIDDEN"));
|
|
2047
|
+
}
|
|
2048
|
+
if (!caller || !hasAdminRole(caller.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2049
|
+
if (!resolveUser) return refuse("unavailable", new ApiError(501, "IMPERSONATION_UNAVAILABLE", "This backend's auth cannot run a request as another user: its auth adapter does not implement resolveUser."));
|
|
2050
|
+
const self = await resolveUser(caller.uid);
|
|
2051
|
+
if (!self || !hasAdminRole(self.roles)) return refuse("not-admin", ApiError.forbidden(ADMIN_ONLY, "IMPERSONATION_FORBIDDEN"));
|
|
2052
|
+
const target = await resolveUser(targetUid);
|
|
2053
|
+
if (!target) return refuse("unknown-target", ApiError.notFound(`No active user has the id "${targetUid}", so there is nobody to run this request as.`, "IMPERSONATION_TARGET_NOT_FOUND"));
|
|
2054
|
+
if (audit) logger.info("[Security Audit] Ran a request as another user", {
|
|
2055
|
+
eventType: "auth.impersonation",
|
|
2056
|
+
impersonatorUid: caller.uid,
|
|
2057
|
+
targetUid: target.uid,
|
|
2058
|
+
targetRoles: target.roles,
|
|
2059
|
+
...audit
|
|
2060
|
+
});
|
|
2061
|
+
return {
|
|
2062
|
+
granted: target,
|
|
2063
|
+
impersonator: caller.uid
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
/** The caller the auth middleware put on the context, narrowed. */
|
|
2067
|
+
function callerOf(c) {
|
|
2068
|
+
const user = c.get("user");
|
|
2069
|
+
if (typeof user !== "object" || user === null) return void 0;
|
|
2070
|
+
const uid = "uid" in user && typeof user.uid === "string" && user.uid !== "" ? user.uid : void 0;
|
|
2071
|
+
if (uid === void 0) return void 0;
|
|
2072
|
+
return {
|
|
2073
|
+
uid,
|
|
2074
|
+
roles: "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : []
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Honour or refuse this request's {@link IMPERSONATE_HEADER}.
|
|
2079
|
+
*
|
|
2080
|
+
* Called by the data-plane auth middlewares once the caller is on the context
|
|
2081
|
+
* and its driver is scoped. When the header names a user the caller may act
|
|
2082
|
+
* as, the context's `user` and `driver` are replaced with that user's and
|
|
2083
|
+
* `impersonator` names the caller.
|
|
2084
|
+
*
|
|
2085
|
+
* @returns The refusal to send, or `undefined` to carry on — as the caller
|
|
2086
|
+
* when the header is absent, as the named user when it was granted.
|
|
2087
|
+
*/
|
|
2088
|
+
async function applyImpersonation(c, { credential, driver, resolveUser }) {
|
|
2089
|
+
const requested = c.req.header(IMPERSONATE_HEADER);
|
|
2090
|
+
if (requested === void 0) return void 0;
|
|
2091
|
+
const caller = callerOf(c);
|
|
2092
|
+
let decision;
|
|
2093
|
+
try {
|
|
2094
|
+
decision = await decideImpersonation({
|
|
2095
|
+
requestedUid: requested,
|
|
2096
|
+
credential,
|
|
2097
|
+
caller,
|
|
2098
|
+
resolveUser,
|
|
2099
|
+
audit: {
|
|
2100
|
+
door: "http",
|
|
2101
|
+
method: c.req.method,
|
|
2102
|
+
path: c.req.path,
|
|
2103
|
+
requestId: c.get("requestId")
|
|
2104
|
+
}
|
|
2105
|
+
});
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
return errorHandler(error instanceof Error ? error : new Error(String(error)), c);
|
|
2108
|
+
}
|
|
2109
|
+
if ("refused" in decision) return errorHandler(decision.refused, c);
|
|
2110
|
+
const target = decision.granted;
|
|
2111
|
+
const isAnonymous = target.isAnonymous === true;
|
|
2112
|
+
c.set("user", {
|
|
2113
|
+
uid: target.uid,
|
|
2114
|
+
email: target.email,
|
|
2115
|
+
roles: target.roles,
|
|
2116
|
+
isAnonymous
|
|
2117
|
+
});
|
|
2118
|
+
c.set("impersonator", { uid: decision.impersonator });
|
|
2119
|
+
try {
|
|
2120
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
2121
|
+
uid: target.uid,
|
|
2122
|
+
roles: target.roles,
|
|
2123
|
+
isAnonymous,
|
|
2124
|
+
...target.claims ? { claims: target.claims } : {}
|
|
2125
|
+
}));
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
logger.error("[AUTH] RLS scoping failed for an impersonated request", { error });
|
|
2128
|
+
return errorHandler(ApiError.internal("Internal authentication error"), c);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Refuse {@link IMPERSONATE_HEADER} on every route that does not honour it.
|
|
2133
|
+
*
|
|
2134
|
+
* Only the data API and custom functions run a request as another user; the
|
|
2135
|
+
* rest — storage, auth, the admin surfaces — authenticate the caller and
|
|
2136
|
+
* ignore the header. Ignored, a request asking to act as B would be answered
|
|
2137
|
+
* as the administrator who sent it, which is the one outcome impersonation
|
|
2138
|
+
* must never have. Mounted ahead of every route, so a surface added later is
|
|
2139
|
+
* refused until it is listed here as honouring the header.
|
|
2140
|
+
*
|
|
2141
|
+
* @param honouredMounts - The mount points whose auth middleware applies the
|
|
2142
|
+
* header (`/api/data`, `/api/functions`). A path is under one when it
|
|
2143
|
+
* is the mount itself or continues it with a `/`.
|
|
2144
|
+
*/
|
|
2145
|
+
function refuseUnhonouredImpersonation(honouredMounts) {
|
|
2146
|
+
return async (c, next) => {
|
|
2147
|
+
if (c.req.header("x-rebase-impersonate") === void 0) return next();
|
|
2148
|
+
const path = c.req.path;
|
|
2149
|
+
if (honouredMounts.some((mount) => path === mount || path.startsWith(`${mount}/`))) return next();
|
|
2150
|
+
logger.warn("[Security Audit] Refused a request to act as another user", {
|
|
2151
|
+
eventType: "auth.impersonation.refused",
|
|
2152
|
+
reason: "unsupported-route",
|
|
2153
|
+
method: c.req.method,
|
|
2154
|
+
path,
|
|
2155
|
+
requestId: c.get("requestId")
|
|
2156
|
+
});
|
|
2157
|
+
return errorHandler(ApiError.badRequest(`This route cannot run as another user, so it refuses the ${IMPERSONATE_HEADER} header. Only the data API and custom functions honour it.`, "IMPERSONATION_UNSUPPORTED"), c);
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region src/storage/requested-object.ts
|
|
2162
|
+
/**
|
|
2163
|
+
* Which object a storage request's URL path names.
|
|
2164
|
+
*
|
|
2165
|
+
* Three parties read the path of a `GET /file/*`, `GET /metadata/*` or
|
|
2166
|
+
* `DELETE /file/*`: the route, which serves or deletes an object;
|
|
2167
|
+
* `publicObjectAuth`, which lets an anonymous caller through when that object
|
|
2168
|
+
* is public; and `fileTokenAuth`, which compares it with the path a download
|
|
2169
|
+
* token grants. A decision is only about the object acted on if it is made on
|
|
2170
|
+
* the same key, so all three derive it here — the wildcard, the decoding, the
|
|
2171
|
+
* bucket segment and the canonical key — and none of them spells a step of its
|
|
2172
|
+
* own.
|
|
2173
|
+
*
|
|
2174
|
+
* They used to. `publicObjectAuth` judged the raw path with a check that strips
|
|
2175
|
+
* everything up to a `://`, while the route canonicalized it, folding `//` to
|
|
2176
|
+
* `/`: `GET /file/notes://public/secret.txt` was public to the one and served
|
|
2177
|
+
* the private key `notes:/public/secret.txt` from the other, with the authorize
|
|
2178
|
+
* hook never asked.
|
|
2179
|
+
*
|
|
2180
|
+
* Nothing here parses a scheme. A request path is a key, optionally behind the
|
|
2181
|
+
* one bucket segment the routes recognise; a `://` in it is a `:` followed by
|
|
2182
|
+
* an empty segment, and canonicalization folds the empty segment away.
|
|
2183
|
+
*/
|
|
2184
|
+
/**
|
|
2185
|
+
* The wildcard part of a storage route's path, still percent-encoded.
|
|
2186
|
+
*
|
|
2187
|
+
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
2188
|
+
* via `app.route(prefix, subRouter)`. Instead it is derived from the
|
|
2189
|
+
* fully-resolved `c.req.path` and `c.req.routePath`.
|
|
2190
|
+
*
|
|
2191
|
+
* For a route `/metadata/*` mounted at `/api/storage`, a request to
|
|
2192
|
+
* `/api/storage/metadata/default/file.jpg` yields routePath
|
|
2193
|
+
* `/api/storage/metadata/*`. The prefix (everything before `/*`) is stripped,
|
|
2194
|
+
* plus one character for the trailing `/`, to obtain `default/file.jpg`.
|
|
2195
|
+
*/
|
|
2196
|
+
function storageRequestWildcard(c) {
|
|
2197
|
+
const prefix = c.req.routePath.replace("/*", "");
|
|
2198
|
+
const fullPath = c.req.path;
|
|
2199
|
+
const idx = fullPath.indexOf(prefix);
|
|
2200
|
+
if (idx < 0) return "";
|
|
2201
|
+
return fullPath.substring(idx + prefix.length + 1);
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* The object a decoded path names: a key, behind an optional `default/` bucket
|
|
2205
|
+
* segment (matched case-insensitively). Throws {@link InvalidStorageKeyError}
|
|
2206
|
+
* when the key cannot be canonicalized.
|
|
2207
|
+
*
|
|
2208
|
+
* For a path that is already text — the folder route's JSON body. A URL path
|
|
2209
|
+
* goes through {@link requestedStorageObject}, which decodes it first.
|
|
2210
|
+
*/
|
|
2211
|
+
function storageObjectOfPath(decodedPath) {
|
|
2212
|
+
const parts = decodedPath.split("/");
|
|
2213
|
+
return {
|
|
2214
|
+
bucket: "default",
|
|
2215
|
+
key: canonicalStorageKey(parts.length > 1 && parts[0].toLowerCase() === "default" ? parts.slice(1).join("/") : decodedPath)
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* The object a request's wildcard path names, or {@link InvalidStorageKeyError}.
|
|
2220
|
+
*
|
|
2221
|
+
* The wildcard arrives as Hono leaves it — `decodeURI`d, with the reserved
|
|
2222
|
+
* characters (`/`, `:`, `%25` among them) still escaped — so one
|
|
2223
|
+
* `decodeURIComponent` yields the path the client encoded. A `%` that does not
|
|
2224
|
+
* begin an escape names no key, and is refused like any other key that cannot be
|
|
2225
|
+
* canonicalized rather than thrown as a `URIError`.
|
|
2226
|
+
*/
|
|
2227
|
+
function requestedStorageObject(wildcard) {
|
|
2228
|
+
let decoded;
|
|
2229
|
+
try {
|
|
2230
|
+
decoded = decodeURIComponent(wildcard);
|
|
2231
|
+
} catch {
|
|
2232
|
+
throw new InvalidStorageKeyError("Storage path is not valid percent-encoding: a '%' must begin an escape such as '%25' (a literal '%').");
|
|
2233
|
+
}
|
|
2234
|
+
return storageObjectOfPath(decoded);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* {@link requestedStorageObject}, or `null` when the path names no object.
|
|
2238
|
+
*
|
|
2239
|
+
* For the middleware, which must fail closed without an exception: a path that
|
|
2240
|
+
* names no object is neither public nor covered by any download token, and the
|
|
2241
|
+
* route answers it with a 400 of its own.
|
|
2242
|
+
*/
|
|
2243
|
+
function tryRequestedStorageObject(wildcard) {
|
|
2244
|
+
try {
|
|
2245
|
+
return requestedStorageObject(wildcard);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
if (err instanceof InvalidStorageKeyError) return null;
|
|
2248
|
+
throw err;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
//#endregion
|
|
1610
2252
|
//#region src/auth/middleware.ts
|
|
1611
2253
|
/**
|
|
1612
2254
|
* Answer a refusal with the canonical envelope, through the same formatter
|
|
@@ -1679,10 +2321,15 @@ function createRequireAuth(options) {
|
|
|
1679
2321
|
const payload = await verifyAccessToken(token);
|
|
1680
2322
|
if (!payload) return refuse(c, ApiError.unauthenticated("Invalid or expired token"));
|
|
1681
2323
|
if (resolveRoles || revocationRepo) try {
|
|
1682
|
-
|
|
2324
|
+
let judgedRoles;
|
|
2325
|
+
if (revocationRepo) {
|
|
2326
|
+
const verdict = await judgeAccessToken(revocationRepo, payload);
|
|
2327
|
+
if (!verdict.live) return refuse(c, verdict.refusal === "account-disabled" ? ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED") : ApiError.unauthorized(verdict.refusal === "account-deleted" ? "This account no longer exists" : "Session has been revoked", "SESSION_REVOKED"));
|
|
2328
|
+
judgedRoles = verdict.roles;
|
|
2329
|
+
}
|
|
1683
2330
|
c.set("user", resolveRoles ? {
|
|
1684
2331
|
...payload,
|
|
1685
|
-
roles: await resolveRoles(payload.uid)
|
|
2332
|
+
roles: judgedRoles ?? await resolveRoles(payload.uid)
|
|
1686
2333
|
} : payload);
|
|
1687
2334
|
return next();
|
|
1688
2335
|
} catch (error) {
|
|
@@ -1700,13 +2347,17 @@ function createRequireAuth(options) {
|
|
|
1700
2347
|
};
|
|
1701
2348
|
}
|
|
1702
2349
|
/**
|
|
1703
|
-
* Middleware that requires the user to
|
|
1704
|
-
* Must be used AFTER requireAuth or on a route where user is
|
|
2350
|
+
* Middleware that requires the user to hold the `admin` role, which holds
|
|
2351
|
+
* every scope. Must be used AFTER requireAuth or on a route where user is
|
|
2352
|
+
* guaranteed.
|
|
2353
|
+
*
|
|
2354
|
+
* Prefer `requireScope` from `./access` for anything a scope names: a scope
|
|
2355
|
+
* can be granted to a narrower role and to a key, and `admin` cannot.
|
|
1705
2356
|
*/
|
|
1706
2357
|
var requireAdmin = async (c, next) => {
|
|
1707
2358
|
const user = c.get("user");
|
|
1708
2359
|
if (!user) return refuse(c, ApiError.unauthorized("User not authenticated. requireAuth middleware is missing?"));
|
|
1709
|
-
if (!
|
|
2360
|
+
if (!hasAdminRole(typeof user === "object" && user !== null && "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [])) return refuse(c, ApiError.forbidden("Admin privileges required for this operation"));
|
|
1710
2361
|
return next();
|
|
1711
2362
|
};
|
|
1712
2363
|
/**
|
|
@@ -1754,12 +2405,14 @@ function createAuthMiddleware(options) {
|
|
|
1754
2405
|
const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
1755
2406
|
return async (c, next) => {
|
|
1756
2407
|
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
2408
|
+
let credential = "none";
|
|
1757
2409
|
if (validator) try {
|
|
1758
2410
|
const authResult = await validator(c);
|
|
1759
2411
|
if (authResult && typeof authResult === "object") {
|
|
1760
2412
|
const id = "uid" in authResult ? authResult.uid : void 0;
|
|
1761
2413
|
if (id) {
|
|
1762
2414
|
const roles = authResult.roles || [];
|
|
2415
|
+
credential = "session";
|
|
1763
2416
|
c.set("user", {
|
|
1764
2417
|
uid: id,
|
|
1765
2418
|
roles
|
|
@@ -1775,6 +2428,7 @@ function createAuthMiddleware(options) {
|
|
|
1775
2428
|
roles: ["anon"]
|
|
1776
2429
|
}));
|
|
1777
2430
|
} else if (authResult === true) {
|
|
2431
|
+
credential = "session";
|
|
1778
2432
|
c.set("user", {
|
|
1779
2433
|
uid: "default",
|
|
1780
2434
|
roles: []
|
|
@@ -1793,10 +2447,12 @@ function createAuthMiddleware(options) {
|
|
|
1793
2447
|
else {
|
|
1794
2448
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
1795
2449
|
if (token !== void 0) if (serviceKey && safeCompare(token, serviceKey)) {
|
|
1796
|
-
|
|
2450
|
+
const serviceUser = {
|
|
1797
2451
|
uid: "service",
|
|
1798
2452
|
roles: ["admin"]
|
|
1799
|
-
}
|
|
2453
|
+
};
|
|
2454
|
+
credential = "service-key";
|
|
2455
|
+
c.set("user", serviceUser);
|
|
1800
2456
|
try {
|
|
1801
2457
|
c.set("driver", await scopeDataDriver(driver, {
|
|
1802
2458
|
uid: "service",
|
|
@@ -1815,9 +2471,11 @@ function createAuthMiddleware(options) {
|
|
|
1815
2471
|
driver
|
|
1816
2472
|
});
|
|
1817
2473
|
if (result !== true) return result;
|
|
2474
|
+
credential = "api-key";
|
|
1818
2475
|
} else {
|
|
1819
2476
|
const payload = await extractUserFromToken(token);
|
|
1820
2477
|
if (payload) {
|
|
2478
|
+
credential = "session";
|
|
1821
2479
|
c.set("user", payload);
|
|
1822
2480
|
try {
|
|
1823
2481
|
const user = {
|
|
@@ -1849,6 +2507,11 @@ function createAuthMiddleware(options) {
|
|
|
1849
2507
|
} }, 500);
|
|
1850
2508
|
}
|
|
1851
2509
|
}
|
|
2510
|
+
const refused = await applyImpersonation(c, {
|
|
2511
|
+
credential,
|
|
2512
|
+
driver
|
|
2513
|
+
});
|
|
2514
|
+
if (refused) return refused;
|
|
1852
2515
|
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
1853
2516
|
return next();
|
|
1854
2517
|
};
|
|
@@ -1889,14 +2552,19 @@ var queryTokenAuth = async (c, next) => {
|
|
|
1889
2552
|
* requested object path is public, it sets a minimal "public" principal so the
|
|
1890
2553
|
* downstream `requireAuth` gate lets the read through. Private paths are left
|
|
1891
2554
|
* untouched, so they still require a valid token.
|
|
2555
|
+
*
|
|
2556
|
+
* Public means the object the route will serve is public, so the decision is
|
|
2557
|
+
* made on that object's canonical key, derived by the route's own function
|
|
2558
|
+
* (`requestedStorageObject`). It used to be made on the raw path, through a
|
|
2559
|
+
* check that strips a `scheme://`: `notes://public/secret.txt` read as
|
|
2560
|
+
* `public/secret.txt` here while the route served the private
|
|
2561
|
+
* `notes:/public/secret.txt`, and the authorize hook — not asked about this
|
|
2562
|
+
* principal — never saw it.
|
|
1892
2563
|
*/
|
|
1893
2564
|
var publicObjectAuth = async (c, next) => {
|
|
1894
2565
|
if (c.get("user")) return next();
|
|
1895
|
-
const
|
|
1896
|
-
|
|
1897
|
-
const idx = fullPath.indexOf(prefix);
|
|
1898
|
-
const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
|
|
1899
|
-
if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
|
|
2566
|
+
const requested = tryRequestedStorageObject(storageRequestWildcard(c));
|
|
2567
|
+
if (requested && isPublicStorageKey(requested.key)) c.set("user", {
|
|
1900
2568
|
uid: "public",
|
|
1901
2569
|
roles: ["public"]
|
|
1902
2570
|
});
|
|
@@ -1920,7 +2588,7 @@ var publicObjectAuth = async (c, next) => {
|
|
|
1920
2588
|
* Hono routes the request, so this comparison already runs on a resolved path.
|
|
1921
2589
|
* But that is a guarantee of the runtime rather than of this code, and it is
|
|
1922
2590
|
* one line to not depend on it. The same rule already guards the public-object
|
|
1923
|
-
* path — see `
|
|
2591
|
+
* path — see `isPublicStorageKey`.
|
|
1924
2592
|
*/
|
|
1925
2593
|
function isPathMatch(requested, allowed) {
|
|
1926
2594
|
if (requested.split("/").some((seg) => seg === "..")) return false;
|
|
@@ -1937,34 +2605,6 @@ var fileTokenAuth = async (c, next) => {
|
|
|
1937
2605
|
const isFileRoute = c.req.path.includes("/storage/file/");
|
|
1938
2606
|
const authHeader = c.req.header("authorization");
|
|
1939
2607
|
const queryToken = c.req.query("token");
|
|
1940
|
-
const extractWildcard = (ctx) => {
|
|
1941
|
-
const prefix = ctx.req.routePath.replace("/*", "");
|
|
1942
|
-
const fullPath = ctx.req.path;
|
|
1943
|
-
const idx = fullPath.indexOf(prefix);
|
|
1944
|
-
if (idx < 0) return "";
|
|
1945
|
-
return fullPath.substring(idx + prefix.length + 1);
|
|
1946
|
-
};
|
|
1947
|
-
/**
|
|
1948
|
-
* Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
|
|
1949
|
-
* path a token GRANTS is minted there and the path a request ASKS FOR is
|
|
1950
|
-
* derived here. `/metadata` canonicalizes before signing, so a raw
|
|
1951
|
-
* comparison here would 403 an otherwise-valid request for any key whose
|
|
1952
|
-
* URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
|
|
1953
|
-
*
|
|
1954
|
-
* A key that cannot be canonicalized yields null, which matches no grant —
|
|
1955
|
-
* the same fail-closed answer `isPathMatch` gives a `..` segment.
|
|
1956
|
-
*/
|
|
1957
|
-
const parseBucketPath = (filePath) => {
|
|
1958
|
-
const parts = filePath.split("/");
|
|
1959
|
-
if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
|
|
1960
|
-
bucket: "default",
|
|
1961
|
-
resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
|
|
1962
|
-
};
|
|
1963
|
-
return {
|
|
1964
|
-
bucket: "default",
|
|
1965
|
-
resolvedPath: tryCanonicalStorageKey(filePath)
|
|
1966
|
-
};
|
|
1967
|
-
};
|
|
1968
2608
|
/**
|
|
1969
2609
|
* Decide what a valid download token entitles this request to.
|
|
1970
2610
|
*
|
|
@@ -1979,11 +2619,11 @@ var fileTokenAuth = async (c, next) => {
|
|
|
1979
2619
|
*/
|
|
1980
2620
|
const evaluateGrant = (payload) => {
|
|
1981
2621
|
if (canonicalStorageId(c.req.query("storageId")) !== payload.storageId) return "deny-storage";
|
|
1982
|
-
const rawPath =
|
|
2622
|
+
const rawPath = storageRequestWildcard(c);
|
|
1983
2623
|
if (!rawPath) return "no-path";
|
|
1984
|
-
const
|
|
1985
|
-
if (
|
|
1986
|
-
return isPathMatch(`${bucket}/${
|
|
2624
|
+
const requested = tryRequestedStorageObject(rawPath);
|
|
2625
|
+
if (requested === null) return "deny-path";
|
|
2626
|
+
return isPathMatch(`${requested.bucket}/${requested.key}`, payload.path) ? "grant" : "deny-path";
|
|
1987
2627
|
};
|
|
1988
2628
|
/**
|
|
1989
2629
|
* Which half of the scope failed is named in the message. The holder of the
|
|
@@ -2043,8 +2683,13 @@ function createAdapterAuthMiddleware(options) {
|
|
|
2043
2683
|
store: apiKeyStore,
|
|
2044
2684
|
driver
|
|
2045
2685
|
});
|
|
2046
|
-
if (result
|
|
2047
|
-
|
|
2686
|
+
if (result !== true) return result;
|
|
2687
|
+
const refused = await applyImpersonation(c, {
|
|
2688
|
+
credential: "api-key",
|
|
2689
|
+
driver
|
|
2690
|
+
});
|
|
2691
|
+
if (refused) return refused;
|
|
2692
|
+
return next();
|
|
2048
2693
|
}
|
|
2049
2694
|
}
|
|
2050
2695
|
let authenticatedUser = null;
|
|
@@ -2083,6 +2728,12 @@ function createAdapterAuthMiddleware(options) {
|
|
|
2083
2728
|
return refuse(c, ApiError.internal("Server configuration error"));
|
|
2084
2729
|
}
|
|
2085
2730
|
}
|
|
2731
|
+
const refused = await applyImpersonation(c, {
|
|
2732
|
+
credential: !authenticatedUser ? "none" : authenticatedUser.uid === SERVICE_IDENTITY.uid ? "service-key" : "session",
|
|
2733
|
+
driver,
|
|
2734
|
+
resolveUser: adapter.resolveUser ? adapter.resolveUser.bind(adapter) : void 0
|
|
2735
|
+
});
|
|
2736
|
+
if (refused) return refused;
|
|
2086
2737
|
if (enforceAuth && !c.get("user")) return refuse(c, ApiError.unauthenticated("Unauthorized: Authentication required. This is the API-level gate, not a row rule — a collection granting public reads still needs a caller. Set AUTH_REQUIRE=false (or `auth.requireAuth: false`) to let RLS alone decide."));
|
|
2087
2738
|
return next();
|
|
2088
2739
|
};
|
|
@@ -2183,9 +2834,45 @@ async function verifyPassword(password, storedHash) {
|
|
|
2183
2834
|
* This is the single point where defaults are applied — all consumers
|
|
2184
2835
|
* call this once and use the resolved hooks throughout.
|
|
2185
2836
|
*/
|
|
2837
|
+
/**
|
|
2838
|
+
* What a hook's thrown error answers.
|
|
2839
|
+
*
|
|
2840
|
+
* The hooks that refuse (`beforeUserCreate`, `beforeLogin`,
|
|
2841
|
+
* `beforeUserDelete`, `beforeEmailChange`) are documented as "throw to reject", and a plain
|
|
2842
|
+
* `Error` is what people throw. It reached the error handler as a 500
|
|
2843
|
+
* "Internal Server Error", so a deployment that limits sign-ups to its own
|
|
2844
|
+
* domain answered an outsider with a server fault. A refusal is the caller's
|
|
2845
|
+
* answer: 400 `HOOK_REJECTED` with the hook's message — or the status an
|
|
2846
|
+
* error carries, when the hook chose one (`ApiError`, or any error with a 4xx
|
|
2847
|
+
* `status`/`statusCode`).
|
|
2848
|
+
*/
|
|
2849
|
+
function hookRefusal(error, hook) {
|
|
2850
|
+
if (error instanceof ApiError) return error;
|
|
2851
|
+
const carried = typeof error === "object" && error !== null ? "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : "status" in error && typeof error.status === "number" ? error.status : void 0 : void 0;
|
|
2852
|
+
const status = carried !== void 0 && carried >= 400 && carried < 500 ? carried : 400;
|
|
2853
|
+
const ownCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && /^[A-Z][A-Z0-9_]+$/.test(error.code) ? error.code : void 0;
|
|
2854
|
+
const message = error instanceof Error ? error.message : typeof error === "string" ? error : `${hook} refused the request`;
|
|
2855
|
+
if (status === 400 && !ownCode) return ApiError.badRequest(message, "HOOK_REJECTED", { hook });
|
|
2856
|
+
return new ApiError(status, ownCode ?? "HOOK_REJECTED", message, { hook });
|
|
2857
|
+
}
|
|
2858
|
+
/** A refusing hook whose throw answers as {@link hookRefusal} says. */
|
|
2859
|
+
function refusable(hook, fn) {
|
|
2860
|
+
if (!fn) return void 0;
|
|
2861
|
+
return async (...args) => {
|
|
2862
|
+
try {
|
|
2863
|
+
return await fn(...args);
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
throw hookRefusal(error, hook);
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2186
2869
|
function resolveAuthHooks(hooks) {
|
|
2187
2870
|
return {
|
|
2188
2871
|
...hooks,
|
|
2872
|
+
beforeUserCreate: refusable("beforeUserCreate", hooks?.beforeUserCreate?.bind(hooks)),
|
|
2873
|
+
beforeLogin: refusable("beforeLogin", hooks?.beforeLogin?.bind(hooks)),
|
|
2874
|
+
beforeUserDelete: refusable("beforeUserDelete", hooks?.beforeUserDelete?.bind(hooks)),
|
|
2875
|
+
beforeEmailChange: refusable("beforeEmailChange", hooks?.beforeEmailChange?.bind(hooks)),
|
|
2189
2876
|
hashPassword: hooks?.hashPassword ?? hashPassword,
|
|
2190
2877
|
verifyPassword: hooks?.verifyPassword ?? verifyPassword,
|
|
2191
2878
|
validatePasswordStrength: hooks?.validatePasswordStrength ?? validatePasswordStrength
|
|
@@ -2643,6 +3330,17 @@ function socketAddress(c) {
|
|
|
2643
3330
|
* resort only, and it is a single shared bucket by design: better that
|
|
2644
3331
|
* anonymous callers throttle each other than that any of them throttles nobody.
|
|
2645
3332
|
*/
|
|
3333
|
+
/**
|
|
3334
|
+
* Where a request comes from, as the rate limiters judge it: the socket's own
|
|
3335
|
+
* address, or — only behind as many proxies as `TRUSTED_PROXY_HOPS` declares —
|
|
3336
|
+
* the address they report. What a session row records as its IP, so the
|
|
3337
|
+
* sessions list shows the same address the limiter counted, rather than the
|
|
3338
|
+
* raw `X-Forwarded-For` (whose leftmost entry the caller chooses) or
|
|
3339
|
+
* `"unknown"` for every direct connection.
|
|
3340
|
+
*/
|
|
3341
|
+
function requestClientAddress(c) {
|
|
3342
|
+
return defaultKeyGenerator(c);
|
|
3343
|
+
}
|
|
2646
3344
|
function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
2647
3345
|
return clientAddress((name) => c.req.header(name), () => socketAddress(c), trustedProxyHops);
|
|
2648
3346
|
}
|
|
@@ -2827,13 +3525,24 @@ function createDataRateLimiter(config = {}) {
|
|
|
2827
3525
|
if (user?.uid) return isAnonymousUid(user.uid) ? void 0 : user.uid;
|
|
2828
3526
|
if (!isJwtConfigured()) return void 0;
|
|
2829
3527
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
2830
|
-
if (token === void 0)
|
|
3528
|
+
if (token === void 0) {
|
|
3529
|
+
const queryToken = c.req.query("token");
|
|
3530
|
+
if (!queryToken) return void 0;
|
|
3531
|
+
const grant = await verifyDownloadToken(queryToken);
|
|
3532
|
+
return grant?.rl ? `file:${grant.rl}` : void 0;
|
|
3533
|
+
}
|
|
2831
3534
|
const payload = await verifyAccessToken(token);
|
|
2832
3535
|
if (!payload?.uid || isAnonymousUid(payload.uid)) return void 0;
|
|
2833
3536
|
return payload.uid;
|
|
2834
3537
|
};
|
|
3538
|
+
const isServiceKey = (c) => {
|
|
3539
|
+
if (!config.serviceKey) return false;
|
|
3540
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
3541
|
+
return token !== void 0 && safeCompare(token, config.serviceKey);
|
|
3542
|
+
};
|
|
2835
3543
|
const bucketOf = async (c) => {
|
|
2836
3544
|
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
3545
|
+
if (isServiceKey(c)) return null;
|
|
2837
3546
|
const apiKey = c.get("apiKey");
|
|
2838
3547
|
return bucketFor({
|
|
2839
3548
|
apiKey,
|
|
@@ -2867,6 +3576,7 @@ function createDataRateLimitCheck(config = {}) {
|
|
|
2867
3576
|
return async (caller) => {
|
|
2868
3577
|
if (caller.uid === SERVICE_IDENTITY.uid) return null;
|
|
2869
3578
|
const bucket = bucketFor({
|
|
3579
|
+
apiKey: caller.apiKey,
|
|
2870
3580
|
uid: caller.uid !== void 0 && !isAnonymousUid(caller.uid) ? caller.uid : void 0,
|
|
2871
3581
|
address: () => clientAddress(caller.header, () => caller.socketAddress, trustedProxyHops)
|
|
2872
3582
|
});
|
|
@@ -3265,7 +3975,8 @@ function keyCandidates() {
|
|
|
3265
3975
|
add(process.env.MFA_ENCRYPTION_KEY, "MFA_ENCRYPTION_KEY");
|
|
3266
3976
|
for (const previous of (process.env.MFA_ENCRYPTION_KEY_PREVIOUS ?? "").split(",")) add(previous, "MFA_ENCRYPTION_KEY_PREVIOUS");
|
|
3267
3977
|
add(process.env.JWT_SECRET, "JWT_SECRET");
|
|
3268
|
-
|
|
3978
|
+
add(configuredJwtSecret(), "auth.jwtSecret");
|
|
3979
|
+
if (candidates.length === 0) throw new Error("Cannot encrypt or decrypt TOTP secrets: none of MFA_ENCRYPTION_KEY, MFA_ENCRYPTION_KEY_PREVIOUS, JWT_SECRET or auth.jwtSecret is configured.");
|
|
3269
3980
|
if (!process.env.MFA_ENCRYPTION_KEY && !warnedAboutFallback) {
|
|
3270
3981
|
warnedAboutFallback = true;
|
|
3271
3982
|
logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production; see the rotation steps in mfa-crypto.ts before you do it on a live deployment.");
|
|
@@ -3369,6 +4080,13 @@ function clearRefreshCookie(c, config) {
|
|
|
3369
4080
|
}
|
|
3370
4081
|
/**
|
|
3371
4082
|
* Read the refresh token from the request — cookie first, then body fallback.
|
|
4083
|
+
*
|
|
4084
|
+
* An empty string is no token, wherever it comes from. In cookie mode every
|
|
4085
|
+
* response this server sends carries `refreshToken: ""` (see
|
|
4086
|
+
* `redactRefreshToken`), and a client that echoes it back — `@rebasepro/client`
|
|
4087
|
+
* did, on every refresh from a live tab — is presenting nothing. Taking the
|
|
4088
|
+
* body's `""` as the token, or refusing it at the schema, kept the cookie
|
|
4089
|
+
* beside it from ever being read.
|
|
3372
4090
|
*/
|
|
3373
4091
|
function readRefreshToken(c, body, config) {
|
|
3374
4092
|
if (config) {
|
|
@@ -3378,10 +4096,13 @@ function readRefreshToken(c, body, config) {
|
|
|
3378
4096
|
const cookies = cookieHeader.split(";");
|
|
3379
4097
|
for (const part of cookies) {
|
|
3380
4098
|
const trimmed = part.trim();
|
|
3381
|
-
if (trimmed.startsWith(prefix))
|
|
4099
|
+
if (trimmed.startsWith(prefix)) {
|
|
4100
|
+
const fromCookie = decodeURIComponent(trimmed.slice(prefix.length));
|
|
4101
|
+
if (fromCookie) return fromCookie;
|
|
4102
|
+
}
|
|
3382
4103
|
}
|
|
3383
4104
|
}
|
|
3384
|
-
return body.refreshToken;
|
|
4105
|
+
return body.refreshToken || void 0;
|
|
3385
4106
|
}
|
|
3386
4107
|
/**
|
|
3387
4108
|
* Redact the refresh token from the response body when cookie mode is active.
|
|
@@ -3507,7 +4228,7 @@ function mountMfaRoutes(opts) {
|
|
|
3507
4228
|
async function resolveLiveStepUpPrincipal(c) {
|
|
3508
4229
|
const principal = await resolveStepUpPrincipal(c);
|
|
3509
4230
|
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
3510
|
-
if (await
|
|
4231
|
+
if (!(await judgeAccessToken(authRepo, principal)).live) throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
3511
4232
|
return principal;
|
|
3512
4233
|
}
|
|
3513
4234
|
/**
|
|
@@ -3560,9 +4281,11 @@ function mountMfaRoutes(opts) {
|
|
|
3560
4281
|
const { secret, uri } = generateTotpSecret(issuer, user.email);
|
|
3561
4282
|
const encryptedSecret = encryptTotpSecret(secret);
|
|
3562
4283
|
const factor = await authRepo.createMfaFactor(user.id, "totp", encryptedSecret, friendlyName);
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
4284
|
+
let codes = null;
|
|
4285
|
+
if (!await authRepo.hasVerifiedMfaFactors(user.id)) {
|
|
4286
|
+
codes = generateRecoveryCodes(10);
|
|
4287
|
+
await authRepo.createRecoveryCodes(user.id, codes.map(hashRecoveryCode));
|
|
4288
|
+
}
|
|
3566
4289
|
return c.json({
|
|
3567
4290
|
factor: {
|
|
3568
4291
|
id: factor.id,
|
|
@@ -3578,6 +4301,24 @@ function mountMfaRoutes(opts) {
|
|
|
3578
4301
|
}, 201);
|
|
3579
4302
|
});
|
|
3580
4303
|
/**
|
|
4304
|
+
* POST /auth/mfa/recovery-codes
|
|
4305
|
+
* Replace the account's recovery codes with ten new ones, shown once.
|
|
4306
|
+
*
|
|
4307
|
+
* The deliberate way to get new codes — after using several, or after
|
|
4308
|
+
* losing the printout. Needs `aal2` once a factor is verified, as every
|
|
4309
|
+
* factor change does: whoever holds only the password must not be able to
|
|
4310
|
+
* swap the codes for ones they know.
|
|
4311
|
+
*/
|
|
4312
|
+
router.post("/mfa/recovery-codes", strictAuthLimiter, requireLiveSession, async (c) => {
|
|
4313
|
+
const userCtx = c.get("user");
|
|
4314
|
+
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4315
|
+
if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) throw ApiError.badRequest("This account has no verified second factor, so recovery codes would recover nothing.", "MFA_NOT_ENROLLED");
|
|
4316
|
+
await requireStepUpForFactorChange(userCtx);
|
|
4317
|
+
const codes = generateRecoveryCodes(10);
|
|
4318
|
+
await authRepo.createRecoveryCodes(userCtx.uid, codes.map(hashRecoveryCode));
|
|
4319
|
+
return c.json({ recoveryCodes: codes });
|
|
4320
|
+
});
|
|
4321
|
+
/**
|
|
3581
4322
|
* POST /auth/mfa/verify
|
|
3582
4323
|
* Verify TOTP code to complete MFA enrollment
|
|
3583
4324
|
*/
|
|
@@ -3613,7 +4354,7 @@ function mountMfaRoutes(opts) {
|
|
|
3613
4354
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
3614
4355
|
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
3615
4356
|
if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
|
|
3616
|
-
const ipAddress = c
|
|
4357
|
+
const ipAddress = requestClientAddress(c);
|
|
3617
4358
|
const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
|
|
3618
4359
|
return c.json({
|
|
3619
4360
|
challengeId: challenge.id,
|
|
@@ -3662,10 +4403,17 @@ function mountMfaRoutes(opts) {
|
|
|
3662
4403
|
throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
3663
4404
|
}
|
|
3664
4405
|
await authRepo.verifyMfaChallenge(challengeId);
|
|
3665
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c
|
|
4406
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", requestClientAddress(c), {
|
|
3666
4407
|
skipMfaGate: true,
|
|
3667
|
-
aal: "aal2"
|
|
4408
|
+
aal: "aal2",
|
|
4409
|
+
method: "mfa"
|
|
3668
4410
|
});
|
|
4411
|
+
if (ops.onAuthenticated) {
|
|
4412
|
+
const account = await authRepo.getUserById(principal.uid);
|
|
4413
|
+
if (account) ops.onAuthenticated(account, "mfa").catch((err) => {
|
|
4414
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4415
|
+
});
|
|
4416
|
+
}
|
|
3669
4417
|
if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
|
|
3670
4418
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
3671
4419
|
});
|
|
@@ -3841,9 +4589,233 @@ function isAnonymousAuthOpen(policy) {
|
|
|
3841
4589
|
return !!policy.allowAnonymous;
|
|
3842
4590
|
}
|
|
3843
4591
|
//#endregion
|
|
4592
|
+
//#region src/auth/address-ownership.ts
|
|
4593
|
+
/**
|
|
4594
|
+
* What happens when the owner of an address proves it for the first time.
|
|
4595
|
+
*
|
|
4596
|
+
* Nothing verifies the address an account is made with: `POST /auth/register`
|
|
4597
|
+
* takes whatever it is given, and so does a sign-in through a provider that
|
|
4598
|
+
* does not vouch for the address (Spotify and Facebook never do; Discord and
|
|
4599
|
+
* multi-tenant Microsoft sometimes do not). So anyone can make an account for
|
|
4600
|
+
* someone else's address and leave a way in on it — a password, or the
|
|
4601
|
+
* provider identity they signed up with — then wait for the owner.
|
|
4602
|
+
*
|
|
4603
|
+
* The owner turns up by proving the address: a magic link, an email code or a
|
|
4604
|
+
* password reset, each read out of their inbox. That proof used to mark the
|
|
4605
|
+
* account verified and leave the attacker's way in where it was, so the owner
|
|
4606
|
+
* and the attacker shared the account from then on. Now the proof also removes
|
|
4607
|
+
* everything on the account that nobody proved: the password, every identity
|
|
4608
|
+
* whose provider did not vouch for this address, and every session.
|
|
4609
|
+
*
|
|
4610
|
+
* @module
|
|
4611
|
+
*/
|
|
4612
|
+
/**
|
|
4613
|
+
* The profile stored with a linked OAuth identity.
|
|
4614
|
+
*
|
|
4615
|
+
* `emailVerified` is recorded so that a later proof of the address can tell an
|
|
4616
|
+
* identity whose provider vouched for it from one whose provider did not.
|
|
4617
|
+
*/
|
|
4618
|
+
function identityProfileData(profile) {
|
|
4619
|
+
return {
|
|
4620
|
+
email: profile.email,
|
|
4621
|
+
emailVerified: profile.emailVerified === true
|
|
4622
|
+
};
|
|
4623
|
+
}
|
|
4624
|
+
/**
|
|
4625
|
+
* Did this identity's provider vouch for `email` when it was linked?
|
|
4626
|
+
*
|
|
4627
|
+
* Only whoever controls the inbox can hold a provider account that vouches for
|
|
4628
|
+
* the address, so such an identity is the owner's. An identity linked before
|
|
4629
|
+
* the flag was recorded reads as not vouching: it is detached, and if its
|
|
4630
|
+
* provider does vouch, the next sign-in with it links it again.
|
|
4631
|
+
*/
|
|
4632
|
+
function identityVouchesForAddress(identity, email) {
|
|
4633
|
+
const profile = identity.profileData;
|
|
4634
|
+
return profile?.emailVerified === true && typeof profile.email === "string" && normalizeEmail(profile.email) === normalizeEmail(email);
|
|
4635
|
+
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Settle an account whose address its owner has just proven, and mark it
|
|
4638
|
+
* verified.
|
|
4639
|
+
*
|
|
4640
|
+
* Call it only for an account that is not yet verified, from a route that has
|
|
4641
|
+
* just consumed something mailed to the address. It detaches every identity
|
|
4642
|
+
* whose provider did not vouch for the address, sets the password to
|
|
4643
|
+
* `passwordHash` — the one the owner just chose on a reset, or `null` to remove
|
|
4644
|
+
* the one somebody else may have registered with — and ends every session, in
|
|
4645
|
+
* that order and before the account is marked verified.
|
|
4646
|
+
*
|
|
4647
|
+
* A repository that cannot detach an identity makes this refuse with 409 when
|
|
4648
|
+
* there is one to detach. Marking the account verified with the identity still
|
|
4649
|
+
* on it is the takeover itself, and leaving the account unverified while
|
|
4650
|
+
* signing the owner in would share the account all the same.
|
|
4651
|
+
*/
|
|
4652
|
+
async function confirmAddressOwnership(authRepo, user, passwordHash, options) {
|
|
4653
|
+
if (options?.everythingProven) {
|
|
4654
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
4655
|
+
return {
|
|
4656
|
+
removedPassword: false,
|
|
4657
|
+
removedProviders: []
|
|
4658
|
+
};
|
|
4659
|
+
}
|
|
4660
|
+
const unproven = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email));
|
|
4661
|
+
if (unproven.length > 0) {
|
|
4662
|
+
if (typeof authRepo.unlinkUserIdentity !== "function") {
|
|
4663
|
+
logger.error("[Security Audit] Proof of address refused: the account has sign-in methods nobody proved, and this auth repository cannot remove them", {
|
|
4664
|
+
eventType: "auth.address_proof.refused",
|
|
4665
|
+
uid: user.id,
|
|
4666
|
+
providers: unproven.map((identity) => identity.provider)
|
|
4667
|
+
});
|
|
4668
|
+
throw ApiError.conflict("This account has sign-in methods that were added before its email address was verified, and this backend cannot remove them. Ask an administrator to review the account.", "UNVERIFIED_IDENTITIES");
|
|
4669
|
+
}
|
|
4670
|
+
for (const identity of unproven) await authRepo.unlinkUserIdentity(user.id, identity.provider, identity.providerId);
|
|
4671
|
+
}
|
|
4672
|
+
await replaceUserPassword(authRepo, user.id, passwordHash);
|
|
4673
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
4674
|
+
const outcome = {
|
|
4675
|
+
removedPassword: Boolean(user.passwordHash) && passwordHash === null,
|
|
4676
|
+
removedProviders: unproven.map((identity) => identity.provider)
|
|
4677
|
+
};
|
|
4678
|
+
if (unproven.length > 0 || user.passwordHash) logger.info("[Security Audit] First proof of address removed sign-in methods nobody proved", {
|
|
4679
|
+
eventType: "auth.address_proof.cleared",
|
|
4680
|
+
uid: user.id,
|
|
4681
|
+
removedProviders: outcome.removedProviders,
|
|
4682
|
+
replacedPassword: Boolean(user.passwordHash) && passwordHash !== user.passwordHash
|
|
4683
|
+
});
|
|
4684
|
+
return outcome;
|
|
4685
|
+
}
|
|
4686
|
+
//#endregion
|
|
4687
|
+
//#region src/auth/deliverable-address.ts
|
|
4688
|
+
/**
|
|
4689
|
+
* Addresses no mail can reach: the synthetic ones a guest and an X (Twitter)
|
|
4690
|
+
* account are given, because `email` is NOT NULL. Nothing is mailed to them,
|
|
4691
|
+
* and no account may move onto one.
|
|
4692
|
+
*/
|
|
4693
|
+
function isDeliverableAddress(email) {
|
|
4694
|
+
const domain = email.slice(email.lastIndexOf("@") + 1).toLowerCase();
|
|
4695
|
+
return domain !== "anonymous.local" && domain !== "twitter.placeholder.rebase";
|
|
4696
|
+
}
|
|
4697
|
+
//#endregion
|
|
4698
|
+
//#region src/auth/email-change-routes.ts
|
|
4699
|
+
/** How long an address-change link stays usable. */
|
|
4700
|
+
var EMAIL_CHANGE_TTL_MS = 1440 * 60 * 1e3;
|
|
4701
|
+
function mountEmailChangeRoutes(opts) {
|
|
4702
|
+
const { router, config, ops, parseBody, requireLiveSession } = opts;
|
|
4703
|
+
const authRepo = config.authRepo;
|
|
4704
|
+
const { emailService, emailConfig } = config;
|
|
4705
|
+
const changeSchema = z.object({ newEmail: z.string().email("Invalid email address").max(255) });
|
|
4706
|
+
const confirmSchema = z.object({ token: z.string().min(1, "Token is required") });
|
|
4707
|
+
/** The repository's address-change methods, or a 501 saying it has none. */
|
|
4708
|
+
function emailChangeStore() {
|
|
4709
|
+
const { setPendingEmailChange, findPendingEmailChange, applyPendingEmailChange } = authRepo;
|
|
4710
|
+
if (!setPendingEmailChange || !findPendingEmailChange || !applyPendingEmailChange) throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot change an account's email address.");
|
|
4711
|
+
return {
|
|
4712
|
+
setPendingEmailChange: setPendingEmailChange.bind(authRepo),
|
|
4713
|
+
findPendingEmailChange: findPendingEmailChange.bind(authRepo),
|
|
4714
|
+
applyPendingEmailChange: applyPendingEmailChange.bind(authRepo)
|
|
4715
|
+
};
|
|
4716
|
+
}
|
|
4717
|
+
/**
|
|
4718
|
+
* POST /auth/change-email { newEmail }
|
|
4719
|
+
*
|
|
4720
|
+
* Two limiters, as on `/send-verification`: per address of the caller's
|
|
4721
|
+
* machine, and per account, which is what bounds the mail one account can
|
|
4722
|
+
* have sent to addresses of its choosing.
|
|
4723
|
+
*/
|
|
4724
|
+
router.post("/change-email", strictAuthLimiter, requireLiveSession, verificationEmailLimiter, async (c) => {
|
|
4725
|
+
const userCtx = c.get("user");
|
|
4726
|
+
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4727
|
+
const { newEmail } = parseBody(changeSchema, await c.req.json());
|
|
4728
|
+
const store = emailChangeStore();
|
|
4729
|
+
if (!emailService?.isConfigured()) throw ApiError.serviceUnavailable("Email service not configured. The confirmation link cannot be sent.", "EMAIL_NOT_CONFIGURED");
|
|
4730
|
+
const user = await authRepo.getUserById(userCtx.uid);
|
|
4731
|
+
if (!user) throw ApiError.notFound("User not found");
|
|
4732
|
+
if (user.isAnonymous) throw ApiError.forbidden("A guest has no address to change. Make it an account first (POST /auth/anonymous/link).", "ANONYMOUS_USER");
|
|
4733
|
+
if (userCtx.aal !== "aal2" && await authRepo.hasVerifiedMfaFactors(user.id)) throw ApiError.forbidden("This account has a second factor. Sign in with it before changing the email address.", "AAL2_REQUIRED");
|
|
4734
|
+
const email = normalizeEmail(newEmail);
|
|
4735
|
+
if (email === normalizeEmail(user.email)) throw ApiError.badRequest("That is already this account's email address.", "EMAIL_UNCHANGED");
|
|
4736
|
+
if (!isDeliverableAddress(email)) throw ApiError.conflict("No mail reaches that address.", "UNDELIVERABLE_ADDRESS");
|
|
4737
|
+
if (await authRepo.getUserByEmail(email)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
4738
|
+
if (ops.beforeEmailChange) await ops.beforeEmailChange(user, email);
|
|
4739
|
+
const token = generateSecureToken();
|
|
4740
|
+
await store.setPendingEmailChange(user.id, {
|
|
4741
|
+
email,
|
|
4742
|
+
tokenHash: hashToken(token)
|
|
4743
|
+
});
|
|
4744
|
+
const confirmUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/confirm-email-change?token=${token}`;
|
|
4745
|
+
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4746
|
+
const recipient = {
|
|
4747
|
+
email: user.email,
|
|
4748
|
+
displayName: user.displayName
|
|
4749
|
+
};
|
|
4750
|
+
const confirmation = emailConfig?.templates?.emailChange ? emailConfig.templates.emailChange(confirmUrl, recipient, email) : getEmailChangeTemplate(confirmUrl, recipient, email, appName, logoUrl);
|
|
4751
|
+
await emailService.send({
|
|
4752
|
+
to: email,
|
|
4753
|
+
subject: confirmation.subject,
|
|
4754
|
+
html: confirmation.html,
|
|
4755
|
+
text: confirmation.text
|
|
4756
|
+
});
|
|
4757
|
+
if (isDeliverableAddress(user.email)) {
|
|
4758
|
+
const notice = emailConfig?.templates?.emailChangeNotice ? emailConfig.templates.emailChangeNotice(recipient, email) : getEmailChangeNoticeTemplate(recipient, email, appName, logoUrl);
|
|
4759
|
+
emailService.send({
|
|
4760
|
+
to: user.email,
|
|
4761
|
+
subject: notice.subject,
|
|
4762
|
+
html: notice.html,
|
|
4763
|
+
text: notice.text
|
|
4764
|
+
}).catch((err) => {
|
|
4765
|
+
logger.error("Failed to send the email change notice", { error: err instanceof Error ? err.message : err });
|
|
4766
|
+
});
|
|
4767
|
+
}
|
|
4768
|
+
logger.info("[Security Audit] Email change requested", {
|
|
4769
|
+
eventType: "auth.email_change.requested",
|
|
4770
|
+
uid: user.id
|
|
4771
|
+
});
|
|
4772
|
+
return c.json({
|
|
4773
|
+
success: true,
|
|
4774
|
+
pendingEmail: email,
|
|
4775
|
+
expiresAt: new Date(Date.now() + EMAIL_CHANGE_TTL_MS).toISOString()
|
|
4776
|
+
});
|
|
4777
|
+
});
|
|
4778
|
+
/**
|
|
4779
|
+
* POST /auth/confirm-email-change { token }
|
|
4780
|
+
*
|
|
4781
|
+
* No session: the link is often opened on another device, and it proves
|
|
4782
|
+
* the new inbox, which is all this needs.
|
|
4783
|
+
*/
|
|
4784
|
+
router.post("/confirm-email-change", strictAuthLimiter, async (c) => {
|
|
4785
|
+
const { token } = parseBody(confirmSchema, await c.req.json());
|
|
4786
|
+
const store = emailChangeStore();
|
|
4787
|
+
const tokenHash = hashToken(token);
|
|
4788
|
+
const invalid = () => ApiError.badRequest("Invalid or expired email change link", "INVALID_TOKEN");
|
|
4789
|
+
const found = await store.findPendingEmailChange(tokenHash);
|
|
4790
|
+
if (!found || Date.now() - found.change.sentAt.getTime() > 864e5) throw invalid();
|
|
4791
|
+
const { user } = found;
|
|
4792
|
+
const previousEmail = user.email;
|
|
4793
|
+
const vouchingForOld = (await authRepo.getUserIdentities(user.id)).filter((identity) => identityVouchesForAddress(identity, previousEmail));
|
|
4794
|
+
if (vouchingForOld.length > 0 && typeof authRepo.unlinkUserIdentity !== "function") throw ApiError.conflict("This account signs in through a provider that vouched for its current address, and this backend cannot detach it. Ask an administrator to change the address.", "UNVERIFIED_IDENTITIES");
|
|
4795
|
+
const updated = await store.applyPendingEmailChange(user.id, tokenHash);
|
|
4796
|
+
if (!updated) throw invalid();
|
|
4797
|
+
for (const identity of vouchingForOld) await authRepo.unlinkUserIdentity?.(user.id, identity.provider, identity.providerId);
|
|
4798
|
+
await authRepo.deleteAllPasswordResetTokensForUser(user.id);
|
|
4799
|
+
logger.info("[Security Audit] Email address changed", {
|
|
4800
|
+
eventType: "auth.email_change.confirmed",
|
|
4801
|
+
uid: user.id,
|
|
4802
|
+
detachedProviders: vouchingForOld.map((identity) => identity.provider)
|
|
4803
|
+
});
|
|
4804
|
+
return c.json({
|
|
4805
|
+
success: true,
|
|
4806
|
+
user: {
|
|
4807
|
+
uid: updated.id,
|
|
4808
|
+
email: updated.email,
|
|
4809
|
+
emailVerified: true
|
|
4810
|
+
},
|
|
4811
|
+
removedProviders: vouchingForOld.map((identity) => identity.provider)
|
|
4812
|
+
});
|
|
4813
|
+
});
|
|
4814
|
+
}
|
|
4815
|
+
//#endregion
|
|
3844
4816
|
//#region src/auth/session-routes.ts
|
|
3845
4817
|
function mountSessionRoutes(opts) {
|
|
3846
|
-
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession, registerCaptcha } = opts;
|
|
4818
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession, registerCaptcha, sendVerificationMail } = opts;
|
|
3847
4819
|
const authRepo = config.authRepo;
|
|
3848
4820
|
/**
|
|
3849
4821
|
* Refuse the anonymous routes unless the deployment opted in.
|
|
@@ -3861,6 +4833,28 @@ function mountSessionRoutes(opts) {
|
|
|
3861
4833
|
})) return;
|
|
3862
4834
|
throw ApiError.forbidden(config.disableSelfRegistration ? "Anonymous sign-in is disabled: disableSelfRegistration blocks it." : "Anonymous sign-in is disabled. Set `auth.allowAnonymous: true` to enable it.", "ANONYMOUS_AUTH_DISABLED");
|
|
3863
4835
|
}
|
|
4836
|
+
/**
|
|
4837
|
+
* How the caller's session was signed in, as `providerId` reports it.
|
|
4838
|
+
*
|
|
4839
|
+
* Read off the session's refresh tokens, which carry it from sign-in
|
|
4840
|
+
* through every rotation, found by the access token's `sid`. A token from
|
|
4841
|
+
* before `sid`, or a session whose rows say nothing, reads `"password"` —
|
|
4842
|
+
* what `/me` always answered.
|
|
4843
|
+
*/
|
|
4844
|
+
async function sessionMethod(userCtx) {
|
|
4845
|
+
if (!userCtx.sid) return "password";
|
|
4846
|
+
return (await authRepo.listRefreshTokensForUser(userCtx.uid).catch(() => [])).find((token) => (token.sessionId ?? token.id) === userCtx.sid && token.method)?.method ?? "password";
|
|
4847
|
+
}
|
|
4848
|
+
/**
|
|
4849
|
+
* The address the account is moving to, while its link is still live —
|
|
4850
|
+
* so a settings screen opened later still says a change is waiting.
|
|
4851
|
+
* `null` otherwise, and on a repository that cannot change addresses.
|
|
4852
|
+
*/
|
|
4853
|
+
async function pendingEmail(uid) {
|
|
4854
|
+
const change = await authRepo.getPendingEmailChange?.(uid).catch(() => null);
|
|
4855
|
+
if (!change || Date.now() - change.sentAt.getTime() > 864e5) return null;
|
|
4856
|
+
return change.email;
|
|
4857
|
+
}
|
|
3864
4858
|
const logoutSchema = z.object({ refreshToken: z.string().optional() });
|
|
3865
4859
|
const linkSchema = z.object({
|
|
3866
4860
|
email: z.string().email("Invalid email address").max(255),
|
|
@@ -3877,18 +4871,25 @@ function mountSessionRoutes(opts) {
|
|
|
3877
4871
|
router.post("/logout", async (c) => {
|
|
3878
4872
|
const body = await c.req.json().catch(() => ({}));
|
|
3879
4873
|
const refreshToken = readRefreshToken(c, parseBody(logoutSchema, body), config.cookieAuth);
|
|
4874
|
+
let signedOutUid;
|
|
3880
4875
|
if (refreshToken) {
|
|
3881
4876
|
const tokenHash = await hashRefreshToken(refreshToken);
|
|
3882
|
-
const
|
|
4877
|
+
const stored = await authRepo.findRefreshTokenByHash(tokenHash).catch(() => null);
|
|
4878
|
+
signedOutUid = stored?.uid;
|
|
4879
|
+
const sessionId = stored?.sessionId;
|
|
3883
4880
|
if (sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(sessionId);
|
|
3884
4881
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
3885
4882
|
}
|
|
3886
4883
|
clearRefreshCookie(c, config.cookieAuth);
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
4884
|
+
if (ops.afterLogout) {
|
|
4885
|
+
if (!signedOutUid) {
|
|
4886
|
+
const accessToken = extractBearerToken(c.req.header("authorization"));
|
|
4887
|
+
if (accessToken !== void 0) {
|
|
4888
|
+
const { verifyAccessToken } = await import("./jwt-R6bSPMjk.js").then((n) => n._);
|
|
4889
|
+
signedOutUid = (await verifyAccessToken(accessToken))?.uid;
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
if (signedOutUid) ops.afterLogout(signedOutUid).catch((err) => {
|
|
3892
4893
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
3893
4894
|
});
|
|
3894
4895
|
}
|
|
@@ -3901,7 +4902,7 @@ function mountSessionRoutes(opts) {
|
|
|
3901
4902
|
router.get("/sessions", requireLiveSession, async (c) => {
|
|
3902
4903
|
const userCtx = c.get("user");
|
|
3903
4904
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
3904
|
-
const currentRefreshToken = c.req.header("x-refresh-token");
|
|
4905
|
+
const currentRefreshToken = userCtx.sid ? void 0 : c.req.header("x-refresh-token");
|
|
3905
4906
|
const currentTokenHash = currentRefreshToken ? await hashRefreshToken(currentRefreshToken) : null;
|
|
3906
4907
|
const tokens = await authRepo.listRefreshTokensForUser(userCtx.uid);
|
|
3907
4908
|
const bySession = /* @__PURE__ */ new Map();
|
|
@@ -3916,7 +4917,7 @@ function mountSessionRoutes(opts) {
|
|
|
3916
4917
|
userAgent: s.userAgent,
|
|
3917
4918
|
ipAddress: s.ipAddress,
|
|
3918
4919
|
createdAt: s.sessionStartedAt ?? s.createdAt,
|
|
3919
|
-
isCurrentSession: currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
|
|
4920
|
+
isCurrentSession: userCtx.sid ? (s.sessionId ?? s.id) === userCtx.sid : currentTokenHash ? tokens.some((t) => t.tokenHash === currentTokenHash && (t.sessionId ?? t.id) === (s.sessionId ?? s.id)) : false
|
|
3920
4921
|
}));
|
|
3921
4922
|
return c.json({ sessions: mappedSessions });
|
|
3922
4923
|
});
|
|
@@ -3964,10 +4965,11 @@ function mountSessionRoutes(opts) {
|
|
|
3964
4965
|
email: result.user.email,
|
|
3965
4966
|
displayName: result.user.displayName,
|
|
3966
4967
|
photoURL: result.user.photoUrl,
|
|
3967
|
-
providerId:
|
|
4968
|
+
providerId: await sessionMethod(userCtx),
|
|
3968
4969
|
isAnonymous: result.user.isAnonymous ?? false,
|
|
3969
4970
|
emailVerified: result.user.emailVerified,
|
|
3970
|
-
|
|
4971
|
+
pendingEmail: await pendingEmail(userCtx.uid),
|
|
4972
|
+
roles: result.roles,
|
|
3971
4973
|
metadata: result.user.metadata ?? {}
|
|
3972
4974
|
} });
|
|
3973
4975
|
});
|
|
@@ -4008,10 +5010,11 @@ function mountSessionRoutes(opts) {
|
|
|
4008
5010
|
email: result.user.email,
|
|
4009
5011
|
displayName: result.user.displayName,
|
|
4010
5012
|
photoURL: result.user.photoUrl,
|
|
4011
|
-
providerId:
|
|
5013
|
+
providerId: await sessionMethod(userCtx),
|
|
4012
5014
|
isAnonymous: result.user.isAnonymous ?? false,
|
|
4013
5015
|
emailVerified: result.user.emailVerified,
|
|
4014
|
-
|
|
5016
|
+
pendingEmail: await pendingEmail(userCtx.uid),
|
|
5017
|
+
roles: result.roles,
|
|
4015
5018
|
metadata: result.user.metadata ?? {}
|
|
4016
5019
|
} });
|
|
4017
5020
|
});
|
|
@@ -4029,7 +5032,7 @@ function mountSessionRoutes(opts) {
|
|
|
4029
5032
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
4030
5033
|
const user = await authRepo.createUser(createData);
|
|
4031
5034
|
if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
4032
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5035
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "anonymous" });
|
|
4033
5036
|
if (ops.afterUserCreate) ops.afterUserCreate(user).catch((err) => {
|
|
4034
5037
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
4035
5038
|
});
|
|
@@ -4053,6 +5056,10 @@ function mountSessionRoutes(opts) {
|
|
|
4053
5056
|
*/
|
|
4054
5057
|
router.post("/anonymous/link", strictAuthLimiter, requireLiveSession, ...registerCaptcha ? [registerCaptcha] : [], async (c) => {
|
|
4055
5058
|
assertAnonymousAuthOpen();
|
|
5059
|
+
if (!isSteadyStateRegistrationOpen({
|
|
5060
|
+
disableSelfRegistration: config.disableSelfRegistration,
|
|
5061
|
+
allowRegistration: config.allowRegistration ?? false
|
|
5062
|
+
})) throw ApiError.forbidden("A guest cannot become an account here: registration is disabled on this backend.", "REGISTRATION_DISABLED");
|
|
4056
5063
|
const userCtx = c.get("user");
|
|
4057
5064
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4058
5065
|
const user = await authRepo.getUserById(userCtx.uid);
|
|
@@ -4074,94 +5081,61 @@ function mountSessionRoutes(opts) {
|
|
|
4074
5081
|
isAnonymous: false
|
|
4075
5082
|
});
|
|
4076
5083
|
if (!updatedUser) throw ApiError.notFound("User not found");
|
|
4077
|
-
|
|
5084
|
+
sendVerificationMail?.(updatedUser);
|
|
5085
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
4078
5086
|
const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken, "password");
|
|
4079
5087
|
const transformedResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
4080
5088
|
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
4081
5089
|
});
|
|
4082
5090
|
}
|
|
4083
5091
|
//#endregion
|
|
4084
|
-
//#region src/auth/
|
|
5092
|
+
//#region src/auth/passwordless-signup.ts
|
|
4085
5093
|
/**
|
|
4086
|
-
*
|
|
4087
|
-
*
|
|
4088
|
-
* Nothing verifies the address an account is made with: `POST /auth/register`
|
|
4089
|
-
* takes whatever it is given, and so does a sign-in through a provider that
|
|
4090
|
-
* does not vouch for the address (Spotify and Facebook never do; Discord and
|
|
4091
|
-
* multi-tenant Microsoft sometimes do not). So anyone can make an account for
|
|
4092
|
-
* someone else's address and leave a way in on it — a password, or the
|
|
4093
|
-
* provider identity they signed up with — then wait for the owner.
|
|
5094
|
+
* Sign-up by magic link or email code (`auth.magicLinkCreatesUsers`).
|
|
4094
5095
|
*
|
|
4095
|
-
*
|
|
4096
|
-
*
|
|
4097
|
-
*
|
|
4098
|
-
*
|
|
4099
|
-
*
|
|
4100
|
-
*
|
|
5096
|
+
* Both passwordless doors refuse an address with no account — silently, so
|
|
5097
|
+
* they do not say which addresses have one. A passwordless-only app then had
|
|
5098
|
+
* no way to create accounts at all. With the option on, and registration open,
|
|
5099
|
+
* an unknown address gets an account at request time — no password, unverified
|
|
5100
|
+
* — and the link or code mailed to it is what proves the address and signs it
|
|
5101
|
+
* in, exactly as for an existing account. Supabase's `shouldCreateUser`.
|
|
4101
5102
|
*
|
|
4102
5103
|
* @module
|
|
4103
5104
|
*/
|
|
4104
5105
|
/**
|
|
4105
|
-
* The
|
|
4106
|
-
*
|
|
4107
|
-
*
|
|
4108
|
-
*
|
|
4109
|
-
*/
|
|
4110
|
-
function identityProfileData(profile) {
|
|
4111
|
-
return {
|
|
4112
|
-
email: profile.email,
|
|
4113
|
-
emailVerified: profile.emailVerified === true
|
|
4114
|
-
};
|
|
4115
|
-
}
|
|
4116
|
-
/**
|
|
4117
|
-
* Did this identity's provider vouch for `email` when it was linked?
|
|
4118
|
-
*
|
|
4119
|
-
* Only whoever controls the inbox can hold a provider account that vouches for
|
|
4120
|
-
* the address, so such an identity is the owner's. An identity linked before
|
|
4121
|
-
* the flag was recorded reads as not vouching: it is detached, and if its
|
|
4122
|
-
* provider does vouch, the next sign-in with it links it again.
|
|
4123
|
-
*/
|
|
4124
|
-
function identityVouchesForAddress(identity, email) {
|
|
4125
|
-
const profile = identity.profileData;
|
|
4126
|
-
return profile?.emailVerified === true && typeof profile.email === "string" && normalizeEmail(profile.email) === normalizeEmail(email);
|
|
4127
|
-
}
|
|
4128
|
-
/**
|
|
4129
|
-
* Settle an account whose address its owner has just proven, and mark it
|
|
4130
|
-
* verified.
|
|
4131
|
-
*
|
|
4132
|
-
* Call it only for an account that is not yet verified, from a route that has
|
|
4133
|
-
* just consumed something mailed to the address. It detaches every identity
|
|
4134
|
-
* whose provider did not vouch for the address, sets the password to
|
|
4135
|
-
* `passwordHash` — the one the owner just chose on a reset, or `null` to remove
|
|
4136
|
-
* the one somebody else may have registered with — and ends every session, in
|
|
4137
|
-
* that order and before the account is marked verified.
|
|
5106
|
+
* The account a passwordless request for `email` should mail, creating it
|
|
5107
|
+
* when the deployment lets passwordless requests sign people up. `null` when
|
|
5108
|
+
* there is none and none may be made — the caller answers as for any unknown
|
|
5109
|
+
* address.
|
|
4138
5110
|
*
|
|
4139
|
-
*
|
|
4140
|
-
*
|
|
4141
|
-
*
|
|
4142
|
-
*
|
|
5111
|
+
* Registration's controls hold: the kill switch, `allowRegistration` (with no
|
|
5112
|
+
* first-user exception: the first admin is made by registering or by the
|
|
5113
|
+
* operator, never by whoever asks for a link first), `beforeUserCreate`,
|
|
5114
|
+
* `afterUserCreate` and the default role.
|
|
4143
5115
|
*/
|
|
4144
|
-
async function
|
|
4145
|
-
const
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
5116
|
+
async function accountForPasswordlessRequest(config, ops, email) {
|
|
5117
|
+
const authRepo = config.authRepo;
|
|
5118
|
+
const existing = await authRepo.getUserByEmail(email);
|
|
5119
|
+
if (existing || !config.magicLinkCreatesUsers) return existing;
|
|
5120
|
+
if (!isSteadyStateRegistrationOpen({
|
|
5121
|
+
disableSelfRegistration: config.disableSelfRegistration,
|
|
5122
|
+
allowRegistration: config.allowRegistration ?? false
|
|
5123
|
+
})) return null;
|
|
5124
|
+
let createData = { email: normalizeEmail(email) };
|
|
5125
|
+
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5126
|
+
let user;
|
|
5127
|
+
try {
|
|
5128
|
+
user = await authRepo.createUser(createData);
|
|
5129
|
+
} catch (error) {
|
|
5130
|
+
const made = await authRepo.getUserByEmail(email);
|
|
5131
|
+
if (made) return made;
|
|
5132
|
+
throw error;
|
|
5133
|
+
}
|
|
5134
|
+
if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
5135
|
+
if (ops.afterUserCreate) ops.afterUserCreate(user).catch((err) => {
|
|
5136
|
+
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
4164
5137
|
});
|
|
5138
|
+
return user;
|
|
4165
5139
|
}
|
|
4166
5140
|
//#endregion
|
|
4167
5141
|
//#region src/auth/magic-link-routes.ts
|
|
@@ -4200,11 +5174,11 @@ function mountMagicLinkRoutes(deps) {
|
|
|
4200
5174
|
const startedAt = Date.now();
|
|
4201
5175
|
const { email } = parseBody(magicLinkSchema, await c.req.json());
|
|
4202
5176
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Magic link login is not available.", "EMAIL_NOT_CONFIGURED");
|
|
4203
|
-
const user = await authRepo.getUserByEmail(email);
|
|
4204
5177
|
if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
|
|
5178
|
+
const user = await accountForPasswordlessRequest(config, ops, email);
|
|
4205
5179
|
if (user) {
|
|
4206
5180
|
const token = generateSecureToken();
|
|
4207
|
-
const tokenHash = hashToken
|
|
5181
|
+
const tokenHash = hashToken(token);
|
|
4208
5182
|
const expiresAt = getMagicLinkExpiry();
|
|
4209
5183
|
await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
|
|
4210
5184
|
const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
|
|
@@ -4237,7 +5211,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
4237
5211
|
*/
|
|
4238
5212
|
router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
|
|
4239
5213
|
const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
|
|
4240
|
-
const tokenHash = hashToken
|
|
5214
|
+
const tokenHash = hashToken(token);
|
|
4241
5215
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
4242
5216
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
4243
5217
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
@@ -4251,7 +5225,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
4251
5225
|
passwordHash: null
|
|
4252
5226
|
};
|
|
4253
5227
|
}
|
|
4254
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5228
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "magic-link" });
|
|
4255
5229
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "magic-link").catch((err) => {
|
|
4256
5230
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4257
5231
|
});
|
|
@@ -4394,11 +5368,11 @@ function mountOtpRoutes(deps) {
|
|
|
4394
5368
|
const startedAt = Date.now();
|
|
4395
5369
|
const { email } = parseBody(requestSchema, await c.req.json());
|
|
4396
5370
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. One-time code login is not available.", "EMAIL_NOT_CONFIGURED");
|
|
4397
|
-
const user = await authRepo.getUserByEmail(email);
|
|
4398
5371
|
if (ops.beforeLogin) await ops.beforeLogin(email, "otp");
|
|
5372
|
+
const user = await accountForPasswordlessRequest(config, ops, email);
|
|
4399
5373
|
if (user) {
|
|
4400
5374
|
const code = generateOtpCode();
|
|
4401
|
-
await authRepo.createMagicLinkToken(user.id, hashToken
|
|
5375
|
+
await authRepo.createMagicLinkToken(user.id, hashToken(otpTokenMaterial(user.email, code)), new Date(Date.now() + OTP_TTL_MS));
|
|
4402
5376
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4403
5377
|
const templateFn = emailConfig?.templates?.emailOtp;
|
|
4404
5378
|
const content = templateFn ? templateFn(code, {
|
|
@@ -4428,7 +5402,7 @@ function mountOtpRoutes(deps) {
|
|
|
4428
5402
|
*/
|
|
4429
5403
|
router.post("/otp/verify", strictAuthLimiter, captureOtpEmail, otpVerificationLimiter, async (c) => {
|
|
4430
5404
|
const { email, code } = parseBody(verifySchema, await c.req.json());
|
|
4431
|
-
const tokenHash = hashToken
|
|
5405
|
+
const tokenHash = hashToken(otpTokenMaterial(email, code));
|
|
4432
5406
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
4433
5407
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
|
|
4434
5408
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
@@ -4442,7 +5416,7 @@ function mountOtpRoutes(deps) {
|
|
|
4442
5416
|
passwordHash: null
|
|
4443
5417
|
};
|
|
4444
5418
|
}
|
|
4445
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5419
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "otp" });
|
|
4446
5420
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "otp").catch((err) => {
|
|
4447
5421
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4448
5422
|
});
|
|
@@ -4519,6 +5493,7 @@ function canonicalRedirectUri(uri) {
|
|
|
4519
5493
|
}
|
|
4520
5494
|
//#endregion
|
|
4521
5495
|
//#region src/auth/routes.ts
|
|
5496
|
+
var REFRESH_TOKEN_REUSE_POLICIES = ["reject", "revoke-session"];
|
|
4522
5497
|
/**
|
|
4523
5498
|
* Helper to build standard auth response output
|
|
4524
5499
|
*/
|
|
@@ -4549,7 +5524,13 @@ function getPasswordResetExpiry() {
|
|
|
4549
5524
|
return new Date(Date.now() + 3600 * 1e3);
|
|
4550
5525
|
}
|
|
4551
5526
|
function createAuthRoutes(config) {
|
|
4552
|
-
if (config.
|
|
5527
|
+
if (config.refreshTokenReuse !== void 0 && !REFRESH_TOKEN_REUSE_POLICIES.includes(config.refreshTokenReuse)) throw new Error(`auth.refreshTokenReuse is "${String(config.refreshTokenReuse)}"; it must be one of ${REFRESH_TOKEN_REUSE_POLICIES.map((p) => `"${p}"`).join(", ")}.`);
|
|
5528
|
+
if (config.refreshTokenReuseIntervalSeconds !== void 0 && !(Number.isFinite(config.refreshTokenReuseIntervalSeconds) && config.refreshTokenReuseIntervalSeconds >= 0)) throw new Error(`auth.refreshTokenReuseIntervalSeconds is ${String(config.refreshTokenReuseIntervalSeconds)}; give a number of seconds, 0 or more.`);
|
|
5529
|
+
if (config.defaultRole) {
|
|
5530
|
+
const declared = getAccessModel().roles;
|
|
5531
|
+
const adminPlane = config.defaultRole === "admin" ? ["every scope"] : Object.prototype.hasOwnProperty.call(declared, config.defaultRole) ? declared[config.defaultRole].scopes.filter((scope) => isAdminScope(parseScope(scope)?.scope ?? "")) : [];
|
|
5532
|
+
if (adminPlane.length > 0) throw new Error(`CRITICAL SECURITY ERROR: defaultRole cannot be '${config.defaultRole}': every registrant would hold ${adminPlane.join(", ")}. Give the default role no admin-plane scopes, and use the POST /admin/bootstrap endpoint to promote the initial administrator.`);
|
|
5533
|
+
}
|
|
4553
5534
|
const captcha = buildCaptchaMiddlewares(config.captcha);
|
|
4554
5535
|
const router = new Hono();
|
|
4555
5536
|
router.onError(errorHandler);
|
|
@@ -4602,11 +5583,16 @@ function createAuthRoutes(config) {
|
|
|
4602
5583
|
token: z.string().min(1, "Token is required"),
|
|
4603
5584
|
password: z.string().min(1, "Password is required").max(128)
|
|
4604
5585
|
});
|
|
5586
|
+
const verifyEmailSchema = z.object({
|
|
5587
|
+
token: z.string().min(1, "Token is required"),
|
|
5588
|
+
password: z.string().min(1).max(128).optional(),
|
|
5589
|
+
removeUnproven: z.boolean().optional()
|
|
5590
|
+
});
|
|
4605
5591
|
const changePasswordSchema = z.object({
|
|
4606
5592
|
oldPassword: z.string().min(1, "Old password is required").max(128),
|
|
4607
5593
|
newPassword: z.string().min(1, "New password is required").max(128)
|
|
4608
5594
|
});
|
|
4609
|
-
const refreshSchema = z.object({ refreshToken: z.string().
|
|
5595
|
+
const refreshSchema = z.object({ refreshToken: z.string().optional() });
|
|
4610
5596
|
/** Parse a Zod schema against the request body, throwing ApiError on failure */
|
|
4611
5597
|
function parseBody(schema, body) {
|
|
4612
5598
|
const result = schema.safeParse(body);
|
|
@@ -4644,11 +5630,11 @@ function createAuthRoutes(config) {
|
|
|
4644
5630
|
* Send welcome email to a newly registered user (fire-and-forget).
|
|
4645
5631
|
*/
|
|
4646
5632
|
function sendWelcomeEmail(user) {
|
|
4647
|
-
if (!isEmailConfigured()) return;
|
|
5633
|
+
if (!isEmailConfigured() || !isDeliverableAddress(user.email)) return;
|
|
4648
5634
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4649
5635
|
const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
|
|
4650
5636
|
const templateFn = emailConfig?.templates?.welcomeEmail;
|
|
4651
|
-
const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl
|
|
5637
|
+
const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl || void 0, logoUrl);
|
|
4652
5638
|
emailService.send({
|
|
4653
5639
|
to: user.email,
|
|
4654
5640
|
subject: emailContent.subject,
|
|
@@ -4659,6 +5645,53 @@ function createAuthRoutes(config) {
|
|
|
4659
5645
|
});
|
|
4660
5646
|
}
|
|
4661
5647
|
/**
|
|
5648
|
+
* Mail a verification link for `user`'s address, and record its token.
|
|
5649
|
+
*
|
|
5650
|
+
* Sent at registration as well as on request, so a self-registered
|
|
5651
|
+
* account proves its address on its owner's terms — and keeps the
|
|
5652
|
+
* password its owner chose, which a later first proof by link or code
|
|
5653
|
+
* would otherwise have to remove (see `confirmAddressOwnership`).
|
|
5654
|
+
* Returns whether a mail was handed to the email service.
|
|
5655
|
+
*/
|
|
5656
|
+
async function sendVerificationMail(user) {
|
|
5657
|
+
if (!isEmailConfigured() || !isDeliverableAddress(user.email)) return false;
|
|
5658
|
+
const token = generateSecureToken();
|
|
5659
|
+
await authRepo.setVerificationToken(user.id, hashToken(token));
|
|
5660
|
+
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
5661
|
+
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
5662
|
+
const templateFn = emailConfig?.templates?.emailVerification;
|
|
5663
|
+
const emailContent = templateFn ? templateFn(verifyUrl, {
|
|
5664
|
+
email: user.email,
|
|
5665
|
+
displayName: user.displayName
|
|
5666
|
+
}) : getEmailVerificationTemplate(verifyUrl, {
|
|
5667
|
+
email: user.email,
|
|
5668
|
+
displayName: user.displayName
|
|
5669
|
+
}, appName, logoUrl);
|
|
5670
|
+
await emailService.send({
|
|
5671
|
+
to: user.email,
|
|
5672
|
+
subject: emailContent.subject,
|
|
5673
|
+
html: emailContent.html,
|
|
5674
|
+
text: emailContent.text
|
|
5675
|
+
});
|
|
5676
|
+
return true;
|
|
5677
|
+
}
|
|
5678
|
+
/** {@link sendVerificationMail}, not awaited: a failure is logged, never answered. */
|
|
5679
|
+
function sendVerificationMailInBackground(user) {
|
|
5680
|
+
sendVerificationMail(user).catch((err) => {
|
|
5681
|
+
logger.error("Failed to send verification email", { error: err instanceof Error ? err.message : err });
|
|
5682
|
+
});
|
|
5683
|
+
}
|
|
5684
|
+
/**
|
|
5685
|
+
* A hash of nothing anyone knows, made with this deployment's own
|
|
5686
|
+
* `hashPassword`, so verifying against it costs what verifying a real
|
|
5687
|
+
* password costs. Made once, on first use.
|
|
5688
|
+
*/
|
|
5689
|
+
let dummyHash;
|
|
5690
|
+
function dummyPasswordHash() {
|
|
5691
|
+
dummyHash ??= ops.hashPassword(randomBytes$1(24).toString("hex"));
|
|
5692
|
+
return dummyHash;
|
|
5693
|
+
}
|
|
5694
|
+
/**
|
|
4662
5695
|
* Is the account just created the only one there is?
|
|
4663
5696
|
*
|
|
4664
5697
|
* Two rows at most: the total says whether anyone else exists, and the
|
|
@@ -4684,11 +5717,12 @@ function createAuthRoutes(config) {
|
|
|
4684
5717
|
* itself, which has just seen the second factor and mints at `aal2`.
|
|
4685
5718
|
*/
|
|
4686
5719
|
async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
|
|
4687
|
-
if (!options
|
|
4688
|
-
const aal = options
|
|
4689
|
-
const roleIds =
|
|
5720
|
+
if (!options.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
|
|
5721
|
+
const aal = options.aal ?? "aal1";
|
|
5722
|
+
const roleIds = await authRepo.getUserRoleIds(uid);
|
|
4690
5723
|
const sessionUser = await authRepo.getUserById(uid);
|
|
4691
5724
|
const isAnonymous = sessionUser?.isAnonymous === true;
|
|
5725
|
+
if (sessionUser?.disabled) throw ApiError.forbidden("This account has been disabled. Contact an administrator.", "ACCOUNT_DISABLED");
|
|
4692
5726
|
let customClaims;
|
|
4693
5727
|
if (ops.customizeAccessToken) {
|
|
4694
5728
|
const user = sessionUser;
|
|
@@ -4701,12 +5735,14 @@ function createAuthRoutes(config) {
|
|
|
4701
5735
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
4702
5736
|
}
|
|
4703
5737
|
}
|
|
4704
|
-
const
|
|
5738
|
+
const sessionId = randomUUID$1();
|
|
5739
|
+
const accessToken = await generateAccessToken(uid, roleIds, aal, customClaims, isAnonymous, sessionId);
|
|
4705
5740
|
const refreshToken = generateRefreshToken();
|
|
4706
5741
|
await authRepo.createRefreshToken(uid, await hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
4707
|
-
id:
|
|
5742
|
+
id: sessionId,
|
|
4708
5743
|
startedAt: /* @__PURE__ */ new Date(),
|
|
4709
|
-
aal
|
|
5744
|
+
aal,
|
|
5745
|
+
method: options.method
|
|
4710
5746
|
});
|
|
4711
5747
|
return {
|
|
4712
5748
|
roleIds,
|
|
@@ -4719,6 +5755,7 @@ function createAuthRoutes(config) {
|
|
|
4719
5755
|
* Create a new account with email/password
|
|
4720
5756
|
*/
|
|
4721
5757
|
router.post("/register", defaultAuthLimiter, ...captcha.register ? [captcha.register] : [], async (c) => {
|
|
5758
|
+
const startedAt = Date.now();
|
|
4722
5759
|
const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
|
|
4723
5760
|
if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
4724
5761
|
let bootstrapRegistration = false;
|
|
@@ -4730,7 +5767,19 @@ function createAuthRoutes(config) {
|
|
|
4730
5767
|
}
|
|
4731
5768
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
4732
5769
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4733
|
-
|
|
5770
|
+
const confirmFirst = () => notBefore$1(startedAt, 400, c.json({
|
|
5771
|
+
success: true,
|
|
5772
|
+
confirmationRequired: true,
|
|
5773
|
+
message: "Check your inbox: follow the link we sent to confirm your address, then sign in."
|
|
5774
|
+
}));
|
|
5775
|
+
const existingUser = await authRepo.getUserByEmail(email);
|
|
5776
|
+
if (existingUser) {
|
|
5777
|
+
if (config.requireEmailVerification) {
|
|
5778
|
+
if (!existingUser.emailVerified) sendVerificationMailInBackground(existingUser);
|
|
5779
|
+
return confirmFirst();
|
|
5780
|
+
}
|
|
5781
|
+
throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
5782
|
+
}
|
|
4734
5783
|
const passwordHash = await ops.hashPassword(password);
|
|
4735
5784
|
let createData = {
|
|
4736
5785
|
email: normalizeEmail(email),
|
|
@@ -4749,11 +5798,21 @@ function createAuthRoutes(config) {
|
|
|
4749
5798
|
if (isFirstUser) logger.warn("[Auth] First account registered in production without promotion; set REBASE_ADMIN_EMAIL/REBASE_ADMIN_PASSWORD or use the service key to assign admin.");
|
|
4750
5799
|
if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
4751
5800
|
}
|
|
4752
|
-
|
|
5801
|
+
if (config.requireEmailVerification) {
|
|
5802
|
+
sendVerificationMailInBackground(user);
|
|
5803
|
+
if (ops.afterUserCreate) try {
|
|
5804
|
+
await ops.afterUserCreate(user);
|
|
5805
|
+
} catch (err) {
|
|
5806
|
+
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
5807
|
+
}
|
|
5808
|
+
return confirmFirst();
|
|
5809
|
+
}
|
|
5810
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
4753
5811
|
sendWelcomeEmail({
|
|
4754
5812
|
email: user.email,
|
|
4755
5813
|
displayName: user.displayName
|
|
4756
5814
|
});
|
|
5815
|
+
sendVerificationMailInBackground(user);
|
|
4757
5816
|
if (ops.afterUserCreate) try {
|
|
4758
5817
|
await ops.afterUserCreate(user);
|
|
4759
5818
|
} catch (err) {
|
|
@@ -4778,8 +5837,10 @@ function createAuthRoutes(config) {
|
|
|
4778
5837
|
if (!user) throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
4779
5838
|
} else {
|
|
4780
5839
|
user = await authRepo.getUserByEmail(email);
|
|
4781
|
-
if (!user
|
|
4782
|
-
|
|
5840
|
+
if (!user || !user.passwordHash) {
|
|
5841
|
+
await ops.verifyPassword(password, await dummyPasswordHash());
|
|
5842
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
5843
|
+
}
|
|
4783
5844
|
if (!await ops.verifyPassword(password, user.passwordHash)) {
|
|
4784
5845
|
logger.warn("[Security Audit] Auth login failure", {
|
|
4785
5846
|
eventType: "auth.login.failure",
|
|
@@ -4789,7 +5850,12 @@ function createAuthRoutes(config) {
|
|
|
4789
5850
|
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
4790
5851
|
}
|
|
4791
5852
|
}
|
|
4792
|
-
|
|
5853
|
+
if (config.requireEmailVerification && !user.emailVerified) {
|
|
5854
|
+
const lastSent = user.emailVerificationSentAt?.getTime() ?? 0;
|
|
5855
|
+
if (Date.now() - lastSent > 6e4) sendVerificationMailInBackground(user);
|
|
5856
|
+
throw ApiError.forbidden("Confirm your email address first: follow the link we sent to it, then sign in.", "EMAIL_NOT_CONFIRMED");
|
|
5857
|
+
}
|
|
5858
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
4793
5859
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "login").catch((err) => {
|
|
4794
5860
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4795
5861
|
});
|
|
@@ -4842,6 +5908,7 @@ function createAuthRoutes(config) {
|
|
|
4842
5908
|
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
4843
5909
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
4844
5910
|
const externalUser = await verifyProviderPayload(provider, payload);
|
|
5911
|
+
if (ops.beforeLogin) await ops.beforeLogin(normalizeEmail(externalUser.email), "oauth");
|
|
4845
5912
|
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
4846
5913
|
if (!user) {
|
|
4847
5914
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
@@ -4869,12 +5936,14 @@ function createAuthRoutes(config) {
|
|
|
4869
5936
|
if (!bootstrapRegistration) throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
4870
5937
|
if (!isBootstrapWindowOpen()) throw ApiError.forbidden(SETUP_REQUIRED_MESSAGE, "SETUP_REQUIRED");
|
|
4871
5938
|
}
|
|
4872
|
-
|
|
5939
|
+
let createData = {
|
|
4873
5940
|
email: normalizeEmail(externalUser.email),
|
|
4874
5941
|
displayName: externalUser.displayName || void 0,
|
|
4875
5942
|
photoUrl: externalUser.photoUrl || void 0,
|
|
4876
5943
|
emailVerified: externalUser.emailVerified === true
|
|
4877
|
-
}
|
|
5944
|
+
};
|
|
5945
|
+
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5946
|
+
user = await authRepo.createUser(createData);
|
|
4878
5947
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4879
5948
|
if (ops.afterUserCreate) try {
|
|
4880
5949
|
await ops.afterUserCreate(user);
|
|
@@ -4897,7 +5966,13 @@ function createAuthRoutes(config) {
|
|
|
4897
5966
|
displayName: externalUser.displayName || user.displayName || void 0,
|
|
4898
5967
|
photoUrl: externalUser.photoUrl || user.photoUrl || void 0
|
|
4899
5968
|
});
|
|
4900
|
-
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c
|
|
5969
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: provider.id });
|
|
5970
|
+
if (ops.onAuthenticated) {
|
|
5971
|
+
const signedIn = user;
|
|
5972
|
+
ops.onAuthenticated(signedIn, "oauth").catch((err) => {
|
|
5973
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
5974
|
+
});
|
|
5975
|
+
}
|
|
4901
5976
|
const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
|
|
4902
5977
|
return c.json(finalResponse);
|
|
4903
5978
|
});
|
|
@@ -4933,6 +6008,33 @@ function createAuthRoutes(config) {
|
|
|
4933
6008
|
alreadyLinked: true,
|
|
4934
6009
|
photoURL: externalUser.photoUrl ?? null
|
|
4935
6010
|
});
|
|
6011
|
+
const linking = await authRepo.getUserById(userCtx.uid);
|
|
6012
|
+
if (linking?.isAnonymous) {
|
|
6013
|
+
if (!isRegistrationAllowed()) throw ApiError.forbidden("A guest cannot become an account here: registration is disabled on this backend.", "REGISTRATION_DISABLED");
|
|
6014
|
+
const vouchedEmail = externalUser.emailVerified === true ? normalizeEmail(externalUser.email) : void 0;
|
|
6015
|
+
if (vouchedEmail && await authRepo.getUserByEmail(vouchedEmail)) throw ApiError.conflict(`An account with this ${provider.id} address already exists. Sign in to it instead.`, "EMAIL_EXISTS");
|
|
6016
|
+
let upgrade = {
|
|
6017
|
+
email: vouchedEmail ?? linking.email,
|
|
6018
|
+
...vouchedEmail ? { emailVerified: true } : {}
|
|
6019
|
+
};
|
|
6020
|
+
if (ops.beforeUserCreate) upgrade = await ops.beforeUserCreate(upgrade);
|
|
6021
|
+
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
6022
|
+
const upgraded = await authRepo.updateUser(userCtx.uid, {
|
|
6023
|
+
email: normalizeEmail(upgrade.email),
|
|
6024
|
+
...upgrade.emailVerified !== void 0 ? { emailVerified: upgrade.emailVerified } : {},
|
|
6025
|
+
isAnonymous: false
|
|
6026
|
+
});
|
|
6027
|
+
if (!upgraded) throw ApiError.notFound("User not found");
|
|
6028
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(upgraded.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: provider.id });
|
|
6029
|
+
const authResponse = buildAuthResponse(upgraded, roleIds, accessToken, refreshToken, provider.id);
|
|
6030
|
+
return c.json({
|
|
6031
|
+
...redactRefreshToken(authResponse, c, refreshToken, config.cookieAuth),
|
|
6032
|
+
success: true,
|
|
6033
|
+
provider: provider.id,
|
|
6034
|
+
alreadyLinked: false,
|
|
6035
|
+
photoURL: externalUser.photoUrl ?? null
|
|
6036
|
+
});
|
|
6037
|
+
}
|
|
4936
6038
|
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4937
6039
|
return c.json({
|
|
4938
6040
|
success: true,
|
|
@@ -4953,7 +6055,7 @@ function createAuthRoutes(config) {
|
|
|
4953
6055
|
const user = await authRepo.getUserByEmail(email);
|
|
4954
6056
|
if (user) {
|
|
4955
6057
|
const token = generateSecureToken();
|
|
4956
|
-
const tokenHash = hashToken
|
|
6058
|
+
const tokenHash = hashToken(token);
|
|
4957
6059
|
const expiresAt = getPasswordResetExpiry();
|
|
4958
6060
|
await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
|
4959
6061
|
const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
@@ -4988,7 +6090,7 @@ function createAuthRoutes(config) {
|
|
|
4988
6090
|
const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
|
|
4989
6091
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
4990
6092
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
4991
|
-
const tokenHash = hashToken
|
|
6093
|
+
const tokenHash = hashToken(token);
|
|
4992
6094
|
const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
|
|
4993
6095
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
|
|
4994
6096
|
const passwordHash = await ops.hashPassword(password);
|
|
@@ -4996,6 +6098,9 @@ function createAuthRoutes(config) {
|
|
|
4996
6098
|
if (account && !account.emailVerified) await confirmAddressOwnership(authRepo, account, passwordHash);
|
|
4997
6099
|
else await replaceUserPassword(authRepo, storedToken.uid, passwordHash);
|
|
4998
6100
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
6101
|
+
if (ops.onAuthenticated && account) ops.onAuthenticated(account, "password-reset").catch((err) => {
|
|
6102
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
6103
|
+
});
|
|
4999
6104
|
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
5000
6105
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
5001
6106
|
});
|
|
@@ -5019,7 +6124,14 @@ function createAuthRoutes(config) {
|
|
|
5019
6124
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
5020
6125
|
const passwordHash = await ops.hashPassword(newPassword);
|
|
5021
6126
|
await replaceUserPassword(authRepo, user.id, passwordHash);
|
|
6127
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), {
|
|
6128
|
+
skipMfaGate: true,
|
|
6129
|
+
aal: userCtx.aal === "aal2" ? "aal2" : "aal1",
|
|
6130
|
+
method: "password"
|
|
6131
|
+
});
|
|
6132
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "password");
|
|
5022
6133
|
return c.json({
|
|
6134
|
+
...redactRefreshToken(authResponse, c, refreshToken, config.cookieAuth),
|
|
5023
6135
|
success: true,
|
|
5024
6136
|
message: "Password has been changed successfully"
|
|
5025
6137
|
});
|
|
@@ -5042,42 +6154,109 @@ function createAuthRoutes(config) {
|
|
|
5042
6154
|
const user = await authRepo.getUserById(userCtx.uid);
|
|
5043
6155
|
if (!user) throw ApiError.notFound("User not found");
|
|
5044
6156
|
if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
5045
|
-
|
|
5046
|
-
await
|
|
5047
|
-
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
5048
|
-
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
5049
|
-
const templateFn = emailConfig?.templates?.emailVerification;
|
|
5050
|
-
const emailContent = templateFn ? templateFn(verifyUrl, {
|
|
5051
|
-
email: user.email,
|
|
5052
|
-
displayName: user.displayName
|
|
5053
|
-
}) : getEmailVerificationTemplate(verifyUrl, {
|
|
5054
|
-
email: user.email,
|
|
5055
|
-
displayName: user.displayName
|
|
5056
|
-
}, appName, logoUrl);
|
|
5057
|
-
await emailService.send({
|
|
5058
|
-
to: user.email,
|
|
5059
|
-
subject: emailContent.subject,
|
|
5060
|
-
html: emailContent.html,
|
|
5061
|
-
text: emailContent.text
|
|
5062
|
-
});
|
|
6157
|
+
if (!isDeliverableAddress(user.email)) throw ApiError.badRequest("This account has no address mail can reach. Set a real email address first.", "UNDELIVERABLE_ADDRESS");
|
|
6158
|
+
await sendVerificationMail(user);
|
|
5063
6159
|
return c.json({
|
|
5064
6160
|
success: true,
|
|
5065
6161
|
message: "Verification email sent"
|
|
5066
6162
|
});
|
|
5067
6163
|
});
|
|
5068
6164
|
/**
|
|
5069
|
-
*
|
|
5070
|
-
*
|
|
6165
|
+
* Who proved what, when a verification link is followed.
|
|
6166
|
+
*
|
|
6167
|
+
* The link proves the inbox. It does not prove whoever registered the
|
|
6168
|
+
* address is that inbox's owner: anyone can register someone else's
|
|
6169
|
+
* address, leave a password on it, and ask for the link to be mailed. So a
|
|
6170
|
+
* first proof of address keeps only what the person following the link
|
|
6171
|
+
* also proved — and takes the rest off, as a magic link, an email code or
|
|
6172
|
+
* a reset does (`confirmAddressOwnership`):
|
|
6173
|
+
*
|
|
6174
|
+
* - `session`: the request carries a live session of this very account.
|
|
6175
|
+
* Only whoever put the credentials on it can hold one, and they now hold
|
|
6176
|
+
* the inbox too — nothing on the account is a stranger's.
|
|
6177
|
+
* - `password`: the request carries the account's password.
|
|
6178
|
+
* - `none`: the password and every identity whose provider did not vouch
|
|
6179
|
+
* for the address are removed, and every session ends.
|
|
6180
|
+
*/
|
|
6181
|
+
async function verificationProof(c, user, password) {
|
|
6182
|
+
const bearer = extractBearerToken(c.req.header("authorization"));
|
|
6183
|
+
if (bearer !== void 0) {
|
|
6184
|
+
const payload = await verifyAccessToken(bearer);
|
|
6185
|
+
if (payload?.uid === user.id && (await judgeAccessToken(authRepo, payload)).live) return "session";
|
|
6186
|
+
}
|
|
6187
|
+
if (password !== void 0) {
|
|
6188
|
+
if (!user.passwordHash || !await ops.verifyPassword(password, user.passwordHash)) throw ApiError.unauthorized("That is not this account's password.", "INVALID_CREDENTIALS");
|
|
6189
|
+
return "password";
|
|
6190
|
+
}
|
|
6191
|
+
return "none";
|
|
6192
|
+
}
|
|
6193
|
+
/** The account a verification token names, if the token is live. */
|
|
6194
|
+
async function accountForVerificationToken(token) {
|
|
6195
|
+
const user = await authRepo.getUserByVerificationToken(hashToken(token));
|
|
6196
|
+
const sentAt = user?.emailVerificationSentAt?.getTime();
|
|
6197
|
+
if (!user || sentAt !== void 0 && Date.now() - sentAt > 864e5) throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
|
|
6198
|
+
return user;
|
|
6199
|
+
}
|
|
6200
|
+
/**
|
|
6201
|
+
* GET /auth/verify-email?token=
|
|
6202
|
+
* Verify an address by its link. Keeps what the request proves — a live
|
|
6203
|
+
* session of the account — and removes what nobody proved. See
|
|
6204
|
+
* `verificationProof`; `POST` is the same with a password and a choice.
|
|
5071
6205
|
*/
|
|
5072
|
-
router.get("/verify-email", async (c) => {
|
|
6206
|
+
router.get("/verify-email", strictAuthLimiter, async (c) => {
|
|
5073
6207
|
const token = c.req.query("token");
|
|
5074
6208
|
if (!token) throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
|
|
5075
|
-
const user = await
|
|
5076
|
-
|
|
5077
|
-
await authRepo
|
|
6209
|
+
const user = await accountForVerificationToken(token);
|
|
6210
|
+
const proof = await verificationProof(c, user, void 0);
|
|
6211
|
+
const outcome = await confirmAddressOwnership(authRepo, user, null, { everythingProven: proof === "session" });
|
|
6212
|
+
return c.json({
|
|
6213
|
+
success: true,
|
|
6214
|
+
message: "Email verified successfully",
|
|
6215
|
+
passwordRemoved: outcome.removedPassword,
|
|
6216
|
+
removedProviders: outcome.removedProviders
|
|
6217
|
+
});
|
|
6218
|
+
});
|
|
6219
|
+
/**
|
|
6220
|
+
* POST /auth/verify-email { token, password?, removeUnproven? }
|
|
6221
|
+
*
|
|
6222
|
+
* The same proof as `GET`, with the two things a link alone cannot say:
|
|
6223
|
+
*
|
|
6224
|
+
* - `password` proves the account's password, which is then kept, and
|
|
6225
|
+
* signs the caller in — they have just proven both the address and the
|
|
6226
|
+
* password. This is how a confirm-first registration completes.
|
|
6227
|
+
* - With neither a session nor a password, an account carrying a password
|
|
6228
|
+
* or an identity nobody proved is answered `409 PROOF_REQUIRED` and the
|
|
6229
|
+
* token is left unspent, so the person holding the link can choose:
|
|
6230
|
+
* prove the password, or `removeUnproven: true` to verify without it.
|
|
6231
|
+
*/
|
|
6232
|
+
router.post("/verify-email", strictAuthLimiter, async (c) => {
|
|
6233
|
+
const { token, password, removeUnproven } = parseBody(verifyEmailSchema, await c.req.json());
|
|
6234
|
+
const user = await accountForVerificationToken(token);
|
|
6235
|
+
const proof = await verificationProof(c, user, password);
|
|
6236
|
+
if (proof === "none" && !removeUnproven) {
|
|
6237
|
+
const unprovenProviders = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email)).map((identity) => identity.provider);
|
|
6238
|
+
if (user.passwordHash || unprovenProviders.length > 0) throw new ApiError(409, "PROOF_REQUIRED", "This account has a way to sign in that was set before its address was verified. Enter its password to keep it, or verify without it and it is removed.", {
|
|
6239
|
+
password: Boolean(user.passwordHash),
|
|
6240
|
+
providers: unprovenProviders
|
|
6241
|
+
});
|
|
6242
|
+
}
|
|
6243
|
+
const outcome = await confirmAddressOwnership(authRepo, user, proof === "password" ? user.passwordHash ?? null : null, { everythingProven: proof === "session" });
|
|
6244
|
+
if (proof !== "password") return c.json({
|
|
6245
|
+
success: true,
|
|
6246
|
+
message: "Email verified successfully",
|
|
6247
|
+
passwordRemoved: outcome.removedPassword,
|
|
6248
|
+
removedProviders: outcome.removedProviders
|
|
6249
|
+
});
|
|
6250
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", requestClientAddress(c), { method: "password" });
|
|
6251
|
+
const transformedResponse = await applyTransformHook(buildAuthResponse({
|
|
6252
|
+
...user,
|
|
6253
|
+
emailVerified: true
|
|
6254
|
+
}, roleIds, accessToken, refreshToken, "password"), "login", c.req.raw, user.id);
|
|
5078
6255
|
return c.json({
|
|
6256
|
+
...redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth),
|
|
5079
6257
|
success: true,
|
|
5080
|
-
|
|
6258
|
+
passwordRemoved: false,
|
|
6259
|
+
removedProviders: outcome.removedProviders
|
|
5081
6260
|
});
|
|
5082
6261
|
});
|
|
5083
6262
|
/**
|
|
@@ -5110,15 +6289,24 @@ function createAuthRoutes(config) {
|
|
|
5110
6289
|
const reuseWindowMs = Math.max(0, config.refreshTokenReuseIntervalSeconds ?? 10) * 1e3;
|
|
5111
6290
|
const supersededAt = storedToken.rotatedAt ? new Date(storedToken.rotatedAt) : null;
|
|
5112
6291
|
if (supersededAt && Date.now() - supersededAt.getTime() > reuseWindowMs) {
|
|
5113
|
-
|
|
6292
|
+
const revoke = config.refreshTokenReuse === "revoke-session";
|
|
6293
|
+
logger.warn("[Security Audit] Refresh token replayed after the reuse window", {
|
|
6294
|
+
eventType: "auth.refresh.reuse",
|
|
5114
6295
|
uid: storedToken.uid,
|
|
5115
6296
|
sessionId: storedToken.sessionId,
|
|
5116
6297
|
supersededSecondsAgo: Math.round((Date.now() - supersededAt.getTime()) / 1e3),
|
|
5117
|
-
userAgent: c.req.header("user-agent") || "unknown"
|
|
6298
|
+
userAgent: c.req.header("user-agent") || "unknown",
|
|
6299
|
+
sessionRevoked: revoke
|
|
5118
6300
|
});
|
|
6301
|
+
if (revoke) {
|
|
6302
|
+
if (storedToken.sessionId && authRepo.revokeRefreshTokenSession) await authRepo.revokeRefreshTokenSession(storedToken.sessionId);
|
|
6303
|
+
else await authRepo.deleteRefreshToken(tokenHash);
|
|
6304
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
6305
|
+
throw ApiError.unauthorized("Refresh token already used; this session has been ended", "SESSION_REVOKED");
|
|
6306
|
+
}
|
|
5119
6307
|
throw ApiError.unauthorized("Refresh token already used", "TOKEN_ALREADY_USED");
|
|
5120
6308
|
}
|
|
5121
|
-
const roleIds =
|
|
6309
|
+
const roleIds = await authRepo.getUserRoleIds(storedToken.uid);
|
|
5122
6310
|
const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
|
|
5123
6311
|
logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
|
|
5124
6312
|
uid: storedToken.uid,
|
|
@@ -5126,7 +6314,13 @@ function createAuthRoutes(config) {
|
|
|
5126
6314
|
});
|
|
5127
6315
|
return null;
|
|
5128
6316
|
});
|
|
6317
|
+
if (user?.disabled) {
|
|
6318
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
6319
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
6320
|
+
throw ApiError.unauthorized("This account has been disabled", "ACCOUNT_DISABLED");
|
|
6321
|
+
}
|
|
5129
6322
|
const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
|
|
6323
|
+
const sessionMethod = storedToken.method ?? "password";
|
|
5130
6324
|
let customClaims;
|
|
5131
6325
|
if (ops.customizeAccessToken && user) {
|
|
5132
6326
|
const defaultClaims = {
|
|
@@ -5136,14 +6330,16 @@ function createAuthRoutes(config) {
|
|
|
5136
6330
|
};
|
|
5137
6331
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
5138
6332
|
}
|
|
5139
|
-
const
|
|
6333
|
+
const sessionId = storedToken.sessionId ?? storedToken.id;
|
|
6334
|
+
const newAccessToken = await generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims, user?.isAnonymous === true, sessionId);
|
|
5140
6335
|
const newRefreshToken = generateRefreshToken();
|
|
5141
6336
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
5142
|
-
const ipAddress = c
|
|
6337
|
+
const ipAddress = requestClientAddress(c);
|
|
5143
6338
|
const session = {
|
|
5144
|
-
id:
|
|
6339
|
+
id: sessionId,
|
|
5145
6340
|
startedAt: sessionStartedAt,
|
|
5146
|
-
aal: sessionAal
|
|
6341
|
+
aal: sessionAal,
|
|
6342
|
+
method: sessionMethod
|
|
5147
6343
|
};
|
|
5148
6344
|
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
5149
6345
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
@@ -5165,14 +6361,24 @@ function createAuthRoutes(config) {
|
|
|
5165
6361
|
} };
|
|
5166
6362
|
let refreshResponse = tokensOnlyResponse;
|
|
5167
6363
|
if (user) try {
|
|
5168
|
-
refreshResponse = buildAuthResponse(user, roleIds, newAccessToken, newRefreshToken,
|
|
6364
|
+
refreshResponse = buildAuthResponse(user, roleIds, newAccessToken, newRefreshToken, sessionMethod);
|
|
5169
6365
|
} catch (err) {
|
|
5170
6366
|
logger.warn("[Auth] Could not build enriched refresh response; returning tokens only", { error: err instanceof Error ? err.message : String(err) });
|
|
5171
6367
|
refreshResponse = tokensOnlyResponse;
|
|
5172
6368
|
}
|
|
6369
|
+
if (ops.onAuthenticated && user) ops.onAuthenticated(user, "refresh").catch((err) => {
|
|
6370
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
6371
|
+
});
|
|
5173
6372
|
const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.uid), c, newRefreshToken, config.cookieAuth);
|
|
5174
6373
|
return c.json(finalResponse);
|
|
5175
6374
|
});
|
|
6375
|
+
mountEmailChangeRoutes({
|
|
6376
|
+
router,
|
|
6377
|
+
config,
|
|
6378
|
+
ops,
|
|
6379
|
+
parseBody,
|
|
6380
|
+
requireLiveSession
|
|
6381
|
+
});
|
|
5176
6382
|
mountSessionRoutes({
|
|
5177
6383
|
router,
|
|
5178
6384
|
config,
|
|
@@ -5182,7 +6388,8 @@ function createAuthRoutes(config) {
|
|
|
5182
6388
|
createSessionAndTokens,
|
|
5183
6389
|
applyTransformHook,
|
|
5184
6390
|
requireLiveSession,
|
|
5185
|
-
registerCaptcha: captcha.register
|
|
6391
|
+
registerCaptcha: captcha.register,
|
|
6392
|
+
sendVerificationMail: sendVerificationMailInBackground
|
|
5186
6393
|
});
|
|
5187
6394
|
mountMfaRoutes({
|
|
5188
6395
|
router,
|
|
@@ -5280,10 +6487,11 @@ function createResetPasswordRoute(config) {
|
|
|
5280
6487
|
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
5281
6488
|
revocationRepo: authRepo
|
|
5282
6489
|
}));
|
|
5283
|
-
router.post("/users/:uid/reset-password",
|
|
6490
|
+
router.post("/users/:uid/reset-password", requireScope("users:write"), async (c) => {
|
|
5284
6491
|
const uid = c.req.param("uid");
|
|
5285
6492
|
const existing = await authRepo.getUserById(uid);
|
|
5286
6493
|
if (!existing) throw ApiError.notFound("User not found");
|
|
6494
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
5287
6495
|
let invitationSent = false;
|
|
5288
6496
|
let temporaryPassword;
|
|
5289
6497
|
let emailDeliveryFailed = false;
|
|
@@ -5313,7 +6521,7 @@ function createResetPasswordRoute(config) {
|
|
|
5313
6521
|
invitationSent = hookResult.invitationSent ?? false;
|
|
5314
6522
|
} else if (!!(emailService && emailService.isConfigured())) {
|
|
5315
6523
|
const token = generateSecureToken();
|
|
5316
|
-
const tokenHash = hashToken
|
|
6524
|
+
const tokenHash = hashToken(token);
|
|
5317
6525
|
const expiresAt = new Date(Date.now() + 3600 * 1e3);
|
|
5318
6526
|
await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
|
|
5319
6527
|
try {
|
|
@@ -5361,9 +6569,11 @@ function createResetPasswordRoute(config) {
|
|
|
5361
6569
|
//#endregion
|
|
5362
6570
|
//#region src/auth/admin-roles-route.ts
|
|
5363
6571
|
/**
|
|
5364
|
-
* Admin endpoint for listing
|
|
6572
|
+
* Admin endpoint for listing roles.
|
|
5365
6573
|
*
|
|
5366
|
-
* Mounts: GET /roles
|
|
6574
|
+
* Mounts: GET /roles — the built-in `admin` role and the roles the app
|
|
6575
|
+
* declares under `auth.roles` on the users collection, with the scopes each
|
|
6576
|
+
* holds. Requires `users:read`.
|
|
5367
6577
|
*/
|
|
5368
6578
|
/**
|
|
5369
6579
|
* Create a standalone admin route for listing roles.
|
|
@@ -5379,9 +6589,8 @@ function createAdminRolesRoute(config) {
|
|
|
5379
6589
|
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
5380
6590
|
revocationRepo: authRepo
|
|
5381
6591
|
}));
|
|
5382
|
-
router.get("/roles",
|
|
5383
|
-
|
|
5384
|
-
return c.json({ roles });
|
|
6592
|
+
router.get("/roles", requireScope("users:read"), (c) => {
|
|
6593
|
+
return c.json({ roles: summarizeRoles(getAccessModel()) });
|
|
5385
6594
|
});
|
|
5386
6595
|
return router;
|
|
5387
6596
|
}
|
|
@@ -5396,6 +6605,7 @@ function createAdminRolesRoute(config) {
|
|
|
5396
6605
|
* POST /users
|
|
5397
6606
|
* PUT /users/:uid
|
|
5398
6607
|
* DELETE /users/:uid
|
|
6608
|
+
* DELETE /users/:uid/mfa
|
|
5399
6609
|
* POST /bootstrap
|
|
5400
6610
|
*/
|
|
5401
6611
|
/** Upper bound for `GET /users?ids=…`, so one request can't fan out unbounded. */
|
|
@@ -5413,6 +6623,7 @@ function createAdminUsersRoute(config) {
|
|
|
5413
6623
|
photoURL: u.photoUrl ?? null,
|
|
5414
6624
|
providerId: "custom",
|
|
5415
6625
|
roles,
|
|
6626
|
+
disabled: u.disabled === true,
|
|
5416
6627
|
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
5417
6628
|
updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : u.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
5418
6629
|
};
|
|
@@ -5481,13 +6692,13 @@ function createAdminUsersRoute(config) {
|
|
|
5481
6692
|
}
|
|
5482
6693
|
});
|
|
5483
6694
|
});
|
|
5484
|
-
router.get("/users",
|
|
6695
|
+
router.get("/users", requireScope("users:read"), async (c) => {
|
|
5485
6696
|
const idsParam = c.req.query("ids");
|
|
5486
6697
|
if (idsParam !== void 0) {
|
|
5487
6698
|
const ids = [...new Set(idsParam.split(",").map((id) => id.trim()).filter(Boolean))].slice(0, MAX_USER_IDS_PER_LOOKUP);
|
|
5488
6699
|
const users = (await Promise.all(ids.map(async (id) => {
|
|
5489
6700
|
const result = await authRepo.getUserWithRoles(id);
|
|
5490
|
-
return result ? toAdminUser(result.user, result.roles
|
|
6701
|
+
return result ? toAdminUser(result.user, result.roles) : void 0;
|
|
5491
6702
|
}))).filter((u) => u !== void 0);
|
|
5492
6703
|
return c.json({
|
|
5493
6704
|
users,
|
|
@@ -5521,17 +6732,18 @@ function createAdminUsersRoute(config) {
|
|
|
5521
6732
|
offset: result.offset
|
|
5522
6733
|
});
|
|
5523
6734
|
});
|
|
5524
|
-
router.get("/users/:uid",
|
|
6735
|
+
router.get("/users/:uid", requireScope("users:read"), async (c) => {
|
|
5525
6736
|
const uid = c.req.param("uid");
|
|
5526
6737
|
const result = await authRepo.getUserWithRoles(uid);
|
|
5527
6738
|
if (!result) throw ApiError.notFound("User not found");
|
|
5528
|
-
const adminUser = toAdminUser(result.user, result.roles
|
|
6739
|
+
const adminUser = toAdminUser(result.user, result.roles);
|
|
5529
6740
|
return c.json({ user: adminUser });
|
|
5530
6741
|
});
|
|
5531
|
-
router.post("/users",
|
|
6742
|
+
router.post("/users", requireScope("users:write"), async (c) => {
|
|
5532
6743
|
const body = await c.req.json();
|
|
5533
6744
|
const { email, roles } = body;
|
|
5534
6745
|
if (!email) throw ApiError.badRequest("Email is required");
|
|
6746
|
+
if (roles !== void 0 && Array.isArray(roles)) assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
|
|
5535
6747
|
if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("A user with this email already exists");
|
|
5536
6748
|
const prepResult = await prepareAdminUserValues(body, {
|
|
5537
6749
|
authRepo,
|
|
@@ -5567,14 +6779,24 @@ function createAdminUsersRoute(config) {
|
|
|
5567
6779
|
...delivery
|
|
5568
6780
|
}, 201);
|
|
5569
6781
|
});
|
|
5570
|
-
router.put("/users/:uid",
|
|
6782
|
+
router.put("/users/:uid", requireScope("users:write"), async (c) => {
|
|
5571
6783
|
const uid = c.req.param("uid");
|
|
5572
|
-
const { password, email, displayName, roles } = await c.req.json();
|
|
6784
|
+
const { password, email, displayName, roles, disabled } = await c.req.json();
|
|
5573
6785
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
5574
|
-
if (
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
6786
|
+
if (disabled !== void 0) {
|
|
6787
|
+
if (typeof disabled !== "boolean") throw ApiError.badRequest("`disabled` must be true or false", "INVALID_INPUT");
|
|
6788
|
+
const caller = c.get("user");
|
|
6789
|
+
if (disabled && caller?.uid === uid) throw ApiError.badRequest("Cannot disable your own account", "SELF_DISABLE");
|
|
6790
|
+
if (typeof authRepo.setUserDisabled !== "function") throw new ApiError(501, "NOT_SUPPORTED", "This backend's auth repository cannot disable accounts.");
|
|
6791
|
+
}
|
|
6792
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
6793
|
+
if (roles !== void 0 && Array.isArray(roles)) {
|
|
6794
|
+
assertMayGrantRoles(c, roles.filter((role) => typeof role === "string"));
|
|
6795
|
+
await assertRoleChangesAllowed(authRepo, [{
|
|
6796
|
+
uid,
|
|
6797
|
+
roles
|
|
6798
|
+
}]);
|
|
6799
|
+
}
|
|
5578
6800
|
const updates = {};
|
|
5579
6801
|
if (email !== void 0) updates.email = await normalizeUserEmailChange(authRepo, uid, email);
|
|
5580
6802
|
if (displayName !== void 0) updates.displayName = displayName;
|
|
@@ -5587,14 +6809,54 @@ function createAdminUsersRoute(config) {
|
|
|
5587
6809
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
5588
6810
|
if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
|
|
5589
6811
|
if (roles !== void 0 && Array.isArray(roles)) await authRepo.setUserRoles(uid, roles);
|
|
6812
|
+
if (typeof disabled === "boolean" && authRepo.setUserDisabled) {
|
|
6813
|
+
await authRepo.setUserDisabled(uid, disabled);
|
|
6814
|
+
if (disabled) await revokeAllSessions(authRepo, uid);
|
|
6815
|
+
logger.info("[Security Audit] Account " + (disabled ? "disabled" : "re-enabled") + " by an administrator", {
|
|
6816
|
+
eventType: disabled ? "auth.account.disabled" : "auth.account.enabled",
|
|
6817
|
+
uid,
|
|
6818
|
+
by: c.get("user")?.uid
|
|
6819
|
+
});
|
|
6820
|
+
}
|
|
5590
6821
|
const result = await authRepo.getUserWithRoles(uid);
|
|
5591
|
-
const adminUser = toAdminUser(result.user, result.roles
|
|
6822
|
+
const adminUser = toAdminUser(result.user, result.roles);
|
|
5592
6823
|
return c.json({ user: adminUser });
|
|
5593
6824
|
});
|
|
5594
|
-
|
|
6825
|
+
/**
|
|
6826
|
+
* DELETE /users/:uid/mfa — remove every second factor and recovery code
|
|
6827
|
+
* an account has, and end its sessions.
|
|
6828
|
+
*
|
|
6829
|
+
* The way back in for someone who lost their authenticator and their
|
|
6830
|
+
* codes, which used to be SQL. It lowers the account's protection, so it
|
|
6831
|
+
* is held to what a password reset is held to: `users:write`, never on an
|
|
6832
|
+
* account that outranks the caller, and every session ends — a session
|
|
6833
|
+
* that passed the old factor must not outlive its removal.
|
|
6834
|
+
*/
|
|
6835
|
+
router.delete("/users/:uid/mfa", requireScope("users:write"), async (c) => {
|
|
6836
|
+
const uid = c.req.param("uid");
|
|
6837
|
+
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
6838
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
6839
|
+
const factors = await authRepo.getMfaFactors(uid);
|
|
6840
|
+
for (const factor of factors) await authRepo.deleteMfaFactor(factor.id, uid);
|
|
6841
|
+
await authRepo.deleteAllRecoveryCodes(uid);
|
|
6842
|
+
await revokeAllSessions(authRepo, uid);
|
|
6843
|
+
const caller = c.get("user");
|
|
6844
|
+
logger.info("[Security Audit] Second factors reset by an administrator", {
|
|
6845
|
+
eventType: "auth.mfa.admin_reset",
|
|
6846
|
+
uid,
|
|
6847
|
+
by: caller?.uid,
|
|
6848
|
+
removedFactors: factors.length
|
|
6849
|
+
});
|
|
6850
|
+
return c.json({
|
|
6851
|
+
success: true,
|
|
6852
|
+
removedFactors: factors.length
|
|
6853
|
+
});
|
|
6854
|
+
});
|
|
6855
|
+
router.delete("/users/:uid", requireScope("users:write"), async (c) => {
|
|
5595
6856
|
const uid = c.req.param("uid");
|
|
5596
6857
|
if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
|
|
5597
6858
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
6859
|
+
assertMayManageAccount(c, await authRepo.getUserRoleIds(uid));
|
|
5598
6860
|
await assertUserDeletionsAllowed(authRepo, [uid]);
|
|
5599
6861
|
if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
|
|
5600
6862
|
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
@@ -5614,7 +6876,7 @@ function createAdminUsersRoute(config) {
|
|
|
5614
6876
|
* when the user passes a plain `RebaseAuthConfig` object.
|
|
5615
6877
|
*/
|
|
5616
6878
|
function createBuiltinAuthAdapter(config) {
|
|
5617
|
-
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, captcha, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, enableEmailOtp = false, cookieAuth } = config;
|
|
6879
|
+
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, captcha, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, enableEmailOtp = false, cookieAuth, requireEmailVerification = false, refreshTokenReuseIntervalSeconds, refreshTokenReuse, magicLinkCreatesUsers = false } = config;
|
|
5618
6880
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
5619
6881
|
/**
|
|
5620
6882
|
* The caller's roles as the database has them now.
|
|
@@ -5639,6 +6901,33 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5639
6901
|
throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
|
|
5640
6902
|
}
|
|
5641
6903
|
}
|
|
6904
|
+
/**
|
|
6905
|
+
* The roles a verified access token runs with now, or `null` when it must
|
|
6906
|
+
* not be honoured at all: revoked, or its account deleted. See
|
|
6907
|
+
* `judgeAccessToken`, which both verify paths ask. A repository that
|
|
6908
|
+
* cannot answer refuses the request (503), as `resolveLiveRoles` does.
|
|
6909
|
+
*/
|
|
6910
|
+
async function liveRoles(payload) {
|
|
6911
|
+
let verdict;
|
|
6912
|
+
try {
|
|
6913
|
+
verdict = await judgeAccessToken(authRepository, payload);
|
|
6914
|
+
} catch (error) {
|
|
6915
|
+
logger.warn("[Auth] Could not judge an access token; refusing it", {
|
|
6916
|
+
uid: payload.uid,
|
|
6917
|
+
error
|
|
6918
|
+
});
|
|
6919
|
+
throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
|
|
6920
|
+
}
|
|
6921
|
+
if (!verdict.live) {
|
|
6922
|
+
logger.warn("[Security Audit] Refused an access token", {
|
|
6923
|
+
eventType: verdict.refusal === "revoked" ? "auth.token.revoked" : "auth.token.account_gone",
|
|
6924
|
+
refusal: verdict.refusal,
|
|
6925
|
+
uid: payload.uid
|
|
6926
|
+
});
|
|
6927
|
+
return null;
|
|
6928
|
+
}
|
|
6929
|
+
return verdict.roles ?? await resolveLiveRoles(payload.uid);
|
|
6930
|
+
}
|
|
5642
6931
|
return {
|
|
5643
6932
|
id: "rebase-builtin",
|
|
5644
6933
|
serviceKey,
|
|
@@ -5654,15 +6943,9 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5654
6943
|
};
|
|
5655
6944
|
const payload = await verifyAccessToken(token);
|
|
5656
6945
|
if (!payload) return null;
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
uid: payload.uid
|
|
5661
|
-
});
|
|
5662
|
-
return null;
|
|
5663
|
-
}
|
|
5664
|
-
const roles = await resolveLiveRoles(payload.uid);
|
|
5665
|
-
const isAdmin = hasAdministrativeRole(roles);
|
|
6946
|
+
const roles = await liveRoles(payload);
|
|
6947
|
+
if (!roles) return null;
|
|
6948
|
+
const isAdmin = hasAdminRole(roles);
|
|
5666
6949
|
return {
|
|
5667
6950
|
uid: payload.uid,
|
|
5668
6951
|
email: payload.email ?? "",
|
|
@@ -5684,15 +6967,9 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5684
6967
|
};
|
|
5685
6968
|
const payload = await verifyAccessToken(token);
|
|
5686
6969
|
if (!payload) return null;
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
uid: payload.uid
|
|
5691
|
-
});
|
|
5692
|
-
return null;
|
|
5693
|
-
}
|
|
5694
|
-
const roles = await resolveLiveRoles(payload.uid);
|
|
5695
|
-
const isAdmin = hasAdministrativeRole(roles);
|
|
6970
|
+
const roles = await liveRoles(payload);
|
|
6971
|
+
if (!roles) return null;
|
|
6972
|
+
const isAdmin = hasAdminRole(roles);
|
|
5696
6973
|
return {
|
|
5697
6974
|
uid: payload.uid,
|
|
5698
6975
|
email: payload.email ?? "",
|
|
@@ -5704,6 +6981,34 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5704
6981
|
rawToken: token
|
|
5705
6982
|
};
|
|
5706
6983
|
},
|
|
6984
|
+
async resolveUser(uid) {
|
|
6985
|
+
let user;
|
|
6986
|
+
try {
|
|
6987
|
+
user = await authRepository.getUserById(uid);
|
|
6988
|
+
} catch (error) {
|
|
6989
|
+
if (isDataException(error)) return null;
|
|
6990
|
+
throw error;
|
|
6991
|
+
}
|
|
6992
|
+
if (!user || user.disabled) return null;
|
|
6993
|
+
const roles = await resolveLiveRoles(uid);
|
|
6994
|
+
const isAnonymous = user.isAnonymous === true;
|
|
6995
|
+
let claims;
|
|
6996
|
+
if (resolvedOps.customizeAccessToken) claims = customClaimsOf(await resolvedOps.customizeAccessToken({
|
|
6997
|
+
uid,
|
|
6998
|
+
roles,
|
|
6999
|
+
aal: "aal1"
|
|
7000
|
+
}, user));
|
|
7001
|
+
return {
|
|
7002
|
+
uid,
|
|
7003
|
+
email: user.email,
|
|
7004
|
+
displayName: user.displayName ?? null,
|
|
7005
|
+
photoUrl: user.photoUrl ?? null,
|
|
7006
|
+
roles,
|
|
7007
|
+
isAdmin: hasAdminRole(roles),
|
|
7008
|
+
isAnonymous,
|
|
7009
|
+
...claims ? { claims } : {}
|
|
7010
|
+
};
|
|
7011
|
+
},
|
|
5707
7012
|
userManagement: createUserManagementFromRepo(authRepository, resolvedOps),
|
|
5708
7013
|
createAuthRoutes() {
|
|
5709
7014
|
return createAuthRoutes({
|
|
@@ -5721,7 +7026,11 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5721
7026
|
enableMagicLink,
|
|
5722
7027
|
enableEmailOtp,
|
|
5723
7028
|
cookieAuth,
|
|
5724
|
-
captcha
|
|
7029
|
+
captcha,
|
|
7030
|
+
requireEmailVerification,
|
|
7031
|
+
refreshTokenReuseIntervalSeconds,
|
|
7032
|
+
refreshTokenReuse,
|
|
7033
|
+
magicLinkCreatesUsers
|
|
5725
7034
|
});
|
|
5726
7035
|
},
|
|
5727
7036
|
createAdminRoutes() {
|
|
@@ -5832,7 +7141,8 @@ function createUserManagementFromRepo(repo, resolvedOps) {
|
|
|
5832
7141
|
passwordHash,
|
|
5833
7142
|
displayName: data.displayName,
|
|
5834
7143
|
photoUrl: data.photoUrl,
|
|
5835
|
-
metadata: data.metadata
|
|
7144
|
+
metadata: data.metadata,
|
|
7145
|
+
...data.emailVerified !== void 0 ? { emailVerified: data.emailVerified } : {}
|
|
5836
7146
|
};
|
|
5837
7147
|
if (resolvedOps.beforeUserCreate) createData = await resolvedOps.beforeUserCreate(createData);
|
|
5838
7148
|
const user = await repo.createUser(createData);
|
|
@@ -7111,9 +8421,76 @@ function resolveRateLimitStoreKind(env) {
|
|
|
7111
8421
|
throw new RateLimitStoreConfigurationError(`REBASE_RATE_LIMIT_STORE="${raw}" is not a rate-limit store.`, "Use \"sql\" to share counts across processes, or \"memory\" (the default) to keep them in this process. A deployment running more than one replica or more than one role wants \"sql\" — otherwise each process enforces the whole limit by itself.");
|
|
7112
8422
|
}
|
|
7113
8423
|
//#endregion
|
|
8424
|
+
//#region src/auth/api-keys/legacy-permissions.ts
|
|
8425
|
+
var OPERATIONS = [
|
|
8426
|
+
"read",
|
|
8427
|
+
"write",
|
|
8428
|
+
"delete"
|
|
8429
|
+
];
|
|
8430
|
+
function isOperation(value) {
|
|
8431
|
+
return OPERATIONS.includes(value);
|
|
8432
|
+
}
|
|
8433
|
+
/** The admin surfaces an `admin: true` key reached. */
|
|
8434
|
+
var ADMIN_KEY_SCOPES = [
|
|
8435
|
+
"users:read",
|
|
8436
|
+
"users:write",
|
|
8437
|
+
"schema:read",
|
|
8438
|
+
"schema:write",
|
|
8439
|
+
"backups:read",
|
|
8440
|
+
"cron:read",
|
|
8441
|
+
"cron:write",
|
|
8442
|
+
"logs:read"
|
|
8443
|
+
];
|
|
8444
|
+
/** Read a stored `permissions` value — JSON text or already-parsed — into entries. */
|
|
8445
|
+
function parseStoredPermissions(value) {
|
|
8446
|
+
let raw = value;
|
|
8447
|
+
if (typeof raw === "string") try {
|
|
8448
|
+
raw = JSON.parse(raw);
|
|
8449
|
+
} catch {
|
|
8450
|
+
return [];
|
|
8451
|
+
}
|
|
8452
|
+
if (!Array.isArray(raw)) return [];
|
|
8453
|
+
const entries = [];
|
|
8454
|
+
for (const item of raw) {
|
|
8455
|
+
if (typeof item !== "object" || item === null) continue;
|
|
8456
|
+
if (!("collection" in item) || typeof item.collection !== "string") continue;
|
|
8457
|
+
const operations = "operations" in item && Array.isArray(item.operations) ? item.operations.filter((op) => typeof op === "string") : [];
|
|
8458
|
+
entries.push({
|
|
8459
|
+
collection: item.collection,
|
|
8460
|
+
operations
|
|
8461
|
+
});
|
|
8462
|
+
}
|
|
8463
|
+
return entries;
|
|
8464
|
+
}
|
|
8465
|
+
/** The scopes and RLS roles a stored key now holds. Never more than it did. */
|
|
8466
|
+
function scopesFromStoredPermissions(permissions, admin) {
|
|
8467
|
+
const scopes = /* @__PURE__ */ new Set();
|
|
8468
|
+
for (const { collection, operations } of permissions) {
|
|
8469
|
+
const ops = operations.filter(isOperation);
|
|
8470
|
+
if (collection === "*") {
|
|
8471
|
+
for (const op of ops) {
|
|
8472
|
+
scopes.add(`data:${op}`);
|
|
8473
|
+
scopes.add(`storage:${op}`);
|
|
8474
|
+
}
|
|
8475
|
+
if (ops.includes("write")) scopes.add("functions:invoke");
|
|
8476
|
+
} else if (collection === "storage") for (const op of ops) scopes.add(`storage:${op}`);
|
|
8477
|
+
else if (collection === "functions") {
|
|
8478
|
+
if (ops.includes("write")) scopes.add("functions:invoke");
|
|
8479
|
+
} else if (collection.startsWith("functions/")) {
|
|
8480
|
+
const name = collection.slice(10);
|
|
8481
|
+
if (name && ops.includes("write")) scopes.add(`functions:invoke:${name}`);
|
|
8482
|
+
} else if (collection) for (const op of ops) scopes.add(`data:${op}:${collection}`);
|
|
8483
|
+
}
|
|
8484
|
+
if (admin) for (const scope of ADMIN_KEY_SCOPES) scopes.add(scope);
|
|
8485
|
+
return {
|
|
8486
|
+
scopes: [...scopes],
|
|
8487
|
+
roles: admin ? ["admin"] : []
|
|
8488
|
+
};
|
|
8489
|
+
}
|
|
8490
|
+
//#endregion
|
|
7114
8491
|
//#region src/auth/api-keys/api-key-store.ts
|
|
7115
8492
|
/**
|
|
7116
|
-
* Database operations for
|
|
8493
|
+
* Database operations for API keys.
|
|
7117
8494
|
*
|
|
7118
8495
|
* Uses the DataDriver's `admin.executeSql` capability (same pattern as
|
|
7119
8496
|
* the cron-store and ensure-tables modules). All data lives in the
|
|
@@ -7149,9 +8526,11 @@ function toMasked(row) {
|
|
|
7149
8526
|
return {
|
|
7150
8527
|
id: row.id,
|
|
7151
8528
|
name: row.name,
|
|
8529
|
+
kind: row.kind,
|
|
7152
8530
|
key_prefix: row.key_prefix,
|
|
7153
|
-
|
|
7154
|
-
|
|
8531
|
+
scopes: row.scopes,
|
|
8532
|
+
roles: row.roles,
|
|
8533
|
+
owner_uid: row.owner_uid,
|
|
7155
8534
|
rate_limit: row.rate_limit,
|
|
7156
8535
|
created_by: row.created_by,
|
|
7157
8536
|
created_at: row.created_at,
|
|
@@ -7161,30 +8540,40 @@ function toMasked(row) {
|
|
|
7161
8540
|
revoked_at: row.revoked_at
|
|
7162
8541
|
};
|
|
7163
8542
|
}
|
|
8543
|
+
/** A JSONB string array as the driver hands it back: parsed, or JSON text. */
|
|
8544
|
+
function stringList(value) {
|
|
8545
|
+
let raw = value;
|
|
8546
|
+
if (typeof raw === "string") try {
|
|
8547
|
+
raw = JSON.parse(raw);
|
|
8548
|
+
} catch {
|
|
8549
|
+
return [];
|
|
8550
|
+
}
|
|
8551
|
+
return Array.isArray(raw) ? raw.filter((entry) => typeof entry === "string") : [];
|
|
8552
|
+
}
|
|
7164
8553
|
/**
|
|
7165
8554
|
* Parse a raw DB row into the typed `ApiKey` shape.
|
|
7166
8555
|
*/
|
|
7167
8556
|
function rowToApiKey(row) {
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
}
|
|
7172
|
-
permissions = [];
|
|
7173
|
-
}
|
|
8557
|
+
const grant = row.scopes === null || row.scopes === void 0 ? scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true) : {
|
|
8558
|
+
scopes: stringList(row.scopes),
|
|
8559
|
+
roles: stringList(row.roles)
|
|
8560
|
+
};
|
|
7174
8561
|
return {
|
|
7175
|
-
id: row.id,
|
|
7176
|
-
name: row.name,
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
8562
|
+
id: String(row.id),
|
|
8563
|
+
name: String(row.name),
|
|
8564
|
+
kind: row.kind === "personal" ? "personal" : "service",
|
|
8565
|
+
key_prefix: String(row.key_prefix),
|
|
8566
|
+
key_hash: String(row.key_hash),
|
|
8567
|
+
scopes: grant.scopes,
|
|
8568
|
+
roles: grant.roles,
|
|
8569
|
+
owner_uid: typeof row.owner_uid === "string" ? row.owner_uid : null,
|
|
7181
8570
|
rate_limit: row.rate_limit !== null && row.rate_limit !== void 0 ? Number(row.rate_limit) : null,
|
|
7182
|
-
created_by: row.created_by,
|
|
7183
|
-
created_at: new Date(row.created_at).toISOString(),
|
|
7184
|
-
updated_at: new Date(row.updated_at).toISOString(),
|
|
7185
|
-
last_used_at: row.last_used_at ? new Date(row.last_used_at).toISOString() : null,
|
|
7186
|
-
expires_at: row.expires_at ? new Date(row.expires_at).toISOString() : null,
|
|
7187
|
-
revoked_at: row.revoked_at ? new Date(row.revoked_at).toISOString() : null
|
|
8571
|
+
created_by: String(row.created_by),
|
|
8572
|
+
created_at: new Date(String(row.created_at)).toISOString(),
|
|
8573
|
+
updated_at: new Date(String(row.updated_at)).toISOString(),
|
|
8574
|
+
last_used_at: row.last_used_at ? new Date(String(row.last_used_at)).toISOString() : null,
|
|
8575
|
+
expires_at: row.expires_at ? new Date(String(row.expires_at)).toISOString() : null,
|
|
8576
|
+
revoked_at: row.revoked_at ? new Date(String(row.revoked_at)).toISOString() : null
|
|
7188
8577
|
};
|
|
7189
8578
|
}
|
|
7190
8579
|
/**
|
|
@@ -7200,6 +8589,26 @@ function createApiKeyStore(driver) {
|
|
|
7200
8589
|
}
|
|
7201
8590
|
const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
|
|
7202
8591
|
const ddl = createDdlBootstrapper(exec, "api-key-store");
|
|
8592
|
+
/**
|
|
8593
|
+
* Give every row stored before scopes existed the scopes it now holds.
|
|
8594
|
+
*
|
|
8595
|
+
* Idempotent and race-safe: a row is only written while its `scopes` is
|
|
8596
|
+
* still NULL, so two instances backfilling together write the same value
|
|
8597
|
+
* once. The `permissions` and `admin` columns are left as they are, so a
|
|
8598
|
+
* runtime that predates scopes still reads its own keys after a rollback.
|
|
8599
|
+
*/
|
|
8600
|
+
async function backfillScopes() {
|
|
8601
|
+
const rows = await exec(`SELECT id, permissions, admin FROM ${TABLE} WHERE scopes IS NULL`);
|
|
8602
|
+
for (const row of rows) {
|
|
8603
|
+
const { scopes, roles } = scopesFromStoredPermissions(parseStoredPermissions(row.permissions), row.admin === true);
|
|
8604
|
+
await exec(`UPDATE ${TABLE} SET scopes = $1::jsonb, roles = $2::jsonb WHERE id = $3 AND scopes IS NULL`, { params: [
|
|
8605
|
+
JSON.stringify(scopes),
|
|
8606
|
+
JSON.stringify(roles),
|
|
8607
|
+
row.id
|
|
8608
|
+
] });
|
|
8609
|
+
}
|
|
8610
|
+
if (rows.length > 0) logger.info(`[api-key-store] Gave ${rows.length} stored API key(s) their scopes.`);
|
|
8611
|
+
}
|
|
7203
8612
|
return {
|
|
7204
8613
|
async ensureTable() {
|
|
7205
8614
|
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
@@ -7207,10 +8616,12 @@ function createApiKeyStore(driver) {
|
|
|
7207
8616
|
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
7208
8617
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
7209
8618
|
name TEXT NOT NULL,
|
|
8619
|
+
kind TEXT NOT NULL DEFAULT 'service',
|
|
7210
8620
|
key_prefix TEXT NOT NULL,
|
|
7211
8621
|
key_hash TEXT NOT NULL UNIQUE,
|
|
7212
|
-
|
|
7213
|
-
|
|
8622
|
+
scopes JSONB,
|
|
8623
|
+
roles JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
8624
|
+
owner_uid TEXT,
|
|
7214
8625
|
rate_limit INTEGER,
|
|
7215
8626
|
created_by TEXT NOT NULL,
|
|
7216
8627
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
@@ -7228,34 +8639,49 @@ function createApiKeyStore(driver) {
|
|
|
7228
8639
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
|
7229
8640
|
ON ${TABLE}(key_prefix)
|
|
7230
8641
|
`);
|
|
7231
|
-
await ddl.ensureObject(`Adding ${TABLE}.
|
|
7232
|
-
ALTER TABLE ${TABLE}
|
|
7233
|
-
|
|
8642
|
+
await ddl.ensureObject(`Adding ${TABLE}.kind`, `
|
|
8643
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'service'
|
|
8644
|
+
`);
|
|
8645
|
+
await ddl.ensureObject(`Adding ${TABLE}.scopes`, `
|
|
8646
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS scopes JSONB
|
|
8647
|
+
`);
|
|
8648
|
+
await ddl.ensureObject(`Adding ${TABLE}.roles`, `
|
|
8649
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS roles JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
8650
|
+
`);
|
|
8651
|
+
await ddl.ensureObject(`Adding ${TABLE}.owner_uid`, `
|
|
8652
|
+
ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS owner_uid TEXT
|
|
8653
|
+
`);
|
|
8654
|
+
await ddl.ensureObject("Creating idx_api_keys_owner", `
|
|
8655
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_owner
|
|
8656
|
+
ON ${TABLE}(owner_uid) WHERE owner_uid IS NOT NULL
|
|
7234
8657
|
`);
|
|
7235
8658
|
if (!await ddl.isReadable(TABLE)) {
|
|
7236
8659
|
logger.error(`❌ [api-key-store] ${TABLE} is unavailable — every API-key authenticated request will be rejected on this instance, and the table could not be taken back off the end-user role.`);
|
|
7237
8660
|
return;
|
|
7238
8661
|
}
|
|
7239
8662
|
await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
|
|
8663
|
+
if ((await exec(`SELECT 1 FROM information_schema.columns
|
|
8664
|
+
WHERE table_schema = 'rebase' AND table_name = 'api_keys' AND column_name = 'permissions'`)).length > 0) await ddl.step("Backfilling API key scopes", backfillScopes);
|
|
7240
8665
|
logger.debug("✅ API keys table ready");
|
|
7241
8666
|
},
|
|
7242
|
-
async createApiKey(
|
|
8667
|
+
async createApiKey(key, createdBy) {
|
|
7243
8668
|
const plaintext = generateApiKey();
|
|
7244
8669
|
const hash = hashKey(plaintext);
|
|
7245
8670
|
const prefix = keyPrefix(plaintext);
|
|
7246
|
-
const permissionsJson = JSON.stringify(request.permissions);
|
|
7247
8671
|
return {
|
|
7248
|
-
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, key_prefix, key_hash,
|
|
7249
|
-
VALUES ($1, $2, $3, $4
|
|
8672
|
+
...toMasked(rowToApiKey((await exec(`INSERT INTO ${TABLE} (name, kind, key_prefix, key_hash, scopes, roles, owner_uid, rate_limit, created_by, expires_at)
|
|
8673
|
+
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10)
|
|
7250
8674
|
RETURNING *`, { params: [
|
|
7251
|
-
|
|
8675
|
+
key.name,
|
|
8676
|
+
key.kind,
|
|
7252
8677
|
prefix,
|
|
7253
8678
|
hash,
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
8679
|
+
JSON.stringify(key.scopes),
|
|
8680
|
+
JSON.stringify(key.kind === "personal" ? [] : key.roles),
|
|
8681
|
+
key.owner_uid,
|
|
8682
|
+
key.rate_limit,
|
|
7257
8683
|
createdBy,
|
|
7258
|
-
|
|
8684
|
+
key.expires_at
|
|
7259
8685
|
] }))[0])),
|
|
7260
8686
|
key: plaintext
|
|
7261
8687
|
};
|
|
@@ -7267,11 +8693,14 @@ function createApiKeyStore(driver) {
|
|
|
7267
8693
|
if (rows.length === 0) return null;
|
|
7268
8694
|
return rowToApiKey(rows[0]);
|
|
7269
8695
|
},
|
|
7270
|
-
async listApiKeys() {
|
|
7271
|
-
return (await exec(`
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
8696
|
+
async listApiKeys(filter) {
|
|
8697
|
+
return (filter.kind === "personal" ? await exec(`SELECT * FROM ${TABLE}
|
|
8698
|
+
WHERE kind = 'personal' AND owner_uid = $1
|
|
8699
|
+
ORDER BY created_at DESC`, { params: [filter.owner_uid] }) : await exec(`
|
|
8700
|
+
SELECT * FROM ${TABLE}
|
|
8701
|
+
WHERE kind = 'service'
|
|
8702
|
+
ORDER BY created_at DESC
|
|
8703
|
+
`)).map((r) => toMasked(rowToApiKey(r)));
|
|
7275
8704
|
},
|
|
7276
8705
|
async getApiKeyById(id) {
|
|
7277
8706
|
const rows = await exec(`SELECT * FROM ${TABLE}
|
|
@@ -7288,13 +8717,13 @@ function createApiKeyStore(driver) {
|
|
|
7288
8717
|
setClauses.push(`name = $${paramIdx++}`);
|
|
7289
8718
|
params.push(updates.name);
|
|
7290
8719
|
}
|
|
7291
|
-
if (updates.
|
|
7292
|
-
setClauses.push(`
|
|
7293
|
-
params.push(JSON.stringify(updates.
|
|
8720
|
+
if (updates.scopes !== void 0) {
|
|
8721
|
+
setClauses.push(`scopes = $${paramIdx++}::jsonb`);
|
|
8722
|
+
params.push(JSON.stringify(updates.scopes));
|
|
7294
8723
|
}
|
|
7295
|
-
if (updates.
|
|
7296
|
-
setClauses.push(`
|
|
7297
|
-
params.push(updates.
|
|
8724
|
+
if (updates.roles !== void 0) {
|
|
8725
|
+
setClauses.push(`roles = $${paramIdx++}::jsonb`);
|
|
8726
|
+
params.push(JSON.stringify(updates.roles));
|
|
7298
8727
|
}
|
|
7299
8728
|
if (updates.rate_limit !== void 0) if (updates.rate_limit !== null) {
|
|
7300
8729
|
setClauses.push(`rate_limit = $${paramIdx++}`);
|
|
@@ -7309,16 +8738,19 @@ function createApiKeyStore(driver) {
|
|
|
7309
8738
|
params.push(id);
|
|
7310
8739
|
const rows = await exec(`UPDATE ${TABLE}
|
|
7311
8740
|
SET ${setClauses.join(", ")}
|
|
7312
|
-
WHERE id = $${paramIdx}
|
|
8741
|
+
WHERE id = $${paramIdx} AND kind = 'service'
|
|
7313
8742
|
RETURNING *`, { params });
|
|
7314
8743
|
if (rows.length === 0) return null;
|
|
7315
8744
|
return toMasked(rowToApiKey(rows[0]));
|
|
7316
8745
|
},
|
|
7317
|
-
async revokeApiKey(id) {
|
|
7318
|
-
return (await exec(`UPDATE ${TABLE}
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
8746
|
+
async revokeApiKey(id, owner_uid) {
|
|
8747
|
+
return (owner_uid === void 0 ? await exec(`UPDATE ${TABLE}
|
|
8748
|
+
SET revoked_at = NOW(), updated_at = NOW()
|
|
8749
|
+
WHERE id = $1 AND kind = 'service' AND revoked_at IS NULL
|
|
8750
|
+
RETURNING id`, { params: [id] }) : await exec(`UPDATE ${TABLE}
|
|
8751
|
+
SET revoked_at = NOW(), updated_at = NOW()
|
|
8752
|
+
WHERE id = $1 AND kind = 'personal' AND owner_uid = $2 AND revoked_at IS NULL
|
|
8753
|
+
RETURNING id`, { params: [id, owner_uid] })).length > 0;
|
|
7322
8754
|
},
|
|
7323
8755
|
async updateLastUsed(id) {
|
|
7324
8756
|
try {
|
|
@@ -7332,62 +8764,156 @@ function createApiKeyStore(driver) {
|
|
|
7332
8764
|
};
|
|
7333
8765
|
}
|
|
7334
8766
|
//#endregion
|
|
7335
|
-
//#region src/auth/api-keys/
|
|
8767
|
+
//#region src/auth/api-keys/key-grant.ts
|
|
7336
8768
|
/**
|
|
7337
|
-
*
|
|
8769
|
+
* What a new or changed key may hold, decided against whoever asked for it.
|
|
7338
8770
|
*
|
|
7339
|
-
*
|
|
7340
|
-
*
|
|
7341
|
-
*
|
|
8771
|
+
* One rule for every door that mints a credential: **nothing is minted with
|
|
8772
|
+
* more than its minter holds.** A key's scopes must be within the creator's
|
|
8773
|
+
* own; a service key's RLS roles must be roles the creator holds, unless the
|
|
8774
|
+
* creator is an admin; and `keys:*` never goes on a key at all.
|
|
7342
8775
|
*
|
|
7343
8776
|
* @module
|
|
7344
8777
|
*/
|
|
8778
|
+
var TARGET_LISTS = {
|
|
8779
|
+
collection: "collections",
|
|
8780
|
+
bucket: "buckets",
|
|
8781
|
+
function: "functions"
|
|
8782
|
+
};
|
|
8783
|
+
/** Read a `scopes` body field: a non-empty array of strings, deduplicated. */
|
|
8784
|
+
function readScopesField(value) {
|
|
8785
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((entry) => typeof entry !== "string")) throw ApiError.badRequest("scopes must be a non-empty array of scope strings, e.g. [\"data:read\", \"data:write:posts\"]", "INVALID_INPUT");
|
|
8786
|
+
return [...new Set(value.map((entry) => entry.trim()))];
|
|
8787
|
+
}
|
|
8788
|
+
/** Read a `roles` body field: an array of non-empty strings, deduplicated, without `service`. */
|
|
8789
|
+
function readRolesField(value) {
|
|
8790
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) throw ApiError.badRequest("roles must be an array of role names", "INVALID_INPUT");
|
|
8791
|
+
return [...new Set(value.map((entry) => entry.trim()))].filter((role) => role !== "service");
|
|
8792
|
+
}
|
|
7345
8793
|
/**
|
|
7346
|
-
*
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
]
|
|
7359
|
-
for (const
|
|
8794
|
+
* Refuse scopes that are malformed, unknown, aimed at a target that does not
|
|
8795
|
+
* exist, for key management, or beyond what `minterScopes` covers.
|
|
8796
|
+
*/
|
|
8797
|
+
function assertScopesGrantable(requested, minterScopes, model, targets) {
|
|
8798
|
+
const problems = validateScopes(requested, model);
|
|
8799
|
+
if (problems.length > 0) throw new ApiError(400, "INVALID_SCOPES", `Not valid scopes: ${problems.map((p) => `"${p.scope}" (${p.reason})`).join(", ")}. Valid scopes are ${knownScopes(model).join(", ")}; data, storage and function scopes take a target after a second colon, e.g. "data:read:posts".`, {
|
|
8800
|
+
problems,
|
|
8801
|
+
validScopes: knownScopes(model)
|
|
8802
|
+
});
|
|
8803
|
+
const management = requested.filter((entry) => KEY_MANAGEMENT_SCOPES.includes(parseScope(entry)?.scope ?? ""));
|
|
8804
|
+
if (management.length > 0) throw new ApiError(400, "KEY_MANAGEMENT_SCOPE", `${management.join(", ")} cannot go on a key: a key that manages keys could mint its own successor. Manage keys as a person, or with the service key.`, { scopes: management });
|
|
8805
|
+
if (targets) {
|
|
8806
|
+
const unknown = [];
|
|
8807
|
+
for (const entry of requested) {
|
|
8808
|
+
const parsed = parseScope(entry);
|
|
8809
|
+
if (!parsed?.target || !isBuiltInScope(parsed.scope)) continue;
|
|
8810
|
+
const kind = SCOPE_TARGETS[parsed.scope];
|
|
8811
|
+
if (!kind) continue;
|
|
8812
|
+
if (!targets[TARGET_LISTS[kind]]().includes(parsed.target)) unknown.push(entry);
|
|
8813
|
+
}
|
|
8814
|
+
if (unknown.length > 0) throw new ApiError(400, "UNKNOWN_SCOPE_TARGET", `These scopes name something this backend does not serve: ${unknown.join(", ")}. A target is a collection slug, a storage source id or a function name.`, {
|
|
8815
|
+
scopes: unknown,
|
|
8816
|
+
collections: targets.collections(),
|
|
8817
|
+
buckets: targets.buckets(),
|
|
8818
|
+
functions: targets.functions()
|
|
8819
|
+
});
|
|
7360
8820
|
}
|
|
7361
|
-
|
|
8821
|
+
const beyond = scopesBeyond(requested, minterScopes);
|
|
8822
|
+
if (beyond.length > 0) throw new ApiError(403, "SCOPE_EXCEEDS_CREATOR", `A key cannot hold more than the account creating it, and you do not hold ${beyond.join(", ")}.`, { scopes: beyond });
|
|
8823
|
+
}
|
|
8824
|
+
/**
|
|
8825
|
+
* Refuse RLS roles the minter does not hold. An admin may give any role — they
|
|
8826
|
+
* already read every row — and anyone else only their own.
|
|
8827
|
+
*/
|
|
8828
|
+
function assertRolesGrantable(requested, minterRoles) {
|
|
8829
|
+
if (hasAdminRole(minterRoles)) return;
|
|
8830
|
+
const beyond = requested.filter((role) => !minterRoles.includes(role));
|
|
8831
|
+
if (beyond.length > 0) throw new ApiError(403, "ROLE_EXCEEDS_CREATOR", `A key cannot run as a role the account creating it does not hold: ${beyond.join(", ")}.`, { roles: beyond });
|
|
8832
|
+
}
|
|
8833
|
+
/** Read an optional `expires_at`: absent, null, or a future ISO-8601 instant. */
|
|
8834
|
+
function readExpiresAt(value, requireFuture) {
|
|
8835
|
+
if (value === void 0) return void 0;
|
|
8836
|
+
if (value === null) return null;
|
|
8837
|
+
const parsed = typeof value === "string" ? new Date(value) : /* @__PURE__ */ new Date(NaN);
|
|
8838
|
+
if (isNaN(parsed.getTime())) throw ApiError.badRequest("expires_at must be a valid ISO-8601 date", "INVALID_INPUT");
|
|
8839
|
+
if (requireFuture && parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
|
|
8840
|
+
return parsed.toISOString();
|
|
8841
|
+
}
|
|
8842
|
+
/** Read an optional `rate_limit`: absent, null, or a positive integer. */
|
|
8843
|
+
function readRateLimit(value) {
|
|
8844
|
+
if (value === void 0) return void 0;
|
|
8845
|
+
if (value === null) return null;
|
|
8846
|
+
if (typeof value !== "number" || value < 1 || !Number.isInteger(value)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
|
|
8847
|
+
return value;
|
|
8848
|
+
}
|
|
8849
|
+
/** Read a key name: a non-empty string, trimmed. */
|
|
8850
|
+
function readName(value) {
|
|
8851
|
+
if (typeof value !== "string" || value.trim().length === 0) throw ApiError.badRequest("Name is required", "INVALID_INPUT");
|
|
8852
|
+
return value.trim();
|
|
7362
8853
|
}
|
|
8854
|
+
//#endregion
|
|
8855
|
+
//#region src/auth/api-keys/api-key-routes.ts
|
|
7363
8856
|
/**
|
|
7364
|
-
*
|
|
8857
|
+
* Routes for managing API keys.
|
|
8858
|
+
*
|
|
8859
|
+
* - {@link createApiKeyRoutes} — the project's service keys, under
|
|
8860
|
+
* `/api/admin/api-keys`, for holders of `keys:read` / `keys:write`.
|
|
8861
|
+
* - {@link createPersonalKeyRoutes} — the caller's own personal keys, under
|
|
8862
|
+
* `/api/auth/keys`, when the app enables `auth.personalKeys`.
|
|
7365
8863
|
*
|
|
7366
|
-
*
|
|
7367
|
-
*
|
|
7368
|
-
*
|
|
7369
|
-
*
|
|
7370
|
-
*
|
|
7371
|
-
|
|
7372
|
-
|
|
8864
|
+
* All routes return masked keys (never the hash). The full plaintext key is
|
|
8865
|
+
* returned exactly once, in the response that creates it. No API key may call
|
|
8866
|
+
* either router.
|
|
8867
|
+
*
|
|
8868
|
+
* @module
|
|
8869
|
+
*/
|
|
8870
|
+
/**
|
|
8871
|
+
* Refuse API-key-authenticated requests to the key-management routes.
|
|
7373
8872
|
*
|
|
7374
|
-
*
|
|
7375
|
-
*
|
|
8873
|
+
* A key that could manage keys could mint its own successor, widen itself, or
|
|
8874
|
+
* revoke the keys other integrations run on — and revoking it would undo none
|
|
8875
|
+
* of that, because the keys it minted are ordinary rows with no link back to
|
|
8876
|
+
* it. So key management is for a person, or the service key. Reads are
|
|
8877
|
+
* refused alongside writes: a listing of every key's name, prefix and scopes
|
|
8878
|
+
* is reconnaissance for exactly that.
|
|
7376
8879
|
*
|
|
7377
|
-
*
|
|
7378
|
-
*
|
|
7379
|
-
*
|
|
7380
|
-
* edges left to get wrong.
|
|
8880
|
+
* `keys:*` can never be granted to a key either (`key-grant.ts`); this guard is
|
|
8881
|
+
* what makes the rule hold for a key minted before that, and for personal
|
|
8882
|
+
* keys, whose routes need no scope.
|
|
7381
8883
|
*/
|
|
7382
8884
|
var rejectApiKeyAuth = async (c, next) => {
|
|
7383
|
-
if (c.get("apiKey")) throw ApiError.forbidden("API keys cannot manage API keys. Authenticate as
|
|
8885
|
+
if (c.get("apiKey")) throw ApiError.forbidden("API keys cannot manage API keys. Authenticate as a person, or use the service key — REBASE_SERVICE_KEY in this server's environment, or `rebase cloud env reveal REBASE_SERVICE_KEY` on Rebase Cloud.", "API_KEY_SELF_MANAGEMENT_FORBIDDEN");
|
|
7384
8886
|
return next();
|
|
7385
8887
|
};
|
|
8888
|
+
/** The caller's uid and roles, as the auth gate resolved them. */
|
|
8889
|
+
function caller(c) {
|
|
8890
|
+
const user = c.get("user");
|
|
8891
|
+
if (!user || typeof user !== "object" || !("uid" in user) || typeof user.uid !== "string") throw ApiError.unauthenticated("Authentication required");
|
|
8892
|
+
const roles = "roles" in user && Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [];
|
|
8893
|
+
const isAnonymous = "isAnonymous" in user && user.isAnonymous === true;
|
|
8894
|
+
return {
|
|
8895
|
+
uid: user.uid,
|
|
8896
|
+
roles,
|
|
8897
|
+
isAnonymous
|
|
8898
|
+
};
|
|
8899
|
+
}
|
|
8900
|
+
async function readBody(c) {
|
|
8901
|
+
let body;
|
|
8902
|
+
try {
|
|
8903
|
+
body = await c.req.json();
|
|
8904
|
+
} catch {
|
|
8905
|
+
throw ApiError.badRequest("Request body must be JSON", "INVALID_INPUT");
|
|
8906
|
+
}
|
|
8907
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw ApiError.badRequest("Request body must be a JSON object", "INVALID_INPUT");
|
|
8908
|
+
return Object.fromEntries(Object.entries(body));
|
|
8909
|
+
}
|
|
7386
8910
|
/**
|
|
7387
|
-
*
|
|
8911
|
+
* The project's service keys: `GET` needs `keys:read`, everything else
|
|
8912
|
+
* `keys:write`.
|
|
7388
8913
|
*/
|
|
7389
8914
|
function createApiKeyRoutes(options) {
|
|
7390
|
-
const { store, serviceKey, resolveRoles, revocationRepo } = options;
|
|
8915
|
+
const { store, serviceKey, resolveRoles, revocationRepo, targets } = options;
|
|
8916
|
+
const model = options.accessModel ?? getAccessModel;
|
|
7391
8917
|
const router = new Hono();
|
|
7392
8918
|
router.onError(errorHandler);
|
|
7393
8919
|
router.use("/*", createRequireAuth({
|
|
@@ -7396,72 +8922,127 @@ function createApiKeyRoutes(options) {
|
|
|
7396
8922
|
revocationRepo
|
|
7397
8923
|
}));
|
|
7398
8924
|
router.use("/*", rejectApiKeyAuth);
|
|
7399
|
-
router.use("/*",
|
|
8925
|
+
router.use("/*", requireScopeByMethod({
|
|
8926
|
+
read: "keys:read",
|
|
8927
|
+
write: "keys:write"
|
|
8928
|
+
}));
|
|
7400
8929
|
router.get("/", async (c) => {
|
|
7401
|
-
const keys = await store.listApiKeys();
|
|
8930
|
+
const keys = await store.listApiKeys({ kind: "service" });
|
|
7402
8931
|
return c.json({ keys });
|
|
7403
8932
|
});
|
|
7404
8933
|
router.post("/", async (c) => {
|
|
7405
|
-
const
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7414
|
-
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
expires_at: expires_at ?? null
|
|
7425
|
-
};
|
|
7426
|
-
const keyWithSecret = await store.createApiKey(request, createdBy);
|
|
7427
|
-
return c.json({ key: keyWithSecret }, 201);
|
|
8934
|
+
const body = await readBody(c);
|
|
8935
|
+
const name = readName(body.name);
|
|
8936
|
+
const scopes = readScopesField(body.scopes);
|
|
8937
|
+
const roles = body.roles === void 0 ? [] : readRolesField(body.roles);
|
|
8938
|
+
const rate_limit = readRateLimit(body.rate_limit) ?? null;
|
|
8939
|
+
const expires_at = readExpiresAt(body.expires_at, true) ?? null;
|
|
8940
|
+
const minter = caller(c);
|
|
8941
|
+
assertScopesGrantable(scopes, callerScopes(c), model(), targets);
|
|
8942
|
+
assertRolesGrantable(roles, minter.roles);
|
|
8943
|
+
const key = await store.createApiKey({
|
|
8944
|
+
name,
|
|
8945
|
+
kind: "service",
|
|
8946
|
+
scopes,
|
|
8947
|
+
roles,
|
|
8948
|
+
owner_uid: null,
|
|
8949
|
+
rate_limit,
|
|
8950
|
+
expires_at
|
|
8951
|
+
}, minter.uid);
|
|
8952
|
+
return c.json({ key }, 201);
|
|
7428
8953
|
});
|
|
7429
8954
|
router.get("/:id", async (c) => {
|
|
7430
|
-
const
|
|
7431
|
-
|
|
7432
|
-
if (!key) throw ApiError.notFound("API key not found");
|
|
8955
|
+
const key = await store.getApiKeyById(c.req.param("id"));
|
|
8956
|
+
if (!key || key.kind !== "service") throw ApiError.notFound("API key not found");
|
|
7433
8957
|
return c.json({ key });
|
|
7434
8958
|
});
|
|
7435
8959
|
router.put("/:id", async (c) => {
|
|
7436
8960
|
const id = c.req.param("id");
|
|
7437
|
-
const
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
if (
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
if (admin !== void 0 && typeof admin !== "boolean") throw ApiError.badRequest("admin must be a boolean", "INVALID_INPUT");
|
|
7445
|
-
if (rate_limit !== void 0 && rate_limit !== null) {
|
|
7446
|
-
if (typeof rate_limit !== "number" || rate_limit < 1 || !Number.isInteger(rate_limit)) throw ApiError.badRequest("rate_limit must be a positive integer or null", "INVALID_INPUT");
|
|
8961
|
+
const body = await readBody(c);
|
|
8962
|
+
const minter = caller(c);
|
|
8963
|
+
const updates = {};
|
|
8964
|
+
if (body.name !== void 0) updates.name = readName(body.name);
|
|
8965
|
+
if (body.scopes !== void 0) {
|
|
8966
|
+
updates.scopes = readScopesField(body.scopes);
|
|
8967
|
+
assertScopesGrantable(updates.scopes, callerScopes(c), model(), targets);
|
|
7447
8968
|
}
|
|
7448
|
-
if (
|
|
7449
|
-
|
|
7450
|
-
|
|
8969
|
+
if (body.roles !== void 0) {
|
|
8970
|
+
updates.roles = readRolesField(body.roles);
|
|
8971
|
+
assertRolesGrantable(updates.roles, minter.roles);
|
|
7451
8972
|
}
|
|
7452
|
-
const
|
|
7453
|
-
if (
|
|
7454
|
-
|
|
7455
|
-
if (
|
|
7456
|
-
if (rate_limit !== void 0) updates.rate_limit = rate_limit;
|
|
7457
|
-
if (expires_at !== void 0) updates.expires_at = expires_at;
|
|
8973
|
+
const rateLimit = readRateLimit(body.rate_limit);
|
|
8974
|
+
if (rateLimit !== void 0) updates.rate_limit = rateLimit;
|
|
8975
|
+
const expiresAt = readExpiresAt(body.expires_at, false);
|
|
8976
|
+
if (expiresAt !== void 0) updates.expires_at = expiresAt;
|
|
7458
8977
|
const key = await store.updateApiKey(id, updates);
|
|
7459
8978
|
if (!key) throw ApiError.notFound("API key not found");
|
|
7460
8979
|
return c.json({ key });
|
|
7461
8980
|
});
|
|
7462
8981
|
router.delete("/:id", async (c) => {
|
|
7463
|
-
|
|
7464
|
-
|
|
8982
|
+
if (!await store.revokeApiKey(c.req.param("id"))) throw ApiError.notFound("API key not found or already revoked");
|
|
8983
|
+
return c.json({ success: true });
|
|
8984
|
+
});
|
|
8985
|
+
return router;
|
|
8986
|
+
}
|
|
8987
|
+
/**
|
|
8988
|
+
* The caller's own keys. Each acts as the caller — their account, their roles
|
|
8989
|
+
* as they are when the key is used — and holds no scope the caller does not.
|
|
8990
|
+
*
|
|
8991
|
+
* For a signed-in account only: not a key, not the service key (it has no
|
|
8992
|
+
* account to act as) and not a guest, whose account is one sign-out from gone.
|
|
8993
|
+
*/
|
|
8994
|
+
function createPersonalKeyRoutes(options) {
|
|
8995
|
+
const { store, enabled, serviceKey, resolveRoles, revocationRepo, targets } = options;
|
|
8996
|
+
const model = options.accessModel ?? getAccessModel;
|
|
8997
|
+
const router = new Hono();
|
|
8998
|
+
router.onError(errorHandler);
|
|
8999
|
+
if (!enabled) {
|
|
9000
|
+
router.all("/*", () => {
|
|
9001
|
+
throw ApiError.forbidden("Personal API keys are switched off on this backend. Set `personalKeys: true` in the auth block of the users collection to let accounts create them.", "PERSONAL_KEYS_DISABLED");
|
|
9002
|
+
});
|
|
9003
|
+
return router;
|
|
9004
|
+
}
|
|
9005
|
+
router.use("/*", createRequireAuth({
|
|
9006
|
+
serviceKey,
|
|
9007
|
+
resolveRoles,
|
|
9008
|
+
revocationRepo
|
|
9009
|
+
}));
|
|
9010
|
+
router.use("/*", rejectApiKeyAuth);
|
|
9011
|
+
router.use("/*", async (c, next) => {
|
|
9012
|
+
const me = caller(c);
|
|
9013
|
+
if (me.uid === "service") throw ApiError.forbidden("The service key has no account for a personal key to act as. Create a service key under /api/admin/api-keys instead.", "PERSONAL_KEY_NEEDS_ACCOUNT");
|
|
9014
|
+
if (me.isAnonymous) throw ApiError.forbidden("A guest session cannot create API keys. Sign in to an account first.", "PERSONAL_KEY_NEEDS_ACCOUNT");
|
|
9015
|
+
return next();
|
|
9016
|
+
});
|
|
9017
|
+
router.get("/", async (c) => {
|
|
9018
|
+
const keys = await store.listApiKeys({
|
|
9019
|
+
kind: "personal",
|
|
9020
|
+
owner_uid: caller(c).uid
|
|
9021
|
+
});
|
|
9022
|
+
return c.json({ keys });
|
|
9023
|
+
});
|
|
9024
|
+
router.post("/", async (c) => {
|
|
9025
|
+
const body = await readBody(c);
|
|
9026
|
+
if (body.roles !== void 0) throw ApiError.badRequest("A personal key runs as your account's roles and takes none of its own.", "INVALID_INPUT");
|
|
9027
|
+
if (body.rate_limit !== void 0) throw ApiError.badRequest("A personal key uses the server's API-key rate limit and cannot set its own.", "INVALID_INPUT");
|
|
9028
|
+
const name = readName(body.name);
|
|
9029
|
+
const scopes = readScopesField(body.scopes);
|
|
9030
|
+
const expires_at = readExpiresAt(body.expires_at, true) ?? null;
|
|
9031
|
+
const me = caller(c);
|
|
9032
|
+
assertScopesGrantable(scopes, callerScopes(c), model(), targets);
|
|
9033
|
+
const key = await store.createApiKey({
|
|
9034
|
+
name,
|
|
9035
|
+
kind: "personal",
|
|
9036
|
+
scopes,
|
|
9037
|
+
roles: [],
|
|
9038
|
+
owner_uid: me.uid,
|
|
9039
|
+
rate_limit: null,
|
|
9040
|
+
expires_at
|
|
9041
|
+
}, me.uid);
|
|
9042
|
+
return c.json({ key }, 201);
|
|
9043
|
+
});
|
|
9044
|
+
router.delete("/:id", async (c) => {
|
|
9045
|
+
if (!await store.revokeApiKey(c.req.param("id"), caller(c).uid)) throw ApiError.notFound("API key not found or already revoked");
|
|
7465
9046
|
return c.json({ success: true });
|
|
7466
9047
|
});
|
|
7467
9048
|
return router;
|
|
@@ -7512,12 +9093,16 @@ function createCustomAuthAdapter(options) {
|
|
|
7512
9093
|
//#endregion
|
|
7513
9094
|
//#region src/auth/index.ts
|
|
7514
9095
|
var auth_exports = /* @__PURE__ */ __exportAll({
|
|
9096
|
+
AccessModelError: () => AccessModelError,
|
|
7515
9097
|
DEFAULT_CAPTCHA_ROUTES: () => DEFAULT_CAPTCHA_ROUTES,
|
|
7516
9098
|
MemoryRateLimitStore: () => MemoryRateLimitStore,
|
|
7517
9099
|
RateLimitStoreConfigurationError: () => RateLimitStoreConfigurationError,
|
|
9100
|
+
accessModelFromCollections: () => accessModelFromCollections,
|
|
7518
9101
|
apiKeyKeyGenerator: () => apiKeyKeyGenerator,
|
|
7519
9102
|
buildBuiltinAuthCapabilities: () => buildBuiltinAuthCapabilities,
|
|
7520
9103
|
buildCaptchaMiddlewares: () => buildCaptchaMiddlewares,
|
|
9104
|
+
callerScopes: () => callerScopes,
|
|
9105
|
+
configureAccess: () => configureAccess,
|
|
7521
9106
|
configureJwt: () => configureJwt,
|
|
7522
9107
|
createAdapterAuthMiddleware: () => createAdapterAuthMiddleware,
|
|
7523
9108
|
createApiKeyRoutes: () => createApiKeyRoutes,
|
|
@@ -7539,6 +9124,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7539
9124
|
createJwksRoutes: () => createJwksRoutes,
|
|
7540
9125
|
createLinkedinProvider: () => createLinkedinProvider,
|
|
7541
9126
|
createMicrosoftProvider: () => createMicrosoftProvider,
|
|
9127
|
+
createPersonalKeyRoutes: () => createPersonalKeyRoutes,
|
|
7542
9128
|
createRateLimiter: () => createRateLimiter,
|
|
7543
9129
|
createResetPasswordRoute: () => createResetPasswordRoute,
|
|
7544
9130
|
createSlackProvider: () => createSlackProvider,
|
|
@@ -7555,17 +9141,18 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7555
9141
|
generateRefreshToken: () => generateRefreshToken,
|
|
7556
9142
|
generateSecurePassword: () => generateSecurePassword,
|
|
7557
9143
|
generateSecureToken: () => generateSecureToken,
|
|
9144
|
+
getAccessModel: () => getAccessModel,
|
|
7558
9145
|
getAccessTokenExpiry: () => getAccessTokenExpiry,
|
|
7559
9146
|
getJwks: () => getJwks,
|
|
7560
9147
|
getRefreshTokenExpiry: () => getRefreshTokenExpiry,
|
|
7561
9148
|
hasAsymmetricSigningKey: () => hasAsymmetricSigningKey,
|
|
9149
|
+
hasScope: () => hasScope,
|
|
7562
9150
|
hashPassword: () => hashPassword,
|
|
7563
9151
|
hashRefreshToken: () => hashRefreshToken,
|
|
7564
|
-
hashToken: () => hashToken
|
|
9152
|
+
hashToken: () => hashToken,
|
|
7565
9153
|
httpMethodToOperation: () => httpMethodToOperation,
|
|
7566
9154
|
isApiKeyToken: () => isApiKeyToken,
|
|
7567
9155
|
isJwtConfigured: () => isJwtConfigured,
|
|
7568
|
-
isOperationAllowed: () => isOperationAllowed,
|
|
7569
9156
|
isRedirectUriAllowed: () => isRedirectUriAllowed,
|
|
7570
9157
|
mountMagicLinkRoutes: () => mountMagicLinkRoutes,
|
|
7571
9158
|
oauthCodeFlowSchema: () => oauthCodeFlowSchema,
|
|
@@ -7576,6 +9163,9 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7576
9163
|
queryTokenAuth: () => queryTokenAuth,
|
|
7577
9164
|
requireAdmin: () => requireAdmin,
|
|
7578
9165
|
requireAuth: () => requireAuth,
|
|
9166
|
+
requireScope: () => requireScope,
|
|
9167
|
+
requireScopeByMethod: () => requireScopeByMethod,
|
|
9168
|
+
resolveApiKey: () => resolveApiKey,
|
|
7579
9169
|
resolveAuthHooks: () => resolveAuthHooks,
|
|
7580
9170
|
resolveCaptchaVerifier: () => resolveCaptchaVerifier,
|
|
7581
9171
|
resolveRateLimitStoreKind: () => resolveRateLimitStoreKind,
|
|
@@ -7590,6 +9180,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7590
9180
|
verifyPassword: () => verifyPassword
|
|
7591
9181
|
});
|
|
7592
9182
|
//#endregion
|
|
7593
|
-
export {
|
|
9183
|
+
export { queryTokenAuth as $, createDataRateLimiter as A, getEmailChangeNoticeTemplate as At, SMTPEmailService as B, escapeHtml as Bt, pkceTokenParams as C, scopeDataDriver as Ct, isBootstrapWindowOpen as D, generateSecurePassword as Dt, createBuiltinAuthAdapter as E, completeUserCreation as Et, activeDevEmailSink as F, getPasswordResetTemplate as Ft, verifyPassword as G, isPublicStorageKey as Gt, resolveAuthHooks as H, raw as Ht, clearActiveDevEmailSink as I, getUserInvitationTemplate as It, createRequireAuth as J, IMPERSONATE_HEADER as Jt, createAdapterAuthMiddleware as K, isPublicStoragePath as Kt, createDevEmailSink as L, getWelcomeEmailTemplate as Lt, defaultAuthLimiter as M, getEmailOtpTemplate as Mt, setSharedRateLimitStore as N, getEmailVerificationTemplate as Nt, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as O, assertEmailLinkBases as Ot, MemoryRateLimitStore as P, getMagicLinkTemplate as Pt, publicObjectAuth as Q, extractLinks as R, resolveEmailBranding as Rt, oauthCodeFlowSchema as S, SERVICE_IDENTITY as St, createJwksRoutes as T, isAccessTokenRevoked as Tt, hashPassword as U, httpMethodToOperation as Ut, createEmailService as V, html as Vt, validatePasswordStrength as W, PUBLIC_STORAGE_PREFIX as Wt, fileTokenAuth as X, extractUserFromToken as Y, optionalAuth as Z, tryVerifyOidcIdToken as _, hasScope as _t, createApiKeyStore as a, decideImpersonation as at, createLinkedinProvider as b, extractBearerToken as bt, createSpotifyProvider as c, createFunctionScopeGuard as ct, createGitLabProvider as d, resolveApiKey as dt, requireAdmin as et, createDiscordProvider as f, validateApiKey as ft, createMicrosoftProvider as g, getAccessModel as gt, createAppleProvider as h, configureAccess as ht, createPersonalKeyRoutes as i, storageRequestWildcard as it, createRateLimiter as j, getEmailChangeTemplate as jt, createDataRateLimitCheck as k, resolveEmailLinkBase as kt, createSlackProvider as l, createTusScopeGuard as lt, createFacebookProvider as m, callerScopes as mt, createCustomAuthAdapter as n, requestedStorageObject as nt, resolveRateLimitStoreKind as o, refuseUnhonouredImpersonation as ot, createTwitterProvider as p, accessModelFromCollections as pt, createAuthMiddleware as q, storageObjectPath as qt, createApiKeyRoutes as r, storageObjectOfPath as rt, createSqlRateLimitStore as s, createApiKeyPreAuth as st, auth_exports as t, requireAuth as tt, createBitbucketProvider as u, isApiKeyToken as ut, verifyOidcIdToken as v, requireScope as vt, providerVerifiedEmail as w, supportsRlsScoping as wt, createGoogleProvider as x, safeCompare as xt, createGitHubProvider as y, requireScopeByMethod as yt, registerDevEmailSink as z, RawHtml as zt };
|
|
7594
9184
|
|
|
7595
|
-
//# sourceMappingURL=auth-
|
|
9185
|
+
//# sourceMappingURL=auth-DMLngxn_.js.map
|