@rebasepro/server 0.13.0 → 0.13.1-canary.g1822133

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 (136) hide show
  1. package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
  2. package/dist/admin_block-H86dCPsj.js.map +1 -0
  3. package/dist/api/ast-schema-editor.d.ts +74 -6
  4. package/dist/api/errors.d.ts +12 -6
  5. package/dist/api/openapi-generator.d.ts +14 -0
  6. package/dist/api/rest/api-generator.d.ts +26 -1
  7. package/dist/api/rest/idempotency.d.ts +62 -4
  8. package/dist/api/rest/query-parser.d.ts +15 -1
  9. package/dist/api/rest/write-validation.d.ts +52 -9
  10. package/dist/auth/adapter-middleware.d.ts +3 -1
  11. package/dist/auth/admin-roles.d.ts +24 -0
  12. package/dist/auth/apple-oauth.d.ts +16 -11
  13. package/dist/auth/bitbucket-oauth.d.ts +2 -4
  14. package/dist/auth/builtin-auth-adapter.d.ts +11 -0
  15. package/dist/auth/discord-oauth.d.ts +5 -6
  16. package/dist/auth/facebook-oauth.d.ts +2 -4
  17. package/dist/auth/github-oauth.d.ts +2 -4
  18. package/dist/auth/gitlab-oauth.d.ts +6 -4
  19. package/dist/auth/google-oauth.d.ts +1 -0
  20. package/dist/auth/index.d.ts +8 -1
  21. package/dist/auth/interfaces.d.ts +81 -1
  22. package/dist/auth/jwt.d.ts +52 -0
  23. package/dist/auth/linkedin-oauth.d.ts +2 -4
  24. package/dist/auth/mfa-crypto.d.ts +54 -3
  25. package/dist/auth/mfa-gate.d.ts +42 -0
  26. package/dist/auth/mfa-routes.d.ts +26 -1
  27. package/dist/auth/mfa.d.ts +16 -0
  28. package/dist/auth/microsoft-oauth.d.ts +19 -4
  29. package/dist/auth/middleware.d.ts +26 -0
  30. package/dist/auth/oauth-code-flow.d.ts +66 -0
  31. package/dist/auth/oauth-signin-policy.d.ts +61 -0
  32. package/dist/auth/oidc-id-token.d.ts +61 -0
  33. package/dist/auth/rate-limiter.d.ts +62 -5
  34. package/dist/auth/registration-policy.d.ts +31 -0
  35. package/dist/auth/rls-scope.d.ts +25 -0
  36. package/dist/auth/routes.d.ts +15 -0
  37. package/dist/auth/slack-oauth.d.ts +2 -4
  38. package/dist/auth/spotify-oauth.d.ts +2 -4
  39. package/dist/auth/token-revocation.d.ts +38 -0
  40. package/dist/auth/twitter-oauth.d.ts +2 -5
  41. package/dist/{auth-CuC9M2x6.js → auth-CYoPVf-E.js} +1996 -426
  42. package/dist/auth-CYoPVf-E.js.map +1 -0
  43. package/dist/{backup-CVggVhR2.js → backup-C6ljYVTp.js} +2 -2
  44. package/dist/{backup-CVggVhR2.js.map → backup-C6ljYVTp.js.map} +1 -1
  45. package/dist/boot/boot.d.ts +19 -0
  46. package/dist/boot/ddl-bootstrap.d.ts +70 -0
  47. package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
  48. package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
  49. package/dist/cron/cron-loader.d.ts +26 -1
  50. package/dist/cron/cron-routes.d.ts +1 -1
  51. package/dist/cron/cron-scheduler.d.ts +13 -3
  52. package/dist/cron/define-cron.d.ts +17 -3
  53. package/dist/cron/index.d.ts +2 -2
  54. package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
  55. package/dist/cron-loader-YhhQeVBM.js.map +1 -0
  56. package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-maM_RlUu.js} +10 -4
  57. package/dist/cron-routes-maM_RlUu.js.map +1 -0
  58. package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-DIpYBmZP.js} +40 -9
  59. package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
  60. package/dist/{cron-store-BywZsyfZ.js → cron-store-Dvr4Y1sZ.js} +66 -47
  61. package/dist/cron-store-Dvr4Y1sZ.js.map +1 -0
  62. package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
  63. package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
  64. package/dist/email/html.d.ts +54 -0
  65. package/dist/email/index.d.ts +3 -0
  66. package/dist/email/link-base.d.ts +39 -0
  67. package/dist/email/smtp-email-service.d.ts +7 -1
  68. package/dist/email/templates.d.ts +9 -1
  69. package/dist/email/types.d.ts +11 -1
  70. package/dist/env.d.ts +5 -0
  71. package/dist/{errors-CgkCzoj7.js → errors-EBYiaJ2E.js} +15 -11
  72. package/dist/errors-EBYiaJ2E.js.map +1 -0
  73. package/dist/function-loader-DDS1v7YX.js +139 -0
  74. package/dist/function-loader-DDS1v7YX.js.map +1 -0
  75. package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
  76. package/dist/function-routes-Btcez1T-.js.map +1 -0
  77. package/dist/functions/define-function.d.ts +25 -6
  78. package/dist/functions/function-loader.d.ts +23 -0
  79. package/dist/functions/function-routes.d.ts +7 -1
  80. package/dist/functions/request-timeout.d.ts +34 -0
  81. package/dist/history/history-routes.d.ts +6 -0
  82. package/dist/index.d.ts +3 -2
  83. package/dist/index.es.js +2147 -845
  84. package/dist/index.es.js.map +1 -1
  85. package/dist/init/docs.d.ts +10 -1
  86. package/dist/init/process-safety.d.ts +25 -0
  87. package/dist/init.d.ts +38 -0
  88. package/dist/{jwt-DD6EtpGj.js → jwt-_IFqfTOg.js} +72 -9
  89. package/dist/jwt-_IFqfTOg.js.map +1 -0
  90. package/dist/logger-DfvF_8r-.js +190 -0
  91. package/dist/logger-DfvF_8r-.js.map +1 -0
  92. package/dist/openapi-generator-DPKtUC9X.js +865 -0
  93. package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
  94. package/dist/request-timeout-RivJsME0.js +65 -0
  95. package/dist/request-timeout-RivJsME0.js.map +1 -0
  96. package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
  97. package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
  98. package/dist/serve-spa.d.ts +5 -4
  99. package/dist/services/outbound-url-guard.d.ts +53 -0
  100. package/dist/services/routed-realtime-service.d.ts +10 -2
  101. package/dist/services/webhook-service.d.ts +76 -1
  102. package/dist/singleton.d.ts +25 -8
  103. package/dist/{src-Cum9kox5.js → src-C7rkDGxA.js} +678 -89
  104. package/dist/src-C7rkDGxA.js.map +1 -0
  105. package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
  106. package/dist/src-Cz9nMgUR.js.map +1 -0
  107. package/dist/storage/LocalStorageController.d.ts +12 -1
  108. package/dist/storage/image-transform.d.ts +57 -0
  109. package/dist/storage/keys.d.ts +98 -0
  110. package/dist/storage/routes.d.ts +11 -0
  111. package/dist/storage/tus-handler.d.ts +22 -2
  112. package/dist/utils/logger.d.ts +18 -0
  113. package/dist/utils/logging.d.ts +0 -4
  114. package/dist/utils/sql.d.ts +11 -6
  115. package/package.json +7 -6
  116. package/dist/admin_block-CcSGdH7M.js.map +0 -1
  117. package/dist/auth-CuC9M2x6.js.map +0 -1
  118. package/dist/backend-CIxN4FVm.js +0 -15
  119. package/dist/backend-CIxN4FVm.js.map +0 -1
  120. package/dist/cron-loader-B1S2MCSl.js.map +0 -1
  121. package/dist/cron-routes-CrQ0tK-_.js.map +0 -1
  122. package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
  123. package/dist/cron-store-BywZsyfZ.js.map +0 -1
  124. package/dist/errors-CgkCzoj7.js.map +0 -1
  125. package/dist/function-loader-B_1fYfUY.js +0 -86
  126. package/dist/function-loader-B_1fYfUY.js.map +0 -1
  127. package/dist/function-routes-C0cLIy3N.js.map +0 -1
  128. package/dist/jwt-DD6EtpGj.js.map +0 -1
  129. package/dist/logger-BYU66ENZ.js +0 -94
  130. package/dist/logger-BYU66ENZ.js.map +0 -1
  131. package/dist/openapi-generator-BEwyiaAr.js +0 -597
  132. package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
  133. package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
  134. package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
  135. package/dist/src-Cum9kox5.js.map +0 -1
  136. package/dist/src-_qQ3RNCK.js.map +0 -1
