@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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persistence PORTS of the authorization server (12-23).
|
|
3
|
+
*
|
|
4
|
+
* Three tables back the AS, and the package owns all three (see
|
|
5
|
+
* `prisma/mcp.prisma`): registered clients, rotating refresh tokens, and the
|
|
6
|
+
* per-user record of which AI host is live. What the package does NOT own is the
|
|
7
|
+
* client library used to reach them — so every read and write in the surface goes
|
|
8
|
+
* through these narrow ports, and `createPrismaMcpStores` (in
|
|
9
|
+
* `./prisma-stores.ts`) fills them for the common case in one line.
|
|
10
|
+
*
|
|
11
|
+
* The shapes are deliberately CLOSED and small: a host on something other than
|
|
12
|
+
* Prisma has a finite surface to fill, and the harness fills exactly this with
|
|
13
|
+
* hand-written SQL over a real Postgres.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The two token-endpoint auth methods the AS accepts (matches the DB CHECK). */
|
|
17
|
+
export type TokenEndpointAuthMethod = "none" | "client_secret_basic";
|
|
18
|
+
|
|
19
|
+
/** A registered OAuth client (an external host app — Claude.ai, ChatGPT…). */
|
|
20
|
+
export interface StoredOAuthClient {
|
|
21
|
+
clientId: string;
|
|
22
|
+
/** SHA-256 hex of the secret; `null` for a public PKCE client. */
|
|
23
|
+
clientSecretHash: string | null;
|
|
24
|
+
/** The EXACT-MATCH allowlist the authorize endpoint validates against. */
|
|
25
|
+
redirectUris: string[];
|
|
26
|
+
clientName: string | null;
|
|
27
|
+
tokenEndpointAuthMethod: string;
|
|
28
|
+
grantTypes: string[];
|
|
29
|
+
scopes: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What `register` persists (the durable subset of RFC 7591 metadata). */
|
|
33
|
+
export interface NewOAuthClient {
|
|
34
|
+
clientId: string;
|
|
35
|
+
clientSecretHash: string | null;
|
|
36
|
+
redirectUris: string[];
|
|
37
|
+
clientName: string | null;
|
|
38
|
+
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
|
|
39
|
+
grantTypes: string[];
|
|
40
|
+
scopes: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OAuthClientStore {
|
|
44
|
+
create(client: NewOAuthClient): Promise<StoredOAuthClient>;
|
|
45
|
+
/** By the PUBLIC `client_id`, or `null` when unknown. */
|
|
46
|
+
findByClientId(clientId: string): Promise<StoredOAuthClient | null>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A rotating refresh token, stored HASHED — never plaintext. */
|
|
50
|
+
export interface StoredRefreshToken {
|
|
51
|
+
tokenHash: string;
|
|
52
|
+
userEmail: string;
|
|
53
|
+
/** The original OAuth subject, kept stable across every rotation. */
|
|
54
|
+
userSub: string;
|
|
55
|
+
clientId: string;
|
|
56
|
+
scopes: string[];
|
|
57
|
+
expiresAt: Date;
|
|
58
|
+
/** The prior token's hash — the rotation lineage. `null` for a root token. */
|
|
59
|
+
rotatedFrom: string | null;
|
|
60
|
+
revokedAt: Date | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A token about to be stored (the plaintext never is). */
|
|
64
|
+
export type NewRefreshToken = Omit<StoredRefreshToken, "revokedAt">;
|
|
65
|
+
|
|
66
|
+
export interface RefreshTokenStore {
|
|
67
|
+
create(token: NewRefreshToken): Promise<void>;
|
|
68
|
+
findByHash(tokenHash: string): Promise<StoredRefreshToken | null>;
|
|
69
|
+
/** Whether some token was already rotated FROM this hash (replay detection). */
|
|
70
|
+
hasSuccessor(tokenHash: string): Promise<boolean>;
|
|
71
|
+
/** Every token of one `(userEmail, clientId)` family — the lineage walk's input. */
|
|
72
|
+
listFamily(userEmail: string, clientId: string): Promise<StoredRefreshToken[]>;
|
|
73
|
+
/** Revoke exactly these hashes (idempotent). */
|
|
74
|
+
revokeHashes(tokenHashes: readonly string[], at: Date): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* CLAIM the parent and store the successor, atomically. The whole of OAuth 2.1
|
|
77
|
+
* §4.3.1 replay protection rests on this one method, so read the contract before
|
|
78
|
+
* implementing it.
|
|
79
|
+
*
|
|
80
|
+
* Returns `true` when THIS call is the one that consumed `parentHash`, `false`
|
|
81
|
+
* when another call already had. `false` MUST mean nothing was written: no
|
|
82
|
+
* successor row, no second revocation.
|
|
83
|
+
*
|
|
84
|
+
* An implementation MUST revoke the parent CONDITIONALLY on it still being
|
|
85
|
+
* unrevoked — `updateMany({ where: { tokenHash: parentHash, revokedAt: null } })`,
|
|
86
|
+
* requiring a count of exactly 1 — and create the successor in the SAME
|
|
87
|
+
* transaction. An unconditional `update` is NOT enough: two concurrent rotations
|
|
88
|
+
* of one parent would both succeed, leaving two live successors of one token with
|
|
89
|
+
* no replay ever detected, because the replay rule fires on a THIRD use of the
|
|
90
|
+
* parent that then never comes. That is replay protection defeated by WINNING a
|
|
91
|
+
* race rather than by arriving second — precisely the attack rotation exists to
|
|
92
|
+
* stop, since an attacker holding a stolen refresh token need only fire it
|
|
93
|
+
* alongside the legitimate client to walk away with a live, independently
|
|
94
|
+
* rotating family.
|
|
95
|
+
*
|
|
96
|
+
* Atomicity against a CRASH is necessary too (a half-applied rotation leaves a
|
|
97
|
+
* live parent AND a live child) but it is not sufficient, and it is the easier
|
|
98
|
+
* half to satisfy by accident.
|
|
99
|
+
*/
|
|
100
|
+
rotate(successor: NewRefreshToken, parentHash: string, at: Date): Promise<boolean>;
|
|
101
|
+
/**
|
|
102
|
+
* Revoke every LIVE token a user holds for one client; returns how many were
|
|
103
|
+
* actually ended (already-revoked rows are skipped, so a repeat reports 0).
|
|
104
|
+
*/
|
|
105
|
+
revokeLiveForClient(userEmail: string, clientId: string): Promise<number>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The AI provider a connection is attributed to. */
|
|
109
|
+
export type McpConnectionHost = string;
|
|
110
|
+
|
|
111
|
+
/** A live connection, as the account surface shows it. */
|
|
112
|
+
export interface StoredMcpConnection {
|
|
113
|
+
oauthClientId: string;
|
|
114
|
+
clientName: string | null;
|
|
115
|
+
/** `null` for a pre-attribution connection. */
|
|
116
|
+
host: string | null;
|
|
117
|
+
connectedAt: Date;
|
|
118
|
+
lastActiveAt: Date;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface McpConnectionStore {
|
|
122
|
+
/** Liveness of one `(user, client)` pair, for the activity throttle. */
|
|
123
|
+
lastActiveAt(userId: string, oauthClientId: string): Promise<Date | null>;
|
|
124
|
+
/**
|
|
125
|
+
* Record (or refresh) liveness. Any activity CLEARS a prior `revokedAt` — the
|
|
126
|
+
* host is talking to us again — and must never blank a known `host` when this
|
|
127
|
+
* grant cannot derive one.
|
|
128
|
+
*/
|
|
129
|
+
recordActivity(input: {
|
|
130
|
+
userId: string;
|
|
131
|
+
oauthClientId: string;
|
|
132
|
+
clientName: string | null;
|
|
133
|
+
host: string | null;
|
|
134
|
+
at: Date;
|
|
135
|
+
}): Promise<void>;
|
|
136
|
+
/** A user's active (non-revoked) connections, most-recently-active first. */
|
|
137
|
+
listActive(userId: string): Promise<StoredMcpConnection[]>;
|
|
138
|
+
/**
|
|
139
|
+
* Revoke every live connection of one provider for this user and return the
|
|
140
|
+
* OAuth client ids that were revoked — the caller ends their refresh tokens,
|
|
141
|
+
* which is what actually cuts access.
|
|
142
|
+
*/
|
|
143
|
+
revokeByHost(userId: string, host: McpConnectionHost): Promise<string[]>;
|
|
144
|
+
/**
|
|
145
|
+
* The self-report path: attribute this user's just-connected, still-unattributed
|
|
146
|
+
* connection to `host`, or refresh the one already attributed to it. Returns
|
|
147
|
+
* rows touched.
|
|
148
|
+
*/
|
|
149
|
+
announce(userId: string, host: McpConnectionHost): Promise<number>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The stores the AS needs. `connections` is optional — see the config docs. */
|
|
153
|
+
export interface McpOauthStores {
|
|
154
|
+
clients: OAuthClientStore;
|
|
155
|
+
refreshTokens: RefreshTokenStore;
|
|
156
|
+
connections?: McpConnectionStore;
|
|
157
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { ACCESS_TOKEN_TTL_SECONDS, signAccessToken } from "./access-token";
|
|
2
|
+
import { AuthorizationCodeError, verifyCode } from "./authorization-code";
|
|
3
|
+
import { providerFromRedirectUris } from "./clients";
|
|
4
|
+
import type { McpConnectionRecording, McpOauthContext } from "./context";
|
|
5
|
+
import { verifyChallenge } from "./pkce";
|
|
6
|
+
import {
|
|
7
|
+
RefreshTokenError,
|
|
8
|
+
getRefreshTokenIdentity,
|
|
9
|
+
issueRefreshToken,
|
|
10
|
+
rotateRefreshToken,
|
|
11
|
+
} from "./refresh";
|
|
12
|
+
import type { McpConnectionStore } from "./stores";
|
|
13
|
+
import {
|
|
14
|
+
authenticateClient,
|
|
15
|
+
readClientCredentials,
|
|
16
|
+
tokenError,
|
|
17
|
+
tokenSuccess,
|
|
18
|
+
type ClientCredentials,
|
|
19
|
+
} from "./token-response";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The two grant handlers of the token endpoint (12-23, ported from future-pay's
|
|
23
|
+
* `lib/mcp/oauth/token-grants.ts`).
|
|
24
|
+
*
|
|
25
|
+
* Security invariants enforced here, unchanged:
|
|
26
|
+
* - **Single-use codes:** the code's `jti` is consumed the moment it is
|
|
27
|
+
* redeemed; a replay of the same code is `invalid_grant`.
|
|
28
|
+
* - **PKCE:** a `code_verifier` that does not S256-match the code's
|
|
29
|
+
* `code_challenge` is `invalid_grant`.
|
|
30
|
+
* - **Client auth:** a public client's `client_id` must equal the code's bound
|
|
31
|
+
* client; a confidential client must present a secret whose SHA-256 matches
|
|
32
|
+
* the stored hash, else `invalid_client` (401).
|
|
33
|
+
* - **Bound `redirect_uri`:** it must equal the one the code was minted with
|
|
34
|
+
* (RFC 6749 §4.1.3).
|
|
35
|
+
* - **Refresh rotation:** client-bound, replay-revoking, narrow-only scope.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Throttle default: don't rewrite liveness on every grant. */
|
|
39
|
+
const DEFAULT_ACTIVITY_THROTTLE_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort: record that this user's AI host (OAuth client) is live, so an
|
|
43
|
+
* account page can show "connected via Claude · active 2 min ago". Runs on the
|
|
44
|
+
* token grant, not the per-request hot path; hosts refresh every ~15 min so
|
|
45
|
+
* liveness stays fresh.
|
|
46
|
+
*
|
|
47
|
+
* NEVER lets a failure break token issuance — a recording error is swallowed, and
|
|
48
|
+
* nothing about it is logged, because the only interesting values here are an
|
|
49
|
+
* email and a client id. Skipped when the host resolves no user row (email is the
|
|
50
|
+
* identity) or when no connection recording is configured at all.
|
|
51
|
+
*/
|
|
52
|
+
async function recordHostConnection(
|
|
53
|
+
context: McpOauthContext,
|
|
54
|
+
email: string,
|
|
55
|
+
clientId: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const recording = context.connections;
|
|
58
|
+
const store = context.stores.connections;
|
|
59
|
+
if (!recording || !store) return;
|
|
60
|
+
try {
|
|
61
|
+
await writeConnectionActivity(context, { recording, store }, email, clientId);
|
|
62
|
+
} catch {
|
|
63
|
+
// Liveness is non-critical — never fail the grant on it. Nothing is logged
|
|
64
|
+
// either: the only values here are an email and a client id.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The write itself, once the recording ports are known to exist. */
|
|
69
|
+
async function writeConnectionActivity(
|
|
70
|
+
context: McpOauthContext,
|
|
71
|
+
ports: { recording: McpConnectionRecording; store: McpConnectionStore },
|
|
72
|
+
email: string,
|
|
73
|
+
clientId: string,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const { recording, store } = ports;
|
|
76
|
+
const [userId, client] = await Promise.all([
|
|
77
|
+
recording.resolveUserId(email),
|
|
78
|
+
context.stores.clients.findByClientId(clientId),
|
|
79
|
+
]);
|
|
80
|
+
// No user row yet: email is the identity the AS binds to, so the grant stands
|
|
81
|
+
// and there is simply nothing to attribute it to.
|
|
82
|
+
if (!userId) return;
|
|
83
|
+
|
|
84
|
+
const throttleMs = recording.activityThrottleMs ?? DEFAULT_ACTIVITY_THROTTLE_MS;
|
|
85
|
+
const lastActiveAt = await store.lastActiveAt(userId, clientId);
|
|
86
|
+
const now = new Date();
|
|
87
|
+
if (lastActiveAt && now.getTime() - lastActiveAt.getTime() < throttleMs) return;
|
|
88
|
+
|
|
89
|
+
await store.recordActivity({
|
|
90
|
+
userId,
|
|
91
|
+
oauthClientId: clientId,
|
|
92
|
+
clientName: client?.clientName ?? null,
|
|
93
|
+
// Attribute to a provider from the client's redirect URIs (claude.ai →
|
|
94
|
+
// claude, chatgpt.com → chatgpt) so an account page lights the right card.
|
|
95
|
+
host: client ? providerFromRedirectUris(client.redirectUris, recording.providerRules) : null,
|
|
96
|
+
at: now,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The presented `authorization_code` grant parameters, once validated present. */
|
|
101
|
+
interface AuthorizationCodeParams {
|
|
102
|
+
code: string;
|
|
103
|
+
redirectUri: string;
|
|
104
|
+
codeVerifier: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read + presence-check the `authorization_code` form parameters. Returns the three
|
|
109
|
+
* required values, or an `invalid_request` (400) naming the first missing field.
|
|
110
|
+
*/
|
|
111
|
+
function readAuthorizationCodeParams(form: URLSearchParams): AuthorizationCodeParams | Response {
|
|
112
|
+
const code = form.get("code");
|
|
113
|
+
const redirectUri = form.get("redirect_uri");
|
|
114
|
+
const codeVerifier = form.get("code_verifier");
|
|
115
|
+
|
|
116
|
+
if (!code) return tokenError("invalid_request", 400, "missing code");
|
|
117
|
+
if (!redirectUri) return tokenError("invalid_request", 400, "missing redirect_uri");
|
|
118
|
+
if (!codeVerifier) return tokenError("invalid_request", 400, "missing code_verifier");
|
|
119
|
+
|
|
120
|
+
return { code, redirectUri, codeVerifier };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Redeem a presented code, or refuse.
|
|
125
|
+
*
|
|
126
|
+
* The ORDER is the security contract, and it is the order future-pay established:
|
|
127
|
+
* verify the code's signature, authenticate the presenting client against the
|
|
128
|
+
* client the code was bound to, check the bound `redirect_uri`, check PKCE — and
|
|
129
|
+
* only THEN consume the single-use `jti`. Consuming earlier would let a failed
|
|
130
|
+
* attempt (a wrong secret, a mismatched verifier) burn a legitimate code.
|
|
131
|
+
*/
|
|
132
|
+
async function redeemCode(
|
|
133
|
+
context: McpOauthContext,
|
|
134
|
+
params: AuthorizationCodeParams,
|
|
135
|
+
credentials: ClientCredentials,
|
|
136
|
+
origin: string,
|
|
137
|
+
): Promise<Awaited<ReturnType<typeof verifyCode>> | Response> {
|
|
138
|
+
const { code, redirectUri, codeVerifier } = params;
|
|
139
|
+
|
|
140
|
+
// Every code-level failure (expired, tampered, wrong-audience) is invalid_grant.
|
|
141
|
+
let verified;
|
|
142
|
+
try {
|
|
143
|
+
verified = await verifyCode(context.signingKey, code, { origin });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof AuthorizationCodeError) {
|
|
146
|
+
return tokenError("invalid_grant", 400, "invalid or expired authorization code");
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const authError = await authenticateClient(
|
|
152
|
+
context.stores.clients,
|
|
153
|
+
credentials,
|
|
154
|
+
verified.clientId,
|
|
155
|
+
);
|
|
156
|
+
if (authError) return authError;
|
|
157
|
+
|
|
158
|
+
// The redirect_uri MUST match the one the code was bound to (RFC 6749 §4.1.3).
|
|
159
|
+
if (redirectUri !== verified.redirectUri) {
|
|
160
|
+
return tokenError("invalid_grant", 400, "redirect_uri mismatch");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// PKCE: the presented verifier must S256-match the bound challenge.
|
|
164
|
+
if (!(await verifyChallenge(codeVerifier, verified.codeChallenge))) {
|
|
165
|
+
return tokenError("invalid_grant", 400, "PKCE verification failed");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Single-use: consume the code's jti; a replay of the same code fails.
|
|
169
|
+
if (!(await context.codeReplay.consume(verified.jti, Date.now()))) {
|
|
170
|
+
return tokenError("invalid_grant", 400, "authorization code already used");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return verified;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Handle the `authorization_code` grant. */
|
|
177
|
+
async function handleAuthorizationCode(
|
|
178
|
+
context: McpOauthContext,
|
|
179
|
+
form: URLSearchParams,
|
|
180
|
+
credentials: ClientCredentials,
|
|
181
|
+
origin: string,
|
|
182
|
+
): Promise<Response> {
|
|
183
|
+
const params = readAuthorizationCodeParams(form);
|
|
184
|
+
if (params instanceof Response) return params;
|
|
185
|
+
|
|
186
|
+
const verified = await redeemCode(context, params, credentials, origin);
|
|
187
|
+
if (verified instanceof Response) return verified;
|
|
188
|
+
|
|
189
|
+
const scopes = verified.scope.split(/\s+/).filter(Boolean);
|
|
190
|
+
|
|
191
|
+
const accessToken = await signAccessToken(context.signingKey, {
|
|
192
|
+
email: verified.email,
|
|
193
|
+
subject: verified.sub,
|
|
194
|
+
scopes,
|
|
195
|
+
origin,
|
|
196
|
+
resourcePath: context.resourcePath,
|
|
197
|
+
ttlSeconds: context.accessTokenTtlSeconds,
|
|
198
|
+
});
|
|
199
|
+
if (!accessToken) {
|
|
200
|
+
// No signing key configured while the surface is on — refuse rather than fall
|
|
201
|
+
// back to a weaker mode (safe-by-default).
|
|
202
|
+
return tokenError("invalid_request", 400, "token issuance unavailable");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const refresh = await issueRefreshToken(
|
|
206
|
+
{ store: context.stores.refreshTokens, ttlMs: context.refreshTokenTtlMs },
|
|
207
|
+
{
|
|
208
|
+
userEmail: verified.email,
|
|
209
|
+
userSub: verified.sub,
|
|
210
|
+
clientId: verified.clientId,
|
|
211
|
+
scopes,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await recordHostConnection(context, verified.email, verified.clientId);
|
|
216
|
+
|
|
217
|
+
return tokenSuccess({
|
|
218
|
+
access_token: accessToken,
|
|
219
|
+
token_type: "Bearer",
|
|
220
|
+
expires_in: context.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
|
|
221
|
+
refresh_token: refresh.refreshToken,
|
|
222
|
+
scope: refresh.scopes.join(" "),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Handle the `refresh_token` grant. */
|
|
227
|
+
async function handleRefreshToken(
|
|
228
|
+
context: McpOauthContext,
|
|
229
|
+
form: URLSearchParams,
|
|
230
|
+
credentials: ClientCredentials,
|
|
231
|
+
origin: string,
|
|
232
|
+
): Promise<Response> {
|
|
233
|
+
const refreshToken = form.get("refresh_token");
|
|
234
|
+
const requestedScope = form.get("scope");
|
|
235
|
+
|
|
236
|
+
if (!refreshToken) return tokenError("invalid_request", 400, "missing refresh_token");
|
|
237
|
+
|
|
238
|
+
// Authenticate the presenting client (public: client_id present; confidential:
|
|
239
|
+
// secret checked). `authenticateClient` rejects a missing client_id, so on
|
|
240
|
+
// success `credentials.clientId` is non-null and is the identity the rotation is
|
|
241
|
+
// bound to below.
|
|
242
|
+
const authError = await authenticateClient(context.stores.clients, credentials);
|
|
243
|
+
if (authError) return authError;
|
|
244
|
+
const clientId = credentials.clientId as string;
|
|
245
|
+
|
|
246
|
+
const newScopes = requestedScope ? requestedScope.split(/\s+/).filter(Boolean) : undefined;
|
|
247
|
+
const refreshContext = {
|
|
248
|
+
store: context.stores.refreshTokens,
|
|
249
|
+
ttlMs: context.refreshTokenTtlMs,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Rotation enforces client binding (the token's stored clientId must equal the
|
|
253
|
+
// authenticated one, else invalid_grant — OAuth 2.1 §4.3), plus
|
|
254
|
+
// replay-revocation and scope-narrowing.
|
|
255
|
+
let rotated;
|
|
256
|
+
try {
|
|
257
|
+
rotated = await rotateRefreshToken(refreshContext, refreshToken, clientId, newScopes);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof RefreshTokenError) {
|
|
260
|
+
return tokenError(error.code, 400, error.message);
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// The refresh token binds the user's email AND original OAuth `sub`; recover both
|
|
266
|
+
// so the successor access token carries the SAME stable `sub` as the initial
|
|
267
|
+
// token (RFC 6749 §5.1 / OIDC §2), not the email.
|
|
268
|
+
const identity = await getRefreshTokenIdentity(refreshContext, rotated.refreshToken);
|
|
269
|
+
if (!identity) return tokenError("invalid_grant", 400, "refresh token binding not found");
|
|
270
|
+
|
|
271
|
+
const accessToken = await signAccessToken(context.signingKey, {
|
|
272
|
+
email: identity.userEmail,
|
|
273
|
+
subject: identity.userSub,
|
|
274
|
+
scopes: rotated.scopes,
|
|
275
|
+
origin,
|
|
276
|
+
resourcePath: context.resourcePath,
|
|
277
|
+
ttlSeconds: context.accessTokenTtlSeconds,
|
|
278
|
+
});
|
|
279
|
+
if (!accessToken) return tokenError("invalid_request", 400, "token issuance unavailable");
|
|
280
|
+
|
|
281
|
+
await recordHostConnection(context, identity.userEmail, clientId);
|
|
282
|
+
|
|
283
|
+
return tokenSuccess({
|
|
284
|
+
access_token: accessToken,
|
|
285
|
+
token_type: "Bearer",
|
|
286
|
+
expires_in: context.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
|
|
287
|
+
refresh_token: rotated.refreshToken,
|
|
288
|
+
scope: rotated.scopes.join(" "),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `POST <token>` — the endpoint itself: gate → parse form → dispatch by
|
|
294
|
+
* `grant_type`. Thin on purpose; the flows above are where the invariants live.
|
|
295
|
+
*/
|
|
296
|
+
export async function tokenEndpoint(
|
|
297
|
+
context: McpOauthContext,
|
|
298
|
+
request: Request,
|
|
299
|
+
): Promise<Response> {
|
|
300
|
+
const origin = context.originOf(request);
|
|
301
|
+
|
|
302
|
+
let form: URLSearchParams;
|
|
303
|
+
try {
|
|
304
|
+
form = new URLSearchParams(await request.text());
|
|
305
|
+
} catch {
|
|
306
|
+
return tokenError("invalid_request", 400, "malformed request body");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const grantType = form.get("grant_type");
|
|
310
|
+
if (!grantType) return tokenError("invalid_request", 400, "missing grant_type");
|
|
311
|
+
|
|
312
|
+
const credentials = readClientCredentials(request, form);
|
|
313
|
+
|
|
314
|
+
switch (grantType) {
|
|
315
|
+
case "authorization_code":
|
|
316
|
+
return handleAuthorizationCode(context, form, credentials, origin);
|
|
317
|
+
case "refresh_token":
|
|
318
|
+
return handleRefreshToken(context, form, credentials, origin);
|
|
319
|
+
default:
|
|
320
|
+
return tokenError(
|
|
321
|
+
"unsupported_grant_type",
|
|
322
|
+
400,
|
|
323
|
+
`grant_type '${grantType}' is not supported`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { OAuthClientStore } from "./stores";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The token endpoint's wire helpers: the RFC 6749 §5.1/§5.2 bodies and client
|
|
7
|
+
* authentication (12-23, split out of the grant handlers so each file stays under
|
|
8
|
+
* the size gate — the same split future-pay made).
|
|
9
|
+
*
|
|
10
|
+
* These bodies are NOT the house `{ data }` envelope, deliberately: they are read
|
|
11
|
+
* by OAuth clients that expect the RFC shapes at the top level, and `Cache-Control:
|
|
12
|
+
* no-store` is required on every one of them because they carry credentials.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** OAuth 2.1 / RFC 6749 §5.2 error codes the token endpoint can emit. */
|
|
16
|
+
type TokenErrorCode =
|
|
17
|
+
| "invalid_request"
|
|
18
|
+
| "invalid_client"
|
|
19
|
+
| "invalid_grant"
|
|
20
|
+
| "invalid_scope"
|
|
21
|
+
| "unsupported_grant_type";
|
|
22
|
+
|
|
23
|
+
/** The RFC 6749 §5.1 successful token response. */
|
|
24
|
+
interface TokenSuccessResponse {
|
|
25
|
+
access_token: string;
|
|
26
|
+
token_type: "Bearer";
|
|
27
|
+
expires_in: number;
|
|
28
|
+
refresh_token: string;
|
|
29
|
+
scope: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const JSON_HEADERS = {
|
|
33
|
+
"content-type": "application/json; charset=utf-8",
|
|
34
|
+
"cache-control": "no-store",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** A JSON error response in the RFC 6749 §5.2 shape. */
|
|
38
|
+
export function tokenError(
|
|
39
|
+
error: TokenErrorCode,
|
|
40
|
+
status: number,
|
|
41
|
+
description?: string,
|
|
42
|
+
headers: Record<string, string> = {},
|
|
43
|
+
): Response {
|
|
44
|
+
const body: { error: TokenErrorCode; error_description?: string } = { error };
|
|
45
|
+
if (description) body.error_description = description;
|
|
46
|
+
return new Response(JSON.stringify(body), {
|
|
47
|
+
status,
|
|
48
|
+
headers: { ...JSON_HEADERS, ...headers },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A JSON success response with `Cache-Control: no-store` (RFC 6749 §5.1). */
|
|
53
|
+
export function tokenSuccess(payload: TokenSuccessResponse): Response {
|
|
54
|
+
return new Response(JSON.stringify(payload), { status: 200, headers: { ...JSON_HEADERS } });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Constant-time equality of two SHA-256 hex digests. */
|
|
58
|
+
function hashesEqual(a: string, b: string): boolean {
|
|
59
|
+
const bufA = Buffer.from(a, "hex");
|
|
60
|
+
const bufB = Buffer.from(b, "hex");
|
|
61
|
+
if (bufA.length !== bufB.length || bufA.length === 0) return false;
|
|
62
|
+
return timingSafeEqual(bufA, bufB);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** SHA-256 hex digest — matches the at-rest client-secret hashing convention. */
|
|
66
|
+
function sha256Hex(value: string): string {
|
|
67
|
+
return createHash("sha256").update(value).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Client credentials extracted from HTTP Basic auth or the form body. */
|
|
71
|
+
export interface ClientCredentials {
|
|
72
|
+
clientId: string | null;
|
|
73
|
+
clientSecret: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the presented client credentials. HTTP Basic (`client_secret_basic`)
|
|
78
|
+
* takes precedence over the form-body `client_id` per RFC 6749 §2.3.1; a malformed
|
|
79
|
+
* Basic header is treated as absent (the form body still applies).
|
|
80
|
+
*/
|
|
81
|
+
export function readClientCredentials(
|
|
82
|
+
request: Request,
|
|
83
|
+
form: URLSearchParams,
|
|
84
|
+
): ClientCredentials {
|
|
85
|
+
const authorization = request.headers.get("authorization");
|
|
86
|
+
if (authorization && authorization.startsWith("Basic ")) {
|
|
87
|
+
const decoded = Buffer.from(authorization.slice(6), "base64").toString("utf8");
|
|
88
|
+
const separator = decoded.indexOf(":");
|
|
89
|
+
if (separator !== -1) {
|
|
90
|
+
return {
|
|
91
|
+
clientId: decoded.slice(0, separator),
|
|
92
|
+
clientSecret: decoded.slice(separator + 1),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { clientId: form.get("client_id"), clientSecret: form.get("client_secret") };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The 401 challenge issued on a client-authentication failure. */
|
|
100
|
+
const CLIENT_AUTH_CHALLENGE = { "www-authenticate": 'Basic realm="oauth-token"' };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Authenticate the client that presents the request. A public client is identified
|
|
104
|
+
* by `client_id` alone (which must equal `expectedClientId` when provided). A
|
|
105
|
+
* confidential client (`client_secret_basic`) MUST present a secret whose SHA-256
|
|
106
|
+
* matches the stored hash. Returns `null` on success, or a 401 `invalid_client`
|
|
107
|
+
* response on failure — never a body that says which half was wrong.
|
|
108
|
+
*/
|
|
109
|
+
export async function authenticateClient(
|
|
110
|
+
clients: OAuthClientStore,
|
|
111
|
+
credentials: ClientCredentials,
|
|
112
|
+
expectedClientId?: string,
|
|
113
|
+
): Promise<Response | null> {
|
|
114
|
+
const clientId = credentials.clientId;
|
|
115
|
+
if (!clientId) {
|
|
116
|
+
return tokenError("invalid_client", 401, "missing client_id", CLIENT_AUTH_CHALLENGE);
|
|
117
|
+
}
|
|
118
|
+
if (expectedClientId && clientId !== expectedClientId) {
|
|
119
|
+
// The presenting client does not match the client the code was issued to.
|
|
120
|
+
return tokenError(
|
|
121
|
+
"invalid_client",
|
|
122
|
+
401,
|
|
123
|
+
"client_id does not match the grant",
|
|
124
|
+
CLIENT_AUTH_CHALLENGE,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const client = await clients.findByClientId(clientId);
|
|
129
|
+
if (!client) {
|
|
130
|
+
return tokenError("invalid_client", 401, "unknown client", CLIENT_AUTH_CHALLENGE);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (client.tokenEndpointAuthMethod === "client_secret_basic") {
|
|
134
|
+
const secret = credentials.clientSecret;
|
|
135
|
+
if (!secret || !client.clientSecretHash) {
|
|
136
|
+
return tokenError(
|
|
137
|
+
"invalid_client",
|
|
138
|
+
401,
|
|
139
|
+
"client authentication required",
|
|
140
|
+
CLIENT_AUTH_CHALLENGE,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!hashesEqual(sha256Hex(secret), client.clientSecretHash)) {
|
|
144
|
+
return tokenError(
|
|
145
|
+
"invalid_client",
|
|
146
|
+
401,
|
|
147
|
+
"invalid client credentials",
|
|
148
|
+
CLIENT_AUTH_CHALLENGE,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null;
|
|
154
|
+
}
|