@megacorp-ai/auth 0.1.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/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/cli.js +332 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3420 -0
- package/dist/index.js +981 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import express from "express";
|
|
3
|
+
import { betterAuth as betterAuth2 } from "better-auth";
|
|
4
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
5
|
+
import { fromNodeHeaders, toNodeHandler } from "better-auth/node";
|
|
6
|
+
import { Pool } from "pg";
|
|
7
|
+
|
|
8
|
+
// src/config.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// src/errors.ts
|
|
12
|
+
var AuthConfigError = class extends Error {
|
|
13
|
+
code = "AUTH_CONFIG_ERROR";
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "AuthConfigError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var SchemaError = class extends Error {
|
|
20
|
+
constructor(message, missing) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.missing = missing;
|
|
23
|
+
this.name = "SchemaError";
|
|
24
|
+
}
|
|
25
|
+
missing;
|
|
26
|
+
code = "AUTH_SCHEMA_ERROR";
|
|
27
|
+
};
|
|
28
|
+
var HttpError = class extends Error {
|
|
29
|
+
constructor(status, code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.name = "HttpError";
|
|
34
|
+
}
|
|
35
|
+
status;
|
|
36
|
+
code;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/config.ts
|
|
40
|
+
var bool = z.preprocess((v) => typeof v === "string" ? ["1", "true", "yes"].includes(v.toLowerCase()) : v, z.boolean());
|
|
41
|
+
var emailList = z.preprocess(
|
|
42
|
+
(v) => typeof v === "string" ? v.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) : v ?? [],
|
|
43
|
+
z.array(z.email())
|
|
44
|
+
);
|
|
45
|
+
var envSchema = z.object({
|
|
46
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
47
|
+
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
|
48
|
+
AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters"),
|
|
49
|
+
AUTH_BASE_URL: z.url(),
|
|
50
|
+
AUTH_FROM_EMAIL: z.email().optional(),
|
|
51
|
+
AUTH_BOOTSTRAP_ADMINS: emailList.default([]),
|
|
52
|
+
AUTH_EDGE: z.enum(["cloudflare", "none"]).default("none"),
|
|
53
|
+
AUTH_ALLOW_IMPERSONATION: bool.default(false),
|
|
54
|
+
AUTH_INVITE_MAX_PER_EMAIL: z.coerce.number().int().positive().default(5),
|
|
55
|
+
AUTH_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
56
|
+
ACS_ENDPOINT: z.url().optional(),
|
|
57
|
+
APPLICATIONINSIGHTS_CONNECTION_STRING: z.string().optional()
|
|
58
|
+
});
|
|
59
|
+
var truthy = (v) => !!v && v !== "0" && v.toLowerCase() !== "false";
|
|
60
|
+
function assertNoVendorTelemetry(env) {
|
|
61
|
+
if (truthy(env.BETTER_AUTH_TELEMETRY) || env.BETTER_AUTH_TELEMETRY_ENDPOINT) {
|
|
62
|
+
throw new AuthConfigError(
|
|
63
|
+
"Refusing to start: BETTER_AUTH_TELEMETRY / BETTER_AUTH_TELEMETRY_ENDPOINT is set. This package disables Better Auth vendor telemetry; unset these variables."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function loadConfig(env = process.env) {
|
|
68
|
+
const parsed = envSchema.safeParse(env);
|
|
69
|
+
if (!parsed.success) {
|
|
70
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "env"}: ${i.message}`).join("; ");
|
|
71
|
+
throw new AuthConfigError(`Invalid auth environment: ${issues}`);
|
|
72
|
+
}
|
|
73
|
+
const cfg = parsed.data;
|
|
74
|
+
if (cfg.NODE_ENV === "production" && !cfg.ACS_ENDPOINT) {
|
|
75
|
+
throw new AuthConfigError(
|
|
76
|
+
"Refusing to start: NODE_ENV=production but ACS_ENDPOINT is not set. The console mailer would print OTP codes to stdout. Set ACS_ENDPOINT (and AUTH_FROM_EMAIL) to use Azure Communication Services."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (cfg.ACS_ENDPOINT && !cfg.AUTH_FROM_EMAIL) {
|
|
80
|
+
throw new AuthConfigError("AUTH_FROM_EMAIL is required when ACS_ENDPOINT is set");
|
|
81
|
+
}
|
|
82
|
+
assertNoVendorTelemetry(env);
|
|
83
|
+
return { ...cfg, isProduction: cfg.NODE_ENV === "production" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/ip.ts
|
|
87
|
+
import { isIPv4, isIPv6 } from "net";
|
|
88
|
+
|
|
89
|
+
// src/cloudflare-ips.ts
|
|
90
|
+
var CLOUDFLARE_IPV4 = [
|
|
91
|
+
"173.245.48.0/20",
|
|
92
|
+
"103.21.244.0/22",
|
|
93
|
+
"103.22.200.0/22",
|
|
94
|
+
"103.31.4.0/22",
|
|
95
|
+
"141.101.64.0/18",
|
|
96
|
+
"108.162.192.0/18",
|
|
97
|
+
"190.93.240.0/20",
|
|
98
|
+
"188.114.96.0/20",
|
|
99
|
+
"197.234.240.0/22",
|
|
100
|
+
"198.41.128.0/17",
|
|
101
|
+
"162.158.0.0/15",
|
|
102
|
+
"104.16.0.0/13",
|
|
103
|
+
"104.24.0.0/14",
|
|
104
|
+
"172.64.0.0/13",
|
|
105
|
+
"131.0.72.0/22"
|
|
106
|
+
];
|
|
107
|
+
var CLOUDFLARE_IPV6 = [
|
|
108
|
+
"2400:cb00::/32",
|
|
109
|
+
"2606:4700::/32",
|
|
110
|
+
"2803:f800::/32",
|
|
111
|
+
"2405:b500::/32",
|
|
112
|
+
"2405:8100::/32",
|
|
113
|
+
"2a06:98c0::/29",
|
|
114
|
+
"2c0f:f248::/32"
|
|
115
|
+
];
|
|
116
|
+
var CLOUDFLARE_IPS = [...CLOUDFLARE_IPV4, ...CLOUDFLARE_IPV6];
|
|
117
|
+
|
|
118
|
+
// src/ip.ts
|
|
119
|
+
function ipAddressHeaders(edge) {
|
|
120
|
+
return edge === "cloudflare" ? ["cf-connecting-ip"] : ["x-forwarded-for"];
|
|
121
|
+
}
|
|
122
|
+
function trustProxySetting(edge) {
|
|
123
|
+
return edge === "cloudflare" ? [...CLOUDFLARE_IPS] : 1;
|
|
124
|
+
}
|
|
125
|
+
function ipToBigInt(ip) {
|
|
126
|
+
if (isIPv4(ip)) {
|
|
127
|
+
return ip.split(".").reduce((acc, o) => (acc << 8n) + BigInt(Number(o)), 0n);
|
|
128
|
+
}
|
|
129
|
+
if (isIPv6(ip)) {
|
|
130
|
+
const clean = ip.replace(/^\[|\]$/g, "").split("%")[0];
|
|
131
|
+
if (clean.startsWith("::ffff:") && isIPv4(clean.slice(7))) return ipToBigInt(clean.slice(7));
|
|
132
|
+
const [head, tail = ""] = clean.split("::");
|
|
133
|
+
const h = head ? head.split(":") : [];
|
|
134
|
+
const t = tail ? tail.split(":") : [];
|
|
135
|
+
const parts = [...h, ...Array(8 - h.length - t.length).fill("0"), ...t];
|
|
136
|
+
return parts.reduce((acc, p) => (acc << 16n) + BigInt(parseInt(p || "0", 16)), 0n);
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
function ipInCidr(ip, cidr) {
|
|
141
|
+
const [range, bitsStr] = cidr.split("/");
|
|
142
|
+
const a = ipToBigInt(ip);
|
|
143
|
+
const r = ipToBigInt(range);
|
|
144
|
+
if (a === null || r === null) return false;
|
|
145
|
+
const v4 = isIPv4(range);
|
|
146
|
+
if (v4 !== isIPv4(ip.startsWith("::ffff:") ? ip.slice(7) : ip)) return false;
|
|
147
|
+
const total = v4 ? 32 : 128;
|
|
148
|
+
const bits = bitsStr ? Number(bitsStr) : total;
|
|
149
|
+
const shift = BigInt(total - bits);
|
|
150
|
+
return a >> shift === r >> shift;
|
|
151
|
+
}
|
|
152
|
+
function isCloudflareIp(ip) {
|
|
153
|
+
return CLOUDFLARE_IPS.some((c) => ipInCidr(ip, c));
|
|
154
|
+
}
|
|
155
|
+
function header(req, name) {
|
|
156
|
+
const v = req.headers[name];
|
|
157
|
+
return Array.isArray(v) ? v[0] : v;
|
|
158
|
+
}
|
|
159
|
+
function getClientIp(req, edge) {
|
|
160
|
+
const peer = req.socket?.remoteAddress;
|
|
161
|
+
if (edge === "cloudflare") {
|
|
162
|
+
const cf = header(req, "cf-connecting-ip")?.trim();
|
|
163
|
+
if (cf && peer && isCloudflareIp(peer)) return cf;
|
|
164
|
+
return peer;
|
|
165
|
+
}
|
|
166
|
+
const xff = header(req, "x-forwarded-for");
|
|
167
|
+
const first = xff?.split(",")[0]?.trim();
|
|
168
|
+
return first || peer;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/invite.ts
|
|
172
|
+
import { z as z2 } from "zod";
|
|
173
|
+
|
|
174
|
+
// src/roles.ts
|
|
175
|
+
var ROLES = ["megacorp_admin", "customer_admin", "member"];
|
|
176
|
+
var DEFAULT_ROLE = "member";
|
|
177
|
+
var INVITE_MATRIX = {
|
|
178
|
+
megacorp_admin: ROLES,
|
|
179
|
+
customer_admin: ["customer_admin", "member"],
|
|
180
|
+
member: []
|
|
181
|
+
};
|
|
182
|
+
function isRole(v) {
|
|
183
|
+
return typeof v === "string" && ROLES.includes(v);
|
|
184
|
+
}
|
|
185
|
+
function parseRoles(role) {
|
|
186
|
+
if (!role) return [];
|
|
187
|
+
return role.split(",").map((r) => r.trim()).filter(isRole);
|
|
188
|
+
}
|
|
189
|
+
function hasRole(userRole, allowed) {
|
|
190
|
+
return parseRoles(userRole).some((r) => allowed.includes(r));
|
|
191
|
+
}
|
|
192
|
+
function canInvite(inviterRole, target) {
|
|
193
|
+
return parseRoles(inviterRole).some((r) => INVITE_MATRIX[r].includes(target));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/invite.ts
|
|
197
|
+
var bodySchema = z2.object({ email: z2.email().transform((e) => e.toLowerCase()), role: z2.enum(ROLES) });
|
|
198
|
+
function createInviteCounter(max, windowMs = 24 * 60 * 60 * 1e3) {
|
|
199
|
+
const sent = /* @__PURE__ */ new Map();
|
|
200
|
+
return {
|
|
201
|
+
take(email, now = Date.now()) {
|
|
202
|
+
const list = (sent.get(email) ?? []).filter((t) => now - t < windowMs);
|
|
203
|
+
if (list.length >= max) {
|
|
204
|
+
sent.set(email, list);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
list.push(now);
|
|
208
|
+
sent.set(email, list);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function createInviteHandler(deps) {
|
|
214
|
+
const counter = createInviteCounter(deps.maxPerEmail);
|
|
215
|
+
return async (req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
const inviter = await deps.getSession(req);
|
|
218
|
+
if (!inviter) throw new HttpError(401, "UNAUTHENTICATED", "Sign in required");
|
|
219
|
+
const parsed = bodySchema.safeParse(req.body);
|
|
220
|
+
if (!parsed.success) throw new HttpError(400, "INVALID_BODY", "Expected { email, role }");
|
|
221
|
+
const { email, role } = parsed.data;
|
|
222
|
+
if (!isRole(role) || !canInvite(inviter.user.role, role)) {
|
|
223
|
+
throw new HttpError(403, "FORBIDDEN", `Role ${inviter.user.role ?? "none"} may not invite ${role}`);
|
|
224
|
+
}
|
|
225
|
+
if (!counter.take(email)) throw new HttpError(429, "INVITE_CAP", "Too many invites for this address; try again later");
|
|
226
|
+
let user = await deps.findUserByEmail(email);
|
|
227
|
+
if (user?.banned) throw new HttpError(409, "USER_DISABLED", "User is disabled");
|
|
228
|
+
if (!user) user = { ...await deps.createUser(email, role), role, banned: false };
|
|
229
|
+
const link = new URL(deps.inviteLinkPath, deps.baseURL);
|
|
230
|
+
link.searchParams.set("email", email);
|
|
231
|
+
await deps.mailer.sendInvite({ to: email, link: link.toString(), appName: deps.appName, role, invitedBy: inviter.user.email });
|
|
232
|
+
deps.onInvite({ inviter, email, role, userId: user.id, req });
|
|
233
|
+
res.status(200).json({ ok: true, userId: user.id, existing: user.role !== null && user.role !== role ? true : void 0 });
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (err instanceof HttpError) return res.status(err.status).json({ error: err.code, message: err.message });
|
|
236
|
+
res.status(500).json({ error: "INTERNAL", message: "Invite failed" });
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/log.ts
|
|
243
|
+
var order = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
244
|
+
function createLogger(base, minLevel = "info") {
|
|
245
|
+
const emit = (level, msg, fields) => {
|
|
246
|
+
if (order[level] < order[minLevel]) return;
|
|
247
|
+
const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, msg, ...base, ...fields });
|
|
248
|
+
(level === "error" ? process.stderr : process.stdout).write(line + "\n");
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
debug: (m, f) => emit("debug", m, f),
|
|
252
|
+
info: (m, f) => emit("info", m, f),
|
|
253
|
+
warn: (m, f) => emit("warn", m, f),
|
|
254
|
+
error: (m, f) => emit("error", m, f)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/mailer/console.ts
|
|
259
|
+
function consoleMailer() {
|
|
260
|
+
return {
|
|
261
|
+
async sendOtp({ to, otp, appName, expiresInSeconds }) {
|
|
262
|
+
process.stdout.write(`
|
|
263
|
+
[${appName}] Sign-in code for ${to}: ${otp} (valid ${expiresInSeconds}s)
|
|
264
|
+
|
|
265
|
+
`);
|
|
266
|
+
},
|
|
267
|
+
async sendInvite({ to, link, appName, role }) {
|
|
268
|
+
process.stdout.write(`
|
|
269
|
+
[${appName}] Invite for ${to} as ${role}: ${link}
|
|
270
|
+
|
|
271
|
+
`);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/mailer/acs.ts
|
|
277
|
+
import { EmailClient } from "@azure/communication-email";
|
|
278
|
+
|
|
279
|
+
// src/azure-credential.ts
|
|
280
|
+
import {
|
|
281
|
+
ChainedTokenCredential,
|
|
282
|
+
DefaultAzureCredential,
|
|
283
|
+
EnvironmentCredential,
|
|
284
|
+
ManagedIdentityCredential
|
|
285
|
+
} from "@azure/identity";
|
|
286
|
+
function azureCredential(production, { clientId, env = process.env } = {}) {
|
|
287
|
+
if (clientId) return new ManagedIdentityCredential({ clientId });
|
|
288
|
+
if (env.AZURE_TOKEN_CREDENTIALS) return new DefaultAzureCredential();
|
|
289
|
+
if (production) return new ChainedTokenCredential(new EnvironmentCredential(), new ManagedIdentityCredential());
|
|
290
|
+
return new DefaultAzureCredential();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/mailer/acs.ts
|
|
294
|
+
function acsMailer({ endpoint, from, appName, credential, production = false }) {
|
|
295
|
+
const client = new EmailClient(endpoint, credential ?? azureCredential(production));
|
|
296
|
+
const send = async (to, subject, plainText, html) => {
|
|
297
|
+
const poller = await client.beginSend({
|
|
298
|
+
senderAddress: from,
|
|
299
|
+
recipients: { to: [{ address: to }] },
|
|
300
|
+
content: { subject, plainText, html },
|
|
301
|
+
headers: { "X-Megacorp-App": appName }
|
|
302
|
+
});
|
|
303
|
+
const result = await poller.pollUntilDone();
|
|
304
|
+
if (result.status !== "Succeeded") {
|
|
305
|
+
throw new Error(`ACS email send failed: ${result.status} ${result.error?.message ?? ""}`.trim());
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
sendOtp: ({ to, otp, expiresInSeconds }) => send(
|
|
310
|
+
to,
|
|
311
|
+
`${appName}: your sign-in code is ${otp}`,
|
|
312
|
+
`Your ${appName} sign-in code is ${otp}. It expires in ${Math.round(expiresInSeconds / 60)} minutes. If you did not request this, ignore this email.`,
|
|
313
|
+
`<p>Your <strong>${esc(appName)}</strong> sign-in code is:</p><p style="font-size:28px;letter-spacing:6px"><strong>${otp}</strong></p><p>It expires in ${Math.round(expiresInSeconds / 60)} minutes. If you did not request this, ignore this email.</p>`
|
|
314
|
+
),
|
|
315
|
+
sendInvite: ({ to, link, role }) => send(
|
|
316
|
+
to,
|
|
317
|
+
`You have been invited to ${appName}`,
|
|
318
|
+
`You have been invited to ${appName} as ${role}. Sign in here: ${link}`,
|
|
319
|
+
`<p>You have been invited to <strong>${esc(appName)}</strong> as <strong>${esc(role)}</strong>.</p><p><a href="${esc(link)}">Sign in</a> with this email address; a one-time code will be sent to you.</p>`
|
|
320
|
+
)
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
324
|
+
|
|
325
|
+
// src/options.ts
|
|
326
|
+
import "better-auth";
|
|
327
|
+
import { admin, emailOTP } from "better-auth/plugins";
|
|
328
|
+
|
|
329
|
+
// src/access.ts
|
|
330
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
331
|
+
import { adminAc, defaultStatements, userAc } from "better-auth/plugins/admin/access";
|
|
332
|
+
var ac = createAccessControl(defaultStatements);
|
|
333
|
+
var accessRoles = {
|
|
334
|
+
megacorp_admin: ac.newRole({ ...adminAc.statements }),
|
|
335
|
+
customer_admin: ac.newRole({ ...userAc.statements }),
|
|
336
|
+
member: ac.newRole({ ...userAc.statements })
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/schema-check.ts
|
|
340
|
+
var TABLES = {
|
|
341
|
+
user: "auth_user",
|
|
342
|
+
session: "auth_session",
|
|
343
|
+
account: "auth_account",
|
|
344
|
+
verification: "auth_verification",
|
|
345
|
+
rateLimit: "auth_rate_limit"
|
|
346
|
+
};
|
|
347
|
+
var REQUIRED_COLUMNS = {
|
|
348
|
+
[TABLES.user]: ["id", "name", "email", "emailVerified", "image", "createdAt", "updatedAt", "role", "banned", "banReason", "banExpires"],
|
|
349
|
+
[TABLES.session]: ["id", "expiresAt", "token", "createdAt", "updatedAt", "ipAddress", "userAgent", "userId", "impersonatedBy"],
|
|
350
|
+
[TABLES.account]: ["id", "accountId", "providerId", "userId", "createdAt", "updatedAt"],
|
|
351
|
+
[TABLES.verification]: ["id", "identifier", "value", "expiresAt", "createdAt", "updatedAt"],
|
|
352
|
+
[TABLES.rateLimit]: ["id", "key", "count", "lastRequest"]
|
|
353
|
+
};
|
|
354
|
+
async function findMissingSchema(pool) {
|
|
355
|
+
const { rows } = await pool.query(
|
|
356
|
+
`select table_name, column_name from information_schema.columns
|
|
357
|
+
where table_schema = current_schema() and table_name = any($1)`,
|
|
358
|
+
[Object.keys(REQUIRED_COLUMNS)]
|
|
359
|
+
);
|
|
360
|
+
const have = new Set(rows.map((r) => `${r.table_name}.${r.column_name}`));
|
|
361
|
+
const tables = new Set(rows.map((r) => r.table_name));
|
|
362
|
+
const missing = [];
|
|
363
|
+
for (const [table, cols] of Object.entries(REQUIRED_COLUMNS)) {
|
|
364
|
+
if (!tables.has(table)) {
|
|
365
|
+
missing.push(table);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
for (const c of cols) if (!have.has(`${table}.${c}`)) missing.push(`${table}.${c}`);
|
|
369
|
+
}
|
|
370
|
+
return missing;
|
|
371
|
+
}
|
|
372
|
+
async function assertSchema(pool) {
|
|
373
|
+
const missing = await findMissingSchema(pool);
|
|
374
|
+
if (missing.length) {
|
|
375
|
+
throw new SchemaError(`Auth schema is incomplete (missing: ${missing.join(", ")}). Run \`npx megacorp-auth migrate\`.`, missing);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/options.ts
|
|
380
|
+
var OTP_EXPIRES_IN = 300;
|
|
381
|
+
var SESSION_EXPIRES_IN = 60 * 60 * 24;
|
|
382
|
+
var SESSION_UPDATE_AGE = 60 * 60;
|
|
383
|
+
function buildBetterAuthOptions({ appName, config, pool, mailer }) {
|
|
384
|
+
return {
|
|
385
|
+
appName,
|
|
386
|
+
baseURL: config.AUTH_BASE_URL,
|
|
387
|
+
basePath: "/api/auth",
|
|
388
|
+
secret: config.AUTH_SECRET,
|
|
389
|
+
trustedOrigins: [config.AUTH_BASE_URL],
|
|
390
|
+
database: pool,
|
|
391
|
+
telemetry: { enabled: false },
|
|
392
|
+
// vendor usage reporting off; env override is rejected in loadConfig
|
|
393
|
+
emailAndPassword: { enabled: false },
|
|
394
|
+
user: {
|
|
395
|
+
modelName: TABLES.user,
|
|
396
|
+
changeEmail: { enabled: false },
|
|
397
|
+
deleteUser: { enabled: false }
|
|
398
|
+
},
|
|
399
|
+
session: {
|
|
400
|
+
modelName: TABLES.session,
|
|
401
|
+
expiresIn: SESSION_EXPIRES_IN,
|
|
402
|
+
updateAge: SESSION_UPDATE_AGE
|
|
403
|
+
},
|
|
404
|
+
account: { modelName: TABLES.account },
|
|
405
|
+
verification: { modelName: TABLES.verification },
|
|
406
|
+
rateLimit: {
|
|
407
|
+
enabled: true,
|
|
408
|
+
// all environments, not just production
|
|
409
|
+
storage: "database",
|
|
410
|
+
modelName: TABLES.rateLimit,
|
|
411
|
+
window: 60,
|
|
412
|
+
max: 60,
|
|
413
|
+
customRules: {
|
|
414
|
+
"/email-otp/send-verification-otp": { window: 600, max: 5 },
|
|
415
|
+
"/sign-in/email-otp": { window: 300, max: 10 },
|
|
416
|
+
"/get-session": { window: 60, max: 120 }
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
advanced: {
|
|
420
|
+
useSecureCookies: config.isProduction,
|
|
421
|
+
cookiePrefix: "mc_auth",
|
|
422
|
+
ipAddress: { ipAddressHeaders: ipAddressHeaders(config.AUTH_EDGE) },
|
|
423
|
+
database: { generateId: "uuid" }
|
|
424
|
+
},
|
|
425
|
+
plugins: [
|
|
426
|
+
emailOTP({
|
|
427
|
+
otpLength: 6,
|
|
428
|
+
expiresIn: OTP_EXPIRES_IN,
|
|
429
|
+
allowedAttempts: 3,
|
|
430
|
+
storeOTP: "hashed",
|
|
431
|
+
disableSignUp: true,
|
|
432
|
+
// users exist only via bootstrap, invite, or import
|
|
433
|
+
resendStrategy: "rotate",
|
|
434
|
+
async sendVerificationOTP({ email, otp, type }) {
|
|
435
|
+
if (type !== "sign-in") return;
|
|
436
|
+
await mailer.sendOtp({ to: email, otp, appName, expiresInSeconds: OTP_EXPIRES_IN });
|
|
437
|
+
}
|
|
438
|
+
}),
|
|
439
|
+
admin({
|
|
440
|
+
defaultRole: DEFAULT_ROLE,
|
|
441
|
+
ac,
|
|
442
|
+
roles: accessRoles,
|
|
443
|
+
adminRoles: ["megacorp_admin"],
|
|
444
|
+
impersonationSessionDuration: 60 * 60
|
|
445
|
+
})
|
|
446
|
+
]
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/telemetry/events.ts
|
|
451
|
+
import { createHash } from "crypto";
|
|
452
|
+
import { z as z3 } from "zod";
|
|
453
|
+
var AUTH_EVENTS = [
|
|
454
|
+
"auth.boot",
|
|
455
|
+
"auth.otp.sent",
|
|
456
|
+
"auth.otp.verified",
|
|
457
|
+
"auth.otp.failed",
|
|
458
|
+
"auth.rate_limited",
|
|
459
|
+
"auth.session.created",
|
|
460
|
+
"auth.session.revoked",
|
|
461
|
+
"auth.invite.sent",
|
|
462
|
+
"auth.user.disabled",
|
|
463
|
+
"auth.admin.impersonate"
|
|
464
|
+
];
|
|
465
|
+
var authEventSchema = z3.object({
|
|
466
|
+
ts: z3.iso.datetime(),
|
|
467
|
+
app: z3.string().min(1),
|
|
468
|
+
pkgVersion: z3.string().min(1),
|
|
469
|
+
event: z3.enum(AUTH_EVENTS),
|
|
470
|
+
outcome: z3.enum(["success", "failure", "denied", "info"]),
|
|
471
|
+
userIdHash: z3.string().length(64).nullable(),
|
|
472
|
+
emailDomain: z3.string().nullable(),
|
|
473
|
+
ip: z3.string().nullable(),
|
|
474
|
+
ua: z3.string().nullable(),
|
|
475
|
+
durationMs: z3.number().nonnegative().nullable(),
|
|
476
|
+
role: z3.string().nullable().optional(),
|
|
477
|
+
reason: z3.string().nullable().optional(),
|
|
478
|
+
meta: z3.record(z3.string(), z3.union([z3.string(), z3.number(), z3.boolean(), z3.null()])).optional()
|
|
479
|
+
});
|
|
480
|
+
var hashUserId = (id) => id ? createHash("sha256").update(id).digest("hex") : null;
|
|
481
|
+
var emailDomain = (email) => {
|
|
482
|
+
const at = email?.lastIndexOf("@") ?? -1;
|
|
483
|
+
return at > 0 ? email.slice(at + 1).toLowerCase() : null;
|
|
484
|
+
};
|
|
485
|
+
function buildEvent(base, input) {
|
|
486
|
+
return {
|
|
487
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
488
|
+
app: base.app,
|
|
489
|
+
pkgVersion: base.pkgVersion,
|
|
490
|
+
event: input.event,
|
|
491
|
+
outcome: input.outcome ?? "info",
|
|
492
|
+
userIdHash: input.userIdHash ?? null,
|
|
493
|
+
emailDomain: input.emailDomain ?? null,
|
|
494
|
+
ip: input.ip ?? null,
|
|
495
|
+
ua: input.ua ?? null,
|
|
496
|
+
durationMs: input.durationMs ?? null,
|
|
497
|
+
...input.role !== void 0 && { role: input.role },
|
|
498
|
+
...input.reason !== void 0 && { reason: input.reason },
|
|
499
|
+
...input.meta && { meta: input.meta }
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/telemetry/appinsights.ts
|
|
504
|
+
import { hostname } from "os";
|
|
505
|
+
var DEFAULT_INGESTION = "https://dc.services.visualstudio.com";
|
|
506
|
+
var DEFAULT_SCOPE = "https://monitor.azure.com/.default";
|
|
507
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
508
|
+
function parseConnectionString(cs) {
|
|
509
|
+
const fields = {};
|
|
510
|
+
for (const pair of cs.split(";")) {
|
|
511
|
+
if (!pair.trim()) continue;
|
|
512
|
+
const eq = pair.indexOf("=");
|
|
513
|
+
if (eq <= 0) throw new Error(`malformed segment "${pair}"`);
|
|
514
|
+
fields[pair.slice(0, eq).trim().toLowerCase()] = pair.slice(eq + 1).trim();
|
|
515
|
+
}
|
|
516
|
+
const instrumentationKey = fields.instrumentationkey;
|
|
517
|
+
if (!instrumentationKey || !UUID.test(instrumentationKey)) throw new Error("InstrumentationKey is missing or not a GUID");
|
|
518
|
+
let ingestionEndpoint = fields.ingestionendpoint;
|
|
519
|
+
if (!ingestionEndpoint && fields.endpointsuffix) ingestionEndpoint = `https://${fields.location ? `${fields.location}.` : ""}dc.${fields.endpointsuffix}`;
|
|
520
|
+
ingestionEndpoint = (ingestionEndpoint ?? DEFAULT_INGESTION).replace(/^http:\/\//, "https://").replace(/\/+$/, "");
|
|
521
|
+
let url;
|
|
522
|
+
try {
|
|
523
|
+
url = new URL(ingestionEndpoint);
|
|
524
|
+
} catch {
|
|
525
|
+
throw new Error(`IngestionEndpoint "${ingestionEndpoint}" is not a URL`);
|
|
526
|
+
}
|
|
527
|
+
if (url.protocol !== "https:") throw new Error("IngestionEndpoint must be https");
|
|
528
|
+
return { instrumentationKey, ingestionEndpoint, ...fields.aadaudience && { aadAudience: fields.aadaudience } };
|
|
529
|
+
}
|
|
530
|
+
var RETRIABLE = /* @__PURE__ */ new Set([206, 401, 403, 408, 429, 439, 500, 502, 503, 504]);
|
|
531
|
+
var isRetriableStatus = (status) => RETRIABLE.has(status);
|
|
532
|
+
function parseRetryAfter(header2, now = Date.now()) {
|
|
533
|
+
if (!header2) return void 0;
|
|
534
|
+
const secs = Number(header2);
|
|
535
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
|
|
536
|
+
const date = Date.parse(header2);
|
|
537
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
|
|
538
|
+
}
|
|
539
|
+
var AppInsightsExporter = class {
|
|
540
|
+
constructor(o) {
|
|
541
|
+
this.o = o;
|
|
542
|
+
this.url = `${o.target.ingestionEndpoint}/v2.1/track`;
|
|
543
|
+
this.scope = o.target.aadAudience ?? DEFAULT_SCOPE;
|
|
544
|
+
this.tags = { "ai.cloud.role": o.app, "ai.cloud.roleInstance": hostname(), "ai.internal.sdkVersion": `node:megacorp-auth:${o.pkgVersion}` };
|
|
545
|
+
this.opts = {
|
|
546
|
+
batchSize: o.batchSize ?? 100,
|
|
547
|
+
flushIntervalMs: o.flushIntervalMs ?? 5e3,
|
|
548
|
+
maxRetries: o.maxRetries ?? 5,
|
|
549
|
+
maxQueue: o.maxQueue ?? 1e4,
|
|
550
|
+
requestTimeoutMs: o.requestTimeoutMs ?? 1e4
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
o;
|
|
554
|
+
queue = [];
|
|
555
|
+
timer = null;
|
|
556
|
+
retryAt = 0;
|
|
557
|
+
flushing = null;
|
|
558
|
+
token = null;
|
|
559
|
+
stopped = false;
|
|
560
|
+
tags;
|
|
561
|
+
url;
|
|
562
|
+
scope;
|
|
563
|
+
opts;
|
|
564
|
+
get pending() {
|
|
565
|
+
return this.queue.length;
|
|
566
|
+
}
|
|
567
|
+
track(e) {
|
|
568
|
+
if (this.stopped) return;
|
|
569
|
+
const { meta, ...rest } = e;
|
|
570
|
+
const properties = {};
|
|
571
|
+
for (const [k, v] of Object.entries({ ...rest, ...meta })) properties[k] = v === null || v === void 0 ? "" : String(v);
|
|
572
|
+
this.queue.push({
|
|
573
|
+
attempt: 0,
|
|
574
|
+
envelope: {
|
|
575
|
+
ver: 1,
|
|
576
|
+
name: "Microsoft.ApplicationInsights.Event",
|
|
577
|
+
time: e.ts,
|
|
578
|
+
sampleRate: 100,
|
|
579
|
+
iKey: this.o.target.instrumentationKey,
|
|
580
|
+
tags: this.tags,
|
|
581
|
+
data: { baseType: "EventData", baseData: { ver: 2, name: e.event, properties } }
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
if (this.queue.length > this.opts.maxQueue) {
|
|
585
|
+
this.queue.splice(0, this.queue.length - this.opts.maxQueue);
|
|
586
|
+
this.o.log.warn("app insights queue full; dropped oldest events", { maxQueue: this.opts.maxQueue });
|
|
587
|
+
}
|
|
588
|
+
if (this.queue.length >= this.opts.batchSize) void this.flush();
|
|
589
|
+
else this.schedule(this.opts.flushIntervalMs);
|
|
590
|
+
}
|
|
591
|
+
/** Send everything queued once. Retriable failures are re-queued for the timer; never throws. */
|
|
592
|
+
flush() {
|
|
593
|
+
if (this.flushing) return this.flushing;
|
|
594
|
+
this.flushing = this.drain().catch((err) => this.o.log.error("app insights flush failed", { error: String(err) })).finally(() => {
|
|
595
|
+
this.flushing = null;
|
|
596
|
+
});
|
|
597
|
+
return this.flushing;
|
|
598
|
+
}
|
|
599
|
+
/** Stop the timer and try to deliver what is queued, honoring backoff, within `timeoutMs`. */
|
|
600
|
+
async shutdown(timeoutMs = 1e4) {
|
|
601
|
+
this.stopped = true;
|
|
602
|
+
if (this.timer) {
|
|
603
|
+
clearTimeout(this.timer);
|
|
604
|
+
this.timer = null;
|
|
605
|
+
}
|
|
606
|
+
const deadline = Date.now() + timeoutMs;
|
|
607
|
+
while (this.queue.length && Date.now() < deadline) {
|
|
608
|
+
const wait = Math.min(this.retryAt - Date.now(), deadline - Date.now());
|
|
609
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
610
|
+
await this.flush();
|
|
611
|
+
}
|
|
612
|
+
if (this.queue.length) this.o.log.warn("app insights shutdown: events not delivered", { count: this.queue.length });
|
|
613
|
+
}
|
|
614
|
+
schedule(delayMs) {
|
|
615
|
+
if (this.timer || this.stopped) return;
|
|
616
|
+
this.timer = setTimeout(() => {
|
|
617
|
+
this.timer = null;
|
|
618
|
+
void this.flush();
|
|
619
|
+
}, delayMs);
|
|
620
|
+
this.timer.unref();
|
|
621
|
+
}
|
|
622
|
+
async drain() {
|
|
623
|
+
while (this.queue.length) {
|
|
624
|
+
if (Date.now() < this.retryAt) {
|
|
625
|
+
this.schedule(this.retryAt - Date.now());
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const batch = this.queue.splice(0, this.opts.batchSize);
|
|
629
|
+
const { retry, delayMs } = await this.send(batch);
|
|
630
|
+
if (retry.length) {
|
|
631
|
+
const kept = retry.filter((q) => ++q.attempt <= this.opts.maxRetries);
|
|
632
|
+
if (kept.length < retry.length) this.o.log.error("app insights: dropped events after max retries", { dropped: retry.length - kept.length });
|
|
633
|
+
this.queue.unshift(...kept);
|
|
634
|
+
this.retryAt = Date.now() + (delayMs ?? Math.min(3e4, 1e3 * 2 ** Math.min(batch[0].attempt, 5)));
|
|
635
|
+
this.schedule(this.retryAt - Date.now());
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/** One request. Returns envelopes that should be retried, and an optional server-dictated delay. */
|
|
641
|
+
async send(batch) {
|
|
642
|
+
const headers = { "content-type": "application/json", accept: "application/json" };
|
|
643
|
+
if (this.o.credential) {
|
|
644
|
+
const token = await this.getToken();
|
|
645
|
+
if (!token) return { retry: batch, delayMs: 5e3 };
|
|
646
|
+
headers.authorization = `Bearer ${token}`;
|
|
647
|
+
}
|
|
648
|
+
let res;
|
|
649
|
+
try {
|
|
650
|
+
res = await (this.o.fetch ?? globalThis.fetch)(this.url, {
|
|
651
|
+
method: "POST",
|
|
652
|
+
headers,
|
|
653
|
+
body: JSON.stringify(batch.map((q) => q.envelope)),
|
|
654
|
+
signal: AbortSignal.timeout(this.opts.requestTimeoutMs)
|
|
655
|
+
});
|
|
656
|
+
} catch (err) {
|
|
657
|
+
this.o.log.warn("app insights request failed", { error: String(err) });
|
|
658
|
+
return { retry: batch };
|
|
659
|
+
}
|
|
660
|
+
const delayMs = parseRetryAfter(res.headers.get("retry-after"));
|
|
661
|
+
if (res.status === 200) return { retry: [] };
|
|
662
|
+
if (res.status === 206) {
|
|
663
|
+
const body = await res.json().catch(() => ({}));
|
|
664
|
+
const retry = [];
|
|
665
|
+
for (const e of body.errors ?? []) {
|
|
666
|
+
const q = batch[e.index];
|
|
667
|
+
if (!q) continue;
|
|
668
|
+
if (isRetriableStatus(e.statusCode)) retry.push(q);
|
|
669
|
+
else this.o.log.error("app insights rejected event", { status: e.statusCode, message: e.message ?? "" });
|
|
670
|
+
}
|
|
671
|
+
return { retry, delayMs };
|
|
672
|
+
}
|
|
673
|
+
if (res.status === 401 || res.status === 403) {
|
|
674
|
+
this.token = null;
|
|
675
|
+
if (!this.o.credential) {
|
|
676
|
+
this.o.log.error("app insights rejected instrumentation key auth; enable Entra auth (Authorization=AAD)", { status: res.status });
|
|
677
|
+
return { retry: [] };
|
|
678
|
+
}
|
|
679
|
+
this.o.log.warn("app insights auth rejected; refreshing token", { status: res.status });
|
|
680
|
+
return { retry: batch, delayMs: delayMs ?? 2e3 };
|
|
681
|
+
}
|
|
682
|
+
if (isRetriableStatus(res.status)) {
|
|
683
|
+
this.o.log.warn("app insights throttled or unavailable", { status: res.status, retryAfterMs: delayMs ?? null });
|
|
684
|
+
return { retry: batch, delayMs };
|
|
685
|
+
}
|
|
686
|
+
this.o.log.error("app insights rejected batch", { status: res.status, body: (await res.text().catch(() => "")).slice(0, 500) });
|
|
687
|
+
return { retry: [] };
|
|
688
|
+
}
|
|
689
|
+
async getToken() {
|
|
690
|
+
if (this.token && this.token.expiresAt - 12e4 > Date.now()) return this.token.value;
|
|
691
|
+
try {
|
|
692
|
+
const t = await this.o.credential.getToken(this.scope);
|
|
693
|
+
if (!t) return null;
|
|
694
|
+
this.token = { value: t.token, expiresAt: t.expiresOnTimestamp };
|
|
695
|
+
return t.token;
|
|
696
|
+
} catch (err) {
|
|
697
|
+
this.o.log.warn("app insights token acquisition failed", { error: String(err) });
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
// src/telemetry/sink.ts
|
|
704
|
+
function parseAuthenticationString(value) {
|
|
705
|
+
if (!value) return null;
|
|
706
|
+
const fields = {};
|
|
707
|
+
for (const pair of value.split(";")) {
|
|
708
|
+
const [k, v] = pair.split("=");
|
|
709
|
+
if (k && v) fields[k.trim().toLowerCase()] = v.trim();
|
|
710
|
+
}
|
|
711
|
+
if (fields.authorization?.toLowerCase() !== "aad") return null;
|
|
712
|
+
return fields.clientid ? { clientId: fields.clientid } : {};
|
|
713
|
+
}
|
|
714
|
+
function stdoutSink() {
|
|
715
|
+
return {
|
|
716
|
+
emit: (e) => process.stdout.write(JSON.stringify({ type: "auth_event", ...e }) + "\n"),
|
|
717
|
+
flush: async () => {
|
|
718
|
+
},
|
|
719
|
+
shutdown: async () => {
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function createSink(o) {
|
|
724
|
+
if (!o.connectionString) return stdoutSink();
|
|
725
|
+
let target;
|
|
726
|
+
try {
|
|
727
|
+
target = parseConnectionString(o.connectionString);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
throw new AuthConfigError(`APPLICATIONINSIGHTS_CONNECTION_STRING is invalid: ${err instanceof Error ? err.message : String(err)}`);
|
|
730
|
+
}
|
|
731
|
+
const env = o.env ?? process.env;
|
|
732
|
+
const aad = parseAuthenticationString(env.APPLICATIONINSIGHTS_AUTHENTICATION_STRING);
|
|
733
|
+
const credential = o.credential ?? (aad ? azureCredential(o.production, { clientId: aad.clientId, env }) : void 0);
|
|
734
|
+
const exporter = new AppInsightsExporter({ target, app: o.app, pkgVersion: o.pkgVersion, log: o.log, credential });
|
|
735
|
+
o.log.info("telemetry sink: application insights", { endpoint: target.ingestionEndpoint, auth: credential ? "entra" : "ikey" });
|
|
736
|
+
return { emit: (e) => exporter.track(e), flush: () => exporter.flush(), shutdown: (t) => exporter.shutdown(t) };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// src/version.ts
|
|
740
|
+
import { createRequire } from "module";
|
|
741
|
+
var require2 = createRequire(import.meta.url);
|
|
742
|
+
var PKG_VERSION = require2("../package.json").version;
|
|
743
|
+
|
|
744
|
+
// src/index.ts
|
|
745
|
+
import { fromNodeHeaders as fromNodeHeaders2 } from "better-auth/node";
|
|
746
|
+
function createAuthInstance({ appName, config, pool, mailer, emit }) {
|
|
747
|
+
const webMeta = (ctx) => {
|
|
748
|
+
const ipHeader = config.AUTH_EDGE === "cloudflare" ? "cf-connecting-ip" : "x-forwarded-for";
|
|
749
|
+
return { ip: ctx.headers?.get(ipHeader)?.split(",")[0]?.trim() ?? null, ua: ctx.headers?.get("user-agent") ?? null };
|
|
750
|
+
};
|
|
751
|
+
const str = (v) => typeof v === "string" ? v : null;
|
|
752
|
+
return betterAuth2({
|
|
753
|
+
...buildBetterAuthOptions({ appName, config, pool, mailer }),
|
|
754
|
+
hooks: {
|
|
755
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
756
|
+
if (ctx.path === "/admin/impersonate-user" && !config.AUTH_ALLOW_IMPERSONATION) {
|
|
757
|
+
emit({ event: "auth.admin.impersonate", outcome: "denied", reason: "AUTH_ALLOW_IMPERSONATION is not true", ...webMeta(ctx) });
|
|
758
|
+
throw new APIError("FORBIDDEN", { message: "Impersonation is disabled for this app" });
|
|
759
|
+
}
|
|
760
|
+
}),
|
|
761
|
+
after: createAuthMiddleware(async (ctx) => {
|
|
762
|
+
const returned = ctx.context.returned;
|
|
763
|
+
const failed = returned instanceof APIError;
|
|
764
|
+
const reason = failed ? returned.message : null;
|
|
765
|
+
const meta = webMeta(ctx);
|
|
766
|
+
const body = ctx.body ?? {};
|
|
767
|
+
const email = str(body.email);
|
|
768
|
+
const actor = () => hashUserId(ctx.context.session?.user.id);
|
|
769
|
+
switch (ctx.path) {
|
|
770
|
+
case "/email-otp/send-verification-otp": {
|
|
771
|
+
const known = !failed && email ? Boolean(await ctx.context.internalAdapter.findUserByEmail(email)) : true;
|
|
772
|
+
emit({
|
|
773
|
+
event: "auth.otp.sent",
|
|
774
|
+
outcome: failed ? "failure" : known ? "success" : "denied",
|
|
775
|
+
emailDomain: emailDomain(email),
|
|
776
|
+
reason: failed ? reason : known ? null : "unknown_email",
|
|
777
|
+
...meta
|
|
778
|
+
});
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
case "/sign-in/email-otp":
|
|
782
|
+
emit({
|
|
783
|
+
event: failed ? "auth.otp.failed" : "auth.otp.verified",
|
|
784
|
+
outcome: failed ? "failure" : "success",
|
|
785
|
+
emailDomain: emailDomain(email),
|
|
786
|
+
userIdHash: hashUserId(ctx.context.newSession?.user.id),
|
|
787
|
+
reason,
|
|
788
|
+
...meta
|
|
789
|
+
});
|
|
790
|
+
break;
|
|
791
|
+
case "/sign-out":
|
|
792
|
+
case "/revoke-session":
|
|
793
|
+
case "/revoke-sessions":
|
|
794
|
+
case "/admin/revoke-user-session":
|
|
795
|
+
case "/admin/revoke-user-sessions":
|
|
796
|
+
if (!failed) emit({ event: "auth.session.revoked", outcome: "success", reason: ctx.path, userIdHash: hashUserId(str(body.userId) ?? ctx.context.session?.user.id), ...meta });
|
|
797
|
+
break;
|
|
798
|
+
case "/admin/ban-user":
|
|
799
|
+
if (!failed) emit({ event: "auth.user.disabled", outcome: "success", userIdHash: hashUserId(str(body.userId)), reason: str(body.banReason), meta: { actorUserIdHash: actor() ?? "" }, ...meta });
|
|
800
|
+
break;
|
|
801
|
+
case "/admin/impersonate-user":
|
|
802
|
+
emit({ event: "auth.admin.impersonate", outcome: failed ? "failure" : "success", userIdHash: hashUserId(str(body.userId)), reason, meta: { actorUserIdHash: actor() ?? "" }, ...meta });
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
})
|
|
806
|
+
},
|
|
807
|
+
databaseHooks: {
|
|
808
|
+
session: {
|
|
809
|
+
create: {
|
|
810
|
+
after: async (session) => {
|
|
811
|
+
const impersonated = Boolean(session.impersonatedBy);
|
|
812
|
+
emit({ event: "auth.session.created", outcome: "success", userIdHash: hashUserId(session.userId), ip: session.ipAddress ?? null, ua: session.userAgent ?? null, meta: { impersonated } });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
function createMegacorpAuth(opts) {
|
|
820
|
+
let config;
|
|
821
|
+
try {
|
|
822
|
+
config = loadConfig(opts.env ?? process.env);
|
|
823
|
+
} catch (err) {
|
|
824
|
+
if (err instanceof AuthConfigError) {
|
|
825
|
+
process.stderr.write(JSON.stringify({ level: "error", msg: err.message, code: err.code }) + "\n");
|
|
826
|
+
process.exit(1);
|
|
827
|
+
}
|
|
828
|
+
throw err;
|
|
829
|
+
}
|
|
830
|
+
const log = createLogger({ app: opts.appName, component: "auth" }, config.AUTH_LOG_LEVEL);
|
|
831
|
+
const mailer = opts.mailer ?? (config.ACS_ENDPOINT ? acsMailer({ endpoint: config.ACS_ENDPOINT, from: config.AUTH_FROM_EMAIL, appName: opts.appName, production: config.isProduction }) : consoleMailer());
|
|
832
|
+
const pool = opts.pool ?? new Pool({ connectionString: config.DATABASE_URL });
|
|
833
|
+
const base = { app: opts.appName, pkgVersion: PKG_VERSION };
|
|
834
|
+
let sink;
|
|
835
|
+
try {
|
|
836
|
+
sink = createSink({ connectionString: config.APPLICATIONINSIGHTS_CONNECTION_STRING, app: opts.appName, pkgVersion: PKG_VERSION, production: config.isProduction, log, credential: opts.telemetryCredential, env: opts.env ?? process.env });
|
|
837
|
+
} catch (err) {
|
|
838
|
+
if (!(err instanceof AuthConfigError)) throw err;
|
|
839
|
+
if (config.isProduction) {
|
|
840
|
+
process.stderr.write(JSON.stringify({ level: "error", msg: err.message, code: err.code }) + "\n");
|
|
841
|
+
process.exit(1);
|
|
842
|
+
}
|
|
843
|
+
log.warn(`${err.message}; falling back to stdout events`);
|
|
844
|
+
sink = stdoutSink();
|
|
845
|
+
}
|
|
846
|
+
const emit = (input) => {
|
|
847
|
+
const e = buildEvent(base, input);
|
|
848
|
+
opts.onEvent?.(e);
|
|
849
|
+
sink.emit(e);
|
|
850
|
+
};
|
|
851
|
+
process.once("beforeExit", () => {
|
|
852
|
+
void sink.shutdown();
|
|
853
|
+
});
|
|
854
|
+
const auth = createAuthInstance({ appName: opts.appName, config, pool, mailer, emit });
|
|
855
|
+
const adapter = async () => (await auth.$context).internalAdapter;
|
|
856
|
+
const newUser = (email, role) => ({ email, name: email.split("@")[0], emailVerified: true, role });
|
|
857
|
+
const getSession = async (req) => {
|
|
858
|
+
const s = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
|
|
859
|
+
if (!s) return null;
|
|
860
|
+
const u = s.user;
|
|
861
|
+
if (u.banned) return null;
|
|
862
|
+
return {
|
|
863
|
+
user: { id: u.id, email: u.email, name: u.name, role: u.role ?? null, banned: u.banned ?? null },
|
|
864
|
+
session: { id: s.session.id, expiresAt: s.session.expiresAt, impersonatedBy: s.session.impersonatedBy ?? null }
|
|
865
|
+
};
|
|
866
|
+
};
|
|
867
|
+
const ready = (async () => {
|
|
868
|
+
const started = Date.now();
|
|
869
|
+
const missing = await findMissingSchema(pool);
|
|
870
|
+
if (missing.length) {
|
|
871
|
+
const msg = `Auth schema is incomplete (missing: ${missing.join(", ")}). Run \`npx megacorp-auth migrate\`.`;
|
|
872
|
+
if (config.isProduction) {
|
|
873
|
+
log.error(msg, { missing });
|
|
874
|
+
emit({ event: "auth.boot", outcome: "failure", reason: "schema_missing" });
|
|
875
|
+
await sink.shutdown();
|
|
876
|
+
process.exit(1);
|
|
877
|
+
}
|
|
878
|
+
log.warn(msg, { missing });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const ia = await adapter();
|
|
882
|
+
for (const email of config.AUTH_BOOTSTRAP_ADMINS) {
|
|
883
|
+
const existing = await ia.findUserByEmail(email);
|
|
884
|
+
if (!existing) {
|
|
885
|
+
await ia.createUser(newUser(email, "megacorp_admin"), { method: "admin" });
|
|
886
|
+
log.info("bootstrap admin created", { emailDomain: emailDomain(email) });
|
|
887
|
+
} else if (existing.user.role !== "megacorp_admin") {
|
|
888
|
+
await ia.updateUser(existing.user.id, { role: "megacorp_admin" });
|
|
889
|
+
log.info("bootstrap admin role set", { emailDomain: emailDomain(email) });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
emit({ event: "auth.boot", outcome: "success", durationMs: Date.now() - started, meta: { edge: config.AUTH_EDGE, mailer: config.ACS_ENDPOINT ? "acs" : "console", impersonation: config.AUTH_ALLOW_IMPERSONATION } });
|
|
893
|
+
})();
|
|
894
|
+
ready.catch((err) => log.error("auth startup failed", { error: String(err) }));
|
|
895
|
+
const requireSession = () => async (req, res, next) => {
|
|
896
|
+
try {
|
|
897
|
+
const s = await getSession(req);
|
|
898
|
+
if (!s) return res.status(401).json({ error: "UNAUTHENTICATED", message: "Sign in required" });
|
|
899
|
+
req.auth = s;
|
|
900
|
+
next();
|
|
901
|
+
} catch (err) {
|
|
902
|
+
next(err);
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
const requireRole = (...roles) => {
|
|
906
|
+
const sessionMw = requireSession();
|
|
907
|
+
return (req, res, next) => sessionMw(req, res, (err) => {
|
|
908
|
+
if (err) return next(err);
|
|
909
|
+
if (!hasRole(req.auth?.user.role, roles)) return res.status(403).json({ error: "FORBIDDEN", message: `Requires role: ${roles.join(" or ")}` });
|
|
910
|
+
next();
|
|
911
|
+
});
|
|
912
|
+
};
|
|
913
|
+
const inviteHandler = createInviteHandler({
|
|
914
|
+
appName: opts.appName,
|
|
915
|
+
baseURL: config.AUTH_BASE_URL,
|
|
916
|
+
inviteLinkPath: opts.inviteLinkPath ?? "/",
|
|
917
|
+
maxPerEmail: config.AUTH_INVITE_MAX_PER_EMAIL,
|
|
918
|
+
mailer,
|
|
919
|
+
getSession,
|
|
920
|
+
findUserByEmail: async (email) => {
|
|
921
|
+
const r = await (await adapter()).findUserByEmail(email);
|
|
922
|
+
if (!r) return null;
|
|
923
|
+
const u = r.user;
|
|
924
|
+
return { id: u.id, role: u.role ?? null, banned: u.banned ?? null };
|
|
925
|
+
},
|
|
926
|
+
createUser: async (email, role) => ({ id: (await (await adapter()).createUser(newUser(email, role), { method: "admin" })).id }),
|
|
927
|
+
onInvite: ({ inviter, email, role, userId, req }) => emit({ event: "auth.invite.sent", outcome: "success", userIdHash: hashUserId(userId), emailDomain: emailDomain(email), role, meta: { inviterUserIdHash: hashUserId(inviter.user.id) ?? "" }, ip: getClientIp(req, config.AUTH_EDGE) ?? null, ua: req.headers["user-agent"] ?? null })
|
|
928
|
+
});
|
|
929
|
+
const mount = (app) => {
|
|
930
|
+
app.set("trust proxy", trustProxySetting(config.AUTH_EDGE));
|
|
931
|
+
const gate = (_req, _res, next) => {
|
|
932
|
+
ready.then(() => next(), next);
|
|
933
|
+
};
|
|
934
|
+
app.post("/api/auth/invite", gate, express.json({ limit: "10kb" }), inviteHandler);
|
|
935
|
+
const handler = toNodeHandler(auth);
|
|
936
|
+
app.all("/api/auth/*splat", gate, (req, res) => {
|
|
937
|
+
res.once("finish", () => {
|
|
938
|
+
if (res.statusCode === 429) emit({ event: "auth.rate_limited", outcome: "denied", reason: req.path.replace(/^\/api\/auth/, ""), ip: getClientIp(req, config.AUTH_EDGE) ?? null, ua: req.headers["user-agent"] ?? null });
|
|
939
|
+
});
|
|
940
|
+
void handler(req, res);
|
|
941
|
+
});
|
|
942
|
+
};
|
|
943
|
+
return { auth, config, pool, ready, mount, requireSession, requireRole, getSession, events: { emit, flush: () => sink.flush(), shutdown: (t) => sink.shutdown(t) } };
|
|
944
|
+
}
|
|
945
|
+
export {
|
|
946
|
+
AUTH_EVENTS,
|
|
947
|
+
AppInsightsExporter,
|
|
948
|
+
AuthConfigError,
|
|
949
|
+
DEFAULT_ROLE,
|
|
950
|
+
HttpError,
|
|
951
|
+
REQUIRED_COLUMNS,
|
|
952
|
+
ROLES,
|
|
953
|
+
SchemaError,
|
|
954
|
+
TABLES,
|
|
955
|
+
acsMailer,
|
|
956
|
+
assertNoVendorTelemetry,
|
|
957
|
+
assertSchema,
|
|
958
|
+
authEventSchema,
|
|
959
|
+
azureCredential,
|
|
960
|
+
buildBetterAuthOptions,
|
|
961
|
+
buildEvent,
|
|
962
|
+
canInvite,
|
|
963
|
+
consoleMailer,
|
|
964
|
+
createMegacorpAuth,
|
|
965
|
+
emailDomain,
|
|
966
|
+
envSchema,
|
|
967
|
+
findMissingSchema,
|
|
968
|
+
fromNodeHeaders2 as fromNodeHeaders,
|
|
969
|
+
getClientIp,
|
|
970
|
+
hasRole,
|
|
971
|
+
hashUserId,
|
|
972
|
+
ipAddressHeaders,
|
|
973
|
+
ipInCidr,
|
|
974
|
+
isCloudflareIp,
|
|
975
|
+
isRole,
|
|
976
|
+
loadConfig,
|
|
977
|
+
parseConnectionString,
|
|
978
|
+
parseRoles,
|
|
979
|
+
trustProxySetting
|
|
980
|
+
};
|
|
981
|
+
//# sourceMappingURL=index.js.map
|