@12-apps/mcp 1.19.0 → 2.0.0
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/ADOPTING.md +248 -0
- package/README.md +39 -12
- package/package.json +28 -9
- package/prisma/mcp.prisma +108 -0
- package/prisma/migrations/20260812150000_add_mcp_oauth_tables/migration.sql +152 -0
- package/scripts/sync-mcp-schema.mjs +61 -0
- package/src/auth/authorization-server-metadata.ts +27 -8
- package/src/coverage-gate/index.ts +243 -0
- package/src/coverage-gate/route-methods.ts +86 -0
- package/src/generate/index.ts +197 -0
- package/src/guide.ts +144 -98
- package/src/hono/index.ts +43 -0
- package/src/index.ts +4 -2
- package/src/oauth/access-token.ts +186 -0
- package/src/oauth/authorization-code.ts +215 -0
- package/src/oauth/authorize.ts +260 -0
- package/src/oauth/clients.ts +158 -0
- package/src/oauth/code-replay.ts +51 -0
- package/src/oauth/config.ts +142 -0
- package/src/oauth/context.ts +253 -0
- package/src/oauth/create-api-mcp-oauth.ts +205 -0
- package/src/oauth/index.ts +117 -0
- package/src/oauth/keys.ts +107 -0
- package/src/oauth/pkce.ts +93 -0
- package/src/oauth/prisma-stores.ts +306 -0
- package/src/oauth/refresh.ts +286 -0
- package/src/oauth/register.ts +282 -0
- package/src/oauth/stores.ts +157 -0
- package/src/oauth/token-grants.ts +326 -0
- package/src/oauth/token-response.ts +154 -0
- package/src/react/ai-onboarding.tsx +54 -13
- package/src/react/index.ts +3 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/mcp/oauth` — the OAuth 2.1 authorization server behind an MCP surface
|
|
3
|
+
* (12-23): register / authorize / token, the JWKS, and the two `.well-known`
|
|
4
|
+
* discovery documents, plus the primitives under them (stateless codes, ES256
|
|
5
|
+
* access tokens, PKCE, hashed rotating refresh tokens with replay revocation).
|
|
6
|
+
*
|
|
7
|
+
* Its own subpath because this half is Node-only — `jose`, `node:crypto` — and the
|
|
8
|
+
* package root is also imported by browsers through `@12-apps/mcp/react`. A barrel
|
|
9
|
+
* is evaluated whole by Node's ESM loader, so mixing the two would drag key
|
|
10
|
+
* material handling into a bundle that has no business with it.
|
|
11
|
+
*/
|
|
12
|
+
export {
|
|
13
|
+
createApiMcpOauth,
|
|
14
|
+
type ApiMcpOauth,
|
|
15
|
+
type McpOauthHandlers,
|
|
16
|
+
type McpOauthRoute,
|
|
17
|
+
} from "./create-api-mcp-oauth";
|
|
18
|
+
export {
|
|
19
|
+
DEFAULT_OAUTH_PATHS,
|
|
20
|
+
resolveMcpOauthConfig,
|
|
21
|
+
type McpConnectionRecording,
|
|
22
|
+
type McpOauthConfig,
|
|
23
|
+
type McpOauthContext,
|
|
24
|
+
type McpOauthPaths,
|
|
25
|
+
type McpOauthSession,
|
|
26
|
+
} from "./context";
|
|
27
|
+
export {
|
|
28
|
+
DEFAULT_MCP_RESOURCE_PATH,
|
|
29
|
+
MCP_SUPPORTED_SCOPES,
|
|
30
|
+
issuer,
|
|
31
|
+
originFromRequest,
|
|
32
|
+
resolveTrustedOrigin,
|
|
33
|
+
resourceAudience,
|
|
34
|
+
trustedOriginsFromEnv,
|
|
35
|
+
type McpScope,
|
|
36
|
+
} from "./config";
|
|
37
|
+
export {
|
|
38
|
+
ACCESS_TOKEN_TTL_SECONDS,
|
|
39
|
+
AccessTokenError,
|
|
40
|
+
signAccessToken,
|
|
41
|
+
verifyAccessToken,
|
|
42
|
+
type AccessTokenErrorCode,
|
|
43
|
+
type SignAccessTokenInput,
|
|
44
|
+
type VerifiedAccessToken,
|
|
45
|
+
type VerifyAccessTokenOptions,
|
|
46
|
+
} from "./access-token";
|
|
47
|
+
export {
|
|
48
|
+
AUTHORIZATION_CODE_AUDIENCE,
|
|
49
|
+
AUTHORIZATION_CODE_TTL_SECONDS,
|
|
50
|
+
AuthorizationCodeError,
|
|
51
|
+
mintCode,
|
|
52
|
+
verifyCode,
|
|
53
|
+
type AuthorizationCodeErrorCode,
|
|
54
|
+
type MintCodeInput,
|
|
55
|
+
type VerifiedAuthorizationCode,
|
|
56
|
+
type VerifyCodeOptions,
|
|
57
|
+
} from "./authorization-code";
|
|
58
|
+
export {
|
|
59
|
+
SUPPORTED_CHALLENGE_METHOD,
|
|
60
|
+
UnsupportedChallengeMethodError,
|
|
61
|
+
computeChallenge,
|
|
62
|
+
verifyChallenge,
|
|
63
|
+
type CodeChallengeMethod,
|
|
64
|
+
} from "./pkce";
|
|
65
|
+
export {
|
|
66
|
+
DEFAULT_SIGNING_KEY_ENV,
|
|
67
|
+
DEFAULT_SIGNING_KEY_ID_ENV,
|
|
68
|
+
SIGNING_ALG,
|
|
69
|
+
loadSigningKeyFromEnv,
|
|
70
|
+
signingKeyProvider,
|
|
71
|
+
type McpSigningKey,
|
|
72
|
+
type McpSigningKeyProvider,
|
|
73
|
+
type PublicSigningJwk,
|
|
74
|
+
} from "./keys";
|
|
75
|
+
export {
|
|
76
|
+
DEFAULT_PROVIDER_ROOTS,
|
|
77
|
+
hashSecret,
|
|
78
|
+
matchesRedirectUri,
|
|
79
|
+
providerFromRedirectUris,
|
|
80
|
+
registerClient,
|
|
81
|
+
type ProviderAttributionRule,
|
|
82
|
+
type RegisterClientInput,
|
|
83
|
+
type RegisteredClient,
|
|
84
|
+
} from "./clients";
|
|
85
|
+
export {
|
|
86
|
+
REFRESH_TOKEN_TTL_MS,
|
|
87
|
+
RefreshTokenError,
|
|
88
|
+
getRefreshTokenIdentity,
|
|
89
|
+
hashToken,
|
|
90
|
+
issueRefreshToken,
|
|
91
|
+
rotateRefreshToken,
|
|
92
|
+
type IssuedRefreshToken,
|
|
93
|
+
type RefreshTokenContext,
|
|
94
|
+
type RefreshTokenErrorCode,
|
|
95
|
+
type RefreshTokenIdentity,
|
|
96
|
+
} from "./refresh";
|
|
97
|
+
export {
|
|
98
|
+
inProcessCodeReplayStore,
|
|
99
|
+
type CodeReplayStore,
|
|
100
|
+
} from "./code-replay";
|
|
101
|
+
export {
|
|
102
|
+
createPrismaMcpStores,
|
|
103
|
+
type McpOauthPrisma,
|
|
104
|
+
type McpOauthPrismaProvider,
|
|
105
|
+
} from "./prisma-stores";
|
|
106
|
+
export type {
|
|
107
|
+
McpConnectionStore,
|
|
108
|
+
McpOauthStores,
|
|
109
|
+
NewOAuthClient,
|
|
110
|
+
NewRefreshToken,
|
|
111
|
+
OAuthClientStore,
|
|
112
|
+
RefreshTokenStore,
|
|
113
|
+
StoredMcpConnection,
|
|
114
|
+
StoredOAuthClient,
|
|
115
|
+
StoredRefreshToken,
|
|
116
|
+
TokenEndpointAuthMethod,
|
|
117
|
+
} from "./stores";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { exportJWK, importPKCS8, type CryptoKey, type JWK } from "jose";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Signing-key / JWK loading for the OAuth authorization server (12-23, ported
|
|
5
|
+
* from future-pay's `lib/mcp/oauth/keys.ts`).
|
|
6
|
+
*
|
|
7
|
+
* ES256 (P-256) from PEM material, the published public JWK (with `kid` for
|
|
8
|
+
* rotation), and a safe-by-default absence signal (`null`) when no key is
|
|
9
|
+
* configured — callers then refuse to issue tokens and serve the JWKS as 503
|
|
10
|
+
* rather than falling back to a weaker mode while the surface is mounted.
|
|
11
|
+
*
|
|
12
|
+
* WHERE the PEM comes from is the host's business: `loadSigningKeyFromEnv` keeps
|
|
13
|
+
* future-pay's env-var wiring, and any other provider (a secrets manager, a KMS
|
|
14
|
+
* export) satisfies the same `McpSigningKeyProvider` shape.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** JWS algorithm for the signing key pair (asymmetric, self-validated via JWKS). */
|
|
18
|
+
export const SIGNING_ALG = "ES256";
|
|
19
|
+
|
|
20
|
+
/** A public JWK safe to publish at the JWKS endpoint (never carries `d`). */
|
|
21
|
+
export interface PublicSigningJwk extends JWK {
|
|
22
|
+
kid: string;
|
|
23
|
+
kty: "EC";
|
|
24
|
+
crv: "P-256";
|
|
25
|
+
alg: typeof SIGNING_ALG;
|
|
26
|
+
use: "sig";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The loaded signing material: the private key for signing + its public JWK. */
|
|
30
|
+
export interface McpSigningKey {
|
|
31
|
+
privateKey: CryptoKey;
|
|
32
|
+
publicJwk: PublicSigningJwk;
|
|
33
|
+
kid: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How the surface obtains signing material. Returning `null` means "not
|
|
38
|
+
* provisioned": the AS then mints nothing and the JWKS answers 503.
|
|
39
|
+
*/
|
|
40
|
+
export type McpSigningKeyProvider = () => Promise<McpSigningKey | null>;
|
|
41
|
+
|
|
42
|
+
async function parseSigningKey(pem: string, kid: string): Promise<McpSigningKey> {
|
|
43
|
+
// `extractable: true` is required so `exportJWK` can derive the public JWK;
|
|
44
|
+
// jose imports keys as non-extractable by default, which blocks the export.
|
|
45
|
+
const privateKey = await importPKCS8(pem, SIGNING_ALG, { extractable: true });
|
|
46
|
+
const jwk = await exportJWK(privateKey);
|
|
47
|
+
// Strip the private component; publish only the public half.
|
|
48
|
+
const { d: _private, ...publicHalf } = jwk;
|
|
49
|
+
void _private;
|
|
50
|
+
|
|
51
|
+
const publicJwk: PublicSigningJwk = {
|
|
52
|
+
...publicHalf,
|
|
53
|
+
kty: "EC",
|
|
54
|
+
crv: "P-256",
|
|
55
|
+
alg: SIGNING_ALG,
|
|
56
|
+
use: "sig",
|
|
57
|
+
kid,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return { privateKey, publicJwk, kid };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a provider over a PKCS#8 PEM + `kid` pair, with a per-process cache.
|
|
65
|
+
*
|
|
66
|
+
* Parsing PKCS#8 and exporting the JWK is pure for a given (pem, kid), so the
|
|
67
|
+
* promise is cached keyed on the material itself. A rotated key (different pem or
|
|
68
|
+
* kid) produces a different cache key and re-parses — the cache never masks a
|
|
69
|
+
* rotation.
|
|
70
|
+
*
|
|
71
|
+
* Rotation is BY `kid`: each key is published in the JWKS and selected by the
|
|
72
|
+
* `kid` header on issued JWTs, so publishing old + new during an overlap window
|
|
73
|
+
* lets both verify.
|
|
74
|
+
*/
|
|
75
|
+
export function signingKeyProvider(
|
|
76
|
+
read: () => { pem: string | undefined; kid: string | undefined },
|
|
77
|
+
): McpSigningKeyProvider {
|
|
78
|
+
let cache: { key: string; promise: Promise<McpSigningKey> } | null = null;
|
|
79
|
+
return async () => {
|
|
80
|
+
const { pem, kid } = read();
|
|
81
|
+
if (!pem || !kid) return null;
|
|
82
|
+
const cacheKey = `${kid} ${pem}`;
|
|
83
|
+
if (cache?.key === cacheKey) return cache.promise;
|
|
84
|
+
const promise = parseSigningKey(pem, kid);
|
|
85
|
+
cache = { key: cacheKey, promise };
|
|
86
|
+
return promise;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Env var carrying the ES256 private key as a PKCS#8 PEM (future-pay's name). */
|
|
91
|
+
export const DEFAULT_SIGNING_KEY_ENV = "MCP_OAUTH_SIGNING_KEY";
|
|
92
|
+
/** Env var carrying the key id (`kid`) used to select the key during rotation. */
|
|
93
|
+
export const DEFAULT_SIGNING_KEY_ID_ENV = "MCP_OAUTH_SIGNING_KEY_ID";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The env-backed provider — future-pay's wiring, kept identical, with the
|
|
97
|
+
* variable names as arguments so the package states no host's vocabulary.
|
|
98
|
+
*/
|
|
99
|
+
export function loadSigningKeyFromEnv(
|
|
100
|
+
keyEnv: string = DEFAULT_SIGNING_KEY_ENV,
|
|
101
|
+
kidEnv: string = DEFAULT_SIGNING_KEY_ID_ENV,
|
|
102
|
+
): McpSigningKeyProvider {
|
|
103
|
+
return signingKeyProvider(() => ({
|
|
104
|
+
pem: typeof process === "undefined" ? undefined : process.env?.[keyEnv],
|
|
105
|
+
kid: typeof process === "undefined" ? undefined : process.env?.[kidEnv],
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) S256 challenge helpers for the OAuth authorization server
|
|
3
|
+
* (12-23, ported verbatim from future-pay's `lib/mcp/oauth/pkce.ts`).
|
|
4
|
+
*
|
|
5
|
+
* OAuth 2.1 mandates the `S256` code-challenge method and forbids `plain`, so
|
|
6
|
+
* this module computes `BASE64URL(SHA-256(code_verifier))` and compares it to
|
|
7
|
+
* the stored `code_challenge` in constant time. The authorization endpoint
|
|
8
|
+
* binds a `code_challenge` into the stateless authorization code; the token
|
|
9
|
+
* endpoint calls {@link verifyChallenge} with the presented `code_verifier` to
|
|
10
|
+
* prove the redeeming client is the one that started the flow.
|
|
11
|
+
*
|
|
12
|
+
* `plain` is refused (throws {@link UnsupportedChallengeMethodError}) rather
|
|
13
|
+
* than silently accepted: `plain` offers no protection against an intercepted
|
|
14
|
+
* authorization code, which is the exact threat PKCE exists to close.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The only PKCE method this server accepts (OAuth 2.1 requires S256). */
|
|
18
|
+
export const SUPPORTED_CHALLENGE_METHOD = "S256";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* PKCE code-challenge methods, including the rejected legacy `plain`.
|
|
22
|
+
*
|
|
23
|
+
* @public exported because it is a parameter type of the exported
|
|
24
|
+
* {@link verifyChallenge}.
|
|
25
|
+
*/
|
|
26
|
+
export type CodeChallengeMethod = "S256" | "plain";
|
|
27
|
+
|
|
28
|
+
/** Thrown when a caller supplies a challenge method other than `S256`. */
|
|
29
|
+
export class UnsupportedChallengeMethodError extends Error {
|
|
30
|
+
readonly method: string;
|
|
31
|
+
|
|
32
|
+
constructor(method: string) {
|
|
33
|
+
super(
|
|
34
|
+
`unsupported code_challenge_method '${method}' — only ${SUPPORTED_CHALLENGE_METHOD} is allowed`,
|
|
35
|
+
);
|
|
36
|
+
this.name = "UnsupportedChallengeMethodError";
|
|
37
|
+
this.method = method;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Encode raw bytes as unpadded base64url (RFC 7636 challenge encoding). */
|
|
42
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
43
|
+
// Buffer.toString("base64url") emits the URL-safe alphabet with no padding.
|
|
44
|
+
return Buffer.from(bytes).toString("base64url");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compute the RFC 7636 S256 challenge for a `code_verifier`:
|
|
49
|
+
* `BASE64URL(SHA-256(ASCII(verifier)))`.
|
|
50
|
+
*/
|
|
51
|
+
export async function computeChallenge(verifier: string): Promise<string> {
|
|
52
|
+
const data = new TextEncoder().encode(verifier);
|
|
53
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
54
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Constant-time string comparison over the base64url challenge bytes.
|
|
59
|
+
*
|
|
60
|
+
* Returns `false` immediately on a length mismatch (lengths are not secret);
|
|
61
|
+
* for equal-length inputs every byte is compared so the timing does not reveal
|
|
62
|
+
* how many leading characters matched.
|
|
63
|
+
*/
|
|
64
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
65
|
+
if (a.length !== b.length) return false;
|
|
66
|
+
let mismatch = 0;
|
|
67
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
68
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
69
|
+
}
|
|
70
|
+
return mismatch === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Verify a presented `code_verifier` against a stored `code_challenge`.
|
|
75
|
+
*
|
|
76
|
+
* Recomputes the S256 challenge from `verifier` and constant-time-compares it
|
|
77
|
+
* to `storedChallenge`. Returns `true` on a match, `false` on a mismatch (or an
|
|
78
|
+
* empty stored challenge). Any method other than `S256` throws
|
|
79
|
+
* {@link UnsupportedChallengeMethodError} — `plain` is never accepted.
|
|
80
|
+
*/
|
|
81
|
+
export async function verifyChallenge(
|
|
82
|
+
verifier: string,
|
|
83
|
+
storedChallenge: string,
|
|
84
|
+
method: CodeChallengeMethod | string = SUPPORTED_CHALLENGE_METHOD,
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
if (method !== SUPPORTED_CHALLENGE_METHOD) {
|
|
87
|
+
throw new UnsupportedChallengeMethodError(method);
|
|
88
|
+
}
|
|
89
|
+
if (!storedChallenge) return false;
|
|
90
|
+
|
|
91
|
+
const computed = await computeChallenge(verifier);
|
|
92
|
+
return constantTimeEquals(computed, storedChallenge);
|
|
93
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
McpConnectionStore,
|
|
3
|
+
McpOauthStores,
|
|
4
|
+
NewOAuthClient,
|
|
5
|
+
NewRefreshToken,
|
|
6
|
+
OAuthClientStore,
|
|
7
|
+
RefreshTokenStore,
|
|
8
|
+
StoredMcpConnection,
|
|
9
|
+
StoredOAuthClient,
|
|
10
|
+
StoredRefreshToken,
|
|
11
|
+
} from "./stores";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The ports of `./stores.ts`, filled by Prisma (12-23).
|
|
15
|
+
*
|
|
16
|
+
* The package owns the three models (`prisma/mcp.prisma`), so their delegate
|
|
17
|
+
* shapes are known and this adapter can be exact. A host with Prisma therefore
|
|
18
|
+
* writes ONE line —
|
|
19
|
+
*
|
|
20
|
+
* stores: createPrismaMcpStores(async () => getPrismaClient() as unknown as McpOauthPrisma)
|
|
21
|
+
*
|
|
22
|
+
* — and no host code at all beyond it. The client is duck-typed (only the
|
|
23
|
+
* delegates used, only the arguments used) so this file never imports a project's
|
|
24
|
+
* generated client, and a non-Prisma host fills the ports directly instead.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A `where` on the composite unique of `mcp_connections`. */
|
|
28
|
+
interface ConnectionKey {
|
|
29
|
+
userId_oauthClientId: { userId: string; oauthClientId: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The minimal Prisma surface the AS needs. Every field is one the surface writes. */
|
|
33
|
+
export interface McpOauthPrisma {
|
|
34
|
+
oAuthClient: {
|
|
35
|
+
create(args: { data: NewOAuthClient }): Promise<StoredOAuthClient>;
|
|
36
|
+
findUnique(args: { where: { clientId: string } }): Promise<StoredOAuthClient | null>;
|
|
37
|
+
};
|
|
38
|
+
oAuthRefreshToken: {
|
|
39
|
+
create(args: { data: NewRefreshToken }): Promise<unknown>;
|
|
40
|
+
findUnique(args: { where: { tokenHash: string } }): Promise<StoredRefreshToken | null>;
|
|
41
|
+
findFirst(args: { where: { rotatedFrom: string } }): Promise<{ tokenHash: string } | null>;
|
|
42
|
+
findMany(args: {
|
|
43
|
+
where: { userEmail: string; clientId: string };
|
|
44
|
+
}): Promise<StoredRefreshToken[]>;
|
|
45
|
+
// No single-row `update`: the rotation used to revoke its parent with one and
|
|
46
|
+
// that was the bug (unconditional, so two concurrent rotations both won). Every
|
|
47
|
+
// revoke here is now an `updateMany` with a predicate that says WHICH rows may
|
|
48
|
+
// move, which is also why this delegate list stays honest about what is written.
|
|
49
|
+
updateMany(args: {
|
|
50
|
+
where:
|
|
51
|
+
| { tokenHash: { in: string[] } }
|
|
52
|
+
| { userEmail: string; clientId: string; revokedAt: null };
|
|
53
|
+
data: { revokedAt: Date };
|
|
54
|
+
}): Promise<{ count: number }>;
|
|
55
|
+
};
|
|
56
|
+
mcpConnection: {
|
|
57
|
+
findUnique(args: {
|
|
58
|
+
where: ConnectionKey;
|
|
59
|
+
select: { lastActiveAt: true };
|
|
60
|
+
}): Promise<{ lastActiveAt: Date } | null>;
|
|
61
|
+
findFirst(args: {
|
|
62
|
+
where: { userId: string; revokedAt: null; host: null };
|
|
63
|
+
orderBy: { lastActiveAt: "desc" };
|
|
64
|
+
select: { id: true };
|
|
65
|
+
}): Promise<{ id: string } | null>;
|
|
66
|
+
findMany(args: {
|
|
67
|
+
where: { userId: string; revokedAt: null; host?: string | null };
|
|
68
|
+
orderBy?: { lastActiveAt: "desc" };
|
|
69
|
+
select: Record<string, true>;
|
|
70
|
+
}): Promise<Record<string, unknown>[]>;
|
|
71
|
+
upsert(args: {
|
|
72
|
+
where: ConnectionKey;
|
|
73
|
+
create: Record<string, unknown>;
|
|
74
|
+
update: Record<string, unknown>;
|
|
75
|
+
}): Promise<unknown>;
|
|
76
|
+
update(args: { where: { id: string }; data: Record<string, unknown> }): Promise<unknown>;
|
|
77
|
+
updateMany(args: {
|
|
78
|
+
where: { id: { in: string[] } } | { userId: string; revokedAt: null; host: string };
|
|
79
|
+
data: Record<string, unknown>;
|
|
80
|
+
}): Promise<{ count: number }>;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Prisma's INTERACTIVE transaction, used for the rotation's claim + write. The
|
|
84
|
+
* callback form (not the array form) is required: the successor may only be
|
|
85
|
+
* created once the conditional revoke has reported that it, and not a concurrent
|
|
86
|
+
* sibling, claimed the parent — see `RefreshTokenStore.rotate`.
|
|
87
|
+
*/
|
|
88
|
+
$transaction<T>(fn: (tx: McpOauthTx) => Promise<T>): Promise<T>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The delegate subset used INSIDE the rotation transaction. Not exported: it is
|
|
93
|
+
* reachable structurally through `McpOauthPrisma.$transaction`, so no host ever
|
|
94
|
+
* needs to name it, and exporting a type nobody imports is what knip flags.
|
|
95
|
+
*/
|
|
96
|
+
interface McpOauthTx {
|
|
97
|
+
oAuthRefreshToken: {
|
|
98
|
+
create(args: { data: NewRefreshToken }): Promise<unknown>;
|
|
99
|
+
updateMany(args: {
|
|
100
|
+
where: { tokenHash: string; revokedAt: null };
|
|
101
|
+
data: { revokedAt: Date };
|
|
102
|
+
}): Promise<{ count: number }>;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A lazily-resolved client, so a host's singleton is awaited per call. */
|
|
107
|
+
export type McpOauthPrismaProvider = () => Promise<McpOauthPrisma>;
|
|
108
|
+
|
|
109
|
+
function clientStore(getPrisma: McpOauthPrismaProvider): OAuthClientStore {
|
|
110
|
+
return {
|
|
111
|
+
async create(client: NewOAuthClient) {
|
|
112
|
+
const prisma = await getPrisma();
|
|
113
|
+
return prisma.oAuthClient.create({ data: client });
|
|
114
|
+
},
|
|
115
|
+
async findByClientId(clientId: string) {
|
|
116
|
+
const prisma = await getPrisma();
|
|
117
|
+
return prisma.oAuthClient.findUnique({ where: { clientId } });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore {
|
|
123
|
+
return {
|
|
124
|
+
async create(token) {
|
|
125
|
+
const prisma = await getPrisma();
|
|
126
|
+
await prisma.oAuthRefreshToken.create({ data: token });
|
|
127
|
+
},
|
|
128
|
+
async findByHash(tokenHash) {
|
|
129
|
+
const prisma = await getPrisma();
|
|
130
|
+
return prisma.oAuthRefreshToken.findUnique({ where: { tokenHash } });
|
|
131
|
+
},
|
|
132
|
+
async hasSuccessor(tokenHash) {
|
|
133
|
+
const prisma = await getPrisma();
|
|
134
|
+
const successor = await prisma.oAuthRefreshToken.findFirst({
|
|
135
|
+
where: { rotatedFrom: tokenHash },
|
|
136
|
+
});
|
|
137
|
+
return successor !== null;
|
|
138
|
+
},
|
|
139
|
+
async listFamily(userEmail, clientId) {
|
|
140
|
+
const prisma = await getPrisma();
|
|
141
|
+
return prisma.oAuthRefreshToken.findMany({ where: { userEmail, clientId } });
|
|
142
|
+
},
|
|
143
|
+
async revokeHashes(tokenHashes, at) {
|
|
144
|
+
if (tokenHashes.length === 0) return;
|
|
145
|
+
const prisma = await getPrisma();
|
|
146
|
+
await prisma.oAuthRefreshToken.updateMany({
|
|
147
|
+
where: { tokenHash: { in: [...tokenHashes] } },
|
|
148
|
+
data: { revokedAt: at },
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
async rotate(successor, parentHash, at) {
|
|
152
|
+
const prisma = await getPrisma();
|
|
153
|
+
return prisma.$transaction(async (tx) => {
|
|
154
|
+
// CLAIM-ONCE. The `revokedAt: null` predicate is what makes this safe under
|
|
155
|
+
// concurrency, and it is load-bearing rather than defensive: on Postgres's
|
|
156
|
+
// default READ COMMITTED, a second transaction's `updateMany` blocks on the
|
|
157
|
+
// row lock, then re-evaluates this WHERE against the COMMITTED row — which
|
|
158
|
+
// now has a `revokedAt` — and reports 0 rows. So exactly one caller can ever
|
|
159
|
+
// see count 1, and it is the only one that goes on to create a successor.
|
|
160
|
+
// An unconditional `update` would let both through: two live successors of
|
|
161
|
+
// one parent, and replay detection silently defeated (it waits for a third
|
|
162
|
+
// use of the parent that now never comes).
|
|
163
|
+
const { count } = await tx.oAuthRefreshToken.updateMany({
|
|
164
|
+
where: { tokenHash: parentHash, revokedAt: null },
|
|
165
|
+
data: { revokedAt: at },
|
|
166
|
+
});
|
|
167
|
+
// Lost the claim: write NOTHING. The zero-row update commits as the no-op
|
|
168
|
+
// it is, so there is nothing to roll back.
|
|
169
|
+
if (count !== 1) return false;
|
|
170
|
+
// Same transaction as the claim, so a crash cannot leave a live parent AND
|
|
171
|
+
// a live child either.
|
|
172
|
+
await tx.oAuthRefreshToken.create({ data: successor });
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
async revokeLiveForClient(userEmail, clientId) {
|
|
177
|
+
const prisma = await getPrisma();
|
|
178
|
+
const { count } = await prisma.oAuthRefreshToken.updateMany({
|
|
179
|
+
where: { userEmail, clientId, revokedAt: null },
|
|
180
|
+
data: { revokedAt: new Date() },
|
|
181
|
+
});
|
|
182
|
+
return count;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The connection columns the account surface reads. */
|
|
188
|
+
const CONNECTION_SELECT = {
|
|
189
|
+
oauthClientId: true,
|
|
190
|
+
clientName: true,
|
|
191
|
+
host: true,
|
|
192
|
+
connectedAt: true,
|
|
193
|
+
lastActiveAt: true,
|
|
194
|
+
} as const;
|
|
195
|
+
|
|
196
|
+
function connectionStore(getPrisma: McpOauthPrismaProvider): McpConnectionStore {
|
|
197
|
+
return {
|
|
198
|
+
async lastActiveAt(userId, oauthClientId) {
|
|
199
|
+
const prisma = await getPrisma();
|
|
200
|
+
const row = await prisma.mcpConnection.findUnique({
|
|
201
|
+
where: { userId_oauthClientId: { userId, oauthClientId } },
|
|
202
|
+
select: { lastActiveAt: true },
|
|
203
|
+
});
|
|
204
|
+
return row?.lastActiveAt ?? null;
|
|
205
|
+
},
|
|
206
|
+
async recordActivity({ userId, oauthClientId, clientName, host, at }) {
|
|
207
|
+
const prisma = await getPrisma();
|
|
208
|
+
await prisma.mcpConnection.upsert({
|
|
209
|
+
where: { userId_oauthClientId: { userId, oauthClientId } },
|
|
210
|
+
create: { userId, oauthClientId, clientName, host, connectedAt: at, lastActiveAt: at },
|
|
211
|
+
// Never blank a known host on refresh — keep the existing attribution
|
|
212
|
+
// when this grant cannot derive one.
|
|
213
|
+
update: {
|
|
214
|
+
clientName,
|
|
215
|
+
lastActiveAt: at,
|
|
216
|
+
revokedAt: null,
|
|
217
|
+
...(host ? { host } : {}),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
async listActive(userId) {
|
|
222
|
+
const prisma = await getPrisma();
|
|
223
|
+
const rows = await prisma.mcpConnection.findMany({
|
|
224
|
+
where: { userId, revokedAt: null },
|
|
225
|
+
orderBy: { lastActiveAt: "desc" },
|
|
226
|
+
select: { ...CONNECTION_SELECT },
|
|
227
|
+
});
|
|
228
|
+
return rows as unknown as StoredMcpConnection[];
|
|
229
|
+
},
|
|
230
|
+
revokeByHost: (userId, host) => revokeByHost(getPrisma, userId, host),
|
|
231
|
+
announce: (userId, host) => announce(getPrisma, userId, host),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Disconnect one provider's connections — and ONLY that provider's.
|
|
237
|
+
*
|
|
238
|
+
* Every read and write is scoped by `userId`: a connection is per-user (an MCP
|
|
239
|
+
* bearer is not tenant-scoped), so the user id IS the isolation here, and the
|
|
240
|
+
* `id` list passed to the update comes from a query that already applied it.
|
|
241
|
+
*/
|
|
242
|
+
async function revokeByHost(
|
|
243
|
+
getPrisma: McpOauthPrismaProvider,
|
|
244
|
+
userId: string,
|
|
245
|
+
host: string,
|
|
246
|
+
): Promise<string[]> {
|
|
247
|
+
const prisma = await getPrisma();
|
|
248
|
+
const attributed = await prisma.mcpConnection.findMany({
|
|
249
|
+
where: { userId, revokedAt: null, host },
|
|
250
|
+
select: { id: true, oauthClientId: true },
|
|
251
|
+
});
|
|
252
|
+
// A legacy `host = null` row is claimed only when the provider has no row of
|
|
253
|
+
// its own: pre-attribution connections must stay disconnectable, but a provider
|
|
254
|
+
// that DID attribute can never revoke another assistant's row.
|
|
255
|
+
const targets =
|
|
256
|
+
attributed.length > 0
|
|
257
|
+
? attributed
|
|
258
|
+
: await prisma.mcpConnection.findMany({
|
|
259
|
+
where: { userId, revokedAt: null, host: null },
|
|
260
|
+
select: { id: true, oauthClientId: true },
|
|
261
|
+
});
|
|
262
|
+
if (targets.length === 0) return [];
|
|
263
|
+
await prisma.mcpConnection.updateMany({
|
|
264
|
+
where: { id: { in: targets.map((row) => String(row.id)) } },
|
|
265
|
+
data: { revokedAt: new Date() },
|
|
266
|
+
});
|
|
267
|
+
return targets.map((row) => String(row.oauthClientId));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A provider's self-report: refresh its own row, or claim the unattributed one. */
|
|
271
|
+
async function announce(
|
|
272
|
+
getPrisma: McpOauthPrismaProvider,
|
|
273
|
+
userId: string,
|
|
274
|
+
host: string,
|
|
275
|
+
): Promise<number> {
|
|
276
|
+
const prisma = await getPrisma();
|
|
277
|
+
const now = new Date();
|
|
278
|
+
const refreshed = await prisma.mcpConnection.updateMany({
|
|
279
|
+
where: { userId, revokedAt: null, host },
|
|
280
|
+
data: { lastActiveAt: now, revokedAt: null },
|
|
281
|
+
});
|
|
282
|
+
if (refreshed.count > 0) return refreshed.count;
|
|
283
|
+
|
|
284
|
+
// No row for this provider yet — attribute the just-connected one. Scoped by
|
|
285
|
+
// user, so a self-report can never reach another account's connection.
|
|
286
|
+
const candidate = await prisma.mcpConnection.findFirst({
|
|
287
|
+
where: { userId, revokedAt: null, host: null },
|
|
288
|
+
orderBy: { lastActiveAt: "desc" },
|
|
289
|
+
select: { id: true },
|
|
290
|
+
});
|
|
291
|
+
if (!candidate) return 0;
|
|
292
|
+
await prisma.mcpConnection.update({
|
|
293
|
+
where: { id: candidate.id },
|
|
294
|
+
data: { host, lastActiveAt: now, revokedAt: null },
|
|
295
|
+
});
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Every port, over one lazily-resolved Prisma client. */
|
|
300
|
+
export function createPrismaMcpStores(getPrisma: McpOauthPrismaProvider): McpOauthStores {
|
|
301
|
+
return {
|
|
302
|
+
clients: clientStore(getPrisma),
|
|
303
|
+
refreshTokens: refreshTokenStore(getPrisma),
|
|
304
|
+
connections: connectionStore(getPrisma),
|
|
305
|
+
};
|
|
306
|
+
}
|