@rebasepro/server 0.13.1-canary.gf57a27e → 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 (126) hide show
  1. package/dist/{admin_block-CLeUMG8G.js → admin_block-H86dCPsj.js} +60 -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-BANhRhN9.js → auth-CYoPVf-E.js} +1814 -354
  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/{contract-routes-FSt0Nyfk.js → contract-routes-Bet-eCNJ.js} +3 -3
  45. package/dist/{contract-routes-FSt0Nyfk.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
  46. package/dist/cron/cron-loader.d.ts +26 -1
  47. package/dist/cron/cron-routes.d.ts +1 -1
  48. package/dist/cron/cron-scheduler.d.ts +5 -0
  49. package/dist/cron/define-cron.d.ts +14 -2
  50. package/dist/cron/index.d.ts +2 -2
  51. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
  52. package/dist/cron-loader-YhhQeVBM.js.map +1 -0
  53. package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
  54. package/dist/cron-routes-maM_RlUu.js.map +1 -0
  55. package/dist/{cron-scheduler-CsQtYLMq.js → cron-scheduler-DIpYBmZP.js} +13 -7
  56. package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
  57. package/dist/{cron-store-ZbjfuvMb.js → cron-store-Dvr4Y1sZ.js} +5 -5
  58. package/dist/{cron-store-ZbjfuvMb.js.map → cron-store-Dvr4Y1sZ.js.map} +1 -1
  59. package/dist/{ddl-bootstrap-Bp3mr6Td.js → ddl-bootstrap-BhXbTnBl.js} +2 -2
  60. package/dist/{ddl-bootstrap-Bp3mr6Td.js.map → ddl-bootstrap-BhXbTnBl.js.map} +1 -1
  61. package/dist/email/html.d.ts +54 -0
  62. package/dist/email/index.d.ts +3 -0
  63. package/dist/email/link-base.d.ts +39 -0
  64. package/dist/email/smtp-email-service.d.ts +7 -1
  65. package/dist/email/templates.d.ts +9 -1
  66. package/dist/email/types.d.ts +11 -1
  67. package/dist/env.d.ts +5 -0
  68. package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
  69. package/dist/errors-EBYiaJ2E.js.map +1 -0
  70. package/dist/function-loader-DDS1v7YX.js +139 -0
  71. package/dist/function-loader-DDS1v7YX.js.map +1 -0
  72. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  73. package/dist/function-routes-Btcez1T-.js.map +1 -0
  74. package/dist/functions/define-function.d.ts +24 -5
  75. package/dist/functions/function-loader.d.ts +23 -0
  76. package/dist/functions/function-routes.d.ts +7 -1
  77. package/dist/functions/request-timeout.d.ts +34 -0
  78. package/dist/index.d.ts +3 -2
  79. package/dist/index.es.js +1437 -694
  80. package/dist/index.es.js.map +1 -1
  81. package/dist/init/docs.d.ts +10 -1
  82. package/dist/init/process-safety.d.ts +25 -0
  83. package/dist/init.d.ts +38 -0
  84. package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
  85. package/dist/jwt-_IFqfTOg.js.map +1 -0
  86. package/dist/logger-DfvF_8r-.js +190 -0
  87. package/dist/logger-DfvF_8r-.js.map +1 -0
  88. package/dist/{openapi-generator-VB0MUH8h.js → openapi-generator-DPKtUC9X.js} +87 -31
  89. package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
  90. package/dist/request-timeout-RivJsME0.js +65 -0
  91. package/dist/request-timeout-RivJsME0.js.map +1 -0
  92. package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
  93. package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
  94. package/dist/serve-spa.d.ts +5 -4
  95. package/dist/services/outbound-url-guard.d.ts +53 -0
  96. package/dist/services/routed-realtime-service.d.ts +10 -2
  97. package/dist/services/webhook-service.d.ts +76 -1
  98. package/dist/singleton.d.ts +25 -8
  99. package/dist/{src-DVb5a0bp.js → src-C7rkDGxA.js} +536 -61
  100. package/dist/src-C7rkDGxA.js.map +1 -0
  101. package/dist/{src-Ca6NhxKs.js → src-Cz9nMgUR.js} +16 -3
  102. package/dist/src-Cz9nMgUR.js.map +1 -0
  103. package/dist/storage/LocalStorageController.d.ts +12 -1
  104. package/dist/storage/image-transform.d.ts +57 -0
  105. package/dist/storage/keys.d.ts +34 -1
  106. package/dist/storage/routes.d.ts +11 -0
  107. package/dist/storage/tus-handler.d.ts +22 -2
  108. package/dist/utils/logger.d.ts +18 -0
  109. package/package.json +6 -5
  110. package/dist/admin_block-CLeUMG8G.js.map +0 -1
  111. package/dist/auth-BANhRhN9.js.map +0 -1
  112. package/dist/cron-loader-B1S2MCSl.js.map +0 -1
  113. package/dist/cron-routes-D5a9v1HY.js.map +0 -1
  114. package/dist/cron-scheduler-CsQtYLMq.js.map +0 -1
  115. package/dist/errors-BpudWAVU.js.map +0 -1
  116. package/dist/function-loader-B_1fYfUY.js +0 -86
  117. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  118. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  119. package/dist/jwt-CzeviDcB.js.map +0 -1
  120. package/dist/logger-BYU66ENZ.js +0 -94
  121. package/dist/logger-BYU66ENZ.js.map +0 -1
  122. package/dist/openapi-generator-VB0MUH8h.js.map +0 -1
  123. package/dist/schema-editor-routes-BqjzvvWU.js +0 -248
  124. package/dist/schema-editor-routes-BqjzvvWU.js.map +0 -1
  125. package/dist/src-Ca6NhxKs.js.map +0 -1
  126. package/dist/src-DVb5a0bp.js.map +0 -1
@@ -2,16 +2,16 @@ 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-DVb5a0bp.js";
6
- import "./src-Ca6NhxKs.js";
7
- import { i as isSQLAdmin, r as revokeInternalTableSql, t as createDdlBootstrapper } from "./ddl-bootstrap-Bp3mr6Td.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
15
  import path from "node:path";
16
16
  import { getConnInfo } from "@hono/node-server/conninfo";
17
17
  //#region ../types/src/controllers/storage.ts
@@ -74,6 +74,290 @@ function normalizeEmail(email) {
74
74
  return typeof email === "string" ? email.trim().toLowerCase() : email;
75
75
  }
76
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
77
361
  //#region src/auth/api-keys/api-key-permission-guard.ts
78
362
  /**
79
363
  * Map an HTTP method string to an `ApiKeyOperation`.
@@ -148,9 +432,116 @@ function isFunctionAllowed(permissions, functionName, operation) {
148
432
  return false;
149
433
  }
150
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
151
517
  //#region src/auth/rls-scope.ts
152
518
  var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
153
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
+ /**
154
545
  * Returns true if the driver supports RLS scoping via `withAuth()`.
155
546
  */
156
547
  function isRLSScopedDriver(driver) {
@@ -443,7 +834,7 @@ function createApiKeyPreAuth(options) {
443
834
  //#endregion
444
835
  //#region src/storage/keys.ts
445
836
  /**
446
- * Canonical storage keys.
837
+ * Canonical storage keys and bucket names.
447
838
  *
448
839
  * Storage is not under RLS, so a `storageAuthorize` hook is the whole access
449
840
  * control model — and a hook can only be correct if the key it is shown is the
@@ -507,9 +898,12 @@ function canonicalStorageKey(rawKey) {
507
898
  if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
508
899
  const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
509
900
  if (withoutLeadingSlashes === "") return "";
901
+ const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
510
902
  const normalized = path.posix.normalize(withoutLeadingSlashes);
511
903
  if (normalized === "." || normalized === "./") return "";
512
- return normalized.replace(/^\.\//, "").replace(/^\/+/, "");
904
+ const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
905
+ if (key === "") return "";
906
+ return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
513
907
  }
514
908
  /**
515
909
  * Canonicalize, or return `null` when the key is not canonicalizable.
@@ -525,6 +919,49 @@ function tryCanonicalStorageKey(rawKey) {
525
919
  return null;
526
920
  }
527
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
+ }
528
965
  //#endregion
529
966
  //#region src/auth/middleware.ts
530
967
  /**
@@ -565,6 +1002,8 @@ var requireAuth = async (c, next) => {
565
1002
  function createRequireAuth(options) {
566
1003
  if (!options?.serviceKey) return requireAuth;
567
1004
  const key = options.serviceKey;
1005
+ const resolveRoles = options.resolveRoles;
1006
+ const revocationRepo = options.revocationRepo;
568
1007
  return async (c, next) => {
569
1008
  if (c.get("user")) return next();
570
1009
  const token = extractBearerToken(c.req.header("authorization"));
@@ -584,6 +1023,26 @@ function createRequireAuth(options) {
584
1023
  message: "Invalid or expired token",
585
1024
  code: "UNAUTHORIZED"
586
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
+ }
587
1046
  c.set("user", payload);
588
1047
  return next();
589
1048
  };
@@ -598,9 +1057,7 @@ var requireAdmin = async (c, next) => {
598
1057
  message: "User not authenticated. requireAuth middleware is missing?",
599
1058
  code: "UNAUTHORIZED"
600
1059
  } }, 401);
601
- if (!(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : []).some((role) => {
602
- return role === "admin" || role === "schema-admin";
603
- })) return c.json({ error: {
1060
+ if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return c.json({ error: {
604
1061
  message: "Admin privileges required for this operation",
605
1062
  code: "FORBIDDEN"
606
1063
  } }, 403);
@@ -930,8 +1387,9 @@ function createAdapterAuthMiddleware(options) {
930
1387
  const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
931
1388
  return async (c, next) => {
932
1389
  const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1390
+ const presentedToken = extractBearerToken(c.req.header("authorization"));
933
1391
  if (apiKeyStore) {
934
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
1392
+ const token = presentedToken ?? "";
935
1393
  if (token.startsWith("rk_")) {
936
1394
  const result = await validateApiKey(c, token, {
937
1395
  store: apiKeyStore,
@@ -968,17 +1426,23 @@ function createAdapterAuthMiddleware(options) {
968
1426
  code: "INTERNAL_ERROR"
969
1427
  } }, 500);
970
1428
  }
971
- } else try {
972
- c.set("driver", await scopeDataDriver(driver, {
973
- uid: ANONYMOUS_USER_ID,
974
- roles: ["anon"]
975
- }));
976
- } catch (error) {
977
- logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
978
- return c.json({ error: {
979
- message: "Server configuration error",
980
- code: "INTERNAL_ERROR"
981
- } }, 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
+ }
982
1446
  }
983
1447
  if (enforceAuth && !c.get("user")) return c.json({ error: {
984
1448
  message: "Unauthorized: Authentication required",
@@ -988,43 +1452,141 @@ function createAdapterAuthMiddleware(options) {
988
1452
  };
989
1453
  }
990
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
991
1532
  //#region src/email/templates.ts
992
1533
  /**
993
- * 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.
994
1551
  */
995
1552
  function getGreeting(user) {
996
1553
  return user.displayName || user.email.split("@")[0];
997
1554
  }
998
1555
  /**
999
- * 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.)
1000
1562
  */
1001
1563
  var styles = {
1002
- container: `
1564
+ container: raw(`
1003
1565
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1004
1566
  max-width: 600px;
1005
1567
  margin: 0 auto;
1006
1568
  padding: 40px 20px;
1007
1569
  background-color: #f8fafc;
1008
- `,
1009
- card: `
1570
+ `),
1571
+ card: raw(`
1010
1572
  background-color: #ffffff;
1011
1573
  border-radius: 12px;
1012
1574
  padding: 40px;
1013
1575
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
1014
- `,
1015
- heading: `
1576
+ `),
1577
+ heading: raw(`
1016
1578
  color: #1e293b;
1017
1579
  font-size: 24px;
1018
1580
  font-weight: 600;
1019
1581
  margin: 0 0 20px 0;
1020
- `,
1021
- paragraph: `
1582
+ `),
1583
+ paragraph: raw(`
1022
1584
  color: #475569;
1023
1585
  font-size: 16px;
1024
1586
  line-height: 1.6;
1025
1587
  margin: 0 0 20px 0;
1026
- `,
1027
- button: `
1588
+ `),
1589
+ button: raw(`
1028
1590
  display: inline-block;
1029
1591
  background-color: #3b82f6;
1030
1592
  color: #ffffff;
@@ -1034,22 +1596,22 @@ var styles = {
1034
1596
  padding: 14px 28px;
1035
1597
  border-radius: 8px;
1036
1598
  margin: 20px 0;
1037
- `,
1038
- footer: `
1599
+ `),
1600
+ footer: raw(`
1039
1601
  color: #94a3b8;
1040
1602
  font-size: 14px;
1041
1603
  margin-top: 30px;
1042
1604
  padding-top: 20px;
1043
1605
  border-top: 1px solid #e2e8f0;
1044
- `,
1045
- warning: `
1606
+ `),
1607
+ warning: raw(`
1046
1608
  color: #64748b;
1047
1609
  font-size: 14px;
1048
1610
  background-color: #fef3c7;
1049
1611
  padding: 12px 16px;
1050
1612
  border-radius: 6px;
1051
1613
  margin-top: 20px;
1052
- `
1614
+ `)
1053
1615
  };
1054
1616
  /**
1055
1617
  * Default password reset email template
@@ -1057,9 +1619,7 @@ var styles = {
1057
1619
  function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
1058
1620
  const greeting = getGreeting(user);
1059
1621
  const subject = `Reset your ${appName} password`;
1060
- return {
1061
- subject,
1062
- html: `
1622
+ const body = html`
1063
1623
  <!DOCTYPE html>
1064
1624
  <html>
1065
1625
  <head>
@@ -1106,8 +1666,8 @@ function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
1106
1666
  </div>
1107
1667
  </body>
1108
1668
  </html>
1109
- `.trim(),
1110
- text: `
1669
+ `;
1670
+ const text = `
1111
1671
  Reset Your Password
1112
1672
 
1113
1673
  Hi ${greeting},
@@ -1121,7 +1681,11 @@ This link will expire in 1 hour for security reasons.
1121
1681
 
1122
1682
  If you didn't request a password reset, you can safely ignore this email.
1123
1683
  Your password will remain unchanged.
1124
- `.trim()
1684
+ `.trim();
1685
+ return {
1686
+ subject,
1687
+ html: body.toString().trim(),
1688
+ text
1125
1689
  };
1126
1690
  }
1127
1691
  /**
@@ -1130,9 +1694,7 @@ Your password will remain unchanged.
1130
1694
  function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1131
1695
  const greeting = getGreeting(user);
1132
1696
  const subject = `Verify your ${appName} email address`;
1133
- return {
1134
- subject,
1135
- html: `
1697
+ const body = html`
1136
1698
  <!DOCTYPE html>
1137
1699
  <html>
1138
1700
  <head>
@@ -1174,8 +1736,8 @@ function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1174
1736
  </div>
1175
1737
  </body>
1176
1738
  </html>
1177
- `.trim(),
1178
- text: `
1739
+ `;
1740
+ const text = `
1179
1741
  Verify Your Email
1180
1742
 
1181
1743
  Hi ${greeting},
@@ -1184,7 +1746,11 @@ Thanks for signing up for ${appName}! Please verify your email address by clicki
1184
1746
  ${verifyUrl}
1185
1747
 
1186
1748
  If you didn't create an account with ${appName}, you can safely ignore this email.
1187
- `.trim()
1749
+ `.trim();
1750
+ return {
1751
+ subject,
1752
+ html: body.toString().trim(),
1753
+ text
1188
1754
  };
1189
1755
  }
1190
1756
  /**
@@ -1194,9 +1760,7 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
1194
1760
  function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1195
1761
  const greeting = getGreeting(user);
1196
1762
  const subject = `You've been invited to ${appName}`;
1197
- return {
1198
- subject,
1199
- html: `
1763
+ const body = html`
1200
1764
  <!DOCTYPE html>
1201
1765
  <html>
1202
1766
  <head>
@@ -1242,8 +1806,8 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1242
1806
  </div>
1243
1807
  </body>
1244
1808
  </html>
1245
- `.trim(),
1246
- text: `
1809
+ `;
1810
+ const text = `
1247
1811
  Welcome to ${appName}!
1248
1812
 
1249
1813
  Hi ${greeting},
@@ -1256,7 +1820,11 @@ ${setPasswordUrl}
1256
1820
  This link will expire in 1 hour for security reasons.
1257
1821
 
1258
1822
  If you weren't expecting this invitation, you can safely ignore this email.
1259
- `.trim()
1823
+ `.trim();
1824
+ return {
1825
+ subject,
1826
+ html: body.toString().trim(),
1827
+ text
1260
1828
  };
1261
1829
  }
1262
1830
  /**
@@ -1267,9 +1835,7 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1267
1835
  const greeting = getGreeting(user);
1268
1836
  const url = loginUrl || "";
1269
1837
  const subject = `¡Bienvenido/a a ${appName}!`;
1270
- return {
1271
- subject,
1272
- html: `
1838
+ const body = html`
1273
1839
  <!DOCTYPE html>
1274
1840
  <html>
1275
1841
  <head>
@@ -1296,11 +1862,11 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1296
1862
  que tenemos para ti.
1297
1863
  </p>
1298
1864
 
1299
- ${url ? `
1865
+ ${url ? html`
1300
1866
  <div style="text-align: center;">
1301
1867
  <a href="${url}" style="${styles.button}">Ir a mi Panel</a>
1302
1868
  </div>
1303
- ` : ""}
1869
+ ` : raw("")}
1304
1870
 
1305
1871
  <p style="${styles.paragraph}">
1306
1872
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
@@ -1315,8 +1881,8 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1315
1881
  </div>
1316
1882
  </body>
1317
1883
  </html>
1318
- `.trim(),
1319
- text: `
1884
+ `;
1885
+ const text = `
1320
1886
  ¡Bienvenido/a a ${appName}!
1321
1887
 
1322
1888
  Hola ${greeting},
@@ -1328,7 +1894,11 @@ Ya puedes acceder a tu panel y empezar a explorar todas las oportunidades que te
1328
1894
  ${url ? `Ir a mi panel: ${url}` : ""}
1329
1895
 
1330
1896
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1331
- `.trim()
1897
+ `.trim();
1898
+ return {
1899
+ subject,
1900
+ html: body.toString().trim(),
1901
+ text
1332
1902
  };
1333
1903
  }
1334
1904
  /**
@@ -1337,9 +1907,7 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1337
1907
  function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1338
1908
  const greeting = getGreeting(user);
1339
1909
  const subject = `Sign in to ${appName}`;
1340
- return {
1341
- subject,
1342
- html: `
1910
+ const body = html`
1343
1911
  <!DOCTYPE html>
1344
1912
  <html>
1345
1913
  <head>
@@ -1386,8 +1954,8 @@ function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1386
1954
  </div>
1387
1955
  </body>
1388
1956
  </html>
1389
- `.trim(),
1390
- text: `
1957
+ `;
1958
+ const text = `
1391
1959
  Sign In to ${appName}
1392
1960
 
1393
1961
  Hi ${greeting},
@@ -1401,7 +1969,11 @@ This link will expire in 15 minutes for security reasons and can only be used on
1401
1969
 
1402
1970
  If you didn't request this sign-in link, you can safely ignore this email.
1403
1971
  No action is needed.
1404
- `.trim()
1972
+ `.trim();
1973
+ return {
1974
+ subject,
1975
+ html: body.toString().trim(),
1976
+ text
1405
1977
  };
1406
1978
  }
1407
1979
  //#endregion
@@ -1668,6 +2240,196 @@ function resolveAuthHooks(hooks) {
1668
2240
  };
1669
2241
  }
1670
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
1671
2433
  //#region src/auth/rate-limit-store.ts
1672
2434
  /**
1673
2435
  * The default store: a sliding window in this process's memory.
@@ -1733,13 +2495,32 @@ var MemoryRateLimitStore = class {
1733
2495
  //#region src/auth/rate-limiter.ts
1734
2496
  /**
1735
2497
  * Resolve the number of trusted proxy hops from an explicit option, the
1736
- * `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.
1737
2502
  */
1738
2503
  function resolveTrustedProxyHops(optionValue) {
1739
2504
  if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue >= 0) return Math.floor(optionValue);
1740
2505
  const fromEnv = Number(process.env.TRUSTED_PROXY_HOPS);
1741
2506
  if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.floor(fromEnv);
1742
- 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.");
1743
2524
  }
1744
2525
  /**
1745
2526
  * Create a rate-limiting middleware.
@@ -1810,7 +2591,11 @@ function socketAddress(c) {
1810
2591
  * anonymous callers throttle each other than that any of them throttles nobody.
1811
2592
  */
1812
2593
  function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1813
- if (trustedProxyHops > 0) {
2594
+ if (trustedProxyHops === 0) {
2595
+ warnIfProxiedButUntrusted(!!c.req.header("x-forwarded-for"));
2596
+ return socketAddress(c) ?? "unknown";
2597
+ }
2598
+ {
1814
2599
  const forwardedFor = c.req.header("x-forwarded-for");
1815
2600
  if (forwardedFor) {
1816
2601
  const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
@@ -1840,6 +2625,34 @@ var strictAuthLimiter = createRateLimiter({
1840
2625
  message: "Too many requests to this sensitive endpoint, please try again later."
1841
2626
  });
1842
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
+ /**
1843
2656
  * Key generator for API-key-based rate limiting.
1844
2657
  *
1845
2658
  * Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
@@ -1851,6 +2664,8 @@ function apiKeyKeyGenerator(c) {
1851
2664
  if (apiKey) return `api-key:${apiKey.id}`;
1852
2665
  return defaultKeyGenerator(c);
1853
2666
  }
2667
+ /** @see DataRateLimitConfig.anonymousFunctions */
2668
+ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
1854
2669
  /**
1855
2670
  * Rate limiting for the data API.
1856
2671
  *
@@ -1870,6 +2685,7 @@ function apiKeyKeyGenerator(c) {
1870
2685
  */
1871
2686
  function createDataRateLimiter(config = {}) {
1872
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);
1873
2689
  return createRateLimiter({
1874
2690
  windowMs,
1875
2691
  store,
@@ -1879,7 +2695,7 @@ function createDataRateLimiter(config = {}) {
1879
2695
  if (key) return `api-key:${key.id}`;
1880
2696
  const user = c.get("user");
1881
2697
  if (user?.uid && !isAnonymousUid(user.uid)) return `user:${user.uid}`;
1882
- return `ip:${defaultKeyGenerator(c)}`;
2698
+ return `ip:${defaultKeyGenerator(c, trustedProxyHops)}`;
1883
2699
  },
1884
2700
  resolveLimit: (c) => {
1885
2701
  const key = c.get("apiKey");
@@ -5896,17 +6712,27 @@ function generateHotp(secret, counter) {
5896
6712
  return (((hash[offset] & 127) << 24 | hash[offset + 1] << 16 | hash[offset + 2] << 8 | hash[offset + 3]) % 1e6).toString().padStart(6, "0");
5897
6713
  }
5898
6714
  /**
5899
- * 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.
5900
6723
  *
5901
6724
  * @param secret - The shared secret as a Buffer
5902
6725
  * @param token - The 6-digit TOTP code to verify
5903
6726
  * @param window - Number of time steps to check on each side (default: 1)
5904
- * @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
5905
6728
  */
5906
- function verifyTotp(secret, token, window = 1) {
6729
+ function verifyTotpCounter(secret, token, window = 1) {
5907
6730
  const counter = BigInt(Math.floor(Date.now() / 1e3 / 30));
5908
- for (let i = -window; i <= window; i++) if (generateHotp(secret, counter + BigInt(i)) === token) return true;
5909
- 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;
5910
6736
  }
5911
6737
  /**
5912
6738
  * Generate a new TOTP secret and return the setup information
@@ -5949,79 +6775,335 @@ function hashRecoveryCode(code) {
5949
6775
  *
5950
6776
  * - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
5951
6777
  * - Generates a random 12-byte IV per encryption call.
5952
- * - 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.
5953
6808
  *
5954
6809
  * @module
5955
6810
  */
5956
6811
  var ALGORITHM = "aes-256-gcm";
5957
6812
  var IV_BYTES = 12;
5958
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
+ }
5959
6826
  /**
5960
- * Resolve the raw key string from environment variables.
6827
+ * Every key this process can decrypt with, most-preferred first.
5961
6828
  *
5962
- * Prefers `MFA_ENCRYPTION_KEY`; falls back to `JWT_SECRET` with a warning.
5963
- * 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.
5964
6831
  */
5965
- function resolveKeyString() {
5966
- const explicit = process.env.MFA_ENCRYPTION_KEY;
5967
- if (explicit) return explicit;
5968
- const fallback = process.env.JWT_SECRET;
5969
- if (fallback) {
5970
- logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production.");
5971
- 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.");
5972
6848
  }
5973
- throw new Error("Cannot encrypt TOTP secret: neither MFA_ENCRYPTION_KEY nor JWT_SECRET is configured.");
6849
+ return candidates;
5974
6850
  }
5975
- /**
5976
- * Derive a deterministic 32-byte AES-256 key from the raw secret string.
5977
- */
5978
- function deriveKey(raw) {
5979
- return createHash$1("sha256").update(raw).digest();
6851
+ /** The key new ciphertexts are written with. */
6852
+ function currentKey() {
6853
+ return keyCandidates()[0];
5980
6854
  }
5981
6855
  /**
5982
6856
  * Encrypt a plaintext TOTP secret.
5983
6857
  *
5984
6858
  * @param plaintext - The Base32 TOTP secret to encrypt.
5985
- * @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`.
5986
6860
  */
5987
6861
  function encryptTotpSecret(plaintext) {
5988
- const key = deriveKey(resolveKeyString());
6862
+ const { id, key } = currentKey();
5989
6863
  const iv = randomBytes$1(IV_BYTES);
5990
6864
  const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5991
6865
  const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
5992
6866
  const authTag = cipher.getAuthTag();
5993
- 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");
5994
6873
  }
5995
6874
  /**
5996
- * Decrypt a previously encrypted TOTP secret.
6875
+ * Decrypt a stored TOTP secret, reporting whether it wants re-wrapping.
5997
6876
  *
5998
- * @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
5999
- * @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.
6000
6879
  */
6001
- function decryptTotpSecret(ciphertext) {
6880
+ function openTotpSecret(ciphertext) {
6002
6881
  const parts = ciphertext.split(":");
6003
- 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");
6004
6897
  const [ivHex, authTagHex, encryptedHex] = parts;
6005
- const key = deriveKey(resolveKeyString());
6006
- const iv = Buffer.from(ivHex, "hex");
6007
- const authTag = Buffer.from(authTagHex, "hex");
6008
- const encrypted = Buffer.from(encryptedHex, "hex");
6009
- const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
6010
- decipher.setAuthTag(authTag);
6011
- 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(", ")}.`);
6012
6906
  }
6013
6907
  //#endregion
6014
- //#region src/auth/mfa-routes.ts
6015
- function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
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
+ };
6975
+ }
6976
+ //#endregion
6977
+ //#region src/auth/mfa-routes.ts
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;
6016
7063
  const authRepo = config.authRepo;
6017
7064
  const emailConfig = config.emailConfig;
6018
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
+ /**
6019
7100
  * POST /auth/mfa/enroll
6020
7101
  * Start MFA enrollment: generate TOTP secret and recovery codes
6021
7102
  */
6022
- router.post("/mfa/enroll", requireAuth, async (c) => {
7103
+ router.post("/mfa/enroll", strictAuthLimiter, requireAuth, async (c) => {
6023
7104
  const userCtx = c.get("user");
6024
7105
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
7106
+ await requireStepUpForFactorChange(userCtx);
6025
7107
  const body = await c.req.json().catch(() => ({}));
6026
7108
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
6027
7109
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
@@ -6051,17 +7133,20 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6051
7133
  * POST /auth/mfa/verify
6052
7134
  * Verify TOTP code to complete MFA enrollment
6053
7135
  */
6054
- router.post("/mfa/verify", requireAuth, async (c) => {
7136
+ router.post("/mfa/verify", strictAuthLimiter, requireAuth, mfaVerificationLimiter, async (c) => {
6055
7137
  const userCtx = c.get("user");
6056
7138
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6057
- const { factorId, code } = parseBody(object({
7139
+ await requireStepUpForFactorChange(userCtx);
7140
+ const verifySchema = object({
6058
7141
  factorId: string().min(1, "Factor ID is required"),
6059
7142
  code: string().length(6, "Code must be 6 digits")
6060
- }), await c.req.json());
7143
+ });
7144
+ const { factorId, code } = parseBody(verifySchema, await c.req.json());
6061
7145
  const factor = await authRepo.getMfaFactorById(factorId);
6062
7146
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6063
7147
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
6064
- 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");
6065
7150
  await authRepo.verifyMfaFactor(factorId);
6066
7151
  return c.json({
6067
7152
  success: true,
@@ -6070,14 +7155,16 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6070
7155
  });
6071
7156
  /**
6072
7157
  * POST /auth/mfa/challenge
6073
- * 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`.
6074
7160
  */
6075
- router.post("/mfa/challenge", requireAuth, async (c) => {
6076
- const userCtx = c.get("user");
6077
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6078
- 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());
6079
7166
  const factor = await authRepo.getMfaFactorById(factorId);
6080
- 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");
6081
7168
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
6082
7169
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
6083
7170
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -6089,49 +7176,73 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6089
7176
  });
6090
7177
  /**
6091
7178
  * POST /auth/mfa/challenge/verify
6092
- * 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`.
6093
7181
  */
6094
- router.post("/mfa/challenge/verify", requireAuth, async (c) => {
6095
- const userCtx = c.get("user");
6096
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6097
- 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({
6098
7186
  challengeId: string().min(1, "Challenge ID is required"),
6099
7187
  code: string().min(1, "Code is required")
6100
- }), await c.req.json());
7188
+ });
7189
+ const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
6101
7190
  const challenge = await authRepo.getMfaChallengeById(challengeId);
6102
7191
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
6103
7192
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
6104
- if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6105
- let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
6106
- 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) {
6107
7205
  const codeHash = hashRecoveryCode(code);
6108
- 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");
6109
7217
  }
6110
- if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
6111
7218
  await authRepo.verifyMfaChallenge(challengeId);
6112
- const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
6113
- const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
6114
- const refreshToken = generateRefreshToken();
6115
- await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
6116
- id: randomUUID$1(),
6117
- 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"
6118
7222
  });
6119
- if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
7223
+ if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
6120
7224
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
6121
7225
  });
6122
- 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: {
6123
7234
  accessToken,
6124
7235
  refreshToken,
6125
7236
  accessTokenExpiresAt: getAccessTokenExpiry()
6126
7237
  } };
6127
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
6128
- 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));
6129
7240
  });
