@rebasepro/server 0.13.0 → 0.13.1-canary.g06dbe5b

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.
Files changed (125) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
  2. package/dist/admin_block-H86dCPsj.js.map +1 -0
  3. package/dist/api/ast-schema-editor.d.ts +74 -6
  4. package/dist/api/errors.d.ts +0 -6
  5. package/dist/api/openapi-generator.d.ts +14 -0
  6. package/dist/api/rest/api-generator.d.ts +1 -1
  7. package/dist/api/rest/idempotency.d.ts +62 -4
  8. package/dist/api/rest/query-parser.d.ts +15 -1
  9. package/dist/api/rest/write-validation.d.ts +52 -9
  10. package/dist/auth/adapter-middleware.d.ts +3 -1
  11. package/dist/auth/apple-oauth.d.ts +16 -11
  12. package/dist/auth/bitbucket-oauth.d.ts +2 -4
  13. package/dist/auth/builtin-auth-adapter.d.ts +2 -0
  14. package/dist/auth/discord-oauth.d.ts +5 -6
  15. package/dist/auth/facebook-oauth.d.ts +2 -4
  16. package/dist/auth/github-oauth.d.ts +2 -4
  17. package/dist/auth/gitlab-oauth.d.ts +6 -4
  18. package/dist/auth/google-oauth.d.ts +1 -0
  19. package/dist/auth/index.d.ts +8 -1
  20. package/dist/auth/interfaces.d.ts +68 -1
  21. package/dist/auth/jwt.d.ts +40 -0
  22. package/dist/auth/linkedin-oauth.d.ts +2 -4
  23. package/dist/auth/mfa-gate.d.ts +42 -0
  24. package/dist/auth/mfa-routes.d.ts +26 -1
  25. package/dist/auth/mfa.d.ts +16 -0
  26. package/dist/auth/microsoft-oauth.d.ts +19 -4
  27. package/dist/auth/oauth-code-flow.d.ts +66 -0
  28. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  29. package/dist/auth/oidc-id-token.d.ts +61 -0
  30. package/dist/auth/rate-limiter.d.ts +37 -2
  31. package/dist/auth/rls-scope.d.ts +25 -0
  32. package/dist/auth/routes.d.ts +8 -0
  33. package/dist/auth/slack-oauth.d.ts +2 -4
  34. package/dist/auth/spotify-oauth.d.ts +2 -4
  35. package/dist/auth/twitter-oauth.d.ts +2 -5
  36. package/dist/{auth-CuC9M2x6.js → auth-DkAHqvf5.js} +1604 -391
  37. package/dist/auth-DkAHqvf5.js.map +1 -0
  38. package/dist/{backup-CVggVhR2.js → backup-DLluVyuA.js} +2 -2
  39. package/dist/{backup-CVggVhR2.js.map → backup-DLluVyuA.js.map} +1 -1
  40. package/dist/boot/boot.d.ts +19 -0
  41. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  42. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-BB1U05sS.js} +3 -3
  43. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-BB1U05sS.js.map} +1 -1
  44. package/dist/cron/cron-scheduler.d.ts +8 -3
  45. package/dist/cron/define-cron.d.ts +17 -3
  46. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-3U5aGILy.js} +2 -2
  47. package/dist/{cron-loader-B1S2MCSl.js.map → cron-loader-3U5aGILy.js.map} +1 -1
  48. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-rZgwlOz4.js} +2 -2
  49. package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-rZgwlOz4.js.map} +1 -1
  50. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-FJAaCAXm.js} +30 -5
  51. package/dist/cron-scheduler-FJAaCAXm.js.map +1 -0
  52. package/dist/{cron-store-BywZsyfZ.js → cron-store-9NmUDfzL.js} +66 -47
  53. package/dist/cron-store-9NmUDfzL.js.map +1 -0
  54. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  55. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  56. package/dist/email/html.d.ts +54 -0
  57. package/dist/email/index.d.ts +3 -0
  58. package/dist/email/link-base.d.ts +39 -0
  59. package/dist/email/smtp-email-service.d.ts +7 -1
  60. package/dist/email/templates.d.ts +9 -1
  61. package/dist/email/types.d.ts +11 -1
  62. package/dist/{errors-CgkCzoj7.js → errors-B1WZEdsK.js} +3 -11
  63. package/dist/errors-B1WZEdsK.js.map +1 -0
  64. package/dist/function-loader-D1SwtCa5.js +139 -0
  65. package/dist/function-loader-D1SwtCa5.js.map +1 -0
  66. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  67. package/dist/function-routes-Btcez1T-.js.map +1 -0
  68. package/dist/functions/define-function.d.ts +25 -6
  69. package/dist/functions/function-loader.d.ts +23 -0
  70. package/dist/functions/function-routes.d.ts +7 -1
  71. package/dist/functions/request-timeout.d.ts +34 -0
  72. package/dist/history/history-routes.d.ts +6 -0
  73. package/dist/index.d.ts +2 -1
  74. package/dist/index.es.js +1960 -794
  75. package/dist/index.es.js.map +1 -1
  76. package/dist/init/docs.d.ts +10 -1
  77. package/dist/init/process-safety.d.ts +25 -0
  78. package/dist/init.d.ts +27 -0
  79. package/dist/{jwt-DD6EtpGj.js → jwt-Cuq6MwNy.js} +70 -8
  80. package/dist/jwt-Cuq6MwNy.js.map +1 -0
  81. package/dist/logger-DfvF_8r-.js +190 -0
  82. package/dist/logger-DfvF_8r-.js.map +1 -0
  83. package/dist/openapi-generator-BQxxxDpb.js +865 -0
  84. package/dist/openapi-generator-BQxxxDpb.js.map +1 -0
  85. package/dist/request-timeout-RivJsME0.js +65 -0
  86. package/dist/request-timeout-RivJsME0.js.map +1 -0
  87. package/dist/schema-editor-routes-DZFKGXgq.js +437 -0
  88. package/dist/schema-editor-routes-DZFKGXgq.js.map +1 -0
  89. package/dist/serve-spa.d.ts +5 -4
  90. package/dist/services/outbound-url-guard.d.ts +53 -0
  91. package/dist/services/routed-realtime-service.d.ts +10 -2
  92. package/dist/services/webhook-service.d.ts +76 -1
  93. package/dist/singleton.d.ts +25 -8
  94. package/dist/{src-Cum9kox5.js → src-By48Ffg0.js} +322 -70
  95. package/dist/src-By48Ffg0.js.map +1 -0
  96. package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
  97. package/dist/src-Ca6NhxKs.js.map +1 -0
  98. package/dist/storage/LocalStorageController.d.ts +12 -1
  99. package/dist/storage/image-transform.d.ts +57 -0
  100. package/dist/storage/keys.d.ts +98 -0
  101. package/dist/storage/routes.d.ts +11 -0
  102. package/dist/storage/tus-handler.d.ts +7 -1
  103. package/dist/utils/logger.d.ts +18 -0
  104. package/dist/utils/logging.d.ts +0 -4
  105. package/dist/utils/sql.d.ts +11 -6
  106. package/package.json +7 -6
  107. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  108. package/dist/auth-CuC9M2x6.js.map +0 -1
  109. package/dist/backend-CIxN4FVm.js +0 -15
  110. package/dist/backend-CIxN4FVm.js.map +0 -1
  111. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  112. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  113. package/dist/errors-CgkCzoj7.js.map +0 -1
  114. package/dist/function-loader-B_1fYfUY.js +0 -86
  115. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  116. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  117. package/dist/jwt-DD6EtpGj.js.map +0 -1
  118. package/dist/logger-BYU66ENZ.js +0 -94
  119. package/dist/logger-BYU66ENZ.js.map +0 -1
  120. package/dist/openapi-generator-BEwyiaAr.js +0 -597
  121. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  122. package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
  123. package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
  124. package/dist/src-Cum9kox5.js.map +0 -1
  125. package/dist/src-_qQ3RNCK.js.map +0 -1
