@lifeaitools/clauth 2.15.3 → 2.15.4
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/.clauth-skill/references/operator-guide.md +12 -2
- package/cli/commands/serve.js +55 -226
- package/cli/http/components/callagent-terminal-component.js +5 -5
- package/cli/http/components/mcp-transport-component.js +17 -10
- package/cli/http/components/oauth-component.js +40 -74
- package/cli/http/components/supervisor-component.js +13 -13
- package/cli/ops/pm2-adapter.js +4 -147
- package/cli/services/file-io.js +3 -21
- package/cli/services/file-io.test.js +1 -33
- package/cli/supervisor-registry.js +59 -454
- package/cli/supervisor-registry.test.js +19 -449
- package/cli/watchdog-registry.js +0 -9
- package/package.json +1 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/functions/auth-vault/index.ts +49 -38
- package/cli/http/components/test-static-oauth-component.js +0 -233
- package/supabase/migrations/004_remove_audit_and_rate_limiting.sql +0 -29
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
// clauth — auth-vault Edge Function
|
|
2
|
-
// IP whitelist
|
|
3
|
-
// and audit logging (clauth_audit) removed 2026-09-03 -- see the comments at
|
|
4
|
-
// validateHMAC and where auditLog used to be defined for why: the rate-limit
|
|
5
|
-
// check was an unindexed COUNT against clauth_audit run on EVERY request,
|
|
6
|
-
// pre-auth, and every rejection it produced also wrote an audit row -- a
|
|
7
|
-
// feedback loop that took the whole project down under sustained load. The
|
|
8
|
-
// security value it provided was already covered, tighter, by the
|
|
9
|
-
// per-machine 5-failed-attempts lockout below.
|
|
1
|
+
// clauth — auth-vault Edge Function v2
|
|
2
|
+
// Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
|
|
10
3
|
|
|
11
4
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
12
5
|
|
|
@@ -18,6 +11,8 @@ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
|
|
|
18
11
|
const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
|
|
19
12
|
.split(",").map(s => s.trim()).filter(Boolean);
|
|
20
13
|
|
|
14
|
+
const RATE_LIMIT_MAX = 30;
|
|
15
|
+
const RATE_LIMIT_WINDOW = 60;
|
|
21
16
|
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
22
17
|
const MAX_FAIL_COUNT = 5;
|
|
23
18
|
const DEFAULT_INSTALL_ID = "default";
|
|
@@ -60,22 +55,18 @@ function checkIP(ip: string): { allowed: boolean; reason?: string } {
|
|
|
60
55
|
return { allowed: false, reason: `IP not whitelisted: ${ip}` };
|
|
61
56
|
}
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// the machine -- well under the 30-per-60s this used to allow). Rate
|
|
76
|
-
// limiting for a genuine runaway client belongs at Cloudflare, in front of
|
|
77
|
-
// this function, not as a synchronous Postgres query on the hot path of
|
|
78
|
-
// every request.
|
|
58
|
+
async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
|
|
59
|
+
const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
|
|
60
|
+
const { count } = await sb.from("clauth_audit")
|
|
61
|
+
.select("id", { count: "exact", head: true })
|
|
62
|
+
.eq("machine_hash", machine_hash)
|
|
63
|
+
.gte("created_at", windowStart);
|
|
64
|
+
if ((count || 0) >= RATE_LIMIT_MAX) {
|
|
65
|
+
return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
|
|
66
|
+
}
|
|
67
|
+
return { allowed: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
79
70
|
async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
|
|
80
71
|
const now = Date.now();
|
|
81
72
|
if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
|
|
@@ -108,23 +99,20 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
|
|
|
108
99
|
return { valid: true };
|
|
109
100
|
}
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// scans -> more rejections). No retention policy ever existed for this
|
|
116
|
-
// table. If durable audit logging is wanted back, it needs its own design --
|
|
117
|
-
// write-only, indexed for its actual read pattern (if any), with a real
|
|
118
|
-
// retention/partition policy -- not a bare insert-on-every-call with no cap.
|
|
102
|
+
async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
|
|
103
|
+
await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
|
|
104
|
+
}
|
|
105
|
+
|
|
119
106
|
async function handleRetrieve(sb: any, body: any, mh: string) {
|
|
120
107
|
const { service } = body;
|
|
121
108
|
if (!service) return { error: "service required" };
|
|
122
109
|
const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
|
|
123
|
-
if (!svc) return { error: "service_not_found" };
|
|
124
|
-
if (!svc.enabled) return { error: "service_disabled" };
|
|
125
|
-
if (!svc.vault_key) return { error: "no_key_stored" };
|
|
110
|
+
if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
|
|
111
|
+
if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
|
|
112
|
+
if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
|
|
126
113
|
const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
|
|
127
114
|
await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
|
|
115
|
+
await auditLog(sb, mh, service, "retrieve", "success");
|
|
128
116
|
return { service, key_type: svc.key_type, value: secret };
|
|
129
117
|
}
|
|
130
118
|
|
|
@@ -133,8 +121,9 @@ async function handleWrite(sb: any, body: any, mh: string) {
|
|
|
133
121
|
if (!service || !value) return { error: "service and value required" };
|
|
134
122
|
const vaultKey = `clauth.${service}`;
|
|
135
123
|
const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
|
|
136
|
-
if (error) return { error: error.message };
|
|
124
|
+
if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
|
|
137
125
|
await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
|
|
126
|
+
await auditLog(sb, mh, service, "write", "success");
|
|
138
127
|
return { success: true, service, vault_key: vaultKey };
|
|
139
128
|
}
|
|
140
129
|
|
|
@@ -144,6 +133,7 @@ async function handleEnable(sb: any, body: any, mh: string) {
|
|
|
144
133
|
q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
|
|
145
134
|
const { error } = await q;
|
|
146
135
|
if (error) return { error: error.message };
|
|
136
|
+
await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
|
|
147
137
|
return { success: true, service, enabled };
|
|
148
138
|
}
|
|
149
139
|
|
|
@@ -154,6 +144,7 @@ async function handleAdd(sb: any, body: any, mh: string) {
|
|
|
154
144
|
if (project) row.project = project;
|
|
155
145
|
const { error } = await sb.from("clauth_services").insert(row);
|
|
156
146
|
if (error) return { error: error.message };
|
|
147
|
+
await auditLog(sb, mh, name, "add", "success");
|
|
157
148
|
return { success: true, name, label, key_type, project: project || null };
|
|
158
149
|
}
|
|
159
150
|
|
|
@@ -166,6 +157,7 @@ async function handleUpdate(sb: any, body: any, mh: string) {
|
|
|
166
157
|
if (description !== undefined) updates.description = description || null;
|
|
167
158
|
const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
|
|
168
159
|
if (error) return { error: error.message };
|
|
160
|
+
await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
|
|
169
161
|
return { success: true, service, ...updates };
|
|
170
162
|
}
|
|
171
163
|
|
|
@@ -174,6 +166,7 @@ async function handleRemove(sb: any, body: any, mh: string) {
|
|
|
174
166
|
if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
|
|
175
167
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
176
168
|
await sb.from("clauth_services").delete().eq("name", service);
|
|
169
|
+
await auditLog(sb, mh, service, "remove", "success");
|
|
177
170
|
return { success: true, service };
|
|
178
171
|
}
|
|
179
172
|
|
|
@@ -189,6 +182,7 @@ async function handleRevoke(sb: any, body: any, mh: string) {
|
|
|
189
182
|
await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
|
|
190
183
|
await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
|
|
191
184
|
}
|
|
185
|
+
await auditLog(sb, mh, service, "revoke", "success");
|
|
192
186
|
return { success: true, service };
|
|
193
187
|
}
|
|
194
188
|
|
|
@@ -199,6 +193,7 @@ async function handleStatus(sb: any, body: any, mh: string) {
|
|
|
199
193
|
.order("name");
|
|
200
194
|
if (body.project) q = q.eq("project", body.project);
|
|
201
195
|
const { data: services } = await q;
|
|
196
|
+
await auditLog(sb, mh, "all", "status", "success");
|
|
202
197
|
return { services: services || [] };
|
|
203
198
|
}
|
|
204
199
|
|
|
@@ -209,6 +204,7 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
|
209
204
|
.update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
|
|
210
205
|
.eq("machine_hash", mh);
|
|
211
206
|
if (error) return { error: error.message };
|
|
207
|
+
await auditLog(sb, mh, "system", "change-password", "success");
|
|
212
208
|
return { success: true };
|
|
213
209
|
}
|
|
214
210
|
|
|
@@ -235,8 +231,12 @@ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
|
|
|
235
231
|
created_by_machine_hash: mh,
|
|
236
232
|
expires_at,
|
|
237
233
|
});
|
|
238
|
-
if (error)
|
|
234
|
+
if (error) {
|
|
235
|
+
await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
|
|
236
|
+
return { error: error.message };
|
|
237
|
+
}
|
|
239
238
|
|
|
239
|
+
await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
|
|
240
240
|
return { success: true, enrollment_code: code, install_id, expires_at, label };
|
|
241
241
|
}
|
|
242
242
|
|
|
@@ -274,6 +274,7 @@ async function handleRedeemEnrollment(sb: any, body: any) {
|
|
|
274
274
|
if (consumeError) return { error: consumeError.message };
|
|
275
275
|
if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
|
|
276
276
|
|
|
277
|
+
await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
|
|
277
278
|
return { success: true, machine_hash, install_id };
|
|
278
279
|
}
|
|
279
280
|
|
|
@@ -313,11 +314,21 @@ Deno.serve(async (req: Request) => {
|
|
|
313
314
|
|
|
314
315
|
const ipCheck = checkIP(ip);
|
|
315
316
|
if (!ipCheck.allowed) {
|
|
317
|
+
await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
|
|
316
318
|
return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
if (body.machine_hash) {
|
|
322
|
+
const rateCheck = await checkRateLimit(sb, body.machine_hash);
|
|
323
|
+
if (!rateCheck.allowed) {
|
|
324
|
+
await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
|
|
325
|
+
return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
319
329
|
const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
|
|
320
330
|
if (!authResult.valid) {
|
|
331
|
+
await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
|
|
321
332
|
return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
|
|
322
333
|
}
|
|
323
334
|
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
// cli/http/components/test-static-oauth-component.js
|
|
2
|
-
// TEST-ONLY, entirely additive: a minimal static-client OAuth 2.0 provider
|
|
3
|
-
// (client_id + client_secret, authorization_code + refresh_token grants)
|
|
4
|
-
// fronting one dummy MCP tool at /test/mcp. Built to test claude.ai's
|
|
5
|
-
// BUILT-IN custom-connector OAuth support (manual client_id/secret entry —
|
|
6
|
-
// no Dynamic Client Registration) side-by-side with the existing DCR+PKCE+
|
|
7
|
-
// attestation stack in oauth-component.js / mcp-transport-component.js.
|
|
8
|
-
// This file does not import from, call into, or modify either of those —
|
|
9
|
-
// separate Maps, separate routes, separate discovery paths.
|
|
10
|
-
//
|
|
11
|
-
// Static credentials come from env so nothing is hardcoded/committed. If
|
|
12
|
-
// unset, a random pair is generated at daemon start and logged once — read
|
|
13
|
-
// it from clauth-serve.log to configure the claude.ai connector.
|
|
14
|
-
// CLAUTH_TEST_OAUTH_CLIENT_ID / CLAUTH_TEST_OAUTH_CLIENT_SECRET
|
|
15
|
-
//
|
|
16
|
-
// Access tokens are deliberately short-lived (10 min) so refresh_token
|
|
17
|
-
// behavior is observable within a normal test session rather than needing
|
|
18
|
-
// to wait 24h like the real flow.
|
|
19
|
-
|
|
20
|
-
import crypto from "node:crypto";
|
|
21
|
-
import fs from "node:fs";
|
|
22
|
-
import path from "node:path";
|
|
23
|
-
import os from "node:os";
|
|
24
|
-
import { readRawBody } from "../request-utils.js";
|
|
25
|
-
|
|
26
|
-
const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
|
|
27
|
-
const ACCESS_TTL_MS = 10 * 60 * 1000;
|
|
28
|
-
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
29
|
-
|
|
30
|
-
function log(msg) {
|
|
31
|
-
try { fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] test-oauth: ${msg}\n`); } catch {}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Constant-time string compare, safe for unequal lengths (timingSafeEqual
|
|
35
|
-
// throws rather than returning false when buffers differ in length).
|
|
36
|
-
function safeEqual(a, b) {
|
|
37
|
-
const bufA = Buffer.from(String(a ?? ""), "utf8");
|
|
38
|
-
const bufB = Buffer.from(String(b ?? ""), "utf8");
|
|
39
|
-
if (bufA.length !== bufB.length) return false;
|
|
40
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function registerTestStaticOAuthRoutes(ctx) {
|
|
44
|
-
const CLIENT_ID = process.env.CLAUTH_TEST_OAUTH_CLIENT_ID || crypto.randomBytes(8).toString("hex");
|
|
45
|
-
const CLIENT_SECRET = process.env.CLAUTH_TEST_OAUTH_CLIENT_SECRET || crypto.randomBytes(24).toString("hex");
|
|
46
|
-
log(`static client ready — client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET}`);
|
|
47
|
-
// No Dynamic Client Registration in this static-client model, so there is
|
|
48
|
-
// no per-client redirect_uris list to validate against by default — the
|
|
49
|
-
// real oauth-component.js has one (client.redirect_uris); this needs its
|
|
50
|
-
// own. Fails closed: unset means no redirect_uri is accepted, not "accept
|
|
51
|
-
// anything", matching this whole surface's opt-in-only posture.
|
|
52
|
-
const ALLOWED_REDIRECT_URIS = (process.env.CLAUTH_TEST_OAUTH_REDIRECT_URIS || "")
|
|
53
|
-
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
54
|
-
|
|
55
|
-
const codes = new Map(); // code -> { redirect_uri, expires }
|
|
56
|
-
const accessTokens = new Map(); // token -> { expires }
|
|
57
|
-
const refreshTokens = new Map(); // token -> { expires }
|
|
58
|
-
|
|
59
|
-
function issueTokenPair() {
|
|
60
|
-
const access_token = crypto.randomBytes(32).toString("hex");
|
|
61
|
-
const refresh_token = crypto.randomBytes(32).toString("hex");
|
|
62
|
-
accessTokens.set(access_token, { expires: Date.now() + ACCESS_TTL_MS });
|
|
63
|
-
refreshTokens.set(refresh_token, { expires: Date.now() + REFRESH_TTL_MS });
|
|
64
|
-
return { access_token, refresh_token };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ── Discovery ──
|
|
68
|
-
// Namespaced entirely under /test/ — mcp-transport-component.js registers
|
|
69
|
-
// a broad /^\/\.well-known\// catch-all BEFORE this component runs, and
|
|
70
|
-
// registerPatternRoute matches in registration order (first match wins,
|
|
71
|
-
// confirmed serve.js:2523), so a bare /.well-known/... path here would be
|
|
72
|
-
// silently swallowed by that existing route rather than reaching this one.
|
|
73
|
-
ctx.registerPatternRoute("GET", /^\/test\/\.well-known\/oauth-protected-resource$/, async (req, res) => {
|
|
74
|
-
const base = ctx.oauthBase();
|
|
75
|
-
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
76
|
-
return res.end(JSON.stringify({
|
|
77
|
-
resource: `${base}/test/mcp`,
|
|
78
|
-
authorization_servers: [`${base}/test`],
|
|
79
|
-
scopes_supported: ["test:tools"],
|
|
80
|
-
bearer_methods_supported: ["header"],
|
|
81
|
-
}));
|
|
82
|
-
});
|
|
83
|
-
function authServerMetadata(base) {
|
|
84
|
-
return {
|
|
85
|
-
issuer: `${base}/test`,
|
|
86
|
-
authorization_endpoint: `${base}/test/authorize`,
|
|
87
|
-
token_endpoint: `${base}/test/token`,
|
|
88
|
-
response_types_supported: ["code"],
|
|
89
|
-
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
90
|
-
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
91
|
-
code_challenge_methods_supported: ["S256"],
|
|
92
|
-
scopes_supported: ["test:tools"],
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
function serveAuthServerMetadata(req, res) {
|
|
96
|
-
const base = ctx.oauthBase();
|
|
97
|
-
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
98
|
-
return res.end(JSON.stringify(authServerMetadata(base)));
|
|
99
|
-
}
|
|
100
|
-
// Observed via live claude.ai request log (2026-08-31): it tries several
|
|
101
|
-
// conventional discovery URL shapes before falling back to guessing
|
|
102
|
-
// <origin>/authorize directly. RFC 8414's actual spec puts .well-known
|
|
103
|
-
// BEFORE the issuer's path (/.well-known/oauth-authorization-server/test),
|
|
104
|
-
// not after -- that was the original bug here. Serving every shape it was
|
|
105
|
-
// observed trying, rather than guessing which one it "should" use.
|
|
106
|
-
ctx.registerPatternRoute("GET", /^\/\.well-known\/oauth-authorization-server\/test$/, serveAuthServerMetadata);
|
|
107
|
-
ctx.registerPatternRoute("GET", /^\/\.well-known\/openid-configuration\/test$/, serveAuthServerMetadata);
|
|
108
|
-
ctx.registerPatternRoute("GET", /^\/test\/\.well-known\/openid-configuration$/, serveAuthServerMetadata);
|
|
109
|
-
ctx.registerPatternRoute("GET", /^\/test\/\.well-known\/oauth-authorization-server$/, serveAuthServerMetadata);
|
|
110
|
-
|
|
111
|
-
// ── Authorize — static client, auto-approve (test only), PKCE optional
|
|
112
|
-
// since this exists specifically to test claude.ai's own client behavior,
|
|
113
|
-
// not to enforce our own policy on it. ──
|
|
114
|
-
ctx.registerWriteRoute("GET", "/test/authorize", async (req, res, url) => {
|
|
115
|
-
const clientId = url.searchParams.get("client_id");
|
|
116
|
-
const redirectUri = url.searchParams.get("redirect_uri");
|
|
117
|
-
const state = url.searchParams.get("state");
|
|
118
|
-
if (clientId !== CLIENT_ID || !redirectUri || !ALLOWED_REDIRECT_URIS.includes(redirectUri)) {
|
|
119
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
120
|
-
return res.end("invalid client_id or unregistered redirect_uri");
|
|
121
|
-
}
|
|
122
|
-
const code = crypto.randomBytes(24).toString("hex");
|
|
123
|
-
codes.set(code, { redirect_uri: redirectUri, expires: Date.now() + 300_000 });
|
|
124
|
-
const redirect = new URL(redirectUri);
|
|
125
|
-
redirect.searchParams.set("code", code);
|
|
126
|
-
if (state) redirect.searchParams.set("state", state);
|
|
127
|
-
log(`authorize -> code issued for client ${clientId}`);
|
|
128
|
-
res.writeHead(302, { Location: redirect.toString(), "Cache-Control": "no-store" });
|
|
129
|
-
return res.end();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// ── Token — authorization_code AND refresh_token grants ──
|
|
133
|
-
ctx.registerWriteRoute("POST", "/test/token", async (req, res) => {
|
|
134
|
-
const raw = await readRawBody(req);
|
|
135
|
-
const ct = req.headers["content-type"] || "";
|
|
136
|
-
const body = ct.includes("application/json") ? JSON.parse(raw || "{}") : Object.fromEntries(new URLSearchParams(raw));
|
|
137
|
-
|
|
138
|
-
// Client auth: client_secret_post (body) or client_secret_basic (header)
|
|
139
|
-
let clientId = body.client_id, clientSecret = body.client_secret;
|
|
140
|
-
const authHeader = req.headers.authorization;
|
|
141
|
-
if (!clientSecret && authHeader?.startsWith("Basic ")) {
|
|
142
|
-
const [u, p] = Buffer.from(authHeader.slice(6), "base64").toString().split(":");
|
|
143
|
-
clientId = clientId || u; clientSecret = p;
|
|
144
|
-
}
|
|
145
|
-
if (clientId !== CLIENT_ID || !safeEqual(clientSecret, CLIENT_SECRET)) {
|
|
146
|
-
log(`token: client auth failed (client_id=${clientId})`);
|
|
147
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
148
|
-
return res.end(JSON.stringify({ error: "invalid_client" }));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (body.grant_type === "authorization_code") {
|
|
152
|
-
const stored = codes.get(body.code);
|
|
153
|
-
codes.delete(body.code);
|
|
154
|
-
if (!stored || stored.expires < Date.now() || stored.redirect_uri !== body.redirect_uri) {
|
|
155
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
156
|
-
return res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
157
|
-
}
|
|
158
|
-
const pair = issueTokenPair();
|
|
159
|
-
log(`token: issued via authorization_code`);
|
|
160
|
-
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
161
|
-
return res.end(JSON.stringify({ ...pair, token_type: "Bearer", scope: "test:tools", expires_in: ACCESS_TTL_MS / 1000 }));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (body.grant_type === "refresh_token") {
|
|
165
|
-
const stored = refreshTokens.get(body.refresh_token);
|
|
166
|
-
if (!stored || stored.expires < Date.now()) {
|
|
167
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
168
|
-
return res.end(JSON.stringify({ error: "invalid_grant" }));
|
|
169
|
-
}
|
|
170
|
-
refreshTokens.delete(body.refresh_token); // rotate — old refresh token is one-time use
|
|
171
|
-
const pair = issueTokenPair();
|
|
172
|
-
log(`token: rotated via refresh_token`);
|
|
173
|
-
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
174
|
-
return res.end(JSON.stringify({ ...pair, token_type: "Bearer", scope: "test:tools", expires_in: ACCESS_TTL_MS / 1000 }));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
178
|
-
return res.end(JSON.stringify({ error: "unsupported_grant_type" }));
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// ── MCP endpoint — self-contained, one dummy tool, no dependency on the
|
|
182
|
-
// real vault/tool surface. 401 gate mirrors the real transport's shape. ──
|
|
183
|
-
ctx.registerWriteRoute("POST", "/test/mcp", async (req, res) => {
|
|
184
|
-
const authHeader = req.headers.authorization;
|
|
185
|
-
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
186
|
-
const stored = token && accessTokens.get(token);
|
|
187
|
-
if (!stored || stored.expires < Date.now()) {
|
|
188
|
-
const base = ctx.oauthBase();
|
|
189
|
-
res.writeHead(401, {
|
|
190
|
-
"Content-Type": "application/json",
|
|
191
|
-
"WWW-Authenticate": `Bearer realm="test-MCP", resource_metadata="${base}/test/.well-known/oauth-protected-resource"`,
|
|
192
|
-
"Cache-Control": "no-store",
|
|
193
|
-
});
|
|
194
|
-
return res.end(JSON.stringify({ error: "unauthorized", error_description: "Bearer token required or expired" }));
|
|
195
|
-
}
|
|
196
|
-
let body;
|
|
197
|
-
try { body = JSON.parse(await readRawBody(req)); } catch {
|
|
198
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
199
|
-
return res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
200
|
-
}
|
|
201
|
-
const { id, method, params } = body;
|
|
202
|
-
if (method === "notifications/initialized" || method === "initialized") { res.writeHead(202); return res.end(); }
|
|
203
|
-
if (method === "initialize") {
|
|
204
|
-
return respond(res, req, { jsonrpc: "2.0", id, result: {
|
|
205
|
-
protocolVersion: "2025-03-26",
|
|
206
|
-
serverInfo: { name: "test-mcp", version: "0.1.0" },
|
|
207
|
-
capabilities: { tools: { listChanged: true } },
|
|
208
|
-
} });
|
|
209
|
-
}
|
|
210
|
-
if (method === "tools/list") {
|
|
211
|
-
return respond(res, req, { jsonrpc: "2.0", id, result: { tools: [{
|
|
212
|
-
name: "test_ping",
|
|
213
|
-
description: "Returns a fixed value — used only to confirm the static-client OAuth token grants tool access.",
|
|
214
|
-
inputSchema: { type: "object", properties: {} },
|
|
215
|
-
}] } });
|
|
216
|
-
}
|
|
217
|
-
if (method === "tools/call" && params?.name === "test_ping") {
|
|
218
|
-
log(`tools/call test_ping invoked`);
|
|
219
|
-
return respond(res, req, { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "pong from test-mcp" }] } });
|
|
220
|
-
}
|
|
221
|
-
return respond(res, req, { jsonrpc: "2.0", id, error: { code: -32601, message: `Unknown method: ${method}` } });
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
function respond(res, req, payload) {
|
|
225
|
-
if ((req.headers.accept || "").includes("text/event-stream")) {
|
|
226
|
-
const buf = Buffer.from(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, "utf8");
|
|
227
|
-
res.writeHead(200, { "Content-Type": "text/event-stream", "Content-Length": buf.length, "Cache-Control": "no-cache" });
|
|
228
|
-
return res.end(buf);
|
|
229
|
-
}
|
|
230
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
231
|
-
return res.end(JSON.stringify(payload));
|
|
232
|
-
}
|
|
233
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
-- Migration: remove clauth_audit and the rate-limiting it backed
|
|
2
|
-
-- Applied live to the LIFEAI project 2026-09-03; this file makes a fresh
|
|
3
|
-
-- `supabase db push` (a new operator following the setup guide) match that
|
|
4
|
-
-- same state instead of creating a table the current auth-vault code never
|
|
5
|
-
-- reads or writes.
|
|
6
|
-
--
|
|
7
|
-
-- Root cause of the 2026-09-03 incident this closes: checkRateLimit() in
|
|
8
|
-
-- supabase/functions/auth-vault/index.ts ran an unindexed COUNT against
|
|
9
|
-
-- clauth_audit, filtered by machine_hash + created_at, on EVERY request,
|
|
10
|
-
-- BEFORE authentication. As the table grew unbounded (no retention policy
|
|
11
|
-
-- ever existed), the scan cost grew with it; under sustained traffic the
|
|
12
|
-
-- queries stacked, Postgres killed them at statement_timeout, and Cloudflare
|
|
13
|
-
-- killed the stacked connections at its own 90s ceiling (522s). Every one of
|
|
14
|
-
-- those rejections also wrote an audit row via auditLog(), which fed the
|
|
15
|
-
-- same table the check was scanning -- a self-reinforcing feedback loop.
|
|
16
|
-
--
|
|
17
|
-
-- The security value the rate limiter provided was already covered, tighter,
|
|
18
|
-
-- by the per-machine lockout in validateHMAC (clauth_machines.fail_count /
|
|
19
|
-
-- .locked -- 5 failed attempts locks the machine, well under the 30-per-60s
|
|
20
|
-
-- window this used to allow). Rate limiting for a genuine runaway client
|
|
21
|
-
-- belongs at Cloudflare, in front of this function, not as a synchronous
|
|
22
|
-
-- Postgres query on the hot path of every request.
|
|
23
|
-
--
|
|
24
|
-
-- Confirmed before dropping the live table: no function in pg_proc
|
|
25
|
-
-- referenced clauth_audit in its source, and no trigger targeted it --
|
|
26
|
-
-- auth-vault's own auditLog() (now removed from the deployed function) was
|
|
27
|
-
-- its only writer, and RLS (no_anon_audit, see 001_clauth_schema.sql) meant
|
|
28
|
-
-- no client ever had direct write access either.
|
|
29
|
-
DROP TABLE IF EXISTS public.clauth_audit CASCADE;
|