@opsee/mcp-server 0.9.57 → 0.9.58
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 +1 -1
- package/src/auth/oauth-provider.ts +151 -18
- package/src/auth/renew.ts +56 -0
package/package.json
CHANGED
|
@@ -11,7 +11,12 @@ import type {
|
|
|
11
11
|
OAuthTokens,
|
|
12
12
|
OAuthTokenRevocationRequest,
|
|
13
13
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
14
|
+
import {
|
|
15
|
+
InvalidGrantError,
|
|
16
|
+
InvalidTokenError,
|
|
17
|
+
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
14
18
|
import type { KVStore } from "./kv-store.js";
|
|
19
|
+
import { renewAccessToken, type TokenRenewer } from "./renew.js";
|
|
15
20
|
|
|
16
21
|
// --- Stored value shapes ---
|
|
17
22
|
|
|
@@ -32,15 +37,41 @@ interface AuthCode {
|
|
|
32
37
|
clientId: string;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
/** Server-side record behind an opaque refresh token. */
|
|
41
|
+
interface RefreshRecord {
|
|
42
|
+
/** The last access token issued to this session. Used to mint the next one. */
|
|
43
|
+
accessToken: string;
|
|
44
|
+
userId: string;
|
|
45
|
+
companyId: string;
|
|
46
|
+
clientId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
// --- Redis key namespacing + TTLs ---
|
|
36
50
|
|
|
37
51
|
const CLIENT_KEY = (id: string) => `oauth:client:${id}`;
|
|
38
52
|
const PENDING_KEY = (id: string) => `oauth:pending:${id}`;
|
|
39
53
|
const CODE_KEY = (code: string) => `oauth:code:${code}`;
|
|
40
54
|
const REVOKED_KEY = (token: string) => `oauth:revoked:${token}`;
|
|
55
|
+
const REFRESH_KEY = (token: string) => `oauth:refresh:${token}`;
|
|
41
56
|
|
|
42
57
|
const FLOW_TTL_SECONDS = 10 * 60; // pending auths + auth codes: 10 minutes
|
|
43
58
|
const REVOKED_FALLBACK_TTL_SECONDS = 24 * 60 * 60; // if token expiry is unknown
|
|
59
|
+
// Refresh tokens outlive the access token they renew, so a client that has been
|
|
60
|
+
// idle for longer than one access-token lifetime can still come back without
|
|
61
|
+
// sending the user through an interactive login.
|
|
62
|
+
const REFRESH_TTL_SECONDS = 180 * 24 * 60 * 60; // 180 days
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Converts an RFC3339 expiry into the `expires_in` seconds an OAuth token
|
|
66
|
+
* response carries. Returns undefined when the expiry is absent or unparseable
|
|
67
|
+
* so the field is simply omitted rather than reported as an immediate expiry.
|
|
68
|
+
*/
|
|
69
|
+
function expiresInFrom(expiresAt: string): number | undefined {
|
|
70
|
+
if (!expiresAt) return undefined;
|
|
71
|
+
const expMs = new Date(expiresAt).getTime();
|
|
72
|
+
if (Number.isNaN(expMs)) return undefined;
|
|
73
|
+
return Math.max(0, Math.floor((expMs - Date.now()) / 1000));
|
|
74
|
+
}
|
|
44
75
|
|
|
45
76
|
export class OpseeClientStore implements OAuthRegisteredClientsStore {
|
|
46
77
|
constructor(private readonly store: KVStore) {}
|
|
@@ -94,12 +125,19 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
94
125
|
private store: KVStore;
|
|
95
126
|
private serverUrl: string;
|
|
96
127
|
private appUrl: string;
|
|
97
|
-
|
|
98
|
-
|
|
128
|
+
private renew: TokenRenewer;
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
serverUrl: string,
|
|
132
|
+
store: KVStore,
|
|
133
|
+
appUrl?: string,
|
|
134
|
+
renew: TokenRenewer = renewAccessToken,
|
|
135
|
+
) {
|
|
99
136
|
this.serverUrl = serverUrl;
|
|
100
137
|
this.store = store;
|
|
101
138
|
this._clientsStore = new OpseeClientStore(store);
|
|
102
139
|
this.appUrl = appUrl || process.env.OPSEE_APP_URL || "https://opsee.ai";
|
|
140
|
+
this.renew = renew;
|
|
103
141
|
}
|
|
104
142
|
|
|
105
143
|
get clientsStore(): OAuthRegisteredClientsStore {
|
|
@@ -162,22 +200,87 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
162
200
|
|
|
163
201
|
const code = JSON.parse(raw) as AuthCode;
|
|
164
202
|
|
|
165
|
-
// Calculate expires_in from the JWT's expiresAt
|
|
166
|
-
let expiresIn: number | undefined;
|
|
167
|
-
if (code.expiresAt) {
|
|
168
|
-
const expMs = new Date(code.expiresAt).getTime() - Date.now();
|
|
169
|
-
expiresIn = Math.max(0, Math.floor(expMs / 1000));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
203
|
return {
|
|
173
204
|
access_token: code.token,
|
|
174
205
|
token_type: "bearer",
|
|
175
|
-
expires_in:
|
|
206
|
+
expires_in: expiresInFrom(code.expiresAt),
|
|
207
|
+
// Without a refresh token the client has exactly one credential: any
|
|
208
|
+
// failure to present it — expiry, revocation, a transient 5xx — can only
|
|
209
|
+
// be recovered by sending the user through an interactive login again.
|
|
210
|
+
refresh_token: await this.issueRefreshToken({
|
|
211
|
+
accessToken: code.token,
|
|
212
|
+
userId: code.userId,
|
|
213
|
+
companyId: code.companyId,
|
|
214
|
+
clientId: code.clientId,
|
|
215
|
+
}),
|
|
176
216
|
};
|
|
177
217
|
}
|
|
178
218
|
|
|
179
|
-
|
|
180
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Mints an opaque refresh token for a session and persists the record it
|
|
221
|
+
* stands for. Refresh tokens are rotated on every use, so each call replaces
|
|
222
|
+
* the previous handle.
|
|
223
|
+
*/
|
|
224
|
+
private async issueRefreshToken(record: RefreshRecord): Promise<string> {
|
|
225
|
+
const refreshToken = randomBytes(32).toString("hex");
|
|
226
|
+
await this.store.set(
|
|
227
|
+
REFRESH_KEY(refreshToken),
|
|
228
|
+
JSON.stringify(record),
|
|
229
|
+
REFRESH_TTL_SECONDS,
|
|
230
|
+
);
|
|
231
|
+
return refreshToken;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Trades a refresh token for a freshly minted access token.
|
|
236
|
+
*
|
|
237
|
+
* The stored access token is used to authenticate the renewal against the
|
|
238
|
+
* backend, so the session rolls forward for as long as the client keeps
|
|
239
|
+
* refreshing — the user is only sent back through the login UI once the
|
|
240
|
+
* stored token has itself lapsed.
|
|
241
|
+
*/
|
|
242
|
+
async exchangeRefreshToken(
|
|
243
|
+
client: OAuthClientInformationFull,
|
|
244
|
+
refreshToken: string,
|
|
245
|
+
): Promise<OAuthTokens> {
|
|
246
|
+
const raw = await this.store.get(REFRESH_KEY(refreshToken));
|
|
247
|
+
if (!raw) throw new InvalidGrantError("Invalid or expired refresh token");
|
|
248
|
+
|
|
249
|
+
const record = JSON.parse(raw) as RefreshRecord;
|
|
250
|
+
|
|
251
|
+
// A refresh token belongs to the client it was issued to. Without this a
|
|
252
|
+
// leaked handle could be redeemed by any other registered client.
|
|
253
|
+
if (record.clientId !== client.client_id) {
|
|
254
|
+
throw new InvalidGrantError("Refresh token was issued to another client");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Rotate: the presented handle is spent whether or not renewal succeeds, so
|
|
258
|
+
// a captured refresh token cannot be replayed.
|
|
259
|
+
await this.store.del(REFRESH_KEY(refreshToken));
|
|
260
|
+
|
|
261
|
+
let renewed;
|
|
262
|
+
try {
|
|
263
|
+
renewed = await this.renew(record.accessToken);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
// The stored token is no longer good enough to mint another (expired,
|
|
266
|
+
// user deactivated, backend rejecting it). Surface invalid_grant so the
|
|
267
|
+
// client falls back to a full authorization instead of retrying forever.
|
|
268
|
+
throw new InvalidGrantError(
|
|
269
|
+
`Could not renew the session: ${err instanceof Error ? err.message : String(err)}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
access_token: renewed.token,
|
|
275
|
+
token_type: "bearer",
|
|
276
|
+
expires_in: expiresInFrom(renewed.expiresAt),
|
|
277
|
+
refresh_token: await this.issueRefreshToken({
|
|
278
|
+
accessToken: renewed.token,
|
|
279
|
+
userId: renewed.userId || record.userId,
|
|
280
|
+
companyId: renewed.companyId || record.companyId,
|
|
281
|
+
clientId: record.clientId,
|
|
282
|
+
}),
|
|
283
|
+
};
|
|
181
284
|
}
|
|
182
285
|
|
|
183
286
|
/**
|
|
@@ -185,13 +288,13 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
185
288
|
* We do lightweight validation here; the backend does full validation on API calls.
|
|
186
289
|
*/
|
|
187
290
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
|
188
|
-
if (await this.
|
|
189
|
-
throw new
|
|
291
|
+
if (await this.isRevoked(token)) {
|
|
292
|
+
throw new InvalidTokenError("Token has been revoked");
|
|
190
293
|
}
|
|
191
294
|
|
|
192
295
|
// Decode JWT payload without crypto verification
|
|
193
296
|
const parts = token.split(".");
|
|
194
|
-
if (parts.length !== 3) throw new
|
|
297
|
+
if (parts.length !== 3) throw new InvalidTokenError("Invalid token format");
|
|
195
298
|
|
|
196
299
|
// Scope the try to ONLY the decode/parse: a malformed payload becomes
|
|
197
300
|
// "Invalid token". The validation checks below run outside the catch so
|
|
@@ -201,16 +304,16 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
201
304
|
try {
|
|
202
305
|
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
203
306
|
} catch {
|
|
204
|
-
throw new
|
|
307
|
+
throw new InvalidTokenError("Invalid token");
|
|
205
308
|
}
|
|
206
309
|
|
|
207
310
|
// Require an expiry claim and reject expired tokens. A token that omits
|
|
208
311
|
// exp must not be treated as valid indefinitely.
|
|
209
312
|
if (typeof payload.exp !== "number") {
|
|
210
|
-
throw new
|
|
313
|
+
throw new InvalidTokenError("Token missing expiry");
|
|
211
314
|
}
|
|
212
315
|
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
213
|
-
throw new
|
|
316
|
+
throw new InvalidTokenError("Token expired");
|
|
214
317
|
}
|
|
215
318
|
|
|
216
319
|
return {
|
|
@@ -226,10 +329,40 @@ export class OpseeOAuthProvider implements OAuthServerProvider {
|
|
|
226
329
|
};
|
|
227
330
|
}
|
|
228
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Checks the revocation denylist, treating an unreachable store as "not
|
|
334
|
+
* revoked".
|
|
335
|
+
*
|
|
336
|
+
* This lookup runs on every authenticated request. Letting a store error
|
|
337
|
+
* propagate would turn a momentary Redis hiccup — a failover, a reconnect, a
|
|
338
|
+
* command timeout — into a failed request for every live session at once,
|
|
339
|
+
* and with no way for a client to tell that apart from a real credential
|
|
340
|
+
* problem, the user ends up re-authenticating by hand. Revocation is a
|
|
341
|
+
* best-effort denylist on top of a token that is already time-bounded and
|
|
342
|
+
* re-validated by the backend on every API call, so a read failure degrades
|
|
343
|
+
* to the pre-revocation behaviour rather than locking everyone out.
|
|
344
|
+
*/
|
|
345
|
+
private async isRevoked(token: string): Promise<boolean> {
|
|
346
|
+
try {
|
|
347
|
+
return (await this.store.get(REVOKED_KEY(token))) !== null;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
console.error(
|
|
350
|
+
"[oauth] revocation check unavailable, allowing token:",
|
|
351
|
+
err instanceof Error ? err.message : err,
|
|
352
|
+
);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
229
357
|
async revokeToken(
|
|
230
358
|
_client: OAuthClientInformationFull,
|
|
231
359
|
request: OAuthTokenRevocationRequest,
|
|
232
360
|
): Promise<void> {
|
|
361
|
+
// A refresh handle is opaque, not a JWT — drop the record outright so it
|
|
362
|
+
// cannot be redeemed, rather than filing it on the JWT denylist where
|
|
363
|
+
// verifyAccessToken would never look it up.
|
|
364
|
+
await this.store.del(REFRESH_KEY(request.token));
|
|
365
|
+
|
|
233
366
|
// Keep the revocation marker only as long as the token could still be
|
|
234
367
|
// valid. Derive that from the JWT's exp when available.
|
|
235
368
|
let ttl = REVOKED_FALLBACK_TTL_SECONDS;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createClient } from "@connectrpc/connect";
|
|
2
|
+
import { createConnectTransport } from "@connectrpc/connect-node";
|
|
3
|
+
import { AccountService } from "../../gen/api/v1/account_pb.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A freshly minted MCP access token plus the identity it belongs to.
|
|
7
|
+
*/
|
|
8
|
+
export interface RenewedToken {
|
|
9
|
+
token: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
companyId: string;
|
|
12
|
+
/** RFC3339 expiry, as returned by the backend. Empty when unknown. */
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mints a new MCP access token on behalf of the holder of `currentToken`.
|
|
18
|
+
*
|
|
19
|
+
* `GenerateMCPToken` authenticates the caller from the bearer token itself, so
|
|
20
|
+
* a still-valid token is all that is needed to roll a new one — no user
|
|
21
|
+
* interaction and no signing key on this side. That is what lets a refresh
|
|
22
|
+
* token renew a session: we keep the last good access token server-side and
|
|
23
|
+
* trade it for a fresh one, rather than handing the same token back forever.
|
|
24
|
+
*/
|
|
25
|
+
export type TokenRenewer = (currentToken: string) => Promise<RenewedToken>;
|
|
26
|
+
|
|
27
|
+
function backendUrl(): string {
|
|
28
|
+
return process.env.OPSEE_API_URL || "https://grpc.api.opsee.ai";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const renewAccessToken: TokenRenewer = async (currentToken) => {
|
|
32
|
+
// Built per call rather than cached: the bearer differs for every renewal,
|
|
33
|
+
// and renewals are rare enough that connection reuse is not worth the risk
|
|
34
|
+
// of leaking one user's token onto another user's request.
|
|
35
|
+
const transport = createConnectTransport({
|
|
36
|
+
baseUrl: backendUrl(),
|
|
37
|
+
httpVersion: "1.1",
|
|
38
|
+
interceptors: [
|
|
39
|
+
(next) => async (req) => {
|
|
40
|
+
req.header.set("Authorization", `Bearer ${currentToken}`);
|
|
41
|
+
req.header.set("X-Opsee-Client", "mcp");
|
|
42
|
+
return await next(req);
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const account = createClient(AccountService, transport);
|
|
48
|
+
const res = await account.generateMCPToken({});
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
token: res.token,
|
|
52
|
+
userId: res.userId,
|
|
53
|
+
companyId: res.companyId,
|
|
54
|
+
expiresAt: res.expiresAt,
|
|
55
|
+
};
|
|
56
|
+
};
|