@hasna/tenants 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 +191 -0
- package/README.md +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +228 -0
- package/dist/db.d.ts +22 -0
- package/dist/db.js +480 -0
- package/dist/generated/storage-kit/health.d.ts +19 -0
- package/dist/generated/storage-kit/index.d.ts +7 -0
- package/dist/generated/storage-kit/migrations.d.ts +47 -0
- package/dist/generated/storage-kit/mode.d.ts +47 -0
- package/dist/generated/storage-kit/pool.d.ts +33 -0
- package/dist/generated/storage-kit/query.d.ts +35 -0
- package/dist/generated/storage-kit/tls.d.ts +25 -0
- package/dist/idp/backfill.d.ts +12 -0
- package/dist/idp/ids.d.ts +24 -0
- package/dist/idp/mailer.d.ts +58 -0
- package/dist/idp/migrations.d.ts +10 -0
- package/dist/idp/passwords.d.ts +4 -0
- package/dist/idp/policy.d.ts +27 -0
- package/dist/idp/service.d.ts +103 -0
- package/dist/idp/store.d.ts +205 -0
- package/dist/idp/tokens.d.ts +89 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +2129 -0
- package/dist/migrations.d.ts +3 -0
- package/dist/migrations.js +452 -0
- package/dist/sdk/client.d.ts +115 -0
- package/dist/sdk/index.d.ts +1 -0
- package/dist/sdk/index.js +88 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2149 -0
- package/dist/server/openapi.d.ts +819 -0
- package/dist/server/serve.d.ts +41 -0
- package/dist/version.d.ts +3 -0
- package/package.json +82 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2129 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/generated/storage-kit/mode.ts
|
|
3
|
+
var DEPRECATED_STORAGE_MODE_ALIASES = [
|
|
4
|
+
"remote",
|
|
5
|
+
"hybrid",
|
|
6
|
+
"self_hosted"
|
|
7
|
+
];
|
|
8
|
+
function normalizeStorageMode(value) {
|
|
9
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
10
|
+
if (normalized === "local")
|
|
11
|
+
return { mode: "local", deprecatedAlias: null };
|
|
12
|
+
if (normalized === "cloud")
|
|
13
|
+
return { mode: "cloud", deprecatedAlias: null };
|
|
14
|
+
if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
|
|
15
|
+
return { mode: "cloud", deprecatedAlias: normalized };
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
|
|
18
|
+
}
|
|
19
|
+
function envToken(name) {
|
|
20
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
21
|
+
}
|
|
22
|
+
function storageEnvKeys(name) {
|
|
23
|
+
const token = envToken(name);
|
|
24
|
+
return {
|
|
25
|
+
modeKeys: [`HASNA_${token}_STORAGE_MODE`, `${token}_STORAGE_MODE`],
|
|
26
|
+
databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`]
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function firstEnv(env, keys) {
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
const value = env[key]?.trim();
|
|
32
|
+
if (value)
|
|
33
|
+
return { key, value };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function resolveStorageMode(name, env = process.env) {
|
|
38
|
+
const { modeKeys, databaseUrlKeys } = storageEnvKeys(name);
|
|
39
|
+
const dbHit = firstEnv(env, databaseUrlKeys);
|
|
40
|
+
const databaseUrlPresent = Boolean(dbHit);
|
|
41
|
+
const databaseUrlSource = dbHit ? dbHit.key : null;
|
|
42
|
+
const modeHit = firstEnv(env, modeKeys);
|
|
43
|
+
if (!modeHit) {
|
|
44
|
+
return {
|
|
45
|
+
mode: "local",
|
|
46
|
+
source: "default",
|
|
47
|
+
deprecatedAlias: null,
|
|
48
|
+
databaseUrlPresent,
|
|
49
|
+
databaseUrlSource,
|
|
50
|
+
warning: null
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const { mode, deprecatedAlias } = normalizeStorageMode(modeHit.value);
|
|
54
|
+
const warnings = [];
|
|
55
|
+
if (deprecatedAlias) {
|
|
56
|
+
warnings.push(`Deprecated storage mode '${deprecatedAlias}' from ${modeHit.key} is treated as 'cloud'. Set ${modeKeys[0]}=cloud instead.`);
|
|
57
|
+
}
|
|
58
|
+
if (mode === "cloud" && !databaseUrlPresent) {
|
|
59
|
+
warnings.push(`cloud mode needs ${databaseUrlKeys[0]} (PURE REMOTE: reads and writes go to cloud Postgres).`);
|
|
60
|
+
}
|
|
61
|
+
if (modeHit.key !== modeKeys[0]) {
|
|
62
|
+
warnings.push(`Using alias env ${modeHit.key}; the canonical key is ${modeKeys[0]}.`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
mode,
|
|
66
|
+
source: modeHit.key,
|
|
67
|
+
deprecatedAlias,
|
|
68
|
+
databaseUrlPresent,
|
|
69
|
+
databaseUrlSource,
|
|
70
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function resolveDatabaseUrl(name, env = process.env) {
|
|
74
|
+
const { databaseUrlKeys } = storageEnvKeys(name);
|
|
75
|
+
const hit = firstEnv(env, databaseUrlKeys);
|
|
76
|
+
return hit ? hit.value : null;
|
|
77
|
+
}
|
|
78
|
+
// src/generated/storage-kit/tls.ts
|
|
79
|
+
import { readFileSync } from "fs";
|
|
80
|
+
function sslModeFromConnectionString(connectionString) {
|
|
81
|
+
const queryStart = connectionString.indexOf("?");
|
|
82
|
+
const params = new URLSearchParams(queryStart === -1 ? "" : connectionString.slice(queryStart + 1));
|
|
83
|
+
const sslmode = params.get("sslmode")?.trim().toLowerCase();
|
|
84
|
+
if (sslmode) {
|
|
85
|
+
switch (sslmode) {
|
|
86
|
+
case "disable":
|
|
87
|
+
case "prefer":
|
|
88
|
+
case "require":
|
|
89
|
+
case "verify-ca":
|
|
90
|
+
case "verify-full":
|
|
91
|
+
return sslmode;
|
|
92
|
+
case "allow":
|
|
93
|
+
return "prefer";
|
|
94
|
+
default:
|
|
95
|
+
throw new Error(`Unknown sslmode '${sslmode}' in connection string.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const ssl = params.get("ssl")?.trim().toLowerCase();
|
|
99
|
+
if (ssl && ["1", "true", "yes", "on", "require"].includes(ssl))
|
|
100
|
+
return "require";
|
|
101
|
+
return "disable";
|
|
102
|
+
}
|
|
103
|
+
function loadCaBundle(options) {
|
|
104
|
+
const env = options.env ?? process.env;
|
|
105
|
+
if (options.ca && options.ca.trim())
|
|
106
|
+
return options.ca;
|
|
107
|
+
const path = options.caCertPath ?? env.PGSSLROOTCERT ?? env.NODE_EXTRA_CA_CERTS;
|
|
108
|
+
if (path && path.trim())
|
|
109
|
+
return readFileSync(path.trim(), "utf8");
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function resolveTlsConfig(connectionString, options = {}) {
|
|
113
|
+
const mode = sslModeFromConnectionString(connectionString);
|
|
114
|
+
if (mode === "disable" || mode === "prefer") {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const ca = loadCaBundle(options);
|
|
118
|
+
if (mode === "require") {
|
|
119
|
+
return ca ? { rejectUnauthorized: false, ca } : { rejectUnauthorized: false };
|
|
120
|
+
}
|
|
121
|
+
if (!ca) {
|
|
122
|
+
throw new Error(`sslmode=${mode} requires a CA bundle. Set PGSSLROOTCERT (or pass caCertPath/ca) to the ` + `Amazon RDS global bundle: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`);
|
|
123
|
+
}
|
|
124
|
+
return { rejectUnauthorized: true, ca };
|
|
125
|
+
}
|
|
126
|
+
// src/generated/storage-kit/query.ts
|
|
127
|
+
function wrapExecutor(executor) {
|
|
128
|
+
return {
|
|
129
|
+
async query(sql, params) {
|
|
130
|
+
const result = await executor.query(sql, params);
|
|
131
|
+
return { rows: result.rows, rowCount: result.rowCount ?? result.rows.length };
|
|
132
|
+
},
|
|
133
|
+
async many(sql, params) {
|
|
134
|
+
const result = await executor.query(sql, params);
|
|
135
|
+
return result.rows;
|
|
136
|
+
},
|
|
137
|
+
async get(sql, params) {
|
|
138
|
+
const result = await executor.query(sql, params);
|
|
139
|
+
return result.rows[0] ?? null;
|
|
140
|
+
},
|
|
141
|
+
async one(sql, params) {
|
|
142
|
+
const result = await executor.query(sql, params);
|
|
143
|
+
if (result.rows.length !== 1) {
|
|
144
|
+
throw new Error(`Expected exactly one row, got ${result.rows.length}.`);
|
|
145
|
+
}
|
|
146
|
+
return result.rows[0];
|
|
147
|
+
},
|
|
148
|
+
async execute(sql, params) {
|
|
149
|
+
await executor.query(sql, params);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function createQueryClient(pool) {
|
|
154
|
+
const base = wrapExecutor(pool);
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
pool,
|
|
158
|
+
async transaction(fn) {
|
|
159
|
+
const client = await pool.connect();
|
|
160
|
+
try {
|
|
161
|
+
await client.query("BEGIN");
|
|
162
|
+
const result = await fn(wrapExecutor(client));
|
|
163
|
+
await client.query("COMMIT");
|
|
164
|
+
return result;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
try {
|
|
167
|
+
await client.query("ROLLBACK");
|
|
168
|
+
} catch {}
|
|
169
|
+
throw error;
|
|
170
|
+
} finally {
|
|
171
|
+
client.release();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async close() {
|
|
175
|
+
await pool.end();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// src/generated/storage-kit/pool.ts
|
|
180
|
+
import pg from "pg";
|
|
181
|
+
function createPgPool(options) {
|
|
182
|
+
const ssl = resolveTlsConfig(options.connectionString, {
|
|
183
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
184
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
185
|
+
...options.env !== undefined ? { env: options.env } : {}
|
|
186
|
+
});
|
|
187
|
+
const config = { connectionString: options.connectionString };
|
|
188
|
+
if (ssl !== undefined)
|
|
189
|
+
config.ssl = ssl;
|
|
190
|
+
if (options.max !== undefined)
|
|
191
|
+
config.max = options.max;
|
|
192
|
+
if (options.idleTimeoutMillis !== undefined)
|
|
193
|
+
config.idleTimeoutMillis = options.idleTimeoutMillis;
|
|
194
|
+
if (options.connectionTimeoutMillis !== undefined)
|
|
195
|
+
config.connectionTimeoutMillis = options.connectionTimeoutMillis;
|
|
196
|
+
if (options.applicationName !== undefined)
|
|
197
|
+
config.application_name = options.applicationName;
|
|
198
|
+
return new pg.Pool(config);
|
|
199
|
+
}
|
|
200
|
+
function createCloudPoolFromEnv(appName, options = {}) {
|
|
201
|
+
const env = options.env ?? process.env;
|
|
202
|
+
const resolution = resolveStorageMode(appName, env);
|
|
203
|
+
if (resolution.mode !== "cloud") {
|
|
204
|
+
throw new Error(`createCloudPoolFromEnv requires ${appName} storage mode 'cloud', got '${resolution.mode}'. ` + `Set HASNA_${appName.toUpperCase().replace(/-/g, "_")}_STORAGE_MODE=cloud.`);
|
|
205
|
+
}
|
|
206
|
+
const connectionString = resolveDatabaseUrl(appName, env);
|
|
207
|
+
if (!connectionString) {
|
|
208
|
+
throw new Error(`cloud mode for ${appName} needs a database URL. Set ` + `HASNA_${appName.toUpperCase().replace(/-/g, "_")}_DATABASE_URL.`);
|
|
209
|
+
}
|
|
210
|
+
const pool = createPgPool({
|
|
211
|
+
connectionString,
|
|
212
|
+
...options.ca !== undefined ? { ca: options.ca } : {},
|
|
213
|
+
...options.caCertPath !== undefined ? { caCertPath: options.caCertPath } : {},
|
|
214
|
+
env,
|
|
215
|
+
...options.max !== undefined ? { max: options.max } : {},
|
|
216
|
+
...options.idleTimeoutMillis !== undefined ? { idleTimeoutMillis: options.idleTimeoutMillis } : {},
|
|
217
|
+
...options.connectionTimeoutMillis !== undefined ? { connectionTimeoutMillis: options.connectionTimeoutMillis } : {},
|
|
218
|
+
...options.applicationName !== undefined ? { applicationName: options.applicationName } : {}
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
client: createQueryClient(pool),
|
|
222
|
+
connectionSource: resolution.databaseUrlSource ?? "unknown"
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// src/generated/storage-kit/migrations.ts
|
|
226
|
+
import { createHash } from "crypto";
|
|
227
|
+
var DEFAULT_MIGRATION_LEDGER_TABLE = "schema_migrations";
|
|
228
|
+
function checksumSql(sql) {
|
|
229
|
+
const normalized = sql.trim().replace(/\r\n/g, `
|
|
230
|
+
`);
|
|
231
|
+
return `sha256:${createHash("sha256").update(normalized).digest("hex")}`;
|
|
232
|
+
}
|
|
233
|
+
function defineMigration(id, sql) {
|
|
234
|
+
return Object.freeze({ id, sql: sql.trim(), checksum: checksumSql(sql) });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
class MigrationLedger {
|
|
238
|
+
client;
|
|
239
|
+
migrations;
|
|
240
|
+
ledgerTable;
|
|
241
|
+
constructor(client, migrations, options = {}) {
|
|
242
|
+
this.client = client;
|
|
243
|
+
this.migrations = migrations;
|
|
244
|
+
this.ledgerTable = options.ledgerTable ?? DEFAULT_MIGRATION_LEDGER_TABLE;
|
|
245
|
+
const seen = new Set;
|
|
246
|
+
for (const migration of migrations) {
|
|
247
|
+
if (seen.has(migration.id))
|
|
248
|
+
throw new Error(`Duplicate migration id: ${migration.id}`);
|
|
249
|
+
seen.add(migration.id);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async ensureLedger() {
|
|
253
|
+
await this.client.execute(`CREATE TABLE IF NOT EXISTS ${this.ledgerTable} (
|
|
254
|
+
id TEXT PRIMARY KEY,
|
|
255
|
+
checksum TEXT NOT NULL,
|
|
256
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
257
|
+
)`);
|
|
258
|
+
}
|
|
259
|
+
async listApplied() {
|
|
260
|
+
await this.ensureLedger();
|
|
261
|
+
return this.readApplied();
|
|
262
|
+
}
|
|
263
|
+
async readApplied() {
|
|
264
|
+
const rows = await this.client.many(`SELECT id, checksum, applied_at FROM ${this.ledgerTable} ORDER BY id ASC`);
|
|
265
|
+
return rows.map((row) => ({
|
|
266
|
+
id: row.id,
|
|
267
|
+
checksum: row.checksum,
|
|
268
|
+
appliedAt: row.applied_at instanceof Date ? row.applied_at.toISOString() : String(row.applied_at)
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
buildPlan(applied) {
|
|
272
|
+
const known = new Set(this.migrations.map((m) => m.id));
|
|
273
|
+
for (const row of applied) {
|
|
274
|
+
if (!known.has(row.id)) {
|
|
275
|
+
throw new Error(`Applied migration '${row.id}' is not recognized by this build (downgrade?).`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const appliedById = new Map(applied.map((row) => [row.id, row]));
|
|
279
|
+
for (const migration of this.migrations) {
|
|
280
|
+
const existing = appliedById.get(migration.id);
|
|
281
|
+
if (existing && existing.checksum !== migration.checksum) {
|
|
282
|
+
throw new Error(`Migration checksum mismatch for '${migration.id}': the SQL changed after it was applied.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return this.migrations.map((migration) => ({
|
|
286
|
+
migration,
|
|
287
|
+
state: appliedById.has(migration.id) ? "already_applied" : "pending"
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
async migrate(opts = {}) {
|
|
291
|
+
const dryRun = opts.dryRun === true;
|
|
292
|
+
await this.ensureLedger();
|
|
293
|
+
const applied = await this.readApplied();
|
|
294
|
+
const plan = this.buildPlan(applied);
|
|
295
|
+
if (dryRun)
|
|
296
|
+
return { dryRun, applied, plan };
|
|
297
|
+
for (const item of plan) {
|
|
298
|
+
if (item.state === "already_applied")
|
|
299
|
+
continue;
|
|
300
|
+
await this.client.execute(item.migration.sql);
|
|
301
|
+
await this.client.execute(`INSERT INTO ${this.ledgerTable} (id, checksum, applied_at) VALUES ($1, $2, now())`, [item.migration.id, item.migration.checksum]);
|
|
302
|
+
}
|
|
303
|
+
return { dryRun, applied: await this.readApplied(), plan };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// src/generated/storage-kit/health.ts
|
|
307
|
+
async function checkHealth(client) {
|
|
308
|
+
const start = Date.now();
|
|
309
|
+
try {
|
|
310
|
+
await client.get("SELECT 1 AS ok");
|
|
311
|
+
return { ok: true, latencyMs: Date.now() - start };
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
latencyMs: Date.now() - start,
|
|
316
|
+
error: error instanceof Error ? error.message : String(error)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function checkReady(client, migrations, options = {}) {
|
|
321
|
+
const start = Date.now();
|
|
322
|
+
try {
|
|
323
|
+
const ledger = new MigrationLedger(client, migrations, options);
|
|
324
|
+
const result = await ledger.migrate({ dryRun: true });
|
|
325
|
+
const pending = result.plan.filter((item) => item.state === "pending").map((item) => item.migration.id);
|
|
326
|
+
return { ok: pending.length === 0, latencyMs: Date.now() - start, pendingMigrations: pending };
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
latencyMs: Date.now() - start,
|
|
331
|
+
pendingMigrations: [],
|
|
332
|
+
error: error instanceof Error ? error.message : String(error)
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// src/migrations.ts
|
|
337
|
+
import { apiKeyMigrations } from "@hasna/contracts/auth";
|
|
338
|
+
|
|
339
|
+
// src/idp/migrations.ts
|
|
340
|
+
var TENANTS_TABLE = "tenants";
|
|
341
|
+
var USERS_TABLE = "users";
|
|
342
|
+
var SERVICE_PRINCIPALS_TABLE = "service_principals";
|
|
343
|
+
var MEMBERSHIPS_TABLE = "memberships";
|
|
344
|
+
var SESSIONS_TABLE = "sessions";
|
|
345
|
+
var AUTH_CHALLENGES_TABLE = "auth_challenges";
|
|
346
|
+
var JWT_SIGNING_KEYS_TABLE = "jwt_signing_keys";
|
|
347
|
+
function idpMigrations(apiKeysTable) {
|
|
348
|
+
return [
|
|
349
|
+
defineMigration("tenants_0001_tenants", `CREATE TABLE IF NOT EXISTS ${TENANTS_TABLE} (
|
|
350
|
+
id UUID PRIMARY KEY,
|
|
351
|
+
slug TEXT UNIQUE NOT NULL,
|
|
352
|
+
name TEXT NOT NULL,
|
|
353
|
+
kind TEXT NOT NULL DEFAULT 'org',
|
|
354
|
+
parent_id UUID REFERENCES ${TENANTS_TABLE}(id),
|
|
355
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
356
|
+
identity_id TEXT,
|
|
357
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
358
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
359
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
360
|
+
)`),
|
|
361
|
+
defineMigration("tenants_0002_users", `CREATE TABLE IF NOT EXISTS ${USERS_TABLE} (
|
|
362
|
+
id UUID PRIMARY KEY,
|
|
363
|
+
kind TEXT NOT NULL,
|
|
364
|
+
email TEXT UNIQUE,
|
|
365
|
+
display_name TEXT,
|
|
366
|
+
identity_id TEXT,
|
|
367
|
+
home_tenant_id UUID REFERENCES ${TENANTS_TABLE}(id),
|
|
368
|
+
auth_method TEXT,
|
|
369
|
+
password_hash TEXT,
|
|
370
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
371
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
372
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
373
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
374
|
+
email_verified_at TIMESTAMPTZ
|
|
375
|
+
)`),
|
|
376
|
+
defineMigration("tenants_0003_service_principals", `CREATE TABLE IF NOT EXISTS ${SERVICE_PRINCIPALS_TABLE} (
|
|
377
|
+
id UUID PRIMARY KEY,
|
|
378
|
+
tenant_id UUID NOT NULL REFERENCES ${TENANTS_TABLE}(id),
|
|
379
|
+
kind TEXT NOT NULL DEFAULT 'machine',
|
|
380
|
+
display_name TEXT,
|
|
381
|
+
identity_id TEXT,
|
|
382
|
+
enrollment_secret_ref TEXT,
|
|
383
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
384
|
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
385
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
386
|
+
last_seen_at TIMESTAMPTZ
|
|
387
|
+
)`),
|
|
388
|
+
defineMigration("tenants_0004_memberships", `CREATE TABLE IF NOT EXISTS ${MEMBERSHIPS_TABLE} (
|
|
389
|
+
id BIGSERIAL PRIMARY KEY,
|
|
390
|
+
tenant_id UUID NOT NULL REFERENCES ${TENANTS_TABLE}(id),
|
|
391
|
+
principal_id UUID NOT NULL,
|
|
392
|
+
principal_type TEXT NOT NULL,
|
|
393
|
+
role TEXT NOT NULL,
|
|
394
|
+
scopes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
395
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
396
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
397
|
+
UNIQUE (tenant_id, principal_id, principal_type)
|
|
398
|
+
)`),
|
|
399
|
+
defineMigration("tenants_0005_sessions", `CREATE TABLE IF NOT EXISTS ${SESSIONS_TABLE} (
|
|
400
|
+
id UUID PRIMARY KEY,
|
|
401
|
+
user_id UUID REFERENCES ${USERS_TABLE}(id),
|
|
402
|
+
tenant_id UUID REFERENCES ${TENANTS_TABLE}(id),
|
|
403
|
+
token_hash TEXT UNIQUE NOT NULL,
|
|
404
|
+
method TEXT,
|
|
405
|
+
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
406
|
+
expires_at TIMESTAMPTZ,
|
|
407
|
+
revoked_at TIMESTAMPTZ,
|
|
408
|
+
ip TEXT,
|
|
409
|
+
user_agent TEXT
|
|
410
|
+
)`),
|
|
411
|
+
defineMigration("tenants_0006_auth_challenges", `CREATE TABLE IF NOT EXISTS ${AUTH_CHALLENGES_TABLE} (
|
|
412
|
+
id UUID PRIMARY KEY,
|
|
413
|
+
email TEXT NOT NULL,
|
|
414
|
+
code_hash TEXT NOT NULL,
|
|
415
|
+
purpose TEXT NOT NULL,
|
|
416
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
417
|
+
consumed_at TIMESTAMPTZ,
|
|
418
|
+
attempts INT NOT NULL DEFAULT 0,
|
|
419
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
420
|
+
)`),
|
|
421
|
+
defineMigration("tenants_0007_auth_challenges_email_idx", `CREATE INDEX IF NOT EXISTS ${AUTH_CHALLENGES_TABLE}_email_idx
|
|
422
|
+
ON ${AUTH_CHALLENGES_TABLE} (email, purpose, created_at DESC)`),
|
|
423
|
+
defineMigration("tenants_0008_jwt_signing_keys", `CREATE TABLE IF NOT EXISTS ${JWT_SIGNING_KEYS_TABLE} (
|
|
424
|
+
kid TEXT PRIMARY KEY,
|
|
425
|
+
alg TEXT NOT NULL DEFAULT 'EdDSA',
|
|
426
|
+
public_jwk JSONB NOT NULL,
|
|
427
|
+
private_jwk JSONB NOT NULL,
|
|
428
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
429
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
430
|
+
retired_at TIMESTAMPTZ
|
|
431
|
+
)`),
|
|
432
|
+
defineMigration("tenants_0009_memberships_principal_idx", `CREATE INDEX IF NOT EXISTS ${MEMBERSHIPS_TABLE}_principal_idx
|
|
433
|
+
ON ${MEMBERSHIPS_TABLE} (principal_id, principal_type)`),
|
|
434
|
+
defineMigration("tenants_0010_api_keys_tenant", `ALTER TABLE ${apiKeysTable} ADD COLUMN IF NOT EXISTS tenant_id UUID;
|
|
435
|
+
ALTER TABLE ${apiKeysTable} ADD COLUMN IF NOT EXISTS user_id UUID;
|
|
436
|
+
ALTER TABLE ${apiKeysTable} ADD COLUMN IF NOT EXISTS principal_type TEXT;`),
|
|
437
|
+
defineMigration("tenants_0011_api_keys_tenant_idx", `CREATE INDEX IF NOT EXISTS ${apiKeysTable}_tenant_idx ON ${apiKeysTable} (tenant_id)`)
|
|
438
|
+
];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/migrations.ts
|
|
442
|
+
var API_KEYS_TABLE = "api_keys";
|
|
443
|
+
function tenantsMigrations() {
|
|
444
|
+
return [
|
|
445
|
+
...apiKeyMigrations(API_KEYS_TABLE).map((m) => defineMigration(m.id, m.sql)),
|
|
446
|
+
...idpMigrations(API_KEYS_TABLE)
|
|
447
|
+
];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/db.ts
|
|
451
|
+
var TENANTS_APP_NAME = "tenants";
|
|
452
|
+
function createCloudClient(options = {}) {
|
|
453
|
+
const { client, connectionSource } = createCloudPoolFromEnv(TENANTS_APP_NAME, {
|
|
454
|
+
applicationName: options.applicationName ?? "tenants-serve"
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
client,
|
|
458
|
+
connectionSource,
|
|
459
|
+
close: async () => {
|
|
460
|
+
await client.close();
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
async function runTenantsMigrations(client, opts = {}) {
|
|
465
|
+
const ledger = new MigrationLedger(client, tenantsMigrations());
|
|
466
|
+
return ledger.migrate(opts);
|
|
467
|
+
}
|
|
468
|
+
async function cloudHealth(client) {
|
|
469
|
+
return checkHealth(client);
|
|
470
|
+
}
|
|
471
|
+
async function cloudReady(client) {
|
|
472
|
+
return checkReady(client, tenantsMigrations());
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/idp/service.ts
|
|
476
|
+
import { mintApiKey, hasScope } from "@hasna/contracts/auth";
|
|
477
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
478
|
+
|
|
479
|
+
// src/idp/passwords.ts
|
|
480
|
+
import { randomBytes, scryptSync, timingSafeEqual, createHash as createHash2 } from "crypto";
|
|
481
|
+
var SCRYPT_COST = 16384;
|
|
482
|
+
var KEY_LEN = 32;
|
|
483
|
+
function hashPassword(password) {
|
|
484
|
+
const salt = randomBytes(16);
|
|
485
|
+
const derived = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_COST });
|
|
486
|
+
return `scrypt$${SCRYPT_COST}$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
|
487
|
+
}
|
|
488
|
+
function verifyPassword(password, stored) {
|
|
489
|
+
const parts = stored.split("$");
|
|
490
|
+
if (parts.length !== 4 || parts[0] !== "scrypt")
|
|
491
|
+
return false;
|
|
492
|
+
const cost = Number(parts[1]);
|
|
493
|
+
if (!Number.isInteger(cost) || cost < 2)
|
|
494
|
+
return false;
|
|
495
|
+
let salt;
|
|
496
|
+
let expected;
|
|
497
|
+
try {
|
|
498
|
+
salt = Buffer.from(parts[2], "base64url");
|
|
499
|
+
expected = Buffer.from(parts[3], "base64url");
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
const derived = scryptSync(password, salt, expected.length, { N: cost });
|
|
504
|
+
return derived.length === expected.length && timingSafeEqual(derived, expected);
|
|
505
|
+
}
|
|
506
|
+
function sha256(value) {
|
|
507
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/idp/ids.ts
|
|
511
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
512
|
+
var DNS_NAMESPACE = Buffer.from("6ba7b8109dad11d180b400c04fd430c8", "hex");
|
|
513
|
+
function uuidv5(name, namespace) {
|
|
514
|
+
const hash = createHash3("sha1");
|
|
515
|
+
hash.update(namespace);
|
|
516
|
+
hash.update(Buffer.from(name, "utf8"));
|
|
517
|
+
const bytes = hash.digest().subarray(0, 16);
|
|
518
|
+
bytes[6] = bytes[6] & 15 | 80;
|
|
519
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
520
|
+
const hex = Buffer.from(bytes).toString("hex");
|
|
521
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
522
|
+
}
|
|
523
|
+
function parseUuid(uuid) {
|
|
524
|
+
return Buffer.from(uuid.replace(/-/g, ""), "hex");
|
|
525
|
+
}
|
|
526
|
+
var HASNA_TENANT_NAMESPACE = uuidv5("hasna.xyz", DNS_NAMESPACE);
|
|
527
|
+
function deriveTenantId(slug, kind) {
|
|
528
|
+
return uuidv5(`tenant:${slug}:${kind}`, parseUuid(HASNA_TENANT_NAMESPACE));
|
|
529
|
+
}
|
|
530
|
+
var ROOT_TENANT_ID = "adfd95c7-ee8b-52cb-ae47-4ae65dae3313";
|
|
531
|
+
var ROOT_TENANT_SLUG = "hasna";
|
|
532
|
+
var SEED_TENANTS = [
|
|
533
|
+
{ id: ROOT_TENANT_ID, slug: ROOT_TENANT_SLUG, name: "Hasna", kind: "root", parentId: null },
|
|
534
|
+
{ id: deriveTenantId("hasnastudio", "brand"), slug: "hasnastudio", name: "Hasna Studio", kind: "brand", parentId: ROOT_TENANT_ID },
|
|
535
|
+
{ id: deriveTenantId("hasnafamily", "brand"), slug: "hasnafamily", name: "Hasna Family", kind: "brand", parentId: ROOT_TENANT_ID },
|
|
536
|
+
{ id: deriveTenantId("hasnatools", "brand"), slug: "hasnatools", name: "Hasna Tools", kind: "brand", parentId: ROOT_TENANT_ID }
|
|
537
|
+
];
|
|
538
|
+
function newId() {
|
|
539
|
+
return randomUUID();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/idp/tokens.ts
|
|
543
|
+
import { createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "crypto";
|
|
544
|
+
var TOKEN_TYPE = "at+jwt";
|
|
545
|
+
var TOKEN_ALG = "EdDSA";
|
|
546
|
+
var TOKEN_ISSUER = "identities";
|
|
547
|
+
var DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60;
|
|
548
|
+
function b64url(input) {
|
|
549
|
+
return Buffer.from(input).toString("base64url");
|
|
550
|
+
}
|
|
551
|
+
function b64urlJson(value) {
|
|
552
|
+
return b64url(JSON.stringify(value));
|
|
553
|
+
}
|
|
554
|
+
function signingKeyFromPrivateJwk(kid, privateJwk) {
|
|
555
|
+
const privateKey = createPrivateKey({ key: privateJwk, format: "jwk" });
|
|
556
|
+
const publicKey = createPublicKey(privateKey);
|
|
557
|
+
const pub = publicKey.export({ format: "jwk" });
|
|
558
|
+
if (pub.crv !== "Ed25519" || !pub.x) {
|
|
559
|
+
throw new Error("Signing key must be an Ed25519 (OKP) key.");
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
kid,
|
|
563
|
+
privateKey,
|
|
564
|
+
publicJwk: { kty: "OKP", crv: "Ed25519", x: pub.x, kid, use: "sig", alg: "EdDSA" }
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function buildJwks(publicJwks) {
|
|
568
|
+
return { keys: publicJwks };
|
|
569
|
+
}
|
|
570
|
+
function signAccessToken(key, input) {
|
|
571
|
+
const nowSec = Math.floor((input.nowMs ?? Date.now()) / 1000);
|
|
572
|
+
const ttl = input.ttlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
|
|
573
|
+
const claims = {
|
|
574
|
+
iss: TOKEN_ISSUER,
|
|
575
|
+
aud: input.aud,
|
|
576
|
+
sub: input.sub,
|
|
577
|
+
tid: input.tid,
|
|
578
|
+
pt: input.pt,
|
|
579
|
+
scope: input.scope,
|
|
580
|
+
iat: nowSec,
|
|
581
|
+
exp: nowSec + ttl,
|
|
582
|
+
jti: input.jti
|
|
583
|
+
};
|
|
584
|
+
const header = { alg: TOKEN_ALG, kid: key.kid, typ: TOKEN_TYPE };
|
|
585
|
+
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
|
|
586
|
+
const signature = edSign(null, Buffer.from(signingInput), key.privateKey);
|
|
587
|
+
return { token: `${signingInput}.${b64url(signature)}`, claims };
|
|
588
|
+
}
|
|
589
|
+
function verifyAccessToken(token, options) {
|
|
590
|
+
const parts = token.split(".");
|
|
591
|
+
if (parts.length !== 3)
|
|
592
|
+
return { ok: false, reason: "malformed" };
|
|
593
|
+
const [headerB64, payloadB64, sigB64] = parts;
|
|
594
|
+
let header;
|
|
595
|
+
let claims;
|
|
596
|
+
try {
|
|
597
|
+
header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8"));
|
|
598
|
+
claims = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
|
|
599
|
+
} catch {
|
|
600
|
+
return { ok: false, reason: "malformed" };
|
|
601
|
+
}
|
|
602
|
+
if (header.alg !== TOKEN_ALG)
|
|
603
|
+
return { ok: false, reason: "unsupported_alg" };
|
|
604
|
+
if (!header.kid)
|
|
605
|
+
return { ok: false, reason: "missing_kid" };
|
|
606
|
+
const jwk = options.jwks.find((k) => k.kid === header.kid);
|
|
607
|
+
if (!jwk)
|
|
608
|
+
return { ok: false, reason: "unknown_kid" };
|
|
609
|
+
let verified = false;
|
|
610
|
+
try {
|
|
611
|
+
const publicKey = createPublicKey({ key: jwk, format: "jwk" });
|
|
612
|
+
verified = edVerify(null, Buffer.from(`${headerB64}.${payloadB64}`), publicKey, Buffer.from(sigB64, "base64url"));
|
|
613
|
+
} catch {
|
|
614
|
+
return { ok: false, reason: "bad_signature" };
|
|
615
|
+
}
|
|
616
|
+
if (!verified)
|
|
617
|
+
return { ok: false, reason: "bad_signature" };
|
|
618
|
+
if (claims.iss !== TOKEN_ISSUER)
|
|
619
|
+
return { ok: false, reason: "issuer_mismatch" };
|
|
620
|
+
if (options.expectedAudience && claims.aud !== options.expectedAudience) {
|
|
621
|
+
return { ok: false, reason: "audience_mismatch" };
|
|
622
|
+
}
|
|
623
|
+
const nowSec = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
|
624
|
+
const leeway = options.leewaySeconds ?? 0;
|
|
625
|
+
if (typeof claims.exp === "number" && nowSec > claims.exp + leeway) {
|
|
626
|
+
return { ok: false, reason: "expired" };
|
|
627
|
+
}
|
|
628
|
+
if (typeof claims.iat === "number" && claims.iat - leeway > nowSec) {
|
|
629
|
+
return { ok: false, reason: "not_yet_valid" };
|
|
630
|
+
}
|
|
631
|
+
return { ok: true, claims, kid: header.kid };
|
|
632
|
+
}
|
|
633
|
+
function looksLikeAccessToken(token) {
|
|
634
|
+
const parts = token.split(".");
|
|
635
|
+
if (parts.length !== 3)
|
|
636
|
+
return false;
|
|
637
|
+
try {
|
|
638
|
+
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
639
|
+
return header?.typ === TOKEN_TYPE || header?.alg === TOKEN_ALG;
|
|
640
|
+
} catch {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// src/idp/policy.ts
|
|
646
|
+
var DEFAULT_ALLOWED_EMAIL_DOMAINS = [
|
|
647
|
+
"hasna.academy",
|
|
648
|
+
"hasna.agency",
|
|
649
|
+
"hasna.ai",
|
|
650
|
+
"hasna.app",
|
|
651
|
+
"hasna.army",
|
|
652
|
+
"hasna.art",
|
|
653
|
+
"hasna.asia",
|
|
654
|
+
"hasna.autos",
|
|
655
|
+
"hasna.band",
|
|
656
|
+
"hasna.best",
|
|
657
|
+
"hasna.bio",
|
|
658
|
+
"hasna.blog",
|
|
659
|
+
"hasna.builders",
|
|
660
|
+
"hasna.business",
|
|
661
|
+
"hasna.cafe",
|
|
662
|
+
"hasna.camp",
|
|
663
|
+
"hasna.capital",
|
|
664
|
+
"hasna.care",
|
|
665
|
+
"hasna.career",
|
|
666
|
+
"hasna.careers",
|
|
667
|
+
"hasna.cash",
|
|
668
|
+
"hasna.cc",
|
|
669
|
+
"hasna.center",
|
|
670
|
+
"hasna.charity",
|
|
671
|
+
"hasna.chat",
|
|
672
|
+
"hasna.city",
|
|
673
|
+
"hasna.clinic",
|
|
674
|
+
"hasna.clothing",
|
|
675
|
+
"hasna.cloud",
|
|
676
|
+
"hasna.club",
|
|
677
|
+
"hasna.co.uk",
|
|
678
|
+
"hasna.coach",
|
|
679
|
+
"hasna.codes",
|
|
680
|
+
"hasna.coffee",
|
|
681
|
+
"hasna.com",
|
|
682
|
+
"hasna.community",
|
|
683
|
+
"hasna.company",
|
|
684
|
+
"hasna.computer",
|
|
685
|
+
"hasna.construction",
|
|
686
|
+
"hasna.consulting",
|
|
687
|
+
"hasna.contact",
|
|
688
|
+
"hasna.cool",
|
|
689
|
+
"hasna.day",
|
|
690
|
+
"hasna.deals",
|
|
691
|
+
"hasna.delivery",
|
|
692
|
+
"hasna.dev",
|
|
693
|
+
"hasna.digital",
|
|
694
|
+
"hasna.dog",
|
|
695
|
+
"hasna.domains",
|
|
696
|
+
"hasna.earth",
|
|
697
|
+
"hasna.eco",
|
|
698
|
+
"hasna.education",
|
|
699
|
+
"hasna.email",
|
|
700
|
+
"hasna.energy",
|
|
701
|
+
"hasna.engineering",
|
|
702
|
+
"hasna.enterprises",
|
|
703
|
+
"hasna.estate",
|
|
704
|
+
"hasna.events",
|
|
705
|
+
"hasna.exchange",
|
|
706
|
+
"hasna.expert",
|
|
707
|
+
"hasna.express",
|
|
708
|
+
"hasna.family",
|
|
709
|
+
"hasna.fan",
|
|
710
|
+
"hasna.fans",
|
|
711
|
+
"hasna.farm",
|
|
712
|
+
"hasna.finance",
|
|
713
|
+
"hasna.financial",
|
|
714
|
+
"hasna.fit",
|
|
715
|
+
"hasna.fitness",
|
|
716
|
+
"hasna.food",
|
|
717
|
+
"hasna.football",
|
|
718
|
+
"hasna.foundation",
|
|
719
|
+
"hasna.fund",
|
|
720
|
+
"hasna.gallery",
|
|
721
|
+
"hasna.games",
|
|
722
|
+
"hasna.global",
|
|
723
|
+
"hasna.gold",
|
|
724
|
+
"hasna.green",
|
|
725
|
+
"hasna.group",
|
|
726
|
+
"hasna.guide",
|
|
727
|
+
"hasna.guru",
|
|
728
|
+
"hasna.health",
|
|
729
|
+
"hasna.healthcare",
|
|
730
|
+
"hasna.holdings",
|
|
731
|
+
"hasna.homes",
|
|
732
|
+
"hasna.hospital",
|
|
733
|
+
"hasna.hosting",
|
|
734
|
+
"hasna.house",
|
|
735
|
+
"hasna.how",
|
|
736
|
+
"hasna.industries",
|
|
737
|
+
"hasna.info",
|
|
738
|
+
"hasna.ink",
|
|
739
|
+
"hasna.institute",
|
|
740
|
+
"hasna.international",
|
|
741
|
+
"hasna.investments",
|
|
742
|
+
"hasna.irish",
|
|
743
|
+
"hasna.land",
|
|
744
|
+
"hasna.legal",
|
|
745
|
+
"hasna.life",
|
|
746
|
+
"hasna.lifestyle",
|
|
747
|
+
"hasna.live",
|
|
748
|
+
"hasna.living",
|
|
749
|
+
"hasna.llc",
|
|
750
|
+
"hasna.loans",
|
|
751
|
+
"hasna.love",
|
|
752
|
+
"hasna.ltd",
|
|
753
|
+
"hasna.luxury",
|
|
754
|
+
"hasna.management",
|
|
755
|
+
"hasna.market",
|
|
756
|
+
"hasna.marketing",
|
|
757
|
+
"hasna.mba",
|
|
758
|
+
"hasna.md",
|
|
759
|
+
"hasna.media",
|
|
760
|
+
"hasna.mobi",
|
|
761
|
+
"hasna.money",
|
|
762
|
+
"hasna.net",
|
|
763
|
+
"hasna.network",
|
|
764
|
+
"hasna.news",
|
|
765
|
+
"hasna.ngo",
|
|
766
|
+
"hasna.ninja",
|
|
767
|
+
"hasna.one",
|
|
768
|
+
"hasna.ooo",
|
|
769
|
+
"hasna.page",
|
|
770
|
+
"hasna.partners",
|
|
771
|
+
"hasna.place",
|
|
772
|
+
"hasna.plus",
|
|
773
|
+
"hasna.pro",
|
|
774
|
+
"hasna.productions",
|
|
775
|
+
"hasna.properties",
|
|
776
|
+
"hasna.quest",
|
|
777
|
+
"hasna.red",
|
|
778
|
+
"hasna.rentals",
|
|
779
|
+
"hasna.review",
|
|
780
|
+
"hasna.reviews",
|
|
781
|
+
"hasna.rocks",
|
|
782
|
+
"hasna.run",
|
|
783
|
+
"hasna.sale",
|
|
784
|
+
"hasna.school",
|
|
785
|
+
"hasna.science",
|
|
786
|
+
"hasna.services",
|
|
787
|
+
"hasna.shopping",
|
|
788
|
+
"hasna.show",
|
|
789
|
+
"hasna.social",
|
|
790
|
+
"hasna.software",
|
|
791
|
+
"hasna.solar",
|
|
792
|
+
"hasna.solutions",
|
|
793
|
+
"hasna.space",
|
|
794
|
+
"hasna.store",
|
|
795
|
+
"hasna.studio",
|
|
796
|
+
"hasna.supply",
|
|
797
|
+
"hasna.support",
|
|
798
|
+
"hasna.systems",
|
|
799
|
+
"hasna.tax",
|
|
800
|
+
"hasna.team",
|
|
801
|
+
"hasna.tech",
|
|
802
|
+
"hasna.technology",
|
|
803
|
+
"hasna.today",
|
|
804
|
+
"hasna.tools",
|
|
805
|
+
"hasna.toys",
|
|
806
|
+
"hasna.trade",
|
|
807
|
+
"hasna.tv",
|
|
808
|
+
"hasna.university",
|
|
809
|
+
"hasna.us",
|
|
810
|
+
"hasna.vc",
|
|
811
|
+
"hasna.ventures",
|
|
812
|
+
"hasna.vip",
|
|
813
|
+
"hasna.vision",
|
|
814
|
+
"hasna.website",
|
|
815
|
+
"hasna.wiki",
|
|
816
|
+
"hasna.win",
|
|
817
|
+
"hasna.work",
|
|
818
|
+
"hasna.works",
|
|
819
|
+
"hasna.world",
|
|
820
|
+
"hasna.ws",
|
|
821
|
+
"hasna.wtf",
|
|
822
|
+
"hasna.xyz",
|
|
823
|
+
"hasna.zone"
|
|
824
|
+
];
|
|
825
|
+
var ALLOWED_EMAIL_DOMAINS_ENV = "HASNA_TENANTS_ALLOWED_EMAIL_DOMAINS";
|
|
826
|
+
function emailDomain(email) {
|
|
827
|
+
const at = email.lastIndexOf("@");
|
|
828
|
+
if (at <= 0 || at === email.length - 1)
|
|
829
|
+
return null;
|
|
830
|
+
const domain = email.slice(at + 1).trim().toLowerCase();
|
|
831
|
+
if (!domain || domain.includes("@") || !domain.includes("."))
|
|
832
|
+
return null;
|
|
833
|
+
return domain;
|
|
834
|
+
}
|
|
835
|
+
function emailPolicyFromEnv(env = process.env) {
|
|
836
|
+
const disabled = env["HASNA_TENANTS_DISABLE_EMAIL_ALLOWLIST"] === "1";
|
|
837
|
+
const override = (env[ALLOWED_EMAIL_DOMAINS_ENV] ?? "").trim();
|
|
838
|
+
const domains = override ? override.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean) : [...DEFAULT_ALLOWED_EMAIL_DOMAINS];
|
|
839
|
+
return {
|
|
840
|
+
allowedDomains: disabled ? null : new Set(domains),
|
|
841
|
+
requireConfirmation: env["HASNA_TENANTS_REQUIRE_EMAIL_CONFIRMATION"] !== "0"
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function isEmailDomainAllowed(email, policy) {
|
|
845
|
+
if (!policy.allowedDomains)
|
|
846
|
+
return true;
|
|
847
|
+
const domain = emailDomain(email);
|
|
848
|
+
if (!domain)
|
|
849
|
+
return false;
|
|
850
|
+
return policy.allowedDomains.has(domain);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/idp/mailer.ts
|
|
854
|
+
import { createHash as createHash4, createHmac } from "crypto";
|
|
855
|
+
|
|
856
|
+
class NoopMailer {
|
|
857
|
+
async sendConfirmation(_input) {
|
|
858
|
+
return { skipped: true, reason: "email_disabled" };
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function sha256Hex(data) {
|
|
862
|
+
return createHash4("sha256").update(data).digest("hex");
|
|
863
|
+
}
|
|
864
|
+
function hmac(key, data) {
|
|
865
|
+
return createHmac("sha256", key).update(data, "utf8").digest();
|
|
866
|
+
}
|
|
867
|
+
async function resolveAwsCredentials(env = process.env) {
|
|
868
|
+
const id = env["AWS_ACCESS_KEY_ID"];
|
|
869
|
+
const secret = env["AWS_SECRET_ACCESS_KEY"];
|
|
870
|
+
if (id && secret) {
|
|
871
|
+
return { accessKeyId: id, secretAccessKey: secret, ...env["AWS_SESSION_TOKEN"] ? { sessionToken: env["AWS_SESSION_TOKEN"] } : {} };
|
|
872
|
+
}
|
|
873
|
+
const relative = env["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"];
|
|
874
|
+
const full = env["AWS_CONTAINER_CREDENTIALS_FULL_URI"];
|
|
875
|
+
const url = relative ? `http://169.254.170.2${relative}` : full;
|
|
876
|
+
if (url) {
|
|
877
|
+
const headers = {};
|
|
878
|
+
const authToken = env["AWS_CONTAINER_AUTHORIZATION_TOKEN"];
|
|
879
|
+
if (authToken)
|
|
880
|
+
headers["Authorization"] = authToken;
|
|
881
|
+
const res = await fetch(url, { headers });
|
|
882
|
+
if (!res.ok)
|
|
883
|
+
throw new Error(`container credentials endpoint returned ${res.status}`);
|
|
884
|
+
const body = await res.json();
|
|
885
|
+
return { accessKeyId: body.AccessKeyId, secretAccessKey: body.SecretAccessKey, ...body.Token ? { sessionToken: body.Token } : {} };
|
|
886
|
+
}
|
|
887
|
+
throw new Error("No AWS credentials available (set AWS_ACCESS_KEY_ID/SECRET or run under an ECS task role).");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
class SesMailer {
|
|
891
|
+
options;
|
|
892
|
+
constructor(options) {
|
|
893
|
+
this.options = options;
|
|
894
|
+
}
|
|
895
|
+
async sendConfirmation(input) {
|
|
896
|
+
const creds = this.options.credentials ?? await resolveAwsCredentials();
|
|
897
|
+
const region = this.options.region;
|
|
898
|
+
const host = `email.${region}.amazonaws.com`;
|
|
899
|
+
const path = "/v2/email/outbound-emails";
|
|
900
|
+
const endpoint = `https://${host}${path}`;
|
|
901
|
+
const login = input.purpose === "login";
|
|
902
|
+
const subject = login ? "Your Hasna login code" : "Confirm your Hasna account";
|
|
903
|
+
const bodyObj = {
|
|
904
|
+
FromEmailAddress: this.options.from,
|
|
905
|
+
Destination: { ToAddresses: [input.to] },
|
|
906
|
+
Content: {
|
|
907
|
+
Simple: {
|
|
908
|
+
Subject: { Data: subject, Charset: "UTF-8" },
|
|
909
|
+
Body: {
|
|
910
|
+
Text: { Data: confirmationText(input), Charset: "UTF-8" },
|
|
911
|
+
Html: { Data: confirmationHtml(input), Charset: "UTF-8" }
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
if (this.options.fromArn)
|
|
917
|
+
bodyObj["FromEmailAddressIdentityArn"] = this.options.fromArn;
|
|
918
|
+
const body = JSON.stringify(bodyObj);
|
|
919
|
+
const now = new Date;
|
|
920
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
921
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
922
|
+
const payloadHash = sha256Hex(body);
|
|
923
|
+
const canonicalHeaders = `content-type:application/json
|
|
924
|
+
` + `host:${host}
|
|
925
|
+
` + `x-amz-content-sha256:${payloadHash}
|
|
926
|
+
` + `x-amz-date:${amzDate}
|
|
927
|
+
` + (creds.sessionToken ? `x-amz-security-token:${creds.sessionToken}
|
|
928
|
+
` : "");
|
|
929
|
+
const signedHeaders = creds.sessionToken ? "content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token" : "content-type;host;x-amz-content-sha256;x-amz-date";
|
|
930
|
+
const canonicalRequest = ["POST", path, "", canonicalHeaders, signedHeaders, payloadHash].join(`
|
|
931
|
+
`);
|
|
932
|
+
const scope = `${dateStamp}/${region}/ses/aws4_request`;
|
|
933
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonicalRequest)].join(`
|
|
934
|
+
`);
|
|
935
|
+
const kDate = hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
|
|
936
|
+
const kRegion = hmac(kDate, region);
|
|
937
|
+
const kService = hmac(kRegion, "ses");
|
|
938
|
+
const kSigning = hmac(kService, "aws4_request");
|
|
939
|
+
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
|
940
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
941
|
+
const headers = {
|
|
942
|
+
"content-type": "application/json",
|
|
943
|
+
host,
|
|
944
|
+
"x-amz-content-sha256": payloadHash,
|
|
945
|
+
"x-amz-date": amzDate,
|
|
946
|
+
authorization
|
|
947
|
+
};
|
|
948
|
+
if (creds.sessionToken)
|
|
949
|
+
headers["x-amz-security-token"] = creds.sessionToken;
|
|
950
|
+
const res = await fetch(endpoint, { method: "POST", headers, body });
|
|
951
|
+
const text = await res.text();
|
|
952
|
+
if (!res.ok) {
|
|
953
|
+
let detail = text;
|
|
954
|
+
try {
|
|
955
|
+
const j = JSON.parse(text);
|
|
956
|
+
detail = j.message || j.Message || j.__type || text;
|
|
957
|
+
} catch {}
|
|
958
|
+
throw new Error(`SES send failed (${res.status}): ${detail}`);
|
|
959
|
+
}
|
|
960
|
+
let messageId;
|
|
961
|
+
try {
|
|
962
|
+
messageId = JSON.parse(text).MessageId;
|
|
963
|
+
} catch {}
|
|
964
|
+
return { ...messageId ? { messageId } : {} };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function confirmationText(i) {
|
|
968
|
+
const login = i.purpose === "login";
|
|
969
|
+
return [
|
|
970
|
+
login ? "Here is your Hasna login code." : "Welcome to Hasna.",
|
|
971
|
+
"",
|
|
972
|
+
`Your ${login ? "login" : "confirmation"} code is: ${i.code}`,
|
|
973
|
+
"",
|
|
974
|
+
`Or ${login ? "sign in" : "confirm"} in one click: ${i.link}`,
|
|
975
|
+
"",
|
|
976
|
+
`This code expires in ${i.expiresMinutes} minutes. If you did not request this, ignore this email.`
|
|
977
|
+
].join(`
|
|
978
|
+
`);
|
|
979
|
+
}
|
|
980
|
+
function confirmationHtml(i) {
|
|
981
|
+
const login = i.purpose === "login";
|
|
982
|
+
const safeLink = i.link.replace(/"/g, """);
|
|
983
|
+
return [
|
|
984
|
+
`<div style="font-family:system-ui,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto">`,
|
|
985
|
+
`<h2>${login ? "Your Hasna login code" : "Welcome to Hasna"}</h2>`,
|
|
986
|
+
`<p>Your ${login ? "login" : "confirmation"} code is:</p>`,
|
|
987
|
+
`<p style="font-size:28px;font-weight:700;letter-spacing:4px">${i.code}</p>`,
|
|
988
|
+
`<p>Or ${login ? "sign in" : "confirm"} in one click:</p>`,
|
|
989
|
+
`<p><a href="${safeLink}" style="background:#111;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none">${login ? "Sign in" : "Confirm my account"}</a></p>`,
|
|
990
|
+
`<p style="color:#666;font-size:13px">This code expires in ${i.expiresMinutes} minutes. If you did not request this, ignore this email.</p>`,
|
|
991
|
+
`</div>`
|
|
992
|
+
].join("");
|
|
993
|
+
}
|
|
994
|
+
var MAIL_FROM_ENV = "HASNA_TENANTS_MAIL_FROM";
|
|
995
|
+
var MAIL_ENABLED_ENV = "HASNA_TENANTS_EMAIL_ENABLED";
|
|
996
|
+
var SES_REGION_ENV = "HASNA_TENANTS_SES_REGION";
|
|
997
|
+
var SES_FROM_ARN_ENV = "HASNA_TENANTS_SES_FROM_ARN";
|
|
998
|
+
var CONFIRM_URL_BASE_ENV = "HASNA_TENANTS_CONFIRM_URL_BASE";
|
|
999
|
+
var DEFAULT_MAIL_FROM = "Hasna Tenants <auth@tenants.hasna.xyz>";
|
|
1000
|
+
var DEFAULT_CONFIRM_URL_BASE = "https://tenants.hasna.xyz";
|
|
1001
|
+
function confirmUrlBaseFromEnv(env = process.env) {
|
|
1002
|
+
return (env[CONFIRM_URL_BASE_ENV] ?? DEFAULT_CONFIRM_URL_BASE).replace(/\/+$/, "");
|
|
1003
|
+
}
|
|
1004
|
+
function createMailerFromEnv(env = process.env) {
|
|
1005
|
+
if (env[MAIL_ENABLED_ENV] !== "1")
|
|
1006
|
+
return new NoopMailer;
|
|
1007
|
+
const region = env[SES_REGION_ENV] || env["AWS_REGION"] || "us-east-1";
|
|
1008
|
+
const from = env[MAIL_FROM_ENV] || DEFAULT_MAIL_FROM;
|
|
1009
|
+
const fromArn = env[SES_FROM_ARN_ENV];
|
|
1010
|
+
return new SesMailer({ region, from, ...fromArn ? { fromArn } : {} });
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// src/idp/service.ts
|
|
1014
|
+
var SESSION_TTL_SECONDS = 24 * 60 * 60;
|
|
1015
|
+
var OTP_TTL_SECONDS = 10 * 60;
|
|
1016
|
+
var OTP_MAX_ATTEMPTS = 5;
|
|
1017
|
+
var FLEET_APPS = [
|
|
1018
|
+
"tenants",
|
|
1019
|
+
"identities",
|
|
1020
|
+
"todos",
|
|
1021
|
+
"skills",
|
|
1022
|
+
"conversations",
|
|
1023
|
+
"mementos",
|
|
1024
|
+
"hooks",
|
|
1025
|
+
"telephony",
|
|
1026
|
+
"infinity",
|
|
1027
|
+
"sandboxes",
|
|
1028
|
+
"domains",
|
|
1029
|
+
"accounts",
|
|
1030
|
+
"files",
|
|
1031
|
+
"sessions",
|
|
1032
|
+
"secrets",
|
|
1033
|
+
"projects",
|
|
1034
|
+
"knowledge"
|
|
1035
|
+
];
|
|
1036
|
+
var SELF_APP = "tenants";
|
|
1037
|
+
|
|
1038
|
+
class AuthError extends Error {
|
|
1039
|
+
status;
|
|
1040
|
+
code;
|
|
1041
|
+
details;
|
|
1042
|
+
constructor(status, message, code, details) {
|
|
1043
|
+
super(message);
|
|
1044
|
+
this.status = status;
|
|
1045
|
+
this.code = code;
|
|
1046
|
+
this.details = details;
|
|
1047
|
+
this.name = "AuthError";
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function roleScopes(app, role) {
|
|
1051
|
+
switch (role) {
|
|
1052
|
+
case "owner":
|
|
1053
|
+
case "admin":
|
|
1054
|
+
return [`${app}:*`];
|
|
1055
|
+
case "member":
|
|
1056
|
+
case "agent":
|
|
1057
|
+
case "service":
|
|
1058
|
+
return [`${app}:read`, `${app}:write`];
|
|
1059
|
+
case "viewer":
|
|
1060
|
+
return [`${app}:read`];
|
|
1061
|
+
default:
|
|
1062
|
+
return [`${app}:read`];
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
function generateOtp() {
|
|
1066
|
+
return String(randomBytes2(4).readUInt32BE(0) % 1e6).padStart(6, "0");
|
|
1067
|
+
}
|
|
1068
|
+
function newSessionToken() {
|
|
1069
|
+
return `hst_${randomBytes2(32).toString("base64url")}`;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
class AuthService {
|
|
1073
|
+
store;
|
|
1074
|
+
signingSecret;
|
|
1075
|
+
apiKeysTable;
|
|
1076
|
+
apiKeys;
|
|
1077
|
+
otpEcho;
|
|
1078
|
+
emailPolicy;
|
|
1079
|
+
mailer;
|
|
1080
|
+
confirmUrlBase;
|
|
1081
|
+
now;
|
|
1082
|
+
constructor(options) {
|
|
1083
|
+
this.store = options.store;
|
|
1084
|
+
this.signingSecret = options.signingSecret;
|
|
1085
|
+
this.apiKeysTable = options.apiKeysTable;
|
|
1086
|
+
this.apiKeys = options.apiKeys;
|
|
1087
|
+
this.otpEcho = options.otpEcho ?? false;
|
|
1088
|
+
this.emailPolicy = options.emailPolicy ?? { allowedDomains: null, requireConfirmation: false };
|
|
1089
|
+
this.mailer = options.mailer ?? new NoopMailer;
|
|
1090
|
+
this.confirmUrlBase = (options.confirmUrlBase ?? "https://tenants.hasna.xyz").replace(/\/+$/, "");
|
|
1091
|
+
this.now = options.nowMs ?? (() => Date.now());
|
|
1092
|
+
}
|
|
1093
|
+
assertEmailAllowed(email) {
|
|
1094
|
+
if (!isEmailDomainAllowed(email, this.emailPolicy)) {
|
|
1095
|
+
throw new AuthError(403, "Sign-up and login are restricted to Hasna email addresses (hasna.xyz and other hasna.<tld> domains).", "email_domain_not_allowed");
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async jwks() {
|
|
1099
|
+
return buildJwks(await this.store.listPublicJwks());
|
|
1100
|
+
}
|
|
1101
|
+
async signup(input) {
|
|
1102
|
+
const email = (input.email ?? "").trim().toLowerCase();
|
|
1103
|
+
if (!email || !email.includes("@"))
|
|
1104
|
+
throw new AuthError(400, "A valid email is required.", "invalid_email");
|
|
1105
|
+
this.assertEmailAllowed(email);
|
|
1106
|
+
const kind = input.kind === "agent" ? "agent" : "human";
|
|
1107
|
+
if (await this.store.getUserByEmail(email)) {
|
|
1108
|
+
throw new AuthError(409, "An account with that email already exists.", "email_taken");
|
|
1109
|
+
}
|
|
1110
|
+
let homeTenantId = ROOT_TENANT_ID;
|
|
1111
|
+
let createdNewOrg = false;
|
|
1112
|
+
if (input.org_name && input.org_name.trim()) {
|
|
1113
|
+
const slug = input.org_name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1114
|
+
const existing = slug ? await this.store.getTenantBySlug(slug) : null;
|
|
1115
|
+
if (existing) {
|
|
1116
|
+
homeTenantId = existing.id;
|
|
1117
|
+
} else {
|
|
1118
|
+
homeTenantId = (await this.store.createTenant({ slug: slug || `org-${newId().slice(0, 8)}`, name: input.org_name.trim(), kind: "org", parentId: ROOT_TENANT_ID })).id;
|
|
1119
|
+
createdNewOrg = true;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const role = createdNewOrg ? "owner" : "member";
|
|
1123
|
+
const passwordHash = input.password ? hashPassword(input.password) : null;
|
|
1124
|
+
const user = await this.store.createUser({
|
|
1125
|
+
kind,
|
|
1126
|
+
email,
|
|
1127
|
+
displayName: input.name ?? null,
|
|
1128
|
+
homeTenantId,
|
|
1129
|
+
authMethod: passwordHash ? "password" : "otp",
|
|
1130
|
+
passwordHash
|
|
1131
|
+
});
|
|
1132
|
+
await this.store.createMembership({ tenantId: homeTenantId, principalId: user.id, principalType: "user", role });
|
|
1133
|
+
const base = {
|
|
1134
|
+
user: this.principalSummary(user),
|
|
1135
|
+
tenant: { tenant_id: homeTenantId },
|
|
1136
|
+
memberships: [{ tenant_id: homeTenantId, role }]
|
|
1137
|
+
};
|
|
1138
|
+
if (this.emailPolicy.requireConfirmation) {
|
|
1139
|
+
const challenge2 = await this.startChallenge(email, "signup");
|
|
1140
|
+
return { ...base, ...challenge2, confirmation_required: true };
|
|
1141
|
+
}
|
|
1142
|
+
if (passwordHash) {
|
|
1143
|
+
const session = await this.issueSession(user, homeTenantId, input.ip ?? null, input.userAgent ?? null, "password");
|
|
1144
|
+
return { ...base, ...session };
|
|
1145
|
+
}
|
|
1146
|
+
const challenge = await this.startChallenge(email, "signup");
|
|
1147
|
+
return { ...base, ...challenge };
|
|
1148
|
+
}
|
|
1149
|
+
async login(input) {
|
|
1150
|
+
const email = (input.email ?? "").trim().toLowerCase();
|
|
1151
|
+
if (!email)
|
|
1152
|
+
throw new AuthError(400, "email is required.", "invalid_email");
|
|
1153
|
+
this.assertEmailAllowed(email);
|
|
1154
|
+
const user = await this.store.getUserByEmail(email);
|
|
1155
|
+
if (input.password) {
|
|
1156
|
+
if (!user || !user.password_hash || !verifyPassword(input.password, user.password_hash)) {
|
|
1157
|
+
throw new AuthError(401, "Invalid email or password.", "invalid_credentials");
|
|
1158
|
+
}
|
|
1159
|
+
if (this.emailPolicy.requireConfirmation && !user.email_verified_at) {
|
|
1160
|
+
const challenge2 = await this.startChallenge(email, "signup");
|
|
1161
|
+
throw new AuthError(403, "Confirm your email before signing in. We just re-sent your confirmation code.", "email_not_confirmed", challenge2);
|
|
1162
|
+
}
|
|
1163
|
+
const session = await this.issueSession(user, user.home_tenant_id ?? ROOT_TENANT_ID, input.ip ?? null, input.userAgent ?? null, "password");
|
|
1164
|
+
return session;
|
|
1165
|
+
}
|
|
1166
|
+
const challenge = await this.startChallenge(email, "login");
|
|
1167
|
+
return challenge;
|
|
1168
|
+
}
|
|
1169
|
+
async resend(input) {
|
|
1170
|
+
const email = (input.email ?? "").trim().toLowerCase();
|
|
1171
|
+
if (!email || !email.includes("@"))
|
|
1172
|
+
throw new AuthError(400, "A valid email is required.", "invalid_email");
|
|
1173
|
+
this.assertEmailAllowed(email);
|
|
1174
|
+
const user = await this.store.getUserByEmail(email);
|
|
1175
|
+
if (user && !user.email_verified_at) {
|
|
1176
|
+
return { ...await this.startChallenge(email, "signup"), confirmation_required: true };
|
|
1177
|
+
}
|
|
1178
|
+
return { challenge: true, purpose: "signup", expires_in: OTP_TTL_SECONDS };
|
|
1179
|
+
}
|
|
1180
|
+
async verify(input) {
|
|
1181
|
+
const email = (input.email ?? "").trim().toLowerCase();
|
|
1182
|
+
const code = (input.code ?? "").trim();
|
|
1183
|
+
if (!email || !code)
|
|
1184
|
+
throw new AuthError(400, "email and code are required.", "invalid_request");
|
|
1185
|
+
for (const purpose of ["login", "signup"]) {
|
|
1186
|
+
const challenge = await this.store.findActiveChallenge(email, purpose);
|
|
1187
|
+
if (!challenge)
|
|
1188
|
+
continue;
|
|
1189
|
+
if (challenge.attempts >= OTP_MAX_ATTEMPTS)
|
|
1190
|
+
throw new AuthError(429, "Too many attempts; request a new code.", "too_many_attempts");
|
|
1191
|
+
if (sha256(code) !== challenge.code_hash) {
|
|
1192
|
+
await this.store.bumpChallengeAttempts(challenge.id);
|
|
1193
|
+
throw new AuthError(401, "Invalid code.", "invalid_code");
|
|
1194
|
+
}
|
|
1195
|
+
await this.store.consumeChallenge(challenge.id);
|
|
1196
|
+
const user = await this.store.getUserByEmail(email);
|
|
1197
|
+
if (!user)
|
|
1198
|
+
throw new AuthError(404, "No account for that email.", "no_user");
|
|
1199
|
+
if (!user.email_verified_at)
|
|
1200
|
+
await this.store.markEmailVerified(user.id);
|
|
1201
|
+
return this.issueSession(user, user.home_tenant_id ?? ROOT_TENANT_ID, input.ip ?? null, input.userAgent ?? null, "otp");
|
|
1202
|
+
}
|
|
1203
|
+
throw new AuthError(401, "No active challenge; request a new code.", "no_challenge");
|
|
1204
|
+
}
|
|
1205
|
+
async token(input) {
|
|
1206
|
+
const { user } = await this.resolveSession(input.sessionToken);
|
|
1207
|
+
const app = (input.app ?? "").trim();
|
|
1208
|
+
if (!app)
|
|
1209
|
+
throw new AuthError(400, "app is required.", "invalid_request");
|
|
1210
|
+
if (!FLEET_APPS.includes(app))
|
|
1211
|
+
throw new AuthError(400, `Unknown app: ${app}`, "unknown_app");
|
|
1212
|
+
const memberships = await this.store.listMembershipsForPrincipal(user.id, "user");
|
|
1213
|
+
let membership;
|
|
1214
|
+
if (input.tenant_id) {
|
|
1215
|
+
membership = memberships.find((m) => m.tenant_id === input.tenant_id);
|
|
1216
|
+
if (!membership)
|
|
1217
|
+
throw new AuthError(403, "No membership for the requested tenant.", "no_membership");
|
|
1218
|
+
} else {
|
|
1219
|
+
const home = user.home_tenant_id ?? ROOT_TENANT_ID;
|
|
1220
|
+
membership = memberships.find((m) => m.tenant_id === home) ?? memberships[0];
|
|
1221
|
+
}
|
|
1222
|
+
if (!membership)
|
|
1223
|
+
throw new AuthError(403, "No tenant membership for this principal.", "no_membership");
|
|
1224
|
+
const granted = roleScopes(app, membership.role);
|
|
1225
|
+
let scope;
|
|
1226
|
+
if (input.scopes && input.scopes.length > 0) {
|
|
1227
|
+
const overreach = input.scopes.filter((s) => !hasScope(granted, s));
|
|
1228
|
+
if (overreach.length > 0) {
|
|
1229
|
+
throw new AuthError(403, `Requested scopes exceed this membership's grants: ${overreach.join(", ")}`, "insufficient_scope");
|
|
1230
|
+
}
|
|
1231
|
+
scope = input.scopes;
|
|
1232
|
+
} else {
|
|
1233
|
+
scope = granted;
|
|
1234
|
+
}
|
|
1235
|
+
const jti = newId();
|
|
1236
|
+
const signingKey = await this.store.getSigningKeyForMinting();
|
|
1237
|
+
const { token, claims } = signAccessToken(signingKey, {
|
|
1238
|
+
aud: app,
|
|
1239
|
+
sub: user.id,
|
|
1240
|
+
tid: membership.tenant_id,
|
|
1241
|
+
pt: "user",
|
|
1242
|
+
scope,
|
|
1243
|
+
...input.ttlSeconds ? { ttlSeconds: input.ttlSeconds } : {},
|
|
1244
|
+
jti,
|
|
1245
|
+
nowMs: this.now()
|
|
1246
|
+
});
|
|
1247
|
+
const response = {
|
|
1248
|
+
access_token: token,
|
|
1249
|
+
token_type: "Bearer",
|
|
1250
|
+
alg: "EdDSA",
|
|
1251
|
+
kid: signingKey.kid,
|
|
1252
|
+
aud: app,
|
|
1253
|
+
tid: membership.tenant_id,
|
|
1254
|
+
uid: user.id,
|
|
1255
|
+
pt: "user",
|
|
1256
|
+
scope,
|
|
1257
|
+
expires_in: claims.exp - claims.iat
|
|
1258
|
+
};
|
|
1259
|
+
if (app === SELF_APP && this.apiKeys) {
|
|
1260
|
+
const minted = mintApiKey({ app: SELF_APP, scopes: scope, signingSecret: this.signingSecret, ...user.email ? { agent: user.email } : {} });
|
|
1261
|
+
await this.apiKeys.insertMinted(minted, user.email ?? undefined);
|
|
1262
|
+
await this.store.recordApiKeyBinding(this.apiKeysTable, {
|
|
1263
|
+
kid: minted.kid,
|
|
1264
|
+
tenantId: membership.tenant_id,
|
|
1265
|
+
userId: user.id,
|
|
1266
|
+
principalType: "user"
|
|
1267
|
+
});
|
|
1268
|
+
response["api_key"] = minted.token;
|
|
1269
|
+
response["api_key_kid"] = minted.kid;
|
|
1270
|
+
response["api_key_expires_at"] = minted.claims.exp ? new Date(minted.claims.exp * 1000).toISOString() : null;
|
|
1271
|
+
}
|
|
1272
|
+
return response;
|
|
1273
|
+
}
|
|
1274
|
+
async whoami(sessionToken) {
|
|
1275
|
+
const { user, memberships } = await this.resolveSession(sessionToken);
|
|
1276
|
+
return {
|
|
1277
|
+
principal: this.principalSummary(user),
|
|
1278
|
+
tenants: memberships.map((m) => ({ tenant_id: m.tenant_id, role: m.role })),
|
|
1279
|
+
apps: FLEET_APPS
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
async introspect(kid) {
|
|
1283
|
+
const binding = await this.store.lookupApiKeyBinding(this.apiKeysTable, kid);
|
|
1284
|
+
if (!binding)
|
|
1285
|
+
return { active: false, kid };
|
|
1286
|
+
return {
|
|
1287
|
+
active: true,
|
|
1288
|
+
kid,
|
|
1289
|
+
tenant_id: binding.tenant_id,
|
|
1290
|
+
user_id: binding.user_id,
|
|
1291
|
+
principal_type: binding.principal_type
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
principalSummary(user) {
|
|
1295
|
+
return { user_id: user.id, kind: user.kind, email: user.email, display_name: user.display_name };
|
|
1296
|
+
}
|
|
1297
|
+
async startChallenge(email, purpose) {
|
|
1298
|
+
const code = generateOtp();
|
|
1299
|
+
await this.store.createChallenge({
|
|
1300
|
+
email,
|
|
1301
|
+
codeHash: sha256(code),
|
|
1302
|
+
purpose,
|
|
1303
|
+
expiresAt: new Date(this.now() + OTP_TTL_SECONDS * 1000)
|
|
1304
|
+
});
|
|
1305
|
+
const out = { challenge: true, purpose, expires_in: OTP_TTL_SECONDS };
|
|
1306
|
+
const link = `${this.confirmUrlBase}/v1/auth/confirm?email=${encodeURIComponent(email)}&code=${code}`;
|
|
1307
|
+
try {
|
|
1308
|
+
const sent = await this.mailer.sendConfirmation({
|
|
1309
|
+
to: email,
|
|
1310
|
+
code,
|
|
1311
|
+
link,
|
|
1312
|
+
expiresMinutes: Math.round(OTP_TTL_SECONDS / 60),
|
|
1313
|
+
purpose
|
|
1314
|
+
});
|
|
1315
|
+
if (sent.messageId)
|
|
1316
|
+
out["email_message_id"] = sent.messageId;
|
|
1317
|
+
out["email_sent"] = !sent.skipped;
|
|
1318
|
+
if (sent.skipped)
|
|
1319
|
+
out["email_skipped_reason"] = sent.reason ?? "disabled";
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
out["email_sent"] = false;
|
|
1322
|
+
out["email_error"] = error instanceof Error ? error.message : String(error);
|
|
1323
|
+
}
|
|
1324
|
+
if (this.otpEcho)
|
|
1325
|
+
out["dev_code"] = code;
|
|
1326
|
+
return out;
|
|
1327
|
+
}
|
|
1328
|
+
async issueSession(user, tenantId, ip, userAgent, method) {
|
|
1329
|
+
const token = newSessionToken();
|
|
1330
|
+
await this.store.createSession({
|
|
1331
|
+
userId: user.id,
|
|
1332
|
+
tenantId,
|
|
1333
|
+
tokenHash: sha256(token),
|
|
1334
|
+
method,
|
|
1335
|
+
expiresAt: new Date(this.now() + SESSION_TTL_SECONDS * 1000),
|
|
1336
|
+
ip,
|
|
1337
|
+
userAgent
|
|
1338
|
+
});
|
|
1339
|
+
const memberships = await this.store.listMembershipsForPrincipal(user.id, "user");
|
|
1340
|
+
return {
|
|
1341
|
+
session: token,
|
|
1342
|
+
session_expires_in: SESSION_TTL_SECONDS,
|
|
1343
|
+
principal: this.principalSummary(user),
|
|
1344
|
+
tenants: memberships.map((m) => ({ tenant_id: m.tenant_id, role: m.role })),
|
|
1345
|
+
apps: FLEET_APPS
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
async resolveSession(sessionToken) {
|
|
1349
|
+
if (!sessionToken)
|
|
1350
|
+
throw new AuthError(401, "Missing session token.", "missing_session");
|
|
1351
|
+
const row = await this.store.getSessionByTokenHash(sha256(sessionToken));
|
|
1352
|
+
if (!row)
|
|
1353
|
+
throw new AuthError(401, "Invalid session.", "invalid_session");
|
|
1354
|
+
if (row.revoked_at)
|
|
1355
|
+
throw new AuthError(401, "Session revoked.", "revoked_session");
|
|
1356
|
+
if (row.expires_at && new Date(row.expires_at).getTime() < this.now())
|
|
1357
|
+
throw new AuthError(401, "Session expired.", "expired_session");
|
|
1358
|
+
const user = row.user_id ? await this.store.getUserById(row.user_id) : null;
|
|
1359
|
+
if (!user)
|
|
1360
|
+
throw new AuthError(401, "Session principal not found.", "invalid_session");
|
|
1361
|
+
const memberships = await this.store.listMembershipsForPrincipal(user.id, "user");
|
|
1362
|
+
return { user, memberships };
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
// src/idp/store.ts
|
|
1366
|
+
import { generateKeyPairSync } from "crypto";
|
|
1367
|
+
var JWT_SIGNING_KEY_ENV = "HASNA_TENANTS_JWT_SIGNING_KEY";
|
|
1368
|
+
var JWT_KID_ENV = "HASNA_TENANTS_JWT_KID";
|
|
1369
|
+
function asJwk(value) {
|
|
1370
|
+
return typeof value === "string" ? JSON.parse(value) : value;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
class IdpStore {
|
|
1374
|
+
client;
|
|
1375
|
+
constructor(client) {
|
|
1376
|
+
this.client = client;
|
|
1377
|
+
}
|
|
1378
|
+
loadEnvSigningKey(env = process.env) {
|
|
1379
|
+
const raw = env[JWT_SIGNING_KEY_ENV]?.trim();
|
|
1380
|
+
if (!raw)
|
|
1381
|
+
return null;
|
|
1382
|
+
let jwk;
|
|
1383
|
+
try {
|
|
1384
|
+
jwk = JSON.parse(raw);
|
|
1385
|
+
} catch {
|
|
1386
|
+
try {
|
|
1387
|
+
jwk = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
1388
|
+
} catch {
|
|
1389
|
+
throw new Error(`${JWT_SIGNING_KEY_ENV} must be a private Ed25519 JWK (JSON or base64url JSON).`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const kid = env[JWT_KID_ENV]?.trim() || String(jwk["kid"] ?? "env");
|
|
1393
|
+
return signingKeyFromPrivateJwk(kid, jwk);
|
|
1394
|
+
}
|
|
1395
|
+
async getOrCreateActiveDbKey() {
|
|
1396
|
+
const existing = await this.client.get(`SELECT kid, alg, public_jwk, private_jwk, status FROM ${JWT_SIGNING_KEYS_TABLE}
|
|
1397
|
+
WHERE status = 'active' ORDER BY created_at ASC LIMIT 1`);
|
|
1398
|
+
if (existing) {
|
|
1399
|
+
return signingKeyFromPrivateJwk(existing.kid, asJwk(existing.private_jwk));
|
|
1400
|
+
}
|
|
1401
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
1402
|
+
const kid = `ed25519_${newId().replace(/-/g, "").slice(0, 16)}`;
|
|
1403
|
+
const publicJwk = publicKey.export({ format: "jwk" });
|
|
1404
|
+
const privateJwk = privateKey.export({ format: "jwk" });
|
|
1405
|
+
await this.client.execute(`INSERT INTO ${JWT_SIGNING_KEYS_TABLE} (kid, alg, public_jwk, private_jwk, status)
|
|
1406
|
+
VALUES ($1, 'EdDSA', $2::jsonb, $3::jsonb, 'active')
|
|
1407
|
+
ON CONFLICT (kid) DO NOTHING`, [kid, JSON.stringify(publicJwk), JSON.stringify(privateJwk)]);
|
|
1408
|
+
const active = await this.client.get(`SELECT kid, alg, public_jwk, private_jwk, status FROM ${JWT_SIGNING_KEYS_TABLE}
|
|
1409
|
+
WHERE status = 'active' ORDER BY created_at ASC LIMIT 1`);
|
|
1410
|
+
if (!active)
|
|
1411
|
+
throw new Error("Failed to persist an active signing key.");
|
|
1412
|
+
return signingKeyFromPrivateJwk(active.kid, asJwk(active.private_jwk));
|
|
1413
|
+
}
|
|
1414
|
+
async getSigningKeyForMinting() {
|
|
1415
|
+
return this.loadEnvSigningKey() ?? await this.getOrCreateActiveDbKey();
|
|
1416
|
+
}
|
|
1417
|
+
async listPublicJwks() {
|
|
1418
|
+
const keys = [];
|
|
1419
|
+
const env = this.loadEnvSigningKey();
|
|
1420
|
+
if (env)
|
|
1421
|
+
keys.push(env.publicJwk);
|
|
1422
|
+
const rows = await this.client.many(`SELECT kid, alg, public_jwk, private_jwk, status FROM ${JWT_SIGNING_KEYS_TABLE}
|
|
1423
|
+
WHERE status = 'active' ORDER BY created_at ASC`);
|
|
1424
|
+
for (const row of rows) {
|
|
1425
|
+
const jwk = asJwk(row.public_jwk);
|
|
1426
|
+
keys.push({ kty: "OKP", crv: "Ed25519", x: String(jwk.x), kid: row.kid, use: "sig", alg: "EdDSA" });
|
|
1427
|
+
}
|
|
1428
|
+
return keys;
|
|
1429
|
+
}
|
|
1430
|
+
async seedTenants() {
|
|
1431
|
+
for (const t of SEED_TENANTS) {
|
|
1432
|
+
await this.client.execute(`INSERT INTO ${TENANTS_TABLE} (id, slug, name, kind, parent_id)
|
|
1433
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
1434
|
+
ON CONFLICT (id) DO NOTHING`, [t.id, t.slug, t.name, t.kind, t.parentId]);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
async getTenantById(id) {
|
|
1438
|
+
return this.client.get(`SELECT id, slug, name, kind, parent_id, status, identity_id FROM ${TENANTS_TABLE} WHERE id = $1`, [id]);
|
|
1439
|
+
}
|
|
1440
|
+
async getTenantBySlug(slug) {
|
|
1441
|
+
return this.client.get(`SELECT id, slug, name, kind, parent_id, status, identity_id FROM ${TENANTS_TABLE} WHERE slug = $1`, [slug]);
|
|
1442
|
+
}
|
|
1443
|
+
async createTenant(input) {
|
|
1444
|
+
const id = newId();
|
|
1445
|
+
await this.client.execute(`INSERT INTO ${TENANTS_TABLE} (id, slug, name, kind, parent_id)
|
|
1446
|
+
VALUES ($1, $2, $3, $4, $5)`, [id, input.slug, input.name, input.kind ?? "org", input.parentId ?? ROOT_TENANT_ID]);
|
|
1447
|
+
const row = await this.getTenantById(id);
|
|
1448
|
+
if (!row)
|
|
1449
|
+
throw new Error("Failed to create tenant.");
|
|
1450
|
+
return row;
|
|
1451
|
+
}
|
|
1452
|
+
async getUserByEmail(email) {
|
|
1453
|
+
return this.client.get(`SELECT id, kind, email, display_name, identity_id, home_tenant_id, auth_method, password_hash, status, email_verified_at
|
|
1454
|
+
FROM ${USERS_TABLE} WHERE lower(email) = lower($1)`, [email]);
|
|
1455
|
+
}
|
|
1456
|
+
async getUserById(id) {
|
|
1457
|
+
return this.client.get(`SELECT id, kind, email, display_name, identity_id, home_tenant_id, auth_method, password_hash, status, email_verified_at
|
|
1458
|
+
FROM ${USERS_TABLE} WHERE id = $1`, [id]);
|
|
1459
|
+
}
|
|
1460
|
+
async markEmailVerified(userId) {
|
|
1461
|
+
await this.client.execute(`UPDATE ${USERS_TABLE} SET email_verified_at = now(), updated_at = now()
|
|
1462
|
+
WHERE id = $1 AND email_verified_at IS NULL`, [userId]);
|
|
1463
|
+
}
|
|
1464
|
+
async createUser(input) {
|
|
1465
|
+
const id = newId();
|
|
1466
|
+
await this.client.execute(`INSERT INTO ${USERS_TABLE} (id, kind, email, display_name, identity_id, home_tenant_id, auth_method, password_hash)
|
|
1467
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
|
|
1468
|
+
id,
|
|
1469
|
+
input.kind,
|
|
1470
|
+
input.email ?? null,
|
|
1471
|
+
input.displayName ?? null,
|
|
1472
|
+
input.identityId ?? null,
|
|
1473
|
+
input.homeTenantId,
|
|
1474
|
+
input.authMethod ?? null,
|
|
1475
|
+
input.passwordHash ?? null
|
|
1476
|
+
]);
|
|
1477
|
+
const row = await this.getUserById(id);
|
|
1478
|
+
if (!row)
|
|
1479
|
+
throw new Error("Failed to create user.");
|
|
1480
|
+
return row;
|
|
1481
|
+
}
|
|
1482
|
+
async setUserPassword(id, passwordHash) {
|
|
1483
|
+
await this.client.execute(`UPDATE ${USERS_TABLE} SET password_hash = $2, auth_method = 'password', updated_at = now() WHERE id = $1`, [id, passwordHash]);
|
|
1484
|
+
}
|
|
1485
|
+
async createMembership(input) {
|
|
1486
|
+
await this.client.execute(`INSERT INTO ${MEMBERSHIPS_TABLE} (tenant_id, principal_id, principal_type, role, scopes)
|
|
1487
|
+
VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
1488
|
+
ON CONFLICT (tenant_id, principal_id, principal_type) DO NOTHING`, [input.tenantId, input.principalId, input.principalType, input.role, JSON.stringify(input.scopes ?? [])]);
|
|
1489
|
+
}
|
|
1490
|
+
async listMembershipsForPrincipal(principalId, principalType) {
|
|
1491
|
+
return this.client.many(`SELECT id, tenant_id, principal_id, principal_type, role, scopes, status
|
|
1492
|
+
FROM ${MEMBERSHIPS_TABLE} WHERE principal_id = $1 AND principal_type = $2 AND status = 'active'`, [principalId, principalType]);
|
|
1493
|
+
}
|
|
1494
|
+
async createServicePrincipal(input) {
|
|
1495
|
+
const id = input.id ?? newId();
|
|
1496
|
+
await this.client.execute(`INSERT INTO ${SERVICE_PRINCIPALS_TABLE} (id, tenant_id, kind, display_name, identity_id)
|
|
1497
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
1498
|
+
ON CONFLICT (id) DO NOTHING`, [id, input.tenantId, input.kind ?? "machine", input.displayName ?? null, input.identityId ?? null]);
|
|
1499
|
+
return id;
|
|
1500
|
+
}
|
|
1501
|
+
async createSession(input) {
|
|
1502
|
+
const id = newId();
|
|
1503
|
+
await this.client.execute(`INSERT INTO ${SESSIONS_TABLE} (id, user_id, tenant_id, token_hash, method, expires_at, ip, user_agent)
|
|
1504
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
|
|
1505
|
+
id,
|
|
1506
|
+
input.userId,
|
|
1507
|
+
input.tenantId,
|
|
1508
|
+
input.tokenHash,
|
|
1509
|
+
input.method ?? "password",
|
|
1510
|
+
input.expiresAt.toISOString(),
|
|
1511
|
+
input.ip ?? null,
|
|
1512
|
+
input.userAgent ?? null
|
|
1513
|
+
]);
|
|
1514
|
+
return id;
|
|
1515
|
+
}
|
|
1516
|
+
async getSessionByTokenHash(tokenHash) {
|
|
1517
|
+
return this.client.get(`SELECT id, user_id, tenant_id, token_hash, method, issued_at, expires_at, revoked_at
|
|
1518
|
+
FROM ${SESSIONS_TABLE} WHERE token_hash = $1`, [tokenHash]);
|
|
1519
|
+
}
|
|
1520
|
+
async revokeSession(tokenHash) {
|
|
1521
|
+
await this.client.execute(`UPDATE ${SESSIONS_TABLE} SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, [tokenHash]);
|
|
1522
|
+
}
|
|
1523
|
+
async createChallenge(input) {
|
|
1524
|
+
const id = newId();
|
|
1525
|
+
await this.client.execute(`INSERT INTO ${AUTH_CHALLENGES_TABLE} (id, email, code_hash, purpose, expires_at)
|
|
1526
|
+
VALUES ($1, $2, $3, $4, $5)`, [id, input.email, input.codeHash, input.purpose, input.expiresAt.toISOString()]);
|
|
1527
|
+
return id;
|
|
1528
|
+
}
|
|
1529
|
+
async findActiveChallenge(email, purpose) {
|
|
1530
|
+
return this.client.get(`SELECT id, code_hash, attempts FROM ${AUTH_CHALLENGES_TABLE}
|
|
1531
|
+
WHERE lower(email) = lower($1) AND purpose = $2 AND consumed_at IS NULL AND expires_at > now()
|
|
1532
|
+
ORDER BY created_at DESC LIMIT 1`, [email, purpose]);
|
|
1533
|
+
}
|
|
1534
|
+
async consumeChallenge(id) {
|
|
1535
|
+
await this.client.execute(`UPDATE ${AUTH_CHALLENGES_TABLE} SET consumed_at = now() WHERE id = $1`, [id]);
|
|
1536
|
+
}
|
|
1537
|
+
async bumpChallengeAttempts(id) {
|
|
1538
|
+
await this.client.execute(`UPDATE ${AUTH_CHALLENGES_TABLE} SET attempts = attempts + 1 WHERE id = $1`, [id]);
|
|
1539
|
+
}
|
|
1540
|
+
async recordApiKeyBinding(apiKeysTable, input) {
|
|
1541
|
+
await this.client.execute(`UPDATE ${apiKeysTable} SET tenant_id = $2, user_id = $3, principal_type = $4 WHERE kid = $1`, [input.kid, input.tenantId, input.userId, input.principalType]);
|
|
1542
|
+
}
|
|
1543
|
+
async lookupApiKeyBinding(apiKeysTable, kid) {
|
|
1544
|
+
return this.client.get(`SELECT tenant_id, user_id, principal_type FROM ${apiKeysTable} WHERE kid = $1`, [kid]);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// src/idp/backfill.ts
|
|
1548
|
+
async function seedAndBackfill(client, apiKeysTable) {
|
|
1549
|
+
const store = new IdpStore(client);
|
|
1550
|
+
await store.seedTenants();
|
|
1551
|
+
await store.getOrCreateActiveDbKey();
|
|
1552
|
+
await client.execute(`UPDATE ${apiKeysTable} SET tenant_id = $1 WHERE tenant_id IS NULL`, [ROOT_TENANT_ID]);
|
|
1553
|
+
const counted = await client.get(`SELECT count(*)::text AS n FROM ${apiKeysTable} WHERE tenant_id = $1`, [ROOT_TENANT_ID]);
|
|
1554
|
+
const grandfathered = await client.get(`WITH upd AS (
|
|
1555
|
+
UPDATE ${USERS_TABLE} SET email_verified_at = now(), updated_at = now()
|
|
1556
|
+
WHERE email_verified_at IS NULL AND auth_method = 'enrollment'
|
|
1557
|
+
RETURNING 1
|
|
1558
|
+
) SELECT count(*)::text AS n FROM upd`);
|
|
1559
|
+
return {
|
|
1560
|
+
tenantsSeeded: 4,
|
|
1561
|
+
apiKeysBackfilled: counted ? Number(counted.n) : 0,
|
|
1562
|
+
usersGrandfatheredVerified: grandfathered ? Number(grandfathered.n) : 0
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
// src/server/openapi.ts
|
|
1566
|
+
function buildOpenApiDocument(version) {
|
|
1567
|
+
return {
|
|
1568
|
+
openapi: "3.1.0",
|
|
1569
|
+
info: {
|
|
1570
|
+
title: "Tenants API",
|
|
1571
|
+
version,
|
|
1572
|
+
description: "Hasna fleet tenant auth / IdP: tenants, users, memberships, service principals, sessions, and asymmetric (EdDSA) fleet token issuance with a published JWKS. Cloud mode is PURE REMOTE: all reads and writes hit the shared cloud Postgres directly."
|
|
1573
|
+
},
|
|
1574
|
+
servers: [{ url: "/" }],
|
|
1575
|
+
components: {
|
|
1576
|
+
securitySchemes: {
|
|
1577
|
+
ApiKeyAuth: { type: "apiKey", in: "header", name: "x-api-key" }
|
|
1578
|
+
},
|
|
1579
|
+
schemas: {
|
|
1580
|
+
ErrorResponse: {
|
|
1581
|
+
type: "object",
|
|
1582
|
+
properties: { error: { type: "string" }, reason: { type: "string" } },
|
|
1583
|
+
required: ["error"]
|
|
1584
|
+
},
|
|
1585
|
+
SignupInput: {
|
|
1586
|
+
type: "object",
|
|
1587
|
+
additionalProperties: true,
|
|
1588
|
+
properties: {
|
|
1589
|
+
email: { type: "string" },
|
|
1590
|
+
name: { type: "string" },
|
|
1591
|
+
kind: { type: "string", enum: ["human", "agent"] },
|
|
1592
|
+
org_name: { type: "string" },
|
|
1593
|
+
password: { type: "string" }
|
|
1594
|
+
},
|
|
1595
|
+
required: ["email"]
|
|
1596
|
+
},
|
|
1597
|
+
LoginInput: {
|
|
1598
|
+
type: "object",
|
|
1599
|
+
additionalProperties: true,
|
|
1600
|
+
properties: { email: { type: "string" }, password: { type: "string" } },
|
|
1601
|
+
required: ["email"]
|
|
1602
|
+
},
|
|
1603
|
+
VerifyInput: {
|
|
1604
|
+
type: "object",
|
|
1605
|
+
properties: { email: { type: "string" }, code: { type: "string" } },
|
|
1606
|
+
required: ["email", "code"]
|
|
1607
|
+
},
|
|
1608
|
+
ResendInput: {
|
|
1609
|
+
type: "object",
|
|
1610
|
+
properties: { email: { type: "string" } },
|
|
1611
|
+
required: ["email"]
|
|
1612
|
+
},
|
|
1613
|
+
TokenInput: {
|
|
1614
|
+
type: "object",
|
|
1615
|
+
additionalProperties: true,
|
|
1616
|
+
properties: {
|
|
1617
|
+
app: { type: "string" },
|
|
1618
|
+
scopes: { type: "array", items: { type: "string" } },
|
|
1619
|
+
tenant_id: { type: "string" },
|
|
1620
|
+
ttlSeconds: { type: "integer" }
|
|
1621
|
+
},
|
|
1622
|
+
required: ["app"]
|
|
1623
|
+
},
|
|
1624
|
+
AuthResponse: {
|
|
1625
|
+
type: "object",
|
|
1626
|
+
additionalProperties: true,
|
|
1627
|
+
properties: {
|
|
1628
|
+
session: { type: "string" },
|
|
1629
|
+
session_expires_in: { type: "integer" },
|
|
1630
|
+
challenge: { type: "boolean" },
|
|
1631
|
+
purpose: { type: "string" },
|
|
1632
|
+
expires_in: { type: "integer" },
|
|
1633
|
+
confirmation_required: { type: "boolean" },
|
|
1634
|
+
email_sent: { type: "boolean" },
|
|
1635
|
+
email_message_id: { type: "string" },
|
|
1636
|
+
principal: { type: "object", additionalProperties: true },
|
|
1637
|
+
tenants: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1638
|
+
apps: { type: "array", items: { type: "string" } }
|
|
1639
|
+
}
|
|
1640
|
+
},
|
|
1641
|
+
TokenResponse: {
|
|
1642
|
+
type: "object",
|
|
1643
|
+
additionalProperties: true,
|
|
1644
|
+
properties: {
|
|
1645
|
+
access_token: { type: "string" },
|
|
1646
|
+
token_type: { type: "string" },
|
|
1647
|
+
alg: { type: "string" },
|
|
1648
|
+
kid: { type: "string" },
|
|
1649
|
+
aud: { type: "string" },
|
|
1650
|
+
tid: { type: "string" },
|
|
1651
|
+
uid: { type: "string" },
|
|
1652
|
+
pt: { type: "string" },
|
|
1653
|
+
scope: { type: "array", items: { type: "string" } },
|
|
1654
|
+
expires_in: { type: "integer" },
|
|
1655
|
+
api_key: { type: "string" }
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
WhoamiResponse: {
|
|
1659
|
+
type: "object",
|
|
1660
|
+
additionalProperties: true,
|
|
1661
|
+
properties: {
|
|
1662
|
+
principal: { type: "object", additionalProperties: true },
|
|
1663
|
+
tenants: { type: "array", items: { type: "object", additionalProperties: true } },
|
|
1664
|
+
apps: { type: "array", items: { type: "string" } }
|
|
1665
|
+
}
|
|
1666
|
+
},
|
|
1667
|
+
IntrospectResponse: {
|
|
1668
|
+
type: "object",
|
|
1669
|
+
additionalProperties: true,
|
|
1670
|
+
properties: {
|
|
1671
|
+
active: { type: "boolean" },
|
|
1672
|
+
kid: { type: "string" },
|
|
1673
|
+
tenant_id: { type: "string" },
|
|
1674
|
+
user_id: { type: "string" },
|
|
1675
|
+
principal_type: { type: "string" }
|
|
1676
|
+
}
|
|
1677
|
+
},
|
|
1678
|
+
Jwks: {
|
|
1679
|
+
type: "object",
|
|
1680
|
+
properties: { keys: { type: "array", items: { type: "object", additionalProperties: true } } },
|
|
1681
|
+
required: ["keys"]
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
security: [{ ApiKeyAuth: [] }],
|
|
1686
|
+
paths: {
|
|
1687
|
+
"/v1/.well-known/jwks.json": {
|
|
1688
|
+
get: {
|
|
1689
|
+
operationId: "getJwks",
|
|
1690
|
+
summary: "Public JWKS (EdDSA) for verifying tenants-issued fleet tokens",
|
|
1691
|
+
security: [],
|
|
1692
|
+
responses: {
|
|
1693
|
+
"200": { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/Jwks" } } } }
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
},
|
|
1697
|
+
"/v1/auth/signup": {
|
|
1698
|
+
post: {
|
|
1699
|
+
operationId: "signup",
|
|
1700
|
+
summary: "Create a tenant/user (hasna emails only) and send an email confirmation code",
|
|
1701
|
+
security: [],
|
|
1702
|
+
requestBody: jsonBody("SignupInput"),
|
|
1703
|
+
responses: jsonResponse("AuthResponse", "201")
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
"/v1/auth/login": {
|
|
1707
|
+
post: {
|
|
1708
|
+
operationId: "login",
|
|
1709
|
+
summary: "Log in with password (session) or request an OTP challenge",
|
|
1710
|
+
security: [],
|
|
1711
|
+
requestBody: jsonBody("LoginInput"),
|
|
1712
|
+
responses: jsonResponse("AuthResponse")
|
|
1713
|
+
}
|
|
1714
|
+
},
|
|
1715
|
+
"/v1/auth/verify": {
|
|
1716
|
+
post: {
|
|
1717
|
+
operationId: "verifyOtp",
|
|
1718
|
+
summary: "Verify an OTP code and open a session",
|
|
1719
|
+
security: [],
|
|
1720
|
+
requestBody: jsonBody("VerifyInput"),
|
|
1721
|
+
responses: jsonResponse("AuthResponse")
|
|
1722
|
+
}
|
|
1723
|
+
},
|
|
1724
|
+
"/v1/auth/confirm": {
|
|
1725
|
+
get: {
|
|
1726
|
+
operationId: "confirm",
|
|
1727
|
+
summary: "Confirm an email via the one-click link (email + code) and open a session",
|
|
1728
|
+
security: [],
|
|
1729
|
+
parameters: [
|
|
1730
|
+
{ name: "email", in: "query", required: true, schema: { type: "string" } },
|
|
1731
|
+
{ name: "code", in: "query", required: true, schema: { type: "string" } }
|
|
1732
|
+
],
|
|
1733
|
+
responses: jsonResponse("AuthResponse")
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
"/v1/auth/resend": {
|
|
1737
|
+
post: {
|
|
1738
|
+
operationId: "resendConfirmation",
|
|
1739
|
+
summary: "Re-send the email confirmation code for an unconfirmed account",
|
|
1740
|
+
security: [],
|
|
1741
|
+
requestBody: jsonBody("ResendInput"),
|
|
1742
|
+
responses: jsonResponse("AuthResponse")
|
|
1743
|
+
}
|
|
1744
|
+
},
|
|
1745
|
+
"/v1/auth/token": {
|
|
1746
|
+
post: {
|
|
1747
|
+
operationId: "issueToken",
|
|
1748
|
+
summary: "Mint a per-app fleet access token from a session (Bearer session)",
|
|
1749
|
+
requestBody: jsonBody("TokenInput"),
|
|
1750
|
+
responses: jsonResponse("TokenResponse")
|
|
1751
|
+
}
|
|
1752
|
+
},
|
|
1753
|
+
"/v1/auth/whoami": {
|
|
1754
|
+
get: {
|
|
1755
|
+
operationId: "whoami",
|
|
1756
|
+
summary: "Resolve the session principal + tenant memberships",
|
|
1757
|
+
responses: jsonResponse("WhoamiResponse")
|
|
1758
|
+
}
|
|
1759
|
+
},
|
|
1760
|
+
"/v1/introspect": {
|
|
1761
|
+
get: {
|
|
1762
|
+
operationId: "introspect",
|
|
1763
|
+
summary: "Introspect an issued key by kid (tenant/user binding + status)",
|
|
1764
|
+
parameters: [{ name: "kid", in: "query", required: true, schema: { type: "string" } }],
|
|
1765
|
+
responses: jsonResponse("IntrospectResponse")
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
function jsonBody(schema) {
|
|
1772
|
+
return {
|
|
1773
|
+
required: true,
|
|
1774
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${schema}` } } }
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
function jsonResponse(schema, status = "200") {
|
|
1778
|
+
return {
|
|
1779
|
+
[status]: {
|
|
1780
|
+
description: "OK",
|
|
1781
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${schema}` } } }
|
|
1782
|
+
},
|
|
1783
|
+
"400": errorResponse(),
|
|
1784
|
+
"401": errorResponse(),
|
|
1785
|
+
"403": errorResponse(),
|
|
1786
|
+
"404": errorResponse()
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
function errorResponse() {
|
|
1790
|
+
return {
|
|
1791
|
+
description: "Error",
|
|
1792
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
// src/server/serve.ts
|
|
1796
|
+
import { ApiKeyStore, extractToken, hasAllScopes, verifyApiKey } from "@hasna/contracts/auth";
|
|
1797
|
+
|
|
1798
|
+
// src/version.ts
|
|
1799
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1800
|
+
import { dirname, join } from "path";
|
|
1801
|
+
import { fileURLToPath } from "url";
|
|
1802
|
+
var FALLBACK_PACKAGE_VERSION = "0.0.0";
|
|
1803
|
+
function getPackageVersion() {
|
|
1804
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
1805
|
+
for (const relativePath of ["../package.json", "../../package.json", "../../../package.json"]) {
|
|
1806
|
+
try {
|
|
1807
|
+
const parsed = JSON.parse(readFileSync2(join(currentDir, relativePath), "utf8"));
|
|
1808
|
+
if (parsed.version)
|
|
1809
|
+
return parsed.version;
|
|
1810
|
+
} catch {}
|
|
1811
|
+
}
|
|
1812
|
+
return FALLBACK_PACKAGE_VERSION;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// src/server/serve.ts
|
|
1816
|
+
var TENANTS_SERVE_APP = "tenants";
|
|
1817
|
+
var DEFAULT_PORT = 15460;
|
|
1818
|
+
var JWKS_CACHE_TTL_MS = 60000;
|
|
1819
|
+
function json(body, status = 200, headers = {}) {
|
|
1820
|
+
return new Response(JSON.stringify(body), {
|
|
1821
|
+
status,
|
|
1822
|
+
headers: { "content-type": "application/json", ...headers }
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
function resolveSigningSecret(explicit) {
|
|
1826
|
+
const secret = explicit || process.env["HASNA_TENANTS_API_SIGNING_KEY"] || process.env["HASNA_API_SIGNING_KEY"];
|
|
1827
|
+
if (!secret) {
|
|
1828
|
+
throw new Error("Missing API signing secret. Set HASNA_TENANTS_API_SIGNING_KEY (or HASNA_API_SIGNING_KEY).");
|
|
1829
|
+
}
|
|
1830
|
+
return secret;
|
|
1831
|
+
}
|
|
1832
|
+
async function readJsonBody(req) {
|
|
1833
|
+
const text = await req.text();
|
|
1834
|
+
if (!text)
|
|
1835
|
+
return {};
|
|
1836
|
+
try {
|
|
1837
|
+
return JSON.parse(text);
|
|
1838
|
+
} catch {
|
|
1839
|
+
throw new HttpError(400, "Invalid JSON body");
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
class HttpError extends Error {
|
|
1844
|
+
status;
|
|
1845
|
+
reason;
|
|
1846
|
+
constructor(status, message, reason) {
|
|
1847
|
+
super(message);
|
|
1848
|
+
this.status = status;
|
|
1849
|
+
this.reason = reason;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
async function authenticate(h, req, requiredScopes) {
|
|
1853
|
+
const url = new URL(req.url);
|
|
1854
|
+
const raw = extractToken(req.headers);
|
|
1855
|
+
if (raw && looksLikeAccessToken(raw)) {
|
|
1856
|
+
const jwks = await h.getJwks();
|
|
1857
|
+
const result = verifyAccessToken(raw, { jwks, expectedAudience: TENANTS_SERVE_APP });
|
|
1858
|
+
if (!result.ok)
|
|
1859
|
+
return json({ error: `access token ${result.reason}`, reason: result.reason }, 401);
|
|
1860
|
+
if (requiredScopes.length > 0 && !hasAllScopes(result.claims.scope ?? [], requiredScopes)) {
|
|
1861
|
+
return json({ error: "insufficient_scope", reason: "insufficient_scope" }, 403);
|
|
1862
|
+
}
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
const decision = await h.verifier.authenticate(req.headers, {
|
|
1866
|
+
method: req.method,
|
|
1867
|
+
path: url.pathname,
|
|
1868
|
+
requiredScopes
|
|
1869
|
+
});
|
|
1870
|
+
if (decision.ok)
|
|
1871
|
+
return null;
|
|
1872
|
+
return json({ error: decision.message, reason: decision.reason }, decision.status);
|
|
1873
|
+
}
|
|
1874
|
+
async function handleV1(h, req, url) {
|
|
1875
|
+
const method = req.method.toUpperCase();
|
|
1876
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
1877
|
+
const resource = segments[1];
|
|
1878
|
+
const scope = method === "GET" ? "tenants:read" : "tenants:write";
|
|
1879
|
+
const authFailure = await authenticate(h, req, [scope]);
|
|
1880
|
+
if (authFailure)
|
|
1881
|
+
return authFailure;
|
|
1882
|
+
try {
|
|
1883
|
+
if (resource === "introspect" && segments.length === 2 && method === "GET") {
|
|
1884
|
+
const kid = url.searchParams.get("kid");
|
|
1885
|
+
if (!kid)
|
|
1886
|
+
throw new HttpError(400, "kid query parameter is required", "invalid_request");
|
|
1887
|
+
return json(await h.auth.introspect(kid));
|
|
1888
|
+
}
|
|
1889
|
+
throw new HttpError(404, `No route for ${method} ${url.pathname}`, "not_found");
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
if (error instanceof HttpError) {
|
|
1892
|
+
return json({ error: error.message, reason: error.reason }, error.status);
|
|
1893
|
+
}
|
|
1894
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1895
|
+
return json({ error: message }, 400);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
function sessionTokenFrom(req, body) {
|
|
1899
|
+
const authz = req.headers.get("authorization");
|
|
1900
|
+
if (authz && /^Bearer\s+/i.test(authz)) {
|
|
1901
|
+
const token = authz.replace(/^Bearer\s+/i, "").trim();
|
|
1902
|
+
if (token.startsWith("hst_"))
|
|
1903
|
+
return token;
|
|
1904
|
+
}
|
|
1905
|
+
if (body && typeof body.session === "string")
|
|
1906
|
+
return body.session;
|
|
1907
|
+
return "";
|
|
1908
|
+
}
|
|
1909
|
+
async function handleAuth(h, req, url) {
|
|
1910
|
+
const path = url.pathname;
|
|
1911
|
+
const method = req.method.toUpperCase();
|
|
1912
|
+
if (method === "GET" && (path === "/jwks" || path === "/.well-known/jwks.json" || path === "/v1/.well-known/jwks.json")) {
|
|
1913
|
+
const jwks = await h.auth.jwks();
|
|
1914
|
+
return json(jwks, 200, { "cache-control": "public, max-age=600" });
|
|
1915
|
+
}
|
|
1916
|
+
const isAuthRoute = path === "/signup" || path === "/login" || path.startsWith("/v1/auth/");
|
|
1917
|
+
if (!isAuthRoute)
|
|
1918
|
+
return null;
|
|
1919
|
+
try {
|
|
1920
|
+
if (method === "POST" && (path === "/signup" || path === "/v1/auth/signup")) {
|
|
1921
|
+
const body = await readJsonBody(req);
|
|
1922
|
+
return json(await h.auth.signup({ ...body, ip: null, userAgent: req.headers.get("user-agent") }), 201);
|
|
1923
|
+
}
|
|
1924
|
+
if (method === "POST" && (path === "/login" || path === "/v1/auth/login")) {
|
|
1925
|
+
const body = await readJsonBody(req);
|
|
1926
|
+
return json(await h.auth.login({ ...body, ip: null, userAgent: req.headers.get("user-agent") }));
|
|
1927
|
+
}
|
|
1928
|
+
if (method === "POST" && path === "/v1/auth/verify") {
|
|
1929
|
+
const body = await readJsonBody(req);
|
|
1930
|
+
return json(await h.auth.verify({ ...body, ip: null, userAgent: req.headers.get("user-agent") }));
|
|
1931
|
+
}
|
|
1932
|
+
if (method === "GET" && path === "/v1/auth/confirm") {
|
|
1933
|
+
const email = url.searchParams.get("email") ?? undefined;
|
|
1934
|
+
const code = url.searchParams.get("code") ?? undefined;
|
|
1935
|
+
return json(await h.auth.verify({ email, code, ip: null, userAgent: req.headers.get("user-agent") }));
|
|
1936
|
+
}
|
|
1937
|
+
if (method === "POST" && path === "/v1/auth/resend") {
|
|
1938
|
+
const body = await readJsonBody(req);
|
|
1939
|
+
return json(await h.auth.resend({ email: body.email }));
|
|
1940
|
+
}
|
|
1941
|
+
if (method === "POST" && path === "/v1/auth/token") {
|
|
1942
|
+
const body = await readJsonBody(req);
|
|
1943
|
+
return json(await h.auth.token({ ...body, sessionToken: sessionTokenFrom(req, body) }));
|
|
1944
|
+
}
|
|
1945
|
+
if (method === "GET" && path === "/v1/auth/whoami") {
|
|
1946
|
+
return json(await h.auth.whoami(sessionTokenFrom(req, null)));
|
|
1947
|
+
}
|
|
1948
|
+
return json({ error: `No auth route for ${method} ${path}`, reason: "not_found" }, 404);
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
if (error instanceof AuthError) {
|
|
1951
|
+
return json({ error: error.message, reason: error.code, ...error.details ?? {} }, error.status);
|
|
1952
|
+
}
|
|
1953
|
+
if (error instanceof HttpError) {
|
|
1954
|
+
return json({ error: error.message, reason: error.reason }, error.status);
|
|
1955
|
+
}
|
|
1956
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1957
|
+
return json({ error: message }, 400);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
async function buildHandler(options = {}) {
|
|
1961
|
+
let client;
|
|
1962
|
+
let close;
|
|
1963
|
+
if (options.client) {
|
|
1964
|
+
client = options.client;
|
|
1965
|
+
close = options.close ?? (async () => {
|
|
1966
|
+
return;
|
|
1967
|
+
});
|
|
1968
|
+
} else {
|
|
1969
|
+
const cloud = createCloudClient();
|
|
1970
|
+
client = cloud.client;
|
|
1971
|
+
close = cloud.close;
|
|
1972
|
+
}
|
|
1973
|
+
const signingSecret = resolveSigningSecret(options.signingSecret);
|
|
1974
|
+
const keys = new ApiKeyStore(client);
|
|
1975
|
+
const verifier = verifyApiKey({
|
|
1976
|
+
app: TENANTS_SERVE_APP,
|
|
1977
|
+
signingSecret,
|
|
1978
|
+
isRevoked: keys.isRevoked,
|
|
1979
|
+
...options.audit ? { audit: options.audit } : {}
|
|
1980
|
+
});
|
|
1981
|
+
const idpStore = new IdpStore(client);
|
|
1982
|
+
const auth = options.auth ?? new AuthService({
|
|
1983
|
+
store: idpStore,
|
|
1984
|
+
signingSecret,
|
|
1985
|
+
apiKeysTable: API_KEYS_TABLE,
|
|
1986
|
+
apiKeys: keys,
|
|
1987
|
+
otpEcho: process.env["HASNA_TENANTS_OTP_ECHO"] === "1",
|
|
1988
|
+
emailPolicy: emailPolicyFromEnv(),
|
|
1989
|
+
mailer: createMailerFromEnv(),
|
|
1990
|
+
confirmUrlBase: confirmUrlBaseFromEnv()
|
|
1991
|
+
});
|
|
1992
|
+
let cached = null;
|
|
1993
|
+
const getJwks = async () => {
|
|
1994
|
+
const now = Date.now();
|
|
1995
|
+
if (cached && now - cached.at < JWKS_CACHE_TTL_MS)
|
|
1996
|
+
return cached.keys;
|
|
1997
|
+
const fresh = (await auth.jwks()).keys;
|
|
1998
|
+
cached = { keys: fresh, at: now };
|
|
1999
|
+
return fresh;
|
|
2000
|
+
};
|
|
2001
|
+
return { client, close, verifier, keys, auth, getJwks, version: getPackageVersion() };
|
|
2002
|
+
}
|
|
2003
|
+
async function createFetchHandler(options = {}) {
|
|
2004
|
+
const handler = await buildHandler(options);
|
|
2005
|
+
const openapi = buildOpenApiDocument(handler.version);
|
|
2006
|
+
const fetch2 = async (req) => {
|
|
2007
|
+
const url = new URL(req.url);
|
|
2008
|
+
const path = url.pathname;
|
|
2009
|
+
if (path === "/health") {
|
|
2010
|
+
return json({ status: "ok", version: handler.version, mode: "cloud" });
|
|
2011
|
+
}
|
|
2012
|
+
if (path === "/version") {
|
|
2013
|
+
return json({ status: "ok", version: handler.version, mode: "cloud" });
|
|
2014
|
+
}
|
|
2015
|
+
if (path === "/ready") {
|
|
2016
|
+
const ready = await cloudReady(handler.client);
|
|
2017
|
+
return json({
|
|
2018
|
+
status: ready.ok ? "ok" : "degraded",
|
|
2019
|
+
version: handler.version,
|
|
2020
|
+
mode: "cloud",
|
|
2021
|
+
latencyMs: ready.latencyMs,
|
|
2022
|
+
pendingMigrations: ready.pendingMigrations,
|
|
2023
|
+
...ready.error ? { error: ready.error } : {}
|
|
2024
|
+
}, ready.ok ? 200 : 503);
|
|
2025
|
+
}
|
|
2026
|
+
if (path === "/openapi.json") {
|
|
2027
|
+
return json(openapi);
|
|
2028
|
+
}
|
|
2029
|
+
if (path === "/") {
|
|
2030
|
+
return json({ name: "@hasna/tenants", status: "ok", version: handler.version, mode: "cloud" });
|
|
2031
|
+
}
|
|
2032
|
+
const authResponse = await handleAuth(handler, req, url);
|
|
2033
|
+
if (authResponse)
|
|
2034
|
+
return authResponse;
|
|
2035
|
+
if (path.startsWith("/v1/") || path === "/v1") {
|
|
2036
|
+
return handleV1(handler, req, url);
|
|
2037
|
+
}
|
|
2038
|
+
return json({ error: `Not found: ${path}` }, 404);
|
|
2039
|
+
};
|
|
2040
|
+
return { handler, fetch: fetch2 };
|
|
2041
|
+
}
|
|
2042
|
+
async function startServer(options = {}) {
|
|
2043
|
+
const port = options.port ?? (Number(process.env["PORT"]) || DEFAULT_PORT);
|
|
2044
|
+
const host = options.host ?? process.env["HOST"] ?? "0.0.0.0";
|
|
2045
|
+
const { fetch: fetch2, handler } = await createFetchHandler(options);
|
|
2046
|
+
const server = Bun.serve({ port, hostname: host, fetch: fetch2 });
|
|
2047
|
+
cloudHealth(handler.client).catch(() => {
|
|
2048
|
+
return;
|
|
2049
|
+
});
|
|
2050
|
+
return {
|
|
2051
|
+
port: server.port ?? port,
|
|
2052
|
+
hostname: host,
|
|
2053
|
+
stop: async () => {
|
|
2054
|
+
server.stop(true);
|
|
2055
|
+
await handler.close().catch(() => {
|
|
2056
|
+
return;
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
export {
|
|
2062
|
+
verifyPassword,
|
|
2063
|
+
verifyAccessToken,
|
|
2064
|
+
uuidv5,
|
|
2065
|
+
tenantsMigrations,
|
|
2066
|
+
startServer,
|
|
2067
|
+
signingKeyFromPrivateJwk,
|
|
2068
|
+
signAccessToken,
|
|
2069
|
+
sha256,
|
|
2070
|
+
seedAndBackfill,
|
|
2071
|
+
runTenantsMigrations,
|
|
2072
|
+
newId,
|
|
2073
|
+
looksLikeAccessToken,
|
|
2074
|
+
isEmailDomainAllowed,
|
|
2075
|
+
idpMigrations,
|
|
2076
|
+
hashPassword,
|
|
2077
|
+
getPackageVersion,
|
|
2078
|
+
emailPolicyFromEnv,
|
|
2079
|
+
emailDomain,
|
|
2080
|
+
deriveTenantId,
|
|
2081
|
+
createMailerFromEnv,
|
|
2082
|
+
createFetchHandler,
|
|
2083
|
+
createCloudClient,
|
|
2084
|
+
confirmUrlBaseFromEnv,
|
|
2085
|
+
cloudReady,
|
|
2086
|
+
cloudHealth,
|
|
2087
|
+
buildOpenApiDocument,
|
|
2088
|
+
buildJwks,
|
|
2089
|
+
buildHandler,
|
|
2090
|
+
USERS_TABLE,
|
|
2091
|
+
TOKEN_TYPE,
|
|
2092
|
+
TOKEN_ISSUER,
|
|
2093
|
+
TOKEN_ALG,
|
|
2094
|
+
TENANTS_TABLE,
|
|
2095
|
+
TENANTS_SERVE_APP,
|
|
2096
|
+
TENANTS_APP_NAME,
|
|
2097
|
+
SesMailer,
|
|
2098
|
+
SES_REGION_ENV,
|
|
2099
|
+
SES_FROM_ARN_ENV,
|
|
2100
|
+
SESSION_TTL_SECONDS,
|
|
2101
|
+
SESSIONS_TABLE,
|
|
2102
|
+
SERVICE_PRINCIPALS_TABLE,
|
|
2103
|
+
SELF_APP,
|
|
2104
|
+
SEED_TENANTS,
|
|
2105
|
+
ROOT_TENANT_SLUG,
|
|
2106
|
+
ROOT_TENANT_ID,
|
|
2107
|
+
OTP_TTL_SECONDS,
|
|
2108
|
+
OTP_MAX_ATTEMPTS,
|
|
2109
|
+
NoopMailer,
|
|
2110
|
+
MEMBERSHIPS_TABLE,
|
|
2111
|
+
MAIL_FROM_ENV,
|
|
2112
|
+
MAIL_ENABLED_ENV,
|
|
2113
|
+
JWT_SIGNING_KEY_ENV,
|
|
2114
|
+
JWT_SIGNING_KEYS_TABLE,
|
|
2115
|
+
JWT_KID_ENV,
|
|
2116
|
+
IdpStore,
|
|
2117
|
+
HASNA_TENANT_NAMESPACE,
|
|
2118
|
+
FLEET_APPS,
|
|
2119
|
+
DEFAULT_MAIL_FROM,
|
|
2120
|
+
DEFAULT_CONFIRM_URL_BASE,
|
|
2121
|
+
DEFAULT_ALLOWED_EMAIL_DOMAINS,
|
|
2122
|
+
DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
|
|
2123
|
+
CONFIRM_URL_BASE_ENV,
|
|
2124
|
+
AuthService,
|
|
2125
|
+
AuthError,
|
|
2126
|
+
AUTH_CHALLENGES_TABLE,
|
|
2127
|
+
API_KEYS_TABLE,
|
|
2128
|
+
ALLOWED_EMAIL_DOMAINS_ENV
|
|
2129
|
+
};
|