@teampitch/mcpx 0.2.1
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/LICENSE +179 -0
- package/README.md +33 -0
- package/mcpx.example.json +43 -0
- package/package.json +52 -0
- package/src/ast.test.ts +41 -0
- package/src/ast.ts +42 -0
- package/src/auth.test.ts +150 -0
- package/src/auth.ts +144 -0
- package/src/backends.test.ts +205 -0
- package/src/backends.ts +159 -0
- package/src/config.test.ts +149 -0
- package/src/config.ts +134 -0
- package/src/executor.test.ts +155 -0
- package/src/executor.ts +195 -0
- package/src/index.ts +364 -0
- package/src/init.ts +115 -0
- package/src/k8s-controller.test.ts +108 -0
- package/src/k8s-controller.ts +201 -0
- package/src/oauth.test.ts +232 -0
- package/src/oauth.ts +265 -0
- package/src/openapi.test.ts +223 -0
- package/src/openapi.ts +253 -0
- package/src/stdio.ts +176 -0
- package/src/watcher.ts +100 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight K8s controller that watches McpxBackend CRDs and generates mcpx.json.
|
|
3
|
+
* Runs as a sidecar — pairs with config hot-reload to pick up changes automatically.
|
|
4
|
+
*
|
|
5
|
+
* Usage: bun src/k8s-controller.ts --namespace default --output /config/mcpx.json
|
|
6
|
+
*/
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
import type { BackendConfig, McpxConfig } from "./config.js";
|
|
10
|
+
|
|
11
|
+
interface McpxBackendSpec {
|
|
12
|
+
transport: "stdio" | "http";
|
|
13
|
+
command?: string;
|
|
14
|
+
args?: string[];
|
|
15
|
+
url?: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
allowedRoles?: string[];
|
|
19
|
+
allowedTeams?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface McpxBackendResource {
|
|
23
|
+
metadata: { name: string; resourceVersion?: string };
|
|
24
|
+
spec: McpxBackendSpec;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface K8sWatchEvent {
|
|
28
|
+
type: "ADDED" | "MODIFIED" | "DELETED";
|
|
29
|
+
object: McpxBackendResource;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface K8sListResponse {
|
|
33
|
+
metadata: { resourceVersion: string };
|
|
34
|
+
items: McpxBackendResource[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Convert CRD resources to mcpx.json config */
|
|
38
|
+
export function crdToConfig(
|
|
39
|
+
resources: Map<string, McpxBackendSpec>,
|
|
40
|
+
baseConfig?: Partial<McpxConfig>,
|
|
41
|
+
): McpxConfig {
|
|
42
|
+
const backends: Record<string, BackendConfig> = {};
|
|
43
|
+
for (const [name, spec] of resources) {
|
|
44
|
+
backends[name] = {
|
|
45
|
+
transport: spec.transport,
|
|
46
|
+
command: spec.command,
|
|
47
|
+
args: spec.args,
|
|
48
|
+
url: spec.url,
|
|
49
|
+
env: spec.env,
|
|
50
|
+
headers: spec.headers,
|
|
51
|
+
allowedRoles: spec.allowedRoles,
|
|
52
|
+
allowedTeams: spec.allowedTeams,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
port: baseConfig?.port ?? 3100,
|
|
58
|
+
authToken: baseConfig?.authToken,
|
|
59
|
+
auth: baseConfig?.auth,
|
|
60
|
+
failOpen: baseConfig?.failOpen ?? true,
|
|
61
|
+
toolRefreshInterval: baseConfig?.toolRefreshInterval,
|
|
62
|
+
sessionTtlMinutes: baseConfig?.sessionTtlMinutes,
|
|
63
|
+
backends,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseArgs(args: string[]): {
|
|
68
|
+
namespace: string;
|
|
69
|
+
output: string;
|
|
70
|
+
baseConfigPath?: string;
|
|
71
|
+
} {
|
|
72
|
+
let namespace = "default";
|
|
73
|
+
let output = "/config/mcpx.json";
|
|
74
|
+
let baseConfigPath: string | undefined;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < args.length; i++) {
|
|
77
|
+
if (args[i] === "--namespace" && args[i + 1]) namespace = args[++i];
|
|
78
|
+
else if (args[i] === "--output" && args[i + 1]) output = args[++i];
|
|
79
|
+
else if (args[i] === "--base-config" && args[i + 1]) baseConfigPath = args[++i];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { namespace, output, baseConfigPath };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function k8sFetch(path: string): Promise<Response> {
|
|
86
|
+
const host = process.env.KUBERNETES_SERVICE_HOST ?? "kubernetes.default.svc";
|
|
87
|
+
const port = process.env.KUBERNETES_SERVICE_PORT ?? "443";
|
|
88
|
+
const tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
|
89
|
+
const caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt";
|
|
90
|
+
|
|
91
|
+
let token: string;
|
|
92
|
+
try {
|
|
93
|
+
token = await Bun.file(tokenPath).text();
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("Not running in K8s — service account token not found");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return fetch(`https://${host}:${port}${path}`, {
|
|
99
|
+
headers: { Authorization: `Bearer ${token.trim()}` },
|
|
100
|
+
tls: { ca: Bun.file(caPath) },
|
|
101
|
+
} as RequestInit);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function startController(opts: {
|
|
105
|
+
namespace: string;
|
|
106
|
+
output: string;
|
|
107
|
+
baseConfig?: Partial<McpxConfig>;
|
|
108
|
+
}): Promise<void> {
|
|
109
|
+
const resources = new Map<string, McpxBackendSpec>();
|
|
110
|
+
const apiPath = `/apis/mcpx.io/v1alpha1/namespaces/${opts.namespace}/mcpxbackends`;
|
|
111
|
+
|
|
112
|
+
function writeConfig() {
|
|
113
|
+
const config = crdToConfig(resources, opts.baseConfig);
|
|
114
|
+
writeFileSync(opts.output, JSON.stringify(config, null, 2) + "\n");
|
|
115
|
+
console.log(`Wrote ${opts.output} (${resources.size} backends)`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Initial list
|
|
119
|
+
const listRes = await k8sFetch(apiPath);
|
|
120
|
+
if (!listRes.ok) {
|
|
121
|
+
throw new Error(`Failed to list McpxBackends: ${listRes.status} ${await listRes.text()}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const list: K8sListResponse = await listRes.json();
|
|
125
|
+
for (const item of list.items) {
|
|
126
|
+
resources.set(item.metadata.name, item.spec);
|
|
127
|
+
}
|
|
128
|
+
writeConfig();
|
|
129
|
+
|
|
130
|
+
// Watch for changes
|
|
131
|
+
let resourceVersion = list.metadata.resourceVersion;
|
|
132
|
+
|
|
133
|
+
async function watch(): Promise<void> {
|
|
134
|
+
while (true) {
|
|
135
|
+
try {
|
|
136
|
+
const watchRes = await k8sFetch(`${apiPath}?watch=true&resourceVersion=${resourceVersion}`);
|
|
137
|
+
|
|
138
|
+
if (!watchRes.ok || !watchRes.body) {
|
|
139
|
+
console.error(`Watch failed: ${watchRes.status}, reconnecting...`);
|
|
140
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const reader = watchRes.body.getReader();
|
|
145
|
+
const decoder = new TextDecoder();
|
|
146
|
+
let buffer = "";
|
|
147
|
+
|
|
148
|
+
while (true) {
|
|
149
|
+
const { done, value } = await reader.read();
|
|
150
|
+
if (done) break;
|
|
151
|
+
|
|
152
|
+
buffer += decoder.decode(value, { stream: true });
|
|
153
|
+
const lines = buffer.split("\n");
|
|
154
|
+
buffer = lines.pop() ?? "";
|
|
155
|
+
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (!line.trim()) continue;
|
|
158
|
+
const event: K8sWatchEvent = JSON.parse(line);
|
|
159
|
+
|
|
160
|
+
if (event.type === "ADDED" || event.type === "MODIFIED") {
|
|
161
|
+
resources.set(event.object.metadata.name, event.object.spec);
|
|
162
|
+
console.log(`${event.type}: ${event.object.metadata.name}`);
|
|
163
|
+
} else if (event.type === "DELETED") {
|
|
164
|
+
resources.delete(event.object.metadata.name);
|
|
165
|
+
console.log(`DELETED: ${event.object.metadata.name}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (event.object.metadata.resourceVersion) {
|
|
169
|
+
resourceVersion = event.object.metadata.resourceVersion;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
writeConfig();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error("Watch error:", (err as Error).message);
|
|
177
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await watch();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// CLI entrypoint
|
|
186
|
+
if (import.meta.main) {
|
|
187
|
+
const { namespace, output, baseConfigPath } = parseArgs(process.argv.slice(2));
|
|
188
|
+
|
|
189
|
+
let baseConfig: Partial<McpxConfig> | undefined;
|
|
190
|
+
if (baseConfigPath) {
|
|
191
|
+
const { loadConfig } = await import("./config.js");
|
|
192
|
+
const full = loadConfig(baseConfigPath);
|
|
193
|
+
baseConfig = { ...full, backends: {} };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(`mcpx k8s controller starting...`);
|
|
197
|
+
console.log(` namespace: ${namespace}`);
|
|
198
|
+
console.log(` output: ${output}`);
|
|
199
|
+
|
|
200
|
+
await startController({ namespace, output, baseConfig });
|
|
201
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
|
|
5
|
+
import { createOAuthRoutes, clearOAuthStores, type OAuthConfig } from "./oauth.js";
|
|
6
|
+
|
|
7
|
+
const oauthConfig: OAuthConfig = {
|
|
8
|
+
issuer: "https://mcp.test.com",
|
|
9
|
+
clients: [{ name: "test-client", redirectUri: "http://localhost:*" }],
|
|
10
|
+
tokenSecret: "test-oauth-secret-at-least-32-chars!!",
|
|
11
|
+
tokenTtlMinutes: 60,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function createApp(): Hono {
|
|
15
|
+
const app = new Hono();
|
|
16
|
+
createOAuthRoutes(oauthConfig, app);
|
|
17
|
+
return app;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function generatePkce(): Promise<{
|
|
21
|
+
verifier: string;
|
|
22
|
+
challenge: string;
|
|
23
|
+
}> {
|
|
24
|
+
const verifier = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(verifier));
|
|
27
|
+
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
28
|
+
.replace(/\+/g, "-")
|
|
29
|
+
.replace(/\//g, "_")
|
|
30
|
+
.replace(/=+$/, "");
|
|
31
|
+
return { verifier, challenge };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
clearOAuthStores();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("OAuth metadata", () => {
|
|
39
|
+
test("returns authorization server metadata", async () => {
|
|
40
|
+
const app = createApp();
|
|
41
|
+
const res = await app.request("/.well-known/oauth-authorization-server");
|
|
42
|
+
expect(res.status).toBe(200);
|
|
43
|
+
const body = await res.json();
|
|
44
|
+
expect(body.issuer).toBe("https://mcp.test.com");
|
|
45
|
+
expect(body.authorization_endpoint).toBe("https://mcp.test.com/authorize");
|
|
46
|
+
expect(body.token_endpoint).toBe("https://mcp.test.com/token");
|
|
47
|
+
expect(body.registration_endpoint).toBe("https://mcp.test.com/register");
|
|
48
|
+
expect(body.code_challenge_methods_supported).toContain("S256");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("Client registration", () => {
|
|
53
|
+
test("registers a new client", async () => {
|
|
54
|
+
const app = createApp();
|
|
55
|
+
const res = await app.request("/register", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
client_name: "my-agent",
|
|
60
|
+
redirect_uris: ["http://localhost:9999/callback"],
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
expect(res.status).toBe(201);
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
expect(body.client_id).toBeTruthy();
|
|
66
|
+
expect(body.client_secret).toBeTruthy();
|
|
67
|
+
expect(body.client_name).toBe("my-agent");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rejects unknown redirect URI", async () => {
|
|
71
|
+
const app = createApp();
|
|
72
|
+
const res = await app.request("/register", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
client_name: "bad-agent",
|
|
77
|
+
redirect_uris: ["https://evil.com/callback"],
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
expect(res.status).toBe(400);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("rejects missing fields", async () => {
|
|
84
|
+
const app = createApp();
|
|
85
|
+
const res = await app.request("/register", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({}),
|
|
89
|
+
});
|
|
90
|
+
expect(res.status).toBe(400);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("Full OAuth flow", () => {
|
|
95
|
+
test("register → authorize → token exchange", async () => {
|
|
96
|
+
const app = createApp();
|
|
97
|
+
|
|
98
|
+
// 1. Register client
|
|
99
|
+
const regRes = await app.request("/register", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json" },
|
|
102
|
+
body: JSON.stringify({
|
|
103
|
+
client_name: "flow-test",
|
|
104
|
+
redirect_uris: ["http://localhost:8888/cb"],
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
const { client_id, client_secret } = await regRes.json();
|
|
108
|
+
|
|
109
|
+
// 2. Generate PKCE
|
|
110
|
+
const { verifier, challenge } = await generatePkce();
|
|
111
|
+
|
|
112
|
+
// 3. Authorize (should redirect with code)
|
|
113
|
+
const authRes = await app.request(
|
|
114
|
+
`/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost:8888/cb")}&code_challenge=${challenge}&code_challenge_method=S256&state=xyz`,
|
|
115
|
+
{ redirect: "manual" },
|
|
116
|
+
);
|
|
117
|
+
expect(authRes.status).toBe(302);
|
|
118
|
+
const location = authRes.headers.get("location")!;
|
|
119
|
+
const redirectUrl = new URL(location);
|
|
120
|
+
const code = redirectUrl.searchParams.get("code");
|
|
121
|
+
expect(code).toBeTruthy();
|
|
122
|
+
expect(redirectUrl.searchParams.get("state")).toBe("xyz");
|
|
123
|
+
|
|
124
|
+
// 4. Exchange code for token
|
|
125
|
+
const tokenRes = await app.request("/token", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
grant_type: "authorization_code",
|
|
130
|
+
code,
|
|
131
|
+
redirect_uri: "http://localhost:8888/cb",
|
|
132
|
+
client_id,
|
|
133
|
+
client_secret,
|
|
134
|
+
code_verifier: verifier,
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
expect(tokenRes.status).toBe(200);
|
|
138
|
+
const tokenBody = await tokenRes.json();
|
|
139
|
+
expect(tokenBody.access_token).toBeTruthy();
|
|
140
|
+
expect(tokenBody.token_type).toBe("Bearer");
|
|
141
|
+
expect(tokenBody.expires_in).toBe(3600);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("rejects wrong PKCE verifier", async () => {
|
|
145
|
+
const app = createApp();
|
|
146
|
+
|
|
147
|
+
const regRes = await app.request("/register", {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
client_name: "pkce-test",
|
|
152
|
+
redirect_uris: ["http://localhost:7777/cb"],
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
const { client_id } = await regRes.json();
|
|
156
|
+
|
|
157
|
+
const { challenge } = await generatePkce();
|
|
158
|
+
|
|
159
|
+
const authRes = await app.request(
|
|
160
|
+
`/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost:7777/cb")}&code_challenge=${challenge}`,
|
|
161
|
+
{ redirect: "manual" },
|
|
162
|
+
);
|
|
163
|
+
const location = authRes.headers.get("location")!;
|
|
164
|
+
const code = new URL(location).searchParams.get("code");
|
|
165
|
+
|
|
166
|
+
// Use wrong verifier
|
|
167
|
+
const tokenRes = await app.request("/token", {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
grant_type: "authorization_code",
|
|
172
|
+
code,
|
|
173
|
+
redirect_uri: "http://localhost:7777/cb",
|
|
174
|
+
client_id,
|
|
175
|
+
code_verifier: "wrong-verifier-that-doesnt-match",
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
expect(tokenRes.status).toBe(400);
|
|
179
|
+
const body = await tokenRes.json();
|
|
180
|
+
expect(body.error_description).toContain("PKCE");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("rejects reused authorization code", async () => {
|
|
184
|
+
const app = createApp();
|
|
185
|
+
|
|
186
|
+
const regRes = await app.request("/register", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
client_name: "reuse-test",
|
|
191
|
+
redirect_uris: ["http://localhost:6666/cb"],
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
const { client_id } = await regRes.json();
|
|
195
|
+
|
|
196
|
+
const { verifier, challenge } = await generatePkce();
|
|
197
|
+
|
|
198
|
+
const authRes = await app.request(
|
|
199
|
+
`/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent("http://localhost:6666/cb")}&code_challenge=${challenge}`,
|
|
200
|
+
{ redirect: "manual" },
|
|
201
|
+
);
|
|
202
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code");
|
|
203
|
+
|
|
204
|
+
// First use — should succeed
|
|
205
|
+
const tokenRes1 = await app.request("/token", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json" },
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
grant_type: "authorization_code",
|
|
210
|
+
code,
|
|
211
|
+
redirect_uri: "http://localhost:6666/cb",
|
|
212
|
+
client_id,
|
|
213
|
+
code_verifier: verifier,
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
expect(tokenRes1.status).toBe(200);
|
|
217
|
+
|
|
218
|
+
// Second use — should fail
|
|
219
|
+
const tokenRes2 = await app.request("/token", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "Content-Type": "application/json" },
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
grant_type: "authorization_code",
|
|
224
|
+
code,
|
|
225
|
+
redirect_uri: "http://localhost:6666/cb",
|
|
226
|
+
client_id,
|
|
227
|
+
code_verifier: verifier,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
expect(tokenRes2.status).toBe(400);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
3
|
+
|
|
4
|
+
import type { AuthClaims } from "./auth.js";
|
|
5
|
+
|
|
6
|
+
export interface OAuthConfig {
|
|
7
|
+
issuer: string;
|
|
8
|
+
clients: Array<{ name: string; redirectUri: string }>;
|
|
9
|
+
tokenSecret: string;
|
|
10
|
+
tokenTtlMinutes: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RegisteredClient {
|
|
14
|
+
clientId: string;
|
|
15
|
+
clientSecret: string;
|
|
16
|
+
name: string;
|
|
17
|
+
redirectUri: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AuthorizationCode {
|
|
21
|
+
code: string;
|
|
22
|
+
clientId: string;
|
|
23
|
+
redirectUri: string;
|
|
24
|
+
codeChallenge: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
claims: AuthClaims;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// In-memory stores (single instance; back with Redis for multi-instance later)
|
|
30
|
+
const clients = new Map<string, RegisteredClient>();
|
|
31
|
+
const authCodes = new Map<string, AuthorizationCode>();
|
|
32
|
+
|
|
33
|
+
function generateId(): string {
|
|
34
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchRedirectUri(pattern: string, uri: string): boolean {
|
|
38
|
+
// Support wildcards: http://localhost:* matches http://localhost:9999/callback
|
|
39
|
+
if (pattern.includes("*")) {
|
|
40
|
+
const regex = new RegExp(
|
|
41
|
+
`^${pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`,
|
|
42
|
+
);
|
|
43
|
+
return regex.test(uri);
|
|
44
|
+
}
|
|
45
|
+
return pattern === uri;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Verify PKCE S256 challenge */
|
|
49
|
+
async function verifyPkce(codeVerifier: string, codeChallenge: string): Promise<boolean> {
|
|
50
|
+
const encoder = new TextEncoder();
|
|
51
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(codeVerifier));
|
|
52
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
53
|
+
.replace(/\+/g, "-")
|
|
54
|
+
.replace(/\//g, "_")
|
|
55
|
+
.replace(/=+$/, "");
|
|
56
|
+
return base64 === codeChallenge;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Mount OAuth 2.0 endpoints on a Hono app */
|
|
60
|
+
export function createOAuthRoutes(config: OAuthConfig, app: Hono): void {
|
|
61
|
+
const tokenSecret = new TextEncoder().encode(config.tokenSecret);
|
|
62
|
+
|
|
63
|
+
// Pre-register configured clients
|
|
64
|
+
for (const clientConfig of config.clients) {
|
|
65
|
+
const client: RegisteredClient = {
|
|
66
|
+
clientId: generateId(),
|
|
67
|
+
clientSecret: generateId(),
|
|
68
|
+
name: clientConfig.name,
|
|
69
|
+
redirectUri: clientConfig.redirectUri,
|
|
70
|
+
};
|
|
71
|
+
clients.set(client.clientId, client);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// RFC 8414 — Authorization Server Metadata
|
|
75
|
+
app.get("/.well-known/oauth-authorization-server", (c) => {
|
|
76
|
+
const baseUrl = config.issuer;
|
|
77
|
+
return c.json({
|
|
78
|
+
issuer: baseUrl,
|
|
79
|
+
authorization_endpoint: `${baseUrl}/authorize`,
|
|
80
|
+
token_endpoint: `${baseUrl}/token`,
|
|
81
|
+
registration_endpoint: `${baseUrl}/register`,
|
|
82
|
+
response_types_supported: ["code"],
|
|
83
|
+
grant_types_supported: ["authorization_code"],
|
|
84
|
+
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
85
|
+
code_challenge_methods_supported: ["S256"],
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// RFC 7591 — Dynamic Client Registration
|
|
90
|
+
app.post("/register", async (c) => {
|
|
91
|
+
const body = await c.req.json();
|
|
92
|
+
const name = body.client_name;
|
|
93
|
+
const redirectUris = body.redirect_uris as string[] | undefined;
|
|
94
|
+
const redirectUri = redirectUris?.[0];
|
|
95
|
+
|
|
96
|
+
if (!name || !redirectUri) {
|
|
97
|
+
return c.json({ error: "client_name and redirect_uris required" }, 400);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate redirect URI against allowed patterns
|
|
101
|
+
const allowed = config.clients.some((cc) => matchRedirectUri(cc.redirectUri, redirectUri));
|
|
102
|
+
if (!allowed) {
|
|
103
|
+
return c.json({ error: "redirect_uri not allowed" }, 400);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const client: RegisteredClient = {
|
|
107
|
+
clientId: generateId(),
|
|
108
|
+
clientSecret: generateId(),
|
|
109
|
+
name,
|
|
110
|
+
redirectUri,
|
|
111
|
+
};
|
|
112
|
+
clients.set(client.clientId, client);
|
|
113
|
+
|
|
114
|
+
return c.json(
|
|
115
|
+
{
|
|
116
|
+
client_id: client.clientId,
|
|
117
|
+
client_secret: client.clientSecret,
|
|
118
|
+
client_name: client.name,
|
|
119
|
+
redirect_uris: [client.redirectUri],
|
|
120
|
+
},
|
|
121
|
+
201,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Authorization endpoint
|
|
126
|
+
app.get("/authorize", async (c) => {
|
|
127
|
+
const clientId = c.req.query("client_id");
|
|
128
|
+
const redirectUri = c.req.query("redirect_uri");
|
|
129
|
+
const codeChallenge = c.req.query("code_challenge");
|
|
130
|
+
const codeChallengeMethod = c.req.query("code_challenge_method");
|
|
131
|
+
const state = c.req.query("state");
|
|
132
|
+
|
|
133
|
+
if (!clientId || !redirectUri || !codeChallenge) {
|
|
134
|
+
return c.json({ error: "client_id, redirect_uri, and code_challenge required" }, 400);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (codeChallengeMethod && codeChallengeMethod !== "S256") {
|
|
138
|
+
return c.json({ error: "only S256 code_challenge_method supported" }, 400);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const client = clients.get(clientId);
|
|
142
|
+
if (!client) {
|
|
143
|
+
return c.json({ error: "unknown client_id" }, 400);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!matchRedirectUri(client.redirectUri, redirectUri)) {
|
|
147
|
+
return c.json({ error: "redirect_uri mismatch" }, 400);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if caller already has a valid token (simple mode — no login page)
|
|
151
|
+
const authHeader = c.req.header("Authorization");
|
|
152
|
+
const existingToken = authHeader?.replace(/^Bearer\s+/i, "");
|
|
153
|
+
let claims: AuthClaims = {};
|
|
154
|
+
|
|
155
|
+
if (existingToken) {
|
|
156
|
+
try {
|
|
157
|
+
const { payload } = await jwtVerify(existingToken, tokenSecret);
|
|
158
|
+
claims = {
|
|
159
|
+
sub: payload.sub,
|
|
160
|
+
email: payload.email as string | undefined,
|
|
161
|
+
roles: payload.roles as string[] | undefined,
|
|
162
|
+
teams: payload.teams as string[] | undefined,
|
|
163
|
+
};
|
|
164
|
+
} catch {
|
|
165
|
+
// Invalid token — proceed with empty claims
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Generate authorization code
|
|
170
|
+
const code = generateId();
|
|
171
|
+
authCodes.set(code, {
|
|
172
|
+
code,
|
|
173
|
+
clientId,
|
|
174
|
+
redirectUri,
|
|
175
|
+
codeChallenge,
|
|
176
|
+
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
|
177
|
+
claims,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const redirectUrl = new URL(redirectUri);
|
|
181
|
+
redirectUrl.searchParams.set("code", code);
|
|
182
|
+
if (state) redirectUrl.searchParams.set("state", state);
|
|
183
|
+
|
|
184
|
+
return c.redirect(redirectUrl.toString());
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Token endpoint
|
|
188
|
+
app.post("/token", async (c) => {
|
|
189
|
+
const body = await c.req.json();
|
|
190
|
+
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier } = body;
|
|
191
|
+
|
|
192
|
+
if (grant_type !== "authorization_code") {
|
|
193
|
+
return c.json({ error: "unsupported_grant_type" }, 400);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!code || !redirect_uri || !client_id || !code_verifier) {
|
|
197
|
+
return c.json({ error: "missing required parameters" }, 400);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Validate client
|
|
201
|
+
const client = clients.get(client_id);
|
|
202
|
+
if (!client || (client_secret && client.clientSecret !== client_secret)) {
|
|
203
|
+
return c.json({ error: "invalid_client" }, 401);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate auth code
|
|
207
|
+
const authCode = authCodes.get(code);
|
|
208
|
+
if (!authCode) {
|
|
209
|
+
return c.json({ error: "invalid_grant", error_description: "unknown code" }, 400);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
authCodes.delete(code); // One-time use
|
|
213
|
+
|
|
214
|
+
if (authCode.expiresAt < Date.now()) {
|
|
215
|
+
return c.json({ error: "invalid_grant", error_description: "code expired" }, 400);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (authCode.clientId !== client_id || authCode.redirectUri !== redirect_uri) {
|
|
219
|
+
return c.json({ error: "invalid_grant", error_description: "parameter mismatch" }, 400);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// PKCE verification
|
|
223
|
+
const pkceValid = await verifyPkce(code_verifier, authCode.codeChallenge);
|
|
224
|
+
if (!pkceValid) {
|
|
225
|
+
return c.json(
|
|
226
|
+
{
|
|
227
|
+
error: "invalid_grant",
|
|
228
|
+
error_description: "PKCE verification failed",
|
|
229
|
+
},
|
|
230
|
+
400,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Issue access token
|
|
235
|
+
const expiresIn = config.tokenTtlMinutes * 60;
|
|
236
|
+
const accessToken = await new SignJWT({
|
|
237
|
+
sub: authCode.claims.sub ?? "anonymous",
|
|
238
|
+
email: authCode.claims.email,
|
|
239
|
+
roles: authCode.claims.roles,
|
|
240
|
+
teams: authCode.claims.teams,
|
|
241
|
+
})
|
|
242
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
243
|
+
.setIssuer(config.issuer)
|
|
244
|
+
.setExpirationTime(`${config.tokenTtlMinutes}m`)
|
|
245
|
+
.setIssuedAt()
|
|
246
|
+
.sign(tokenSecret);
|
|
247
|
+
|
|
248
|
+
return c.json({
|
|
249
|
+
access_token: accessToken,
|
|
250
|
+
token_type: "Bearer",
|
|
251
|
+
expires_in: expiresIn,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Get registered clients (for testing) */
|
|
257
|
+
export function getRegisteredClients(): Map<string, RegisteredClient> {
|
|
258
|
+
return clients;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Clear stores (for testing) */
|
|
262
|
+
export function clearOAuthStores(): void {
|
|
263
|
+
clients.clear();
|
|
264
|
+
authCodes.clear();
|
|
265
|
+
}
|