chainlesschain 0.51.0 → 0.66.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/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{AppLayout-Rvi759IS.js → AppLayout-6SPt_8Y_.js} +1 -1
- package/src/assets/web-panel/assets/{Dashboard-DBhFxXYQ.js → Dashboard-Br7kCwKJ.js} +2 -2
- package/src/assets/web-panel/assets/Dashboard-CKeMmCoT.css +1 -0
- package/src/assets/web-panel/assets/{index-uL0cZ8N_.js → index-tN-8TosE.js} +2 -2
- package/src/assets/web-panel/index.html +2 -2
- package/src/commands/agent-network.js +785 -0
- package/src/commands/automation.js +654 -0
- package/src/commands/dao.js +565 -0
- package/src/commands/did-v2.js +620 -0
- package/src/commands/economy.js +578 -0
- package/src/commands/evolution.js +391 -0
- package/src/commands/hmemory.js +442 -0
- package/src/commands/perf.js +433 -0
- package/src/commands/pipeline.js +449 -0
- package/src/commands/plugin-ecosystem.js +517 -0
- package/src/commands/sandbox.js +401 -0
- package/src/commands/social.js +311 -0
- package/src/commands/sso.js +798 -0
- package/src/commands/workflow.js +320 -0
- package/src/commands/zkp.js +227 -1
- package/src/index.js +21 -0
- package/src/lib/agent-economy.js +479 -0
- package/src/lib/agent-network.js +1121 -0
- package/src/lib/automation-engine.js +948 -0
- package/src/lib/dao-governance.js +569 -0
- package/src/lib/did-v2-manager.js +1127 -0
- package/src/lib/evolution-system.js +453 -0
- package/src/lib/hierarchical-memory.js +481 -0
- package/src/lib/perf-tuning.js +734 -0
- package/src/lib/pipeline-orchestrator.js +928 -0
- package/src/lib/plugin-ecosystem.js +1109 -0
- package/src/lib/sandbox-v2.js +306 -0
- package/src/lib/social-graph-analytics.js +707 -0
- package/src/lib/sso-manager.js +841 -0
- package/src/lib/workflow-engine.js +454 -1
- package/src/lib/zkp-engine.js +249 -20
- package/src/assets/web-panel/assets/Dashboard-BS-tzGNj.css +0 -1
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSO Manager — CLI port of Phase 14 SSO Enterprise Authentication.
|
|
3
|
+
*
|
|
4
|
+
* Scope note: the CLI can't open a browser and isn't a redirect target,
|
|
5
|
+
* so it covers configuration, PKCE helpers, authorization-URL / SAML
|
|
6
|
+
* AuthnRequest builders, token storage (AES-256-GCM), session lifecycle
|
|
7
|
+
* and the DID ↔ SSO identity bridge. The actual IdP browser round-trip
|
|
8
|
+
* is driven by an external tool; callers feed the resulting tokens back
|
|
9
|
+
* in via `completeLogin()`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
|
|
14
|
+
/* ── Constants ─────────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
export const SSO_PROTOCOLS = Object.freeze({
|
|
17
|
+
SAML: "saml",
|
|
18
|
+
OAUTH2: "oauth2",
|
|
19
|
+
OIDC: "oidc",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const SUPPORTED_PROTOCOLS = new Set(Object.values(SSO_PROTOCOLS));
|
|
23
|
+
|
|
24
|
+
export const PROVIDER_TYPES = Object.freeze([
|
|
25
|
+
"azure_ad",
|
|
26
|
+
"okta",
|
|
27
|
+
"google",
|
|
28
|
+
"onelogin",
|
|
29
|
+
"custom",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const SESSION_STATUS = Object.freeze({
|
|
33
|
+
ACTIVE: "active",
|
|
34
|
+
EXPIRED: "expired",
|
|
35
|
+
REVOKED: "revoked",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const TEST_STATUS = Object.freeze({
|
|
39
|
+
UNTESTED: "untested",
|
|
40
|
+
SUCCESS: "success",
|
|
41
|
+
FAILED: "failed",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/* ── Provider templates (static snapshot) ──────────────────── */
|
|
45
|
+
|
|
46
|
+
const PROVIDER_TEMPLATES = Object.freeze([
|
|
47
|
+
Object.freeze({
|
|
48
|
+
id: "azure_ad_oidc",
|
|
49
|
+
name: "Azure AD (OIDC)",
|
|
50
|
+
providerType: "azure_ad",
|
|
51
|
+
protocol: SSO_PROTOCOLS.OIDC,
|
|
52
|
+
hints: Object.freeze({
|
|
53
|
+
authorizationUrl:
|
|
54
|
+
"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
|
|
55
|
+
tokenUrl: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
|
56
|
+
userInfoUrl: "https://graph.microsoft.com/oidc/userinfo",
|
|
57
|
+
scopes: Object.freeze(["openid", "profile", "email", "offline_access"]),
|
|
58
|
+
codeChallengeMethod: "S256",
|
|
59
|
+
}),
|
|
60
|
+
}),
|
|
61
|
+
Object.freeze({
|
|
62
|
+
id: "okta_oidc",
|
|
63
|
+
name: "Okta (OIDC)",
|
|
64
|
+
providerType: "okta",
|
|
65
|
+
protocol: SSO_PROTOCOLS.OIDC,
|
|
66
|
+
hints: Object.freeze({
|
|
67
|
+
authorizationUrl: "https://{domain}/oauth2/default/v1/authorize",
|
|
68
|
+
tokenUrl: "https://{domain}/oauth2/default/v1/token",
|
|
69
|
+
userInfoUrl: "https://{domain}/oauth2/default/v1/userinfo",
|
|
70
|
+
scopes: Object.freeze(["openid", "profile", "email", "offline_access"]),
|
|
71
|
+
codeChallengeMethod: "S256",
|
|
72
|
+
}),
|
|
73
|
+
}),
|
|
74
|
+
Object.freeze({
|
|
75
|
+
id: "google_oidc",
|
|
76
|
+
name: "Google Workspace (OIDC)",
|
|
77
|
+
providerType: "google",
|
|
78
|
+
protocol: SSO_PROTOCOLS.OIDC,
|
|
79
|
+
hints: Object.freeze({
|
|
80
|
+
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
81
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
82
|
+
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
83
|
+
scopes: Object.freeze(["openid", "profile", "email"]),
|
|
84
|
+
codeChallengeMethod: "S256",
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
Object.freeze({
|
|
88
|
+
id: "okta_saml",
|
|
89
|
+
name: "Okta (SAML 2.0)",
|
|
90
|
+
providerType: "okta",
|
|
91
|
+
protocol: SSO_PROTOCOLS.SAML,
|
|
92
|
+
hints: Object.freeze({
|
|
93
|
+
nameIdFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
94
|
+
signRequests: true,
|
|
95
|
+
wantAssertionsSigned: true,
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
id: "custom_oauth2",
|
|
100
|
+
name: "Generic OAuth 2.0",
|
|
101
|
+
providerType: "custom",
|
|
102
|
+
protocol: SSO_PROTOCOLS.OAUTH2,
|
|
103
|
+
hints: Object.freeze({
|
|
104
|
+
codeChallengeMethod: "S256",
|
|
105
|
+
}),
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
export function listProviderTemplates() {
|
|
110
|
+
return PROVIDER_TEMPLATES.map((t) => ({
|
|
111
|
+
...t,
|
|
112
|
+
hints: {
|
|
113
|
+
...t.hints,
|
|
114
|
+
scopes: t.hints.scopes ? [...t.hints.scopes] : undefined,
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getProviderTemplate(id) {
|
|
120
|
+
const t = PROVIDER_TEMPLATES.find((x) => x.id === id);
|
|
121
|
+
return t
|
|
122
|
+
? {
|
|
123
|
+
...t,
|
|
124
|
+
hints: {
|
|
125
|
+
...t.hints,
|
|
126
|
+
scopes: t.hints.scopes ? [...t.hints.scopes] : undefined,
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── Schema ────────────────────────────────────────────────── */
|
|
133
|
+
|
|
134
|
+
export function ensureSSOTables(db) {
|
|
135
|
+
db.exec(`
|
|
136
|
+
CREATE TABLE IF NOT EXISTS sso_configurations (
|
|
137
|
+
id TEXT PRIMARY KEY,
|
|
138
|
+
name TEXT NOT NULL,
|
|
139
|
+
protocol TEXT NOT NULL,
|
|
140
|
+
provider_type TEXT NOT NULL,
|
|
141
|
+
config TEXT NOT NULL,
|
|
142
|
+
enabled INTEGER DEFAULT 1,
|
|
143
|
+
metadata TEXT,
|
|
144
|
+
last_tested INTEGER,
|
|
145
|
+
test_status TEXT DEFAULT 'untested',
|
|
146
|
+
test_error TEXT,
|
|
147
|
+
created_at INTEGER NOT NULL,
|
|
148
|
+
updated_at INTEGER NOT NULL
|
|
149
|
+
)
|
|
150
|
+
`);
|
|
151
|
+
db.exec(`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS sso_sessions (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
config_id TEXT NOT NULL,
|
|
155
|
+
did TEXT,
|
|
156
|
+
access_token TEXT,
|
|
157
|
+
refresh_token TEXT,
|
|
158
|
+
id_token TEXT,
|
|
159
|
+
token_expires_at INTEGER,
|
|
160
|
+
user_info TEXT,
|
|
161
|
+
status TEXT DEFAULT 'active',
|
|
162
|
+
created_at INTEGER NOT NULL,
|
|
163
|
+
last_refreshed INTEGER
|
|
164
|
+
)
|
|
165
|
+
`);
|
|
166
|
+
db.exec(`
|
|
167
|
+
CREATE TABLE IF NOT EXISTS sso_identity_mappings (
|
|
168
|
+
id TEXT PRIMARY KEY,
|
|
169
|
+
did TEXT NOT NULL,
|
|
170
|
+
sso_provider TEXT NOT NULL,
|
|
171
|
+
sso_user_id TEXT NOT NULL,
|
|
172
|
+
sso_email TEXT,
|
|
173
|
+
sso_display_name TEXT,
|
|
174
|
+
attributes TEXT,
|
|
175
|
+
linked_at INTEGER NOT NULL,
|
|
176
|
+
last_login INTEGER,
|
|
177
|
+
UNIQUE(sso_provider, sso_user_id)
|
|
178
|
+
)
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── ID + helpers ──────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
function _id(prefix) {
|
|
185
|
+
return `${prefix}_${crypto.randomBytes(6).toString("hex")}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _now() {
|
|
189
|
+
return Date.now();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _parseJSON(value, fallback = null) {
|
|
193
|
+
if (value === null || value === undefined || value === "") return fallback;
|
|
194
|
+
if (typeof value !== "string") return value;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(value);
|
|
197
|
+
} catch {
|
|
198
|
+
return fallback;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _mapConfigRow(row) {
|
|
203
|
+
if (!row) return null;
|
|
204
|
+
return {
|
|
205
|
+
id: row.id,
|
|
206
|
+
name: row.name,
|
|
207
|
+
protocol: row.protocol,
|
|
208
|
+
providerType: row.provider_type,
|
|
209
|
+
config: _parseJSON(row.config, {}),
|
|
210
|
+
enabled: !!row.enabled,
|
|
211
|
+
metadata: _parseJSON(row.metadata, {}),
|
|
212
|
+
lastTested: row.last_tested || null,
|
|
213
|
+
testStatus: row.test_status || TEST_STATUS.UNTESTED,
|
|
214
|
+
testError: row.test_error || null,
|
|
215
|
+
createdAt: row.created_at,
|
|
216
|
+
updatedAt: row.updated_at,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _mapSessionRow(row) {
|
|
221
|
+
if (!row) return null;
|
|
222
|
+
return {
|
|
223
|
+
id: row.id,
|
|
224
|
+
configId: row.config_id,
|
|
225
|
+
did: row.did || null,
|
|
226
|
+
accessToken: _parseJSON(row.access_token, null),
|
|
227
|
+
refreshToken: _parseJSON(row.refresh_token, null),
|
|
228
|
+
idToken: _parseJSON(row.id_token, null),
|
|
229
|
+
tokenExpiresAt: row.token_expires_at || null,
|
|
230
|
+
userInfo: _parseJSON(row.user_info, {}),
|
|
231
|
+
status: row.status,
|
|
232
|
+
createdAt: row.created_at,
|
|
233
|
+
lastRefreshed: row.last_refreshed || null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _mapMappingRow(row) {
|
|
238
|
+
if (!row) return null;
|
|
239
|
+
return {
|
|
240
|
+
id: row.id,
|
|
241
|
+
did: row.did,
|
|
242
|
+
ssoProvider: row.sso_provider,
|
|
243
|
+
ssoUserId: row.sso_user_id,
|
|
244
|
+
ssoEmail: row.sso_email || null,
|
|
245
|
+
ssoDisplayName: row.sso_display_name || null,
|
|
246
|
+
attributes: _parseJSON(row.attributes, {}),
|
|
247
|
+
linkedAt: row.linked_at,
|
|
248
|
+
lastLogin: row.last_login || null,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ── Configuration CRUD ────────────────────────────────────── */
|
|
253
|
+
|
|
254
|
+
export function createConfiguration(db, input = {}) {
|
|
255
|
+
const {
|
|
256
|
+
name,
|
|
257
|
+
protocol,
|
|
258
|
+
providerType = "custom",
|
|
259
|
+
config = {},
|
|
260
|
+
metadata = {},
|
|
261
|
+
enabled = true,
|
|
262
|
+
} = input;
|
|
263
|
+
if (!name || typeof name !== "string") throw new Error("name is required");
|
|
264
|
+
if (!SUPPORTED_PROTOCOLS.has(protocol)) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`protocol must be one of ${[...SUPPORTED_PROTOCOLS].join(", ")}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (!PROVIDER_TYPES.includes(providerType)) {
|
|
270
|
+
throw new Error(`providerType must be one of ${PROVIDER_TYPES.join(", ")}`);
|
|
271
|
+
}
|
|
272
|
+
_validateConfigShape(protocol, config);
|
|
273
|
+
|
|
274
|
+
const id = _id("sso");
|
|
275
|
+
const now = _now();
|
|
276
|
+
db.prepare(
|
|
277
|
+
`
|
|
278
|
+
INSERT INTO sso_configurations (id, name, protocol, provider_type, config, enabled, metadata, test_status, created_at, updated_at)
|
|
279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
280
|
+
`,
|
|
281
|
+
).run(
|
|
282
|
+
id,
|
|
283
|
+
name,
|
|
284
|
+
protocol,
|
|
285
|
+
providerType,
|
|
286
|
+
JSON.stringify(config),
|
|
287
|
+
enabled ? 1 : 0,
|
|
288
|
+
JSON.stringify(metadata),
|
|
289
|
+
TEST_STATUS.UNTESTED,
|
|
290
|
+
now,
|
|
291
|
+
now,
|
|
292
|
+
);
|
|
293
|
+
return getConfiguration(db, id);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function _validateConfigShape(protocol, config) {
|
|
297
|
+
if (!config || typeof config !== "object")
|
|
298
|
+
throw new Error("config must be an object");
|
|
299
|
+
if (protocol === SSO_PROTOCOLS.SAML) {
|
|
300
|
+
const required = [
|
|
301
|
+
"entityId",
|
|
302
|
+
"assertionConsumerServiceUrl",
|
|
303
|
+
"idpMetadataUrl",
|
|
304
|
+
];
|
|
305
|
+
for (const k of required) {
|
|
306
|
+
if (!config[k] || typeof config[k] !== "string") {
|
|
307
|
+
throw new Error(`SAML config missing ${k}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
const required = [
|
|
312
|
+
"clientId",
|
|
313
|
+
"authorizationUrl",
|
|
314
|
+
"tokenUrl",
|
|
315
|
+
"redirectUri",
|
|
316
|
+
];
|
|
317
|
+
for (const k of required) {
|
|
318
|
+
if (!config[k] || typeof config[k] !== "string") {
|
|
319
|
+
throw new Error(`${protocol} config missing ${k}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function getConfiguration(db, configId) {
|
|
326
|
+
const row = db
|
|
327
|
+
.prepare(`SELECT * FROM sso_configurations WHERE id = ?`)
|
|
328
|
+
.get(configId);
|
|
329
|
+
return _mapConfigRow(row);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function listConfigurations(db, filter = {}) {
|
|
333
|
+
const rows = db
|
|
334
|
+
.prepare(`SELECT * FROM sso_configurations ORDER BY created_at DESC`)
|
|
335
|
+
.all();
|
|
336
|
+
let out = rows.map(_mapConfigRow);
|
|
337
|
+
if (filter.protocol) out = out.filter((c) => c.protocol === filter.protocol);
|
|
338
|
+
if (filter.providerType)
|
|
339
|
+
out = out.filter((c) => c.providerType === filter.providerType);
|
|
340
|
+
if (filter.enabled !== undefined)
|
|
341
|
+
out = out.filter((c) => c.enabled === !!filter.enabled);
|
|
342
|
+
if (Number.isInteger(filter.limit) && filter.limit > 0)
|
|
343
|
+
out = out.slice(0, filter.limit);
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function updateConfiguration(db, configId, updates = {}) {
|
|
348
|
+
const current = getConfiguration(db, configId);
|
|
349
|
+
if (!current) throw new Error(`Configuration not found: ${configId}`);
|
|
350
|
+
|
|
351
|
+
const next = {
|
|
352
|
+
name: updates.name ?? current.name,
|
|
353
|
+
protocol: updates.protocol ?? current.protocol,
|
|
354
|
+
providerType: updates.providerType ?? current.providerType,
|
|
355
|
+
config: updates.config ?? current.config,
|
|
356
|
+
enabled:
|
|
357
|
+
updates.enabled === undefined ? current.enabled : !!updates.enabled,
|
|
358
|
+
metadata: updates.metadata ?? current.metadata,
|
|
359
|
+
};
|
|
360
|
+
if (!SUPPORTED_PROTOCOLS.has(next.protocol))
|
|
361
|
+
throw new Error(`Invalid protocol`);
|
|
362
|
+
if (!PROVIDER_TYPES.includes(next.providerType))
|
|
363
|
+
throw new Error(`Invalid providerType`);
|
|
364
|
+
_validateConfigShape(next.protocol, next.config);
|
|
365
|
+
|
|
366
|
+
db.prepare(
|
|
367
|
+
`
|
|
368
|
+
UPDATE sso_configurations
|
|
369
|
+
SET name = ?, protocol = ?, provider_type = ?, config = ?, enabled = ?, metadata = ?, updated_at = ?
|
|
370
|
+
WHERE id = ?
|
|
371
|
+
`,
|
|
372
|
+
).run(
|
|
373
|
+
next.name,
|
|
374
|
+
next.protocol,
|
|
375
|
+
next.providerType,
|
|
376
|
+
JSON.stringify(next.config),
|
|
377
|
+
next.enabled ? 1 : 0,
|
|
378
|
+
JSON.stringify(next.metadata),
|
|
379
|
+
_now(),
|
|
380
|
+
configId,
|
|
381
|
+
);
|
|
382
|
+
return getConfiguration(db, configId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function deleteConfiguration(db, configId) {
|
|
386
|
+
const existed = !!getConfiguration(db, configId);
|
|
387
|
+
db.prepare(`DELETE FROM sso_configurations WHERE id = ?`).run(configId);
|
|
388
|
+
db.prepare(`DELETE FROM sso_sessions WHERE config_id = ?`).run(configId);
|
|
389
|
+
return { deleted: existed };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function recordTestResult(db, configId, success, error = null) {
|
|
393
|
+
const current = getConfiguration(db, configId);
|
|
394
|
+
if (!current) throw new Error(`Configuration not found: ${configId}`);
|
|
395
|
+
db.prepare(
|
|
396
|
+
`
|
|
397
|
+
UPDATE sso_configurations
|
|
398
|
+
SET last_tested = ?, test_status = ?, test_error = ?, updated_at = ?
|
|
399
|
+
WHERE id = ?
|
|
400
|
+
`,
|
|
401
|
+
).run(
|
|
402
|
+
_now(),
|
|
403
|
+
success ? TEST_STATUS.SUCCESS : TEST_STATUS.FAILED,
|
|
404
|
+
error,
|
|
405
|
+
_now(),
|
|
406
|
+
configId,
|
|
407
|
+
);
|
|
408
|
+
return getConfiguration(db, configId);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* ── PKCE helpers ──────────────────────────────────────────── */
|
|
412
|
+
|
|
413
|
+
export function generatePKCE() {
|
|
414
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
415
|
+
const codeChallenge = crypto
|
|
416
|
+
.createHash("sha256")
|
|
417
|
+
.update(codeVerifier)
|
|
418
|
+
.digest("base64url");
|
|
419
|
+
return { codeVerifier, codeChallenge, codeChallengeMethod: "S256" };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* ── Authorization URL / AuthnRequest builders ─────────────── */
|
|
423
|
+
|
|
424
|
+
export function buildAuthorizationUrl(config, pkce, opts = {}) {
|
|
425
|
+
if (!config || !config.authorizationUrl)
|
|
426
|
+
throw new Error("config.authorizationUrl required");
|
|
427
|
+
if (!config.clientId) throw new Error("config.clientId required");
|
|
428
|
+
if (!config.redirectUri) throw new Error("config.redirectUri required");
|
|
429
|
+
if (!pkce || !pkce.codeChallenge)
|
|
430
|
+
throw new Error("pkce.codeChallenge required");
|
|
431
|
+
|
|
432
|
+
const params = new URLSearchParams({
|
|
433
|
+
response_type: "code",
|
|
434
|
+
client_id: config.clientId,
|
|
435
|
+
redirect_uri: config.redirectUri,
|
|
436
|
+
scope: (config.scopes || ["openid", "profile", "email"]).join(" "),
|
|
437
|
+
state: opts.state || crypto.randomBytes(16).toString("base64url"),
|
|
438
|
+
code_challenge: pkce.codeChallenge,
|
|
439
|
+
code_challenge_method: pkce.codeChallengeMethod || "S256",
|
|
440
|
+
});
|
|
441
|
+
if (opts.nonce) params.set("nonce", opts.nonce);
|
|
442
|
+
if (opts.prompt) params.set("prompt", opts.prompt);
|
|
443
|
+
const sep = config.authorizationUrl.includes("?") ? "&" : "?";
|
|
444
|
+
return `${config.authorizationUrl}${sep}${params.toString()}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function buildSamlAuthnRequest(config, opts = {}) {
|
|
448
|
+
if (!config || !config.entityId || !config.assertionConsumerServiceUrl) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
"SAML config missing entityId or assertionConsumerServiceUrl",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const id = `_${crypto.randomBytes(16).toString("hex")}`;
|
|
454
|
+
const issueInstant = new Date().toISOString();
|
|
455
|
+
const nameIdFormat =
|
|
456
|
+
config.nameIdFormat ||
|
|
457
|
+
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
|
458
|
+
const relayState =
|
|
459
|
+
opts.relayState || crypto.randomBytes(8).toString("base64url");
|
|
460
|
+
const xml = [
|
|
461
|
+
`<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"`,
|
|
462
|
+
` xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"`,
|
|
463
|
+
` ID="${id}" Version="2.0" IssueInstant="${issueInstant}"`,
|
|
464
|
+
` AssertionConsumerServiceURL="${config.assertionConsumerServiceUrl}"`,
|
|
465
|
+
` ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">`,
|
|
466
|
+
`<saml:Issuer>${config.entityId}</saml:Issuer>`,
|
|
467
|
+
`<samlp:NameIDPolicy Format="${nameIdFormat}" AllowCreate="true"/>`,
|
|
468
|
+
`</samlp:AuthnRequest>`,
|
|
469
|
+
].join("");
|
|
470
|
+
return { id, issueInstant, relayState, xml };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* ── Token encryption (AES-256-GCM) ────────────────────────── */
|
|
474
|
+
|
|
475
|
+
function _deriveKey(masterKey, salt) {
|
|
476
|
+
return crypto.pbkdf2Sync(String(masterKey), salt, 100000, 32, "sha512");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function encryptToken(token, masterKey) {
|
|
480
|
+
if (token === null || token === undefined) return null;
|
|
481
|
+
if (!masterKey) throw new Error("masterKey required for token encryption");
|
|
482
|
+
const salt = crypto.randomBytes(16);
|
|
483
|
+
const iv = crypto.randomBytes(12);
|
|
484
|
+
const key = _deriveKey(masterKey, salt);
|
|
485
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
486
|
+
const plaintext = typeof token === "string" ? token : JSON.stringify(token);
|
|
487
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
488
|
+
const authTag = cipher.getAuthTag();
|
|
489
|
+
return {
|
|
490
|
+
alg: "aes-256-gcm",
|
|
491
|
+
salt: salt.toString("base64"),
|
|
492
|
+
iv: iv.toString("base64"),
|
|
493
|
+
tag: authTag.toString("base64"),
|
|
494
|
+
ct: enc.toString("base64"),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function decryptToken(envelope, masterKey) {
|
|
499
|
+
if (!envelope) return null;
|
|
500
|
+
if (!masterKey) throw new Error("masterKey required for token decryption");
|
|
501
|
+
if (envelope.alg !== "aes-256-gcm")
|
|
502
|
+
throw new Error(`Unsupported alg: ${envelope.alg}`);
|
|
503
|
+
const salt = Buffer.from(envelope.salt, "base64");
|
|
504
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
505
|
+
const tag = Buffer.from(envelope.tag, "base64");
|
|
506
|
+
const ct = Buffer.from(envelope.ct, "base64");
|
|
507
|
+
const key = _deriveKey(masterKey, salt);
|
|
508
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
509
|
+
decipher.setAuthTag(tag);
|
|
510
|
+
const dec = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
511
|
+
const s = dec.toString("utf8");
|
|
512
|
+
try {
|
|
513
|
+
return JSON.parse(s);
|
|
514
|
+
} catch {
|
|
515
|
+
return s;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* ── Session lifecycle ────────────────────────────────────── */
|
|
520
|
+
|
|
521
|
+
export function createSession(db, input = {}) {
|
|
522
|
+
const {
|
|
523
|
+
configId,
|
|
524
|
+
did = null,
|
|
525
|
+
tokens = {},
|
|
526
|
+
userInfo = {},
|
|
527
|
+
masterKey = null,
|
|
528
|
+
tokenExpiresAt = null,
|
|
529
|
+
} = input;
|
|
530
|
+
if (!configId) throw new Error("configId is required");
|
|
531
|
+
const config = getConfiguration(db, configId);
|
|
532
|
+
if (!config) throw new Error(`Configuration not found: ${configId}`);
|
|
533
|
+
const id = _id("sess");
|
|
534
|
+
const now = _now();
|
|
535
|
+
const access = masterKey
|
|
536
|
+
? encryptToken(tokens.accessToken, masterKey)
|
|
537
|
+
: tokens.accessToken || null;
|
|
538
|
+
const refresh = masterKey
|
|
539
|
+
? encryptToken(tokens.refreshToken, masterKey)
|
|
540
|
+
: tokens.refreshToken || null;
|
|
541
|
+
const idtok = masterKey
|
|
542
|
+
? encryptToken(tokens.idToken, masterKey)
|
|
543
|
+
: tokens.idToken || null;
|
|
544
|
+
db.prepare(
|
|
545
|
+
`
|
|
546
|
+
INSERT INTO sso_sessions (id, config_id, did, access_token, refresh_token, id_token, token_expires_at, user_info, status, created_at, last_refreshed)
|
|
547
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
548
|
+
`,
|
|
549
|
+
).run(
|
|
550
|
+
id,
|
|
551
|
+
configId,
|
|
552
|
+
did,
|
|
553
|
+
access === null ? null : JSON.stringify(access),
|
|
554
|
+
refresh === null ? null : JSON.stringify(refresh),
|
|
555
|
+
idtok === null ? null : JSON.stringify(idtok),
|
|
556
|
+
tokenExpiresAt,
|
|
557
|
+
JSON.stringify(userInfo),
|
|
558
|
+
SESSION_STATUS.ACTIVE,
|
|
559
|
+
now,
|
|
560
|
+
null,
|
|
561
|
+
);
|
|
562
|
+
return getSession(db, id);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function getSession(db, sessionId) {
|
|
566
|
+
const row = db
|
|
567
|
+
.prepare(`SELECT * FROM sso_sessions WHERE id = ?`)
|
|
568
|
+
.get(sessionId);
|
|
569
|
+
return _mapSessionRow(row);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function listSessions(db, filter = {}) {
|
|
573
|
+
const rows = db
|
|
574
|
+
.prepare(`SELECT * FROM sso_sessions ORDER BY created_at DESC`)
|
|
575
|
+
.all();
|
|
576
|
+
let out = rows.map(_mapSessionRow);
|
|
577
|
+
if (filter.configId) out = out.filter((s) => s.configId === filter.configId);
|
|
578
|
+
if (filter.status) out = out.filter((s) => s.status === filter.status);
|
|
579
|
+
if (filter.did) out = out.filter((s) => s.did === filter.did);
|
|
580
|
+
if (Number.isInteger(filter.limit) && filter.limit > 0)
|
|
581
|
+
out = out.slice(0, filter.limit);
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function listActiveSessions(db, filter = {}) {
|
|
586
|
+
return listSessions(db, { ...filter, status: SESSION_STATUS.ACTIVE });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function refreshSessionTokens(db, sessionId, tokens = {}, opts = {}) {
|
|
590
|
+
const current = getSession(db, sessionId);
|
|
591
|
+
if (!current) throw new Error(`Session not found: ${sessionId}`);
|
|
592
|
+
const { masterKey = null, tokenExpiresAt = null } = opts;
|
|
593
|
+
const access = masterKey
|
|
594
|
+
? encryptToken(tokens.accessToken, masterKey)
|
|
595
|
+
: tokens.accessToken || null;
|
|
596
|
+
const refresh =
|
|
597
|
+
tokens.refreshToken === undefined
|
|
598
|
+
? null
|
|
599
|
+
: masterKey
|
|
600
|
+
? encryptToken(tokens.refreshToken, masterKey)
|
|
601
|
+
: tokens.refreshToken;
|
|
602
|
+
const idtok =
|
|
603
|
+
tokens.idToken === undefined
|
|
604
|
+
? null
|
|
605
|
+
: masterKey
|
|
606
|
+
? encryptToken(tokens.idToken, masterKey)
|
|
607
|
+
: tokens.idToken;
|
|
608
|
+
const now = _now();
|
|
609
|
+
|
|
610
|
+
const accessJson = access === null ? null : JSON.stringify(access);
|
|
611
|
+
const refreshJson =
|
|
612
|
+
refresh === null
|
|
613
|
+
? tokens.refreshToken === undefined
|
|
614
|
+
? null
|
|
615
|
+
: null
|
|
616
|
+
: JSON.stringify(refresh);
|
|
617
|
+
const idJson =
|
|
618
|
+
idtok === null
|
|
619
|
+
? tokens.idToken === undefined
|
|
620
|
+
? null
|
|
621
|
+
: null
|
|
622
|
+
: JSON.stringify(idtok);
|
|
623
|
+
|
|
624
|
+
if (tokens.refreshToken === undefined && tokens.idToken === undefined) {
|
|
625
|
+
db.prepare(
|
|
626
|
+
`
|
|
627
|
+
UPDATE sso_sessions
|
|
628
|
+
SET access_token = ?, token_expires_at = ?, last_refreshed = ?, status = ?
|
|
629
|
+
WHERE id = ?
|
|
630
|
+
`,
|
|
631
|
+
).run(accessJson, tokenExpiresAt, now, SESSION_STATUS.ACTIVE, sessionId);
|
|
632
|
+
} else {
|
|
633
|
+
db.prepare(
|
|
634
|
+
`
|
|
635
|
+
UPDATE sso_sessions
|
|
636
|
+
SET access_token = ?, refresh_token = ?, id_token = ?, token_expires_at = ?, last_refreshed = ?, status = ?
|
|
637
|
+
WHERE id = ?
|
|
638
|
+
`,
|
|
639
|
+
).run(
|
|
640
|
+
accessJson,
|
|
641
|
+
refreshJson,
|
|
642
|
+
idJson,
|
|
643
|
+
tokenExpiresAt,
|
|
644
|
+
now,
|
|
645
|
+
SESSION_STATUS.ACTIVE,
|
|
646
|
+
sessionId,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return getSession(db, sessionId);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function destroySession(db, sessionId) {
|
|
653
|
+
const current = getSession(db, sessionId);
|
|
654
|
+
if (!current) return { deleted: false };
|
|
655
|
+
db.prepare(`UPDATE sso_sessions SET status = ? WHERE id = ?`).run(
|
|
656
|
+
SESSION_STATUS.REVOKED,
|
|
657
|
+
sessionId,
|
|
658
|
+
);
|
|
659
|
+
return { deleted: true };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export function expireSession(db, sessionId) {
|
|
663
|
+
db.prepare(`UPDATE sso_sessions SET status = ? WHERE id = ?`).run(
|
|
664
|
+
SESSION_STATUS.EXPIRED,
|
|
665
|
+
sessionId,
|
|
666
|
+
);
|
|
667
|
+
return getSession(db, sessionId);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function isSessionValid(db, sessionId, now = _now()) {
|
|
671
|
+
const s = getSession(db, sessionId);
|
|
672
|
+
if (!s) return false;
|
|
673
|
+
if (s.status !== SESSION_STATUS.ACTIVE) return false;
|
|
674
|
+
if (s.tokenExpiresAt && s.tokenExpiresAt < now) return false;
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* ── Identity bridge (DID ↔ SSO) ───────────────────────────── */
|
|
679
|
+
|
|
680
|
+
export function linkIdentity(db, input = {}) {
|
|
681
|
+
const {
|
|
682
|
+
did,
|
|
683
|
+
ssoProvider,
|
|
684
|
+
ssoUserId,
|
|
685
|
+
ssoEmail = null,
|
|
686
|
+
ssoDisplayName = null,
|
|
687
|
+
attributes = {},
|
|
688
|
+
} = input;
|
|
689
|
+
if (!did || !ssoProvider || !ssoUserId) {
|
|
690
|
+
throw new Error("did, ssoProvider, ssoUserId are required");
|
|
691
|
+
}
|
|
692
|
+
const existing = db
|
|
693
|
+
.prepare(
|
|
694
|
+
`SELECT * FROM sso_identity_mappings WHERE sso_provider = ? AND sso_user_id = ?`,
|
|
695
|
+
)
|
|
696
|
+
.get(ssoProvider, ssoUserId);
|
|
697
|
+
if (existing && existing.did !== did) {
|
|
698
|
+
throw new Error(`SSO identity already linked to DID ${existing.did}`);
|
|
699
|
+
}
|
|
700
|
+
if (existing) {
|
|
701
|
+
db.prepare(
|
|
702
|
+
`
|
|
703
|
+
UPDATE sso_identity_mappings
|
|
704
|
+
SET sso_email = ?, sso_display_name = ?, attributes = ?, last_login = ?
|
|
705
|
+
WHERE id = ?
|
|
706
|
+
`,
|
|
707
|
+
).run(
|
|
708
|
+
ssoEmail,
|
|
709
|
+
ssoDisplayName,
|
|
710
|
+
JSON.stringify(attributes),
|
|
711
|
+
_now(),
|
|
712
|
+
existing.id,
|
|
713
|
+
);
|
|
714
|
+
return _mapMappingRow(
|
|
715
|
+
db
|
|
716
|
+
.prepare(`SELECT * FROM sso_identity_mappings WHERE id = ?`)
|
|
717
|
+
.get(existing.id),
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const id = _id("idmap");
|
|
721
|
+
db.prepare(
|
|
722
|
+
`
|
|
723
|
+
INSERT INTO sso_identity_mappings (id, did, sso_provider, sso_user_id, sso_email, sso_display_name, attributes, linked_at, last_login)
|
|
724
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
725
|
+
`,
|
|
726
|
+
).run(
|
|
727
|
+
id,
|
|
728
|
+
did,
|
|
729
|
+
ssoProvider,
|
|
730
|
+
ssoUserId,
|
|
731
|
+
ssoEmail,
|
|
732
|
+
ssoDisplayName,
|
|
733
|
+
JSON.stringify(attributes),
|
|
734
|
+
_now(),
|
|
735
|
+
_now(),
|
|
736
|
+
);
|
|
737
|
+
return _mapMappingRow(
|
|
738
|
+
db.prepare(`SELECT * FROM sso_identity_mappings WHERE id = ?`).get(id),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function unlinkIdentity(db, did, ssoProvider) {
|
|
743
|
+
if (!did || !ssoProvider) throw new Error("did and ssoProvider are required");
|
|
744
|
+
const rows = db
|
|
745
|
+
.prepare(
|
|
746
|
+
`SELECT id FROM sso_identity_mappings WHERE did = ? AND sso_provider = ?`,
|
|
747
|
+
)
|
|
748
|
+
.all(did, ssoProvider);
|
|
749
|
+
if (rows.length === 0) return { unlinked: false };
|
|
750
|
+
db.prepare(
|
|
751
|
+
`DELETE FROM sso_identity_mappings WHERE did = ? AND sso_provider = ?`,
|
|
752
|
+
).run(did, ssoProvider);
|
|
753
|
+
return { unlinked: true, count: rows.length };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export function getSSOIdentities(db, did) {
|
|
757
|
+
if (!did) return [];
|
|
758
|
+
const rows = db
|
|
759
|
+
.prepare(
|
|
760
|
+
`SELECT * FROM sso_identity_mappings WHERE did = ? ORDER BY linked_at DESC`,
|
|
761
|
+
)
|
|
762
|
+
.all(did);
|
|
763
|
+
return rows.map(_mapMappingRow);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function getDIDForSSO(db, ssoProvider, ssoUserId) {
|
|
767
|
+
if (!ssoProvider || !ssoUserId) return null;
|
|
768
|
+
const row = db
|
|
769
|
+
.prepare(
|
|
770
|
+
`SELECT * FROM sso_identity_mappings WHERE sso_provider = ? AND sso_user_id = ?`,
|
|
771
|
+
)
|
|
772
|
+
.get(ssoProvider, ssoUserId);
|
|
773
|
+
return row ? _mapMappingRow(row) : null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function listIdentityMappings(db, filter = {}) {
|
|
777
|
+
const rows = db
|
|
778
|
+
.prepare(`SELECT * FROM sso_identity_mappings ORDER BY linked_at DESC`)
|
|
779
|
+
.all();
|
|
780
|
+
let out = rows.map(_mapMappingRow);
|
|
781
|
+
if (filter.ssoProvider)
|
|
782
|
+
out = out.filter((m) => m.ssoProvider === filter.ssoProvider);
|
|
783
|
+
if (filter.did) out = out.filter((m) => m.did === filter.did);
|
|
784
|
+
if (Number.isInteger(filter.limit) && filter.limit > 0)
|
|
785
|
+
out = out.slice(0, filter.limit);
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function checkIdentityConflict(db, ssoProvider, ssoUserId) {
|
|
790
|
+
const existing = getDIDForSSO(db, ssoProvider, ssoUserId);
|
|
791
|
+
return existing
|
|
792
|
+
? { conflict: true, did: existing.did, mapping: existing }
|
|
793
|
+
: { conflict: false };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/* ── Stats ─────────────────────────────────────────────────── */
|
|
797
|
+
|
|
798
|
+
export function getStats(db) {
|
|
799
|
+
const configs = listConfigurations(db);
|
|
800
|
+
const sessions = listSessions(db);
|
|
801
|
+
const mappings = listIdentityMappings(db);
|
|
802
|
+
|
|
803
|
+
const byProtocol = {};
|
|
804
|
+
const byProviderType = {};
|
|
805
|
+
for (const c of configs) {
|
|
806
|
+
byProtocol[c.protocol] = (byProtocol[c.protocol] || 0) + 1;
|
|
807
|
+
byProviderType[c.providerType] = (byProviderType[c.providerType] || 0) + 1;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const sessionStatus = { active: 0, expired: 0, revoked: 0 };
|
|
811
|
+
for (const s of sessions) {
|
|
812
|
+
if (sessionStatus[s.status] !== undefined) sessionStatus[s.status]++;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const mappingsByProvider = {};
|
|
816
|
+
const didsWithSSO = new Set();
|
|
817
|
+
for (const m of mappings) {
|
|
818
|
+
mappingsByProvider[m.ssoProvider] =
|
|
819
|
+
(mappingsByProvider[m.ssoProvider] || 0) + 1;
|
|
820
|
+
didsWithSSO.add(m.did);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
configurations: {
|
|
825
|
+
total: configs.length,
|
|
826
|
+
enabled: configs.filter((c) => c.enabled).length,
|
|
827
|
+
disabled: configs.filter((c) => !c.enabled).length,
|
|
828
|
+
byProtocol,
|
|
829
|
+
byProviderType,
|
|
830
|
+
},
|
|
831
|
+
sessions: {
|
|
832
|
+
total: sessions.length,
|
|
833
|
+
...sessionStatus,
|
|
834
|
+
},
|
|
835
|
+
identities: {
|
|
836
|
+
totalMappings: mappings.length,
|
|
837
|
+
uniqueDIDs: didsWithSSO.size,
|
|
838
|
+
byProvider: mappingsByProvider,
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
}
|