@@ -2,38 +2,18 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
5
- import { _ as ANONYMOUS_USER_ID, v as isAnonymousUid } from "./src-Cum9kox5.js";
6
- import "./src-_qQ3RNCK.js";
7
- import { t as isSQLAdmin } from "./backend-CIxN4FVm.js";
8
- import { t as logger } from "./logger-BYU66ENZ.js";
9
- import { n as errorHandler, t as ApiError } from "./errors-CgkCzoj7.js";
10
- import { a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, i as generateDownloadToken, l as hashRefreshToken, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-DD6EtpGj.js";
5
+ import { C as isAnonymousUid, S as ANONYMOUS_USER_ID, i as deserializeLogicalCondition, r as deserializeFilter, x as resolveClientListLimit, y as ListLimitError } from "./src-By48Ffg0.js";
6
+ import "./src-Ca6NhxKs.js";
7
+ import { i as isSQLAdmin, r as revokeInternalTableSql, t as createDdlBootstrapper } from "./ddl-bootstrap-BhXbTnBl.js";
8
+ import { t as logger } from "./logger-DfvF_8r-.js";
9
+ import { n as errorHandler, t as ApiError } from "./errors-B1WZEdsK.js";
10
+ import { a as generateMfaPendingToken, c as getRefreshTokenExpiry, d as isJwtConfigured, g as require_jsonwebtoken, h as verifyMfaPendingToken, i as generateDownloadToken, l as getRefreshTokenTtlMs, m as verifyDownloadToken, n as configureJwt, o as generateRefreshToken, p as verifyAccessToken, r as generateAccessToken, s as getAccessTokenExpiry, t as MAX_COOKIE_AGE_MS, u as hashRefreshToken } from "./jwt-Cuq6MwNy.js";
11
11
  import { createHash, randomBytes, randomInt } from "node:crypto";
12
12
  import { Hono } from "hono";
13
13
  import { promisify } from "util";
14
- import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
15
- /** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
16
- var MAX_LIST_LIMIT = 1e3;
17
- /**
18
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
19
- *
20
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
21
- * so `0`, negatives, and absurd values can never bypass the cap.
22
- * - An absent / blank / non-numeric limit falls back to the mode default:
23
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
24
- *
25
- * The return is never `undefined` — no ingress that routes its client limit
26
- * through this can produce an unbounded read.
27
- */
28
- function resolveClientListLimit(rawLimit, opts = {}) {
29
- const maxLimit = opts.maxLimit ?? 1e3;
30
- if (rawLimit != null && String(rawLimit).trim() !== "") {
31
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
32
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
33
- }
34
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
35
- }
36
- //#endregion
14
+ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
15
+ import path from "node:path";
16
+ import { getConnInfo } from "@hono/node-server/conninfo";
37
17
  //#region ../types/src/controllers/storage.ts
38
18
  /**
39
19
  * Path prefix that marks an object as **public**. Files stored under this
@@ -94,6 +74,247 @@ function normalizeEmail(email) {
94
74
  return typeof email === "string" ? email.trim().toLowerCase() : email;
95
75
  }
96
76
  //#endregion
77
+ //#region src/api/rest/query-parser.ts
78
+ function getLastValue(val) {
79
+ if (Array.isArray(val)) return val[val.length - 1];
80
+ return val;
81
+ }
82
+ /**
83
+ * Parse an `or(...)` / `and(...)` logical group from its wire form.
84
+ *
85
+ * The wire carries the inner conditions wrapped in parens (e.g.
86
+ * `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
87
+ * delegate to the canonical filter dialect (`@rebasepro/common`). Values are
88
+ * preserved as strings — type coercion is the schema-aware driver's job, so
89
+ * this path stays byte-for-byte consistent with the SDK/admin path (which
90
+ * also parses via the shared dialect).
91
+ */
92
+ function parseLogicalGroup(type, raw) {
93
+ let inner = String(raw).trim();
94
+ if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
95
+ inner = inner.trim();
96
+ if (!inner) return void 0;
97
+ let parsed;
98
+ try {
99
+ parsed = deserializeLogicalCondition(`${type}(${inner})`);
100
+ } catch (e) {
101
+ throw ApiError.badRequest(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
102
+ }
103
+ return "type" in parsed ? parsed : void 0;
104
+ }
105
+ /**
106
+ * Parse the `?where=` JSON filter object.
107
+ *
108
+ * This is the dialect the OpenAPI document publishes on every
109
+ * `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
110
+ * `[WhereFilterOp, value]` tuple. It is normalized through the same
111
+ * `deserializeFilter` as the `?field=op.value` params below, so a value that
112
+ * arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
113
+ * scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
114
+ * querystring dialect, JSON carries types — a number stays a number.
115
+ *
116
+ * A malformed value is a 400 rather than a silent drop: dropping the filter
117
+ * would run the read unfiltered and return everything RLS happens to allow.
118
+ */
119
+ function parseWhereParam(raw) {
120
+ const str = String(raw).trim();
121
+ if (!str) return void 0;
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(str);
125
+ } catch {
126
+ throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
127
+ }
128
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
129
+ const filter = deserializeFilter(parsed);
130
+ return Object.keys(filter).length > 0 ? filter : void 0;
131
+ }
132
+ function invalidOrderBy(detail) {
133
+ throw ApiError.badRequest(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
134
+ }
135
+ /** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
136
+ function toDirection(raw, context) {
137
+ if (raw === void 0 || raw === null) return "asc";
138
+ if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
139
+ const lowered = raw.toLowerCase();
140
+ if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
141
+ return lowered;
142
+ }
143
+ /** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
144
+ function toOrderByEntry(raw, index) {
145
+ const context = `entry ${index}`;
146
+ if (typeof raw === "string") {
147
+ const idx = raw.indexOf(":");
148
+ const field = (idx === -1 ? raw : raw.slice(0, idx)).trim();
149
+ if (!field) invalidOrderBy(`${context} is an empty field name`);
150
+ return {
151
+ field,
152
+ direction: idx === -1 ? "asc" : toDirection(raw.slice(idx + 1), context)
153
+ };
154
+ }
155
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
156
+ const entry = raw;
157
+ if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
158
+ return {
159
+ field: entry.field,
160
+ direction: toDirection(entry.direction, context)
161
+ };
162
+ }
163
+ /**
164
+ * Parse the `orderBy` query parameter.
165
+ *
166
+ * The field *name* has been validated against the schema for a while — an
167
+ * `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
168
+ * that silently dropping the sort leaves the caller believing in an order that
169
+ * is not there. The parameter's *shape* was never checked the same way, and it
170
+ * failed in exactly the same silent manner one layer earlier: whatever
171
+ * `JSON.parse` returned was assigned to an option declared as an array of
172
+ * `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
173
+ * `?orderBy={"field":"name"}` — an object rather than an array, and the most
174
+ * natural thing for a client to try — read `undefined`, dropped the ORDER BY,
175
+ * and answered 200. So did a number, a boolean, `null`, and `["name"]`.
176
+ *
177
+ * This refuses those, the way `parseWhereParam` above already refuses a
178
+ * malformed filter and for the same reason. What it keeps working is every
179
+ * shape that worked before: the `field` and `field:desc` shorthands, and the
180
+ * canonical JSON array.
181
+ */
182
+ function parseOrderByParam(raw) {
183
+ if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
184
+ const str = String(raw).trim();
185
+ if (!str) return void 0;
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(str);
189
+ } catch {
190
+ return [toOrderByEntry(str, 0)];
191
+ }
192
+ if (Array.isArray(parsed)) {
193
+ if (parsed.length === 0) return void 0;
194
+ return parsed.map(toOrderByEntry);
195
+ }
196
+ if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
197
+ if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
198
+ invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
199
+ }
200
+ /**
201
+ * {@link resolveClientListLimit} for an HTTP route: the same bounds, answered
202
+ * with a 400 rather than a 500.
203
+ *
204
+ * The shared resolver throws a `ListLimitError`, which carries `status` — but
205
+ * the Hono error handler discriminates on `statusCode`, so an unconverted one
206
+ * reaches the client as `INTERNAL_ERROR` with its message stripped, telling the
207
+ * caller nothing about the parameter it got wrong. Every REST list ingress
208
+ * routes its `limit` through here so all of them name the ceiling the same way.
209
+ */
210
+ function resolveListLimitParam(rawLimit, opts = {}) {
211
+ try {
212
+ return resolveClientListLimit(rawLimit, opts);
213
+ } catch (e) {
214
+ if (e instanceof ListLimitError) throw ApiError.badRequest(e.message, "INVALID_LIMIT");
215
+ throw e;
216
+ }
217
+ }
218
+ /**
219
+ * Parse query parameters into QueryOptions
220
+ */
221
+ function parseQueryOptions(query, limits = {}) {
222
+ const options = {};
223
+ const rawLimit = getLastValue(query.limit);
224
+ const offsetVal = getLastValue(query.offset);
225
+ if (offsetVal) options.offset = parseInt(String(offsetVal));
226
+ const pageVal = getLastValue(query.page);
227
+ if (pageVal) {
228
+ const page = parseInt(String(pageVal));
229
+ const limit = resolveListLimitParam(rawLimit, {
230
+ defaultLimit: limits.defaultLimit,
231
+ maxLimit: limits.maxLimit
232
+ });
233
+ options.offset = (page - 1) * limit;
234
+ }
235
+ const orVal = getLastValue(query.or);
236
+ const andVal = getLastValue(query.and);
237
+ if (orVal) {
238
+ const logical = parseLogicalGroup("or", orVal);
239
+ if (logical) options.logical = logical;
240
+ } else if (andVal) {
241
+ const logical = parseLogicalGroup("and", andVal);
242
+ if (logical) options.logical = logical;
243
+ }
244
+ const reservedQueryKeys = [
245
+ "limit",
246
+ "offset",
247
+ "page",
248
+ "orderBy",
249
+ "include",
250
+ "fields",
251
+ "searchString",
252
+ "searchExplain",
253
+ "vector_search",
254
+ "vector",
255
+ "vector_distance",
256
+ "vector_threshold",
257
+ "or",
258
+ "and",
259
+ "where"
260
+ ];
261
+ const filterDict = {};
262
+ for (const [key, rawValue] of Object.entries(query)) {
263
+ if (reservedQueryKeys.includes(key)) continue;
264
+ filterDict[key] = rawValue;
265
+ }
266
+ const whereVal = getLastValue(query.where);
267
+ const where = {
268
+ ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
269
+ ...deserializeFilter(filterDict)
270
+ };
271
+ if (Object.keys(where).length > 0) options.where = where;
272
+ const orderByVal = getLastValue(query.orderBy);
273
+ if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
274
+ const includeVal = getLastValue(query.include);
275
+ if (includeVal) {
276
+ const includeStr = String(includeVal).trim();
277
+ if (includeStr === "*") options.include = ["*"];
278
+ else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
279
+ }
280
+ const fieldsVal = getLastValue(query.fields);
281
+ if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
282
+ const vectorSearchVal = getLastValue(query.vector_search);
283
+ const vectorVal = getLastValue(query.vector);
284
+ if (vectorSearchVal && vectorVal) {
285
+ const vectorStr = String(vectorVal);
286
+ let decoded;
287
+ try {
288
+ decoded = JSON.parse(vectorStr);
289
+ } catch {
290
+ decoded = void 0;
291
+ }
292
+ if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw ApiError.badRequest("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
293
+ const queryVector = decoded;
294
+ const distanceParamVal = getLastValue(query.vector_distance);
295
+ const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
296
+ if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
297
+ const vectorSearch = {
298
+ property: String(vectorSearchVal),
299
+ vector: queryVector,
300
+ distance: distanceParam
301
+ };
302
+ const thresholdVal = getLastValue(query.vector_threshold);
303
+ if (thresholdVal) {
304
+ const threshold = parseFloat(String(thresholdVal));
305
+ if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
306
+ vectorSearch.threshold = threshold;
307
+ }
308
+ options.vectorSearch = vectorSearch;
309
+ }
310
+ options.limit = resolveListLimitParam(rawLimit, {
311
+ vectorSearch: !!options.vectorSearch,
312
+ defaultLimit: limits.defaultLimit,
313
+ maxLimit: limits.maxLimit
314
+ });
315
+ return options;
316
+ }
317
+ //#endregion
97
318
  //#region src/auth/api-keys/api-key-permission-guard.ts
98
319
  /**
99
320
  * Map an HTTP method string to an `ApiKeyOperation`.
@@ -171,6 +392,31 @@ function isFunctionAllowed(permissions, functionName, operation) {
171
392
  //#region src/auth/rls-scope.ts
172
393
  var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
173
394
  /**
395
+ * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
396
+ * caller presenting the service key.
397
+ *
398
+ * Naming it makes the privilege legible, because it is narrower than it reads.
399
+ * This is **not** an RLS bypass — a driver scoped with it is an
400
+ * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
401
+ * transaction that has done `SET LOCAL ROLE rebase_user` with
402
+ * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
403
+ * knowing before you write a policy:
404
+ *
405
+ * - It passes the default policies through their `rolesOverlap(['admin'])`
406
+ * arm — the same arm an application user holding the `admin` role passes.
407
+ * - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
408
+ * **false** for it. A collection with `disableDefaultPolicies: true` whose
409
+ * only rule is `serverContext()` denies these writes (42501) and returns
410
+ * zero rows for these reads.
411
+ *
412
+ * The true bypass is `rebase.sql()`, which runs on the owner connection and
413
+ * never goes near `withAuth`.
414
+ */
415
+ var SERVICE_IDENTITY = {
416
+ uid: "service",
417
+ roles: ["admin"]
418
+ };
419
+ /**
174
420
  * Returns true if the driver supports RLS scoping via `withAuth()`.
175
421
  */
176
422
  function isRLSScopedDriver(driver) {
@@ -461,6 +707,137 @@ function createApiKeyPreAuth(options) {
461
707
  };
462
708
  }
463
709
  //#endregion
710
+ //#region src/storage/keys.ts
711
+ /**
712
+ * Canonical storage keys and bucket names.
713
+ *
714
+ * Storage is not under RLS, so a `storageAuthorize` hook is the whole access
715
+ * control model — and a hook can only be correct if the key it is shown is the
716
+ * key that is written. That is the invariant this module exists to hold: one
717
+ * canonical string, computed once per request, handed to the hook, to the
718
+ * controller, and to the download token alike.
719
+ *
720
+ * ## Why rejecting beats stripping
721
+ *
722
+ * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
723
+ * things were wrong with that, and only one of them was the obvious one.
724
+ *
725
+ * The obvious one: a single pass is not a fixed point. `....//` contains `../`
726
+ * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
727
+ * the traversal it was there to remove. `users/alice/....//bob/x` came out as
728
+ * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
729
+ * `users/alice/`) and the filesystem reads as bob's. The hook approved one
730
+ * object and the controller wrote another.
731
+ *
732
+ * The subtler one, and the reason this is a rewrite rather than a loop: even a
733
+ * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
734
+ * gets an object at `a/b` was not protected, they were misled — and every later
735
+ * read, ownership row and audit line now refers to a path nobody chose. So a key
736
+ * that means something other than what it says is refused (400), not repaired.
737
+ *
738
+ * Note what is NOT traversal under this rule: `....` is an ordinary directory
739
+ * name, and `users/alice/....//bob/x` canonicalizes to
740
+ * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
741
+ * exactly right. Only a real `..` segment is refused.
742
+ */
743
+ /** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
744
+ var MAX_STORAGE_KEY_LENGTH = 1024;
745
+ /**
746
+ * A key that cannot be canonicalized. Carries no path back to the caller
747
+ * beyond what they sent, so it is safe to surface as a 400 message.
748
+ */
749
+ var InvalidStorageKeyError = class extends Error {
750
+ constructor(message) {
751
+ super(message);
752
+ this.name = "InvalidStorageKeyError";
753
+ }
754
+ };
755
+ /**
756
+ * Canonicalize a caller-supplied storage key, or throw
757
+ * {@link InvalidStorageKeyError}.
758
+ *
759
+ * Normalizations applied (safe, idempotent, and meaning-preserving):
760
+ * - leading slashes removed — `/a/b` and `a/b` name the same object
761
+ * - `.` segments and repeated slashes collapsed
762
+ *
763
+ * Refusals (the key means something other than what it says):
764
+ * - any `..` segment, on either separator, at any depth
765
+ * - null bytes
766
+ * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
767
+ *
768
+ * A trailing slash is preserved: it is how the folder route marks a prefix.
769
+ */
770
+ function canonicalStorageKey(rawKey) {
771
+ if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
772
+ if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
773
+ if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
774
+ const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
775
+ if (withoutLeadingSlashes === "") return "";
776
+ const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
777
+ const normalized = path.posix.normalize(withoutLeadingSlashes);
778
+ if (normalized === "." || normalized === "./") return "";
779
+ const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
780
+ if (key === "") return "";
781
+ return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
782
+ }
783
+ /**
784
+ * Canonicalize, or return `null` when the key is not canonicalizable.
785
+ *
786
+ * For callers that must fail closed without an exception — the download-token
787
+ * middleware compares a request path against a granted path, and a key it
788
+ * cannot canonicalize simply matches nothing.
789
+ */
790
+ function tryCanonicalStorageKey(rawKey) {
791
+ try {
792
+ return canonicalStorageKey(rawKey);
793
+ } catch {
794
+ return null;
795
+ }
796
+ }
797
+ /** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
798
+ var InvalidStorageBucketError = class extends Error {
799
+ constructor(message) {
800
+ super(message);
801
+ this.name = "InvalidStorageBucketError";
802
+ }
803
+ };
804
+ /**
805
+ * One path segment: letters, digits, `.`, `_`, `-`, first character
806
+ * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
807
+ * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
808
+ * absolute paths and anything containing a separator unrepresentable.
809
+ */
810
+ var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
811
+ /**
812
+ * Canonicalize a caller-supplied bucket name, or throw
813
+ * {@link InvalidStorageBucketError}.
814
+ *
815
+ * The bucket is the *other* caller-controlled routing value in an upload
816
+ * request, and it was the one nobody validated. `LocalStorageController`
817
+ * builds `join(basePath, bucket)` and then checks containment against that
818
+ * result, so a bucket of `../../etc` moved the boundary rather than crossing
819
+ * it — the guard passed because the guard's reference point was the attacker's.
820
+ * The containment check now resolves against the storage root, and this is the
821
+ * check at the route boundary that stops the value before it gets there.
822
+ *
823
+ * A bucket is configuration, not user data: there is no legitimate caller that
824
+ * needs a separator, a leading dot, or a `..` in one. So this refuses rather
825
+ * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
826
+ * bucket would silently store the object somewhere the caller did not ask for.
827
+ *
828
+ * Returns `undefined` when the caller named no bucket (absent, or an empty
829
+ * form field), which is how every controller spells "use my default". An empty
830
+ * string used to reach `getFullPath` and resolve to the storage *root* rather
831
+ * than the `default` bucket, which is a third place a bare key did not
832
+ * round-trip.
833
+ */
834
+ function canonicalStorageBucket(rawBucket) {
835
+ if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
836
+ if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
837
+ if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) throw new InvalidStorageBucketError("Storage bucket must be a single name of letters, digits, '.', '_' or '-', starting with a letter or digit.");
838
+ return rawBucket;
839
+ }
840
+ //#endregion
464
841
  //#region src/auth/middleware.ts
465
842
  /**
466
843
  * Hono middleware that requires a valid JWT token via Authorization header.
@@ -551,7 +928,7 @@ var requireAdmin = async (c, next) => {
551
928
  var optionalAuth = async (c, next) => {
552
929
  if (c.get("user")) return next();
553
930
  const token = extractBearerToken(c.req.header("authorization"));
554
- if (token !== void 0) {
931
+ if (token !== void 0 && isJwtConfigured()) {
555
932
  const payload = verifyAccessToken(token);
556
933
  if (payload) c.set("user", payload);
557
934
  }
@@ -561,6 +938,7 @@ var optionalAuth = async (c, next) => {
561
938
  * Extract user from token - for WebSocket authentication
562
939
  */
563
940
  function extractUserFromToken(token) {
941
+ if (!isJwtConfigured()) return null;
564
942
  return verifyAccessToken(token);
565
943
  }
566
944
  /**
@@ -711,7 +1089,7 @@ function createAuthMiddleware(options) {
711
1089
  var queryTokenAuth = async (c, next) => {
712
1090
  if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
713
1091
  const queryToken = c.req.query("token");
714
- if (queryToken) {
1092
+ if (queryToken && isJwtConfigured()) {
715
1093
  const payload = verifyAccessToken(queryToken);
716
1094
  if (payload) c.set("user", payload);
717
1095
  }
@@ -782,15 +1160,25 @@ var fileTokenAuth = async (c, next) => {
782
1160
  if (idx < 0) return "";
783
1161
  return fullPath.substring(idx + prefix.length + 1);
784
1162
  };
1163
+ /**
1164
+ * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
1165
+ * path a token GRANTS is minted there and the path a request ASKS FOR is
1166
+ * derived here. `/metadata` canonicalizes before signing, so a raw
1167
+ * comparison here would 403 an otherwise-valid request for any key whose
1168
+ * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
1169
+ *
1170
+ * A key that cannot be canonicalized yields null, which matches no grant —
1171
+ * the same fail-closed answer `isPathMatch` gives a `..` segment.
1172
+ */
785
1173
  const parseBucketPath = (filePath) => {
786
1174
  const parts = filePath.split("/");
787
1175
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
788
1176
  bucket: "default",
789
- resolvedPath: parts.slice(1).join("/")
1177
+ resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
790
1178
  };
791
1179
  return {
792
1180
  bucket: "default",
793
- resolvedPath: filePath
1181
+ resolvedPath: tryCanonicalStorageKey(filePath)
794
1182
  };
795
1183
  };
796
1184
  const bearerToken = extractBearerToken(authHeader);
