speclock 2.5.0 → 3.5.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/README.md +99 -5
- package/package.json +15 -3
- package/src/cli/index.js +314 -1
- package/src/core/auth.js +341 -0
- package/src/core/compliance.js +1 -1
- package/src/core/crypto.js +158 -0
- package/src/core/engine.js +62 -0
- package/src/core/policy.js +719 -0
- package/src/core/sso.js +386 -0
- package/src/core/storage.js +23 -4
- package/src/core/telemetry.js +281 -0
- package/src/dashboard/index.html +338 -0
- package/src/mcp/http-server.js +248 -3
- package/src/mcp/server.js +172 -1
package/src/core/sso.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock OAuth/OIDC SSO Framework (v3.5)
|
|
3
|
+
* Integrates with corporate identity providers (Okta, Azure AD, Auth0).
|
|
4
|
+
* OAuth 2.0 Authorization Code flow with PKCE.
|
|
5
|
+
* Token-based session management for HTTP server.
|
|
6
|
+
*
|
|
7
|
+
* Configuration via .speclock/sso.json or environment variables:
|
|
8
|
+
* SPECLOCK_SSO_ISSUER — OIDC issuer URL
|
|
9
|
+
* SPECLOCK_SSO_CLIENT_ID — OAuth client ID
|
|
10
|
+
* SPECLOCK_SSO_CLIENT_SECRET — OAuth client secret
|
|
11
|
+
* SPECLOCK_SSO_REDIRECT_URI — Callback URL (default: http://localhost:3000/auth/callback)
|
|
12
|
+
*
|
|
13
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import path from "path";
|
|
18
|
+
import crypto from "crypto";
|
|
19
|
+
|
|
20
|
+
const SSO_CONFIG_FILE = "sso.json";
|
|
21
|
+
const TOKEN_STORE_FILE = "sso-tokens.json";
|
|
22
|
+
|
|
23
|
+
// --- Config ---
|
|
24
|
+
|
|
25
|
+
function ssoConfigPath(root) {
|
|
26
|
+
return path.join(root, ".speclock", SSO_CONFIG_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tokenStorePath(root) {
|
|
30
|
+
return path.join(root, ".speclock", TOKEN_STORE_FILE);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if SSO is configured
|
|
35
|
+
*/
|
|
36
|
+
export function isSSOEnabled(root) {
|
|
37
|
+
const config = getSSOConfig(root);
|
|
38
|
+
return !!(config.issuer && config.clientId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get SSO configuration from file or env vars
|
|
43
|
+
*/
|
|
44
|
+
export function getSSOConfig(root) {
|
|
45
|
+
// Try file first
|
|
46
|
+
const p = ssoConfigPath(root);
|
|
47
|
+
let config = {};
|
|
48
|
+
if (fs.existsSync(p)) {
|
|
49
|
+
try {
|
|
50
|
+
config = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
51
|
+
} catch {
|
|
52
|
+
config = {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Env vars override file
|
|
57
|
+
return {
|
|
58
|
+
issuer: process.env.SPECLOCK_SSO_ISSUER || config.issuer || "",
|
|
59
|
+
clientId: process.env.SPECLOCK_SSO_CLIENT_ID || config.clientId || "",
|
|
60
|
+
clientSecret: process.env.SPECLOCK_SSO_CLIENT_SECRET || config.clientSecret || "",
|
|
61
|
+
redirectUri: process.env.SPECLOCK_SSO_REDIRECT_URI || config.redirectUri || "http://localhost:3000/auth/callback",
|
|
62
|
+
scopes: config.scopes || ["openid", "profile", "email"],
|
|
63
|
+
roleMapping: config.roleMapping || {
|
|
64
|
+
// Map OIDC groups/roles to SpecLock roles
|
|
65
|
+
// e.g., { "speclock-admin": "admin", "speclock-dev": "developer" }
|
|
66
|
+
},
|
|
67
|
+
defaultRole: config.defaultRole || "viewer",
|
|
68
|
+
sessionTtlMinutes: config.sessionTtlMinutes || 480, // 8 hours
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Save SSO configuration to file
|
|
74
|
+
*/
|
|
75
|
+
export function saveSSOConfig(root, config) {
|
|
76
|
+
const p = ssoConfigPath(root);
|
|
77
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2));
|
|
78
|
+
|
|
79
|
+
// Ensure gitignored
|
|
80
|
+
const giPath = path.join(root, ".speclock", ".gitignore");
|
|
81
|
+
let giContent = "";
|
|
82
|
+
if (fs.existsSync(giPath)) {
|
|
83
|
+
giContent = fs.readFileSync(giPath, "utf-8");
|
|
84
|
+
}
|
|
85
|
+
for (const file of [SSO_CONFIG_FILE, TOKEN_STORE_FILE]) {
|
|
86
|
+
if (!giContent.includes(file)) {
|
|
87
|
+
const line = giContent.endsWith("\n") || giContent === "" ? file + "\n" : "\n" + file + "\n";
|
|
88
|
+
fs.appendFileSync(giPath, line);
|
|
89
|
+
giContent += line;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { success: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- PKCE helpers ---
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate PKCE code verifier (43-128 chars, [A-Za-z0-9-._~])
|
|
100
|
+
*/
|
|
101
|
+
export function generateCodeVerifier() {
|
|
102
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Generate PKCE code challenge from verifier (S256)
|
|
107
|
+
*/
|
|
108
|
+
export function generateCodeChallenge(verifier) {
|
|
109
|
+
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- OAuth 2.0 Authorization Code Flow ---
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate the authorization URL for user redirect
|
|
116
|
+
*/
|
|
117
|
+
export function getAuthorizationUrl(root, state) {
|
|
118
|
+
const config = getSSOConfig(root);
|
|
119
|
+
if (!config.issuer || !config.clientId) {
|
|
120
|
+
return { success: false, error: "SSO not configured. Set issuer and clientId." };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const codeVerifier = generateCodeVerifier();
|
|
124
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
125
|
+
const stateParam = state || crypto.randomBytes(16).toString("hex");
|
|
126
|
+
|
|
127
|
+
// Store PKCE verifier and state for callback validation
|
|
128
|
+
const pendingAuth = {
|
|
129
|
+
state: stateParam,
|
|
130
|
+
codeVerifier,
|
|
131
|
+
createdAt: new Date().toISOString(),
|
|
132
|
+
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 min
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const storePath = tokenStorePath(root);
|
|
136
|
+
let store = {};
|
|
137
|
+
if (fs.existsSync(storePath)) {
|
|
138
|
+
try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { store = {}; }
|
|
139
|
+
}
|
|
140
|
+
if (!store.pending) store.pending = {};
|
|
141
|
+
store.pending[stateParam] = pendingAuth;
|
|
142
|
+
fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
|
|
143
|
+
|
|
144
|
+
const params = new URLSearchParams({
|
|
145
|
+
response_type: "code",
|
|
146
|
+
client_id: config.clientId,
|
|
147
|
+
redirect_uri: config.redirectUri,
|
|
148
|
+
scope: config.scopes.join(" "),
|
|
149
|
+
state: stateParam,
|
|
150
|
+
code_challenge: codeChallenge,
|
|
151
|
+
code_challenge_method: "S256",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const authUrl = `${config.issuer}/authorize?${params.toString()}`;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
success: true,
|
|
158
|
+
url: authUrl,
|
|
159
|
+
state: stateParam,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Exchange authorization code for tokens (callback handler)
|
|
165
|
+
*/
|
|
166
|
+
export async function handleCallback(root, code, state) {
|
|
167
|
+
const config = getSSOConfig(root);
|
|
168
|
+
const storePath = tokenStorePath(root);
|
|
169
|
+
|
|
170
|
+
let store = {};
|
|
171
|
+
if (fs.existsSync(storePath)) {
|
|
172
|
+
try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { store = {}; }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Validate state
|
|
176
|
+
const pending = store.pending?.[state];
|
|
177
|
+
if (!pending) {
|
|
178
|
+
return { success: false, error: "Invalid or expired state parameter." };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check expiration
|
|
182
|
+
if (new Date(pending.expiresAt) < new Date()) {
|
|
183
|
+
delete store.pending[state];
|
|
184
|
+
fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
|
|
185
|
+
return { success: false, error: "Authorization request expired." };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Exchange code for tokens
|
|
189
|
+
const tokenEndpoint = `${config.issuer}/oauth/token`;
|
|
190
|
+
const body = new URLSearchParams({
|
|
191
|
+
grant_type: "authorization_code",
|
|
192
|
+
client_id: config.clientId,
|
|
193
|
+
client_secret: config.clientSecret,
|
|
194
|
+
code,
|
|
195
|
+
redirect_uri: config.redirectUri,
|
|
196
|
+
code_verifier: pending.codeVerifier,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const response = await fetch(tokenEndpoint, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
203
|
+
body: body.toString(),
|
|
204
|
+
signal: AbortSignal.timeout(10000),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
const error = await response.text();
|
|
209
|
+
return { success: false, error: `Token exchange failed: ${error}` };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const tokens = await response.json();
|
|
213
|
+
|
|
214
|
+
// Parse ID token to get user info
|
|
215
|
+
const userInfo = parseIdToken(tokens.id_token);
|
|
216
|
+
|
|
217
|
+
// Map user's groups/roles to SpecLock role
|
|
218
|
+
const role = mapToSpecLockRole(config, userInfo);
|
|
219
|
+
|
|
220
|
+
// Create session
|
|
221
|
+
const sessionId = crypto.randomBytes(16).toString("hex");
|
|
222
|
+
const session = {
|
|
223
|
+
sessionId,
|
|
224
|
+
userId: userInfo.sub || userInfo.email || "unknown",
|
|
225
|
+
email: userInfo.email || "",
|
|
226
|
+
name: userInfo.name || "",
|
|
227
|
+
role,
|
|
228
|
+
accessToken: tokens.access_token,
|
|
229
|
+
refreshToken: tokens.refresh_token || null,
|
|
230
|
+
idToken: tokens.id_token || null,
|
|
231
|
+
expiresAt: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString(),
|
|
232
|
+
createdAt: new Date().toISOString(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Store session
|
|
236
|
+
if (!store.sessions) store.sessions = {};
|
|
237
|
+
store.sessions[sessionId] = session;
|
|
238
|
+
|
|
239
|
+
// Clean up pending auth
|
|
240
|
+
delete store.pending[state];
|
|
241
|
+
|
|
242
|
+
fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
success: true,
|
|
246
|
+
sessionId,
|
|
247
|
+
userId: session.userId,
|
|
248
|
+
email: session.email,
|
|
249
|
+
name: session.name,
|
|
250
|
+
role,
|
|
251
|
+
expiresAt: session.expiresAt,
|
|
252
|
+
};
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return { success: false, error: `Token exchange error: ${err.message}` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Validate an SSO session token
|
|
260
|
+
*/
|
|
261
|
+
export function validateSession(root, sessionId) {
|
|
262
|
+
if (!sessionId) {
|
|
263
|
+
return { valid: false, error: "Session ID required." };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const storePath = tokenStorePath(root);
|
|
267
|
+
if (!fs.existsSync(storePath)) {
|
|
268
|
+
return { valid: false, error: "No SSO sessions." };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let store;
|
|
272
|
+
try {
|
|
273
|
+
store = JSON.parse(fs.readFileSync(storePath, "utf-8"));
|
|
274
|
+
} catch {
|
|
275
|
+
return { valid: false, error: "Corrupted token store." };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const session = store.sessions?.[sessionId];
|
|
279
|
+
if (!session) {
|
|
280
|
+
return { valid: false, error: "Session not found." };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Check expiration
|
|
284
|
+
if (new Date(session.expiresAt) < new Date()) {
|
|
285
|
+
delete store.sessions[sessionId];
|
|
286
|
+
fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
|
|
287
|
+
return { valid: false, error: "Session expired." };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
valid: true,
|
|
292
|
+
userId: session.userId,
|
|
293
|
+
email: session.email,
|
|
294
|
+
name: session.name,
|
|
295
|
+
role: session.role,
|
|
296
|
+
expiresAt: session.expiresAt,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Revoke/logout an SSO session
|
|
302
|
+
*/
|
|
303
|
+
export function revokeSession(root, sessionId) {
|
|
304
|
+
const storePath = tokenStorePath(root);
|
|
305
|
+
if (!fs.existsSync(storePath)) return { success: false, error: "No sessions." };
|
|
306
|
+
|
|
307
|
+
let store;
|
|
308
|
+
try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { return { success: false }; }
|
|
309
|
+
|
|
310
|
+
if (!store.sessions?.[sessionId]) {
|
|
311
|
+
return { success: false, error: "Session not found." };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
delete store.sessions[sessionId];
|
|
315
|
+
fs.writeFileSync(storePath, JSON.stringify(store, null, 2));
|
|
316
|
+
return { success: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* List active SSO sessions
|
|
321
|
+
*/
|
|
322
|
+
export function listSessions(root) {
|
|
323
|
+
const storePath = tokenStorePath(root);
|
|
324
|
+
if (!fs.existsSync(storePath)) return { sessions: [], total: 0 };
|
|
325
|
+
|
|
326
|
+
let store;
|
|
327
|
+
try { store = JSON.parse(fs.readFileSync(storePath, "utf-8")); } catch { return { sessions: [], total: 0 }; }
|
|
328
|
+
|
|
329
|
+
const now = new Date();
|
|
330
|
+
const sessions = Object.values(store.sessions || {})
|
|
331
|
+
.filter(s => new Date(s.expiresAt) > now)
|
|
332
|
+
.map(s => ({
|
|
333
|
+
sessionId: s.sessionId,
|
|
334
|
+
userId: s.userId,
|
|
335
|
+
email: s.email,
|
|
336
|
+
name: s.name,
|
|
337
|
+
role: s.role,
|
|
338
|
+
expiresAt: s.expiresAt,
|
|
339
|
+
createdAt: s.createdAt,
|
|
340
|
+
}));
|
|
341
|
+
|
|
342
|
+
return { sessions, total: sessions.length };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Token parsing ---
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Parse JWT ID token payload (without verification — verification done by IdP)
|
|
349
|
+
*/
|
|
350
|
+
function parseIdToken(idToken) {
|
|
351
|
+
if (!idToken) return {};
|
|
352
|
+
try {
|
|
353
|
+
const parts = idToken.split(".");
|
|
354
|
+
if (parts.length !== 3) return {};
|
|
355
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
356
|
+
return payload;
|
|
357
|
+
} catch {
|
|
358
|
+
return {};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Map OIDC user info to a SpecLock role
|
|
364
|
+
*/
|
|
365
|
+
function mapToSpecLockRole(config, userInfo) {
|
|
366
|
+
const roleMapping = config.roleMapping || {};
|
|
367
|
+
|
|
368
|
+
// Check user's groups
|
|
369
|
+
const groups = userInfo.groups || userInfo["cognito:groups"] || [];
|
|
370
|
+
for (const group of groups) {
|
|
371
|
+
if (roleMapping[group]) return roleMapping[group];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Check user's roles claim
|
|
375
|
+
const roles = userInfo.roles || userInfo.realm_access?.roles || [];
|
|
376
|
+
for (const role of roles) {
|
|
377
|
+
if (roleMapping[role]) return roleMapping[role];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Check email domain mapping
|
|
381
|
+
if (userInfo.email && roleMapping["@" + userInfo.email.split("@")[1]]) {
|
|
382
|
+
return roleMapping["@" + userInfo.email.split("@")[1]];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return config.defaultRole || "viewer";
|
|
386
|
+
}
|
package/src/core/storage.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
4
|
import { signEvent, isAuditEnabled } from "./audit.js";
|
|
5
|
+
import { isEncryptionEnabled, encrypt, decrypt, isEncrypted } from "./crypto.js";
|
|
5
6
|
|
|
6
7
|
export function nowIso() {
|
|
7
8
|
return new Date().toISOString();
|
|
@@ -121,7 +122,11 @@ export function migrateBrainV1toV2(brain) {
|
|
|
121
122
|
export function readBrain(root) {
|
|
122
123
|
const p = brainPath(root);
|
|
123
124
|
if (!fs.existsSync(p)) return null;
|
|
124
|
-
|
|
125
|
+
let raw = fs.readFileSync(p, "utf8");
|
|
126
|
+
// Transparent decryption (v3.0)
|
|
127
|
+
if (isEncrypted(raw)) {
|
|
128
|
+
try { raw = decrypt(raw); } catch { return null; }
|
|
129
|
+
}
|
|
125
130
|
let brain = JSON.parse(raw);
|
|
126
131
|
if (brain.version < 2) {
|
|
127
132
|
brain = migrateBrainV1toV2(brain);
|
|
@@ -137,7 +142,12 @@ export function readBrain(root) {
|
|
|
137
142
|
export function writeBrain(root, brain) {
|
|
138
143
|
brain.project.updatedAt = nowIso();
|
|
139
144
|
const p = brainPath(root);
|
|
140
|
-
|
|
145
|
+
let data = JSON.stringify(brain, null, 2);
|
|
146
|
+
// Transparent encryption (v3.0)
|
|
147
|
+
if (isEncryptionEnabled()) {
|
|
148
|
+
data = encrypt(data);
|
|
149
|
+
}
|
|
150
|
+
fs.writeFileSync(p, data);
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
export function appendEvent(root, event) {
|
|
@@ -149,7 +159,11 @@ export function appendEvent(root, event) {
|
|
|
149
159
|
} catch {
|
|
150
160
|
// Audit error — write event without hash (graceful degradation)
|
|
151
161
|
}
|
|
152
|
-
|
|
162
|
+
let line = JSON.stringify(event);
|
|
163
|
+
// Transparent per-line encryption (v3.0)
|
|
164
|
+
if (isEncryptionEnabled()) {
|
|
165
|
+
line = encrypt(line);
|
|
166
|
+
}
|
|
153
167
|
fs.appendFileSync(eventsPath(root), `${line}\n`);
|
|
154
168
|
}
|
|
155
169
|
|
|
@@ -163,7 +177,12 @@ export function readEvents(root, opts = {}) {
|
|
|
163
177
|
|
|
164
178
|
let events = raw.split("\n").map((line) => {
|
|
165
179
|
try {
|
|
166
|
-
|
|
180
|
+
// Transparent per-line decryption (v3.0)
|
|
181
|
+
let decoded = line;
|
|
182
|
+
if (isEncrypted(decoded)) {
|
|
183
|
+
try { decoded = decrypt(decoded); } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
return JSON.parse(decoded);
|
|
167
186
|
} catch {
|
|
168
187
|
return null;
|
|
169
188
|
}
|