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.
@@ -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
+ }
@@ -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
- const raw = fs.readFileSync(p, "utf8");
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
- fs.writeFileSync(p, JSON.stringify(brain, null, 2));
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
- const line = JSON.stringify(event);
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
- return JSON.parse(line);
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
  }