@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,215 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify, importJWK, type JWTPayload } from "jose";
|
|
2
|
+
|
|
3
|
+
import { issuer } from "./config";
|
|
4
|
+
import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stateless authorization-code mint/verify (12-23, ported from future-pay's
|
|
8
|
+
* `lib/mcp/oauth/authorization-code.ts` — behaviour unchanged; the signing key
|
|
9
|
+
* arrives through a provider instead of an env read).
|
|
10
|
+
*
|
|
11
|
+
* The authorization code is a short-lived (<=60s) ES256-signed JWT — no DB table,
|
|
12
|
+
* no cleanup job. It binds the signed-in user (`sub`/`email`), the `client_id`,
|
|
13
|
+
* the `redirect_uri`, the PKCE `code_challenge`, and the requested `scope`, plus a
|
|
14
|
+
* unique `jti` the token endpoint records once to enforce single-use (replay)
|
|
15
|
+
* semantics on top of the short expiry.
|
|
16
|
+
*
|
|
17
|
+
* The code carries a DISTINCT audience (`oauth:code`) from the access token
|
|
18
|
+
* (`${origin}/api/mcp`), so a code can never be presented to the resource server
|
|
19
|
+
* as a bearer access token (and vice versa): {@link verifyCode} pins
|
|
20
|
+
* `audience: "oauth:code"`, and the access-token verifier pins the resource
|
|
21
|
+
* audience — each rejects the other's blobs.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Audience pinning the code to the OAuth code-exchange step only. Distinct from
|
|
26
|
+
* the access-token audience so a code cannot be replayed as an access token.
|
|
27
|
+
*/
|
|
28
|
+
export const AUTHORIZATION_CODE_AUDIENCE = "oauth:code";
|
|
29
|
+
|
|
30
|
+
/** Authorization-code lifetime — single-use and short-lived (<=60s per spec). */
|
|
31
|
+
export const AUTHORIZATION_CODE_TTL_SECONDS = 60;
|
|
32
|
+
|
|
33
|
+
/** Clock skew tolerated on `exp`/`iat` validation, in seconds. */
|
|
34
|
+
const CLOCK_TOLERANCE_SECONDS = 5;
|
|
35
|
+
|
|
36
|
+
/** Fields bound into a minted authorization code. */
|
|
37
|
+
export interface MintCodeInput {
|
|
38
|
+
/** The OAuth subject bound to the code (identity from the cookie session). */
|
|
39
|
+
sub: string;
|
|
40
|
+
/** The signed-in user's email (the identity all downstream tokens bind to). */
|
|
41
|
+
email: string;
|
|
42
|
+
/** The OAuth client the code is issued to. */
|
|
43
|
+
clientId: string;
|
|
44
|
+
/** The exact registered redirect URI the flow started with. */
|
|
45
|
+
redirectUri: string;
|
|
46
|
+
/** The PKCE S256 `code_challenge` the token endpoint verifies against. */
|
|
47
|
+
codeChallenge: string;
|
|
48
|
+
/** The requested scope (space-delimited), carried through to the token. */
|
|
49
|
+
scope: string;
|
|
50
|
+
/** The deployment origin — derives the code's `iss`. */
|
|
51
|
+
origin: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The bound fields a verified authorization code resolves to. */
|
|
55
|
+
export interface VerifiedAuthorizationCode {
|
|
56
|
+
sub: string;
|
|
57
|
+
email: string;
|
|
58
|
+
clientId: string;
|
|
59
|
+
redirectUri: string;
|
|
60
|
+
codeChallenge: string;
|
|
61
|
+
scope: string;
|
|
62
|
+
/** The one-time identifier the token endpoint records to enforce single-use. */
|
|
63
|
+
jti: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The single failure discriminator for the OAuth token endpoint. */
|
|
67
|
+
export type AuthorizationCodeErrorCode = "invalid_grant";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A typed authorization-code failure. Every rejection (expired, wrong-audience,
|
|
71
|
+
* wrong-issuer, tampered, bad-signature, unconfigured key) surfaces as
|
|
72
|
+
* `invalid_grant` per RFC 6749 §5.2 for the token endpoint.
|
|
73
|
+
*/
|
|
74
|
+
export class AuthorizationCodeError extends Error {
|
|
75
|
+
readonly code: AuthorizationCodeErrorCode;
|
|
76
|
+
|
|
77
|
+
constructor(message?: string) {
|
|
78
|
+
super(message ?? "invalid_grant");
|
|
79
|
+
this.name = "AuthorizationCodeError";
|
|
80
|
+
this.code = "invalid_grant";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Deterministic-clock option shared by mint + verify. */
|
|
85
|
+
interface ClockOption {
|
|
86
|
+
/** Epoch milliseconds; defaults to `Date.now()`. Injected for deterministic tests. */
|
|
87
|
+
now?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The JWT claim shape of an authorization code. */
|
|
91
|
+
interface AuthorizationCodeClaims extends JWTPayload {
|
|
92
|
+
email: string;
|
|
93
|
+
client_id: string;
|
|
94
|
+
redirect_uri: string;
|
|
95
|
+
code_challenge: string;
|
|
96
|
+
scope: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nowSeconds(now?: number): number {
|
|
100
|
+
return Math.floor((now ?? Date.now()) / 1000);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read a required string claim, or `null` when absent/wrong-typed. */
|
|
104
|
+
function stringClaim(payload: JWTPayload, key: string): string | null {
|
|
105
|
+
const value = payload[key];
|
|
106
|
+
return typeof value === "string" ? value : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract the bound fields from an already-signature/iss/aud/exp-validated code
|
|
111
|
+
* payload, enforcing that every required claim is a present string (`scope` may be
|
|
112
|
+
* the empty string but must be present). Throws {@link AuthorizationCodeError}
|
|
113
|
+
* when any required bound field is missing or wrong-typed.
|
|
114
|
+
*/
|
|
115
|
+
function extractBoundFields(payload: JWTPayload): VerifiedAuthorizationCode {
|
|
116
|
+
const sub = stringClaim(payload, "sub");
|
|
117
|
+
const email = stringClaim(payload, "email");
|
|
118
|
+
const clientId = stringClaim(payload, "client_id");
|
|
119
|
+
const redirectUri = stringClaim(payload, "redirect_uri");
|
|
120
|
+
const codeChallenge = stringClaim(payload, "code_challenge");
|
|
121
|
+
const scope = stringClaim(payload, "scope");
|
|
122
|
+
const jti = stringClaim(payload, "jti");
|
|
123
|
+
|
|
124
|
+
if (!sub || !email || !clientId || !redirectUri || !codeChallenge || scope === null || !jti) {
|
|
125
|
+
throw new AuthorizationCodeError("code is missing required bound fields");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { sub, email, clientId, redirectUri, codeChallenge, scope, jti };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mint a single-use, stateless authorization code bound to the flow inputs.
|
|
133
|
+
*
|
|
134
|
+
* Returns `null` when no signing key is configured (safe-by-default: the AS
|
|
135
|
+
* refuses to issue rather than falling back to a weaker mode). Sets the `kid`
|
|
136
|
+
* header so the same key resolves the code at verify time.
|
|
137
|
+
*/
|
|
138
|
+
export async function mintCode(
|
|
139
|
+
loadSigningKey: McpSigningKeyProvider,
|
|
140
|
+
input: MintCodeInput,
|
|
141
|
+
options?: ClockOption,
|
|
142
|
+
): Promise<string | null> {
|
|
143
|
+
const key = await loadSigningKey();
|
|
144
|
+
if (!key) return null;
|
|
145
|
+
|
|
146
|
+
const iat = nowSeconds(options?.now);
|
|
147
|
+
const exp = iat + AUTHORIZATION_CODE_TTL_SECONDS;
|
|
148
|
+
|
|
149
|
+
const claims: AuthorizationCodeClaims = {
|
|
150
|
+
email: input.email,
|
|
151
|
+
client_id: input.clientId,
|
|
152
|
+
redirect_uri: input.redirectUri,
|
|
153
|
+
code_challenge: input.codeChallenge,
|
|
154
|
+
scope: input.scope,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return new SignJWT(claims)
|
|
158
|
+
.setProtectedHeader({ alg: SIGNING_ALG, kid: key.kid })
|
|
159
|
+
.setIssuer(issuer(input.origin))
|
|
160
|
+
.setAudience(AUTHORIZATION_CODE_AUDIENCE)
|
|
161
|
+
.setSubject(input.sub)
|
|
162
|
+
.setIssuedAt(iat)
|
|
163
|
+
.setExpirationTime(exp)
|
|
164
|
+
.setJti(crypto.randomUUID())
|
|
165
|
+
.sign(key.privateKey);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Options for {@link verifyCode}. */
|
|
169
|
+
export interface VerifyCodeOptions extends ClockOption {
|
|
170
|
+
/** The deployment origin — derives the expected `iss`. */
|
|
171
|
+
origin: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Verify a stateless authorization code and return its bound fields.
|
|
176
|
+
*
|
|
177
|
+
* Validates signature (via the public JWK selected by `kid`), `iss`, the
|
|
178
|
+
* `oauth:code` audience, and `exp`. Every failure — expired, wrong-audience (e.g.
|
|
179
|
+
* an access token), wrong-issuer, tampered, bad-signature, or no configured key —
|
|
180
|
+
* throws an {@link AuthorizationCodeError} (`invalid_grant`).
|
|
181
|
+
*
|
|
182
|
+
* The returned `jti` is the one-time identifier the token endpoint records to
|
|
183
|
+
* enforce single-use on top of the short expiry (replay guard).
|
|
184
|
+
*/
|
|
185
|
+
export async function verifyCode(
|
|
186
|
+
loadSigningKey: McpSigningKeyProvider,
|
|
187
|
+
code: string,
|
|
188
|
+
options: VerifyCodeOptions,
|
|
189
|
+
): Promise<VerifiedAuthorizationCode> {
|
|
190
|
+
const key = await loadSigningKey();
|
|
191
|
+
if (!key) {
|
|
192
|
+
// No signing key configured → nothing can verify (safe-by-default).
|
|
193
|
+
throw new AuthorizationCodeError("no signing key configured");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const publicKey = await importJWK(key.publicJwk, SIGNING_ALG);
|
|
197
|
+
|
|
198
|
+
let payload: JWTPayload;
|
|
199
|
+
try {
|
|
200
|
+
const result = await jwtVerify(code, publicKey, {
|
|
201
|
+
algorithms: [SIGNING_ALG],
|
|
202
|
+
issuer: issuer(options.origin),
|
|
203
|
+
audience: AUTHORIZATION_CODE_AUDIENCE,
|
|
204
|
+
clockTolerance: CLOCK_TOLERANCE_SECONDS,
|
|
205
|
+
currentDate: options.now === undefined ? undefined : new Date(options.now),
|
|
206
|
+
});
|
|
207
|
+
payload = result.payload;
|
|
208
|
+
} catch {
|
|
209
|
+
// jose throws on bad signature, wrong iss/aud, expiry, malformed token,
|
|
210
|
+
// unknown key — all map to a single opaque `invalid_grant`.
|
|
211
|
+
throw new AuthorizationCodeError("code verification failed");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return extractBoundFields(payload);
|
|
215
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { mintCode } from "./authorization-code";
|
|
2
|
+
import { matchesRedirectUri } from "./clients";
|
|
3
|
+
import type { McpOauthContext } from "./context";
|
|
4
|
+
import { SUPPORTED_CHALLENGE_METHOD } from "./pkce";
|
|
5
|
+
import type { StoredOAuthClient } from "./stores";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The OAuth 2.1 Authorization Code + PKCE authorization endpoint (12-23, ported
|
|
9
|
+
* from future-pay's `app/api/oauth/authorize/route.ts`).
|
|
10
|
+
*
|
|
11
|
+
* It renders no UI: it authenticates the caller against the host's cookie session
|
|
12
|
+
* (through `resolveSession`), validates the request, and either 302-redirects an
|
|
13
|
+
* unauthenticated caller into the host's sign-in flow (so the flow resumes
|
|
14
|
+
* post-login) or, for a signed-in caller with a valid request, mints a stateless
|
|
15
|
+
* authorization code bound to the SESSION identity and 302-redirects back to the
|
|
16
|
+
* client's registered `redirect_uri` with the code and echoed `state`.
|
|
17
|
+
*
|
|
18
|
+
* Security invariants, unchanged:
|
|
19
|
+
* - **Open-redirect prevention:** `client_id` + `redirect_uri` are validated
|
|
20
|
+
* against the registered client BEFORE anything else; an unknown client or a
|
|
21
|
+
* `redirect_uri` that is not an EXACT registered match yields a 400 plain-text
|
|
22
|
+
* response — the endpoint NEVER redirects an error to an unvalidated URI. Only
|
|
23
|
+
* once the URI is validated do other failures redirect back to it.
|
|
24
|
+
* - **Mandatory PKCE S256:** a missing `code_challenge`, or a method other than
|
|
25
|
+
* `S256` (incl. `plain`), is rejected.
|
|
26
|
+
* - **Identity from the session only:** `sub`/`email` come solely from the
|
|
27
|
+
* verified session; a client can never supply the identity via a query param.
|
|
28
|
+
* - **No key, no code:** an unprovisioned signing key is a `server_error`
|
|
29
|
+
* redirect, never a weaker mode.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** OAuth 2.1 error codes this endpoint can emit on a validated redirect_uri. */
|
|
33
|
+
type AuthorizeErrorCode =
|
|
34
|
+
| "invalid_request"
|
|
35
|
+
| "unsupported_response_type"
|
|
36
|
+
| "invalid_scope"
|
|
37
|
+
/** The resource owner said no — or nobody was asked and nobody approved. */
|
|
38
|
+
| "access_denied"
|
|
39
|
+
| "server_error";
|
|
40
|
+
|
|
41
|
+
/** The parsed, still-untrusted query parameters of an authorize request. */
|
|
42
|
+
interface AuthorizeParams {
|
|
43
|
+
responseType: string | null;
|
|
44
|
+
clientId: string | null;
|
|
45
|
+
redirectUri: string | null;
|
|
46
|
+
codeChallenge: string | null;
|
|
47
|
+
codeChallengeMethod: string | null;
|
|
48
|
+
scope: string | null;
|
|
49
|
+
state: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseParams(url: URL): AuthorizeParams {
|
|
53
|
+
const q = url.searchParams;
|
|
54
|
+
return {
|
|
55
|
+
responseType: q.get("response_type"),
|
|
56
|
+
clientId: q.get("client_id"),
|
|
57
|
+
redirectUri: q.get("redirect_uri"),
|
|
58
|
+
codeChallenge: q.get("code_challenge"),
|
|
59
|
+
codeChallengeMethod: q.get("code_challenge_method"),
|
|
60
|
+
scope: q.get("scope"),
|
|
61
|
+
state: q.get("state"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A 302 response to `location` with no body. */
|
|
66
|
+
function redirectTo(location: string): Response {
|
|
67
|
+
return new Response(null, { status: 302, headers: { location } });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A 400 plain-text refusal used ONLY when the `redirect_uri`/`client_id` are
|
|
72
|
+
* themselves invalid — i.e. there is no validated URI to safely redirect an error
|
|
73
|
+
* to (the open-redirect guard).
|
|
74
|
+
*/
|
|
75
|
+
function badRequest(message: string): Response {
|
|
76
|
+
return new Response(message, {
|
|
77
|
+
status: 400,
|
|
78
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build an error redirect back to the (already-validated) `redirect_uri`, carrying
|
|
84
|
+
* the OAuth `error` and the echoed `state` per OAuth 2.1 §4.1.2.1.
|
|
85
|
+
*/
|
|
86
|
+
function errorRedirect(
|
|
87
|
+
redirectUri: string,
|
|
88
|
+
error: AuthorizeErrorCode,
|
|
89
|
+
state: string | null,
|
|
90
|
+
): Response {
|
|
91
|
+
const target = new URL(redirectUri);
|
|
92
|
+
target.searchParams.set("error", error);
|
|
93
|
+
if (state !== null) target.searchParams.set("state", state);
|
|
94
|
+
return redirectTo(target.toString());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether every space-delimited requested scope is within `allowed`. An
|
|
99
|
+
* empty/absent scope is permitted (the server applies its default), but any present
|
|
100
|
+
* scope must be in `allowed` — and `allowed` is the SPECIFIC CLIENT's registered
|
|
101
|
+
* scopes, so a client that registered for only `mcp:read` cannot request
|
|
102
|
+
* `mcp:write` and be issued a code for it ("no privilege escalation via metadata",
|
|
103
|
+
* enforced at authorize rather than trusted at registration).
|
|
104
|
+
*/
|
|
105
|
+
function scopeIsSupported(scope: string | null, allowed: readonly string[]): boolean {
|
|
106
|
+
if (!scope) return true;
|
|
107
|
+
const requested = scope.split(/\s+/).filter(Boolean);
|
|
108
|
+
const allowedSet = new Set<string>(allowed);
|
|
109
|
+
return requested.every((candidate) => allowedSet.has(candidate));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve + validate the client and its `redirect_uri` FIRST (the open-redirect
|
|
114
|
+
* guard). Returns the validated URI AND the client's registered scopes, or a plain
|
|
115
|
+
* 400 — NEVER a redirect — when the client or URI is unknown/unregistered, so an
|
|
116
|
+
* error is never steered to an unvalidated URI.
|
|
117
|
+
*/
|
|
118
|
+
async function validateClientAndRedirect(
|
|
119
|
+
context: McpOauthContext,
|
|
120
|
+
params: AuthorizeParams,
|
|
121
|
+
): Promise<{ client: StoredOAuthClient; redirectUri: string } | Response> {
|
|
122
|
+
if (!params.clientId) return badRequest("invalid_request: missing client_id");
|
|
123
|
+
if (!params.redirectUri) return badRequest("invalid_request: missing redirect_uri");
|
|
124
|
+
|
|
125
|
+
const client = await context.stores.clients.findByClientId(params.clientId);
|
|
126
|
+
if (!client) return badRequest("invalid_client: unknown client_id");
|
|
127
|
+
if (!matchesRedirectUri(client, params.redirectUri)) {
|
|
128
|
+
return badRequest("invalid_request: redirect_uri is not registered");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { client, redirectUri: params.redirectUri };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate the response_type, mandatory PKCE S256, and the requested scope against
|
|
136
|
+
* the already-validated `redirectUri`. Returns `null` when the request passes, or an
|
|
137
|
+
* error redirect back to the validated URI on the first failure.
|
|
138
|
+
*/
|
|
139
|
+
function validateAuthorizeRequest(
|
|
140
|
+
params: AuthorizeParams,
|
|
141
|
+
redirectUri: string,
|
|
142
|
+
clientScopes: readonly string[],
|
|
143
|
+
): Response | null {
|
|
144
|
+
const { state } = params;
|
|
145
|
+
|
|
146
|
+
if (params.responseType !== "code") {
|
|
147
|
+
return errorRedirect(redirectUri, "unsupported_response_type", state);
|
|
148
|
+
}
|
|
149
|
+
// Mandatory PKCE S256: reject a missing challenge or any non-S256 method.
|
|
150
|
+
if (!params.codeChallenge || params.codeChallengeMethod !== SUPPORTED_CHALLENGE_METHOD) {
|
|
151
|
+
return errorRedirect(redirectUri, "invalid_request", state);
|
|
152
|
+
}
|
|
153
|
+
if (!scopeIsSupported(params.scope, clientScopes)) {
|
|
154
|
+
return errorRedirect(redirectUri, "invalid_scope", state);
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The already-validated inputs an authorize request resolves to before minting. */
|
|
160
|
+
interface ValidatedAuthorize {
|
|
161
|
+
/** The registered client — needed by the approval seam, not just its id. */
|
|
162
|
+
client: StoredOAuthClient;
|
|
163
|
+
clientId: string;
|
|
164
|
+
redirectUri: string;
|
|
165
|
+
codeChallenge: string;
|
|
166
|
+
scope: string;
|
|
167
|
+
state: string | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* With a validated request, resolve the authenticated session (identity from the
|
|
172
|
+
* session ONLY) and either send the caller through sign-in, mint the code, or
|
|
173
|
+
* `server_error` when no signing key is configured.
|
|
174
|
+
*/
|
|
175
|
+
async function authenticateAndMint(
|
|
176
|
+
context: McpOauthContext,
|
|
177
|
+
request: Request,
|
|
178
|
+
url: URL,
|
|
179
|
+
origin: string,
|
|
180
|
+
validated: ValidatedAuthorize,
|
|
181
|
+
): Promise<Response> {
|
|
182
|
+
const { redirectUri, state } = validated;
|
|
183
|
+
|
|
184
|
+
const session = await context.resolveSession(request);
|
|
185
|
+
if (!session?.email) {
|
|
186
|
+
// No session: send the caller through the host's sign-in flow with a callback
|
|
187
|
+
// back to THIS authorize URL so the flow resumes post-login. No code is minted
|
|
188
|
+
// for an unauthenticated request.
|
|
189
|
+
const loginUrl = new URL(context.loginPath, origin);
|
|
190
|
+
loginUrl.searchParams.set(context.loginCallbackParam, url.pathname + url.search);
|
|
191
|
+
return redirectTo(loginUrl.toString());
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// CONSENT. A session proves WHO is asking; it never proves they agreed to THIS
|
|
195
|
+
// client holding THESE scopes. Registration is open (RFC 7591), so without this
|
|
196
|
+
// step anyone may register a client carrying their own redirect URI and their own
|
|
197
|
+
// scope ceiling, send a signed-in admin a single link, and have that admin's
|
|
198
|
+
// browser mint them a code — and the two guards that look like they would stop it,
|
|
199
|
+
// exact redirect-URI matching and the per-client scope ceiling, are both checked
|
|
200
|
+
// against the ATTACKER'S OWN registration. Refuses by default; see
|
|
201
|
+
// `resolveApproval` / `preApprovedClientIds`.
|
|
202
|
+
const scopes = validated.scope.split(/\s+/).filter(Boolean);
|
|
203
|
+
if (!(await context.approve(request, validated.client, scopes))) {
|
|
204
|
+
// The answer a human refusal gives, at the URI validated further up.
|
|
205
|
+
return errorRedirect(redirectUri, "access_denied", state);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const code = await mintCode(context.signingKey, {
|
|
209
|
+
// The subject is the OAuth `sub` the host resolved, NOT a DB id: downstream
|
|
210
|
+
// guards resolve the user by EMAIL, and the code carries only what the session
|
|
211
|
+
// verified.
|
|
212
|
+
sub: session.subject || session.email,
|
|
213
|
+
email: session.email,
|
|
214
|
+
clientId: validated.clientId,
|
|
215
|
+
redirectUri,
|
|
216
|
+
codeChallenge: validated.codeChallenge,
|
|
217
|
+
scope: validated.scope,
|
|
218
|
+
origin,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (!code) {
|
|
222
|
+
// No signing key configured while the surface is on — refuse to issue rather
|
|
223
|
+
// than fall back to a weaker mode (safe-by-default).
|
|
224
|
+
return errorRedirect(redirectUri, "server_error", state);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const success = new URL(redirectUri);
|
|
228
|
+
success.searchParams.set("code", code);
|
|
229
|
+
if (state !== null) success.searchParams.set("state", state);
|
|
230
|
+
return redirectTo(success.toString());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `GET <authorize>` — the whole endpoint. */
|
|
234
|
+
export async function authorizeEndpoint(
|
|
235
|
+
context: McpOauthContext,
|
|
236
|
+
request: Request,
|
|
237
|
+
): Promise<Response> {
|
|
238
|
+
const url = new URL(request.url);
|
|
239
|
+
const origin = context.originOf(request);
|
|
240
|
+
const params = parseParams(url);
|
|
241
|
+
|
|
242
|
+
// --- Validate client + redirect_uri FIRST (the open-redirect guard) --------
|
|
243
|
+
const clientResult = await validateClientAndRedirect(context, params);
|
|
244
|
+
if (clientResult instanceof Response) return clientResult;
|
|
245
|
+
const { client, redirectUri } = clientResult;
|
|
246
|
+
|
|
247
|
+
// --- Validate the rest (scope checked against the CLIENT's own registration)
|
|
248
|
+
const requestError = validateAuthorizeRequest(params, redirectUri, client.scopes);
|
|
249
|
+
if (requestError) return requestError;
|
|
250
|
+
|
|
251
|
+
// The guards above guarantee a present client_id + PKCE challenge; narrow them.
|
|
252
|
+
return authenticateAndMint(context, request, url, origin, {
|
|
253
|
+
client,
|
|
254
|
+
clientId: params.clientId as string,
|
|
255
|
+
redirectUri,
|
|
256
|
+
codeChallenge: params.codeChallenge as string,
|
|
257
|
+
scope: params.scope ?? "",
|
|
258
|
+
state: params.state,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
OAuthClientStore,
|
|
5
|
+
StoredOAuthClient,
|
|
6
|
+
TokenEndpointAuthMethod,
|
|
7
|
+
} from "./stores";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Client registration and the open-redirect guard (12-23, ported from
|
|
11
|
+
* future-pay's `lib/mcp/oauth/clients.ts`).
|
|
12
|
+
*
|
|
13
|
+
* A registered client is an external host (a Claude.ai / ChatGPT connector) from
|
|
14
|
+
* RFC 7591 dynamic client registration, or a static registration an operator
|
|
15
|
+
* created out of band. A confidential client's secret is generated HERE, returned
|
|
16
|
+
* exactly once, and stored only as a SHA-256 hash — it is never persisted in
|
|
17
|
+
* plaintext, never logged, and never re-derivable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Default grant types for a registered client (OAuth 2.1 code + refresh). */
|
|
21
|
+
const DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const;
|
|
22
|
+
|
|
23
|
+
/** Bytes of entropy for a generated confidential-client secret (→ 64 hex). */
|
|
24
|
+
const CLIENT_SECRET_BYTES = 32;
|
|
25
|
+
|
|
26
|
+
/** RFC 7591 registration input (the durable subset the store persists). */
|
|
27
|
+
export interface RegisterClientInput {
|
|
28
|
+
/** The exact-match redirect-uri allowlist (open-redirect guard). */
|
|
29
|
+
redirectUris: string[];
|
|
30
|
+
clientName?: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* `none` (public PKCE client, the default) or `client_secret_basic`
|
|
33
|
+
* (confidential — a secret is generated and its hash stored).
|
|
34
|
+
*/
|
|
35
|
+
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
36
|
+
/** Grant types; defaults to authorization_code + refresh_token. */
|
|
37
|
+
grantTypes?: string[];
|
|
38
|
+
scopes: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The registration RESULT. `clientSecret` is present (plaintext, ONCE) only for a
|
|
43
|
+
* confidential client — it is never stored and never returned again.
|
|
44
|
+
*/
|
|
45
|
+
export interface RegisteredClient {
|
|
46
|
+
clientId: string;
|
|
47
|
+
clientSecret?: string;
|
|
48
|
+
redirectUris: string[];
|
|
49
|
+
clientName: string | null;
|
|
50
|
+
tokenEndpointAuthMethod: TokenEndpointAuthMethod;
|
|
51
|
+
grantTypes: string[];
|
|
52
|
+
scopes: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SHA-256 hex digest — the at-rest form of the client secret. */
|
|
56
|
+
export function hashSecret(secret: string): string {
|
|
57
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Register an OAuth client under a generated `clientId`. For a confidential
|
|
62
|
+
* client a random secret is generated and its hash stored; the plaintext is
|
|
63
|
+
* returned once.
|
|
64
|
+
*/
|
|
65
|
+
export async function registerClient(
|
|
66
|
+
store: OAuthClientStore,
|
|
67
|
+
input: RegisterClientInput,
|
|
68
|
+
): Promise<RegisteredClient> {
|
|
69
|
+
const clientId = randomUUID();
|
|
70
|
+
const authMethod: TokenEndpointAuthMethod = input.tokenEndpointAuthMethod ?? "none";
|
|
71
|
+
const grantTypes = input.grantTypes ?? [...DEFAULT_GRANT_TYPES];
|
|
72
|
+
|
|
73
|
+
// Only a confidential client gets a secret; a public PKCE client has none.
|
|
74
|
+
const clientSecret =
|
|
75
|
+
authMethod === "client_secret_basic"
|
|
76
|
+
? randomBytes(CLIENT_SECRET_BYTES).toString("hex")
|
|
77
|
+
: undefined;
|
|
78
|
+
|
|
79
|
+
const row = await store.create({
|
|
80
|
+
clientId,
|
|
81
|
+
clientSecretHash: clientSecret ? hashSecret(clientSecret) : null,
|
|
82
|
+
redirectUris: input.redirectUris,
|
|
83
|
+
clientName: input.clientName ?? null,
|
|
84
|
+
tokenEndpointAuthMethod: authMethod,
|
|
85
|
+
grantTypes,
|
|
86
|
+
scopes: input.scopes,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
clientId: row.clientId,
|
|
91
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
92
|
+
redirectUris: row.redirectUris,
|
|
93
|
+
clientName: row.clientName,
|
|
94
|
+
tokenEndpointAuthMethod: row.tokenEndpointAuthMethod as TokenEndpointAuthMethod,
|
|
95
|
+
grantTypes: row.grantTypes,
|
|
96
|
+
scopes: row.scopes,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Open-redirect guard: a redirect target is accepted ONLY when it EXACTLY equals a
|
|
102
|
+
* registered `redirect_uri`. No normalization, no prefix, no trailing-slash
|
|
103
|
+
* leniency — an intercepted authorization request must not be steerable to any URI
|
|
104
|
+
* the client did not register.
|
|
105
|
+
*/
|
|
106
|
+
export function matchesRedirectUri(
|
|
107
|
+
client: Pick<StoredOAuthClient, "redirectUris">,
|
|
108
|
+
redirectUri: string,
|
|
109
|
+
): boolean {
|
|
110
|
+
if (!redirectUri) return false;
|
|
111
|
+
return client.redirectUris.includes(redirectUri);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Provider attribution rules: the canonical root domains that own each host's
|
|
116
|
+
* OAuth callback. A redirect host matches a root only as the exact domain or a
|
|
117
|
+
* real (dot-guarded) subdomain — never a suffix, so `evilchatgpt.com` never
|
|
118
|
+
* matches `chatgpt.com`.
|
|
119
|
+
*/
|
|
120
|
+
export interface ProviderAttributionRule {
|
|
121
|
+
roots: readonly string[];
|
|
122
|
+
provider: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** future-pay's rules, and a sane default for any host talking to the same two. */
|
|
126
|
+
export const DEFAULT_PROVIDER_ROOTS: readonly ProviderAttributionRule[] = [
|
|
127
|
+
{ roots: ["claude.ai", "anthropic.com"], provider: "claude" },
|
|
128
|
+
{ roots: ["chatgpt.com", "openai.com"], provider: "chatgpt" },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/** Whether `host` is exactly `root` or a real subdomain of it (dot-guarded). */
|
|
132
|
+
function hostMatchesRoot(host: string, root: string): boolean {
|
|
133
|
+
return host === root || host.endsWith(`.${root}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Best-effort provider attribution from a client's redirect URIs: the host that
|
|
138
|
+
* owns the callback (`claude.ai` → claude, `chatgpt.com` → chatgpt). Returns
|
|
139
|
+
* `null` when nothing matches — the UI then falls back to what the owner
|
|
140
|
+
* completed the flow with, and a self-report (the `announce` path) can attribute
|
|
141
|
+
* it later.
|
|
142
|
+
*/
|
|
143
|
+
export function providerFromRedirectUris(
|
|
144
|
+
redirectUris: readonly string[],
|
|
145
|
+
rules: readonly ProviderAttributionRule[] = DEFAULT_PROVIDER_ROOTS,
|
|
146
|
+
): string | null {
|
|
147
|
+
for (const uri of redirectUris) {
|
|
148
|
+
let host: string;
|
|
149
|
+
try {
|
|
150
|
+
host = new URL(uri).host.toLowerCase();
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const match = rules.find((rule) => rule.roots.some((root) => hostMatchesRoot(host, root)));
|
|
155
|
+
if (match) return match.provider;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-use guard for the stateless authorization codes (12-23, ported from
|
|
3
|
+
* future-pay's `lib/mcp/oauth/token-replay.ts`).
|
|
4
|
+
*
|
|
5
|
+
* A code is a signed blob with a `jti`, so "already redeemed" has to be remembered
|
|
6
|
+
* somewhere. The in-process option remembers it IN THIS PROCESS: a small map of
|
|
7
|
+
* `jti → expiry`, self-pruning once the code that carried it would have expired
|
|
8
|
+
* anyway, so the set never grows without bound.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ MULTI-INSTANCE LIMITATION (best-effort single-use): that map lives in ONE
|
|
11
|
+
* process. On a horizontally-scaled deployment a code could be replayed against a
|
|
12
|
+
* second instance that has not yet seen the `jti`, within the ≤60s code lifetime.
|
|
13
|
+
* Single-use is therefore strictly guaranteed only on a SINGLE instance — which is
|
|
14
|
+
* why choosing it is explicit and cannot happen by omission: `codeReplay` has no
|
|
15
|
+
* default, so a host either names a shared store or types `'in-process'`. BEFORE
|
|
16
|
+
* running this surface on more than one instance, pass a `codeReplay` store backed
|
|
17
|
+
* by something shared and atomic — a short-TTL row with a unique constraint, or a
|
|
18
|
+
* distributed cache with an atomic set-if-absent. The port exists precisely so
|
|
19
|
+
* that is a config change rather than a patch to the grant handler.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface CodeReplayStore {
|
|
23
|
+
/**
|
|
24
|
+
* Record a code's `jti` as consumed. `false` means it was ALREADY recorded (a
|
|
25
|
+
* replay); `true` is the first redemption. Must be atomic to be a real guard.
|
|
26
|
+
*/
|
|
27
|
+
consume(jti: string, nowMs: number): Promise<boolean> | boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Retain slightly beyond the 60s code TTL to cover the verify clock tolerance. */
|
|
31
|
+
const RETENTION_MS = 90_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The in-process store — correct on ONE instance, see the caveat above. Reached by
|
|
35
|
+
* passing `codeReplay: 'in-process'`, which is a required acknowledgement rather
|
|
36
|
+
* than a default: the config has no default for this field precisely because the
|
|
37
|
+
* only possible one would fail open on a multi-pod deployment.
|
|
38
|
+
*/
|
|
39
|
+
export function inProcessCodeReplayStore(): CodeReplayStore {
|
|
40
|
+
const usedJtis = new Map<string, number>();
|
|
41
|
+
return {
|
|
42
|
+
consume(jti: string, nowMs: number): boolean {
|
|
43
|
+
for (const [seen, expiresAt] of usedJtis) {
|
|
44
|
+
if (expiresAt <= nowMs) usedJtis.delete(seen);
|
|
45
|
+
}
|
|
46
|
+
if (usedJtis.has(jti)) return false;
|
|
47
|
+
usedJtis.set(jti, nowMs + RETENTION_MS);
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|