shraga 0.1.112 → 0.1.113
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 +2 -0
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +1 -1
- package/dist/client/assets/index-DIDtPQb-.css +10 -0
- package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +61 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/lib/api.ts +21 -5
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/api-key-routes.ts +64 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +158 -104
- package/src/server/claude.ts +98 -3
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +44 -14
- package/src/server/engine/types.ts +7 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +498 -0
- package/src/server/security/enforce.ts +306 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +329 -0
- package/src/server/security/owner-only.ts +15 -0
- package/src/server/security/owner-routes.ts +43 -0
- package/src/server/security/policy.ts +413 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +174 -0
- package/src/server/sessions.ts +35 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-DIMte_k6.css +0 -10
- package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
package/src/server/auth.ts
CHANGED
|
@@ -28,7 +28,25 @@ const _origGetKey = (JwtHelper as any)['getGooglePublicKey'].bind(JwtHelper);
|
|
|
28
28
|
return p;
|
|
29
29
|
};
|
|
30
30
|
import { dataPath } from './paths.ts';
|
|
31
|
-
import { validateApiKey } from './api-keys.ts';
|
|
31
|
+
import { apiKeyPrincipal, validateApiKey } from './api-keys.ts';
|
|
32
|
+
import { isOwnerEmail } from './owners.ts';
|
|
33
|
+
import { fromAuthUser, fromInternal, type Principal } from './security/principal.ts';
|
|
34
|
+
import { security } from './security/runtime.ts';
|
|
35
|
+
import { firebaseIssuedAt, tokenRevoked } from './security/revocation.ts';
|
|
36
|
+
|
|
37
|
+
// Issued-at (`iat`) + revocation: every token format below carries `iat` for NEW tokens, in a shape the pre-iat
|
|
38
|
+
// verifier REJECTS (never mis-parses into a different identity) — so for these tokens a rollback only forces re-auth.
|
|
39
|
+
// API keys are NOT covered: pre-hashing code breaks on hashed entries, so a rollback invalidates every key (see the
|
|
40
|
+
// Rollback note in api-keys.ts). Old tokens
|
|
41
|
+
// without iat still verify here, with an implied iat (see security/revocation.ts). Checked against
|
|
42
|
+
// policy.tokensValidAfter for the token's principal: an in-memory lookup, no I/O.
|
|
43
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
44
|
+
const hmacEq = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
45
|
+
const userPrincipalId = (uid: string, email: string) => fromAuthUser({ uid, email }).id;
|
|
46
|
+
|
|
47
|
+
/** Fixed TTLs (seconds). Legacy tokens without iat are treated as issued at `exp - TTL`. */
|
|
48
|
+
export const MCP_TOKEN_TTL = { access: 3600, refresh: 60 * 60 * 24 * 30 } as const;
|
|
49
|
+
export const LOCAL_TOKEN_TTL = 30 * 24 * 3600;
|
|
32
50
|
|
|
33
51
|
/** Server secret for signing scoped internal tokens — stable per startup. */
|
|
34
52
|
const INTERNAL_SECRET = process.env.INTERNAL_API_TOKEN || randomBytes(32).toString('hex');
|
|
@@ -36,23 +54,36 @@ process.env.INTERNAL_API_TOKEN = INTERNAL_SECRET;
|
|
|
36
54
|
const tmpDir = join(dataPath('..'), '.tmp');
|
|
37
55
|
try { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, '.internal-token'), INTERNAL_SECRET); } catch (e: any) { console.error('[auth] failed to write .internal-token:', e.message); }
|
|
38
56
|
|
|
39
|
-
/** Sign a scoped internal token embedding user identity. Agent subprocess uses this as INTERNAL_API_TOKEN — single env var carries both auth + identity.
|
|
57
|
+
/** Sign a scoped internal token embedding user identity. Agent subprocess uses this as INTERNAL_API_TOKEN — single env var carries both auth + identity.
|
|
58
|
+
* Format `<sig64>.<iat>:<uid>:<email>`, sig = HMAC("v2:<iat>:<uid>:<email>"). (Legacy: `<sig64>:<uid>:<email>`, no iat.) */
|
|
40
59
|
export function signInternalToken(uid: string, email: string): string {
|
|
41
|
-
const
|
|
42
|
-
const sig = createHmac('sha256', INTERNAL_SECRET).update(
|
|
43
|
-
return `${sig}
|
|
60
|
+
const rest = `${nowSec()}:${uid}:${email}`;
|
|
61
|
+
const sig = createHmac('sha256', INTERNAL_SECRET).update(`v2:${rest}`).digest('hex');
|
|
62
|
+
return `${sig}.${rest}`;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
|
-
/** Verify and extract user identity from a scoped internal token. Falls back to legacy global token check.
|
|
65
|
+
/** Verify and extract user identity from a scoped internal token. Falls back to legacy global token check.
|
|
66
|
+
* Revocation: rejected if issued before tokensValidAfter of `internal:<uid>` or of the user it acts for. */
|
|
47
67
|
function verifyInternalToken(token: string): { uid: string; email: string } | null {
|
|
68
|
+
const scoped = (uid: string, email: string, iat: number) =>
|
|
69
|
+
tokenRevoked([fromInternal({ uid }).id, userPrincipalId(uid, email)], iat) ? null : { uid, email };
|
|
70
|
+
if (token[64] === '.') {
|
|
71
|
+
const rest = token.slice(65);
|
|
72
|
+
if (!hmacEq(token.slice(0, 64), createHmac('sha256', INTERNAL_SECRET).update(`v2:${rest}`).digest('hex'))) return null;
|
|
73
|
+
const [iatStr, uid, ...emailParts] = rest.split(':');
|
|
74
|
+
const iat = Number(iatStr);
|
|
75
|
+
if (!Number.isInteger(iat) || !uid || !emailParts.length) return null;
|
|
76
|
+
return scoped(uid, emailParts.join(':'), iat);
|
|
77
|
+
}
|
|
48
78
|
const firstColon = token.indexOf(':');
|
|
49
79
|
if (firstColon === 64) {
|
|
50
80
|
const sig = token.slice(0, 64);
|
|
51
81
|
const payload = token.slice(65);
|
|
52
82
|
const expected = createHmac('sha256', INTERNAL_SECRET).update(payload).digest('hex');
|
|
53
|
-
if (
|
|
83
|
+
if (hmacEq(sig, expected)) {
|
|
54
84
|
const sepIdx = payload.indexOf(':');
|
|
55
|
-
|
|
85
|
+
// Legacy scoped token: no exp, no iat ⇒ issued at 0, so any revocation of the principal kills it.
|
|
86
|
+
if (sepIdx > 0) return scoped(payload.slice(0, sepIdx), payload.slice(sepIdx + 1), 0);
|
|
56
87
|
}
|
|
57
88
|
return null;
|
|
58
89
|
}
|
|
@@ -91,9 +122,10 @@ function getMcpSecret(): string {
|
|
|
91
122
|
* Provider-agnostic: identity is established upstream by requireAuth (Firebase today,
|
|
92
123
|
* email-password later) — this only carries the resulting {uid, email}. No storage needed.
|
|
93
124
|
*/
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
125
|
+
/** Payload `mcp2:kind:uid:email:iat:exp`. (Legacy `mcp:kind:uid:email:exp` — the pre-iat verifier rejects `mcp2`.) */
|
|
126
|
+
export function signMcpToken(uid: string, email: string, kind: 'access' | 'refresh' = 'access', ttlSec: number = MCP_TOKEN_TTL[kind]): string {
|
|
127
|
+
const iat = nowSec();
|
|
128
|
+
const payload = `mcp2:${kind}:${uid}:${email}:${iat}:${iat + ttlSec}`;
|
|
97
129
|
const sig = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
|
|
98
130
|
return `mcp_${sig}.${Buffer.from(payload).toString('base64url')}`;
|
|
99
131
|
}
|
|
@@ -107,12 +139,19 @@ export function verifyMcpToken(token: string): { uid: string; email: string; kin
|
|
|
107
139
|
let payload: string;
|
|
108
140
|
try { payload = Buffer.from(body.slice(dot + 1), 'base64url').toString(); } catch { return null; }
|
|
109
141
|
const expected = createHmac('sha256', getMcpSecret()).update(payload).digest('hex');
|
|
110
|
-
if (
|
|
111
|
-
const parts = payload.split(':'); //
|
|
112
|
-
|
|
142
|
+
if (!hmacEq(sig, expected)) return null;
|
|
143
|
+
const parts = payload.split(':'); // tag:kind:uid:email[:iat]:exp (uid never contains ':')
|
|
144
|
+
const v2 = parts[0] === 'mcp2';
|
|
145
|
+
if (!(v2 || parts[0] === 'mcp') || parts.length < (v2 ? 6 : 5)) return null;
|
|
146
|
+
const kind = parts[1];
|
|
147
|
+
if (kind !== 'access' && kind !== 'refresh') return null;
|
|
113
148
|
const exp = Number(parts[parts.length - 1]);
|
|
114
|
-
if (!exp ||
|
|
115
|
-
|
|
149
|
+
if (!exp || nowSec() > exp) return null;
|
|
150
|
+
const iat = v2 ? Number(parts[parts.length - 2]) : exp - MCP_TOKEN_TTL[kind];
|
|
151
|
+
if (!Number.isInteger(iat)) return null;
|
|
152
|
+
const uid = parts[2], email = parts.slice(3, parts.length - (v2 ? 2 : 1)).join(':');
|
|
153
|
+
if (tokenRevoked(userPrincipalId(uid, email), iat)) return null;
|
|
154
|
+
return { kind, uid, email };
|
|
116
155
|
}
|
|
117
156
|
|
|
118
157
|
const WHITELIST_PATH = dataPath('whitelist.json');
|
|
@@ -131,20 +170,19 @@ export interface AuthUser {
|
|
|
131
170
|
email: string;
|
|
132
171
|
/** Owners can see all sessions/schedules across users (view-only bypass — mutations still restricted to owner of record). */
|
|
133
172
|
isOwner: boolean;
|
|
173
|
+
/** Who is calling, per auth branch (login → user, api key → apikey, internal token → internal). */
|
|
174
|
+
principal: Principal;
|
|
134
175
|
}
|
|
135
176
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// Emailless tokens (e.g. anonymous Firebase users) are never owners — guard the .toLowerCase() crash.
|
|
146
|
-
return !!email && loadOwners().includes(email.toLowerCase());
|
|
147
|
-
}
|
|
177
|
+
// Owner comes from an interactive login, a server-minted scoped internal token acting for one, or an UNCAPPED API key
|
|
178
|
+
// (a delegated login credential — only an interactive login can mint a key, e.g. `shraga term`'s /cli-auth consent).
|
|
179
|
+
// A role-capped key is never owner. OWNERS is read per call, so removing the creator takes effect on the next request.
|
|
180
|
+
const ownerCapable = (p: Principal) => p.kind === 'user' || p.kind === 'internal' || (p.kind === 'apikey' && typeof p.attrs.role !== 'string');
|
|
181
|
+
const authUser = (uid: string, email: string, principal: Principal): AuthUser => ({
|
|
182
|
+
uid, email, principal, isOwner: ownerCapable(principal) && isOwnerEmail(email),
|
|
183
|
+
});
|
|
184
|
+
const internalUser = (t: { uid: string; email: string }) => authUser(t.uid, t.email, fromInternal(t));
|
|
185
|
+
const apiKeyUser = (k: { id: string; uid: string; email: string; role?: string }) => authUser(k.uid, k.email, apiKeyPrincipal(k));
|
|
148
186
|
|
|
149
187
|
export async function verifyToken(token: string): Promise<AuthUser> {
|
|
150
188
|
const projectId = JSON.parse(process.env.FIREBASE_CONFIG_PROD ?? process.env.VITE_FIREBASE_CONFIG_PROD ?? '{}').projectId;
|
|
@@ -161,7 +199,11 @@ export async function verifyToken(token: string): Promise<AuthUser> {
|
|
|
161
199
|
if (whitelist.length > 0 && !whitelist.includes(payload.email)) {
|
|
162
200
|
throw new Error('User not in whitelist');
|
|
163
201
|
}
|
|
164
|
-
|
|
202
|
+
const uid = payload.user_id || payload.sub;
|
|
203
|
+
const principal = fromAuthUser({ uid, email: payload.email });
|
|
204
|
+
// auth_time = sign-in time; the hourly-refreshed iat would outlive a revocation.
|
|
205
|
+
if (tokenRevoked(principal.id, firebaseIssuedAt(payload))) throw new Error('Token revoked — sign in again');
|
|
206
|
+
return authUser(uid, payload.email, principal);
|
|
165
207
|
}
|
|
166
208
|
|
|
167
209
|
// ── Pluggable auth provider ──────────────────────────────────────────────────
|
|
@@ -201,15 +243,17 @@ function localSecret(): string {
|
|
|
201
243
|
} catch { _localSecret = INTERNAL_SECRET; }
|
|
202
244
|
return _localSecret;
|
|
203
245
|
}
|
|
204
|
-
/** Issue a signed local session token: sha_<sig>.<base64url(email:exp)
|
|
246
|
+
/** Issue a signed local session token: sha_<sig>.<base64url(email:iat:exp)>, sig = HMAC("v2:" + payload).
|
|
247
|
+
* (Legacy: payload `email:exp`, sig over the payload alone — the "v2:" domain separation makes the pre-iat verifier
|
|
248
|
+
* reject new tokens instead of reading `email:iat` as the email.) */
|
|
205
249
|
export function localLogin(email: string, password: string): string | null {
|
|
206
250
|
const rec = loadLocalUsers().find((u) => u.email === email);
|
|
207
251
|
if (!rec) return null;
|
|
208
252
|
const { hash } = hashPw(password, rec.salt);
|
|
209
253
|
if (hash.length !== rec.hash.length || !timingSafeEqual(Buffer.from(hash), Buffer.from(rec.hash))) return null;
|
|
210
|
-
const
|
|
211
|
-
const payload = `${email}:${
|
|
212
|
-
const sig = createHmac('sha256', localSecret()).update(payload).digest('hex');
|
|
254
|
+
const iat = nowSec();
|
|
255
|
+
const payload = `${email}:${iat}:${iat + LOCAL_TOKEN_TTL}`;
|
|
256
|
+
const sig = createHmac('sha256', localSecret()).update(`v2:${payload}`).digest('hex');
|
|
213
257
|
return `sha_${sig}.${Buffer.from(payload).toString('base64url')}`;
|
|
214
258
|
}
|
|
215
259
|
function verifyLocalToken(token: string): AuthUser {
|
|
@@ -219,12 +263,18 @@ function verifyLocalToken(token: string): AuthUser {
|
|
|
219
263
|
if (dot < 0) throw new Error('Malformed token');
|
|
220
264
|
const sig = body.slice(0, dot);
|
|
221
265
|
const payload = Buffer.from(body.slice(dot + 1), 'base64url').toString();
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
266
|
+
const v2 = hmacEq(sig, createHmac('sha256', localSecret()).update(`v2:${payload}`).digest('hex'));
|
|
267
|
+
if (!v2 && !hmacEq(sig, createHmac('sha256', localSecret()).update(payload).digest('hex'))) throw new Error('Bad token signature');
|
|
224
268
|
const sep = payload.lastIndexOf(':');
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
269
|
+
const exp = Number(payload.slice(sep + 1));
|
|
270
|
+
let head = payload.slice(0, sep), iat = exp - LOCAL_TOKEN_TTL;
|
|
271
|
+
if (v2) { const s = head.lastIndexOf(':'); iat = Number(head.slice(s + 1)); head = head.slice(0, s); }
|
|
272
|
+
const email = head;
|
|
273
|
+
if (!Number.isFinite(exp) || nowSec() > exp) throw new Error('Token expired — sign in again');
|
|
274
|
+
const principal = fromAuthUser({ uid: email, email });
|
|
275
|
+
if (!Number.isInteger(iat)) throw new Error('Malformed token');
|
|
276
|
+
if (tokenRevoked(principal.id, iat)) throw new Error('Token revoked — sign in again');
|
|
277
|
+
return authUser(email, email, principal);
|
|
228
278
|
}
|
|
229
279
|
|
|
230
280
|
/** Dispatch bearer verification to the active provider. */
|
|
@@ -239,10 +289,10 @@ export async function authenticateToken(token: string | undefined | null): Promi
|
|
|
239
289
|
if (!token) return null;
|
|
240
290
|
if (token.startsWith('uck_')) {
|
|
241
291
|
const identity = validateApiKey(token);
|
|
242
|
-
return identity ?
|
|
292
|
+
return identity ? apiKeyUser(identity) : null;
|
|
243
293
|
}
|
|
244
294
|
const internal = verifyInternalToken(token);
|
|
245
|
-
if (internal) return
|
|
295
|
+
if (internal) return internalUser(internal);
|
|
246
296
|
try { return await verifyBearer(token); } catch { return null; }
|
|
247
297
|
}
|
|
248
298
|
|
|
@@ -251,26 +301,42 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
|
|
|
251
301
|
if (internalToken) {
|
|
252
302
|
const identity = verifyInternalToken(internalToken);
|
|
253
303
|
if (identity) {
|
|
254
|
-
|
|
304
|
+
const user = internalUser(identity);
|
|
305
|
+
(req as any).user = user;
|
|
306
|
+
security()?.authAllow(user.principal, 'http:internal');
|
|
255
307
|
return next();
|
|
256
308
|
}
|
|
257
309
|
}
|
|
258
310
|
|
|
259
311
|
const token = req.headers.authorization?.replace('Bearer ', '') || (req.query.token as string);
|
|
260
|
-
if (!token)
|
|
312
|
+
if (!token) {
|
|
313
|
+
security()?.authDeny('http', 'missing-token', req.ip);
|
|
314
|
+
return void res.status(401).json({ error: 'Missing token' });
|
|
315
|
+
}
|
|
261
316
|
|
|
262
317
|
// API key auth (uck_…)
|
|
263
318
|
if (token.startsWith('uck_')) {
|
|
264
319
|
const identity = validateApiKey(token);
|
|
265
|
-
if (!identity)
|
|
266
|
-
|
|
320
|
+
if (!identity) {
|
|
321
|
+
security()?.authDeny('http:apikey', 'invalid-api-key', req.ip);
|
|
322
|
+
return void res.status(401).json({ error: 'Invalid API key' });
|
|
323
|
+
}
|
|
324
|
+
const user = apiKeyUser(identity);
|
|
325
|
+
(req as any).user = user;
|
|
326
|
+
security()?.authAllow(user.principal, 'http:apikey');
|
|
267
327
|
return next();
|
|
268
328
|
}
|
|
269
329
|
|
|
330
|
+
let user: AuthUser;
|
|
270
331
|
try {
|
|
271
|
-
|
|
272
|
-
next();
|
|
332
|
+
user = await verifyBearer(token);
|
|
273
333
|
} catch (err: any) {
|
|
274
|
-
|
|
334
|
+
const whitelisted = err?.message?.includes('whitelist');
|
|
335
|
+
const reason = whitelisted ? 'not-whitelisted' : err?.message?.includes('revoked') ? 'token-revoked' : 'invalid-bearer';
|
|
336
|
+
security()?.authDeny('http:bearer', reason, req.ip);
|
|
337
|
+
return void res.status(whitelisted ? 403 : 401).json({ error: err.message });
|
|
275
338
|
}
|
|
339
|
+
(req as any).user = user;
|
|
340
|
+
security()?.authAllow(user.principal, 'http:bearer');
|
|
341
|
+
next();
|
|
276
342
|
}
|