@@ -2,38 +2,18 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
5
- import { _ as ANONYMOUS_USER_ID, v as isAnonymousUid } from "./src-Cum9kox5.js";
6
- import "./src-_qQ3RNCK.js";
7
- import { t as isSQLAdmin } from "./backend-CIxN4FVm.js";
8
- import { t as logger } from "./logger-BYU66ENZ.js";
9
- import { n as errorHandler, t as ApiError } from "./errors-CgkCzoj7.js";
10
- import { a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, i as generateDownloadToken, l as hashRefreshToken, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-DD6EtpGj.js";
5
+ import { C as 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";
15
- /** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
16
- var MAX_LIST_LIMIT = 1e3;
17
- /**
18
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
19
- *
20
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
21
- * so `0`, negatives, and absurd values can never bypass the cap.
22
- * - An absent / blank / non-numeric limit falls back to the mode default:
23
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
24
- *
25
- * The return is never `undefined` — no ingress that routes its client limit
26
- * through this can produce an unbounded read.
27
- */
28
- function resolveClientListLimit(rawLimit, opts = {}) {
29
- const maxLimit = opts.maxLimit ?? 1e3;
30
- if (rawLimit != null && String(rawLimit).trim() !== "") {
31
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
32
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
33
- }
34
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
35
- }
36
- //#endregion
14
+ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
15
+ import path from "node:path";
16
+ import { getConnInfo } from "@hono/node-server/conninfo";
37
17
  //#region ../types/src/controllers/storage.ts
38
18
  /**
39
19
  * Path prefix that marks an object as **public**. Files stored under this
@@ -94,6 +74,290 @@ function normalizeEmail(email) {
94
74
  return typeof email === "string" ? email.trim().toLowerCase() : email;
95
75
  }
96
76
  //#endregion
77
+ //#region src/api/rest/query-parser.ts
78
+ /**
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
97
361
  //#region src/auth/api-keys/api-key-permission-guard.ts
98
362
  /**
99
363
  * Map an HTTP method string to an `ApiKeyOperation`.
@@ -168,9 +432,116 @@ function isFunctionAllowed(permissions, functionName, operation) {
168
432
  return false;
169
433
  }
170
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
171
517
  //#region src/auth/rls-scope.ts
172
518
  var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
173
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
+ /**
174
545
  * Returns true if the driver supports RLS scoping via `withAuth()`.
175
546
  */
176
547
  function isRLSScopedDriver(driver) {
@@ -461,6 +832,137 @@ function createApiKeyPreAuth(options) {
461
832
  };
462
833
  }
463
834
  //#endregion
835
+ //#region src/storage/keys.ts
836
+ /**
837
+ * Canonical storage keys and bucket names.
838
+ *
839
+ * Storage is not under RLS, so a `storageAuthorize` hook is the whole access
840
+ * control model — and a hook can only be correct if the key it is shown is the
841
+ * key that is written. That is the invariant this module exists to hold: one
842
+ * canonical string, computed once per request, handed to the hook, to the
843
+ * controller, and to the download token alike.
844
+ *
845
+ * ## Why rejecting beats stripping
846
+ *
847
+ * The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
848
+ * things were wrong with that, and only one of them was the obvious one.
849
+ *
850
+ * The obvious one: a single pass is not a fixed point. `....//` contains `../`
851
+ * at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
852
+ * the traversal it was there to remove. `users/alice/....//bob/x` came out as
853
+ * `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
854
+ * `users/alice/`) and the filesystem reads as bob's. The hook approved one
855
+ * object and the controller wrote another.
856
+ *
857
+ * The subtler one, and the reason this is a rewrite rather than a loop: even a
858
+ * correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
859
+ * gets an object at `a/b` was not protected, they were misled — and every later
860
+ * read, ownership row and audit line now refers to a path nobody chose. So a key
861
+ * that means something other than what it says is refused (400), not repaired.
862
+ *
863
+ * Note what is NOT traversal under this rule: `....` is an ordinary directory
864
+ * name, and `users/alice/....//bob/x` canonicalizes to
865
+ * `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
866
+ * exactly right. Only a real `..` segment is refused.
867
+ */
868
+ /** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
869
+ var MAX_STORAGE_KEY_LENGTH = 1024;
870
+ /**
871
+ * A key that cannot be canonicalized. Carries no path back to the caller
872
+ * beyond what they sent, so it is safe to surface as a 400 message.
873
+ */
874
+ var InvalidStorageKeyError = class extends Error {
875
+ constructor(message) {
876
+ super(message);
877
+ this.name = "InvalidStorageKeyError";
878
+ }
879
+ };
880
+ /**
881
+ * Canonicalize a caller-supplied storage key, or throw
882
+ * {@link InvalidStorageKeyError}.
883
+ *
884
+ * Normalizations applied (safe, idempotent, and meaning-preserving):
885
+ * - leading slashes removed — `/a/b` and `a/b` name the same object
886
+ * - `.` segments and repeated slashes collapsed
887
+ *
888
+ * Refusals (the key means something other than what it says):
889
+ * - any `..` segment, on either separator, at any depth
890
+ * - null bytes
891
+ * - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
892
+ *
893
+ * A trailing slash is preserved: it is how the folder route marks a prefix.
894
+ */
895
+ function canonicalStorageKey(rawKey) {
896
+ if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
897
+ if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
898
+ if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
899
+ const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
900
+ if (withoutLeadingSlashes === "") return "";
901
+ const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
902
+ const normalized = path.posix.normalize(withoutLeadingSlashes);
903
+ if (normalized === "." || normalized === "./") return "";
904
+ const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
905
+ if (key === "") return "";
906
+ return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
907
+ }
908
+ /**
909
+ * Canonicalize, or return `null` when the key is not canonicalizable.
910
+ *
911
+ * For callers that must fail closed without an exception — the download-token
912
+ * middleware compares a request path against a granted path, and a key it
913
+ * cannot canonicalize simply matches nothing.
914
+ */
915
+ function tryCanonicalStorageKey(rawKey) {
916
+ try {
917
+ return canonicalStorageKey(rawKey);
918
+ } catch {
919
+ return null;
920
+ }
921
+ }
922
+ /** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
923
+ var InvalidStorageBucketError = class extends Error {
924
+ constructor(message) {
925
+ super(message);
926
+ this.name = "InvalidStorageBucketError";
927
+ }
928
+ };
929
+ /**
930
+ * One path segment: letters, digits, `.`, `_`, `-`, first character
931
+ * alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
932
+ * and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
933
+ * absolute paths and anything containing a separator unrepresentable.
934
+ */
935
+ var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
936
+ /**
937
+ * Canonicalize a caller-supplied bucket name, or throw
938
+ * {@link InvalidStorageBucketError}.
939
+ *
940
+ * The bucket is the *other* caller-controlled routing value in an upload
941
+ * request, and it was the one nobody validated. `LocalStorageController`
942
+ * builds `join(basePath, bucket)` and then checks containment against that
943
+ * result, so a bucket of `../../etc` moved the boundary rather than crossing
944
+ * it — the guard passed because the guard's reference point was the attacker's.
945
+ * The containment check now resolves against the storage root, and this is the
946
+ * check at the route boundary that stops the value before it gets there.
947
+ *
948
+ * A bucket is configuration, not user data: there is no legitimate caller that
949
+ * needs a separator, a leading dot, or a `..` in one. So this refuses rather
950
+ * than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
951
+ * bucket would silently store the object somewhere the caller did not ask for.
952
+ *
953
+ * Returns `undefined` when the caller named no bucket (absent, or an empty
954
+ * form field), which is how every controller spells "use my default". An empty
955
+ * string used to reach `getFullPath` and resolve to the storage *root* rather
956
+ * than the `default` bucket, which is a third place a bare key did not
957
+ * round-trip.
958
+ */
959
+ function canonicalStorageBucket(rawBucket) {
960
+ if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
961
+ if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
962
+ if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) throw new InvalidStorageBucketError("Storage bucket must be a single name of letters, digits, '.', '_' or '-', starting with a letter or digit.");
963
+ return rawBucket;
964
+ }
965
+ //#endregion
464
966
  //#region src/auth/middleware.ts
465
967
  /**
466
968
  * Hono middleware that requires a valid JWT token via Authorization header.
@@ -500,6 +1002,8 @@ var requireAuth = async (c, next) => {
500
1002
  function createRequireAuth(options) {
501
1003
  if (!options?.serviceKey) return requireAuth;
502
1004
  const key = options.serviceKey;
1005
+ const resolveRoles = options.resolveRoles;
1006
+ const revocationRepo = options.revocationRepo;
503
1007
  return async (c, next) => {
504
1008
  if (c.get("user")) return next();
505
1009
  const token = extractBearerToken(c.req.header("authorization"));
@@ -519,6 +1023,26 @@ function createRequireAuth(options) {
519
1023
  message: "Invalid or expired token",
520
1024
  code: "UNAUTHORIZED"
521
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
+ }
522
1046
  c.set("user", payload);
523
1047
  return next();
524
1048
  };
@@ -533,9 +1057,7 @@ var requireAdmin = async (c, next) => {
533
1057
  message: "User not authenticated. requireAuth middleware is missing?",
534
1058
  code: "UNAUTHORIZED"
535
1059
  } }, 401);
536
- if (!(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : []).some((role) => {
537
- return role === "admin" || role === "schema-admin";
538
- })) return c.json({ error: {
1060
+ if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return c.json({ error: {
539
1061
  message: "Admin privileges required for this operation",
540
1062
  code: "FORBIDDEN"
541
1063
  } }, 403);
@@ -551,7 +1073,7 @@ var requireAdmin = async (c, next) => {
551
1073
  var optionalAuth = async (c, next) => {
552
1074
  if (c.get("user")) return next();
553
1075
  const token = extractBearerToken(c.req.header("authorization"));
554
- if (token !== void 0) {
1076
+ if (token !== void 0 && isJwtConfigured()) {
555
1077
  const payload = verifyAccessToken(token);
556
1078
  if (payload) c.set("user", payload);
557
1079
  }
@@ -561,6 +1083,7 @@ var optionalAuth = async (c, next) => {
561
1083
  * Extract user from token - for WebSocket authentication
562
1084
  */
563
1085
  function extractUserFromToken(token) {
1086
+ if (!isJwtConfigured()) return null;
564
1087
  return verifyAccessToken(token);
565
1088
  }
566
1089
  /**
@@ -711,7 +1234,7 @@ function createAuthMiddleware(options) {
711
1234
  var queryTokenAuth = async (c, next) => {
712
1235
  if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
713
1236
  const queryToken = c.req.query("token");
714
- if (queryToken) {
1237
+ if (queryToken && isJwtConfigured()) {
715
1238
  const payload = verifyAccessToken(queryToken);
716
1239
  if (payload) c.set("user", payload);
717
1240
  }
@@ -782,15 +1305,25 @@ var fileTokenAuth = async (c, next) => {
782
1305
  if (idx < 0) return "";
783
1306
  return fullPath.substring(idx + prefix.length + 1);
784
1307
  };
1308
+ /**
1309
+ * Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
1310
+ * path a token GRANTS is minted there and the path a request ASKS FOR is
1311
+ * derived here. `/metadata` canonicalizes before signing, so a raw
1312
+ * comparison here would 403 an otherwise-valid request for any key whose
1313
+ * URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
1314
+ *
1315
+ * A key that cannot be canonicalized yields null, which matches no grant —
1316
+ * the same fail-closed answer `isPathMatch` gives a `..` segment.
1317
+ */
785
1318
  const parseBucketPath = (filePath) => {
786
1319
  const parts = filePath.split("/");
787
1320
  if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
788
1321
  bucket: "default",
789
- resolvedPath: parts.slice(1).join("/")
1322
+ resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
790
1323
  };
791
1324
  return {
792
1325
  bucket: "default",
793
- resolvedPath: filePath
1326
+ resolvedPath: tryCanonicalStorageKey(filePath)
794
1327
  };
795
1328
  };
796
1329
  const bearerToken = extractBearerToken(authHeader);