@@ -800,7 +1188,8 @@ var fileTokenAuth = async (c, next) => {
800
1188
  const rawPath = extractWildcard(c);
801
1189
  if (rawPath) {
802
1190
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
803
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
1191
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
1192
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
804
1193
  c.set("user", {
805
1194
  uid: "download-token",
806
1195
  roles: ["reader"]
@@ -824,7 +1213,8 @@ var fileTokenAuth = async (c, next) => {
824
1213
  const rawPath = extractWildcard(c);
825
1214
  if (rawPath) {
826
1215
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
827
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
1216
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
1217
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
828
1218
  c.set("user", {
829
1219
  uid: "download-token",
830
1220
  roles: ["reader"]
@@ -852,8 +1242,9 @@ function createAdapterAuthMiddleware(options) {
852
1242
  const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
853
1243
  return async (c, next) => {
854
1244
  const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1245
+ const presentedToken = extractBearerToken(c.req.header("authorization"));
855
1246
  if (apiKeyStore) {
856
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
1247
+ const token = presentedToken ?? "";
857
1248
  if (token.startsWith("rk_")) {
858
1249
  const result = await validateApiKey(c, token, {
859
1250
  store: apiKeyStore,
@@ -890,17 +1281,23 @@ function createAdapterAuthMiddleware(options) {
890
1281
  code: "INTERNAL_ERROR"
891
1282
  } }, 500);
892
1283
  }
893
- } else try {
894
- c.set("driver", await scopeDataDriver(driver, {
895
- uid: ANONYMOUS_USER_ID,
896
- roles: ["anon"]
897
- }));
898
- } catch (error) {
899
- logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
900
- return c.json({ error: {
901
- message: "Server configuration error",
902
- code: "INTERNAL_ERROR"
903
- } }, 500);
1284
+ } else {
1285
+ if (presentedToken !== void 0) return c.json({ error: {
1286
+ message: "Invalid or expired token",
1287
+ code: "UNAUTHORIZED"
1288
+ } }, 401);
1289
+ try {
1290
+ c.set("driver", await scopeDataDriver(driver, {
1291
+ uid: ANONYMOUS_USER_ID,
1292
+ roles: ["anon"]
1293
+ }));
1294
+ } catch (error) {
1295
+ logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
1296
+ return c.json({ error: {
1297
+ message: "Server configuration error",
1298
+ code: "INTERNAL_ERROR"
1299
+ } }, 500);
1300
+ }
904
1301
  }
905
1302
  if (enforceAuth && !c.get("user")) return c.json({ error: {
906
1303
  message: "Unauthorized: Authentication required",
@@ -910,43 +1307,141 @@ function createAdapterAuthMiddleware(options) {
910
1307
  };
911
1308
  }
912
1309
  //#endregion
1310
+ //#region src/email/html.ts
1311
+ /**
1312
+ * HTML escaping for email bodies.
1313
+ *
1314
+ * Every default template interpolates values the server does not control — a
1315
+ * `displayName` chosen at registration, an `appName` from config, a link base a
1316
+ * host set — into markup that is then mailed, signed by the sending domain, to
1317
+ * an address the same request chose. Unescaped, that turns `POST /auth/register`
1318
+ * into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
1319
+ * whose SPF and DKIM check out.
1320
+ *
1321
+ * The fix is deliberately not five escaping calls at five interpolation sites:
1322
+ * a sixth template would simply forget. Templates are built with the {@link html}
1323
+ * tag, which escapes *every* interpolated value by default, and markup that is
1324
+ * meant to pass through verbatim — the static style strings, a nested fragment —
1325
+ * must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
1326
+ * in a test; forgetting to escape is invisible until someone exploits it.
1327
+ */
1328
+ /**
1329
+ * Markup that has already been vetted and must be interpolated verbatim.
1330
+ *
1331
+ * Only ever construct this from a string literal in this package's own source
1332
+ * (or from the {@link html} tag, which produces one). Wrapping user input in
1333
+ * `raw()` defeats the entire mechanism.
1334
+ */
1335
+ var RawHtml = class {
1336
+ value;
1337
+ constructor(value) {
1338
+ this.value = value;
1339
+ }
1340
+ toString() {
1341
+ return this.value;
1342
+ }
1343
+ };
1344
+ /**
1345
+ * Mark a static, author-controlled string as safe to interpolate unescaped.
1346
+ */
1347
+ function raw(markup) {
1348
+ return new RawHtml(markup);
1349
+ }
1350
+ /**
1351
+ * Escape the five characters that can change the meaning of HTML text or of a
1352
+ * quoted attribute value.
1353
+ *
1354
+ * `&` goes first: escaping it after the others would double-escape the entities
1355
+ * they just produced. `'` is included because it is a legal attribute delimiter,
1356
+ * and `"` because it is the one this file uses.
1357
+ */
1358
+ function escapeHtml(value) {
1359
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1360
+ }
1361
+ /**
1362
+ * Render one interpolated value: pass {@link RawHtml} through, drop nullish,
1363
+ * escape everything else.
1364
+ */
1365
+ function interpolate(value) {
1366
+ if (value instanceof RawHtml) return value.value;
1367
+ if (value === null || value === void 0) return "";
1368
+ return escapeHtml(String(value));
1369
+ }
1370
+ /**
1371
+ * Tagged template for email markup. Every `${...}` is escaped unless it is
1372
+ * {@link RawHtml}.
1373
+ *
1374
+ * Returns `RawHtml` so fragments nest without being escaped a second time:
1375
+ *
1376
+ * ```ts
1377
+ * const button = url ? html`<a href="${url}">Open</a>` : raw("");
1378
+ * const body = html`<div>${button}</div>`;
1379
+ * ```
1380
+ */
1381
+ function html(strings, ...values) {
1382
+ let out = strings[0];
1383
+ for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
1384
+ return new RawHtml(out);
1385
+ }
1386
+ //#endregion
913
1387
  //#region src/email/templates.ts
914
1388
  /**
915
- * Get a greeting name for the user
1389
+ * Default email templates for authentication emails.
1390
+ *
1391
+ * Every HTML body is built with the `html` tag from `./html`, which escapes each
1392
+ * interpolated value unless it is explicitly marked `raw`. `displayName` reaches
1393
+ * these templates straight from the registration body, and the resulting mail is
1394
+ * sent — signed by the sending domain — to an address the same anonymous request
1395
+ * chose, so an unescaped interpolation here is a phishing primitive, not a
1396
+ * rendering glitch. Escaping lives in the tag rather than at the call sites so a
1397
+ * sixth template inherits it instead of having to remember it.
1398
+ */
1399
+ /**
1400
+ * Get a greeting name for the user.
1401
+ *
1402
+ * Returns the raw value — `displayName` is whatever the account was registered
1403
+ * with, including markup. Escaping is the `html` tag's job, so that a template
1404
+ * that interpolates the greeting into text (the plain-text bodies below) and one
1405
+ * that interpolates it into markup can share this.
916
1406
  */
917
1407
  function getGreeting(user) {
918
1408
  return user.displayName || user.email.split("@")[0];
919
1409
  }
920
1410
  /**
921
- * Common email styles
1411
+ * Common email styles.
1412
+ *
1413
+ * `raw` because these are static, author-written CSS — the one category of
1414
+ * interpolation the `html` tag lets through unescaped. (Escaping them would
1415
+ * survive an HTML parser but not every mail client's CSS parser: the
1416
+ * `'Segoe UI'` quotes would arrive as entities.)
922
1417
  */
923
1418
  var styles = {
924
- container: `
1419
+ container: raw(`
925
1420
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
926
1421
  max-width: 600px;
927
1422
  margin: 0 auto;
928
1423
  padding: 40px 20px;
929
1424
  background-color: #f8fafc;
930
- `,
931
- card: `
1425
+ `),
1426
+ card: raw(`
932
1427
  background-color: #ffffff;
933
1428
  border-radius: 12px;
934
1429
  padding: 40px;
935
1430
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
936
- `,
937
- heading: `
1431
+ `),
1432
+ heading: raw(`
938
1433
  color: #1e293b;
939
1434
  font-size: 24px;
940
1435
  font-weight: 600;
941
1436
  margin: 0 0 20px 0;
942
- `,
943
- paragraph: `
1437
+ `),
1438
+ paragraph: raw(`
944
1439
  color: #475569;
945
1440
  font-size: 16px;
946
1441
  line-height: 1.6;
947
1442
  margin: 0 0 20px 0;
948
- `,
949
- button: `
1443
+ `),
1444
+ button: raw(`
950
1445
  display: inline-block;
951
1446
  background-color: #3b82f6;
952
1447
  color: #ffffff;
@@ -956,22 +1451,22 @@ var styles = {
956
1451
  padding: 14px 28px;
957
1452
  border-radius: 8px;
958
1453
  margin: 20px 0;
959
- `,
960
- footer: `
1454
+ `),
1455
+ footer: raw(`
961
1456
  color: #94a3b8;
962
1457
  font-size: 14px;
963
1458
  margin-top: 30px;
964
1459
  padding-top: 20px;
965
1460
  border-top: 1px solid #e2e8f0;
966
- `,
967
- warning: `
1461
+ `),
1462
+ warning: raw(`
968
1463
  color: #64748b;
969
1464
  font-size: 14px;
970
1465
  background-color: #fef3c7;
971
1466
  padding: 12px 16px;
972
1467
  border-radius: 6px;
973
1468
  margin-top: 20px;
974
- `
1469
+ `)
975
1470
  };
976
1471
  /**
977
1472
  * Default password reset email template
@@ -979,9 +1474,7 @@ var styles = {
979
1474
  function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
980
1475
  const greeting = getGreeting(user);
981
1476
  const subject = `Reset your ${appName} password`;
982
- return {
983
- subject,
984
- html: `
1477
+ const body = html`
985
1478
  <!DOCTYPE html>
986
1479
  <html>
987
1480
  <head>
@@ -1028,8 +1521,8 @@ function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
1028
1521
  </div>
1029
1522
  </body>
1030
1523
  </html>
1031
- `.trim(),
1032
- text: `
1524
+ `;
1525
+ const text = `
1033
1526
  Reset Your Password
1034
1527
 
1035
1528
  Hi ${greeting},
@@ -1043,7 +1536,11 @@ This link will expire in 1 hour for security reasons.
1043
1536
 
1044
1537
  If you didn't request a password reset, you can safely ignore this email.
1045
1538
  Your password will remain unchanged.
1046
- `.trim()
1539
+ `.trim();
1540
+ return {
1541
+ subject,
1542
+ html: body.toString().trim(),
1543
+ text
1047
1544
  };
1048
1545
  }
1049
1546
  /**
@@ -1052,9 +1549,7 @@ Your password will remain unchanged.
1052
1549
  function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1053
1550
  const greeting = getGreeting(user);
1054
1551
  const subject = `Verify your ${appName} email address`;
1055
- return {
1056
- subject,
1057
- html: `
1552
+ const body = html`
1058
1553
  <!DOCTYPE html>
1059
1554
  <html>
1060
1555
  <head>
@@ -1096,8 +1591,8 @@ function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1096
1591
  </div>
1097
1592
  </body>
1098
1593
  </html>
1099
- `.trim(),
1100
- text: `
1594
+ `;
1595
+ const text = `
1101
1596
  Verify Your Email
1102
1597
 
1103
1598
  Hi ${greeting},
@@ -1106,7 +1601,11 @@ Thanks for signing up for ${appName}! Please verify your email address by clicki
1106
1601
  ${verifyUrl}
1107
1602
 
1108
1603
  If you didn't create an account with ${appName}, you can safely ignore this email.
1109
- `.trim()
1604
+ `.trim();
1605
+ return {
1606
+ subject,
1607
+ html: body.toString().trim(),
1608
+ text
1110
1609
  };
1111
1610
  }
1112
1611
  /**
@@ -1116,9 +1615,7 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
1116
1615
  function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1117
1616
  const greeting = getGreeting(user);
1118
1617
  const subject = `You've been invited to ${appName}`;
1119
- return {
1120
- subject,
1121
- html: `
1618
+ const body = html`
1122
1619
  <!DOCTYPE html>
1123
1620
  <html>
1124
1621
  <head>
@@ -1164,8 +1661,8 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1164
1661
  </div>
1165
1662
  </body>
1166
1663
  </html>
1167
- `.trim(),
1168
- text: `
1664
+ `;
1665
+ const text = `
1169
1666
  Welcome to ${appName}!
1170
1667
 
1171
1668
  Hi ${greeting},
@@ -1178,7 +1675,11 @@ ${setPasswordUrl}
1178
1675
  This link will expire in 1 hour for security reasons.
1179
1676
 
1180
1677
  If you weren't expecting this invitation, you can safely ignore this email.
1181
- `.trim()
1678
+ `.trim();
1679
+ return {
1680
+ subject,
1681
+ html: body.toString().trim(),
1682
+ text
1182
1683
  };
1183
1684
  }
1184
1685
  /**
@@ -1189,9 +1690,7 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1189
1690
  const greeting = getGreeting(user);
1190
1691
  const url = loginUrl || "";
1191
1692
  const subject = `¡Bienvenido/a a ${appName}!`;
1192
- return {
1193
- subject,
1194
- html: `
1693
+ const body = html`
1195
1694
  <!DOCTYPE html>
1196
1695
  <html>
1197
1696
  <head>
@@ -1218,11 +1717,11 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1218
1717
  que tenemos para ti.
1219
1718
  </p>
1220
1719
 
1221
- ${url ? `
1720
+ ${url ? html`
1222
1721
  <div style="text-align: center;">
1223
1722
  <a href="${url}" style="${styles.button}">Ir a mi Panel</a>
1224
1723
  </div>
1225
- ` : ""}
1724
+ ` : raw("")}
1226
1725
 
1227
1726
  <p style="${styles.paragraph}">
1228
1727
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
@@ -1237,8 +1736,8 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1237
1736
  </div>
1238
1737
  </body>
1239
1738
  </html>
1240
- `.trim(),
1241
- text: `
1739
+ `;
1740
+ const text = `
1242
1741
  ¡Bienvenido/a a ${appName}!
1243
1742
 
1244
1743
  Hola ${greeting},
@@ -1250,7 +1749,11 @@ Ya puedes acceder a tu panel y empezar a explorar todas las oportunidades que te
1250
1749
  ${url ? `Ir a mi panel: ${url}` : ""}
1251
1750
 
1252
1751
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1253
- `.trim()
1752
+ `.trim();
1753
+ return {
1754
+ subject,
1755
+ html: body.toString().trim(),
1756
+ text
1254
1757
  };
1255
1758
  }
1256
1759
  /**
@@ -1259,9 +1762,7 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1259
1762
  function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1260
1763
  const greeting = getGreeting(user);
1261
1764
  const subject = `Sign in to ${appName}`;
1262
- return {
1263
- subject,
1264
- html: `
1765
+ const body = html`
1265
1766
  <!DOCTYPE html>
1266
1767
  <html>
1267
1768
  <head>
@@ -1308,8 +1809,8 @@ function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1308
1809
  </div>
1309
1810
  </body>
1310
1811
  </html>
1311
- `.trim(),
1312
- text: `
1812
+ `;
1813
+ const text = `
1313
1814
  Sign In to ${appName}
1314
1815
 
1315
1816
  Hi ${greeting},
@@ -1323,7 +1824,11 @@ This link will expire in 15 minutes for security reasons and can only be used on
1323
1824
 
1324
1825
  If you didn't request this sign-in link, you can safely ignore this email.
1325
1826
  No action is needed.
1326
- `.trim()
1827
+ `.trim();
1828
+ return {
1829
+ subject,
1830
+ html: body.toString().trim(),
1831
+ text
1327
1832
  };
1328
1833
  }
1329
1834
  //#endregion
@@ -1458,11 +1963,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1458
1963
  await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
1459
1964
  const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
1460
1965
  const appName = ctx.emailConfig?.appName || "Rebase";
1461
- const templateFn = ctx.emailConfig?.templates?.passwordReset;
1966
+ const templateFn = ctx.emailConfig?.templates?.userInvitation;
1462
1967
  const emailContent = templateFn ? templateFn(setPasswordUrl, {
1463
1968
  email: entity.values.email,
1464
1969
  displayName: entity.values.displayName
1465
- }) : getPasswordResetTemplate(setPasswordUrl, {
1970
+ }) : getUserInvitationTemplate(setPasswordUrl, {
1466
1971
  email: entity.values.email,
1467
1972
  displayName: entity.values.displayName
1468
1973
  }, appName);
@@ -1491,6 +1996,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1491
1996
  var scryptAsync = promisify(scrypt);
1492
1997
  var SALT_LENGTH = 32;
1493
1998
  var KEY_LENGTH = 64;
1999
+ var SCRYPT_PARAMS = {
2000
+ N: 16384,
2001
+ r: 8,
2002
+ p: 1
2003
+ };
1494
2004
  /**
1495
2005
  * Password requirements:
1496
2006
  * - Minimum 8 characters
@@ -1515,7 +2025,7 @@ function validatePasswordStrength(password) {
1515
2025
  */
1516
2026
  async function hashPassword(password) {
1517
2027
  const salt = randomBytes$1(SALT_LENGTH);
1518
- const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
2028
+ const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
1519
2029
  return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
1520
2030
  }
1521
2031
  /**
@@ -1527,7 +2037,7 @@ async function verifyPassword(password, storedHash) {
1527
2037
  if (!saltHex || !hashHex) return false;
1528
2038
  const salt = Buffer.from(saltHex, "hex");
1529
2039
  const storedKey = Buffer.from(hashHex, "hex");
1530
- return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH), storedKey);
2040
+ return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS), storedKey);
1531
2041
  }
1532
2042
  //#endregion
1533
2043
  //#region src/auth/auth-hooks.ts
@@ -1585,6 +2095,196 @@ function resolveAuthHooks(hooks) {
1585
2095
  };
1586
2096
  }
1587
2097
  //#endregion
2098
+ //#region src/email/link-base.ts
2099
+ /**
2100
+ * The config fields consulted for each link kind, most specific first.
2101
+ *
2102
+ * `resetPasswordUrl` is the general fallback because it is the only one any boot
2103
+ * path sets, and in every real deployment all three point at the same frontend.
2104
+ */
2105
+ var LINK_BASE_FIELDS = {
2106
+ resetPassword: ["resetPasswordUrl"],
2107
+ verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
2108
+ magicLink: ["magicLinkUrl", "resetPasswordUrl"]
2109
+ };
2110
+ /**
2111
+ * True when `value` is an absolute `http(s)` URL — the only kind a mail client
2112
+ * can follow. A protocol-relative or path-only base is treated as missing.
2113
+ */
2114
+ function isAbsoluteHttpUrl(value) {
2115
+ try {
2116
+ const url = new URL(value);
2117
+ return url.protocol === "http:" || url.protocol === "https:";
2118
+ } catch {
2119
+ return false;
2120
+ }
2121
+ }
2122
+ /** Drop a trailing slash so callers can append `/verify-email?token=…`. */
2123
+ function normalizeBase(value) {
2124
+ return value.replace(/\/+$/, "");
2125
+ }
2126
+ /**
2127
+ * The absolute base URL for a link kind, or `""` when the config has none.
2128
+ *
2129
+ * Callers append their path to the result. `""` is only reachable on a config
2130
+ * that never went through {@link assertEmailLinkBases} — a hand-constructed
2131
+ * `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
2132
+ * misconfiguration cannot turn a password-reset request into a 500 that
2133
+ * distinguishes existing accounts from missing ones.
2134
+ */
2135
+ function resolveEmailLinkBase(config, kind) {
2136
+ if (!config) return "";
2137
+ for (const field of LINK_BASE_FIELDS[kind]) {
2138
+ const value = config[field];
2139
+ if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
2140
+ }
2141
+ return "";
2142
+ }
2143
+ /**
2144
+ * Throw when an email configuration cannot produce a followable link.
2145
+ *
2146
+ * Called from `createEmailService`, i.e. from every boot path that wires email
2147
+ * up (the managed runtime, both driver bootstrappers, and any app that passes
2148
+ * `auth.email`). A base that is set but relative is reported separately from one
2149
+ * that is missing, because the two have different fixes.
2150
+ */
2151
+ function assertEmailLinkBases(config) {
2152
+ const relative = [
2153
+ "resetPasswordUrl",
2154
+ "verifyEmailUrl",
2155
+ "magicLinkUrl"
2156
+ ].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
2157
+ 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.`);
2158
+ 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\".");
2159
+ }
2160
+ //#endregion
2161
+ //#region src/email/smtp-email-service.ts
2162
+ var _nodemailer;
2163
+ async function loadNodemailer() {
2164
+ if (!_nodemailer) try {
2165
+ _nodemailer = await import("nodemailer");
2166
+ } catch {
2167
+ throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
2168
+ }
2169
+ return _nodemailer;
2170
+ }
2171
+ /**
2172
+ * Safely parse a hostname from a URL string
2173
+ */
2174
+ function getHostname(urlStr) {
2175
+ try {
2176
+ return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
2177
+ } catch {
2178
+ return;
2179
+ }
2180
+ }
2181
+ /**
2182
+ * SMTP Email Service implementation using Nodemailer
2183
+ */
2184
+ var SMTPEmailService = class {
2185
+ transporter = null;
2186
+ config;
2187
+ _initialized = false;
2188
+ constructor(config) {
2189
+ this.config = config;
2190
+ }
2191
+ /**
2192
+ * Lazily initialize the SMTP transporter on first use
2193
+ */
2194
+ async ensureTransporter() {
2195
+ if (this._initialized) return;
2196
+ this._initialized = true;
2197
+ if (this.config.smtp) {
2198
+ const nodemailer = await loadNodemailer();
2199
+ let smtpName = this.config.smtp.name;
2200
+ if (!smtpName) {
2201
+ const urlsToTry = [
2202
+ process.env.FRONTEND_URL,
2203
+ this.config.resetPasswordUrl,
2204
+ this.config.verifyEmailUrl
2205
+ ];
2206
+ for (const urlStr of urlsToTry) if (urlStr) {
2207
+ const hostname = getHostname(urlStr);
2208
+ if (hostname) {
2209
+ smtpName = hostname;
2210
+ break;
2211
+ }
2212
+ }
2213
+ }
2214
+ this.transporter = nodemailer.createTransport({
2215
+ name: smtpName,
2216
+ host: this.config.smtp.host,
2217
+ port: this.config.smtp.port,
2218
+ secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
2219
+ auth: this.config.smtp.auth ? {
2220
+ user: this.config.smtp.auth.user,
2221
+ pass: this.config.smtp.auth.pass
2222
+ } : void 0
2223
+ });
2224
+ }
2225
+ }
2226
+ /**
2227
+ * Check if the email service is properly configured
2228
+ */
2229
+ isConfigured() {
2230
+ return !!(this.config.smtp || this.config.sendEmail);
2231
+ }
2232
+ /**
2233
+ * Send an email using SMTP or custom send function
2234
+ */
2235
+ async send(options) {
2236
+ if (this.config.sendEmail) {
2237
+ await this.config.sendEmail(options);
2238
+ return;
2239
+ }
2240
+ await this.ensureTransporter();
2241
+ if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
2242
+ const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
2243
+ try {
2244
+ await this.transporter.sendMail({
2245
+ from: this.config.from,
2246
+ to,
2247
+ subject: options.subject,
2248
+ html: options.html,
2249
+ text: options.text,
2250
+ replyTo: options.replyTo
2251
+ });
2252
+ } catch (error) {
2253
+ const message = error instanceof Error ? error.message : String(error);
2254
+ logger.error("Failed to send email", { detail: message });
2255
+ throw new Error(`Failed to send email: ${message}`);
2256
+ }
2257
+ }
2258
+ /**
2259
+ * Verify SMTP connection (useful for startup checks)
2260
+ */
2261
+ async verifyConnection() {
2262
+ await this.ensureTransporter();
2263
+ if (!this.transporter) return !!this.config.sendEmail;
2264
+ try {
2265
+ await this.transporter.verify();
2266
+ return true;
2267
+ } catch (error) {
2268
+ const message = error instanceof Error ? error.message : String(error);
2269
+ logger.error("SMTP connection verification failed", { detail: message });
2270
+ return false;
2271
+ }
2272
+ }
2273
+ };
2274
+ /**
2275
+ * Create an email service from configuration.
2276
+ *
2277
+ * This is the choke point every boot path goes through — the managed runtime,
2278
+ * both driver bootstrappers, and any app that passes `auth.email` — so it is
2279
+ * where the link bases are checked. A config that can send mail but cannot build
2280
+ * an absolute link fails the start rather than delivering dead `href="/…"` links
2281
+ * and reporting success; see `assertEmailLinkBases`.
2282
+ */
2283
+ function createEmailService(config) {
2284
+ assertEmailLinkBases(config);
2285
+ return new SMTPEmailService(config);
2286
+ }
2287
+ //#endregion
1588
2288
  //#region src/auth/rate-limit-store.ts
1589
2289
  /**
1590
2290
  * The default store: a sliding window in this process's memory.
@@ -1686,7 +2386,23 @@ function createRateLimiter(options = {}) {
1686
2386
  };
1687
2387
  }
1688
2388
  /**
1689
- * Default key generator: extract client IP from standard headers.
2389
+ * The address the socket is actually connected to, or `undefined` on a runtime
2390
+ * that cannot say. Unforgeable, unlike every header below.
2391
+ *
2392
+ * `getConnInfo` is Node-specific and throws elsewhere, so the failure is
2393
+ * swallowed: a runtime without connection info falls back to the shared bucket,
2394
+ * which is a availability trade the caller cannot exploit.
2395
+ */
2396
+ function socketAddress(c) {
2397
+ try {
2398
+ return getConnInfo(c).remote.address;
2399
+ } catch {
2400
+ return;
2401
+ }
2402
+ }
2403
+ /**
2404
+ * Default key generator: the client's address, from the most trustworthy source
2405
+ * this deployment has.
1690
2406
  *
1691
2407
  * `X-Forwarded-For` is a client-writable header; only the entries appended by
1692
2408
  * trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
@@ -1695,8 +2411,20 @@ function createRateLimiter(options = {}) {
1695
2411
  * client-supplied and must be ignored. This is what prevents a caller from
1696
2412
  * spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
1697
2413
  *
1698
- * With `trustedProxyHops === 0` (server exposed directly, no proxy),
1699
- * `X-Forwarded-For` is not trusted at all and `X-Real-IP` is used instead.
2414
+ * `X-Real-IP` is the *same* kind of header and needs the same rule, which it
2415
+ * did not have: it was read unconditionally, including under
2416
+ * `trustedProxyHops === 0` — the mode whose entire meaning is "no proxy is in
2417
+ * front of me". With no proxy there, nothing writes `X-Real-IP` except the
2418
+ * caller, so the key was theirs to choose: one header per request bought an
2419
+ * unlimited number of buckets, and the limiters on login, registration and
2420
+ * password reset counted to one. The reasoning had been done carefully for one
2421
+ * spelling of a proxy header and not carried to its twin.
2422
+ *
2423
+ * So `X-Real-IP` is now believed only where a proxy is declared to exist. With
2424
+ * none, the connection's own address is used — unforgeable, and available
2425
+ * because the server runs on `@hono/node-server`. `"unknown"` is the last
2426
+ * resort only, and it is a single shared bucket by design: better that
2427
+ * anonymous callers throttle each other than that any of them throttles nobody.
1700
2428
  */
1701
2429
  function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1702
2430
  if (trustedProxyHops > 0) {
@@ -1705,8 +2433,10 @@ function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1705
2433
  const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
1706
2434
  if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
1707
2435
  }
2436
+ const realIp = c.req.header("x-real-ip");
2437
+ if (realIp) return realIp;
1708
2438
  }
1709
- return c.req.header("x-real-ip") || "unknown";
2439
+ return socketAddress(c) ?? "unknown";
1710
2440
  }
1711
2441
  /**
1712
2442
  * Pre-configured rate limiter for general auth endpoints (login, register).
@@ -1727,6 +2457,34 @@ var strictAuthLimiter = createRateLimiter({
1727
2457
  message: "Too many requests to this sensitive endpoint, please try again later."
1728
2458
  });
1729
2459
  /**
2460
+ * Limiter for `POST /auth/send-verification`, keyed by the authenticated user.
2461
+ *
2462
+ * That route had no limiter at all while every one of its email-sending
2463
+ * siblings had one, and being authenticated is not the protection it looks
2464
+ * like: registration does not verify the address it is given, so an attacker
2465
+ * registers a victim's address, signs in to the account they just made, and
2466
+ * loops the route. Each call mints a token and mails the victim.
2467
+ *
2468
+ * An IP limiter cannot express what is wanted here — the recipient is the
2469
+ * quantity being protected, not the caller — so this one keys on the uid, which
2470
+ * on this route is one-to-one with the recipient address (emails are unique per
2471
+ * account). `strictAuthLimiter` still runs in front of it to bound the caller by
2472
+ * IP; this bounds what any single address can be sent.
2473
+ *
2474
+ * Five per 15 minutes is generous for "I didn't get the email, resend it" and
2475
+ * useless as a mail bomb. Unauthenticated requests fall back to the IP bucket so
2476
+ * the limiter is never a no-op if it is ever mounted before the auth middleware.
2477
+ */
2478
+ var verificationEmailLimiter = createRateLimiter({
2479
+ windowMs: 900 * 1e3,
2480
+ limit: 5,
2481
+ keyGenerator: (c) => {
2482
+ const user = c.get("user");
2483
+ return user?.uid ? `verification-email:user:${user.uid}` : `verification-email:ip:${defaultKeyGenerator(c)}`;
2484
+ },
2485
+ message: "Too many verification emails requested, please try again later."
2486
+ });
2487
+ /**
1730
2488
  * Key generator for API-key-based rate limiting.
1731
2489
  *
1732
2490
  * Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
@@ -1738,6 +2496,8 @@ function apiKeyKeyGenerator(c) {
1738
2496
  if (apiKey) return `api-key:${apiKey.id}`;
1739
2497
  return defaultKeyGenerator(c);
1740
2498
  }
2499
+ /** @see DataRateLimitConfig.anonymousFunctions */
2500
+ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
1741
2501
  /**
1742
2502
  * Rate limiting for the data API.
1743
2503
  *
@@ -5783,17 +6543,27 @@ function generateHotp(secret, counter) {
5783
6543
  return (((hash[offset] & 127) << 24 | hash[offset + 1] << 16 | hash[offset + 2] << 8 | hash[offset + 3]) % 1e6).toString().padStart(6, "0");
5784
6544
  }
5785
6545
  /**
5786
- * Verify a TOTP token with a configurable time window
6546
+ * Verify a TOTP token and report *which* time step matched.
6547
+ *
6548
+ * RFC 6238 §5.2 requires that an accepted OTP not be accepted a second time,
6549
+ * and a boolean cannot express what was accepted: with `window = 1` the same
6550
+ * six digits stay valid for up to 90 seconds, so a code observed once — by a
6551
+ * phishing relay, over a shoulder, in a pasted support message — can be
6552
+ * presented again for the rest of that window. The caller records the returned
6553
+ * counter against the factor and refuses anything at or below it.
5787
6554
  *
5788
6555
  * @param secret - The shared secret as a Buffer
5789
6556
  * @param token - The 6-digit TOTP code to verify
5790
6557
  * @param window - Number of time steps to check on each side (default: 1)
5791
- * @returns true if the token is valid within the window
6558
+ * @returns The matched time step, or `null` when no step in the window matches
5792
6559
  */
5793
- function verifyTotp(secret, token, window = 1) {
6560
+ function verifyTotpCounter(secret, token, window = 1) {
5794
6561
  const counter = BigInt(Math.floor(Date.now() / 1e3 / 30));
5795
- for (let i = -window; i <= window; i++) if (generateHotp(secret, counter + BigInt(i)) === token) return true;
5796
- return false;
6562
+ for (let i = -window; i <= window; i++) {
6563
+ const step = counter + BigInt(i);
6564
+ if (generateHotp(secret, step) === token) return Number(step);
6565
+ }
6566
+ return null;
5797
6567
  }
5798
6568
  /**
5799
6569
  * Generate a new TOTP secret and return the setup information
@@ -5880,35 +6650,195 @@ function encryptTotpSecret(plaintext) {
5880
6650
  return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
5881
6651
  }
5882
6652
  /**
5883
- * Decrypt a previously encrypted TOTP secret.
6653
+ * Decrypt a previously encrypted TOTP secret.
6654
+ *
6655
+ * @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
6656
+ * @returns The original Base32 TOTP secret.
6657
+ */
6658
+ function decryptTotpSecret(ciphertext) {
6659
+ const parts = ciphertext.split(":");
6660
+ if (parts.length !== 3) throw new Error("Invalid encrypted TOTP secret format — expected iv:authTag:ciphertext");
6661
+ const [ivHex, authTagHex, encryptedHex] = parts;
6662
+ const key = deriveKey(resolveKeyString());
6663
+ const iv = Buffer.from(ivHex, "hex");
6664
+ const authTag = Buffer.from(authTagHex, "hex");
6665
+ const encrypted = Buffer.from(encryptedHex, "hex");
6666
+ const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
6667
+ decipher.setAuthTag(authTag);
6668
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
6669
+ }
6670
+ //#endregion
6671
+ //#region src/auth/cookie-utils.ts
6672
+ /**
6673
+ * Cookie auth helpers for httpOnly refresh tokens.
6674
+ */
6675
+ function getCookieSettings(config) {
6676
+ return {
6677
+ name: config?.cookieName || "__rb_refresh",
6678
+ path: config?.path || "/",
6679
+ sameSite: config?.sameSite || "Lax",
6680
+ domain: config?.domain,
6681
+ secure: config?.secure
6682
+ };
6683
+ }
6684
+ /**
6685
+ * Set the refresh token as an httpOnly cookie on the response.
6686
+ */
6687
+ function setRefreshCookie(c, refreshToken, config) {
6688
+ if (!config) return;
6689
+ const settings = getCookieSettings(config);
6690
+ const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
6691
+ let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6692
+ if (isSecure) cookie += "; Secure";
6693
+ if (settings.domain) cookie += `; Domain=${settings.domain}`;
6694
+ const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6695
+ cookie += `; Max-Age=${maxAgeSeconds}`;
6696
+ c.header("Set-Cookie", cookie, { append: true });
6697
+ }
6698
+ /**
6699
+ * Clear the refresh token cookie.
6700
+ */
6701
+ function clearRefreshCookie(c, config) {
6702
+ if (!config) return;
6703
+ const settings = getCookieSettings(config);
6704
+ let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6705
+ if (settings.domain) cookie += `; Domain=${settings.domain}`;
6706
+ c.header("Set-Cookie", cookie, { append: true });
6707
+ }
6708
+ /**
6709
+ * Read the refresh token from the request — cookie first, then body fallback.
6710
+ */
6711
+ function readRefreshToken(c, body, config) {
6712
+ if (config) {
6713
+ const settings = getCookieSettings(config);
6714
+ const cookieHeader = c.req.header("cookie") || "";
6715
+ const prefix = `${settings.name}=`;
6716
+ const cookies = cookieHeader.split(";");
6717
+ for (const part of cookies) {
6718
+ const trimmed = part.trim();
6719
+ if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6720
+ }
6721
+ }
6722
+ return body.refreshToken;
6723
+ }
6724
+ /**
6725
+ * Redact the refresh token from the response body when cookie mode is active.
6726
+ */
6727
+ function redactRefreshToken(response, c, refreshToken, config) {
6728
+ if (!config) return response;
6729
+ setRefreshCookie(c, refreshToken, config);
6730
+ return {
6731
+ ...response,
6732
+ tokens: {
6733
+ ...response.tokens,
6734
+ refreshToken: ""
6735
+ }
6736
+ };
6737
+ }
6738
+ //#endregion
6739
+ //#region src/auth/mfa-routes.ts
6740
+ /**
6741
+ * How many failed verifications one challenge tolerates before it is spent.
5884
6742
  *
5885
- * @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
5886
- * @returns The original Base32 TOTP secret.
6743
+ * A 6-digit code with a ±1 step window accepts 3 of 1,000,000 values, so the
6744
+ * only thing standing between an attacker and the second factor is how many
6745
+ * guesses a challenge will take. Five, and then they must open another one —
6746
+ * which the per-user limiter below is counting.
5887
6747
  */
5888
- function decryptTotpSecret(ciphertext) {
5889
- const parts = ciphertext.split(":");
5890
- if (parts.length !== 3) throw new Error("Invalid encrypted TOTP secret format — expected iv:authTag:ciphertext");
5891
- const [ivHex, authTagHex, encryptedHex] = parts;
5892
- const key = deriveKey(resolveKeyString());
5893
- const iv = Buffer.from(ivHex, "hex");
5894
- const authTag = Buffer.from(authTagHex, "hex");
5895
- const encrypted = Buffer.from(encryptedHex, "hex");
5896
- const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5897
- decipher.setAuthTag(authTag);
5898
- return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
6748
+ var MAX_CHALLENGE_ATTEMPTS = 5;
6749
+ /** Verification attempts one *account* may make per window, across all IPs. */
6750
+ var MFA_VERIFICATION_ATTEMPTS_PER_WINDOW = 10;
6751
+ /**
6752
+ * Resolve the caller of a challenge route from either a full session or a
6753
+ * pre-auth token.
6754
+ *
6755
+ * Both are parsed here rather than by `requireAuth` because the whole point of
6756
+ * the challenge pair is that it runs before the session exists — a login by an
6757
+ * MFA-enrolled user never receives an access token until it is finished.
6758
+ */
6759
+ function resolveStepUpPrincipal(c) {
6760
+ const existing = c.get("user");
6761
+ if (existing?.uid) return {
6762
+ uid: existing.uid,
6763
+ aal: existing.aal === "aal2" ? "aal2" : "aal1",
6764
+ pending: false
6765
+ };
6766
+ const token = extractBearerToken(c.req.header("authorization"));
6767
+ if (token === void 0) return null;
6768
+ const session = verifyAccessToken(token);
6769
+ if (session) return {
6770
+ uid: session.uid,
6771
+ aal: session.aal === "aal2" ? "aal2" : "aal1",
6772
+ pending: false
6773
+ };
6774
+ const pending = verifyMfaPendingToken(token);
6775
+ if (pending) return {
6776
+ uid: pending.uid,
6777
+ aal: "aal1",
6778
+ pending: true
6779
+ };
6780
+ return null;
5899
6781
  }
5900
- //#endregion
5901
- //#region src/auth/mfa-routes.ts
5902
- function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6782
+ /**
6783
+ * Per-account throttle on code verification.
6784
+ *
6785
+ * Keyed on the uid rather than the IP because an IP is the attacker's to
6786
+ * rotate and the account under attack is not: a distributed run against one
6787
+ * account passes an IP-keyed limiter untouched. The IP limiter still runs in
6788
+ * front of this one — they bound different things.
6789
+ */
6790
+ var mfaVerificationLimiter = createRateLimiter({
6791
+ windowMs: 900 * 1e3,
6792
+ limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
6793
+ keyGenerator: (c) => `mfa-verify:${resolveStepUpPrincipal(c)?.uid ?? "unidentified"}`,
6794
+ message: "Too many verification attempts for this account, please try again later."
6795
+ });
6796
+ function mountMfaRoutes(opts) {
6797
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
5903
6798
  const authRepo = config.authRepo;
5904
6799
  const emailConfig = config.emailConfig;
5905
6800
  /**
6801
+ * Changing which factors an account trusts is itself a privileged act.
6802
+ *
6803
+ * An account that already has a verified factor may only gain or confirm
6804
+ * another one from a session that presented the existing one. Without this
6805
+ * the `aal2` gate on unenroll was self-service: an `aal1` session enrolled
6806
+ * a factor of its own, verified it with a code it computed from the secret
6807
+ * the enrol response had just handed it, stepped up on that, and deleted
6808
+ * the victim's real factor — arriving at `aal2` without ever holding the
6809
+ * second factor the account was protected by.
6810
+ *
6811
+ * The first enrolment on an account with no verified factor is deliberately
6812
+ * allowed at `aal1`: there is no second factor to demand yet.
6813
+ */
6814
+ async function requireStepUpForFactorChange(userCtx) {
6815
+ if (userCtx.aal === "aal2") return;
6816
+ if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) return;
6817
+ throw ApiError.forbidden("This account already has a verified second factor. Re-authenticate with it before changing enrolled factors.", "AAL2_REQUIRED");
6818
+ }
6819
+ /**
6820
+ * Spend a TOTP time step, or refuse it as already spent.
6821
+ *
6822
+ * RFC 6238 §5.2: an accepted OTP must not be accepted again. The window
6823
+ * that exists for clock drift is also a 90-second replay window, and a
6824
+ * replay here is not momentary — `challenge/verify` mints a refresh token,
6825
+ * so one observed code buys a durable session.
6826
+ */
6827
+ async function claimTotpStep(factorId, counter) {
6828
+ if (typeof authRepo.claimMfaFactorCounter !== "function") {
6829
+ logger.warn("[MFA] Auth repository cannot record spent TOTP steps; an accepted code stays replayable for its window", { factorId });
6830
+ return true;
6831
+ }
6832
+ return authRepo.claimMfaFactorCounter(factorId, counter);
6833
+ }
6834
+ /**
5906
6835
  * POST /auth/mfa/enroll
5907
6836
  * Start MFA enrollment: generate TOTP secret and recovery codes
5908
6837
  */
5909
- router.post("/mfa/enroll", requireAuth, async (c) => {
6838
+ router.post("/mfa/enroll", strictAuthLimiter, requireAuth, async (c) => {
5910
6839
  const userCtx = c.get("user");
5911
6840
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6841
+ await requireStepUpForFactorChange(userCtx);
5912
6842
  const body = await c.req.json().catch(() => ({}));
5913
6843
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
5914
6844
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
@@ -5938,17 +6868,20 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5938
6868
  * POST /auth/mfa/verify
5939
6869
  * Verify TOTP code to complete MFA enrollment
5940
6870
  */
5941
- router.post("/mfa/verify", requireAuth, async (c) => {
6871
+ router.post("/mfa/verify", strictAuthLimiter, requireAuth, mfaVerificationLimiter, async (c) => {
5942
6872
  const userCtx = c.get("user");
5943
6873
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5944
- const { factorId, code } = parseBody(object({
6874
+ await requireStepUpForFactorChange(userCtx);
6875
+ const verifySchema = object({
5945
6876
  factorId: string().min(1, "Factor ID is required"),
5946
6877
  code: string().length(6, "Code must be 6 digits")
5947
- }), await c.req.json());
6878
+ });
6879
+ const { factorId, code } = parseBody(verifySchema, await c.req.json());
5948
6880
  const factor = await authRepo.getMfaFactorById(factorId);
5949
6881
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
5950
6882
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
5951
- if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
6883
+ const matchedCounter = verifyTotpCounter(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
6884
+ if (matchedCounter === null || !await claimTotpStep(factor.id, matchedCounter)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
5952
6885
  await authRepo.verifyMfaFactor(factorId);
5953
6886
  return c.json({
5954
6887
  success: true,
@@ -5957,14 +6890,16 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5957
6890
  });
5958
6891
  /**
5959
6892
  * POST /auth/mfa/challenge
5960
- * Create an MFA challenge during login (user has MFA enrolled)
6893
+ * Open a challenge — either to finish a sign-in that answered
6894
+ * `MFA_REQUIRED`, or to step an existing session up to `aal2`.
5961
6895
  */
5962
- router.post("/mfa/challenge", requireAuth, async (c) => {
5963
- const userCtx = c.get("user");
5964
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5965
- const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
6896
+ router.post("/mfa/challenge", strictAuthLimiter, async (c) => {
6897
+ const principal = resolveStepUpPrincipal(c);
6898
+ if (!principal) throw ApiError.unauthorized("Not authenticated");
6899
+ const challengeSchema = object({ factorId: string().min(1, "Factor ID is required") });
6900
+ const { factorId } = parseBody(challengeSchema, await c.req.json());
5966
6901
  const factor = await authRepo.getMfaFactorById(factorId);
5967
- if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6902
+ if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
5968
6903
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
5969
6904
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
5970
6905
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -5976,49 +6911,73 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5976
6911
  });
5977
6912
  /**
5978
6913
  * POST /auth/mfa/challenge/verify
5979
- * Verify a TOTP code for an active challenge, upgrade aal1 → aal2
6914
+ * Answer a challenge: this is where an MFA-enrolled account's session is
6915
+ * minted, at `aal2`.
5980
6916
  */
5981
- router.post("/mfa/challenge/verify", requireAuth, async (c) => {
5982
- const userCtx = c.get("user");
5983
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5984
- const { challengeId, code } = parseBody(object({
6917
+ router.post("/mfa/challenge/verify", strictAuthLimiter, mfaVerificationLimiter, async (c) => {
6918
+ const principal = resolveStepUpPrincipal(c);
6919
+ if (!principal) throw ApiError.unauthorized("Not authenticated");
6920
+ const challengeVerifySchema = object({
5985
6921
  challengeId: string().min(1, "Challenge ID is required"),
5986
6922
  code: string().min(1, "Code is required")
5987
- }), await c.req.json());
6923
+ });
6924
+ const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
5988
6925
  const challenge = await authRepo.getMfaChallengeById(challengeId);
5989
6926
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
5990
6927
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
5991
- if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
5992
- let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
5993
- if (!isValid) {
6928
+ if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
6929
+ if ((challenge.attempts ?? 0) >= MAX_CHALLENGE_ATTEMPTS) {
6930
+ logger.warn("[Security Audit] MFA challenge attempt limit reached", {
6931
+ eventType: "auth.mfa.challenge.exhausted",
6932
+ uid: principal.uid,
6933
+ challengeId
6934
+ });
6935
+ throw ApiError.unauthorized("Too many failed attempts for this challenge", "CHALLENGE_EXHAUSTED");
6936
+ }
6937
+ const matchedCounter = verifyTotpCounter(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
6938
+ let isValid = matchedCounter !== null && await claimTotpStep(factor.id, matchedCounter);
6939
+ if (!isValid && matchedCounter === null) {
5994
6940
  const codeHash = hashRecoveryCode(code);
5995
- isValid = await authRepo.useRecoveryCode(userCtx.uid, codeHash);
6941
+ isValid = await authRepo.useRecoveryCode(principal.uid, codeHash);
6942
+ }
6943
+ if (!isValid) {
6944
+ const attempts = typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
6945
+ logger.warn("[Security Audit] MFA verification failed", {
6946
+ eventType: "auth.mfa.verify.failure",
6947
+ uid: principal.uid,
6948
+ challengeId,
6949
+ attempts
6950
+ });
6951
+ throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
5996
6952
  }
5997
- if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
5998
6953
  await authRepo.verifyMfaChallenge(challengeId);
5999
- const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
6000
- const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
6001
- const refreshToken = generateRefreshToken();
6002
- await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
6003
- id: randomUUID$1(),
6004
- startedAt: /* @__PURE__ */ new Date()
6954
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
6955
+ skipMfaGate: true,
6956
+ aal: "aal2"
6005
6957
  });
6006
- if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
6958
+ if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
6007
6959
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
6008
6960
  });
6009
- let mfaResponse = { tokens: {
6961
+ logger.info("[Security Audit] MFA verification success", {
6962
+ eventType: "auth.mfa.verify.success",
6963
+ uid: principal.uid,
6964
+ factorId: factor.id,
6965
+ completedSignIn: principal.pending
6966
+ });
6967
+ const user = await authRepo.getUserById(principal.uid).catch(() => null);
6968
+ let mfaResponse = user ? buildAuthResponse(user, roleIds, accessToken, refreshToken, "mfa") : { tokens: {
6010
6969
  accessToken,
6011
6970
  refreshToken,
6012
6971
  accessTokenExpiresAt: getAccessTokenExpiry()
6013
6972
  } };
6014
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
6015
- return c.json(mfaResponse);
6973
+ if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, principal.uid);
6974
+ return c.json(redactRefreshToken(mfaResponse, c, refreshToken, config.cookieAuth));
6016
6975
  });
6017
6976
  /**
6018
6977
  * GET /auth/mfa/factors
6019
6978
  * List enrolled MFA factors for the current user
6020
6979
  */
6021
- router.get("/mfa/factors", requireAuth, async (c) => {
6980
+ router.get("/mfa/factors", strictAuthLimiter, requireAuth, async (c) => {
6022
6981
  const userCtx = c.get("user");
6023
6982
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6024
6983
  const factors = await authRepo.getMfaFactors(userCtx.uid);
@@ -6034,11 +6993,12 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6034
6993
  * DELETE /auth/mfa/unenroll
6035
6994
  * Remove an MFA factor
6036
6995
  */
6037
- router.delete("/mfa/unenroll", requireAuth, async (c) => {
6996
+ router.delete("/mfa/unenroll", strictAuthLimiter, requireAuth, async (c) => {
6038
6997
  const userCtx = c.get("user");
6039
6998
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6040
6999
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
6041
- const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
7000
+ const unenrollSchema = object({ factorId: string().min(1, "Factor ID is required") });
7001
+ const { factorId } = parseBody(unenrollSchema, await c.req.json());
6042
7002
  const factor = await authRepo.getMfaFactorById(factorId);
6043
7003
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6044
7004
  await authRepo.deleteMfaFactor(factorId, userCtx.uid);
@@ -6050,72 +7010,51 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6050
7010
  });
6051
7011
  }
6052
7012
  //#endregion
6053
- //#region src/auth/cookie-utils.ts
6054
- /**
6055
- * Cookie auth helpers for httpOnly refresh tokens.
6056
- */
6057
- function getCookieSettings(config) {
6058
- return {
6059
- name: config?.cookieName || "__rb_refresh",
6060
- path: config?.path || "/",
6061
- sameSite: config?.sameSite || "Lax",
6062
- domain: config?.domain,
6063
- secure: config?.secure
6064
- };
6065
- }
6066
- /**
6067
- * Set the refresh token as an httpOnly cookie on the response.
6068
- */
6069
- function setRefreshCookie(c, refreshToken, config) {
6070
- if (!config) return;
6071
- const settings = getCookieSettings(config);
6072
- const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
6073
- let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6074
- if (isSecure) cookie += "; Secure";
6075
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6076
- const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6077
- cookie += `; Max-Age=${maxAgeSeconds}`;
6078
- c.header("Set-Cookie", cookie, { append: true });
6079
- }
7013
+ //#region src/auth/mfa-gate.ts
6080
7014
  /**
6081
- * Clear the refresh token cookie.
7015
+ * The gate between "first factor accepted" and "session issued".
7016
+ *
7017
+ * MFA used to be modelled as an optional step-up that no resource required: a
7018
+ * user could enrol TOTP, print recovery codes, and change nothing about what a
7019
+ * stolen password bought, because every session-minting route issued a full
7020
+ * `aal1` token without ever asking whether the account had a second factor.
7021
+ * This module is the ask. It lives apart from the routes so that there is one
7022
+ * decision, made once, that `createSessionAndTokens` cannot be extended past —
7023
+ * a new sign-in route inherits the gate by construction rather than by the
7024
+ * author remembering it.
7025
+ *
7026
+ * @module
6082
7027
  */
6083
- function clearRefreshCookie(c, config) {
6084
- if (!config) return;
6085
- const settings = getCookieSettings(config);
6086
- let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6087
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6088
- c.header("Set-Cookie", cookie, { append: true });
6089
- }
7028
+ /** Error code a client watches for to switch a sign-in into its second step. */
7029
+ var MFA_REQUIRED_CODE = "MFA_REQUIRED";
6090
7030
  /**
6091
- * Read the refresh token from the request — cookie first, then body fallback.
7031
+ * Refuse to mint a session for an account whose second factor has not been
7032
+ * presented, handing back what the client needs to present it.
7033
+ *
7034
+ * A 401 rather than a 200 with a different body: a client that has not been
7035
+ * taught about MFA must fail, not proceed. The credential in `details` is
7036
+ * useless for anything except the challenge routes.
6092
7037
  */
6093
- function readRefreshToken(c, body, config) {
6094
- if (config) {
6095
- const settings = getCookieSettings(config);
6096
- const cookieHeader = c.req.header("cookie") || "";
6097
- const prefix = `${settings.name}=`;
6098
- const cookies = cookieHeader.split(";");
6099
- for (const part of cookies) {
6100
- const trimmed = part.trim();
6101
- if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6102
- }
7038
+ async function assertMfaSatisfied(authRepo, uid) {
7039
+ if (typeof authRepo.hasVerifiedMfaFactors !== "function") {
7040
+ logger.warn("[MFA] Auth repository cannot report enrolled factors; the login step-up gate is inert on this backend");
7041
+ return;
6103
7042
  }
6104
- return body.refreshToken;
6105
- }
6106
- /**
6107
- * Redact the refresh token from the response body when cookie mode is active.
6108
- */
6109
- function redactRefreshToken(response, c, refreshToken, config) {
6110
- if (!config) return response;
6111
- setRefreshCookie(c, refreshToken, config);
6112
- return {
6113
- ...response,
6114
- tokens: {
6115
- ...response.tokens,
6116
- refreshToken: ""
6117
- }
7043
+ if (!await authRepo.hasVerifiedMfaFactors(uid)) return;
7044
+ const factors = (await authRepo.getMfaFactors(uid)).filter((f) => f.verified);
7045
+ const details = {
7046
+ mfaToken: generateMfaPendingToken(uid),
7047
+ factors: factors.map((f) => ({
7048
+ id: f.id,
7049
+ factorType: f.factorType,
7050
+ friendlyName: f.friendlyName
7051
+ }))
6118
7052
  };
7053
+ logger.info("[Security Audit] MFA step-up required", {
7054
+ eventType: "auth.mfa.required",
7055
+ uid
7056
+ });
7057
+ throw new ApiError(401, MFA_REQUIRED_CODE, "Multi-factor authentication is required to complete sign-in.", details, true);
6119
7058
  }
6120
7059
  //#endregion
6121
7060
  //#region src/auth/registration-policy.ts
@@ -6168,7 +7107,7 @@ function mountSessionRoutes(opts) {
6168
7107
  clearRefreshCookie(c, config.cookieAuth);
6169
7108
  const accessToken = extractBearerToken(c.req.header("authorization"));
6170
7109
  if (ops.afterLogout && accessToken !== void 0) {
6171
- const { verifyAccessToken } = await import("./jwt-DD6EtpGj.js").then((n) => n.u);
7110
+ const { verifyAccessToken } = await import("./jwt-Cuq6MwNy.js").then((n) => n.f);
6172
7111
  const payload = verifyAccessToken(accessToken);
6173
7112
  if (payload) ops.afterLogout(payload.uid).catch((err) => {
6174
7113
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
@@ -6422,7 +7361,7 @@ function mountMagicLinkRoutes(deps) {
6422
7361
  const tokenHash = hashToken(token);
6423
7362
  const expiresAt = getMagicLinkExpiry();
6424
7363
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
6425
- const magicLinkUrl = `${emailConfig?.magicLinkUrl || emailConfig?.resetPasswordUrl || ""}/auth/magic-link?token=${token}`;
7364
+ const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
6426
7365
  const appName = emailConfig?.appName || "Rebase";
6427
7366
  const templateFn = emailConfig?.templates?.magicLink;
6428
7367
  const emailContent = templateFn ? templateFn(magicLinkUrl, {
@@ -6474,6 +7413,64 @@ function mountMagicLinkRoutes(deps) {
6474
7413
  });
6475
7414
  }
6476
7415
  //#endregion
7416
+ //#region src/auth/oauth-signin-policy.ts
7417
+ /**
7418
+ * May an OAuth identity be attached to a pre-existing account found *by email*?
7419
+ *
7420
+ * Both sides have to be trustworthy, and only one of them used to be checked:
7421
+ *
7422
+ * - the **provider** must have verified the address, or the caller has not
7423
+ * shown they control it;
7424
+ * - the **local account** must itself be trustworthy — either its address was
7425
+ * verified, or it has no password at all (it was created by an OAuth
7426
+ * sign-in or an invitation, so there is no credential an attacker could
7427
+ * have planted in advance).
7428
+ *
7429
+ * A refusal is not a dead end: `POST /auth/link/<provider>` attaches the
7430
+ * identity once the caller proves ownership by holding a session.
7431
+ */
7432
+ function decideOAuthAutoLink(args) {
7433
+ if (args.providerEmailVerified !== true) return {
7434
+ allowed: false,
7435
+ reason: "provider-email-unverified"
7436
+ };
7437
+ if (Boolean(args.existingUser.passwordHash) && !args.existingUser.emailVerified) return {
7438
+ allowed: false,
7439
+ reason: "local-account-unverified"
7440
+ };
7441
+ return { allowed: true };
7442
+ }
7443
+ /**
7444
+ * Is `redirectUri` one the operator authorised?
7445
+ *
7446
+ * The provider's own registered-URI match is a real control but a coarse one:
7447
+ * it authorises *every* URI registered on that OAuth client, so a `localhost`
7448
+ * entry kept for development, or a second product sharing the client id, can
7449
+ * mint codes this backend accepts. An empty or absent allowlist keeps the old
7450
+ * behaviour (the provider's check is the only one) so existing deployments are
7451
+ * unaffected; setting one narrows it to the origins this backend serves.
7452
+ *
7453
+ * Comparison is on origin plus path, with the origin lowercased and a trailing
7454
+ * slash ignored — query strings and fragments are not part of the identity of a
7455
+ * redirect URI, and neither is the case of the host.
7456
+ */
7457
+ function isRedirectUriAllowed(redirectUri, allowlist) {
7458
+ if (!allowlist || allowlist.length === 0) return true;
7459
+ const candidate = canonicalRedirectUri(redirectUri);
7460
+ if (!candidate) return false;
7461
+ return allowlist.some((allowed) => canonicalRedirectUri(allowed) === candidate);
7462
+ }
7463
+ function canonicalRedirectUri(uri) {
7464
+ let parsed;
7465
+ try {
7466
+ parsed = new URL(uri);
7467
+ } catch {
7468
+ return null;
7469
+ }
7470
+ const path = parsed.pathname.replace(/\/+$/, "");
7471
+ return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${path}`;
7472
+ }
7473
+ //#endregion
6477
7474
  //#region src/auth/routes.ts
6478
7475
  /**
6479
7476
  * Helper to build standard auth response output
@@ -6549,11 +7546,6 @@ function createAuthRoutes(config) {
6549
7546
  newPassword: string().min(1, "New password is required").max(128)
6550
7547
  });
6551
7548
  const refreshSchema = object({ refreshToken: string().min(1).optional() });
6552
- object({ refreshToken: string().optional() });
6553
- object({
6554
- displayName: string().max(255).optional(),
6555
- photoURL: string().url().max(2048).optional()
6556
- });
6557
7549
  /** Parse a Zod schema against the request body, throwing ApiError on failure */
6558
7550
  function parseBody(schema, body) {
6559
7551
  const result = schema.safeParse(body);
@@ -6593,7 +7585,7 @@ function createAuthRoutes(config) {
6593
7585
  function sendWelcomeEmail(user) {
6594
7586
  if (!isEmailConfigured()) return;
6595
7587
  const appName = emailConfig?.appName || "Rebase";
6596
- const loginUrl = emailConfig?.resetPasswordUrl || "";
7588
+ const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
6597
7589
  const templateFn = emailConfig?.templates?.welcomeEmail;
6598
7590
  const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0);
6599
7591
  emailService.send({
@@ -6606,9 +7598,21 @@ function createAuthRoutes(config) {
6606
7598
  });
6607
7599
  }
6608
7600
  /**
6609
- * Helper to generate and store session tokens
7601
+ * Helper to generate and store session tokens.
7602
+ *
7603
+ * Every route that signs somebody in comes through here — password login,
7604
+ * register, each OAuth provider, magic link, anonymous and anonymous-link —
7605
+ * which is why the second-factor gate is *inside* it rather than repeated at
7606
+ * each call site. An account with a verified factor gets no session from
7607
+ * this function at all: it throws `MFA_REQUIRED`, and the only thing that
7608
+ * mints a session for that user is `POST /auth/mfa/challenge/verify`.
7609
+ *
7610
+ * `skipMfaGate` exists for exactly one caller: the challenge-verify route
7611
+ * itself, which has just seen the second factor and mints at `aal2`.
6610
7612
  */
6611
- async function createSessionAndTokens(uid, userAgent, ipAddress) {
7613
+ async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
7614
+ if (!options?.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
7615
+ const aal = options?.aal ?? "aal1";
6612
7616
  const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
6613
7617
  let customClaims;
6614
7618
  if (ops.customizeAccessToken) {
@@ -6617,16 +7621,17 @@ function createAuthRoutes(config) {
6617
7621
  const defaultClaims = {
6618
7622
  uid,
6619
7623
  roles: roleIds,
6620
- aal: "aal1"
7624
+ aal
6621
7625
  };
6622
7626
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
6623
7627
  }
6624
7628
  }
6625
- const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
7629
+ const accessToken = generateAccessToken(uid, roleIds, aal, customClaims);
6626
7630
  const refreshToken = generateRefreshToken();
6627
7631
  await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
6628
7632
  id: randomUUID$1(),
6629
- startedAt: /* @__PURE__ */ new Date()
7633
+ startedAt: /* @__PURE__ */ new Date(),
7634
+ aal
6630
7635
  });
6631
7636
  return {
6632
7637
  roleIds,
@@ -6724,29 +7729,46 @@ function createAuthRoutes(config) {
6724
7729
  if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
6725
7730
  router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
6726
7731
  const payload = parseBody(provider.schema, await c.req.json());
7732
+ const requestedRedirectUri = payload.redirectUri;
7733
+ if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
6727
7734
  let externalUser;
6728
7735
  try {
6729
7736
  externalUser = await provider.verify(payload);
6730
7737
  } catch (err) {
6731
- const msg = err instanceof Error ? err.message : String(err);
6732
- throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
7738
+ logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
7739
+ throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
6733
7740
  }
6734
7741
  if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
6735
7742
  let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
6736
7743
  if (!user) {
6737
7744
  user = await authRepo.getUserByEmail(externalUser.email);
6738
7745
  if (user) {
6739
- if (!externalUser.emailVerified) throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
7746
+ const decision = decideOAuthAutoLink({
7747
+ providerEmailVerified: externalUser.emailVerified,
7748
+ existingUser: user
7749
+ });
7750
+ if (!decision.allowed) {
7751
+ const why = decision.reason === "provider-email-unverified" ? `${provider.id} has not verified this email address, so it cannot be linked automatically.` : "That account's email address was never verified, so it cannot be linked automatically.";
7752
+ throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${why} Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
7753
+ }
6740
7754
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6741
7755
  await authRepo.updateUser(user.id, {
6742
7756
  displayName: user.displayName || externalUser.displayName || void 0,
6743
7757
  photoUrl: user.photoUrl || externalUser.photoUrl || void 0
6744
7758
  });
6745
7759
  } else {
7760
+ if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
7761
+ let bootstrapRegistration = false;
7762
+ if (!isRegistrationAllowed()) {
7763
+ const { total } = await authRepo.listUsersPaginated({ limit: 1 });
7764
+ bootstrapRegistration = total === 0;
7765
+ if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
7766
+ }
6746
7767
  user = await authRepo.createUser({
6747
7768
  email: normalizeEmail(externalUser.email),
6748
7769
  displayName: externalUser.displayName || void 0,
6749
- photoUrl: externalUser.photoUrl || void 0
7770
+ photoUrl: externalUser.photoUrl || void 0,
7771
+ emailVerified: externalUser.emailVerified === true
6750
7772
  });
6751
7773
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6752
7774
  if (ops.afterUserCreate) try {
@@ -6755,7 +7777,12 @@ function createAuthRoutes(config) {
6755
7777
  logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
6756
7778
  }
6757
7779
  const allUsers = await authRepo.listUsers();
6758
- if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
7780
+ const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
7781
+ if (bootstrapRegistration && !isFirstUser) {
7782
+ await authRepo.deleteUser(user.id);
7783
+ throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
7784
+ }
7785
+ if (isFirstUser) await authRepo.setUserRoles(user.id, ["admin"]);
6759
7786
  else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
6760
7787
  sendWelcomeEmail({
6761
7788
  email: user.email,
@@ -6829,7 +7856,7 @@ function createAuthRoutes(config) {
6829
7856
  const tokenHash = hashToken(token);
6830
7857
  const expiresAt = getPasswordResetExpiry();
6831
7858
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
6832
- const resetUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
7859
+ const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
6833
7860
  const appName = emailConfig?.appName || "Rebase";
6834
7861
  const templateFn = emailConfig?.templates?.passwordReset;
6835
7862
  const emailContent = templateFn ? templateFn(resetUrl, {
@@ -6904,8 +7931,15 @@ function createAuthRoutes(config) {
6904
7931
  /**
6905
7932
  * POST /auth/send-verification
6906
7933
  * Send email verification link (authenticated)
7934
+ *
7935
+ * Two limiters, because they bound different things: `strictAuthLimiter`
7936
+ * bounds the caller by IP as it does on every other email-sending route, and
7937
+ * `verificationEmailLimiter` bounds how much mail one account — i.e. one
7938
+ * recipient address — can be made to receive. Authentication is not a bound
7939
+ * here: the address is unverified by construction, so an attacker can hold a
7940
+ * session for a mailbox that is not theirs.
6907
7941
  */
6908
- router.post("/send-verification", requireAuth, async (c) => {
7942
+ router.post("/send-verification", strictAuthLimiter, requireAuth, verificationEmailLimiter, async (c) => {
6909
7943
  const userCtx = c.get("user");
6910
7944
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6911
7945
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
@@ -6914,7 +7948,7 @@ function createAuthRoutes(config) {
6914
7948
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
6915
7949
  const token = generateSecureToken();
6916
7950
  await authRepo.setVerificationToken(user.id, hashToken(token));
6917
- const verifyUrl = `${emailConfig?.verifyEmailUrl || ""}/verify-email?token=${token}`;
7951
+ const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
6918
7952
  const appName = emailConfig?.appName || "Rebase";
6919
7953
  const templateFn = emailConfig?.templates?.emailVerification;
6920
7954
  const emailContent = templateFn ? templateFn(verifyUrl, {
@@ -6996,22 +8030,24 @@ function createAuthRoutes(config) {
6996
8030
  });
6997
8031
  return null;
6998
8032
  });
8033
+ const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
6999
8034
  let customClaims;
7000
8035
  if (ops.customizeAccessToken && user) {
7001
8036
  const defaultClaims = {
7002
8037
  uid: storedToken.uid,
7003
8038
  roles: roleIds,
7004
- aal: "aal1"
8039
+ aal: sessionAal
7005
8040
  };
7006
8041
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
7007
8042
  }
7008
- const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
8043
+ const newAccessToken = generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims);
7009
8044
  const newRefreshToken = generateRefreshToken();
7010
8045
  const userAgent = c.req.header("user-agent") || "unknown";
7011
8046
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
7012
8047
  const session = {
7013
8048
  id: storedToken.sessionId ?? storedToken.id,
7014
- startedAt: sessionStartedAt
8049
+ startedAt: sessionStartedAt,
8050
+ aal: sessionAal
7015
8051
  };
7016
8052
  if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
7017
8053
  else await authRepo.deleteRefreshToken(tokenHash);
@@ -7043,7 +8079,15 @@ function createAuthRoutes(config) {
7043
8079
  createSessionAndTokens,
7044
8080
  applyTransformHook
7045
8081
  });
7046
- mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
8082
+ mountMfaRoutes({
8083
+ router,
8084
+ config,
8085
+ ops,
8086
+ parseBody,
8087
+ buildAuthResponse,
8088
+ createSessionAndTokens,
8089
+ applyTransformHook
8090
+ });
7047
8091
  if (config.enableMagicLink) mountMagicLinkRoutes({
7048
8092
  router,
7049
8093
  config,
@@ -7287,7 +8331,7 @@ function createAdminUsersRoute(config) {
7287
8331
  const search = c.req.query("search");
7288
8332
  const orderBy = c.req.query("orderBy");
7289
8333
  const orderDir = c.req.query("orderDir");
7290
- const limit = resolveClientListLimit(limitParam, { defaultLimit: 25 });
8334
+ const limit = resolveListLimitParam(limitParam, { defaultLimit: 25 });
7291
8335
  const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
7292
8336
  const result = await authRepo.listUsersPaginated({
7293
8337
  limit,
@@ -7405,7 +8449,7 @@ function createAdminUsersRoute(config) {
7405
8449
  * when the user passes a plain `RebaseAuthConfig` object.
7406
8450
  */
7407
8451
  function createBuiltinAuthAdapter(config) {
7408
- const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
8452
+ const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
7409
8453
  const resolvedOps = resolveAuthHooks(authHooks);
7410
8454
  return {
7411
8455
  id: "rebase-builtin",
@@ -7481,6 +8525,7 @@ function createBuiltinAuthAdapter(config) {
7481
8525
  allowUserLookup,
7482
8526
  defaultRole,
7483
8527
  oauthProviders,
8528
+ allowedRedirectUris,
7484
8529
  authHooks,
7485
8530
  enableMagicLink,
7486
8531
  cookieAuth
@@ -7647,6 +8692,54 @@ function toAuthUserData(user) {
7647
8692
  };
7648
8693
  }
7649
8694
  //#endregion
8695
+ //#region src/auth/oauth-code-flow.ts
8696
+ /**
8697
+ * Build the request schema for an authorization-code provider.
8698
+ *
8699
+ * `pkce` defaults to `"optional"`: the client decides whether it sent a
8700
+ * `code_challenge`, and providers that do not implement PKCE simply never see
8701
+ * a verifier because their clients never generate one. `"required"` is for
8702
+ * providers that mandate PKCE (Twitter/X).
8703
+ */
8704
+ function oauthCodeFlowSchema(opts = {}) {
8705
+ const verifier = opts.pkce === "required" ? string().min(1, "PKCE code verifier is required") : string().min(1).optional();
8706
+ return object({
8707
+ code: string().min(1, "Auth code is required"),
8708
+ redirectUri: string().url("Valid redirect URI is required"),
8709
+ codeVerifier: verifier
8710
+ });
8711
+ }
8712
+ /**
8713
+ * The `code_verifier` token-endpoint parameter, or nothing.
8714
+ *
8715
+ * Returned as a spreadable object so a provider adds PKCE with one `...` in
8716
+ * its existing body literal, rather than branching.
8717
+ */
8718
+ function pkceTokenParams(codeVerifier) {
8719
+ return codeVerifier ? { code_verifier: codeVerifier } : {};
8720
+ }
8721
+ /**
8722
+ * Normalise a provider's email-verification signal into the boolean that
8723
+ * `OAuthProviderProfile.emailVerified` promises.
8724
+ *
8725
+ * The whole point is the default: anything that is not an affirmative
8726
+ * verification signal — `undefined`, `null`, a missing field, an empty string,
8727
+ * `false` — becomes `false`. A provider that reports nothing therefore cannot
8728
+ * accidentally assert that it verified the address, which is exactly the
8729
+ * mistake five providers had made.
8730
+ *
8731
+ * Accepts the string forms because OIDC issuers are inconsistent about whether
8732
+ * `email_verified` is a JSON boolean or a string.
8733
+ */
8734
+ function providerVerifiedEmail(signal) {
8735
+ if (signal === true) return true;
8736
+ if (typeof signal === "string") {
8737
+ const normalized = signal.trim().toLowerCase();
8738
+ return normalized === "true" || normalized === "1";
8739
+ }
8740
+ return false;
8741
+ }
8742
+ //#endregion
7650
8743
  //#region src/auth/google-oauth.ts
7651
8744
  var _googleAuth;
7652
8745
  async function loadGoogleAuth() {
@@ -7679,7 +8772,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
7679
8772
  const tokenInfo = await tokenInfoRes.json();
7680
8773
  if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
7681
8774
  if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
7682
- const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === "true";
8775
+ const emailVerified = providerVerifiedEmail(tokenInfo.email_verified);
7683
8776
  let displayName = null;
7684
8777
  let photoUrl = null;
7685
8778
  try {
@@ -7734,7 +8827,8 @@ function createGoogleProvider(config) {
7734
8827
  idToken: string().min(1).optional(),
7735
8828
  accessToken: string().min(1).optional(),
7736
8829
  code: string().min(1).optional(),
7737
- redirectUri: string().min(1).optional()
8830
+ redirectUri: string().min(1).optional(),
8831
+ codeVerifier: string().min(1).optional()
7738
8832
  }).refine((data) => data.idToken || data.accessToken || data.code && data.redirectUri, { message: "One of idToken, accessToken, or code+redirectUri is required" }),
7739
8833
  verify: async (payload) => {
7740
8834
  try {
@@ -7749,7 +8843,7 @@ function createGoogleProvider(config) {
7749
8843
  email: content.email || "",
7750
8844
  displayName: content.name || null,
7751
8845
  photoUrl: content.picture || null,
7752
- emailVerified: content.email_verified === true
8846
+ emailVerified: providerVerifiedEmail(content.email_verified)
7753
8847
  };
7754
8848
  }
7755
8849
  if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
@@ -7763,7 +8857,8 @@ function createGoogleProvider(config) {
7763
8857
  client_id: clientId,
7764
8858
  client_secret: clientSecret,
7765
8859
  redirect_uri: payload.redirectUri,
7766
- grant_type: "authorization_code"
8860
+ grant_type: "authorization_code",
8861
+ ...pkceTokenParams(payload.codeVerifier)
7767
8862
  })
7768
8863
  });
7769
8864
  if (!tokenResponse.ok) {
@@ -7783,7 +8878,7 @@ function createGoogleProvider(config) {
7783
8878
  email: content.email || "",
7784
8879
  displayName: content.name || null,
7785
8880
  photoUrl: content.picture || null,
7786
- emailVerified: content.email_verified === true
8881
+ emailVerified: providerVerifiedEmail(content.email_verified)
7787
8882
  };
7788
8883
  }
7789
8884
  if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
@@ -7805,10 +8900,7 @@ function createGoogleProvider(config) {
7805
8900
  function createLinkedinProvider(config) {
7806
8901
  return {
7807
8902
  id: "linkedin",
7808
- schema: object({
7809
- code: string().min(1, "Auth code is required"),
7810
- redirectUri: string().url("Valid redirect URI is required")
7811
- }),
8903
+ schema: oauthCodeFlowSchema(),
7812
8904
  verify: async (payload) => {
7813
8905
  try {
7814
8906
  const tokenResponse = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
@@ -7819,7 +8911,8 @@ function createLinkedinProvider(config) {
7819
8911
  code: payload.code,
7820
8912
  redirect_uri: payload.redirectUri,
7821
8913
  client_id: config.clientId,
7822
- client_secret: config.clientSecret
8914
+ client_secret: config.clientSecret,
8915
+ ...pkceTokenParams(payload.codeVerifier)
7823
8916
  })
7824
8917
  });
7825
8918
  if (!tokenResponse.ok) {
@@ -7835,12 +8928,16 @@ function createLinkedinProvider(config) {
7835
8928
  return null;
7836
8929
  }
7837
8930
  const profileData = await profileResponse.json();
8931
+ if (!profileData.email) {
8932
+ logger.error("LinkedIn user has no email (the 'email' scope may not have been granted)");
8933
+ return null;
8934
+ }
7838
8935
  return {
7839
8936
  providerId: profileData.sub,
7840
8937
  email: profileData.email,
7841
8938
  displayName: profileData.name || null,
7842
8939
  photoUrl: profileData.picture || null,
7843
- emailVerified: profileData.email_verified === true
8940
+ emailVerified: providerVerifiedEmail(profileData.email_verified)
7844
8941
  };
7845
8942
  } catch (error) {
7846
8943
  logger.error("LinkedIn OAuth error", { error });
@@ -7861,10 +8958,7 @@ function createLinkedinProvider(config) {
7861
8958
  function createGitHubProvider(config) {
7862
8959
  return {
7863
8960
  id: "github",
7864
- schema: object({
7865
- code: string().min(1, "Auth code is required"),
7866
- redirectUri: string().url("Valid redirect URI is required")
7867
- }),
8961
+ schema: oauthCodeFlowSchema(),
7868
8962
  verify: async (payload) => {
7869
8963
  try {
7870
8964
  const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
@@ -7877,7 +8971,8 @@ function createGitHubProvider(config) {
7877
8971
  client_id: config.clientId,
7878
8972
  client_secret: config.clientSecret,
7879
8973
  code: payload.code,
7880
- redirect_uri: payload.redirectUri
8974
+ redirect_uri: payload.redirectUri,
8975
+ ...pkceTokenParams(payload.codeVerifier)
7881
8976
  })
7882
8977
  });
7883
8978
  if (!tokenResponse.ok) {
@@ -7901,19 +8996,22 @@ function createGitHubProvider(config) {
7901
8996
  }
7902
8997
  const profileData = await profileResponse.json();
7903
8998
  let email = profileData.email;
7904
- if (!email) {
7905
- const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
7906
- "Authorization": `Bearer ${accessToken}`,
7907
- "Accept": "application/vnd.github+json",
7908
- "User-Agent": "Rebase-Auth"
7909
- } });
7910
- if (emailsResponse.ok) {
7911
- const emails = await emailsResponse.json();
7912
- email = (emails.find((e) => e.primary && e.verified) || emails.find((e) => e.verified))?.email || null;
8999
+ let emailVerified = false;
9000
+ const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
9001
+ "Authorization": `Bearer ${accessToken}`,
9002
+ "Accept": "application/vnd.github+json",
9003
+ "User-Agent": "Rebase-Auth"
9004
+ } });
9005
+ if (emailsResponse.ok) {
9006
+ const emails = await emailsResponse.json();
9007
+ const chosen = emails.find((e) => e.primary && e.verified) || emails.find((e) => e.verified) || (email ? emails.find((e) => e.email.toLowerCase() === email.toLowerCase()) : void 0);
9008
+ if (chosen) {
9009
+ email = chosen.email;
9010
+ emailVerified = providerVerifiedEmail(chosen.verified);
7913
9011
  }
7914
9012
  }
7915
9013
  if (!email) {
7916
- logger.error("GitHub user has no verified email");
9014
+ logger.error("GitHub user has no email");
7917
9015
  return null;
7918
9016
  }
7919
9017
  return {
@@ -7921,7 +9019,7 @@ function createGitHubProvider(config) {
7921
9019
  email,
7922
9020
  displayName: profileData.name || profileData.login || null,
7923
9021
  photoUrl: profileData.avatar_url || null,
7924
- emailVerified: true
9022
+ emailVerified
7925
9023
  };
7926
9024
  } catch (error) {
7927
9025
  logger.error("GitHub OAuth error", { error });
@@ -7931,21 +9029,109 @@ function createGitHubProvider(config) {
7931
9029
  };
7932
9030
  }
7933
9031
  //#endregion
9032
+ //#region src/auth/oidc-id-token.ts
9033
+ var JWKS_TTL_MS = 600 * 1e3;
9034
+ var jwksCache = /* @__PURE__ */ new Map();
9035
+ async function loadJwks(jwksUri, fetchImpl, forceRefresh) {
9036
+ const cached = jwksCache.get(jwksUri);
9037
+ if (!forceRefresh && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
9038
+ const response = await fetchImpl(jwksUri);
9039
+ if (!response.ok) throw new Error(`JWKS fetch failed (${response.status})`);
9040
+ const body = await response.json();
9041
+ const keys = Array.isArray(body.keys) ? body.keys : [];
9042
+ jwksCache.set(jwksUri, {
9043
+ keys,
9044
+ fetchedAt: Date.now()
9045
+ });
9046
+ return keys;
9047
+ }
9048
+ function toKeyObject(jwk) {
9049
+ return createPublicKey({
9050
+ key: jwk,
9051
+ format: "jwk"
9052
+ });
9053
+ }
9054
+ function decodeKid(idToken) {
9055
+ return import_jsonwebtoken.default.decode(idToken, { complete: true })?.header?.kid;
9056
+ }
9057
+ /**
9058
+ * Verify an id_token's signature, `aud`, `iss` and `exp`, and return its claims.
9059
+ *
9060
+ * Throws on any failure — callers treat a throw as "reject this sign-in",
9061
+ * never as "continue without the claims".
9062
+ */
9063
+ async function verifyOidcIdToken(options) {
9064
+ const { idToken, jwksUri, issuer, audience, algorithms = ["RS256"], clockToleranceSec = 60, nonce, fetchImpl = fetch } = options;
9065
+ if (!idToken || typeof idToken !== "string") throw new Error("id_token missing");
9066
+ const kid = decodeKid(idToken);
9067
+ let keys = await loadJwks(jwksUri, fetchImpl, false);
9068
+ let jwk = keys.find((k) => !kid || k.kid === kid);
9069
+ if (!jwk) {
9070
+ keys = await loadJwks(jwksUri, fetchImpl, true);
9071
+ jwk = keys.find((k) => !kid || k.kid === kid);
9072
+ }
9073
+ if (!jwk) throw new Error(`No JWKS key matches kid "${kid ?? "<none>"}"`);
9074
+ const claims = import_jsonwebtoken.default.verify(idToken, toKeyObject(jwk), {
9075
+ algorithms,
9076
+ audience,
9077
+ clockTolerance: clockToleranceSec
9078
+ });
9079
+ if (!(typeof issuer === "string" ? claims.iss === issuer : issuer.test(String(claims.iss ?? "")))) throw new Error(`Unexpected id_token issuer "${claims.iss}"`);
9080
+ if (nonce !== void 0 && claims.nonce !== nonce) throw new Error("id_token nonce mismatch");
9081
+ return claims;
9082
+ }
9083
+ /**
9084
+ * `verifyOidcIdToken` that reports failure as `null` and logs it, for the one
9085
+ * call site that has to decide between "reject" and "degrade".
9086
+ */
9087
+ async function tryVerifyOidcIdToken(providerId, options) {
9088
+ try {
9089
+ return await verifyOidcIdToken(options);
9090
+ } catch (error) {
9091
+ logger.error(`[${providerId}] id_token verification failed`, { error: error instanceof Error ? error.message : error });
9092
+ return null;
9093
+ }
9094
+ }
9095
+ //#endregion
7934
9096
  //#region src/auth/microsoft-oauth.ts
9097
+ /** Endpoints that federate to more than one directory. */
9098
+ var MULTI_TENANT_ENDPOINTS = /* @__PURE__ */ new Set([
9099
+ "common",
9100
+ "organizations",
9101
+ "consumers"
9102
+ ]);
7935
9103
  /**
7936
9104
  * Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
7937
9105
  *
7938
9106
  * Supports both personal Microsoft accounts and work/school (Azure AD) accounts
7939
9107
  * via the "common" tenant endpoint. Uses the authorization code flow.
9108
+ *
9109
+ * ## On `emailVerified`
9110
+ *
9111
+ * Microsoft Graph exposes no email-verification field, and `mail` is *not* a
9112
+ * substitute: it is a directory attribute a tenant administrator sets to any
9113
+ * string they like, through Graph or AAD Connect sync. With the default
9114
+ * `tenantId: "common"` every Entra tenant in the world is an accepted issuer,
9115
+ * so "there is a `mail` value" would mean "anybody who can create a free
9116
+ * tenant can nominate any address" — including one that already has an account
9117
+ * here.
9118
+ *
9119
+ * The only signal Microsoft offers is the `xms_edov` ("email domain owner
9120
+ * verified") optional claim on the id_token, so that is what this provider
9121
+ * reads, off a signature-verified token. If the app registration does not emit
9122
+ * `xms_edov`, sign-in still works and the address comes back unverified —
9123
+ * which routes the user to `POST /auth/link/microsoft` rather than silently
9124
+ * handing them somebody else's account.
7940
9125
  */
7941
9126
  function createMicrosoftProvider(config) {
7942
9127
  const tenantId = config.tenantId || "common";
9128
+ const multiTenant = MULTI_TENANT_ENDPOINTS.has(tenantId.toLowerCase());
9129
+ const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
9130
+ const issuer = multiTenant ? /^https:\/\/login\.microsoftonline\.com\/[0-9a-fA-F-]{36}\/v2\.0$/ : `https://login.microsoftonline.com/${tenantId}/v2.0`;
9131
+ if (multiTenant) logger.warn(`[Rebase] Microsoft provider configured with tenantId "${tenantId}", which accepts sign-ins from every Entra tenant. Email addresses are reported as unverified unless the app registration emits the xms_edov optional claim. Set an explicit tenantId, or enable xms_edov, to allow automatic linking onto existing accounts.`);
7943
9132
  return {
7944
9133
  id: "microsoft",
7945
- schema: object({
7946
- code: string().min(1, "Auth code is required"),
7947
- redirectUri: string().url("Valid redirect URI is required")
7948
- }),
9134
+ schema: oauthCodeFlowSchema(),
7949
9135
  verify: async (payload) => {
7950
9136
  try {
7951
9137
  const tokenResponse = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
@@ -7957,14 +9143,26 @@ function createMicrosoftProvider(config) {
7957
9143
  code: payload.code,
7958
9144
  redirect_uri: payload.redirectUri,
7959
9145
  grant_type: "authorization_code",
7960
- scope: "openid profile email User.Read"
9146
+ scope: "openid profile email User.Read",
9147
+ ...pkceTokenParams(payload.codeVerifier)
7961
9148
  })
7962
9149
  });
7963
9150
  if (!tokenResponse.ok) {
7964
9151
  logger.error("Failed to get Microsoft access token", { detail: await tokenResponse.text() });
7965
9152
  return null;
7966
9153
  }
7967
- const accessToken = (await tokenResponse.json()).access_token;
9154
+ const tokenData = await tokenResponse.json();
9155
+ const accessToken = tokenData.access_token;
9156
+ let idTokenClaims = null;
9157
+ if (tokenData.id_token) {
9158
+ idTokenClaims = await tryVerifyOidcIdToken("microsoft", {
9159
+ idToken: tokenData.id_token,
9160
+ jwksUri,
9161
+ issuer,
9162
+ audience: config.clientId
9163
+ });
9164
+ if (!idTokenClaims) return null;
9165
+ }
7968
9166
  const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { "Authorization": `Bearer ${accessToken}` } });
7969
9167
  if (!profileResponse.ok) {
7970
9168
  logger.error("Failed to get Microsoft user info", { detail: await profileResponse.text() });
@@ -7976,7 +9174,8 @@ function createMicrosoftProvider(config) {
7976
9174
  logger.error("Microsoft user has no email");
7977
9175
  return null;
7978
9176
  }
7979
- const emailVerified = Boolean(profileData.mail);
9177
+ const claimedEmail = idTokenClaims?.email ?? idTokenClaims?.preferred_username;
9178
+ const emailVerified = providerVerifiedEmail(idTokenClaims?.xms_edov) && typeof claimedEmail === "string" && claimedEmail.toLowerCase() === email.toLowerCase();
7980
9179
  return {
7981
9180
  providerId: profileData.id,
7982
9181
  email,
@@ -7993,6 +9192,8 @@ function createMicrosoftProvider(config) {
7993
9192
  }
7994
9193
  //#endregion
7995
9194
  //#region src/auth/apple-oauth.ts
9195
+ var APPLE_ISSUER = "https://appleid.apple.com";
9196
+ var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
7996
9197
  /**
7997
9198
  * Creates an Apple Sign In OAuth Provider integration.
7998
9199
  *
@@ -8022,18 +9223,19 @@ function createAppleProvider(config) {
8022
9223
  }
8023
9224
  return {
8024
9225
  id: "apple",
8025
- schema: object({
8026
- code: string().min(1, "Auth code is required"),
8027
- redirectUri: string().url("Valid redirect URI is required"),
8028
- /** Apple sends user info only on first authorization; the frontend must forward it. */
8029
- user: object({
8030
- name: object({
8031
- firstName: string().optional(),
8032
- lastName: string().optional()
8033
- }).optional(),
8034
- email: string().email().optional()
8035
- }).optional()
8036
- }),
9226
+ schema: oauthCodeFlowSchema().extend({
9227
+ /**
9228
+ * Apple sends user info only on first authorization; the frontend
9229
+ * must forward it. Only the *name* is accepted — an `email` here
9230
+ * used to be promoted to the account's identity whenever the
9231
+ * id_token had no `email` claim, and an attacker chooses whether
9232
+ * that claim is absent simply by omitting the `email` scope from
9233
+ * their own authorization request.
9234
+ */
9235
+ user: object({ name: object({
9236
+ firstName: string().optional(),
9237
+ lastName: string().optional()
9238
+ }).optional() }).optional() }),
8037
9239
  verify: async (payload) => {
8038
9240
  try {
8039
9241
  const clientSecret = await generateClientSecret();
@@ -8045,18 +9247,28 @@ function createAppleProvider(config) {
8045
9247
  client_secret: clientSecret,
8046
9248
  code: payload.code,
8047
9249
  grant_type: "authorization_code",
8048
- redirect_uri: payload.redirectUri
9250
+ redirect_uri: payload.redirectUri,
9251
+ ...pkceTokenParams(payload.codeVerifier)
8049
9252
  })
8050
9253
  });
8051
9254
  if (!tokenResponse.ok) {
8052
9255
  logger.error("Failed to get Apple access token", { detail: await tokenResponse.text() });
8053
9256
  return null;
8054
9257
  }
8055
- const [, payloadB64] = (await tokenResponse.json()).id_token.split(".");
8056
- const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
8057
- const email = decoded.email || payload.user?.email;
9258
+ const tokenData = await tokenResponse.json();
9259
+ const decoded = tokenData.id_token ? await tryVerifyOidcIdToken("apple", {
9260
+ idToken: tokenData.id_token,
9261
+ jwksUri: APPLE_JWKS_URI,
9262
+ issuer: APPLE_ISSUER,
9263
+ audience: config.clientId
9264
+ }) : null;
9265
+ if (!decoded) {
9266
+ logger.error("Apple token exchange returned no verifiable id_token");
9267
+ return null;
9268
+ }
9269
+ const email = decoded.email;
8058
9270
  if (!email) {
8059
- logger.error("Apple user has no email");
9271
+ logger.error("Apple id_token carries no email claim");
8060
9272
  return null;
8061
9273
  }
8062
9274
  let displayName = null;
@@ -8069,7 +9281,7 @@ function createAppleProvider(config) {
8069
9281
  email,
8070
9282
  displayName,
8071
9283
  photoUrl: null,
8072
- emailVerified: decoded.email_verified === true || decoded.email_verified === "true"
9284
+ emailVerified: providerVerifiedEmail(decoded.email_verified)
8073
9285
  };
8074
9286
  } catch (error) {
8075
9287
  logger.error("Apple OAuth error", { error });
@@ -8089,18 +9301,20 @@ function createAppleProvider(config) {
8089
9301
  function createFacebookProvider(config) {
8090
9302
  return {
8091
9303
  id: "facebook",
8092
- schema: object({
8093
- code: string().min(1, "Auth code is required"),
8094
- redirectUri: string().url("Valid redirect URI is required")
8095
- }),
9304
+ schema: oauthCodeFlowSchema(),
8096
9305
  verify: async (payload) => {
8097
9306
  try {
8098
- const tokenUrl = new URL("https://graph.facebook.com/v19.0/oauth/access_token");
8099
- tokenUrl.searchParams.set("client_id", config.clientId);
8100
- tokenUrl.searchParams.set("client_secret", config.clientSecret);
8101
- tokenUrl.searchParams.set("redirect_uri", payload.redirectUri);
8102
- tokenUrl.searchParams.set("code", payload.code);
8103
- const tokenResponse = await fetch(tokenUrl.toString());
9307
+ const tokenResponse = await fetch("https://graph.facebook.com/v19.0/oauth/access_token", {
9308
+ method: "POST",
9309
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
9310
+ body: new URLSearchParams({
9311
+ client_id: config.clientId,
9312
+ client_secret: config.clientSecret,
9313
+ redirect_uri: payload.redirectUri,
9314
+ code: payload.code,
9315
+ ...pkceTokenParams(payload.codeVerifier)
9316
+ })
9317
+ });
8104
9318
  if (!tokenResponse.ok) {
8105
9319
  logger.error("Failed to get Facebook access token", { detail: await tokenResponse.text() });
8106
9320
  return null;
@@ -8108,8 +9322,7 @@ function createFacebookProvider(config) {
8108
9322
  const accessToken = (await tokenResponse.json()).access_token;
8109
9323
  const profileUrl = new URL("https://graph.facebook.com/v19.0/me");
8110
9324
  profileUrl.searchParams.set("fields", "id,name,email,picture.type(large)");
8111
- profileUrl.searchParams.set("access_token", accessToken);
8112
- const profileResponse = await fetch(profileUrl.toString());
9325
+ const profileResponse = await fetch(profileUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } });
8113
9326
  if (!profileResponse.ok) {
8114
9327
  logger.error("Failed to get Facebook user info", { detail: await profileResponse.text() });
8115
9328
  return null;
@@ -8124,7 +9337,7 @@ function createFacebookProvider(config) {
8124
9337
  email: profileData.email,
8125
9338
  displayName: profileData.name || null,
8126
9339
  photoUrl: profileData.picture?.data?.url || null,
8127
- emailVerified: true
9340
+ emailVerified: false
8128
9341
  };
8129
9342
  } catch (error) {
8130
9343
  logger.error("Facebook OAuth error", { error });
@@ -8147,11 +9360,7 @@ function createFacebookProvider(config) {
8147
9360
  function createTwitterProvider(config) {
8148
9361
  return {
8149
9362
  id: "twitter",
8150
- schema: object({
8151
- code: string().min(1, "Auth code is required"),
8152
- redirectUri: string().url("Valid redirect URI is required"),
8153
- codeVerifier: string().min(1, "PKCE code verifier is required")
8154
- }),
9363
+ schema: oauthCodeFlowSchema({ pkce: "required" }),
8155
9364
  verify: async (payload) => {
8156
9365
  try {
8157
9366
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8165,7 +9374,7 @@ function createTwitterProvider(config) {
8165
9374
  code: payload.code,
8166
9375
  grant_type: "authorization_code",
8167
9376
  redirect_uri: payload.redirectUri,
8168
- code_verifier: payload.codeVerifier
9377
+ ...pkceTokenParams(payload.codeVerifier)
8169
9378
  })
8170
9379
  });
8171
9380
  if (!tokenResponse.ok) {
@@ -8211,16 +9420,14 @@ function createTwitterProvider(config) {
8211
9420
  /**
8212
9421
  * Creates a Discord OAuth2 Provider integration.
8213
9422
  *
8214
- * Uses the authorization code flow. Requires the "identify" and "email"
8215
- * scopes to retrieve the user's email and profile information.
9423
+ * Uses the authorization code flow (with PKCE when the client supplies a
9424
+ * verifier). Requires the "identify" and "email" scopes to retrieve the
9425
+ * user's email and profile information.
8216
9426
  */
8217
9427
  function createDiscordProvider(config) {
8218
9428
  return {
8219
9429
  id: "discord",
8220
- schema: object({
8221
- code: string().min(1, "Auth code is required"),
8222
- redirectUri: string().url("Valid redirect URI is required")
8223
- }),
9430
+ schema: oauthCodeFlowSchema(),
8224
9431
  verify: async (payload) => {
8225
9432
  try {
8226
9433
  const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
@@ -8231,7 +9438,8 @@ function createDiscordProvider(config) {
8231
9438
  client_secret: config.clientSecret,
8232
9439
  grant_type: "authorization_code",
8233
9440
  code: payload.code,
8234
- redirect_uri: payload.redirectUri
9441
+ redirect_uri: payload.redirectUri,
9442
+ ...pkceTokenParams(payload.codeVerifier)
8235
9443
  })
8236
9444
  });
8237
9445
  if (!tokenResponse.ok) {
@@ -8259,7 +9467,7 @@ function createDiscordProvider(config) {
8259
9467
  email: profileData.email,
8260
9468
  displayName: profileData.global_name || profileData.username || null,
8261
9469
  photoUrl,
8262
- emailVerified: profileData.verified === true
9470
+ emailVerified: providerVerifiedEmail(profileData.verified)
8263
9471
  };
8264
9472
  } catch (error) {
8265
9473
  logger.error("Discord OAuth error", { error });
@@ -8273,15 +9481,16 @@ function createDiscordProvider(config) {
8273
9481
  /**
8274
9482
  * Creates a GitLab OAuth Provider integration.
8275
9483
  * Works with both GitLab.com and self-hosted instances.
9484
+ *
9485
+ * `baseUrl` is configuration only and must stay that way: a caller-supplied
9486
+ * instance URL would make this provider an SSRF primitive and an
9487
+ * arbitrary-identity oracle in one step.
8276
9488
  */
8277
9489
  function createGitLabProvider(config) {
8278
9490
  const gitlabUrl = (config.baseUrl || "https://gitlab.com").replace(/\/$/, "");
8279
9491
  return {
8280
9492
  id: "gitlab",
8281
- schema: object({
8282
- code: string().min(1, "Auth code is required"),
8283
- redirectUri: string().url("Valid redirect URI is required")
8284
- }),
9493
+ schema: oauthCodeFlowSchema(),
8285
9494
  verify: async (payload) => {
8286
9495
  try {
8287
9496
  const tokenResponse = await fetch(`${gitlabUrl}/oauth/token`, {
@@ -8292,7 +9501,8 @@ function createGitLabProvider(config) {
8292
9501
  client_secret: config.clientSecret,
8293
9502
  code: payload.code,
8294
9503
  grant_type: "authorization_code",
8295
- redirect_uri: payload.redirectUri
9504
+ redirect_uri: payload.redirectUri,
9505
+ ...pkceTokenParams(payload.codeVerifier)
8296
9506
  })
8297
9507
  });
8298
9508
  if (!tokenResponse.ok) {
@@ -8315,7 +9525,7 @@ function createGitLabProvider(config) {
8315
9525
  email: p.email,
8316
9526
  displayName: p.name || p.username || null,
8317
9527
  photoUrl: p.avatar_url || null,
8318
- emailVerified: true
9528
+ emailVerified: Boolean(p.confirmed_at)
8319
9529
  };
8320
9530
  } catch (error) {
8321
9531
  logger.error("GitLab OAuth error", { error });
@@ -8332,10 +9542,7 @@ function createGitLabProvider(config) {
8332
9542
  function createBitbucketProvider(config) {
8333
9543
  return {
8334
9544
  id: "bitbucket",
8335
- schema: object({
8336
- code: string().min(1, "Auth code is required"),
8337
- redirectUri: string().url("Valid redirect URI is required")
8338
- }),
9545
+ schema: oauthCodeFlowSchema(),
8339
9546
  verify: async (payload) => {
8340
9547
  try {
8341
9548
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8348,7 +9555,8 @@ function createBitbucketProvider(config) {
8348
9555
  body: new URLSearchParams({
8349
9556
  grant_type: "authorization_code",
8350
9557
  code: payload.code,
8351
- redirect_uri: payload.redirectUri
9558
+ redirect_uri: payload.redirectUri,
9559
+ ...pkceTokenParams(payload.codeVerifier)
8352
9560
  })
8353
9561
  });
8354
9562
  if (!tokenResponse.ok) {
@@ -8363,21 +9571,21 @@ function createBitbucketProvider(config) {
8363
9571
  }
8364
9572
  const p = await profileResponse.json();
8365
9573
  const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", { headers: { "Authorization": `Bearer ${accessToken}` } });
8366
- let email = null;
9574
+ let selected;
8367
9575
  if (emailsResponse.ok) {
8368
9576
  const emailData = await emailsResponse.json();
8369
- email = (emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed))?.email || null;
9577
+ selected = emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed);
8370
9578
  }
8371
- if (!email) {
9579
+ if (!selected?.email) {
8372
9580
  logger.error("Bitbucket user has no verified email");
8373
9581
  return null;
8374
9582
  }
8375
9583
  return {
8376
9584
  providerId: p.uuid,
8377
- email,
9585
+ email: selected.email,
8378
9586
  displayName: p.display_name || p.username || null,
8379
9587
  photoUrl: p.links?.avatar?.href || null,
8380
- emailVerified: true
9588
+ emailVerified: providerVerifiedEmail(selected.is_confirmed)
8381
9589
  };
8382
9590
  } catch (error) {
8383
9591
  logger.error("Bitbucket OAuth error", { error });
@@ -8395,10 +9603,7 @@ function createBitbucketProvider(config) {
8395
9603
  function createSlackProvider(config) {
8396
9604
  return {
8397
9605
  id: "slack",
8398
- schema: object({
8399
- code: string().min(1, "Auth code is required"),
8400
- redirectUri: string().url("Valid redirect URI is required")
8401
- }),
9606
+ schema: oauthCodeFlowSchema(),
8402
9607
  verify: async (payload) => {
8403
9608
  try {
8404
9609
  const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
@@ -8409,7 +9614,8 @@ function createSlackProvider(config) {
8409
9614
  client_secret: config.clientSecret,
8410
9615
  code: payload.code,
8411
9616
  redirect_uri: payload.redirectUri,
8412
- grant_type: "authorization_code"
9617
+ grant_type: "authorization_code",
9618
+ ...pkceTokenParams(payload.codeVerifier)
8413
9619
  })
8414
9620
  });
8415
9621
  if (!tokenResponse.ok) {
@@ -8436,7 +9642,7 @@ function createSlackProvider(config) {
8436
9642
  email: p.email,
8437
9643
  displayName: p.name || null,
8438
9644
  photoUrl: p.picture || null,
8439
- emailVerified: p.email_verified === true
9645
+ emailVerified: providerVerifiedEmail(p.email_verified)
8440
9646
  };
8441
9647
  } catch (error) {
8442
9648
  logger.error("Slack OAuth error", { error });
@@ -8454,10 +9660,7 @@ function createSlackProvider(config) {
8454
9660
  function createSpotifyProvider(config) {
8455
9661
  return {
8456
9662
  id: "spotify",
8457
- schema: object({
8458
- code: string().min(1, "Auth code is required"),
8459
- redirectUri: string().url("Valid redirect URI is required")
8460
- }),
9663
+ schema: oauthCodeFlowSchema(),
8461
9664
  verify: async (payload) => {
8462
9665
  try {
8463
9666
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8470,7 +9673,8 @@ function createSpotifyProvider(config) {
8470
9673
  body: new URLSearchParams({
8471
9674
  grant_type: "authorization_code",
8472
9675
  code: payload.code,
8473
- redirect_uri: payload.redirectUri
9676
+ redirect_uri: payload.redirectUri,
9677
+ ...pkceTokenParams(payload.codeVerifier)
8474
9678
  })
8475
9679
  });
8476
9680
  if (!tokenResponse.ok) {
@@ -8493,7 +9697,7 @@ function createSpotifyProvider(config) {
8493
9697
  email: p.email,
8494
9698
  displayName: p.display_name || null,
8495
9699
  photoUrl: p.images?.[0]?.url || null,
8496
- emailVerified: true
9700
+ emailVerified: false
8497
9701
  };
8498
9702
  } catch (error) {
8499
9703
  logger.error("Spotify OAuth error", { error });
@@ -8591,44 +9795,45 @@ function createApiKeyStore(driver) {
8591
9795
  return;
8592
9796
  }
8593
9797
  const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
9798
+ const ddl = createDdlBootstrapper(exec, "api-key-store");
8594
9799
  return {
8595
9800
  async ensureTable() {
8596
- try {
8597
- await exec("CREATE SCHEMA IF NOT EXISTS rebase");
8598
- await exec(`
8599
- CREATE TABLE IF NOT EXISTS ${TABLE} (
8600
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8601
- name TEXT NOT NULL,
8602
- key_prefix TEXT NOT NULL,
8603
- key_hash TEXT NOT NULL UNIQUE,
8604
- permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
8605
- admin BOOLEAN NOT NULL DEFAULT FALSE,
8606
- rate_limit INTEGER,
8607
- created_by TEXT NOT NULL,
8608
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8609
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8610
- last_used_at TIMESTAMPTZ,
8611
- expires_at TIMESTAMPTZ,
8612
- revoked_at TIMESTAMPTZ
8613
- )
8614
- `);
8615
- await exec(`
8616
- CREATE INDEX IF NOT EXISTS idx_api_keys_hash
8617
- ON ${TABLE}(key_hash)
8618
- `);
8619
- await exec(`
8620
- CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8621
- ON ${TABLE}(key_prefix)
8622
- `);
8623
- await exec(`
8624
- ALTER TABLE ${TABLE}
8625
- ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
8626
- `);
8627
- logger.info("✅ API keys table ready");
8628
- } catch (err) {
8629
- logger.error("❌ Failed to create API keys table", { error: err });
8630
- logger.warn("⚠️ Continuing without API keys support.");
9801
+ await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
9802
+ await ddl.ensureObject(`Creating ${TABLE}`, `
9803
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
9804
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
9805
+ name TEXT NOT NULL,
9806
+ key_prefix TEXT NOT NULL,
9807
+ key_hash TEXT NOT NULL UNIQUE,
9808
+ permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
9809
+ admin BOOLEAN NOT NULL DEFAULT FALSE,
9810
+ rate_limit INTEGER,
9811
+ created_by TEXT NOT NULL,
9812
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9813
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9814
+ last_used_at TIMESTAMPTZ,
9815
+ expires_at TIMESTAMPTZ,
9816
+ revoked_at TIMESTAMPTZ
9817
+ )
9818
+ `);
9819
+ await ddl.ensureObject("Creating idx_api_keys_hash", `
9820
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash
9821
+ ON ${TABLE}(key_hash)
9822
+ `);
9823
+ await ddl.ensureObject("Creating idx_api_keys_prefix", `
9824
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
9825
+ ON ${TABLE}(key_prefix)
9826
+ `);
9827
+ await ddl.ensureObject(`Adding ${TABLE}.admin`, `
9828
+ ALTER TABLE ${TABLE}
9829
+ ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
9830
+ `);
9831
+ if (!await ddl.isReadable(TABLE)) {
9832
+ 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.`);
9833
+ return;
8631
9834
  }
9835
+ await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
9836
+ logger.info("✅ API keys table ready");
8632
9837
  },
8633
9838
  async createApiKey(request, createdBy) {
8634
9839
  const plaintext = generateApiKey();
@@ -8923,6 +10128,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8923
10128
  createSlackProvider: () => createSlackProvider,
8924
10129
  createSpotifyProvider: () => createSpotifyProvider,
8925
10130
  createTwitterProvider: () => createTwitterProvider,
10131
+ decideOAuthAutoLink: () => decideOAuthAutoLink,
8926
10132
  defaultAuthLimiter: () => defaultAuthLimiter,
8927
10133
  extractUserFromToken: () => extractUserFromToken,
8928
10134
  fileTokenAuth: () => fileTokenAuth,
@@ -8939,23 +10145,30 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8939
10145
  hashToken: () => hashToken,
8940
10146
  httpMethodToOperation: () => httpMethodToOperation,
8941
10147
  isApiKeyToken: () => isApiKeyToken,
10148
+ isJwtConfigured: () => isJwtConfigured,
8942
10149
  isOperationAllowed: () => isOperationAllowed,
10150
+ isRedirectUriAllowed: () => isRedirectUriAllowed,
8943
10151
  mountMagicLinkRoutes: () => mountMagicLinkRoutes,
10152
+ oauthCodeFlowSchema: () => oauthCodeFlowSchema,
8944
10153
  optionalAuth: () => optionalAuth,
10154
+ pkceTokenParams: () => pkceTokenParams,
8945
10155
  prepareAdminUserValues: () => prepareAdminUserValues,
10156
+ providerVerifiedEmail: () => providerVerifiedEmail,
8946
10157
  queryTokenAuth: () => queryTokenAuth,
8947
10158
  requireAdmin: () => requireAdmin,
8948
10159
  requireAuth: () => requireAuth,
8949
10160
  resolveAuthHooks: () => resolveAuthHooks,
8950
10161
  safeCompare: () => safeCompare,
8951
10162
  strictAuthLimiter: () => strictAuthLimiter,
10163
+ tryVerifyOidcIdToken: () => tryVerifyOidcIdToken,
8952
10164
  validateApiKey: () => validateApiKey,
8953
10165
  validatePasswordStrength: () => validatePasswordStrength,
8954
10166
  verifyAccessToken: () => verifyAccessToken,
8955
10167
  verifyDownloadToken: () => verifyDownloadToken,
10168
+ verifyOidcIdToken: () => verifyOidcIdToken,
8956
10169
  verifyPassword: () => verifyPassword
8957
10170
  });
8958
10171
  //#endregion
8959
- export { httpMethodToOperation as $, getEmailVerificationTemplate as A, optionalAuth as B, createDataRateLimiter as C, validatePasswordStrength as D, hashPassword as E, createAdapterAuthMiddleware as F, createApiKeyPreAuth as G, queryTokenAuth as H, createAuthMiddleware as I, isApiKeyToken as J, createFunctionApiKeyGuard as K, createRequireAuth as L, getPasswordResetTemplate as M, getUserInvitationTemplate as N, verifyPassword as O, getWelcomeEmailTemplate as P, scopeDataDriver as Q, extractUserFromToken as R, _coercedNumber as S, resolveAuthHooks as T, requireAdmin as U, publicObjectAuth as V, requireAuth as W, extractBearerToken as X, validateApiKey as Y, safeCompare as Z, createBuiltinAuthAdapter as _, createSpotifyProvider as a, object as b, createGitLabProvider as c, createFacebookProvider as d, isOperationAllowed as et, createAppleProvider as f, createGoogleProvider as g, createLinkedinProvider as h, createApiKeyStore as i, resolveClientListLimit as it, getMagicLinkTemplate as j, generateSecurePassword as k, createDiscordProvider as l, createGitHubProvider as m, createCustomAuthAdapter as n, isPublicStoragePath as nt, createSlackProvider as o, createMicrosoftProvider as p, createStorageApiKeyGuard as q, createApiKeyRoutes as r, MAX_LIST_LIMIT as rt, createBitbucketProvider as s, auth_exports as t, PUBLIC_STORAGE_PREFIX as tt, createTwitterProvider as u, ZodNumber as v, MemoryRateLimitStore as w, string as x, _enum as y, fileTokenAuth as z };
10172
+ export { fileTokenAuth as $, MemoryRateLimitStore as A, getEmailVerificationTemplate as B, ZodNumber as C, isPublicStoragePath as Ct, _coercedNumber as D, string as E, resolveAuthHooks as F, RawHtml as G, getPasswordResetTemplate as H, hashPassword as I, raw as J, escapeHtml as K, validatePasswordStrength as L, createEmailService as M, assertEmailLinkBases as N, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as O, resolveEmailLinkBase as P, extractUserFromToken as Q, verifyPassword as R, createBuiltinAuthAdapter as S, PUBLIC_STORAGE_PREFIX as St, object as T, getUserInvitationTemplate as U, getMagicLinkTemplate as V, getWelcomeEmailTemplate as W, createAuthMiddleware as X, createAdapterAuthMiddleware as Y, createRequireAuth as Z, createLinkedinProvider as _, scopeDataDriver as _t, createSpotifyProvider as a, InvalidStorageBucketError as at, pkceTokenParams as b, parseQueryOptions as bt, createGitLabProvider as c, canonicalStorageKey as ct, createFacebookProvider as d, createStorageApiKeyGuard as dt, optionalAuth as et, createAppleProvider as f, isApiKeyToken as ft, createGitHubProvider as g, SERVICE_IDENTITY as gt, verifyOidcIdToken as h, safeCompare as ht, createApiKeyStore as i, requireAuth as it, SMTPEmailService as j, createDataRateLimiter as k, createDiscordProvider as l, createApiKeyPreAuth as lt, tryVerifyOidcIdToken as m, extractBearerToken as mt, createCustomAuthAdapter as n, queryTokenAuth as nt, createSlackProvider as o, InvalidStorageKeyError as ot, createMicrosoftProvider as p, validateApiKey as pt, html as q, createApiKeyRoutes as r, requireAdmin as rt, createBitbucketProvider as s, canonicalStorageBucket as st, auth_exports as t, publicObjectAuth as tt, createTwitterProvider as u, createFunctionApiKeyGuard as ut, createGoogleProvider as v, httpMethodToOperation as vt, _enum as w, providerVerifiedEmail as x, resolveListLimitParam as xt, oauthCodeFlowSchema as y, isOperationAllowed as yt, generateSecurePassword as z };
8960
10173
 
8961
- //# sourceMappingURL=auth-CuC9M2x6.js.map
10174
+ //# sourceMappingURL=auth-DkAHqvf5.js.map