@rebasepro/server 0.13.0 → 0.13.1-canary.g06dbe5b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
- package/dist/admin_block-H86dCPsj.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +74 -6
- package/dist/api/errors.d.ts +0 -6
- package/dist/api/openapi-generator.d.ts +14 -0
- package/dist/api/rest/api-generator.d.ts +1 -1
- package/dist/api/rest/idempotency.d.ts +62 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +52 -9
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/apple-oauth.d.ts +16 -11
- package/dist/auth/bitbucket-oauth.d.ts +2 -4
- package/dist/auth/builtin-auth-adapter.d.ts +2 -0
- package/dist/auth/discord-oauth.d.ts +5 -6
- package/dist/auth/facebook-oauth.d.ts +2 -4
- package/dist/auth/github-oauth.d.ts +2 -4
- package/dist/auth/gitlab-oauth.d.ts +6 -4
- package/dist/auth/google-oauth.d.ts +1 -0
- package/dist/auth/index.d.ts +8 -1
- package/dist/auth/interfaces.d.ts +68 -1
- package/dist/auth/jwt.d.ts +40 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- 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/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 +37 -2
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +8 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-CuC9M2x6.js → auth-DkAHqvf5.js} +1604 -391
- package/dist/auth-DkAHqvf5.js.map +1 -0
- package/dist/{backup-CVggVhR2.js → backup-DLluVyuA.js} +2 -2
- package/dist/{backup-CVggVhR2.js.map → backup-DLluVyuA.js.map} +1 -1
- package/dist/boot/boot.d.ts +19 -0
- package/dist/boot/ddl-bootstrap.d.ts +70 -0
- package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-BB1U05sS.js} +3 -3
- package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-BB1U05sS.js.map} +1 -1
- package/dist/cron/cron-scheduler.d.ts +8 -3
- package/dist/cron/define-cron.d.ts +17 -3
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-3U5aGILy.js} +2 -2
- package/dist/{cron-loader-B1S2MCSl.js.map → cron-loader-3U5aGILy.js.map} +1 -1
- package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-rZgwlOz4.js} +2 -2
- package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-rZgwlOz4.js.map} +1 -1
- package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-FJAaCAXm.js} +30 -5
- package/dist/cron-scheduler-FJAaCAXm.js.map +1 -0
- package/dist/{cron-store-BywZsyfZ.js → cron-store-9NmUDfzL.js} +66 -47
- package/dist/cron-store-9NmUDfzL.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/{errors-CgkCzoj7.js → errors-B1WZEdsK.js} +3 -11
- package/dist/errors-B1WZEdsK.js.map +1 -0
- package/dist/function-loader-D1SwtCa5.js +139 -0
- package/dist/function-loader-D1SwtCa5.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 +2 -1
- package/dist/index.es.js +1960 -794
- 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 +27 -0
- package/dist/{jwt-DD6EtpGj.js → jwt-Cuq6MwNy.js} +70 -8
- package/dist/jwt-Cuq6MwNy.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-BQxxxDpb.js +865 -0
- package/dist/openapi-generator-BQxxxDpb.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-DZFKGXgq.js +437 -0
- package/dist/schema-editor-routes-DZFKGXgq.js.map +1 -0
- package/dist/serve-spa.d.ts +5 -4
- package/dist/services/outbound-url-guard.d.ts +53 -0
- package/dist/services/routed-realtime-service.d.ts +10 -2
- package/dist/services/webhook-service.d.ts +76 -1
- package/dist/singleton.d.ts +25 -8
- package/dist/{src-Cum9kox5.js → src-By48Ffg0.js} +322 -70
- package/dist/src-By48Ffg0.js.map +1 -0
- package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
- package/dist/src-Ca6NhxKs.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 +7 -1
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/logging.d.ts +0 -4
- package/dist/utils/sql.d.ts +11 -6
- package/package.json +7 -6
- package/dist/admin_block-CcSGdH7M.js.map +0 -1
- package/dist/auth-CuC9M2x6.js.map +0 -1
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
- package/dist/cron-store-BywZsyfZ.js.map +0 -1
- package/dist/errors-CgkCzoj7.js.map +0 -1
- package/dist/function-loader-B_1fYfUY.js +0 -86
- package/dist/function-loader-B_1fYfUY.js.map +0 -1
- package/dist/function-routes-C0cLIy3N.js.map +0 -1
- package/dist/jwt-DD6EtpGj.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-BEwyiaAr.js +0 -597
- package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
- package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
- package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
- package/dist/src-Cum9kox5.js.map +0 -1
- package/dist/src-_qQ3RNCK.js.map +0 -1
|
@@ -2,38 +2,18 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
|
|
5
|
-
import {
|
|
6
|
-
import "./src-
|
|
7
|
-
import {
|
|
8
|
-
import { t as logger } from "./logger-
|
|
9
|
-
import { n as errorHandler, t as ApiError } from "./errors-
|
|
10
|
-
import { a as
|
|
5
|
+
import { C as isAnonymousUid, S as ANONYMOUS_USER_ID, i as deserializeLogicalCondition, r as deserializeFilter, x as resolveClientListLimit, y as ListLimitError } from "./src-By48Ffg0.js";
|
|
6
|
+
import "./src-Ca6NhxKs.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-B1WZEdsK.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-Cuq6MwNy.js";
|
|
11
11
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
12
12
|
import { Hono } from "hono";
|
|
13
13
|
import { promisify } from "util";
|
|
14
|
-
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
19
|
-
*
|
|
20
|
-
* - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
|
|
21
|
-
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
22
|
-
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
23
|
-
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
24
|
-
*
|
|
25
|
-
* The return is never `undefined` — no ingress that routes its client limit
|
|
26
|
-
* through this can produce an unbounded read.
|
|
27
|
-
*/
|
|
28
|
-
function resolveClientListLimit(rawLimit, opts = {}) {
|
|
29
|
-
const maxLimit = opts.maxLimit ?? 1e3;
|
|
30
|
-
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
31
|
-
const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
|
|
32
|
-
if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
|
|
33
|
-
}
|
|
34
|
-
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
35
|
-
}
|
|
36
|
-
//#endregion
|
|
14
|
+
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
37
17
|
//#region ../types/src/controllers/storage.ts
|
|
38
18
|
/**
|
|
39
19
|
* Path prefix that marks an object as **public**. Files stored under this
|
|
@@ -94,6 +74,247 @@ function normalizeEmail(email) {
|
|
|
94
74
|
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
95
75
|
}
|
|
96
76
|
//#endregion
|
|
77
|
+
//#region src/api/rest/query-parser.ts
|
|
78
|
+
function getLastValue(val) {
|
|
79
|
+
if (Array.isArray(val)) return val[val.length - 1];
|
|
80
|
+
return val;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse an `or(...)` / `and(...)` logical group from its wire form.
|
|
84
|
+
*
|
|
85
|
+
* The wire carries the inner conditions wrapped in parens (e.g.
|
|
86
|
+
* `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
|
|
87
|
+
* delegate to the canonical filter dialect (`@rebasepro/common`). Values are
|
|
88
|
+
* preserved as strings — type coercion is the schema-aware driver's job, so
|
|
89
|
+
* this path stays byte-for-byte consistent with the SDK/admin path (which
|
|
90
|
+
* also parses via the shared dialect).
|
|
91
|
+
*/
|
|
92
|
+
function parseLogicalGroup(type, raw) {
|
|
93
|
+
let inner = String(raw).trim();
|
|
94
|
+
if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
|
|
95
|
+
inner = inner.trim();
|
|
96
|
+
if (!inner) return void 0;
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = deserializeLogicalCondition(`${type}(${inner})`);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
throw ApiError.badRequest(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
|
|
102
|
+
}
|
|
103
|
+
return "type" in parsed ? parsed : void 0;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Parse the `?where=` JSON filter object.
|
|
107
|
+
*
|
|
108
|
+
* This is the dialect the OpenAPI document publishes on every
|
|
109
|
+
* `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
|
|
110
|
+
* `[WhereFilterOp, value]` tuple. It is normalized through the same
|
|
111
|
+
* `deserializeFilter` as the `?field=op.value` params below, so a value that
|
|
112
|
+
* arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
|
|
113
|
+
* scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
|
|
114
|
+
* querystring dialect, JSON carries types — a number stays a number.
|
|
115
|
+
*
|
|
116
|
+
* A malformed value is a 400 rather than a silent drop: dropping the filter
|
|
117
|
+
* would run the read unfiltered and return everything RLS happens to allow.
|
|
118
|
+
*/
|
|
119
|
+
function parseWhereParam(raw) {
|
|
120
|
+
const str = String(raw).trim();
|
|
121
|
+
if (!str) return void 0;
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(str);
|
|
125
|
+
} catch {
|
|
126
|
+
throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
127
|
+
}
|
|
128
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
129
|
+
const filter = deserializeFilter(parsed);
|
|
130
|
+
return Object.keys(filter).length > 0 ? filter : void 0;
|
|
131
|
+
}
|
|
132
|
+
function invalidOrderBy(detail) {
|
|
133
|
+
throw ApiError.badRequest(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
|
|
134
|
+
}
|
|
135
|
+
/** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
|
|
136
|
+
function toDirection(raw, context) {
|
|
137
|
+
if (raw === void 0 || raw === null) return "asc";
|
|
138
|
+
if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
|
|
139
|
+
const lowered = raw.toLowerCase();
|
|
140
|
+
if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
|
|
141
|
+
return lowered;
|
|
142
|
+
}
|
|
143
|
+
/** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
|
|
144
|
+
function toOrderByEntry(raw, index) {
|
|
145
|
+
const context = `entry ${index}`;
|
|
146
|
+
if (typeof raw === "string") {
|
|
147
|
+
const idx = raw.indexOf(":");
|
|
148
|
+
const field = (idx === -1 ? raw : raw.slice(0, idx)).trim();
|
|
149
|
+
if (!field) invalidOrderBy(`${context} is an empty field name`);
|
|
150
|
+
return {
|
|
151
|
+
field,
|
|
152
|
+
direction: idx === -1 ? "asc" : toDirection(raw.slice(idx + 1), context)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
|
|
156
|
+
const entry = raw;
|
|
157
|
+
if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
|
|
158
|
+
return {
|
|
159
|
+
field: entry.field,
|
|
160
|
+
direction: toDirection(entry.direction, context)
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Parse the `orderBy` query parameter.
|
|
165
|
+
*
|
|
166
|
+
* The field *name* has been validated against the schema for a while — an
|
|
167
|
+
* `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
|
|
168
|
+
* that silently dropping the sort leaves the caller believing in an order that
|
|
169
|
+
* is not there. The parameter's *shape* was never checked the same way, and it
|
|
170
|
+
* failed in exactly the same silent manner one layer earlier: whatever
|
|
171
|
+
* `JSON.parse` returned was assigned to an option declared as an array of
|
|
172
|
+
* `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
|
|
173
|
+
* `?orderBy={"field":"name"}` — an object rather than an array, and the most
|
|
174
|
+
* natural thing for a client to try — read `undefined`, dropped the ORDER BY,
|
|
175
|
+
* and answered 200. So did a number, a boolean, `null`, and `["name"]`.
|
|
176
|
+
*
|
|
177
|
+
* This refuses those, the way `parseWhereParam` above already refuses a
|
|
178
|
+
* malformed filter and for the same reason. What it keeps working is every
|
|
179
|
+
* shape that worked before: the `field` and `field:desc` shorthands, and the
|
|
180
|
+
* canonical JSON array.
|
|
181
|
+
*/
|
|
182
|
+
function parseOrderByParam(raw) {
|
|
183
|
+
if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
|
|
184
|
+
const str = String(raw).trim();
|
|
185
|
+
if (!str) return void 0;
|
|
186
|
+
let parsed;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(str);
|
|
189
|
+
} catch {
|
|
190
|
+
return [toOrderByEntry(str, 0)];
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(parsed)) {
|
|
193
|
+
if (parsed.length === 0) return void 0;
|
|
194
|
+
return parsed.map(toOrderByEntry);
|
|
195
|
+
}
|
|
196
|
+
if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
|
|
197
|
+
if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
|
|
198
|
+
invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* {@link resolveClientListLimit} for an HTTP route: the same bounds, answered
|
|
202
|
+
* with a 400 rather than a 500.
|
|
203
|
+
*
|
|
204
|
+
* The shared resolver throws a `ListLimitError`, which carries `status` — but
|
|
205
|
+
* the Hono error handler discriminates on `statusCode`, so an unconverted one
|
|
206
|
+
* reaches the client as `INTERNAL_ERROR` with its message stripped, telling the
|
|
207
|
+
* caller nothing about the parameter it got wrong. Every REST list ingress
|
|
208
|
+
* routes its `limit` through here so all of them name the ceiling the same way.
|
|
209
|
+
*/
|
|
210
|
+
function resolveListLimitParam(rawLimit, opts = {}) {
|
|
211
|
+
try {
|
|
212
|
+
return resolveClientListLimit(rawLimit, opts);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
if (e instanceof ListLimitError) throw ApiError.badRequest(e.message, "INVALID_LIMIT");
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Parse query parameters into QueryOptions
|
|
220
|
+
*/
|
|
221
|
+
function parseQueryOptions(query, limits = {}) {
|
|
222
|
+
const options = {};
|
|
223
|
+
const rawLimit = getLastValue(query.limit);
|
|
224
|
+
const offsetVal = getLastValue(query.offset);
|
|
225
|
+
if (offsetVal) options.offset = parseInt(String(offsetVal));
|
|
226
|
+
const pageVal = getLastValue(query.page);
|
|
227
|
+
if (pageVal) {
|
|
228
|
+
const page = parseInt(String(pageVal));
|
|
229
|
+
const limit = resolveListLimitParam(rawLimit, {
|
|
230
|
+
defaultLimit: limits.defaultLimit,
|
|
231
|
+
maxLimit: limits.maxLimit
|
|
232
|
+
});
|
|
233
|
+
options.offset = (page - 1) * limit;
|
|
234
|
+
}
|
|
235
|
+
const orVal = getLastValue(query.or);
|
|
236
|
+
const andVal = getLastValue(query.and);
|
|
237
|
+
if (orVal) {
|
|
238
|
+
const logical = parseLogicalGroup("or", orVal);
|
|
239
|
+
if (logical) options.logical = logical;
|
|
240
|
+
} else if (andVal) {
|
|
241
|
+
const logical = parseLogicalGroup("and", andVal);
|
|
242
|
+
if (logical) options.logical = logical;
|
|
243
|
+
}
|
|
244
|
+
const reservedQueryKeys = [
|
|
245
|
+
"limit",
|
|
246
|
+
"offset",
|
|
247
|
+
"page",
|
|
248
|
+
"orderBy",
|
|
249
|
+
"include",
|
|
250
|
+
"fields",
|
|
251
|
+
"searchString",
|
|
252
|
+
"searchExplain",
|
|
253
|
+
"vector_search",
|
|
254
|
+
"vector",
|
|
255
|
+
"vector_distance",
|
|
256
|
+
"vector_threshold",
|
|
257
|
+
"or",
|
|
258
|
+
"and",
|
|
259
|
+
"where"
|
|
260
|
+
];
|
|
261
|
+
const filterDict = {};
|
|
262
|
+
for (const [key, rawValue] of Object.entries(query)) {
|
|
263
|
+
if (reservedQueryKeys.includes(key)) continue;
|
|
264
|
+
filterDict[key] = rawValue;
|
|
265
|
+
}
|
|
266
|
+
const whereVal = getLastValue(query.where);
|
|
267
|
+
const where = {
|
|
268
|
+
...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
|
|
269
|
+
...deserializeFilter(filterDict)
|
|
270
|
+
};
|
|
271
|
+
if (Object.keys(where).length > 0) options.where = where;
|
|
272
|
+
const orderByVal = getLastValue(query.orderBy);
|
|
273
|
+
if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
|
|
274
|
+
const includeVal = getLastValue(query.include);
|
|
275
|
+
if (includeVal) {
|
|
276
|
+
const includeStr = String(includeVal).trim();
|
|
277
|
+
if (includeStr === "*") options.include = ["*"];
|
|
278
|
+
else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
279
|
+
}
|
|
280
|
+
const fieldsVal = getLastValue(query.fields);
|
|
281
|
+
if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
|
|
282
|
+
const vectorSearchVal = getLastValue(query.vector_search);
|
|
283
|
+
const vectorVal = getLastValue(query.vector);
|
|
284
|
+
if (vectorSearchVal && vectorVal) {
|
|
285
|
+
const vectorStr = String(vectorVal);
|
|
286
|
+
let decoded;
|
|
287
|
+
try {
|
|
288
|
+
decoded = JSON.parse(vectorStr);
|
|
289
|
+
} catch {
|
|
290
|
+
decoded = void 0;
|
|
291
|
+
}
|
|
292
|
+
if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw ApiError.badRequest("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
|
|
293
|
+
const queryVector = decoded;
|
|
294
|
+
const distanceParamVal = getLastValue(query.vector_distance);
|
|
295
|
+
const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
|
|
296
|
+
if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
|
|
297
|
+
const vectorSearch = {
|
|
298
|
+
property: String(vectorSearchVal),
|
|
299
|
+
vector: queryVector,
|
|
300
|
+
distance: distanceParam
|
|
301
|
+
};
|
|
302
|
+
const thresholdVal = getLastValue(query.vector_threshold);
|
|
303
|
+
if (thresholdVal) {
|
|
304
|
+
const threshold = parseFloat(String(thresholdVal));
|
|
305
|
+
if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
|
|
306
|
+
vectorSearch.threshold = threshold;
|
|
307
|
+
}
|
|
308
|
+
options.vectorSearch = vectorSearch;
|
|
309
|
+
}
|
|
310
|
+
options.limit = resolveListLimitParam(rawLimit, {
|
|
311
|
+
vectorSearch: !!options.vectorSearch,
|
|
312
|
+
defaultLimit: limits.defaultLimit,
|
|
313
|
+
maxLimit: limits.maxLimit
|
|
314
|
+
});
|
|
315
|
+
return options;
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
97
318
|
//#region src/auth/api-keys/api-key-permission-guard.ts
|
|
98
319
|
/**
|
|
99
320
|
* Map an HTTP method string to an `ApiKeyOperation`.
|
|
@@ -171,6 +392,31 @@ function isFunctionAllowed(permissions, functionName, operation) {
|
|
|
171
392
|
//#region src/auth/rls-scope.ts
|
|
172
393
|
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
173
394
|
/**
|
|
395
|
+
* The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
|
|
396
|
+
* caller presenting the service key.
|
|
397
|
+
*
|
|
398
|
+
* Naming it makes the privilege legible, because it is narrower than it reads.
|
|
399
|
+
* This is **not** an RLS bypass — a driver scoped with it is an
|
|
400
|
+
* `AuthenticatedPostgresBackendDriver`, so every statement runs in a
|
|
401
|
+
* transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
402
|
+
* `app.uid = 'service'`, and policies are evaluated. Two consequences worth
|
|
403
|
+
* knowing before you write a policy:
|
|
404
|
+
*
|
|
405
|
+
* - It passes the default policies through their `rolesOverlap(['admin'])`
|
|
406
|
+
* arm — the same arm an application user holding the `admin` role passes.
|
|
407
|
+
* - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
|
|
408
|
+
* **false** for it. A collection with `disableDefaultPolicies: true` whose
|
|
409
|
+
* only rule is `serverContext()` denies these writes (42501) and returns
|
|
410
|
+
* zero rows for these reads.
|
|
411
|
+
*
|
|
412
|
+
* The true bypass is `rebase.sql()`, which runs on the owner connection and
|
|
413
|
+
* never goes near `withAuth`.
|
|
414
|
+
*/
|
|
415
|
+
var SERVICE_IDENTITY = {
|
|
416
|
+
uid: "service",
|
|
417
|
+
roles: ["admin"]
|
|
418
|
+
};
|
|
419
|
+
/**
|
|
174
420
|
* Returns true if the driver supports RLS scoping via `withAuth()`.
|
|
175
421
|
*/
|
|
176
422
|
function isRLSScopedDriver(driver) {
|
|
@@ -461,6 +707,137 @@ function createApiKeyPreAuth(options) {
|
|
|
461
707
|
};
|
|
462
708
|
}
|
|
463
709
|
//#endregion
|
|
710
|
+
//#region src/storage/keys.ts
|
|
711
|
+
/**
|
|
712
|
+
* Canonical storage keys and bucket names.
|
|
713
|
+
*
|
|
714
|
+
* Storage is not under RLS, so a `storageAuthorize` hook is the whole access
|
|
715
|
+
* control model — and a hook can only be correct if the key it is shown is the
|
|
716
|
+
* key that is written. That is the invariant this module exists to hold: one
|
|
717
|
+
* canonical string, computed once per request, handed to the hook, to the
|
|
718
|
+
* controller, and to the download token alike.
|
|
719
|
+
*
|
|
720
|
+
* ## Why rejecting beats stripping
|
|
721
|
+
*
|
|
722
|
+
* The previous `sanitizeStorageKey` *stripped* `../` in a single pass. Two
|
|
723
|
+
* things were wrong with that, and only one of them was the obvious one.
|
|
724
|
+
*
|
|
725
|
+
* The obvious one: a single pass is not a fixed point. `....//` contains `../`
|
|
726
|
+
* at offset 2, so removing it leaves `../` behind — the sanitizer manufactured
|
|
727
|
+
* the traversal it was there to remove. `users/alice/....//bob/x` came out as
|
|
728
|
+
* `users/alice/../bob/x`, which a prefix hook reads as alice's (it starts with
|
|
729
|
+
* `users/alice/`) and the filesystem reads as bob's. The hook approved one
|
|
730
|
+
* object and the controller wrote another.
|
|
731
|
+
*
|
|
732
|
+
* The subtler one, and the reason this is a rewrite rather than a loop: even a
|
|
733
|
+
* correct strip is a silent rewrite. A caller who asks to store at `a/../b` and
|
|
734
|
+
* gets an object at `a/b` was not protected, they were misled — and every later
|
|
735
|
+
* read, ownership row and audit line now refers to a path nobody chose. So a key
|
|
736
|
+
* that means something other than what it says is refused (400), not repaired.
|
|
737
|
+
*
|
|
738
|
+
* Note what is NOT traversal under this rule: `....` is an ordinary directory
|
|
739
|
+
* name, and `users/alice/....//bob/x` canonicalizes to
|
|
740
|
+
* `users/alice/..../bob/x` — still comfortably inside alice's prefix, which is
|
|
741
|
+
* exactly right. Only a real `..` segment is refused.
|
|
742
|
+
*/
|
|
743
|
+
/** Longest key accepted, in UTF-16 code units. Matches the previous cap. */
|
|
744
|
+
var MAX_STORAGE_KEY_LENGTH = 1024;
|
|
745
|
+
/**
|
|
746
|
+
* A key that cannot be canonicalized. Carries no path back to the caller
|
|
747
|
+
* beyond what they sent, so it is safe to surface as a 400 message.
|
|
748
|
+
*/
|
|
749
|
+
var InvalidStorageKeyError = class extends Error {
|
|
750
|
+
constructor(message) {
|
|
751
|
+
super(message);
|
|
752
|
+
this.name = "InvalidStorageKeyError";
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
/**
|
|
756
|
+
* Canonicalize a caller-supplied storage key, or throw
|
|
757
|
+
* {@link InvalidStorageKeyError}.
|
|
758
|
+
*
|
|
759
|
+
* Normalizations applied (safe, idempotent, and meaning-preserving):
|
|
760
|
+
* - leading slashes removed — `/a/b` and `a/b` name the same object
|
|
761
|
+
* - `.` segments and repeated slashes collapsed
|
|
762
|
+
*
|
|
763
|
+
* Refusals (the key means something other than what it says):
|
|
764
|
+
* - any `..` segment, on either separator, at any depth
|
|
765
|
+
* - null bytes
|
|
766
|
+
* - keys longer than {@link MAX_STORAGE_KEY_LENGTH}
|
|
767
|
+
*
|
|
768
|
+
* A trailing slash is preserved: it is how the folder route marks a prefix.
|
|
769
|
+
*/
|
|
770
|
+
function canonicalStorageKey(rawKey) {
|
|
771
|
+
if (rawKey.includes("\0")) throw new InvalidStorageKeyError("Storage key contains a null byte.");
|
|
772
|
+
if (rawKey.length > 1024) throw new InvalidStorageKeyError(`Storage key exceeds the maximum length of ${MAX_STORAGE_KEY_LENGTH} characters.`);
|
|
773
|
+
if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
|
|
774
|
+
const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
|
|
775
|
+
if (withoutLeadingSlashes === "") return "";
|
|
776
|
+
const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
|
|
777
|
+
const normalized = path.posix.normalize(withoutLeadingSlashes);
|
|
778
|
+
if (normalized === "." || normalized === "./") return "";
|
|
779
|
+
const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
780
|
+
if (key === "") return "";
|
|
781
|
+
return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Canonicalize, or return `null` when the key is not canonicalizable.
|
|
785
|
+
*
|
|
786
|
+
* For callers that must fail closed without an exception — the download-token
|
|
787
|
+
* middleware compares a request path against a granted path, and a key it
|
|
788
|
+
* cannot canonicalize simply matches nothing.
|
|
789
|
+
*/
|
|
790
|
+
function tryCanonicalStorageKey(rawKey) {
|
|
791
|
+
try {
|
|
792
|
+
return canonicalStorageKey(rawKey);
|
|
793
|
+
} catch {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
|
|
798
|
+
var InvalidStorageBucketError = class extends Error {
|
|
799
|
+
constructor(message) {
|
|
800
|
+
super(message);
|
|
801
|
+
this.name = "InvalidStorageBucketError";
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
/**
|
|
805
|
+
* One path segment: letters, digits, `.`, `_`, `-`, first character
|
|
806
|
+
* alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
|
|
807
|
+
* and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
|
|
808
|
+
* absolute paths and anything containing a separator unrepresentable.
|
|
809
|
+
*/
|
|
810
|
+
var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
811
|
+
/**
|
|
812
|
+
* Canonicalize a caller-supplied bucket name, or throw
|
|
813
|
+
* {@link InvalidStorageBucketError}.
|
|
814
|
+
*
|
|
815
|
+
* The bucket is the *other* caller-controlled routing value in an upload
|
|
816
|
+
* request, and it was the one nobody validated. `LocalStorageController`
|
|
817
|
+
* builds `join(basePath, bucket)` and then checks containment against that
|
|
818
|
+
* result, so a bucket of `../../etc` moved the boundary rather than crossing
|
|
819
|
+
* it — the guard passed because the guard's reference point was the attacker's.
|
|
820
|
+
* The containment check now resolves against the storage root, and this is the
|
|
821
|
+
* check at the route boundary that stops the value before it gets there.
|
|
822
|
+
*
|
|
823
|
+
* A bucket is configuration, not user data: there is no legitimate caller that
|
|
824
|
+
* needs a separator, a leading dot, or a `..` in one. So this refuses rather
|
|
825
|
+
* than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
|
|
826
|
+
* bucket would silently store the object somewhere the caller did not ask for.
|
|
827
|
+
*
|
|
828
|
+
* Returns `undefined` when the caller named no bucket (absent, or an empty
|
|
829
|
+
* form field), which is how every controller spells "use my default". An empty
|
|
830
|
+
* string used to reach `getFullPath` and resolve to the storage *root* rather
|
|
831
|
+
* than the `default` bucket, which is a third place a bare key did not
|
|
832
|
+
* round-trip.
|
|
833
|
+
*/
|
|
834
|
+
function canonicalStorageBucket(rawBucket) {
|
|
835
|
+
if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
|
|
836
|
+
if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
|
|
837
|
+
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.");
|
|
838
|
+
return rawBucket;
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
464
841
|
//#region src/auth/middleware.ts
|
|
465
842
|
/**
|
|
466
843
|
* Hono middleware that requires a valid JWT token via Authorization header.
|
|
@@ -551,7 +928,7 @@ var requireAdmin = async (c, next) => {
|
|
|
551
928
|
var optionalAuth = async (c, next) => {
|
|
552
929
|
if (c.get("user")) return next();
|
|
553
930
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
554
|
-
if (token !== void 0) {
|
|
931
|
+
if (token !== void 0 && isJwtConfigured()) {
|
|
555
932
|
const payload = verifyAccessToken(token);
|
|
556
933
|
if (payload) c.set("user", payload);
|
|
557
934
|
}
|
|
@@ -561,6 +938,7 @@ var optionalAuth = async (c, next) => {
|
|
|
561
938
|
* Extract user from token - for WebSocket authentication
|
|
562
939
|
*/
|
|
563
940
|
function extractUserFromToken(token) {
|
|
941
|
+
if (!isJwtConfigured()) return null;
|
|
564
942
|
return verifyAccessToken(token);
|
|
565
943
|
}
|
|
566
944
|
/**
|
|
@@ -711,7 +1089,7 @@ function createAuthMiddleware(options) {
|
|
|
711
1089
|
var queryTokenAuth = async (c, next) => {
|
|
712
1090
|
if (extractBearerToken(c.req.header("authorization")) !== void 0) return next();
|
|
713
1091
|
const queryToken = c.req.query("token");
|
|
714
|
-
if (queryToken) {
|
|
1092
|
+
if (queryToken && isJwtConfigured()) {
|
|
715
1093
|
const payload = verifyAccessToken(queryToken);
|
|
716
1094
|
if (payload) c.set("user", payload);
|
|
717
1095
|
}
|
|
@@ -782,15 +1160,25 @@ var fileTokenAuth = async (c, next) => {
|
|
|
782
1160
|
if (idx < 0) return "";
|
|
783
1161
|
return fullPath.substring(idx + prefix.length + 1);
|
|
784
1162
|
};
|
|
1163
|
+
/**
|
|
1164
|
+
* Must agree with `parseBucketAndPath` in `storage/routes.ts`, because the
|
|
1165
|
+
* path a token GRANTS is minted there and the path a request ASKS FOR is
|
|
1166
|
+
* derived here. `/metadata` canonicalizes before signing, so a raw
|
|
1167
|
+
* comparison here would 403 an otherwise-valid request for any key whose
|
|
1168
|
+
* URL form is not already canonical (`a//b.txt`, `a/./b.txt`).
|
|
1169
|
+
*
|
|
1170
|
+
* A key that cannot be canonicalized yields null, which matches no grant —
|
|
1171
|
+
* the same fail-closed answer `isPathMatch` gives a `..` segment.
|
|
1172
|
+
*/
|
|
785
1173
|
const parseBucketPath = (filePath) => {
|
|
786
1174
|
const parts = filePath.split("/");
|
|
787
1175
|
if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
|
|
788
1176
|
bucket: "default",
|
|
789
|
-
resolvedPath: parts.slice(1).join("/")
|
|
1177
|
+
resolvedPath: tryCanonicalStorageKey(parts.slice(1).join("/"))
|
|
790
1178
|
};
|
|
791
1179
|
return {
|
|
792
1180
|
bucket: "default",
|
|
793
|
-
resolvedPath: filePath
|
|
1181
|
+
resolvedPath: tryCanonicalStorageKey(filePath)
|
|
794
1182
|
};
|
|
795
1183
|
};
|
|
796
1184
|
const bearerToken = extractBearerToken(authHeader);
|
|
@@ -800,7 +1188,8 @@ var fileTokenAuth = async (c, next) => {
|
|
|
800
1188
|
const rawPath = extractWildcard(c);
|
|
801
1189
|
if (rawPath) {
|
|
802
1190
|
const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
|
|
803
|
-
|
|
1191
|
+
const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
|
|
1192
|
+
if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
|
|
804
1193
|
c.set("user", {
|
|
805
1194
|
uid: "download-token",
|
|
806
1195
|
roles: ["reader"]
|
|
@@ -824,7 +1213,8 @@ var fileTokenAuth = async (c, next) => {
|
|
|
824
1213
|
const rawPath = extractWildcard(c);
|
|
825
1214
|
if (rawPath) {
|
|
826
1215
|
const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
|
|
827
|
-
|
|
1216
|
+
const requestedFullPath = resolvedPath === null ? null : `${bucket}/${resolvedPath}`;
|
|
1217
|
+
if (requestedFullPath !== null && isPathMatch(requestedFullPath, payload.path)) {
|
|
828
1218
|
c.set("user", {
|
|
829
1219
|
uid: "download-token",
|
|
830
1220
|
roles: ["reader"]
|
|
@@ -852,8 +1242,9 @@ function createAdapterAuthMiddleware(options) {
|
|
|
852
1242
|
const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
|
|
853
1243
|
return async (c, next) => {
|
|
854
1244
|
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
1245
|
+
const presentedToken = extractBearerToken(c.req.header("authorization"));
|
|
855
1246
|
if (apiKeyStore) {
|
|
856
|
-
const token =
|
|
1247
|
+
const token = presentedToken ?? "";
|
|
857
1248
|
if (token.startsWith("rk_")) {
|
|
858
1249
|
const result = await validateApiKey(c, token, {
|
|
859
1250
|
store: apiKeyStore,
|
|
@@ -890,17 +1281,23 @@ function createAdapterAuthMiddleware(options) {
|
|
|
890
1281
|
code: "INTERNAL_ERROR"
|
|
891
1282
|
} }, 500);
|
|
892
1283
|
}
|
|
893
|
-
} else
|
|
894
|
-
c.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
})
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
1284
|
+
} else {
|
|
1285
|
+
if (presentedToken !== void 0) return c.json({ error: {
|
|
1286
|
+
message: "Invalid or expired token",
|
|
1287
|
+
code: "UNAUTHORIZED"
|
|
1288
|
+
} }, 401);
|
|
1289
|
+
try {
|
|
1290
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1291
|
+
uid: ANONYMOUS_USER_ID,
|
|
1292
|
+
roles: ["anon"]
|
|
1293
|
+
}));
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
|
|
1296
|
+
return c.json({ error: {
|
|
1297
|
+
message: "Server configuration error",
|
|
1298
|
+
code: "INTERNAL_ERROR"
|
|
1299
|
+
} }, 500);
|
|
1300
|
+
}
|
|
904
1301
|
}
|
|
905
1302
|
if (enforceAuth && !c.get("user")) return c.json({ error: {
|
|
906
1303
|
message: "Unauthorized: Authentication required",
|
|
@@ -910,43 +1307,141 @@ function createAdapterAuthMiddleware(options) {
|
|
|
910
1307
|
};
|
|
911
1308
|
}
|
|
912
1309
|
//#endregion
|
|
1310
|
+
//#region src/email/html.ts
|
|
1311
|
+
/**
|
|
1312
|
+
* HTML escaping for email bodies.
|
|
1313
|
+
*
|
|
1314
|
+
* Every default template interpolates values the server does not control — a
|
|
1315
|
+
* `displayName` chosen at registration, an `appName` from config, a link base a
|
|
1316
|
+
* host set — into markup that is then mailed, signed by the sending domain, to
|
|
1317
|
+
* an address the same request chose. Unescaped, that turns `POST /auth/register`
|
|
1318
|
+
* into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
|
|
1319
|
+
* whose SPF and DKIM check out.
|
|
1320
|
+
*
|
|
1321
|
+
* The fix is deliberately not five escaping calls at five interpolation sites:
|
|
1322
|
+
* a sixth template would simply forget. Templates are built with the {@link html}
|
|
1323
|
+
* tag, which escapes *every* interpolated value by default, and markup that is
|
|
1324
|
+
* meant to pass through verbatim — the static style strings, a nested fragment —
|
|
1325
|
+
* must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
|
|
1326
|
+
* in a test; forgetting to escape is invisible until someone exploits it.
|
|
1327
|
+
*/
|
|
1328
|
+
/**
|
|
1329
|
+
* Markup that has already been vetted and must be interpolated verbatim.
|
|
1330
|
+
*
|
|
1331
|
+
* Only ever construct this from a string literal in this package's own source
|
|
1332
|
+
* (or from the {@link html} tag, which produces one). Wrapping user input in
|
|
1333
|
+
* `raw()` defeats the entire mechanism.
|
|
1334
|
+
*/
|
|
1335
|
+
var RawHtml = class {
|
|
1336
|
+
value;
|
|
1337
|
+
constructor(value) {
|
|
1338
|
+
this.value = value;
|
|
1339
|
+
}
|
|
1340
|
+
toString() {
|
|
1341
|
+
return this.value;
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
/**
|
|
1345
|
+
* Mark a static, author-controlled string as safe to interpolate unescaped.
|
|
1346
|
+
*/
|
|
1347
|
+
function raw(markup) {
|
|
1348
|
+
return new RawHtml(markup);
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Escape the five characters that can change the meaning of HTML text or of a
|
|
1352
|
+
* quoted attribute value.
|
|
1353
|
+
*
|
|
1354
|
+
* `&` goes first: escaping it after the others would double-escape the entities
|
|
1355
|
+
* they just produced. `'` is included because it is a legal attribute delimiter,
|
|
1356
|
+
* and `"` because it is the one this file uses.
|
|
1357
|
+
*/
|
|
1358
|
+
function escapeHtml(value) {
|
|
1359
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Render one interpolated value: pass {@link RawHtml} through, drop nullish,
|
|
1363
|
+
* escape everything else.
|
|
1364
|
+
*/
|
|
1365
|
+
function interpolate(value) {
|
|
1366
|
+
if (value instanceof RawHtml) return value.value;
|
|
1367
|
+
if (value === null || value === void 0) return "";
|
|
1368
|
+
return escapeHtml(String(value));
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Tagged template for email markup. Every `${...}` is escaped unless it is
|
|
1372
|
+
* {@link RawHtml}.
|
|
1373
|
+
*
|
|
1374
|
+
* Returns `RawHtml` so fragments nest without being escaped a second time:
|
|
1375
|
+
*
|
|
1376
|
+
* ```ts
|
|
1377
|
+
* const button = url ? html`<a href="${url}">Open</a>` : raw("");
|
|
1378
|
+
* const body = html`<div>${button}</div>`;
|
|
1379
|
+
* ```
|
|
1380
|
+
*/
|
|
1381
|
+
function html(strings, ...values) {
|
|
1382
|
+
let out = strings[0];
|
|
1383
|
+
for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
|
|
1384
|
+
return new RawHtml(out);
|
|
1385
|
+
}
|
|
1386
|
+
//#endregion
|
|
913
1387
|
//#region src/email/templates.ts
|
|
914
1388
|
/**
|
|
915
|
-
*
|
|
1389
|
+
* Default email templates for authentication emails.
|
|
1390
|
+
*
|
|
1391
|
+
* Every HTML body is built with the `html` tag from `./html`, which escapes each
|
|
1392
|
+
* interpolated value unless it is explicitly marked `raw`. `displayName` reaches
|
|
1393
|
+
* these templates straight from the registration body, and the resulting mail is
|
|
1394
|
+
* sent — signed by the sending domain — to an address the same anonymous request
|
|
1395
|
+
* chose, so an unescaped interpolation here is a phishing primitive, not a
|
|
1396
|
+
* rendering glitch. Escaping lives in the tag rather than at the call sites so a
|
|
1397
|
+
* sixth template inherits it instead of having to remember it.
|
|
1398
|
+
*/
|
|
1399
|
+
/**
|
|
1400
|
+
* Get a greeting name for the user.
|
|
1401
|
+
*
|
|
1402
|
+
* Returns the raw value — `displayName` is whatever the account was registered
|
|
1403
|
+
* with, including markup. Escaping is the `html` tag's job, so that a template
|
|
1404
|
+
* that interpolates the greeting into text (the plain-text bodies below) and one
|
|
1405
|
+
* that interpolates it into markup can share this.
|
|
916
1406
|
*/
|
|
917
1407
|
function getGreeting(user) {
|
|
918
1408
|
return user.displayName || user.email.split("@")[0];
|
|
919
1409
|
}
|
|
920
1410
|
/**
|
|
921
|
-
* Common email styles
|
|
1411
|
+
* Common email styles.
|
|
1412
|
+
*
|
|
1413
|
+
* `raw` because these are static, author-written CSS — the one category of
|
|
1414
|
+
* interpolation the `html` tag lets through unescaped. (Escaping them would
|
|
1415
|
+
* survive an HTML parser but not every mail client's CSS parser: the
|
|
1416
|
+
* `'Segoe UI'` quotes would arrive as entities.)
|
|
922
1417
|
*/
|
|
923
1418
|
var styles = {
|
|
924
|
-
container: `
|
|
1419
|
+
container: raw(`
|
|
925
1420
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
926
1421
|
max-width: 600px;
|
|
927
1422
|
margin: 0 auto;
|
|
928
1423
|
padding: 40px 20px;
|
|
929
1424
|
background-color: #f8fafc;
|
|
930
|
-
|
|
931
|
-
card: `
|
|
1425
|
+
`),
|
|
1426
|
+
card: raw(`
|
|
932
1427
|
background-color: #ffffff;
|
|
933
1428
|
border-radius: 12px;
|
|
934
1429
|
padding: 40px;
|
|
935
1430
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
936
|
-
|
|
937
|
-
heading: `
|
|
1431
|
+
`),
|
|
1432
|
+
heading: raw(`
|
|
938
1433
|
color: #1e293b;
|
|
939
1434
|
font-size: 24px;
|
|
940
1435
|
font-weight: 600;
|
|
941
1436
|
margin: 0 0 20px 0;
|
|
942
|
-
|
|
943
|
-
paragraph: `
|
|
1437
|
+
`),
|
|
1438
|
+
paragraph: raw(`
|
|
944
1439
|
color: #475569;
|
|
945
1440
|
font-size: 16px;
|
|
946
1441
|
line-height: 1.6;
|
|
947
1442
|
margin: 0 0 20px 0;
|
|
948
|
-
|
|
949
|
-
button: `
|
|
1443
|
+
`),
|
|
1444
|
+
button: raw(`
|
|
950
1445
|
display: inline-block;
|
|
951
1446
|
background-color: #3b82f6;
|
|
952
1447
|
color: #ffffff;
|
|
@@ -956,22 +1451,22 @@ var styles = {
|
|
|
956
1451
|
padding: 14px 28px;
|
|
957
1452
|
border-radius: 8px;
|
|
958
1453
|
margin: 20px 0;
|
|
959
|
-
|
|
960
|
-
footer: `
|
|
1454
|
+
`),
|
|
1455
|
+
footer: raw(`
|
|
961
1456
|
color: #94a3b8;
|
|
962
1457
|
font-size: 14px;
|
|
963
1458
|
margin-top: 30px;
|
|
964
1459
|
padding-top: 20px;
|
|
965
1460
|
border-top: 1px solid #e2e8f0;
|
|
966
|
-
|
|
967
|
-
warning: `
|
|
1461
|
+
`),
|
|
1462
|
+
warning: raw(`
|
|
968
1463
|
color: #64748b;
|
|
969
1464
|
font-size: 14px;
|
|
970
1465
|
background-color: #fef3c7;
|
|
971
1466
|
padding: 12px 16px;
|
|
972
1467
|
border-radius: 6px;
|
|
973
1468
|
margin-top: 20px;
|
|
974
|
-
`
|
|
1469
|
+
`)
|
|
975
1470
|
};
|
|
976
1471
|
/**
|
|
977
1472
|
* Default password reset email template
|
|
@@ -979,9 +1474,7 @@ var styles = {
|
|
|
979
1474
|
function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
|
|
980
1475
|
const greeting = getGreeting(user);
|
|
981
1476
|
const subject = `Reset your ${appName} password`;
|
|
982
|
-
|
|
983
|
-
subject,
|
|
984
|
-
html: `
|
|
1477
|
+
const body = html`
|
|
985
1478
|
<!DOCTYPE html>
|
|
986
1479
|
<html>
|
|
987
1480
|
<head>
|
|
@@ -1028,8 +1521,8 @@ function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
|
|
|
1028
1521
|
</div>
|
|
1029
1522
|
</body>
|
|
1030
1523
|
</html>
|
|
1031
|
-
|
|
1032
|
-
|
|
1524
|
+
`;
|
|
1525
|
+
const text = `
|
|
1033
1526
|
Reset Your Password
|
|
1034
1527
|
|
|
1035
1528
|
Hi ${greeting},
|
|
@@ -1043,7 +1536,11 @@ This link will expire in 1 hour for security reasons.
|
|
|
1043
1536
|
|
|
1044
1537
|
If you didn't request a password reset, you can safely ignore this email.
|
|
1045
1538
|
Your password will remain unchanged.
|
|
1046
|
-
`.trim()
|
|
1539
|
+
`.trim();
|
|
1540
|
+
return {
|
|
1541
|
+
subject,
|
|
1542
|
+
html: body.toString().trim(),
|
|
1543
|
+
text
|
|
1047
1544
|
};
|
|
1048
1545
|
}
|
|
1049
1546
|
/**
|
|
@@ -1052,9 +1549,7 @@ Your password will remain unchanged.
|
|
|
1052
1549
|
function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
|
|
1053
1550
|
const greeting = getGreeting(user);
|
|
1054
1551
|
const subject = `Verify your ${appName} email address`;
|
|
1055
|
-
|
|
1056
|
-
subject,
|
|
1057
|
-
html: `
|
|
1552
|
+
const body = html`
|
|
1058
1553
|
<!DOCTYPE html>
|
|
1059
1554
|
<html>
|
|
1060
1555
|
<head>
|
|
@@ -1096,8 +1591,8 @@ function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
|
|
|
1096
1591
|
</div>
|
|
1097
1592
|
</body>
|
|
1098
1593
|
</html>
|
|
1099
|
-
|
|
1100
|
-
|
|
1594
|
+
`;
|
|
1595
|
+
const text = `
|
|
1101
1596
|
Verify Your Email
|
|
1102
1597
|
|
|
1103
1598
|
Hi ${greeting},
|
|
@@ -1106,7 +1601,11 @@ Thanks for signing up for ${appName}! Please verify your email address by clicki
|
|
|
1106
1601
|
${verifyUrl}
|
|
1107
1602
|
|
|
1108
1603
|
If you didn't create an account with ${appName}, you can safely ignore this email.
|
|
1109
|
-
`.trim()
|
|
1604
|
+
`.trim();
|
|
1605
|
+
return {
|
|
1606
|
+
subject,
|
|
1607
|
+
html: body.toString().trim(),
|
|
1608
|
+
text
|
|
1110
1609
|
};
|
|
1111
1610
|
}
|
|
1112
1611
|
/**
|
|
@@ -1116,9 +1615,7 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
|
|
|
1116
1615
|
function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
|
|
1117
1616
|
const greeting = getGreeting(user);
|
|
1118
1617
|
const subject = `You've been invited to ${appName}`;
|
|
1119
|
-
|
|
1120
|
-
subject,
|
|
1121
|
-
html: `
|
|
1618
|
+
const body = html`
|
|
1122
1619
|
<!DOCTYPE html>
|
|
1123
1620
|
<html>
|
|
1124
1621
|
<head>
|
|
@@ -1164,8 +1661,8 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
|
|
|
1164
1661
|
</div>
|
|
1165
1662
|
</body>
|
|
1166
1663
|
</html>
|
|
1167
|
-
|
|
1168
|
-
|
|
1664
|
+
`;
|
|
1665
|
+
const text = `
|
|
1169
1666
|
Welcome to ${appName}!
|
|
1170
1667
|
|
|
1171
1668
|
Hi ${greeting},
|
|
@@ -1178,7 +1675,11 @@ ${setPasswordUrl}
|
|
|
1178
1675
|
This link will expire in 1 hour for security reasons.
|
|
1179
1676
|
|
|
1180
1677
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
1181
|
-
`.trim()
|
|
1678
|
+
`.trim();
|
|
1679
|
+
return {
|
|
1680
|
+
subject,
|
|
1681
|
+
html: body.toString().trim(),
|
|
1682
|
+
text
|
|
1182
1683
|
};
|
|
1183
1684
|
}
|
|
1184
1685
|
/**
|
|
@@ -1189,9 +1690,7 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
|
|
|
1189
1690
|
const greeting = getGreeting(user);
|
|
1190
1691
|
const url = loginUrl || "";
|
|
1191
1692
|
const subject = `¡Bienvenido/a a ${appName}!`;
|
|
1192
|
-
|
|
1193
|
-
subject,
|
|
1194
|
-
html: `
|
|
1693
|
+
const body = html`
|
|
1195
1694
|
<!DOCTYPE html>
|
|
1196
1695
|
<html>
|
|
1197
1696
|
<head>
|
|
@@ -1218,11 +1717,11 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
|
|
|
1218
1717
|
que tenemos para ti.
|
|
1219
1718
|
</p>
|
|
1220
1719
|
|
|
1221
|
-
${url ? `
|
|
1720
|
+
${url ? html`
|
|
1222
1721
|
<div style="text-align: center;">
|
|
1223
1722
|
<a href="${url}" style="${styles.button}">Ir a mi Panel</a>
|
|
1224
1723
|
</div>
|
|
1225
|
-
` : ""}
|
|
1724
|
+
` : raw("")}
|
|
1226
1725
|
|
|
1227
1726
|
<p style="${styles.paragraph}">
|
|
1228
1727
|
Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
@@ -1237,8 +1736,8 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
|
|
|
1237
1736
|
</div>
|
|
1238
1737
|
</body>
|
|
1239
1738
|
</html>
|
|
1240
|
-
|
|
1241
|
-
|
|
1739
|
+
`;
|
|
1740
|
+
const text = `
|
|
1242
1741
|
¡Bienvenido/a a ${appName}!
|
|
1243
1742
|
|
|
1244
1743
|
Hola ${greeting},
|
|
@@ -1250,7 +1749,11 @@ Ya puedes acceder a tu panel y empezar a explorar todas las oportunidades que te
|
|
|
1250
1749
|
${url ? `Ir a mi panel: ${url}` : ""}
|
|
1251
1750
|
|
|
1252
1751
|
Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
1253
|
-
`.trim()
|
|
1752
|
+
`.trim();
|
|
1753
|
+
return {
|
|
1754
|
+
subject,
|
|
1755
|
+
html: body.toString().trim(),
|
|
1756
|
+
text
|
|
1254
1757
|
};
|
|
1255
1758
|
}
|
|
1256
1759
|
/**
|
|
@@ -1259,9 +1762,7 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
|
1259
1762
|
function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
|
|
1260
1763
|
const greeting = getGreeting(user);
|
|
1261
1764
|
const subject = `Sign in to ${appName}`;
|
|
1262
|
-
|
|
1263
|
-
subject,
|
|
1264
|
-
html: `
|
|
1765
|
+
const body = html`
|
|
1265
1766
|
<!DOCTYPE html>
|
|
1266
1767
|
<html>
|
|
1267
1768
|
<head>
|
|
@@ -1308,8 +1809,8 @@ function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
|
|
|
1308
1809
|
</div>
|
|
1309
1810
|
</body>
|
|
1310
1811
|
</html>
|
|
1311
|
-
|
|
1312
|
-
|
|
1812
|
+
`;
|
|
1813
|
+
const text = `
|
|
1313
1814
|
Sign In to ${appName}
|
|
1314
1815
|
|
|
1315
1816
|
Hi ${greeting},
|
|
@@ -1323,7 +1824,11 @@ This link will expire in 15 minutes for security reasons and can only be used on
|
|
|
1323
1824
|
|
|
1324
1825
|
If you didn't request this sign-in link, you can safely ignore this email.
|
|
1325
1826
|
No action is needed.
|
|
1326
|
-
`.trim()
|
|
1827
|
+
`.trim();
|
|
1828
|
+
return {
|
|
1829
|
+
subject,
|
|
1830
|
+
html: body.toString().trim(),
|
|
1831
|
+
text
|
|
1327
1832
|
};
|
|
1328
1833
|
}
|
|
1329
1834
|
//#endregion
|
|
@@ -1458,11 +1963,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
|
|
|
1458
1963
|
await ctx.authRepo.createPasswordResetToken(entity.id, tokenHash, expiresAt);
|
|
1459
1964
|
const setPasswordUrl = `${ctx.emailConfig?.resetPasswordUrl || ""}/reset-password?token=${token}`;
|
|
1460
1965
|
const appName = ctx.emailConfig?.appName || "Rebase";
|
|
1461
|
-
const templateFn = ctx.emailConfig?.templates?.
|
|
1966
|
+
const templateFn = ctx.emailConfig?.templates?.userInvitation;
|
|
1462
1967
|
const emailContent = templateFn ? templateFn(setPasswordUrl, {
|
|
1463
1968
|
email: entity.values.email,
|
|
1464
1969
|
displayName: entity.values.displayName
|
|
1465
|
-
}) :
|
|
1970
|
+
}) : getUserInvitationTemplate(setPasswordUrl, {
|
|
1466
1971
|
email: entity.values.email,
|
|
1467
1972
|
displayName: entity.values.displayName
|
|
1468
1973
|
}, appName);
|
|
@@ -1491,6 +1996,11 @@ async function finalizeAdminUserCreation(entity, clearPassword, ctx) {
|
|
|
1491
1996
|
var scryptAsync = promisify(scrypt);
|
|
1492
1997
|
var SALT_LENGTH = 32;
|
|
1493
1998
|
var KEY_LENGTH = 64;
|
|
1999
|
+
var SCRYPT_PARAMS = {
|
|
2000
|
+
N: 16384,
|
|
2001
|
+
r: 8,
|
|
2002
|
+
p: 1
|
|
2003
|
+
};
|
|
1494
2004
|
/**
|
|
1495
2005
|
* Password requirements:
|
|
1496
2006
|
* - Minimum 8 characters
|
|
@@ -1515,7 +2025,7 @@ function validatePasswordStrength(password) {
|
|
|
1515
2025
|
*/
|
|
1516
2026
|
async function hashPassword(password) {
|
|
1517
2027
|
const salt = randomBytes$1(SALT_LENGTH);
|
|
1518
|
-
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
|
|
2028
|
+
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS);
|
|
1519
2029
|
return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
|
|
1520
2030
|
}
|
|
1521
2031
|
/**
|
|
@@ -1527,7 +2037,7 @@ async function verifyPassword(password, storedHash) {
|
|
|
1527
2037
|
if (!saltHex || !hashHex) return false;
|
|
1528
2038
|
const salt = Buffer.from(saltHex, "hex");
|
|
1529
2039
|
const storedKey = Buffer.from(hashHex, "hex");
|
|
1530
|
-
return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH), storedKey);
|
|
2040
|
+
return timingSafeEqual(await scryptAsync(password, salt, KEY_LENGTH, SCRYPT_PARAMS), storedKey);
|
|
1531
2041
|
}
|
|
1532
2042
|
//#endregion
|
|
1533
2043
|
//#region src/auth/auth-hooks.ts
|
|
@@ -1585,6 +2095,196 @@ function resolveAuthHooks(hooks) {
|
|
|
1585
2095
|
};
|
|
1586
2096
|
}
|
|
1587
2097
|
//#endregion
|
|
2098
|
+
//#region src/email/link-base.ts
|
|
2099
|
+
/**
|
|
2100
|
+
* The config fields consulted for each link kind, most specific first.
|
|
2101
|
+
*
|
|
2102
|
+
* `resetPasswordUrl` is the general fallback because it is the only one any boot
|
|
2103
|
+
* path sets, and in every real deployment all three point at the same frontend.
|
|
2104
|
+
*/
|
|
2105
|
+
var LINK_BASE_FIELDS = {
|
|
2106
|
+
resetPassword: ["resetPasswordUrl"],
|
|
2107
|
+
verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
|
|
2108
|
+
magicLink: ["magicLinkUrl", "resetPasswordUrl"]
|
|
2109
|
+
};
|
|
2110
|
+
/**
|
|
2111
|
+
* True when `value` is an absolute `http(s)` URL — the only kind a mail client
|
|
2112
|
+
* can follow. A protocol-relative or path-only base is treated as missing.
|
|
2113
|
+
*/
|
|
2114
|
+
function isAbsoluteHttpUrl(value) {
|
|
2115
|
+
try {
|
|
2116
|
+
const url = new URL(value);
|
|
2117
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
2118
|
+
} catch {
|
|
2119
|
+
return false;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/** Drop a trailing slash so callers can append `/verify-email?token=…`. */
|
|
2123
|
+
function normalizeBase(value) {
|
|
2124
|
+
return value.replace(/\/+$/, "");
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* The absolute base URL for a link kind, or `""` when the config has none.
|
|
2128
|
+
*
|
|
2129
|
+
* Callers append their path to the result. `""` is only reachable on a config
|
|
2130
|
+
* that never went through {@link assertEmailLinkBases} — a hand-constructed
|
|
2131
|
+
* `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
|
|
2132
|
+
* misconfiguration cannot turn a password-reset request into a 500 that
|
|
2133
|
+
* distinguishes existing accounts from missing ones.
|
|
2134
|
+
*/
|
|
2135
|
+
function resolveEmailLinkBase(config, kind) {
|
|
2136
|
+
if (!config) return "";
|
|
2137
|
+
for (const field of LINK_BASE_FIELDS[kind]) {
|
|
2138
|
+
const value = config[field];
|
|
2139
|
+
if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
|
|
2140
|
+
}
|
|
2141
|
+
return "";
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Throw when an email configuration cannot produce a followable link.
|
|
2145
|
+
*
|
|
2146
|
+
* Called from `createEmailService`, i.e. from every boot path that wires email
|
|
2147
|
+
* up (the managed runtime, both driver bootstrappers, and any app that passes
|
|
2148
|
+
* `auth.email`). A base that is set but relative is reported separately from one
|
|
2149
|
+
* that is missing, because the two have different fixes.
|
|
2150
|
+
*/
|
|
2151
|
+
function assertEmailLinkBases(config) {
|
|
2152
|
+
const relative = [
|
|
2153
|
+
"resetPasswordUrl",
|
|
2154
|
+
"verifyEmailUrl",
|
|
2155
|
+
"magicLinkUrl"
|
|
2156
|
+
].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
|
|
2157
|
+
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.`);
|
|
2158
|
+
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\".");
|
|
2159
|
+
}
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region src/email/smtp-email-service.ts
|
|
2162
|
+
var _nodemailer;
|
|
2163
|
+
async function loadNodemailer() {
|
|
2164
|
+
if (!_nodemailer) try {
|
|
2165
|
+
_nodemailer = await import("nodemailer");
|
|
2166
|
+
} catch {
|
|
2167
|
+
throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
|
|
2168
|
+
}
|
|
2169
|
+
return _nodemailer;
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* Safely parse a hostname from a URL string
|
|
2173
|
+
*/
|
|
2174
|
+
function getHostname(urlStr) {
|
|
2175
|
+
try {
|
|
2176
|
+
return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
|
|
2177
|
+
} catch {
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* SMTP Email Service implementation using Nodemailer
|
|
2183
|
+
*/
|
|
2184
|
+
var SMTPEmailService = class {
|
|
2185
|
+
transporter = null;
|
|
2186
|
+
config;
|
|
2187
|
+
_initialized = false;
|
|
2188
|
+
constructor(config) {
|
|
2189
|
+
this.config = config;
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Lazily initialize the SMTP transporter on first use
|
|
2193
|
+
*/
|
|
2194
|
+
async ensureTransporter() {
|
|
2195
|
+
if (this._initialized) return;
|
|
2196
|
+
this._initialized = true;
|
|
2197
|
+
if (this.config.smtp) {
|
|
2198
|
+
const nodemailer = await loadNodemailer();
|
|
2199
|
+
let smtpName = this.config.smtp.name;
|
|
2200
|
+
if (!smtpName) {
|
|
2201
|
+
const urlsToTry = [
|
|
2202
|
+
process.env.FRONTEND_URL,
|
|
2203
|
+
this.config.resetPasswordUrl,
|
|
2204
|
+
this.config.verifyEmailUrl
|
|
2205
|
+
];
|
|
2206
|
+
for (const urlStr of urlsToTry) if (urlStr) {
|
|
2207
|
+
const hostname = getHostname(urlStr);
|
|
2208
|
+
if (hostname) {
|
|
2209
|
+
smtpName = hostname;
|
|
2210
|
+
break;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
this.transporter = nodemailer.createTransport({
|
|
2215
|
+
name: smtpName,
|
|
2216
|
+
host: this.config.smtp.host,
|
|
2217
|
+
port: this.config.smtp.port,
|
|
2218
|
+
secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
|
|
2219
|
+
auth: this.config.smtp.auth ? {
|
|
2220
|
+
user: this.config.smtp.auth.user,
|
|
2221
|
+
pass: this.config.smtp.auth.pass
|
|
2222
|
+
} : void 0
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Check if the email service is properly configured
|
|
2228
|
+
*/
|
|
2229
|
+
isConfigured() {
|
|
2230
|
+
return !!(this.config.smtp || this.config.sendEmail);
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Send an email using SMTP or custom send function
|
|
2234
|
+
*/
|
|
2235
|
+
async send(options) {
|
|
2236
|
+
if (this.config.sendEmail) {
|
|
2237
|
+
await this.config.sendEmail(options);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
await this.ensureTransporter();
|
|
2241
|
+
if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
|
|
2242
|
+
const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
|
|
2243
|
+
try {
|
|
2244
|
+
await this.transporter.sendMail({
|
|
2245
|
+
from: this.config.from,
|
|
2246
|
+
to,
|
|
2247
|
+
subject: options.subject,
|
|
2248
|
+
html: options.html,
|
|
2249
|
+
text: options.text,
|
|
2250
|
+
replyTo: options.replyTo
|
|
2251
|
+
});
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2254
|
+
logger.error("Failed to send email", { detail: message });
|
|
2255
|
+
throw new Error(`Failed to send email: ${message}`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Verify SMTP connection (useful for startup checks)
|
|
2260
|
+
*/
|
|
2261
|
+
async verifyConnection() {
|
|
2262
|
+
await this.ensureTransporter();
|
|
2263
|
+
if (!this.transporter) return !!this.config.sendEmail;
|
|
2264
|
+
try {
|
|
2265
|
+
await this.transporter.verify();
|
|
2266
|
+
return true;
|
|
2267
|
+
} catch (error) {
|
|
2268
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2269
|
+
logger.error("SMTP connection verification failed", { detail: message });
|
|
2270
|
+
return false;
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
/**
|
|
2275
|
+
* Create an email service from configuration.
|
|
2276
|
+
*
|
|
2277
|
+
* This is the choke point every boot path goes through — the managed runtime,
|
|
2278
|
+
* both driver bootstrappers, and any app that passes `auth.email` — so it is
|
|
2279
|
+
* where the link bases are checked. A config that can send mail but cannot build
|
|
2280
|
+
* an absolute link fails the start rather than delivering dead `href="/…"` links
|
|
2281
|
+
* and reporting success; see `assertEmailLinkBases`.
|
|
2282
|
+
*/
|
|
2283
|
+
function createEmailService(config) {
|
|
2284
|
+
assertEmailLinkBases(config);
|
|
2285
|
+
return new SMTPEmailService(config);
|
|
2286
|
+
}
|
|
2287
|
+
//#endregion
|
|
1588
2288
|
//#region src/auth/rate-limit-store.ts
|
|
1589
2289
|
/**
|
|
1590
2290
|
* The default store: a sliding window in this process's memory.
|
|
@@ -1686,7 +2386,23 @@ function createRateLimiter(options = {}) {
|
|
|
1686
2386
|
};
|
|
1687
2387
|
}
|
|
1688
2388
|
/**
|
|
1689
|
-
*
|
|
2389
|
+
* The address the socket is actually connected to, or `undefined` on a runtime
|
|
2390
|
+
* that cannot say. Unforgeable, unlike every header below.
|
|
2391
|
+
*
|
|
2392
|
+
* `getConnInfo` is Node-specific and throws elsewhere, so the failure is
|
|
2393
|
+
* swallowed: a runtime without connection info falls back to the shared bucket,
|
|
2394
|
+
* which is a availability trade the caller cannot exploit.
|
|
2395
|
+
*/
|
|
2396
|
+
function socketAddress(c) {
|
|
2397
|
+
try {
|
|
2398
|
+
return getConnInfo(c).remote.address;
|
|
2399
|
+
} catch {
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Default key generator: the client's address, from the most trustworthy source
|
|
2405
|
+
* this deployment has.
|
|
1690
2406
|
*
|
|
1691
2407
|
* `X-Forwarded-For` is a client-writable header; only the entries appended by
|
|
1692
2408
|
* trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
|
|
@@ -1695,8 +2411,20 @@ function createRateLimiter(options = {}) {
|
|
|
1695
2411
|
* client-supplied and must be ignored. This is what prevents a caller from
|
|
1696
2412
|
* spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
|
|
1697
2413
|
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
2414
|
+
* `X-Real-IP` is the *same* kind of header and needs the same rule, which it
|
|
2415
|
+
* did not have: it was read unconditionally, including under
|
|
2416
|
+
* `trustedProxyHops === 0` — the mode whose entire meaning is "no proxy is in
|
|
2417
|
+
* front of me". With no proxy there, nothing writes `X-Real-IP` except the
|
|
2418
|
+
* caller, so the key was theirs to choose: one header per request bought an
|
|
2419
|
+
* unlimited number of buckets, and the limiters on login, registration and
|
|
2420
|
+
* password reset counted to one. The reasoning had been done carefully for one
|
|
2421
|
+
* spelling of a proxy header and not carried to its twin.
|
|
2422
|
+
*
|
|
2423
|
+
* So `X-Real-IP` is now believed only where a proxy is declared to exist. With
|
|
2424
|
+
* none, the connection's own address is used — unforgeable, and available
|
|
2425
|
+
* because the server runs on `@hono/node-server`. `"unknown"` is the last
|
|
2426
|
+
* resort only, and it is a single shared bucket by design: better that
|
|
2427
|
+
* anonymous callers throttle each other than that any of them throttles nobody.
|
|
1700
2428
|
*/
|
|
1701
2429
|
function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
1702
2430
|
if (trustedProxyHops > 0) {
|
|
@@ -1705,8 +2433,10 @@ function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
|
1705
2433
|
const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1706
2434
|
if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
|
|
1707
2435
|
}
|
|
2436
|
+
const realIp = c.req.header("x-real-ip");
|
|
2437
|
+
if (realIp) return realIp;
|
|
1708
2438
|
}
|
|
1709
|
-
return c
|
|
2439
|
+
return socketAddress(c) ?? "unknown";
|
|
1710
2440
|
}
|
|
1711
2441
|
/**
|
|
1712
2442
|
* Pre-configured rate limiter for general auth endpoints (login, register).
|
|
@@ -1727,6 +2457,34 @@ var strictAuthLimiter = createRateLimiter({
|
|
|
1727
2457
|
message: "Too many requests to this sensitive endpoint, please try again later."
|
|
1728
2458
|
});
|
|
1729
2459
|
/**
|
|
2460
|
+
* Limiter for `POST /auth/send-verification`, keyed by the authenticated user.
|
|
2461
|
+
*
|
|
2462
|
+
* That route had no limiter at all while every one of its email-sending
|
|
2463
|
+
* siblings had one, and being authenticated is not the protection it looks
|
|
2464
|
+
* like: registration does not verify the address it is given, so an attacker
|
|
2465
|
+
* registers a victim's address, signs in to the account they just made, and
|
|
2466
|
+
* loops the route. Each call mints a token and mails the victim.
|
|
2467
|
+
*
|
|
2468
|
+
* An IP limiter cannot express what is wanted here — the recipient is the
|
|
2469
|
+
* quantity being protected, not the caller — so this one keys on the uid, which
|
|
2470
|
+
* on this route is one-to-one with the recipient address (emails are unique per
|
|
2471
|
+
* account). `strictAuthLimiter` still runs in front of it to bound the caller by
|
|
2472
|
+
* IP; this bounds what any single address can be sent.
|
|
2473
|
+
*
|
|
2474
|
+
* Five per 15 minutes is generous for "I didn't get the email, resend it" and
|
|
2475
|
+
* useless as a mail bomb. Unauthenticated requests fall back to the IP bucket so
|
|
2476
|
+
* the limiter is never a no-op if it is ever mounted before the auth middleware.
|
|
2477
|
+
*/
|
|
2478
|
+
var verificationEmailLimiter = createRateLimiter({
|
|
2479
|
+
windowMs: 900 * 1e3,
|
|
2480
|
+
limit: 5,
|
|
2481
|
+
keyGenerator: (c) => {
|
|
2482
|
+
const user = c.get("user");
|
|
2483
|
+
return user?.uid ? `verification-email:user:${user.uid}` : `verification-email:ip:${defaultKeyGenerator(c)}`;
|
|
2484
|
+
},
|
|
2485
|
+
message: "Too many verification emails requested, please try again later."
|
|
2486
|
+
});
|
|
2487
|
+
/**
|
|
1730
2488
|
* Key generator for API-key-based rate limiting.
|
|
1731
2489
|
*
|
|
1732
2490
|
* Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
|
|
@@ -1738,6 +2496,8 @@ function apiKeyKeyGenerator(c) {
|
|
|
1738
2496
|
if (apiKey) return `api-key:${apiKey.id}`;
|
|
1739
2497
|
return defaultKeyGenerator(c);
|
|
1740
2498
|
}
|
|
2499
|
+
/** @see DataRateLimitConfig.anonymousFunctions */
|
|
2500
|
+
var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
|
|
1741
2501
|
/**
|
|
1742
2502
|
* Rate limiting for the data API.
|
|
1743
2503
|
*
|
|
@@ -5783,17 +6543,27 @@ function generateHotp(secret, counter) {
|
|
|
5783
6543
|
return (((hash[offset] & 127) << 24 | hash[offset + 1] << 16 | hash[offset + 2] << 8 | hash[offset + 3]) % 1e6).toString().padStart(6, "0");
|
|
5784
6544
|
}
|
|
5785
6545
|
/**
|
|
5786
|
-
* Verify a TOTP token
|
|
6546
|
+
* Verify a TOTP token and report *which* time step matched.
|
|
6547
|
+
*
|
|
6548
|
+
* RFC 6238 §5.2 requires that an accepted OTP not be accepted a second time,
|
|
6549
|
+
* and a boolean cannot express what was accepted: with `window = 1` the same
|
|
6550
|
+
* six digits stay valid for up to 90 seconds, so a code observed once — by a
|
|
6551
|
+
* phishing relay, over a shoulder, in a pasted support message — can be
|
|
6552
|
+
* presented again for the rest of that window. The caller records the returned
|
|
6553
|
+
* counter against the factor and refuses anything at or below it.
|
|
5787
6554
|
*
|
|
5788
6555
|
* @param secret - The shared secret as a Buffer
|
|
5789
6556
|
* @param token - The 6-digit TOTP code to verify
|
|
5790
6557
|
* @param window - Number of time steps to check on each side (default: 1)
|
|
5791
|
-
* @returns
|
|
6558
|
+
* @returns The matched time step, or `null` when no step in the window matches
|
|
5792
6559
|
*/
|
|
5793
|
-
function
|
|
6560
|
+
function verifyTotpCounter(secret, token, window = 1) {
|
|
5794
6561
|
const counter = BigInt(Math.floor(Date.now() / 1e3 / 30));
|
|
5795
|
-
for (let i = -window; i <= window; i++)
|
|
5796
|
-
|
|
6562
|
+
for (let i = -window; i <= window; i++) {
|
|
6563
|
+
const step = counter + BigInt(i);
|
|
6564
|
+
if (generateHotp(secret, step) === token) return Number(step);
|
|
6565
|
+
}
|
|
6566
|
+
return null;
|
|
5797
6567
|
}
|
|
5798
6568
|
/**
|
|
5799
6569
|
* Generate a new TOTP secret and return the setup information
|
|
@@ -5880,35 +6650,195 @@ function encryptTotpSecret(plaintext) {
|
|
|
5880
6650
|
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
5881
6651
|
}
|
|
5882
6652
|
/**
|
|
5883
|
-
* Decrypt a previously encrypted TOTP secret.
|
|
6653
|
+
* Decrypt a previously encrypted TOTP secret.
|
|
6654
|
+
*
|
|
6655
|
+
* @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
|
|
6656
|
+
* @returns The original Base32 TOTP secret.
|
|
6657
|
+
*/
|
|
6658
|
+
function decryptTotpSecret(ciphertext) {
|
|
6659
|
+
const parts = ciphertext.split(":");
|
|
6660
|
+
if (parts.length !== 3) throw new Error("Invalid encrypted TOTP secret format — expected iv:authTag:ciphertext");
|
|
6661
|
+
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
6662
|
+
const key = deriveKey(resolveKeyString());
|
|
6663
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
6664
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
6665
|
+
const encrypted = Buffer.from(encryptedHex, "hex");
|
|
6666
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
6667
|
+
decipher.setAuthTag(authTag);
|
|
6668
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
6669
|
+
}
|
|
6670
|
+
//#endregion
|
|
6671
|
+
//#region src/auth/cookie-utils.ts
|
|
6672
|
+
/**
|
|
6673
|
+
* Cookie auth helpers for httpOnly refresh tokens.
|
|
6674
|
+
*/
|
|
6675
|
+
function getCookieSettings(config) {
|
|
6676
|
+
return {
|
|
6677
|
+
name: config?.cookieName || "__rb_refresh",
|
|
6678
|
+
path: config?.path || "/",
|
|
6679
|
+
sameSite: config?.sameSite || "Lax",
|
|
6680
|
+
domain: config?.domain,
|
|
6681
|
+
secure: config?.secure
|
|
6682
|
+
};
|
|
6683
|
+
}
|
|
6684
|
+
/**
|
|
6685
|
+
* Set the refresh token as an httpOnly cookie on the response.
|
|
6686
|
+
*/
|
|
6687
|
+
function setRefreshCookie(c, refreshToken, config) {
|
|
6688
|
+
if (!config) return;
|
|
6689
|
+
const settings = getCookieSettings(config);
|
|
6690
|
+
const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
|
|
6691
|
+
let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
|
|
6692
|
+
if (isSecure) cookie += "; Secure";
|
|
6693
|
+
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6694
|
+
const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
|
|
6695
|
+
cookie += `; Max-Age=${maxAgeSeconds}`;
|
|
6696
|
+
c.header("Set-Cookie", cookie, { append: true });
|
|
6697
|
+
}
|
|
6698
|
+
/**
|
|
6699
|
+
* Clear the refresh token cookie.
|
|
6700
|
+
*/
|
|
6701
|
+
function clearRefreshCookie(c, config) {
|
|
6702
|
+
if (!config) return;
|
|
6703
|
+
const settings = getCookieSettings(config);
|
|
6704
|
+
let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
|
|
6705
|
+
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6706
|
+
c.header("Set-Cookie", cookie, { append: true });
|
|
6707
|
+
}
|
|
6708
|
+
/**
|
|
6709
|
+
* Read the refresh token from the request — cookie first, then body fallback.
|
|
6710
|
+
*/
|
|
6711
|
+
function readRefreshToken(c, body, config) {
|
|
6712
|
+
if (config) {
|
|
6713
|
+
const settings = getCookieSettings(config);
|
|
6714
|
+
const cookieHeader = c.req.header("cookie") || "";
|
|
6715
|
+
const prefix = `${settings.name}=`;
|
|
6716
|
+
const cookies = cookieHeader.split(";");
|
|
6717
|
+
for (const part of cookies) {
|
|
6718
|
+
const trimmed = part.trim();
|
|
6719
|
+
if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
|
|
6720
|
+
}
|
|
6721
|
+
}
|
|
6722
|
+
return body.refreshToken;
|
|
6723
|
+
}
|
|
6724
|
+
/**
|
|
6725
|
+
* Redact the refresh token from the response body when cookie mode is active.
|
|
6726
|
+
*/
|
|
6727
|
+
function redactRefreshToken(response, c, refreshToken, config) {
|
|
6728
|
+
if (!config) return response;
|
|
6729
|
+
setRefreshCookie(c, refreshToken, config);
|
|
6730
|
+
return {
|
|
6731
|
+
...response,
|
|
6732
|
+
tokens: {
|
|
6733
|
+
...response.tokens,
|
|
6734
|
+
refreshToken: ""
|
|
6735
|
+
}
|
|
6736
|
+
};
|
|
6737
|
+
}
|
|
6738
|
+
//#endregion
|
|
6739
|
+
//#region src/auth/mfa-routes.ts
|
|
6740
|
+
/**
|
|
6741
|
+
* How many failed verifications one challenge tolerates before it is spent.
|
|
5884
6742
|
*
|
|
5885
|
-
*
|
|
5886
|
-
*
|
|
6743
|
+
* A 6-digit code with a ±1 step window accepts 3 of 1,000,000 values, so the
|
|
6744
|
+
* only thing standing between an attacker and the second factor is how many
|
|
6745
|
+
* guesses a challenge will take. Five, and then they must open another one —
|
|
6746
|
+
* which the per-user limiter below is counting.
|
|
5887
6747
|
*/
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
6748
|
+
var MAX_CHALLENGE_ATTEMPTS = 5;
|
|
6749
|
+
/** Verification attempts one *account* may make per window, across all IPs. */
|
|
6750
|
+
var MFA_VERIFICATION_ATTEMPTS_PER_WINDOW = 10;
|
|
6751
|
+
/**
|
|
6752
|
+
* Resolve the caller of a challenge route from either a full session or a
|
|
6753
|
+
* pre-auth token.
|
|
6754
|
+
*
|
|
6755
|
+
* Both are parsed here rather than by `requireAuth` because the whole point of
|
|
6756
|
+
* the challenge pair is that it runs before the session exists — a login by an
|
|
6757
|
+
* MFA-enrolled user never receives an access token until it is finished.
|
|
6758
|
+
*/
|
|
6759
|
+
function resolveStepUpPrincipal(c) {
|
|
6760
|
+
const existing = c.get("user");
|
|
6761
|
+
if (existing?.uid) return {
|
|
6762
|
+
uid: existing.uid,
|
|
6763
|
+
aal: existing.aal === "aal2" ? "aal2" : "aal1",
|
|
6764
|
+
pending: false
|
|
6765
|
+
};
|
|
6766
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
6767
|
+
if (token === void 0) return null;
|
|
6768
|
+
const session = verifyAccessToken(token);
|
|
6769
|
+
if (session) return {
|
|
6770
|
+
uid: session.uid,
|
|
6771
|
+
aal: session.aal === "aal2" ? "aal2" : "aal1",
|
|
6772
|
+
pending: false
|
|
6773
|
+
};
|
|
6774
|
+
const pending = verifyMfaPendingToken(token);
|
|
6775
|
+
if (pending) return {
|
|
6776
|
+
uid: pending.uid,
|
|
6777
|
+
aal: "aal1",
|
|
6778
|
+
pending: true
|
|
6779
|
+
};
|
|
6780
|
+
return null;
|
|
5899
6781
|
}
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
6782
|
+
/**
|
|
6783
|
+
* Per-account throttle on code verification.
|
|
6784
|
+
*
|
|
6785
|
+
* Keyed on the uid rather than the IP because an IP is the attacker's to
|
|
6786
|
+
* rotate and the account under attack is not: a distributed run against one
|
|
6787
|
+
* account passes an IP-keyed limiter untouched. The IP limiter still runs in
|
|
6788
|
+
* front of this one — they bound different things.
|
|
6789
|
+
*/
|
|
6790
|
+
var mfaVerificationLimiter = createRateLimiter({
|
|
6791
|
+
windowMs: 900 * 1e3,
|
|
6792
|
+
limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
|
|
6793
|
+
keyGenerator: (c) => `mfa-verify:${resolveStepUpPrincipal(c)?.uid ?? "unidentified"}`,
|
|
6794
|
+
message: "Too many verification attempts for this account, please try again later."
|
|
6795
|
+
});
|
|
6796
|
+
function mountMfaRoutes(opts) {
|
|
6797
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
|
|
5903
6798
|
const authRepo = config.authRepo;
|
|
5904
6799
|
const emailConfig = config.emailConfig;
|
|
5905
6800
|
/**
|
|
6801
|
+
* Changing which factors an account trusts is itself a privileged act.
|
|
6802
|
+
*
|
|
6803
|
+
* An account that already has a verified factor may only gain or confirm
|
|
6804
|
+
* another one from a session that presented the existing one. Without this
|
|
6805
|
+
* the `aal2` gate on unenroll was self-service: an `aal1` session enrolled
|
|
6806
|
+
* a factor of its own, verified it with a code it computed from the secret
|
|
6807
|
+
* the enrol response had just handed it, stepped up on that, and deleted
|
|
6808
|
+
* the victim's real factor — arriving at `aal2` without ever holding the
|
|
6809
|
+
* second factor the account was protected by.
|
|
6810
|
+
*
|
|
6811
|
+
* The first enrolment on an account with no verified factor is deliberately
|
|
6812
|
+
* allowed at `aal1`: there is no second factor to demand yet.
|
|
6813
|
+
*/
|
|
6814
|
+
async function requireStepUpForFactorChange(userCtx) {
|
|
6815
|
+
if (userCtx.aal === "aal2") return;
|
|
6816
|
+
if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) return;
|
|
6817
|
+
throw ApiError.forbidden("This account already has a verified second factor. Re-authenticate with it before changing enrolled factors.", "AAL2_REQUIRED");
|
|
6818
|
+
}
|
|
6819
|
+
/**
|
|
6820
|
+
* Spend a TOTP time step, or refuse it as already spent.
|
|
6821
|
+
*
|
|
6822
|
+
* RFC 6238 §5.2: an accepted OTP must not be accepted again. The window
|
|
6823
|
+
* that exists for clock drift is also a 90-second replay window, and a
|
|
6824
|
+
* replay here is not momentary — `challenge/verify` mints a refresh token,
|
|
6825
|
+
* so one observed code buys a durable session.
|
|
6826
|
+
*/
|
|
6827
|
+
async function claimTotpStep(factorId, counter) {
|
|
6828
|
+
if (typeof authRepo.claimMfaFactorCounter !== "function") {
|
|
6829
|
+
logger.warn("[MFA] Auth repository cannot record spent TOTP steps; an accepted code stays replayable for its window", { factorId });
|
|
6830
|
+
return true;
|
|
6831
|
+
}
|
|
6832
|
+
return authRepo.claimMfaFactorCounter(factorId, counter);
|
|
6833
|
+
}
|
|
6834
|
+
/**
|
|
5906
6835
|
* POST /auth/mfa/enroll
|
|
5907
6836
|
* Start MFA enrollment: generate TOTP secret and recovery codes
|
|
5908
6837
|
*/
|
|
5909
|
-
router.post("/mfa/enroll", requireAuth, async (c) => {
|
|
6838
|
+
router.post("/mfa/enroll", strictAuthLimiter, requireAuth, async (c) => {
|
|
5910
6839
|
const userCtx = c.get("user");
|
|
5911
6840
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6841
|
+
await requireStepUpForFactorChange(userCtx);
|
|
5912
6842
|
const body = await c.req.json().catch(() => ({}));
|
|
5913
6843
|
const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
|
|
5914
6844
|
const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
|
|
@@ -5938,17 +6868,20 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
5938
6868
|
* POST /auth/mfa/verify
|
|
5939
6869
|
* Verify TOTP code to complete MFA enrollment
|
|
5940
6870
|
*/
|
|
5941
|
-
router.post("/mfa/verify", requireAuth, async (c) => {
|
|
6871
|
+
router.post("/mfa/verify", strictAuthLimiter, requireAuth, mfaVerificationLimiter, async (c) => {
|
|
5942
6872
|
const userCtx = c.get("user");
|
|
5943
6873
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
5944
|
-
|
|
6874
|
+
await requireStepUpForFactorChange(userCtx);
|
|
6875
|
+
const verifySchema = object({
|
|
5945
6876
|
factorId: string().min(1, "Factor ID is required"),
|
|
5946
6877
|
code: string().length(6, "Code must be 6 digits")
|
|
5947
|
-
})
|
|
6878
|
+
});
|
|
6879
|
+
const { factorId, code } = parseBody(verifySchema, await c.req.json());
|
|
5948
6880
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
5949
6881
|
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
5950
6882
|
if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
|
|
5951
|
-
|
|
6883
|
+
const matchedCounter = verifyTotpCounter(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
|
|
6884
|
+
if (matchedCounter === null || !await claimTotpStep(factor.id, matchedCounter)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
|
|
5952
6885
|
await authRepo.verifyMfaFactor(factorId);
|
|
5953
6886
|
return c.json({
|
|
5954
6887
|
success: true,
|
|
@@ -5957,14 +6890,16 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
5957
6890
|
});
|
|
5958
6891
|
/**
|
|
5959
6892
|
* POST /auth/mfa/challenge
|
|
5960
|
-
*
|
|
6893
|
+
* Open a challenge — either to finish a sign-in that answered
|
|
6894
|
+
* `MFA_REQUIRED`, or to step an existing session up to `aal2`.
|
|
5961
6895
|
*/
|
|
5962
|
-
router.post("/mfa/challenge",
|
|
5963
|
-
const
|
|
5964
|
-
if (!
|
|
5965
|
-
const
|
|
6896
|
+
router.post("/mfa/challenge", strictAuthLimiter, async (c) => {
|
|
6897
|
+
const principal = resolveStepUpPrincipal(c);
|
|
6898
|
+
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
6899
|
+
const challengeSchema = object({ factorId: string().min(1, "Factor ID is required") });
|
|
6900
|
+
const { factorId } = parseBody(challengeSchema, await c.req.json());
|
|
5966
6901
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
5967
|
-
if (!factor || factor.uid !==
|
|
6902
|
+
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
5968
6903
|
if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
|
|
5969
6904
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
5970
6905
|
const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
|
|
@@ -5976,49 +6911,73 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
5976
6911
|
});
|
|
5977
6912
|
/**
|
|
5978
6913
|
* POST /auth/mfa/challenge/verify
|
|
5979
|
-
*
|
|
6914
|
+
* Answer a challenge: this is where an MFA-enrolled account's session is
|
|
6915
|
+
* minted, at `aal2`.
|
|
5980
6916
|
*/
|
|
5981
|
-
router.post("/mfa/challenge/verify",
|
|
5982
|
-
const
|
|
5983
|
-
if (!
|
|
5984
|
-
const
|
|
6917
|
+
router.post("/mfa/challenge/verify", strictAuthLimiter, mfaVerificationLimiter, async (c) => {
|
|
6918
|
+
const principal = resolveStepUpPrincipal(c);
|
|
6919
|
+
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
6920
|
+
const challengeVerifySchema = object({
|
|
5985
6921
|
challengeId: string().min(1, "Challenge ID is required"),
|
|
5986
6922
|
code: string().min(1, "Code is required")
|
|
5987
|
-
})
|
|
6923
|
+
});
|
|
6924
|
+
const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
|
|
5988
6925
|
const challenge = await authRepo.getMfaChallengeById(challengeId);
|
|
5989
6926
|
if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
|
|
5990
6927
|
const factor = await authRepo.getMfaFactorById(challenge.factorId);
|
|
5991
|
-
if (!factor || factor.uid !==
|
|
5992
|
-
|
|
5993
|
-
|
|
6928
|
+
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
6929
|
+
if ((challenge.attempts ?? 0) >= MAX_CHALLENGE_ATTEMPTS) {
|
|
6930
|
+
logger.warn("[Security Audit] MFA challenge attempt limit reached", {
|
|
6931
|
+
eventType: "auth.mfa.challenge.exhausted",
|
|
6932
|
+
uid: principal.uid,
|
|
6933
|
+
challengeId
|
|
6934
|
+
});
|
|
6935
|
+
throw ApiError.unauthorized("Too many failed attempts for this challenge", "CHALLENGE_EXHAUSTED");
|
|
6936
|
+
}
|
|
6937
|
+
const matchedCounter = verifyTotpCounter(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
|
|
6938
|
+
let isValid = matchedCounter !== null && await claimTotpStep(factor.id, matchedCounter);
|
|
6939
|
+
if (!isValid && matchedCounter === null) {
|
|
5994
6940
|
const codeHash = hashRecoveryCode(code);
|
|
5995
|
-
isValid = await authRepo.useRecoveryCode(
|
|
6941
|
+
isValid = await authRepo.useRecoveryCode(principal.uid, codeHash);
|
|
6942
|
+
}
|
|
6943
|
+
if (!isValid) {
|
|
6944
|
+
const attempts = typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
|
|
6945
|
+
logger.warn("[Security Audit] MFA verification failed", {
|
|
6946
|
+
eventType: "auth.mfa.verify.failure",
|
|
6947
|
+
uid: principal.uid,
|
|
6948
|
+
challengeId,
|
|
6949
|
+
attempts
|
|
6950
|
+
});
|
|
6951
|
+
throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
5996
6952
|
}
|
|
5997
|
-
if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
5998
6953
|
await authRepo.verifyMfaChallenge(challengeId);
|
|
5999
|
-
const roleIds =
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
|
|
6003
|
-
id: randomUUID$1(),
|
|
6004
|
-
startedAt: /* @__PURE__ */ new Date()
|
|
6954
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
|
|
6955
|
+
skipMfaGate: true,
|
|
6956
|
+
aal: "aal2"
|
|
6005
6957
|
});
|
|
6006
|
-
if (ops.onMfaVerified) ops.onMfaVerified(
|
|
6958
|
+
if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
|
|
6007
6959
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
6008
6960
|
});
|
|
6009
|
-
|
|
6961
|
+
logger.info("[Security Audit] MFA verification success", {
|
|
6962
|
+
eventType: "auth.mfa.verify.success",
|
|
6963
|
+
uid: principal.uid,
|
|
6964
|
+
factorId: factor.id,
|
|
6965
|
+
completedSignIn: principal.pending
|
|
6966
|
+
});
|
|
6967
|
+
const user = await authRepo.getUserById(principal.uid).catch(() => null);
|
|
6968
|
+
let mfaResponse = user ? buildAuthResponse(user, roleIds, accessToken, refreshToken, "mfa") : { tokens: {
|
|
6010
6969
|
accessToken,
|
|
6011
6970
|
refreshToken,
|
|
6012
6971
|
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
6013
6972
|
} };
|
|
6014
|
-
if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw,
|
|
6015
|
-
return c.json(mfaResponse);
|
|
6973
|
+
if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, principal.uid);
|
|
6974
|
+
return c.json(redactRefreshToken(mfaResponse, c, refreshToken, config.cookieAuth));
|
|
6016
6975
|
});
|
|
6017
6976
|
/**
|
|
6018
6977
|
* GET /auth/mfa/factors
|
|
6019
6978
|
* List enrolled MFA factors for the current user
|
|
6020
6979
|
*/
|
|
6021
|
-
router.get("/mfa/factors", requireAuth, async (c) => {
|
|
6980
|
+
router.get("/mfa/factors", strictAuthLimiter, requireAuth, async (c) => {
|
|
6022
6981
|
const userCtx = c.get("user");
|
|
6023
6982
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6024
6983
|
const factors = await authRepo.getMfaFactors(userCtx.uid);
|
|
@@ -6034,11 +6993,12 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6034
6993
|
* DELETE /auth/mfa/unenroll
|
|
6035
6994
|
* Remove an MFA factor
|
|
6036
6995
|
*/
|
|
6037
|
-
router.delete("/mfa/unenroll", requireAuth, async (c) => {
|
|
6996
|
+
router.delete("/mfa/unenroll", strictAuthLimiter, requireAuth, async (c) => {
|
|
6038
6997
|
const userCtx = c.get("user");
|
|
6039
6998
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6040
6999
|
if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
|
|
6041
|
-
const
|
|
7000
|
+
const unenrollSchema = object({ factorId: string().min(1, "Factor ID is required") });
|
|
7001
|
+
const { factorId } = parseBody(unenrollSchema, await c.req.json());
|
|
6042
7002
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
6043
7003
|
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
6044
7004
|
await authRepo.deleteMfaFactor(factorId, userCtx.uid);
|
|
@@ -6050,72 +7010,51 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6050
7010
|
});
|
|
6051
7011
|
}
|
|
6052
7012
|
//#endregion
|
|
6053
|
-
//#region src/auth/
|
|
6054
|
-
/**
|
|
6055
|
-
* Cookie auth helpers for httpOnly refresh tokens.
|
|
6056
|
-
*/
|
|
6057
|
-
function getCookieSettings(config) {
|
|
6058
|
-
return {
|
|
6059
|
-
name: config?.cookieName || "__rb_refresh",
|
|
6060
|
-
path: config?.path || "/",
|
|
6061
|
-
sameSite: config?.sameSite || "Lax",
|
|
6062
|
-
domain: config?.domain,
|
|
6063
|
-
secure: config?.secure
|
|
6064
|
-
};
|
|
6065
|
-
}
|
|
6066
|
-
/**
|
|
6067
|
-
* Set the refresh token as an httpOnly cookie on the response.
|
|
6068
|
-
*/
|
|
6069
|
-
function setRefreshCookie(c, refreshToken, config) {
|
|
6070
|
-
if (!config) return;
|
|
6071
|
-
const settings = getCookieSettings(config);
|
|
6072
|
-
const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
|
|
6073
|
-
let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
|
|
6074
|
-
if (isSecure) cookie += "; Secure";
|
|
6075
|
-
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6076
|
-
const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
|
|
6077
|
-
cookie += `; Max-Age=${maxAgeSeconds}`;
|
|
6078
|
-
c.header("Set-Cookie", cookie, { append: true });
|
|
6079
|
-
}
|
|
7013
|
+
//#region src/auth/mfa-gate.ts
|
|
6080
7014
|
/**
|
|
6081
|
-
*
|
|
7015
|
+
* The gate between "first factor accepted" and "session issued".
|
|
7016
|
+
*
|
|
7017
|
+
* MFA used to be modelled as an optional step-up that no resource required: a
|
|
7018
|
+
* user could enrol TOTP, print recovery codes, and change nothing about what a
|
|
7019
|
+
* stolen password bought, because every session-minting route issued a full
|
|
7020
|
+
* `aal1` token without ever asking whether the account had a second factor.
|
|
7021
|
+
* This module is the ask. It lives apart from the routes so that there is one
|
|
7022
|
+
* decision, made once, that `createSessionAndTokens` cannot be extended past —
|
|
7023
|
+
* a new sign-in route inherits the gate by construction rather than by the
|
|
7024
|
+
* author remembering it.
|
|
7025
|
+
*
|
|
7026
|
+
* @module
|
|
6082
7027
|
*/
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
const settings = getCookieSettings(config);
|
|
6086
|
-
let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
|
|
6087
|
-
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6088
|
-
c.header("Set-Cookie", cookie, { append: true });
|
|
6089
|
-
}
|
|
7028
|
+
/** Error code a client watches for to switch a sign-in into its second step. */
|
|
7029
|
+
var MFA_REQUIRED_CODE = "MFA_REQUIRED";
|
|
6090
7030
|
/**
|
|
6091
|
-
*
|
|
7031
|
+
* Refuse to mint a session for an account whose second factor has not been
|
|
7032
|
+
* presented, handing back what the client needs to present it.
|
|
7033
|
+
*
|
|
7034
|
+
* A 401 rather than a 200 with a different body: a client that has not been
|
|
7035
|
+
* taught about MFA must fail, not proceed. The credential in `details` is
|
|
7036
|
+
* useless for anything except the challenge routes.
|
|
6092
7037
|
*/
|
|
6093
|
-
function
|
|
6094
|
-
if (
|
|
6095
|
-
|
|
6096
|
-
|
|
6097
|
-
const prefix = `${settings.name}=`;
|
|
6098
|
-
const cookies = cookieHeader.split(";");
|
|
6099
|
-
for (const part of cookies) {
|
|
6100
|
-
const trimmed = part.trim();
|
|
6101
|
-
if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
|
|
6102
|
-
}
|
|
7038
|
+
async function assertMfaSatisfied(authRepo, uid) {
|
|
7039
|
+
if (typeof authRepo.hasVerifiedMfaFactors !== "function") {
|
|
7040
|
+
logger.warn("[MFA] Auth repository cannot report enrolled factors; the login step-up gate is inert on this backend");
|
|
7041
|
+
return;
|
|
6103
7042
|
}
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
...response,
|
|
6114
|
-
tokens: {
|
|
6115
|
-
...response.tokens,
|
|
6116
|
-
refreshToken: ""
|
|
6117
|
-
}
|
|
7043
|
+
if (!await authRepo.hasVerifiedMfaFactors(uid)) return;
|
|
7044
|
+
const factors = (await authRepo.getMfaFactors(uid)).filter((f) => f.verified);
|
|
7045
|
+
const details = {
|
|
7046
|
+
mfaToken: generateMfaPendingToken(uid),
|
|
7047
|
+
factors: factors.map((f) => ({
|
|
7048
|
+
id: f.id,
|
|
7049
|
+
factorType: f.factorType,
|
|
7050
|
+
friendlyName: f.friendlyName
|
|
7051
|
+
}))
|
|
6118
7052
|
};
|
|
7053
|
+
logger.info("[Security Audit] MFA step-up required", {
|
|
7054
|
+
eventType: "auth.mfa.required",
|
|
7055
|
+
uid
|
|
7056
|
+
});
|
|
7057
|
+
throw new ApiError(401, MFA_REQUIRED_CODE, "Multi-factor authentication is required to complete sign-in.", details, true);
|
|
6119
7058
|
}
|
|
6120
7059
|
//#endregion
|
|
6121
7060
|
//#region src/auth/registration-policy.ts
|
|
@@ -6168,7 +7107,7 @@ function mountSessionRoutes(opts) {
|
|
|
6168
7107
|
clearRefreshCookie(c, config.cookieAuth);
|
|
6169
7108
|
const accessToken = extractBearerToken(c.req.header("authorization"));
|
|
6170
7109
|
if (ops.afterLogout && accessToken !== void 0) {
|
|
6171
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
7110
|
+
const { verifyAccessToken } = await import("./jwt-Cuq6MwNy.js").then((n) => n.f);
|
|
6172
7111
|
const payload = verifyAccessToken(accessToken);
|
|
6173
7112
|
if (payload) ops.afterLogout(payload.uid).catch((err) => {
|
|
6174
7113
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
@@ -6422,7 +7361,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
6422
7361
|
const tokenHash = hashToken(token);
|
|
6423
7362
|
const expiresAt = getMagicLinkExpiry();
|
|
6424
7363
|
await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
|
|
6425
|
-
const magicLinkUrl = `${emailConfig
|
|
7364
|
+
const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
|
|
6426
7365
|
const appName = emailConfig?.appName || "Rebase";
|
|
6427
7366
|
const templateFn = emailConfig?.templates?.magicLink;
|
|
6428
7367
|
const emailContent = templateFn ? templateFn(magicLinkUrl, {
|
|
@@ -6474,6 +7413,64 @@ function mountMagicLinkRoutes(deps) {
|
|
|
6474
7413
|
});
|
|
6475
7414
|
}
|
|
6476
7415
|
//#endregion
|
|
7416
|
+
//#region src/auth/oauth-signin-policy.ts
|
|
7417
|
+
/**
|
|
7418
|
+
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
7419
|
+
*
|
|
7420
|
+
* Both sides have to be trustworthy, and only one of them used to be checked:
|
|
7421
|
+
*
|
|
7422
|
+
* - the **provider** must have verified the address, or the caller has not
|
|
7423
|
+
* shown they control it;
|
|
7424
|
+
* - the **local account** must itself be trustworthy — either its address was
|
|
7425
|
+
* verified, or it has no password at all (it was created by an OAuth
|
|
7426
|
+
* sign-in or an invitation, so there is no credential an attacker could
|
|
7427
|
+
* have planted in advance).
|
|
7428
|
+
*
|
|
7429
|
+
* A refusal is not a dead end: `POST /auth/link/<provider>` attaches the
|
|
7430
|
+
* identity once the caller proves ownership by holding a session.
|
|
7431
|
+
*/
|
|
7432
|
+
function decideOAuthAutoLink(args) {
|
|
7433
|
+
if (args.providerEmailVerified !== true) return {
|
|
7434
|
+
allowed: false,
|
|
7435
|
+
reason: "provider-email-unverified"
|
|
7436
|
+
};
|
|
7437
|
+
if (Boolean(args.existingUser.passwordHash) && !args.existingUser.emailVerified) return {
|
|
7438
|
+
allowed: false,
|
|
7439
|
+
reason: "local-account-unverified"
|
|
7440
|
+
};
|
|
7441
|
+
return { allowed: true };
|
|
7442
|
+
}
|
|
7443
|
+
/**
|
|
7444
|
+
* Is `redirectUri` one the operator authorised?
|
|
7445
|
+
*
|
|
7446
|
+
* The provider's own registered-URI match is a real control but a coarse one:
|
|
7447
|
+
* it authorises *every* URI registered on that OAuth client, so a `localhost`
|
|
7448
|
+
* entry kept for development, or a second product sharing the client id, can
|
|
7449
|
+
* mint codes this backend accepts. An empty or absent allowlist keeps the old
|
|
7450
|
+
* behaviour (the provider's check is the only one) so existing deployments are
|
|
7451
|
+
* unaffected; setting one narrows it to the origins this backend serves.
|
|
7452
|
+
*
|
|
7453
|
+
* Comparison is on origin plus path, with the origin lowercased and a trailing
|
|
7454
|
+
* slash ignored — query strings and fragments are not part of the identity of a
|
|
7455
|
+
* redirect URI, and neither is the case of the host.
|
|
7456
|
+
*/
|
|
7457
|
+
function isRedirectUriAllowed(redirectUri, allowlist) {
|
|
7458
|
+
if (!allowlist || allowlist.length === 0) return true;
|
|
7459
|
+
const candidate = canonicalRedirectUri(redirectUri);
|
|
7460
|
+
if (!candidate) return false;
|
|
7461
|
+
return allowlist.some((allowed) => canonicalRedirectUri(allowed) === candidate);
|
|
7462
|
+
}
|
|
7463
|
+
function canonicalRedirectUri(uri) {
|
|
7464
|
+
let parsed;
|
|
7465
|
+
try {
|
|
7466
|
+
parsed = new URL(uri);
|
|
7467
|
+
} catch {
|
|
7468
|
+
return null;
|
|
7469
|
+
}
|
|
7470
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
7471
|
+
return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${path}`;
|
|
7472
|
+
}
|
|
7473
|
+
//#endregion
|
|
6477
7474
|
//#region src/auth/routes.ts
|
|
6478
7475
|
/**
|
|
6479
7476
|
* Helper to build standard auth response output
|
|
@@ -6549,11 +7546,6 @@ function createAuthRoutes(config) {
|
|
|
6549
7546
|
newPassword: string().min(1, "New password is required").max(128)
|
|
6550
7547
|
});
|
|
6551
7548
|
const refreshSchema = object({ refreshToken: string().min(1).optional() });
|
|
6552
|
-
object({ refreshToken: string().optional() });
|
|
6553
|
-
object({
|
|
6554
|
-
displayName: string().max(255).optional(),
|
|
6555
|
-
photoURL: string().url().max(2048).optional()
|
|
6556
|
-
});
|
|
6557
7549
|
/** Parse a Zod schema against the request body, throwing ApiError on failure */
|
|
6558
7550
|
function parseBody(schema, body) {
|
|
6559
7551
|
const result = schema.safeParse(body);
|
|
@@ -6593,7 +7585,7 @@ function createAuthRoutes(config) {
|
|
|
6593
7585
|
function sendWelcomeEmail(user) {
|
|
6594
7586
|
if (!isEmailConfigured()) return;
|
|
6595
7587
|
const appName = emailConfig?.appName || "Rebase";
|
|
6596
|
-
const loginUrl = emailConfig
|
|
7588
|
+
const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
|
|
6597
7589
|
const templateFn = emailConfig?.templates?.welcomeEmail;
|
|
6598
7590
|
const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0);
|
|
6599
7591
|
emailService.send({
|
|
@@ -6606,9 +7598,21 @@ function createAuthRoutes(config) {
|
|
|
6606
7598
|
});
|
|
6607
7599
|
}
|
|
6608
7600
|
/**
|
|
6609
|
-
* Helper to generate and store session tokens
|
|
7601
|
+
* Helper to generate and store session tokens.
|
|
7602
|
+
*
|
|
7603
|
+
* Every route that signs somebody in comes through here — password login,
|
|
7604
|
+
* register, each OAuth provider, magic link, anonymous and anonymous-link —
|
|
7605
|
+
* which is why the second-factor gate is *inside* it rather than repeated at
|
|
7606
|
+
* each call site. An account with a verified factor gets no session from
|
|
7607
|
+
* this function at all: it throws `MFA_REQUIRED`, and the only thing that
|
|
7608
|
+
* mints a session for that user is `POST /auth/mfa/challenge/verify`.
|
|
7609
|
+
*
|
|
7610
|
+
* `skipMfaGate` exists for exactly one caller: the challenge-verify route
|
|
7611
|
+
* itself, which has just seen the second factor and mints at `aal2`.
|
|
6610
7612
|
*/
|
|
6611
|
-
async function createSessionAndTokens(uid, userAgent, ipAddress) {
|
|
7613
|
+
async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
|
|
7614
|
+
if (!options?.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
|
|
7615
|
+
const aal = options?.aal ?? "aal1";
|
|
6612
7616
|
const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
|
|
6613
7617
|
let customClaims;
|
|
6614
7618
|
if (ops.customizeAccessToken) {
|
|
@@ -6617,16 +7621,17 @@ function createAuthRoutes(config) {
|
|
|
6617
7621
|
const defaultClaims = {
|
|
6618
7622
|
uid,
|
|
6619
7623
|
roles: roleIds,
|
|
6620
|
-
aal
|
|
7624
|
+
aal
|
|
6621
7625
|
};
|
|
6622
7626
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
6623
7627
|
}
|
|
6624
7628
|
}
|
|
6625
|
-
const accessToken = generateAccessToken(uid, roleIds,
|
|
7629
|
+
const accessToken = generateAccessToken(uid, roleIds, aal, customClaims);
|
|
6626
7630
|
const refreshToken = generateRefreshToken();
|
|
6627
7631
|
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
6628
7632
|
id: randomUUID$1(),
|
|
6629
|
-
startedAt: /* @__PURE__ */ new Date()
|
|
7633
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
7634
|
+
aal
|
|
6630
7635
|
});
|
|
6631
7636
|
return {
|
|
6632
7637
|
roleIds,
|
|
@@ -6724,29 +7729,46 @@ function createAuthRoutes(config) {
|
|
|
6724
7729
|
if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
|
|
6725
7730
|
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
6726
7731
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
7732
|
+
const requestedRedirectUri = payload.redirectUri;
|
|
7733
|
+
if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
|
|
6727
7734
|
let externalUser;
|
|
6728
7735
|
try {
|
|
6729
7736
|
externalUser = await provider.verify(payload);
|
|
6730
7737
|
} catch (err) {
|
|
6731
|
-
|
|
6732
|
-
throw ApiError.unauthorized(
|
|
7738
|
+
logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
|
|
7739
|
+
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
|
|
6733
7740
|
}
|
|
6734
7741
|
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
6735
7742
|
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
6736
7743
|
if (!user) {
|
|
6737
7744
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
6738
7745
|
if (user) {
|
|
6739
|
-
|
|
7746
|
+
const decision = decideOAuthAutoLink({
|
|
7747
|
+
providerEmailVerified: externalUser.emailVerified,
|
|
7748
|
+
existingUser: user
|
|
7749
|
+
});
|
|
7750
|
+
if (!decision.allowed) {
|
|
7751
|
+
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.";
|
|
7752
|
+
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");
|
|
7753
|
+
}
|
|
6740
7754
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
6741
7755
|
await authRepo.updateUser(user.id, {
|
|
6742
7756
|
displayName: user.displayName || externalUser.displayName || void 0,
|
|
6743
7757
|
photoUrl: user.photoUrl || externalUser.photoUrl || void 0
|
|
6744
7758
|
});
|
|
6745
7759
|
} else {
|
|
7760
|
+
if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
7761
|
+
let bootstrapRegistration = false;
|
|
7762
|
+
if (!isRegistrationAllowed()) {
|
|
7763
|
+
const { total } = await authRepo.listUsersPaginated({ limit: 1 });
|
|
7764
|
+
bootstrapRegistration = total === 0;
|
|
7765
|
+
if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
7766
|
+
}
|
|
6746
7767
|
user = await authRepo.createUser({
|
|
6747
7768
|
email: normalizeEmail(externalUser.email),
|
|
6748
7769
|
displayName: externalUser.displayName || void 0,
|
|
6749
|
-
photoUrl: externalUser.photoUrl || void 0
|
|
7770
|
+
photoUrl: externalUser.photoUrl || void 0,
|
|
7771
|
+
emailVerified: externalUser.emailVerified === true
|
|
6750
7772
|
});
|
|
6751
7773
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
6752
7774
|
if (ops.afterUserCreate) try {
|
|
@@ -6755,7 +7777,12 @@ function createAuthRoutes(config) {
|
|
|
6755
7777
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
6756
7778
|
}
|
|
6757
7779
|
const allUsers = await authRepo.listUsers();
|
|
6758
|
-
|
|
7780
|
+
const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
|
|
7781
|
+
if (bootstrapRegistration && !isFirstUser) {
|
|
7782
|
+
await authRepo.deleteUser(user.id);
|
|
7783
|
+
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
7784
|
+
}
|
|
7785
|
+
if (isFirstUser) await authRepo.setUserRoles(user.id, ["admin"]);
|
|
6759
7786
|
else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
6760
7787
|
sendWelcomeEmail({
|
|
6761
7788
|
email: user.email,
|
|
@@ -6829,7 +7856,7 @@ function createAuthRoutes(config) {
|
|
|
6829
7856
|
const tokenHash = hashToken(token);
|
|
6830
7857
|
const expiresAt = getPasswordResetExpiry();
|
|
6831
7858
|
await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
|
6832
|
-
const resetUrl = `${emailConfig
|
|
7859
|
+
const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
6833
7860
|
const appName = emailConfig?.appName || "Rebase";
|
|
6834
7861
|
const templateFn = emailConfig?.templates?.passwordReset;
|
|
6835
7862
|
const emailContent = templateFn ? templateFn(resetUrl, {
|
|
@@ -6904,8 +7931,15 @@ function createAuthRoutes(config) {
|
|
|
6904
7931
|
/**
|
|
6905
7932
|
* POST /auth/send-verification
|
|
6906
7933
|
* Send email verification link (authenticated)
|
|
7934
|
+
*
|
|
7935
|
+
* Two limiters, because they bound different things: `strictAuthLimiter`
|
|
7936
|
+
* bounds the caller by IP as it does on every other email-sending route, and
|
|
7937
|
+
* `verificationEmailLimiter` bounds how much mail one account — i.e. one
|
|
7938
|
+
* recipient address — can be made to receive. Authentication is not a bound
|
|
7939
|
+
* here: the address is unverified by construction, so an attacker can hold a
|
|
7940
|
+
* session for a mailbox that is not theirs.
|
|
6907
7941
|
*/
|
|
6908
|
-
router.post("/send-verification", requireAuth, async (c) => {
|
|
7942
|
+
router.post("/send-verification", strictAuthLimiter, requireAuth, verificationEmailLimiter, async (c) => {
|
|
6909
7943
|
const userCtx = c.get("user");
|
|
6910
7944
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6911
7945
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
|
|
@@ -6914,7 +7948,7 @@ function createAuthRoutes(config) {
|
|
|
6914
7948
|
if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
6915
7949
|
const token = generateSecureToken();
|
|
6916
7950
|
await authRepo.setVerificationToken(user.id, hashToken(token));
|
|
6917
|
-
const verifyUrl = `${emailConfig
|
|
7951
|
+
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
6918
7952
|
const appName = emailConfig?.appName || "Rebase";
|
|
6919
7953
|
const templateFn = emailConfig?.templates?.emailVerification;
|
|
6920
7954
|
const emailContent = templateFn ? templateFn(verifyUrl, {
|
|
@@ -6996,22 +8030,24 @@ function createAuthRoutes(config) {
|
|
|
6996
8030
|
});
|
|
6997
8031
|
return null;
|
|
6998
8032
|
});
|
|
8033
|
+
const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
|
|
6999
8034
|
let customClaims;
|
|
7000
8035
|
if (ops.customizeAccessToken && user) {
|
|
7001
8036
|
const defaultClaims = {
|
|
7002
8037
|
uid: storedToken.uid,
|
|
7003
8038
|
roles: roleIds,
|
|
7004
|
-
aal:
|
|
8039
|
+
aal: sessionAal
|
|
7005
8040
|
};
|
|
7006
8041
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
7007
8042
|
}
|
|
7008
|
-
const newAccessToken = generateAccessToken(storedToken.uid, roleIds,
|
|
8043
|
+
const newAccessToken = generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims);
|
|
7009
8044
|
const newRefreshToken = generateRefreshToken();
|
|
7010
8045
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
7011
8046
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
7012
8047
|
const session = {
|
|
7013
8048
|
id: storedToken.sessionId ?? storedToken.id,
|
|
7014
|
-
startedAt: sessionStartedAt
|
|
8049
|
+
startedAt: sessionStartedAt,
|
|
8050
|
+
aal: sessionAal
|
|
7015
8051
|
};
|
|
7016
8052
|
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
7017
8053
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
@@ -7043,7 +8079,15 @@ function createAuthRoutes(config) {
|
|
|
7043
8079
|
createSessionAndTokens,
|
|
7044
8080
|
applyTransformHook
|
|
7045
8081
|
});
|
|
7046
|
-
mountMfaRoutes(
|
|
8082
|
+
mountMfaRoutes({
|
|
8083
|
+
router,
|
|
8084
|
+
config,
|
|
8085
|
+
ops,
|
|
8086
|
+
parseBody,
|
|
8087
|
+
buildAuthResponse,
|
|
8088
|
+
createSessionAndTokens,
|
|
8089
|
+
applyTransformHook
|
|
8090
|
+
});
|
|
7047
8091
|
if (config.enableMagicLink) mountMagicLinkRoutes({
|
|
7048
8092
|
router,
|
|
7049
8093
|
config,
|
|
@@ -7287,7 +8331,7 @@ function createAdminUsersRoute(config) {
|
|
|
7287
8331
|
const search = c.req.query("search");
|
|
7288
8332
|
const orderBy = c.req.query("orderBy");
|
|
7289
8333
|
const orderDir = c.req.query("orderDir");
|
|
7290
|
-
const limit =
|
|
8334
|
+
const limit = resolveListLimitParam(limitParam, { defaultLimit: 25 });
|
|
7291
8335
|
const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
|
|
7292
8336
|
const result = await authRepo.listUsersPaginated({
|
|
7293
8337
|
limit,
|
|
@@ -7405,7 +8449,7 @@ function createAdminUsersRoute(config) {
|
|
|
7405
8449
|
* when the user passes a plain `RebaseAuthConfig` object.
|
|
7406
8450
|
*/
|
|
7407
8451
|
function createBuiltinAuthAdapter(config) {
|
|
7408
|
-
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
|
|
8452
|
+
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
|
|
7409
8453
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
7410
8454
|
return {
|
|
7411
8455
|
id: "rebase-builtin",
|
|
@@ -7481,6 +8525,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7481
8525
|
allowUserLookup,
|
|
7482
8526
|
defaultRole,
|
|
7483
8527
|
oauthProviders,
|
|
8528
|
+
allowedRedirectUris,
|
|
7484
8529
|
authHooks,
|
|
7485
8530
|
enableMagicLink,
|
|
7486
8531
|
cookieAuth
|
|
@@ -7647,6 +8692,54 @@ function toAuthUserData(user) {
|
|
|
7647
8692
|
};
|
|
7648
8693
|
}
|
|
7649
8694
|
//#endregion
|
|
8695
|
+
//#region src/auth/oauth-code-flow.ts
|
|
8696
|
+
/**
|
|
8697
|
+
* Build the request schema for an authorization-code provider.
|
|
8698
|
+
*
|
|
8699
|
+
* `pkce` defaults to `"optional"`: the client decides whether it sent a
|
|
8700
|
+
* `code_challenge`, and providers that do not implement PKCE simply never see
|
|
8701
|
+
* a verifier because their clients never generate one. `"required"` is for
|
|
8702
|
+
* providers that mandate PKCE (Twitter/X).
|
|
8703
|
+
*/
|
|
8704
|
+
function oauthCodeFlowSchema(opts = {}) {
|
|
8705
|
+
const verifier = opts.pkce === "required" ? string().min(1, "PKCE code verifier is required") : string().min(1).optional();
|
|
8706
|
+
return object({
|
|
8707
|
+
code: string().min(1, "Auth code is required"),
|
|
8708
|
+
redirectUri: string().url("Valid redirect URI is required"),
|
|
8709
|
+
codeVerifier: verifier
|
|
8710
|
+
});
|
|
8711
|
+
}
|
|
8712
|
+
/**
|
|
8713
|
+
* The `code_verifier` token-endpoint parameter, or nothing.
|
|
8714
|
+
*
|
|
8715
|
+
* Returned as a spreadable object so a provider adds PKCE with one `...` in
|
|
8716
|
+
* its existing body literal, rather than branching.
|
|
8717
|
+
*/
|
|
8718
|
+
function pkceTokenParams(codeVerifier) {
|
|
8719
|
+
return codeVerifier ? { code_verifier: codeVerifier } : {};
|
|
8720
|
+
}
|
|
8721
|
+
/**
|
|
8722
|
+
* Normalise a provider's email-verification signal into the boolean that
|
|
8723
|
+
* `OAuthProviderProfile.emailVerified` promises.
|
|
8724
|
+
*
|
|
8725
|
+
* The whole point is the default: anything that is not an affirmative
|
|
8726
|
+
* verification signal — `undefined`, `null`, a missing field, an empty string,
|
|
8727
|
+
* `false` — becomes `false`. A provider that reports nothing therefore cannot
|
|
8728
|
+
* accidentally assert that it verified the address, which is exactly the
|
|
8729
|
+
* mistake five providers had made.
|
|
8730
|
+
*
|
|
8731
|
+
* Accepts the string forms because OIDC issuers are inconsistent about whether
|
|
8732
|
+
* `email_verified` is a JSON boolean or a string.
|
|
8733
|
+
*/
|
|
8734
|
+
function providerVerifiedEmail(signal) {
|
|
8735
|
+
if (signal === true) return true;
|
|
8736
|
+
if (typeof signal === "string") {
|
|
8737
|
+
const normalized = signal.trim().toLowerCase();
|
|
8738
|
+
return normalized === "true" || normalized === "1";
|
|
8739
|
+
}
|
|
8740
|
+
return false;
|
|
8741
|
+
}
|
|
8742
|
+
//#endregion
|
|
7650
8743
|
//#region src/auth/google-oauth.ts
|
|
7651
8744
|
var _googleAuth;
|
|
7652
8745
|
async function loadGoogleAuth() {
|
|
@@ -7679,7 +8772,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
|
|
|
7679
8772
|
const tokenInfo = await tokenInfoRes.json();
|
|
7680
8773
|
if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
|
|
7681
8774
|
if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
|
|
7682
|
-
const emailVerified = tokenInfo.email_verified
|
|
8775
|
+
const emailVerified = providerVerifiedEmail(tokenInfo.email_verified);
|
|
7683
8776
|
let displayName = null;
|
|
7684
8777
|
let photoUrl = null;
|
|
7685
8778
|
try {
|
|
@@ -7734,7 +8827,8 @@ function createGoogleProvider(config) {
|
|
|
7734
8827
|
idToken: string().min(1).optional(),
|
|
7735
8828
|
accessToken: string().min(1).optional(),
|
|
7736
8829
|
code: string().min(1).optional(),
|
|
7737
|
-
redirectUri: string().min(1).optional()
|
|
8830
|
+
redirectUri: string().min(1).optional(),
|
|
8831
|
+
codeVerifier: string().min(1).optional()
|
|
7738
8832
|
}).refine((data) => data.idToken || data.accessToken || data.code && data.redirectUri, { message: "One of idToken, accessToken, or code+redirectUri is required" }),
|
|
7739
8833
|
verify: async (payload) => {
|
|
7740
8834
|
try {
|
|
@@ -7749,7 +8843,7 @@ function createGoogleProvider(config) {
|
|
|
7749
8843
|
email: content.email || "",
|
|
7750
8844
|
displayName: content.name || null,
|
|
7751
8845
|
photoUrl: content.picture || null,
|
|
7752
|
-
emailVerified: content.email_verified
|
|
8846
|
+
emailVerified: providerVerifiedEmail(content.email_verified)
|
|
7753
8847
|
};
|
|
7754
8848
|
}
|
|
7755
8849
|
if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
|
|
@@ -7763,7 +8857,8 @@ function createGoogleProvider(config) {
|
|
|
7763
8857
|
client_id: clientId,
|
|
7764
8858
|
client_secret: clientSecret,
|
|
7765
8859
|
redirect_uri: payload.redirectUri,
|
|
7766
|
-
grant_type: "authorization_code"
|
|
8860
|
+
grant_type: "authorization_code",
|
|
8861
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7767
8862
|
})
|
|
7768
8863
|
});
|
|
7769
8864
|
if (!tokenResponse.ok) {
|
|
@@ -7783,7 +8878,7 @@ function createGoogleProvider(config) {
|
|
|
7783
8878
|
email: content.email || "",
|
|
7784
8879
|
displayName: content.name || null,
|
|
7785
8880
|
photoUrl: content.picture || null,
|
|
7786
|
-
emailVerified: content.email_verified
|
|
8881
|
+
emailVerified: providerVerifiedEmail(content.email_verified)
|
|
7787
8882
|
};
|
|
7788
8883
|
}
|
|
7789
8884
|
if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
|
|
@@ -7805,10 +8900,7 @@ function createGoogleProvider(config) {
|
|
|
7805
8900
|
function createLinkedinProvider(config) {
|
|
7806
8901
|
return {
|
|
7807
8902
|
id: "linkedin",
|
|
7808
|
-
schema:
|
|
7809
|
-
code: string().min(1, "Auth code is required"),
|
|
7810
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
7811
|
-
}),
|
|
8903
|
+
schema: oauthCodeFlowSchema(),
|
|
7812
8904
|
verify: async (payload) => {
|
|
7813
8905
|
try {
|
|
7814
8906
|
const tokenResponse = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
|
|
@@ -7819,7 +8911,8 @@ function createLinkedinProvider(config) {
|
|
|
7819
8911
|
code: payload.code,
|
|
7820
8912
|
redirect_uri: payload.redirectUri,
|
|
7821
8913
|
client_id: config.clientId,
|
|
7822
|
-
client_secret: config.clientSecret
|
|
8914
|
+
client_secret: config.clientSecret,
|
|
8915
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7823
8916
|
})
|
|
7824
8917
|
});
|
|
7825
8918
|
if (!tokenResponse.ok) {
|
|
@@ -7835,12 +8928,16 @@ function createLinkedinProvider(config) {
|
|
|
7835
8928
|
return null;
|
|
7836
8929
|
}
|
|
7837
8930
|
const profileData = await profileResponse.json();
|
|
8931
|
+
if (!profileData.email) {
|
|
8932
|
+
logger.error("LinkedIn user has no email (the 'email' scope may not have been granted)");
|
|
8933
|
+
return null;
|
|
8934
|
+
}
|
|
7838
8935
|
return {
|
|
7839
8936
|
providerId: profileData.sub,
|
|
7840
8937
|
email: profileData.email,
|
|
7841
8938
|
displayName: profileData.name || null,
|
|
7842
8939
|
photoUrl: profileData.picture || null,
|
|
7843
|
-
emailVerified: profileData.email_verified
|
|
8940
|
+
emailVerified: providerVerifiedEmail(profileData.email_verified)
|
|
7844
8941
|
};
|
|
7845
8942
|
} catch (error) {
|
|
7846
8943
|
logger.error("LinkedIn OAuth error", { error });
|
|
@@ -7861,10 +8958,7 @@ function createLinkedinProvider(config) {
|
|
|
7861
8958
|
function createGitHubProvider(config) {
|
|
7862
8959
|
return {
|
|
7863
8960
|
id: "github",
|
|
7864
|
-
schema:
|
|
7865
|
-
code: string().min(1, "Auth code is required"),
|
|
7866
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
7867
|
-
}),
|
|
8961
|
+
schema: oauthCodeFlowSchema(),
|
|
7868
8962
|
verify: async (payload) => {
|
|
7869
8963
|
try {
|
|
7870
8964
|
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
@@ -7877,7 +8971,8 @@ function createGitHubProvider(config) {
|
|
|
7877
8971
|
client_id: config.clientId,
|
|
7878
8972
|
client_secret: config.clientSecret,
|
|
7879
8973
|
code: payload.code,
|
|
7880
|
-
redirect_uri: payload.redirectUri
|
|
8974
|
+
redirect_uri: payload.redirectUri,
|
|
8975
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7881
8976
|
})
|
|
7882
8977
|
});
|
|
7883
8978
|
if (!tokenResponse.ok) {
|
|
@@ -7901,19 +8996,22 @@ function createGitHubProvider(config) {
|
|
|
7901
8996
|
}
|
|
7902
8997
|
const profileData = await profileResponse.json();
|
|
7903
8998
|
let email = profileData.email;
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
8999
|
+
let emailVerified = false;
|
|
9000
|
+
const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
|
|
9001
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
9002
|
+
"Accept": "application/vnd.github+json",
|
|
9003
|
+
"User-Agent": "Rebase-Auth"
|
|
9004
|
+
} });
|
|
9005
|
+
if (emailsResponse.ok) {
|
|
9006
|
+
const emails = await emailsResponse.json();
|
|
9007
|
+
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);
|
|
9008
|
+
if (chosen) {
|
|
9009
|
+
email = chosen.email;
|
|
9010
|
+
emailVerified = providerVerifiedEmail(chosen.verified);
|
|
7913
9011
|
}
|
|
7914
9012
|
}
|
|
7915
9013
|
if (!email) {
|
|
7916
|
-
logger.error("GitHub user has no
|
|
9014
|
+
logger.error("GitHub user has no email");
|
|
7917
9015
|
return null;
|
|
7918
9016
|
}
|
|
7919
9017
|
return {
|
|
@@ -7921,7 +9019,7 @@ function createGitHubProvider(config) {
|
|
|
7921
9019
|
email,
|
|
7922
9020
|
displayName: profileData.name || profileData.login || null,
|
|
7923
9021
|
photoUrl: profileData.avatar_url || null,
|
|
7924
|
-
emailVerified
|
|
9022
|
+
emailVerified
|
|
7925
9023
|
};
|
|
7926
9024
|
} catch (error) {
|
|
7927
9025
|
logger.error("GitHub OAuth error", { error });
|
|
@@ -7931,21 +9029,109 @@ function createGitHubProvider(config) {
|
|
|
7931
9029
|
};
|
|
7932
9030
|
}
|
|
7933
9031
|
//#endregion
|
|
9032
|
+
//#region src/auth/oidc-id-token.ts
|
|
9033
|
+
var JWKS_TTL_MS = 600 * 1e3;
|
|
9034
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
9035
|
+
async function loadJwks(jwksUri, fetchImpl, forceRefresh) {
|
|
9036
|
+
const cached = jwksCache.get(jwksUri);
|
|
9037
|
+
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
|
|
9038
|
+
const response = await fetchImpl(jwksUri);
|
|
9039
|
+
if (!response.ok) throw new Error(`JWKS fetch failed (${response.status})`);
|
|
9040
|
+
const body = await response.json();
|
|
9041
|
+
const keys = Array.isArray(body.keys) ? body.keys : [];
|
|
9042
|
+
jwksCache.set(jwksUri, {
|
|
9043
|
+
keys,
|
|
9044
|
+
fetchedAt: Date.now()
|
|
9045
|
+
});
|
|
9046
|
+
return keys;
|
|
9047
|
+
}
|
|
9048
|
+
function toKeyObject(jwk) {
|
|
9049
|
+
return createPublicKey({
|
|
9050
|
+
key: jwk,
|
|
9051
|
+
format: "jwk"
|
|
9052
|
+
});
|
|
9053
|
+
}
|
|
9054
|
+
function decodeKid(idToken) {
|
|
9055
|
+
return import_jsonwebtoken.default.decode(idToken, { complete: true })?.header?.kid;
|
|
9056
|
+
}
|
|
9057
|
+
/**
|
|
9058
|
+
* Verify an id_token's signature, `aud`, `iss` and `exp`, and return its claims.
|
|
9059
|
+
*
|
|
9060
|
+
* Throws on any failure — callers treat a throw as "reject this sign-in",
|
|
9061
|
+
* never as "continue without the claims".
|
|
9062
|
+
*/
|
|
9063
|
+
async function verifyOidcIdToken(options) {
|
|
9064
|
+
const { idToken, jwksUri, issuer, audience, algorithms = ["RS256"], clockToleranceSec = 60, nonce, fetchImpl = fetch } = options;
|
|
9065
|
+
if (!idToken || typeof idToken !== "string") throw new Error("id_token missing");
|
|
9066
|
+
const kid = decodeKid(idToken);
|
|
9067
|
+
let keys = await loadJwks(jwksUri, fetchImpl, false);
|
|
9068
|
+
let jwk = keys.find((k) => !kid || k.kid === kid);
|
|
9069
|
+
if (!jwk) {
|
|
9070
|
+
keys = await loadJwks(jwksUri, fetchImpl, true);
|
|
9071
|
+
jwk = keys.find((k) => !kid || k.kid === kid);
|
|
9072
|
+
}
|
|
9073
|
+
if (!jwk) throw new Error(`No JWKS key matches kid "${kid ?? "<none>"}"`);
|
|
9074
|
+
const claims = import_jsonwebtoken.default.verify(idToken, toKeyObject(jwk), {
|
|
9075
|
+
algorithms,
|
|
9076
|
+
audience,
|
|
9077
|
+
clockTolerance: clockToleranceSec
|
|
9078
|
+
});
|
|
9079
|
+
if (!(typeof issuer === "string" ? claims.iss === issuer : issuer.test(String(claims.iss ?? "")))) throw new Error(`Unexpected id_token issuer "${claims.iss}"`);
|
|
9080
|
+
if (nonce !== void 0 && claims.nonce !== nonce) throw new Error("id_token nonce mismatch");
|
|
9081
|
+
return claims;
|
|
9082
|
+
}
|
|
9083
|
+
/**
|
|
9084
|
+
* `verifyOidcIdToken` that reports failure as `null` and logs it, for the one
|
|
9085
|
+
* call site that has to decide between "reject" and "degrade".
|
|
9086
|
+
*/
|
|
9087
|
+
async function tryVerifyOidcIdToken(providerId, options) {
|
|
9088
|
+
try {
|
|
9089
|
+
return await verifyOidcIdToken(options);
|
|
9090
|
+
} catch (error) {
|
|
9091
|
+
logger.error(`[${providerId}] id_token verification failed`, { error: error instanceof Error ? error.message : error });
|
|
9092
|
+
return null;
|
|
9093
|
+
}
|
|
9094
|
+
}
|
|
9095
|
+
//#endregion
|
|
7934
9096
|
//#region src/auth/microsoft-oauth.ts
|
|
9097
|
+
/** Endpoints that federate to more than one directory. */
|
|
9098
|
+
var MULTI_TENANT_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
9099
|
+
"common",
|
|
9100
|
+
"organizations",
|
|
9101
|
+
"consumers"
|
|
9102
|
+
]);
|
|
7935
9103
|
/**
|
|
7936
9104
|
* Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
|
|
7937
9105
|
*
|
|
7938
9106
|
* Supports both personal Microsoft accounts and work/school (Azure AD) accounts
|
|
7939
9107
|
* via the "common" tenant endpoint. Uses the authorization code flow.
|
|
9108
|
+
*
|
|
9109
|
+
* ## On `emailVerified`
|
|
9110
|
+
*
|
|
9111
|
+
* Microsoft Graph exposes no email-verification field, and `mail` is *not* a
|
|
9112
|
+
* substitute: it is a directory attribute a tenant administrator sets to any
|
|
9113
|
+
* string they like, through Graph or AAD Connect sync. With the default
|
|
9114
|
+
* `tenantId: "common"` every Entra tenant in the world is an accepted issuer,
|
|
9115
|
+
* so "there is a `mail` value" would mean "anybody who can create a free
|
|
9116
|
+
* tenant can nominate any address" — including one that already has an account
|
|
9117
|
+
* here.
|
|
9118
|
+
*
|
|
9119
|
+
* The only signal Microsoft offers is the `xms_edov` ("email domain owner
|
|
9120
|
+
* verified") optional claim on the id_token, so that is what this provider
|
|
9121
|
+
* reads, off a signature-verified token. If the app registration does not emit
|
|
9122
|
+
* `xms_edov`, sign-in still works and the address comes back unverified —
|
|
9123
|
+
* which routes the user to `POST /auth/link/microsoft` rather than silently
|
|
9124
|
+
* handing them somebody else's account.
|
|
7940
9125
|
*/
|
|
7941
9126
|
function createMicrosoftProvider(config) {
|
|
7942
9127
|
const tenantId = config.tenantId || "common";
|
|
9128
|
+
const multiTenant = MULTI_TENANT_ENDPOINTS.has(tenantId.toLowerCase());
|
|
9129
|
+
const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
|
|
9130
|
+
const issuer = multiTenant ? /^https:\/\/login\.microsoftonline\.com\/[0-9a-fA-F-]{36}\/v2\.0$/ : `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
|
9131
|
+
if (multiTenant) logger.warn(`[Rebase] Microsoft provider configured with tenantId "${tenantId}", which accepts sign-ins from every Entra tenant. Email addresses are reported as unverified unless the app registration emits the xms_edov optional claim. Set an explicit tenantId, or enable xms_edov, to allow automatic linking onto existing accounts.`);
|
|
7943
9132
|
return {
|
|
7944
9133
|
id: "microsoft",
|
|
7945
|
-
schema:
|
|
7946
|
-
code: string().min(1, "Auth code is required"),
|
|
7947
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
7948
|
-
}),
|
|
9134
|
+
schema: oauthCodeFlowSchema(),
|
|
7949
9135
|
verify: async (payload) => {
|
|
7950
9136
|
try {
|
|
7951
9137
|
const tokenResponse = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
@@ -7957,14 +9143,26 @@ function createMicrosoftProvider(config) {
|
|
|
7957
9143
|
code: payload.code,
|
|
7958
9144
|
redirect_uri: payload.redirectUri,
|
|
7959
9145
|
grant_type: "authorization_code",
|
|
7960
|
-
scope: "openid profile email User.Read"
|
|
9146
|
+
scope: "openid profile email User.Read",
|
|
9147
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7961
9148
|
})
|
|
7962
9149
|
});
|
|
7963
9150
|
if (!tokenResponse.ok) {
|
|
7964
9151
|
logger.error("Failed to get Microsoft access token", { detail: await tokenResponse.text() });
|
|
7965
9152
|
return null;
|
|
7966
9153
|
}
|
|
7967
|
-
const
|
|
9154
|
+
const tokenData = await tokenResponse.json();
|
|
9155
|
+
const accessToken = tokenData.access_token;
|
|
9156
|
+
let idTokenClaims = null;
|
|
9157
|
+
if (tokenData.id_token) {
|
|
9158
|
+
idTokenClaims = await tryVerifyOidcIdToken("microsoft", {
|
|
9159
|
+
idToken: tokenData.id_token,
|
|
9160
|
+
jwksUri,
|
|
9161
|
+
issuer,
|
|
9162
|
+
audience: config.clientId
|
|
9163
|
+
});
|
|
9164
|
+
if (!idTokenClaims) return null;
|
|
9165
|
+
}
|
|
7968
9166
|
const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { "Authorization": `Bearer ${accessToken}` } });
|
|
7969
9167
|
if (!profileResponse.ok) {
|
|
7970
9168
|
logger.error("Failed to get Microsoft user info", { detail: await profileResponse.text() });
|
|
@@ -7976,7 +9174,8 @@ function createMicrosoftProvider(config) {
|
|
|
7976
9174
|
logger.error("Microsoft user has no email");
|
|
7977
9175
|
return null;
|
|
7978
9176
|
}
|
|
7979
|
-
const
|
|
9177
|
+
const claimedEmail = idTokenClaims?.email ?? idTokenClaims?.preferred_username;
|
|
9178
|
+
const emailVerified = providerVerifiedEmail(idTokenClaims?.xms_edov) && typeof claimedEmail === "string" && claimedEmail.toLowerCase() === email.toLowerCase();
|
|
7980
9179
|
return {
|
|
7981
9180
|
providerId: profileData.id,
|
|
7982
9181
|
email,
|
|
@@ -7993,6 +9192,8 @@ function createMicrosoftProvider(config) {
|
|
|
7993
9192
|
}
|
|
7994
9193
|
//#endregion
|
|
7995
9194
|
//#region src/auth/apple-oauth.ts
|
|
9195
|
+
var APPLE_ISSUER = "https://appleid.apple.com";
|
|
9196
|
+
var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
|
|
7996
9197
|
/**
|
|
7997
9198
|
* Creates an Apple Sign In OAuth Provider integration.
|
|
7998
9199
|
*
|
|
@@ -8022,18 +9223,19 @@ function createAppleProvider(config) {
|
|
|
8022
9223
|
}
|
|
8023
9224
|
return {
|
|
8024
9225
|
id: "apple",
|
|
8025
|
-
schema:
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8033
|
-
|
|
8034
|
-
|
|
8035
|
-
|
|
8036
|
-
|
|
9226
|
+
schema: oauthCodeFlowSchema().extend({
|
|
9227
|
+
/**
|
|
9228
|
+
* Apple sends user info only on first authorization; the frontend
|
|
9229
|
+
* must forward it. Only the *name* is accepted — an `email` here
|
|
9230
|
+
* used to be promoted to the account's identity whenever the
|
|
9231
|
+
* id_token had no `email` claim, and an attacker chooses whether
|
|
9232
|
+
* that claim is absent simply by omitting the `email` scope from
|
|
9233
|
+
* their own authorization request.
|
|
9234
|
+
*/
|
|
9235
|
+
user: object({ name: object({
|
|
9236
|
+
firstName: string().optional(),
|
|
9237
|
+
lastName: string().optional()
|
|
9238
|
+
}).optional() }).optional() }),
|
|
8037
9239
|
verify: async (payload) => {
|
|
8038
9240
|
try {
|
|
8039
9241
|
const clientSecret = await generateClientSecret();
|
|
@@ -8045,18 +9247,28 @@ function createAppleProvider(config) {
|
|
|
8045
9247
|
client_secret: clientSecret,
|
|
8046
9248
|
code: payload.code,
|
|
8047
9249
|
grant_type: "authorization_code",
|
|
8048
|
-
redirect_uri: payload.redirectUri
|
|
9250
|
+
redirect_uri: payload.redirectUri,
|
|
9251
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8049
9252
|
})
|
|
8050
9253
|
});
|
|
8051
9254
|
if (!tokenResponse.ok) {
|
|
8052
9255
|
logger.error("Failed to get Apple access token", { detail: await tokenResponse.text() });
|
|
8053
9256
|
return null;
|
|
8054
9257
|
}
|
|
8055
|
-
const
|
|
8056
|
-
const decoded =
|
|
8057
|
-
|
|
9258
|
+
const tokenData = await tokenResponse.json();
|
|
9259
|
+
const decoded = tokenData.id_token ? await tryVerifyOidcIdToken("apple", {
|
|
9260
|
+
idToken: tokenData.id_token,
|
|
9261
|
+
jwksUri: APPLE_JWKS_URI,
|
|
9262
|
+
issuer: APPLE_ISSUER,
|
|
9263
|
+
audience: config.clientId
|
|
9264
|
+
}) : null;
|
|
9265
|
+
if (!decoded) {
|
|
9266
|
+
logger.error("Apple token exchange returned no verifiable id_token");
|
|
9267
|
+
return null;
|
|
9268
|
+
}
|
|
9269
|
+
const email = decoded.email;
|
|
8058
9270
|
if (!email) {
|
|
8059
|
-
logger.error("Apple
|
|
9271
|
+
logger.error("Apple id_token carries no email claim");
|
|
8060
9272
|
return null;
|
|
8061
9273
|
}
|
|
8062
9274
|
let displayName = null;
|
|
@@ -8069,7 +9281,7 @@ function createAppleProvider(config) {
|
|
|
8069
9281
|
email,
|
|
8070
9282
|
displayName,
|
|
8071
9283
|
photoUrl: null,
|
|
8072
|
-
emailVerified: decoded.email_verified
|
|
9284
|
+
emailVerified: providerVerifiedEmail(decoded.email_verified)
|
|
8073
9285
|
};
|
|
8074
9286
|
} catch (error) {
|
|
8075
9287
|
logger.error("Apple OAuth error", { error });
|
|
@@ -8089,18 +9301,20 @@ function createAppleProvider(config) {
|
|
|
8089
9301
|
function createFacebookProvider(config) {
|
|
8090
9302
|
return {
|
|
8091
9303
|
id: "facebook",
|
|
8092
|
-
schema:
|
|
8093
|
-
code: string().min(1, "Auth code is required"),
|
|
8094
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8095
|
-
}),
|
|
9304
|
+
schema: oauthCodeFlowSchema(),
|
|
8096
9305
|
verify: async (payload) => {
|
|
8097
9306
|
try {
|
|
8098
|
-
const
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
9307
|
+
const tokenResponse = await fetch("https://graph.facebook.com/v19.0/oauth/access_token", {
|
|
9308
|
+
method: "POST",
|
|
9309
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
9310
|
+
body: new URLSearchParams({
|
|
9311
|
+
client_id: config.clientId,
|
|
9312
|
+
client_secret: config.clientSecret,
|
|
9313
|
+
redirect_uri: payload.redirectUri,
|
|
9314
|
+
code: payload.code,
|
|
9315
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
9316
|
+
})
|
|
9317
|
+
});
|
|
8104
9318
|
if (!tokenResponse.ok) {
|
|
8105
9319
|
logger.error("Failed to get Facebook access token", { detail: await tokenResponse.text() });
|
|
8106
9320
|
return null;
|
|
@@ -8108,8 +9322,7 @@ function createFacebookProvider(config) {
|
|
|
8108
9322
|
const accessToken = (await tokenResponse.json()).access_token;
|
|
8109
9323
|
const profileUrl = new URL("https://graph.facebook.com/v19.0/me");
|
|
8110
9324
|
profileUrl.searchParams.set("fields", "id,name,email,picture.type(large)");
|
|
8111
|
-
profileUrl.
|
|
8112
|
-
const profileResponse = await fetch(profileUrl.toString());
|
|
9325
|
+
const profileResponse = await fetch(profileUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } });
|
|
8113
9326
|
if (!profileResponse.ok) {
|
|
8114
9327
|
logger.error("Failed to get Facebook user info", { detail: await profileResponse.text() });
|
|
8115
9328
|
return null;
|
|
@@ -8124,7 +9337,7 @@ function createFacebookProvider(config) {
|
|
|
8124
9337
|
email: profileData.email,
|
|
8125
9338
|
displayName: profileData.name || null,
|
|
8126
9339
|
photoUrl: profileData.picture?.data?.url || null,
|
|
8127
|
-
emailVerified:
|
|
9340
|
+
emailVerified: false
|
|
8128
9341
|
};
|
|
8129
9342
|
} catch (error) {
|
|
8130
9343
|
logger.error("Facebook OAuth error", { error });
|
|
@@ -8147,11 +9360,7 @@ function createFacebookProvider(config) {
|
|
|
8147
9360
|
function createTwitterProvider(config) {
|
|
8148
9361
|
return {
|
|
8149
9362
|
id: "twitter",
|
|
8150
|
-
schema:
|
|
8151
|
-
code: string().min(1, "Auth code is required"),
|
|
8152
|
-
redirectUri: string().url("Valid redirect URI is required"),
|
|
8153
|
-
codeVerifier: string().min(1, "PKCE code verifier is required")
|
|
8154
|
-
}),
|
|
9363
|
+
schema: oauthCodeFlowSchema({ pkce: "required" }),
|
|
8155
9364
|
verify: async (payload) => {
|
|
8156
9365
|
try {
|
|
8157
9366
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
@@ -8165,7 +9374,7 @@ function createTwitterProvider(config) {
|
|
|
8165
9374
|
code: payload.code,
|
|
8166
9375
|
grant_type: "authorization_code",
|
|
8167
9376
|
redirect_uri: payload.redirectUri,
|
|
8168
|
-
|
|
9377
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8169
9378
|
})
|
|
8170
9379
|
});
|
|
8171
9380
|
if (!tokenResponse.ok) {
|
|
@@ -8211,16 +9420,14 @@ function createTwitterProvider(config) {
|
|
|
8211
9420
|
/**
|
|
8212
9421
|
* Creates a Discord OAuth2 Provider integration.
|
|
8213
9422
|
*
|
|
8214
|
-
* Uses the authorization code flow
|
|
8215
|
-
*
|
|
9423
|
+
* Uses the authorization code flow (with PKCE when the client supplies a
|
|
9424
|
+
* verifier). Requires the "identify" and "email" scopes to retrieve the
|
|
9425
|
+
* user's email and profile information.
|
|
8216
9426
|
*/
|
|
8217
9427
|
function createDiscordProvider(config) {
|
|
8218
9428
|
return {
|
|
8219
9429
|
id: "discord",
|
|
8220
|
-
schema:
|
|
8221
|
-
code: string().min(1, "Auth code is required"),
|
|
8222
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8223
|
-
}),
|
|
9430
|
+
schema: oauthCodeFlowSchema(),
|
|
8224
9431
|
verify: async (payload) => {
|
|
8225
9432
|
try {
|
|
8226
9433
|
const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
|
|
@@ -8231,7 +9438,8 @@ function createDiscordProvider(config) {
|
|
|
8231
9438
|
client_secret: config.clientSecret,
|
|
8232
9439
|
grant_type: "authorization_code",
|
|
8233
9440
|
code: payload.code,
|
|
8234
|
-
redirect_uri: payload.redirectUri
|
|
9441
|
+
redirect_uri: payload.redirectUri,
|
|
9442
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8235
9443
|
})
|
|
8236
9444
|
});
|
|
8237
9445
|
if (!tokenResponse.ok) {
|
|
@@ -8259,7 +9467,7 @@ function createDiscordProvider(config) {
|
|
|
8259
9467
|
email: profileData.email,
|
|
8260
9468
|
displayName: profileData.global_name || profileData.username || null,
|
|
8261
9469
|
photoUrl,
|
|
8262
|
-
emailVerified: profileData.verified
|
|
9470
|
+
emailVerified: providerVerifiedEmail(profileData.verified)
|
|
8263
9471
|
};
|
|
8264
9472
|
} catch (error) {
|
|
8265
9473
|
logger.error("Discord OAuth error", { error });
|
|
@@ -8273,15 +9481,16 @@ function createDiscordProvider(config) {
|
|
|
8273
9481
|
/**
|
|
8274
9482
|
* Creates a GitLab OAuth Provider integration.
|
|
8275
9483
|
* Works with both GitLab.com and self-hosted instances.
|
|
9484
|
+
*
|
|
9485
|
+
* `baseUrl` is configuration only and must stay that way: a caller-supplied
|
|
9486
|
+
* instance URL would make this provider an SSRF primitive and an
|
|
9487
|
+
* arbitrary-identity oracle in one step.
|
|
8276
9488
|
*/
|
|
8277
9489
|
function createGitLabProvider(config) {
|
|
8278
9490
|
const gitlabUrl = (config.baseUrl || "https://gitlab.com").replace(/\/$/, "");
|
|
8279
9491
|
return {
|
|
8280
9492
|
id: "gitlab",
|
|
8281
|
-
schema:
|
|
8282
|
-
code: string().min(1, "Auth code is required"),
|
|
8283
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8284
|
-
}),
|
|
9493
|
+
schema: oauthCodeFlowSchema(),
|
|
8285
9494
|
verify: async (payload) => {
|
|
8286
9495
|
try {
|
|
8287
9496
|
const tokenResponse = await fetch(`${gitlabUrl}/oauth/token`, {
|
|
@@ -8292,7 +9501,8 @@ function createGitLabProvider(config) {
|
|
|
8292
9501
|
client_secret: config.clientSecret,
|
|
8293
9502
|
code: payload.code,
|
|
8294
9503
|
grant_type: "authorization_code",
|
|
8295
|
-
redirect_uri: payload.redirectUri
|
|
9504
|
+
redirect_uri: payload.redirectUri,
|
|
9505
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8296
9506
|
})
|
|
8297
9507
|
});
|
|
8298
9508
|
if (!tokenResponse.ok) {
|
|
@@ -8315,7 +9525,7 @@ function createGitLabProvider(config) {
|
|
|
8315
9525
|
email: p.email,
|
|
8316
9526
|
displayName: p.name || p.username || null,
|
|
8317
9527
|
photoUrl: p.avatar_url || null,
|
|
8318
|
-
emailVerified:
|
|
9528
|
+
emailVerified: Boolean(p.confirmed_at)
|
|
8319
9529
|
};
|
|
8320
9530
|
} catch (error) {
|
|
8321
9531
|
logger.error("GitLab OAuth error", { error });
|
|
@@ -8332,10 +9542,7 @@ function createGitLabProvider(config) {
|
|
|
8332
9542
|
function createBitbucketProvider(config) {
|
|
8333
9543
|
return {
|
|
8334
9544
|
id: "bitbucket",
|
|
8335
|
-
schema:
|
|
8336
|
-
code: string().min(1, "Auth code is required"),
|
|
8337
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8338
|
-
}),
|
|
9545
|
+
schema: oauthCodeFlowSchema(),
|
|
8339
9546
|
verify: async (payload) => {
|
|
8340
9547
|
try {
|
|
8341
9548
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
@@ -8348,7 +9555,8 @@ function createBitbucketProvider(config) {
|
|
|
8348
9555
|
body: new URLSearchParams({
|
|
8349
9556
|
grant_type: "authorization_code",
|
|
8350
9557
|
code: payload.code,
|
|
8351
|
-
redirect_uri: payload.redirectUri
|
|
9558
|
+
redirect_uri: payload.redirectUri,
|
|
9559
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8352
9560
|
})
|
|
8353
9561
|
});
|
|
8354
9562
|
if (!tokenResponse.ok) {
|
|
@@ -8363,21 +9571,21 @@ function createBitbucketProvider(config) {
|
|
|
8363
9571
|
}
|
|
8364
9572
|
const p = await profileResponse.json();
|
|
8365
9573
|
const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", { headers: { "Authorization": `Bearer ${accessToken}` } });
|
|
8366
|
-
let
|
|
9574
|
+
let selected;
|
|
8367
9575
|
if (emailsResponse.ok) {
|
|
8368
9576
|
const emailData = await emailsResponse.json();
|
|
8369
|
-
|
|
9577
|
+
selected = emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed);
|
|
8370
9578
|
}
|
|
8371
|
-
if (!email) {
|
|
9579
|
+
if (!selected?.email) {
|
|
8372
9580
|
logger.error("Bitbucket user has no verified email");
|
|
8373
9581
|
return null;
|
|
8374
9582
|
}
|
|
8375
9583
|
return {
|
|
8376
9584
|
providerId: p.uuid,
|
|
8377
|
-
email,
|
|
9585
|
+
email: selected.email,
|
|
8378
9586
|
displayName: p.display_name || p.username || null,
|
|
8379
9587
|
photoUrl: p.links?.avatar?.href || null,
|
|
8380
|
-
emailVerified:
|
|
9588
|
+
emailVerified: providerVerifiedEmail(selected.is_confirmed)
|
|
8381
9589
|
};
|
|
8382
9590
|
} catch (error) {
|
|
8383
9591
|
logger.error("Bitbucket OAuth error", { error });
|
|
@@ -8395,10 +9603,7 @@ function createBitbucketProvider(config) {
|
|
|
8395
9603
|
function createSlackProvider(config) {
|
|
8396
9604
|
return {
|
|
8397
9605
|
id: "slack",
|
|
8398
|
-
schema:
|
|
8399
|
-
code: string().min(1, "Auth code is required"),
|
|
8400
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8401
|
-
}),
|
|
9606
|
+
schema: oauthCodeFlowSchema(),
|
|
8402
9607
|
verify: async (payload) => {
|
|
8403
9608
|
try {
|
|
8404
9609
|
const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
|
|
@@ -8409,7 +9614,8 @@ function createSlackProvider(config) {
|
|
|
8409
9614
|
client_secret: config.clientSecret,
|
|
8410
9615
|
code: payload.code,
|
|
8411
9616
|
redirect_uri: payload.redirectUri,
|
|
8412
|
-
grant_type: "authorization_code"
|
|
9617
|
+
grant_type: "authorization_code",
|
|
9618
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8413
9619
|
})
|
|
8414
9620
|
});
|
|
8415
9621
|
if (!tokenResponse.ok) {
|
|
@@ -8436,7 +9642,7 @@ function createSlackProvider(config) {
|
|
|
8436
9642
|
email: p.email,
|
|
8437
9643
|
displayName: p.name || null,
|
|
8438
9644
|
photoUrl: p.picture || null,
|
|
8439
|
-
emailVerified: p.email_verified
|
|
9645
|
+
emailVerified: providerVerifiedEmail(p.email_verified)
|
|
8440
9646
|
};
|
|
8441
9647
|
} catch (error) {
|
|
8442
9648
|
logger.error("Slack OAuth error", { error });
|
|
@@ -8454,10 +9660,7 @@ function createSlackProvider(config) {
|
|
|
8454
9660
|
function createSpotifyProvider(config) {
|
|
8455
9661
|
return {
|
|
8456
9662
|
id: "spotify",
|
|
8457
|
-
schema:
|
|
8458
|
-
code: string().min(1, "Auth code is required"),
|
|
8459
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8460
|
-
}),
|
|
9663
|
+
schema: oauthCodeFlowSchema(),
|
|
8461
9664
|
verify: async (payload) => {
|
|
8462
9665
|
try {
|
|
8463
9666
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
@@ -8470,7 +9673,8 @@ function createSpotifyProvider(config) {
|
|
|
8470
9673
|
body: new URLSearchParams({
|
|
8471
9674
|
grant_type: "authorization_code",
|
|
8472
9675
|
code: payload.code,
|
|
8473
|
-
redirect_uri: payload.redirectUri
|
|
9676
|
+
redirect_uri: payload.redirectUri,
|
|
9677
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8474
9678
|
})
|
|
8475
9679
|
});
|
|
8476
9680
|
if (!tokenResponse.ok) {
|
|
@@ -8493,7 +9697,7 @@ function createSpotifyProvider(config) {
|
|
|
8493
9697
|
email: p.email,
|
|
8494
9698
|
displayName: p.display_name || null,
|
|
8495
9699
|
photoUrl: p.images?.[0]?.url || null,
|
|
8496
|
-
emailVerified:
|
|
9700
|
+
emailVerified: false
|
|
8497
9701
|
};
|
|
8498
9702
|
} catch (error) {
|
|
8499
9703
|
logger.error("Spotify OAuth error", { error });
|
|
@@ -8591,44 +9795,45 @@ function createApiKeyStore(driver) {
|
|
|
8591
9795
|
return;
|
|
8592
9796
|
}
|
|
8593
9797
|
const exec = (sqlText, options) => admin.executeSql(sqlText, options?.params ? { params: options.params } : void 0);
|
|
9798
|
+
const ddl = createDdlBootstrapper(exec, "api-key-store");
|
|
8594
9799
|
return {
|
|
8595
9800
|
async ensureTable() {
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
logger.
|
|
8628
|
-
|
|
8629
|
-
logger.error("❌ Failed to create API keys table", { error: err });
|
|
8630
|
-
logger.warn("⚠️ Continuing without API keys support.");
|
|
9801
|
+
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
9802
|
+
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
9803
|
+
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
9804
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
9805
|
+
name TEXT NOT NULL,
|
|
9806
|
+
key_prefix TEXT NOT NULL,
|
|
9807
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
9808
|
+
permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
9809
|
+
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
9810
|
+
rate_limit INTEGER,
|
|
9811
|
+
created_by TEXT NOT NULL,
|
|
9812
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
9813
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
9814
|
+
last_used_at TIMESTAMPTZ,
|
|
9815
|
+
expires_at TIMESTAMPTZ,
|
|
9816
|
+
revoked_at TIMESTAMPTZ
|
|
9817
|
+
)
|
|
9818
|
+
`);
|
|
9819
|
+
await ddl.ensureObject("Creating idx_api_keys_hash", `
|
|
9820
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash
|
|
9821
|
+
ON ${TABLE}(key_hash)
|
|
9822
|
+
`);
|
|
9823
|
+
await ddl.ensureObject("Creating idx_api_keys_prefix", `
|
|
9824
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
|
9825
|
+
ON ${TABLE}(key_prefix)
|
|
9826
|
+
`);
|
|
9827
|
+
await ddl.ensureObject(`Adding ${TABLE}.admin`, `
|
|
9828
|
+
ALTER TABLE ${TABLE}
|
|
9829
|
+
ADD COLUMN IF NOT EXISTS admin BOOLEAN NOT NULL DEFAULT FALSE
|
|
9830
|
+
`);
|
|
9831
|
+
if (!await ddl.isReadable(TABLE)) {
|
|
9832
|
+
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.`);
|
|
9833
|
+
return;
|
|
8631
9834
|
}
|
|
9835
|
+
await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
|
|
9836
|
+
logger.info("✅ API keys table ready");
|
|
8632
9837
|
},
|
|
8633
9838
|
async createApiKey(request, createdBy) {
|
|
8634
9839
|
const plaintext = generateApiKey();
|
|
@@ -8923,6 +10128,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
8923
10128
|
createSlackProvider: () => createSlackProvider,
|
|
8924
10129
|
createSpotifyProvider: () => createSpotifyProvider,
|
|
8925
10130
|
createTwitterProvider: () => createTwitterProvider,
|
|
10131
|
+
decideOAuthAutoLink: () => decideOAuthAutoLink,
|
|
8926
10132
|
defaultAuthLimiter: () => defaultAuthLimiter,
|
|
8927
10133
|
extractUserFromToken: () => extractUserFromToken,
|
|
8928
10134
|
fileTokenAuth: () => fileTokenAuth,
|
|
@@ -8939,23 +10145,30 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
8939
10145
|
hashToken: () => hashToken,
|
|
8940
10146
|
httpMethodToOperation: () => httpMethodToOperation,
|
|
8941
10147
|
isApiKeyToken: () => isApiKeyToken,
|
|
10148
|
+
isJwtConfigured: () => isJwtConfigured,
|
|
8942
10149
|
isOperationAllowed: () => isOperationAllowed,
|
|
10150
|
+
isRedirectUriAllowed: () => isRedirectUriAllowed,
|
|
8943
10151
|
mountMagicLinkRoutes: () => mountMagicLinkRoutes,
|
|
10152
|
+
oauthCodeFlowSchema: () => oauthCodeFlowSchema,
|
|
8944
10153
|
optionalAuth: () => optionalAuth,
|
|
10154
|
+
pkceTokenParams: () => pkceTokenParams,
|
|
8945
10155
|
prepareAdminUserValues: () => prepareAdminUserValues,
|
|
10156
|
+
providerVerifiedEmail: () => providerVerifiedEmail,
|
|
8946
10157
|
queryTokenAuth: () => queryTokenAuth,
|
|
8947
10158
|
requireAdmin: () => requireAdmin,
|
|
8948
10159
|
requireAuth: () => requireAuth,
|
|
8949
10160
|
resolveAuthHooks: () => resolveAuthHooks,
|
|
8950
10161
|
safeCompare: () => safeCompare,
|
|
8951
10162
|
strictAuthLimiter: () => strictAuthLimiter,
|
|
10163
|
+
tryVerifyOidcIdToken: () => tryVerifyOidcIdToken,
|
|
8952
10164
|
validateApiKey: () => validateApiKey,
|
|
8953
10165
|
validatePasswordStrength: () => validatePasswordStrength,
|
|
8954
10166
|
verifyAccessToken: () => verifyAccessToken,
|
|
8955
10167
|
verifyDownloadToken: () => verifyDownloadToken,
|
|
10168
|
+
verifyOidcIdToken: () => verifyOidcIdToken,
|
|
8956
10169
|
verifyPassword: () => verifyPassword
|
|
8957
10170
|
});
|
|
8958
10171
|
//#endregion
|
|
8959
|
-
export {
|
|
10172
|
+
export { fileTokenAuth as $, MemoryRateLimitStore as A, getEmailVerificationTemplate as B, ZodNumber as C, isPublicStoragePath as Ct, _coercedNumber as D, string as E, resolveAuthHooks as F, RawHtml as G, getPasswordResetTemplate as H, hashPassword as I, raw as J, escapeHtml as K, validatePasswordStrength as L, createEmailService as M, assertEmailLinkBases as N, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as O, resolveEmailLinkBase as P, extractUserFromToken as Q, verifyPassword as R, createBuiltinAuthAdapter as S, PUBLIC_STORAGE_PREFIX as St, object as T, getUserInvitationTemplate as U, getMagicLinkTemplate as V, getWelcomeEmailTemplate as W, createAuthMiddleware as X, createAdapterAuthMiddleware as Y, createRequireAuth as Z, createLinkedinProvider as _, scopeDataDriver as _t, createSpotifyProvider as a, InvalidStorageBucketError as at, pkceTokenParams as b, parseQueryOptions as bt, createGitLabProvider as c, canonicalStorageKey as ct, createFacebookProvider as d, createStorageApiKeyGuard as dt, optionalAuth as et, createAppleProvider as f, isApiKeyToken as ft, createGitHubProvider as g, SERVICE_IDENTITY as gt, verifyOidcIdToken as h, safeCompare as ht, createApiKeyStore as i, requireAuth as it, SMTPEmailService as j, createDataRateLimiter as k, createDiscordProvider as l, createApiKeyPreAuth as lt, tryVerifyOidcIdToken as m, extractBearerToken as mt, createCustomAuthAdapter as n, queryTokenAuth as nt, createSlackProvider as o, InvalidStorageKeyError as ot, createMicrosoftProvider as p, validateApiKey as pt, html as q, createApiKeyRoutes as r, requireAdmin as rt, createBitbucketProvider as s, canonicalStorageBucket as st, auth_exports as t, publicObjectAuth as tt, createTwitterProvider as u, createFunctionApiKeyGuard as ut, createGoogleProvider as v, httpMethodToOperation as vt, _enum as w, providerVerifiedEmail as x, resolveListLimitParam as xt, oauthCodeFlowSchema as y, isOperationAllowed as yt, generateSecurePassword as z };
|
|
8960
10173
|
|
|
8961
|
-
//# sourceMappingURL=auth-
|
|
10174
|
+
//# sourceMappingURL=auth-DkAHqvf5.js.map
|