@rebasepro/server 0.13.1-canary.gf57a27e → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{admin_block-CLeUMG8G.js → admin_block-H86dCPsj.js} +60 -2
- package/dist/admin_block-H86dCPsj.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +74 -6
- package/dist/api/errors.d.ts +12 -0
- package/dist/api/rest/api-generator.d.ts +26 -1
- package/dist/api/rest/idempotency.d.ts +25 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +40 -4
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/admin-roles.d.ts +24 -0
- package/dist/auth/apple-oauth.d.ts +16 -11
- package/dist/auth/bitbucket-oauth.d.ts +2 -4
- package/dist/auth/builtin-auth-adapter.d.ts +11 -0
- package/dist/auth/discord-oauth.d.ts +5 -6
- package/dist/auth/facebook-oauth.d.ts +2 -4
- package/dist/auth/github-oauth.d.ts +2 -4
- package/dist/auth/gitlab-oauth.d.ts +6 -4
- package/dist/auth/google-oauth.d.ts +1 -0
- package/dist/auth/index.d.ts +7 -0
- package/dist/auth/interfaces.d.ts +81 -1
- package/dist/auth/jwt.d.ts +40 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- package/dist/auth/mfa-crypto.d.ts +54 -3
- package/dist/auth/mfa-gate.d.ts +42 -0
- package/dist/auth/mfa-routes.d.ts +26 -1
- package/dist/auth/mfa.d.ts +16 -0
- package/dist/auth/microsoft-oauth.d.ts +19 -4
- package/dist/auth/middleware.d.ts +26 -0
- package/dist/auth/oauth-code-flow.d.ts +66 -0
- package/dist/auth/oauth-signin-policy.d.ts +61 -0
- package/dist/auth/oidc-id-token.d.ts +61 -0
- package/dist/auth/rate-limiter.d.ts +62 -5
- package/dist/auth/registration-policy.d.ts +31 -0
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +15 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/token-revocation.d.ts +38 -0
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-BANhRhN9.js → auth-CYoPVf-E.js} +1814 -354
- package/dist/auth-CYoPVf-E.js.map +1 -0
- package/dist/{backup-BJiY_mM-.js → backup-C6ljYVTp.js} +2 -2
- package/dist/{backup-BJiY_mM-.js.map → backup-C6ljYVTp.js.map} +1 -1
- package/dist/{contract-routes-FSt0Nyfk.js → contract-routes-Bet-eCNJ.js} +3 -3
- package/dist/{contract-routes-FSt0Nyfk.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
- package/dist/cron/cron-loader.d.ts +26 -1
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +5 -0
- package/dist/cron/define-cron.d.ts +14 -2
- package/dist/cron/index.d.ts +2 -2
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
- package/dist/cron-loader-YhhQeVBM.js.map +1 -0
- package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
- package/dist/cron-routes-maM_RlUu.js.map +1 -0
- package/dist/{cron-scheduler-CsQtYLMq.js → cron-scheduler-DIpYBmZP.js} +13 -7
- package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
- package/dist/{cron-store-ZbjfuvMb.js → cron-store-Dvr4Y1sZ.js} +5 -5
- package/dist/{cron-store-ZbjfuvMb.js.map → cron-store-Dvr4Y1sZ.js.map} +1 -1
- package/dist/{ddl-bootstrap-Bp3mr6Td.js → ddl-bootstrap-BhXbTnBl.js} +2 -2
- package/dist/{ddl-bootstrap-Bp3mr6Td.js.map → ddl-bootstrap-BhXbTnBl.js.map} +1 -1
- package/dist/email/html.d.ts +54 -0
- package/dist/email/index.d.ts +3 -0
- package/dist/email/link-base.d.ts +39 -0
- package/dist/email/smtp-email-service.d.ts +7 -1
- package/dist/email/templates.d.ts +9 -1
- package/dist/email/types.d.ts +11 -1
- package/dist/env.d.ts +5 -0
- package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
- package/dist/errors-EBYiaJ2E.js.map +1 -0
- package/dist/function-loader-DDS1v7YX.js +139 -0
- package/dist/function-loader-DDS1v7YX.js.map +1 -0
- package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
- package/dist/function-routes-Btcez1T-.js.map +1 -0
- package/dist/functions/define-function.d.ts +24 -5
- 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/index.d.ts +3 -2
- package/dist/index.es.js +1437 -694
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +10 -1
- package/dist/init/process-safety.d.ts +25 -0
- package/dist/init.d.ts +38 -0
- package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
- package/dist/jwt-_IFqfTOg.js.map +1 -0
- package/dist/logger-DfvF_8r-.js +190 -0
- package/dist/logger-DfvF_8r-.js.map +1 -0
- package/dist/{openapi-generator-VB0MUH8h.js → openapi-generator-DPKtUC9X.js} +87 -31
- package/dist/openapi-generator-DPKtUC9X.js.map +1 -0
- package/dist/request-timeout-RivJsME0.js +65 -0
- package/dist/request-timeout-RivJsME0.js.map +1 -0
- package/dist/schema-editor-routes-CRcS3ArS.js +437 -0
- package/dist/schema-editor-routes-CRcS3ArS.js.map +1 -0
- package/dist/serve-spa.d.ts +5 -4
- package/dist/services/outbound-url-guard.d.ts +53 -0
- package/dist/services/routed-realtime-service.d.ts +10 -2
- package/dist/services/webhook-service.d.ts +76 -1
- package/dist/singleton.d.ts +25 -8
- package/dist/{src-DVb5a0bp.js → src-C7rkDGxA.js} +536 -61
- package/dist/src-C7rkDGxA.js.map +1 -0
- package/dist/{src-Ca6NhxKs.js → src-Cz9nMgUR.js} +16 -3
- package/dist/src-Cz9nMgUR.js.map +1 -0
- package/dist/storage/LocalStorageController.d.ts +12 -1
- package/dist/storage/image-transform.d.ts +57 -0
- package/dist/storage/keys.d.ts +34 -1
- package/dist/storage/routes.d.ts +11 -0
- package/dist/storage/tus-handler.d.ts +22 -2
- package/dist/utils/logger.d.ts +18 -0
- package/package.json +6 -5
- package/dist/admin_block-CLeUMG8G.js.map +0 -1
- package/dist/auth-BANhRhN9.js.map +0 -1
- package/dist/cron-loader-B1S2MCSl.js.map +0 -1
- package/dist/cron-routes-D5a9v1HY.js.map +0 -1
- package/dist/cron-scheduler-CsQtYLMq.js.map +0 -1
- package/dist/errors-BpudWAVU.js.map +0 -1
- package/dist/function-loader-B_1fYfUY.js +0 -86
- package/dist/function-loader-B_1fYfUY.js.map +0 -1
- package/dist/function-routes-C0cLIy3N.js.map +0 -1
- package/dist/jwt-CzeviDcB.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-VB0MUH8h.js.map +0 -1
- package/dist/schema-editor-routes-BqjzvvWU.js +0 -248
- package/dist/schema-editor-routes-BqjzvvWU.js.map +0 -1
- package/dist/src-Ca6NhxKs.js.map +0 -1
- package/dist/src-DVb5a0bp.js.map +0 -1
|
@@ -2,16 +2,16 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
|
|
5
|
-
import {
|
|
6
|
-
import "./src-
|
|
7
|
-
import { i as isSQLAdmin, r as revokeInternalTableSql, t as createDdlBootstrapper } from "./ddl-bootstrap-
|
|
8
|
-
import { t as logger } from "./logger-
|
|
9
|
-
import { n as errorHandler, t as ApiError } from "./errors-
|
|
10
|
-
import { a as
|
|
5
|
+
import { C as resolveClientListLimit, T as isAnonymousUid, a as deserializeLogicalCondition, i as deserializeFilter, r as UnknownFilterOperatorError, w as ANONYMOUS_USER_ID, x as ListLimitError } from "./src-C7rkDGxA.js";
|
|
6
|
+
import "./src-Cz9nMgUR.js";
|
|
7
|
+
import { i as isSQLAdmin, r as revokeInternalTableSql, t as createDdlBootstrapper } from "./ddl-bootstrap-BhXbTnBl.js";
|
|
8
|
+
import { t as logger } from "./logger-DfvF_8r-.js";
|
|
9
|
+
import { n as errorHandler, t as ApiError } from "./errors-EBYiaJ2E.js";
|
|
10
|
+
import { a as generateMfaPendingToken, c as getRefreshTokenExpiry, d as isJwtConfigured, g as require_jsonwebtoken, h as verifyMfaPendingToken, i as generateDownloadToken, l as getRefreshTokenTtlMs, m as verifyDownloadToken, n as configureJwt, o as generateRefreshToken, p as verifyAccessToken, r as generateAccessToken, s as getAccessTokenExpiry, t as MAX_COOKIE_AGE_MS, u as hashRefreshToken } from "./jwt-_IFqfTOg.js";
|
|
11
11
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
12
12
|
import { Hono } from "hono";
|
|
13
13
|
import { promisify } from "util";
|
|
14
|
-
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
14
|
+
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, createPublicKey, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
17
17
|
//#region ../types/src/controllers/storage.ts
|
|
@@ -74,6 +74,290 @@ function normalizeEmail(email) {
|
|
|
74
74
|
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
75
75
|
}
|
|
76
76
|
//#endregion
|
|
77
|
+
//#region src/api/rest/query-parser.ts
|
|
78
|
+
/**
|
|
79
|
+
* A malformed query parameter, refused with a 400.
|
|
80
|
+
*
|
|
81
|
+
* Every rejection in this file is one of these, and every one of them is
|
|
82
|
+
* `expected` — the flag `errorHandler` reads to log a routine outcome at debug
|
|
83
|
+
* instead of warn. A client that mistypes an operator, a sort direction or a
|
|
84
|
+
* limit is not an incident: nothing on the server is wrong, the request never
|
|
85
|
+
* reached the database, and the caller has already been told what to fix in the
|
|
86
|
+
* response body. Left at warn, a single frontend holding a stale field name
|
|
87
|
+
* writes a `⚠️` line per request forever, and the warn level stops meaning
|
|
88
|
+
* anything — which is why "routine 4xx logs at WARN" is a standing finding
|
|
89
|
+
* against this API.
|
|
90
|
+
*
|
|
91
|
+
* Not a factory on `ApiError`: the class's members are part of the tracked
|
|
92
|
+
* runtime surface (`api-surface/server.api.txt`), and this needs no addition to
|
|
93
|
+
* it. `ApiError.unauthenticated` is the same idea one status code up.
|
|
94
|
+
*/
|
|
95
|
+
function invalidParam(message, code, details) {
|
|
96
|
+
return new ApiError(400, code, message, details, true);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Decode a filter, turning the shared codec's operator rejection into a 400.
|
|
100
|
+
*
|
|
101
|
+
* `deserializeFilter` lives in `@rebasepro/common`, which cannot throw an
|
|
102
|
+
* `ApiError` — it does not depend on this package, and the browser SDK decodes
|
|
103
|
+
* through the same function and has nothing to render one with. So it throws
|
|
104
|
+
* `UnknownFilterOperatorError`, and the HTTP boundary is where that becomes a
|
|
105
|
+
* status code. Same seam `parseLogicalGroup` uses for the nesting bound.
|
|
106
|
+
*
|
|
107
|
+
* Without this the operator string became a *value*: `?where={"title":
|
|
108
|
+
* ["!!","Hello"]}` compiled to `title IN ('!!','Hello')` and answered 200 with
|
|
109
|
+
* the row the caller was filtering out, and `{"id":[">>",0]}` reached Postgres
|
|
110
|
+
* and came back a 500 quoting `invalid input syntax for type integer`. Both are
|
|
111
|
+
* malformed requests and now say so.
|
|
112
|
+
*/
|
|
113
|
+
function decodeFilter(query) {
|
|
114
|
+
try {
|
|
115
|
+
return deserializeFilter(query);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e instanceof UnknownFilterOperatorError) throw invalidParam(e.message, e.code, e.details);
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function getLastValue(val) {
|
|
122
|
+
if (Array.isArray(val)) return val[val.length - 1];
|
|
123
|
+
return val;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse an `or(...)` / `and(...)` logical group from its wire form.
|
|
127
|
+
*
|
|
128
|
+
* The wire carries the inner conditions wrapped in parens (e.g.
|
|
129
|
+
* `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
|
|
130
|
+
* delegate to the canonical filter dialect (`@rebasepro/common`). Values are
|
|
131
|
+
* preserved as strings — type coercion is the schema-aware driver's job, so
|
|
132
|
+
* this path stays byte-for-byte consistent with the SDK/admin path (which
|
|
133
|
+
* also parses via the shared dialect).
|
|
134
|
+
*/
|
|
135
|
+
function parseLogicalGroup(type, raw) {
|
|
136
|
+
let inner = String(raw).trim();
|
|
137
|
+
if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
|
|
138
|
+
inner = inner.trim();
|
|
139
|
+
if (!inner) return void 0;
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = deserializeLogicalCondition(`${type}(${inner})`);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
throw invalidParam(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
|
|
145
|
+
}
|
|
146
|
+
return "type" in parsed ? parsed : void 0;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Parse the `?where=` JSON filter object.
|
|
150
|
+
*
|
|
151
|
+
* This is the dialect the OpenAPI document publishes on every
|
|
152
|
+
* `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
|
|
153
|
+
* `[WhereFilterOp, value]` tuple. It is normalized through the same
|
|
154
|
+
* `deserializeFilter` as the `?field=op.value` params below, so a value that
|
|
155
|
+
* arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
|
|
156
|
+
* scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
|
|
157
|
+
* querystring dialect, JSON carries types — a number stays a number.
|
|
158
|
+
*
|
|
159
|
+
* A malformed value is a 400 rather than a silent drop: dropping the filter
|
|
160
|
+
* would run the read unfiltered and return everything RLS happens to allow.
|
|
161
|
+
*/
|
|
162
|
+
function parseWhereParam(raw) {
|
|
163
|
+
const str = String(raw).trim();
|
|
164
|
+
if (!str) return void 0;
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(str);
|
|
168
|
+
} catch {
|
|
169
|
+
throw invalidParam("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
170
|
+
}
|
|
171
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw invalidParam("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
172
|
+
const filter = decodeFilter(parsed);
|
|
173
|
+
return Object.keys(filter).length > 0 ? filter : void 0;
|
|
174
|
+
}
|
|
175
|
+
function invalidOrderBy(detail) {
|
|
176
|
+
throw invalidParam(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
|
|
177
|
+
}
|
|
178
|
+
/** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
|
|
179
|
+
function toDirection(raw, context) {
|
|
180
|
+
if (raw === void 0 || raw === null) return "asc";
|
|
181
|
+
if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
|
|
182
|
+
const lowered = raw.toLowerCase();
|
|
183
|
+
if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
|
|
184
|
+
return lowered;
|
|
185
|
+
}
|
|
186
|
+
/** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
|
|
187
|
+
function toOrderByEntry(raw, index) {
|
|
188
|
+
const context = `entry ${index}`;
|
|
189
|
+
if (typeof raw === "string") {
|
|
190
|
+
const idx = raw.indexOf(":");
|
|
191
|
+
const field = (idx === -1 ? raw : raw.slice(0, idx)).trim();
|
|
192
|
+
if (!field) invalidOrderBy(`${context} is an empty field name`);
|
|
193
|
+
return {
|
|
194
|
+
field,
|
|
195
|
+
direction: idx === -1 ? "asc" : toDirection(raw.slice(idx + 1), context)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
|
|
199
|
+
const entry = raw;
|
|
200
|
+
if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
|
|
201
|
+
return {
|
|
202
|
+
field: entry.field,
|
|
203
|
+
direction: toDirection(entry.direction, context)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Parse the `orderBy` query parameter.
|
|
208
|
+
*
|
|
209
|
+
* The field *name* has been validated against the schema for a while — an
|
|
210
|
+
* `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
|
|
211
|
+
* that silently dropping the sort leaves the caller believing in an order that
|
|
212
|
+
* is not there. The parameter's *shape* was never checked the same way, and it
|
|
213
|
+
* failed in exactly the same silent manner one layer earlier: whatever
|
|
214
|
+
* `JSON.parse` returned was assigned to an option declared as an array of
|
|
215
|
+
* `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
|
|
216
|
+
* `?orderBy={"field":"name"}` — an object rather than an array, and the most
|
|
217
|
+
* natural thing for a client to try — read `undefined`, dropped the ORDER BY,
|
|
218
|
+
* and answered 200. So did a number, a boolean, `null`, and `["name"]`.
|
|
219
|
+
*
|
|
220
|
+
* This refuses those, the way `parseWhereParam` above already refuses a
|
|
221
|
+
* malformed filter and for the same reason. What it keeps working is every
|
|
222
|
+
* shape that worked before: the `field` and `field:desc` shorthands, and the
|
|
223
|
+
* canonical JSON array.
|
|
224
|
+
*/
|
|
225
|
+
function parseOrderByParam(raw) {
|
|
226
|
+
if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
|
|
227
|
+
const str = String(raw).trim();
|
|
228
|
+
if (!str) return void 0;
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = JSON.parse(str);
|
|
232
|
+
} catch {
|
|
233
|
+
return [toOrderByEntry(str, 0)];
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(parsed)) {
|
|
236
|
+
if (parsed.length === 0) return void 0;
|
|
237
|
+
return parsed.map(toOrderByEntry);
|
|
238
|
+
}
|
|
239
|
+
if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
|
|
240
|
+
if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
|
|
241
|
+
invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* {@link resolveClientListLimit} for an HTTP route: the same bounds, answered
|
|
245
|
+
* with a 400 rather than a 500.
|
|
246
|
+
*
|
|
247
|
+
* The shared resolver throws a `ListLimitError`, which carries `status` — but
|
|
248
|
+
* the Hono error handler discriminates on `statusCode`, so an unconverted one
|
|
249
|
+
* reaches the client as `INTERNAL_ERROR` with its message stripped, telling the
|
|
250
|
+
* caller nothing about the parameter it got wrong. Every REST list ingress
|
|
251
|
+
* routes its `limit` through here so all of them name the ceiling the same way.
|
|
252
|
+
*/
|
|
253
|
+
function resolveListLimitParam(rawLimit, opts = {}) {
|
|
254
|
+
try {
|
|
255
|
+
return resolveClientListLimit(rawLimit, opts);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (e instanceof ListLimitError) throw invalidParam(e.message, "INVALID_LIMIT");
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Parse query parameters into QueryOptions
|
|
263
|
+
*/
|
|
264
|
+
function parseQueryOptions(query, limits = {}) {
|
|
265
|
+
const options = {};
|
|
266
|
+
const rawLimit = getLastValue(query.limit);
|
|
267
|
+
const offsetVal = getLastValue(query.offset);
|
|
268
|
+
if (offsetVal) options.offset = parseInt(String(offsetVal));
|
|
269
|
+
const pageVal = getLastValue(query.page);
|
|
270
|
+
if (pageVal) {
|
|
271
|
+
const page = parseInt(String(pageVal));
|
|
272
|
+
const limit = resolveListLimitParam(rawLimit, {
|
|
273
|
+
defaultLimit: limits.defaultLimit,
|
|
274
|
+
maxLimit: limits.maxLimit
|
|
275
|
+
});
|
|
276
|
+
options.offset = (page - 1) * limit;
|
|
277
|
+
}
|
|
278
|
+
const orVal = getLastValue(query.or);
|
|
279
|
+
const andVal = getLastValue(query.and);
|
|
280
|
+
if (orVal) {
|
|
281
|
+
const logical = parseLogicalGroup("or", orVal);
|
|
282
|
+
if (logical) options.logical = logical;
|
|
283
|
+
} else if (andVal) {
|
|
284
|
+
const logical = parseLogicalGroup("and", andVal);
|
|
285
|
+
if (logical) options.logical = logical;
|
|
286
|
+
}
|
|
287
|
+
const reservedQueryKeys = [
|
|
288
|
+
"limit",
|
|
289
|
+
"offset",
|
|
290
|
+
"page",
|
|
291
|
+
"orderBy",
|
|
292
|
+
"include",
|
|
293
|
+
"fields",
|
|
294
|
+
"searchString",
|
|
295
|
+
"searchExplain",
|
|
296
|
+
"vector_search",
|
|
297
|
+
"vector",
|
|
298
|
+
"vector_distance",
|
|
299
|
+
"vector_threshold",
|
|
300
|
+
"or",
|
|
301
|
+
"and",
|
|
302
|
+
"where"
|
|
303
|
+
];
|
|
304
|
+
const filterDict = {};
|
|
305
|
+
for (const [key, rawValue] of Object.entries(query)) {
|
|
306
|
+
if (reservedQueryKeys.includes(key)) continue;
|
|
307
|
+
filterDict[key] = rawValue;
|
|
308
|
+
}
|
|
309
|
+
const whereVal = getLastValue(query.where);
|
|
310
|
+
const where = {
|
|
311
|
+
...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
|
|
312
|
+
...decodeFilter(filterDict)
|
|
313
|
+
};
|
|
314
|
+
if (Object.keys(where).length > 0) options.where = where;
|
|
315
|
+
const orderByVal = getLastValue(query.orderBy);
|
|
316
|
+
if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
|
|
317
|
+
const includeVal = getLastValue(query.include);
|
|
318
|
+
if (includeVal) {
|
|
319
|
+
const includeStr = String(includeVal).trim();
|
|
320
|
+
if (includeStr === "*") options.include = ["*"];
|
|
321
|
+
else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
322
|
+
}
|
|
323
|
+
const fieldsVal = getLastValue(query.fields);
|
|
324
|
+
if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
|
|
325
|
+
const vectorSearchVal = getLastValue(query.vector_search);
|
|
326
|
+
const vectorVal = getLastValue(query.vector);
|
|
327
|
+
if (vectorSearchVal && vectorVal) {
|
|
328
|
+
const vectorStr = String(vectorVal);
|
|
329
|
+
let decoded;
|
|
330
|
+
try {
|
|
331
|
+
decoded = JSON.parse(vectorStr);
|
|
332
|
+
} catch {
|
|
333
|
+
decoded = void 0;
|
|
334
|
+
}
|
|
335
|
+
if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw invalidParam("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
|
|
336
|
+
const queryVector = decoded;
|
|
337
|
+
const distanceParamVal = getLastValue(query.vector_distance);
|
|
338
|
+
const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
|
|
339
|
+
if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw invalidParam(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
|
|
340
|
+
const vectorSearch = {
|
|
341
|
+
property: String(vectorSearchVal),
|
|
342
|
+
vector: queryVector,
|
|
343
|
+
distance: distanceParam
|
|
344
|
+
};
|
|
345
|
+
const thresholdVal = getLastValue(query.vector_threshold);
|
|
346
|
+
if (thresholdVal) {
|
|
347
|
+
const threshold = parseFloat(String(thresholdVal));
|
|
348
|
+
if (isNaN(threshold)) throw invalidParam("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
|
|
349
|
+
vectorSearch.threshold = threshold;
|
|
350
|
+
}
|
|
351
|
+
options.vectorSearch = vectorSearch;
|
|
352
|
+
}
|
|
353
|
+
options.limit = resolveListLimitParam(rawLimit, {
|
|
354
|
+
vectorSearch: !!options.vectorSearch,
|
|
355
|
+
defaultLimit: limits.defaultLimit,
|
|
356
|
+
maxLimit: limits.maxLimit
|
|
357
|
+
});
|
|
358
|
+
return options;
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
77
361
|
//#region src/auth/api-keys/api-key-permission-guard.ts
|
|
78
362
|
/**
|
|
79
363
|
* Map an HTTP method string to an `ApiKeyOperation`.
|
|
@@ -148,9 +432,116 @@ function isFunctionAllowed(permissions, functionName, operation) {
|
|
|
148
432
|
return false;
|
|
149
433
|
}
|
|
150
434
|
//#endregion
|
|
435
|
+
//#region src/auth/token-revocation.ts
|
|
436
|
+
/**
|
|
437
|
+
* Has this access token been revoked?
|
|
438
|
+
*
|
|
439
|
+
* `logout`, `change-password`, `reset-password` and `DELETE /auth/sessions` all
|
|
440
|
+
* stamp a `tokensValidAfter` watermark on the user. Every one of them also
|
|
441
|
+
* deletes refresh-token rows, which is what made the gap easy to miss: the
|
|
442
|
+
* session really is gone, and the *refresh* path really does check the
|
|
443
|
+
* watermark — so signing out looked like it worked.
|
|
444
|
+
*
|
|
445
|
+
* The access token was untouched. It is a bearer credential that nothing
|
|
446
|
+
* consulted a database about, so it stayed valid for its full lifetime after
|
|
447
|
+
* every one of those actions. "Sign out everywhere" left the stolen token
|
|
448
|
+
* working; so did changing your password because you thought it had leaked.
|
|
449
|
+
*
|
|
450
|
+
* The watermark had exactly one read in the repository, on refresh. This adds
|
|
451
|
+
* the other one.
|
|
452
|
+
*
|
|
453
|
+
* ## Cost
|
|
454
|
+
*
|
|
455
|
+
* One indexed lookup per request, on the paths that already make one. The
|
|
456
|
+
* adapter re-reads roles for every authenticated request; the admin routes now
|
|
457
|
+
* do too. Both call this with the row they were already fetching in mind — a
|
|
458
|
+
* repository that wants to serve both from one query is free to cache
|
|
459
|
+
* internally, but nothing here assumes it.
|
|
460
|
+
*
|
|
461
|
+
* ## Failure
|
|
462
|
+
*
|
|
463
|
+
* Fails **open**, deliberately, and this is the one place in the auth stack
|
|
464
|
+
* where that is right. The watermark is an extra revocation signal layered over
|
|
465
|
+
* a token that has already been verified — signature, expiry and purpose all
|
|
466
|
+
* checked. Refusing every request when the database is unreachable would turn a
|
|
467
|
+
* transient outage into a total sign-out of an entire deployment, and the
|
|
468
|
+
* attacker this protects against needs to have already stolen a live token. The
|
|
469
|
+
* failure is logged at warn so it is visible rather than silent.
|
|
470
|
+
*/
|
|
471
|
+
async function isAccessTokenRevoked(authRepo, payload) {
|
|
472
|
+
if (typeof authRepo.getTokensValidAfter !== "function") return false;
|
|
473
|
+
if (typeof payload.iat !== "number") return false;
|
|
474
|
+
let validAfter;
|
|
475
|
+
try {
|
|
476
|
+
validAfter = await authRepo.getTokensValidAfter(payload.uid);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
logger.warn("[Auth] Could not read the token revocation watermark; allowing the request", {
|
|
479
|
+
uid: payload.uid,
|
|
480
|
+
error
|
|
481
|
+
});
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
if (!validAfter) return false;
|
|
485
|
+
return payload.iat < Math.floor(validAfter.getTime() / 1e3);
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/auth/admin-roles.ts
|
|
489
|
+
/**
|
|
490
|
+
* Which role names carry administrative privilege.
|
|
491
|
+
*
|
|
492
|
+
* One definition, because there used to be several and they disagreed.
|
|
493
|
+
* `requireAdmin` accepted `admin` **or** `schema-admin`; the guard that refuses
|
|
494
|
+
* a dangerous `defaultRole` compared against `admin` alone. So
|
|
495
|
+
* `AUTH_DEFAULT_ROLE=schema-admin` passed the guard and made every public
|
|
496
|
+
* registrant an administrator — and, since a `schema-admin` may edit users, one
|
|
497
|
+
* of them could then grant themselves real `admin` and keep it.
|
|
498
|
+
*
|
|
499
|
+
* The guard and the check have to read the same list or the gap comes back, so
|
|
500
|
+
* neither of them owns it.
|
|
501
|
+
*
|
|
502
|
+
* `schema-admin` is here deliberately: it can reach the schema editor and the
|
|
503
|
+
* SQL surfaces, which is administrative by any definition that matters. If a
|
|
504
|
+
* genuinely lesser role is added later, it does not belong in this list — it
|
|
505
|
+
* belongs in a capability check of its own.
|
|
506
|
+
*/
|
|
507
|
+
var ADMINISTRATIVE_ROLES = ["admin", "schema-admin"];
|
|
508
|
+
/** Does this single role name carry administrative privilege? */
|
|
509
|
+
function isAdministrativeRole(role) {
|
|
510
|
+
return ADMINISTRATIVE_ROLES.includes(role);
|
|
511
|
+
}
|
|
512
|
+
/** Does any role in this list carry administrative privilege? */
|
|
513
|
+
function hasAdministrativeRole(roles) {
|
|
514
|
+
return !!roles?.some(isAdministrativeRole);
|
|
515
|
+
}
|
|
516
|
+
//#endregion
|
|
151
517
|
//#region src/auth/rls-scope.ts
|
|
152
518
|
var import_jsonwebtoken = /* @__PURE__ */ __toESM(require_jsonwebtoken(), 1);
|
|
153
519
|
/**
|
|
520
|
+
* The identity the trusted server plane runs as: `rebase.dataAsAdmin`, and any
|
|
521
|
+
* caller presenting the service key.
|
|
522
|
+
*
|
|
523
|
+
* Naming it makes the privilege legible, because it is narrower than it reads.
|
|
524
|
+
* This is **not** an RLS bypass — a driver scoped with it is an
|
|
525
|
+
* `AuthenticatedPostgresBackendDriver`, so every statement runs in a
|
|
526
|
+
* transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
527
|
+
* `app.uid = 'service'`, and policies are evaluated. Two consequences worth
|
|
528
|
+
* knowing before you write a policy:
|
|
529
|
+
*
|
|
530
|
+
* - It passes the default policies through their `rolesOverlap(['admin'])`
|
|
531
|
+
* arm — the same arm an application user holding the `admin` role passes.
|
|
532
|
+
* - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
|
|
533
|
+
* **false** for it. A collection with `disableDefaultPolicies: true` whose
|
|
534
|
+
* only rule is `serverContext()` denies these writes (42501) and returns
|
|
535
|
+
* zero rows for these reads.
|
|
536
|
+
*
|
|
537
|
+
* The true bypass is `rebase.sql()`, which runs on the owner connection and
|
|
538
|
+
* never goes near `withAuth`.
|
|
539
|
+
*/
|
|
540
|
+
var SERVICE_IDENTITY = {
|
|
541
|
+
uid: "service",
|
|
542
|
+
roles: ["admin"]
|
|
543
|
+
};
|
|
544
|
+
/**
|
|
154
545
|
* Returns true if the driver supports RLS scoping via `withAuth()`.
|
|
155
546
|
*/
|
|
156
547
|
function isRLSScopedDriver(driver) {
|
|
@@ -443,7 +834,7 @@ function createApiKeyPreAuth(options) {
|
|
|
443
834
|
//#endregion
|
|
444
835
|
//#region src/storage/keys.ts
|
|
445
836
|
/**
|
|
446
|
-
* Canonical storage keys.
|
|
837
|
+
* Canonical storage keys and bucket names.
|
|
447
838
|
*
|
|
448
839
|
* Storage is not under RLS, so a `storageAuthorize` hook is the whole access
|
|
449
840
|
* control model — and a hook can only be correct if the key it is shown is the
|
|
@@ -507,9 +898,12 @@ function canonicalStorageKey(rawKey) {
|
|
|
507
898
|
if (rawKey.split(/[\\/]/).some((segment) => segment === "..")) throw new InvalidStorageKeyError("Storage key contains a '..' path segment. Keys must name an object directly.");
|
|
508
899
|
const withoutLeadingSlashes = rawKey.replace(/^\/+/, "");
|
|
509
900
|
if (withoutLeadingSlashes === "") return "";
|
|
901
|
+
const denotesDirectory = /(?:^|\/)\.?$/.test(withoutLeadingSlashes);
|
|
510
902
|
const normalized = path.posix.normalize(withoutLeadingSlashes);
|
|
511
903
|
if (normalized === "." || normalized === "./") return "";
|
|
512
|
-
|
|
904
|
+
const key = normalized.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
905
|
+
if (key === "") return "";
|
|
906
|
+
return denotesDirectory && !key.endsWith("/") ? `${key}/` : key;
|
|
513
907
|
}
|
|
514
908
|
/**
|
|
515
909
|
* Canonicalize, or return `null` when the key is not canonicalizable.
|
|
@@ -525,6 +919,49 @@ function tryCanonicalStorageKey(rawKey) {
|
|
|
525
919
|
return null;
|
|
526
920
|
}
|
|
527
921
|
}
|
|
922
|
+
/** A bucket name that does not name a bucket. See {@link canonicalStorageBucket}. */
|
|
923
|
+
var InvalidStorageBucketError = class extends Error {
|
|
924
|
+
constructor(message) {
|
|
925
|
+
super(message);
|
|
926
|
+
this.name = "InvalidStorageBucketError";
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
/**
|
|
930
|
+
* One path segment: letters, digits, `.`, `_`, `-`, first character
|
|
931
|
+
* alphanumeric. Deliberately narrow — it is the intersection of what S3, GCS
|
|
932
|
+
* and a filesystem directory all accept, and it makes `..`, `.tus-uploads`,
|
|
933
|
+
* absolute paths and anything containing a separator unrepresentable.
|
|
934
|
+
*/
|
|
935
|
+
var STORAGE_BUCKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
936
|
+
/**
|
|
937
|
+
* Canonicalize a caller-supplied bucket name, or throw
|
|
938
|
+
* {@link InvalidStorageBucketError}.
|
|
939
|
+
*
|
|
940
|
+
* The bucket is the *other* caller-controlled routing value in an upload
|
|
941
|
+
* request, and it was the one nobody validated. `LocalStorageController`
|
|
942
|
+
* builds `join(basePath, bucket)` and then checks containment against that
|
|
943
|
+
* result, so a bucket of `../../etc` moved the boundary rather than crossing
|
|
944
|
+
* it — the guard passed because the guard's reference point was the attacker's.
|
|
945
|
+
* The containment check now resolves against the storage root, and this is the
|
|
946
|
+
* check at the route boundary that stops the value before it gets there.
|
|
947
|
+
*
|
|
948
|
+
* A bucket is configuration, not user data: there is no legitimate caller that
|
|
949
|
+
* needs a separator, a leading dot, or a `..` in one. So this refuses rather
|
|
950
|
+
* than repairs, exactly as {@link canonicalStorageKey} does — a rewritten
|
|
951
|
+
* bucket would silently store the object somewhere the caller did not ask for.
|
|
952
|
+
*
|
|
953
|
+
* Returns `undefined` when the caller named no bucket (absent, or an empty
|
|
954
|
+
* form field), which is how every controller spells "use my default". An empty
|
|
955
|
+
* string used to reach `getFullPath` and resolve to the storage *root* rather
|
|
956
|
+
* than the `default` bucket, which is a third place a bare key did not
|
|
957
|
+
* round-trip.
|
|
958
|
+
*/
|
|
959
|
+
function canonicalStorageBucket(rawBucket) {
|
|
960
|
+
if (rawBucket === void 0 || rawBucket === null || rawBucket === "") return void 0;
|
|
961
|
+
if (rawBucket.length > 63) throw new InvalidStorageBucketError(`Storage bucket exceeds the maximum length of 63 characters.`);
|
|
962
|
+
if (!STORAGE_BUCKET_PATTERN.test(rawBucket)) throw new InvalidStorageBucketError("Storage bucket must be a single name of letters, digits, '.', '_' or '-', starting with a letter or digit.");
|
|
963
|
+
return rawBucket;
|
|
964
|
+
}
|
|
528
965
|
//#endregion
|
|
529
966
|
//#region src/auth/middleware.ts
|
|
530
967
|
/**
|
|
@@ -565,6 +1002,8 @@ var requireAuth = async (c, next) => {
|
|
|
565
1002
|
function createRequireAuth(options) {
|
|
566
1003
|
if (!options?.serviceKey) return requireAuth;
|
|
567
1004
|
const key = options.serviceKey;
|
|
1005
|
+
const resolveRoles = options.resolveRoles;
|
|
1006
|
+
const revocationRepo = options.revocationRepo;
|
|
568
1007
|
return async (c, next) => {
|
|
569
1008
|
if (c.get("user")) return next();
|
|
570
1009
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
@@ -584,6 +1023,26 @@ function createRequireAuth(options) {
|
|
|
584
1023
|
message: "Invalid or expired token",
|
|
585
1024
|
code: "UNAUTHORIZED"
|
|
586
1025
|
} }, 401);
|
|
1026
|
+
if (resolveRoles) try {
|
|
1027
|
+
if (revocationRepo && await isAccessTokenRevoked(revocationRepo, payload)) return c.json({ error: {
|
|
1028
|
+
message: "Session has been revoked",
|
|
1029
|
+
code: "SESSION_REVOKED"
|
|
1030
|
+
} }, 401);
|
|
1031
|
+
c.set("user", {
|
|
1032
|
+
...payload,
|
|
1033
|
+
roles: await resolveRoles(payload.uid)
|
|
1034
|
+
});
|
|
1035
|
+
return next();
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
logger.warn("[Auth] Could not resolve roles for an admin-gated request", {
|
|
1038
|
+
uid: payload.uid,
|
|
1039
|
+
error
|
|
1040
|
+
});
|
|
1041
|
+
return c.json({ error: {
|
|
1042
|
+
message: "Could not verify your permissions. Please try again.",
|
|
1043
|
+
code: "ROLE_LOOKUP_FAILED"
|
|
1044
|
+
} }, 503);
|
|
1045
|
+
}
|
|
587
1046
|
c.set("user", payload);
|
|
588
1047
|
return next();
|
|
589
1048
|
};
|
|
@@ -598,9 +1057,7 @@ var requireAdmin = async (c, next) => {
|
|
|
598
1057
|
message: "User not authenticated. requireAuth middleware is missing?",
|
|
599
1058
|
code: "UNAUTHORIZED"
|
|
600
1059
|
} }, 401);
|
|
601
|
-
if (!(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : []).
|
|
602
|
-
return role === "admin" || role === "schema-admin";
|
|
603
|
-
})) return c.json({ error: {
|
|
1060
|
+
if (!hasAdministrativeRole(typeof user === "object" && user !== null && "roles" in user ? user.roles || [] : [])) return c.json({ error: {
|
|
604
1061
|
message: "Admin privileges required for this operation",
|
|
605
1062
|
code: "FORBIDDEN"
|
|
606
1063
|
} }, 403);
|
|
@@ -930,8 +1387,9 @@ function createAdapterAuthMiddleware(options) {
|
|
|
930
1387
|
const { adapter, driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, apiKeyStore } = options;
|
|
931
1388
|
return async (c, next) => {
|
|
932
1389
|
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
1390
|
+
const presentedToken = extractBearerToken(c.req.header("authorization"));
|
|
933
1391
|
if (apiKeyStore) {
|
|
934
|
-
const token =
|
|
1392
|
+
const token = presentedToken ?? "";
|
|
935
1393
|
if (token.startsWith("rk_")) {
|
|
936
1394
|
const result = await validateApiKey(c, token, {
|
|
937
1395
|
store: apiKeyStore,
|
|
@@ -968,17 +1426,23 @@ function createAdapterAuthMiddleware(options) {
|
|
|
968
1426
|
code: "INTERNAL_ERROR"
|
|
969
1427
|
} }, 500);
|
|
970
1428
|
}
|
|
971
|
-
} else
|
|
972
|
-
c.
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
})
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
}
|
|
1429
|
+
} else {
|
|
1430
|
+
if (presentedToken !== void 0) return c.json({ error: {
|
|
1431
|
+
message: "Invalid or expired token",
|
|
1432
|
+
code: "UNAUTHORIZED"
|
|
1433
|
+
} }, 401);
|
|
1434
|
+
try {
|
|
1435
|
+
c.set("driver", await scopeDataDriver(driver, {
|
|
1436
|
+
uid: ANONYMOUS_USER_ID,
|
|
1437
|
+
roles: ["anon"]
|
|
1438
|
+
}));
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
logger.error("[AUTH-ADAPTER] Failed to create anon-scoped driver", { error });
|
|
1441
|
+
return c.json({ error: {
|
|
1442
|
+
message: "Server configuration error",
|
|
1443
|
+
code: "INTERNAL_ERROR"
|
|
1444
|
+
} }, 500);
|
|
1445
|
+
}
|
|
982
1446
|
}
|
|
983
1447
|
if (enforceAuth && !c.get("user")) return c.json({ error: {
|
|
984
1448
|
message: "Unauthorized: Authentication required",
|
|
@@ -988,43 +1452,141 @@ function createAdapterAuthMiddleware(options) {
|
|
|
988
1452
|
};
|
|
989
1453
|
}
|
|
990
1454
|
//#endregion
|
|
1455
|
+
//#region src/email/html.ts
|
|
1456
|
+
/**
|
|
1457
|
+
* HTML escaping for email bodies.
|
|
1458
|
+
*
|
|
1459
|
+
* Every default template interpolates values the server does not control — a
|
|
1460
|
+
* `displayName` chosen at registration, an `appName` from config, a link base a
|
|
1461
|
+
* host set — into markup that is then mailed, signed by the sending domain, to
|
|
1462
|
+
* an address the same request chose. Unescaped, that turns `POST /auth/register`
|
|
1463
|
+
* into a way to deliver arbitrary HTML (a heading, an anchor) from a domain
|
|
1464
|
+
* whose SPF and DKIM check out.
|
|
1465
|
+
*
|
|
1466
|
+
* The fix is deliberately not five escaping calls at five interpolation sites:
|
|
1467
|
+
* a sixth template would simply forget. Templates are built with the {@link html}
|
|
1468
|
+
* tag, which escapes *every* interpolated value by default, and markup that is
|
|
1469
|
+
* meant to pass through verbatim — the static style strings, a nested fragment —
|
|
1470
|
+
* must say so with {@link raw}. Forgetting `raw` produces visibly escaped text
|
|
1471
|
+
* in a test; forgetting to escape is invisible until someone exploits it.
|
|
1472
|
+
*/
|
|
1473
|
+
/**
|
|
1474
|
+
* Markup that has already been vetted and must be interpolated verbatim.
|
|
1475
|
+
*
|
|
1476
|
+
* Only ever construct this from a string literal in this package's own source
|
|
1477
|
+
* (or from the {@link html} tag, which produces one). Wrapping user input in
|
|
1478
|
+
* `raw()` defeats the entire mechanism.
|
|
1479
|
+
*/
|
|
1480
|
+
var RawHtml = class {
|
|
1481
|
+
value;
|
|
1482
|
+
constructor(value) {
|
|
1483
|
+
this.value = value;
|
|
1484
|
+
}
|
|
1485
|
+
toString() {
|
|
1486
|
+
return this.value;
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
/**
|
|
1490
|
+
* Mark a static, author-controlled string as safe to interpolate unescaped.
|
|
1491
|
+
*/
|
|
1492
|
+
function raw(markup) {
|
|
1493
|
+
return new RawHtml(markup);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Escape the five characters that can change the meaning of HTML text or of a
|
|
1497
|
+
* quoted attribute value.
|
|
1498
|
+
*
|
|
1499
|
+
* `&` goes first: escaping it after the others would double-escape the entities
|
|
1500
|
+
* they just produced. `'` is included because it is a legal attribute delimiter,
|
|
1501
|
+
* and `"` because it is the one this file uses.
|
|
1502
|
+
*/
|
|
1503
|
+
function escapeHtml(value) {
|
|
1504
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Render one interpolated value: pass {@link RawHtml} through, drop nullish,
|
|
1508
|
+
* escape everything else.
|
|
1509
|
+
*/
|
|
1510
|
+
function interpolate(value) {
|
|
1511
|
+
if (value instanceof RawHtml) return value.value;
|
|
1512
|
+
if (value === null || value === void 0) return "";
|
|
1513
|
+
return escapeHtml(String(value));
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Tagged template for email markup. Every `${...}` is escaped unless it is
|
|
1517
|
+
* {@link RawHtml}.
|
|
1518
|
+
*
|
|
1519
|
+
* Returns `RawHtml` so fragments nest without being escaped a second time:
|
|
1520
|
+
*
|
|
1521
|
+
* ```ts
|
|
1522
|
+
* const button = url ? html`<a href="${url}">Open</a>` : raw("");
|
|
1523
|
+
* const body = html`<div>${button}</div>`;
|
|
1524
|
+
* ```
|
|
1525
|
+
*/
|
|
1526
|
+
function html(strings, ...values) {
|
|
1527
|
+
let out = strings[0];
|
|
1528
|
+
for (let i = 0; i < values.length; i++) out += interpolate(values[i]) + strings[i + 1];
|
|
1529
|
+
return new RawHtml(out);
|
|
1530
|
+
}
|
|
1531
|
+
//#endregion
|
|
991
1532
|
//#region src/email/templates.ts
|
|
992
1533
|
/**
|
|
993
|
-
*
|
|
1534
|
+
* Default email templates for authentication emails.
|
|
1535
|
+
*
|
|
1536
|
+
* Every HTML body is built with the `html` tag from `./html`, which escapes each
|
|
1537
|
+
* interpolated value unless it is explicitly marked `raw`. `displayName` reaches
|
|
1538
|
+
* these templates straight from the registration body, and the resulting mail is
|
|
1539
|
+
* sent — signed by the sending domain — to an address the same anonymous request
|
|
1540
|
+
* chose, so an unescaped interpolation here is a phishing primitive, not a
|
|
1541
|
+
* rendering glitch. Escaping lives in the tag rather than at the call sites so a
|
|
1542
|
+
* sixth template inherits it instead of having to remember it.
|
|
1543
|
+
*/
|
|
1544
|
+
/**
|
|
1545
|
+
* Get a greeting name for the user.
|
|
1546
|
+
*
|
|
1547
|
+
* Returns the raw value — `displayName` is whatever the account was registered
|
|
1548
|
+
* with, including markup. Escaping is the `html` tag's job, so that a template
|
|
1549
|
+
* that interpolates the greeting into text (the plain-text bodies below) and one
|
|
1550
|
+
* that interpolates it into markup can share this.
|
|
994
1551
|
*/
|
|
995
1552
|
function getGreeting(user) {
|
|
996
1553
|
return user.displayName || user.email.split("@")[0];
|
|
997
1554
|
}
|
|
998
1555
|
/**
|
|
999
|
-
* Common email styles
|
|
1556
|
+
* Common email styles.
|
|
1557
|
+
*
|
|
1558
|
+
* `raw` because these are static, author-written CSS — the one category of
|
|
1559
|
+
* interpolation the `html` tag lets through unescaped. (Escaping them would
|
|
1560
|
+
* survive an HTML parser but not every mail client's CSS parser: the
|
|
1561
|
+
* `'Segoe UI'` quotes would arrive as entities.)
|
|
1000
1562
|
*/
|
|
1001
1563
|
var styles = {
|
|
1002
|
-
container: `
|
|
1564
|
+
container: raw(`
|
|
1003
1565
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
1004
1566
|
max-width: 600px;
|
|
1005
1567
|
margin: 0 auto;
|
|
1006
1568
|
padding: 40px 20px;
|
|
1007
1569
|
background-color: #f8fafc;
|
|
1008
|
-
|
|
1009
|
-
card: `
|
|
1570
|
+
`),
|
|
1571
|
+
card: raw(`
|
|
1010
1572
|
background-color: #ffffff;
|
|
1011
1573
|
border-radius: 12px;
|
|
1012
1574
|
padding: 40px;
|
|
1013
1575
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
1014
|
-
|
|
1015
|
-
heading: `
|
|
1576
|
+
`),
|
|
1577
|
+
heading: raw(`
|
|
1016
1578
|
color: #1e293b;
|
|
1017
1579
|
font-size: 24px;
|
|
1018
1580
|
font-weight: 600;
|
|
1019
1581
|
margin: 0 0 20px 0;
|
|
1020
|
-
|
|
1021
|
-
paragraph: `
|
|
1582
|
+
`),
|
|
1583
|
+
paragraph: raw(`
|
|
1022
1584
|
color: #475569;
|
|
1023
1585
|
font-size: 16px;
|
|
1024
1586
|
line-height: 1.6;
|
|
1025
1587
|
margin: 0 0 20px 0;
|
|
1026
|
-
|
|
1027
|
-
button: `
|
|
1588
|
+
`),
|
|
1589
|
+
button: raw(`
|
|
1028
1590
|
display: inline-block;
|
|
1029
1591
|
background-color: #3b82f6;
|
|
1030
1592
|
color: #ffffff;
|
|
@@ -1034,22 +1596,22 @@ var styles = {
|
|
|
1034
1596
|
padding: 14px 28px;
|
|
1035
1597
|
border-radius: 8px;
|
|
1036
1598
|
margin: 20px 0;
|
|
1037
|
-
|
|
1038
|
-
footer: `
|
|
1599
|
+
`),
|
|
1600
|
+
footer: raw(`
|
|
1039
1601
|
color: #94a3b8;
|
|
1040
1602
|
font-size: 14px;
|
|
1041
1603
|
margin-top: 30px;
|
|
1042
1604
|
padding-top: 20px;
|
|
1043
1605
|
border-top: 1px solid #e2e8f0;
|
|
1044
|
-
|
|
1045
|
-
warning: `
|
|
1606
|
+
`),
|
|
1607
|
+
warning: raw(`
|
|
1046
1608
|
color: #64748b;
|
|
1047
1609
|
font-size: 14px;
|
|
1048
1610
|
background-color: #fef3c7;
|
|
1049
1611
|
padding: 12px 16px;
|
|
1050
1612
|
border-radius: 6px;
|
|
1051
1613
|
margin-top: 20px;
|
|
1052
|
-
`
|
|
1614
|
+
`)
|
|
1053
1615
|
};
|
|
1054
1616
|
/**
|
|
1055
1617
|
* Default password reset email template
|
|
@@ -1057,9 +1619,7 @@ var styles = {
|
|
|
1057
1619
|
function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
|
|
1058
1620
|
const greeting = getGreeting(user);
|
|
1059
1621
|
const subject = `Reset your ${appName} password`;
|
|
1060
|
-
|
|
1061
|
-
subject,
|
|
1062
|
-
html: `
|
|
1622
|
+
const body = html`
|
|
1063
1623
|
<!DOCTYPE html>
|
|
1064
1624
|
<html>
|
|
1065
1625
|
<head>
|
|
@@ -1106,8 +1666,8 @@ function getPasswordResetTemplate(resetUrl, user, appName = "Rebase") {
|
|
|
1106
1666
|
</div>
|
|
1107
1667
|
</body>
|
|
1108
1668
|
</html>
|
|
1109
|
-
|
|
1110
|
-
|
|
1669
|
+
`;
|
|
1670
|
+
const text = `
|
|
1111
1671
|
Reset Your Password
|
|
1112
1672
|
|
|
1113
1673
|
Hi ${greeting},
|
|
@@ -1121,7 +1681,11 @@ This link will expire in 1 hour for security reasons.
|
|
|
1121
1681
|
|
|
1122
1682
|
If you didn't request a password reset, you can safely ignore this email.
|
|
1123
1683
|
Your password will remain unchanged.
|
|
1124
|
-
`.trim()
|
|
1684
|
+
`.trim();
|
|
1685
|
+
return {
|
|
1686
|
+
subject,
|
|
1687
|
+
html: body.toString().trim(),
|
|
1688
|
+
text
|
|
1125
1689
|
};
|
|
1126
1690
|
}
|
|
1127
1691
|
/**
|
|
@@ -1130,9 +1694,7 @@ Your password will remain unchanged.
|
|
|
1130
1694
|
function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
|
|
1131
1695
|
const greeting = getGreeting(user);
|
|
1132
1696
|
const subject = `Verify your ${appName} email address`;
|
|
1133
|
-
|
|
1134
|
-
subject,
|
|
1135
|
-
html: `
|
|
1697
|
+
const body = html`
|
|
1136
1698
|
<!DOCTYPE html>
|
|
1137
1699
|
<html>
|
|
1138
1700
|
<head>
|
|
@@ -1174,8 +1736,8 @@ function getEmailVerificationTemplate(verifyUrl, user, appName = "Rebase") {
|
|
|
1174
1736
|
</div>
|
|
1175
1737
|
</body>
|
|
1176
1738
|
</html>
|
|
1177
|
-
|
|
1178
|
-
|
|
1739
|
+
`;
|
|
1740
|
+
const text = `
|
|
1179
1741
|
Verify Your Email
|
|
1180
1742
|
|
|
1181
1743
|
Hi ${greeting},
|
|
@@ -1184,7 +1746,11 @@ Thanks for signing up for ${appName}! Please verify your email address by clicki
|
|
|
1184
1746
|
${verifyUrl}
|
|
1185
1747
|
|
|
1186
1748
|
If you didn't create an account with ${appName}, you can safely ignore this email.
|
|
1187
|
-
`.trim()
|
|
1749
|
+
`.trim();
|
|
1750
|
+
return {
|
|
1751
|
+
subject,
|
|
1752
|
+
html: body.toString().trim(),
|
|
1753
|
+
text
|
|
1188
1754
|
};
|
|
1189
1755
|
}
|
|
1190
1756
|
/**
|
|
@@ -1194,9 +1760,7 @@ If you didn't create an account with ${appName}, you can safely ignore this emai
|
|
|
1194
1760
|
function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
|
|
1195
1761
|
const greeting = getGreeting(user);
|
|
1196
1762
|
const subject = `You've been invited to ${appName}`;
|
|
1197
|
-
|
|
1198
|
-
subject,
|
|
1199
|
-
html: `
|
|
1763
|
+
const body = html`
|
|
1200
1764
|
<!DOCTYPE html>
|
|
1201
1765
|
<html>
|
|
1202
1766
|
<head>
|
|
@@ -1242,8 +1806,8 @@ function getUserInvitationTemplate(setPasswordUrl, user, appName = "Rebase") {
|
|
|
1242
1806
|
</div>
|
|
1243
1807
|
</body>
|
|
1244
1808
|
</html>
|
|
1245
|
-
|
|
1246
|
-
|
|
1809
|
+
`;
|
|
1810
|
+
const text = `
|
|
1247
1811
|
Welcome to ${appName}!
|
|
1248
1812
|
|
|
1249
1813
|
Hi ${greeting},
|
|
@@ -1256,7 +1820,11 @@ ${setPasswordUrl}
|
|
|
1256
1820
|
This link will expire in 1 hour for security reasons.
|
|
1257
1821
|
|
|
1258
1822
|
If you weren't expecting this invitation, you can safely ignore this email.
|
|
1259
|
-
`.trim()
|
|
1823
|
+
`.trim();
|
|
1824
|
+
return {
|
|
1825
|
+
subject,
|
|
1826
|
+
html: body.toString().trim(),
|
|
1827
|
+
text
|
|
1260
1828
|
};
|
|
1261
1829
|
}
|
|
1262
1830
|
/**
|
|
@@ -1267,9 +1835,7 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
|
|
|
1267
1835
|
const greeting = getGreeting(user);
|
|
1268
1836
|
const url = loginUrl || "";
|
|
1269
1837
|
const subject = `¡Bienvenido/a a ${appName}!`;
|
|
1270
|
-
|
|
1271
|
-
subject,
|
|
1272
|
-
html: `
|
|
1838
|
+
const body = html`
|
|
1273
1839
|
<!DOCTYPE html>
|
|
1274
1840
|
<html>
|
|
1275
1841
|
<head>
|
|
@@ -1296,11 +1862,11 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
|
|
|
1296
1862
|
que tenemos para ti.
|
|
1297
1863
|
</p>
|
|
1298
1864
|
|
|
1299
|
-
${url ? `
|
|
1865
|
+
${url ? html`
|
|
1300
1866
|
<div style="text-align: center;">
|
|
1301
1867
|
<a href="${url}" style="${styles.button}">Ir a mi Panel</a>
|
|
1302
1868
|
</div>
|
|
1303
|
-
` : ""}
|
|
1869
|
+
` : raw("")}
|
|
1304
1870
|
|
|
1305
1871
|
<p style="${styles.paragraph}">
|
|
1306
1872
|
Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
@@ -1315,8 +1881,8 @@ function getWelcomeEmailTemplate(user, appName = "Rebase", loginUrl) {
|
|
|
1315
1881
|
</div>
|
|
1316
1882
|
</body>
|
|
1317
1883
|
</html>
|
|
1318
|
-
|
|
1319
|
-
|
|
1884
|
+
`;
|
|
1885
|
+
const text = `
|
|
1320
1886
|
¡Bienvenido/a a ${appName}!
|
|
1321
1887
|
|
|
1322
1888
|
Hola ${greeting},
|
|
@@ -1328,7 +1894,11 @@ Ya puedes acceder a tu panel y empezar a explorar todas las oportunidades que te
|
|
|
1328
1894
|
${url ? `Ir a mi panel: ${url}` : ""}
|
|
1329
1895
|
|
|
1330
1896
|
Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
1331
|
-
`.trim()
|
|
1897
|
+
`.trim();
|
|
1898
|
+
return {
|
|
1899
|
+
subject,
|
|
1900
|
+
html: body.toString().trim(),
|
|
1901
|
+
text
|
|
1332
1902
|
};
|
|
1333
1903
|
}
|
|
1334
1904
|
/**
|
|
@@ -1337,9 +1907,7 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
|
1337
1907
|
function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
|
|
1338
1908
|
const greeting = getGreeting(user);
|
|
1339
1909
|
const subject = `Sign in to ${appName}`;
|
|
1340
|
-
|
|
1341
|
-
subject,
|
|
1342
|
-
html: `
|
|
1910
|
+
const body = html`
|
|
1343
1911
|
<!DOCTYPE html>
|
|
1344
1912
|
<html>
|
|
1345
1913
|
<head>
|
|
@@ -1386,8 +1954,8 @@ function getMagicLinkTemplate(magicLinkUrl, user, appName = "Rebase") {
|
|
|
1386
1954
|
</div>
|
|
1387
1955
|
</body>
|
|
1388
1956
|
</html>
|
|
1389
|
-
|
|
1390
|
-
|
|
1957
|
+
`;
|
|
1958
|
+
const text = `
|
|
1391
1959
|
Sign In to ${appName}
|
|
1392
1960
|
|
|
1393
1961
|
Hi ${greeting},
|
|
@@ -1401,7 +1969,11 @@ This link will expire in 15 minutes for security reasons and can only be used on
|
|
|
1401
1969
|
|
|
1402
1970
|
If you didn't request this sign-in link, you can safely ignore this email.
|
|
1403
1971
|
No action is needed.
|
|
1404
|
-
`.trim()
|
|
1972
|
+
`.trim();
|
|
1973
|
+
return {
|
|
1974
|
+
subject,
|
|
1975
|
+
html: body.toString().trim(),
|
|
1976
|
+
text
|
|
1405
1977
|
};
|
|
1406
1978
|
}
|
|
1407
1979
|
//#endregion
|
|
@@ -1668,6 +2240,196 @@ function resolveAuthHooks(hooks) {
|
|
|
1668
2240
|
};
|
|
1669
2241
|
}
|
|
1670
2242
|
//#endregion
|
|
2243
|
+
//#region src/email/link-base.ts
|
|
2244
|
+
/**
|
|
2245
|
+
* The config fields consulted for each link kind, most specific first.
|
|
2246
|
+
*
|
|
2247
|
+
* `resetPasswordUrl` is the general fallback because it is the only one any boot
|
|
2248
|
+
* path sets, and in every real deployment all three point at the same frontend.
|
|
2249
|
+
*/
|
|
2250
|
+
var LINK_BASE_FIELDS = {
|
|
2251
|
+
resetPassword: ["resetPasswordUrl"],
|
|
2252
|
+
verifyEmail: ["verifyEmailUrl", "resetPasswordUrl"],
|
|
2253
|
+
magicLink: ["magicLinkUrl", "resetPasswordUrl"]
|
|
2254
|
+
};
|
|
2255
|
+
/**
|
|
2256
|
+
* True when `value` is an absolute `http(s)` URL — the only kind a mail client
|
|
2257
|
+
* can follow. A protocol-relative or path-only base is treated as missing.
|
|
2258
|
+
*/
|
|
2259
|
+
function isAbsoluteHttpUrl(value) {
|
|
2260
|
+
try {
|
|
2261
|
+
const url = new URL(value);
|
|
2262
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
2263
|
+
} catch {
|
|
2264
|
+
return false;
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
/** Drop a trailing slash so callers can append `/verify-email?token=…`. */
|
|
2268
|
+
function normalizeBase(value) {
|
|
2269
|
+
return value.replace(/\/+$/, "");
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* The absolute base URL for a link kind, or `""` when the config has none.
|
|
2273
|
+
*
|
|
2274
|
+
* Callers append their path to the result. `""` is only reachable on a config
|
|
2275
|
+
* that never went through {@link assertEmailLinkBases} — a hand-constructed
|
|
2276
|
+
* `SMTPEmailService` in a test, say — and is kept rather than thrown so that a
|
|
2277
|
+
* misconfiguration cannot turn a password-reset request into a 500 that
|
|
2278
|
+
* distinguishes existing accounts from missing ones.
|
|
2279
|
+
*/
|
|
2280
|
+
function resolveEmailLinkBase(config, kind) {
|
|
2281
|
+
if (!config) return "";
|
|
2282
|
+
for (const field of LINK_BASE_FIELDS[kind]) {
|
|
2283
|
+
const value = config[field];
|
|
2284
|
+
if (typeof value === "string" && isAbsoluteHttpUrl(value)) return normalizeBase(value);
|
|
2285
|
+
}
|
|
2286
|
+
return "";
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Throw when an email configuration cannot produce a followable link.
|
|
2290
|
+
*
|
|
2291
|
+
* Called from `createEmailService`, i.e. from every boot path that wires email
|
|
2292
|
+
* up (the managed runtime, both driver bootstrappers, and any app that passes
|
|
2293
|
+
* `auth.email`). A base that is set but relative is reported separately from one
|
|
2294
|
+
* that is missing, because the two have different fixes.
|
|
2295
|
+
*/
|
|
2296
|
+
function assertEmailLinkBases(config) {
|
|
2297
|
+
const relative = [
|
|
2298
|
+
"resetPasswordUrl",
|
|
2299
|
+
"verifyEmailUrl",
|
|
2300
|
+
"magicLinkUrl"
|
|
2301
|
+
].filter((field) => typeof config[field] === "string" && config[field] !== "" && !isAbsoluteHttpUrl(config[field]));
|
|
2302
|
+
if (relative.length > 0) throw new Error(`Email is configured with a relative link base: ${relative.map((f) => `email.${f}`).join(", ")}. Emailed links must be absolute (e.g. "https://app.example.com") — a mail client has no base document to resolve a relative href against, so the link is dead.`);
|
|
2303
|
+
if (!resolveEmailLinkBase(config, "resetPassword") && !resolveEmailLinkBase(config, "verifyEmail")) throw new Error("Email is configured but no base URL for emailed links is set. Password-reset, verification and magic-link emails would carry relative hrefs, which are dead links in every mail client. Set FRONTEND_URL (or `auth.email.resetPasswordUrl`) to an absolute URL such as \"https://app.example.com\".");
|
|
2304
|
+
}
|
|
2305
|
+
//#endregion
|
|
2306
|
+
//#region src/email/smtp-email-service.ts
|
|
2307
|
+
var _nodemailer;
|
|
2308
|
+
async function loadNodemailer() {
|
|
2309
|
+
if (!_nodemailer) try {
|
|
2310
|
+
_nodemailer = await import("nodemailer");
|
|
2311
|
+
} catch {
|
|
2312
|
+
throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
|
|
2313
|
+
}
|
|
2314
|
+
return _nodemailer;
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Safely parse a hostname from a URL string
|
|
2318
|
+
*/
|
|
2319
|
+
function getHostname(urlStr) {
|
|
2320
|
+
try {
|
|
2321
|
+
return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
|
|
2322
|
+
} catch {
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* SMTP Email Service implementation using Nodemailer
|
|
2328
|
+
*/
|
|
2329
|
+
var SMTPEmailService = class {
|
|
2330
|
+
transporter = null;
|
|
2331
|
+
config;
|
|
2332
|
+
_initialized = false;
|
|
2333
|
+
constructor(config) {
|
|
2334
|
+
this.config = config;
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Lazily initialize the SMTP transporter on first use
|
|
2338
|
+
*/
|
|
2339
|
+
async ensureTransporter() {
|
|
2340
|
+
if (this._initialized) return;
|
|
2341
|
+
this._initialized = true;
|
|
2342
|
+
if (this.config.smtp) {
|
|
2343
|
+
const nodemailer = await loadNodemailer();
|
|
2344
|
+
let smtpName = this.config.smtp.name;
|
|
2345
|
+
if (!smtpName) {
|
|
2346
|
+
const urlsToTry = [
|
|
2347
|
+
process.env.FRONTEND_URL,
|
|
2348
|
+
this.config.resetPasswordUrl,
|
|
2349
|
+
this.config.verifyEmailUrl
|
|
2350
|
+
];
|
|
2351
|
+
for (const urlStr of urlsToTry) if (urlStr) {
|
|
2352
|
+
const hostname = getHostname(urlStr);
|
|
2353
|
+
if (hostname) {
|
|
2354
|
+
smtpName = hostname;
|
|
2355
|
+
break;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
this.transporter = nodemailer.createTransport({
|
|
2360
|
+
name: smtpName,
|
|
2361
|
+
host: this.config.smtp.host,
|
|
2362
|
+
port: this.config.smtp.port,
|
|
2363
|
+
secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
|
|
2364
|
+
auth: this.config.smtp.auth ? {
|
|
2365
|
+
user: this.config.smtp.auth.user,
|
|
2366
|
+
pass: this.config.smtp.auth.pass
|
|
2367
|
+
} : void 0
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* Check if the email service is properly configured
|
|
2373
|
+
*/
|
|
2374
|
+
isConfigured() {
|
|
2375
|
+
return !!(this.config.smtp || this.config.sendEmail);
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Send an email using SMTP or custom send function
|
|
2379
|
+
*/
|
|
2380
|
+
async send(options) {
|
|
2381
|
+
if (this.config.sendEmail) {
|
|
2382
|
+
await this.config.sendEmail(options);
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
await this.ensureTransporter();
|
|
2386
|
+
if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
|
|
2387
|
+
const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
|
|
2388
|
+
try {
|
|
2389
|
+
await this.transporter.sendMail({
|
|
2390
|
+
from: this.config.from,
|
|
2391
|
+
to,
|
|
2392
|
+
subject: options.subject,
|
|
2393
|
+
html: options.html,
|
|
2394
|
+
text: options.text,
|
|
2395
|
+
replyTo: options.replyTo
|
|
2396
|
+
});
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2399
|
+
logger.error("Failed to send email", { detail: message });
|
|
2400
|
+
throw new Error(`Failed to send email: ${message}`);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Verify SMTP connection (useful for startup checks)
|
|
2405
|
+
*/
|
|
2406
|
+
async verifyConnection() {
|
|
2407
|
+
await this.ensureTransporter();
|
|
2408
|
+
if (!this.transporter) return !!this.config.sendEmail;
|
|
2409
|
+
try {
|
|
2410
|
+
await this.transporter.verify();
|
|
2411
|
+
return true;
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2414
|
+
logger.error("SMTP connection verification failed", { detail: message });
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
};
|
|
2419
|
+
/**
|
|
2420
|
+
* Create an email service from configuration.
|
|
2421
|
+
*
|
|
2422
|
+
* This is the choke point every boot path goes through — the managed runtime,
|
|
2423
|
+
* both driver bootstrappers, and any app that passes `auth.email` — so it is
|
|
2424
|
+
* where the link bases are checked. A config that can send mail but cannot build
|
|
2425
|
+
* an absolute link fails the start rather than delivering dead `href="/…"` links
|
|
2426
|
+
* and reporting success; see `assertEmailLinkBases`.
|
|
2427
|
+
*/
|
|
2428
|
+
function createEmailService(config) {
|
|
2429
|
+
assertEmailLinkBases(config);
|
|
2430
|
+
return new SMTPEmailService(config);
|
|
2431
|
+
}
|
|
2432
|
+
//#endregion
|
|
1671
2433
|
//#region src/auth/rate-limit-store.ts
|
|
1672
2434
|
/**
|
|
1673
2435
|
* The default store: a sliding window in this process's memory.
|
|
@@ -1733,13 +2495,32 @@ var MemoryRateLimitStore = class {
|
|
|
1733
2495
|
//#region src/auth/rate-limiter.ts
|
|
1734
2496
|
/**
|
|
1735
2497
|
* Resolve the number of trusted proxy hops from an explicit option, the
|
|
1736
|
-
* `TRUSTED_PROXY_HOPS` env var, or the default of
|
|
2498
|
+
* `TRUSTED_PROXY_HOPS` env var, or the fail-safe default of 0.
|
|
2499
|
+
*
|
|
2500
|
+
* Zero, not one: trusting a hop that is not there hands the rate-limit key to
|
|
2501
|
+
* the caller, and a server has no way to detect the difference from a request.
|
|
1737
2502
|
*/
|
|
1738
2503
|
function resolveTrustedProxyHops(optionValue) {
|
|
1739
2504
|
if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue >= 0) return Math.floor(optionValue);
|
|
1740
2505
|
const fromEnv = Number(process.env.TRUSTED_PROXY_HOPS);
|
|
1741
2506
|
if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.floor(fromEnv);
|
|
1742
|
-
return
|
|
2507
|
+
return 0;
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Warn once when the deployment looks proxied but has not said so.
|
|
2511
|
+
*
|
|
2512
|
+
* The safe default is the wrong answer for the other common topology: behind an
|
|
2513
|
+
* ingress, every request arrives from the proxy, so ignoring `X-Forwarded-For`
|
|
2514
|
+
* collapses every client into one bucket and the limiter starts locking real
|
|
2515
|
+
* users out of each other's quota. That is a loud failure in production and a
|
|
2516
|
+
* silent one in staging, so it gets a warning rather than a guess — guessing is
|
|
2517
|
+
* what made the header trustworthy in the first place.
|
|
2518
|
+
*/
|
|
2519
|
+
var warnedAboutUntrustedForwardedFor = false;
|
|
2520
|
+
function warnIfProxiedButUntrusted(hasForwardedFor) {
|
|
2521
|
+
if (!hasForwardedFor || warnedAboutUntrustedForwardedFor) return;
|
|
2522
|
+
warnedAboutUntrustedForwardedFor = true;
|
|
2523
|
+
logger.warn("[RateLimit] Requests carry X-Forwarded-For but TRUSTED_PROXY_HOPS is 0, so it is being ignored and every client behind the proxy shares one rate-limit bucket. If this server really is behind a reverse proxy, set TRUSTED_PROXY_HOPS to the number of proxies in front of it (1 for a single ingress or load balancer). If it is exposed directly, this warning means someone is sending the header themselves and 0 is the correct setting.");
|
|
1743
2524
|
}
|
|
1744
2525
|
/**
|
|
1745
2526
|
* Create a rate-limiting middleware.
|
|
@@ -1810,7 +2591,11 @@ function socketAddress(c) {
|
|
|
1810
2591
|
* anonymous callers throttle each other than that any of them throttles nobody.
|
|
1811
2592
|
*/
|
|
1812
2593
|
function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
1813
|
-
if (trustedProxyHops
|
|
2594
|
+
if (trustedProxyHops === 0) {
|
|
2595
|
+
warnIfProxiedButUntrusted(!!c.req.header("x-forwarded-for"));
|
|
2596
|
+
return socketAddress(c) ?? "unknown";
|
|
2597
|
+
}
|
|
2598
|
+
{
|
|
1814
2599
|
const forwardedFor = c.req.header("x-forwarded-for");
|
|
1815
2600
|
if (forwardedFor) {
|
|
1816
2601
|
const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
|
|
@@ -1840,6 +2625,34 @@ var strictAuthLimiter = createRateLimiter({
|
|
|
1840
2625
|
message: "Too many requests to this sensitive endpoint, please try again later."
|
|
1841
2626
|
});
|
|
1842
2627
|
/**
|
|
2628
|
+
* Limiter for `POST /auth/send-verification`, keyed by the authenticated user.
|
|
2629
|
+
*
|
|
2630
|
+
* That route had no limiter at all while every one of its email-sending
|
|
2631
|
+
* siblings had one, and being authenticated is not the protection it looks
|
|
2632
|
+
* like: registration does not verify the address it is given, so an attacker
|
|
2633
|
+
* registers a victim's address, signs in to the account they just made, and
|
|
2634
|
+
* loops the route. Each call mints a token and mails the victim.
|
|
2635
|
+
*
|
|
2636
|
+
* An IP limiter cannot express what is wanted here — the recipient is the
|
|
2637
|
+
* quantity being protected, not the caller — so this one keys on the uid, which
|
|
2638
|
+
* on this route is one-to-one with the recipient address (emails are unique per
|
|
2639
|
+
* account). `strictAuthLimiter` still runs in front of it to bound the caller by
|
|
2640
|
+
* IP; this bounds what any single address can be sent.
|
|
2641
|
+
*
|
|
2642
|
+
* Five per 15 minutes is generous for "I didn't get the email, resend it" and
|
|
2643
|
+
* useless as a mail bomb. Unauthenticated requests fall back to the IP bucket so
|
|
2644
|
+
* the limiter is never a no-op if it is ever mounted before the auth middleware.
|
|
2645
|
+
*/
|
|
2646
|
+
var verificationEmailLimiter = createRateLimiter({
|
|
2647
|
+
windowMs: 900 * 1e3,
|
|
2648
|
+
limit: 5,
|
|
2649
|
+
keyGenerator: (c) => {
|
|
2650
|
+
const user = c.get("user");
|
|
2651
|
+
return user?.uid ? `verification-email:user:${user.uid}` : `verification-email:ip:${defaultKeyGenerator(c)}`;
|
|
2652
|
+
},
|
|
2653
|
+
message: "Too many verification emails requested, please try again later."
|
|
2654
|
+
});
|
|
2655
|
+
/**
|
|
1843
2656
|
* Key generator for API-key-based rate limiting.
|
|
1844
2657
|
*
|
|
1845
2658
|
* Uses the API key ID (from `c.get("apiKey")`) as the rate limit key.
|
|
@@ -1851,6 +2664,8 @@ function apiKeyKeyGenerator(c) {
|
|
|
1851
2664
|
if (apiKey) return `api-key:${apiKey.id}`;
|
|
1852
2665
|
return defaultKeyGenerator(c);
|
|
1853
2666
|
}
|
|
2667
|
+
/** @see DataRateLimitConfig.anonymousFunctions */
|
|
2668
|
+
var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
|
|
1854
2669
|
/**
|
|
1855
2670
|
* Rate limiting for the data API.
|
|
1856
2671
|
*
|
|
@@ -1870,6 +2685,7 @@ function apiKeyKeyGenerator(c) {
|
|
|
1870
2685
|
*/
|
|
1871
2686
|
function createDataRateLimiter(config = {}) {
|
|
1872
2687
|
const { windowMs = 900 * 1e3, apiKey: apiKeyLimit = 1e3, user: userLimit = 1e3, anonymous: anonLimit = 300, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
2688
|
+
const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
|
|
1873
2689
|
return createRateLimiter({
|
|
1874
2690
|
windowMs,
|
|
1875
2691
|
store,
|
|
@@ -1879,7 +2695,7 @@ function createDataRateLimiter(config = {}) {
|
|
|
1879
2695
|
if (key) return `api-key:${key.id}`;
|
|
1880
2696
|
const user = c.get("user");
|
|
1881
2697
|
if (user?.uid && !isAnonymousUid(user.uid)) return `user:${user.uid}`;
|
|
1882
|
-
return `ip:${defaultKeyGenerator(c)}`;
|
|
2698
|
+
return `ip:${defaultKeyGenerator(c, trustedProxyHops)}`;
|
|
1883
2699
|
},
|
|
1884
2700
|
resolveLimit: (c) => {
|
|
1885
2701
|
const key = c.get("apiKey");
|
|
@@ -5896,17 +6712,27 @@ function generateHotp(secret, counter) {
|
|
|
5896
6712
|
return (((hash[offset] & 127) << 24 | hash[offset + 1] << 16 | hash[offset + 2] << 8 | hash[offset + 3]) % 1e6).toString().padStart(6, "0");
|
|
5897
6713
|
}
|
|
5898
6714
|
/**
|
|
5899
|
-
* Verify a TOTP token
|
|
6715
|
+
* Verify a TOTP token and report *which* time step matched.
|
|
6716
|
+
*
|
|
6717
|
+
* RFC 6238 §5.2 requires that an accepted OTP not be accepted a second time,
|
|
6718
|
+
* and a boolean cannot express what was accepted: with `window = 1` the same
|
|
6719
|
+
* six digits stay valid for up to 90 seconds, so a code observed once — by a
|
|
6720
|
+
* phishing relay, over a shoulder, in a pasted support message — can be
|
|
6721
|
+
* presented again for the rest of that window. The caller records the returned
|
|
6722
|
+
* counter against the factor and refuses anything at or below it.
|
|
5900
6723
|
*
|
|
5901
6724
|
* @param secret - The shared secret as a Buffer
|
|
5902
6725
|
* @param token - The 6-digit TOTP code to verify
|
|
5903
6726
|
* @param window - Number of time steps to check on each side (default: 1)
|
|
5904
|
-
* @returns
|
|
6727
|
+
* @returns The matched time step, or `null` when no step in the window matches
|
|
5905
6728
|
*/
|
|
5906
|
-
function
|
|
6729
|
+
function verifyTotpCounter(secret, token, window = 1) {
|
|
5907
6730
|
const counter = BigInt(Math.floor(Date.now() / 1e3 / 30));
|
|
5908
|
-
for (let i = -window; i <= window; i++)
|
|
5909
|
-
|
|
6731
|
+
for (let i = -window; i <= window; i++) {
|
|
6732
|
+
const step = counter + BigInt(i);
|
|
6733
|
+
if (generateHotp(secret, step) === token) return Number(step);
|
|
6734
|
+
}
|
|
6735
|
+
return null;
|
|
5910
6736
|
}
|
|
5911
6737
|
/**
|
|
5912
6738
|
* Generate a new TOTP secret and return the setup information
|
|
@@ -5949,79 +6775,335 @@ function hashRecoveryCode(code) {
|
|
|
5949
6775
|
*
|
|
5950
6776
|
* - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
|
|
5951
6777
|
* - Generates a random 12-byte IV per encryption call.
|
|
5952
|
-
* -
|
|
6778
|
+
* - Stamps the key that made the ciphertext, so the key can be changed without
|
|
6779
|
+
* locking every enrolled user out — see "Rotating the key" below.
|
|
6780
|
+
*
|
|
6781
|
+
* ## Rotating the key
|
|
6782
|
+
*
|
|
6783
|
+
* A ciphertext used to be `iv:authTag:ciphertext` and recorded nothing about
|
|
6784
|
+
* which key produced it, while the key was re-derived from the environment on
|
|
6785
|
+
* every call. Setting `MFA_ENCRYPTION_KEY` on a deployment that had been
|
|
6786
|
+
* falling back to `JWT_SECRET` therefore made every stored factor undecryptable
|
|
6787
|
+
* — AES-GCM authenticates, so it did not return a wrong secret, it threw, and
|
|
6788
|
+
* every MFA sign-in failed until the variable was removed again.
|
|
6789
|
+
*
|
|
6790
|
+
* Now a ciphertext carries `v1.<keyId>`, where `keyId` is derived from the key
|
|
6791
|
+
* itself (no key names to keep in sync), and decryption runs against a list of
|
|
6792
|
+
* candidates: `MFA_ENCRYPTION_KEY`, then each comma-separated entry in
|
|
6793
|
+
* `MFA_ENCRYPTION_KEY_PREVIOUS`, then `JWT_SECRET`. To rotate:
|
|
6794
|
+
*
|
|
6795
|
+
* 1. **Deploy this code first.** It reads both formats. Doing it the other way
|
|
6796
|
+
* round — setting the key before the readers understand the old ciphertexts
|
|
6797
|
+
* — is the outage this exists to prevent.
|
|
6798
|
+
* 2. Move the old value into `MFA_ENCRYPTION_KEY_PREVIOUS` and set the new one
|
|
6799
|
+
* as `MFA_ENCRYPTION_KEY`.
|
|
6800
|
+
* 3. Each factor is re-wrapped with the current key the next time its owner
|
|
6801
|
+
* authenticates (`openTotpSecret` reports it via `rewrapped`).
|
|
6802
|
+
* 4. Once every enrolled user has signed in, drop `MFA_ENCRYPTION_KEY_PREVIOUS`.
|
|
6803
|
+
* Any factor still on the old key then fails loudly, naming the key id,
|
|
6804
|
+
* rather than being silently unreadable.
|
|
6805
|
+
*
|
|
6806
|
+
* Trial decryption across candidates is safe here: GCM's auth tag is a 128-bit
|
|
6807
|
+
* MAC, so a wrong key throws instead of yielding a plausible-looking secret.
|
|
5953
6808
|
*
|
|
5954
6809
|
* @module
|
|
5955
6810
|
*/
|
|
5956
6811
|
var ALGORITHM = "aes-256-gcm";
|
|
5957
6812
|
var IV_BYTES = 12;
|
|
5958
6813
|
var AUTH_TAG_LENGTH = 16;
|
|
6814
|
+
/** Ciphertext prefix marking the key-stamped format. */
|
|
6815
|
+
var VERSION = "v1";
|
|
6816
|
+
/** Warn once, not once per sign-in. */
|
|
6817
|
+
var warnedAboutFallback = false;
|
|
6818
|
+
function toCandidate(raw, source) {
|
|
6819
|
+
const digest = createHash$1("sha256").update(raw).digest();
|
|
6820
|
+
return {
|
|
6821
|
+
id: digest.toString("hex").slice(0, 8),
|
|
6822
|
+
key: digest,
|
|
6823
|
+
source
|
|
6824
|
+
};
|
|
6825
|
+
}
|
|
5959
6826
|
/**
|
|
5960
|
-
*
|
|
6827
|
+
* Every key this process can decrypt with, most-preferred first.
|
|
5961
6828
|
*
|
|
5962
|
-
*
|
|
5963
|
-
*
|
|
6829
|
+
* The head of the list is the key new ciphertexts are written with; the rest
|
|
6830
|
+
* exist so that ciphertexts written before a rotation still open.
|
|
5964
6831
|
*/
|
|
5965
|
-
function
|
|
5966
|
-
const
|
|
5967
|
-
|
|
5968
|
-
const
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
6832
|
+
function keyCandidates() {
|
|
6833
|
+
const candidates = [];
|
|
6834
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6835
|
+
const add = (raw, source) => {
|
|
6836
|
+
const trimmed = raw?.trim();
|
|
6837
|
+
if (!trimmed || seen.has(trimmed)) return;
|
|
6838
|
+
seen.add(trimmed);
|
|
6839
|
+
candidates.push(toCandidate(trimmed, source));
|
|
6840
|
+
};
|
|
6841
|
+
add(process.env.MFA_ENCRYPTION_KEY, "MFA_ENCRYPTION_KEY");
|
|
6842
|
+
for (const previous of (process.env.MFA_ENCRYPTION_KEY_PREVIOUS ?? "").split(",")) add(previous, "MFA_ENCRYPTION_KEY_PREVIOUS");
|
|
6843
|
+
add(process.env.JWT_SECRET, "JWT_SECRET");
|
|
6844
|
+
if (candidates.length === 0) throw new Error("Cannot encrypt or decrypt TOTP secrets: none of MFA_ENCRYPTION_KEY, MFA_ENCRYPTION_KEY_PREVIOUS or JWT_SECRET is configured.");
|
|
6845
|
+
if (!process.env.MFA_ENCRYPTION_KEY && !warnedAboutFallback) {
|
|
6846
|
+
warnedAboutFallback = true;
|
|
6847
|
+
logger.warn("[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. Set a dedicated MFA_ENCRYPTION_KEY for production; see the rotation steps in mfa-crypto.ts before you do it on a live deployment.");
|
|
5972
6848
|
}
|
|
5973
|
-
|
|
6849
|
+
return candidates;
|
|
5974
6850
|
}
|
|
5975
|
-
/**
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
function deriveKey(raw) {
|
|
5979
|
-
return createHash$1("sha256").update(raw).digest();
|
|
6851
|
+
/** The key new ciphertexts are written with. */
|
|
6852
|
+
function currentKey() {
|
|
6853
|
+
return keyCandidates()[0];
|
|
5980
6854
|
}
|
|
5981
6855
|
/**
|
|
5982
6856
|
* Encrypt a plaintext TOTP secret.
|
|
5983
6857
|
*
|
|
5984
6858
|
* @param plaintext - The Base32 TOTP secret to encrypt.
|
|
5985
|
-
* @returns A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
|
|
6859
|
+
* @returns A string in the format `v1.<keyId>:iv_hex:authTag_hex:ciphertext_hex`.
|
|
5986
6860
|
*/
|
|
5987
6861
|
function encryptTotpSecret(plaintext) {
|
|
5988
|
-
const key =
|
|
6862
|
+
const { id, key } = currentKey();
|
|
5989
6863
|
const iv = randomBytes$1(IV_BYTES);
|
|
5990
6864
|
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
5991
6865
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
5992
6866
|
const authTag = cipher.getAuthTag();
|
|
5993
|
-
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
6867
|
+
return `${VERSION}.${id}:${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
6868
|
+
}
|
|
6869
|
+
function openWith(candidate, ivHex, authTagHex, encryptedHex) {
|
|
6870
|
+
const decipher = createDecipheriv(ALGORITHM, candidate.key, Buffer.from(ivHex, "hex"), { authTagLength: AUTH_TAG_LENGTH });
|
|
6871
|
+
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
|
6872
|
+
return Buffer.concat([decipher.update(Buffer.from(encryptedHex, "hex")), decipher.final()]).toString("utf8");
|
|
5994
6873
|
}
|
|
5995
6874
|
/**
|
|
5996
|
-
* Decrypt a
|
|
6875
|
+
* Decrypt a stored TOTP secret, reporting whether it wants re-wrapping.
|
|
5997
6876
|
*
|
|
5998
|
-
*
|
|
5999
|
-
*
|
|
6877
|
+
* Accepts both the key-stamped format and the original unstamped
|
|
6878
|
+
* `iv:authTag:ciphertext`, which is tried against each candidate in turn.
|
|
6000
6879
|
*/
|
|
6001
|
-
function
|
|
6880
|
+
function openTotpSecret(ciphertext) {
|
|
6002
6881
|
const parts = ciphertext.split(":");
|
|
6003
|
-
|
|
6882
|
+
const candidates = keyCandidates();
|
|
6883
|
+
const current = candidates[0];
|
|
6884
|
+
if (parts.length === 4) {
|
|
6885
|
+
const [stamp, ivHex, authTagHex, encryptedHex] = parts;
|
|
6886
|
+
const [version, keyId] = stamp.split(".");
|
|
6887
|
+
if (version !== VERSION || !keyId) throw new Error(`Unsupported encrypted TOTP secret version "${stamp}"`);
|
|
6888
|
+
const named = candidates.find((c) => c.id === keyId);
|
|
6889
|
+
if (!named) throw new Error(`No configured key matches this TOTP secret (key id "${keyId}"). Restore the key that encrypted it via MFA_ENCRYPTION_KEY_PREVIOUS. Configured key ids: ${candidates.map((c) => `${c.id} (${c.source})`).join(", ")}.`);
|
|
6890
|
+
const secret = openWith(named, ivHex, authTagHex, encryptedHex);
|
|
6891
|
+
return {
|
|
6892
|
+
secret,
|
|
6893
|
+
rewrapped: named.id === current.id ? null : encryptTotpSecret(secret)
|
|
6894
|
+
};
|
|
6895
|
+
}
|
|
6896
|
+
if (parts.length !== 3) throw new Error("Invalid encrypted TOTP secret format — expected v1.<keyId>:iv:authTag:ciphertext or the legacy iv:authTag:ciphertext");
|
|
6004
6897
|
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
6005
|
-
const
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6898
|
+
for (const candidate of candidates) try {
|
|
6899
|
+
const secret = openWith(candidate, ivHex, authTagHex, encryptedHex);
|
|
6900
|
+
return {
|
|
6901
|
+
secret,
|
|
6902
|
+
rewrapped: encryptTotpSecret(secret)
|
|
6903
|
+
};
|
|
6904
|
+
} catch {}
|
|
6905
|
+
throw new Error(`Could not decrypt TOTP secret with any configured key. Tried: ${candidates.map((c) => c.source).join(", ")}.`);
|
|
6012
6906
|
}
|
|
6013
6907
|
//#endregion
|
|
6014
|
-
//#region src/auth/
|
|
6015
|
-
|
|
6908
|
+
//#region src/auth/cookie-utils.ts
|
|
6909
|
+
/**
|
|
6910
|
+
* Cookie auth helpers for httpOnly refresh tokens.
|
|
6911
|
+
*/
|
|
6912
|
+
function getCookieSettings(config) {
|
|
6913
|
+
return {
|
|
6914
|
+
name: config?.cookieName || "__rb_refresh",
|
|
6915
|
+
path: config?.path || "/",
|
|
6916
|
+
sameSite: config?.sameSite || "Lax",
|
|
6917
|
+
domain: config?.domain,
|
|
6918
|
+
secure: config?.secure
|
|
6919
|
+
};
|
|
6920
|
+
}
|
|
6921
|
+
/**
|
|
6922
|
+
* Set the refresh token as an httpOnly cookie on the response.
|
|
6923
|
+
*/
|
|
6924
|
+
function setRefreshCookie(c, refreshToken, config) {
|
|
6925
|
+
if (!config) return;
|
|
6926
|
+
const settings = getCookieSettings(config);
|
|
6927
|
+
const isSecure = settings.secure ?? true;
|
|
6928
|
+
let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
|
|
6929
|
+
if (isSecure) cookie += "; Secure";
|
|
6930
|
+
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6931
|
+
const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
|
|
6932
|
+
cookie += `; Max-Age=${maxAgeSeconds}`;
|
|
6933
|
+
c.header("Set-Cookie", cookie, { append: true });
|
|
6934
|
+
}
|
|
6935
|
+
/**
|
|
6936
|
+
* Clear the refresh token cookie.
|
|
6937
|
+
*/
|
|
6938
|
+
function clearRefreshCookie(c, config) {
|
|
6939
|
+
if (!config) return;
|
|
6940
|
+
const settings = getCookieSettings(config);
|
|
6941
|
+
let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
|
|
6942
|
+
if (settings.secure ?? true) cookie += "; Secure";
|
|
6943
|
+
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6944
|
+
c.header("Set-Cookie", cookie, { append: true });
|
|
6945
|
+
}
|
|
6946
|
+
/**
|
|
6947
|
+
* Read the refresh token from the request — cookie first, then body fallback.
|
|
6948
|
+
*/
|
|
6949
|
+
function readRefreshToken(c, body, config) {
|
|
6950
|
+
if (config) {
|
|
6951
|
+
const settings = getCookieSettings(config);
|
|
6952
|
+
const cookieHeader = c.req.header("cookie") || "";
|
|
6953
|
+
const prefix = `${settings.name}=`;
|
|
6954
|
+
const cookies = cookieHeader.split(";");
|
|
6955
|
+
for (const part of cookies) {
|
|
6956
|
+
const trimmed = part.trim();
|
|
6957
|
+
if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
return body.refreshToken;
|
|
6961
|
+
}
|
|
6962
|
+
/**
|
|
6963
|
+
* Redact the refresh token from the response body when cookie mode is active.
|
|
6964
|
+
*/
|
|
6965
|
+
function redactRefreshToken(response, c, refreshToken, config) {
|
|
6966
|
+
if (!config) return response;
|
|
6967
|
+
setRefreshCookie(c, refreshToken, config);
|
|
6968
|
+
return {
|
|
6969
|
+
...response,
|
|
6970
|
+
tokens: {
|
|
6971
|
+
...response.tokens,
|
|
6972
|
+
refreshToken: ""
|
|
6973
|
+
}
|
|
6974
|
+
};
|
|
6975
|
+
}
|
|
6976
|
+
//#endregion
|
|
6977
|
+
//#region src/auth/mfa-routes.ts
|
|
6978
|
+
/**
|
|
6979
|
+
* How many failed verifications one challenge tolerates before it is spent.
|
|
6980
|
+
*
|
|
6981
|
+
* A 6-digit code with a ±1 step window accepts 3 of 1,000,000 values, so the
|
|
6982
|
+
* only thing standing between an attacker and the second factor is how many
|
|
6983
|
+
* guesses a challenge will take. Five, and then they must open another one —
|
|
6984
|
+
* which the per-user limiter below is counting.
|
|
6985
|
+
*/
|
|
6986
|
+
var MAX_CHALLENGE_ATTEMPTS = 5;
|
|
6987
|
+
/** Verification attempts one *account* may make per window, across all IPs. */
|
|
6988
|
+
var MFA_VERIFICATION_ATTEMPTS_PER_WINDOW = 10;
|
|
6989
|
+
/**
|
|
6990
|
+
* Resolve the caller of a challenge route from either a full session or a
|
|
6991
|
+
* pre-auth token.
|
|
6992
|
+
*
|
|
6993
|
+
* Both are parsed here rather than by `requireAuth` because the whole point of
|
|
6994
|
+
* the challenge pair is that it runs before the session exists — a login by an
|
|
6995
|
+
* MFA-enrolled user never receives an access token until it is finished.
|
|
6996
|
+
*/
|
|
6997
|
+
/**
|
|
6998
|
+
* Open a factor's stored secret, re-storing it under the current key if it
|
|
6999
|
+
* opened with an older one.
|
|
7000
|
+
*
|
|
7001
|
+
* This is what makes an `MFA_ENCRYPTION_KEY` rotation complete on its own: each
|
|
7002
|
+
* factor moves to the new key the next time its owner authenticates, so the
|
|
7003
|
+
* previous key can eventually be dropped. See `mfa-crypto.ts` for the sequence.
|
|
7004
|
+
*
|
|
7005
|
+
* A failed re-wrap must never fail the verification — the code the user typed
|
|
7006
|
+
* was already checked against the right secret, and the only cost of not
|
|
7007
|
+
* persisting is that this factor is re-wrapped again next time.
|
|
7008
|
+
*/
|
|
7009
|
+
async function openAndRewrap(authRepo, factorId, secretEncrypted) {
|
|
7010
|
+
const { secret, rewrapped } = openTotpSecret(secretEncrypted);
|
|
7011
|
+
if (!rewrapped) return secret;
|
|
7012
|
+
if (typeof authRepo.updateMfaFactorSecret !== "function") return secret;
|
|
7013
|
+
try {
|
|
7014
|
+
await authRepo.updateMfaFactorSecret(factorId, rewrapped);
|
|
7015
|
+
logger.info("[MFA-Crypto] Re-wrapped a TOTP secret under the current key", { factorId });
|
|
7016
|
+
} catch (error) {
|
|
7017
|
+
logger.warn("[MFA-Crypto] Could not re-wrap a TOTP secret; leaving it on its original key", {
|
|
7018
|
+
factorId,
|
|
7019
|
+
error
|
|
7020
|
+
});
|
|
7021
|
+
}
|
|
7022
|
+
return secret;
|
|
7023
|
+
}
|
|
7024
|
+
function resolveStepUpPrincipal(c) {
|
|
7025
|
+
const existing = c.get("user");
|
|
7026
|
+
if (existing?.uid) return {
|
|
7027
|
+
uid: existing.uid,
|
|
7028
|
+
aal: existing.aal === "aal2" ? "aal2" : "aal1",
|
|
7029
|
+
pending: false
|
|
7030
|
+
};
|
|
7031
|
+
const token = extractBearerToken(c.req.header("authorization"));
|
|
7032
|
+
if (token === void 0) return null;
|
|
7033
|
+
const session = verifyAccessToken(token);
|
|
7034
|
+
if (session) return {
|
|
7035
|
+
uid: session.uid,
|
|
7036
|
+
aal: session.aal === "aal2" ? "aal2" : "aal1",
|
|
7037
|
+
pending: false
|
|
7038
|
+
};
|
|
7039
|
+
const pending = verifyMfaPendingToken(token);
|
|
7040
|
+
if (pending) return {
|
|
7041
|
+
uid: pending.uid,
|
|
7042
|
+
aal: "aal1",
|
|
7043
|
+
pending: true
|
|
7044
|
+
};
|
|
7045
|
+
return null;
|
|
7046
|
+
}
|
|
7047
|
+
/**
|
|
7048
|
+
* Per-account throttle on code verification.
|
|
7049
|
+
*
|
|
7050
|
+
* Keyed on the uid rather than the IP because an IP is the attacker's to
|
|
7051
|
+
* rotate and the account under attack is not: a distributed run against one
|
|
7052
|
+
* account passes an IP-keyed limiter untouched. The IP limiter still runs in
|
|
7053
|
+
* front of this one — they bound different things.
|
|
7054
|
+
*/
|
|
7055
|
+
var mfaVerificationLimiter = createRateLimiter({
|
|
7056
|
+
windowMs: 900 * 1e3,
|
|
7057
|
+
limit: MFA_VERIFICATION_ATTEMPTS_PER_WINDOW,
|
|
7058
|
+
keyGenerator: (c) => `mfa-verify:${resolveStepUpPrincipal(c)?.uid ?? "unidentified"}`,
|
|
7059
|
+
message: "Too many verification attempts for this account, please try again later."
|
|
7060
|
+
});
|
|
7061
|
+
function mountMfaRoutes(opts) {
|
|
7062
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
|
|
6016
7063
|
const authRepo = config.authRepo;
|
|
6017
7064
|
const emailConfig = config.emailConfig;
|
|
6018
7065
|
/**
|
|
7066
|
+
* Changing which factors an account trusts is itself a privileged act.
|
|
7067
|
+
*
|
|
7068
|
+
* An account that already has a verified factor may only gain or confirm
|
|
7069
|
+
* another one from a session that presented the existing one. Without this
|
|
7070
|
+
* the `aal2` gate on unenroll was self-service: an `aal1` session enrolled
|
|
7071
|
+
* a factor of its own, verified it with a code it computed from the secret
|
|
7072
|
+
* the enrol response had just handed it, stepped up on that, and deleted
|
|
7073
|
+
* the victim's real factor — arriving at `aal2` without ever holding the
|
|
7074
|
+
* second factor the account was protected by.
|
|
7075
|
+
*
|
|
7076
|
+
* The first enrolment on an account with no verified factor is deliberately
|
|
7077
|
+
* allowed at `aal1`: there is no second factor to demand yet.
|
|
7078
|
+
*/
|
|
7079
|
+
async function requireStepUpForFactorChange(userCtx) {
|
|
7080
|
+
if (userCtx.aal === "aal2") return;
|
|
7081
|
+
if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) return;
|
|
7082
|
+
throw ApiError.forbidden("This account already has a verified second factor. Re-authenticate with it before changing enrolled factors.", "AAL2_REQUIRED");
|
|
7083
|
+
}
|
|
7084
|
+
/**
|
|
7085
|
+
* Spend a TOTP time step, or refuse it as already spent.
|
|
7086
|
+
*
|
|
7087
|
+
* RFC 6238 §5.2: an accepted OTP must not be accepted again. The window
|
|
7088
|
+
* that exists for clock drift is also a 90-second replay window, and a
|
|
7089
|
+
* replay here is not momentary — `challenge/verify` mints a refresh token,
|
|
7090
|
+
* so one observed code buys a durable session.
|
|
7091
|
+
*/
|
|
7092
|
+
async function claimTotpStep(factorId, counter) {
|
|
7093
|
+
if (typeof authRepo.claimMfaFactorCounter !== "function") {
|
|
7094
|
+
logger.warn("[MFA] Auth repository cannot record spent TOTP steps; an accepted code stays replayable for its window", { factorId });
|
|
7095
|
+
return true;
|
|
7096
|
+
}
|
|
7097
|
+
return authRepo.claimMfaFactorCounter(factorId, counter);
|
|
7098
|
+
}
|
|
7099
|
+
/**
|
|
6019
7100
|
* POST /auth/mfa/enroll
|
|
6020
7101
|
* Start MFA enrollment: generate TOTP secret and recovery codes
|
|
6021
7102
|
*/
|
|
6022
|
-
router.post("/mfa/enroll", requireAuth, async (c) => {
|
|
7103
|
+
router.post("/mfa/enroll", strictAuthLimiter, requireAuth, async (c) => {
|
|
6023
7104
|
const userCtx = c.get("user");
|
|
6024
7105
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
7106
|
+
await requireStepUpForFactorChange(userCtx);
|
|
6025
7107
|
const body = await c.req.json().catch(() => ({}));
|
|
6026
7108
|
const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
|
|
6027
7109
|
const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
|
|
@@ -6051,17 +7133,20 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6051
7133
|
* POST /auth/mfa/verify
|
|
6052
7134
|
* Verify TOTP code to complete MFA enrollment
|
|
6053
7135
|
*/
|
|
6054
|
-
router.post("/mfa/verify", requireAuth, async (c) => {
|
|
7136
|
+
router.post("/mfa/verify", strictAuthLimiter, requireAuth, mfaVerificationLimiter, async (c) => {
|
|
6055
7137
|
const userCtx = c.get("user");
|
|
6056
7138
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6057
|
-
|
|
7139
|
+
await requireStepUpForFactorChange(userCtx);
|
|
7140
|
+
const verifySchema = object({
|
|
6058
7141
|
factorId: string().min(1, "Factor ID is required"),
|
|
6059
7142
|
code: string().length(6, "Code must be 6 digits")
|
|
6060
|
-
})
|
|
7143
|
+
});
|
|
7144
|
+
const { factorId, code } = parseBody(verifySchema, await c.req.json());
|
|
6061
7145
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
6062
7146
|
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
6063
7147
|
if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
|
|
6064
|
-
|
|
7148
|
+
const matchedCounter = verifyTotpCounter(base32Decode(await openAndRewrap(authRepo, factor.id, factor.secretEncrypted)), code);
|
|
7149
|
+
if (matchedCounter === null || !await claimTotpStep(factor.id, matchedCounter)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
|
|
6065
7150
|
await authRepo.verifyMfaFactor(factorId);
|
|
6066
7151
|
return c.json({
|
|
6067
7152
|
success: true,
|
|
@@ -6070,14 +7155,16 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6070
7155
|
});
|
|
6071
7156
|
/**
|
|
6072
7157
|
* POST /auth/mfa/challenge
|
|
6073
|
-
*
|
|
7158
|
+
* Open a challenge — either to finish a sign-in that answered
|
|
7159
|
+
* `MFA_REQUIRED`, or to step an existing session up to `aal2`.
|
|
6074
7160
|
*/
|
|
6075
|
-
router.post("/mfa/challenge",
|
|
6076
|
-
const
|
|
6077
|
-
if (!
|
|
6078
|
-
const
|
|
7161
|
+
router.post("/mfa/challenge", strictAuthLimiter, async (c) => {
|
|
7162
|
+
const principal = resolveStepUpPrincipal(c);
|
|
7163
|
+
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
7164
|
+
const challengeSchema = object({ factorId: string().min(1, "Factor ID is required") });
|
|
7165
|
+
const { factorId } = parseBody(challengeSchema, await c.req.json());
|
|
6079
7166
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
6080
|
-
if (!factor || factor.uid !==
|
|
7167
|
+
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
6081
7168
|
if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
|
|
6082
7169
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
6083
7170
|
const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
|
|
@@ -6089,49 +7176,73 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6089
7176
|
});
|
|
6090
7177
|
/**
|
|
6091
7178
|
* POST /auth/mfa/challenge/verify
|
|
6092
|
-
*
|
|
7179
|
+
* Answer a challenge: this is where an MFA-enrolled account's session is
|
|
7180
|
+
* minted, at `aal2`.
|
|
6093
7181
|
*/
|
|
6094
|
-
router.post("/mfa/challenge/verify",
|
|
6095
|
-
const
|
|
6096
|
-
if (!
|
|
6097
|
-
const
|
|
7182
|
+
router.post("/mfa/challenge/verify", strictAuthLimiter, mfaVerificationLimiter, async (c) => {
|
|
7183
|
+
const principal = resolveStepUpPrincipal(c);
|
|
7184
|
+
if (!principal) throw ApiError.unauthorized("Not authenticated");
|
|
7185
|
+
const challengeVerifySchema = object({
|
|
6098
7186
|
challengeId: string().min(1, "Challenge ID is required"),
|
|
6099
7187
|
code: string().min(1, "Code is required")
|
|
6100
|
-
})
|
|
7188
|
+
});
|
|
7189
|
+
const { challengeId, code } = parseBody(challengeVerifySchema, await c.req.json());
|
|
6101
7190
|
const challenge = await authRepo.getMfaChallengeById(challengeId);
|
|
6102
7191
|
if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
|
|
6103
7192
|
const factor = await authRepo.getMfaFactorById(challenge.factorId);
|
|
6104
|
-
if (!factor || factor.uid !==
|
|
6105
|
-
|
|
6106
|
-
|
|
7193
|
+
if (!factor || factor.uid !== principal.uid) throw ApiError.notFound("MFA factor not found");
|
|
7194
|
+
if ((challenge.attempts ?? 0) >= MAX_CHALLENGE_ATTEMPTS) {
|
|
7195
|
+
logger.warn("[Security Audit] MFA challenge attempt limit reached", {
|
|
7196
|
+
eventType: "auth.mfa.challenge.exhausted",
|
|
7197
|
+
uid: principal.uid,
|
|
7198
|
+
challengeId
|
|
7199
|
+
});
|
|
7200
|
+
throw ApiError.unauthorized("Too many failed attempts for this challenge", "CHALLENGE_EXHAUSTED");
|
|
7201
|
+
}
|
|
7202
|
+
const matchedCounter = verifyTotpCounter(base32Decode(await openAndRewrap(authRepo, factor.id, factor.secretEncrypted)), code);
|
|
7203
|
+
let isValid = matchedCounter !== null && await claimTotpStep(factor.id, matchedCounter);
|
|
7204
|
+
if (!isValid && matchedCounter === null) {
|
|
6107
7205
|
const codeHash = hashRecoveryCode(code);
|
|
6108
|
-
isValid = await authRepo.useRecoveryCode(
|
|
7206
|
+
isValid = await authRepo.useRecoveryCode(principal.uid, codeHash);
|
|
7207
|
+
}
|
|
7208
|
+
if (!isValid) {
|
|
7209
|
+
const attempts = typeof authRepo.recordMfaChallengeAttempt === "function" ? await authRepo.recordMfaChallengeAttempt(challengeId) : void 0;
|
|
7210
|
+
logger.warn("[Security Audit] MFA verification failed", {
|
|
7211
|
+
eventType: "auth.mfa.verify.failure",
|
|
7212
|
+
uid: principal.uid,
|
|
7213
|
+
challengeId,
|
|
7214
|
+
attempts
|
|
7215
|
+
});
|
|
7216
|
+
throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
6109
7217
|
}
|
|
6110
|
-
if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
6111
7218
|
await authRepo.verifyMfaChallenge(challengeId);
|
|
6112
|
-
const roleIds =
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
|
|
6116
|
-
id: randomUUID$1(),
|
|
6117
|
-
startedAt: /* @__PURE__ */ new Date()
|
|
7219
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(principal.uid, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown", {
|
|
7220
|
+
skipMfaGate: true,
|
|
7221
|
+
aal: "aal2"
|
|
6118
7222
|
});
|
|
6119
|
-
if (ops.onMfaVerified) ops.onMfaVerified(
|
|
7223
|
+
if (ops.onMfaVerified) ops.onMfaVerified(principal.uid, factor.id).catch((err) => {
|
|
6120
7224
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
6121
7225
|
});
|
|
6122
|
-
|
|
7226
|
+
logger.info("[Security Audit] MFA verification success", {
|
|
7227
|
+
eventType: "auth.mfa.verify.success",
|
|
7228
|
+
uid: principal.uid,
|
|
7229
|
+
factorId: factor.id,
|
|
7230
|
+
completedSignIn: principal.pending
|
|
7231
|
+
});
|
|
7232
|
+
const user = await authRepo.getUserById(principal.uid).catch(() => null);
|
|
7233
|
+
let mfaResponse = user ? buildAuthResponse(user, roleIds, accessToken, refreshToken, "mfa") : { tokens: {
|
|
6123
7234
|
accessToken,
|
|
6124
7235
|
refreshToken,
|
|
6125
7236
|
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
6126
7237
|
} };
|
|
6127
|
-
if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw,
|
|
6128
|
-
return c.json(mfaResponse);
|
|
7238
|
+
if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, principal.uid);
|
|
7239
|
+
return c.json(redactRefreshToken(mfaResponse, c, refreshToken, config.cookieAuth));
|
|
6129
7240
|
});
|
|
6130
7241
|
/**
|
|
6131
7242
|
* GET /auth/mfa/factors
|
|
6132
7243
|
* List enrolled MFA factors for the current user
|
|
6133
7244
|
*/
|
|
6134
|
-
router.get("/mfa/factors", requireAuth, async (c) => {
|
|
7245
|
+
router.get("/mfa/factors", strictAuthLimiter, requireAuth, async (c) => {
|
|
6135
7246
|
const userCtx = c.get("user");
|
|
6136
7247
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6137
7248
|
const factors = await authRepo.getMfaFactors(userCtx.uid);
|
|
@@ -6147,11 +7258,12 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6147
7258
|
* DELETE /auth/mfa/unenroll
|
|
6148
7259
|
* Remove an MFA factor
|
|
6149
7260
|
*/
|
|
6150
|
-
router.delete("/mfa/unenroll", requireAuth, async (c) => {
|
|
7261
|
+
router.delete("/mfa/unenroll", strictAuthLimiter, requireAuth, async (c) => {
|
|
6151
7262
|
const userCtx = c.get("user");
|
|
6152
7263
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6153
7264
|
if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
|
|
6154
|
-
const
|
|
7265
|
+
const unenrollSchema = object({ factorId: string().min(1, "Factor ID is required") });
|
|
7266
|
+
const { factorId } = parseBody(unenrollSchema, await c.req.json());
|
|
6155
7267
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
6156
7268
|
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
6157
7269
|
await authRepo.deleteMfaFactor(factorId, userCtx.uid);
|
|
@@ -6163,72 +7275,51 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
6163
7275
|
});
|
|
6164
7276
|
}
|
|
6165
7277
|
//#endregion
|
|
6166
|
-
//#region src/auth/
|
|
7278
|
+
//#region src/auth/mfa-gate.ts
|
|
6167
7279
|
/**
|
|
6168
|
-
*
|
|
6169
|
-
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
* Set the refresh token as an httpOnly cookie on the response.
|
|
6181
|
-
*/
|
|
6182
|
-
function setRefreshCookie(c, refreshToken, config) {
|
|
6183
|
-
if (!config) return;
|
|
6184
|
-
const settings = getCookieSettings(config);
|
|
6185
|
-
const isSecure = settings.secure ?? (settings.sameSite === "None" ? true : c.req.url.startsWith("https"));
|
|
6186
|
-
let cookie = `${settings.name}=${encodeURIComponent(refreshToken)}; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}`;
|
|
6187
|
-
if (isSecure) cookie += "; Secure";
|
|
6188
|
-
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6189
|
-
const maxAgeSeconds = Math.floor(Math.min(getRefreshTokenTtlMs(), MAX_COOKIE_AGE_MS) / 1e3);
|
|
6190
|
-
cookie += `; Max-Age=${maxAgeSeconds}`;
|
|
6191
|
-
c.header("Set-Cookie", cookie, { append: true });
|
|
6192
|
-
}
|
|
6193
|
-
/**
|
|
6194
|
-
* Clear the refresh token cookie.
|
|
7280
|
+
* The gate between "first factor accepted" and "session issued".
|
|
7281
|
+
*
|
|
7282
|
+
* MFA used to be modelled as an optional step-up that no resource required: a
|
|
7283
|
+
* user could enrol TOTP, print recovery codes, and change nothing about what a
|
|
7284
|
+
* stolen password bought, because every session-minting route issued a full
|
|
7285
|
+
* `aal1` token without ever asking whether the account had a second factor.
|
|
7286
|
+
* This module is the ask. It lives apart from the routes so that there is one
|
|
7287
|
+
* decision, made once, that `createSessionAndTokens` cannot be extended past —
|
|
7288
|
+
* a new sign-in route inherits the gate by construction rather than by the
|
|
7289
|
+
* author remembering it.
|
|
7290
|
+
*
|
|
7291
|
+
* @module
|
|
6195
7292
|
*/
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
const settings = getCookieSettings(config);
|
|
6199
|
-
let cookie = `${settings.name}=; Path=${settings.path}; HttpOnly; SameSite=${settings.sameSite}; Max-Age=0`;
|
|
6200
|
-
if (settings.domain) cookie += `; Domain=${settings.domain}`;
|
|
6201
|
-
c.header("Set-Cookie", cookie, { append: true });
|
|
6202
|
-
}
|
|
7293
|
+
/** Error code a client watches for to switch a sign-in into its second step. */
|
|
7294
|
+
var MFA_REQUIRED_CODE = "MFA_REQUIRED";
|
|
6203
7295
|
/**
|
|
6204
|
-
*
|
|
7296
|
+
* Refuse to mint a session for an account whose second factor has not been
|
|
7297
|
+
* presented, handing back what the client needs to present it.
|
|
7298
|
+
*
|
|
7299
|
+
* A 401 rather than a 200 with a different body: a client that has not been
|
|
7300
|
+
* taught about MFA must fail, not proceed. The credential in `details` is
|
|
7301
|
+
* useless for anything except the challenge routes.
|
|
6205
7302
|
*/
|
|
6206
|
-
function
|
|
6207
|
-
if (
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
const prefix = `${settings.name}=`;
|
|
6211
|
-
const cookies = cookieHeader.split(";");
|
|
6212
|
-
for (const part of cookies) {
|
|
6213
|
-
const trimmed = part.trim();
|
|
6214
|
-
if (trimmed.startsWith(prefix)) return decodeURIComponent(trimmed.slice(prefix.length));
|
|
6215
|
-
}
|
|
7303
|
+
async function assertMfaSatisfied(authRepo, uid) {
|
|
7304
|
+
if (typeof authRepo.hasVerifiedMfaFactors !== "function") {
|
|
7305
|
+
logger.warn("[MFA] Auth repository cannot report enrolled factors; the login step-up gate is inert on this backend");
|
|
7306
|
+
return;
|
|
6216
7307
|
}
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
...response,
|
|
6227
|
-
tokens: {
|
|
6228
|
-
...response.tokens,
|
|
6229
|
-
refreshToken: ""
|
|
6230
|
-
}
|
|
7308
|
+
if (!await authRepo.hasVerifiedMfaFactors(uid)) return;
|
|
7309
|
+
const factors = (await authRepo.getMfaFactors(uid)).filter((f) => f.verified);
|
|
7310
|
+
const details = {
|
|
7311
|
+
mfaToken: generateMfaPendingToken(uid),
|
|
7312
|
+
factors: factors.map((f) => ({
|
|
7313
|
+
id: f.id,
|
|
7314
|
+
factorType: f.factorType,
|
|
7315
|
+
friendlyName: f.friendlyName
|
|
7316
|
+
}))
|
|
6231
7317
|
};
|
|
7318
|
+
logger.info("[Security Audit] MFA step-up required", {
|
|
7319
|
+
eventType: "auth.mfa.required",
|
|
7320
|
+
uid
|
|
7321
|
+
});
|
|
7322
|
+
throw new ApiError(401, MFA_REQUIRED_CODE, "Multi-factor authentication is required to complete sign-in.", details, true);
|
|
6232
7323
|
}
|
|
6233
7324
|
//#endregion
|
|
6234
7325
|
//#region src/auth/registration-policy.ts
|
|
@@ -6250,11 +7341,58 @@ function isSteadyStateRegistrationOpen(policy) {
|
|
|
6250
7341
|
needsSetup: false
|
|
6251
7342
|
});
|
|
6252
7343
|
}
|
|
7344
|
+
/**
|
|
7345
|
+
* Whether `POST /auth/anonymous` may mint a user.
|
|
7346
|
+
*
|
|
7347
|
+
* Anonymous sign-in *is* registration: it inserts a `users` row and assigns
|
|
7348
|
+
* `defaultRole` exactly as `POST /auth/register` does. It simply never asked.
|
|
7349
|
+
* Both anonymous routes were mounted unconditionally and consulted none of the
|
|
7350
|
+
* gates above, so a backend with `allowRegistration: false` **and**
|
|
7351
|
+
* `disableSelfRegistration: true` — the documented hard kill switch — still
|
|
7352
|
+
* handed a permanent account to any caller in two unauthenticated requests:
|
|
7353
|
+
* `POST /auth/anonymous` for the row and the session, then
|
|
7354
|
+
* `POST /auth/anonymous/link` to put an email and password on it.
|
|
7355
|
+
*
|
|
7356
|
+
* Two terms, because the two questions are genuinely different:
|
|
7357
|
+
*
|
|
7358
|
+
* - `allowAnonymous` is the feature switch, and it is **opt-in**. There was no
|
|
7359
|
+
* key at all before, so a deployment could not turn anonymous auth off even
|
|
7360
|
+
* after finding it on; defaulting it to `true` here would keep that hole
|
|
7361
|
+
* open for everyone who never learns the key exists.
|
|
7362
|
+
* - `disableSelfRegistration` still wins over it. Its docstring calls it a
|
|
7363
|
+
* hard kill switch that blocks "self-registration outright", and an account
|
|
7364
|
+
* created without credentials is still an account created by the public.
|
|
7365
|
+
* An operator who sets both gets the kill switch they asked for.
|
|
7366
|
+
*
|
|
7367
|
+
* `allowRegistration` deliberately does **not** gate this. A public read-mostly
|
|
7368
|
+
* app that wants anonymous sessions but no sign-up forms is a real deployment,
|
|
7369
|
+
* and `allowAnonymous: true` says so precisely.
|
|
7370
|
+
*/
|
|
7371
|
+
function isAnonymousAuthOpen(policy) {
|
|
7372
|
+
if (policy.disableSelfRegistration) return false;
|
|
7373
|
+
return !!policy.allowAnonymous;
|
|
7374
|
+
}
|
|
6253
7375
|
//#endregion
|
|
6254
7376
|
//#region src/auth/session-routes.ts
|
|
6255
7377
|
function mountSessionRoutes(opts) {
|
|
6256
7378
|
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
|
|
6257
7379
|
const authRepo = config.authRepo;
|
|
7380
|
+
/**
|
|
7381
|
+
* Refuse the anonymous routes unless the deployment opted in.
|
|
7382
|
+
*
|
|
7383
|
+
* 403 rather than 404: the route exists, and a client that was told
|
|
7384
|
+
* `anonymousLogin: true` by `GET /auth/config` and then sees a 404 has no
|
|
7385
|
+
* way to tell a disabled feature from a version skew. The error names the
|
|
7386
|
+
* key, because the failure this closes was an operator believing they had
|
|
7387
|
+
* already switched registration off.
|
|
7388
|
+
*/
|
|
7389
|
+
function assertAnonymousAuthOpen() {
|
|
7390
|
+
if (isAnonymousAuthOpen({
|
|
7391
|
+
allowAnonymous: config.allowAnonymous,
|
|
7392
|
+
disableSelfRegistration: config.disableSelfRegistration
|
|
7393
|
+
})) return;
|
|
7394
|
+
throw ApiError.forbidden(config.disableSelfRegistration ? "Anonymous sign-in is disabled: disableSelfRegistration blocks it." : "Anonymous sign-in is disabled. Set `auth.allowAnonymous: true` to enable it.", "ANONYMOUS_AUTH_DISABLED");
|
|
7395
|
+
}
|
|
6258
7396
|
const logoutSchema = object({ refreshToken: string().optional() });
|
|
6259
7397
|
const linkSchema = object({
|
|
6260
7398
|
email: string().email("Invalid email address").max(255),
|
|
@@ -6281,7 +7419,7 @@ function mountSessionRoutes(opts) {
|
|
|
6281
7419
|
clearRefreshCookie(c, config.cookieAuth);
|
|
6282
7420
|
const accessToken = extractBearerToken(c.req.header("authorization"));
|
|
6283
7421
|
if (ops.afterLogout && accessToken !== void 0) {
|
|
6284
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
7422
|
+
const { verifyAccessToken } = await import("./jwt-_IFqfTOg.js").then((n) => n.f);
|
|
6285
7423
|
const payload = verifyAccessToken(accessToken);
|
|
6286
7424
|
if (payload) ops.afterLogout(payload.uid).catch((err) => {
|
|
6287
7425
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
@@ -6447,6 +7585,10 @@ function mountSessionRoutes(opts) {
|
|
|
6447
7585
|
registrationEnabled: registrationAllowed,
|
|
6448
7586
|
emailServiceEnabled: isEmailConfigured(),
|
|
6449
7587
|
magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
|
|
7588
|
+
anonymousLoginEnabled: isAnonymousAuthOpen({
|
|
7589
|
+
allowAnonymous: config.allowAnonymous,
|
|
7590
|
+
disableSelfRegistration: config.disableSelfRegistration
|
|
7591
|
+
}),
|
|
6450
7592
|
enabledProviders
|
|
6451
7593
|
});
|
|
6452
7594
|
});
|
|
@@ -6455,6 +7597,7 @@ function mountSessionRoutes(opts) {
|
|
|
6455
7597
|
* Create an anonymous user with temporary credentials
|
|
6456
7598
|
*/
|
|
6457
7599
|
router.post("/anonymous", strictAuthLimiter, async (c) => {
|
|
7600
|
+
assertAnonymousAuthOpen();
|
|
6458
7601
|
let createData = {
|
|
6459
7602
|
email: `anon_${randomBytes$1(16).toString("hex")}@anonymous.local`,
|
|
6460
7603
|
emailVerified: false,
|
|
@@ -6478,7 +7621,8 @@ function mountSessionRoutes(opts) {
|
|
|
6478
7621
|
* POST /auth/anonymous/link
|
|
6479
7622
|
* Upgrade an anonymous user to a permanent account with email/password
|
|
6480
7623
|
*/
|
|
6481
|
-
router.post("/anonymous/link", requireAuth, async (c) => {
|
|
7624
|
+
router.post("/anonymous/link", strictAuthLimiter, requireAuth, async (c) => {
|
|
7625
|
+
assertAnonymousAuthOpen();
|
|
6482
7626
|
const userCtx = c.get("user");
|
|
6483
7627
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
6484
7628
|
const user = await authRepo.getUserById(userCtx.uid);
|
|
@@ -6535,7 +7679,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
6535
7679
|
const tokenHash = hashToken(token);
|
|
6536
7680
|
const expiresAt = getMagicLinkExpiry();
|
|
6537
7681
|
await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
|
|
6538
|
-
const magicLinkUrl = `${emailConfig
|
|
7682
|
+
const magicLinkUrl = `${resolveEmailLinkBase(emailConfig, "magicLink")}/auth/magic-link?token=${token}`;
|
|
6539
7683
|
const appName = emailConfig?.appName || "Rebase";
|
|
6540
7684
|
const templateFn = emailConfig?.templates?.magicLink;
|
|
6541
7685
|
const emailContent = templateFn ? templateFn(magicLinkUrl, {
|
|
@@ -6587,6 +7731,64 @@ function mountMagicLinkRoutes(deps) {
|
|
|
6587
7731
|
});
|
|
6588
7732
|
}
|
|
6589
7733
|
//#endregion
|
|
7734
|
+
//#region src/auth/oauth-signin-policy.ts
|
|
7735
|
+
/**
|
|
7736
|
+
* May an OAuth identity be attached to a pre-existing account found *by email*?
|
|
7737
|
+
*
|
|
7738
|
+
* Both sides have to be trustworthy, and only one of them used to be checked:
|
|
7739
|
+
*
|
|
7740
|
+
* - the **provider** must have verified the address, or the caller has not
|
|
7741
|
+
* shown they control it;
|
|
7742
|
+
* - the **local account** must itself be trustworthy — either its address was
|
|
7743
|
+
* verified, or it has no password at all (it was created by an OAuth
|
|
7744
|
+
* sign-in or an invitation, so there is no credential an attacker could
|
|
7745
|
+
* have planted in advance).
|
|
7746
|
+
*
|
|
7747
|
+
* A refusal is not a dead end: `POST /auth/link/<provider>` attaches the
|
|
7748
|
+
* identity once the caller proves ownership by holding a session.
|
|
7749
|
+
*/
|
|
7750
|
+
function decideOAuthAutoLink(args) {
|
|
7751
|
+
if (args.providerEmailVerified !== true) return {
|
|
7752
|
+
allowed: false,
|
|
7753
|
+
reason: "provider-email-unverified"
|
|
7754
|
+
};
|
|
7755
|
+
if (Boolean(args.existingUser.passwordHash) && !args.existingUser.emailVerified) return {
|
|
7756
|
+
allowed: false,
|
|
7757
|
+
reason: "local-account-unverified"
|
|
7758
|
+
};
|
|
7759
|
+
return { allowed: true };
|
|
7760
|
+
}
|
|
7761
|
+
/**
|
|
7762
|
+
* Is `redirectUri` one the operator authorised?
|
|
7763
|
+
*
|
|
7764
|
+
* The provider's own registered-URI match is a real control but a coarse one:
|
|
7765
|
+
* it authorises *every* URI registered on that OAuth client, so a `localhost`
|
|
7766
|
+
* entry kept for development, or a second product sharing the client id, can
|
|
7767
|
+
* mint codes this backend accepts. An empty or absent allowlist keeps the old
|
|
7768
|
+
* behaviour (the provider's check is the only one) so existing deployments are
|
|
7769
|
+
* unaffected; setting one narrows it to the origins this backend serves.
|
|
7770
|
+
*
|
|
7771
|
+
* Comparison is on origin plus path, with the origin lowercased and a trailing
|
|
7772
|
+
* slash ignored — query strings and fragments are not part of the identity of a
|
|
7773
|
+
* redirect URI, and neither is the case of the host.
|
|
7774
|
+
*/
|
|
7775
|
+
function isRedirectUriAllowed(redirectUri, allowlist) {
|
|
7776
|
+
if (!allowlist || allowlist.length === 0) return true;
|
|
7777
|
+
const candidate = canonicalRedirectUri(redirectUri);
|
|
7778
|
+
if (!candidate) return false;
|
|
7779
|
+
return allowlist.some((allowed) => canonicalRedirectUri(allowed) === candidate);
|
|
7780
|
+
}
|
|
7781
|
+
function canonicalRedirectUri(uri) {
|
|
7782
|
+
let parsed;
|
|
7783
|
+
try {
|
|
7784
|
+
parsed = new URL(uri);
|
|
7785
|
+
} catch {
|
|
7786
|
+
return null;
|
|
7787
|
+
}
|
|
7788
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
7789
|
+
return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${path}`;
|
|
7790
|
+
}
|
|
7791
|
+
//#endregion
|
|
6590
7792
|
//#region src/auth/routes.ts
|
|
6591
7793
|
/**
|
|
6592
7794
|
* Helper to build standard auth response output
|
|
@@ -6618,7 +7820,7 @@ function getPasswordResetExpiry() {
|
|
|
6618
7820
|
return new Date(Date.now() + 3600 * 1e3);
|
|
6619
7821
|
}
|
|
6620
7822
|
function createAuthRoutes(config) {
|
|
6621
|
-
if (config.defaultRole
|
|
7823
|
+
if (config.defaultRole && isAdministrativeRole(config.defaultRole)) throw new Error(`CRITICAL SECURITY ERROR: defaultRole cannot be '${config.defaultRole}'. Administrative privilege escalation via registration is strictly forbidden (administrative roles: ${ADMINISTRATIVE_ROLES.join(", ")}). Use the POST /admin/bootstrap endpoint to promote the initial administrator.`);
|
|
6622
7824
|
const router = new Hono();
|
|
6623
7825
|
router.onError(errorHandler);
|
|
6624
7826
|
const authRepo = config.authRepo;
|
|
@@ -6701,7 +7903,7 @@ function createAuthRoutes(config) {
|
|
|
6701
7903
|
function sendWelcomeEmail(user) {
|
|
6702
7904
|
if (!isEmailConfigured()) return;
|
|
6703
7905
|
const appName = emailConfig?.appName || "Rebase";
|
|
6704
|
-
const loginUrl = emailConfig
|
|
7906
|
+
const loginUrl = resolveEmailLinkBase(emailConfig, "resetPassword");
|
|
6705
7907
|
const templateFn = emailConfig?.templates?.welcomeEmail;
|
|
6706
7908
|
const emailContent = templateFn ? templateFn(user, appName) : getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : void 0);
|
|
6707
7909
|
emailService.send({
|
|
@@ -6714,9 +7916,21 @@ function createAuthRoutes(config) {
|
|
|
6714
7916
|
});
|
|
6715
7917
|
}
|
|
6716
7918
|
/**
|
|
6717
|
-
* Helper to generate and store session tokens
|
|
7919
|
+
* Helper to generate and store session tokens.
|
|
7920
|
+
*
|
|
7921
|
+
* Every route that signs somebody in comes through here — password login,
|
|
7922
|
+
* register, each OAuth provider, magic link, anonymous and anonymous-link —
|
|
7923
|
+
* which is why the second-factor gate is *inside* it rather than repeated at
|
|
7924
|
+
* each call site. An account with a verified factor gets no session from
|
|
7925
|
+
* this function at all: it throws `MFA_REQUIRED`, and the only thing that
|
|
7926
|
+
* mints a session for that user is `POST /auth/mfa/challenge/verify`.
|
|
7927
|
+
*
|
|
7928
|
+
* `skipMfaGate` exists for exactly one caller: the challenge-verify route
|
|
7929
|
+
* itself, which has just seen the second factor and mints at `aal2`.
|
|
6718
7930
|
*/
|
|
6719
|
-
async function createSessionAndTokens(uid, userAgent, ipAddress) {
|
|
7931
|
+
async function createSessionAndTokens(uid, userAgent, ipAddress, options) {
|
|
7932
|
+
if (!options?.skipMfaGate) await assertMfaSatisfied(authRepo, uid);
|
|
7933
|
+
const aal = options?.aal ?? "aal1";
|
|
6720
7934
|
const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
|
|
6721
7935
|
let customClaims;
|
|
6722
7936
|
if (ops.customizeAccessToken) {
|
|
@@ -6725,16 +7939,17 @@ function createAuthRoutes(config) {
|
|
|
6725
7939
|
const defaultClaims = {
|
|
6726
7940
|
uid,
|
|
6727
7941
|
roles: roleIds,
|
|
6728
|
-
aal
|
|
7942
|
+
aal
|
|
6729
7943
|
};
|
|
6730
7944
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
6731
7945
|
}
|
|
6732
7946
|
}
|
|
6733
|
-
const accessToken = generateAccessToken(uid, roleIds,
|
|
7947
|
+
const accessToken = generateAccessToken(uid, roleIds, aal, customClaims);
|
|
6734
7948
|
const refreshToken = generateRefreshToken();
|
|
6735
7949
|
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress, {
|
|
6736
7950
|
id: randomUUID$1(),
|
|
6737
|
-
startedAt: /* @__PURE__ */ new Date()
|
|
7951
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
7952
|
+
aal
|
|
6738
7953
|
});
|
|
6739
7954
|
return {
|
|
6740
7955
|
roleIds,
|
|
@@ -6832,29 +8047,46 @@ function createAuthRoutes(config) {
|
|
|
6832
8047
|
if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
|
|
6833
8048
|
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
6834
8049
|
const payload = parseBody(provider.schema, await c.req.json());
|
|
8050
|
+
const requestedRedirectUri = payload.redirectUri;
|
|
8051
|
+
if (typeof requestedRedirectUri === "string" && !isRedirectUriAllowed(requestedRedirectUri, config.allowedRedirectUris)) throw ApiError.badRequest("redirectUri is not allowed for this backend", "REDIRECT_URI_NOT_ALLOWED");
|
|
6835
8052
|
let externalUser;
|
|
6836
8053
|
try {
|
|
6837
8054
|
externalUser = await provider.verify(payload);
|
|
6838
8055
|
} catch (err) {
|
|
6839
|
-
|
|
6840
|
-
throw ApiError.unauthorized(
|
|
8056
|
+
logger.error(`[OAuth] ${provider.id} verification threw`, { error: err instanceof Error ? err.message : String(err) });
|
|
8057
|
+
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "OAUTH_ERROR");
|
|
6841
8058
|
}
|
|
6842
8059
|
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
6843
8060
|
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
6844
8061
|
if (!user) {
|
|
6845
8062
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
6846
8063
|
if (user) {
|
|
6847
|
-
|
|
8064
|
+
const decision = decideOAuthAutoLink({
|
|
8065
|
+
providerEmailVerified: externalUser.emailVerified,
|
|
8066
|
+
existingUser: user
|
|
8067
|
+
});
|
|
8068
|
+
if (!decision.allowed) {
|
|
8069
|
+
const why = decision.reason === "provider-email-unverified" ? `${provider.id} has not verified this email address, so it cannot be linked automatically.` : "That account's email address was never verified, so it cannot be linked automatically.";
|
|
8070
|
+
throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${why} Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
|
|
8071
|
+
}
|
|
6848
8072
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
6849
8073
|
await authRepo.updateUser(user.id, {
|
|
6850
8074
|
displayName: user.displayName || externalUser.displayName || void 0,
|
|
6851
8075
|
photoUrl: user.photoUrl || externalUser.photoUrl || void 0
|
|
6852
8076
|
});
|
|
6853
8077
|
} else {
|
|
8078
|
+
if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
8079
|
+
let bootstrapRegistration = false;
|
|
8080
|
+
if (!isRegistrationAllowed()) {
|
|
8081
|
+
const { total } = await authRepo.listUsersPaginated({ limit: 1 });
|
|
8082
|
+
bootstrapRegistration = total === 0;
|
|
8083
|
+
if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
8084
|
+
}
|
|
6854
8085
|
user = await authRepo.createUser({
|
|
6855
8086
|
email: normalizeEmail(externalUser.email),
|
|
6856
8087
|
displayName: externalUser.displayName || void 0,
|
|
6857
|
-
photoUrl: externalUser.photoUrl || void 0
|
|
8088
|
+
photoUrl: externalUser.photoUrl || void 0,
|
|
8089
|
+
emailVerified: externalUser.emailVerified === true
|
|
6858
8090
|
});
|
|
6859
8091
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
6860
8092
|
if (ops.afterUserCreate) try {
|
|
@@ -6863,7 +8095,12 @@ function createAuthRoutes(config) {
|
|
|
6863
8095
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
6864
8096
|
}
|
|
6865
8097
|
const allUsers = await authRepo.listUsers();
|
|
6866
|
-
|
|
8098
|
+
const isFirstUser = allUsers.length === 1 && allUsers[0].id === user.id;
|
|
8099
|
+
if (bootstrapRegistration && !isFirstUser) {
|
|
8100
|
+
await authRepo.deleteUser(user.id);
|
|
8101
|
+
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
8102
|
+
}
|
|
8103
|
+
if (isFirstUser) await authRepo.setUserRoles(user.id, ["admin"]);
|
|
6867
8104
|
else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
6868
8105
|
sendWelcomeEmail({
|
|
6869
8106
|
email: user.email,
|
|
@@ -6937,7 +8174,7 @@ function createAuthRoutes(config) {
|
|
|
6937
8174
|
const tokenHash = hashToken(token);
|
|
6938
8175
|
const expiresAt = getPasswordResetExpiry();
|
|
6939
8176
|
await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
|
6940
|
-
const resetUrl = `${emailConfig
|
|
8177
|
+
const resetUrl = `${resolveEmailLinkBase(emailConfig, "resetPassword")}/reset-password?token=${token}`;
|
|
6941
8178
|
const appName = emailConfig?.appName || "Rebase";
|
|
6942
8179
|
const templateFn = emailConfig?.templates?.passwordReset;
|
|
6943
8180
|
const emailContent = templateFn ? templateFn(resetUrl, {
|
|
@@ -7012,8 +8249,15 @@ function createAuthRoutes(config) {
|
|
|
7012
8249
|
/**
|
|
7013
8250
|
* POST /auth/send-verification
|
|
7014
8251
|
* Send email verification link (authenticated)
|
|
8252
|
+
*
|
|
8253
|
+
* Two limiters, because they bound different things: `strictAuthLimiter`
|
|
8254
|
+
* bounds the caller by IP as it does on every other email-sending route, and
|
|
8255
|
+
* `verificationEmailLimiter` bounds how much mail one account — i.e. one
|
|
8256
|
+
* recipient address — can be made to receive. Authentication is not a bound
|
|
8257
|
+
* here: the address is unverified by construction, so an attacker can hold a
|
|
8258
|
+
* session for a mailbox that is not theirs.
|
|
7015
8259
|
*/
|
|
7016
|
-
router.post("/send-verification", requireAuth, async (c) => {
|
|
8260
|
+
router.post("/send-verification", strictAuthLimiter, requireAuth, verificationEmailLimiter, async (c) => {
|
|
7017
8261
|
const userCtx = c.get("user");
|
|
7018
8262
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
7019
8263
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
|
|
@@ -7022,7 +8266,7 @@ function createAuthRoutes(config) {
|
|
|
7022
8266
|
if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
7023
8267
|
const token = generateSecureToken();
|
|
7024
8268
|
await authRepo.setVerificationToken(user.id, hashToken(token));
|
|
7025
|
-
const verifyUrl = `${emailConfig
|
|
8269
|
+
const verifyUrl = `${resolveEmailLinkBase(emailConfig, "verifyEmail")}/verify-email?token=${token}`;
|
|
7026
8270
|
const appName = emailConfig?.appName || "Rebase";
|
|
7027
8271
|
const templateFn = emailConfig?.templates?.emailVerification;
|
|
7028
8272
|
const emailContent = templateFn ? templateFn(verifyUrl, {
|
|
@@ -7104,22 +8348,24 @@ function createAuthRoutes(config) {
|
|
|
7104
8348
|
});
|
|
7105
8349
|
return null;
|
|
7106
8350
|
});
|
|
8351
|
+
const sessionAal = storedToken.aal === "aal2" ? "aal2" : "aal1";
|
|
7107
8352
|
let customClaims;
|
|
7108
8353
|
if (ops.customizeAccessToken && user) {
|
|
7109
8354
|
const defaultClaims = {
|
|
7110
8355
|
uid: storedToken.uid,
|
|
7111
8356
|
roles: roleIds,
|
|
7112
|
-
aal:
|
|
8357
|
+
aal: sessionAal
|
|
7113
8358
|
};
|
|
7114
8359
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
7115
8360
|
}
|
|
7116
|
-
const newAccessToken = generateAccessToken(storedToken.uid, roleIds,
|
|
8361
|
+
const newAccessToken = generateAccessToken(storedToken.uid, roleIds, sessionAal, customClaims);
|
|
7117
8362
|
const newRefreshToken = generateRefreshToken();
|
|
7118
8363
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
7119
8364
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
7120
8365
|
const session = {
|
|
7121
8366
|
id: storedToken.sessionId ?? storedToken.id,
|
|
7122
|
-
startedAt: sessionStartedAt
|
|
8367
|
+
startedAt: sessionStartedAt,
|
|
8368
|
+
aal: sessionAal
|
|
7123
8369
|
};
|
|
7124
8370
|
if (!supersededAt) if (authRepo.markRefreshTokenRotated) await authRepo.markRefreshTokenRotated(tokenHash);
|
|
7125
8371
|
else await authRepo.deleteRefreshToken(tokenHash);
|
|
@@ -7151,7 +8397,15 @@ function createAuthRoutes(config) {
|
|
|
7151
8397
|
createSessionAndTokens,
|
|
7152
8398
|
applyTransformHook
|
|
7153
8399
|
});
|
|
7154
|
-
mountMfaRoutes(
|
|
8400
|
+
mountMfaRoutes({
|
|
8401
|
+
router,
|
|
8402
|
+
config,
|
|
8403
|
+
ops,
|
|
8404
|
+
parseBody,
|
|
8405
|
+
buildAuthResponse,
|
|
8406
|
+
createSessionAndTokens,
|
|
8407
|
+
applyTransformHook
|
|
8408
|
+
});
|
|
7155
8409
|
if (config.enableMagicLink) mountMagicLinkRoutes({
|
|
7156
8410
|
router,
|
|
7157
8411
|
config,
|
|
@@ -7184,7 +8438,11 @@ function createResetPasswordRoute(config) {
|
|
|
7184
8438
|
const { emailService, emailConfig, collectionAuthConfig } = config;
|
|
7185
8439
|
const ops = resolveAuthHooks(config.authHooks);
|
|
7186
8440
|
router.onError(errorHandler);
|
|
7187
|
-
router.use("/*", createRequireAuth({
|
|
8441
|
+
router.use("/*", createRequireAuth({
|
|
8442
|
+
serviceKey: config.serviceKey,
|
|
8443
|
+
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
8444
|
+
revocationRepo: authRepo
|
|
8445
|
+
}));
|
|
7188
8446
|
router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
|
|
7189
8447
|
const uid = c.req.param("uid");
|
|
7190
8448
|
const existing = await authRepo.getUserById(uid);
|
|
@@ -7287,7 +8545,11 @@ function createAdminRolesRoute(config) {
|
|
|
7287
8545
|
const router = new Hono();
|
|
7288
8546
|
const authRepo = config.authRepo;
|
|
7289
8547
|
router.onError(errorHandler);
|
|
7290
|
-
router.use("/*", createRequireAuth({
|
|
8548
|
+
router.use("/*", createRequireAuth({
|
|
8549
|
+
serviceKey: config.serviceKey,
|
|
8550
|
+
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
8551
|
+
revocationRepo: authRepo
|
|
8552
|
+
}));
|
|
7291
8553
|
router.get("/roles", requireAdmin, async (c) => {
|
|
7292
8554
|
const roles = await authRepo.listRoles();
|
|
7293
8555
|
return c.json({ roles });
|
|
@@ -7327,7 +8589,11 @@ function createAdminUsersRoute(config) {
|
|
|
7327
8589
|
};
|
|
7328
8590
|
}
|
|
7329
8591
|
router.onError(errorHandler);
|
|
7330
|
-
router.use("/*", createRequireAuth({
|
|
8592
|
+
router.use("/*", createRequireAuth({
|
|
8593
|
+
serviceKey: config.serviceKey,
|
|
8594
|
+
resolveRoles: (uid) => authRepo.getUserRoleIds(uid),
|
|
8595
|
+
revocationRepo: authRepo
|
|
8596
|
+
}));
|
|
7331
8597
|
router.post("/bootstrap", async (c) => {
|
|
7332
8598
|
const user = c.get("user");
|
|
7333
8599
|
if (!user || typeof user !== "object") throw ApiError.unauthorized("Not authenticated");
|
|
@@ -7341,11 +8607,19 @@ function createAdminUsersRoute(config) {
|
|
|
7341
8607
|
break;
|
|
7342
8608
|
}
|
|
7343
8609
|
if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
|
|
7344
|
-
const uid = "uid" in user ? user.uid :
|
|
8610
|
+
const uid = "uid" in user ? user.uid : void 0;
|
|
7345
8611
|
if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
|
|
8612
|
+
if (isAnonymousUid(uid)) {
|
|
8613
|
+
logger.warn("[Security Audit] Bootstrap denied: anonymous caller", {
|
|
8614
|
+
eventType: "auth.bootstrap.denied.anonymous",
|
|
8615
|
+
callerId: uid
|
|
8616
|
+
});
|
|
8617
|
+
throw ApiError.forbidden("An anonymous session cannot claim the initial admin role. Register a real account and bootstrap from it, or assign the admin role using the service key.", "BOOTSTRAP_ANONYMOUS");
|
|
8618
|
+
}
|
|
7346
8619
|
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
|
|
7347
|
-
|
|
7348
|
-
|
|
8620
|
+
const registeredUsers = users.filter((u) => !isAnonymousUid(u.id));
|
|
8621
|
+
if (registeredUsers.length > 0) {
|
|
8622
|
+
const earliest = registeredUsers.reduce((a, b) => {
|
|
7349
8623
|
const at = new Date(a.createdAt).getTime();
|
|
7350
8624
|
const bt = new Date(b.createdAt).getTime();
|
|
7351
8625
|
if (at !== bt) return at < bt ? a : b;
|
|
@@ -7395,7 +8669,7 @@ function createAdminUsersRoute(config) {
|
|
|
7395
8669
|
const search = c.req.query("search");
|
|
7396
8670
|
const orderBy = c.req.query("orderBy");
|
|
7397
8671
|
const orderDir = c.req.query("orderDir");
|
|
7398
|
-
const limit =
|
|
8672
|
+
const limit = resolveListLimitParam(limitParam, { defaultLimit: 25 });
|
|
7399
8673
|
const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
|
|
7400
8674
|
const result = await authRepo.listUsersPaginated({
|
|
7401
8675
|
limit,
|
|
@@ -7434,13 +8708,15 @@ function createAdminUsersRoute(config) {
|
|
|
7434
8708
|
resolvedHooks: ops,
|
|
7435
8709
|
collectionAuthConfig
|
|
7436
8710
|
});
|
|
7437
|
-
|
|
8711
|
+
let createData = {
|
|
7438
8712
|
email: prepResult.values.email,
|
|
7439
8713
|
passwordHash: prepResult.values.passwordHash,
|
|
7440
8714
|
displayName: prepResult.values.displayName,
|
|
7441
8715
|
photoUrl: prepResult.values.photoUrl,
|
|
7442
8716
|
metadata: prepResult.values.metadata
|
|
7443
|
-
}
|
|
8717
|
+
};
|
|
8718
|
+
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
8719
|
+
const user = await authRepo.createUser(createData);
|
|
7444
8720
|
if (roles && Array.isArray(roles)) await authRepo.setUserRoles(user.id, roles);
|
|
7445
8721
|
const finalizeResult = await finalizeAdminUserCreation({
|
|
7446
8722
|
id: user.id,
|
|
@@ -7498,7 +8774,11 @@ function createAdminUsersRoute(config) {
|
|
|
7498
8774
|
limit: 1
|
|
7499
8775
|
})).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
|
|
7500
8776
|
}
|
|
8777
|
+
if (ops.beforeUserDelete) await ops.beforeUserDelete(uid);
|
|
7501
8778
|
await authRepo.deleteUser(uid);
|
|
8779
|
+
if (ops.afterUserDelete) ops.afterUserDelete(uid).catch((err) => {
|
|
8780
|
+
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
8781
|
+
});
|
|
7502
8782
|
return c.json({ success: true });
|
|
7503
8783
|
});
|
|
7504
8784
|
return router;
|
|
@@ -7513,7 +8793,7 @@ function createAdminUsersRoute(config) {
|
|
|
7513
8793
|
* when the user passes a plain `RebaseAuthConfig` object.
|
|
7514
8794
|
*/
|
|
7515
8795
|
function createBuiltinAuthAdapter(config) {
|
|
7516
|
-
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
|
|
8796
|
+
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowAnonymous = false, allowUserLookup = false, defaultRole, oauthProviders = [], allowedRedirectUris, serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
|
|
7517
8797
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
7518
8798
|
return {
|
|
7519
8799
|
id: "rebase-builtin",
|
|
@@ -7530,6 +8810,13 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7530
8810
|
};
|
|
7531
8811
|
const payload = verifyAccessToken(token);
|
|
7532
8812
|
if (!payload) return null;
|
|
8813
|
+
if (await isAccessTokenRevoked(authRepository, payload)) {
|
|
8814
|
+
logger.warn("[Security Audit] Refused a revoked access token", {
|
|
8815
|
+
eventType: "auth.token.revoked",
|
|
8816
|
+
uid: payload.uid
|
|
8817
|
+
});
|
|
8818
|
+
return null;
|
|
8819
|
+
}
|
|
7533
8820
|
let roles = payload.roles || [];
|
|
7534
8821
|
try {
|
|
7535
8822
|
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
@@ -7539,7 +8826,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7539
8826
|
error: err
|
|
7540
8827
|
});
|
|
7541
8828
|
}
|
|
7542
|
-
const isAdmin = roles
|
|
8829
|
+
const isAdmin = hasAdministrativeRole(roles);
|
|
7543
8830
|
return {
|
|
7544
8831
|
uid: payload.uid,
|
|
7545
8832
|
email: payload.email ?? "",
|
|
@@ -7568,7 +8855,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7568
8855
|
error: err
|
|
7569
8856
|
});
|
|
7570
8857
|
}
|
|
7571
|
-
const isAdmin = roles
|
|
8858
|
+
const isAdmin = hasAdministrativeRole(roles);
|
|
7572
8859
|
return {
|
|
7573
8860
|
uid: payload.uid,
|
|
7574
8861
|
email: payload.email ?? "",
|
|
@@ -7586,9 +8873,11 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7586
8873
|
emailConfig,
|
|
7587
8874
|
allowRegistration,
|
|
7588
8875
|
disableSelfRegistration,
|
|
8876
|
+
allowAnonymous,
|
|
7589
8877
|
allowUserLookup,
|
|
7590
8878
|
defaultRole,
|
|
7591
8879
|
oauthProviders,
|
|
8880
|
+
allowedRedirectUris,
|
|
7592
8881
|
authHooks,
|
|
7593
8882
|
enableMagicLink,
|
|
7594
8883
|
cookieAuth
|
|
@@ -7660,6 +8949,10 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7660
8949
|
allowRegistration,
|
|
7661
8950
|
needsSetup
|
|
7662
8951
|
});
|
|
8952
|
+
const anonymousAllowed = isAnonymousAuthOpen({
|
|
8953
|
+
allowAnonymous,
|
|
8954
|
+
disableSelfRegistration
|
|
8955
|
+
});
|
|
7663
8956
|
return {
|
|
7664
8957
|
hasBuiltInAuthRoutes: true,
|
|
7665
8958
|
emailPasswordLogin: true,
|
|
@@ -7671,6 +8964,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7671
8964
|
profileUpdate: true,
|
|
7672
8965
|
emailVerification: !!emailService?.isConfigured(),
|
|
7673
8966
|
magicLink: enableMagicLink && !!emailService?.isConfigured(),
|
|
8967
|
+
anonymousLogin: anonymousAllowed,
|
|
7674
8968
|
enabledProviders,
|
|
7675
8969
|
needsSetup
|
|
7676
8970
|
};
|
|
@@ -7755,6 +9049,54 @@ function toAuthUserData(user) {
|
|
|
7755
9049
|
};
|
|
7756
9050
|
}
|
|
7757
9051
|
//#endregion
|
|
9052
|
+
//#region src/auth/oauth-code-flow.ts
|
|
9053
|
+
/**
|
|
9054
|
+
* Build the request schema for an authorization-code provider.
|
|
9055
|
+
*
|
|
9056
|
+
* `pkce` defaults to `"optional"`: the client decides whether it sent a
|
|
9057
|
+
* `code_challenge`, and providers that do not implement PKCE simply never see
|
|
9058
|
+
* a verifier because their clients never generate one. `"required"` is for
|
|
9059
|
+
* providers that mandate PKCE (Twitter/X).
|
|
9060
|
+
*/
|
|
9061
|
+
function oauthCodeFlowSchema(opts = {}) {
|
|
9062
|
+
const verifier = opts.pkce === "required" ? string().min(1, "PKCE code verifier is required") : string().min(1).optional();
|
|
9063
|
+
return object({
|
|
9064
|
+
code: string().min(1, "Auth code is required"),
|
|
9065
|
+
redirectUri: string().url("Valid redirect URI is required"),
|
|
9066
|
+
codeVerifier: verifier
|
|
9067
|
+
});
|
|
9068
|
+
}
|
|
9069
|
+
/**
|
|
9070
|
+
* The `code_verifier` token-endpoint parameter, or nothing.
|
|
9071
|
+
*
|
|
9072
|
+
* Returned as a spreadable object so a provider adds PKCE with one `...` in
|
|
9073
|
+
* its existing body literal, rather than branching.
|
|
9074
|
+
*/
|
|
9075
|
+
function pkceTokenParams(codeVerifier) {
|
|
9076
|
+
return codeVerifier ? { code_verifier: codeVerifier } : {};
|
|
9077
|
+
}
|
|
9078
|
+
/**
|
|
9079
|
+
* Normalise a provider's email-verification signal into the boolean that
|
|
9080
|
+
* `OAuthProviderProfile.emailVerified` promises.
|
|
9081
|
+
*
|
|
9082
|
+
* The whole point is the default: anything that is not an affirmative
|
|
9083
|
+
* verification signal — `undefined`, `null`, a missing field, an empty string,
|
|
9084
|
+
* `false` — becomes `false`. A provider that reports nothing therefore cannot
|
|
9085
|
+
* accidentally assert that it verified the address, which is exactly the
|
|
9086
|
+
* mistake five providers had made.
|
|
9087
|
+
*
|
|
9088
|
+
* Accepts the string forms because OIDC issuers are inconsistent about whether
|
|
9089
|
+
* `email_verified` is a JSON boolean or a string.
|
|
9090
|
+
*/
|
|
9091
|
+
function providerVerifiedEmail(signal) {
|
|
9092
|
+
if (signal === true) return true;
|
|
9093
|
+
if (typeof signal === "string") {
|
|
9094
|
+
const normalized = signal.trim().toLowerCase();
|
|
9095
|
+
return normalized === "true" || normalized === "1";
|
|
9096
|
+
}
|
|
9097
|
+
return false;
|
|
9098
|
+
}
|
|
9099
|
+
//#endregion
|
|
7758
9100
|
//#region src/auth/google-oauth.ts
|
|
7759
9101
|
var _googleAuth;
|
|
7760
9102
|
async function loadGoogleAuth() {
|
|
@@ -7787,7 +9129,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
|
|
|
7787
9129
|
const tokenInfo = await tokenInfoRes.json();
|
|
7788
9130
|
if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
|
|
7789
9131
|
if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
|
|
7790
|
-
const emailVerified = tokenInfo.email_verified
|
|
9132
|
+
const emailVerified = providerVerifiedEmail(tokenInfo.email_verified);
|
|
7791
9133
|
let displayName = null;
|
|
7792
9134
|
let photoUrl = null;
|
|
7793
9135
|
try {
|
|
@@ -7842,7 +9184,8 @@ function createGoogleProvider(config) {
|
|
|
7842
9184
|
idToken: string().min(1).optional(),
|
|
7843
9185
|
accessToken: string().min(1).optional(),
|
|
7844
9186
|
code: string().min(1).optional(),
|
|
7845
|
-
redirectUri: string().min(1).optional()
|
|
9187
|
+
redirectUri: string().min(1).optional(),
|
|
9188
|
+
codeVerifier: string().min(1).optional()
|
|
7846
9189
|
}).refine((data) => data.idToken || data.accessToken || data.code && data.redirectUri, { message: "One of idToken, accessToken, or code+redirectUri is required" }),
|
|
7847
9190
|
verify: async (payload) => {
|
|
7848
9191
|
try {
|
|
@@ -7857,7 +9200,7 @@ function createGoogleProvider(config) {
|
|
|
7857
9200
|
email: content.email || "",
|
|
7858
9201
|
displayName: content.name || null,
|
|
7859
9202
|
photoUrl: content.picture || null,
|
|
7860
|
-
emailVerified: content.email_verified
|
|
9203
|
+
emailVerified: providerVerifiedEmail(content.email_verified)
|
|
7861
9204
|
};
|
|
7862
9205
|
}
|
|
7863
9206
|
if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
|
|
@@ -7871,7 +9214,8 @@ function createGoogleProvider(config) {
|
|
|
7871
9214
|
client_id: clientId,
|
|
7872
9215
|
client_secret: clientSecret,
|
|
7873
9216
|
redirect_uri: payload.redirectUri,
|
|
7874
|
-
grant_type: "authorization_code"
|
|
9217
|
+
grant_type: "authorization_code",
|
|
9218
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7875
9219
|
})
|
|
7876
9220
|
});
|
|
7877
9221
|
if (!tokenResponse.ok) {
|
|
@@ -7891,7 +9235,7 @@ function createGoogleProvider(config) {
|
|
|
7891
9235
|
email: content.email || "",
|
|
7892
9236
|
displayName: content.name || null,
|
|
7893
9237
|
photoUrl: content.picture || null,
|
|
7894
|
-
emailVerified: content.email_verified
|
|
9238
|
+
emailVerified: providerVerifiedEmail(content.email_verified)
|
|
7895
9239
|
};
|
|
7896
9240
|
}
|
|
7897
9241
|
if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
|
|
@@ -7913,10 +9257,7 @@ function createGoogleProvider(config) {
|
|
|
7913
9257
|
function createLinkedinProvider(config) {
|
|
7914
9258
|
return {
|
|
7915
9259
|
id: "linkedin",
|
|
7916
|
-
schema:
|
|
7917
|
-
code: string().min(1, "Auth code is required"),
|
|
7918
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
7919
|
-
}),
|
|
9260
|
+
schema: oauthCodeFlowSchema(),
|
|
7920
9261
|
verify: async (payload) => {
|
|
7921
9262
|
try {
|
|
7922
9263
|
const tokenResponse = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
|
|
@@ -7927,7 +9268,8 @@ function createLinkedinProvider(config) {
|
|
|
7927
9268
|
code: payload.code,
|
|
7928
9269
|
redirect_uri: payload.redirectUri,
|
|
7929
9270
|
client_id: config.clientId,
|
|
7930
|
-
client_secret: config.clientSecret
|
|
9271
|
+
client_secret: config.clientSecret,
|
|
9272
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7931
9273
|
})
|
|
7932
9274
|
});
|
|
7933
9275
|
if (!tokenResponse.ok) {
|
|
@@ -7943,12 +9285,16 @@ function createLinkedinProvider(config) {
|
|
|
7943
9285
|
return null;
|
|
7944
9286
|
}
|
|
7945
9287
|
const profileData = await profileResponse.json();
|
|
9288
|
+
if (!profileData.email) {
|
|
9289
|
+
logger.error("LinkedIn user has no email (the 'email' scope may not have been granted)");
|
|
9290
|
+
return null;
|
|
9291
|
+
}
|
|
7946
9292
|
return {
|
|
7947
9293
|
providerId: profileData.sub,
|
|
7948
9294
|
email: profileData.email,
|
|
7949
9295
|
displayName: profileData.name || null,
|
|
7950
9296
|
photoUrl: profileData.picture || null,
|
|
7951
|
-
emailVerified: profileData.email_verified
|
|
9297
|
+
emailVerified: providerVerifiedEmail(profileData.email_verified)
|
|
7952
9298
|
};
|
|
7953
9299
|
} catch (error) {
|
|
7954
9300
|
logger.error("LinkedIn OAuth error", { error });
|
|
@@ -7969,10 +9315,7 @@ function createLinkedinProvider(config) {
|
|
|
7969
9315
|
function createGitHubProvider(config) {
|
|
7970
9316
|
return {
|
|
7971
9317
|
id: "github",
|
|
7972
|
-
schema:
|
|
7973
|
-
code: string().min(1, "Auth code is required"),
|
|
7974
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
7975
|
-
}),
|
|
9318
|
+
schema: oauthCodeFlowSchema(),
|
|
7976
9319
|
verify: async (payload) => {
|
|
7977
9320
|
try {
|
|
7978
9321
|
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
@@ -7985,7 +9328,8 @@ function createGitHubProvider(config) {
|
|
|
7985
9328
|
client_id: config.clientId,
|
|
7986
9329
|
client_secret: config.clientSecret,
|
|
7987
9330
|
code: payload.code,
|
|
7988
|
-
redirect_uri: payload.redirectUri
|
|
9331
|
+
redirect_uri: payload.redirectUri,
|
|
9332
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
7989
9333
|
})
|
|
7990
9334
|
});
|
|
7991
9335
|
if (!tokenResponse.ok) {
|
|
@@ -8009,19 +9353,22 @@ function createGitHubProvider(config) {
|
|
|
8009
9353
|
}
|
|
8010
9354
|
const profileData = await profileResponse.json();
|
|
8011
9355
|
let email = profileData.email;
|
|
8012
|
-
|
|
8013
|
-
|
|
8014
|
-
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
|
|
8020
|
-
|
|
9356
|
+
let emailVerified = false;
|
|
9357
|
+
const emailsResponse = await fetch("https://api.github.com/user/emails", { headers: {
|
|
9358
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
9359
|
+
"Accept": "application/vnd.github+json",
|
|
9360
|
+
"User-Agent": "Rebase-Auth"
|
|
9361
|
+
} });
|
|
9362
|
+
if (emailsResponse.ok) {
|
|
9363
|
+
const emails = await emailsResponse.json();
|
|
9364
|
+
const chosen = emails.find((e) => e.primary && e.verified) || emails.find((e) => e.verified) || (email ? emails.find((e) => e.email.toLowerCase() === email.toLowerCase()) : void 0);
|
|
9365
|
+
if (chosen) {
|
|
9366
|
+
email = chosen.email;
|
|
9367
|
+
emailVerified = providerVerifiedEmail(chosen.verified);
|
|
8021
9368
|
}
|
|
8022
9369
|
}
|
|
8023
9370
|
if (!email) {
|
|
8024
|
-
logger.error("GitHub user has no
|
|
9371
|
+
logger.error("GitHub user has no email");
|
|
8025
9372
|
return null;
|
|
8026
9373
|
}
|
|
8027
9374
|
return {
|
|
@@ -8029,7 +9376,7 @@ function createGitHubProvider(config) {
|
|
|
8029
9376
|
email,
|
|
8030
9377
|
displayName: profileData.name || profileData.login || null,
|
|
8031
9378
|
photoUrl: profileData.avatar_url || null,
|
|
8032
|
-
emailVerified
|
|
9379
|
+
emailVerified
|
|
8033
9380
|
};
|
|
8034
9381
|
} catch (error) {
|
|
8035
9382
|
logger.error("GitHub OAuth error", { error });
|
|
@@ -8039,21 +9386,109 @@ function createGitHubProvider(config) {
|
|
|
8039
9386
|
};
|
|
8040
9387
|
}
|
|
8041
9388
|
//#endregion
|
|
9389
|
+
//#region src/auth/oidc-id-token.ts
|
|
9390
|
+
var JWKS_TTL_MS = 600 * 1e3;
|
|
9391
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
9392
|
+
async function loadJwks(jwksUri, fetchImpl, forceRefresh) {
|
|
9393
|
+
const cached = jwksCache.get(jwksUri);
|
|
9394
|
+
if (!forceRefresh && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
|
|
9395
|
+
const response = await fetchImpl(jwksUri);
|
|
9396
|
+
if (!response.ok) throw new Error(`JWKS fetch failed (${response.status})`);
|
|
9397
|
+
const body = await response.json();
|
|
9398
|
+
const keys = Array.isArray(body.keys) ? body.keys : [];
|
|
9399
|
+
jwksCache.set(jwksUri, {
|
|
9400
|
+
keys,
|
|
9401
|
+
fetchedAt: Date.now()
|
|
9402
|
+
});
|
|
9403
|
+
return keys;
|
|
9404
|
+
}
|
|
9405
|
+
function toKeyObject(jwk) {
|
|
9406
|
+
return createPublicKey({
|
|
9407
|
+
key: jwk,
|
|
9408
|
+
format: "jwk"
|
|
9409
|
+
});
|
|
9410
|
+
}
|
|
9411
|
+
function decodeKid(idToken) {
|
|
9412
|
+
return import_jsonwebtoken.default.decode(idToken, { complete: true })?.header?.kid;
|
|
9413
|
+
}
|
|
9414
|
+
/**
|
|
9415
|
+
* Verify an id_token's signature, `aud`, `iss` and `exp`, and return its claims.
|
|
9416
|
+
*
|
|
9417
|
+
* Throws on any failure — callers treat a throw as "reject this sign-in",
|
|
9418
|
+
* never as "continue without the claims".
|
|
9419
|
+
*/
|
|
9420
|
+
async function verifyOidcIdToken(options) {
|
|
9421
|
+
const { idToken, jwksUri, issuer, audience, algorithms = ["RS256"], clockToleranceSec = 60, nonce, fetchImpl = fetch } = options;
|
|
9422
|
+
if (!idToken || typeof idToken !== "string") throw new Error("id_token missing");
|
|
9423
|
+
const kid = decodeKid(idToken);
|
|
9424
|
+
let keys = await loadJwks(jwksUri, fetchImpl, false);
|
|
9425
|
+
let jwk = keys.find((k) => !kid || k.kid === kid);
|
|
9426
|
+
if (!jwk) {
|
|
9427
|
+
keys = await loadJwks(jwksUri, fetchImpl, true);
|
|
9428
|
+
jwk = keys.find((k) => !kid || k.kid === kid);
|
|
9429
|
+
}
|
|
9430
|
+
if (!jwk) throw new Error(`No JWKS key matches kid "${kid ?? "<none>"}"`);
|
|
9431
|
+
const claims = import_jsonwebtoken.default.verify(idToken, toKeyObject(jwk), {
|
|
9432
|
+
algorithms,
|
|
9433
|
+
audience,
|
|
9434
|
+
clockTolerance: clockToleranceSec
|
|
9435
|
+
});
|
|
9436
|
+
if (!(typeof issuer === "string" ? claims.iss === issuer : issuer.test(String(claims.iss ?? "")))) throw new Error(`Unexpected id_token issuer "${claims.iss}"`);
|
|
9437
|
+
if (nonce !== void 0 && claims.nonce !== nonce) throw new Error("id_token nonce mismatch");
|
|
9438
|
+
return claims;
|
|
9439
|
+
}
|
|
9440
|
+
/**
|
|
9441
|
+
* `verifyOidcIdToken` that reports failure as `null` and logs it, for the one
|
|
9442
|
+
* call site that has to decide between "reject" and "degrade".
|
|
9443
|
+
*/
|
|
9444
|
+
async function tryVerifyOidcIdToken(providerId, options) {
|
|
9445
|
+
try {
|
|
9446
|
+
return await verifyOidcIdToken(options);
|
|
9447
|
+
} catch (error) {
|
|
9448
|
+
logger.error(`[${providerId}] id_token verification failed`, { error: error instanceof Error ? error.message : error });
|
|
9449
|
+
return null;
|
|
9450
|
+
}
|
|
9451
|
+
}
|
|
9452
|
+
//#endregion
|
|
8042
9453
|
//#region src/auth/microsoft-oauth.ts
|
|
9454
|
+
/** Endpoints that federate to more than one directory. */
|
|
9455
|
+
var MULTI_TENANT_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
9456
|
+
"common",
|
|
9457
|
+
"organizations",
|
|
9458
|
+
"consumers"
|
|
9459
|
+
]);
|
|
8043
9460
|
/**
|
|
8044
9461
|
* Creates a Microsoft / Entra ID (Azure AD) OAuth Provider integration.
|
|
8045
9462
|
*
|
|
8046
9463
|
* Supports both personal Microsoft accounts and work/school (Azure AD) accounts
|
|
8047
9464
|
* via the "common" tenant endpoint. Uses the authorization code flow.
|
|
9465
|
+
*
|
|
9466
|
+
* ## On `emailVerified`
|
|
9467
|
+
*
|
|
9468
|
+
* Microsoft Graph exposes no email-verification field, and `mail` is *not* a
|
|
9469
|
+
* substitute: it is a directory attribute a tenant administrator sets to any
|
|
9470
|
+
* string they like, through Graph or AAD Connect sync. With the default
|
|
9471
|
+
* `tenantId: "common"` every Entra tenant in the world is an accepted issuer,
|
|
9472
|
+
* so "there is a `mail` value" would mean "anybody who can create a free
|
|
9473
|
+
* tenant can nominate any address" — including one that already has an account
|
|
9474
|
+
* here.
|
|
9475
|
+
*
|
|
9476
|
+
* The only signal Microsoft offers is the `xms_edov` ("email domain owner
|
|
9477
|
+
* verified") optional claim on the id_token, so that is what this provider
|
|
9478
|
+
* reads, off a signature-verified token. If the app registration does not emit
|
|
9479
|
+
* `xms_edov`, sign-in still works and the address comes back unverified —
|
|
9480
|
+
* which routes the user to `POST /auth/link/microsoft` rather than silently
|
|
9481
|
+
* handing them somebody else's account.
|
|
8048
9482
|
*/
|
|
8049
9483
|
function createMicrosoftProvider(config) {
|
|
8050
9484
|
const tenantId = config.tenantId || "common";
|
|
9485
|
+
const multiTenant = MULTI_TENANT_ENDPOINTS.has(tenantId.toLowerCase());
|
|
9486
|
+
const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`;
|
|
9487
|
+
const issuer = multiTenant ? /^https:\/\/login\.microsoftonline\.com\/[0-9a-fA-F-]{36}\/v2\.0$/ : `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
|
9488
|
+
if (multiTenant) logger.warn(`[Rebase] Microsoft provider configured with tenantId "${tenantId}", which accepts sign-ins from every Entra tenant. Email addresses are reported as unverified unless the app registration emits the xms_edov optional claim. Set an explicit tenantId, or enable xms_edov, to allow automatic linking onto existing accounts.`);
|
|
8051
9489
|
return {
|
|
8052
9490
|
id: "microsoft",
|
|
8053
|
-
schema:
|
|
8054
|
-
code: string().min(1, "Auth code is required"),
|
|
8055
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8056
|
-
}),
|
|
9491
|
+
schema: oauthCodeFlowSchema(),
|
|
8057
9492
|
verify: async (payload) => {
|
|
8058
9493
|
try {
|
|
8059
9494
|
const tokenResponse = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
@@ -8065,14 +9500,26 @@ function createMicrosoftProvider(config) {
|
|
|
8065
9500
|
code: payload.code,
|
|
8066
9501
|
redirect_uri: payload.redirectUri,
|
|
8067
9502
|
grant_type: "authorization_code",
|
|
8068
|
-
scope: "openid profile email User.Read"
|
|
9503
|
+
scope: "openid profile email User.Read",
|
|
9504
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8069
9505
|
})
|
|
8070
9506
|
});
|
|
8071
9507
|
if (!tokenResponse.ok) {
|
|
8072
9508
|
logger.error("Failed to get Microsoft access token", { detail: await tokenResponse.text() });
|
|
8073
9509
|
return null;
|
|
8074
9510
|
}
|
|
8075
|
-
const
|
|
9511
|
+
const tokenData = await tokenResponse.json();
|
|
9512
|
+
const accessToken = tokenData.access_token;
|
|
9513
|
+
let idTokenClaims = null;
|
|
9514
|
+
if (tokenData.id_token) {
|
|
9515
|
+
idTokenClaims = await tryVerifyOidcIdToken("microsoft", {
|
|
9516
|
+
idToken: tokenData.id_token,
|
|
9517
|
+
jwksUri,
|
|
9518
|
+
issuer,
|
|
9519
|
+
audience: config.clientId
|
|
9520
|
+
});
|
|
9521
|
+
if (!idTokenClaims) return null;
|
|
9522
|
+
}
|
|
8076
9523
|
const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { headers: { "Authorization": `Bearer ${accessToken}` } });
|
|
8077
9524
|
if (!profileResponse.ok) {
|
|
8078
9525
|
logger.error("Failed to get Microsoft user info", { detail: await profileResponse.text() });
|
|
@@ -8084,7 +9531,8 @@ function createMicrosoftProvider(config) {
|
|
|
8084
9531
|
logger.error("Microsoft user has no email");
|
|
8085
9532
|
return null;
|
|
8086
9533
|
}
|
|
8087
|
-
const
|
|
9534
|
+
const claimedEmail = idTokenClaims?.email ?? idTokenClaims?.preferred_username;
|
|
9535
|
+
const emailVerified = providerVerifiedEmail(idTokenClaims?.xms_edov) && typeof claimedEmail === "string" && claimedEmail.toLowerCase() === email.toLowerCase();
|
|
8088
9536
|
return {
|
|
8089
9537
|
providerId: profileData.id,
|
|
8090
9538
|
email,
|
|
@@ -8101,6 +9549,8 @@ function createMicrosoftProvider(config) {
|
|
|
8101
9549
|
}
|
|
8102
9550
|
//#endregion
|
|
8103
9551
|
//#region src/auth/apple-oauth.ts
|
|
9552
|
+
var APPLE_ISSUER = "https://appleid.apple.com";
|
|
9553
|
+
var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
|
|
8104
9554
|
/**
|
|
8105
9555
|
* Creates an Apple Sign In OAuth Provider integration.
|
|
8106
9556
|
*
|
|
@@ -8130,18 +9580,19 @@ function createAppleProvider(config) {
|
|
|
8130
9580
|
}
|
|
8131
9581
|
return {
|
|
8132
9582
|
id: "apple",
|
|
8133
|
-
schema:
|
|
8134
|
-
|
|
8135
|
-
|
|
8136
|
-
|
|
8137
|
-
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8144
|
-
|
|
9583
|
+
schema: oauthCodeFlowSchema().extend({
|
|
9584
|
+
/**
|
|
9585
|
+
* Apple sends user info only on first authorization; the frontend
|
|
9586
|
+
* must forward it. Only the *name* is accepted — an `email` here
|
|
9587
|
+
* used to be promoted to the account's identity whenever the
|
|
9588
|
+
* id_token had no `email` claim, and an attacker chooses whether
|
|
9589
|
+
* that claim is absent simply by omitting the `email` scope from
|
|
9590
|
+
* their own authorization request.
|
|
9591
|
+
*/
|
|
9592
|
+
user: object({ name: object({
|
|
9593
|
+
firstName: string().optional(),
|
|
9594
|
+
lastName: string().optional()
|
|
9595
|
+
}).optional() }).optional() }),
|
|
8145
9596
|
verify: async (payload) => {
|
|
8146
9597
|
try {
|
|
8147
9598
|
const clientSecret = await generateClientSecret();
|
|
@@ -8153,18 +9604,28 @@ function createAppleProvider(config) {
|
|
|
8153
9604
|
client_secret: clientSecret,
|
|
8154
9605
|
code: payload.code,
|
|
8155
9606
|
grant_type: "authorization_code",
|
|
8156
|
-
redirect_uri: payload.redirectUri
|
|
9607
|
+
redirect_uri: payload.redirectUri,
|
|
9608
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8157
9609
|
})
|
|
8158
9610
|
});
|
|
8159
9611
|
if (!tokenResponse.ok) {
|
|
8160
9612
|
logger.error("Failed to get Apple access token", { detail: await tokenResponse.text() });
|
|
8161
9613
|
return null;
|
|
8162
9614
|
}
|
|
8163
|
-
const
|
|
8164
|
-
const decoded =
|
|
8165
|
-
|
|
9615
|
+
const tokenData = await tokenResponse.json();
|
|
9616
|
+
const decoded = tokenData.id_token ? await tryVerifyOidcIdToken("apple", {
|
|
9617
|
+
idToken: tokenData.id_token,
|
|
9618
|
+
jwksUri: APPLE_JWKS_URI,
|
|
9619
|
+
issuer: APPLE_ISSUER,
|
|
9620
|
+
audience: config.clientId
|
|
9621
|
+
}) : null;
|
|
9622
|
+
if (!decoded) {
|
|
9623
|
+
logger.error("Apple token exchange returned no verifiable id_token");
|
|
9624
|
+
return null;
|
|
9625
|
+
}
|
|
9626
|
+
const email = decoded.email;
|
|
8166
9627
|
if (!email) {
|
|
8167
|
-
logger.error("Apple
|
|
9628
|
+
logger.error("Apple id_token carries no email claim");
|
|
8168
9629
|
return null;
|
|
8169
9630
|
}
|
|
8170
9631
|
let displayName = null;
|
|
@@ -8177,7 +9638,7 @@ function createAppleProvider(config) {
|
|
|
8177
9638
|
email,
|
|
8178
9639
|
displayName,
|
|
8179
9640
|
photoUrl: null,
|
|
8180
|
-
emailVerified: decoded.email_verified
|
|
9641
|
+
emailVerified: providerVerifiedEmail(decoded.email_verified)
|
|
8181
9642
|
};
|
|
8182
9643
|
} catch (error) {
|
|
8183
9644
|
logger.error("Apple OAuth error", { error });
|
|
@@ -8197,18 +9658,20 @@ function createAppleProvider(config) {
|
|
|
8197
9658
|
function createFacebookProvider(config) {
|
|
8198
9659
|
return {
|
|
8199
9660
|
id: "facebook",
|
|
8200
|
-
schema:
|
|
8201
|
-
code: string().min(1, "Auth code is required"),
|
|
8202
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8203
|
-
}),
|
|
9661
|
+
schema: oauthCodeFlowSchema(),
|
|
8204
9662
|
verify: async (payload) => {
|
|
8205
9663
|
try {
|
|
8206
|
-
const
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8211
|
-
|
|
9664
|
+
const tokenResponse = await fetch("https://graph.facebook.com/v19.0/oauth/access_token", {
|
|
9665
|
+
method: "POST",
|
|
9666
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
9667
|
+
body: new URLSearchParams({
|
|
9668
|
+
client_id: config.clientId,
|
|
9669
|
+
client_secret: config.clientSecret,
|
|
9670
|
+
redirect_uri: payload.redirectUri,
|
|
9671
|
+
code: payload.code,
|
|
9672
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
9673
|
+
})
|
|
9674
|
+
});
|
|
8212
9675
|
if (!tokenResponse.ok) {
|
|
8213
9676
|
logger.error("Failed to get Facebook access token", { detail: await tokenResponse.text() });
|
|
8214
9677
|
return null;
|
|
@@ -8216,8 +9679,7 @@ function createFacebookProvider(config) {
|
|
|
8216
9679
|
const accessToken = (await tokenResponse.json()).access_token;
|
|
8217
9680
|
const profileUrl = new URL("https://graph.facebook.com/v19.0/me");
|
|
8218
9681
|
profileUrl.searchParams.set("fields", "id,name,email,picture.type(large)");
|
|
8219
|
-
profileUrl.
|
|
8220
|
-
const profileResponse = await fetch(profileUrl.toString());
|
|
9682
|
+
const profileResponse = await fetch(profileUrl.toString(), { headers: { "Authorization": `Bearer ${accessToken}` } });
|
|
8221
9683
|
if (!profileResponse.ok) {
|
|
8222
9684
|
logger.error("Failed to get Facebook user info", { detail: await profileResponse.text() });
|
|
8223
9685
|
return null;
|
|
@@ -8232,7 +9694,7 @@ function createFacebookProvider(config) {
|
|
|
8232
9694
|
email: profileData.email,
|
|
8233
9695
|
displayName: profileData.name || null,
|
|
8234
9696
|
photoUrl: profileData.picture?.data?.url || null,
|
|
8235
|
-
emailVerified:
|
|
9697
|
+
emailVerified: false
|
|
8236
9698
|
};
|
|
8237
9699
|
} catch (error) {
|
|
8238
9700
|
logger.error("Facebook OAuth error", { error });
|
|
@@ -8255,11 +9717,7 @@ function createFacebookProvider(config) {
|
|
|
8255
9717
|
function createTwitterProvider(config) {
|
|
8256
9718
|
return {
|
|
8257
9719
|
id: "twitter",
|
|
8258
|
-
schema:
|
|
8259
|
-
code: string().min(1, "Auth code is required"),
|
|
8260
|
-
redirectUri: string().url("Valid redirect URI is required"),
|
|
8261
|
-
codeVerifier: string().min(1, "PKCE code verifier is required")
|
|
8262
|
-
}),
|
|
9720
|
+
schema: oauthCodeFlowSchema({ pkce: "required" }),
|
|
8263
9721
|
verify: async (payload) => {
|
|
8264
9722
|
try {
|
|
8265
9723
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
@@ -8273,7 +9731,7 @@ function createTwitterProvider(config) {
|
|
|
8273
9731
|
code: payload.code,
|
|
8274
9732
|
grant_type: "authorization_code",
|
|
8275
9733
|
redirect_uri: payload.redirectUri,
|
|
8276
|
-
|
|
9734
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8277
9735
|
})
|
|
8278
9736
|
});
|
|
8279
9737
|
if (!tokenResponse.ok) {
|
|
@@ -8319,16 +9777,14 @@ function createTwitterProvider(config) {
|
|
|
8319
9777
|
/**
|
|
8320
9778
|
* Creates a Discord OAuth2 Provider integration.
|
|
8321
9779
|
*
|
|
8322
|
-
* Uses the authorization code flow
|
|
8323
|
-
*
|
|
9780
|
+
* Uses the authorization code flow (with PKCE when the client supplies a
|
|
9781
|
+
* verifier). Requires the "identify" and "email" scopes to retrieve the
|
|
9782
|
+
* user's email and profile information.
|
|
8324
9783
|
*/
|
|
8325
9784
|
function createDiscordProvider(config) {
|
|
8326
9785
|
return {
|
|
8327
9786
|
id: "discord",
|
|
8328
|
-
schema:
|
|
8329
|
-
code: string().min(1, "Auth code is required"),
|
|
8330
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8331
|
-
}),
|
|
9787
|
+
schema: oauthCodeFlowSchema(),
|
|
8332
9788
|
verify: async (payload) => {
|
|
8333
9789
|
try {
|
|
8334
9790
|
const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
|
|
@@ -8339,7 +9795,8 @@ function createDiscordProvider(config) {
|
|
|
8339
9795
|
client_secret: config.clientSecret,
|
|
8340
9796
|
grant_type: "authorization_code",
|
|
8341
9797
|
code: payload.code,
|
|
8342
|
-
redirect_uri: payload.redirectUri
|
|
9798
|
+
redirect_uri: payload.redirectUri,
|
|
9799
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8343
9800
|
})
|
|
8344
9801
|
});
|
|
8345
9802
|
if (!tokenResponse.ok) {
|
|
@@ -8367,7 +9824,7 @@ function createDiscordProvider(config) {
|
|
|
8367
9824
|
email: profileData.email,
|
|
8368
9825
|
displayName: profileData.global_name || profileData.username || null,
|
|
8369
9826
|
photoUrl,
|
|
8370
|
-
emailVerified: profileData.verified
|
|
9827
|
+
emailVerified: providerVerifiedEmail(profileData.verified)
|
|
8371
9828
|
};
|
|
8372
9829
|
} catch (error) {
|
|
8373
9830
|
logger.error("Discord OAuth error", { error });
|
|
@@ -8381,15 +9838,16 @@ function createDiscordProvider(config) {
|
|
|
8381
9838
|
/**
|
|
8382
9839
|
* Creates a GitLab OAuth Provider integration.
|
|
8383
9840
|
* Works with both GitLab.com and self-hosted instances.
|
|
9841
|
+
*
|
|
9842
|
+
* `baseUrl` is configuration only and must stay that way: a caller-supplied
|
|
9843
|
+
* instance URL would make this provider an SSRF primitive and an
|
|
9844
|
+
* arbitrary-identity oracle in one step.
|
|
8384
9845
|
*/
|
|
8385
9846
|
function createGitLabProvider(config) {
|
|
8386
9847
|
const gitlabUrl = (config.baseUrl || "https://gitlab.com").replace(/\/$/, "");
|
|
8387
9848
|
return {
|
|
8388
9849
|
id: "gitlab",
|
|
8389
|
-
schema:
|
|
8390
|
-
code: string().min(1, "Auth code is required"),
|
|
8391
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8392
|
-
}),
|
|
9850
|
+
schema: oauthCodeFlowSchema(),
|
|
8393
9851
|
verify: async (payload) => {
|
|
8394
9852
|
try {
|
|
8395
9853
|
const tokenResponse = await fetch(`${gitlabUrl}/oauth/token`, {
|
|
@@ -8400,7 +9858,8 @@ function createGitLabProvider(config) {
|
|
|
8400
9858
|
client_secret: config.clientSecret,
|
|
8401
9859
|
code: payload.code,
|
|
8402
9860
|
grant_type: "authorization_code",
|
|
8403
|
-
redirect_uri: payload.redirectUri
|
|
9861
|
+
redirect_uri: payload.redirectUri,
|
|
9862
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8404
9863
|
})
|
|
8405
9864
|
});
|
|
8406
9865
|
if (!tokenResponse.ok) {
|
|
@@ -8423,7 +9882,7 @@ function createGitLabProvider(config) {
|
|
|
8423
9882
|
email: p.email,
|
|
8424
9883
|
displayName: p.name || p.username || null,
|
|
8425
9884
|
photoUrl: p.avatar_url || null,
|
|
8426
|
-
emailVerified:
|
|
9885
|
+
emailVerified: Boolean(p.confirmed_at)
|
|
8427
9886
|
};
|
|
8428
9887
|
} catch (error) {
|
|
8429
9888
|
logger.error("GitLab OAuth error", { error });
|
|
@@ -8440,10 +9899,7 @@ function createGitLabProvider(config) {
|
|
|
8440
9899
|
function createBitbucketProvider(config) {
|
|
8441
9900
|
return {
|
|
8442
9901
|
id: "bitbucket",
|
|
8443
|
-
schema:
|
|
8444
|
-
code: string().min(1, "Auth code is required"),
|
|
8445
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8446
|
-
}),
|
|
9902
|
+
schema: oauthCodeFlowSchema(),
|
|
8447
9903
|
verify: async (payload) => {
|
|
8448
9904
|
try {
|
|
8449
9905
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
@@ -8456,7 +9912,8 @@ function createBitbucketProvider(config) {
|
|
|
8456
9912
|
body: new URLSearchParams({
|
|
8457
9913
|
grant_type: "authorization_code",
|
|
8458
9914
|
code: payload.code,
|
|
8459
|
-
redirect_uri: payload.redirectUri
|
|
9915
|
+
redirect_uri: payload.redirectUri,
|
|
9916
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8460
9917
|
})
|
|
8461
9918
|
});
|
|
8462
9919
|
if (!tokenResponse.ok) {
|
|
@@ -8471,21 +9928,21 @@ function createBitbucketProvider(config) {
|
|
|
8471
9928
|
}
|
|
8472
9929
|
const p = await profileResponse.json();
|
|
8473
9930
|
const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", { headers: { "Authorization": `Bearer ${accessToken}` } });
|
|
8474
|
-
let
|
|
9931
|
+
let selected;
|
|
8475
9932
|
if (emailsResponse.ok) {
|
|
8476
9933
|
const emailData = await emailsResponse.json();
|
|
8477
|
-
|
|
9934
|
+
selected = emailData.values.find((e) => e.is_primary && e.is_confirmed) || emailData.values.find((e) => e.is_confirmed);
|
|
8478
9935
|
}
|
|
8479
|
-
if (!email) {
|
|
9936
|
+
if (!selected?.email) {
|
|
8480
9937
|
logger.error("Bitbucket user has no verified email");
|
|
8481
9938
|
return null;
|
|
8482
9939
|
}
|
|
8483
9940
|
return {
|
|
8484
9941
|
providerId: p.uuid,
|
|
8485
|
-
email,
|
|
9942
|
+
email: selected.email,
|
|
8486
9943
|
displayName: p.display_name || p.username || null,
|
|
8487
9944
|
photoUrl: p.links?.avatar?.href || null,
|
|
8488
|
-
emailVerified:
|
|
9945
|
+
emailVerified: providerVerifiedEmail(selected.is_confirmed)
|
|
8489
9946
|
};
|
|
8490
9947
|
} catch (error) {
|
|
8491
9948
|
logger.error("Bitbucket OAuth error", { error });
|
|
@@ -8503,10 +9960,7 @@ function createBitbucketProvider(config) {
|
|
|
8503
9960
|
function createSlackProvider(config) {
|
|
8504
9961
|
return {
|
|
8505
9962
|
id: "slack",
|
|
8506
|
-
schema:
|
|
8507
|
-
code: string().min(1, "Auth code is required"),
|
|
8508
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8509
|
-
}),
|
|
9963
|
+
schema: oauthCodeFlowSchema(),
|
|
8510
9964
|
verify: async (payload) => {
|
|
8511
9965
|
try {
|
|
8512
9966
|
const tokenResponse = await fetch("https://slack.com/api/openid.connect.token", {
|
|
@@ -8517,7 +9971,8 @@ function createSlackProvider(config) {
|
|
|
8517
9971
|
client_secret: config.clientSecret,
|
|
8518
9972
|
code: payload.code,
|
|
8519
9973
|
redirect_uri: payload.redirectUri,
|
|
8520
|
-
grant_type: "authorization_code"
|
|
9974
|
+
grant_type: "authorization_code",
|
|
9975
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8521
9976
|
})
|
|
8522
9977
|
});
|
|
8523
9978
|
if (!tokenResponse.ok) {
|
|
@@ -8544,7 +9999,7 @@ function createSlackProvider(config) {
|
|
|
8544
9999
|
email: p.email,
|
|
8545
10000
|
displayName: p.name || null,
|
|
8546
10001
|
photoUrl: p.picture || null,
|
|
8547
|
-
emailVerified: p.email_verified
|
|
10002
|
+
emailVerified: providerVerifiedEmail(p.email_verified)
|
|
8548
10003
|
};
|
|
8549
10004
|
} catch (error) {
|
|
8550
10005
|
logger.error("Slack OAuth error", { error });
|
|
@@ -8562,10 +10017,7 @@ function createSlackProvider(config) {
|
|
|
8562
10017
|
function createSpotifyProvider(config) {
|
|
8563
10018
|
return {
|
|
8564
10019
|
id: "spotify",
|
|
8565
|
-
schema:
|
|
8566
|
-
code: string().min(1, "Auth code is required"),
|
|
8567
|
-
redirectUri: string().url("Valid redirect URI is required")
|
|
8568
|
-
}),
|
|
10020
|
+
schema: oauthCodeFlowSchema(),
|
|
8569
10021
|
verify: async (payload) => {
|
|
8570
10022
|
try {
|
|
8571
10023
|
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
@@ -8578,7 +10030,8 @@ function createSpotifyProvider(config) {
|
|
|
8578
10030
|
body: new URLSearchParams({
|
|
8579
10031
|
grant_type: "authorization_code",
|
|
8580
10032
|
code: payload.code,
|
|
8581
|
-
redirect_uri: payload.redirectUri
|
|
10033
|
+
redirect_uri: payload.redirectUri,
|
|
10034
|
+
...pkceTokenParams(payload.codeVerifier)
|
|
8582
10035
|
})
|
|
8583
10036
|
});
|
|
8584
10037
|
if (!tokenResponse.ok) {
|
|
@@ -8601,7 +10054,7 @@ function createSpotifyProvider(config) {
|
|
|
8601
10054
|
email: p.email,
|
|
8602
10055
|
displayName: p.display_name || null,
|
|
8603
10056
|
photoUrl: p.images?.[0]?.url || null,
|
|
8604
|
-
emailVerified:
|
|
10057
|
+
emailVerified: false
|
|
8605
10058
|
};
|
|
8606
10059
|
} catch (error) {
|
|
8607
10060
|
logger.error("Spotify OAuth error", { error });
|
|
@@ -8737,7 +10190,7 @@ function createApiKeyStore(driver) {
|
|
|
8737
10190
|
return;
|
|
8738
10191
|
}
|
|
8739
10192
|
await ddl.step("Revoking end-user access to api_keys", () => exec(revokeInternalTableSql("rebase", "api_keys")));
|
|
8740
|
-
logger.
|
|
10193
|
+
logger.debug("✅ API keys table ready");
|
|
8741
10194
|
},
|
|
8742
10195
|
async createApiKey(request, createdBy) {
|
|
8743
10196
|
const plaintext = generateApiKey();
|
|
@@ -9032,6 +10485,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
9032
10485
|
createSlackProvider: () => createSlackProvider,
|
|
9033
10486
|
createSpotifyProvider: () => createSpotifyProvider,
|
|
9034
10487
|
createTwitterProvider: () => createTwitterProvider,
|
|
10488
|
+
decideOAuthAutoLink: () => decideOAuthAutoLink,
|
|
9035
10489
|
defaultAuthLimiter: () => defaultAuthLimiter,
|
|
9036
10490
|
extractUserFromToken: () => extractUserFromToken,
|
|
9037
10491
|
fileTokenAuth: () => fileTokenAuth,
|
|
@@ -9050,22 +10504,28 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
9050
10504
|
isApiKeyToken: () => isApiKeyToken,
|
|
9051
10505
|
isJwtConfigured: () => isJwtConfigured,
|
|
9052
10506
|
isOperationAllowed: () => isOperationAllowed,
|
|
10507
|
+
isRedirectUriAllowed: () => isRedirectUriAllowed,
|
|
9053
10508
|
mountMagicLinkRoutes: () => mountMagicLinkRoutes,
|
|
10509
|
+
oauthCodeFlowSchema: () => oauthCodeFlowSchema,
|
|
9054
10510
|
optionalAuth: () => optionalAuth,
|
|
10511
|
+
pkceTokenParams: () => pkceTokenParams,
|
|
9055
10512
|
prepareAdminUserValues: () => prepareAdminUserValues,
|
|
10513
|
+
providerVerifiedEmail: () => providerVerifiedEmail,
|
|
9056
10514
|
queryTokenAuth: () => queryTokenAuth,
|
|
9057
10515
|
requireAdmin: () => requireAdmin,
|
|
9058
10516
|
requireAuth: () => requireAuth,
|
|
9059
10517
|
resolveAuthHooks: () => resolveAuthHooks,
|
|
9060
10518
|
safeCompare: () => safeCompare,
|
|
9061
10519
|
strictAuthLimiter: () => strictAuthLimiter,
|
|
10520
|
+
tryVerifyOidcIdToken: () => tryVerifyOidcIdToken,
|
|
9062
10521
|
validateApiKey: () => validateApiKey,
|
|
9063
10522
|
validatePasswordStrength: () => validatePasswordStrength,
|
|
9064
10523
|
verifyAccessToken: () => verifyAccessToken,
|
|
9065
10524
|
verifyDownloadToken: () => verifyDownloadToken,
|
|
10525
|
+
verifyOidcIdToken: () => verifyOidcIdToken,
|
|
9066
10526
|
verifyPassword: () => verifyPassword
|
|
9067
10527
|
});
|
|
9068
10528
|
//#endregion
|
|
9069
|
-
export {
|
|
10529
|
+
export { fileTokenAuth as $, MemoryRateLimitStore as A, getEmailVerificationTemplate as B, ZodNumber as C, isPublicStoragePath as Ct, _coercedNumber as D, string as E, resolveAuthHooks as F, RawHtml as G, getPasswordResetTemplate as H, hashPassword as I, raw as J, escapeHtml as K, validatePasswordStrength as L, createEmailService as M, assertEmailLinkBases as N, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as O, resolveEmailLinkBase as P, extractUserFromToken as Q, verifyPassword as R, createBuiltinAuthAdapter as S, PUBLIC_STORAGE_PREFIX as St, object as T, getUserInvitationTemplate as U, getMagicLinkTemplate as V, getWelcomeEmailTemplate as W, createAuthMiddleware as X, createAdapterAuthMiddleware as Y, createRequireAuth as Z, createLinkedinProvider as _, scopeDataDriver as _t, createSpotifyProvider as a, InvalidStorageBucketError as at, pkceTokenParams as b, parseQueryOptions as bt, createGitLabProvider as c, canonicalStorageKey as ct, createFacebookProvider as d, createStorageApiKeyGuard as dt, optionalAuth as et, createAppleProvider as f, isApiKeyToken as ft, createGitHubProvider as g, SERVICE_IDENTITY as gt, verifyOidcIdToken as h, safeCompare as ht, createApiKeyStore as i, requireAuth as it, SMTPEmailService as j, createDataRateLimiter as k, createDiscordProvider as l, createApiKeyPreAuth as lt, tryVerifyOidcIdToken as m, extractBearerToken as mt, createCustomAuthAdapter as n, queryTokenAuth as nt, createSlackProvider as o, InvalidStorageKeyError as ot, createMicrosoftProvider as p, validateApiKey as pt, html as q, createApiKeyRoutes as r, requireAdmin as rt, createBitbucketProvider as s, canonicalStorageBucket as st, auth_exports as t, publicObjectAuth as tt, createTwitterProvider as u, createFunctionApiKeyGuard as ut, createGoogleProvider as v, httpMethodToOperation as vt, _enum as w, providerVerifiedEmail as x, resolveListLimitParam as xt, oauthCodeFlowSchema as y, isOperationAllowed as yt, generateSecurePassword as z };
|
|
9070
10530
|
|
|
9071
|
-
//# sourceMappingURL=auth-
|
|
10531
|
+
//# sourceMappingURL=auth-CYoPVf-E.js.map
|