@opsee/mcp-server 0.7.0 → 0.7.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/package.json +2 -1
- package/src/auth/__tests__/oauth-provider.test.ts +182 -0
- package/src/auth/kv-store.ts +103 -0
- package/src/auth/oauth-provider.ts +84 -69
- package/src/server-http.ts +5 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsee/mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Opsee MCP server — manage projects, tasks, docs, and cycles from AI coding environments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
22
22
|
"cors": "^2.8.6",
|
|
23
23
|
"express": "^5.2.1",
|
|
24
|
+
"ioredis": "^5.11.1",
|
|
24
25
|
"open": "^10.1.0",
|
|
25
26
|
"tsx": "^4.21.0",
|
|
26
27
|
"zod": "^4.3.6"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import type { Response } from "express";
|
|
3
|
+
import { InMemoryKVStore } from "../kv-store.js";
|
|
4
|
+
import { OpseeClientStore, OpseeOAuthProvider } from "../oauth-provider.js";
|
|
5
|
+
import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
6
|
+
|
|
7
|
+
const SERVER_URL = "https://mcp.example.com";
|
|
8
|
+
const REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
|
|
9
|
+
|
|
10
|
+
function makeClient(): Omit<
|
|
11
|
+
OAuthClientInformationFull,
|
|
12
|
+
"client_id" | "client_id_issued_at"
|
|
13
|
+
> {
|
|
14
|
+
return {
|
|
15
|
+
redirect_uris: [REDIRECT_URI],
|
|
16
|
+
token_endpoint_auth_method: "none",
|
|
17
|
+
grant_types: ["authorization_code"],
|
|
18
|
+
response_types: ["code"],
|
|
19
|
+
} as unknown as Omit<
|
|
20
|
+
OAuthClientInformationFull,
|
|
21
|
+
"client_id" | "client_id_issued_at"
|
|
22
|
+
>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("InMemoryKVStore", () => {
|
|
26
|
+
test("stores and retrieves values", async () => {
|
|
27
|
+
const store = new InMemoryKVStore();
|
|
28
|
+
await store.set("k", "v");
|
|
29
|
+
expect(await store.get("k")).toBe("v");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns null for missing keys", async () => {
|
|
33
|
+
const store = new InMemoryKVStore();
|
|
34
|
+
expect(await store.get("nope")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("deletes values", async () => {
|
|
38
|
+
const store = new InMemoryKVStore();
|
|
39
|
+
await store.set("k", "v");
|
|
40
|
+
await store.del("k");
|
|
41
|
+
expect(await store.get("k")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("expires values after the TTL elapses", async () => {
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
const store = new InMemoryKVStore();
|
|
47
|
+
await store.set("k", "v", 10); // 10 second TTL
|
|
48
|
+
expect(await store.get("k")).toBe("v");
|
|
49
|
+
vi.advanceTimersByTime(11_000);
|
|
50
|
+
expect(await store.get("k")).toBeNull();
|
|
51
|
+
vi.useRealTimers();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("OpseeClientStore", () => {
|
|
56
|
+
test("registerClient persists redirect_uris and getClient round-trips them", async () => {
|
|
57
|
+
const store = new InMemoryKVStore();
|
|
58
|
+
const clientStore = new OpseeClientStore(store);
|
|
59
|
+
|
|
60
|
+
const registered = await clientStore.registerClient(makeClient());
|
|
61
|
+
expect(registered.client_id).toBeTruthy();
|
|
62
|
+
expect(registered.client_secret).toBeTruthy();
|
|
63
|
+
|
|
64
|
+
const fetched = await clientStore.getClient(registered.client_id);
|
|
65
|
+
expect(fetched).toBeDefined();
|
|
66
|
+
// The actual fix: the registered redirect_uri survives the round-trip, so
|
|
67
|
+
// the SDK's `redirect_uris.includes(redirect_uri)` check passes.
|
|
68
|
+
expect(fetched?.redirect_uris).toContain(REDIRECT_URI);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("getClient returns undefined for an unknown client (no empty-uri fallback)", async () => {
|
|
72
|
+
const store = new InMemoryKVStore();
|
|
73
|
+
const clientStore = new OpseeClientStore(store);
|
|
74
|
+
expect(await clientStore.getClient("never-registered")).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("OpseeOAuthProvider flow", () => {
|
|
79
|
+
let store: InMemoryKVStore;
|
|
80
|
+
let provider: OpseeOAuthProvider;
|
|
81
|
+
let client: OAuthClientInformationFull;
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
store = new InMemoryKVStore();
|
|
85
|
+
provider = new OpseeOAuthProvider(SERVER_URL, store, "https://app.example.com");
|
|
86
|
+
client = await (provider.clientsStore as OpseeClientStore).registerClient(
|
|
87
|
+
makeClient(),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Captures the pendingId the provider embedded in the login redirect URL.
|
|
92
|
+
async function authorizeAndGetPendingId(): Promise<string> {
|
|
93
|
+
let location = "";
|
|
94
|
+
const res = {
|
|
95
|
+
redirect: (url: string) => {
|
|
96
|
+
location = url;
|
|
97
|
+
},
|
|
98
|
+
} as unknown as Response;
|
|
99
|
+
|
|
100
|
+
await provider.authorize(
|
|
101
|
+
client,
|
|
102
|
+
{
|
|
103
|
+
redirectUri: REDIRECT_URI,
|
|
104
|
+
codeChallenge: "challenge-123",
|
|
105
|
+
state: "state-xyz",
|
|
106
|
+
},
|
|
107
|
+
res,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(location).toContain("https://app.example.com/auth/mcp");
|
|
111
|
+
const callback = new URL(
|
|
112
|
+
decodeURIComponent(location.split("callback=")[1]),
|
|
113
|
+
);
|
|
114
|
+
const pendingId = callback.searchParams.get("pending");
|
|
115
|
+
expect(pendingId).toBeTruthy();
|
|
116
|
+
return pendingId as string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("authorize → callback → exchange issues the backend token", async () => {
|
|
120
|
+
const pendingId = await authorizeAndGetPendingId();
|
|
121
|
+
|
|
122
|
+
const result = await provider.handleCallback(
|
|
123
|
+
pendingId,
|
|
124
|
+
"backend.jwt.token",
|
|
125
|
+
"user-1",
|
|
126
|
+
"company-1",
|
|
127
|
+
"",
|
|
128
|
+
);
|
|
129
|
+
expect("redirectUri" in result).toBe(true);
|
|
130
|
+
const redirectUri = (result as { redirectUri: string }).redirectUri;
|
|
131
|
+
|
|
132
|
+
const url = new URL(redirectUri);
|
|
133
|
+
expect(url.searchParams.get("state")).toBe("state-xyz");
|
|
134
|
+
const code = url.searchParams.get("code");
|
|
135
|
+
expect(code).toBeTruthy();
|
|
136
|
+
|
|
137
|
+
// PKCE challenge is recoverable for the issued code.
|
|
138
|
+
expect(await provider.challengeForAuthorizationCode(client, code as string)).toBe(
|
|
139
|
+
"challenge-123",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const tokens = await provider.exchangeAuthorizationCode(client, code as string);
|
|
143
|
+
expect(tokens.access_token).toBe("backend.jwt.token");
|
|
144
|
+
expect(tokens.token_type).toBe("bearer");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("authorization codes are single-use", async () => {
|
|
148
|
+
const pendingId = await authorizeAndGetPendingId();
|
|
149
|
+
const result = await provider.handleCallback(pendingId, "t", "u", "c", "");
|
|
150
|
+
const code = new URL((result as { redirectUri: string }).redirectUri).searchParams.get(
|
|
151
|
+
"code",
|
|
152
|
+
) as string;
|
|
153
|
+
|
|
154
|
+
await provider.exchangeAuthorizationCode(client, code);
|
|
155
|
+
await expect(provider.exchangeAuthorizationCode(client, code)).rejects.toThrow(
|
|
156
|
+
/Invalid or expired authorization code/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a pendingId cannot be replayed", async () => {
|
|
161
|
+
const pendingId = await authorizeAndGetPendingId();
|
|
162
|
+
await provider.handleCallback(pendingId, "t", "u", "c", "");
|
|
163
|
+
const second = await provider.handleCallback(pendingId, "t", "u", "c", "");
|
|
164
|
+
expect("error" in second).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("revoked tokens fail verification", async () => {
|
|
168
|
+
// Build a JWT-shaped token with a future exp so only revocation can reject it.
|
|
169
|
+
const future = Math.floor(Date.now() / 1000) + 3600;
|
|
170
|
+
const payload = Buffer.from(
|
|
171
|
+
JSON.stringify({ exp: future, userId: "u", companyId: "c" }),
|
|
172
|
+
).toString("base64url");
|
|
173
|
+
const token = `header.${payload}.sig`;
|
|
174
|
+
|
|
175
|
+
await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({
|
|
176
|
+
clientId: "opsee",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await provider.revokeToken(client, { token });
|
|
180
|
+
await expect(provider.verifyAccessToken(token)).rejects.toThrow(/revoked/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal key/value store used to persist OAuth state.
|
|
5
|
+
*
|
|
6
|
+
* The OAuth flow keeps clients, pending authorizations, authorization codes,
|
|
7
|
+
* and revoked tokens here. Backing it with Redis (instead of in-process Maps)
|
|
8
|
+
* lets registration on one request survive a server restart and be visible to
|
|
9
|
+
* other replicas — which is what makes horizontal scaling safe.
|
|
10
|
+
*/
|
|
11
|
+
export interface KVStore {
|
|
12
|
+
/** Returns the stored value, or null if missing/expired. */
|
|
13
|
+
get(key: string): Promise<string | null>;
|
|
14
|
+
/** Stores a value, optionally expiring it after `ttlSeconds`. */
|
|
15
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<void>;
|
|
16
|
+
/** Deletes a key. No-op if absent. */
|
|
17
|
+
del(key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process-local fallback used when Redis is not configured (local dev, stdio
|
|
22
|
+
* usage). State is lost on restart and is NOT shared across replicas, so this
|
|
23
|
+
* must not be used in a multi-replica deployment.
|
|
24
|
+
*/
|
|
25
|
+
export class InMemoryKVStore implements KVStore {
|
|
26
|
+
private entries = new Map<string, { value: string; expiresAt: number | null }>();
|
|
27
|
+
|
|
28
|
+
async get(key: string): Promise<string | null> {
|
|
29
|
+
const entry = this.entries.get(key);
|
|
30
|
+
if (!entry) return null;
|
|
31
|
+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
|
|
32
|
+
this.entries.delete(key);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return entry.value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
|
39
|
+
const expiresAt =
|
|
40
|
+
ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null;
|
|
41
|
+
this.entries.set(key, { value, expiresAt });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async del(key: string): Promise<void> {
|
|
45
|
+
this.entries.delete(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Redis-backed store. Expiry is delegated to Redis via the EX option. */
|
|
50
|
+
export class RedisKVStore implements KVStore {
|
|
51
|
+
constructor(private readonly redis: Redis) {}
|
|
52
|
+
|
|
53
|
+
async get(key: string): Promise<string | null> {
|
|
54
|
+
return this.redis.get(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
|
58
|
+
if (ttlSeconds !== undefined) {
|
|
59
|
+
await this.redis.set(key, value, "EX", ttlSeconds);
|
|
60
|
+
} else {
|
|
61
|
+
await this.redis.set(key, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async del(key: string): Promise<void> {
|
|
66
|
+
await this.redis.del(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds a KVStore from the environment. Uses Redis when REDIS_HOST is set,
|
|
72
|
+
* mirroring the backend's REDIS_* env conventions; otherwise falls back to an
|
|
73
|
+
* in-process store and warns that scaling beyond one replica is unsafe.
|
|
74
|
+
*/
|
|
75
|
+
export function createKVStore(): KVStore {
|
|
76
|
+
const host = process.env.REDIS_HOST;
|
|
77
|
+
if (!host) {
|
|
78
|
+
console.warn(
|
|
79
|
+
"[oauth] REDIS_HOST not set — using in-memory OAuth state. " +
|
|
80
|
+
"OAuth registrations will not survive restarts or scale beyond one replica.",
|
|
81
|
+
);
|
|
82
|
+
return new InMemoryKVStore();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const redis = new Redis({
|
|
86
|
+
host,
|
|
87
|
+
port: parseInt(process.env.REDIS_PORT || "6379", 10),
|
|
88
|
+
password: process.env.REDIS_PASSWORD || undefined,
|
|
89
|
+
db: parseInt(process.env.REDIS_DB || "0", 10),
|
|
90
|
+
tls: process.env.REDIS_TLS === "true" ? {} : undefined,
|
|
91
|
+
// Don't queue commands forever if Redis is unreachable; surface errors.
|
|
92
|
+
maxRetriesPerRequest: 3,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
redis.on("error", (err: Error) => {
|
|
96
|
+
console.error("[oauth] Redis error:", err.message);
|
|
97
|
+
});
|
|
98
|
+
redis.on("connect", () => {
|
|
99
|
+
console.log(`[oauth] Connected to Redis at ${host}`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return new RedisKVStore(redis);
|
|
103
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes, randomUUID
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import type { Response } from "express";
|
|
3
3
|
import type {
|
|
4
4
|
OAuthServerProvider,
|
|
@@ -11,15 +11,15 @@ import type {
|
|
|
11
11
|
OAuthTokens,
|
|
12
12
|
OAuthTokenRevocationRequest,
|
|
13
13
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
14
|
+
import type { KVStore } from "./kv-store.js";
|
|
14
15
|
|
|
15
|
-
// ---
|
|
16
|
+
// --- Stored value shapes ---
|
|
16
17
|
|
|
17
18
|
interface PendingAuth {
|
|
18
19
|
clientId: string;
|
|
19
20
|
redirectUri: string;
|
|
20
21
|
state?: string;
|
|
21
22
|
codeChallenge: string;
|
|
22
|
-
createdAt: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
interface AuthCode {
|
|
@@ -30,33 +30,41 @@ interface AuthCode {
|
|
|
30
30
|
codeChallenge: string;
|
|
31
31
|
redirectUri: string;
|
|
32
32
|
clientId: string;
|
|
33
|
-
createdAt: number;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
// --- Redis key namespacing + TTLs ---
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const CLIENT_KEY = (id: string) => `oauth:client:${id}`;
|
|
38
|
+
const PENDING_KEY = (id: string) => `oauth:pending:${id}`;
|
|
39
|
+
const CODE_KEY = (code: string) => `oauth:code:${code}`;
|
|
40
|
+
const REVOKED_KEY = (token: string) => `oauth:revoked:${token}`;
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const FLOW_TTL_SECONDS = 10 * 60; // pending auths + auth codes: 10 minutes
|
|
43
|
+
const CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; // dynamic client registrations: 30 days
|
|
44
|
+
const REVOKED_FALLBACK_TTL_SECONDS = 24 * 60 * 60; // if token expiry is unknown
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
export class OpseeClientStore implements OAuthRegisteredClientsStore {
|
|
47
|
+
constructor(private readonly store: KVStore) {}
|
|
48
|
+
|
|
49
|
+
async getClient(
|
|
50
|
+
clientId: string,
|
|
51
|
+
): Promise<OAuthClientInformationFull | undefined> {
|
|
52
|
+
const raw = await this.store.get(CLIENT_KEY(clientId));
|
|
53
|
+
if (!raw) {
|
|
54
|
+
// Unknown client. With a shared Redis store this means the client was
|
|
55
|
+
// never registered (or its registration expired) — return undefined so
|
|
56
|
+
// the SDK responds with invalid_client and the MCP client re-registers
|
|
57
|
+
// via Dynamic Client Registration. (Previously we returned a synthetic
|
|
58
|
+
// client with an empty redirect_uris list, which made the SDK reject
|
|
59
|
+
// every redirect_uri with "Unregistered redirect_uri".)
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(raw) as OAuthClientInformationFull;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
registerClient(
|
|
65
|
+
async registerClient(
|
|
58
66
|
client: Omit<OAuthClientInformationFull, "client_id" | "client_id_issued_at">,
|
|
59
|
-
): OAuthClientInformationFull {
|
|
67
|
+
): Promise<OAuthClientInformationFull> {
|
|
60
68
|
const clientId = randomUUID();
|
|
61
69
|
const clientSecret = randomBytes(32).toString("hex");
|
|
62
70
|
const registered: OAuthClientInformationFull = {
|
|
@@ -65,22 +73,25 @@ export class OpseeClientStore implements OAuthRegisteredClientsStore {
|
|
|
65
73
|
client_secret: clientSecret,
|
|
66
74
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
67
75
|
};
|
|
68
|
-
this.
|
|
76
|
+
await this.store.set(
|
|
77
|
+
CLIENT_KEY(clientId),
|
|
78
|
+
JSON.stringify(registered),
|
|
79
|
+
CLIENT_TTL_SECONDS,
|
|
80
|
+
);
|
|
69
81
|
return registered;
|
|
70
82
|
}
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
74
|
-
private _clientsStore
|
|
75
|
-
private
|
|
76
|
-
private authCodes = new Map<string, AuthCode>();
|
|
77
|
-
private revokedTokens = new Set<string>();
|
|
78
|
-
|
|
86
|
+
private _clientsStore: OpseeClientStore;
|
|
87
|
+
private store: KVStore;
|
|
79
88
|
private serverUrl: string;
|
|
80
89
|
private appUrl: string;
|
|
81
90
|
|
|
82
|
-
constructor(serverUrl: string, appUrl?: string) {
|
|
91
|
+
constructor(serverUrl: string, store: KVStore, appUrl?: string) {
|
|
83
92
|
this.serverUrl = serverUrl;
|
|
93
|
+
this.store = store;
|
|
94
|
+
this._clientsStore = new OpseeClientStore(store);
|
|
84
95
|
this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
|
|
85
96
|
}
|
|
86
97
|
|
|
@@ -98,13 +109,17 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
98
109
|
): Promise<void> {
|
|
99
110
|
const pendingId = randomBytes(16).toString("hex");
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
const pending: PendingAuth = {
|
|
102
113
|
clientId: client.client_id,
|
|
103
114
|
redirectUri: params.redirectUri,
|
|
104
115
|
state: params.state,
|
|
105
116
|
codeChallenge: params.codeChallenge,
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
};
|
|
118
|
+
await this.store.set(
|
|
119
|
+
PENDING_KEY(pendingId),
|
|
120
|
+
JSON.stringify(pending),
|
|
121
|
+
FLOW_TTL_SECONDS,
|
|
122
|
+
);
|
|
108
123
|
|
|
109
124
|
// Build callback URL back to our server
|
|
110
125
|
const callbackUrl = `${this.serverUrl}/oauth/callback?pending=${pendingId}`;
|
|
@@ -120,9 +135,9 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
120
135
|
_client: OAuthClientInformationFull,
|
|
121
136
|
authorizationCode: string,
|
|
122
137
|
): Promise<string> {
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
return
|
|
138
|
+
const raw = await this.store.get(CODE_KEY(authorizationCode));
|
|
139
|
+
if (!raw) throw new Error("Invalid authorization code");
|
|
140
|
+
return (JSON.parse(raw) as AuthCode).codeChallenge;
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
/**
|
|
@@ -132,16 +147,13 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
132
147
|
_client: OAuthClientInformationFull,
|
|
133
148
|
authorizationCode: string,
|
|
134
149
|
): Promise<OAuthTokens> {
|
|
135
|
-
const
|
|
136
|
-
if (!
|
|
137
|
-
|
|
138
|
-
if (Date.now() - code.createdAt > TTL_MS) {
|
|
139
|
-
this.authCodes.delete(authorizationCode);
|
|
140
|
-
throw new Error("Authorization code expired");
|
|
141
|
-
}
|
|
150
|
+
const raw = await this.store.get(CODE_KEY(authorizationCode));
|
|
151
|
+
if (!raw) throw new Error("Invalid or expired authorization code");
|
|
142
152
|
|
|
143
153
|
// One-time use
|
|
144
|
-
this.
|
|
154
|
+
await this.store.del(CODE_KEY(authorizationCode));
|
|
155
|
+
|
|
156
|
+
const code = JSON.parse(raw) as AuthCode;
|
|
145
157
|
|
|
146
158
|
// Calculate expires_in from the JWT's expiresAt
|
|
147
159
|
let expiresIn: number | undefined;
|
|
@@ -166,7 +178,7 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
166
178
|
* We do lightweight validation here; the backend does full validation on API calls.
|
|
167
179
|
*/
|
|
168
180
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
|
169
|
-
if (this.
|
|
181
|
+
if (await this.store.get(REVOKED_KEY(token))) {
|
|
170
182
|
throw new Error("Token has been revoked");
|
|
171
183
|
}
|
|
172
184
|
|
|
@@ -205,7 +217,24 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
205
217
|
_client: OAuthClientInformationFull,
|
|
206
218
|
request: OAuthTokenRevocationRequest,
|
|
207
219
|
): Promise<void> {
|
|
208
|
-
|
|
220
|
+
// Keep the revocation marker only as long as the token could still be
|
|
221
|
+
// valid. Derive that from the JWT's exp when available.
|
|
222
|
+
let ttl = REVOKED_FALLBACK_TTL_SECONDS;
|
|
223
|
+
const parts = request.token.split(".");
|
|
224
|
+
if (parts.length === 3) {
|
|
225
|
+
try {
|
|
226
|
+
const payload = JSON.parse(
|
|
227
|
+
Buffer.from(parts[1], "base64url").toString("utf-8"),
|
|
228
|
+
);
|
|
229
|
+
if (payload.exp) {
|
|
230
|
+
const remaining = payload.exp - Math.floor(Date.now() / 1000);
|
|
231
|
+
if (remaining > 0) ttl = remaining;
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// Fall back to default TTL on undecodable tokens.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
await this.store.set(REVOKED_KEY(request.token), "1", ttl);
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
// --- Custom methods for the callback flow ---
|
|
@@ -214,27 +243,24 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
214
243
|
* Step 2: Opsee login redirects back to /oauth/callback.
|
|
215
244
|
* We generate an auth code and redirect to Claude's redirect_uri.
|
|
216
245
|
*/
|
|
217
|
-
handleCallback(
|
|
246
|
+
async handleCallback(
|
|
218
247
|
pendingId: string,
|
|
219
248
|
token: string,
|
|
220
249
|
userId: string,
|
|
221
250
|
companyId: string,
|
|
222
251
|
expiresAt: string,
|
|
223
|
-
): { redirectUri: string } | { error: string } {
|
|
224
|
-
const
|
|
225
|
-
if (!
|
|
226
|
-
|
|
227
|
-
if (Date.now() - pending.createdAt > TTL_MS) {
|
|
228
|
-
this.pendingAuths.delete(pendingId);
|
|
229
|
-
return { error: "Pending authorization expired" };
|
|
230
|
-
}
|
|
252
|
+
): Promise<{ redirectUri: string } | { error: string }> {
|
|
253
|
+
const raw = await this.store.get(PENDING_KEY(pendingId));
|
|
254
|
+
if (!raw) return { error: "Invalid or expired pending authorization" };
|
|
231
255
|
|
|
232
256
|
// One-time use
|
|
233
|
-
this.
|
|
257
|
+
await this.store.del(PENDING_KEY(pendingId));
|
|
258
|
+
|
|
259
|
+
const pending = JSON.parse(raw) as PendingAuth;
|
|
234
260
|
|
|
235
261
|
// Generate authorization code
|
|
236
262
|
const authCode = randomBytes(32).toString("hex");
|
|
237
|
-
|
|
263
|
+
const code: AuthCode = {
|
|
238
264
|
token,
|
|
239
265
|
userId,
|
|
240
266
|
companyId,
|
|
@@ -242,8 +268,8 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
242
268
|
codeChallenge: pending.codeChallenge,
|
|
243
269
|
redirectUri: pending.redirectUri,
|
|
244
270
|
clientId: pending.clientId,
|
|
245
|
-
|
|
246
|
-
|
|
271
|
+
};
|
|
272
|
+
await this.store.set(CODE_KEY(authCode), JSON.stringify(code), FLOW_TTL_SECONDS);
|
|
247
273
|
|
|
248
274
|
// Build redirect URL with code and state
|
|
249
275
|
const url = new URL(pending.redirectUri);
|
|
@@ -252,15 +278,4 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
252
278
|
|
|
253
279
|
return { redirectUri: url.toString() };
|
|
254
280
|
}
|
|
255
|
-
|
|
256
|
-
/** Periodic cleanup of expired entries */
|
|
257
|
-
cleanup(): void {
|
|
258
|
-
const now = Date.now();
|
|
259
|
-
for (const [id, entry] of this.pendingAuths) {
|
|
260
|
-
if (now - entry.createdAt > TTL_MS) this.pendingAuths.delete(id);
|
|
261
|
-
}
|
|
262
|
-
for (const [code, entry] of this.authCodes) {
|
|
263
|
-
if (now - entry.createdAt > TTL_MS) this.authCodes.delete(code);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
281
|
}
|
package/src/server-http.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
6
6
|
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
7
7
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
8
8
|
import { OpseeOAuthProvider } from "./auth/oauth-provider.js";
|
|
9
|
+
import { createKVStore } from "./auth/kv-store.js";
|
|
9
10
|
import { tokenContext } from "./auth/token-context.js";
|
|
10
11
|
import { createServer } from "./server.js";
|
|
11
12
|
|
|
@@ -28,12 +29,10 @@ export async function startHttpServer(): Promise<void> {
|
|
|
28
29
|
const backendUrl =
|
|
29
30
|
process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai";
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
+
const store = createKVStore();
|
|
33
|
+
const provider = new OpseeOAuthProvider(serverUrl, store);
|
|
32
34
|
const issuerUrl = new URL(serverUrl);
|
|
33
35
|
|
|
34
|
-
// Periodically clean up expired auth entries
|
|
35
|
-
setInterval(() => provider.cleanup(), 60_000);
|
|
36
|
-
|
|
37
36
|
const app = express();
|
|
38
37
|
// Trust proxy headers (X-Forwarded-For) from nginx ingress
|
|
39
38
|
app.set("trust proxy", 1);
|
|
@@ -57,7 +56,7 @@ export async function startHttpServer(): Promise<void> {
|
|
|
57
56
|
);
|
|
58
57
|
|
|
59
58
|
// --- Custom OAuth callback (Opsee login redirects here) ---
|
|
60
|
-
app.get("/oauth/callback", (req, res) => {
|
|
59
|
+
app.get("/oauth/callback", async (req, res) => {
|
|
61
60
|
const { pending, token, userId, companyId, expiresAt } = req.query as Record<string, string>;
|
|
62
61
|
|
|
63
62
|
if (!pending || !token) {
|
|
@@ -65,7 +64,7 @@ export async function startHttpServer(): Promise<void> {
|
|
|
65
64
|
return;
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
const result = provider.handleCallback(
|
|
67
|
+
const result = await provider.handleCallback(
|
|
69
68
|
pending,
|
|
70
69
|
token,
|
|
71
70
|
userId || "",
|