@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.
- package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
- package/dist/admin_block-H86dCPsj.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +74 -6
- package/dist/api/errors.d.ts +12 -6
- package/dist/api/openapi-generator.d.ts +14 -0
- package/dist/api/rest/api-generator.d.ts +26 -1
- package/dist/api/rest/idempotency.d.ts +62 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +52 -9
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/admin-roles.d.ts +24 -0
- package/dist/auth/apple-oauth.d.ts +16 -11
- package/dist/auth/bitbucket-oauth.d.ts +2 -4
- package/dist/auth/builtin-auth-adapter.d.ts +11 -0
- package/dist/auth/discord-oauth.d.ts +5 -6
- package/dist/auth/facebook-oauth.d.ts +2 -4
- package/dist/auth/github-oauth.d.ts +2 -4
- package/dist/auth/gitlab-oauth.d.ts +6 -4
- package/dist/auth/google-oauth.d.ts +1 -0
- package/dist/auth/index.d.ts +8 -1
- package/dist/auth/interfaces.d.ts +81 -1
- package/dist/auth/jwt.d.ts +52 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- package/dist/auth/mfa-crypto.d.ts +54 -3
- package/dist/auth/mfa-gate.d.ts +42 -0
- package/dist/auth/mfa-routes.d.ts +26 -1
- package/dist/auth/mfa.d.ts +16 -0
- package/dist/auth/microsoft-oauth.d.ts +19 -4
- package/dist/auth/middleware.d.ts +26 -0
- package/dist/auth/oauth-code-flow.d.ts +66 -0
- package/dist/auth/oauth-signin-policy.d.ts +61 -0
- package/dist/auth/oidc-id-token.d.ts +61 -0
- package/dist/auth/rate-limiter.d.ts +62 -5
- package/dist/auth/registration-policy.d.ts +31 -0
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +15 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/token-revocation.d.ts +38 -0
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-CuC9M2x6.js → auth-CYoPVf-E.js} +1996 -426
- package/dist/auth-CYoPVf-E.js.map +1 -0
- package/dist/{backup-CVggVhR2.js → backup-C6ljYVTp.js} +2 -2
- package/dist/{backup-CVggVhR2.js.map → backup-C6ljYVTp.js.map} +1 -1
- package/dist/boot/boot.d.ts +19 -0
- package/dist/boot/ddl-bootstrap.d.ts +70 -0
- package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
- package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
- package/dist/cron/cron-loader.d.ts +26 -1
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +13 -3
- package/dist/cron/define-cron.d.ts +17 -3
- package/dist/cron/index.d.ts +2 -2
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
- package/dist/cron-loader-YhhQeVBM.js.map +1 -0
- package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-maM_RlUu.js} +10 -4
- package/dist/cron-routes-maM_RlUu.js.map +1 -0
- package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-DIpYBmZP.js} +40 -9
- package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
- package/dist/{cron-store-BywZsyfZ.js → cron-store-Dvr4Y1sZ.js} +66 -47
- package/dist/cron-store-Dvr4Y1sZ.js.map +1 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
- package/dist/email/html.d.ts +54 -0
- package/dist/email/index.d.ts +3 -0
- package/dist/email/link-base.d.ts +39 -0
- package/dist/email/smtp-email-service.d.ts +7 -1
- package/dist/email/templates.d.ts +9 -1
- package/dist/email/types.d.ts +11 -1
- package/dist/env.d.ts +5 -0
- package/dist/{errors-CgkCzoj7.js → errors-EBYiaJ2E.js} +15 -11
- package/dist/errors-EBYiaJ2E.js.map +1 -0
- package/dist/function-loader-DDS1v7YX.js +139 -0
- package/dist/function-loader-DDS1v7YX.js.map +1 -0
- package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
- package/dist/function-routes-Btcez1T-.js.map +1 -0
- package/dist/functions/define-function.d.ts +25 -6
- package/dist/functions/function-loader.d.ts +23 -0
- package/dist/functions/function-routes.d.ts +7 -1
- package/dist/functions/request-timeout.d.ts +34 -0
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.es.js +2147 -845
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +10 -1
- package/dist/init/process-safety.d.ts +25 -0
- package/dist/init.d.ts +38 -0
- package/dist/{jwt-DD6EtpGj.js → jwt-_IFqfTOg.js} +72 -9
- package/dist/jwt-_IFqfTOg.js.map +1 -0
- package/dist/logger-DfvF_8r-.js +190 -0
- package/dist/logger-DfvF_8r-.js.map +1 -0
- package/dist/openapi-generator-DPKtUC9X.js +865 -0
- package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
- package/dist/request-timeout-RivJsME0.js +65 -0
- package/dist/request-timeout-RivJsME0.js.map +1 -0
- package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
- package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
- package/dist/serve-spa.d.ts +5 -4
- package/dist/services/outbound-url-guard.d.ts +53 -0
- package/dist/services/routed-realtime-service.d.ts +10 -2
- package/dist/services/webhook-service.d.ts +76 -1
- package/dist/singleton.d.ts +25 -8
- package/dist/{src-Cum9kox5.js → src-C7rkDGxA.js} +678 -89
- package/dist/src-C7rkDGxA.js.map +1 -0
- package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
- package/dist/src-Cz9nMgUR.js.map +1 -0
- package/dist/storage/LocalStorageController.d.ts +12 -1
- package/dist/storage/image-transform.d.ts +57 -0
- package/dist/storage/keys.d.ts +98 -0
- package/dist/storage/routes.d.ts +11 -0
- package/dist/storage/tus-handler.d.ts +22 -2
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/logging.d.ts +0 -4
- package/dist/utils/sql.d.ts +11 -6
- package/package.json +7 -6
- package/dist/admin_block-CcSGdH7M.js.map +0 -1
- package/dist/auth-CuC9M2x6.js.map +0 -1
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/cron-loader-B1S2MCSl.js.map +0 -1
- package/dist/cron-routes-CrQ0tK-_.js.map +0 -1
- package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
- package/dist/cron-store-BywZsyfZ.js.map +0 -1
- package/dist/errors-CgkCzoj7.js.map +0 -1
- package/dist/function-loader-B_1fYfUY.js +0 -86
- package/dist/function-loader-B_1fYfUY.js.map +0 -1
- package/dist/function-routes-C0cLIy3N.js.map +0 -1
- package/dist/jwt-DD6EtpGj.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-BEwyiaAr.js +0 -597
- package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
- package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
- package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
- package/dist/src-Cum9kox5.js.map +0 -1
- 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 {
|
|
6
|
-
import "./src-
|
|
7
|
-
import {
|
|
8
|
-
import { t as logger } from "./logger-
|
|
9
|
-
import { n as errorHandler, t as ApiError } from "./errors-
|
|
10
|
-
import { a as
|
|
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
|
-
|
|
16
|
-
|
|
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 || [] : []).
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
894
|
-
c.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
})
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
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?.
|
|
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
|
-
}) :
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1699
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6727
|
+
* @returns The matched time step, or `null` when no step in the window matches
|
|
5792
6728
|
*/
|
|
5793
|
-
function
|
|
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++)
|
|
5796
|
-
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
6827
|
+
* Every key this process can decrypt with, most-preferred first.
|
|
5848
6828
|
*
|
|
5849
|
-
*
|
|
5850
|
-
*
|
|
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
|
|
5853
|
-
const
|
|
5854
|
-
|
|
5855
|
-
const
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
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
|
-
|
|
6849
|
+
return candidates;
|
|
5861
6850
|
}
|
|
5862
|
-
/**
|
|
5863
|
-
|
|
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 =
|
|
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
|
|
6875
|
+
* Decrypt a stored TOTP secret, reporting whether it wants re-wrapping.
|
|
5884
6876
|
*
|
|
5885
|
-
*
|
|
5886
|
-
*
|
|
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
|
|
6880
|
+
function openTotpSecret(ciphertext) {
|
|
5889
6881
|
const parts = ciphertext.split(":");
|
|
5890
|
-
|
|
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
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
*
|
|
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",
|
|
5963
|
-
const
|
|
5964
|
-
if (!
|
|
5965
|
-
const
|
|
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 !==
|
|
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
|
-
*
|
|
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",
|
|
5982
|
-
const
|
|
5983
|
-
if (!
|
|
5984
|
-
const
|
|
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
|
-
})
|
|
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 !==
|
|
5992
|
-
|
|
5993
|
-
|
|
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(
|
|
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 =
|
|
6000
|
-
|
|
6001
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
|
|
6084
|
-
|
|
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
|
-
*
|
|
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
|
|
6094
|
-
if (
|
|
6095
|
-
|
|
6096
|
-
|
|
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
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7942
|
+
aal
|
|
6621
7943
|
};
|
|
6622
7944
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
6623
7945
|
}
|
|
6624
7946
|
}
|
|
6625
|
-
const accessToken = generateAccessToken(uid, roleIds,
|
|
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
|
-
|
|
6732
|
-
throw ApiError.unauthorized(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
8357
|
+
aal: sessionAal
|
|
7005
8358
|
};
|
|
7006
8359
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
7007
8360
|
}
|
|
7008
|
-
const newAccessToken = generateAccessToken(storedToken.uid, roleIds,
|
|
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(
|
|
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({
|
|
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({
|
|
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({
|
|
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 :
|
|
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
|
-
|
|
7240
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
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
|
|
8056
|
-
const decoded =
|
|
8057
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
8215
|
-
*
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
9931
|
+
let selected;
|
|
8367
9932
|
if (emailsResponse.ok) {
|
|
8368
9933
|
const emailData = await emailsResponse.json();
|
|
8369
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
logger.
|
|
8628
|
-
|
|
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 {
|
|
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-
|
|
10531
|
+
//# sourceMappingURL=auth-CYoPVf-E.js.map
|