@rebasepro/server 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/{admin_block-EmIHae6X.js → admin_block-H86dCPsj.js} +61 -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 +12 -0
  5. package/dist/api/rest/api-generator.d.ts +26 -1
  6. package/dist/api/rest/idempotency.d.ts +25 -4
  7. package/dist/api/rest/query-parser.d.ts +15 -1
  8. package/dist/api/rest/write-validation.d.ts +40 -4
  9. package/dist/auth/adapter-middleware.d.ts +3 -1
  10. package/dist/auth/admin-roles.d.ts +24 -0
  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 +11 -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 +7 -0
  20. package/dist/auth/interfaces.d.ts +81 -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-crypto.d.ts +54 -3
  24. package/dist/auth/mfa-gate.d.ts +42 -0
  25. package/dist/auth/mfa-routes.d.ts +26 -1
  26. package/dist/auth/mfa.d.ts +16 -0
  27. package/dist/auth/microsoft-oauth.d.ts +19 -4
  28. package/dist/auth/middleware.d.ts +26 -0
  29. package/dist/auth/oauth-code-flow.d.ts +66 -0
  30. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  31. package/dist/auth/oidc-id-token.d.ts +61 -0
  32. package/dist/auth/rate-limiter.d.ts +62 -5
  33. package/dist/auth/registration-policy.d.ts +31 -0
  34. package/dist/auth/rls-scope.d.ts +25 -0
  35. package/dist/auth/routes.d.ts +15 -0
  36. package/dist/auth/slack-oauth.d.ts +2 -4
  37. package/dist/auth/spotify-oauth.d.ts +2 -4
  38. package/dist/auth/token-revocation.d.ts +38 -0
  39. package/dist/auth/twitter-oauth.d.ts +2 -5
  40. package/dist/{auth-DU4ShERo.js → auth-CYoPVf-E.js} +1985 -395
  41. package/dist/auth-CYoPVf-E.js.map +1 -0
  42. package/dist/{backup-BJiY_mM-.js → backup-C6ljYVTp.js} +2 -2
  43. package/dist/{backup-BJiY_mM-.js.map → backup-C6ljYVTp.js.map} +1 -1
  44. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  45. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
  46. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
  47. package/dist/cron/cron-loader.d.ts +26 -1
  48. package/dist/cron/cron-routes.d.ts +1 -1
  49. package/dist/cron/cron-scheduler.d.ts +13 -3
  50. package/dist/cron/define-cron.d.ts +17 -3
  51. package/dist/cron/index.d.ts +2 -2
  52. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
  53. package/dist/cron-loader-YhhQeVBM.js.map +1 -0
  54. package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
  55. package/dist/cron-routes-maM_RlUu.js.map +1 -0
  56. package/dist/{cron-scheduler-BhFZWR0T.js → cron-scheduler-DIpYBmZP.js} +20 -8
  57. package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
  58. package/dist/{cron-store-Cu4sOseb.js → cron-store-Dvr4Y1sZ.js} +60 -51
  59. package/dist/cron-store-Dvr4Y1sZ.js.map +1 -0
  60. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  61. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  62. package/dist/email/html.d.ts +54 -0
  63. package/dist/email/index.d.ts +3 -0
  64. package/dist/email/link-base.d.ts +39 -0
  65. package/dist/email/smtp-email-service.d.ts +7 -1
  66. package/dist/email/templates.d.ts +9 -1
  67. package/dist/email/types.d.ts +11 -1
  68. package/dist/env.d.ts +5 -0
  69. package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
  70. package/dist/errors-EBYiaJ2E.js.map +1 -0
  71. package/dist/function-loader-DDS1v7YX.js +139 -0
  72. package/dist/function-loader-DDS1v7YX.js.map +1 -0
  73. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  74. package/dist/function-routes-Btcez1T-.js.map +1 -0
  75. package/dist/functions/define-function.d.ts +25 -6
  76. package/dist/functions/function-loader.d.ts +23 -0
  77. package/dist/functions/function-routes.d.ts +7 -1
  78. package/dist/functions/request-timeout.d.ts +34 -0
  79. package/dist/history/history-routes.d.ts +6 -0
  80. package/dist/index.d.ts +3 -2
  81. package/dist/index.es.js +1893 -714
  82. package/dist/index.es.js.map +1 -1
  83. package/dist/init/docs.d.ts +10 -1
  84. package/dist/init/process-safety.d.ts +25 -0
  85. package/dist/init.d.ts +38 -0
  86. package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
  87. package/dist/jwt-_IFqfTOg.js.map +1 -0
  88. package/dist/logger-DfvF_8r-.js +190 -0
  89. package/dist/logger-DfvF_8r-.js.map +1 -0
  90. package/dist/{openapi-generator-D5xsfVJY.js → openapi-generator-DPKtUC9X.js} +326 -55
  91. package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
  92. package/dist/request-timeout-RivJsME0.js +65 -0
  93. package/dist/request-timeout-RivJsME0.js.map +1 -0
  94. package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
  95. package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
  96. package/dist/serve-spa.d.ts +5 -4
  97. package/dist/services/outbound-url-guard.d.ts +53 -0
  98. package/dist/services/routed-realtime-service.d.ts +10 -2
  99. package/dist/services/webhook-service.d.ts +76 -1
  100. package/dist/singleton.d.ts +25 -8
  101. package/dist/{src-CKOZBpeJ.js → src-C7rkDGxA.js} +619 -70
  102. package/dist/src-C7rkDGxA.js.map +1 -0
  103. package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
  104. package/dist/src-Cz9nMgUR.js.map +1 -0
  105. package/dist/storage/LocalStorageController.d.ts +12 -1
  106. package/dist/storage/image-transform.d.ts +57 -0
  107. package/dist/storage/keys.d.ts +98 -0
  108. package/dist/storage/routes.d.ts +11 -0
  109. package/dist/storage/tus-handler.d.ts +22 -2
  110. package/dist/utils/logger.d.ts +18 -0
  111. package/dist/utils/sql.d.ts +11 -6
  112. package/package.json +6 -5
  113. package/dist/admin_block-EmIHae6X.js.map +0 -1
  114. package/dist/auth-DU4ShERo.js.map +0 -1
  115. package/dist/backend-CIxN4FVm.js +0 -15
  116. package/dist/backend-CIxN4FVm.js.map +0 -1
  117. package/dist/cron-loader-B1S2MCSl.js.map +0 -1
  118. package/dist/cron-routes-D5a9v1HY.js.map +0 -1
  119. package/dist/cron-scheduler-BhFZWR0T.js.map +0 -1
  120. package/dist/cron-store-Cu4sOseb.js.map +0 -1
  121. package/dist/errors-BpudWAVU.js.map +0 -1
  122. package/dist/function-loader-B_1fYfUY.js +0 -86
  123. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  124. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  125. package/dist/jwt-CzeviDcB.js.map +0 -1
  126. package/dist/logger-BYU66ENZ.js +0 -94
  127. package/dist/logger-BYU66ENZ.js.map +0 -1
  128. package/dist/openapi-generator-D5xsfVJY.js.map +0 -1
  129. package/dist/schema-editor-routes-C1DxDnDR.js +0 -248
  130. package/dist/schema-editor-routes-C1DxDnDR.js.map +0 -1
  131. package/dist/src-CKOZBpeJ.js.map +0 -1
  132. package/dist/src-_qQ3RNCK.js.map +0 -1
@@ -2,16 +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 { S as isAnonymousUid, b as resolveClientListLimit, x as ANONYMOUS_USER_ID } from "./src-CKOZBpeJ.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-BpudWAVU.js";
10
- import { a as generateRefreshToken, c as getRefreshTokenTtlMs, f as verifyAccessToken, i as generateDownloadToken, l as hashRefreshToken, m as require_jsonwebtoken, n as configureJwt, o as getAccessTokenExpiry, p as verifyDownloadToken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS, u as isJwtConfigured } from "./jwt-CzeviDcB.js";
5
+ import { C as resolveClientListLimit, T as isAnonymousUid, a as deserializeLogicalCondition, i as deserializeFilter, r as UnknownFilterOperatorError, w as ANONYMOUS_USER_ID, x as ListLimitError } from "./src-C7rkDGxA.js";
6
+ import "./src-Cz9nMgUR.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-EBYiaJ2E.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-_IFqfTOg.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";
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";
15
17
  //#region ../types/src/controllers/storage.ts