6130
7241
  /**
6131
7242
  * GET /auth/mfa/factors
6132
7243
  * List enrolled MFA factors for the current user
6133
7244
  */
6134
- router.get("/mfa/factors", requireAuth, async (c) => {
7245
+ router.get("/mfa/factors", strictAuthLimiter, requireAuth, async (c) => {
6135
7246
  const userCtx = c.get("user");
6136
7247
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6137
7248
  const factors = await authRepo.getMfaFactors(userCtx.uid);
@@ -6147,11 +7258,12 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6147
7258
  * DELETE /auth/mfa/unenroll
6148
7259
  * Remove an MFA factor
6149
7260
  */
6150
- router.delete("/mfa/unenroll", requireAuth, async (c) => {
7261
+ router.delete("/mfa/unenroll", strictAuthLimiter, requireAuth, async (c) => {
6151
7262
  const userCtx = c.get("user");
6152
7263
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6153
7264
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
6154
- 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());
6155
7267
  const factor = await authRepo.getMfaFactorById(factorId);
6156
7268
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6157
7269
  await authRepo.deleteMfaFactor(factorId, userCtx.uid);
@@ -6163,72 +7275,51 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6163
7275
  });
6164
7276
  }
6165
7277
  //#endregion
6166
- //#region src/auth/cookie-utils.ts
7278
+ //#region src/auth/mfa-gate.ts
6167
7279
  /**
6168
- * Cookie auth helpers for httpOnly refresh tokens.
6169
- */
6170
- function getCookieSettings(config) {
6171
- return {
6172
- name: config?.cookieName || "__rb_refresh",
6173
- path: config?.path || "/",
6174
- sameSite: config?.sameSite || "Lax",
6175
- domain: config?.domain,
6176
- secure: config?.secure
6177
- };
6178
- }
6179
- /**
6180
- * Set the refresh token as an httpOnly cookie on the response.
6181
- */
6182
- function setRefreshCookie(c, refreshToken, config) {
6183
- if (!config) return;
6184
- const settings = getCookieSettings(config);
6185
- const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
6186
- let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6187
- if (isSecure) cookie += "; Secure";
6188
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6189
- const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6190
- cookie += `; Max-Age=${maxAgeSeconds}`;
6191
- c.header("Set-Cookie", cookie, { append: true });
6192
- }
6193
- /**
6194
- * 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
6195
7292
  */
6196
- function clearRefreshCookie(c, config) {
6197
- if (!config) return;
6198
- const settings = getCookieSettings(config);
6199
- let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6200
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6201
- c.header("Set-Cookie", cookie, { append: true });
6202
- }
7293
+ /** Error code a client watches for to switch a sign-in into its second step. */
7294
+ var MFA_REQUIRED_CODE = "MFA_REQUIRED";
6203
7295
  /**
6204
- * 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.
6205
7302
  */
6206
- function readRefreshToken(c, body, config) {
6207
- if (config) {
6208
- const settings = getCookieSettings(config);
6209
- const cookieHeader = c.req.header("cookie") || "";
6210
- const prefix = `${settings.name}=`;
6211
- const cookies = cookieHeader.split(";");
6212
- for (const part of cookies) {
6213
- const trimmed = part.trim();
6214
- if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6215
- }
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;
6216
7307
  }
6217
- return body.refreshToken;
6218
- }
6219
- /**
6220
- * Redact the refresh token from the response body when cookie mode is active.
6221
- */
6222
- function redactRefreshToken(response, c, refreshToken, config) {
6223
- if (!config) return response;
6224
- setRefreshCookie(c, refreshToken, config);
6225
- return {
6226
- ...response,
6227
- tokens: {
6228
- ...response.tokens,
6229
- refreshToken: ""
6230
- }
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
+ }))
6231
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);
6232
7323
  }
6233
7324
  //#endregion
6234
7325
  //#region src/auth/registration-policy.ts
@@ -6250,11 +7341,58 @@ function isSteadyStateRegistrationOpen(policy) {
6250
7341
  needsSetup: false
6251
7342
  });
