@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.
@@ -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
+ }