16
18
  /**
17
19
  * Path prefix that marks an object as **public**. Files stored under this
@@ -72,6 +74,290 @@ function normalizeEmail(email) {
72
74
  return typeof email === "string" ? email.trim().toLowerCase() : email;
73
75
  }
74
76
  //#endregion
77
+ //#region src/api/rest/query-parser.ts
78
+ /**
79
+ * A malformed query parameter, refused with a 400.
80
+ *
81
+ * Every rejection in this file is one of these, and every one of them is
82
+ * `expected` — the flag `errorHandler` reads to log a routine outcome at debug
83
+ * instead of warn. A client that mistypes an operator, a sort direction or a
84
+ * limit is not an incident: nothing on the server is wrong, the request never
85
+ * reached the database, and the caller has already been told what to fix in the
86
+ * response body. Left at warn, a single frontend holding a stale field name
87
+ * writes a `⚠️` line per request forever, and the warn level stops meaning
88
+ * anything — which is why "routine 4xx logs at WARN" is a standing finding
89
+ * against this API.
90
+ *
91
+ * Not a factory on `ApiError`: the class's members are part of the tracked
92
+ * runtime surface (`api-surface/server.api.txt`), and this needs no addition to
93
+ * it. `ApiError.unauthenticated` is the same idea one status code up.
94
+ */
95
+ function invalidParam(message, code, details) {
96
+ return new ApiError(400, code, message, details, true);
97
+ }
98
+ /**
99
+ * Decode a filter, turning the shared codec's operator rejection into a 400.
100
+ *
101
+ * `deserializeFilter` lives in `@rebasepro/common`, which cannot throw an
102
+ * `ApiError` — it does not depend on this package, and the browser SDK decodes
103
+ * through the same function and has nothing to render one with. So it throws
104
+ * `UnknownFilterOperatorError`, and the HTTP boundary is where that becomes a
105
+ * status code. Same seam `parseLogicalGroup` uses for the nesting bound.
106
+ *
107
+ * Without this the operator string became a *value*: `?where={"title":
108
+ * ["!!","Hello"]}` compiled to `title IN ('!!','Hello')` and answered 200 with
109
+ * the row the caller was filtering out, and `{"id":[">>",0]}` reached Postgres
110
+ * and came back a 500 quoting `invalid input syntax for type integer`. Both are
111
+ * malformed requests and now say so.
112
+ */
113
+ function decodeFilter(query) {
114
+ try {
115
+ return deserializeFilter(query);
116
+ } catch (e) {
117
+ if (e instanceof UnknownFilterOperatorError) throw invalidParam(e.message, e.code, e.details);
118
+ throw e;
119
+ }
120
+ }
121
+ function getLastValue(val) {
122
+ if (Array.isArray(val)) return val[val.length - 1];
123
+ return val;
124
+ }
125
+ /**
126
+ * Parse an `or(...)` / `and(...)` logical group from its wire form.
127
+ *
128
+ * The wire carries the inner conditions wrapped in parens (e.g.
129
+ * `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
130
+ * delegate to the canonical filter dialect (`@rebasepro/common`). Values are
131
+ * preserved as strings — type coercion is the schema-aware driver's job, so
132
+ * this path stays byte-for-byte consistent with the SDK/admin path (which
133
+ * also parses via the shared dialect).
134
+ */
135
+ function parseLogicalGroup(type, raw) {
136
+ let inner = String(raw).trim();
137
+ if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
138
+ inner = inner.trim();
139
+ if (!inner) return void 0;
140
+ let parsed;
141
+ try {
142
+ parsed = deserializeLogicalCondition(`${type}(${inner})`);
143
+ } catch (e) {
144
+ throw invalidParam(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
145
+ }
146
+ return "type" in parsed ? parsed : void 0;
147
+ }
148
+ /**
149
+ * Parse the `?where=` JSON filter object.
150
+ *
151
+ * This is the dialect the OpenAPI document publishes on every
152
+ * `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
153
+ * `[WhereFilterOp, value]` tuple. It is normalized through the same
154
+ * `deserializeFilter` as the `?field=op.value` params below, so a value that
155
+ * arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
156
+ * scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
157
+ * querystring dialect, JSON carries types — a number stays a number.
158
+ *
159
+ * A malformed value is a 400 rather than a silent drop: dropping the filter
160
+ * would run the read unfiltered and return everything RLS happens to allow.
161
+ */
162
+ function parseWhereParam(raw) {
163
+ const str = String(raw).trim();
164
+ if (!str) return void 0;
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(str);
168
+ } catch {
169
+ throw invalidParam("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
170
+ }
171
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw invalidParam("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
172
+ const filter = decodeFilter(parsed);
173
+ return Object.keys(filter).length > 0 ? filter : void 0;
174
+ }
175
+ function invalidOrderBy(detail) {
176
+ throw invalidParam(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
177
+ }
178
+ /** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
179
+ function toDirection(raw, context) {
180
+ if (raw === void 0 || raw === null) return "asc";
181
+ if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
182
+ const lowered = raw.toLowerCase();
183
+ if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
184
+ return lowered;
185
+ }
186
+ /** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
187
+ function toOrderByEntry(raw, index) {
188
+ const context = `entry ${index}`;
189
+ if (typeof raw === "string") {
190
+ const idx = raw.indexOf(":");
191
+ const field = (idx === -1 ? raw : raw.slice(0, idx)).trim();
192
+ if (!field) invalidOrderBy(`${context} is an empty field name`);
193
+ return {
194
+ field,
195
+ direction: idx === -1 ? "asc" : toDirection(raw.slice(idx + 1), context)
196
+ };
197
+ }
198
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
199
+ const entry = raw;
200
+ if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
201
+ return {
202
+ field: entry.field,
203
+ direction: toDirection(entry.direction, context)
204
+ };
205
+ }
206
+ /**
207
+ * Parse the `orderBy` query parameter.
208
+ *
209
+ * The field *name* has been validated against the schema for a while — an
210
+ * `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
211
+ * that silently dropping the sort leaves the caller believing in an order that
212
+ * is not there. The parameter's *shape* was never checked the same way, and it
213
+ * failed in exactly the same silent manner one layer earlier: whatever
214
+ * `JSON.parse` returned was assigned to an option declared as an array of
215
+ * `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
216
+ * `?orderBy={"field":"name"}` — an object rather than an array, and the most
217
+ * natural thing for a client to try — read `undefined`, dropped the ORDER BY,
218
+ * and answered 200. So did a number, a boolean, `null`, and `["name"]`.
219
+ *
220
+ * This refuses those, the way `parseWhereParam` above already refuses a
221
+ * malformed filter and for the same reason. What it keeps working is every
222
+ * shape that worked before: the `field` and `field:desc` shorthands, and the
223
+ * canonical JSON array.
224
+ */
225
+ function parseOrderByParam(raw) {
226
+ if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
227
+ const str = String(raw).trim();
228
+ if (!str) return void 0;
229
+ let parsed;
230
+ try {
231
+ parsed = JSON.parse(str);
232
+ } catch {
233
+ return [toOrderByEntry(str, 0)];
234
+ }
235
+ if (Array.isArray(parsed)) {
236
+ if (parsed.length === 0) return void 0;
237
+ return parsed.map(toOrderByEntry);
238
+ }
239
+ if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
240
+ if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
241
+ invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
242
+ }
243
+ /**
244
+ * {@link resolveClientListLimit} for an HTTP route: the same bounds, answered
245
+ * with a 400 rather than a 500.
246
+ *
247
+ * The shared resolver throws a `ListLimitError`, which carries `status` — but
248
+ * the Hono error handler discriminates on `statusCode`, so an unconverted one
249
+ * reaches the client as `INTERNAL_ERROR` with its message stripped, telling the
250
+ * caller nothing about the parameter it got wrong. Every REST list ingress
251
+ * routes its `limit` through here so all of them name the ceiling the same way.
252
+ */
253
+ function resolveListLimitParam(rawLimit, opts = {}) {
254
+ try {
255
+ return resolveClientListLimit(rawLimit, opts);
256
+ } catch (e) {
257
+ if (e instanceof ListLimitError) throw invalidParam(e.message, "INVALID_LIMIT");
258
+ throw e;
259
+ }
260
+ }
261
+ /**
262
+ * Parse query parameters into QueryOptions
263
+ */
264
+ function parseQueryOptions(query, limits = {}) {
265
+ const options = {};
266
+ const rawLimit = getLastValue(query.limit);
267
+ const offsetVal = getLastValue(query.offset);
268
+ if (offsetVal) options.offset = parseInt(String(offsetVal));
269
+ const pageVal = getLastValue(query.page);
270
+ if (pageVal) {
271
+ const page = parseInt(String(pageVal));
272
+ const limit = resolveListLimitParam(rawLimit, {
273
+ defaultLimit: limits.defaultLimit,
274
+ maxLimit: limits.maxLimit
275
+ });
276
+ options.offset = (page - 1) * limit;
277
+ }
278
+ const orVal = getLastValue(query.or);
279
+ const andVal = getLastValue(query.and);
280
+ if (orVal) {
281
+ const logical = parseLogicalGroup("or", orVal);
282
+ if (logical) options.logical = logical;
283
+ } else if (andVal) {
284
+ const logical = parseLogicalGroup("and", andVal);
285
+ if (logical) options.logical = logical;
286
+ }
287
+ const reservedQueryKeys = [
288
+ "limit",
289
+ "offset",
290
+ "page",
291
+ "orderBy",
292
+ "include",
293
+ "fields",
294
+ "searchString",
295
+ "searchExplain",
296
+ "vector_search",
297
+ "vector",
298
+ "vector_distance",
299
+ "vector_threshold",
300
+ "or",
301
+ "and",
302
+ "where"
303
+ ];
304
+ const filterDict = {};
305
+ for (const [key, rawValue] of Object.entries(query)) {
306
+ if (reservedQueryKeys.includes(key)) continue;
307
+ filterDict[key] = rawValue;
308
+ }
309
+ const whereVal = getLastValue(query.where);
310
+ const where = {
311
+ ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
312
+ ...decodeFilter(filterDict)
313
+ };
314
+ if (Object.keys(where).length > 0) options.where = where;
315
+ const orderByVal = getLastValue(query.orderBy);
316
+ if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
317
+ const includeVal = getLastValue(query.include);
318
+ if (includeVal) {
319
+ const includeStr = String(includeVal).trim();
320
+ if (includeStr === "*") options.include = ["*"];
321
+ else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
322
+ }
323
+ const fieldsVal = getLastValue(query.fields);
324
+ if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
325
+ const vectorSearchVal = getLastValue(query.vector_search);
326
+ const vectorVal = getLastValue(query.vector);
327
+ if (vectorSearchVal && vectorVal) {
328
+ const vectorStr = String(vectorVal);
329
+ let decoded;
330
+ try {
331
+ decoded = JSON.parse(vectorStr);
332
+ } catch {
333
+ decoded = void 0;
334
+ }
335
+ if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw invalidParam("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
336
+ const queryVector = decoded;
337
+ const distanceParamVal = getLastValue(query.vector_distance);
338
+ const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
339
+ if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw invalidParam(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
340
+ const vectorSearch = {
341
+ property: String(vectorSearchVal),
342
+ vector: queryVector,
343
+ distance: distanceParam
344
+ };
345
+ const thresholdVal = getLastValue(query.vector_threshold);
346
+ if (thresholdVal) {
347
+ const threshold = parseFloat(String(thresholdVal));
348
+ if (isNaN(threshold)) throw invalidParam("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
349
+ vectorSearch.threshold = threshold;
350
+ }
351
+ options.vectorSearch = vectorSearch;
352
+ }
353
+ options.limit = resolveListLimitParam(rawLimit, {
354
+ vectorSearch: !!options.vectorSearch,
355
+ defaultLimit: limits.defaultLimit,
356
+ maxLimit: limits.maxLimit
357
+ });
358
+ return options;
359
+ }
360
+ //#endregion
75
361
  //#region src/auth/api-keys/api-key-permission-guard.ts
76
362
  /**
77
363
  * Map an HTTP method string to an `ApiKeyOperation`.
@@ -146,9 +432,116 @@ function isFunctionAllowed(permissions, functionName, operation) {
146
432
  return false;
147
433
  }
148
434
  //#endregion
435
+ //#region src/auth/token-revocation.ts
436
+ /**
437
+ * Has this access token been revoked?
438
+ *
439
+ * `logout`, `change-password`, `reset-password` and `DELETE /auth/sessions` all
440
+ * stamp a `tokensValidAfter` watermark on the user. Every one of them also
441
+ * deletes refresh-token rows, which is what made the gap easy to miss: the
442
+ * session really is gone, and the *refresh* path really does check the
443
+ * watermark — so signing out looked like it worked.
444
+ *
445
+ * The access token was untouched. It is a bearer credential that nothing
446
+ * consulted a database about, so it stayed valid for its full lifetime after
447
+ * every one of those actions. "Sign out everywhere" left the stolen token
448
+ * working; so did changing your password because you thought it had leaked.
449
+ *
450
+ * The watermark had exactly one read in the repository, on refresh. This adds
451
+ * the other one.
452
+ *
453
+ * ## Cost
454
+ *
455
+ * One indexed lookup per request, on the paths that already make one. The
456
+ * adapter re-reads roles for every authenticated request; the admin routes now
457
+ * do too. Both call this with the row they were already fetching in mind — a
458
+ * repository that wants to serve both from one query is free to cache
459
+ * internally, but nothing here assumes it.
460
+ *
461
+ * ## Failure
462
+ *
463
+ * Fails **open**, deliberately, and this is the one place in the auth stack
464
+ * where that is right. The watermark is an extra revocation signal layered over
465
+ * a token that has already been verified — signature, expiry and purpose all
466
+ * checked. Refusing every request when the database is unreachable would turn a
467
+ * transient outage into a total sign-out of an entire deployment, and the
468
+ * attacker this protects against needs to have already stolen a live token. The
469
+ * failure is logged at warn so it is visible rather than silent.
470
+ */
471
+ async function isAccessTokenRevoked(authRepo, payload) {
472
+ if (typeof authRepo.getTokensValidAfter !== "function") return false;
473
+ if (typeof payload.iat !== "number") return false;
474
+ let validAfter;
475
+ try {
476
+ validAfter = await authRepo.getTokensValidAfter(payload.uid);
477
+ } catch (error) {
478
+ logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
479
+ uid: payload.uid,
480
+ error
481
+ });
482
+ return false;
483
+ }
484
+ if (!validAfter) return false;
485
+ return payload.iat < Math.floor(validAfter.getTime() / 1e3);
486
+ }
487
+ //#endregion
488
+ //#region src/auth/admin-roles.ts
489
+ /**
490
+ * Which role names carry administrative privilege.
491
+ *
492
+ * One definition, because there used to be several and they disagreed.
493
+ * `requireAdmin` accepted `admin` **or** `schema-admin`; the guard that refuses
494
+ * a dangerous `defaultRole` compared against `admin` alone. So
495
+ * `AUTH_DEFAULT_ROLE=schema-admin` passed the guard and made every public
496
+ * registrant an administrator — and, since a `schema-admin` may edit users, one
497
+ * of them could then grant themselves real `admin` and keep it.
498
+ *
499
+ * The guard and the check have to read the same list or the gap comes back, so
500
+ * neither of them owns it.
501
+ *
502
+ * `schema-admin` is here deliberately: it can reach the schema editor and the
503
+ * SQL surfaces, which is administrative by any definition that matters. If a
504
+ * genuinely lesser role is added later, it does not belong in this list — it
505
+ * belongs in a capability check of its own.
506
+ */
507
+ var ADMINISTRATIVE_ROLES = ["admin", "schema-admin"];
508
+ /** Does this single role name carry administrative privilege? */
509
+ function isAdministrativeRole(role) {
510
+ return ADMINISTRATIVE_ROLES.includes(role);
511
+ }
512
+ /** Does any role in this list carry administrative privilege? */
513
+ function hasAdministrativeRole(roles) {
514
+ return !!roles?.some(isAdministrativeRole);
515
+ }
516
+ //#endregion
149
517
  //#region src/auth/rls-scope.ts
150
518
  var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
151
519
  /**
520
+ * The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
521
+ * caller presenting the service key.
522
+ *
523
+ * Naming it makes the privilege legible, because it is narrower than it reads.
524
+ * This is **not** an RLS bypass — a driver scoped with it is an
525
+ * `AuthenticatedPostgresBackendDriver`, so every statement runs in a
526
+ * transaction that has done `SET LOCAL ROLE rebase_user` with
527
+ * `app.uid = 'service'`, and policies are evaluated. Two consequences worth
528
+ * knowing before you write a policy:
529
+ *
530
+ * - It passes the default policies through their `rolesOverlap(['admin'])`
531
+ * arm — the same arm an application user holding the `admin` role passes.
532
+ * - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
533
+ * **false** for it. A collection with `disableDefaultPolicies: true` whose
534
+ * only rule is `serverContext()` denies these writes (42501) and returns
535
+ * zero rows for these reads.
536
+ *
537
+ * The true bypass is `rebase.sql()`, which runs on the owner connection and
538
+ * never goes near `withAuth`.
539
+ */
540
+ var SERVICE_IDENTITY = {
541
+ uid: "service",
542
+ roles: ["admin"]
543
+ };
544
+ /**
152
545
  * Returns true if the driver supports RLS scoping via `withAuth()`.
153
546
  */
154
547
  function isRLSScopedDriver(driver) {
@@ -439,6 +832,137 @@ function createApiKeyPreAuth(options) {
439
832
  };
440
833
  }
441
834
  //#endregion
835
+ //#region src/storage/keys.ts
836
+ /**
837
+ * Canonical storage keys and bucket names.
838
+ *
839
+ * Storage is not under RLS, so a `storageAuthorize` hook is the whole access
840
+ * control model — and a hook can only be correct if the key it is shown is the
841
+ * key that is written. That is the invariant this module exists to hold: one
842
+ * canonical string, computed once per request, handed to the hook, to the
843
+ * controller, and to the download token alike.
844
+ *
845
+ * ## Why rejecting beats stripping
846
+ *
847
+ * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
848
+ * things were wrong with that, and only one of them was the obvious one.
849
+ *
850
+ * The obvious one: a single pass is not a fixed point. `....//` contains `../`
851
+ * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
852
+ * the traversal it was there to remove. `users/alice/....//bob/x` came out as
853
+ * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
854
+ * `users/alice/`) and the filesystem reads as bob's. The hook approved one
855
+ * object and the controller wrote another.
856
+ *
857
+ * The subtler one, and the reason this is a rewrite rather than a loop: even a
858
+ * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
859
+ * gets an object at `a/b` was not protected, they were misled — and every later
860
+ * read, ownership row and audit line now refers to a path nobody chose. So a key
861
+ * that means something other than what it says is refused (400), not repaired.
862
+ *
863
+ * Note what is NOT traversal under this rule: `....` is an ordinary directory
864
+ * name, and `users/alice/....//bob/x` canonicalizes to
865
+ * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
866
+ * exactly right. Only a real `..` segment is refused.
867
+ */
868
+ /** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
869
+ var MAX_STORAGE_KEY_LENGTH = 1024;
870
+ /**
871
+ * A key that cannot be canonicalized. Carries no path back to the caller
872
+ * beyond what they sent, so it is safe to surface as a 400 message.
873
+ */
874
+ var InvalidStorageKeyError = class extends Error {
875
+ constructor(message) {
876
+ super(message);
877
+ this.name = "InvalidStorageKeyError";
878
+ }
879
+ };
880
+ /**
881
+ * Canonicalize a caller-supplied storage key, or throw
882
+ * {@link InvalidStorageKeyError}.
883
+ *
884
+ * Normalizations applied (safe, idempotent, and meaning-preserving):
885
+ * - leading slashes removed — `/a/b` and `a/b` name the same object
886
+ * - `.` segments and repeated slashes collapsed
887
+ *
888
+ * Refusals (the key means something other than what it says):
889
+ * - any `..` segment, on either separator, at any depth
890
+ * - null bytes
891
+ * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
892
+ *
893
+ * A trailing slash is preserved: it is how the folder route marks a prefix.
894
+ */
895
+ function canonicalStorageKey(rawKey) {
896
+ if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
897
+ if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
898
+ if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
899
+ const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
900
+ if (withoutLeadingSlashes === "") return "";
901
+ const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
902
+ const normalized = path.posix.normalize(withoutLeadingSlashes);
903
+ if (normalized === "." || normalized === "./") return "";
904
+ const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
905
+ if (key === "") return "";
906
+ return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
907
+ }
908
+ /**
909
+ * Canonicalize, or return `null` when the key is not canonicalizable.
910
+ *
911
+ * For callers that must fail closed without an exception — the download-token
912
+ * middleware compares a request path against a granted path, and a key it
913
+ * cannot canonicalize simply matches nothing.
914
+ */
915
+ function tryCanonicalStorageKey(rawKey) {
916
+ try {
917
+ return canonicalStorageKey(rawKey);
918
+ } catch {
919
+ return null;
920
+ }
921
+ }
922
+ /** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
923
+ var InvalidStorageBucketError = class extends Error {
924
+ constructor(message) {
925
+ super(message);
926
+ this.name = "InvalidStorageBucketError";
927
+ }
928
+ };
929
+ /**
930
+ * One path segment: letters, digits, `.`, `_`, `-`, first character
931
+ * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
932
+ * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
933
+ * absolute paths and anything containing a separator unrepresentable.
934
+ */
935
+ var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
936
+ /**
937
+ * Canonicalize a caller-supplied bucket name, or throw
938
+ * {@link InvalidStorageBucketError}.
939
+ *
940
+ * The bucket is the *other* caller-controlled routing value in an upload
941
+ * request, and it was the one nobody validated. `LocalStorageController`
942
+ * builds `join(basePath, bucket)` and then checks containment against that
943
+ * result, so a bucket of `../../etc` moved the boundary rather than crossing
944
+ * it — the guard passed because the guard's reference point was the attacker's.
945
+ * The containment check now resolves against the storage root, and this is the
946
+ * check at the route boundary that stops the value before it gets there.
947
+ *
948
+ * A bucket is configuration, not user data: there is no legitimate caller that
949
+ * needs a separator, a leading dot, or a `..` in one. So this refuses rather
950
+ * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
951
+ * bucket would silently store the object somewhere the caller did not ask for.
952
+ *
953
+ * Returns `undefined` when the caller named no bucket (absent, or an empty
954
+ * form field), which is how every controller spells "use my default". An empty
955
+ * string used to reach `getFullPath` and resolve to the storage *root* rather
956
+ * than the `default` bucket, which is a third place a bare key did not
957
+ * round-trip.
958
+ */
959
+ function canonicalStorageBucket(rawBucket) {
960
+ if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
961
+ if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
962
+ 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.");
963
+ return rawBucket;
964
+ }
965
+ //#endregion
442
966
  //#region src/auth/middleware.ts
443
967
  /**
444
968
  * Hono middleware that requires a valid JWT token via Authorization header.
@@ -478,6 +1002,8 @@ var requireAuth = async (c, next) => {
478
1002
  function createRequireAuth(options) {
479
1003
  if (!options?.serviceKey) return requireAuth;
480
1004
  const key = options.serviceKey;
1005
+ const resolveRoles = options.resolveRoles;
1006
+ const revocationRepo = options.revocationRepo;
481
1007
  return async (c, next) => {
482
1008
  if (c.get("user")) return next();
483
1009
  const token = extractBearerToken(c.req.header("authorization"));
@@ -497,6 +1023,26 @@ function createRequireAuth(options) {
497
1023
  message: "Invalid or expired token",
498
1024
  code: "UNAUTHORIZED"
499
1025
  } }, 401);
1026
+ if (resolveRoles) try {
1027
+ if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return c.json({ error: {
1028
+ message: "Session has been revoked",
1029
+ code: "SESSION_REVOKED"
1030
+ } }, 401);
1031
+ c.set("user", {
1032
+ ...payload,
1033
+ roles: await resolveRoles(payload.uid)
1034
+ });
1035
+ return next();
1036
+ } catch (error) {
1037
+ logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
1038
+ uid: payload.uid,
1039
+ error
1040
+ });
1041
+ return c.json({ error: {
1042
+ message: "Could not verify your permissions. Please try again.",
1043
+ code: "ROLE_LOOKUP_FAILED"
1044
+ } }, 503);
1045
+ }
500
1046
  c.set("user", payload);
501
1047
  return next();
502
1048
  };
@@ -511,9 +1057,7 @@ var requireAdmin = async (c, next) => {
511
1057
  message: "User not authenticated. requireAuth middleware is missing?",
512
1058
  code: "UNAUTHORIZED"
513
1059
  } }, 401);
514
- if (!(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : []).some((role) => {
515
- return role === "admin" || role === "schema-admin";
516
- })) return c.json({ error: {
1060
+ if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return c.json({ error: {
517
1061
  message: "Admin privileges required for this operation",
518
1062
  code: "FORBIDDEN"
519
1063
  } }, 403);
@@ -761,15 +1305,25 @@ var fileTokenAuth = async (c, next) => {
761
1305
  if (idx < 0) return "";
762
1306
  return fullPath.substring(idx + prefix.length + 1);
763
1307
  };
1308
+ /**
1309
+ * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
1310
+ * path a token GRANTS is minted there and the path a request ASKS FOR is
1311
+ * derived here. `/metadata` canonicalizes before signing, so a raw
1312
+ * comparison here would 403 an otherwise-valid request for any key whose
1313
+ * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
1314
+ *
1315
+ * A key that cannot be canonicalized yields null, which matches no grant —
1316
+ * the same fail-closed answer `isPathMatch` gives a `..` segment.
1317
+ */
764
1318
  const parseBucketPath = (filePath) => {
765
1319
  const parts = filePath.split("/");
766
1320
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
767
1321
  bucket: "default",
768
- resolvedPath: parts.slice(1).join("/")
1322
+ resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
769
1323
  };
770
1324
  return {
771
1325
  bucket: "default",
772
- resolvedPath: filePath
1326
+ resolvedPath: tryCanonicalStorageKey(filePath)
773
1327
  };
774
1328
  };
775
1329
  const bearerToken = extractBearerToken(authHeader);
@@ -779,7 +1333,8 @@ var fileTokenAuth = async (c, next) => {
779
1333
  const rawPath = extractWildcard(c);
780
1334
  if (rawPath) {
781
1335
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
782
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
1336
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
1337
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
783
1338
  c.set("user", {
784
1339
  uid: "download-token",
785
1340
  roles: ["reader"]
@@ -803,7 +1358,8 @@ var fileTokenAuth = async (c, next) => {
803
1358
  const rawPath = extractWildcard(c);
804
1359
  if (rawPath) {
805
1360
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
806
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
1361
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
1362
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
807
1363
  c.set("user", {
808
1364
  uid: "download-token",
809
1365
  roles: ["reader"]
@@ -831,8 +1387,9 @@ function createAdapterAuthMiddleware(options) {
831
1387
  const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
832
1388
  return async (c, next) => {
833
1389
  const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1390
+ const presentedToken = extractBearerToken(c.req.header("authorization"));
834
1391
  if (apiKeyStore) {
835
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
1392
+ const token = presentedToken ?? "";
836
1393
  if (token.startsWith("rk_")) {
837
1394
  const result = await validateApiKey(c, token, {
838
1395
  store: apiKeyStore,
@@ -869,17 +1426,23 @@ function createAdapterAuthMiddleware(options) {
869
1426
  code: "INTERNAL_ERROR"
870
1427
  } }, 500);
871
1428
  }
872
- } else try {
873
- c.set("driver", await scopeDataDriver(driver, {
874
- uid: ANONYMOUS_USER_ID,
875
- roles: ["anon"]
876
- }));
877
- } catch (error) {
878
- logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
879
- return c.json({ error: {
880
- message: "Server configuration error",
881
- code: "INTERNAL_ERROR"
882
- } }, 500);
1429
+ } else {
1430
+ if (presentedToken !== void 0) return c.json({ error: {
1431
+ message: "Invalid or expired token",
1432
+ code: "UNAUTHORIZED"
1433
+ } }, 401);
1434
+ try {
1435
+ c.set("driver", await scopeDataDriver(driver, {
1436
+ uid: ANONYMOUS_USER_ID,
1437
+ roles: ["anon"]
1438
+ }));
1439
+ } catch (error) {
1440
+ logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
1441
+ return c.json({ error: {
1442
+ message: "Server configuration error",
1443
+ code: "INTERNAL_ERROR"
1444
+ } }, 500);
1445
+ }
883
1446
  }
884
1447
  if (enforceAuth && !c.get("user")) return c.json({ error: {
885
1448
  message: "Unauthorized: Authentication required",
@@ -889,43 +1452,141 @@ function createAdapterAuthMiddleware(options) {
889
1452
  };
890
1453
  }
891
1454
  //#endregion
1455
+ //#region src/email/html.ts
1456
+ /**
1457
+ * HTML escaping for email bodies.
1458
+ *
1459
+ * Every default template interpolates values the server does not control — a
1460
+ * `displayName` chosen at registration, an `appName` from config, a link base a
1461
+ * host set — into markup that is then mailed, signed by the sending domain, to
1462
+ * an address the same request chose. Unescaped, that turns `POST /auth/register`
1463
+ * into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
1464
+ * whose SPF and DKIM check out.
1465
+ *
1466
+ * The fix is deliberately not five escaping calls at five interpolation sites:
1467
+ * a sixth template would simply forget. Templates are built with the {@link html}
1468
+ * tag, which escapes *every* interpolated value by default, and markup that is
1469
+ * meant to pass through verbatim — the static style strings, a nested fragment —
1470
+ * must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
1471
+ * in a test; forgetting to escape is invisible until someone exploits it.
1472
+ */
1473
+ /**
1474
+ * Markup that has already been vetted and must be interpolated verbatim.
1475
+ *
1476
+ * Only ever construct this from a string literal in this package's own source
1477
+ * (or from the {@link html} tag, which produces one). Wrapping user input in
1478
+ * `raw()` defeats the entire mechanism.
1479
+ */
1480
+ var RawHtml = class {
1481
+ value;
1482
+ constructor(value) {
1483
+ this.value = value;
1484
+ }
1485
+ toString() {
1486
+ return this.value;
1487
+ }
1488
+ };
1489
+ /**
1490
+ * Mark a static, author-controlled string as safe to interpolate unescaped.
1491
+ */
1492
+ function raw(markup) {
1493
+ return new RawHtml(markup);
1494
+ }
1495
+ /**
1496
+ * Escape the five characters that can change the meaning of HTML text or of a
1497
+ * quoted attribute value.
1498
+ *
1499
+ * `&` goes first: escaping it after the others would double-escape the entities
1500
+ * they just produced. `'` is included because it is a legal attribute delimiter,
1501
+ * and `"` because it is the one this file uses.
1502
+ */
1503
+ function escapeHtml(value) {
1504
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1505
+ }
1506
+ /**
1507
+ * Render one interpolated value: pass {@link RawHtml} through, drop nullish,
1508
+ * escape everything else.
1509
+ */
1510
+ function interpolate(value) {
1511
+ if (value instanceof RawHtml) return value.value;
1512
+ if (value === null || value === void 0) return "";
1513
+ return escapeHtml(String(value));
1514
+ }
1515
+ /**
1516
+ * Tagged template for email markup. Every `${...}` is escaped unless it is
1517
+ * {@link RawHtml}.
1518
+ *
1519
+ * Returns `RawHtml` so fragments nest without being escaped a second time:
1520
+ *
1521
+ * ```ts
1522
+ * const button = url ? html`<a href="${url}">Open</a>` : raw("");
1523
+ * const body = html`<div>${button}</div>`;
1524
+ * ```
1525
+ */
1526
+ function html(strings, ...values) {
1527
+ let out = strings[0];
1528
+ for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
1529
+ return new RawHtml(out);
1530
+ }
1531
+ //#endregion
892
1532
  //#region src/email/templates.ts
893
1533
  /**
894
- * Get a greeting name for the user
1534
+ * Default email templates for authentication emails.
1535
+ *
1536
+ * Every HTML body is built with the `html` tag from `./html`, which escapes each
1537
+ * interpolated value unless it is explicitly marked `raw`. `displayName` reaches
1538
+ * these templates straight from the registration body, and the resulting mail is
1539
+ * sent — signed by the sending domain — to an address the same anonymous request
1540
+ * chose, so an unescaped interpolation here is a phishing primitive, not a
1541
+ * rendering glitch. Escaping lives in the tag rather than at the call sites so a
1542
+ * sixth template inherits it instead of having to remember it.
1543
+ */
1544
+ /**
1545
+ * Get a greeting name for the user.
1546
+ *
1547
+ * Returns the raw value — `displayName` is whatever the account was registered
1548
+ * with, including markup. Escaping is the `html` tag's job, so that a template
1549
+ * that interpolates the greeting into text (the plain-text bodies below) and one
1550
+ * that interpolates it into markup can share this.
895
1551
  */
896
1552
  function getGreeting(user) {
897
1553
  return user.displayName || user.email.split("@")[0];
898
1554
  }
899
1555
  /**
900
- * Common email styles
1556
+ * Common email styles.
1557
+ *
1558
+ * `raw` because these are static, author-written CSS — the one category of
1559
+ * interpolation the `html` tag lets through unescaped. (Escaping them would
1560
+ * survive an HTML parser but not every mail client's CSS parser: the
1561
+ * `'Segoe UI'` quotes would arrive as entities.)
901
1562
  */
902
1563
  var styles = {
903
- container: `
1564
+ container: raw(`
904
1565
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
905
1566
  max-width: 600px;
906
1567
  margin: 0 auto;
907
1568
  padding: 40px 20px;
908
1569
  background-color: #f8fafc;
909
- `,
910
- card: `
1570
+ `),
1571
+ card: raw(`
911
1572
  background-color: #ffffff;
912
1573
  border-radius: 12px;
913
1574
  padding: 40px;
914
1575
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
915
- `,
916
- heading: `
1576
+ `),
1577
+ heading: raw(`
917
1578
  color: #1e293b;
918
1579
  font-size: 24px;
919
1580
  font-weight: 600;
920
1581
  margin: 0 0 20px 0;
921
- `,
922
- paragraph: `
1582
+ `),
1583
+ paragraph: raw(`
923
1584
  color: #475569;
924
1585
  font-size: 16px;
925
1586
  line-height: 1.6;
926
1587
  margin: 0 0 20px 0;
927
- `,
928
- button: `
1588
+ `),
1589
+ button: raw(`
929
1590
  display: inline-block;
930
1591
  background-color: #3b82f6;
931
1592
  color: #ffffff;
@@ -935,22 +1596,22 @@ var styles = {
935
1596
  padding: 14px 28px;
936
1597
  border-radius: 8px;
937
1598
  margin: 20px 0;
938
- `,
939
- footer: `
1599
+ `),
1600
+ footer: raw(`
940
1601
  color: #94a3b8;
941
1602
  font-size: 14px;
942
1603
  margin-top: 30px;
943
1604
  padding-top: 20px;
944
1605
  border-top: 1px solid #e2e8f0;
945
- `,
946
- warning: `
1606
+ `),
1607
+ warning: raw(`
947
1608
  color: #64748b;
948
1609
  font-size: 14px;
949
1610
  background-color: #fef3c7;
950
1611
  padding: 12px 16px;
951
1612
  border-radius: 6px;
952
1613
  margin-top: 20px;
953
- `
1614
+ `)
954
1615
  };
955
1616
  /**
956
1617
  * Default password reset email template
@@ -958,9 +1619,7 @@ var styles = {
958
1619
  function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
959
1620
  const greeting = getGreeting(user);
960
1621
  const subject = `Reset your ${appName} password`;
961
- return {
962
- subject,
963
- html: `
1622
+ const body = html`
964
1623
  <!DOCTYPE html>
965
1624
  <html>
966
1625
  <head>
@@ -1007,8 +1666,8 @@ function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
1007
1666
  </div>
1008
1667
  </body>
1009
1668
  </html>
1010
- `.trim(),
1011
- text: `
1669
+ `;
1670
+ const text = `
1012
1671
  Reset Your Password
1013
1672
 
1014
1673
  Hi ${greeting},
@@ -1022,7 +1681,11 @@ This link will expire in 1 hour for security reasons.
1022
1681
 
1023
1682
  If you didn't request a password reset, you can safely ignore this email.
1024
1683
  Your password will remain unchanged.
1025
- `.trim()
1684
+ `.trim();
1685
+ return {
1686
+ subject,
1687
+ html: body.toString().trim(),
1688
+ text
1026
1689
  };
1027
1690
  }
1028
1691
  /**
@@ -1031,9 +1694,7 @@ Your password will remain unchanged.
1031
1694
  function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1032
1695
  const greeting = getGreeting(user);
1033
1696
  const subject = `Verify your ${appName} email address`;
1034
- return {
1035
- subject,
1036
- html: `
1697
+ const body = html`
1037
1698
  <!DOCTYPE html>
1038
1699
  <html>
1039
1700
  <head>
@@ -1075,8 +1736,8 @@ function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1075
1736
  </div>
1076
1737
  </body>
1077
1738
  </html>
1078
- `.trim(),
1079
- text: `
1739
+ `;
1740
+ const text = `
1080
1741
  Verify Your Email
1081
1742
 
1082
1743
  Hi ${greeting},
@@ -1085,7 +1746,11 @@ Thanks for signing up for ${appName}! Please verify your email address by clicki
1085
1746
  ${verifyUrl}
1086
1747
 
1087
1748
  If you didn't create an account with ${appName}, you can safely ignore this email.
1088
- `.trim()
1749
+ `.trim();
1750
+ return {
1751
+ subject,
1752
+ html: body.toString().trim(),
1753
+ text
1089
1754
  };
1090
1755
  }
1091
1756
  /**
@@ -1095,9 +1760,7 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
1095
1760
  function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1096
1761
  const greeting = getGreeting(user);
1097
1762
  const subject = `You've been invited to ${appName}`;
1098
- return {
1099
- subject,
1100
- html: `
1763
+ const body = html`
1101
1764
  <!DOCTYPE html>
1102
1765
  <html>
1103
1766
  <head>
@@ -1143,8 +1806,8 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1143
1806
  </div>
1144
1807
  </body>
1145
1808
  </html>
1146
- `.trim(),
1147
- text: `
1809
+ `;
1810
+ const text = `
1148
1811
  Welcome to ${appName}!
1149
1812
 
1150
1813
  Hi ${greeting},
@@ -1157,7 +1820,11 @@ ${setPasswordUrl}
1157
1820
  This link will expire in 1 hour for security reasons.
1158
1821
 
1159
1822
  If you weren't expecting this invitation, you can safely ignore this email.
1160
- `.trim()
1823
+ `.trim();
1824
+ return {
1825
+ subject,
1826
+ html: body.toString().trim(),
1827
+ text
1161
1828
  };
1162
1829
  }
1163
1830
  /**
@@ -1168,9 +1835,7 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1168
1835
  const greeting = getGreeting(user);
1169
1836
  const url = loginUrl || "";
1170
1837
  const subject = `¡Bienvenido/a a ${appName}!`;
1171
- return {
1172
- subject,
1173
- html: `
1838
+ const body = html`
1174
1839
  <!DOCTYPE html>
1175
1840
  <html>
1176
1841
  <head>
@@ -1197,11 +1862,11 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1197
1862
  que tenemos para ti.
1198
1863
  </p>
1199
1864
 
1200
- ${url ? `
1865
+ ${url ? html`
1201
1866
  <div style="text-align: center;">
1202
1867
  <a href="${url}" style="${styles.button}">Ir a mi Panel</a>
1203
1868
  </div>
1204
- ` : ""}
1869
+ ` : raw("")}
1205
1870
 
1206
1871
  <p style="${styles.paragraph}">
1207
1872
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
@@ -1216,8 +1881,8 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1216
1881
  </div>
1217
1882
  </body>
1218
1883
  </html>
1219
- `.trim(),
1220
- text: `
1884
+ `;
1885
+ const text = `
1221
1886
  ¡Bienvenido/a a ${appName}!
1222
1887
 
1223
1888
  Hola ${greeting},
@@ -1229,7 +1894,11 @@ Ya puedes acceder a tu panel y empezar a explorar todas las oportunidades que te
1229
1894
  ${url ? `Ir a mi panel: ${url}` : ""}
1230
1895
 
1231
1896
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1232
- `.trim()
1897
+ `.trim();
1898
+ return {
1899
+ subject,
1900
+ html: body.toString().trim(),
1901
+ text
1233
1902
  };
1234
1903
  }
1235
1904
  /**
@@ -1238,9 +1907,7 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1238
1907
  function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1239
1908
  const greeting = getGreeting(user);
1240
1909
  const subject = `Sign in to ${appName}`;
1241
- return {
1242
- subject,
1243
- html: `
1910
+ const body = html`
1244
1911
  <!DOCTYPE html>
1245
1912
  <html>
1246
1913
  <head>
@@ -1287,8 +1954,8 @@ function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1287
1954
  </div>
1288
1955
  </body>
1289
1956
  </html>
1290
- `.trim(),
1291
- text: `
1957
+ `;
1958
+ const text = `
1292
1959
  Sign In to ${appName}
1293
1960
 
1294
1961
  Hi ${greeting},
@@ -1302,7 +1969,11 @@ This link will expire in 15 minutes for security reasons and can only be used on
1302
1969
 
1303
1970
  If you didn't request this sign-in link, you can safely ignore this email.
1304
1971
  No action is needed.
1305
- `.trim()
1972
+ `.trim();
1973
+ return {
1974
+ subject,
1975
+ html: body.toString().trim(),
1976
+ text
1306
1977
  };
1307
1978
  }
1308
1979
  //#endregion
@@ -1437,11 +2108,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1437
2108
  await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
1438
2109
  const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
1439
2110
  const appName = ctx.emailConfig?.appName || "Rebase";
1440
- const templateFn = ctx.emailConfig?.templates?.passwordReset;
2111
+ const templateFn = ctx.emailConfig?.templates?.userInvitation;
1441
2112
  const emailContent = templateFn ? templateFn(setPasswordUrl, {
1442
2113
  email: entity.values.email,
1443
2114
  displayName: entity.values.displayName
1444
- }) : getPasswordResetTemplate(setPasswordUrl, {
2115
+ }) : getUserInvitationTemplate(setPasswordUrl, {
1445
2116
  email: entity.values.email,
1446
2117
  displayName: entity.values.displayName
1447
2118
  }, appName);
@@ -1569,6 +2240,196 @@ function resolveAuthHooks(hooks) {
1569
2240
  };
1570
2241
  }
1571
2242
  //#endregion
2243
+ //#region src/email/link-base.ts
2244
+ /**
2245
+ * The config fields consulted for each link kind, most specific first.
2246
+ *
2247
+ * `resetPasswordUrl` is the general fallback because it is the only one any boot
2248
+ * path sets, and in every real deployment all three point at the same frontend.
2249
+ */
2250
+ var LINK_BASE_FIELDS = {
2251
+ resetPassword: ["resetPasswordUrl"],
2252
+ verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
2253
+ magicLink: ["magicLinkUrl", "resetPasswordUrl"]
2254
+ };
2255
+ /**
2256
+ * True when `value` is an absolute `http(s)` URL — the only kind a mail client
2257
+ * can follow. A protocol-relative or path-only base is treated as missing.
2258
+ */
2259
+ function isAbsoluteHttpUrl(value) {
2260
+ try {
2261
+ const url = new URL(value);
2262
+ return url.protocol === "http:" || url.protocol === "https:";
2263
+ } catch {
2264
+ return false;
2265
+ }
2266
+ }
2267
+ /** Drop a trailing slash so callers can append `/verify-email?token=…`. */
2268
+ function normalizeBase(value) {
2269
+ return value.replace(/\/+$/, "");
2270
+ }
2271
+ /**
2272
+ * The absolute base URL for a link kind, or `""` when the config has none.
2273
+ *
2274
+ * Callers append their path to the result. `""` is only reachable on a config
2275
+ * that never went through {@link assertEmailLinkBases} — a hand-constructed
2276
+ * `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
2277
+ * misconfiguration cannot turn a password-reset request into a 500 that
2278
+ * distinguishes existing accounts from missing ones.
2279
+ */
2280
+ function resolveEmailLinkBase(config, kind) {
2281
+ if (!config) return "";
2282
+ for (const field of LINK_BASE_FIELDS[kind]) {
2283
+ const value = config[field];
2284
+ if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
2285
+ }
2286
+ return "";
2287
+ }
2288
+ /**
2289
+ * Throw when an email configuration cannot produce a followable link.
2290
+ *
2291
+ * Called from `createEmailService`, i.e. from every boot path that wires email
2292
+ * up (the managed runtime, both driver bootstrappers, and any app that passes
2293
+ * `auth.email`). A base that is set but relative is reported separately from one
2294
+ * that is missing, because the two have different fixes.
2295
+ */
2296
+ function assertEmailLinkBases(config) {
2297
+ const relative = [
2298
+ "resetPasswordUrl",
2299
+ "verifyEmailUrl",
2300
+ "magicLinkUrl"
2301
+ ].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
2302
+ 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.`);
2303
+ 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\".");
2304
+ }
2305
+ //#endregion
2306
+ //#region src/email/smtp-email-service.ts
2307
+ var _nodemailer;
2308
+ async function loadNodemailer() {
2309
+ if (!_nodemailer) try {
2310
+ _nodemailer = await import("nodemailer");
2311
+ } catch {
2312
+ throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
2313
+ }
2314
+ return _nodemailer;
2315
+ }
2316
+ /**
2317
+ * Safely parse a hostname from a URL string
2318
+ */
2319
+ function getHostname(urlStr) {
2320
+ try {
2321
+ return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
2322
+ } catch {
2323
+ return;
2324
+ }
2325
+ }
2326
+ /**
2327
+ * SMTP Email Service implementation using Nodemailer
2328
+ */
2329
+ var SMTPEmailService = class {
2330
+ transporter = null;
2331
+ config;
2332
+ _initialized = false;
2333
+ constructor(config) {
2334
+ this.config = config;
2335
+ }
2336
+ /**
2337
+ * Lazily initialize the SMTP transporter on first use
2338
+ */
2339
+ async ensureTransporter() {
2340
+ if (this._initialized) return;
2341
+ this._initialized = true;
2342
+ if (this.config.smtp) {
2343
+ const nodemailer = await loadNodemailer();
2344
+ let smtpName = this.config.smtp.name;
2345
+ if (!smtpName) {
2346
+ const urlsToTry = [
2347
+ process.env.FRONTEND_URL,
2348
+ this.config.resetPasswordUrl,
2349
+ this.config.verifyEmailUrl
2350
+ ];
2351
+ for (const urlStr of urlsToTry) if (urlStr) {
2352
+ const hostname = getHostname(urlStr);
2353
+ if (hostname) {
2354
+ smtpName = hostname;
2355
+ break;
2356
+ }
2357
+ }
2358
+ }
2359
+ this.transporter = nodemailer.createTransport({
2360
+ name: smtpName,
2361
+ host: this.config.smtp.host,
2362
+ port: this.config.smtp.port,
2363
+ secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
2364
+ auth: this.config.smtp.auth ? {
2365
+ user: this.config.smtp.auth.user,
2366
+ pass: this.config.smtp.auth.pass
2367
+ } : void 0
2368
+ });
2369
+ }
2370
+ }
2371
+ /**
2372
+ * Check if the email service is properly configured
2373
+ */
2374
+ isConfigured() {
2375
+ return !!(this.config.smtp || this.config.sendEmail);
2376
+ }
2377
+ /**
2378
+ * Send an email using SMTP or custom send function
2379
+ */
2380
+ async send(options) {
2381
+ if (this.config.sendEmail) {
2382
+ await this.config.sendEmail(options);
2383
+ return;
2384
+ }
2385
+ await this.ensureTransporter();
2386
+ if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
2387
+ const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
2388
+ try {
2389
+ await this.transporter.sendMail({
2390
+ from: this.config.from,
2391
+ to,
2392
+ subject: options.subject,
2393
+ html: options.html,
2394
+ text: options.text,
2395
+ replyTo: options.replyTo
2396
+ });
2397
+ } catch (error) {
2398
+ const message = error instanceof Error ? error.message : String(error);
2399
+ logger.error("Failed to send email", { detail: message });
2400
+ throw new Error(`Failed to send email: ${message}`);
2401
+ }
2402
+ }
2403
+ /**
2404
+ * Verify SMTP connection (useful for startup checks)
2405
+ */
2406
+ async verifyConnection() {
2407
+ await this.ensureTransporter();
2408
+ if (!this.transporter) return !!this.config.sendEmail;
2409
+ try {
2410
+ await this.transporter.verify();
2411
+ return true;
2412
+ } catch (error) {
2413
+ const message = error instanceof Error ? error.message : String(error);
2414
+ logger.error("SMTP connection verification failed", { detail: message });
2415
+ return false;
2416
+ }
2417
+ }
2418
+ };
2419
+ /**
2420
+ * Create an email service from configuration.
2421
+ *
2422
+ * This is the choke point every boot path goes through — the managed runtime,
2423
+ * both driver bootstrappers, and any app that passes `auth.email` — so it is
2424
+ * where the link bases are checked. A config that can send mail but cannot build
2425
+ * an absolute link fails the start rather than delivering dead `href="/…"` links
2426
+ * and reporting success; see `assertEmailLinkBases`.
2427
+ */
2428
+ function createEmailService(config) {
2429
+ assertEmailLinkBases(config);
2430
+ return new SMTPEmailService(config);
2431
+ }
2432
+ //#endregion
1572
2433
  //#region src/auth/rate-limit-store.ts
1573
2434
  /**
1574
2435
  * The default store: a sliding window in this process's memory.
@@ -1634,13 +2495,32 @@ var MemoryRateLimitStore = class {
1634
2495
  //#region src/auth/rate-limiter.ts
1635
2496
  /**
1636
2497
  * Resolve the number of trusted proxy hops from an explicit option, the
1637
- * `TRUSTED_PROXY_HOPS` env var, or the default of 1.
2498
+ * `TRUSTED_PROXY_HOPS` env var, or the fail-safe default of 0.
2499
+ *
2500
+ * Zero, not one: trusting a hop that is not there hands the rate-limit key to
2501
+ * the caller, and a server has no way to detect the difference from a request.
1638
2502
  */
1639
2503
  function resolveTrustedProxyHops(optionValue) {
1640
2504
  if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue >= 0) return Math.floor(optionValue);
1641
2505
  const fromEnv = Number(process.env.TRUSTED_PROXY_HOPS);
1642
2506
  if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.floor(fromEnv);
1643
- return 1;
2507
+ return 0;
2508
+ }
2509
+ /**
2510
+ * Warn once when the deployment looks proxied but has not said so.
2511
+ *
2512
+ * The safe default is the wrong answer for the other common topology: behind an
2513
+ * ingress, every request arrives from the proxy, so ignoring `X-Forwarded-For`
2514
+ * collapses every client into one bucket and the limiter starts locking real
2515
+ * users out of each other's quota. That is a loud failure in production and a
2516
+ * silent one in staging, so it gets a warning rather than a guess — guessing is
2517
+ * what made the header trustworthy in the first place.
2518
+ */
2519
+ var warnedAboutUntrustedForwardedFor = false;
2520
+ function warnIfProxiedButUntrusted(hasForwardedFor) {
2521
+ if (!hasForwardedFor || warnedAboutUntrustedForwardedFor) return;
2522
+ warnedAboutUntrustedForwardedFor = true;
2523
+ logger.warn("[RateLimit] Requests carry X-Forwarded-For but TRUSTED_PROXY_HOPS is 0, so it is being ignored and every client behind the proxy shares one rate-limit bucket. If this server really is behind a reverse proxy, set TRUSTED_PROXY_HOPS to the number of proxies in front of it (1 for a single ingress or load balancer). If it is exposed directly, this warning means someone is sending the header themselves and 0 is the correct setting.");
1644
2524
  }
1645
2525
  /**
1646
2526
  * Create a rate-limiting middleware.
@@ -1670,7 +2550,23 @@ function createRateLimiter(options = {}) {
1670
2550
  };
1671
2551
  }
1672
2552
  /**
1673
- * Default key generator: extract client IP from standard headers.
2553
+ * The address the socket is actually connected to, or `undefined` on a runtime
2554
+ * that cannot say. Unforgeable, unlike every header below.
2555
+ *
2556
+ * `getConnInfo` is Node-specific and throws elsewhere, so the failure is
2557
+ * swallowed: a runtime without connection info falls back to the shared bucket,
2558
+ * which is a availability trade the caller cannot exploit.
2559
+ */
2560
+ function socketAddress(c) {
2561
+ try {
2562
+ return getConnInfo(c).remote.address;
2563
+ } catch {
2564
+ return;
2565
+ }
2566
+ }
2567
+ /**
2568
+ * Default key generator: the client's address, from the most trustworthy source
2569
+ * this deployment has.
1674
2570
  *
1675
2571
  * `X-Forwarded-For` is a client-writable header; only the entries appended by
1676
2572
  * trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
@@ -1679,18 +2575,36 @@ function createRateLimiter(options = {}) {
1679
2575
  * client-supplied and must be ignored. This is what prevents a caller from
1680
2576
  * spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
1681
2577
  *
1682
- * With `trustedProxyHops === 0` (server exposed directly, no proxy),
1683
- * `X-Forwarded-For` is not trusted at all and `X-Real-IP` is used instead.
2578
+ * `X-Real-IP` is the *same* kind of header and needs the same rule, which it
2579
+ * did not have: it was read unconditionally, including under
2580
+ * `trustedProxyHops === 0` — the mode whose entire meaning is "no proxy is in
2581
+ * front of me". With no proxy there, nothing writes `X-Real-IP` except the
2582
+ * caller, so the key was theirs to choose: one header per request bought an
2583
+ * unlimited number of buckets, and the limiters on login, registration and
2584
+ * password reset counted to one. The reasoning had been done carefully for one
2585
+ * spelling of a proxy header and not carried to its twin.
2586
+ *
2587
+ * So `X-Real-IP` is now believed only where a proxy is declared to exist. With
2588
+ * none, the connection's own address is used — unforgeable, and available
2589
+ * because the server runs on `@hono/node-server`. `"unknown"` is the last
2590
+ * resort only, and it is a single shared bucket by design: better that
2591
+ * anonymous callers throttle each other than that any of them throttles nobody.
1684
2592
  */
1685
2593
  function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1686
- if (trustedProxyHops > 0) {
2594
+ if (trustedProxyHops === 0) {
2595
+ warnIfProxiedButUntrusted(!!c.req.header("x-forwarded-for"));
2596
+ return socketAddress(c) ?? "unknown";
2597
+ }
2598
+ {
1687
2599
  const forwardedFor = c.req.header("x-forwarded-for");
1688
2600
  if (forwardedFor) {
1689
2601
  const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
1690
2602
  if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
1691
2603
  }
2604
+ const realIp = c.req.header("x-real-ip");
2605
+ if (realIp) return realIp;
1692
2606
  }
1693
- return c.req.header("x-real-ip") || "unknown";
2607
+ return socketAddress(c) ?? "unknown";
1694
2608
  }
1695
2609
  /**
1696
2610
  * Pre-configured rate limiter for general auth endpoints (login, register).
@@ -1711,6 +2625,34 @@ var strictAuthLimiter = createRateLimiter({
1711
2625
  message: "Too many requests to this sensitive endpoint, please try again later."
1712
2626
  });
1713
2627
  /**
2628
+ * Limiter for `POST /auth/send-verification`, keyed by the authenticated user.
2629
+ *
2630
+ * That route had no limiter at all while every one of its email-sending
2631
+ * siblings had one, and being authenticated is not the protection it looks
2632
+ * like: registration does not verify the address it is given, so an attacker
2633
+ * registers a victim's address, signs in to the account they just made, and
2634
+ * loops the route. Each call mints a token and mails the victim.
2635
+ *
2636
+ * An IP limiter cannot express what is wanted here — the recipient is the
2637
+ * quantity being protected, not the caller — so this one keys on the uid, which
2638
+ * on this route is one-to-one with the recipient address (emails are unique per
2639
+ * account). `strictAuthLimiter` still runs in front of it to bound the caller by
2640
+ * IP; this bounds what any single address can be sent.
2641
+ *
2642
+ * Five per 15 minutes is generous for "I didn't get the email, resend it" and
2643
+ * useless as a mail bomb. Unauthenticated requests fall back to the IP bucket so
2644
+ * the limiter is never a no-op if it is ever mounted before the auth middleware.
2645
+ */
2646
+ var verificationEmailLimiter = createRateLimiter({
2647
+ windowMs: 900 * 1e3,
2648
+ limit: 5,
2649
+ keyGenerator: (c) => {
2650
+ const user = c.get("user");
2651
+ return user?.uid ? `verification-email:user:${user.uid}` : `verification-email:ip:${defaultKeyGenerator(c)}`;
2652
+ },
2653
+ message: "Too many verification emails requested, please try again later."
2654
+ });
2655
+ /**
1714
2656
  * Key generator for API-key-based rate limiting.
1715
2657
  *
1716
2658
  * Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
@@ -1722,6 +2664,8 @@ function apiKeyKeyGenerator(c) {
1722
2664
  if (apiKey) return `api-key:${apiKey.id}`;
1723
2665
  return defaultKeyGenerator(c);
1724
2666
  }
2667
+ /** @see DataRateLimitConfig.anonymousFunctions */
2668
+ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
1725
2669
  /**
1726
2670
  * Rate limiting for the data API.
1727
2671
  *
@@ -1741,6 +2685,7 @@ function apiKeyKeyGenerator(c) {
1741
2685
  */
1742
2686
  function createDataRateLimiter(config = {}) {
1743
2687
  const { windowMs = 900 * 1e3, apiKey: apiKeyLimit = 1e3, user: userLimit = 1e3, anonymous: anonLimit = 300, store = new MemoryRateLimitStore(windowMs) } = config;
2688
+ const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
1744
2689
  return createRateLimiter({
1745
2690
  windowMs,
1746
2691
  store,
@@ -1750,7 +2695,7 @@ function createDataRateLimiter(config = {}) {
1750
2695
  if (key) return `api-key:${key.id}`;
1751
2696
  const user = c.get("user");
1752
2697
  if (user?.uid && !isAnonymousUid(user.uid)) return `user:${user.uid}`;
1753
- return `ip:${defaultKeyGenerator(c)}`;
2698
+ return `ip:${defaultKeyGenerator(c, trustedProxyHops)}`;
1754
2699
  },
1755
2700
  resolveLimit: (c) => {
1756
2701
  const key = c.get("apiKey");
@@ -5767,17 +6712,27 @@ function generateHotp(secret, counter) {
5767
6712
  return (((hash[offset] & 127) << 24 | hash[offset + 1] << 16 | hash[offset + 2] << 8 | hash[offset + 3]) % 1e6).toString().padStart(6, "0");
5768
6713
  }
5769
6714
  /**
5770
- * Verify a TOTP token with a configurable time window
6715
+ * Verify a TOTP token and report *which* time step matched.
6716
+ *
6717
+ * RFC 6238 §5.2 requires that an accepted OTP not be accepted a second time,
6718
+ * and a boolean cannot express what was accepted: with `window = 1` the same
6719
+ * six digits stay valid for up to 90 seconds, so a code observed once — by a
6720
+ * phishing relay, over a shoulder, in a pasted support message — can be
6721
+ * presented again for the rest of that window. The caller records the returned
6722
+ * counter against the factor and refuses anything at or below it.
5771
6723
  *
5772
6724
  * @param secret - The shared secret as a Buffer
5773
6725
  * @param token - The 6-digit TOTP code to verify
5774
6726
  * @param window - Number of time steps to check on each side (default: 1)
5775
- * @returns true if the token is valid within the window
6727
+ * @returns The matched time step, or `null` when no step in the window matches
5776
6728
  */
5777
- function verifyTotp(secret, token, window = 1) {
6729
+ function verifyTotpCounter(secret, token, window = 1) {
5778
6730
  const counter = BigInt(Math.floor(Date.now() / 1e3 / 30));
5779
- for (let i = -window; i <= window; i++) if (generateHotp(secret, counter + BigInt(i)) === token) return true;
5780
- return false;
6731
+ for (let i = -window; i <= window; i++) {
6732
+ const step = counter + BigInt(i);
6733
+ if (generateHotp(secret, step) === token) return Number(step);
6734
+ }
6735
+ return null;
5781
6736
  }
5782
6737
  /**
5783
6738
  * Generate a new TOTP secret and return the setup information
@@ -5820,79 +6775,335 @@ function hashRecoveryCode(code) {
5820
6775
  *
5821
6776
  * - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
5822
6777
  * - Generates a random 12-byte IV per encryption call.
5823
- * - Returns ciphertexts in the format `iv_hex:authTag_hex:ciphertext_hex`.
6778
+ * - Stamps the key that made the ciphertext, so the key can be changed without
6779
+ * locking every enrolled user out — see "Rotating the key" below.
6780
+ *
6781
+ * ## Rotating the key
6782
+ *
6783
+ * A ciphertext used to be `iv:authTag:ciphertext` and recorded nothing about
6784
+ * which key produced it, while the key was re-derived from the environment on
6785
+ * every call. Setting `MFA_ENCRYPTION_KEY` on a deployment that had been
6786
+ * falling back to `JWT_SECRET` therefore made every stored factor undecryptable
6787
+ * — AES-GCM authenticates, so it did not return a wrong secret, it threw, and
6788
+ * every MFA sign-in failed until the variable was removed again.
6789
+ *
6790
+ * Now a ciphertext carries `v1.<keyId>`, where `keyId` is derived from the key
6791
+ * itself (no key names to keep in sync), and decryption runs against a list of
6792
+ * candidates: `MFA_ENCRYPTION_KEY`, then each comma-separated entry in
6793
+ * `MFA_ENCRYPTION_KEY_PREVIOUS`, then `JWT_SECRET`. To rotate:
6794
+ *
6795
+ * 1. **Deploy this code first.** It reads both formats. Doing it the other way
6796
+ * round — setting the key before the readers understand the old ciphertexts
6797
+ * — is the outage this exists to prevent.
6798
+ * 2. Move the old value into `MFA_ENCRYPTION_KEY_PREVIOUS` and set the new one
6799
+ * as `MFA_ENCRYPTION_KEY`.
6800
+ * 3. Each factor is re-wrapped with the current key the next time its owner
6801
+ * authenticates (`openTotpSecret` reports it via `rewrapped`).
6802
+ * 4. Once every enrolled user has signed in, drop `MFA_ENCRYPTION_KEY_PREVIOUS`.
6803
+ * Any factor still on the old key then fails loudly, naming the key id,
6804
+ * rather than being silently unreadable.
6805
+ *
6806
+ * Trial decryption across candidates is safe here: GCM's auth tag is a 128-bit
6807
+ * MAC, so a wrong key throws instead of yielding a plausible-looking secret.
5824
6808
  *
5825
6809
  * @module
5826
6810
  */
5827
6811
  var ALGORITHM = "aes-256-gcm";
5828
6812
  var IV_BYTES = 12;
5829
6813
  var AUTH_TAG_LENGTH = 16;
6814
+ /** Ciphertext prefix marking the key-stamped format. */
6815
+ var VERSION = "v1";
6816
+ /** Warn once, not once per sign-in. */
6817
+ var warnedAboutFallback = false;
6818
+ function toCandidate(raw, source) {
6819
+ const digest = createHash$1("sha256").update(raw).digest();
6820
+ return {
6821
+ id: digest.toString("hex").slice(0, 8),
6822
+ key: digest,
6823
+ source
6824
+ };
6825
+ }
5830
6826
  /**
5831
- * Resolve the raw key string from environment variables.
6827
+ * Every key this process can decrypt with, most-preferred first.
5832
6828
  *
5833
- * Prefers `MFA_ENCRYPTION_KEY`; falls back to `JWT_SECRET` with a warning.
5834
- * Throws if neither is set.
6829
+ * The head of the list is the key new ciphertexts are written with; the rest
6830
+ * exist so that ciphertexts written before a rotation still open.
5835
6831
  */
5836
- function resolveKeyString() {
5837
- const explicit = process.env.MFA_ENCRYPTION_KEY;
5838
- if (explicit) return explicit;
5839
- const fallback = process.env.JWT_SECRET;
5840
- if (fallback) {
5841
- logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production.");
5842
- return fallback;
6832
+ function keyCandidates() {
6833
+ const candidates = [];
6834
+ const seen = /* @__PURE__ */ new Set();
6835
+ const add = (raw, source) => {
6836
+ const trimmed = raw?.trim();
6837
+ if (!trimmed || seen.has(trimmed)) return;
6838
+ seen.add(trimmed);
6839
+ candidates.push(toCandidate(trimmed, source));
6840
+ };
6841
+ add(process.env.MFA_ENCRYPTION_KEY, "MFA_ENCRYPTION_KEY");
6842
+ for (const previous of (process.env.MFA_ENCRYPTION_KEY_PREVIOUS ?? "").split(",")) add(previous, "MFA_ENCRYPTION_KEY_PREVIOUS");
6843
+ add(process.env.JWT_SECRET, "JWT_SECRET");
6844
+ if (candidates.length === 0) throw new Error("Cannot encrypt or decrypt TOTP secrets: none of MFA_ENCRYPTION_KEY, MFA_ENCRYPTION_KEY_PREVIOUS or JWT_SECRET is configured.");
6845
+ if (!process.env.MFA_ENCRYPTION_KEY && !warnedAboutFallback) {
6846
+ warnedAboutFallback = true;
6847
+ logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production; see the rotation steps in mfa-crypto.ts before you do it on a live deployment.");
5843
6848
  }
5844
- throw new Error("Cannot encrypt TOTP secret: neither MFA_ENCRYPTION_KEY nor JWT_SECRET is configured.");
6849
+ return candidates;
5845
6850
  }
5846
- /**
5847
- * Derive a deterministic 32-byte AES-256 key from the raw secret string.
5848
- */
5849
- function deriveKey(raw) {
5850
- return createHash$1("sha256").update(raw).digest();
6851
+ /** The key new ciphertexts are written with. */
6852
+ function currentKey() {
6853
+ return keyCandidates()[0];
5851
6854
  }
5852
6855
  /**
5853
6856
  * Encrypt a plaintext TOTP secret.
5854
6857
  *
5855
6858
  * @param plaintext - The Base32 TOTP secret to encrypt.
5856
- * @returns A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
6859
+ * @returns A string in the format `v1.<keyId>:iv_hex:authTag_hex:ciphertext_hex`.
5857
6860
  */
5858
6861
  function encryptTotpSecret(plaintext) {
5859
- const key = deriveKey(resolveKeyString());
6862
+ const { id, key } = currentKey();
5860
6863
  const iv = randomBytes$1(IV_BYTES);
5861
6864
  const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5862
6865
  const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
5863
6866
  const authTag = cipher.getAuthTag();
5864
- return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
6867
+ return `${VERSION}.${id}:${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
6868
+ }
6869
+ function openWith(candidate, ivHex, authTagHex, encryptedHex) {
6870
+ const decipher = createDecipheriv(ALGORITHM, candidate.key, Buffer.from(ivHex, "hex"), { authTagLength: AUTH_TAG_LENGTH });
6871
+ decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
6872
+ return Buffer.concat([decipher.update(Buffer.from(encryptedHex, "hex")), decipher.final()]).toString("utf8");
5865
6873
  }
5866
6874
  /**
5867
- * Decrypt a previously encrypted TOTP secret.
6875
+ * Decrypt a stored TOTP secret, reporting whether it wants re-wrapping.
5868
6876
  *
5869
- * @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
5870
- * @returns The original Base32 TOTP secret.
6877
+ * Accepts both the key-stamped format and the original unstamped
6878
+ * `iv:authTag:ciphertext`, which is tried against each candidate in turn.
5871
6879
  */
5872
- function decryptTotpSecret(ciphertext) {
6880
+ function openTotpSecret(ciphertext) {
5873
6881
  const parts = ciphertext.split(":");
5874
- if (parts.length !== 3) throw new Error("Invalid encrypted TOTP secret format — expected iv:authTag:ciphertext");
6882
+ const candidates = keyCandidates();
6883
+ const current = candidates[0];
6884
+ if (parts.length === 4) {
6885
+ const [stamp, ivHex, authTagHex, encryptedHex] = parts;
6886
+ const [version, keyId] = stamp.split(".");
6887
+ if (version !== VERSION || !keyId) throw new Error(`Unsupported encrypted TOTP secret version "${stamp}"`);
6888
+ const named = candidates.find((c) => c.id === keyId);
6889
+ if (!named) throw new Error(`No configured key matches this TOTP secret (key id "${keyId}"). Restore the key that encrypted it via MFA_ENCRYPTION_KEY_PREVIOUS. Configured key ids: ${candidates.map((c) => `${c.id} (${c.source})`).join(", ")}.`);
6890
+ const secret = openWith(named, ivHex, authTagHex, encryptedHex);
6891
+ return {
6892
+ secret,
6893
+ rewrapped: named.id === current.id ? null : encryptTotpSecret(secret)
6894
+ };
6895
+ }
6896
+ if (parts.length !== 3) throw new Error("Invalid encrypted TOTP secret format — expected v1.<keyId>:iv:authTag:ciphertext or the legacy iv:authTag:ciphertext");
5875
6897
  const [ivHex, authTagHex, encryptedHex] = parts;
5876
- const key = deriveKey(resolveKeyString());
5877
- const iv = Buffer.from(ivHex, "hex");
5878
- const authTag = Buffer.from(authTagHex, "hex");
5879
- const encrypted = Buffer.from(encryptedHex, "hex");
5880
- const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5881
- decipher.setAuthTag(authTag);
5882
- return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
6898
+ for (const candidate of candidates) try {
6899
+ const secret = openWith(candidate, ivHex, authTagHex, encryptedHex);
6900
+ return {
6901
+ secret,
6902
+ rewrapped: encryptTotpSecret(secret)
6903
+ };
6904
+ } catch {}
6905
+ throw new Error(`Could not decrypt TOTP secret with any configured key. Tried: ${candidates.map((c) => c.source).join(", ")}.`);
6906
+ }
6907
+ //#endregion
6908
+ //#region src/auth/cookie-utils.ts
6909
+ /**
6910
+ * Cookie auth helpers for httpOnly refresh tokens.
6911
+ */
6912
+ function getCookieSettings(config) {
6913
+ return {
6914
+ name: config?.cookieName || "__rb_refresh",
6915
+ path: config?.path || "/",
6916
+ sameSite: config?.sameSite || "Lax",
6917
+ domain: config?.domain,
6918
+ secure: config?.secure
6919
+ };
6920
+ }
6921
+ /**
6922
+ * Set the refresh token as an httpOnly cookie on the response.
6923
+ */
6924
+ function setRefreshCookie(c, refreshToken, config) {
6925
+ if (!config) return;
6926
+ const settings = getCookieSettings(config);
6927
+ const isSecure = settings.secure ?? true;
6928
+ let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6929
+ if (isSecure) cookie += "; Secure";
6930
+ if (settings.domain) cookie += `; Domain=${settings.domain}`;
6931
+ const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6932
+ cookie += `; Max-Age=${maxAgeSeconds}`;
6933
+ c.header("Set-Cookie", cookie, { append: true });
6934
+ }
6935
+ /**
6936
+ * Clear the refresh token cookie.
6937
+ */
6938
+ function clearRefreshCookie(c, config) {
6939
+ if (!config) return;
6940
+ const settings = getCookieSettings(config);
6941
+ let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6942
+ if (settings.secure ?? true) cookie += "; Secure";
6943
+ if (settings.domain) cookie += `; Domain=${settings.domain}`;
6944
+ c.header("Set-Cookie", cookie, { append: true });
6945
+ }
6946
+ /**
6947
+ * Read the refresh token from the request — cookie first, then body fallback.
6948
+ */
6949
+ function readRefreshToken(c, body, config) {
6950
+ if (config) {
6951
+ const settings = getCookieSettings(config);
6952
+ const cookieHeader = c.req.header("cookie") || "";
6953
+ const prefix = `${settings.name}=`;
6954
+ const cookies = cookieHeader.split(";");
6955
+ for (const part of cookies) {
6956
+ const trimmed = part.trim();
6957
+ if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6958
+ }
6959
+ }
6960
+ return body.refreshToken;
6961
+ }
6962
+ /**
6963
+ * Redact the refresh token from the response body when cookie mode is active.
6964
+ */
6965
+ function redactRefreshToken(response, c, refreshToken, config) {
6966
+ if (!config) return response;
6967
+ setRefreshCookie(c, refreshToken, config);
6968
+ return {
6969
+ ...response,
6970
+ tokens: {
6971
+ ...response.tokens,
6972
+ refreshToken: ""
6973
+ }
6974
+ };
5883
6975
  }
5884
6976
  //#endregion
5885
6977
  //#region src/auth/mfa-routes.ts
5886
- function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6978
+ /**
6979
+ * How many failed verifications one challenge tolerates before it is spent.
6980
+ *
6981
+ * A 6-digit code with a ±1 step window accepts 3 of 1,000,000 values, so the
6982
+ * only thing standing between an attacker and the second factor is how many
6983
+ * guesses a challenge will take. Five, and then they must open another one —
6984
+ * which the per-user limiter below is counting.
6985
+ */
6986
+ var MAX_CHALLENGE_ATTEMPTS = 5;
6987
+ /** Verification attempts one *account* may make per window, across all IPs. */
6988
+ var MFA_VERIFICATION_ATTEMPTS_PER_WINDOW = 10;
6989
+ /**
6990
+ * Resolve the caller of a challenge route from either a full session or a
6991
+ * pre-auth token.
6992
+ *
6993
+ * Both are parsed here rather than by `requireAuth` because the whole point of
6994
+ * the challenge pair is that it runs before the session exists — a login by an
6995
+ * MFA-enrolled user never receives an access token until it is finished.
6996
+ */
6997
+ /**
6998
+ * Open a factor's stored secret, re-storing it under the current key if it
6999
+ * opened with an older one.
7000
+ *
7001
+ * This is what makes an `MFA_ENCRYPTION_KEY` rotation complete on its own: each
7002
+ * factor moves to the new key the next time its owner authenticates, so the
7003
+ * previous key can eventually be dropped. See `mfa-crypto.ts` for the sequence.
7004
+ *
7005
+ * A failed re-wrap must never fail the verification — the code the user typed
7006
+ * was already checked against the right secret, and the only cost of not
7007
+ * persisting is that this factor is re-wrapped again next time.
7008
+ */
7009
+ async function openAndRewrap(authRepo, factorId, secretEncrypted) {
7010
+ const { secret, rewrapped } = openTotpSecret(secretEncrypted);
7011
+ if (!rewrapped) return secret;
7012
+ if (typeof authRepo.updateMfaFactorSecret !== "function") return secret;
7013
+ try {
7014
+ await authRepo.updateMfaFactorSecret(factorId, rewrapped);
7015
+ logger.info("[MFA-Crypto] Re-wrapped a TOTP secret under the current key", { factorId });
7016
+ } catch (error) {
7017
+ logger.warn("[MFA-Crypto] Could not re-wrap a TOTP secret; leaving it on its original key", {
7018
+ factorId,
7019
+ error
7020
+ });
7021
+ }
7022
+ return secret;
7023
+ }
7024
+ function resolveStepUpPrincipal(c) {
7025
+ const existing = c.get("user");
7026
+ if (existing?.uid) return {
7027
+ uid: existing.uid,
7028
+ aal: existing.aal === "aal2" ? "aal2" : "aal1",
7029
+ pending: false
7030
+ };
7031
+ const token = extractBearerToken(c.req.header("authorization"));
7032
+ if (token === void 0) return null;
7033
+ const session = verifyAccessToken(token);
7034
+ if (session) return {
7035
+ uid: session.uid,
7036
+ aal: session.aal === "aal2" ? "aal2" : "aal1",
7037
+ pending: false
7038
+ };
7039
+ const pending = verifyMfaPendingToken(token);
7040
+ if (pending) return {
7041
+ uid: pending.uid,
7042
+ aal: "aal1",
7043
+ pending: true
7044
+ };
7045
+ return null;
7046
+ }
7047
+ /**
7048
+ * Per-account throttle on code verification.
7049
+ *
7050
+ * Keyed on the uid rather than the IP because an IP is the attacker's to
7051
+ * rotate and the account under attack is not: a distributed run against one
7052
+ * account passes an IP-keyed limiter untouched. The IP limiter still runs in
7053
+ * front of this one — they bound different things.
7054
+ */
7055
+ var mfaVerificationLimiter = createRateLimiter({
7056
+ windowMs: 900 * 1e3,
7057
+ limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
7058
+ keyGenerator: (c) => `mfa-verify:${resolveStepUpPrincipal(c)?.uid ?? "unidentified"}`,
7059
+ message: "Too many verification attempts for this account, please try again later."
7060
+ });
7061
+ function mountMfaRoutes(opts) {
7062
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
5887
7063
  const authRepo = config.authRepo;
5888
7064
  const emailConfig = config.emailConfig;
5889
7065
  /**
7066
+ * Changing which factors an account trusts is itself a privileged act.
7067
+ *
7068
+ * An account that already has a verified factor may only gain or confirm
7069
+ * another one from a session that presented the existing one. Without this
7070
+ * the `aal2` gate on unenroll was self-service: an `aal1` session enrolled
7071
+ * a factor of its own, verified it with a code it computed from the secret
7072
+ * the enrol response had just handed it, stepped up on that, and deleted
7073
+ * the victim's real factor — arriving at `aal2` without ever holding the
7074
+ * second factor the account was protected by.
7075
+ *
7076
+ * The first enrolment on an account with no verified factor is deliberately
7077
+ * allowed at `aal1`: there is no second factor to demand yet.
7078
+ */
7079
+ async function requireStepUpForFactorChange(userCtx) {
7080
+ if (userCtx.aal === "aal2") return;
7081
+ if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) return;
7082
+ throw ApiError.forbidden("This account already has a verified second factor. Re-authenticate with it before changing enrolled factors.", "AAL2_REQUIRED");
7083
+ }
7084
+ /**
7085
+ * Spend a TOTP time step, or refuse it as already spent.
7086
+ *
7087
+ * RFC 6238 §5.2: an accepted OTP must not be accepted again. The window
7088
+ * that exists for clock drift is also a 90-second replay window, and a
7089
+ * replay here is not momentary — `challenge/verify` mints a refresh token,
7090
+ * so one observed code buys a durable session.
7091
+ */
7092
+ async function claimTotpStep(factorId, counter) {
7093
+ if (typeof authRepo.claimMfaFactorCounter !== "function") {
7094
+ logger.warn("[MFA] Auth repository cannot record spent TOTP steps; an accepted code stays replayable for its window", { factorId });
7095
+ return true;
7096
+ }
7097
+ return authRepo.claimMfaFactorCounter(factorId, counter);
7098
+ }
7099
+ /**
5890
7100
  * POST /auth/mfa/enroll
5891
7101
  * Start MFA enrollment: generate TOTP secret and recovery codes
5892
7102
  */
5893
- router.post("/mfa/enroll", requireAuth, async (c) => {
7103
+ router.post("/mfa/enroll", strictAuthLimiter, requireAuth, async (c) => {
5894
7104
  const userCtx = c.get("user");
5895
7105
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
7106
+ await requireStepUpForFactorChange(userCtx);
5896
7107
  const body = await c.req.json().catch(() => ({}));
5897
7108
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
5898
7109
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
@@ -5922,17 +7133,20 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5922
7133
  * POST /auth/mfa/verify
5923
7134
  * Verify TOTP code to complete MFA enrollment
5924
7135
  */
5925
- router.post("/mfa/verify", requireAuth, async (c) => {
7136
+ router.post("/mfa/verify", strictAuthLimiter, requireAuth, mfaVerificationLimiter, async (c) => {
5926
7137
  const userCtx = c.get("user");
5927
7138
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5928
- const { factorId, code } = parseBody(object({
7139
+ await requireStepUpForFactorChange(userCtx);
7140
+ const verifySchema = object({
5929
7141
  factorId: string().min(1, "Factor ID is required"),
5930
7142
  code: string().length(6, "Code must be 6 digits")
5931
- }), await c.req.json());
7143
+ });
7144
+ const { factorId, code } = parseBody(verifySchema, await c.req.json());
5932
7145
  const factor = await authRepo.getMfaFactorById(factorId);
5933
7146
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
5934
7147
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
5935
- if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
7148
+ const matchedCounter = verifyTotpCounter(base32Decode(await openAndRewrap(authRepo, factor.id, factor.secretEncrypted)), code);
7149
+ if (matchedCounter === null || !await claimTotpStep(factor.id, matchedCounter)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
5936
7150
  await authRepo.verifyMfaFactor(factorId);
5937
7151
  return c.json({
5938
7152
  success: true,
@@ -5941,14 +7155,16 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5941
7155
  });
5942
7156
  /**
5943
7157
  * POST /auth/mfa/challenge
5944
- * Create an MFA challenge during login (user has MFA enrolled)
7158
+ * Open a challenge — either to finish a sign-in that answered
7159
+ * `MFA_REQUIRED`, or to step an existing session up to `aal2`.
5945
7160
  */
5946
- router.post("/mfa/challenge", requireAuth, async (c) => {
5947
- const userCtx = c.get("user");
5948
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5949
- const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
7161
+ router.post("/mfa/challenge", strictAuthLimiter, async (c) => {
7162
+ const principal = resolveStepUpPrincipal(c);
7163
+ if (!principal) throw ApiError.unauthorized("Not authenticated");
7164
+ const challengeSchema = object({ factorId: string().min(1, "Factor ID is required") });
7165
+ const { factorId } = parseBody(challengeSchema, await c.req.json());
5950
7166
  const factor = await authRepo.getMfaFactorById(factorId);
5951
- if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
7167
+ if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
5952
7168
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
5953
7169
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
5954
7170
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -5960,49 +7176,73 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5960
7176
  });
5961
7177
  /**
5962
7178
  * POST /auth/mfa/challenge/verify
5963
- * Verify a TOTP code for an active challenge, upgrade aal1 → aal2
7179
+ * Answer a challenge: this is where an MFA-enrolled account's session is
7180
+ * minted, at `aal2`.
5964
7181
  */
5965
- router.post("/mfa/challenge/verify", requireAuth, async (c) => {
5966
- const userCtx = c.get("user");
5967
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5968
- const { challengeId, code } = parseBody(object({
7182
+ router.post("/mfa/challenge/verify", strictAuthLimiter, mfaVerificationLimiter, async (c) => {
7183
+ const principal = resolveStepUpPrincipal(c);
7184
+ if (!principal) throw ApiError.unauthorized("Not authenticated");
7185
+ const challengeVerifySchema = object({
5969
7186
  challengeId: string().min(1, "Challenge ID is required"),
5970
7187
  code: string().min(1, "Code is required")
5971
- }), await c.req.json());
7188
+ });
7189
+ const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
5972
7190
  const challenge = await authRepo.getMfaChallengeById(challengeId);
5973
7191
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
5974
7192
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
5975
- if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
5976
- let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
5977
- if (!isValid) {
7193
+ if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
7194
+ if ((challenge.attempts ?? 0) >= MAX_CHALLENGE_ATTEMPTS) {
7195
+ logger.warn("[Security Audit] MFA challenge attempt limit reached", {
7196
+ eventType: "auth.mfa.challenge.exhausted",
7197
+ uid: principal.uid,
7198
+ challengeId
7199
+ });
7200
+ throw ApiError.unauthorized("Too many failed attempts for this challenge", "CHALLENGE_EXHAUSTED");
7201
+ }
7202
+ const matchedCounter = verifyTotpCounter(base32Decode(await openAndRewrap(authRepo, factor.id, factor.secretEncrypted)), code);
7203
+ let isValid = matchedCounter !== null && await claimTotpStep(factor.id, matchedCounter);
7204
+ if (!isValid && matchedCounter === null) {
5978
7205
  const codeHash = hashRecoveryCode(code);
5979
- isValid = await authRepo.useRecoveryCode(userCtx.uid, codeHash);
7206
+ isValid = await authRepo.useRecoveryCode(principal.uid, codeHash);
7207
+ }
7208
+ if (!isValid) {
7209
+ const attempts = typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
7210
+ logger.warn("[Security Audit] MFA verification failed", {
7211
+ eventType: "auth.mfa.verify.failure",
7212
+ uid: principal.uid,
7213
+ challengeId,
7214
+ attempts
7215
+ });
7216
+ throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
5980
7217
  }
5981
- if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
5982
7218
  await authRepo.verifyMfaChallenge(challengeId);
5983
- const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
5984
- const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
5985
- const refreshToken = generateRefreshToken();
5986
- await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
5987
- id: randomUUID$1(),
5988
- startedAt: /* @__PURE__ */ new Date()
7219
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
7220
+ skipMfaGate: true,
7221
+ aal: "aal2"
5989
7222
  });
5990
- if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
7223
+ if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
5991
7224
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
5992
7225
  });
5993
- let mfaResponse = { tokens: {
7226
+ logger.info("[Security Audit] MFA verification success", {
7227
+ eventType: "auth.mfa.verify.success",
7228
+ uid: principal.uid,
7229
+ factorId: factor.id,
7230
+ completedSignIn: principal.pending
7231
+ });
7232
+ const user = await authRepo.getUserById(principal.uid).catch(() => null);
7233
+ let mfaResponse = user ? buildAuthResponse(user, roleIds, accessToken, refreshToken, "mfa") : { tokens: {
5994
7234
  accessToken,
5995
7235
  refreshToken,
5996
7236
  accessTokenExpiresAt: getAccessTokenExpiry()
5997
7237
  } };
5998
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
5999
- return c.json(mfaResponse);
7238
+ if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, principal.uid);
7239
+ return c.json(redactRefreshToken(mfaResponse, c, refreshToken, config.cookieAuth));
6000
7240
  });
6001
7241
  /**
6002
7242
  * GET /auth/mfa/factors
6003
7243
  * List enrolled MFA factors for the current user
6004
7244
  */
6005
- router.get("/mfa/factors", requireAuth, async (c) => {
7245
+ router.get("/mfa/factors", strictAuthLimiter, requireAuth, async (c) => {
6006
7246
  const userCtx = c.get("user");
6007
7247
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6008
7248
  const factors = await authRepo.getMfaFactors(userCtx.uid);
@@ -6018,11 +7258,12 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6018
7258
  * DELETE /auth/mfa/unenroll
6019
7259
  * Remove an MFA factor
6020
7260
  */
6021
- router.delete("/mfa/unenroll", requireAuth, async (c) => {
7261
+ router.delete("/mfa/unenroll", strictAuthLimiter, requireAuth, async (c) => {
6022
7262
  const userCtx = c.get("user");
6023
7263
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6024
7264
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
6025
- const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
7265
+ const unenrollSchema = object({ factorId: string().min(1, "Factor ID is required") });
7266
+ const { factorId } = parseBody(unenrollSchema, await c.req.json());
6026
7267
  const factor = await authRepo.getMfaFactorById(factorId);
6027
7268
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6028
7269
  await authRepo.deleteMfaFactor(factorId, userCtx.uid);
@@ -6034,72 +7275,51 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6034
7275
  });
6035
7276
  }
6036
7277
  //#endregion
6037
- //#region src/auth/cookie-utils.ts
6038
- /**
6039
- * Cookie auth helpers for httpOnly refresh tokens.
6040
- */
6041
- function getCookieSettings(config) {
6042
- return {
6043
- name: config?.cookieName || "__rb_refresh",
6044
- path: config?.path || "/",
6045
- sameSite: config?.sameSite || "Lax",
6046
- domain: config?.domain,
6047
- secure: config?.secure
6048
- };
6049
- }
6050
- /**
6051
- * Set the refresh token as an httpOnly cookie on the response.
6052
- */
6053
- function setRefreshCookie(c, refreshToken, config) {
6054
- if (!config) return;
6055
- const settings = getCookieSettings(config);
6056
- const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
6057
- let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6058
- if (isSecure) cookie += "; Secure";
6059
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6060
- const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6061
- cookie += `; Max-Age=${maxAgeSeconds}`;
6062
- c.header("Set-Cookie", cookie, { append: true });
6063
- }
7278
+ //#region src/auth/mfa-gate.ts
6064
7279
  /**
6065
- * Clear the refresh token cookie.
7280
+ * The gate between "first factor accepted" and "session issued".
7281
+ *
7282
+ * MFA used to be modelled as an optional step-up that no resource required: a
7283
+ * user could enrol TOTP, print recovery codes, and change nothing about what a
7284
+ * stolen password bought, because every session-minting route issued a full
7285
+ * `aal1` token without ever asking whether the account had a second factor.
7286
+ * This module is the ask. It lives apart from the routes so that there is one
7287
+ * decision, made once, that `createSessionAndTokens` cannot be extended past —
7288
+ * a new sign-in route inherits the gate by construction rather than by the
7289
+ * author remembering it.
7290
+ *
7291
+ * @module
6066
7292
  */
6067
- function clearRefreshCookie(c, config) {
6068
- if (!config) return;
6069
- const settings = getCookieSettings(config);
6070
- let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6071
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6072
- c.header("Set-Cookie", cookie, { append: true });
6073
- }
7293
+ /** Error code a client watches for to switch a sign-in into its second step. */
7294
+ var MFA_REQUIRED_CODE = "MFA_REQUIRED";
6074
7295
  /**
6075
- * Read the refresh token from the request — cookie first, then body fallback.
7296
+ * Refuse to mint a session for an account whose second factor has not been
7297
+ * presented, handing back what the client needs to present it.
7298
+ *
7299
+ * A 401 rather than a 200 with a different body: a client that has not been
7300
+ * taught about MFA must fail, not proceed. The credential in `details` is
7301
+ * useless for anything except the challenge routes.
6076
7302
  */
6077
- function readRefreshToken(c, body, config) {
6078
- if (config) {
6079
- const settings = getCookieSettings(config);
6080
- const cookieHeader = c.req.header("cookie") || "";
6081
- const prefix = `${settings.name}=`;
6082
- const cookies = cookieHeader.split(";");
6083
- for (const part of cookies) {
6084
- const trimmed = part.trim();
6085
- if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6086
- }
7303
+ async function assertMfaSatisfied(authRepo, uid) {
7304
+ if (typeof authRepo.hasVerifiedMfaFactors !== "function") {
7305
+ logger.warn("[MFA] Auth repository cannot report enrolled factors; the login step-up gate is inert on this backend");
7306
+ return;
6087
7307
  }
6088
- return body.refreshToken;
6089
- }
6090
- /**
6091
- * Redact the refresh token from the response body when cookie mode is active.
6092
- */
6093
- function redactRefreshToken(response, c, refreshToken, config) {
6094
- if (!config) return response;
6095
- setRefreshCookie(c, refreshToken, config);
6096
- return {
6097
- ...response,
6098
- tokens: {
6099
- ...response.tokens,
6100
- refreshToken: ""
6101
- }
7308
+ if (!await authRepo.hasVerifiedMfaFactors(uid)) return;
7309
+ const factors = (await authRepo.getMfaFactors(uid)).filter((f) => f.verified);
7310
+ const details = {
7311
+ mfaToken: generateMfaPendingToken(uid),
7312
+ factors: factors.map((f) => ({
7313
+ id: f.id,
7314
+ factorType: f.factorType,
7315
+ friendlyName: f.friendlyName
7316
+ }))
6102
7317
  };
7318
+ logger.info("[Security Audit] MFA step-up required", {
7319
+ eventType: "auth.mfa.required",
7320
+ uid
7321
+ });
7322
+ throw new ApiError(401, MFA_REQUIRED_CODE, "Multi-factor authentication is required to complete sign-in.", details, true);
6103
7323
  }
6104
7324
  //#endregion
6105
7325
  //#region src/auth/registration-policy.ts
@@ -6121,11 +7341,58 @@ function isSteadyStateRegistrationOpen(policy) {
6121
7341
  needsSetup: false
6122
7342
  });
6123
7343
  }
7344
+ /**
7345
+ * Whether `POST /auth/anonymous` may mint a user.
7346
+ *
7347
+ * Anonymous sign-in *is* registration: it inserts a `users` row and assigns
7348
+ * `defaultRole` exactly as `POST /auth/register` does. It simply never asked.
7349
+ * Both anonymous routes were mounted unconditionally and consulted none of the
7350
+ * gates above, so a backend with `allowRegistration: false` **and**
7351
+ * `disableSelfRegistration: true` — the documented hard kill switch — still
7352
+ * handed a permanent account to any caller in two unauthenticated requests:
7353
+ * `POST /auth/anonymous` for the row and the session, then
7354
+ * `POST /auth/anonymous/link` to put an email and password on it.
7355
+ *
7356
+ * Two terms, because the two questions are genuinely different:
7357
+ *
7358
+ * - `allowAnonymous` is the feature switch, and it is **opt-in**. There was no
7359
+ * key at all before, so a deployment could not turn anonymous auth off even
7360
+ * after finding it on; defaulting it to `true` here would keep that hole
7361
+ * open for everyone who never learns the key exists.
7362
+ * - `disableSelfRegistration` still wins over it. Its docstring calls it a
7363
+ * hard kill switch that blocks "self-registration outright", and an account
7364
+ * created without credentials is still an account created by the public.
7365
+ * An operator who sets both gets the kill switch they asked for.
7366
+ *
7367
+ * `allowRegistration` deliberately does **not** gate this. A public read-mostly
7368
+ * app that wants anonymous sessions but no sign-up forms is a real deployment,
7369
+ * and `allowAnonymous: true` says so precisely.
7370
+ */
7371
+ function isAnonymousAuthOpen(policy) {
7372
+ if (policy.disableSelfRegistration) return false;
7373
+ return !!policy.allowAnonymous;
7374
+ }
6124
7375
  //#endregion
6125
7376
  //#region src/auth/session-routes.ts
6126
7377
  function mountSessionRoutes(opts) {
6127
7378
  const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
6128
7379
  const authRepo = config.authRepo;
7380
+ /**
7381
+ * Refuse the anonymous routes unless the deployment opted in.
7382
+ *
7383
+ * 403 rather than 404: the route exists, and a client that was told
7384
+ * `anonymousLogin: true` by `GET /auth/config` and then sees a 404 has no
7385
+ * way to tell a disabled feature from a version skew. The error names the
7386
+ * key, because the failure this closes was an operator believing they had
7387
+ * already switched registration off.
7388
+ */
7389
+ function assertAnonymousAuthOpen() {
7390
+ if (isAnonymousAuthOpen({
7391
+ allowAnonymous: config.allowAnonymous,
7392
+ disableSelfRegistration: config.disableSelfRegistration
7393
+ })) return;
7394
+ throw ApiError.forbidden(config.disableSelfRegistration ? "Anonymous sign-in is disabled: disableSelfRegistration blocks it." : "Anonymous sign-in is disabled. Set `auth.allowAnonymous: true` to enable it.", "ANONYMOUS_AUTH_DISABLED");
7395
+ }
6129
7396
  const logoutSchema = object({ refreshToken: string().optional() });
6130
7397
  const linkSchema = object({
6131
7398
  email: string().email("Invalid email address").max(255),
@@ -6152,7 +7419,7 @@ function mountSessionRoutes(opts) {
6152
7419
  clearRefreshCookie(c, config.cookieAuth);
6153
7420
  const accessToken = extractBearerToken(c.req.header("authorization"));
6154
7421
  if (ops.afterLogout && accessToken !== void 0) {
6155
- const { verifyAccessToken } = await import("./jwt-CzeviDcB.js").then((n) => n.d);
7422
+ const { verifyAccessToken } = await import("./jwt-_IFqfTOg.js").then((n) => n.f);
6156
7423
  const payload = verifyAccessToken(accessToken);
6157
7424
  if (payload) ops.afterLogout(payload.uid).catch((err) => {
6158
7425
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
@@ -6318,6 +7585,10 @@ function mountSessionRoutes(opts) {
6318
7585
  registrationEnabled: registrationAllowed,
6319
7586
  emailServiceEnabled: isEmailConfigured(),
6320
7587
  magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
7588
+ anonymousLoginEnabled: isAnonymousAuthOpen({
7589
+ allowAnonymous: config.allowAnonymous,
7590
+ disableSelfRegistration: config.disableSelfRegistration
7591
+ }),
6321
7592
  enabledProviders
6322
7593
  });
6323
7594
  });
@@ -6326,6 +7597,7 @@ function mountSessionRoutes(opts) {
6326
7597
  * Create an anonymous user with temporary credentials
6327
7598
  */
6328
7599
  router.post("/anonymous", strictAuthLimiter, async (c) => {
7600
+ assertAnonymousAuthOpen();
6329
7601
  let createData = {
6330
7602
  email: `anon_${randomBytes$1(16).toString("hex")}@anonymous.local`,
6331
7603
  emailVerified: false,
@@ -6349,7 +7621,8 @@ function mountSessionRoutes(opts) {
6349
7621
  * POST /auth/anonymous/link
6350
7622
  * Upgrade an anonymous user to a permanent account with email/password
6351
7623
  */
6352
- router.post("/anonymous/link", requireAuth, async (c) => {
7624
+ router.post("/anonymous/link", strictAuthLimiter, requireAuth, async (c) => {
7625
+ assertAnonymousAuthOpen();
6353
7626
  const userCtx = c.get("user");
6354
7627
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6355
7628
  const user = await authRepo.getUserById(userCtx.uid);
@@ -6406,7 +7679,7 @@ function mountMagicLinkRoutes(deps) {
6406
7679
  const tokenHash = hashToken(token);
6407
7680
  const expiresAt = getMagicLinkExpiry();
6408
7681
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
6409
- const magicLinkUrl = `${emailConfig?.magicLinkUrl || emailConfig?.resetPasswordUrl || ""}/auth/magic-link?token=${token}`;
7682
+ const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
6410
7683
  const appName = emailConfig?.appName || "Rebase";
6411
7684
  const templateFn = emailConfig?.templates?.magicLink;
6412
7685
  const emailContent = templateFn ? templateFn(magicLinkUrl, {
@@ -6458,6 +7731,64 @@ function mountMagicLinkRoutes(deps) {
6458
7731
  });
6459
7732
  }
6460
7733
  //#endregion
7734
+ //#region src/auth/oauth-signin-policy.ts
7735
+ /**
7736
+ * May an OAuth identity be attached to a pre-existing account found *by email*?
7737
+ *
7738
+ * Both sides have to be trustworthy, and only one of them used to be checked:
7739
+ *
7740
+ * - the **provider** must have verified the address, or the caller has not
7741
+ * shown they control it;
7742
+ * - the **local account** must itself be trustworthy — either its address was
7743
+ * verified, or it has no password at all (it was created by an OAuth
7744
+ * sign-in or an invitation, so there is no credential an attacker could
7745
+ * have planted in advance).
7746
+ *
7747
+ * A refusal is not a dead end: `POST /auth/link/<provider>` attaches the
7748
+ * identity once the caller proves ownership by holding a session.
7749
+ */
7750
+ function decideOAuthAutoLink(args) {
7751
+ if (args.providerEmailVerified !== true) return {
7752
+ allowed: false,
7753
+ reason: "provider-email-unverified"
7754
+ };
7755
+ if (Boolean(args.existingUser.passwordHash) && !args.existingUser.emailVerified) return {
7756
+ allowed: false,
7757
+ reason: "local-account-unverified"
7758
+ };
7759
+ return { allowed: true };
7760
+ }
7761
+ /**
7762
+ * Is `redirectUri` one the operator authorised?
7763
+ *
7764
+ * The provider's own registered-URI match is a real control but a coarse one:
7765
+ * it authorises *every* URI registered on that OAuth client, so a `localhost`
7766
+ * entry kept for development, or a second product sharing the client id, can
7767
+ * mint codes this backend accepts. An empty or absent allowlist keeps the old
7768
+ * behaviour (the provider's check is the only one) so existing deployments are
7769
+ * unaffected; setting one narrows it to the origins this backend serves.
7770
+ *
7771
+ * Comparison is on origin plus path, with the origin lowercased and a trailing
7772
+ * slash ignored — query strings and fragments are not part of the identity of a
7773
+ * redirect URI, and neither is the case of the host.
7774
+ */
7775
+ function isRedirectUriAllowed(redirectUri, allowlist) {
7776
+ if (!allowlist || allowlist.length === 0) return true;
7777
+ const candidate = canonicalRedirectUri(redirectUri);
7778
+ if (!candidate) return false;
7779
+ return allowlist.some((allowed) => canonicalRedirectUri(allowed) === candidate);
7780
+ }
7781
+ function canonicalRedirectUri(uri) {
7782
+ let parsed;
7783
+ try {
7784
+ parsed = new URL(uri);
7785
+ } catch {
7786
+ return null;
7787
+ }
7788
+ const path = parsed.pathname.replace(/\/+$/, "");
7789
+ return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${path}`;
7790
+ }
7791
+ //#endregion
6461
7792
  //#region src/auth/routes.ts
6462
7793
  /**
6463
7794
  * Helper to build standard auth response output
@@ -6489,7 +7820,7 @@ function getPasswordResetExpiry() {
6489
7820
  return new Date(Date.now() + 3600 * 1e3);
6490
7821
  }
6491
7822
  function createAuthRoutes(config) {
6492
- if (config.defaultRole === "admin") throw new Error("CRITICAL SECURITY ERROR: defaultRole cannot be 'admin'. Administrative privilege escalation via registration is strictly forbidden. Use the POST /admin/bootstrap endpoint to promote the initial administrator.");
7823
+ if (config.defaultRole && isAdministrativeRole(config.defaultRole)) throw new Error(`CRITICAL SECURITY ERROR: defaultRole cannot be '${config.defaultRole}'. Administrative privilege escalation via registration is strictly forbidden (administrative roles: ${ADMINISTRATIVE_ROLES.join(", ")}). Use the POST /admin/bootstrap endpoint to promote the initial administrator.`);
6493
7824
  const router = new Hono();
6494
7825
  router.onError(errorHandler);
6495
7826
  const authRepo = config.authRepo;
@@ -6572,7 +7903,7 @@ function createAuthRoutes(config) {
6572
7903
  function sendWelcomeEmail(user) {
6573
7904
  if (!isEmailConfigured()) return;
6574
7905
  const appName = emailConfig?.appName || "Rebase";
6575
- const loginUrl = emailConfig?.resetPasswordUrl || "";
7906
+ const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
6576
7907
  const templateFn = emailConfig?.templates?.welcomeEmail;
6577
7908
  const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0);
6578
7909
  emailService.send({
@@ -6585,9 +7916,21 @@ function createAuthRoutes(config) {
6585
7916
  });
6586
7917
  }
6587
7918
  /**
6588
- * Helper to generate and store session tokens
7919
+ * Helper to generate and store session tokens.
7920
+ *
7921
+ * Every route that signs somebody in comes through here — password login,
7922
+ * register, each OAuth provider, magic link, anonymous and anonymous-link —
7923
+ * which is why the second-factor gate is *inside* it rather than repeated at
7924
+ * each call site. An account with a verified factor gets no session from
7925
+ * this function at all: it throws `MFA_REQUIRED`, and the only thing that
7926
+ * mints a session for that user is `POST /auth/mfa/challenge/verify`.
7927
+ *
7928
+ * `skipMfaGate` exists for exactly one caller: the challenge-verify route
7929
+ * itself, which has just seen the second factor and mints at `aal2`.
6589
7930
  */
6590
- async function createSessionAndTokens(uid, userAgent, ipAddress) {
7931
+ async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
7932
+ if (!options?.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
7933
+ const aal = options?.aal ?? "aal1";
6591
7934
  const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
6592
7935
  let customClaims;
6593
7936
  if (ops.customizeAccessToken) {
@@ -6596,16 +7939,17 @@ function createAuthRoutes(config) {
6596
7939
  const defaultClaims = {
6597
7940
  uid,
6598
7941
  roles: roleIds,
6599
- aal: "aal1"
7942
+ aal
6600
7943
  };
6601
7944
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
6602
7945
  }
6603
7946
  }
6604
- const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
7947
+ const accessToken = generateAccessToken(uid, roleIds, aal, customClaims);
6605
7948
  const refreshToken = generateRefreshToken();
6606
7949
  await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
6607
7950
  id: randomUUID$1(),
6608
- startedAt: /* @__PURE__ */ new Date()
7951
+ startedAt: /* @__PURE__ */ new Date(),
7952
+ aal
6609
7953
  });
6610
7954
  return {
6611
7955
  roleIds,
@@ -6703,29 +8047,46 @@ function createAuthRoutes(config) {
6703
8047
  if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
6704
8048
  router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
6705
8049
  const payload = parseBody(provider.schema, await c.req.json());
8050
+ const requestedRedirectUri = payload.redirectUri;
8051
+ if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
6706
8052
  let externalUser;
6707
8053
  try {
6708
8054
  externalUser = await provider.verify(payload);
6709
8055
  } catch (err) {
6710
- const msg = err instanceof Error ? err.message : String(err);
6711
- throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
8056
+ logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
8057
+ throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
6712
8058
  }
6713
8059
  if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
6714
8060
  let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
6715
8061
  if (!user) {
6716
8062
  user = await authRepo.getUserByEmail(externalUser.email);
6717
8063
  if (user) {
6718
- 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");
8064
+ const decision = decideOAuthAutoLink({
8065
+ providerEmailVerified: externalUser.emailVerified,
8066
+ existingUser: user
8067
+ });
8068
+ if (!decision.allowed) {
8069
+ 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.";
8070
+ 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");
8071
+ }
6719
8072
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6720
8073
  await authRepo.updateUser(user.id, {
6721
8074
  displayName: user.displayName || externalUser.displayName || void 0,
6722
8075
  photoUrl: user.photoUrl || externalUser.photoUrl || void 0
6723
8076
  });
6724
8077
  } else {
8078
+ if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
8079
+ let bootstrapRegistration = false;
8080
+ if (!isRegistrationAllowed()) {
8081
+ const { total } = await authRepo.listUsersPaginated({ limit: 1 });
8082
+ bootstrapRegistration = total === 0;
8083
+ if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
8084
+ }
6725
8085
  user = await authRepo.createUser({
6726
8086
  email: normalizeEmail(externalUser.email),
6727
8087
  displayName: externalUser.displayName || void 0,
6728
- photoUrl: externalUser.photoUrl || void 0
8088
+ photoUrl: externalUser.photoUrl || void 0,
8089
+ emailVerified: externalUser.emailVerified === true
6729
8090
  });
6730
8091
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6731
8092
  if (ops.afterUserCreate) try {
@@ -6734,7 +8095,12 @@ function createAuthRoutes(config) {
6734
8095
  logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
6735
8096
  }
6736
8097
  const allUsers = await authRepo.listUsers();
6737
- if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
8098
+ const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
8099
+ if (bootstrapRegistration && !isFirstUser) {
8100
+ await authRepo.deleteUser(user.id);
8101
+ throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
8102
+ }
8103
+ if (isFirstUser) await authRepo.setUserRoles(user.id, ["admin"]);
6738
8104
  else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
6739
8105
  sendWelcomeEmail({
6740
8106
  email: user.email,
@@ -6808,7 +8174,7 @@ function createAuthRoutes(config) {
6808
8174
  const tokenHash = hashToken(token);
6809
8175
  const expiresAt = getPasswordResetExpiry();
6810
8176
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
6811
- const resetUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
8177
+ const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
6812
8178
  const appName = emailConfig?.appName || "Rebase";
6813
8179
  const templateFn = emailConfig?.templates?.passwordReset;
6814
8180
  const emailContent = templateFn ? templateFn(resetUrl, {
@@ -6883,8 +8249,15 @@ function createAuthRoutes(config) {
6883
8249
  /**
6884
8250
  * POST /auth/send-verification
6885
8251
  * Send email verification link (authenticated)
8252
+ *
8253
+ * Two limiters, because they bound different things: `strictAuthLimiter`
8254
+ * bounds the caller by IP as it does on every other email-sending route, and
8255
+ * `verificationEmailLimiter` bounds how much mail one account — i.e. one
8256
+ * recipient address — can be made to receive. Authentication is not a bound
8257
+ * here: the address is unverified by construction, so an attacker can hold a
8258
+ * session for a mailbox that is not theirs.
6886
8259
  */
6887
- router.post("/send-verification", requireAuth, async (c) => {
8260
+ router.post("/send-verification", strictAuthLimiter, requireAuth, verificationEmailLimiter, async (c) => {
6888
8261
  const userCtx = c.get("user");
6889
8262
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6890
8263
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
@@ -6893,7 +8266,7 @@ function createAuthRoutes(config) {
6893
8266
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
6894
8267
  const token = generateSecureToken();
6895
8268
  await authRepo.setVerificationToken(user.id, hashToken(token));
6896
- const verifyUrl = `${emailConfig?.verifyEmailUrl || ""}/verify-email?token=${token}`;
8269
+ const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
6897
8270
  const appName = emailConfig?.appName || "Rebase";
6898
8271
  const templateFn = emailConfig?.templates?.emailVerification;
6899
8272
  const emailContent = templateFn ? templateFn(verifyUrl, {
@@ -6975,22 +8348,24 @@ function createAuthRoutes(config) {
6975
8348
  });
6976
8349
  return null;
6977
8350
  });
8351
+ const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
6978
8352
  let customClaims;
6979
8353
  if (ops.customizeAccessToken && user) {
6980
8354
  const defaultClaims = {
6981
8355
  uid: storedToken.uid,
6982
8356
  roles: roleIds,
6983
- aal: "aal1"
8357
+ aal: sessionAal
6984
8358
  };
6985
8359
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
6986
8360
  }
6987
- const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
8361
+ const newAccessToken = generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims);
6988
8362
  const newRefreshToken = generateRefreshToken();
6989
8363
  const userAgent = c.req.header("user-agent") || "unknown";
6990
8364
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
6991
8365
  const session = {
6992
8366
  id: storedToken.sessionId ?? storedToken.id,
6993
- startedAt: sessionStartedAt
8367
+ startedAt: sessionStartedAt,
8368
+ aal: sessionAal
6994
8369
  };
6995
8370
  if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
6996
8371
  else await authRepo.deleteRefreshToken(tokenHash);
@@ -7022,7 +8397,15 @@ function createAuthRoutes(config) {
7022
8397
  createSessionAndTokens,
7023
8398
  applyTransformHook
7024
8399
  });
7025
- mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
8400
+ mountMfaRoutes({
8401
+ router,
8402
+ config,
8403
+ ops,
8404
+ parseBody,
8405
+ buildAuthResponse,
8406
+ createSessionAndTokens,
8407
+ applyTransformHook
8408
+ });
7026
8409
  if (config.enableMagicLink) mountMagicLinkRoutes({
7027
8410
  router,
7028
8411
  config,
@@ -7055,7 +8438,11 @@ function createResetPasswordRoute(config) {
7055
8438
  const { emailService, emailConfig, collectionAuthConfig } = config;
7056
8439
  const ops = resolveAuthHooks(config.authHooks);
7057
8440
  router.onError(errorHandler);
7058
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8441
+ router.use("/*", createRequireAuth({
8442
+ serviceKey: config.serviceKey,
8443
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8444
+ revocationRepo: authRepo
8445
+ }));
7059
8446
  router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
7060
8447
  const uid = c.req.param("uid");
7061
8448
  const existing = await authRepo.getUserById(uid);
@@ -7158,7 +8545,11 @@ function createAdminRolesRoute(config) {
7158
8545
  const router = new Hono();
7159
8546
  const authRepo = config.authRepo;
7160
8547
  router.onError(errorHandler);
7161
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8548
+ router.use("/*", createRequireAuth({
8549
+ serviceKey: config.serviceKey,
8550
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8551
+ revocationRepo: authRepo
8552
+ }));
7162
8553
  router.get("/roles", requireAdmin, async (c) => {
7163
8554
  const roles = await authRepo.listRoles();
7164
8555
  return c.json({ roles });
@@ -7198,7 +8589,11 @@ function createAdminUsersRoute(config) {
7198
8589
  };
7199
8590
  }
7200
8591
  router.onError(errorHandler);
7201
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8592
+ router.use("/*", createRequireAuth({
8593
+ serviceKey: config.serviceKey,
8594
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8595
+ revocationRepo: authRepo
8596
+ }));
7202
8597
  router.post("/bootstrap", async (c) => {
7203
8598
  const user = c.get("user");
7204
8599
  if (!user || typeof user !== "object") throw ApiError.unauthorized("Not authenticated");
@@ -7212,11 +8607,19 @@ function createAdminUsersRoute(config) {
7212
8607
  break;
7213
8608
  }
7214
8609
  if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
7215
- const uid = "uid" in user ? user.uid : "uid" in user ? user.uid : void 0;
8610
+ const uid = "uid" in user ? user.uid : void 0;
7216
8611
  if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
8612
+ if (isAnonymousUid(uid)) {
8613
+ logger.warn("[Security Audit] Bootstrap denied: anonymous caller", {
8614
+ eventType: "auth.bootstrap.denied.anonymous",
8615
+ callerId: uid
8616
+ });
8617
+ throw ApiError.forbidden("An anonymous session cannot claim the initial admin role. Register a real account and bootstrap from it, or assign the admin role using the service key.", "BOOTSTRAP_ANONYMOUS");
8618
+ }
7217
8619
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
7218
- if (users.length > 0) {
7219
- const earliest = users.reduce((a, b) => {
8620
+ const registeredUsers = users.filter((u) => !isAnonymousUid(u.id));
8621
+ if (registeredUsers.length > 0) {
8622
+ const earliest = registeredUsers.reduce((a, b) => {
7220
8623
  const at = new Date(a.createdAt).getTime();
7221
8624
  const bt = new Date(b.createdAt).getTime();
7222
8625
  if (at !== bt) return at < bt ? a : b;
@@ -7266,7 +8669,7 @@ function createAdminUsersRoute(config) {
7266
8669
  const search = c.req.query("search");
7267
8670
  const orderBy = c.req.query("orderBy");
7268
8671
  const orderDir = c.req.query("orderDir");
7269
- const limit = resolveClientListLimit(limitParam, { defaultLimit: 25 });
8672
+ const limit = resolveListLimitParam(limitParam, { defaultLimit: 25 });
7270
8673
  const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
7271
8674
  const result = await authRepo.listUsersPaginated({
7272
8675
  limit,
@@ -7305,13 +8708,15 @@ function createAdminUsersRoute(config) {
7305
8708
  resolvedHooks: ops,
7306
8709
  collectionAuthConfig
7307
8710
  });
7308
- const user = await authRepo.createUser({
8711
+ let createData = {
7309
8712
  email: prepResult.values.email,
7310
8713
  passwordHash: prepResult.values.passwordHash,
7311
8714
  displayName: prepResult.values.displayName,
7312
8715
  photoUrl: prepResult.values.photoUrl,
7313
8716
  metadata: prepResult.values.metadata
7314
- });
8717
+ };
8718
+ if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
8719
+ const user = await authRepo.createUser(createData);
7315
8720
  if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
7316
8721
  const finalizeResult = await finalizeAdminUserCreation({
7317
8722
  id: user.id,
@@ -7369,7 +8774,11 @@ function createAdminUsersRoute(config) {
7369
8774
  limit: 1
7370
8775
  })).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
7371
8776
  }
8777
+ if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
7372
8778
  await authRepo.deleteUser(uid);
8779
+ if (ops.afterUserDelete) ops.afterUserDelete(uid).catch((err) => {
8780
+ logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
8781
+ });
7373
8782
  return c.json({ success: true });
7374
8783
  });
7375
8784
  return router;
@@ -7384,7 +8793,7 @@ function createAdminUsersRoute(config) {
7384
8793
  * when the user passes a plain `RebaseAuthConfig` object.
7385
8794
  */
7386
8795
  function createBuiltinAuthAdapter(config) {
7387
- const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
8796
+ const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
7388
8797
  const resolvedOps = resolveAuthHooks(authHooks);
7389
8798
  return {
7390
8799
  id: "rebase-builtin",
@@ -7401,6 +8810,13 @@ function createBuiltinAuthAdapter(config) {
7401
8810
  };
7402
8811
  const payload = verifyAccessToken(token);
7403
8812
  if (!payload) return null;
8813
+ if (await isAccessTokenRevoked(authRepository, payload)) {
8814
+ logger.warn("[Security Audit] Refused a revoked access token", {
8815
+ eventType: "auth.token.revoked",
8816
+ uid: payload.uid
8817
+ });
8818
+ return null;
8819
+ }
7404
8820
  let roles = payload.roles || [];
7405
8821
  try {
7406
8822
  roles = await authRepository.getUserRoleIds(payload.uid);
@@ -7410,7 +8826,7 @@ function createBuiltinAuthAdapter(config) {
7410
8826
  error: err
7411
8827
  });
7412
8828
  }
7413
- const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
8829
+ const isAdmin = hasAdministrativeRole(roles);
7414
8830
  return {
7415
8831
  uid: payload.uid,
7416
8832
  email: payload.email ?? "",
@@ -7439,7 +8855,7 @@ function createBuiltinAuthAdapter(config) {
7439
8855
  error: err
7440
8856
  });
7441
8857
  }
7442
- const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
8858
+ const isAdmin = hasAdministrativeRole(roles);
7443
8859
  return {
7444
8860
  uid: payload.uid,
7445
8861
  email: payload.email ?? "",
@@ -7457,9 +8873,11 @@ function createBuiltinAuthAdapter(config) {
7457
8873
  emailConfig,
7458
8874
  allowRegistration,
7459
8875
  disableSelfRegistration,
8876
+ allowAnonymous,
7460
8877
  allowUserLookup,
7461
8878
  defaultRole,
7462
8879
  oauthProviders,
8880
+ allowedRedirectUris,
7463
8881
  authHooks,
7464
8882
  enableMagicLink,
7465
8883
  cookieAuth
@@ -7531,6 +8949,10 @@ function createBuiltinAuthAdapter(config) {
7531
8949
  allowRegistration,
7532
8950
  needsSetup
7533
8951
  });
8952
+ const anonymousAllowed = isAnonymousAuthOpen({
8953
+ allowAnonymous,
8954
+ disableSelfRegistration
8955
+ });
7534
8956
  return {
7535
8957
  hasBuiltInAuthRoutes: true,
7536
8958
  emailPasswordLogin: true,
@@ -7542,6 +8964,7 @@ function createBuiltinAuthAdapter(config) {
7542
8964
  profileUpdate: true,
7543
8965
  emailVerification: !!emailService?.isConfigured(),
7544
8966
  magicLink: enableMagicLink && !!emailService?.isConfigured(),
8967
+ anonymousLogin: anonymousAllowed,
7545
8968
  enabledProviders,
7546
8969
  needsSetup
7547
8970
  };
@@ -7626,6 +9049,54 @@ function toAuthUserData(user) {
7626
9049
  };
7627
9050
  }
7628
9051
  //#endregion
9052
+ //#region src/auth/oauth-code-flow.ts
9053
+ /**
9054
+ * Build the request schema for an authorization-code provider.
9055
+ *
9056
+ * `pkce` defaults to `"optional"`: the client decides whether it sent a
9057
+ * `code_challenge`, and providers that do not implement PKCE simply never see
9058
+ * a verifier because their clients never generate one. `"required"` is for
9059
+ * providers that mandate PKCE (Twitter/X).
9060
+ */
9061
+ function oauthCodeFlowSchema(opts = {}) {
9062
+ const verifier = opts.pkce === "required" ? string().min(1, "PKCE code verifier is required") : string().min(1).optional();
9063
+ return object({
9064
+ code: string().min(1, "Auth code is required"),
9065
+ redirectUri: string().url("Valid redirect URI is required"),
9066
+ codeVerifier: verifier
9067
+ });
9068
+ }
9069
+ /**
9070
+ * The `code_verifier` token-endpoint parameter, or nothing.
9071
+ *
9072
+ * Returned as a spreadable object so a provider adds PKCE with one `...` in
9073
+ * its existing body literal, rather than branching.
9074
+ */
9075
+ function pkceTokenParams(codeVerifier) {
9076
+ return codeVerifier ? { code_verifier: codeVerifier } : {};
9077
+ }
9078
+ /**
9079
+ * Normalise a provider's email-verification signal into the boolean that
9080
+ * `OAuthProviderProfile.emailVerified` promises.
9081
+ *
9082
+ * The whole point is the default: anything that is not an affirmative
9083
+ * verification signal — `undefined`, `null`, a missing field, an empty string,
9084
+ * `false` — becomes `false`. A provider that reports nothing therefore cannot
9085
+ * accidentally assert that it verified the address, which is exactly the
9086
+ * mistake five providers had made.
9087
+ *
9088
+ * Accepts the string forms because OIDC issuers are inconsistent about whether
9089
+ * `email_verified` is a JSON boolean or a string.
9090
+ */
9091
+ function providerVerifiedEmail(signal) {
9092
+ if (signal === true) return true;
9093
+ if (typeof signal === "string") {
9094
+ const normalized = signal.trim().toLowerCase();
9095
+ return normalized === "true" || normalized === "1";
9096
+ }
9097
+ return false;
9098
+ }
9099
+ //#endregion
7629
9100
  //#region src/auth/google-oauth.ts
7630
9101
  var _googleAuth;
7631
9102
  async function loadGoogleAuth() {
@@ -7658,7 +9129,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
7658
9129
  const tokenInfo = await tokenInfoRes.json();
7659
9130
  if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
7660
9131
  if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
7661
- const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === "true";
9132
+ const emailVerified = providerVerifiedEmail(tokenInfo.email_verified);
7662
9133
  let displayName = null;
7663
9134
  let photoUrl = null;
7664
9135
  try {
@@ -7713,7 +9184,8 @@ function createGoogleProvider(config) {
7713
9184
  idToken: string().min(1).optional(),
7714
9185
  accessToken: string().min(1).optional(),
7715
9186
  code: string().min(1).optional(),
7716
- redirectUri: string().min(1).optional()
9187
+ redirectUri: string().min(1).optional(),
9188
+ codeVerifier: string().min(1).optional()
7717
9189
  }).refine((data) => data.idToken || data.accessToken || data.code && data.redirectUri, { message: "One of idToken, accessToken, or code+redirectUri is required" }),
7718
9190
  verify: async (payload) => {
7719
9191
  try {
@@ -7728,7 +9200,7 @@ function createGoogleProvider(config) {
7728
9200
  email: content.email || "",
7729
9201
  displayName: content.name || null,
7730
9202
  photoUrl: content.picture || null,
7731
- emailVerified: content.email_verified === true
9203
+ emailVerified: providerVerifiedEmail(content.email_verified)
7732
9204
  };
7733
9205
  }
7734
9206
  if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
@@ -7742,7 +9214,8 @@ function createGoogleProvider(config) {
7742
9214
  client_id: clientId,
7743
9215
  client_secret: clientSecret,
7744
9216
  redirect_uri: payload.redirectUri,
7745
- grant_type: "authorization_code"
9217
+ grant_type: "authorization_code",
9218
+ ...pkceTokenParams(payload.codeVerifier)
7746
9219
  })
7747
9220
  });
7748
9221
  if (!tokenResponse.ok) {
@@ -7762,7 +9235,7 @@ function createGoogleProvider(config) {
7762
9235
  email: content.email || "",
7763
9236
  displayName: content.name || null,
7764
9237
  photoUrl: content.picture || null,
7765
- emailVerified: content.email_verified === true
9238
+ emailVerified: providerVerifiedEmail(content.email_verified)
7766
9239
  };
7767
9240
  }
7768
9241
  if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
@@ -7784,10 +9257,7 @@ function createGoogleProvider(config) {
7784
9257
  function createLinkedinProvider(config) {
7785
9258
  return {
7786
9259
  id: "linkedin",
7787
- schema: object({
7788
- code: string().min(1, "Auth code is required"),
7789
- redirectUri: string().url("Valid redirect URI is required")
7790
- }),
9260
+ schema: oauthCodeFlowSchema(),
7791
9261
  verify: async (payload) => {
7792
9262
  try {
7793
9263
  const tokenResponse = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
@@ -7798,7 +9268,8 @@ function createLinkedinProvider(config) {
7798
9268
  code: payload.code,
7799
9269
  redirect_uri: payload.redirectUri,
7800
9270
  client_id: config.clientId,
7801
- client_secret: config.clientSecret
9271
+ client_secret: config.clientSecret,
9272
+ ...pkceTokenParams(payload.codeVerifier)
7802
9273
  })
7803
9274
  });
7804
9275
  if (!tokenResponse.ok) {
@@ -7814,12 +9285,16 @@ function createLinkedinProvider(config) {
7814
9285
  return null;
7815
9286
  }
7816
9287
  const profileData = await profileResponse.json();
9288
+ if (!profileData.email) {
9289
+ logger.error("LinkedIn user has no email (the 'email' scope may not have been granted)");
9290
+ return null;
9291
+ }
7817
9292
  return {
7818
9293
  providerId: profileData.sub,
7819
9294
  email: profileData.email,
7820
9295
  displayName: profileData.name || null,
7821
9296
  photoUrl: profileData.picture || null,
7822
- emailVerified: profileData.email_verified === true
9297
+ emailVerified: providerVerifiedEmail(profileData.email_verified)
7823
9298
  };
7824
9299
  } catch (error) {
7825
9300
  logger.error("LinkedIn OAuth error", { error });
@@ -7840,10 +9315,7 @@ function createLinkedinProvider(config) {
7840
9315
  function createGitHubProvider(config) {
7841
9316
  return {
7842
9317
  id: "github",
7843
- schema: object({
7844
- code: string().min(1, "Auth code is required"),
7845
- redirectUri: string().url("Valid redirect URI is required")
7846
- }),
9318
+ schema: oauthCodeFlowSchema(),
7847
9319
  verify: async (payload) => {
7848
9320
  try {
7849
9321
  const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
@@ -7856,7 +9328,8 @@ function createGitHubProvider(config) {
7856
9328
  client_id: config.clientId,
7857
9329
  client_secret: config.clientSecret,
7858
9330
  code: payload.code,
7859
- redirect_uri: payload.redirectUri
9331
+ redirect_uri: payload.redirectUri,
9332
+ ...pkceTokenParams(payload.codeVerifier)
7860
9333
  })
7861
9334
  });
7862
9335
  if (!tokenResponse.ok) {
@@ -7880,19 +9353,22 @@ function createGitHubProvider(config) {
7880
9353
  }
7881
9354
  const profileData = await profileResponse.json();
7882
9355
  let email = profileData.email;
7883
- if (!email) {
7884
- const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
7885
- "Authorization": `Bearer ${accessToken}`,
7886
- "Accept": "application/vnd.github+json",
7887
- "User-Agent": "Rebase-Auth"
7888
- } });
7889
- if (emailsResponse.ok) {
7890
- const emails = await emailsResponse.json();
7891
- email = (emails.find((e) => e.primary && e.verified) || emails.find((e) => e.verified))?.email || null;
9356
+ let emailVerified = false;
9357
+ const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
9358
+ "Authorization": `Bearer ${accessToken}`,
9359
+ "Accept": "application/vnd.github+json",
9360
+ "User-Agent": "Rebase-Auth"
9361
+ } });
9362
+ if (emailsResponse.ok) {
9363
+ const emails = await emailsResponse.json();
9364
+ 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);
9365
+ if (chosen) {
9366
+ email = chosen.email;
9367
+ emailVerified = providerVerifiedEmail(chosen.verified);
7892
9368
  }
7893
9369
  }
7894
9370
  if (!email) {
7895
- logger.error("GitHub user has no verified email");
9371
+ logger.error("GitHub user has no email");
7896
9372
  return null;
7897
9373
  }
7898
9374
  return {
@@ -7900,7 +9376,7 @@ function createGitHubProvider(config) {
7900
9376
  email,
7901
9377
  displayName: profileData.name || profileData.login || null,
7902
9378
  photoUrl: profileData.avatar_url || null,
7903
- emailVerified: true
9379
+ emailVerified
7904
9380
  };
7905
9381
  } catch (error) {
7906
9382
  logger.error("GitHub OAuth error", { error });
@@ -7910,21 +9386,109 @@ function createGitHubProvider(config) {
7910
9386
  };
7911
9387
  }
7912
9388
  //#endregion
9389
+ //#region src/auth/oidc-id-token.ts
9390
+ var JWKS_TTL_MS = 600 * 1e3;
9391
+ var jwksCache = /* @__PURE__ */ new Map();
9392
+ async function loadJwks(jwksUri, fetchImpl, forceRefresh) {
9393
+ const cached = jwksCache.get(jwksUri);
9394
+ if (!forceRefresh && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
9395
+ const response = await fetchImpl(jwksUri);
9396
+ if (!response.ok) throw new Error(`JWKS fetch failed (${response.status})`);
9397
+ const body = await response.json();
9398
+ const keys = Array.isArray(body.keys) ? body.keys : [];
9399
+ jwksCache.set(jwksUri, {
9400
+ keys,
9401
+ fetchedAt: Date.now()
9402
+ });
9403
+ return keys;
9404
+ }
9405
+ function toKeyObject(jwk) {
9406
+ return createPublicKey({
9407
+ key: jwk,
9408
+ format: "jwk"
9409
+ });
9410
+ }
9411
+ function decodeKid(idToken) {
9412
+ return import_jsonwebtoken.default.decode(idToken, { complete: true })?.header?.kid;
9413
+ }
9414
+ /**
9415
+ * Verify an id_token's signature, `aud`, `iss` and `exp`, and return its claims.
9416
+ *
9417
+ * Throws on any failure — callers treat a throw as "reject this sign-in",
9418
+ * never as "continue without the claims".
9419
+ */
9420
+ async function verifyOidcIdToken(options) {
9421
+ const { idToken, jwksUri, issuer, audience, algorithms = ["RS256"], clockToleranceSec = 60, nonce, fetchImpl = fetch } = options;
9422
+ if (!idToken || typeof idToken !== "string") throw new Error("id_token missing");
9423
+ const kid = decodeKid(idToken);
9424
+ let keys = await loadJwks(jwksUri, fetchImpl, false);
9425
+ let jwk = keys.find((k) => !kid || k.kid === kid);
9426
+ if (!jwk) {
9427
+ keys = await loadJwks(jwksUri, fetchImpl, true);
9428
+ jwk = keys.find((k) => !kid || k.kid === kid);
9429
+ }
9430
+ if (!jwk) throw new Error(`No JWKS key matches kid "${kid ?? "<none>"}"`);
9431
+ const claims = import_jsonwebtoken.default.verify(idToken, toKeyObject(jwk), {
9432
+ algorithms,
9433
+ audience,
9434
+ clockTolerance: clockToleranceSec
9435
+ });
9436
+ if (!(typeof issuer === "string" ? claims.iss === issuer : issuer.test(String(claims.iss ?? "")))) throw new Error(`Unexpected id_token issuer "${claims.iss}"`);
9437
+ if (nonce !== void 0 && claims.nonce !== nonce) throw new Error("id_token nonce mismatch");
9438
+ return claims;
9439
+ }
9440
+ /**
9441
+ * `verifyOidcIdToken` that reports failure as `null` and logs it, for the one
9442
+ * call site that has to decide between "reject" and "degrade".
9443
+ */
9444
+ async function tryVerifyOidcIdToken(providerId, options) {
9445
+ try {
9446
+ return await verifyOidcIdToken(options);
9447
+ } catch (error) {
9448
+ logger.error(`[${providerId}] id_token verification failed`, { error: error instanceof Error ? error.message : error });
9449
+ return null;
9450
+ }
9451
+ }
9452
+ //#endregion
7913
9453
  //#region src/auth/microsoft-oauth.ts
9454
+ /** Endpoints that federate to more than one directory. */
9455
+ var MULTI_TENANT_ENDPOINTS = /* @__PURE__ */ new Set([
9456
+ "common",
9457
+ "organizations",
9458
+ "consumers"
9459
+ ]);
7914
9460
  /**
7915
9461
  * Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
7916
9462
  *
7917
9463
  * Supports both personal Microsoft accounts and work/school (Azure AD) accounts
7918
9464
  * via the "common" tenant endpoint. Uses the authorization code flow.
9465
+ *
9466
+ * ## On `emailVerified`
9467
+ *
9468
+ * Microsoft Graph exposes no email-verification field, and `mail` is *not* a
9469
+ * substitute: it is a directory attribute a tenant administrator sets to any
9470
+ * string they like, through Graph or AAD Connect sync. With the default
9471
+ * `tenantId: "common"` every Entra tenant in the world is an accepted issuer,
9472
+ * so "there is a `mail` value" would mean "anybody who can create a free
9473
+ * tenant can nominate any address" — including one that already has an account
9474
+ * here.
9475
+ *
9476
+ * The only signal Microsoft offers is the `xms_edov` ("email domain owner
9477
+ * verified") optional claim on the id_token, so that is what this provider
9478
+ * reads, off a signature-verified token. If the app registration does not emit
9479
+ * `xms_edov`, sign-in still works and the address comes back unverified —
9480
+ * which routes the user to `POST /auth/link/microsoft` rather than silently
9481
+ * handing them somebody else's account.
7919
9482
  */
7920
9483
  function createMicrosoftProvider(config) {
7921
9484
  const tenantId = config.tenantId || "common";
9485
+ const multiTenant = MULTI_TENANT_ENDPOINTS.has(tenantId.toLowerCase());
9486
+ const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
9487
+ const issuer = multiTenant ? /^https:\/\/login\.microsoftonline\.com\/[0-9a-fA-F-]{36}\/v2\.0$/ : `https://login.microsoftonline.com/${tenantId}/v2.0`;
9488
+ 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.`);
7922
9489
  return {
7923
9490
  id: "microsoft",
7924
- schema: object({
7925
- code: string().min(1, "Auth code is required"),
7926
- redirectUri: string().url("Valid redirect URI is required")
7927
- }),
9491
+ schema: oauthCodeFlowSchema(),
7928
9492
  verify: async (payload) => {
7929
9493
  try {
7930
9494
  const tokenResponse = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
@@ -7936,14 +9500,26 @@ function createMicrosoftProvider(config) {
7936
9500
  code: payload.code,
7937
9501
  redirect_uri: payload.redirectUri,
7938
9502
  grant_type: "authorization_code",
7939
- scope: "openid profile email User.Read"
9503
+ scope: "openid profile email User.Read",
9504
+ ...pkceTokenParams(payload.codeVerifier)
7940
9505
  })
7941
9506
  });
7942
9507
  if (!tokenResponse.ok) {
7943
9508
  logger.error("Failed to get Microsoft access token", { detail: await tokenResponse.text() });
7944
9509
  return null;
7945
9510
  }
7946
- const accessToken = (await tokenResponse.json()).access_token;
9511
+ const tokenData = await tokenResponse.json();
9512
+ const accessToken = tokenData.access_token;
9513
+ let idTokenClaims = null;
9514
+ if (tokenData.id_token) {
9515
+ idTokenClaims = await tryVerifyOidcIdToken("microsoft", {
9516
+ idToken: tokenData.id_token,
9517
+ jwksUri,
9518
+ issuer,
9519
+ audience: config.clientId
9520
+ });
9521
+ if (!idTokenClaims) return null;
9522
+ }
7947
9523
  const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { "Authorization": `Bearer ${accessToken}` } });
7948
9524
  if (!profileResponse.ok) {
7949
9525
  logger.error("Failed to get Microsoft user info", { detail: await profileResponse.text() });
@@ -7955,7 +9531,8 @@ function createMicrosoftProvider(config) {
7955
9531
  logger.error("Microsoft user has no email");
7956
9532
  return null;
7957
9533
  }
7958
- const emailVerified = Boolean(profileData.mail);
9534
+ const claimedEmail = idTokenClaims?.email ?? idTokenClaims?.preferred_username;
9535
+ const emailVerified = providerVerifiedEmail(idTokenClaims?.xms_edov) && typeof claimedEmail === "string" && claimedEmail.toLowerCase() === email.toLowerCase();
7959
9536
  return {
7960
9537
  providerId: profileData.id,
7961
9538
  email,
@@ -7972,6 +9549,8 @@ function createMicrosoftProvider(config) {
7972
9549
  }
7973
9550
  //#endregion
7974
9551
  //#region src/auth/apple-oauth.ts
9552
+ var APPLE_ISSUER = "https://appleid.apple.com";
9553
+ var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
7975
9554
  /**
7976
9555
  * Creates an Apple Sign In OAuth Provider integration.
7977
9556
  *
@@ -8001,18 +9580,19 @@ function createAppleProvider(config) {
8001
9580
  }
8002
9581
  return {
8003
9582
  id: "apple",
8004
- schema: object({
8005
- code: string().min(1, "Auth code is required"),
8006
- redirectUri: string().url("Valid redirect URI is required"),
8007
- /** Apple sends user info only on first authorization; the frontend must forward it. */
8008
- user: object({
8009
- name: object({
8010
- firstName: string().optional(),
8011
- lastName: string().optional()
8012
- }).optional(),
8013
- email: string().email().optional()
8014
- }).optional()
8015
- }),
9583
+ schema: oauthCodeFlowSchema().extend({
9584
+ /**
9585
+ * Apple sends user info only on first authorization; the frontend
9586
+ * must forward it. Only the *name* is accepted — an `email` here
9587
+ * used to be promoted to the account's identity whenever the
9588
+ * id_token had no `email` claim, and an attacker chooses whether
9589
+ * that claim is absent simply by omitting the `email` scope from
9590
+ * their own authorization request.
9591
+ */
9592
+ user: object({ name: object({
9593
+ firstName: string().optional(),
9594
+ lastName: string().optional()
9595
+ }).optional() }).optional() }),
8016
9596
  verify: async (payload) => {
8017
9597
  try {
8018
9598
  const clientSecret = await generateClientSecret();
@@ -8024,18 +9604,28 @@ function createAppleProvider(config) {
8024
9604
  client_secret: clientSecret,
8025
9605
  code: payload.code,
8026
9606
  grant_type: "authorization_code",
8027
- redirect_uri: payload.redirectUri
9607
+ redirect_uri: payload.redirectUri,
9608
+ ...pkceTokenParams(payload.codeVerifier)
8028
9609
  })
8029
9610
  });
8030
9611
  if (!tokenResponse.ok) {
8031
9612
  logger.error("Failed to get Apple access token", { detail: await tokenResponse.text() });
8032
9613
  return null;
8033
9614
  }
8034
- const [, payloadB64] = (await tokenResponse.json()).id_token.split(".");
8035
- const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
8036
- const email = decoded.email || payload.user?.email;
9615
+ const tokenData = await tokenResponse.json();
9616
+ const decoded = tokenData.id_token ? await tryVerifyOidcIdToken("apple", {
9617
+ idToken: tokenData.id_token,
9618
+ jwksUri: APPLE_JWKS_URI,
9619
+ issuer: APPLE_ISSUER,
9620
+ audience: config.clientId
9621
+ }) : null;
9622
+ if (!decoded) {
9623
+ logger.error("Apple token exchange returned no verifiable id_token");
9624
+ return null;
9625
+ }
9626
+ const email = decoded.email;
8037
9627
  if (!email) {
8038
- logger.error("Apple user has no email");
9628
+ logger.error("Apple id_token carries no email claim");
8039
9629
  return null;
8040
9630
  }
8041
9631
  let displayName = null;
@@ -8048,7 +9638,7 @@ function createAppleProvider(config) {
8048
9638
  email,
8049
9639
  displayName,
8050
9640
  photoUrl: null,
8051
- emailVerified: decoded.email_verified === true || decoded.email_verified === "true"
9641
+ emailVerified: providerVerifiedEmail(decoded.email_verified)
8052
9642
  };
8053
9643
  } catch (error) {
8054
9644
  logger.error("Apple OAuth error", { error });
@@ -8068,18 +9658,20 @@ function createAppleProvider(config) {
8068
9658
  function createFacebookProvider(config) {
8069
9659
  return {
8070
9660
  id: "facebook",
8071
- schema: object({
8072
- code: string().min(1, "Auth code is required"),
8073
- redirectUri: string().url("Valid redirect URI is required")
8074
- }),
9661
+ schema: oauthCodeFlowSchema(),
8075
9662
  verify: async (payload) => {
8076
9663
  try {
8077
- const tokenUrl = new URL("https://graph.facebook.com/v19.0/oauth/access_token");
8078
- tokenUrl.searchParams.set("client_id", config.clientId);
8079
- tokenUrl.searchParams.set("client_secret", config.clientSecret);
8080
- tokenUrl.searchParams.set("redirect_uri", payload.redirectUri);
8081
- tokenUrl.searchParams.set("code", payload.code);
8082
- const tokenResponse = await fetch(tokenUrl.toString());
9664
+ const tokenResponse = await fetch("https://graph.facebook.com/v19.0/oauth/access_token", {
9665
+ method: "POST",
9666
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
9667
+ body: new URLSearchParams({
9668
+ client_id: config.clientId,
9669
+ client_secret: config.clientSecret,
9670
+ redirect_uri: payload.redirectUri,
9671
+ code: payload.code,
9672
+ ...pkceTokenParams(payload.codeVerifier)
9673
+ })
9674
+ });
8083
9675
  if (!tokenResponse.ok) {
8084
9676
  logger.error("Failed to get Facebook access token", { detail: await tokenResponse.text() });
8085
9677
  return null;
@@ -8087,8 +9679,7 @@ function createFacebookProvider(config) {
8087
9679
  const accessToken = (await tokenResponse.json()).access_token;
8088
9680
  const profileUrl = new URL("https://graph.facebook.com/v19.0/me");
8089
9681
  profileUrl.searchParams.set("fields", "id,name,email,picture.type(large)");
8090
- profileUrl.searchParams.set("access_token", accessToken);
8091
- const profileResponse = await fetch(profileUrl.toString());
9682
+ const profileResponse = await fetch(profileUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } });
8092
9683
  if (!profileResponse.ok) {
8093
9684
  logger.error("Failed to get Facebook user info", { detail: await profileResponse.text() });
8094
9685
  return null;
@@ -8103,7 +9694,7 @@ function createFacebookProvider(config) {
8103
9694
  email: profileData.email,
8104
9695
  displayName: profileData.name || null,
8105
9696
  photoUrl: profileData.picture?.data?.url || null,
8106
- emailVerified: true
9697
+ emailVerified: false
8107
9698
  };
8108
9699
  } catch (error) {
8109
9700
  logger.error("Facebook OAuth error", { error });
@@ -8126,11 +9717,7 @@ function createFacebookProvider(config) {
8126
9717
  function createTwitterProvider(config) {
8127
9718
  return {
8128
9719
  id: "twitter",
8129
- schema: object({
8130
- code: string().min(1, "Auth code is required"),
8131
- redirectUri: string().url("Valid redirect URI is required"),
8132
- codeVerifier: string().min(1, "PKCE code verifier is required")
8133
- }),
9720
+ schema: oauthCodeFlowSchema({ pkce: "required" }),
8134
9721
  verify: async (payload) => {
8135
9722
  try {
8136
9723
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8144,7 +9731,7 @@ function createTwitterProvider(config) {
8144
9731
  code: payload.code,
8145
9732
  grant_type: "authorization_code",
8146
9733
  redirect_uri: payload.redirectUri,
8147
- code_verifier: payload.codeVerifier
9734
+ ...pkceTokenParams(payload.codeVerifier)
8148
9735
  })
8149
9736
  });
8150
9737
  if (!tokenResponse.ok) {
@@ -8190,16 +9777,14 @@ function createTwitterProvider(config) {
8190
9777
  /**
8191
9778
  * Creates a Discord OAuth2 Provider integration.
8192
9779
  *
8193
- * Uses the authorization code flow. Requires the "identify" and "email"
8194
- * scopes to retrieve the user's email and profile information.
9780
+ * Uses the authorization code flow (with PKCE when the client supplies a
9781
+ * verifier). Requires the "identify" and "email" scopes to retrieve the
9782
+ * user's email and profile information.
8195
9783
  */
8196
9784
  function createDiscordProvider(config) {
8197
9785
  return {
8198
9786
  id: "discord",
8199
- schema: object({
8200
- code: string().min(1, "Auth code is required"),
8201
- redirectUri: string().url("Valid redirect URI is required")
8202
- }),
9787
+ schema: oauthCodeFlowSchema(),
8203
9788
  verify: async (payload) => {
8204
9789
  try {
8205
9790
  const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
@@ -8210,7 +9795,8 @@ function createDiscordProvider(config) {
8210
9795
  client_secret: config.clientSecret,
8211
9796
  grant_type: "authorization_code",
8212
9797
  code: payload.code,
8213
- redirect_uri: payload.redirectUri
9798
+ redirect_uri: payload.redirectUri,
9799
+ ...pkceTokenParams(payload.codeVerifier)
8214
9800
  })
8215
9801
  });
8216
9802
  if (!tokenResponse.ok) {
@@ -8238,7 +9824,7 @@ function createDiscordProvider(config) {
8238
9824
  email: profileData.email,
8239
9825
  displayName: profileData.global_name || profileData.username || null,
8240
9826
  photoUrl,
8241
- emailVerified: profileData.verified === true
9827
+ emailVerified: providerVerifiedEmail(profileData.verified)
8242
9828
  };
8243
9829
  } catch (error) {
8244
9830
  logger.error("Discord OAuth error", { error });
@@ -8252,15 +9838,16 @@ function createDiscordProvider(config) {
8252
9838
  /**
8253
9839
  * Creates a GitLab OAuth Provider integration.
8254
9840
  * Works with both GitLab.com and self-hosted instances.
9841
+ *
9842
+ * `baseUrl` is configuration only and must stay that way: a caller-supplied
9843
+ * instance URL would make this provider an SSRF primitive and an
9844
+ * arbitrary-identity oracle in one step.
8255
9845
  */
8256
9846
  function createGitLabProvider(config) {
8257
9847
  const gitlabUrl = (config.baseUrl || "https://gitlab.com").replace(/\/$/, "");
8258
9848
  return {
8259
9849
  id: "gitlab",
8260
- schema: object({
8261
- code: string().min(1, "Auth code is required"),
8262
- redirectUri: string().url("Valid redirect URI is required")
8263
- }),
9850
+ schema: oauthCodeFlowSchema(),
8264
9851
  verify: async (payload) => {
8265
9852
  try {
8266
9853
  const tokenResponse = await fetch(`${gitlabUrl}/oauth/token`, {
@@ -8271,7 +9858,8 @@ function createGitLabProvider(config) {
8271
9858
  client_secret: config.clientSecret,
8272
9859
  code: payload.code,
8273
9860
  grant_type: "authorization_code",
8274
- redirect_uri: payload.redirectUri
9861
+ redirect_uri: payload.redirectUri,
9862
+ ...pkceTokenParams(payload.codeVerifier)
8275
9863
  })
8276
9864
  });
8277
9865
  if (!tokenResponse.ok) {
@@ -8294,7 +9882,7 @@ function createGitLabProvider(config) {
8294
9882
  email: p.email,
8295
9883
  displayName: p.name || p.username || null,
8296
9884
  photoUrl: p.avatar_url || null,
8297
- emailVerified: true
9885
+ emailVerified: Boolean(p.confirmed_at)
8298
9886
  };
8299
9887
  } catch (error) {
8300
9888
  logger.error("GitLab OAuth error", { error });
@@ -8311,10 +9899,7 @@ function createGitLabProvider(config) {
8311
9899
  function createBitbucketProvider(config) {
8312
9900
  return {
8313
9901
  id: "bitbucket",
8314
- schema: object({
8315
- code: string().min(1, "Auth code is required"),
8316
- redirectUri: string().url("Valid redirect URI is required")
8317
- }),
9902
+ schema: oauthCodeFlowSchema(),
8318
9903
  verify: async (payload) => {
8319
9904
  try {
8320
9905
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8327,7 +9912,8 @@ function createBitbucketProvider(config) {
8327
9912
  body: new URLSearchParams({
8328
9913
  grant_type: "authorization_code",
8329
9914
  code: payload.code,
8330
- redirect_uri: payload.redirectUri
9915
+ redirect_uri: payload.redirectUri,
9916
+ ...pkceTokenParams(payload.codeVerifier)
8331
9917
  })
8332
9918
  });
8333
9919
  if (!tokenResponse.ok) {
@@ -8342,21 +9928,21 @@ function createBitbucketProvider(config) {
8342
9928
  }
8343
9929
  const p = await profileResponse.json();
8344
9930
  const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", { headers: { "Authorization": `Bearer ${accessToken}` } });
8345
- let email = null;
9931
+ let selected;
8346
9932
  if (emailsResponse.ok) {
8347
9933
  const emailData = await emailsResponse.json();
8348
- email = (emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed))?.email || null;
9934
+ selected = emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed);
8349
9935
  }
8350
- if (!email) {
9936
+ if (!selected?.email) {
8351
9937
  logger.error("Bitbucket user has no verified email");
8352
9938
  return null;
8353
9939
  }
8354
9940
  return {
8355
9941
  providerId: p.uuid,
8356
- email,
9942
+ email: selected.email,
8357
9943
  displayName: p.display_name || p.username || null,
8358
9944
  photoUrl: p.links?.avatar?.href || null,
8359
- emailVerified: true
9945
+ emailVerified: providerVerifiedEmail(selected.is_confirmed)
8360
9946
  };
8361
9947
  } catch (error) {
8362
9948
  logger.error("Bitbucket OAuth error", { error });
@@ -8374,10 +9960,7 @@ function createBitbucketProvider(config) {
8374
9960
  function createSlackProvider(config) {
8375
9961
  return {
8376
9962
  id: "slack",
8377
- schema: object({
8378
- code: string().min(1, "Auth code is required"),
8379
- redirectUri: string().url("Valid redirect URI is required")
8380
- }),
9963
+ schema: oauthCodeFlowSchema(),
8381
9964
  verify: async (payload) => {
8382
9965
  try {
8383
9966
  const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
@@ -8388,7 +9971,8 @@ function createSlackProvider(config) {
8388
9971
  client_secret: config.clientSecret,
8389
9972
  code: payload.code,
8390
9973
  redirect_uri: payload.redirectUri,
8391
- grant_type: "authorization_code"
9974
+ grant_type: "authorization_code",
9975
+ ...pkceTokenParams(payload.codeVerifier)
8392
9976
  })
8393
9977
  });
8394
9978
  if (!tokenResponse.ok) {
@@ -8415,7 +9999,7 @@ function createSlackProvider(config) {
8415
9999
  email: p.email,
8416
10000
  displayName: p.name || null,
8417
10001
  photoUrl: p.picture || null,
8418
- emailVerified: p.email_verified === true
10002
+ emailVerified: providerVerifiedEmail(p.email_verified)
8419
10003
  };
8420
10004
  } catch (error) {
8421
10005
  logger.error("Slack OAuth error", { error });
@@ -8433,10 +10017,7 @@ function createSlackProvider(config) {
8433
10017
  function createSpotifyProvider(config) {
8434
10018
  return {
8435
10019
  id: "spotify",
8436
- schema: object({
8437
- code: string().min(1, "Auth code is required"),
8438
- redirectUri: string().url("Valid redirect URI is required")
8439
- }),
10020
+ schema: oauthCodeFlowSchema(),
8440
10021
  verify: async (payload) => {
8441
10022
  try {
8442
10023
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8449,7 +10030,8 @@ function createSpotifyProvider(config) {
8449
10030
  body: new URLSearchParams({
8450
10031
  grant_type: "authorization_code",
8451
10032
  code: payload.code,
8452
- redirect_uri: payload.redirectUri
10033
+ redirect_uri: payload.redirectUri,
10034
+ ...pkceTokenParams(payload.codeVerifier)
8453
10035
  })
8454
10036
  });
8455
10037
  if (!tokenResponse.ok) {
@@ -8472,7 +10054,7 @@ function createSpotifyProvider(config) {
8472
10054
  email: p.email,
8473
10055
  displayName: p.display_name || null,
8474
10056
  photoUrl: p.images?.[0]?.url || null,
8475
- emailVerified: true
10057
+ emailVerified: false
8476
10058
  };
8477
10059
  } catch (error) {
8478
10060
  logger.error("Spotify OAuth error", { error });
@@ -8570,44 +10152,45 @@ function createApiKeyStore(driver) {
8570
10152
  return;
8571
10153
  }
8572
10154
  const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
10155
+ const ddl = createDdlBootstrapper(exec, "api-key-store");
8573
10156
  return {
8574
10157
  async ensureTable() {
8575
- try {
8576
- await exec("CREATE SCHEMA IF NOT EXISTS rebase");
8577
- await exec(`
8578
- CREATE TABLE IF NOT EXISTS ${TABLE} (
8579
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8580
- name TEXT NOT NULL,
8581
- key_prefix TEXT NOT NULL,
8582
- key_hash TEXT NOT NULL UNIQUE,
8583
- permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
8584
- admin BOOLEAN NOT NULL DEFAULT FALSE,
8585
- rate_limit INTEGER,
8586
- created_by TEXT NOT NULL,
8587
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8588
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8589
- last_used_at TIMESTAMPTZ,
8590
- expires_at TIMESTAMPTZ,
8591
- revoked_at TIMESTAMPTZ
8592
- )
8593
- `);
8594
- await exec(`
8595
- CREATE INDEX IF NOT EXISTS idx_api_keys_hash
8596
- ON ${TABLE}(key_hash)
8597
- `);
8598
- await exec(`
8599
- CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8600
- ON ${TABLE}(key_prefix)
8601
- `);
8602
- await exec(`
8603
- ALTER TABLE ${TABLE}
8604
- ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
8605
- `);
8606
- logger.info("✅ API keys table ready");
8607
- } catch (err) {
8608
- logger.error("❌ Failed to create API keys table", { error: err });
8609
- logger.warn("⚠️ Continuing without API keys support.");
10158
+ await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
10159
+ await ddl.ensureObject(`Creating ${TABLE}`, `
10160
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
10161
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
10162
+ name TEXT NOT NULL,
10163
+ key_prefix TEXT NOT NULL,
10164
+ key_hash TEXT NOT NULL UNIQUE,
10165
+ permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
10166
+ admin BOOLEAN NOT NULL DEFAULT FALSE,
10167
+ rate_limit INTEGER,
10168
+ created_by TEXT NOT NULL,
10169
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10170
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10171
+ last_used_at TIMESTAMPTZ,
10172
+ expires_at TIMESTAMPTZ,
10173
+ revoked_at TIMESTAMPTZ
10174
+ )
10175
+ `);
10176
+ await ddl.ensureObject("Creating idx_api_keys_hash", `
10177
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash
10178
+ ON ${TABLE}(key_hash)
10179
+ `);
10180
+ await ddl.ensureObject("Creating idx_api_keys_prefix", `
10181
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
10182
+ ON ${TABLE}(key_prefix)
10183
+ `);
10184
+ await ddl.ensureObject(`Adding ${TABLE}.admin`, `
10185
+ ALTER TABLE ${TABLE}
10186
+ ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
10187
+ `);
10188
+ if (!await ddl.isReadable(TABLE)) {
10189
+ 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.`);
10190
+ return;
8610
10191
  }
10192
+ await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
10193
+ logger.debug("✅ API keys table ready");
8611
10194
  },
8612
10195
  async createApiKey(request, createdBy) {
8613
10196
  const plaintext = generateApiKey();
@@ -8902,6 +10485,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8902
10485
  createSlackProvider: () => createSlackProvider,
8903
10486
  createSpotifyProvider: () => createSpotifyProvider,
8904
10487
  createTwitterProvider: () => createTwitterProvider,
10488
+ decideOAuthAutoLink: () => decideOAuthAutoLink,
8905
10489
  defaultAuthLimiter: () => defaultAuthLimiter,
8906
10490
  extractUserFromToken: () => extractUserFromToken,
8907
10491
  fileTokenAuth: () => fileTokenAuth,
@@ -8920,22 +10504,28 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8920
10504
  isApiKeyToken: () => isApiKeyToken,
8921
10505
  isJwtConfigured: () => isJwtConfigured,
8922
10506
  isOperationAllowed: () => isOperationAllowed,
10507
+ isRedirectUriAllowed: () => isRedirectUriAllowed,
8923
10508
  mountMagicLinkRoutes: () => mountMagicLinkRoutes,
10509
+ oauthCodeFlowSchema: () => oauthCodeFlowSchema,
8924
10510
  optionalAuth: () => optionalAuth,
10511
+ pkceTokenParams: () => pkceTokenParams,
8925
10512
  prepareAdminUserValues: () => prepareAdminUserValues,
10513
+ providerVerifiedEmail: () => providerVerifiedEmail,
8926
10514
  queryTokenAuth: () => queryTokenAuth,
8927
10515
  requireAdmin: () => requireAdmin,
8928
10516
  requireAuth: () => requireAuth,
8929
10517
  resolveAuthHooks: () => resolveAuthHooks,
8930
10518
  safeCompare: () => safeCompare,
8931
10519
  strictAuthLimiter: () => strictAuthLimiter,
10520
+ tryVerifyOidcIdToken: () => tryVerifyOidcIdToken,
8932
10521
  validateApiKey: () => validateApiKey,
8933
10522
  validatePasswordStrength: () => validatePasswordStrength,
8934
10523
  verifyAccessToken: () => verifyAccessToken,
8935
10524
  verifyDownloadToken: () => verifyDownloadToken,
10525
+ verifyOidcIdToken: () => verifyOidcIdToken,
8936
10526
  verifyPassword: () => verifyPassword
8937
10527
  });
8938
10528
  //#endregion
8939
- 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, 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, 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 };
10529
+ 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 };
8940
10530
 
8941
- //# sourceMappingURL=auth-DU4ShERo.js.map
10531
+ //# sourceMappingURL=auth-CYoPVf-E.js.map