@rebasepro/server 0.21.2-canary.g1ea48be → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-CjrA4PMo.js} +37 -23
- package/dist/GCSStorageController-CjrA4PMo.js.map +1 -0
- package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-B6pKDNVj.js} +2 -2
- package/dist/{S3StorageController-Dcuf8lMA.js.map → S3StorageController-B6pKDNVj.js.map} +1 -1
- package/dist/api/ast-schema-editor.d.ts +35 -0
- package/dist/api/live-schema-routes.d.ts +14 -0
- package/dist/api/rest/api-generator.d.ts +117 -35
- package/dist/api/rest/auth-collection-writes.d.ts +85 -0
- package/dist/api/rest/field-access-query.d.ts +6 -2
- package/dist/api/rest/idempotency.d.ts +7 -1
- package/dist/api/rest/nested-write-access.d.ts +46 -0
- package/dist/api/rest/write-validation.d.ts +32 -0
- package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-Mvr50v_S.js} +104 -4
- package/dist/ast-schema-editor-Mvr50v_S.js.map +1 -0
- package/dist/auth/address-ownership.d.ts +53 -0
- package/dist/auth/admin-user-ops.d.ts +35 -2
- package/dist/auth/auth-hooks.d.ts +4 -0
- package/dist/auth/captcha.d.ts +5 -0
- package/dist/auth/interfaces.d.ts +36 -6
- package/dist/auth/jwt.d.ts +17 -0
- package/dist/auth/oauth-signin-policy.d.ts +25 -8
- package/dist/auth/rate-limiter.d.ts +31 -1
- package/dist/auth/session-routes.d.ts +7 -0
- package/dist/auth/token-revocation.d.ts +4 -1
- package/dist/{auth-DZXQRmYD.js → auth-B-GIMpDG.js} +643 -230
- package/dist/auth-B-GIMpDG.js.map +1 -0
- package/dist/backend-DTAOsLQc.js +30 -0
- package/dist/backend-DTAOsLQc.js.map +1 -0
- package/dist/backup/backup-common.d.ts +19 -0
- package/dist/{backup-DzI9jLwc.js → backup-D7YR94N3.js} +69 -8
- package/dist/backup-D7YR94N3.js.map +1 -0
- package/dist/boot/driver.d.ts +10 -0
- package/dist/boot/env.d.ts +2 -2
- package/dist/boot/fetch-bundle.d.ts +18 -1
- package/dist/boot/rls-audit-option.d.ts +26 -0
- package/dist/boot/sources.d.ts +1 -0
- package/dist/{contract-routes-eLxV0le1.js → contract-routes-CbFjuBwa.js} +2 -2
- package/dist/{contract-routes-eLxV0le1.js.map → contract-routes-CbFjuBwa.js.map} +1 -1
- package/dist/cron/cron-routes.d.ts +7 -2
- package/dist/cron/cron-scheduler.d.ts +128 -8
- package/dist/cron/cron-store.d.ts +72 -8
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-CQjvjpEw.js → cron-loader-DfTj2Hbi.js} +2 -2
- package/dist/{cron-loader-CQjvjpEw.js.map → cron-loader-DfTj2Hbi.js.map} +1 -1
- package/dist/{cron-routes-cv0hSd-O.js → cron-routes-eE8nif_b.js} +33 -12
- package/dist/cron-routes-eE8nif_b.js.map +1 -0
- package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-B0pLfAix.js} +394 -68
- package/dist/cron-scheduler-B0pLfAix.js.map +1 -0
- package/dist/{cron-store-Bdm7JqFB.js → cron-store-TcoGz-xS.js} +133 -12
- package/dist/cron-store-TcoGz-xS.js.map +1 -0
- package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-C6mo0Kmz.js} +2 -25
- package/dist/ddl-bootstrap-C6mo0Kmz.js.map +1 -0
- package/dist/email/link-base.d.ts +5 -4
- package/dist/email/smtp-email-service.d.ts +13 -1
- package/dist/email/templates.d.ts +9 -0
- package/dist/email/types.d.ts +3 -2
- package/dist/env.d.ts +24 -5
- package/dist/{history-recorder-CYfkqP2X.js → history-recorder-B4MpJfJK.js} +8 -6
- package/dist/history-recorder-B4MpJfJK.js.map +1 -0
- package/dist/{history-store-B1CBwLfi.js → history-store-BhxWOuz9.js} +2 -2
- package/dist/{history-store-B1CBwLfi.js.map → history-store-BhxWOuz9.js.map} +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.es.js +2769 -878
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +5 -2
- package/dist/init/global-callbacks.d.ts +21 -0
- package/dist/init/shutdown.d.ts +8 -3
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/job-queue.d.ts +23 -2
- package/dist/jobs/job-store.d.ts +37 -5
- package/dist/jobs/types.d.ts +8 -6
- package/dist/{jobs-2fO2BI8P.js → jobs-CazMYhyy.js} +305 -166
- package/dist/jobs-CazMYhyy.js.map +1 -0
- package/dist/{jwt-C4OW-DNq.js → jwt-DnQHNFCl.js} +77 -25
- package/dist/{jwt-C4OW-DNq.js.map → jwt-DnQHNFCl.js.map} +1 -1
- package/dist/{keys-Qfc4XieN.js → keys-CogCQpxG.js} +18 -3
- package/dist/{keys-Qfc4XieN.js.map → keys-CogCQpxG.js.map} +1 -1
- package/dist/{logs-routes-3EEzPjhl.js → logs-routes-Bj4TYYUl.js} +7 -4
- package/dist/{logs-routes-3EEzPjhl.js.map → logs-routes-Bj4TYYUl.js.map} +1 -1
- package/dist/mcp/mcp-routes.d.ts +38 -2
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-routes.d.ts +27 -0
- package/dist/mcp/oauth-store.d.ts +29 -13
- package/dist/metrics/history-recorder.d.ts +1 -1
- package/dist/{openapi-generator-D8ZIN_ss.js → openapi-generator-O_O24MAT.js} +34 -12
- package/dist/openapi-generator-O_O24MAT.js.map +1 -0
- package/dist/{query-parser-CoIOmflr.js → query-parser-DGRVFNM3.js} +39 -30
- package/dist/query-parser-DGRVFNM3.js.map +1 -0
- package/dist/rls-audit/index.d.ts +4 -0
- package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-C5-lh_jO.js} +2 -2
- package/dist/{schema-editor-routes-DdLihzp0.js.map → schema-editor-routes-C5-lh_jO.js.map} +1 -1
- package/dist/{src-BYax9_rm.js → src-pmvW7BFx.js} +189 -14
- package/dist/src-pmvW7BFx.js.map +1 -0
- package/dist/{src-Br6ARbs6.js → src-vkcwKXbT.js} +2 -27
- package/dist/{src-Br6ARbs6.js.map → src-vkcwKXbT.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +11 -1
- package/dist/storage/keys.d.ts +12 -0
- package/dist/storage/rendition-cache.d.ts +11 -1
- package/dist/storage/request-keys.d.ts +67 -0
- package/dist/storage/types.d.ts +17 -1
- package/dist/types-BfKcm9do.js.map +1 -1
- package/package.json +9 -9
- package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
- package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
- package/dist/auth-DZXQRmYD.js.map +0 -1
- package/dist/backup-DzI9jLwc.js.map +0 -1
- package/dist/cron-routes-cv0hSd-O.js.map +0 -1
- package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
- package/dist/cron-store-Bdm7JqFB.js.map +0 -1
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
- package/dist/history-recorder-CYfkqP2X.js.map +0 -1
- package/dist/jobs-2fO2BI8P.js.map +0 -1
- package/dist/openapi-generator-D8ZIN_ss.js.map +0 -1
- package/dist/query-parser-CoIOmflr.js.map +0 -1
- package/dist/src-BYax9_rm.js.map +0 -1
|
@@ -3,20 +3,21 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
7
|
-
import "./src-
|
|
8
|
-
import {
|
|
6
|
+
import { F as revokeInternalTableSql, ct as ANONYMOUS_USER_ID, lt as isAnonymousUid } from "./src-pmvW7BFx.js";
|
|
7
|
+
import "./src-vkcwKXbT.js";
|
|
8
|
+
import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
|
|
9
9
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
10
10
|
import { r as logger } from "./logger-DO2PZc4i.js";
|
|
11
11
|
import { r as errorHandler, t as ApiError } from "./errors-DWsX4yTd.js";
|
|
12
|
-
import { a as resolveListLimitParam } from "./query-parser-
|
|
13
|
-
import { C as require_jsonwebtoken, E as randomInt$1, O as sha256Hex, _ as verifyAccessToken, b as verifyMfaPendingToken, c as getAccessTokenExpiry, d as getRefreshTokenTtlMs, f as hasAsymmetricSigningKey, i as generateDownloadToken, l as getJwks, m as isJwtConfigured, n as configureJwt, o as generateMfaPendingToken, p as hashRefreshToken, r as generateAccessToken, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as constantTimeEqual } from "./jwt-
|
|
12
|
+
import { a as resolveListLimitParam } from "./query-parser-DGRVFNM3.js";
|
|
13
|
+
import { C as require_jsonwebtoken, E as randomInt$1, O as sha256Hex, _ as verifyAccessToken, b as verifyMfaPendingToken, c as getAccessTokenExpiry, d as getRefreshTokenTtlMs, f as hasAsymmetricSigningKey, i as generateDownloadToken, l as getJwks, m as isJwtConfigured, n as configureJwt, o as generateMfaPendingToken, p as hashRefreshToken, r as generateAccessToken, s as generateRefreshToken, t as MAX_COOKIE_AGE_MS, u as getRefreshTokenExpiry, v as verifyDownloadToken, w as constantTimeEqual } from "./jwt-DnQHNFCl.js";
|
|
14
14
|
import { n as hasAdministrativeRole, r as isAdministrativeRole, t as ADMINISTRATIVE_ROLES } from "./admin-roles-vYdp_Pil.js";
|
|
15
|
-
import {
|
|
15
|
+
import { a as canonicalStorageId, l as tryCanonicalStorageKey } from "./keys-CogCQpxG.js";
|
|
16
|
+
import { n as createDdlBootstrapper } from "./ddl-bootstrap-C6mo0Kmz.js";
|
|
16
17
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
17
18
|
import { Hono } from "hono";
|
|
18
19
|
import { promisify } from "util";
|
|
19
|
-
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID, scrypt, timingSafeEqual } from "crypto";
|
|
20
|
+
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
20
21
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
21
22
|
import { createMiddleware } from "hono/factory";
|
|
22
23
|
import { z } from "zod";
|
|
@@ -553,6 +554,16 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
|
|
|
553
554
|
};
|
|
554
555
|
}
|
|
555
556
|
/**
|
|
557
|
+
* How long an invitation's set-password link stays valid.
|
|
558
|
+
*
|
|
559
|
+
* One number for both halves: `finalizeAdminUserCreation` mints the token for
|
|
560
|
+
* this long and the template below tells the invitee this long. They were two
|
|
561
|
+
* literals, and the email said "1 hour" for a token that lived 24 — so an
|
|
562
|
+
* invitee who opened it the next morning believed the link was dead.
|
|
563
|
+
*/
|
|
564
|
+
var USER_INVITATION_LINK_TTL_MS = 1440 * 60 * 1e3;
|
|
565
|
+
var USER_INVITATION_LINK_TTL_HOURS = USER_INVITATION_LINK_TTL_MS / (3600 * 1e3);
|
|
566
|
+
/**
|
|
556
567
|
* Default user invitation email template
|
|
557
568
|
* Sent when an admin creates a new user account
|
|
558
569
|
*/
|
|
@@ -593,7 +604,7 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = DEFAULT_APP_N
|
|
|
593
604
|
</p>
|
|
594
605
|
|
|
595
606
|
<div style="${styles.warning}">
|
|
596
|
-
⏰ This link will expire in
|
|
607
|
+
⏰ This link will expire in ${USER_INVITATION_LINK_TTL_HOURS} hours for security reasons.
|
|
597
608
|
</div>
|
|
598
609
|
|
|
599
610
|
<div style="${styles.footer}">
|
|
@@ -616,7 +627,7 @@ An account has been created for you on ${appName}.
|
|
|
616
627
|
Click this link to set your password and get started:
|
|
617
628
|
${setPasswordUrl}
|
|
618
629
|
|
|
619
|
-
This link will expire in
|
|
630
|
+
This link will expire in ${USER_INVITATION_LINK_TTL_HOURS} hours for security reasons.
|
|
620
631
|
|
|
621
632
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
622
633
|
`.trim();
|
|
@@ -838,12 +849,86 @@ No action is needed.
|
|
|
838
849
|
};
|
|
839
850
|
}
|
|
840
851
|
//#endregion
|
|
852
|
+
//#region src/email/link-base.ts
|
|
853
|
+
/**
|
|
854
|
+
* The config fields consulted for each link kind, most specific first.
|
|
855
|
+
*
|
|
856
|
+
* `resetPasswordUrl` is the general fallback because it is the only one any boot
|
|
857
|
+
* path sets, and in every real deployment all three point at the same frontend.
|
|
858
|
+
*/
|
|
859
|
+
var LINK_BASE_FIELDS = {
|
|
860
|
+
resetPassword: ["resetPasswordUrl"],
|
|
861
|
+
verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
|
|
862
|
+
magicLink: ["magicLinkUrl", "resetPasswordUrl"]
|
|
863
|
+
};
|
|
864
|
+
/**
|
|
865
|
+
* True when `value` is an absolute `http(s)` URL — the only kind a mail client
|
|
866
|
+
* can follow. A protocol-relative or path-only base is treated as missing.
|
|
867
|
+
*/
|
|
868
|
+
function isAbsoluteHttpUrl(value) {
|
|
869
|
+
try {
|
|
870
|
+
const url = new URL(value);
|
|
871
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
872
|
+
} catch {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/** Drop a trailing slash so callers can append `/verify-email?token=…`. */
|
|
877
|
+
function normalizeBase(value) {
|
|
878
|
+
return value.replace(/\/+$/, "");
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* The absolute base URL for a link kind, or `""` when the config has none.
|
|
882
|
+
*
|
|
883
|
+
* Callers append their path to the result. `""` is only reachable on a config
|
|
884
|
+
* that never went through {@link assertEmailLinkBases} — a hand-constructed
|
|
885
|
+
* `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
|
|
886
|
+
* misconfiguration cannot turn a password-reset request into a 500 that
|
|
887
|
+
* distinguishes existing accounts from missing ones.
|
|
888
|
+
*/
|
|
889
|
+
function resolveEmailLinkBase(config, kind) {
|
|
890
|
+
if (!config) return "";
|
|
891
|
+
for (const field of LINK_BASE_FIELDS[kind]) {
|
|
892
|
+
const value = config[field];
|
|
893
|
+
if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
|
|
894
|
+
}
|
|
895
|
+
return "";
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Throw when an email configuration cannot produce a followable link of every
|
|
899
|
+
* kind it sends.
|
|
900
|
+
*
|
|
901
|
+
* Called from `createEmailService`, i.e. from every boot path that wires email
|
|
902
|
+
* up (the managed runtime, both driver bootstrappers, and any app that passes
|
|
903
|
+
* `auth.email`). A base that is set but relative is reported separately from one
|
|
904
|
+
* that is missing, because the two have different fixes.
|
|
905
|
+
*/
|
|
906
|
+
function assertEmailLinkBases(config) {
|
|
907
|
+
const relative = [
|
|
908
|
+
"resetPasswordUrl",
|
|
909
|
+
"verifyEmailUrl",
|
|
910
|
+
"magicLinkUrl"
|
|
911
|
+
].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
|
|
912
|
+
if (relative.length > 0) throw new Error(`Email is configured with a relative link base: ${relative.map((f) => `email.${f}`).join(", ")}. Emailed links must be absolute (e.g. "https://app.example.com") — a mail client has no base document to resolve a relative href against, so the link is dead.`);
|
|
913
|
+
const unresolved = Object.keys(LINK_BASE_FIELDS).filter((kind) => !resolveEmailLinkBase(config, kind));
|
|
914
|
+
if (unresolved.length === Object.keys(LINK_BASE_FIELDS).length) throw new Error("Email is configured but no base URL for emailed links is set. Password-reset, verification and magic-link emails would carry relative hrefs, which are dead links in every mail client. Set FRONTEND_URL (or `auth.email.resetPasswordUrl`) to an absolute URL such as \"https://app.example.com\".");
|
|
915
|
+
if (unresolved.length > 0) throw new Error(`Email is configured without a base URL for ${unresolved.map((kind) => LINK_KIND_LABELS[kind]).join(" and ")} emails, so their links would be relative hrefs — dead in every mail client. Those links fall back to \`auth.email.resetPasswordUrl\` only: set FRONTEND_URL (or \`auth.email.resetPasswordUrl\`) to an absolute URL such as "https://app.example.com".`);
|
|
916
|
+
}
|
|
917
|
+
/** How each link kind is named in a boot error. */
|
|
918
|
+
var LINK_KIND_LABELS = {
|
|
919
|
+
resetPassword: "password-reset",
|
|
920
|
+
verifyEmail: "verification",
|
|
921
|
+
magicLink: "magic-link"
|
|
922
|
+
};
|
|
923
|
+
//#endregion
|
|
841
924
|
//#region src/auth/admin-user-ops.ts
|
|
842
925
|
/**
|
|
843
926
|
* Admin User Operations
|
|
844
927
|
*
|
|
845
928
|
* Shared utilities and orchestration for admin-initiated user management
|
|
846
|
-
* (user creation via REST API, password reset via admin panel)
|
|
929
|
+
* (user creation via REST API, password reset via admin panel), and the rules
|
|
930
|
+
* user administration holds to whichever door it arrives by — the admin user
|
|
931
|
+
* routes, or the auth collection through the data API.
|
|
847
932
|
*
|
|
848
933
|
* Hook resolution order:
|
|
849
934
|
* 1. Collection-level hook (`auth.onCreateUser` on the collection) — closest to the data
|
|
@@ -965,9 +1050,9 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
|
|
|
965
1050
|
if (!!(ctx.emailService && ctx.emailService.isConfigured())) try {
|
|
966
1051
|
const token = generateSecureToken();
|
|
967
1052
|
const tokenHash = hashToken$1(token);
|
|
968
|
-
const expiresAt = new Date(Date.now() +
|
|
1053
|
+
const expiresAt = new Date(Date.now() + USER_INVITATION_LINK_TTL_MS);
|
|
969
1054
|
await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
|
|
970
|
-
const setPasswordUrl = `${ctx.emailConfig
|
|
1055
|
+
const setPasswordUrl = `${resolveEmailLinkBase(ctx.emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
971
1056
|
const { appName, logoUrl } = resolveEmailBranding(ctx.emailConfig);
|
|
972
1057
|
const templateFn = ctx.emailConfig?.templates?.userInvitation;
|
|
973
1058
|
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
@@ -1029,6 +1114,88 @@ async function completeUserCreation(prepared, finalize) {
|
|
|
1029
1114
|
...result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
1030
1115
|
};
|
|
1031
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Refuse a change that leaves no administrator: `leaving` are users losing the
|
|
1119
|
+
* role (demoted, or deleted), and the project must keep at least one.
|
|
1120
|
+
*
|
|
1121
|
+
* Judged over the whole set, so a bulk write that takes every admin at once is
|
|
1122
|
+
* refused even though each of its rows alone would leave one behind.
|
|
1123
|
+
*/
|
|
1124
|
+
async function assertAdministratorsRemain(authRepo, leaving, message) {
|
|
1125
|
+
const admins = [];
|
|
1126
|
+
for (const uid of new Set(leaving)) if ((await authRepo.getUserRoleIds(uid)).includes("admin")) admins.push(uid);
|
|
1127
|
+
if (admins.length === 0) return;
|
|
1128
|
+
const { total } = await authRepo.listUsersPaginated({
|
|
1129
|
+
roleId: "admin",
|
|
1130
|
+
limit: 1
|
|
1131
|
+
});
|
|
1132
|
+
if (total <= admins.length) throw ApiError.forbidden(message, "LAST_ADMIN");
|
|
1133
|
+
}
|
|
1134
|
+
/** Refuse deleting these users when that would leave the project with no administrator. */
|
|
1135
|
+
async function assertUserDeletionsAllowed(authRepo, uids) {
|
|
1136
|
+
await assertAdministratorsRemain(authRepo, uids, uids.length > 1 ? "Cannot delete every administrator" : "Cannot delete the last administrator");
|
|
1137
|
+
}
|
|
1138
|
+
/** Refuse setting these users' roles when that would leave the project with no administrator. */
|
|
1139
|
+
async function assertRoleChangesAllowed(authRepo, changes) {
|
|
1140
|
+
await assertAdministratorsRemain(authRepo, changes.filter((change) => !change.roles.includes("admin")).map((change) => change.uid), changes.length > 1 ? "Cannot demote every administrator" : "Cannot demote the last administrator");
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* A user's new email, in the form sign-in looks it up by — or a 409 when
|
|
1144
|
+
* another account holds it.
|
|
1145
|
+
*
|
|
1146
|
+
* Sign-in, password reset and magic links all find the account by
|
|
1147
|
+
* `normalizeEmail(input)`. An address stored as typed is one those lookups
|
|
1148
|
+
* never match: its owner is locked out, and registering the address again hits
|
|
1149
|
+
* the case-insensitive unique index.
|
|
1150
|
+
*/
|
|
1151
|
+
async function normalizeUserEmailChange(authRepo, uid, email) {
|
|
1152
|
+
const normalized = normalizeEmail(email);
|
|
1153
|
+
const holder = await authRepo.getUserByEmail(normalized);
|
|
1154
|
+
if (holder && holder.id !== uid) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
1155
|
+
return normalized;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Run `afterUserDelete` without letting it fail the deletion it follows: the
|
|
1159
|
+
* user is already gone, so a failing listener is logged, not answered.
|
|
1160
|
+
*/
|
|
1161
|
+
function runAfterUserDelete(resolvedHooks, uid) {
|
|
1162
|
+
if (!resolvedHooks.afterUserDelete) return;
|
|
1163
|
+
resolvedHooks.afterUserDelete(uid).catch((err) => {
|
|
1164
|
+
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* The auth adapter's side of user administration through the auth collection
|
|
1169
|
+
* (`AuthAdapter.prepareUserUpdates` and the deletion pair): the admin user
|
|
1170
|
+
* routes' rules and hooks, for the data API's writes to the same rows.
|
|
1171
|
+
*/
|
|
1172
|
+
function authCollectionUserAdmin(ctx) {
|
|
1173
|
+
const { authRepo, resolvedHooks } = ctx;
|
|
1174
|
+
return {
|
|
1175
|
+
async prepareUserUpdates(updates) {
|
|
1176
|
+
await assertRoleChangesAllowed(authRepo, updates.flatMap(({ uid, values }) => Array.isArray(values.roles) ? [{
|
|
1177
|
+
uid,
|
|
1178
|
+
roles: values.roles
|
|
1179
|
+
}] : []));
|
|
1180
|
+
const prepared = [];
|
|
1181
|
+
for (const { uid, values } of updates) prepared.push(typeof values.email === "string" ? {
|
|
1182
|
+
...values,
|
|
1183
|
+
email: await normalizeUserEmailChange(authRepo, uid, values.email)
|
|
1184
|
+
} : values);
|
|
1185
|
+
return prepared;
|
|
1186
|
+
},
|
|
1187
|
+
async prepareUserDeletions(uids) {
|
|
1188
|
+
await assertUserDeletionsAllowed(authRepo, uids);
|
|
1189
|
+
if (resolvedHooks.beforeUserDelete) for (const uid of uids) await resolvedHooks.beforeUserDelete(uid);
|
|
1190
|
+
},
|
|
1191
|
+
async finalizeUserDeletions(uids) {
|
|
1192
|
+
for (const uid of uids) {
|
|
1193
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
1194
|
+
runAfterUserDelete(resolvedHooks, uid);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1032
1199
|
//#endregion
|
|
1033
1200
|
//#region src/auth/token-revocation.ts
|
|
1034
1201
|
/**
|
|
@@ -1124,6 +1291,9 @@ async function revokeAllSessions(authRepo, uid) {
|
|
|
1124
1291
|
* `test/password-change-revokes-sessions.test.ts` holds every route that sets a
|
|
1125
1292
|
* password to this, and fails if anything but this function calls
|
|
1126
1293
|
* `updatePassword`.
|
|
1294
|
+
*
|
|
1295
|
+
* `null` removes the password instead, for the same reason and with the same
|
|
1296
|
+
* revocation: see `confirmAddressOwnership`.
|
|
1127
1297
|
*/
|
|
1128
1298
|
async function replaceUserPassword(authRepo, uid, passwordHash) {
|
|
1129
1299
|
await authRepo.updatePassword(uid, passwordHash);
|
|
@@ -1881,7 +2051,7 @@ function createAdapterAuthMiddleware(options) {
|
|
|
1881
2051
|
try {
|
|
1882
2052
|
authenticatedUser = await adapter.verifyRequest(c.req.raw);
|
|
1883
2053
|
} catch (error) {
|
|
1884
|
-
return refuse(c, ApiError.unauthenticated("Unauthorized"));
|
|
2054
|
+
return refuse(c, error instanceof ApiError && error.statusCode >= 400 ? error : ApiError.unauthenticated("Unauthorized"));
|
|
1885
2055
|
}
|
|
1886
2056
|
if (authenticatedUser) {
|
|
1887
2057
|
c.set("user", {
|
|
@@ -1894,7 +2064,8 @@ function createAdapterAuthMiddleware(options) {
|
|
|
1894
2064
|
c.set("driver", await scopeDataDriver(driver, {
|
|
1895
2065
|
uid: authenticatedUser.uid,
|
|
1896
2066
|
roles: authenticatedUser.roles,
|
|
1897
|
-
isAnonymous: authenticatedUser.isAnonymous === true
|
|
2067
|
+
isAnonymous: authenticatedUser.isAnonymous === true,
|
|
2068
|
+
...authenticatedUser.claims ? { claims: authenticatedUser.claims } : {}
|
|
1898
2069
|
}));
|
|
1899
2070
|
} catch (error) {
|
|
1900
2071
|
logger.error("[AUTH-ADAPTER] RLS scoping failed for authenticated user", { error });
|
|
@@ -1962,6 +2133,7 @@ async function verifyPassword(password, storedHash) {
|
|
|
1962
2133
|
if (!saltHex || !hashHex) return false;
|
|
1963
2134
|
const salt = Buffer.from(saltHex, "hex");
|
|
1964
2135
|
const storedKey = Buffer.from(hashHex, "hex");
|
|
2136
|
+
if (storedKey.length !== KEY_LENGTH) return false;
|
|
1965
2137
|
return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS), storedKey);
|
|
1966
2138
|
}
|
|
1967
2139
|
//#endregion
|
|
@@ -2020,69 +2192,6 @@ function resolveAuthHooks(hooks) {
|
|
|
2020
2192
|
};
|
|
2021
2193
|
}
|
|
2022
2194
|
//#endregion
|
|
2023
|
-
//#region src/email/link-base.ts
|
|
2024
|
-
/**
|
|
2025
|
-
* The config fields consulted for each link kind, most specific first.
|
|
2026
|
-
*
|
|
2027
|
-
* `resetPasswordUrl` is the general fallback because it is the only one any boot
|
|
2028
|
-
* path sets, and in every real deployment all three point at the same frontend.
|
|
2029
|
-
*/
|
|
2030
|
-
var LINK_BASE_FIELDS = {
|
|
2031
|
-
resetPassword: ["resetPasswordUrl"],
|
|
2032
|
-
verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
|
|
2033
|
-
magicLink: ["magicLinkUrl", "resetPasswordUrl"]
|
|
2034
|
-
};
|
|
2035
|
-
/**
|
|
2036
|
-
* True when `value` is an absolute `http(s)` URL — the only kind a mail client
|
|
2037
|
-
* can follow. A protocol-relative or path-only base is treated as missing.
|
|
2038
|
-
*/
|
|
2039
|
-
function isAbsoluteHttpUrl(value) {
|
|
2040
|
-
try {
|
|
2041
|
-
const url = new URL(value);
|
|
2042
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
2043
|
-
} catch {
|
|
2044
|
-
return false;
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
/** Drop a trailing slash so callers can append `/verify-email?token=…`. */
|
|
2048
|
-
function normalizeBase(value) {
|
|
2049
|
-
return value.replace(/\/+$/, "");
|
|
2050
|
-
}
|
|
2051
|
-
/**
|
|
2052
|
-
* The absolute base URL for a link kind, or `""` when the config has none.
|
|
2053
|
-
*
|
|
2054
|
-
* Callers append their path to the result. `""` is only reachable on a config
|
|
2055
|
-
* that never went through {@link assertEmailLinkBases} — a hand-constructed
|
|
2056
|
-
* `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
|
|
2057
|
-
* misconfiguration cannot turn a password-reset request into a 500 that
|
|
2058
|
-
* distinguishes existing accounts from missing ones.
|
|
2059
|
-
*/
|
|
2060
|
-
function resolveEmailLinkBase(config, kind) {
|
|
2061
|
-
if (!config) return "";
|
|
2062
|
-
for (const field of LINK_BASE_FIELDS[kind]) {
|
|
2063
|
-
const value = config[field];
|
|
2064
|
-
if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
|
|
2065
|
-
}
|
|
2066
|
-
return "";
|
|
2067
|
-
}
|
|
2068
|
-
/**
|
|
2069
|
-
* Throw when an email configuration cannot produce a followable link.
|
|
2070
|
-
*
|
|
2071
|
-
* Called from `createEmailService`, i.e. from every boot path that wires email
|
|
2072
|
-
* up (the managed runtime, both driver bootstrappers, and any app that passes
|
|
2073
|
-
* `auth.email`). A base that is set but relative is reported separately from one
|
|
2074
|
-
* that is missing, because the two have different fixes.
|
|
2075
|
-
*/
|
|
2076
|
-
function assertEmailLinkBases(config) {
|
|
2077
|
-
const relative = [
|
|
2078
|
-
"resetPasswordUrl",
|
|
2079
|
-
"verifyEmailUrl",
|
|
2080
|
-
"magicLinkUrl"
|
|
2081
|
-
].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
|
|
2082
|
-
if (relative.length > 0) throw new Error(`Email is configured with a relative link base: ${relative.map((f) => `email.${f}`).join(", ")}. Emailed links must be absolute (e.g. "https://app.example.com") — a mail client has no base document to resolve a relative href against, so the link is dead.`);
|
|
2083
|
-
if (!resolveEmailLinkBase(config, "resetPassword") && !resolveEmailLinkBase(config, "verifyEmail")) throw new Error("Email is configured but no base URL for emailed links is set. Password-reset, verification and magic-link emails would carry relative hrefs, which are dead links in every mail client. Set FRONTEND_URL (or `auth.email.resetPasswordUrl`) to an absolute URL such as \"https://app.example.com\".");
|
|
2084
|
-
}
|
|
2085
|
-
//#endregion
|
|
2086
2195
|
//#region src/email/smtp-email-service.ts
|
|
2087
2196
|
var _nodemailer;
|
|
2088
2197
|
async function loadNodemailer() {
|
|
@@ -2136,16 +2245,32 @@ function normalizeMessageId(value) {
|
|
|
2136
2245
|
var SMTPEmailService = class {
|
|
2137
2246
|
transporter = null;
|
|
2138
2247
|
config;
|
|
2139
|
-
|
|
2248
|
+
/**
|
|
2249
|
+
* The transporter being built, shared by every send that arrives while it
|
|
2250
|
+
* is.
|
|
2251
|
+
*
|
|
2252
|
+
* A flag set before the `await` said "initialised" while nodemailer was
|
|
2253
|
+
* still loading, so a second send in that window found no transporter and
|
|
2254
|
+
* threw "Email service not configured" — the first burst of mail after a
|
|
2255
|
+
* boot lost every message but one. Every caller awaits this one promise
|
|
2256
|
+
* instead. Cleared on failure, so one bad moment is not remembered for the
|
|
2257
|
+
* life of the process.
|
|
2258
|
+
*/
|
|
2259
|
+
transporterReady = null;
|
|
2140
2260
|
constructor(config) {
|
|
2141
2261
|
this.config = config;
|
|
2142
2262
|
}
|
|
2143
2263
|
/**
|
|
2144
2264
|
* Lazily initialize the SMTP transporter on first use
|
|
2145
2265
|
*/
|
|
2146
|
-
|
|
2147
|
-
if (this.
|
|
2148
|
-
|
|
2266
|
+
ensureTransporter() {
|
|
2267
|
+
if (!this.transporterReady) this.transporterReady = this.buildTransporter().catch((error) => {
|
|
2268
|
+
this.transporterReady = null;
|
|
2269
|
+
throw error;
|
|
2270
|
+
});
|
|
2271
|
+
return this.transporterReady;
|
|
2272
|
+
}
|
|
2273
|
+
async buildTransporter() {
|
|
2149
2274
|
if (this.config.smtp) {
|
|
2150
2275
|
const nodemailer = await loadNodemailer();
|
|
2151
2276
|
let smtpName = this.config.smtp.name;
|
|
@@ -2422,13 +2547,42 @@ function warnIfProxiedButUntrusted(hasForwardedFor) {
|
|
|
2422
2547
|
logger.warn("[RateLimit] Requests carry X-Forwarded-For but TRUSTED_PROXY_HOPS is 0, so it is being ignored and every client behind the proxy shares one rate-limit bucket. If this server really is behind a reverse proxy, set TRUSTED_PROXY_HOPS to the number of proxies in front of it (1 for a single ingress or load balancer). If it is exposed directly, this warning means someone is sending the header themselves and 0 is the correct setting.");
|
|
2423
2548
|
}
|
|
2424
2549
|
/**
|
|
2550
|
+
* The store this deployment shares its counts in, when it has one.
|
|
2551
|
+
*
|
|
2552
|
+
* Module state, because the limiters that read it are module constants: the
|
|
2553
|
+
* auth limiters are built at import time, before `initializeRebaseBackend`
|
|
2554
|
+
* has read `REBASE_RATE_LIMIT_STORE` or `rateLimit.store`, so they cannot be
|
|
2555
|
+
* handed the store the way the data limiter is. Without this they each kept a
|
|
2556
|
+
* private memory store whatever the deployment asked for, and on N replicas
|
|
2557
|
+
* every login, reset, OTP and MFA limit was N times looser than configured.
|
|
2558
|
+
*/
|
|
2559
|
+
var sharedRateLimitStore;
|
|
2560
|
+
/**
|
|
2561
|
+
* Point every named limiter at the deployment's shared store — or, with
|
|
2562
|
+
* `undefined`, back at its own memory. Called once per boot with the store
|
|
2563
|
+
* the data limiters use, and only when that store is shared (SQL, or one the
|
|
2564
|
+
* operator supplied): the default memory store is swept on the data window,
|
|
2565
|
+
* which is not every limiter's window.
|
|
2566
|
+
*/
|
|
2567
|
+
function setSharedRateLimitStore(store) {
|
|
2568
|
+
sharedRateLimitStore = store;
|
|
2569
|
+
}
|
|
2570
|
+
/** The deployment's store under `name` when there is one, this limiter's own memory otherwise. */
|
|
2571
|
+
function namedStore(name, windowMs) {
|
|
2572
|
+
const local = new MemoryRateLimitStore(windowMs);
|
|
2573
|
+
return {
|
|
2574
|
+
hit: (key, window, limit) => sharedRateLimitStore ? sharedRateLimitStore.hit(`${name}:${key}`, window, limit) : local.hit(key, window, limit),
|
|
2575
|
+
dispose: () => local.dispose()
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2425
2579
|
* Create a rate-limiting middleware.
|
|
2426
2580
|
*
|
|
2427
2581
|
* Uses a sliding window: only hits within the last `windowMs` are counted.
|
|
2428
2582
|
*/
|
|
2429
2583
|
function createRateLimiter(options = {}) {
|
|
2430
2584
|
const trustedProxyHops = resolveTrustedProxyHops(options.trustedProxyHops);
|
|
2431
|
-
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", store = new MemoryRateLimitStore(windowMs), resolveLimit } = options;
|
|
2585
|
+
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", name, store = name ? namedStore(name, windowMs) : new MemoryRateLimitStore(windowMs), resolveLimit } = options;
|
|
2432
2586
|
return async (c, next) => {
|
|
2433
2587
|
const effectiveLimit = resolveLimit ? await resolveLimit(c) : limit;
|
|
2434
2588
|
if (effectiveLimit === null) return next();
|
|
@@ -2490,26 +2644,36 @@ function socketAddress(c) {
|
|
|
2490
2644
|
* anonymous callers throttle each other than that any of them throttles nobody.
|
|
2491
2645
|
*/
|
|
2492
2646
|
function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
2647
|
+
return clientAddress((name) => c.req.header(name), () => socketAddress(c), trustedProxyHops);
|
|
2648
|
+
}
|
|
2649
|
+
/**
|
|
2650
|
+
* {@link defaultKeyGenerator}'s rule, over a request's headers and connection
|
|
2651
|
+
* address rather than a Hono context — so a door that is not an HTTP request
|
|
2652
|
+
* (the realtime socket's upgrade request) reads the caller's address the same
|
|
2653
|
+
* way.
|
|
2654
|
+
*/
|
|
2655
|
+
function clientAddress(header, connectionAddress, trustedProxyHops) {
|
|
2493
2656
|
if (trustedProxyHops === 0) {
|
|
2494
|
-
warnIfProxiedButUntrusted(!!
|
|
2495
|
-
return
|
|
2657
|
+
warnIfProxiedButUntrusted(!!header("x-forwarded-for"));
|
|
2658
|
+
return connectionAddress() ?? "unknown";
|
|
2496
2659
|
}
|
|
2497
2660
|
{
|
|
2498
|
-
const forwardedFor =
|
|
2661
|
+
const forwardedFor = header("x-forwarded-for");
|
|
2499
2662
|
if (forwardedFor) {
|
|
2500
2663
|
const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2501
2664
|
if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
|
|
2502
2665
|
}
|
|
2503
|
-
const realIp =
|
|
2666
|
+
const realIp = header("x-real-ip");
|
|
2504
2667
|
if (realIp) return realIp;
|
|
2505
2668
|
}
|
|
2506
|
-
return
|
|
2669
|
+
return connectionAddress() ?? "unknown";
|
|
2507
2670
|
}
|
|
2508
2671
|
/**
|
|
2509
2672
|
* Pre-configured rate limiter for general auth endpoints (login, register).
|
|
2510
2673
|
* 200 requests per 15 minutes per IP.
|
|
2511
2674
|
*/
|
|
2512
2675
|
var defaultAuthLimiter = createRateLimiter({
|
|
2676
|
+
name: "auth-default",
|
|
2513
2677
|
windowMs: 900 * 1e3,
|
|
2514
2678
|
limit: 200,
|
|
2515
2679
|
message: "Too many authentication attempts, please try again later."
|
|
@@ -2519,6 +2683,7 @@ var defaultAuthLimiter = createRateLimiter({
|
|
|
2519
2683
|
* 50 requests per 15 minutes per IP.
|
|
2520
2684
|
*/
|
|
2521
2685
|
var strictAuthLimiter = createRateLimiter({
|
|
2686
|
+
name: "auth-strict",
|
|
2522
2687
|
windowMs: 900 * 1e3,
|
|
2523
2688
|
limit: 50,
|
|
2524
2689
|
message: "Too many requests to this sensitive endpoint, please try again later."
|
|
@@ -2577,6 +2742,7 @@ var captureRecipientEmail = async (c, next) => {
|
|
|
2577
2742
|
* the limiter is never silently a no-op.
|
|
2578
2743
|
*/
|
|
2579
2744
|
var recipientEmailLimiter = createRateLimiter({
|
|
2745
|
+
name: "auth-recipient-email",
|
|
2580
2746
|
windowMs: 900 * 1e3,
|
|
2581
2747
|
limit: 5,
|
|
2582
2748
|
keyGenerator: (c) => {
|
|
@@ -2592,6 +2758,7 @@ async function notBefore$1(startedAt, floorMs, result) {
|
|
|
2592
2758
|
return result;
|
|
2593
2759
|
}
|
|
2594
2760
|
var verificationEmailLimiter = createRateLimiter({
|
|
2761
|
+
name: "auth-verification-email",
|
|
2595
2762
|
windowMs: 900 * 1e3,
|
|
2596
2763
|
limit: 5,
|
|
2597
2764
|
keyGenerator: (c) => {
|
|
@@ -2632,8 +2799,9 @@ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
|
|
|
2632
2799
|
* `enabled: false` rather than pay for it twice.
|
|
2633
2800
|
*/
|
|
2634
2801
|
function createDataRateLimiter(config = {}) {
|
|
2635
|
-
const { windowMs = 900 * 1e3,
|
|
2802
|
+
const { windowMs = 900 * 1e3, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
2636
2803
|
const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
|
|
2804
|
+
const bucketFor = dataRateLimitBuckets(config);
|
|
2637
2805
|
/**
|
|
2638
2806
|
* Who this request is, for bucketing only.
|
|
2639
2807
|
*
|
|
@@ -2664,25 +2832,81 @@ function createDataRateLimiter(config = {}) {
|
|
|
2664
2832
|
if (!payload?.uid || isAnonymousUid(payload.uid)) return void 0;
|
|
2665
2833
|
return payload.uid;
|
|
2666
2834
|
};
|
|
2835
|
+
const bucketOf = async (c) => {
|
|
2836
|
+
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
2837
|
+
const apiKey = c.get("apiKey");
|
|
2838
|
+
return bucketFor({
|
|
2839
|
+
apiKey,
|
|
2840
|
+
uid: apiKey ? void 0 : await identify(c),
|
|
2841
|
+
address: () => defaultKeyGenerator(c, trustedProxyHops)
|
|
2842
|
+
});
|
|
2843
|
+
};
|
|
2667
2844
|
return createRateLimiter({
|
|
2668
2845
|
windowMs,
|
|
2669
2846
|
store,
|
|
2670
2847
|
message: "Too many requests, please try again later.",
|
|
2671
|
-
keyGenerator: async (c) => {
|
|
2672
|
-
|
|
2673
|
-
if (key) return `api-key:${key.id}`;
|
|
2674
|
-
const uid = await identify(c);
|
|
2675
|
-
if (uid) return `user:${uid}`;
|
|
2676
|
-
return `ip:${defaultKeyGenerator(c, trustedProxyHops)}`;
|
|
2677
|
-
},
|
|
2678
|
-
resolveLimit: async (c) => {
|
|
2679
|
-
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
2680
|
-
const key = c.get("apiKey");
|
|
2681
|
-
if (key) return key.rate_limit ?? apiKeyLimit;
|
|
2682
|
-
return await identify(c) ? userLimit : anonLimit;
|
|
2683
|
-
}
|
|
2848
|
+
keyGenerator: async (c) => (await bucketOf(c))?.key ?? `ip:${defaultKeyGenerator(c, trustedProxyHops)}`,
|
|
2849
|
+
resolveLimit: async (c) => (await bucketOf(c))?.limit ?? null
|
|
2684
2850
|
});
|
|
2685
2851
|
}
|
|
2852
|
+
/**
|
|
2853
|
+
* The data API's per-caller limit, as a check rather than a middleware — for
|
|
2854
|
+
* the door into the same data that is not an HTTP request.
|
|
2855
|
+
*
|
|
2856
|
+
* The realtime socket serves the rows the data API serves, one frame per
|
|
2857
|
+
* request. It counted them per connection only, so a caller bought more budget
|
|
2858
|
+
* by opening more sockets, and a frame never met the per-person limit its HTTP
|
|
2859
|
+
* twin does. Built from the same config and store as
|
|
2860
|
+
* {@link createDataRateLimiter}, it puts a caller in the same bucket under the
|
|
2861
|
+
* same key: one person's allowance, spent through either door.
|
|
2862
|
+
*/
|
|
2863
|
+
function createDataRateLimitCheck(config = {}) {
|
|
2864
|
+
const { windowMs = 900 * 1e3, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
2865
|
+
const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
|
|
2866
|
+
const bucketFor = dataRateLimitBuckets(config);
|
|
2867
|
+
return async (caller) => {
|
|
2868
|
+
if (caller.uid === SERVICE_IDENTITY.uid) return null;
|
|
2869
|
+
const bucket = bucketFor({
|
|
2870
|
+
uid: caller.uid !== void 0 && !isAnonymousUid(caller.uid) ? caller.uid : void 0,
|
|
2871
|
+
address: () => clientAddress(caller.header, () => caller.socketAddress, trustedProxyHops)
|
|
2872
|
+
});
|
|
2873
|
+
return bucket ? store.hit(bucket.key, windowMs, bucket.limit) : null;
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Which bucket a data request counts in — the rule both doors share.
|
|
2878
|
+
*
|
|
2879
|
+
* Most specific first: an API key by its id, at its own `rate_limit`; a
|
|
2880
|
+
* signed-in user by their uid; anyone else by address at the anonymous
|
|
2881
|
+
* allowance, or not at all when that is `null`.
|
|
2882
|
+
*
|
|
2883
|
+
* The service key never reaches here: it is not a caller to bound. It is the
|
|
2884
|
+
* deployment's own credential and already reaches every row through
|
|
2885
|
+
* `SERVICE_IDENTITY`, so a limit on it protects nothing that is not already
|
|
2886
|
+
* open to whoever holds it — while reliably stopping the backfills,
|
|
2887
|
+
* migrations, imports and server-to-server jobs the key exists for, which
|
|
2888
|
+
* arrive as thousands of legitimate requests. A managed deployment has no
|
|
2889
|
+
* `rateLimit` surface to raise. Each door recognises it itself, where its
|
|
2890
|
+
* identity is known.
|
|
2891
|
+
*/
|
|
2892
|
+
function dataRateLimitBuckets(config) {
|
|
2893
|
+
const { apiKey: apiKeyLimit = 1e3, user: userLimit = 1e3, anonymous: anonLimit = 300 } = config;
|
|
2894
|
+
return (who) => {
|
|
2895
|
+
if (who.apiKey) return {
|
|
2896
|
+
key: `api-key:${who.apiKey.id}`,
|
|
2897
|
+
limit: who.apiKey.rate_limit ?? apiKeyLimit
|
|
2898
|
+
};
|
|
2899
|
+
if (who.uid) return {
|
|
2900
|
+
key: `user:${who.uid}`,
|
|
2901
|
+
limit: userLimit
|
|
2902
|
+
};
|
|
2903
|
+
if (anonLimit === null) return null;
|
|
2904
|
+
return {
|
|
2905
|
+
key: `ip:${who.address()}`,
|
|
2906
|
+
limit: anonLimit
|
|
2907
|
+
};
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2686
2910
|
//#endregion
|
|
2687
2911
|
//#region src/auth/captcha.ts
|
|
2688
2912
|
/**
|
|
@@ -3226,7 +3450,8 @@ async function resolveStepUpPrincipal(c) {
|
|
|
3226
3450
|
if (existing?.uid) return {
|
|
3227
3451
|
uid: existing.uid,
|
|
3228
3452
|
aal: existing.aal === "aal2" ? "aal2" : "aal1",
|
|
3229
|
-
pending: false
|
|
3453
|
+
pending: false,
|
|
3454
|
+
iat: existing.iat
|
|
3230
3455
|
};
|
|
3231
3456
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
3232
3457
|
if (token === void 0) return null;
|
|
@@ -3234,13 +3459,15 @@ async function resolveStepUpPrincipal(c) {
|
|
|
3234
3459
|
if (session) return {
|
|
3235
3460
|
uid: session.uid,
|
|
3236
3461
|
aal: session.aal === "aal2" ? "aal2" : "aal1",
|
|
3237
|
-
pending: false
|
|
3462
|
+
pending: false,
|
|
3463
|
+
iat: session.iat
|
|
3238
3464
|
};
|
|
3239
3465
|
const pending = await verifyMfaPendingToken(token);
|
|
3240
3466
|
if (pending) return {
|
|
3241
3467
|
uid: pending.uid,
|
|
3242
3468
|
aal: "aal1",
|
|
3243
|
-
pending: true
|
|
3469
|
+
pending: true,
|
|
3470
|
+
iat: pending.iat
|
|
3244
3471
|
};
|
|
3245
3472
|
return null;
|
|
3246
3473
|
}
|
|
@@ -3253,6 +3480,7 @@ async function resolveStepUpPrincipal(c) {
|
|
|
3253
3480
|
* front of this one — they bound different things.
|
|
3254
3481
|
*/
|
|
3255
3482
|
var mfaVerificationLimiter = createRateLimiter({
|
|
3483
|
+
name: "auth-mfa-verify",
|
|
3256
3484
|
windowMs: 900 * 1e3,
|
|
3257
3485
|
limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
|
|
3258
3486
|
keyGenerator: async (c) => `mfa-verify:${(await resolveStepUpPrincipal(c))?.uid ?? "unidentified"}`,
|
|
@@ -3263,6 +3491,26 @@ function mountMfaRoutes(opts) {
|
|
|
3263
3491
|
const authRepo = config.authRepo;
|
|
3264
3492
|
const emailConfig = config.emailConfig;
|
|
3265
3493
|
/**
|
|
3494
|
+
* The caller of a challenge route, refused if the user has since ended
|
|
3495
|
+
* every session.
|
|
3496
|
+
*
|
|
3497
|
+
* These two routes resolve their own caller, because a sign-in that
|
|
3498
|
+
* answered `MFA_REQUIRED` has no session yet, and so they skipped the
|
|
3499
|
+
* revocation mark that `requireLiveSession` checks everywhere else. A
|
|
3500
|
+
* stolen access token that had enrolled its own factor (allowed at `aal1`
|
|
3501
|
+
* while the account had none) kept working here after the victim signed
|
|
3502
|
+
* out everywhere or changed their password, and the verify route then
|
|
3503
|
+
* minted it a new session dated after the mark, which the mark could not
|
|
3504
|
+
* reach. The pending token is a first factor accepted at its `iat`, so it
|
|
3505
|
+
* is held to the same mark.
|
|
3506
|
+
*/
|
|
3507
|
+
async function resolveLiveStepUpPrincipal(c) {
|
|
3508
|
+
const principal = await resolveStepUpPrincipal(c);
|
|
3509
|
+
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
3510
|
+
if (await isAccessTokenRevoked(authRepo, principal)) throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
3511
|
+
return principal;
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3266
3514
|
* Changing which factors an account trusts is itself a privileged act.
|
|
3267
3515
|
*
|
|
3268
3516
|
* An account that already has a verified factor may only gain or confirm
|
|
@@ -3359,8 +3607,7 @@ function mountMfaRoutes(opts) {
|
|
|
3359
3607
|
* `MFA_REQUIRED`, or to step an existing session up to `aal2`.
|
|
3360
3608
|
*/
|
|
3361
3609
|
router.post("/mfa/challenge", strictAuthLimiter, async (c) => {
|
|
3362
|
-
const principal = await
|
|
3363
|
-
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
3610
|
+
const principal = await resolveLiveStepUpPrincipal(c);
|
|
3364
3611
|
const challengeSchema = z.object({ factorId: z.string().min(1, "Factor ID is required") });
|
|
3365
3612
|
const { factorId } = parseBody(challengeSchema, await c.req.json());
|
|
3366
3613
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
@@ -3380,8 +3627,7 @@ function mountMfaRoutes(opts) {
|
|
|
3380
3627
|
* minted, at `aal2`.
|
|
3381
3628
|
*/
|
|
3382
3629
|
router.post("/mfa/challenge/verify", strictAuthLimiter, mfaVerificationLimiter, async (c) => {
|
|
3383
|
-
const principal = await
|
|
3384
|
-
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
3630
|
+
const principal = await resolveLiveStepUpPrincipal(c);
|
|
3385
3631
|
const challengeVerifySchema = z.object({
|
|
3386
3632
|
challengeId: z.string().min(1, "Challenge ID is required"),
|
|
3387
3633
|
code: z.string().min(1, "Code is required")
|
|
@@ -3391,7 +3637,8 @@ function mountMfaRoutes(opts) {
|
|
|
3391
3637
|
if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
|
|
3392
3638
|
const factor = await authRepo.getMfaFactorById(challenge.factorId);
|
|
3393
3639
|
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
3394
|
-
|
|
3640
|
+
const attempts = (challenge.attempts ?? 0) >= MAX_CHALLENGE_ATTEMPTS ? 6 : typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
|
|
3641
|
+
if (attempts !== void 0 && attempts > MAX_CHALLENGE_ATTEMPTS) {
|
|
3395
3642
|
logger.warn("[Security Audit] MFA challenge attempt limit reached", {
|
|
3396
3643
|
eventType: "auth.mfa.challenge.exhausted",
|
|
3397
3644
|
uid: principal.uid,
|
|
@@ -3406,7 +3653,6 @@ function mountMfaRoutes(opts) {
|
|
|
3406
3653
|
isValid = await authRepo.useRecoveryCode(principal.uid, codeHash);
|
|
3407
3654
|
}
|
|
3408
3655
|
if (!isValid) {
|
|
3409
|
-
const attempts = typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
|
|
3410
3656
|
logger.warn("[Security Audit] MFA verification failed", {
|
|
3411
3657
|
eventType: "auth.mfa.verify.failure",
|
|
3412
3658
|
uid: principal.uid,
|
|
@@ -3597,7 +3843,7 @@ function isAnonymousAuthOpen(policy) {
|
|
|
3597
3843
|
//#endregion
|
|
3598
3844
|
//#region src/auth/session-routes.ts
|
|
3599
3845
|
function mountSessionRoutes(opts) {
|
|
3600
|
-
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession } = opts;
|
|
3846
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook, requireLiveSession, registerCaptcha } = opts;
|
|
3601
3847
|
const authRepo = config.authRepo;
|
|
3602
3848
|
/**
|
|
3603
3849
|
* Refuse the anonymous routes unless the deployment opted in.
|
|
@@ -3640,7 +3886,7 @@ function mountSessionRoutes(opts) {
|
|
|
3640
3886
|
clearRefreshCookie(c, config.cookieAuth);
|
|
3641
3887
|
const accessToken = extractBearerToken(c.req.header("authorization"));
|
|
3642
3888
|
if (ops.afterLogout && accessToken !== void 0) {
|
|
3643
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
3889
|
+
const { verifyAccessToken } = await import("./jwt-DnQHNFCl.js").then((n) => n.h);
|
|
3644
3890
|
const payload = await verifyAccessToken(accessToken);
|
|
3645
3891
|
if (payload) ops.afterLogout(payload.uid).catch((err) => {
|
|
3646
3892
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
@@ -3791,14 +4037,21 @@ function mountSessionRoutes(opts) {
|
|
|
3791
4037
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
3792
4038
|
});
|
|
3793
4039
|
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "anonymous");
|
|
3794
|
-
const
|
|
3795
|
-
return c.json(
|
|
4040
|
+
const transformedResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
4041
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth), 201);
|
|
3796
4042
|
});
|
|
3797
4043
|
/**
|
|
3798
4044
|
* POST /auth/anonymous/link
|
|
3799
4045
|
* Upgrade an anonymous user to a permanent account with email/password
|
|
4046
|
+
*
|
|
4047
|
+
* This is registration: before it the row is a guest, after it an account
|
|
4048
|
+
* that signs in with a password. So it answers to registration's controls,
|
|
4049
|
+
* the `register` captcha and `beforeUserCreate` on the credentials the
|
|
4050
|
+
* account is getting. `/anonymous` carries neither, deliberately, because
|
|
4051
|
+
* apps mint guests on page load; the two in a row used to be a way to make
|
|
4052
|
+
* an account that passed neither.
|
|
3800
4053
|
*/
|
|
3801
|
-
router.post("/anonymous/link", strictAuthLimiter, requireLiveSession, async (c) => {
|
|
4054
|
+
router.post("/anonymous/link", strictAuthLimiter, requireLiveSession, ...registerCaptcha ? [registerCaptcha] : [], async (c) => {
|
|
3802
4055
|
assertAnonymousAuthOpen();
|
|
3803
4056
|
const userCtx = c.get("user");
|
|
3804
4057
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
@@ -3809,16 +4062,105 @@ function mountSessionRoutes(opts) {
|
|
|
3809
4062
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
3810
4063
|
if (await authRepo.getUserByEmail(normalizeEmail(email))) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
3811
4064
|
const passwordHash = await ops.hashPassword(password);
|
|
3812
|
-
|
|
4065
|
+
let linkData = {
|
|
3813
4066
|
email: normalizeEmail(email),
|
|
3814
|
-
passwordHash
|
|
4067
|
+
passwordHash
|
|
4068
|
+
};
|
|
4069
|
+
if (ops.beforeUserCreate) linkData = await ops.beforeUserCreate(linkData);
|
|
4070
|
+
const updatedUser = await authRepo.updateUser(user.id, {
|
|
4071
|
+
...linkData,
|
|
4072
|
+
email: normalizeEmail(linkData.email),
|
|
4073
|
+
passwordHash: linkData.passwordHash,
|
|
3815
4074
|
isAnonymous: false
|
|
3816
4075
|
});
|
|
3817
4076
|
if (!updatedUser) throw ApiError.notFound("User not found");
|
|
3818
4077
|
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
|
|
3819
4078
|
const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken, "password");
|
|
3820
|
-
const
|
|
3821
|
-
return c.json(
|
|
4079
|
+
const transformedResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
4080
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
4081
|
+
});
|
|
4082
|
+
}
|
|
4083
|
+
//#endregion
|
|
4084
|
+
//#region src/auth/address-ownership.ts
|
|
4085
|
+
/**
|
|
4086
|
+
* What happens when the owner of an address proves it for the first time.
|
|
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.
|
|
4094
|
+
*
|
|
4095
|
+
* The owner turns up by proving the address: a magic link, an email code or a
|
|
4096
|
+
* password reset, each read out of their inbox. That proof used to mark the
|
|
4097
|
+
* account verified and leave the attacker's way in where it was, so the owner
|
|
4098
|
+
* and the attacker shared the account from then on. Now the proof also removes
|
|
4099
|
+
* everything on the account that nobody proved: the password, every identity
|
|
4100
|
+
* whose provider did not vouch for this address, and every session.
|
|
4101
|
+
*
|
|
4102
|
+
* @module
|
|
4103
|
+
*/
|
|
4104
|
+
/**
|
|
4105
|
+
* The profile stored with a linked OAuth identity.
|
|
4106
|
+
*
|
|
4107
|
+
* `emailVerified` is recorded so that a later proof of the address can tell an
|
|
4108
|
+
* identity whose provider vouched for it from one whose provider did not.
|
|
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.
|
|
4138
|
+
*
|
|
4139
|
+
* A repository that cannot detach an identity makes this refuse with 409 when
|
|
4140
|
+
* there is one to detach. Marking the account verified with the identity still
|
|
4141
|
+
* on it is the takeover itself, and leaving the account unverified while
|
|
4142
|
+
* signing the owner in would share the account all the same.
|
|
4143
|
+
*/
|
|
4144
|
+
async function confirmAddressOwnership(authRepo, user, passwordHash) {
|
|
4145
|
+
const unproven = (await authRepo.getUserIdentities(user.id)).filter((identity) => !identityVouchesForAddress(identity, user.email));
|
|
4146
|
+
if (unproven.length > 0) {
|
|
4147
|
+
if (typeof authRepo.unlinkUserIdentity !== "function") {
|
|
4148
|
+
logger.error("[Security Audit] Proof of address refused: the account has sign-in methods nobody proved, and this auth repository cannot remove them", {
|
|
4149
|
+
eventType: "auth.address_proof.refused",
|
|
4150
|
+
uid: user.id,
|
|
4151
|
+
providers: unproven.map((identity) => identity.provider)
|
|
4152
|
+
});
|
|
4153
|
+
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");
|
|
4154
|
+
}
|
|
4155
|
+
for (const identity of unproven) await authRepo.unlinkUserIdentity(user.id, identity.provider, identity.providerId);
|
|
4156
|
+
}
|
|
4157
|
+
await replaceUserPassword(authRepo, user.id, passwordHash);
|
|
4158
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
4159
|
+
if (unproven.length > 0 || user.passwordHash) logger.info("[Security Audit] First proof of address removed sign-in methods nobody proved", {
|
|
4160
|
+
eventType: "auth.address_proof.cleared",
|
|
4161
|
+
uid: user.id,
|
|
4162
|
+
removedProviders: unproven.map((identity) => identity.provider),
|
|
4163
|
+
replacedPassword: Boolean(user.passwordHash)
|
|
3822
4164
|
});
|
|
3823
4165
|
}
|
|
3824
4166
|
//#endregion
|
|
@@ -3845,13 +4187,22 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3845
4187
|
/**
|
|
3846
4188
|
* POST /auth/magic-link
|
|
3847
4189
|
* Request a magic link email
|
|
4190
|
+
*
|
|
4191
|
+
* Answers the same words whether or not the address has an account, and
|
|
4192
|
+
* is held to the same three things as its siblings `/otp` and
|
|
4193
|
+
* `/forgot-password` so that nothing else answers the question either:
|
|
4194
|
+
* the same response time, a per-address send limit, and `beforeLogin` on
|
|
4195
|
+
* every attempt. This route had none of the three — the SMTP send was
|
|
4196
|
+
* awaited only for a real account, and the only limit was per IP — so its
|
|
4197
|
+
* timing named the customers and a spread-out run could fill one inbox.
|
|
3848
4198
|
*/
|
|
3849
|
-
router.post("/magic-link", strictAuthLimiter, ...captchaMiddleware ? [captchaMiddleware] : [], async (c) => {
|
|
4199
|
+
router.post("/magic-link", strictAuthLimiter, captureRecipientEmail, recipientEmailLimiter, ...captchaMiddleware ? [captchaMiddleware] : [], async (c) => {
|
|
4200
|
+
const startedAt = Date.now();
|
|
3850
4201
|
const { email } = parseBody(magicLinkSchema, await c.req.json());
|
|
3851
4202
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Magic link login is not available.", "EMAIL_NOT_CONFIGURED");
|
|
3852
4203
|
const user = await authRepo.getUserByEmail(email);
|
|
4204
|
+
if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
|
|
3853
4205
|
if (user) {
|
|
3854
|
-
if (ops.beforeLogin) await ops.beforeLogin(email, "magic-link");
|
|
3855
4206
|
const token = generateSecureToken();
|
|
3856
4207
|
const tokenHash = hashToken$1(token);
|
|
3857
4208
|
const expiresAt = getMagicLinkExpiry();
|
|
@@ -3866,21 +4217,19 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3866
4217
|
email: user.email,
|
|
3867
4218
|
displayName: user.displayName
|
|
3868
4219
|
}, appName, logoUrl);
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
});
|
|
3876
|
-
} catch (emailError) {
|
|
4220
|
+
emailService.send({
|
|
4221
|
+
to: user.email,
|
|
4222
|
+
subject: emailContent.subject,
|
|
4223
|
+
html: emailContent.html,
|
|
4224
|
+
text: emailContent.text
|
|
4225
|
+
}).catch((emailError) => {
|
|
3877
4226
|
logger.error("Failed to send magic link email", { error: emailError instanceof Error ? emailError.message : emailError });
|
|
3878
|
-
}
|
|
4227
|
+
});
|
|
3879
4228
|
}
|
|
3880
|
-
return c.json({
|
|
4229
|
+
return notBefore$1(startedAt, 400, c.json({
|
|
3881
4230
|
success: true,
|
|
3882
4231
|
message: "If an account with that email exists, a magic link has been sent."
|
|
3883
|
-
});
|
|
4232
|
+
}));
|
|
3884
4233
|
});
|
|
3885
4234
|
/**
|
|
3886
4235
|
* POST /auth/magic-link/verify
|
|
@@ -3892,19 +4241,23 @@ function mountMagicLinkRoutes(deps) {
|
|
|
3892
4241
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
3893
4242
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
3894
4243
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
3895
|
-
|
|
4244
|
+
let user = await authRepo.getUserById(storedToken.uid);
|
|
3896
4245
|
if (!user) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
3897
4246
|
if (!user.emailVerified) {
|
|
3898
|
-
await authRepo
|
|
3899
|
-
user
|
|
4247
|
+
await confirmAddressOwnership(authRepo, user, null);
|
|
4248
|
+
user = {
|
|
4249
|
+
...user,
|
|
4250
|
+
emailVerified: true,
|
|
4251
|
+
passwordHash: null
|
|
4252
|
+
};
|
|
3900
4253
|
}
|
|
3901
4254
|
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
|
|
3902
4255
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "magic-link").catch((err) => {
|
|
3903
4256
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
3904
4257
|
});
|
|
3905
4258
|
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "magic-link");
|
|
3906
|
-
const
|
|
3907
|
-
return c.json(
|
|
4259
|
+
const transformedResponse = await applyTransformHook(authResponse, "magic-link", c.req.raw, user.id);
|
|
4260
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
3908
4261
|
});
|
|
3909
4262
|
}
|
|
3910
4263
|
//#endregion
|
|
@@ -3949,6 +4302,7 @@ function otpTokenMaterial(email, code) {
|
|
|
3949
4302
|
* replicas with the default, the effective budget is five attempts per replica.
|
|
3950
4303
|
*/
|
|
3951
4304
|
var otpVerificationLimiter = createRateLimiter({
|
|
4305
|
+
name: "auth-otp-verify",
|
|
3952
4306
|
windowMs: 900 * 1e3,
|
|
3953
4307
|
limit: OTP_ATTEMPTS_PER_WINDOW,
|
|
3954
4308
|
keyGenerator: (c) => {
|
|
@@ -3969,6 +4323,7 @@ var otpVerificationLimiter = createRateLimiter({
|
|
|
3969
4323
|
* and useless as a mail bomb, matching `verificationEmailLimiter`.
|
|
3970
4324
|
*/
|
|
3971
4325
|
var otpSendLimiter = createRateLimiter({
|
|
4326
|
+
name: "auth-otp-send",
|
|
3972
4327
|
windowMs: 900 * 1e3,
|
|
3973
4328
|
limit: 5,
|
|
3974
4329
|
keyGenerator: (c) => {
|
|
@@ -4077,19 +4432,23 @@ function mountOtpRoutes(deps) {
|
|
|
4077
4432
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
4078
4433
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
|
|
4079
4434
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
4080
|
-
|
|
4435
|
+
let user = await authRepo.getUserById(storedToken.uid);
|
|
4081
4436
|
if (!user) throw ApiError.badRequest("Invalid or expired code", "INVALID_CODE");
|
|
4082
4437
|
if (!user.emailVerified) {
|
|
4083
|
-
await authRepo
|
|
4084
|
-
user
|
|
4438
|
+
await confirmAddressOwnership(authRepo, user, null);
|
|
4439
|
+
user = {
|
|
4440
|
+
...user,
|
|
4441
|
+
emailVerified: true,
|
|
4442
|
+
passwordHash: null
|
|
4443
|
+
};
|
|
4085
4444
|
}
|
|
4086
4445
|
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
|
|
4087
4446
|
if (ops.onAuthenticated) ops.onAuthenticated(user, "otp").catch((err) => {
|
|
4088
4447
|
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
4089
4448
|
});
|
|
4090
4449
|
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken, "otp");
|
|
4091
|
-
const
|
|
4092
|
-
return c.json(
|
|
4450
|
+
const transformedResponse = await applyTransformHook(authResponse, "otp", c.req.raw, user.id);
|
|
4451
|
+
return c.json(redactRefreshToken(transformedResponse, c, refreshToken, config.cookieAuth));
|
|
4093
4452
|
});
|
|
4094
4453
|
}
|
|
4095
4454
|
//#endregion
|
|
@@ -4097,26 +4456,34 @@ function mountOtpRoutes(deps) {
|
|
|
4097
4456
|
/**
|
|
4098
4457
|
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
4099
4458
|
*
|
|
4100
|
-
* Both sides have to be trustworthy
|
|
4459
|
+
* Both sides have to be trustworthy:
|
|
4101
4460
|
*
|
|
4102
4461
|
* - the **provider** must have verified the address, or the caller has not
|
|
4103
4462
|
* shown they control it;
|
|
4104
|
-
* - the **local account** must
|
|
4105
|
-
*
|
|
4106
|
-
*
|
|
4107
|
-
*
|
|
4108
|
-
*
|
|
4109
|
-
*
|
|
4110
|
-
*
|
|
4463
|
+
* - the **local account**'s address must have been verified too. Whoever
|
|
4464
|
+
* made an unverified account never proved the address, and every way in
|
|
4465
|
+
* they left on it — a password, an identity from a provider that did not
|
|
4466
|
+
* vouch for the address — would go on opening the account the owner is
|
|
4467
|
+
* about to be signed into.
|
|
4468
|
+
*
|
|
4469
|
+
* The second rule used to exempt an account with no password, on the theory
|
|
4470
|
+
* that one made by an OAuth sign-in holds no credential an attacker could have
|
|
4471
|
+
* planted. It holds exactly one: the identity it was made with, which is an
|
|
4472
|
+
* attacker's whenever the provider did not vouch for the address.
|
|
4473
|
+
*
|
|
4474
|
+
* A refusal is not a dead end. `POST /auth/link/<provider>` attaches the
|
|
4475
|
+
* identity once the caller proves ownership by holding a session, and proving
|
|
4476
|
+
* the address (a magic link, an email code, a password reset) verifies the
|
|
4477
|
+
* account — removing what nobody proved — after which this answers yes.
|
|
4111
4478
|
*/
|
|
4112
4479
|
function decideOAuthAutoLink(args) {
|
|
4113
4480
|
if (args.providerEmailVerified !== true) return {
|
|
4114
4481
|
allowed: false,
|
|
4115
4482
|
reason: "provider-email-unverified"
|
|
4116
4483
|
};
|
|
4117
|
-
if (
|
|
4484
|
+
if (!args.existingUser.emailVerified) return {
|
|
4118
4485
|
allowed: false,
|
|
4119
|
-
reason: "local-account-unverified"
|
|
4486
|
+
reason: args.existingUser.passwordHash ? "local-account-unverified" : "local-account-unverified-passwordless"
|
|
4120
4487
|
};
|
|
4121
4488
|
return { allowed: true };
|
|
4122
4489
|
}
|
|
@@ -4292,6 +4659,18 @@ function createAuthRoutes(config) {
|
|
|
4292
4659
|
});
|
|
4293
4660
|
}
|
|
4294
4661
|
/**
|
|
4662
|
+
* Is the account just created the only one there is?
|
|
4663
|
+
*
|
|
4664
|
+
* Two rows at most: the total says whether anyone else exists, and the
|
|
4665
|
+
* one row left when nobody does says whether it is this account. It used
|
|
4666
|
+
* to be `listUsers()` — the whole users table, password hashes included,
|
|
4667
|
+
* on every sign-up, to compare its length with one.
|
|
4668
|
+
*/
|
|
4669
|
+
async function isOnlyUser(uid) {
|
|
4670
|
+
const { users, total } = await authRepo.listUsersPaginated({ limit: 2 });
|
|
4671
|
+
return total === 1 && users[0]?.id === uid;
|
|
4672
|
+
}
|
|
4673
|
+
/**
|
|
4295
4674
|
* Helper to generate and store session tokens.
|
|
4296
4675
|
*
|
|
4297
4676
|
* Every route that signs somebody in comes through here — password login,
|
|
@@ -4325,7 +4704,7 @@ function createAuthRoutes(config) {
|
|
|
4325
4704
|
const accessToken = await generateAccessToken(uid, roleIds, aal, customClaims, isAnonymous);
|
|
4326
4705
|
const refreshToken = generateRefreshToken();
|
|
4327
4706
|
await authRepo.createRefreshToken(uid, await hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
4328
|
-
id: randomUUID(),
|
|
4707
|
+
id: randomUUID$1(),
|
|
4329
4708
|
startedAt: /* @__PURE__ */ new Date(),
|
|
4330
4709
|
aal
|
|
4331
4710
|
});
|
|
@@ -4360,8 +4739,7 @@ function createAuthRoutes(config) {
|
|
|
4360
4739
|
};
|
|
4361
4740
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
4362
4741
|
const user = await authRepo.createUser(createData);
|
|
4363
|
-
const
|
|
4364
|
-
const isFirstUser = existingUsers.length === 1 && existingUsers[0].id === user.id;
|
|
4742
|
+
const isFirstUser = await isOnlyUser(user.id);
|
|
4365
4743
|
if (bootstrapRegistration && !isFirstUser) {
|
|
4366
4744
|
await authRepo.deleteUser(user.id);
|
|
4367
4745
|
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
@@ -4424,21 +4802,46 @@ function createAuthRoutes(config) {
|
|
|
4424
4802
|
return c.json(finalResponse);
|
|
4425
4803
|
});
|
|
4426
4804
|
/**
|
|
4805
|
+
* Turn an OAuth request body into the provider's profile of the caller.
|
|
4806
|
+
*
|
|
4807
|
+
* Both OAuth doors come through here — sign-in and `/link/<provider>` —
|
|
4808
|
+
* because both end with that provider identity resolving to an account,
|
|
4809
|
+
* so both need the same two controls. The link door used to spend the
|
|
4810
|
+
* code without the first and echo the provider's error without the second:
|
|
4811
|
+
* a victim's code leaked through a stale registered URI could be linked to
|
|
4812
|
+
* the attacker's account, after which the victim's own "Sign in with …"
|
|
4813
|
+
* landed there.
|
|
4814
|
+
*
|
|
4815
|
+
* The redirect URI is allowlisted before the code is spent. The provider's
|
|
4816
|
+
* own registered-URI match authorises *every* URI on that OAuth client — a
|
|
4817
|
+
* leftover localhost entry, a staging host, a second product sharing the
|
|
4818
|
+
* client id — and any of them can mint a code this backend would otherwise
|
|
4819
|
+
* accept.
|
|
4820
|
+
*
|
|
4821
|
+
* A provider's error message is logged, never returned: its token-endpoint
|
|
4822
|
+
* error body routinely echoes the client_id, the redirect URI and
|
|
4823
|
+
* diagnostics about the credential state.
|
|
4824
|
+
*/
|
|
4825
|
+
async function verifyProviderPayload(provider, payload) {
|
|
4826
|
+
const requestedRedirectUri = typeof payload === "object" && payload !== null && "redirectUri" in payload ? payload.redirectUri : void 0;
|
|
4827
|
+
if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
|
|
4828
|
+
let externalUser;
|
|
4829
|
+
try {
|
|
4830
|
+
externalUser = await provider.verify(payload);
|
|
4831
|
+
} catch (err) {
|
|
4832
|
+
logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
|
|
4833
|
+
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
|
|
4834
|
+
}
|
|
4835
|
+
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
4836
|
+
return externalUser;
|
|
4837
|
+
}
|
|
4838
|
+
/**
|
|
4427
4839
|
* Dynamically mount OAuth provider routes
|
|
4428
4840
|
*/
|
|
4429
4841
|
if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
|
|
4430
4842
|
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
4431
4843
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
4432
|
-
const
|
|
4433
|
-
if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
|
|
4434
|
-
let externalUser;
|
|
4435
|
-
try {
|
|
4436
|
-
externalUser = await provider.verify(payload);
|
|
4437
|
-
} catch (err) {
|
|
4438
|
-
logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
|
|
4439
|
-
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
|
|
4440
|
-
}
|
|
4441
|
-
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
4844
|
+
const externalUser = await verifyProviderPayload(provider, payload);
|
|
4442
4845
|
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
4443
4846
|
if (!user) {
|
|
4444
4847
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
@@ -4451,7 +4854,7 @@ function createAuthRoutes(config) {
|
|
|
4451
4854
|
reason: decision.reason,
|
|
4452
4855
|
provider: provider.id
|
|
4453
4856
|
});
|
|
4454
|
-
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId,
|
|
4857
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4455
4858
|
await authRepo.updateUser(user.id, {
|
|
4456
4859
|
displayName: user.displayName || externalUser.displayName || void 0,
|
|
4457
4860
|
photoUrl: user.photoUrl || externalUser.photoUrl || void 0
|
|
@@ -4472,14 +4875,13 @@ function createAuthRoutes(config) {
|
|
|
4472
4875
|
photoUrl: externalUser.photoUrl || void 0,
|
|
4473
4876
|
emailVerified: externalUser.emailVerified === true
|
|
4474
4877
|
});
|
|
4475
|
-
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId,
|
|
4878
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4476
4879
|
if (ops.afterUserCreate) try {
|
|
4477
4880
|
await ops.afterUserCreate(user);
|
|
4478
4881
|
} catch (err) {
|
|
4479
4882
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
4480
4883
|
}
|
|
4481
|
-
const
|
|
4482
|
-
const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
|
|
4884
|
+
const isFirstUser = await isOnlyUser(user.id);
|
|
4483
4885
|
if (bootstrapRegistration && !isFirstUser) {
|
|
4484
4886
|
await authRepo.deleteUser(user.id);
|
|
4485
4887
|
throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
@@ -4522,26 +4924,21 @@ function createAuthRoutes(config) {
|
|
|
4522
4924
|
const userCtx = c.get("user");
|
|
4523
4925
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4524
4926
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
4525
|
-
|
|
4526
|
-
try {
|
|
4527
|
-
externalUser = await provider.verify(payload);
|
|
4528
|
-
} catch (err) {
|
|
4529
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4530
|
-
throw ApiError.unauthorized(`${provider.id} link failed: ${msg}`, "OAUTH_ERROR");
|
|
4531
|
-
}
|
|
4532
|
-
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
4927
|
+
const externalUser = await verifyProviderPayload(provider, payload);
|
|
4533
4928
|
const identityOwner = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
4534
4929
|
if (identityOwner && identityOwner.id !== userCtx.uid) throw ApiError.conflict(`That ${provider.id} account is already linked to a different user.`, "IDENTITY_ALREADY_LINKED");
|
|
4535
4930
|
if (identityOwner) return c.json({
|
|
4536
4931
|
success: true,
|
|
4537
4932
|
provider: provider.id,
|
|
4538
|
-
alreadyLinked: true
|
|
4933
|
+
alreadyLinked: true,
|
|
4934
|
+
photoURL: externalUser.photoUrl ?? null
|
|
4539
4935
|
});
|
|
4540
|
-
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId,
|
|
4936
|
+
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, identityProfileData(externalUser));
|
|
4541
4937
|
return c.json({
|
|
4542
4938
|
success: true,
|
|
4543
4939
|
provider: provider.id,
|
|
4544
|
-
alreadyLinked: false
|
|
4940
|
+
alreadyLinked: false,
|
|
4941
|
+
photoURL: externalUser.photoUrl ?? null
|
|
4545
4942
|
});
|
|
4546
4943
|
});
|
|
4547
4944
|
}
|
|
@@ -4595,7 +4992,9 @@ function createAuthRoutes(config) {
|
|
|
4595
4992
|
const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
|
|
4596
4993
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
|
|
4597
4994
|
const passwordHash = await ops.hashPassword(password);
|
|
4598
|
-
await
|
|
4995
|
+
const account = await authRepo.getUserById(storedToken.uid);
|
|
4996
|
+
if (account && !account.emailVerified) await confirmAddressOwnership(authRepo, account, passwordHash);
|
|
4997
|
+
else await replaceUserPassword(authRepo, storedToken.uid, passwordHash);
|
|
4599
4998
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
4600
4999
|
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
4601
5000
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
@@ -4748,7 +5147,14 @@ function createAuthRoutes(config) {
|
|
|
4748
5147
|
};
|
|
4749
5148
|
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
4750
5149
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
4751
|
-
|
|
5150
|
+
const presentedKept = Boolean(supersededAt) || typeof authRepo.markRefreshTokenRotated === "function";
|
|
5151
|
+
if (await authRepo.createRefreshToken(storedToken.uid, await hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, presentedKept ? {
|
|
5152
|
+
...session,
|
|
5153
|
+
rotatedFrom: tokenHash
|
|
5154
|
+
} : session) === false) {
|
|
5155
|
+
clearRefreshCookie(c, config.cookieAuth);
|
|
5156
|
+
throw ApiError.unauthorized("Session has been revoked", "SESSION_REVOKED");
|
|
5157
|
+
}
|
|
4752
5158
|
authRepo.pruneRefreshTokens?.(storedToken.uid, session.id, new Date(Date.now() - reuseWindowMs)).catch((err) => {
|
|
4753
5159
|
logger.warn("[Auth] Refresh token prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
4754
5160
|
});
|
|
@@ -4775,7 +5181,8 @@ function createAuthRoutes(config) {
|
|
|
4775
5181
|
buildAuthResponse,
|
|
4776
5182
|
createSessionAndTokens,
|
|
4777
5183
|
applyTransformHook,
|
|
4778
|
-
requireLiveSession
|
|
5184
|
+
requireLiveSession,
|
|
5185
|
+
registerCaptcha: captcha.register
|
|
4779
5186
|
});
|
|
4780
5187
|
mountMfaRoutes({
|
|
4781
5188
|
router,
|
|
@@ -4910,7 +5317,7 @@ function createResetPasswordRoute(config) {
|
|
|
4910
5317
|
const expiresAt = new Date(Date.now() + 3600 * 1e3);
|
|
4911
5318
|
await authRepo.createPasswordResetToken(existing.id, tokenHash, expiresAt);
|
|
4912
5319
|
try {
|
|
4913
|
-
const setPasswordUrl = `${emailConfig
|
|
5320
|
+
const setPasswordUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
4914
5321
|
const { appName, logoUrl } = resolveEmailBranding(emailConfig);
|
|
4915
5322
|
const templateFn = emailConfig?.templates?.passwordReset;
|
|
4916
5323
|
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
@@ -5032,15 +5439,18 @@ function createAdminUsersRoute(config) {
|
|
|
5032
5439
|
}
|
|
5033
5440
|
const uid = "uid" in user ? user.uid : void 0;
|
|
5034
5441
|
if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
|
|
5035
|
-
|
|
5442
|
+
const refuseAnonymousCaller = () => {
|
|
5036
5443
|
logger.warn("[Security Audit] Bootstrap denied: anonymous caller", {
|
|
5037
5444
|
eventType: "auth.bootstrap.denied.anonymous",
|
|
5038
5445
|
callerId: uid
|
|
5039
5446
|
});
|
|
5040
5447
|
throw ApiError.forbidden("An anonymous session cannot claim the initial admin role. Register a real account and bootstrap from it, or assign the admin role using the service key.", "BOOTSTRAP_ANONYMOUS");
|
|
5041
|
-
}
|
|
5042
|
-
if (
|
|
5043
|
-
const
|
|
5448
|
+
};
|
|
5449
|
+
if (isAnonymousUid(uid)) refuseAnonymousCaller();
|
|
5450
|
+
const caller = await authRepo.getUserById(uid);
|
|
5451
|
+
if (!caller) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
|
|
5452
|
+
if (caller.isAnonymous === true) refuseAnonymousCaller();
|
|
5453
|
+
const registeredUsers = users.filter((u) => u.isAnonymous !== true && !isAnonymousUid(u.id));
|
|
5044
5454
|
if (registeredUsers.length > 0) {
|
|
5045
5455
|
const earliest = registeredUsers.reduce((a, b) => {
|
|
5046
5456
|
const at = new Date(a.createdAt).getTime();
|
|
@@ -5135,7 +5545,8 @@ function createAdminUsersRoute(config) {
|
|
|
5135
5545
|
passwordHash: prepResult.values.passwordHash,
|
|
5136
5546
|
displayName: prepResult.values.displayName,
|
|
5137
5547
|
photoUrl: prepResult.values.photoUrl,
|
|
5138
|
-
metadata: prepResult.values.metadata
|
|
5548
|
+
metadata: prepResult.values.metadata,
|
|
5549
|
+
emailVerified: prepResult.values.emailVerified === true
|
|
5139
5550
|
};
|
|
5140
5551
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5141
5552
|
const user = await authRepo.createUser(createData);
|
|
@@ -5160,8 +5571,12 @@ function createAdminUsersRoute(config) {
|
|
|
5160
5571
|
const uid = c.req.param("uid");
|
|
5161
5572
|
const { password, email, displayName, roles } = await c.req.json();
|
|
5162
5573
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
5574
|
+
if (roles !== void 0 && Array.isArray(roles)) await assertRoleChangesAllowed(authRepo, [{
|
|
5575
|
+
uid,
|
|
5576
|
+
roles
|
|
5577
|
+
}]);
|
|
5163
5578
|
const updates = {};
|
|
5164
|
-
if (email !== void 0) updates.email =
|
|
5579
|
+
if (email !== void 0) updates.email = await normalizeUserEmailChange(authRepo, uid, email);
|
|
5165
5580
|
if (displayName !== void 0) updates.displayName = displayName;
|
|
5166
5581
|
let passwordHash;
|
|
5167
5582
|
if (password) {
|
|
@@ -5171,17 +5586,7 @@ function createAdminUsersRoute(config) {
|
|
|
5171
5586
|
}
|
|
5172
5587
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
5173
5588
|
if (passwordHash) await replaceUserPassword(authRepo, uid, passwordHash);
|
|
5174
|
-
if (roles !== void 0 && Array.isArray(roles))
|
|
5175
|
-
const wasAdmin = (await authRepo.getUserRoleIds(uid)).includes("admin");
|
|
5176
|
-
const willBeAdmin = roles.includes("admin");
|
|
5177
|
-
if (wasAdmin && !willBeAdmin) {
|
|
5178
|
-
if ((await authRepo.listUsersPaginated({
|
|
5179
|
-
roleId: "admin",
|
|
5180
|
-
limit: 1
|
|
5181
|
-
})).total <= 1) throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
|
|
5182
|
-
}
|
|
5183
|
-
await authRepo.setUserRoles(uid, roles);
|
|
5184
|
-
}
|
|
5589
|
+
if (roles !== void 0 && Array.isArray(roles)) await authRepo.setUserRoles(uid, roles);
|
|
5185
5590
|
const result = await authRepo.getUserWithRoles(uid);
|
|
5186
5591
|
const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
|
|
5187
5592
|
return c.json({ user: adminUser });
|
|
@@ -5190,17 +5595,11 @@ function createAdminUsersRoute(config) {
|
|
|
5190
5595
|
const uid = c.req.param("uid");
|
|
5191
5596
|
if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
|
|
5192
5597
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
5193
|
-
|
|
5194
|
-
if ((await authRepo.listUsersPaginated({
|
|
5195
|
-
roleId: "admin",
|
|
5196
|
-
limit: 1
|
|
5197
|
-
})).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
|
|
5198
|
-
}
|
|
5598
|
+
await assertUserDeletionsAllowed(authRepo, [uid]);
|
|
5199
5599
|
if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
|
|
5600
|
+
await authRepo.deleteAllRefreshTokensForUser(uid);
|
|
5200
5601
|
await authRepo.deleteUser(uid);
|
|
5201
|
-
|
|
5202
|
-
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
5203
|
-
});
|
|
5602
|
+
runAfterUserDelete(ops, uid);
|
|
5204
5603
|
return c.json({ success: true });
|
|
5205
5604
|
});
|
|
5206
5605
|
return router;
|
|
@@ -5217,6 +5616,29 @@ function createAdminUsersRoute(config) {
|
|
|
5217
5616
|
function createBuiltinAuthAdapter(config) {
|
|
5218
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;
|
|
5219
5618
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
5619
|
+
/**
|
|
5620
|
+
* The caller's roles as the database has them now.
|
|
5621
|
+
*
|
|
5622
|
+
* Read per request because the token's `roles` claim can be a whole
|
|
5623
|
+
* access-token lifetime stale — a demoted admin still holds a token that
|
|
5624
|
+
* says `admin`. So when the read fails, falling back to the claim hands
|
|
5625
|
+
* that token exactly the access the lookup exists to take away; during a
|
|
5626
|
+
* database blip it scoped a demoted admin with admin RLS. This refuses
|
|
5627
|
+
* instead, with the 503 `createRequireAuth` already gives the admin
|
|
5628
|
+
* routes: the adapter middleware answers with it, and the socket treats
|
|
5629
|
+
* a throwing adapter as an invalid token.
|
|
5630
|
+
*/
|
|
5631
|
+
async function resolveLiveRoles(uid) {
|
|
5632
|
+
try {
|
|
5633
|
+
return await authRepository.getUserRoleIds(uid);
|
|
5634
|
+
} catch (error) {
|
|
5635
|
+
logger.warn("[Auth] Could not resolve roles for a request; refusing it", {
|
|
5636
|
+
uid,
|
|
5637
|
+
error
|
|
5638
|
+
});
|
|
5639
|
+
throw new ApiError(503, "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
|
|
5640
|
+
}
|
|
5641
|
+
}
|
|
5220
5642
|
return {
|
|
5221
5643
|
id: "rebase-builtin",
|
|
5222
5644
|
serviceKey,
|
|
@@ -5239,15 +5661,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5239
5661
|
});
|
|
5240
5662
|
return null;
|
|
5241
5663
|
}
|
|
5242
|
-
|
|
5243
|
-
try {
|
|
5244
|
-
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
5245
|
-
} catch (err) {
|
|
5246
|
-
logger.warn("Role lookup from repository failed, using token roles as fallback", {
|
|
5247
|
-
uid: payload.uid,
|
|
5248
|
-
error: err
|
|
5249
|
-
});
|
|
5250
|
-
}
|
|
5664
|
+
const roles = await resolveLiveRoles(payload.uid);
|
|
5251
5665
|
const isAdmin = hasAdministrativeRole(roles);
|
|
5252
5666
|
return {
|
|
5253
5667
|
uid: payload.uid,
|
|
@@ -5256,6 +5670,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5256
5670
|
roles,
|
|
5257
5671
|
isAdmin,
|
|
5258
5672
|
isAnonymous: payload.isAnonymous === true,
|
|
5673
|
+
...payload.claims ? { claims: payload.claims } : {},
|
|
5259
5674
|
rawToken: token
|
|
5260
5675
|
};
|
|
5261
5676
|
},
|
|
@@ -5276,15 +5691,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5276
5691
|
});
|
|
5277
5692
|
return null;
|
|
5278
5693
|
}
|
|
5279
|
-
|
|
5280
|
-
try {
|
|
5281
|
-
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
5282
|
-
} catch (err) {
|
|
5283
|
-
logger.warn("Role lookup from repository failed, using token roles as fallback", {
|
|
5284
|
-
uid: payload.uid,
|
|
5285
|
-
error: err
|
|
5286
|
-
});
|
|
5287
|
-
}
|
|
5694
|
+
const roles = await resolveLiveRoles(payload.uid);
|
|
5288
5695
|
const isAdmin = hasAdministrativeRole(roles);
|
|
5289
5696
|
return {
|
|
5290
5697
|
uid: payload.uid,
|
|
@@ -5293,6 +5700,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5293
5700
|
roles,
|
|
5294
5701
|
isAdmin,
|
|
5295
5702
|
isAnonymous: payload.isAnonymous === true,
|
|
5703
|
+
...payload.claims ? { claims: payload.claims } : {},
|
|
5296
5704
|
rawToken: token
|
|
5297
5705
|
};
|
|
5298
5706
|
},
|
|
@@ -5371,6 +5779,10 @@ function createBuiltinAuthAdapter(config) {
|
|
|
5371
5779
|
collectionAuthConfig
|
|
5372
5780
|
});
|
|
5373
5781
|
},
|
|
5782
|
+
...authCollectionUserAdmin({
|
|
5783
|
+
authRepo: authRepository,
|
|
5784
|
+
resolvedHooks: resolvedOps
|
|
5785
|
+
}),
|
|
5374
5786
|
async getCapabilities() {
|
|
5375
5787
|
let needsSetup = false;
|
|
5376
5788
|
try {
|
|
@@ -5444,6 +5856,7 @@ function createUserManagementFromRepo(repo, resolvedOps) {
|
|
|
5444
5856
|
},
|
|
5445
5857
|
async deleteUser(id) {
|
|
5446
5858
|
if (resolvedOps.beforeUserDelete) await resolvedOps.beforeUserDelete(id);
|
|
5859
|
+
await repo.deleteAllRefreshTokensForUser(id);
|
|
5447
5860
|
await repo.deleteUser(id);
|
|
5448
5861
|
if (resolvedOps.afterUserDelete) resolvedOps.afterUserDelete(id).catch((err) => {
|
|
5449
5862
|
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
@@ -7177,6 +7590,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
7177
7590
|
verifyPassword: () => verifyPassword
|
|
7178
7591
|
});
|
|
7179
7592
|
//#endregion
|
|
7180
|
-
export { requireAdmin as $,
|
|
7593
|
+
export { requireAdmin as $, createRateLimiter as A, isPublicStoragePath as At, createEmailService as B, providerVerifiedEmail as C, RawHtml as Ct, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as D, httpMethodToOperation as Dt, isBootstrapWindowOpen as E, raw as Et, clearActiveDevEmailSink as F, createAdapterAuthMiddleware as G, hashPassword as H, createDevEmailSink as I, extractUserFromToken as J, createAuthMiddleware as K, extractLinks as L, setSharedRateLimitStore as M, MemoryRateLimitStore as N, createDataRateLimitCheck as O, isOperationAllowed as Ot, activeDevEmailSink as P, queryTokenAuth as Q, registerDevEmailSink as R, pkceTokenParams as S, resolveEmailBranding as St, createBuiltinAuthAdapter as T, html as Tt, validatePasswordStrength as U, resolveAuthHooks as V, verifyPassword as W, optionalAuth as X, fileTokenAuth as Y, publicObjectAuth as Z, verifyOidcIdToken as _, getEmailVerificationTemplate as _t, resolveRateLimitStoreKind as a, validateApiKey as at, createGoogleProvider as b, getUserInvitationTemplate as bt, createSlackProvider as c, SERVICE_IDENTITY as ct, createDiscordProvider as d, isAccessTokenRevoked as dt, requireAuth as et, createTwitterProvider as f, completeUserCreation as ft, tryVerifyOidcIdToken as g, getEmailOtpTemplate as gt, createMicrosoftProvider as h, resolveEmailLinkBase as ht, createApiKeyStore as i, isApiKeyToken as it, defaultAuthLimiter as j, createDataRateLimiter as k, PUBLIC_STORAGE_PREFIX as kt, createBitbucketProvider as l, scopeDataDriver as lt, createAppleProvider as m, assertEmailLinkBases as mt, createCustomAuthAdapter as n, createFunctionApiKeyGuard as nt, createSqlRateLimitStore as o, extractBearerToken as ot, createFacebookProvider as p, generateSecurePassword as pt, createRequireAuth as q, createApiKeyRoutes as r, createStorageApiKeyGuard as rt, createSpotifyProvider as s, safeCompare as st, auth_exports as t, createApiKeyPreAuth as tt, createGitLabProvider as u, supportsRlsScoping as ut, createGitHubProvider as v, getMagicLinkTemplate as vt, createJwksRoutes as w, escapeHtml as wt, oauthCodeFlowSchema as x, getWelcomeEmailTemplate as xt, createLinkedinProvider as y, getPasswordResetTemplate as yt, SMTPEmailService as z };
|
|
7181
7594
|
|
|
7182
|
-
//# sourceMappingURL=auth-
|
|
7595
|
+
//# sourceMappingURL=auth-B-GIMpDG.js.map
|