6252
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
+ }
6253
7375
  //#endregion
6254
7376
  //#region src/auth/session-routes.ts
6255
7377
  function mountSessionRoutes(opts) {
6256
7378
  const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
6257
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
+ }
6258
7396
  const logoutSchema = object({ refreshToken: string().optional() });
6259
7397
  const linkSchema = object({
6260
7398
  email: string().email("Invalid email address").max(255),
@@ -6281,7 +7419,7 @@ function mountSessionRoutes(opts) {
6281
7419
  clearRefreshCookie(c, config.cookieAuth);
6282
7420
  const accessToken = extractBearerToken(c.req.header("authorization"));
6283
7421
  if (ops.afterLogout && accessToken !== void 0) {
6284
- const { verifyAccessToken } = await import("./jwt-CzeviDcB.js").then((n) => n.d);
7422
+ const { verifyAccessToken } = await import("./jwt-_IFqfTOg.js").then((n) => n.f);
6285
7423
  const payload = verifyAccessToken(accessToken);
6286
7424
  if (payload) ops.afterLogout(payload.uid).catch((err) => {
6287
7425
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
@@ -6447,6 +7585,10 @@ function mountSessionRoutes(opts) {
6447
7585
  registrationEnabled: registrationAllowed,
6448
7586
  emailServiceEnabled: isEmailConfigured(),
6449
7587
  magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
7588
+ anonymousLoginEnabled: isAnonymousAuthOpen({
7589
+ allowAnonymous: config.allowAnonymous,
7590
+ disableSelfRegistration: config.disableSelfRegistration
7591
+ }),
6450
7592
  enabledProviders
6451
7593
  });
6452
7594
  });
@@ -6455,6 +7597,7 @@ function mountSessionRoutes(opts) {
6455
7597
  * Create an anonymous user with temporary credentials
6456
7598
  */
6457
7599
  router.post("/anonymous", strictAuthLimiter, async (c) => {
7600
+ assertAnonymousAuthOpen();
6458
7601
  let createData = {
6459
7602
  email: `anon_${randomBytes$1(16).toString("hex")}@anonymous.local`,
6460
7603
  emailVerified: false,
@@ -6478,7 +7621,8 @@ function mountSessionRoutes(opts) {
6478
7621
  * POST /auth/anonymous/link
6479
7622
  * Upgrade an anonymous user to a permanent account with email/password
6480
7623
  */
6481
- router.post("/anonymous/link", requireAuth, async (c) => {
7624
+ router.post("/anonymous/link", strictAuthLimiter, requireAuth, async (c) => {
7625
+ assertAnonymousAuthOpen();
6482
7626
  const userCtx = c.get("user");
6483
7627
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6484
7628
  const user = await authRepo.getUserById(userCtx.uid);
@@ -6535,7 +7679,7 @@ function mountMagicLinkRoutes(deps) {
6535
7679
  const tokenHash = hashToken(token);
6536
7680
  const expiresAt = getMagicLinkExpiry();
6537
7681
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
6538
- const magicLinkUrl = `${emailConfig?.magicLinkUrl || emailConfig?.resetPasswordUrl || ""}/auth/magic-link?token=${token}`;
7682
+ const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
6539
7683
  const appName = emailConfig?.appName || "Rebase";
6540
7684
  const templateFn = emailConfig?.templates?.magicLink;
6541
7685
  const emailContent = templateFn ? templateFn(magicLinkUrl, {
@@ -6587,6 +7731,64 @@ function mountMagicLinkRoutes(deps) {
6587
7731
  });
6588
7732
  }
6589
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
6590
7792
  //#region src/auth/routes.ts
6591
7793
  /**
6592
7794
  * Helper to build standard auth response output
@@ -6618,7 +7820,7 @@ function getPasswordResetExpiry() {
6618
7820
  return new Date(Date.now() + 3600 * 1e3);
6619
7821
  }
6620
7822
  function createAuthRoutes(config) {
6621
- 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.`);
6622
7824
  const router = new Hono();
6623
7825
  router.onError(errorHandler);
6624
7826
  const authRepo = config.authRepo;
@@ -6701,7 +7903,7 @@ function createAuthRoutes(config) {
6701
7903
  function sendWelcomeEmail(user) {
6702
7904
  if (!isEmailConfigured()) return;
6703
7905
  const appName = emailConfig?.appName || "Rebase";
6704
- const loginUrl = emailConfig?.resetPasswordUrl || "";
7906
+ const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
6705
7907
  const templateFn = emailConfig?.templates?.welcomeEmail;
6706
7908
  const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0);
6707
7909
  emailService.send({
@@ -6714,9 +7916,21 @@ function createAuthRoutes(config) {
6714
7916
  });
6715
7917
  }
6716
7918
  /**
6717
- * 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`.
6718
7930
  */
6719
- 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";
6720
7934
  const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
6721
7935
  let customClaims;
6722
7936
  if (ops.customizeAccessToken) {
@@ -6725,16 +7939,17 @@ function createAuthRoutes(config) {
6725
7939
  const defaultClaims = {
6726
7940
  uid,
6727
7941
  roles: roleIds,
6728
- aal: "aal1"
7942
+ aal
6729
7943
  };
6730
7944
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
6731
7945
  }
6732
7946
  }
6733
- const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
7947
+ const accessToken = generateAccessToken(uid, roleIds, aal, customClaims);
6734
7948
  const refreshToken = generateRefreshToken();
6735
7949
  await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
6736
7950
  id: randomUUID$1(),
6737
- startedAt: /* @__PURE__ */ new Date()
7951
+ startedAt: /* @__PURE__ */ new Date(),
7952
+ aal
6738
7953
  });
6739
7954
  return {
6740
7955
  roleIds,
@@ -6832,29 +8047,46 @@ function createAuthRoutes(config) {
6832
8047
  if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
6833
8048
  router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
6834
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");
6835
8052
  let externalUser;
6836
8053
  try {
6837
8054
  externalUser = await provider.verify(payload);
6838
8055
  } catch (err) {
6839
- const msg = err instanceof Error ? err.message : String(err);
6840
- 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");
6841
8058
  }
6842
8059
  if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
6843
8060
  let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
6844
8061
  if (!user) {
6845
8062
  user = await authRepo.getUserByEmail(externalUser.email);
6846
8063
  if (user) {
6847
- 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
+ }
6848
8072
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6849
8073
  await authRepo.updateUser(user.id, {
6850
8074
  displayName: user.displayName || externalUser.displayName || void 0,
6851
8075
  photoUrl: user.photoUrl || externalUser.photoUrl || void 0
6852
8076
  });
6853
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
+ }
6854
8085
  user = await authRepo.createUser({
6855
8086
  email: normalizeEmail(externalUser.email),
6856
8087
  displayName: externalUser.displayName || void 0,
6857
- photoUrl: externalUser.photoUrl || void 0
8088
+ photoUrl: externalUser.photoUrl || void 0,
8089
+ emailVerified: externalUser.emailVerified === true
6858
8090
  });
6859
8091
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6860
8092
  if (ops.afterUserCreate) try {
@@ -6863,7 +8095,12 @@ function createAuthRoutes(config) {
6863
8095
  logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
6864
8096
  }
6865
8097
  const allUsers = await authRepo.listUsers();
6866
- 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"]);
6867
8104
  else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
6868
8105
  sendWelcomeEmail({
6869
8106
  email: user.email,
@@ -6937,7 +8174,7 @@ function createAuthRoutes(config) {
6937
8174
  const tokenHash = hashToken(token);
6938
8175
  const expiresAt = getPasswordResetExpiry();
6939
8176
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
6940
- const resetUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
8177
+ const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
6941
8178
  const appName = emailConfig?.appName || "Rebase";
6942
8179
  const templateFn = emailConfig?.templates?.passwordReset;
6943
8180
  const emailContent = templateFn ? templateFn(resetUrl, {
@@ -7012,8 +8249,15 @@ function createAuthRoutes(config) {
7012
8249
  /**
7013
8250
  * POST /auth/send-verification
7014
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.
7015
8259
  */
7016
- router.post("/send-verification", requireAuth, async (c) => {
8260
+ router.post("/send-verification", strictAuthLimiter, requireAuth, verificationEmailLimiter, async (c) => {
7017
8261
  const userCtx = c.get("user");
7018
8262
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
7019
8263
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
@@ -7022,7 +8266,7 @@ function createAuthRoutes(config) {
7022
8266
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
7023
8267
  const token = generateSecureToken();
7024
8268
  await authRepo.setVerificationToken(user.id, hashToken(token));
7025
- const verifyUrl = `${emailConfig?.verifyEmailUrl || ""}/verify-email?token=${token}`;
8269
+ const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
7026
8270
  const appName = emailConfig?.appName || "Rebase";
7027
8271
  const templateFn = emailConfig?.templates?.emailVerification;
7028
8272
  const emailContent = templateFn ? templateFn(verifyUrl, {
@@ -7104,22 +8348,24 @@ function createAuthRoutes(config) {
7104
8348
  });
7105
8349
  return null;
7106
8350
  });
8351
+ const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
7107
8352
  let customClaims;
7108
8353
  if (ops.customizeAccessToken && user) {
7109
8354
  const defaultClaims = {
7110
8355
  uid: storedToken.uid,
7111
8356
  roles: roleIds,
7112
- aal: "aal1"
8357
+ aal: sessionAal
7113
8358
  };
7114
8359
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
7115
8360
  }
7116
- const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
8361
+ const newAccessToken = generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims);
7117
8362
  const newRefreshToken = generateRefreshToken();
7118
8363
  const userAgent = c.req.header("user-agent") || "unknown";
7119
8364
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
7120
8365
  const session = {
7121
8366
  id: storedToken.sessionId ?? storedToken.id,
7122
- startedAt: sessionStartedAt
8367
+ startedAt: sessionStartedAt,
8368
+ aal: sessionAal
7123
8369
  };
7124
8370
  if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
7125
8371
  else await authRepo.deleteRefreshToken(tokenHash);
@@ -7151,7 +8397,15 @@ function createAuthRoutes(config) {
7151
8397
  createSessionAndTokens,
7152
8398
  applyTransformHook
7153
8399
  });
7154
- mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
8400
+ mountMfaRoutes({
8401
+ router,
8402
+ config,
8403
+ ops,
8404
+ parseBody,
8405
+ buildAuthResponse,
8406
+ createSessionAndTokens,
8407
+ applyTransformHook
8408
+ });
7155
8409
  if (config.enableMagicLink) mountMagicLinkRoutes({
7156
8410
  router,
7157
8411
  config,
@@ -7184,7 +8438,11 @@ function createResetPasswordRoute(config) {
7184
8438
  const { emailService, emailConfig, collectionAuthConfig } = config;
7185
8439
  const ops = resolveAuthHooks(config.authHooks);
7186
8440
  router.onError(errorHandler);
7187
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8441
+ router.use("/*", createRequireAuth({
8442
+ serviceKey: config.serviceKey,
8443
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8444
+ revocationRepo: authRepo
8445
+ }));
7188
8446
  router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
7189
8447
  const uid = c.req.param("uid");
7190
8448
  const existing = await authRepo.getUserById(uid);
@@ -7287,7 +8545,11 @@ function createAdminRolesRoute(config) {
7287
8545
  const router = new Hono();
7288
8546
  const authRepo = config.authRepo;
7289
8547
  router.onError(errorHandler);
7290
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8548
+ router.use("/*", createRequireAuth({
8549
+ serviceKey: config.serviceKey,
8550
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8551
+ revocationRepo: authRepo
8552
+ }));
7291
8553
  router.get("/roles", requireAdmin, async (c) => {
7292
8554
  const roles = await authRepo.listRoles();
7293
8555
  return c.json({ roles });
@@ -7327,7 +8589,11 @@ function createAdminUsersRoute(config) {
7327
8589
  };
7328
8590
  }
7329
8591
  router.onError(errorHandler);
7330
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8592
+ router.use("/*", createRequireAuth({
8593
+ serviceKey: config.serviceKey,
8594
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8595
+ revocationRepo: authRepo
8596
+ }));
7331
8597
  router.post("/bootstrap", async (c) => {
7332
8598
  const user = c.get("user");
7333
8599
  if (!user || typeof user !== "object") throw ApiError.unauthorized("Not authenticated");
@@ -7341,11 +8607,19 @@ function createAdminUsersRoute(config) {
7341
8607
  break;
7342
8608
  }
7343
8609
  if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
7344
- const uid = "uid" in user ? user.uid : "uid" in user ? user.uid : void 0;
8610
+ const uid = "uid" in user ? user.uid : void 0;
7345
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
+ }
7346
8619
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
7347
- if (users.length > 0) {
7348
- 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) => {
7349
8623
  const at = new Date(a.createdAt).getTime();
7350
8624
  const bt = new Date(b.createdAt).getTime();
7351
8625
  if (at !== bt) return at < bt ? a : b;
@@ -7395,7 +8669,7 @@ function createAdminUsersRoute(config) {
7395
8669
  const search = c.req.query("search");
7396
8670
  const orderBy = c.req.query("orderBy");
7397
8671
  const orderDir = c.req.query("orderDir");
7398
- const limit = resolveClientListLimit(limitParam, { defaultLimit: 25 });
8672
+ const limit = resolveListLimitParam(limitParam, { defaultLimit: 25 });
7399
8673
  const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
7400
8674
  const result = await authRepo.listUsersPaginated({
7401
8675
  limit,
@@ -7434,13 +8708,15 @@ function createAdminUsersRoute(config) {
7434
8708
  resolvedHooks: ops,
7435
8709
  collectionAuthConfig
7436
8710
  });
7437
- const user = await authRepo.createUser({
8711
+ let createData = {
7438
8712
  email: prepResult.values.email,
7439
8713
  passwordHash: prepResult.values.passwordHash,
7440
8714
  displayName: prepResult.values.displayName,
7441
8715
  photoUrl: prepResult.values.photoUrl,
7442
8716
  metadata: prepResult.values.metadata
7443
- });
8717
+ };
8718
+ if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
8719
+ const user = await authRepo.createUser(createData);
7444
8720
  if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
7445
8721
  const finalizeResult = await finalizeAdminUserCreation({
7446
8722
  id: user.id,
@@ -7498,7 +8774,11 @@ function createAdminUsersRoute(config) {
7498
8774
  limit: 1
7499
8775
  })).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
7500
8776
  }
8777
+ if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
7501
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
+ });
7502
8782
  return c.json({ success: true });
7503
8783
  });
7504
8784
  return router;
@@ -7513,7 +8793,7 @@ function createAdminUsersRoute(config) {
7513
8793
  * when the user passes a plain `RebaseAuthConfig` object.
7514
8794
  */
7515
8795
  function createBuiltinAuthAdapter(config) {
7516
- 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;
7517
8797
  const resolvedOps = resolveAuthHooks(authHooks);
7518
8798
  return {
7519
8799
  id: "rebase-builtin",
@@ -7530,6 +8810,13 @@ function createBuiltinAuthAdapter(config) {
7530
8810
  };
7531
8811
  const payload = verifyAccessToken(token);
7532
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
+ }
7533
8820
  let roles = payload.roles || [];
7534
8821
  try {
7535
8822
  roles = await authRepository.getUserRoleIds(payload.uid);
@@ -7539,7 +8826,7 @@ function createBuiltinAuthAdapter(config) {
7539
8826
  error: err
7540
8827
  });
7541
8828
  }
7542
- const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
8829
+ const isAdmin = hasAdministrativeRole(roles);
7543
8830
  return {
7544
8831
  uid: payload.uid,
7545
8832
  email: payload.email ?? "",
@@ -7568,7 +8855,7 @@ function createBuiltinAuthAdapter(config) {
7568
8855
  error: err
7569
8856
  });
7570
8857
  }
7571
- const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
8858
+ const isAdmin = hasAdministrativeRole(roles);
7572
8859
  return {
7573
8860
  uid: payload.uid,
7574
8861
  email: payload.email ?? "",
@@ -7586,9 +8873,11 @@ function createBuiltinAuthAdapter(config) {
7586
8873
  emailConfig,
7587
8874
  allowRegistration,
7588
8875
  disableSelfRegistration,
8876
+ allowAnonymous,
7589
8877
  allowUserLookup,
7590
8878
  defaultRole,
7591
8879
  oauthProviders,
8880
+ allowedRedirectUris,
7592
8881
  authHooks,
7593
8882
  enableMagicLink,
7594
8883
  cookieAuth
@@ -7660,6 +8949,10 @@ function createBuiltinAuthAdapter(config) {
7660
8949
  allowRegistration,
7661
8950
  needsSetup
7662
8951
  });
8952
+ const anonymousAllowed = isAnonymousAuthOpen({
8953
+ allowAnonymous,
8954
+ disableSelfRegistration
8955
+ });
7663
8956
  return {
7664
8957
  hasBuiltInAuthRoutes: true,
7665
8958
  emailPasswordLogin: true,
@@ -7671,6 +8964,7 @@ function createBuiltinAuthAdapter(config) {
7671
8964
  profileUpdate: true,
7672
8965
  emailVerification: !!emailService?.isConfigured(),
7673
8966
  magicLink: enableMagicLink && !!emailService?.isConfigured(),
8967
+ anonymousLogin: anonymousAllowed,
7674
8968
  enabledProviders,
7675
8969
  needsSetup
7676
8970
  };
@@ -7755,6 +9049,54 @@ function toAuthUserData(user) {
7755
9049
  };
7756
9050
  }
7757
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
7758
9100
  //#region src/auth/google-oauth.ts
7759
9101
  var _googleAuth;
7760
9102
  async function loadGoogleAuth() {
@@ -7787,7 +9129,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
7787
9129
  const tokenInfo = await tokenInfoRes.json();
7788
9130
  if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
7789
9131
  if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
7790
- const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === "true";
9132
+ const emailVerified = providerVerifiedEmail(tokenInfo.email_verified);
7791
9133
  let displayName = null;
7792
9134
  let photoUrl = null;
7793
9135
  try {
@@ -7842,7 +9184,8 @@ function createGoogleProvider(config) {
7842
9184
  idToken: string().min(1).optional(),
7843
9185
  accessToken: string().min(1).optional(),
7844
9186
  code: string().min(1).optional(),
7845
- redirectUri: string().min(1).optional()
9187
+ redirectUri: string().min(1).optional(),
9188
+ codeVerifier: string().min(1).optional()
7846
9189
  }).refine((data) => data.idToken || data.accessToken || data.code && data.redirectUri, { message: "One of idToken, accessToken, or code+redirectUri is required" }),
7847
9190
  verify: async (payload) => {
7848
9191
  try {
@@ -7857,7 +9200,7 @@ function createGoogleProvider(config) {
7857
9200
  email: content.email || "",
7858
9201
  displayName: content.name || null,
7859
9202
  photoUrl: content.picture || null,
7860
- emailVerified: content.email_verified === true
9203
+ emailVerified: providerVerifiedEmail(content.email_verified)
7861
9204
  };
7862
9205
  }
7863
9206
  if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
@@ -7871,7 +9214,8 @@ function createGoogleProvider(config) {
7871
9214
  client_id: clientId,
7872
9215
  client_secret: clientSecret,
7873
9216
  redirect_uri: payload.redirectUri,
7874
- grant_type: "authorization_code"
9217
+ grant_type: "authorization_code",
9218
+ ...pkceTokenParams(payload.codeVerifier)
7875
9219
  })
7876
9220
  });
7877
9221
  if (!tokenResponse.ok) {
@@ -7891,7 +9235,7 @@ function createGoogleProvider(config) {
7891
9235
  email: content.email || "",
7892
9236
  displayName: content.name || null,
7893
9237
  photoUrl: content.picture || null,
7894
- emailVerified: content.email_verified === true
9238
+ emailVerified: providerVerifiedEmail(content.email_verified)
7895
9239
  };
7896
9240
  }
7897
9241
  if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
@@ -7913,10 +9257,7 @@ function createGoogleProvider(config) {
7913
9257
  function createLinkedinProvider(config) {
7914
9258
  return {
7915
9259
  id: "linkedin",
7916
- schema: object({
7917
- code: string().min(1, "Auth code is required"),
7918
- redirectUri: string().url("Valid redirect URI is required")
7919
- }),
9260
+ schema: oauthCodeFlowSchema(),
7920
9261
  verify: async (payload) => {
7921
9262
  try {
7922
9263
  const tokenResponse = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
@@ -7927,7 +9268,8 @@ function createLinkedinProvider(config) {
7927
9268
  code: payload.code,
7928
9269
  redirect_uri: payload.redirectUri,
7929
9270
  client_id: config.clientId,
7930
- client_secret: config.clientSecret
9271
+ client_secret: config.clientSecret,
9272
+ ...pkceTokenParams(payload.codeVerifier)
7931
9273
  })
7932
9274
  });
7933
9275
  if (!tokenResponse.ok) {
@@ -7943,12 +9285,16 @@ function createLinkedinProvider(config) {
7943
9285
  return null;
7944
9286
  }
7945
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
+ }
7946
9292
  return {
7947
9293
  providerId: profileData.sub,
7948
9294
  email: profileData.email,
7949
9295
  displayName: profileData.name || null,
7950
9296
  photoUrl: profileData.picture || null,
7951
- emailVerified: profileData.email_verified === true
9297
+ emailVerified: providerVerifiedEmail(profileData.email_verified)
7952
9298
  };
7953
9299
  } catch (error) {
7954
9300
  logger.error("LinkedIn OAuth error", { error });
@@ -7969,10 +9315,7 @@ function createLinkedinProvider(config) {
7969
9315
  function createGitHubProvider(config) {
7970
9316
  return {
7971
9317
  id: "github",
7972
- schema: object({
7973
- code: string().min(1, "Auth code is required"),
7974
- redirectUri: string().url("Valid redirect URI is required")
7975
- }),
9318
+ schema: oauthCodeFlowSchema(),
7976
9319
  verify: async (payload) => {
7977
9320
  try {
7978
9321
  const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
@@ -7985,7 +9328,8 @@ function createGitHubProvider(config) {
7985
9328
  client_id: config.clientId,
7986
9329
  client_secret: config.clientSecret,
7987
9330
  code: payload.code,
7988
- redirect_uri: payload.redirectUri
9331
+ redirect_uri: payload.redirectUri,
9332
+ ...pkceTokenParams(payload.codeVerifier)
7989
9333
  })
7990
9334
  });
7991
9335
  if (!tokenResponse.ok) {
@@ -8009,19 +9353,22 @@ function createGitHubProvider(config) {
8009
9353
  }
8010
9354
  const profileData = await profileResponse.json();
8011
9355
  let email = profileData.email;
8012
- if (!email) {
8013
- const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
8014
- "Authorization": `Bearer ${accessToken}`,
8015
- "Accept": "application/vnd.github+json",
8016
- "User-Agent": "Rebase-Auth"
8017
- } });
8018
- if (emailsResponse.ok) {
8019
- const emails = await emailsResponse.json();
8020
- 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);
8021
9368
  }
8022
9369
  }
8023
9370
  if (!email) {
8024
- logger.error("GitHub user has no verified email");
9371
+ logger.error("GitHub user has no email");
8025
9372
  return null;
8026
9373
  }
8027
9374
  return {
@@ -8029,7 +9376,7 @@ function createGitHubProvider(config) {
8029
9376
  email,
8030
9377
  displayName: profileData.name || profileData.login || null,
8031
9378
  photoUrl: profileData.avatar_url || null,
8032
- emailVerified: true
9379
+ emailVerified
8033
9380
  };
8034
9381
  } catch (error) {
8035
9382
  logger.error("GitHub OAuth error", { error });
@@ -8039,21 +9386,109 @@ function createGitHubProvider(config) {
8039
9386
  };
8040
9387
  }
8041
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
8042
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
+ ]);
8043
9460
  /**
8044
9461
  * Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
8045
9462
  *
8046
9463
  * Supports both personal Microsoft accounts and work/school (Azure AD) accounts
8047
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.
8048
9482
  */
8049
9483
  function createMicrosoftProvider(config) {
8050
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.`);
8051
9489
  return {
8052
9490
  id: "microsoft",
8053
- schema: object({
8054
- code: string().min(1, "Auth code is required"),
8055
- redirectUri: string().url("Valid redirect URI is required")
8056
- }),
9491
+ schema: oauthCodeFlowSchema(),
8057
9492
  verify: async (payload) => {
8058
9493
  try {
8059
9494
  const tokenResponse = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
@@ -8065,14 +9500,26 @@ function createMicrosoftProvider(config) {
8065
9500
  code: payload.code,
8066
9501
  redirect_uri: payload.redirectUri,
8067
9502
  grant_type: "authorization_code",
8068
- scope: "openid profile email User.Read"
9503
+ scope: "openid profile email User.Read",
9504
+ ...pkceTokenParams(payload.codeVerifier)
8069
9505
  })
8070
9506
  });
8071
9507
  if (!tokenResponse.ok) {
8072
9508
  logger.error("Failed to get Microsoft access token", { detail: await tokenResponse.text() });
8073
9509
  return null;
8074
9510
  }
8075
- 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
+ }
8076
9523
  const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { "Authorization": `Bearer ${accessToken}` } });
8077
9524
  if (!profileResponse.ok) {
8078
9525
  logger.error("Failed to get Microsoft user info", { detail: await profileResponse.text() });
@@ -8084,7 +9531,8 @@ function createMicrosoftProvider(config) {
8084
9531
  logger.error("Microsoft user has no email");
8085
9532
  return null;
8086
9533
  }
8087
- 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();
8088
9536
  return {
8089
9537
  providerId: profileData.id,
8090
9538
  email,
@@ -8101,6 +9549,8 @@ function createMicrosoftProvider(config) {
8101
9549
  }
8102
9550
  //#endregion
8103
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";
8104
9554
  /**
8105
9555
  * Creates an Apple Sign In OAuth Provider integration.
8106
9556
  *
@@ -8130,18 +9580,19 @@ function createAppleProvider(config) {
8130
9580
  }
8131
9581
  return {
8132
9582
  id: "apple",
8133
- schema: object({
8134
- code: string().min(1, "Auth code is required"),
8135
- redirectUri: string().url("Valid redirect URI is required"),
8136
- /** Apple sends user info only on first authorization; the frontend must forward it. */
8137
- user: object({
8138
- name: object({
8139
- firstName: string().optional(),
8140
- lastName: string().optional()
8141
- }).optional(),
8142
- email: string().email().optional()
8143
- }).optional()
8144
- }),
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() }),
8145
9596
  verify: async (payload) => {
8146
9597
  try {
8147
9598
  const clientSecret = await generateClientSecret();
@@ -8153,18 +9604,28 @@ function createAppleProvider(config) {
8153
9604
  client_secret: clientSecret,
8154
9605
  code: payload.code,
8155
9606
  grant_type: "authorization_code",
8156
- redirect_uri: payload.redirectUri
9607
+ redirect_uri: payload.redirectUri,
9608
+ ...pkceTokenParams(payload.codeVerifier)
8157
9609
  })
8158
9610
  });
8159
9611
  if (!tokenResponse.ok) {
8160
9612
  logger.error("Failed to get Apple access token", { detail: await tokenResponse.text() });
8161
9613
  return null;
8162
9614
  }
8163
- const [, payloadB64] = (await tokenResponse.json()).id_token.split(".");
8164
- const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
8165
- 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;
8166
9627
  if (!email) {
8167
- logger.error("Apple user has no email");
9628
+ logger.error("Apple id_token carries no email claim");
8168
9629
  return null;
8169
9630
  }
8170
9631
  let displayName = null;
@@ -8177,7 +9638,7 @@ function createAppleProvider(config) {
8177
9638
  email,
8178
9639
  displayName,
8179
9640
  photoUrl: null,
8180
- emailVerified: decoded.email_verified === true || decoded.email_verified === "true"
9641
+ emailVerified: providerVerifiedEmail(decoded.email_verified)
8181
9642
  };
8182
9643
  } catch (error) {
8183
9644
  logger.error("Apple OAuth error", { error });
@@ -8197,18 +9658,20 @@ function createAppleProvider(config) {
8197
9658
  function createFacebookProvider(config) {
8198
9659
  return {
8199
9660
  id: "facebook",
8200
- schema: object({
8201
- code: string().min(1, "Auth code is required"),
8202
- redirectUri: string().url("Valid redirect URI is required")
8203
- }),
9661
+ schema: oauthCodeFlowSchema(),
8204
9662
  verify: async (payload) => {
8205
9663
  try {
8206
- const tokenUrl = new URL("https://graph.facebook.com/v19.0/oauth/access_token");
8207
- tokenUrl.searchParams.set("client_id", config.clientId);
8208
- tokenUrl.searchParams.set("client_secret", config.clientSecret);
8209
- tokenUrl.searchParams.set("redirect_uri", payload.redirectUri);
8210
- tokenUrl.searchParams.set("code", payload.code);
8211
- 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
+ });
8212
9675
  if (!tokenResponse.ok) {
8213
9676
  logger.error("Failed to get Facebook access token", { detail: await tokenResponse.text() });
8214
9677
  return null;
@@ -8216,8 +9679,7 @@ function createFacebookProvider(config) {
8216
9679
  const accessToken = (await tokenResponse.json()).access_token;
8217
9680
  const profileUrl = new URL("https://graph.facebook.com/v19.0/me");
8218
9681
  profileUrl.searchParams.set("fields", "id,name,email,picture.type(large)");
8219
- profileUrl.searchParams.set("access_token", accessToken);
8220
- const profileResponse = await fetch(profileUrl.toString());
9682
+ const profileResponse = await fetch(profileUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } });
8221
9683
  if (!profileResponse.ok) {
8222
9684
  logger.error("Failed to get Facebook user info", { detail: await profileResponse.text() });
8223
9685
  return null;
@@ -8232,7 +9694,7 @@ function createFacebookProvider(config) {
8232
9694
  email: profileData.email,
8233
9695
  displayName: profileData.name || null,
8234
9696
  photoUrl: profileData.picture?.data?.url || null,
8235
- emailVerified: true
9697
+ emailVerified: false
8236
9698
  };
8237
9699
  } catch (error) {
8238
9700
  logger.error("Facebook OAuth error", { error });
@@ -8255,11 +9717,7 @@ function createFacebookProvider(config) {
8255
9717
  function createTwitterProvider(config) {
8256
9718
  return {
8257
9719
  id: "twitter",
8258
- schema: object({
8259
- code: string().min(1, "Auth code is required"),
8260
- redirectUri: string().url("Valid redirect URI is required"),
8261
- codeVerifier: string().min(1, "PKCE code verifier is required")
8262
- }),
9720
+ schema: oauthCodeFlowSchema({ pkce: "required" }),
8263
9721
  verify: async (payload) => {
8264
9722
  try {
8265
9723
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8273,7 +9731,7 @@ function createTwitterProvider(config) {
8273
9731
  code: payload.code,
8274
9732
  grant_type: "authorization_code",
8275
9733
  redirect_uri: payload.redirectUri,
8276
- code_verifier: payload.codeVerifier
9734
+ ...pkceTokenParams(payload.codeVerifier)
8277
9735
  })
8278
9736
  });
8279
9737
  if (!tokenResponse.ok) {
@@ -8319,16 +9777,14 @@ function createTwitterProvider(config) {
8319
9777
  /**
8320
9778
  * Creates a Discord OAuth2 Provider integration.
8321
9779
  *
8322
- * Uses the authorization code flow. Requires the "identify" and "email"
8323
- * 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.
8324
9783
  */
8325
9784
  function createDiscordProvider(config) {
8326
9785
  return {
8327
9786
  id: "discord",
8328
- schema: object({
8329
- code: string().min(1, "Auth code is required"),
8330
- redirectUri: string().url("Valid redirect URI is required")
8331
- }),
9787
+ schema: oauthCodeFlowSchema(),
8332
9788
  verify: async (payload) => {
8333
9789
  try {
8334
9790
  const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
@@ -8339,7 +9795,8 @@ function createDiscordProvider(config) {
8339
9795
  client_secret: config.clientSecret,
8340
9796
  grant_type: "authorization_code",
8341
9797
  code: payload.code,
8342
- redirect_uri: payload.redirectUri
9798
+ redirect_uri: payload.redirectUri,
9799
+ ...pkceTokenParams(payload.codeVerifier)
8343
9800
  })
8344
9801
  });
8345
9802
  if (!tokenResponse.ok) {
@@ -8367,7 +9824,7 @@ function createDiscordProvider(config) {
8367
9824
  email: profileData.email,
8368
9825
  displayName: profileData.global_name || profileData.username || null,
8369
9826
  photoUrl,
8370
- emailVerified: profileData.verified === true
9827
+ emailVerified: providerVerifiedEmail(profileData.verified)
8371
9828
  };
8372
9829
  } catch (error) {
8373
9830
  logger.error("Discord OAuth error", { error });
@@ -8381,15 +9838,16 @@ function createDiscordProvider(config) {
8381
9838
  /**
8382
9839
  * Creates a GitLab OAuth Provider integration.
8383
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.
8384
9845
  */
8385
9846
  function createGitLabProvider(config) {
8386
9847
  const gitlabUrl = (config.baseUrl || "https://gitlab.com").replace(/\/$/, "");
8387
9848
  return {
8388
9849
  id: "gitlab",
8389
- schema: object({
8390
- code: string().min(1, "Auth code is required"),
8391
- redirectUri: string().url("Valid redirect URI is required")
8392
- }),
9850
+ schema: oauthCodeFlowSchema(),
8393
9851
  verify: async (payload) => {
8394
9852
  try {
8395
9853
  const tokenResponse = await fetch(`${gitlabUrl}/oauth/token`, {
@@ -8400,7 +9858,8 @@ function createGitLabProvider(config) {
8400
9858
  client_secret: config.clientSecret,
8401
9859
  code: payload.code,
8402
9860
  grant_type: "authorization_code",
8403
- redirect_uri: payload.redirectUri
9861
+ redirect_uri: payload.redirectUri,
9862
+ ...pkceTokenParams(payload.codeVerifier)
8404
9863
  })
8405
9864
  });
8406
9865
  if (!tokenResponse.ok) {
@@ -8423,7 +9882,7 @@ function createGitLabProvider(config) {
8423
9882
  email: p.email,
8424
9883
  displayName: p.name || p.username || null,
8425
9884
  photoUrl: p.avatar_url || null,
8426
- emailVerified: true
9885
+ emailVerified: Boolean(p.confirmed_at)
8427
9886
  };
8428
9887
  } catch (error) {
8429
9888
  logger.error("GitLab OAuth error", { error });
@@ -8440,10 +9899,7 @@ function createGitLabProvider(config) {
8440
9899
  function createBitbucketProvider(config) {
8441
9900
  return {
8442
9901
  id: "bitbucket",
8443
- schema: object({
8444
- code: string().min(1, "Auth code is required"),
8445
- redirectUri: string().url("Valid redirect URI is required")
8446
- }),
9902
+ schema: oauthCodeFlowSchema(),
8447
9903
  verify: async (payload) => {
8448
9904
  try {
8449
9905
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8456,7 +9912,8 @@ function createBitbucketProvider(config) {
8456
9912
  body: new URLSearchParams({
8457
9913
  grant_type: "authorization_code",
8458
9914
  code: payload.code,
8459
- redirect_uri: payload.redirectUri
9915
+ redirect_uri: payload.redirectUri,
9916
+ ...pkceTokenParams(payload.codeVerifier)
8460
9917
  })
8461
9918
  });
8462
9919
  if (!tokenResponse.ok) {
@@ -8471,21 +9928,21 @@ function createBitbucketProvider(config) {
8471
9928
  }
8472
9929
  const p = await profileResponse.json();
8473
9930
  const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", { headers: { "Authorization": `Bearer ${accessToken}` } });
8474
- let email = null;
9931
+ let selected;
8475
9932
  if (emailsResponse.ok) {
8476
9933
  const emailData = await emailsResponse.json();
8477
- 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);
8478
9935
  }
8479
- if (!email) {
9936
+ if (!selected?.email) {
8480
9937
  logger.error("Bitbucket user has no verified email");
8481
9938
  return null;
8482
9939
  }
8483
9940
  return {
8484
9941
  providerId: p.uuid,
8485
- email,
9942
+ email: selected.email,
8486
9943
  displayName: p.display_name || p.username || null,
8487
9944
  photoUrl: p.links?.avatar?.href || null,
8488
- emailVerified: true
9945
+ emailVerified: providerVerifiedEmail(selected.is_confirmed)
8489
9946
  };
8490
9947
  } catch (error) {
8491
9948
  logger.error("Bitbucket OAuth error", { error });
@@ -8503,10 +9960,7 @@ function createBitbucketProvider(config) {
8503
9960
  function createSlackProvider(config) {
8504
9961
  return {
8505
9962
  id: "slack",
8506
- schema: object({
8507
- code: string().min(1, "Auth code is required"),
8508
- redirectUri: string().url("Valid redirect URI is required")
8509
- }),
9963
+ schema: oauthCodeFlowSchema(),
8510
9964
  verify: async (payload) => {
8511
9965
  try {
8512
9966
  const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
@@ -8517,7 +9971,8 @@ function createSlackProvider(config) {
8517
9971
  client_secret: config.clientSecret,
8518
9972
  code: payload.code,
8519
9973
  redirect_uri: payload.redirectUri,
8520
- grant_type: "authorization_code"
9974
+ grant_type: "authorization_code",
9975
+ ...pkceTokenParams(payload.codeVerifier)
8521
9976
  })
8522
9977
  });
8523
9978
  if (!tokenResponse.ok) {
@@ -8544,7 +9999,7 @@ function createSlackProvider(config) {
8544
9999
  email: p.email,
8545
10000
  displayName: p.name || null,
8546
10001
  photoUrl: p.picture || null,
8547
- emailVerified: p.email_verified === true
10002
+ emailVerified: providerVerifiedEmail(p.email_verified)
8548
10003
  };
8549
10004
  } catch (error) {
8550
10005
  logger.error("Slack OAuth error", { error });
@@ -8562,10 +10017,7 @@ function createSlackProvider(config) {
8562
10017
  function createSpotifyProvider(config) {
8563
10018
  return {
8564
10019
  id: "spotify",
8565
- schema: object({
8566
- code: string().min(1, "Auth code is required"),
8567
- redirectUri: string().url("Valid redirect URI is required")
8568
- }),
10020
+ schema: oauthCodeFlowSchema(),
8569
10021
  verify: async (payload) => {
8570
10022
  try {
8571
10023
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8578,7 +10030,8 @@ function createSpotifyProvider(config) {
8578
10030
  body: new URLSearchParams({
8579
10031
  grant_type: "authorization_code",
8580
10032
  code: payload.code,
8581
- redirect_uri: payload.redirectUri
10033
+ redirect_uri: payload.redirectUri,
10034
+ ...pkceTokenParams(payload.codeVerifier)
8582
10035
  })
8583
10036
  });
8584
10037
  if (!tokenResponse.ok) {
@@ -8601,7 +10054,7 @@ function createSpotifyProvider(config) {
8601
10054
  email: p.email,
8602
10055
  displayName: p.display_name || null,
8603
10056
  photoUrl: p.images?.[0]?.url || null,
8604
- emailVerified: true
10057
+ emailVerified: false
8605
10058
  };
8606
10059
  } catch (error) {
8607
10060
  logger.error("Spotify OAuth error", { error });
@@ -8737,7 +10190,7 @@ function createApiKeyStore(driver) {
8737
10190
  return;
8738
10191
  }
8739
10192
  await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
8740
- logger.info("✅ API keys table ready");
10193
+ logger.debug("✅ API keys table ready");
8741
10194
  },
8742
10195
  async createApiKey(request, createdBy) {
8743
10196
  const plaintext = generateApiKey();
@@ -9032,6 +10485,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
9032
10485
  createSlackProvider: () => createSlackProvider,
9033
10486
  createSpotifyProvider: () => createSpotifyProvider,
9034
10487
  createTwitterProvider: () => createTwitterProvider,
10488
+ decideOAuthAutoLink: () => decideOAuthAutoLink,
9035
10489
  defaultAuthLimiter: () => defaultAuthLimiter,
9036
10490
  extractUserFromToken: () => extractUserFromToken,
9037
10491
  fileTokenAuth: () => fileTokenAuth,
@@ -9050,22 +10504,28 @@ var auth_exports = /* @__PURE__ */ __exportAll({
9050
10504
  isApiKeyToken: () => isApiKeyToken,
9051
10505
  isJwtConfigured: () => isJwtConfigured,
9052
10506
  isOperationAllowed: () => isOperationAllowed,
10507
+ isRedirectUriAllowed: () => isRedirectUriAllowed,
9053
10508
  mountMagicLinkRoutes: () => mountMagicLinkRoutes,
10509
+ oauthCodeFlowSchema: () => oauthCodeFlowSchema,
9054
10510
  optionalAuth: () => optionalAuth,
10511
+ pkceTokenParams: () => pkceTokenParams,
9055
10512
  prepareAdminUserValues: () => prepareAdminUserValues,
10513
+ providerVerifiedEmail: () => providerVerifiedEmail,
9056
10514
  queryTokenAuth: () => queryTokenAuth,
9057
10515
  requireAdmin: () => requireAdmin,
9058
10516
  requireAuth: () => requireAuth,
9059
10517
  resolveAuthHooks: () => resolveAuthHooks,
9060
10518
  safeCompare: () => safeCompare,
9061
10519
  strictAuthLimiter: () => strictAuthLimiter,
10520
+ tryVerifyOidcIdToken: () => tryVerifyOidcIdToken,
9062
10521
  validateApiKey: () => validateApiKey,
9063
10522
  validatePasswordStrength: () => validatePasswordStrength,
9064
10523
  verifyAccessToken: () => verifyAccessToken,
9065
10524
  verifyDownloadToken: () => verifyDownloadToken,
10525
+ verifyOidcIdToken: () => verifyOidcIdToken,
9066
10526
  verifyPassword: () => verifyPassword
9067
10527
  });
9068
10528
  //#endregion
9069
- export { safeCompare as $, getEmailVerificationTemplate as A, optionalAuth as B, createDataRateLimiter as C, validatePasswordStrength as D, hashPassword as E, createAdapterAuthMiddleware as F, InvalidStorageKeyError as G, queryTokenAuth as H, createAuthMiddleware as I, createFunctionApiKeyGuard as J, canonicalStorageKey as K, createRequireAuth as L, getPasswordResetTemplate as M, getUserInvitationTemplate as N, verifyPassword as O, getWelcomeEmailTemplate as P, extractBearerToken as Q, extractUserFromToken as R, _coercedNumber as S, resolveAuthHooks as T, requireAdmin as U, publicObjectAuth as V, requireAuth as W, isApiKeyToken as X, createStorageApiKeyGuard as Y, validateApiKey as Z, createBuiltinAuthAdapter as _, createSpotifyProvider as a, object as b, createGitLabProvider as c, createFacebookProvider as d, scopeDataDriver as et, createAppleProvider as f, createGoogleProvider as g, createLinkedinProvider as h, createApiKeyStore as i, isPublicStoragePath as it, getMagicLinkTemplate as j, generateSecurePassword as k, createDiscordProvider as l, createGitHubProvider as m, createCustomAuthAdapter as n, isOperationAllowed as nt, createSlackProvider as o, createMicrosoftProvider as p, createApiKeyPreAuth as q, createApiKeyRoutes as r, PUBLIC_STORAGE_PREFIX as rt, createBitbucketProvider as s, auth_exports as t, httpMethodToOperation 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 };
9070
10530
 
9071
- //# sourceMappingURL=auth-BANhRhN9.js.map
10531
+ //# sourceMappingURL=auth-CYoPVf-E.js.map