@@ -800,7 +1333,8 @@ var fileTokenAuth = async (c, next) => {
800
1333
  const rawPath = extractWildcard(c);
801
1334
  if (rawPath) {
802
1335
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
803
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
1336
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
1337
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
804
1338
  c.set("user", {
805
1339
  uid: "download-token",
806
1340
  roles: ["reader"]
@@ -824,7 +1358,8 @@ var fileTokenAuth = async (c, next) => {
824
1358
  const rawPath = extractWildcard(c);
825
1359
  if (rawPath) {
826
1360
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
827
- if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
1361
+ const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
1362
+ if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
828
1363
  c.set("user", {
829
1364
  uid: "download-token",
830
1365
  roles: ["reader"]
@@ -852,8 +1387,9 @@ function createAdapterAuthMiddleware(options) {
852
1387
  const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
853
1388
  return async (c, next) => {
854
1389
  const driver = resolveDriver ? resolveDriver(c) : baseDriver;
1390
+ const presentedToken = extractBearerToken(c.req.header("authorization"));
855
1391
  if (apiKeyStore) {
856
- const token = extractBearerToken(c.req.header("authorization")) ?? "";
1392
+ const token = presentedToken ?? "";
857
1393
  if (token.startsWith("rk_")) {
858
1394
  const result = await validateApiKey(c, token, {
859
1395
  store: apiKeyStore,
@@ -890,17 +1426,23 @@ function createAdapterAuthMiddleware(options) {
890
1426
  code: "INTERNAL_ERROR"
891
1427
  } }, 500);
892
1428
  }
893
- } else try {
894
- c.set("driver", await scopeDataDriver(driver, {
895
- uid: ANONYMOUS_USER_ID,
896
- roles: ["anon"]
897
- }));
898
- } catch (error) {
899
- logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
900
- return c.json({ error: {
901
- message: "Server configuration error",
902
- code: "INTERNAL_ERROR"
903
- } }, 500);
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
+ }
904
1446
  }
905
1447
  if (enforceAuth && !c.get("user")) return c.json({ error: {
906
1448
  message: "Unauthorized: Authentication required",
@@ -910,43 +1452,141 @@ function createAdapterAuthMiddleware(options) {
910
1452
  };
911
1453
  }
912
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
913
1532
  //#region src/email/templates.ts
914
1533
  /**
915
- * 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.
916
1551
  */
917
1552
  function getGreeting(user) {
918
1553
  return user.displayName || user.email.split("@")[0];
919
1554
  }
920
1555
  /**
921
- * 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.)
922
1562
  */
923
1563
  var styles = {
924
- container: `
1564
+ container: raw(`
925
1565
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
926
1566
  max-width: 600px;
927
1567
  margin: 0 auto;
928
1568
  padding: 40px 20px;
929
1569
  background-color: #f8fafc;
930
- `,
931
- card: `
1570
+ `),
1571
+ card: raw(`
932
1572
  background-color: #ffffff;
933
1573
  border-radius: 12px;
934
1574
  padding: 40px;
935
1575
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
936
- `,
937
- heading: `
1576
+ `),
1577
+ heading: raw(`
938
1578
  color: #1e293b;
939
1579
  font-size: 24px;
940
1580
  font-weight: 600;
941
1581
  margin: 0 0 20px 0;
942
- `,
943
- paragraph: `
1582
+ `),
1583
+ paragraph: raw(`
944
1584
  color: #475569;
945
1585
  font-size: 16px;
946
1586
  line-height: 1.6;
947
1587
  margin: 0 0 20px 0;
948
- `,
949
- button: `
1588
+ `),
1589
+ button: raw(`
950
1590
  display: inline-block;
951
1591
  background-color: #3b82f6;
952
1592
  color: #ffffff;
@@ -956,22 +1596,22 @@ var styles = {
956
1596
  padding: 14px 28px;
957
1597
  border-radius: 8px;
958
1598
  margin: 20px 0;
959
- `,
960
- footer: `
1599
+ `),
1600
+ footer: raw(`
961
1601
  color: #94a3b8;
962
1602
  font-size: 14px;
963
1603
  margin-top: 30px;
964
1604
  padding-top: 20px;
965
1605
  border-top: 1px solid #e2e8f0;
966
- `,
967
- warning: `
1606
+ `),
1607
+ warning: raw(`
968
1608
  color: #64748b;
969
1609
  font-size: 14px;
970
1610
  background-color: #fef3c7;
971
1611
  padding: 12px 16px;
972
1612
  border-radius: 6px;
973
1613
  margin-top: 20px;
974
- `
1614
+ `)
975
1615
  };
976
1616
  /**
977
1617
  * Default password reset email template
@@ -979,9 +1619,7 @@ var styles = {
979
1619
  function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
980
1620
  const greeting = getGreeting(user);
981
1621
  const subject = `Reset your ${appName} password`;
982
- return {
983
- subject,
984
- html: `
1622
+ const body = html`
985
1623
  <!DOCTYPE html>
986
1624
  <html>
987
1625
  <head>
@@ -1028,8 +1666,8 @@ function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
1028
1666
  </div>
1029
1667
  </body>
1030
1668
  </html>
1031
- `.trim(),
1032
- text: `
1669
+ `;
1670
+ const text = `
1033
1671
  Reset Your Password
1034
1672
 
1035
1673
  Hi ${greeting},
@@ -1043,7 +1681,11 @@ This link will expire in 1 hour for security reasons.
1043
1681
 
1044
1682
  If you didn't request a password reset, you can safely ignore this email.
1045
1683
  Your password will remain unchanged.
1046
- `.trim()
1684
+ `.trim();
1685
+ return {
1686
+ subject,
1687
+ html: body.toString().trim(),
1688
+ text
1047
1689
  };
1048
1690
  }
1049
1691
  /**
@@ -1052,9 +1694,7 @@ Your password will remain unchanged.
1052
1694
  function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1053
1695
  const greeting = getGreeting(user);
1054
1696
  const subject = `Verify your ${appName} email address`;
1055
- return {
1056
- subject,
1057
- html: `
1697
+ const body = html`
1058
1698
  <!DOCTYPE html>
1059
1699
  <html>
1060
1700
  <head>
@@ -1096,8 +1736,8 @@ function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
1096
1736
  </div>
1097
1737
  </body>
1098
1738
  </html>
1099
- `.trim(),
1100
- text: `
1739
+ `;
1740
+ const text = `
1101
1741
  Verify Your Email
1102
1742
 
1103
1743
  Hi ${greeting},
@@ -1106,7 +1746,11 @@ Thanks for signing up for ${appName}! Please verify your email address by clicki
1106
1746
  ${verifyUrl}
1107
1747
 
1108
1748
  If you didn't create an account with ${appName}, you can safely ignore this email.
1109
- `.trim()
1749
+ `.trim();
1750
+ return {
1751
+ subject,
1752
+ html: body.toString().trim(),
1753
+ text
1110
1754
  };
1111
1755
  }
1112
1756
  /**
@@ -1116,9 +1760,7 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
1116
1760
  function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1117
1761
  const greeting = getGreeting(user);
1118
1762
  const subject = `You've been invited to ${appName}`;
1119
- return {
1120
- subject,
1121
- html: `
1763
+ const body = html`
1122
1764
  <!DOCTYPE html>
1123
1765
  <html>
1124
1766
  <head>
@@ -1164,8 +1806,8 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
1164
1806
  </div>
1165
1807
  </body>
1166
1808
  </html>
1167
- `.trim(),
1168
- text: `
1809
+ `;
1810
+ const text = `
1169
1811
  Welcome to ${appName}!
1170
1812
 
1171
1813
  Hi ${greeting},
@@ -1178,7 +1820,11 @@ ${setPasswordUrl}
1178
1820
  This link will expire in 1 hour for security reasons.
1179
1821
 
1180
1822
  If you weren't expecting this invitation, you can safely ignore this email.
1181
- `.trim()
1823
+ `.trim();
1824
+ return {
1825
+ subject,
1826
+ html: body.toString().trim(),
1827
+ text
1182
1828
  };
1183
1829
  }
1184
1830
  /**
@@ -1189,9 +1835,7 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1189
1835
  const greeting = getGreeting(user);
1190
1836
  const url = loginUrl || "";
1191
1837
  const subject = `¡Bienvenido/a a ${appName}!`;
1192
- return {
1193
- subject,
1194
- html: `
1838
+ const body = html`
1195
1839
  <!DOCTYPE html>
1196
1840
  <html>
1197
1841
  <head>
@@ -1218,11 +1862,11 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1218
1862
  que tenemos para ti.
1219
1863
  </p>
1220
1864
 
1221
- ${url ? `
1865
+ ${url ? html`
1222
1866
  <div style="text-align: center;">
1223
1867
  <a href="${url}" style="${styles.button}">Ir a mi Panel</a>
1224
1868
  </div>
1225
- ` : ""}
1869
+ ` : raw("")}
1226
1870
 
1227
1871
  <p style="${styles.paragraph}">
1228
1872
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
@@ -1237,8 +1881,8 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
1237
1881
  </div>
1238
1882
  </body>
1239
1883
  </html>
1240
- `.trim(),
1241
- text: `
1884
+ `;
1885
+ const text = `
1242
1886
  ¡Bienvenido/a a ${appName}!
1243
1887
 
1244
1888
  Hola ${greeting},
@@ -1250,7 +1894,11 @@ Ya puedes acceder a tu panel y empezar a explorar todas las oportunidades que te
1250
1894
  ${url ? `Ir a mi panel: ${url}` : ""}
1251
1895
 
1252
1896
  Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1253
- `.trim()
1897
+ `.trim();
1898
+ return {
1899
+ subject,
1900
+ html: body.toString().trim(),
1901
+ text
1254
1902
  };
1255
1903
  }
1256
1904
  /**
@@ -1259,9 +1907,7 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
1259
1907
  function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1260
1908
  const greeting = getGreeting(user);
1261
1909
  const subject = `Sign in to ${appName}`;
1262
- return {
1263
- subject,
1264
- html: `
1910
+ const body = html`
1265
1911
  <!DOCTYPE html>
1266
1912
  <html>
1267
1913
  <head>
@@ -1308,8 +1954,8 @@ function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
1308
1954
  </div>
1309
1955
  </body>
1310
1956
  </html>
1311
- `.trim(),
1312
- text: `
1957
+ `;
1958
+ const text = `
1313
1959
  Sign In to ${appName}
1314
1960
 
1315
1961
  Hi ${greeting},
@@ -1323,7 +1969,11 @@ This link will expire in 15 minutes for security reasons and can only be used on
1323
1969
 
1324
1970
  If you didn't request this sign-in link, you can safely ignore this email.
1325
1971
  No action is needed.
1326
- `.trim()
1972
+ `.trim();
1973
+ return {
1974
+ subject,
1975
+ html: body.toString().trim(),
1976
+ text
1327
1977
  };
1328
1978
  }
1329
1979
  //#endregion
@@ -1458,11 +2108,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1458
2108
  await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
1459
2109
  const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
1460
2110
  const appName = ctx.emailConfig?.appName || "Rebase";
1461
- const templateFn = ctx.emailConfig?.templates?.passwordReset;
2111
+ const templateFn = ctx.emailConfig?.templates?.userInvitation;
1462
2112
  const emailContent = templateFn ? templateFn(setPasswordUrl, {
1463
2113
  email: entity.values.email,
1464
2114
  displayName: entity.values.displayName
1465
- }) : getPasswordResetTemplate(setPasswordUrl, {
2115
+ }) : getUserInvitationTemplate(setPasswordUrl, {
1466
2116
  email: entity.values.email,
1467
2117
  displayName: entity.values.displayName
1468
2118
  }, appName);
@@ -1491,6 +2141,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
1491
2141
  var scryptAsync = promisify(scrypt);
1492
2142
  var SALT_LENGTH = 32;
1493
2143
  var KEY_LENGTH = 64;
2144
+ var SCRYPT_PARAMS = {
2145
+ N: 16384,
2146
+ r: 8,
2147
+ p: 1
2148
+ };
1494
2149
  /**
1495
2150
  * Password requirements:
1496
2151
  * - Minimum 8 characters
@@ -1515,7 +2170,7 @@ function validatePasswordStrength(password) {
1515
2170
  */
1516
2171
  async function hashPassword(password) {
1517
2172
  const salt = randomBytes$1(SALT_LENGTH);
1518
- const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
2173
+ const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
1519
2174
  return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
1520
2175
  }
1521
2176
  /**
@@ -1527,7 +2182,7 @@ async function verifyPassword(password, storedHash) {
1527
2182
  if (!saltHex || !hashHex) return false;
1528
2183
  const salt = Buffer.from(saltHex, "hex");
1529
2184
  const storedKey = Buffer.from(hashHex, "hex");
1530
- return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH), storedKey);
2185
+ return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS), storedKey);
1531
2186
  }
1532
2187
  //#endregion
1533
2188
  //#region src/auth/auth-hooks.ts
@@ -1585,6 +2240,196 @@ function resolveAuthHooks(hooks) {
1585
2240
  };
1586
2241
  }
1587
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
1588
2433
  //#region src/auth/rate-limit-store.ts
1589
2434
  /**
1590
2435
  * The default store: a sliding window in this process's memory.
@@ -1650,13 +2495,32 @@ var MemoryRateLimitStore = class {
1650
2495
  //#region src/auth/rate-limiter.ts
1651
2496
  /**
1652
2497
  * Resolve the number of trusted proxy hops from an explicit option, the
1653
- * `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.
1654
2502
  */
1655
2503
  function resolveTrustedProxyHops(optionValue) {
1656
2504
  if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue >= 0) return Math.floor(optionValue);
1657
2505
  const fromEnv = Number(process.env.TRUSTED_PROXY_HOPS);
1658
2506
  if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.floor(fromEnv);
1659
- 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.");
1660
2524
  }
1661
2525
  /**
1662
2526
  * Create a rate-limiting middleware.
@@ -1686,7 +2550,23 @@ function createRateLimiter(options = {}) {
1686
2550
  };
1687
2551
  }
1688
2552
  /**
1689
- * Default key generator: extract client IP from standard headers.
2553
+ * The address the socket is actually connected to, or `undefined` on a runtime
2554
+ * that cannot say. Unforgeable, unlike every header below.
2555
+ *
2556
+ * `getConnInfo` is Node-specific and throws elsewhere, so the failure is
2557
+ * swallowed: a runtime without connection info falls back to the shared bucket,
2558
+ * which is a availability trade the caller cannot exploit.
2559
+ */
2560
+ function socketAddress(c) {
2561
+ try {
2562
+ return getConnInfo(c).remote.address;
2563
+ } catch {
2564
+ return;
2565
+ }
2566
+ }
2567
+ /**
2568
+ * Default key generator: the client's address, from the most trustworthy source
2569
+ * this deployment has.
1690
2570
  *
1691
2571
  * `X-Forwarded-For` is a client-writable header; only the entries appended by
1692
2572
  * trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
@@ -1695,18 +2575,36 @@ function createRateLimiter(options = {}) {
1695
2575
  * client-supplied and must be ignored. This is what prevents a caller from
1696
2576
  * spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
1697
2577
  *
1698
- * With `trustedProxyHops === 0` (server exposed directly, no proxy),
1699
- * `X-Forwarded-For` is not trusted at all and `X-Real-IP` is used instead.
2578
+ * `X-Real-IP` is the *same* kind of header and needs the same rule, which it
2579
+ * did not have: it was read unconditionally, including under
2580
+ * `trustedProxyHops === 0` — the mode whose entire meaning is "no proxy is in
2581
+ * front of me". With no proxy there, nothing writes `X-Real-IP` except the
2582
+ * caller, so the key was theirs to choose: one header per request bought an
2583
+ * unlimited number of buckets, and the limiters on login, registration and
2584
+ * password reset counted to one. The reasoning had been done carefully for one
2585
+ * spelling of a proxy header and not carried to its twin.
2586
+ *
2587
+ * So `X-Real-IP` is now believed only where a proxy is declared to exist. With
2588
+ * none, the connection's own address is used — unforgeable, and available
2589
+ * because the server runs on `@hono/node-server`. `"unknown"` is the last
2590
+ * resort only, and it is a single shared bucket by design: better that
2591
+ * anonymous callers throttle each other than that any of them throttles nobody.
1700
2592
  */
1701
2593
  function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
1702
- if (trustedProxyHops > 0) {
2594
+ if (trustedProxyHops === 0) {
2595
+ warnIfProxiedButUntrusted(!!c.req.header("x-forwarded-for"));
2596
+ return socketAddress(c) ?? "unknown";
2597
+ }
2598
+ {
1703
2599
  const forwardedFor = c.req.header("x-forwarded-for");
1704
2600
  if (forwardedFor) {
1705
2601
  const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
1706
2602
  if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
1707
2603
  }
2604
+ const realIp = c.req.header("x-real-ip");
2605
+ if (realIp) return realIp;
1708
2606
  }
1709
- return c.req.header("x-real-ip") || "unknown";
2607
+ return socketAddress(c) ?? "unknown";
1710
2608
  }
1711
2609
  /**
1712
2610
  * Pre-configured rate limiter for general auth endpoints (login, register).
@@ -1727,6 +2625,34 @@ var strictAuthLimiter = createRateLimiter({
1727
2625
  message: "Too many requests to this sensitive endpoint, please try again later."
1728
2626
  });
1729
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
+ /**
1730
2656
  * Key generator for API-key-based rate limiting.
1731
2657
  *
1732
2658
  * Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
@@ -1738,6 +2664,8 @@ function apiKeyKeyGenerator(c) {
1738
2664
  if (apiKey) return `api-key:${apiKey.id}`;
1739
2665
  return defaultKeyGenerator(c);
1740
2666
  }
2667
+ /** @see DataRateLimitConfig.anonymousFunctions */
2668
+ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
1741
2669
  /**
1742
2670
  * Rate limiting for the data API.
1743
2671
  *
@@ -1757,6 +2685,7 @@ function apiKeyKeyGenerator(c) {
1757
2685
  */
1758
2686
  function createDataRateLimiter(config = {}) {
1759
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);
1760
2689
  return createRateLimiter({
1761
2690
  windowMs,
1762
2691
  store,
@@ -1766,7 +2695,7 @@ function createDataRateLimiter(config = {}) {
1766
2695
  if (key) return `api-key:${key.id}`;
1767
2696
  const user = c.get("user");
1768
2697
  if (user?.uid && !isAnonymousUid(user.uid)) return `user:${user.uid}`;
1769
- return `ip:${defaultKeyGenerator(c)}`;
2698
+ return `ip:${defaultKeyGenerator(c, trustedProxyHops)}`;
1770
2699
  },
1771
2700
  resolveLimit: (c) => {
1772
2701
  const key = c.get("apiKey");
@@ -5783,17 +6712,27 @@ function generateHotp(secret, counter) {
5783
6712
  return (((hash[offset] & 127) << 24 | hash[offset + 1] << 16 | hash[offset + 2] << 8 | hash[offset + 3]) % 1e6).toString().padStart(6, "0");
5784
6713
  }
5785
6714
  /**
5786
- * 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.
5787
6723
  *
5788
6724
  * @param secret - The shared secret as a Buffer
5789
6725
  * @param token - The 6-digit TOTP code to verify
5790
6726
  * @param window - Number of time steps to check on each side (default: 1)
5791
- * @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
5792
6728
  */
5793
- function verifyTotp(secret, token, window = 1) {
6729
+ function verifyTotpCounter(secret, token, window = 1) {
5794
6730
  const counter = BigInt(Math.floor(Date.now() / 1e3 / 30));
5795
- for (let i = -window; i <= window; i++) if (generateHotp(secret, counter + BigInt(i)) === token) return true;
5796
- return false;
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;
5797
6736
  }
5798
6737
  /**
5799
6738
  * Generate a new TOTP secret and return the setup information
@@ -5836,79 +6775,335 @@ function hashRecoveryCode(code) {
5836
6775
  *
5837
6776
  * - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
5838
6777
  * - Generates a random 12-byte IV per encryption call.
5839
- * - 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.
5840
6808
  *
5841
6809
  * @module
5842
6810
  */
5843
6811
  var ALGORITHM = "aes-256-gcm";
5844
6812
  var IV_BYTES = 12;
5845
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
+ }
5846
6826
  /**
5847
- * Resolve the raw key string from environment variables.
6827
+ * Every key this process can decrypt with, most-preferred first.
5848
6828
  *
5849
- * Prefers `MFA_ENCRYPTION_KEY`; falls back to `JWT_SECRET` with a warning.
5850
- * 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.
5851
6831
  */
5852
- function resolveKeyString() {
5853
- const explicit = process.env.MFA_ENCRYPTION_KEY;
5854
- if (explicit) return explicit;
5855
- const fallback = process.env.JWT_SECRET;
5856
- if (fallback) {
5857
- logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production.");
5858
- 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.");
5859
6848
  }
5860
- throw new Error("Cannot encrypt TOTP secret: neither MFA_ENCRYPTION_KEY nor JWT_SECRET is configured.");
6849
+ return candidates;
5861
6850
  }
5862
- /**
5863
- * Derive a deterministic 32-byte AES-256 key from the raw secret string.
5864
- */
5865
- function deriveKey(raw) {
5866
- return createHash$1("sha256").update(raw).digest();
6851
+ /** The key new ciphertexts are written with. */
6852
+ function currentKey() {
6853
+ return keyCandidates()[0];
5867
6854
  }
5868
6855
  /**
5869
6856
  * Encrypt a plaintext TOTP secret.
5870
6857
  *
5871
6858
  * @param plaintext - The Base32 TOTP secret to encrypt.
5872
- * @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`.
5873
6860
  */
5874
6861
  function encryptTotpSecret(plaintext) {
5875
- const key = deriveKey(resolveKeyString());
6862
+ const { id, key } = currentKey();
5876
6863
  const iv = randomBytes$1(IV_BYTES);
5877
6864
  const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5878
6865
  const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
5879
6866
  const authTag = cipher.getAuthTag();
5880
- 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");
5881
6873
  }
5882
6874
  /**
5883
- * Decrypt a previously encrypted TOTP secret.
6875
+ * Decrypt a stored TOTP secret, reporting whether it wants re-wrapping.
5884
6876
  *
5885
- * @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
5886
- * @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.
5887
6879
  */
5888
- function decryptTotpSecret(ciphertext) {
6880
+ function openTotpSecret(ciphertext) {
5889
6881
  const parts = ciphertext.split(":");
5890
- 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");
5891
6897
  const [ivHex, authTagHex, encryptedHex] = parts;
5892
- const key = deriveKey(resolveKeyString());
5893
- const iv = Buffer.from(ivHex, "hex");
5894
- const authTag = Buffer.from(authTagHex, "hex");
5895
- const encrypted = Buffer.from(encryptedHex, "hex");
5896
- const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5897
- decipher.setAuthTag(authTag);
5898
- return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
6898
+ for (const candidate of candidates) try {
6899
+ const secret = openWith(candidate, ivHex, authTagHex, encryptedHex);
6900
+ return {
6901
+ secret,
6902
+ rewrapped: encryptTotpSecret(secret)
6903
+ };
6904
+ } catch {}
6905
+ throw new Error(`Could not decrypt TOTP secret with any configured key. Tried: ${candidates.map((c) => c.source).join(", ")}.`);
6906
+ }
6907
+ //#endregion
6908
+ //#region src/auth/cookie-utils.ts
6909
+ /**
6910
+ * Cookie auth helpers for httpOnly refresh tokens.
6911
+ */
6912
+ function getCookieSettings(config) {
6913
+ return {
6914
+ name: config?.cookieName || "__rb_refresh",
6915
+ path: config?.path || "/",
6916
+ sameSite: config?.sameSite || "Lax",
6917
+ domain: config?.domain,
6918
+ secure: config?.secure
6919
+ };
6920
+ }
6921
+ /**
6922
+ * Set the refresh token as an httpOnly cookie on the response.
6923
+ */
6924
+ function setRefreshCookie(c, refreshToken, config) {
6925
+ if (!config) return;
6926
+ const settings = getCookieSettings(config);
6927
+ const isSecure = settings.secure ?? true;
6928
+ let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6929
+ if (isSecure) cookie += "; Secure";
6930
+ if (settings.domain) cookie += `; Domain=${settings.domain}`;
6931
+ const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6932
+ cookie += `; Max-Age=${maxAgeSeconds}`;
6933
+ c.header("Set-Cookie", cookie, { append: true });
6934
+ }
6935
+ /**
6936
+ * Clear the refresh token cookie.
6937
+ */
6938
+ function clearRefreshCookie(c, config) {
6939
+ if (!config) return;
6940
+ const settings = getCookieSettings(config);
6941
+ let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6942
+ if (settings.secure ?? true) cookie += "; Secure";
6943
+ if (settings.domain) cookie += `; Domain=${settings.domain}`;
6944
+ c.header("Set-Cookie", cookie, { append: true });
6945
+ }
6946
+ /**
6947
+ * Read the refresh token from the request — cookie first, then body fallback.
6948
+ */
6949
+ function readRefreshToken(c, body, config) {
6950
+ if (config) {
6951
+ const settings = getCookieSettings(config);
6952
+ const cookieHeader = c.req.header("cookie") || "";
6953
+ const prefix = `${settings.name}=`;
6954
+ const cookies = cookieHeader.split(";");
6955
+ for (const part of cookies) {
6956
+ const trimmed = part.trim();
6957
+ if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6958
+ }
6959
+ }
6960
+ return body.refreshToken;
6961
+ }
6962
+ /**
6963
+ * Redact the refresh token from the response body when cookie mode is active.
6964
+ */
6965
+ function redactRefreshToken(response, c, refreshToken, config) {
6966
+ if (!config) return response;
6967
+ setRefreshCookie(c, refreshToken, config);
6968
+ return {
6969
+ ...response,
6970
+ tokens: {
6971
+ ...response.tokens,
6972
+ refreshToken: ""
6973
+ }
6974
+ };
5899
6975
  }
5900
6976
  //#endregion
5901
6977
  //#region src/auth/mfa-routes.ts
5902
- function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6978
+ /**
6979
+ * How many failed verifications one challenge tolerates before it is spent.
6980
+ *
6981
+ * A 6-digit code with a ±1 step window accepts 3 of 1,000,000 values, so the
6982
+ * only thing standing between an attacker and the second factor is how many
6983
+ * guesses a challenge will take. Five, and then they must open another one —
6984
+ * which the per-user limiter below is counting.
6985
+ */
6986
+ var MAX_CHALLENGE_ATTEMPTS = 5;
6987
+ /** Verification attempts one *account* may make per window, across all IPs. */
6988
+ var MFA_VERIFICATION_ATTEMPTS_PER_WINDOW = 10;
6989
+ /**
6990
+ * Resolve the caller of a challenge route from either a full session or a
6991
+ * pre-auth token.
6992
+ *
6993
+ * Both are parsed here rather than by `requireAuth` because the whole point of
6994
+ * the challenge pair is that it runs before the session exists — a login by an
6995
+ * MFA-enrolled user never receives an access token until it is finished.
6996
+ */
6997
+ /**
6998
+ * Open a factor's stored secret, re-storing it under the current key if it
6999
+ * opened with an older one.
7000
+ *
7001
+ * This is what makes an `MFA_ENCRYPTION_KEY` rotation complete on its own: each
7002
+ * factor moves to the new key the next time its owner authenticates, so the
7003
+ * previous key can eventually be dropped. See `mfa-crypto.ts` for the sequence.
7004
+ *
7005
+ * A failed re-wrap must never fail the verification — the code the user typed
7006
+ * was already checked against the right secret, and the only cost of not
7007
+ * persisting is that this factor is re-wrapped again next time.
7008
+ */
7009
+ async function openAndRewrap(authRepo, factorId, secretEncrypted) {
7010
+ const { secret, rewrapped } = openTotpSecret(secretEncrypted);
7011
+ if (!rewrapped) return secret;
7012
+ if (typeof authRepo.updateMfaFactorSecret !== "function") return secret;
7013
+ try {
7014
+ await authRepo.updateMfaFactorSecret(factorId, rewrapped);
7015
+ logger.info("[MFA-Crypto] Re-wrapped a TOTP secret under the current key", { factorId });
7016
+ } catch (error) {
7017
+ logger.warn("[MFA-Crypto] Could not re-wrap a TOTP secret; leaving it on its original key", {
7018
+ factorId,
7019
+ error
7020
+ });
7021
+ }
7022
+ return secret;
7023
+ }
7024
+ function resolveStepUpPrincipal(c) {
7025
+ const existing = c.get("user");
7026
+ if (existing?.uid) return {
7027
+ uid: existing.uid,
7028
+ aal: existing.aal === "aal2" ? "aal2" : "aal1",
7029
+ pending: false
7030
+ };
7031
+ const token = extractBearerToken(c.req.header("authorization"));
7032
+ if (token === void 0) return null;
7033
+ const session = verifyAccessToken(token);
7034
+ if (session) return {
7035
+ uid: session.uid,
7036
+ aal: session.aal === "aal2" ? "aal2" : "aal1",
7037
+ pending: false
7038
+ };
7039
+ const pending = verifyMfaPendingToken(token);
7040
+ if (pending) return {
7041
+ uid: pending.uid,
7042
+ aal: "aal1",
7043
+ pending: true
7044
+ };
7045
+ return null;
7046
+ }
7047
+ /**
7048
+ * Per-account throttle on code verification.
7049
+ *
7050
+ * Keyed on the uid rather than the IP because an IP is the attacker's to
7051
+ * rotate and the account under attack is not: a distributed run against one
7052
+ * account passes an IP-keyed limiter untouched. The IP limiter still runs in
7053
+ * front of this one — they bound different things.
7054
+ */
7055
+ var mfaVerificationLimiter = createRateLimiter({
7056
+ windowMs: 900 * 1e3,
7057
+ limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
7058
+ keyGenerator: (c) => `mfa-verify:${resolveStepUpPrincipal(c)?.uid ?? "unidentified"}`,
7059
+ message: "Too many verification attempts for this account, please try again later."
7060
+ });
7061
+ function mountMfaRoutes(opts) {
7062
+ const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
5903
7063
  const authRepo = config.authRepo;
5904
7064
  const emailConfig = config.emailConfig;
5905
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
+ /**
5906
7100
  * POST /auth/mfa/enroll
5907
7101
  * Start MFA enrollment: generate TOTP secret and recovery codes
5908
7102
  */
5909
- router.post("/mfa/enroll", requireAuth, async (c) => {
7103
+ router.post("/mfa/enroll", strictAuthLimiter, requireAuth, async (c) => {
5910
7104
  const userCtx = c.get("user");
5911
7105
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
7106
+ await requireStepUpForFactorChange(userCtx);
5912
7107
  const body = await c.req.json().catch(() => ({}));
5913
7108
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
5914
7109
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
@@ -5938,17 +7133,20 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5938
7133
  * POST /auth/mfa/verify
5939
7134
  * Verify TOTP code to complete MFA enrollment
5940
7135
  */
5941
- router.post("/mfa/verify", requireAuth, async (c) => {
7136
+ router.post("/mfa/verify", strictAuthLimiter, requireAuth, mfaVerificationLimiter, async (c) => {
5942
7137
  const userCtx = c.get("user");
5943
7138
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5944
- const { factorId, code } = parseBody(object({
7139
+ await requireStepUpForFactorChange(userCtx);
7140
+ const verifySchema = object({
5945
7141
  factorId: string().min(1, "Factor ID is required"),
5946
7142
  code: string().length(6, "Code must be 6 digits")
5947
- }), await c.req.json());
7143
+ });
7144
+ const { factorId, code } = parseBody(verifySchema, await c.req.json());
5948
7145
  const factor = await authRepo.getMfaFactorById(factorId);
5949
7146
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
5950
7147
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
5951
- if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
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");
5952
7150
  await authRepo.verifyMfaFactor(factorId);
5953
7151
  return c.json({
5954
7152
  success: true,
@@ -5957,14 +7155,16 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5957
7155
  });
5958
7156
  /**
5959
7157
  * POST /auth/mfa/challenge
5960
- * 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`.
5961
7160
  */
5962
- router.post("/mfa/challenge", requireAuth, async (c) => {
5963
- const userCtx = c.get("user");
5964
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5965
- const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
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());
5966
7166
  const factor = await authRepo.getMfaFactorById(factorId);
5967
- 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");
5968
7168
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
5969
7169
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
5970
7170
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -5976,49 +7176,73 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
5976
7176
  });
5977
7177
  /**
5978
7178
  * POST /auth/mfa/challenge/verify
5979
- * 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`.
5980
7181
  */
5981
- router.post("/mfa/challenge/verify", requireAuth, async (c) => {
5982
- const userCtx = c.get("user");
5983
- if (!userCtx) throw ApiError.unauthorized("Not authenticated");
5984
- const { challengeId, code } = parseBody(object({
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({
5985
7186
  challengeId: string().min(1, "Challenge ID is required"),
5986
7187
  code: string().min(1, "Code is required")
5987
- }), await c.req.json());
7188
+ });
7189
+ const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
5988
7190
  const challenge = await authRepo.getMfaChallengeById(challengeId);
5989
7191
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
5990
7192
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
5991
- if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
5992
- let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
5993
- if (!isValid) {
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) {
5994
7205
  const codeHash = hashRecoveryCode(code);
5995
- 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");
5996
7217
  }
5997
- if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
5998
7218
  await authRepo.verifyMfaChallenge(challengeId);
5999
- const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
6000
- const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
6001
- const refreshToken = generateRefreshToken();
6002
- await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
6003
- id: randomUUID$1(),
6004
- startedAt: /* @__PURE__ */ new Date()
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"
6005
7222
  });
6006
- if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
7223
+ if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
6007
7224
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
6008
7225
  });
6009
- 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: {
6010
7234
  accessToken,
6011
7235
  refreshToken,
6012
7236
  accessTokenExpiresAt: getAccessTokenExpiry()
6013
7237
  } };
6014
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
6015
- 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));
6016
7240
  });
6017
7241
  /**
6018
7242
  * GET /auth/mfa/factors
6019
7243
  * List enrolled MFA factors for the current user
6020
7244
  */
6021
- router.get("/mfa/factors", requireAuth, async (c) => {
7245
+ router.get("/mfa/factors", strictAuthLimiter, requireAuth, async (c) => {
6022
7246
  const userCtx = c.get("user");
6023
7247
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6024
7248
  const factors = await authRepo.getMfaFactors(userCtx.uid);
@@ -6034,11 +7258,12 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6034
7258
  * DELETE /auth/mfa/unenroll
6035
7259
  * Remove an MFA factor
6036
7260
  */
6037
- router.delete("/mfa/unenroll", requireAuth, async (c) => {
7261
+ router.delete("/mfa/unenroll", strictAuthLimiter, requireAuth, async (c) => {
6038
7262
  const userCtx = c.get("user");
6039
7263
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6040
7264
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
6041
- const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
7265
+ const unenrollSchema = object({ factorId: string().min(1, "Factor ID is required") });
7266
+ const { factorId } = parseBody(unenrollSchema, await c.req.json());
6042
7267
  const factor = await authRepo.getMfaFactorById(factorId);
6043
7268
  if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
6044
7269
  await authRepo.deleteMfaFactor(factorId, userCtx.uid);
@@ -6050,72 +7275,51 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
6050
7275
  });
6051
7276
  }
6052
7277
  //#endregion
6053
- //#region src/auth/cookie-utils.ts
6054
- /**
6055
- * Cookie auth helpers for httpOnly refresh tokens.
6056
- */
6057
- function getCookieSettings(config) {
6058
- return {
6059
- name: config?.cookieName || "__rb_refresh",
6060
- path: config?.path || "/",
6061
- sameSite: config?.sameSite || "Lax",
6062
- domain: config?.domain,
6063
- secure: config?.secure
6064
- };
6065
- }
6066
- /**
6067
- * Set the refresh token as an httpOnly cookie on the response.
6068
- */
6069
- function setRefreshCookie(c, refreshToken, config) {
6070
- if (!config) return;
6071
- const settings = getCookieSettings(config);
6072
- const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
6073
- let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
6074
- if (isSecure) cookie += "; Secure";
6075
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6076
- const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
6077
- cookie += `; Max-Age=${maxAgeSeconds}`;
6078
- c.header("Set-Cookie", cookie, { append: true });
6079
- }
7278
+ //#region src/auth/mfa-gate.ts
6080
7279
  /**
6081
- * 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
6082
7292
  */
6083
- function clearRefreshCookie(c, config) {
6084
- if (!config) return;
6085
- const settings = getCookieSettings(config);
6086
- let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
6087
- if (settings.domain) cookie += `; Domain=${settings.domain}`;
6088
- c.header("Set-Cookie", cookie, { append: true });
6089
- }
7293
+ /** Error code a client watches for to switch a sign-in into its second step. */
7294
+ var MFA_REQUIRED_CODE = "MFA_REQUIRED";
6090
7295
  /**
6091
- * 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.
6092
7302
  */
6093
- function readRefreshToken(c, body, config) {
6094
- if (config) {
6095
- const settings = getCookieSettings(config);
6096
- const cookieHeader = c.req.header("cookie") || "";
6097
- const prefix = `${settings.name}=`;
6098
- const cookies = cookieHeader.split(";");
6099
- for (const part of cookies) {
6100
- const trimmed = part.trim();
6101
- if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
6102
- }
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;
6103
7307
  }
6104
- return body.refreshToken;
6105
- }
6106
- /**
6107
- * Redact the refresh token from the response body when cookie mode is active.
6108
- */
6109
- function redactRefreshToken(response, c, refreshToken, config) {
6110
- if (!config) return response;
6111
- setRefreshCookie(c, refreshToken, config);
6112
- return {
6113
- ...response,
6114
- tokens: {
6115
- ...response.tokens,
6116
- refreshToken: ""
6117
- }
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
+ }))
6118
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);
6119
7323
  }
6120
7324
  //#endregion
6121
7325
  //#region src/auth/registration-policy.ts
@@ -6137,11 +7341,58 @@ function isSteadyStateRegistrationOpen(policy) {
6137
7341
  needsSetup: false
6138
7342
  });
6139
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
+ }
6140
7375
  //#endregion
6141
7376
  //#region src/auth/session-routes.ts
6142
7377
  function mountSessionRoutes(opts) {
6143
7378
  const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
6144
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
+ }
6145
7396
  const logoutSchema = object({ refreshToken: string().optional() });
6146
7397
  const linkSchema = object({
6147
7398
  email: string().email("Invalid email address").max(255),
@@ -6168,7 +7419,7 @@ function mountSessionRoutes(opts) {
6168
7419
  clearRefreshCookie(c, config.cookieAuth);
6169
7420
  const accessToken = extractBearerToken(c.req.header("authorization"));
6170
7421
  if (ops.afterLogout && accessToken !== void 0) {
6171
- const { verifyAccessToken } = await import("./jwt-DD6EtpGj.js").then((n) => n.u);
7422
+ const { verifyAccessToken } = await import("./jwt-_IFqfTOg.js").then((n) => n.f);
6172
7423
  const payload = verifyAccessToken(accessToken);
6173
7424
  if (payload) ops.afterLogout(payload.uid).catch((err) => {
6174
7425
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
@@ -6334,6 +7585,10 @@ function mountSessionRoutes(opts) {
6334
7585
  registrationEnabled: registrationAllowed,
6335
7586
  emailServiceEnabled: isEmailConfigured(),
6336
7587
  magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
7588
+ anonymousLoginEnabled: isAnonymousAuthOpen({
7589
+ allowAnonymous: config.allowAnonymous,
7590
+ disableSelfRegistration: config.disableSelfRegistration
7591
+ }),
6337
7592
  enabledProviders
6338
7593
  });
6339
7594
  });
@@ -6342,6 +7597,7 @@ function mountSessionRoutes(opts) {
6342
7597
  * Create an anonymous user with temporary credentials
6343
7598
  */
6344
7599
  router.post("/anonymous", strictAuthLimiter, async (c) => {
7600
+ assertAnonymousAuthOpen();
6345
7601
  let createData = {
6346
7602
  email: `anon_${randomBytes$1(16).toString("hex")}@anonymous.local`,
6347
7603
  emailVerified: false,
@@ -6365,7 +7621,8 @@ function mountSessionRoutes(opts) {
6365
7621
  * POST /auth/anonymous/link
6366
7622
  * Upgrade an anonymous user to a permanent account with email/password
6367
7623
  */
6368
- router.post("/anonymous/link", requireAuth, async (c) => {
7624
+ router.post("/anonymous/link", strictAuthLimiter, requireAuth, async (c) => {
7625
+ assertAnonymousAuthOpen();
6369
7626
  const userCtx = c.get("user");
6370
7627
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6371
7628
  const user = await authRepo.getUserById(userCtx.uid);
@@ -6422,7 +7679,7 @@ function mountMagicLinkRoutes(deps) {
6422
7679
  const tokenHash = hashToken(token);
6423
7680
  const expiresAt = getMagicLinkExpiry();
6424
7681
  await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
6425
- const magicLinkUrl = `${emailConfig?.magicLinkUrl || emailConfig?.resetPasswordUrl || ""}/auth/magic-link?token=${token}`;
7682
+ const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
6426
7683
  const appName = emailConfig?.appName || "Rebase";
6427
7684
  const templateFn = emailConfig?.templates?.magicLink;
6428
7685
  const emailContent = templateFn ? templateFn(magicLinkUrl, {
@@ -6474,6 +7731,64 @@ function mountMagicLinkRoutes(deps) {
6474
7731
  });
6475
7732
  }
6476
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
6477
7792
  //#region src/auth/routes.ts
6478
7793
  /**
6479
7794
  * Helper to build standard auth response output
@@ -6505,7 +7820,7 @@ function getPasswordResetExpiry() {
6505
7820
  return new Date(Date.now() + 3600 * 1e3);
6506
7821
  }
6507
7822
  function createAuthRoutes(config) {
6508
- 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.`);
6509
7824
  const router = new Hono();
6510
7825
  router.onError(errorHandler);
6511
7826
  const authRepo = config.authRepo;
@@ -6549,11 +7864,6 @@ function createAuthRoutes(config) {
6549
7864
  newPassword: string().min(1, "New password is required").max(128)
6550
7865
  });
6551
7866
  const refreshSchema = object({ refreshToken: string().min(1).optional() });
6552
- object({ refreshToken: string().optional() });
6553
- object({
6554
- displayName: string().max(255).optional(),
6555
- photoURL: string().url().max(2048).optional()
6556
- });
6557
7867
  /** Parse a Zod schema against the request body, throwing ApiError on failure */
6558
7868
  function parseBody(schema, body) {
6559
7869
  const result = schema.safeParse(body);
@@ -6593,7 +7903,7 @@ function createAuthRoutes(config) {
6593
7903
  function sendWelcomeEmail(user) {
6594
7904
  if (!isEmailConfigured()) return;
6595
7905
  const appName = emailConfig?.appName || "Rebase";
6596
- const loginUrl = emailConfig?.resetPasswordUrl || "";
7906
+ const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
6597
7907
  const templateFn = emailConfig?.templates?.welcomeEmail;
6598
7908
  const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0);
6599
7909
  emailService.send({
@@ -6606,9 +7916,21 @@ function createAuthRoutes(config) {
6606
7916
  });
6607
7917
  }
6608
7918
  /**
6609
- * 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`.
6610
7930
  */
6611
- 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";
6612
7934
  const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
6613
7935
  let customClaims;
6614
7936
  if (ops.customizeAccessToken) {
@@ -6617,16 +7939,17 @@ function createAuthRoutes(config) {
6617
7939
  const defaultClaims = {
6618
7940
  uid,
6619
7941
  roles: roleIds,
6620
- aal: "aal1"
7942
+ aal
6621
7943
  };
6622
7944
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
6623
7945
  }
6624
7946
  }
6625
- const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
7947
+ const accessToken = generateAccessToken(uid, roleIds, aal, customClaims);
6626
7948
  const refreshToken = generateRefreshToken();
6627
7949
  await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
6628
7950
  id: randomUUID$1(),
6629
- startedAt: /* @__PURE__ */ new Date()
7951
+ startedAt: /* @__PURE__ */ new Date(),
7952
+ aal
6630
7953
  });
6631
7954
  return {
6632
7955
  roleIds,
@@ -6724,29 +8047,46 @@ function createAuthRoutes(config) {
6724
8047
  if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
6725
8048
  router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
6726
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");
6727
8052
  let externalUser;
6728
8053
  try {
6729
8054
  externalUser = await provider.verify(payload);
6730
8055
  } catch (err) {
6731
- const msg = err instanceof Error ? err.message : String(err);
6732
- 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");
6733
8058
  }
6734
8059
  if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
6735
8060
  let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
6736
8061
  if (!user) {
6737
8062
  user = await authRepo.getUserByEmail(externalUser.email);
6738
8063
  if (user) {
6739
- if (!externalUser.emailVerified) throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
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
+ }
6740
8072
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6741
8073
  await authRepo.updateUser(user.id, {
6742
8074
  displayName: user.displayName || externalUser.displayName || void 0,
6743
8075
  photoUrl: user.photoUrl || externalUser.photoUrl || void 0
6744
8076
  });
6745
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
+ }
6746
8085
  user = await authRepo.createUser({
6747
8086
  email: normalizeEmail(externalUser.email),
6748
8087
  displayName: externalUser.displayName || void 0,
6749
- photoUrl: externalUser.photoUrl || void 0
8088
+ photoUrl: externalUser.photoUrl || void 0,
8089
+ emailVerified: externalUser.emailVerified === true
6750
8090
  });
6751
8091
  await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
6752
8092
  if (ops.afterUserCreate) try {
@@ -6755,7 +8095,12 @@ function createAuthRoutes(config) {
6755
8095
  logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
6756
8096
  }
6757
8097
  const allUsers = await authRepo.listUsers();
6758
- 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"]);
6759
8104
  else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
6760
8105
  sendWelcomeEmail({
6761
8106
  email: user.email,
@@ -6829,7 +8174,7 @@ function createAuthRoutes(config) {
6829
8174
  const tokenHash = hashToken(token);
6830
8175
  const expiresAt = getPasswordResetExpiry();
6831
8176
  await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
6832
- const resetUrl = `${emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
8177
+ const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
6833
8178
  const appName = emailConfig?.appName || "Rebase";
6834
8179
  const templateFn = emailConfig?.templates?.passwordReset;
6835
8180
  const emailContent = templateFn ? templateFn(resetUrl, {
@@ -6904,8 +8249,15 @@ function createAuthRoutes(config) {
6904
8249
  /**
6905
8250
  * POST /auth/send-verification
6906
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.
6907
8259
  */
6908
- router.post("/send-verification", requireAuth, async (c) => {
8260
+ router.post("/send-verification", strictAuthLimiter, requireAuth, verificationEmailLimiter, async (c) => {
6909
8261
  const userCtx = c.get("user");
6910
8262
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
6911
8263
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
@@ -6914,7 +8266,7 @@ function createAuthRoutes(config) {
6914
8266
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
6915
8267
  const token = generateSecureToken();
6916
8268
  await authRepo.setVerificationToken(user.id, hashToken(token));
6917
- const verifyUrl = `${emailConfig?.verifyEmailUrl || ""}/verify-email?token=${token}`;
8269
+ const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
6918
8270
  const appName = emailConfig?.appName || "Rebase";
6919
8271
  const templateFn = emailConfig?.templates?.emailVerification;
6920
8272
  const emailContent = templateFn ? templateFn(verifyUrl, {
@@ -6996,22 +8348,24 @@ function createAuthRoutes(config) {
6996
8348
  });
6997
8349
  return null;
6998
8350
  });
8351
+ const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
6999
8352
  let customClaims;
7000
8353
  if (ops.customizeAccessToken && user) {
7001
8354
  const defaultClaims = {
7002
8355
  uid: storedToken.uid,
7003
8356
  roles: roleIds,
7004
- aal: "aal1"
8357
+ aal: sessionAal
7005
8358
  };
7006
8359
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
7007
8360
  }
7008
- const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
8361
+ const newAccessToken = generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims);
7009
8362
  const newRefreshToken = generateRefreshToken();
7010
8363
  const userAgent = c.req.header("user-agent") || "unknown";
7011
8364
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
7012
8365
  const session = {
7013
8366
  id: storedToken.sessionId ?? storedToken.id,
7014
- startedAt: sessionStartedAt
8367
+ startedAt: sessionStartedAt,
8368
+ aal: sessionAal
7015
8369
  };
7016
8370
  if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
7017
8371
  else await authRepo.deleteRefreshToken(tokenHash);
@@ -7043,7 +8397,15 @@ function createAuthRoutes(config) {
7043
8397
  createSessionAndTokens,
7044
8398
  applyTransformHook
7045
8399
  });
7046
- mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
8400
+ mountMfaRoutes({
8401
+ router,
8402
+ config,
8403
+ ops,
8404
+ parseBody,
8405
+ buildAuthResponse,
8406
+ createSessionAndTokens,
8407
+ applyTransformHook
8408
+ });
7047
8409
  if (config.enableMagicLink) mountMagicLinkRoutes({
7048
8410
  router,
7049
8411
  config,
@@ -7076,7 +8438,11 @@ function createResetPasswordRoute(config) {
7076
8438
  const { emailService, emailConfig, collectionAuthConfig } = config;
7077
8439
  const ops = resolveAuthHooks(config.authHooks);
7078
8440
  router.onError(errorHandler);
7079
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8441
+ router.use("/*", createRequireAuth({
8442
+ serviceKey: config.serviceKey,
8443
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8444
+ revocationRepo: authRepo
8445
+ }));
7080
8446
  router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
7081
8447
  const uid = c.req.param("uid");
7082
8448
  const existing = await authRepo.getUserById(uid);
@@ -7179,7 +8545,11 @@ function createAdminRolesRoute(config) {
7179
8545
  const router = new Hono();
7180
8546
  const authRepo = config.authRepo;
7181
8547
  router.onError(errorHandler);
7182
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8548
+ router.use("/*", createRequireAuth({
8549
+ serviceKey: config.serviceKey,
8550
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8551
+ revocationRepo: authRepo
8552
+ }));
7183
8553
  router.get("/roles", requireAdmin, async (c) => {
7184
8554
  const roles = await authRepo.listRoles();
7185
8555
  return c.json({ roles });
@@ -7219,7 +8589,11 @@ function createAdminUsersRoute(config) {
7219
8589
  };
7220
8590
  }
7221
8591
  router.onError(errorHandler);
7222
- router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
8592
+ router.use("/*", createRequireAuth({
8593
+ serviceKey: config.serviceKey,
8594
+ resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
8595
+ revocationRepo: authRepo
8596
+ }));
7223
8597
  router.post("/bootstrap", async (c) => {
7224
8598
  const user = c.get("user");
7225
8599
  if (!user || typeof user !== "object") throw ApiError.unauthorized("Not authenticated");
@@ -7233,11 +8607,19 @@ function createAdminUsersRoute(config) {
7233
8607
  break;
7234
8608
  }
7235
8609
  if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
7236
- const uid = "uid" in user ? user.uid : "uid" in user ? user.uid : void 0;
8610
+ const uid = "uid" in user ? user.uid : void 0;
7237
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
+ }
7238
8619
  if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
7239
- if (users.length > 0) {
7240
- 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) => {
7241
8623
  const at = new Date(a.createdAt).getTime();
7242
8624
  const bt = new Date(b.createdAt).getTime();
7243
8625
  if (at !== bt) return at < bt ? a : b;
@@ -7287,7 +8669,7 @@ function createAdminUsersRoute(config) {
7287
8669
  const search = c.req.query("search");
7288
8670
  const orderBy = c.req.query("orderBy");
7289
8671
  const orderDir = c.req.query("orderDir");
7290
- const limit = resolveClientListLimit(limitParam, { defaultLimit: 25 });
8672
+ const limit = resolveListLimitParam(limitParam, { defaultLimit: 25 });
7291
8673
  const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
7292
8674
  const result = await authRepo.listUsersPaginated({
7293
8675
  limit,
@@ -7326,13 +8708,15 @@ function createAdminUsersRoute(config) {
7326
8708
  resolvedHooks: ops,
7327
8709
  collectionAuthConfig
7328
8710
  });
7329
- const user = await authRepo.createUser({
8711
+ let createData = {
7330
8712
  email: prepResult.values.email,
7331
8713
  passwordHash: prepResult.values.passwordHash,
7332
8714
  displayName: prepResult.values.displayName,
7333
8715
  photoUrl: prepResult.values.photoUrl,
7334
8716
  metadata: prepResult.values.metadata
7335
- });
8717
+ };
8718
+ if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
8719
+ const user = await authRepo.createUser(createData);
7336
8720
  if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
7337
8721
  const finalizeResult = await finalizeAdminUserCreation({
7338
8722
  id: user.id,
@@ -7390,7 +8774,11 @@ function createAdminUsersRoute(config) {
7390
8774
  limit: 1
7391
8775
  })).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
7392
8776
  }
8777
+ if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
7393
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
+ });
7394
8782
  return c.json({ success: true });
7395
8783
  });
7396
8784
  return router;
@@ -7405,7 +8793,7 @@ function createAdminUsersRoute(config) {
7405
8793
  * when the user passes a plain `RebaseAuthConfig` object.
7406
8794
  */
7407
8795
  function createBuiltinAuthAdapter(config) {
7408
- 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;
7409
8797
  const resolvedOps = resolveAuthHooks(authHooks);
7410
8798
  return {
7411
8799
  id: "rebase-builtin",
@@ -7422,6 +8810,13 @@ function createBuiltinAuthAdapter(config) {
7422
8810
  };
7423
8811
  const payload = verifyAccessToken(token);
7424
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
+ }
7425
8820
  let roles = payload.roles || [];
7426
8821
  try {
7427
8822
  roles = await authRepository.getUserRoleIds(payload.uid);
@@ -7431,7 +8826,7 @@ function createBuiltinAuthAdapter(config) {
7431
8826
  error: err
7432
8827
  });
7433
8828
  }
7434
- const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
8829
+ const isAdmin = hasAdministrativeRole(roles);
7435
8830
  return {
7436
8831
  uid: payload.uid,
7437
8832
  email: payload.email ?? "",
@@ -7460,7 +8855,7 @@ function createBuiltinAuthAdapter(config) {
7460
8855
  error: err
7461
8856
  });
7462
8857
  }
7463
- const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
8858
+ const isAdmin = hasAdministrativeRole(roles);
7464
8859
  return {
7465
8860
  uid: payload.uid,
7466
8861
  email: payload.email ?? "",
@@ -7478,9 +8873,11 @@ function createBuiltinAuthAdapter(config) {
7478
8873
  emailConfig,
7479
8874
  allowRegistration,
7480
8875
  disableSelfRegistration,
8876
+ allowAnonymous,
7481
8877
  allowUserLookup,
7482
8878
  defaultRole,
7483
8879
  oauthProviders,
8880
+ allowedRedirectUris,
7484
8881
  authHooks,
7485
8882
  enableMagicLink,
7486
8883
  cookieAuth
@@ -7552,6 +8949,10 @@ function createBuiltinAuthAdapter(config) {
7552
8949
  allowRegistration,
7553
8950
  needsSetup
7554
8951
  });
8952
+ const anonymousAllowed = isAnonymousAuthOpen({
8953
+ allowAnonymous,
8954
+ disableSelfRegistration
8955
+ });
7555
8956
  return {
7556
8957
  hasBuiltInAuthRoutes: true,
7557
8958
  emailPasswordLogin: true,
@@ -7563,6 +8964,7 @@ function createBuiltinAuthAdapter(config) {
7563
8964
  profileUpdate: true,
7564
8965
  emailVerification: !!emailService?.isConfigured(),
7565
8966
  magicLink: enableMagicLink && !!emailService?.isConfigured(),
8967
+ anonymousLogin: anonymousAllowed,
7566
8968
  enabledProviders,
7567
8969
  needsSetup
7568
8970
  };
@@ -7647,6 +9049,54 @@ function toAuthUserData(user) {
7647
9049
  };
7648
9050
  }
7649
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
7650
9100
  //#region src/auth/google-oauth.ts
7651
9101
  var _googleAuth;
7652
9102
  async function loadGoogleAuth() {
@@ -7679,7 +9129,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
7679
9129
  const tokenInfo = await tokenInfoRes.json();
7680
9130
  if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
7681
9131
  if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
7682
- const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === "true";
9132
+ const emailVerified = providerVerifiedEmail(tokenInfo.email_verified);
7683
9133
  let displayName = null;
7684
9134
  let photoUrl = null;
7685
9135
  try {
@@ -7734,7 +9184,8 @@ function createGoogleProvider(config) {
7734
9184
  idToken: string().min(1).optional(),
7735
9185
  accessToken: string().min(1).optional(),
7736
9186
  code: string().min(1).optional(),
7737
- redirectUri: string().min(1).optional()
9187
+ redirectUri: string().min(1).optional(),
9188
+ codeVerifier: string().min(1).optional()
7738
9189
  }).refine((data) => data.idToken || data.accessToken || data.code && data.redirectUri, { message: "One of idToken, accessToken, or code+redirectUri is required" }),
7739
9190
  verify: async (payload) => {
7740
9191
  try {
@@ -7749,7 +9200,7 @@ function createGoogleProvider(config) {
7749
9200
  email: content.email || "",
7750
9201
  displayName: content.name || null,
7751
9202
  photoUrl: content.picture || null,
7752
- emailVerified: content.email_verified === true
9203
+ emailVerified: providerVerifiedEmail(content.email_verified)
7753
9204
  };
7754
9205
  }
7755
9206
  if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
@@ -7763,7 +9214,8 @@ function createGoogleProvider(config) {
7763
9214
  client_id: clientId,
7764
9215
  client_secret: clientSecret,
7765
9216
  redirect_uri: payload.redirectUri,
7766
- grant_type: "authorization_code"
9217
+ grant_type: "authorization_code",
9218
+ ...pkceTokenParams(payload.codeVerifier)
7767
9219
  })
7768
9220
  });
7769
9221
  if (!tokenResponse.ok) {
@@ -7783,7 +9235,7 @@ function createGoogleProvider(config) {
7783
9235
  email: content.email || "",
7784
9236
  displayName: content.name || null,
7785
9237
  photoUrl: content.picture || null,
7786
- emailVerified: content.email_verified === true
9238
+ emailVerified: providerVerifiedEmail(content.email_verified)
7787
9239
  };
7788
9240
  }
7789
9241
  if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
@@ -7805,10 +9257,7 @@ function createGoogleProvider(config) {
7805
9257
  function createLinkedinProvider(config) {
7806
9258
  return {
7807
9259
  id: "linkedin",
7808
- schema: object({
7809
- code: string().min(1, "Auth code is required"),
7810
- redirectUri: string().url("Valid redirect URI is required")
7811
- }),
9260
+ schema: oauthCodeFlowSchema(),
7812
9261
  verify: async (payload) => {
7813
9262
  try {
7814
9263
  const tokenResponse = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
@@ -7819,7 +9268,8 @@ function createLinkedinProvider(config) {
7819
9268
  code: payload.code,
7820
9269
  redirect_uri: payload.redirectUri,
7821
9270
  client_id: config.clientId,
7822
- client_secret: config.clientSecret
9271
+ client_secret: config.clientSecret,
9272
+ ...pkceTokenParams(payload.codeVerifier)
7823
9273
  })
7824
9274
  });
7825
9275
  if (!tokenResponse.ok) {
@@ -7835,12 +9285,16 @@ function createLinkedinProvider(config) {
7835
9285
  return null;
7836
9286
  }
7837
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
+ }
7838
9292
  return {
7839
9293
  providerId: profileData.sub,
7840
9294
  email: profileData.email,
7841
9295
  displayName: profileData.name || null,
7842
9296
  photoUrl: profileData.picture || null,
7843
- emailVerified: profileData.email_verified === true
9297
+ emailVerified: providerVerifiedEmail(profileData.email_verified)
7844
9298
  };
7845
9299
  } catch (error) {
7846
9300
  logger.error("LinkedIn OAuth error", { error });
@@ -7861,10 +9315,7 @@ function createLinkedinProvider(config) {
7861
9315
  function createGitHubProvider(config) {
7862
9316
  return {
7863
9317
  id: "github",
7864
- schema: object({
7865
- code: string().min(1, "Auth code is required"),
7866
- redirectUri: string().url("Valid redirect URI is required")
7867
- }),
9318
+ schema: oauthCodeFlowSchema(),
7868
9319
  verify: async (payload) => {
7869
9320
  try {
7870
9321
  const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
@@ -7877,7 +9328,8 @@ function createGitHubProvider(config) {
7877
9328
  client_id: config.clientId,
7878
9329
  client_secret: config.clientSecret,
7879
9330
  code: payload.code,
7880
- redirect_uri: payload.redirectUri
9331
+ redirect_uri: payload.redirectUri,
9332
+ ...pkceTokenParams(payload.codeVerifier)
7881
9333
  })
7882
9334
  });
7883
9335
  if (!tokenResponse.ok) {
@@ -7901,19 +9353,22 @@ function createGitHubProvider(config) {
7901
9353
  }
7902
9354
  const profileData = await profileResponse.json();
7903
9355
  let email = profileData.email;
7904
- if (!email) {
7905
- const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
7906
- "Authorization": `Bearer ${accessToken}`,
7907
- "Accept": "application/vnd.github+json",
7908
- "User-Agent": "Rebase-Auth"
7909
- } });
7910
- if (emailsResponse.ok) {
7911
- const emails = await emailsResponse.json();
7912
- email = (emails.find((e) => e.primary && e.verified) || emails.find((e) => e.verified))?.email || null;
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);
7913
9368
  }
7914
9369
  }
7915
9370
  if (!email) {
7916
- logger.error("GitHub user has no verified email");
9371
+ logger.error("GitHub user has no email");
7917
9372
  return null;
7918
9373
  }
7919
9374
  return {
@@ -7921,7 +9376,7 @@ function createGitHubProvider(config) {
7921
9376
  email,
7922
9377
  displayName: profileData.name || profileData.login || null,
7923
9378
  photoUrl: profileData.avatar_url || null,
7924
- emailVerified: true
9379
+ emailVerified
7925
9380
  };
7926
9381
  } catch (error) {
7927
9382
  logger.error("GitHub OAuth error", { error });
@@ -7931,21 +9386,109 @@ function createGitHubProvider(config) {
7931
9386
  };
7932
9387
  }
7933
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
7934
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
+ ]);
7935
9460
  /**
7936
9461
  * Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
7937
9462
  *
7938
9463
  * Supports both personal Microsoft accounts and work/school (Azure AD) accounts
7939
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.
7940
9482
  */
7941
9483
  function createMicrosoftProvider(config) {
7942
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.`);
7943
9489
  return {
7944
9490
  id: "microsoft",
7945
- schema: object({
7946
- code: string().min(1, "Auth code is required"),
7947
- redirectUri: string().url("Valid redirect URI is required")
7948
- }),
9491
+ schema: oauthCodeFlowSchema(),
7949
9492
  verify: async (payload) => {
7950
9493
  try {
7951
9494
  const tokenResponse = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
@@ -7957,14 +9500,26 @@ function createMicrosoftProvider(config) {
7957
9500
  code: payload.code,
7958
9501
  redirect_uri: payload.redirectUri,
7959
9502
  grant_type: "authorization_code",
7960
- scope: "openid profile email User.Read"
9503
+ scope: "openid profile email User.Read",
9504
+ ...pkceTokenParams(payload.codeVerifier)
7961
9505
  })
7962
9506
  });
7963
9507
  if (!tokenResponse.ok) {
7964
9508
  logger.error("Failed to get Microsoft access token", { detail: await tokenResponse.text() });
7965
9509
  return null;
7966
9510
  }
7967
- 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
+ }
7968
9523
  const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { "Authorization": `Bearer ${accessToken}` } });
7969
9524
  if (!profileResponse.ok) {
7970
9525
  logger.error("Failed to get Microsoft user info", { detail: await profileResponse.text() });
@@ -7976,7 +9531,8 @@ function createMicrosoftProvider(config) {
7976
9531
  logger.error("Microsoft user has no email");
7977
9532
  return null;
7978
9533
  }
7979
- 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();
7980
9536
  return {
7981
9537
  providerId: profileData.id,
7982
9538
  email,
@@ -7993,6 +9549,8 @@ function createMicrosoftProvider(config) {
7993
9549
  }
7994
9550
  //#endregion
7995
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";
7996
9554
  /**
7997
9555
  * Creates an Apple Sign In OAuth Provider integration.
7998
9556
  *
@@ -8022,18 +9580,19 @@ function createAppleProvider(config) {
8022
9580
  }
8023
9581
  return {
8024
9582
  id: "apple",
8025
- schema: object({
8026
- code: string().min(1, "Auth code is required"),
8027
- redirectUri: string().url("Valid redirect URI is required"),
8028
- /** Apple sends user info only on first authorization; the frontend must forward it. */
8029
- user: object({
8030
- name: object({
8031
- firstName: string().optional(),
8032
- lastName: string().optional()
8033
- }).optional(),
8034
- email: string().email().optional()
8035
- }).optional()
8036
- }),
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() }),
8037
9596
  verify: async (payload) => {
8038
9597
  try {
8039
9598
  const clientSecret = await generateClientSecret();
@@ -8045,18 +9604,28 @@ function createAppleProvider(config) {
8045
9604
  client_secret: clientSecret,
8046
9605
  code: payload.code,
8047
9606
  grant_type: "authorization_code",
8048
- redirect_uri: payload.redirectUri
9607
+ redirect_uri: payload.redirectUri,
9608
+ ...pkceTokenParams(payload.codeVerifier)
8049
9609
  })
8050
9610
  });
8051
9611
  if (!tokenResponse.ok) {
8052
9612
  logger.error("Failed to get Apple access token", { detail: await tokenResponse.text() });
8053
9613
  return null;
8054
9614
  }
8055
- const [, payloadB64] = (await tokenResponse.json()).id_token.split(".");
8056
- const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
8057
- const email = decoded.email || payload.user?.email;
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;
8058
9627
  if (!email) {
8059
- logger.error("Apple user has no email");
9628
+ logger.error("Apple id_token carries no email claim");
8060
9629
  return null;
8061
9630
  }
8062
9631
  let displayName = null;
@@ -8069,7 +9638,7 @@ function createAppleProvider(config) {
8069
9638
  email,
8070
9639
  displayName,
8071
9640
  photoUrl: null,
8072
- emailVerified: decoded.email_verified === true || decoded.email_verified === "true"
9641
+ emailVerified: providerVerifiedEmail(decoded.email_verified)
8073
9642
  };
8074
9643
  } catch (error) {
8075
9644
  logger.error("Apple OAuth error", { error });
@@ -8089,18 +9658,20 @@ function createAppleProvider(config) {
8089
9658
  function createFacebookProvider(config) {
8090
9659
  return {
8091
9660
  id: "facebook",
8092
- schema: object({
8093
- code: string().min(1, "Auth code is required"),
8094
- redirectUri: string().url("Valid redirect URI is required")
8095
- }),
9661
+ schema: oauthCodeFlowSchema(),
8096
9662
  verify: async (payload) => {
8097
9663
  try {
8098
- const tokenUrl = new URL("https://graph.facebook.com/v19.0/oauth/access_token");
8099
- tokenUrl.searchParams.set("client_id", config.clientId);
8100
- tokenUrl.searchParams.set("client_secret", config.clientSecret);
8101
- tokenUrl.searchParams.set("redirect_uri", payload.redirectUri);
8102
- tokenUrl.searchParams.set("code", payload.code);
8103
- const tokenResponse = await fetch(tokenUrl.toString());
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
+ });
8104
9675
  if (!tokenResponse.ok) {
8105
9676
  logger.error("Failed to get Facebook access token", { detail: await tokenResponse.text() });
8106
9677
  return null;
@@ -8108,8 +9679,7 @@ function createFacebookProvider(config) {
8108
9679
  const accessToken = (await tokenResponse.json()).access_token;
8109
9680
  const profileUrl = new URL("https://graph.facebook.com/v19.0/me");
8110
9681
  profileUrl.searchParams.set("fields", "id,name,email,picture.type(large)");
8111
- profileUrl.searchParams.set("access_token", accessToken);
8112
- const profileResponse = await fetch(profileUrl.toString());
9682
+ const profileResponse = await fetch(profileUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } });
8113
9683
  if (!profileResponse.ok) {
8114
9684
  logger.error("Failed to get Facebook user info", { detail: await profileResponse.text() });
8115
9685
  return null;
@@ -8124,7 +9694,7 @@ function createFacebookProvider(config) {
8124
9694
  email: profileData.email,
8125
9695
  displayName: profileData.name || null,
8126
9696
  photoUrl: profileData.picture?.data?.url || null,
8127
- emailVerified: true
9697
+ emailVerified: false
8128
9698
  };
8129
9699
  } catch (error) {
8130
9700
  logger.error("Facebook OAuth error", { error });
@@ -8147,11 +9717,7 @@ function createFacebookProvider(config) {
8147
9717
  function createTwitterProvider(config) {
8148
9718
  return {
8149
9719
  id: "twitter",
8150
- schema: object({
8151
- code: string().min(1, "Auth code is required"),
8152
- redirectUri: string().url("Valid redirect URI is required"),
8153
- codeVerifier: string().min(1, "PKCE code verifier is required")
8154
- }),
9720
+ schema: oauthCodeFlowSchema({ pkce: "required" }),
8155
9721
  verify: async (payload) => {
8156
9722
  try {
8157
9723
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8165,7 +9731,7 @@ function createTwitterProvider(config) {
8165
9731
  code: payload.code,
8166
9732
  grant_type: "authorization_code",
8167
9733
  redirect_uri: payload.redirectUri,
8168
- code_verifier: payload.codeVerifier
9734
+ ...pkceTokenParams(payload.codeVerifier)
8169
9735
  })
8170
9736
  });
8171
9737
  if (!tokenResponse.ok) {
@@ -8211,16 +9777,14 @@ function createTwitterProvider(config) {
8211
9777
  /**
8212
9778
  * Creates a Discord OAuth2 Provider integration.
8213
9779
  *
8214
- * Uses the authorization code flow. Requires the "identify" and "email"
8215
- * 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.
8216
9783
  */
8217
9784
  function createDiscordProvider(config) {
8218
9785
  return {
8219
9786
  id: "discord",
8220
- schema: object({
8221
- code: string().min(1, "Auth code is required"),
8222
- redirectUri: string().url("Valid redirect URI is required")
8223
- }),
9787
+ schema: oauthCodeFlowSchema(),
8224
9788
  verify: async (payload) => {
8225
9789
  try {
8226
9790
  const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
@@ -8231,7 +9795,8 @@ function createDiscordProvider(config) {
8231
9795
  client_secret: config.clientSecret,
8232
9796
  grant_type: "authorization_code",
8233
9797
  code: payload.code,
8234
- redirect_uri: payload.redirectUri
9798
+ redirect_uri: payload.redirectUri,
9799
+ ...pkceTokenParams(payload.codeVerifier)
8235
9800
  })
8236
9801
  });
8237
9802
  if (!tokenResponse.ok) {
@@ -8259,7 +9824,7 @@ function createDiscordProvider(config) {
8259
9824
  email: profileData.email,
8260
9825
  displayName: profileData.global_name || profileData.username || null,
8261
9826
  photoUrl,
8262
- emailVerified: profileData.verified === true
9827
+ emailVerified: providerVerifiedEmail(profileData.verified)
8263
9828
  };
8264
9829
  } catch (error) {
8265
9830
  logger.error("Discord OAuth error", { error });
@@ -8273,15 +9838,16 @@ function createDiscordProvider(config) {
8273
9838
  /**
8274
9839
  * Creates a GitLab OAuth Provider integration.
8275
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.
8276
9845
  */
8277
9846
  function createGitLabProvider(config) {
8278
9847
  const gitlabUrl = (config.baseUrl || "https://gitlab.com").replace(/\/$/, "");
8279
9848
  return {
8280
9849
  id: "gitlab",
8281
- schema: object({
8282
- code: string().min(1, "Auth code is required"),
8283
- redirectUri: string().url("Valid redirect URI is required")
8284
- }),
9850
+ schema: oauthCodeFlowSchema(),
8285
9851
  verify: async (payload) => {
8286
9852
  try {
8287
9853
  const tokenResponse = await fetch(`${gitlabUrl}/oauth/token`, {
@@ -8292,7 +9858,8 @@ function createGitLabProvider(config) {
8292
9858
  client_secret: config.clientSecret,
8293
9859
  code: payload.code,
8294
9860
  grant_type: "authorization_code",
8295
- redirect_uri: payload.redirectUri
9861
+ redirect_uri: payload.redirectUri,
9862
+ ...pkceTokenParams(payload.codeVerifier)
8296
9863
  })
8297
9864
  });
8298
9865
  if (!tokenResponse.ok) {
@@ -8315,7 +9882,7 @@ function createGitLabProvider(config) {
8315
9882
  email: p.email,
8316
9883
  displayName: p.name || p.username || null,
8317
9884
  photoUrl: p.avatar_url || null,
8318
- emailVerified: true
9885
+ emailVerified: Boolean(p.confirmed_at)
8319
9886
  };
8320
9887
  } catch (error) {
8321
9888
  logger.error("GitLab OAuth error", { error });
@@ -8332,10 +9899,7 @@ function createGitLabProvider(config) {
8332
9899
  function createBitbucketProvider(config) {
8333
9900
  return {
8334
9901
  id: "bitbucket",
8335
- schema: object({
8336
- code: string().min(1, "Auth code is required"),
8337
- redirectUri: string().url("Valid redirect URI is required")
8338
- }),
9902
+ schema: oauthCodeFlowSchema(),
8339
9903
  verify: async (payload) => {
8340
9904
  try {
8341
9905
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8348,7 +9912,8 @@ function createBitbucketProvider(config) {
8348
9912
  body: new URLSearchParams({
8349
9913
  grant_type: "authorization_code",
8350
9914
  code: payload.code,
8351
- redirect_uri: payload.redirectUri
9915
+ redirect_uri: payload.redirectUri,
9916
+ ...pkceTokenParams(payload.codeVerifier)
8352
9917
  })
8353
9918
  });
8354
9919
  if (!tokenResponse.ok) {
@@ -8363,21 +9928,21 @@ function createBitbucketProvider(config) {
8363
9928
  }
8364
9929
  const p = await profileResponse.json();
8365
9930
  const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", { headers: { "Authorization": `Bearer ${accessToken}` } });
8366
- let email = null;
9931
+ let selected;
8367
9932
  if (emailsResponse.ok) {
8368
9933
  const emailData = await emailsResponse.json();
8369
- email = (emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed))?.email || null;
9934
+ selected = emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed);
8370
9935
  }
8371
- if (!email) {
9936
+ if (!selected?.email) {
8372
9937
  logger.error("Bitbucket user has no verified email");
8373
9938
  return null;
8374
9939
  }
8375
9940
  return {
8376
9941
  providerId: p.uuid,
8377
- email,
9942
+ email: selected.email,
8378
9943
  displayName: p.display_name || p.username || null,
8379
9944
  photoUrl: p.links?.avatar?.href || null,
8380
- emailVerified: true
9945
+ emailVerified: providerVerifiedEmail(selected.is_confirmed)
8381
9946
  };
8382
9947
  } catch (error) {
8383
9948
  logger.error("Bitbucket OAuth error", { error });
@@ -8395,10 +9960,7 @@ function createBitbucketProvider(config) {
8395
9960
  function createSlackProvider(config) {
8396
9961
  return {
8397
9962
  id: "slack",
8398
- schema: object({
8399
- code: string().min(1, "Auth code is required"),
8400
- redirectUri: string().url("Valid redirect URI is required")
8401
- }),
9963
+ schema: oauthCodeFlowSchema(),
8402
9964
  verify: async (payload) => {
8403
9965
  try {
8404
9966
  const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
@@ -8409,7 +9971,8 @@ function createSlackProvider(config) {
8409
9971
  client_secret: config.clientSecret,
8410
9972
  code: payload.code,
8411
9973
  redirect_uri: payload.redirectUri,
8412
- grant_type: "authorization_code"
9974
+ grant_type: "authorization_code",
9975
+ ...pkceTokenParams(payload.codeVerifier)
8413
9976
  })
8414
9977
  });
8415
9978
  if (!tokenResponse.ok) {
@@ -8436,7 +9999,7 @@ function createSlackProvider(config) {
8436
9999
  email: p.email,
8437
10000
  displayName: p.name || null,
8438
10001
  photoUrl: p.picture || null,
8439
- emailVerified: p.email_verified === true
10002
+ emailVerified: providerVerifiedEmail(p.email_verified)
8440
10003
  };
8441
10004
  } catch (error) {
8442
10005
  logger.error("Slack OAuth error", { error });
@@ -8454,10 +10017,7 @@ function createSlackProvider(config) {
8454
10017
  function createSpotifyProvider(config) {
8455
10018
  return {
8456
10019
  id: "spotify",
8457
- schema: object({
8458
- code: string().min(1, "Auth code is required"),
8459
- redirectUri: string().url("Valid redirect URI is required")
8460
- }),
10020
+ schema: oauthCodeFlowSchema(),
8461
10021
  verify: async (payload) => {
8462
10022
  try {
8463
10023
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
@@ -8470,7 +10030,8 @@ function createSpotifyProvider(config) {
8470
10030
  body: new URLSearchParams({
8471
10031
  grant_type: "authorization_code",
8472
10032
  code: payload.code,
8473
- redirect_uri: payload.redirectUri
10033
+ redirect_uri: payload.redirectUri,
10034
+ ...pkceTokenParams(payload.codeVerifier)
8474
10035
  })
8475
10036
  });
8476
10037
  if (!tokenResponse.ok) {
@@ -8493,7 +10054,7 @@ function createSpotifyProvider(config) {
8493
10054
  email: p.email,
8494
10055
  displayName: p.display_name || null,
8495
10056
  photoUrl: p.images?.[0]?.url || null,
8496
- emailVerified: true
10057
+ emailVerified: false
8497
10058
  };
8498
10059
  } catch (error) {
8499
10060
  logger.error("Spotify OAuth error", { error });
@@ -8591,44 +10152,45 @@ function createApiKeyStore(driver) {
8591
10152
  return;
8592
10153
  }
8593
10154
  const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
10155
+ const ddl = createDdlBootstrapper(exec, "api-key-store");
8594
10156
  return {
8595
10157
  async ensureTable() {
8596
- try {
8597
- await exec("CREATE SCHEMA IF NOT EXISTS rebase");
8598
- await exec(`
8599
- CREATE TABLE IF NOT EXISTS ${TABLE} (
8600
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
8601
- name TEXT NOT NULL,
8602
- key_prefix TEXT NOT NULL,
8603
- key_hash TEXT NOT NULL UNIQUE,
8604
- permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
8605
- admin BOOLEAN NOT NULL DEFAULT FALSE,
8606
- rate_limit INTEGER,
8607
- created_by TEXT NOT NULL,
8608
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8609
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8610
- last_used_at TIMESTAMPTZ,
8611
- expires_at TIMESTAMPTZ,
8612
- revoked_at TIMESTAMPTZ
8613
- )
8614
- `);
8615
- await exec(`
8616
- CREATE INDEX IF NOT EXISTS idx_api_keys_hash
8617
- ON ${TABLE}(key_hash)
8618
- `);
8619
- await exec(`
8620
- CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
8621
- ON ${TABLE}(key_prefix)
8622
- `);
8623
- await exec(`
8624
- ALTER TABLE ${TABLE}
8625
- ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
8626
- `);
8627
- logger.info("✅ API keys table ready");
8628
- } catch (err) {
8629
- logger.error("❌ Failed to create API keys table", { error: err });
8630
- logger.warn("⚠️ Continuing without API keys support.");
10158
+ await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
10159
+ await ddl.ensureObject(`Creating ${TABLE}`, `
10160
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
10161
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
10162
+ name TEXT NOT NULL,
10163
+ key_prefix TEXT NOT NULL,
10164
+ key_hash TEXT NOT NULL UNIQUE,
10165
+ permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
10166
+ admin BOOLEAN NOT NULL DEFAULT FALSE,
10167
+ rate_limit INTEGER,
10168
+ created_by TEXT NOT NULL,
10169
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10170
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10171
+ last_used_at TIMESTAMPTZ,
10172
+ expires_at TIMESTAMPTZ,
10173
+ revoked_at TIMESTAMPTZ
10174
+ )
10175
+ `);
10176
+ await ddl.ensureObject("Creating idx_api_keys_hash", `
10177
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash
10178
+ ON ${TABLE}(key_hash)
10179
+ `);
10180
+ await ddl.ensureObject("Creating idx_api_keys_prefix", `
10181
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
10182
+ ON ${TABLE}(key_prefix)
10183
+ `);
10184
+ await ddl.ensureObject(`Adding ${TABLE}.admin`, `
10185
+ ALTER TABLE ${TABLE}
10186
+ ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
10187
+ `);
10188
+ if (!await ddl.isReadable(TABLE)) {
10189
+ logger.error(`❌ [api-key-store] ${TABLE} is unavailable — every API-key authenticated request will be rejected on this instance, and the table could not be taken back off the end-user role.`);
10190
+ return;
8631
10191
  }
10192
+ await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
10193
+ logger.debug("✅ API keys table ready");
8632
10194
  },
8633
10195
  async createApiKey(request, createdBy) {
8634
10196
  const plaintext = generateApiKey();
@@ -8923,6 +10485,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8923
10485
  createSlackProvider: () => createSlackProvider,
8924
10486
  createSpotifyProvider: () => createSpotifyProvider,
8925
10487
  createTwitterProvider: () => createTwitterProvider,
10488
+ decideOAuthAutoLink: () => decideOAuthAutoLink,
8926
10489
  defaultAuthLimiter: () => defaultAuthLimiter,
8927
10490
  extractUserFromToken: () => extractUserFromToken,
8928
10491
  fileTokenAuth: () => fileTokenAuth,
@@ -8939,23 +10502,30 @@ var auth_exports = /* @__PURE__ */ __exportAll({
8939
10502
  hashToken: () => hashToken,
8940
10503
  httpMethodToOperation: () => httpMethodToOperation,
8941
10504
  isApiKeyToken: () => isApiKeyToken,
10505
+ isJwtConfigured: () => isJwtConfigured,
8942
10506
  isOperationAllowed: () => isOperationAllowed,
10507
+ isRedirectUriAllowed: () => isRedirectUriAllowed,
8943
10508
  mountMagicLinkRoutes: () => mountMagicLinkRoutes,
10509
+ oauthCodeFlowSchema: () => oauthCodeFlowSchema,
8944
10510
  optionalAuth: () => optionalAuth,
10511
+ pkceTokenParams: () => pkceTokenParams,
8945
10512
  prepareAdminUserValues: () => prepareAdminUserValues,
10513
+ providerVerifiedEmail: () => providerVerifiedEmail,
8946
10514
  queryTokenAuth: () => queryTokenAuth,
8947
10515
  requireAdmin: () => requireAdmin,
8948
10516
  requireAuth: () => requireAuth,
8949
10517
  resolveAuthHooks: () => resolveAuthHooks,
8950
10518
  safeCompare: () => safeCompare,
8951
10519
  strictAuthLimiter: () => strictAuthLimiter,
10520
+ tryVerifyOidcIdToken: () => tryVerifyOidcIdToken,
8952
10521
  validateApiKey: () => validateApiKey,
8953
10522
  validatePasswordStrength: () => validatePasswordStrength,
8954
10523
  verifyAccessToken: () => verifyAccessToken,
8955
10524
  verifyDownloadToken: () => verifyDownloadToken,
10525
+ verifyOidcIdToken: () => verifyOidcIdToken,
8956
10526
  verifyPassword: () => verifyPassword
8957
10527
  });
8958
10528
  //#endregion
8959
- export { httpMethodToOperation as $, getEmailVerificationTemplate as A, optionalAuth as B, createDataRateLimiter as C, validatePasswordStrength as D, hashPassword as E, createAdapterAuthMiddleware as F, createApiKeyPreAuth as G, queryTokenAuth as H, createAuthMiddleware as I, isApiKeyToken as J, createFunctionApiKeyGuard as K, createRequireAuth as L, getPasswordResetTemplate as M, getUserInvitationTemplate as N, verifyPassword as O, getWelcomeEmailTemplate as P, scopeDataDriver as Q, extractUserFromToken as R, _coercedNumber as S, resolveAuthHooks as T, requireAdmin as U, publicObjectAuth as V, requireAuth as W, extractBearerToken as X, validateApiKey as Y, safeCompare as Z, createBuiltinAuthAdapter as _, createSpotifyProvider as a, object as b, createGitLabProvider as c, createFacebookProvider as d, isOperationAllowed as et, createAppleProvider as f, createGoogleProvider as g, createLinkedinProvider as h, createApiKeyStore as i, resolveClientListLimit as it, getMagicLinkTemplate as j, generateSecurePassword as k, createDiscordProvider as l, createGitHubProvider as m, createCustomAuthAdapter as n, isPublicStoragePath as nt, createSlackProvider as o, createMicrosoftProvider as p, createStorageApiKeyGuard as q, createApiKeyRoutes as r, MAX_LIST_LIMIT as rt, createBitbucketProvider as s, auth_exports as t, PUBLIC_STORAGE_PREFIX as tt, createTwitterProvider as u, ZodNumber as v, MemoryRateLimitStore as w, string as x, _enum as y, fileTokenAuth as z };
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 };
8960
10530
 
8961
- //# sourceMappingURL=auth-CuC9M2x6.js.map
10531
+ //# sourceMappingURL=auth-CYoPVf-E.js.map