@openephemeris/mcp-server 3.13.9 → 3.13.10
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 -2
- package/config/dev-allowlist.json +28 -4
- package/dist/index.js +45 -0
- package/dist/oauth/dcr.d.ts +11 -0
- package/dist/oauth/dcr.js +49 -0
- package/dist/oauth/discovery.d.ts +11 -0
- package/dist/oauth/discovery.js +40 -0
- package/dist/oauth/pkce.d.ts +18 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/rate-limit.d.ts +20 -0
- package/dist/oauth/rate-limit.js +65 -0
- package/dist/oauth/store.d.ts +68 -0
- package/dist/oauth/store.js +247 -0
- package/dist/oauth/supabase-jwt.d.ts +39 -0
- package/dist/oauth/supabase-jwt.js +194 -0
- package/dist/oauth/token.d.ts +21 -0
- package/dist/oauth/token.js +213 -0
- package/dist/prompts.js +171 -159
- package/dist/server-sse.js +134 -36
- package/dist/tools/apps/bazi-app.d.ts +23 -0
- package/dist/tools/apps/bazi-app.js +224 -0
- package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
- package/dist/tools/apps/bi-wheel-app.js +293 -108
- package/dist/tools/apps/bodygraph-app.js +38 -3
- package/dist/tools/apps/moon-phase-app.d.ts +19 -0
- package/dist/tools/apps/moon-phase-app.js +190 -0
- package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
- package/dist/tools/apps/transit-timeline-app.js +243 -0
- package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
- package/dist/tools/apps/vedic-chart-app.js +226 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/specialized/acg.js +2 -2
- package/dist/tools/specialized/bazi.js +367 -44
- package/dist/tools/specialized/bi_wheel.js +1 -1
- package/dist/tools/specialized/chart_wheel.js +1 -1
- package/dist/tools/specialized/comparative.js +4 -4
- package/dist/tools/specialized/eclipse.js +1 -1
- package/dist/tools/specialized/electional.js +4 -4
- package/dist/tools/specialized/ephemeris_core.js +2 -2
- package/dist/tools/specialized/ephemeris_extended.js +8 -8
- package/dist/tools/specialized/hd_bodygraph.js +1 -1
- package/dist/tools/specialized/hd_cycles.js +2 -2
- package/dist/tools/specialized/hd_group.js +2 -2
- package/dist/tools/specialized/human_design.js +1 -1
- package/dist/tools/specialized/moon.js +2 -2
- package/dist/tools/specialized/natal.js +1 -1
- package/dist/tools/specialized/progressed.js +1 -1
- package/dist/tools/specialized/relocation.js +1 -1
- package/dist/tools/specialized/returns.js +3 -3
- package/dist/tools/specialized/synastry.js +1 -1
- package/dist/tools/specialized/transits.js +1 -1
- package/dist/tools/specialized/vedic.js +1 -1
- package/dist/tools/specialized/venus_star_points.js +6 -6
- package/dist/ui/bazi.html +213 -0
- package/dist/ui/bi-wheel.html +762 -489
- package/dist/ui/bodygraph.html +1788 -1649
- package/dist/ui/chart-wheel.html +1772 -1593
- package/dist/ui/moon-phase.html +6758 -0
- package/dist/ui/transit-timeline.html +6835 -0
- package/dist/ui/vedic-chart.html +210 -0
- package/package.json +2 -2
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/store.ts — OAuth authorization code store.
|
|
3
|
+
*
|
|
4
|
+
* Architecture: in-memory is always the source of truth for the MCP server
|
|
5
|
+
* process. When Supabase env vars are present AND valid, codes are ALSO
|
|
6
|
+
* persisted async to Supabase for durability and multi-instance lookup.
|
|
7
|
+
*
|
|
8
|
+
* This means:
|
|
9
|
+
* - Unit tests (no env vars) → pure in-memory, zero network calls ✓
|
|
10
|
+
* - Production (Supabase set) → in-memory for hot-path, Supabase for
|
|
11
|
+
* persistence/audit trail ✓
|
|
12
|
+
*
|
|
13
|
+
* NOTE: The consent page (/api/oauth/approve) writes directly to Supabase
|
|
14
|
+
* using the service role key without going through this store. The store
|
|
15
|
+
* is used by the MCP server's own /oauth/* endpoints.
|
|
16
|
+
*
|
|
17
|
+
* Security:
|
|
18
|
+
* - NEVER log the `code` value. Log only the row `id` (uuid).
|
|
19
|
+
* - consumeCodeAsync marks codes consumed atomically in Supabase when
|
|
20
|
+
* Supabase is reachable; falls through to in-memory otherwise.
|
|
21
|
+
*/
|
|
22
|
+
import https from "node:https";
|
|
23
|
+
import { generateAuthorizationCode } from "./pkce.js";
|
|
24
|
+
import { randomBytes } from "node:crypto";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Supabase config (read at runtime, never at import time)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
function getSupabaseConfig() {
|
|
29
|
+
const env = process.env;
|
|
30
|
+
const url = env["SUPABASE_URL"] || "";
|
|
31
|
+
const key = env["SUPABASE_SERVICE_ROLE_KEY"] || "";
|
|
32
|
+
if (!url || !key)
|
|
33
|
+
return null;
|
|
34
|
+
return { url, key };
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Raw HTTPS helper (mirrors device-db.ts pattern)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
function postgrestRequest(method, path, body, extraHeaders) {
|
|
40
|
+
const cfg = getSupabaseConfig();
|
|
41
|
+
const parsed = new URL(path, cfg.url);
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const options = {
|
|
44
|
+
hostname: parsed.hostname,
|
|
45
|
+
port: 443,
|
|
46
|
+
path: parsed.pathname + parsed.search,
|
|
47
|
+
method,
|
|
48
|
+
headers: {
|
|
49
|
+
apikey: cfg.key,
|
|
50
|
+
Authorization: `Bearer ${cfg.key}`,
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
Prefer: method === "POST" ? "return=representation" : "return=minimal",
|
|
53
|
+
...extraHeaders,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const req = https.request(options, (res) => {
|
|
57
|
+
let raw = "";
|
|
58
|
+
res.on("data", (chunk) => { raw += chunk; });
|
|
59
|
+
res.on("end", () => {
|
|
60
|
+
try {
|
|
61
|
+
resolve({ status: res.statusCode ?? 500, data: JSON.parse(raw) });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
resolve({ status: res.statusCode ?? 500, data: raw });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
req.on("error", reject);
|
|
69
|
+
if (body)
|
|
70
|
+
req.write(JSON.stringify(body));
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Supabase persistence (fire-and-forget durability write)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
function persistCodeToSupabase(code, options, expiresAt) {
|
|
78
|
+
const cfg = getSupabaseConfig();
|
|
79
|
+
if (!cfg)
|
|
80
|
+
return; // No-op in tests
|
|
81
|
+
postgrestRequest("POST", "/rest/v1/oauth_authorization_codes", {
|
|
82
|
+
code,
|
|
83
|
+
client_id: options.client_id,
|
|
84
|
+
user_id: options.user_id ?? "00000000-0000-0000-0000-000000000000",
|
|
85
|
+
redirect_uri: options.redirect_uri,
|
|
86
|
+
code_challenge: options.code_challenge,
|
|
87
|
+
code_challenge_method: options.code_challenge_method,
|
|
88
|
+
scopes: ["mcp:tools"],
|
|
89
|
+
consumed: false,
|
|
90
|
+
expires_at: expiresAt.toISOString(),
|
|
91
|
+
})
|
|
92
|
+
.then(({ status, data }) => {
|
|
93
|
+
if (status < 200 || status >= 300) {
|
|
94
|
+
console.warn(`[store] Supabase persist failed: ${status}`, data);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
98
|
+
const id = rows[0]?.id ?? "(unknown)";
|
|
99
|
+
console.info(`[store] code persisted to Supabase id=${id}`);
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
// Non-fatal — in-memory store is the authoritative source
|
|
103
|
+
console.warn("[store] Supabase persist error (non-fatal):", err.message);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// OAuthStore
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
export class OAuthStore {
|
|
110
|
+
/** In-memory is ALWAYS the hot-path source of truth */
|
|
111
|
+
codes = new Map();
|
|
112
|
+
refreshTokens = new Map();
|
|
113
|
+
cleanupTimer = null;
|
|
114
|
+
constructor() {
|
|
115
|
+
// Auto-cleanup expired codes every 60 seconds
|
|
116
|
+
this.cleanupTimer = setInterval(() => this.purgeExpired(), 60_000);
|
|
117
|
+
if (this.cleanupTimer.unref)
|
|
118
|
+
this.cleanupTimer.unref();
|
|
119
|
+
}
|
|
120
|
+
// ------------------------------------------------------------------
|
|
121
|
+
// Authorization codes
|
|
122
|
+
// ------------------------------------------------------------------
|
|
123
|
+
/**
|
|
124
|
+
* Issue a new authorization code.
|
|
125
|
+
*
|
|
126
|
+
* Always writes to in-memory (fast, test-safe).
|
|
127
|
+
* Also fire-and-forgets a Supabase persist when env vars are set.
|
|
128
|
+
*/
|
|
129
|
+
issueCode(options) {
|
|
130
|
+
const code = generateAuthorizationCode();
|
|
131
|
+
const expiresInMs = options.expiresInMs ?? 600_000; // 10 min default
|
|
132
|
+
const expires_at = new Date(Date.now() + expiresInMs);
|
|
133
|
+
this.codes.set(code, { ...options, used: false, expires_at });
|
|
134
|
+
// Async durability write — non-blocking
|
|
135
|
+
persistCodeToSupabase(code, options, expires_at);
|
|
136
|
+
return code;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Consume a code synchronously from in-memory (single-use).
|
|
140
|
+
* This is the fast-path used by the token endpoint.
|
|
141
|
+
*/
|
|
142
|
+
consumeCode(code) {
|
|
143
|
+
const stored = this.codes.get(code);
|
|
144
|
+
if (!stored)
|
|
145
|
+
return null;
|
|
146
|
+
if (stored.used)
|
|
147
|
+
return null;
|
|
148
|
+
if (stored.expires_at < new Date()) {
|
|
149
|
+
this.codes.delete(code);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
stored.used = true;
|
|
153
|
+
return { ...stored };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Async consume — checks in-memory first, then Supabase.
|
|
157
|
+
*
|
|
158
|
+
* Use this from token.ts so production deployments with Supabase
|
|
159
|
+
* can survive process restarts (code issued before restart lives in DB).
|
|
160
|
+
*/
|
|
161
|
+
async consumeCodeAsync(code) {
|
|
162
|
+
// Fast path: in-memory hit (covers 99% of cases)
|
|
163
|
+
const inMem = this.consumeCode(code);
|
|
164
|
+
if (inMem)
|
|
165
|
+
return inMem;
|
|
166
|
+
// Slow path: try Supabase (handles post-restart lookups)
|
|
167
|
+
const cfg = getSupabaseConfig();
|
|
168
|
+
if (!cfg)
|
|
169
|
+
return null;
|
|
170
|
+
try {
|
|
171
|
+
const selectPath = `/rest/v1/oauth_authorization_codes` +
|
|
172
|
+
`?code=eq.${encodeURIComponent(code)}` +
|
|
173
|
+
`&consumed=eq.false` +
|
|
174
|
+
`&expires_at=gt.${encodeURIComponent(new Date().toISOString())}` +
|
|
175
|
+
`&limit=1`;
|
|
176
|
+
const { status: selStatus, data: selData } = await postgrestRequest("GET", selectPath);
|
|
177
|
+
if (selStatus < 200 || selStatus >= 300)
|
|
178
|
+
return null;
|
|
179
|
+
const rows = Array.isArray(selData) ? selData : [];
|
|
180
|
+
if (rows.length === 0)
|
|
181
|
+
return null;
|
|
182
|
+
const row = rows[0];
|
|
183
|
+
// Optimistic lock: mark consumed=true WHERE consumed=false
|
|
184
|
+
const updatePath = `/rest/v1/oauth_authorization_codes?id=eq.${row.id}&consumed=eq.false`;
|
|
185
|
+
const { status: updStatus } = await postgrestRequest("PATCH", updatePath, {
|
|
186
|
+
consumed: true,
|
|
187
|
+
});
|
|
188
|
+
if (updStatus < 200 || updStatus >= 300) {
|
|
189
|
+
console.warn(`[store] Supabase consume mark failed id=${row.id} status=${updStatus}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
console.info(`[store] code consumed from Supabase id=${row.id}`);
|
|
193
|
+
return {
|
|
194
|
+
client_id: row.client_id,
|
|
195
|
+
user_id: row.user_id,
|
|
196
|
+
redirect_uri: row.redirect_uri,
|
|
197
|
+
code_challenge: row.code_challenge,
|
|
198
|
+
code_challenge_method: row.code_challenge_method,
|
|
199
|
+
used: true,
|
|
200
|
+
expires_at: new Date(row.expires_at),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.warn("[store] Supabase consume error:", err.message);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ------------------------------------------------------------------
|
|
209
|
+
// Refresh tokens (always in-memory — Supabase JWTs handle their own rotation)
|
|
210
|
+
// ------------------------------------------------------------------
|
|
211
|
+
storeRefreshToken(token, meta) {
|
|
212
|
+
this.refreshTokens.set(token, { ...meta, consumed: false });
|
|
213
|
+
}
|
|
214
|
+
consumeRefreshToken(token) {
|
|
215
|
+
const stored = this.refreshTokens.get(token);
|
|
216
|
+
if (!stored || stored.consumed)
|
|
217
|
+
return null;
|
|
218
|
+
stored.consumed = true;
|
|
219
|
+
return { ...stored };
|
|
220
|
+
}
|
|
221
|
+
generateRefreshToken() {
|
|
222
|
+
return randomBytes(32).toString("hex");
|
|
223
|
+
}
|
|
224
|
+
// ------------------------------------------------------------------
|
|
225
|
+
// Test utilities
|
|
226
|
+
// ------------------------------------------------------------------
|
|
227
|
+
reset() {
|
|
228
|
+
this.codes.clear();
|
|
229
|
+
this.refreshTokens.clear();
|
|
230
|
+
}
|
|
231
|
+
// ------------------------------------------------------------------
|
|
232
|
+
// Cleanup
|
|
233
|
+
// ------------------------------------------------------------------
|
|
234
|
+
purgeExpired() {
|
|
235
|
+
const now = new Date();
|
|
236
|
+
for (const [code, stored] of this.codes) {
|
|
237
|
+
if (stored.expires_at < now)
|
|
238
|
+
this.codes.delete(code);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
destroy() {
|
|
242
|
+
if (this.cleanupTimer) {
|
|
243
|
+
clearInterval(this.cleanupTimer);
|
|
244
|
+
this.cleanupTimer = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/supabase-jwt.ts — Issue real Supabase JWTs for OAuth code exchange.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. After the consent page stores an authorization code (with user_id) in
|
|
6
|
+
* Supabase, the token endpoint consumes that code and retrieves user_id.
|
|
7
|
+
* 2. This module verifies the user exists via the Admin API, then signs an
|
|
8
|
+
* HS256 JWT using SUPABASE_JWT_SECRET — the same secret Supabase GoTrue
|
|
9
|
+
* uses internally.
|
|
10
|
+
* 3. The resulting JWT starts with "eyJ" → extractAuth classifies it as a
|
|
11
|
+
* JWT → the Go sidecar validates it via ValidateSupabaseJWT (HS256 path).
|
|
12
|
+
*
|
|
13
|
+
* Why HS256 self-sign instead of GoTrue session APIs:
|
|
14
|
+
* - Zero extra HTTP round-trips beyond the user-existence check.
|
|
15
|
+
* - The Go sidecar uses loadLiveBillingMetadata to fetch tier from the DB,
|
|
16
|
+
* so JWT-embedded app_metadata.billing_tier isn't required.
|
|
17
|
+
* - generate_link + OTP exchange adds fragile indirection for no benefit.
|
|
18
|
+
*
|
|
19
|
+
* Fallback: If env vars aren't set (test/dev), returns null and the token
|
|
20
|
+
* endpoint falls back to placeholder tokens.
|
|
21
|
+
*
|
|
22
|
+
* Required env vars:
|
|
23
|
+
* - SUPABASE_URL (e.g. https://xxxx.supabase.co)
|
|
24
|
+
* - SUPABASE_SERVICE_ROLE_KEY (for Admin API user lookup)
|
|
25
|
+
* - SUPABASE_JWT_SECRET (for HS256 signing)
|
|
26
|
+
*/
|
|
27
|
+
export interface SupabaseSessionTokens {
|
|
28
|
+
access_token: string;
|
|
29
|
+
refresh_token: string;
|
|
30
|
+
expires_in: number;
|
|
31
|
+
token_type: "bearer";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Verify the user exists in Supabase, then self-sign an HS256 JWT using
|
|
35
|
+
* SUPABASE_JWT_SECRET. Returns null if any required env var is missing
|
|
36
|
+
* or the user doesn't exist.
|
|
37
|
+
*/
|
|
38
|
+
export declare function issueSupabaseJWT(userId: string): Promise<SupabaseSessionTokens | null>;
|
|
39
|
+
export declare function refreshSupabaseSession(refreshToken: string): Promise<SupabaseSessionTokens | null>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/supabase-jwt.ts — Issue real Supabase JWTs for OAuth code exchange.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. After the consent page stores an authorization code (with user_id) in
|
|
6
|
+
* Supabase, the token endpoint consumes that code and retrieves user_id.
|
|
7
|
+
* 2. This module verifies the user exists via the Admin API, then signs an
|
|
8
|
+
* HS256 JWT using SUPABASE_JWT_SECRET — the same secret Supabase GoTrue
|
|
9
|
+
* uses internally.
|
|
10
|
+
* 3. The resulting JWT starts with "eyJ" → extractAuth classifies it as a
|
|
11
|
+
* JWT → the Go sidecar validates it via ValidateSupabaseJWT (HS256 path).
|
|
12
|
+
*
|
|
13
|
+
* Why HS256 self-sign instead of GoTrue session APIs:
|
|
14
|
+
* - Zero extra HTTP round-trips beyond the user-existence check.
|
|
15
|
+
* - The Go sidecar uses loadLiveBillingMetadata to fetch tier from the DB,
|
|
16
|
+
* so JWT-embedded app_metadata.billing_tier isn't required.
|
|
17
|
+
* - generate_link + OTP exchange adds fragile indirection for no benefit.
|
|
18
|
+
*
|
|
19
|
+
* Fallback: If env vars aren't set (test/dev), returns null and the token
|
|
20
|
+
* endpoint falls back to placeholder tokens.
|
|
21
|
+
*
|
|
22
|
+
* Required env vars:
|
|
23
|
+
* - SUPABASE_URL (e.g. https://xxxx.supabase.co)
|
|
24
|
+
* - SUPABASE_SERVICE_ROLE_KEY (for Admin API user lookup)
|
|
25
|
+
* - SUPABASE_JWT_SECRET (for HS256 signing)
|
|
26
|
+
*/
|
|
27
|
+
import https from "node:https";
|
|
28
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
29
|
+
function getConfig() {
|
|
30
|
+
const url = process.env["SUPABASE_URL"] || "";
|
|
31
|
+
const key = process.env["SUPABASE_SERVICE_ROLE_KEY"] || "";
|
|
32
|
+
if (!url || !key)
|
|
33
|
+
return null;
|
|
34
|
+
return { url, serviceRoleKey: key };
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Admin API request helper (raw HTTPS, no dependencies)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
function adminRequest(method, pathname, body, cfg) {
|
|
40
|
+
const parsed = new URL(pathname, cfg.url);
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const options = {
|
|
43
|
+
hostname: parsed.hostname,
|
|
44
|
+
port: 443,
|
|
45
|
+
path: parsed.pathname + parsed.search,
|
|
46
|
+
method,
|
|
47
|
+
headers: {
|
|
48
|
+
apikey: cfg.serviceRoleKey,
|
|
49
|
+
Authorization: `Bearer ${cfg.serviceRoleKey}`,
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const req = https.request(options, (res) => {
|
|
54
|
+
let raw = "";
|
|
55
|
+
res.on("data", (chunk) => { raw += chunk; });
|
|
56
|
+
res.on("end", () => {
|
|
57
|
+
try {
|
|
58
|
+
resolve({ status: res.statusCode ?? 500, data: JSON.parse(raw) });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
resolve({ status: res.statusCode ?? 500, data: raw });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
req.on("error", reject);
|
|
66
|
+
req.setTimeout(10_000, () => {
|
|
67
|
+
req.destroy(new Error("Supabase admin request timed out"));
|
|
68
|
+
});
|
|
69
|
+
if (body)
|
|
70
|
+
req.write(JSON.stringify(body));
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Get user by ID — verifies existence and retrieves email for JWT claims
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
async function getUserById(userId, cfg) {
|
|
78
|
+
try {
|
|
79
|
+
const { status, data } = await adminRequest("GET", `/auth/v1/admin/users/${encodeURIComponent(userId)}`, null, cfg);
|
|
80
|
+
if (status < 200 || status >= 300) {
|
|
81
|
+
console.warn(`[supabase-jwt] getUserById failed: status=${status}`, data);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const user = data;
|
|
85
|
+
if (!user.id || !user.email)
|
|
86
|
+
return null;
|
|
87
|
+
return { id: user.id, email: user.email };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
console.warn("[supabase-jwt] getUserById error:", err.message);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Issue a real Supabase-compatible JWT for a given user_id
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
/**
|
|
98
|
+
* Verify the user exists in Supabase, then self-sign an HS256 JWT using
|
|
99
|
+
* SUPABASE_JWT_SECRET. Returns null if any required env var is missing
|
|
100
|
+
* or the user doesn't exist.
|
|
101
|
+
*/
|
|
102
|
+
export async function issueSupabaseJWT(userId) {
|
|
103
|
+
const cfg = getConfig();
|
|
104
|
+
if (!cfg)
|
|
105
|
+
return null;
|
|
106
|
+
const secret = process.env["SUPABASE_JWT_SECRET"];
|
|
107
|
+
if (!secret) {
|
|
108
|
+
console.warn("[supabase-jwt] SUPABASE_JWT_SECRET not set, cannot issue JWT");
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
// Verify the user exists and get their email for the JWT claims
|
|
112
|
+
const user = await getUserById(userId, cfg);
|
|
113
|
+
if (!user) {
|
|
114
|
+
console.warn(`[supabase-jwt] Cannot issue JWT: user ${userId} not found`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return signJWT(userId, user.email, secret);
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// HS256 JWT signing — mirrors Supabase GoTrue's internal token structure
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// The Go sidecar's ValidateSupabaseJWT accepts HS256 tokens signed with
|
|
123
|
+
// SUPABASE_JWT_SECRET. Tier/billing resolution happens via a live DB lookup
|
|
124
|
+
// in loadLiveBillingMetadata, so the JWT doesn't need billing_tier in
|
|
125
|
+
// app_metadata — sub (user_id) is sufficient.
|
|
126
|
+
function signJWT(userId, email, secret) {
|
|
127
|
+
const now = Math.floor(Date.now() / 1000);
|
|
128
|
+
const expiresIn = 3600; // 1 hour
|
|
129
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
130
|
+
const payload = {
|
|
131
|
+
aud: "authenticated",
|
|
132
|
+
exp: now + expiresIn,
|
|
133
|
+
iat: now,
|
|
134
|
+
nbf: now,
|
|
135
|
+
iss: process.env["SUPABASE_URL"]
|
|
136
|
+
? `${process.env["SUPABASE_URL"]}/auth/v1`
|
|
137
|
+
: "https://mcp.openephemeris.com/auth/v1",
|
|
138
|
+
sub: userId,
|
|
139
|
+
email,
|
|
140
|
+
role: "authenticated",
|
|
141
|
+
session_id: `mcp_oauth_${Date.now()}`,
|
|
142
|
+
app_metadata: { provider: "mcp_oauth", providers: ["mcp_oauth"] },
|
|
143
|
+
user_metadata: {},
|
|
144
|
+
};
|
|
145
|
+
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
|
|
146
|
+
const headerEncoded = b64url(header);
|
|
147
|
+
const payloadEncoded = b64url(payload);
|
|
148
|
+
const signingInput = `${headerEncoded}.${payloadEncoded}`;
|
|
149
|
+
const signature = createHmac("sha256", secret)
|
|
150
|
+
.update(signingInput)
|
|
151
|
+
.digest("base64url");
|
|
152
|
+
const accessToken = `${signingInput}.${signature}`;
|
|
153
|
+
const refreshToken = randomBytes(32).toString("hex");
|
|
154
|
+
console.info(`[supabase-jwt] HS256 JWT issued for user ${userId}`);
|
|
155
|
+
return {
|
|
156
|
+
access_token: accessToken,
|
|
157
|
+
refresh_token: refreshToken,
|
|
158
|
+
expires_in: expiresIn,
|
|
159
|
+
token_type: "bearer",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Refresh a Supabase session (for grant_type=refresh_token flow)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Note: Our self-signed JWTs use random hex refresh tokens, not real Supabase
|
|
166
|
+
// refresh tokens. This function handles the case where a real Supabase refresh
|
|
167
|
+
// token was obtained (e.g. from a future generate_link integration). For
|
|
168
|
+
// self-signed tokens, the token endpoint falls back to issueSupabaseJWT with
|
|
169
|
+
// the stored user_id instead of calling this.
|
|
170
|
+
export async function refreshSupabaseSession(refreshToken) {
|
|
171
|
+
const cfg = getConfig();
|
|
172
|
+
if (!cfg)
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
const { status, data } = await adminRequest("POST", "/auth/v1/token?grant_type=refresh_token", { refresh_token: refreshToken }, cfg);
|
|
176
|
+
if (status < 200 || status >= 300) {
|
|
177
|
+
console.warn(`[supabase-jwt] refresh failed: status=${status}`);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const result = data;
|
|
181
|
+
if (!result.access_token?.startsWith("eyJ"))
|
|
182
|
+
return null;
|
|
183
|
+
return {
|
|
184
|
+
access_token: result.access_token,
|
|
185
|
+
refresh_token: result.refresh_token || "",
|
|
186
|
+
expires_in: result.expires_in || 3600,
|
|
187
|
+
token_type: "bearer",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
console.warn("[supabase-jwt] refresh error:", err.message);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/token.ts — OAuth 2.1 Token endpoint.
|
|
3
|
+
*
|
|
4
|
+
* POST /oauth/token
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* - grant_type=authorization_code (with PKCE S256 verification)
|
|
8
|
+
* - grant_type=refresh_token (with rotation enforcement)
|
|
9
|
+
*
|
|
10
|
+
* Token issuance strategy:
|
|
11
|
+
* 1. In-memory store validates code/PKCE/redirect_uri
|
|
12
|
+
* 2. If Supabase env vars are set AND the stored code has a user_id,
|
|
13
|
+
* issue a real Supabase JWT via supabase-jwt.ts
|
|
14
|
+
* 3. Otherwise fall back to local placeholder tokens (test path)
|
|
15
|
+
*
|
|
16
|
+
* The Supabase path is never attempted unless BOTH SUPABASE_URL and
|
|
17
|
+
* SUPABASE_SERVICE_ROLE_KEY are set. Tests run without either.
|
|
18
|
+
*/
|
|
19
|
+
import express from "express";
|
|
20
|
+
import type { OAuthStore } from "./store.js";
|
|
21
|
+
export declare function oauthTokenRouter(store: OAuthStore): express.Router;
|