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