@vidofy/mcp 0.1.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.
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `GET /mcp-app/authorize` — the protocol half of the authorization endpoint.
3
+ *
4
+ * It does everything that does not need to know who the user is: identify the
5
+ * calling client, check what it asked for, park the request, and hand the
6
+ * browser to the consent screen. Deciding is the user's half, and that lives at
7
+ * /en/oauth/consent on the site itself, because the session cookie is
8
+ * host-scoped to vidofy.ai by the site's session layer, and this process
9
+ * cannot read it.
10
+ *
11
+ * TWO URLS, ONE FLOW — and they are named differently on purpose:
12
+ *
13
+ * /mcp-app/authorize machine-facing. What `authorization_endpoint` advertises.
14
+ * /en/oauth/consent human-facing. Product-neutral, so the CLI or any later
15
+ * client reaches the same screen (owner, 2026-09-12).
16
+ *
17
+ * Calling both "authorize" was the first draft and would have cost somebody an
18
+ * hour six months from now.
19
+ *
20
+ * THE ERROR RULE THAT MATTERS
21
+ * ---------------------------
22
+ * OAuth 2.1 splits failures in two, and the split is a security boundary rather
23
+ * than a style:
24
+ *
25
+ * • client_id or redirect_uri is bad → answer HERE, never redirect. Redirecting
26
+ * to a URI we have not validated IS the open redirect.
27
+ * • anything else is wrong → redirect to the VALIDATED redirect_uri
28
+ * with `error` and `state`, because the client is waiting there and a page
29
+ * served by us is a dead end it cannot recover from.
30
+ */
31
+ import { fetchClientMetadata, redirectUriAllowed, ClientError } from './clients.js';
32
+ import { savePending, readPending, consumePending, issueCode } from './store.js';
33
+ import { resolveBaseUrl } from '../config.js';
34
+ /** Where the human decides. Product-neutral — see the header. */
35
+ const CONSENT_PATH = '/en/oauth/consent';
36
+ /**
37
+ * The only scope this server grants, as the client sees it.
38
+ *
39
+ * It must stay the name advertised in `scopes_supported` in the
40
+ * authorization-server document (http.ts) and in the 401's `scope` parameter —
41
+ * three places, one string. The credential's INTERNAL scope is a different name
42
+ * because that one is what the server enforces per endpoint; they are
43
+ * deliberately not the same value and neither is derived from the other.
44
+ */
45
+ const GRANTED_SCOPE = 'vidofy.generate';
46
+ /** Fail before the redirect_uri is trusted: answer directly. */
47
+ function failHere(res, status, error, description) {
48
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
49
+ res.end(JSON.stringify({ error, error_description: description }, null, 2));
50
+ }
51
+ /** Fail after it is trusted: send the client its error, with state intact. */
52
+ function failToClient(res, redirectUri, error, description, state) {
53
+ const u = new URL(redirectUri);
54
+ u.searchParams.set('error', error);
55
+ u.searchParams.set('error_description', description);
56
+ if (state !== null)
57
+ u.searchParams.set('state', state);
58
+ res.writeHead(302, { location: u.href });
59
+ res.end();
60
+ }
61
+ /**
62
+ * @param canonicalResource This server's own resource identifier — the value
63
+ * both hosts send as `resource`. A request naming anything else is asking
64
+ * us to mint a token for an audience we do not serve.
65
+ */
66
+ export async function handleAuthorize(res, url, canonicalResource, opts = {}) {
67
+ const q = url.searchParams;
68
+ const get = (k) => {
69
+ const v = q.get(k);
70
+ return v === null || v.trim() === '' ? null : v.trim();
71
+ };
72
+ const clientId = get('client_id');
73
+ const redirectUri = get('redirect_uri');
74
+ const state = get('state');
75
+ if (clientId === null) {
76
+ failHere(res, 400, 'invalid_request', 'client_id is required.');
77
+ return;
78
+ }
79
+ if (redirectUri === null) {
80
+ failHere(res, 400, 'invalid_request', 'redirect_uri is required.');
81
+ return;
82
+ }
83
+ /* Identify the client from its own metadata document, and only then trust
84
+ the redirect_uri it sent. Both checks must pass before a single redirect
85
+ happens — that ordering is the whole protection. */
86
+ let client;
87
+ try {
88
+ client = await fetchClientMetadata(clientId, {
89
+ ...(opts.allowPrivateClients === true ? { allowPrivate: true } : {}),
90
+ });
91
+ }
92
+ catch (err) {
93
+ failHere(res, 400, 'invalid_client', err instanceof ClientError ? err.message : 'client_id could not be resolved.');
94
+ return;
95
+ }
96
+ if (!redirectUriAllowed(client, redirectUri)) {
97
+ failHere(res, 400, 'invalid_request', 'redirect_uri is not listed in the client_id document.');
98
+ return;
99
+ }
100
+ // ── From here the redirect_uri is trusted, so errors go back to the client ──
101
+ if (get('response_type') !== 'code') {
102
+ failToClient(res, redirectUri, 'unsupported_response_type', 'Only response_type=code is supported.', state);
103
+ return;
104
+ }
105
+ /* PKCE is mandatory in OAuth 2.1 and we advertise S256 alone. `plain` exists
106
+ in the RFC for clients that cannot hash; no MCP client is one, and
107
+ accepting it would mean a challenge an eavesdropper can replay. */
108
+ const codeChallenge = get('code_challenge');
109
+ if (codeChallenge === null) {
110
+ failToClient(res, redirectUri, 'invalid_request', 'code_challenge is required.', state);
111
+ return;
112
+ }
113
+ if (get('code_challenge_method') !== 'S256') {
114
+ failToClient(res, redirectUri, 'invalid_request', 'code_challenge_method must be S256.', state);
115
+ return;
116
+ }
117
+ /* RFC 8707. Measured 2026-09-12: claude.ai and ChatGPT BOTH send it, so this
118
+ is the live path and not a defensive branch. The token we eventually issue
119
+ is bound to this value; a request naming another resource is asking for a
120
+ token we must not mint, and `invalid_target` is the RFC's own code for it.
121
+ Absent is tolerated — the spec puts the MUST on clients, and refusing a
122
+ compliant-enough client would break it for no gain when we bind to our own
123
+ resource anyway. */
124
+ const resource = get('resource');
125
+ if (resource !== null && resource !== canonicalResource) {
126
+ failToClient(res, redirectUri, 'invalid_target', 'resource does not identify this server.', state);
127
+ return;
128
+ }
129
+ const id = await savePending({
130
+ clientId,
131
+ clientName: client.clientName,
132
+ redirectUri,
133
+ codeChallenge,
134
+ resource: canonicalResource,
135
+ /* The GRANTED scope, fixed — not the requested one.
136
+ *
137
+ * This used to be `get('scope') ?? 'vidofy.generate'`, so whatever string
138
+ * the client asked for was stored and then echoed back from /token as
139
+ * though it had been granted. It never was: the server mints exactly
140
+ * one scope (`generate_read`, its default) and the router enforces
141
+ * that and nothing else. A client asking for `admin` would have been told
142
+ * `admin` and given `generate_read` — a lie in a protocol field clients are
143
+ * entitled to believe.
144
+ *
145
+ * When more than one scope exists, the requested value gets intersected
146
+ * with what we are willing to grant HERE, and the result is what is stored.
147
+ * Until then there is one answer and this is it. */
148
+ scope: GRANTED_SCOPE,
149
+ state,
150
+ // ChatGPT sends ui_locales=en-US, Claude sends none. Carried through so
151
+ // the consent screen can honour it; nothing reads it yet.
152
+ uiLocales: get('ui_locales'),
153
+ });
154
+ /* The consent screen lives on the SITE, not here — a different origin in
155
+ development and the same one in production. Built from
156
+ VIDOFY_API_BASE through the same validator the credential path uses, so a
157
+ hostile value cannot send the user somewhere else. */
158
+ const consent = new URL(CONSENT_PATH, resolveBaseUrl());
159
+ consent.searchParams.set('request', id);
160
+ res.writeHead(302, { location: consent.href });
161
+ res.end();
162
+ }
163
+ /**
164
+ * `GET /mcp-app/authorize/decide?request=<id>` — the browser coming back from
165
+ * the consent page.
166
+ *
167
+ * Turns a recorded decision into the OAuth response the waiting client expects:
168
+ * a code, or `access_denied`.
169
+ *
170
+ * WHAT IT TRUSTS, AND WHY
171
+ * -----------------------
172
+ * Everything comes from the stored record; nothing from this request except the
173
+ * id. In particular the redirect_uri is the one /authorize validated against the
174
+ * client's metadata document minutes ago — reading it from the query here would
175
+ * undo that check and hand anyone with a request id a code sent wherever they
176
+ * like.
177
+ *
178
+ * The approving user's identity comes from the record too, written by the
179
+ * site's consent page after it authenticated the session. This process
180
+ * cannot verify a session cookie (host-scoped to the site), so the trust
181
+ * boundary is Redis itself: only the site and this connector can write those
182
+ * keys, and Redis is not reachable from outside the host.
183
+ */
184
+ export async function handleAuthorizeDecide(res, url) {
185
+ const id = url.searchParams.get('request');
186
+ if (id === null || id.trim() === '') {
187
+ failHere(res, 400, 'invalid_request', 'request is required.');
188
+ return;
189
+ }
190
+ /* READ first. Only a DECIDED record is consumed.
191
+ *
192
+ * ⚠ THIS USED TO CONSUME UNCONDITIONALLY, and the comment that stood here
193
+ * defended it — "one consent is worth exactly one code" — which is true and
194
+ * was not the whole story. GETDEL ran before anything checked whether a
195
+ * decision existed, so a hit on this URL with an id and NO approval deleted
196
+ * the pending record and the old comment shrugged: "the record is already
197
+ * gone". That is a denial of service on somebody else's sign-in. The id is
198
+ * not guessable (32 random bytes) but it is not secret either — it sits in
199
+ * the address bar of the consent page, so it is in browser history, in a
200
+ * screen share, in a pasted URL. One request, and the user who then clicks
201
+ * Allow is told their request expired, with nothing anywhere explaining why.
202
+ *
203
+ * Found by test/oauth_flow.test.mjs on its first run — this endpoint had no
204
+ * test at all until then.
205
+ *
206
+ * The single-use property is kept exactly: an undecided record is only read,
207
+ * and a decided one is taken with GETDEL, so two concurrent hits after an
208
+ * approval still produce one code. Consuming late does not weaken that; it
209
+ * only stops the undecided case from being destructive. */
210
+ const peek = await readPending(id.trim());
211
+ if (peek === null) {
212
+ failHere(res, 400, 'invalid_request', 'This authorization request has expired or was already completed.');
213
+ return;
214
+ }
215
+ if (peek.decision === undefined) {
216
+ /* Nobody approved anything: someone reached this URL without passing
217
+ through the consent page. The record is LEFT ALONE so the real flow can
218
+ still finish. Answered here rather than redirected — the client is not
219
+ owed a response to a flow its user never completed. */
220
+ failHere(res, 400, 'access_denied', 'This request was not approved.');
221
+ return;
222
+ }
223
+ const pending = await consumePending(id.trim());
224
+ if (pending === null) {
225
+ /* Lost the race against a concurrent decide, or it expired in the
226
+ microseconds between the two calls. Either way exactly one caller got
227
+ it, which is the property that matters. */
228
+ failHere(res, 400, 'invalid_request', 'This authorization request has expired or was already completed.');
229
+ return;
230
+ }
231
+ if (pending.decision === undefined) {
232
+ // Cannot happen — the peek above saw one. Fail closed rather than assume.
233
+ failHere(res, 400, 'access_denied', 'This request was not approved.');
234
+ return;
235
+ }
236
+ /* An approval with no token is an approval we cannot honour: the consent
237
+ page refuses to record one, so reaching here means the record was
238
+ tampered with or written by an older build. Treated as a denial rather
239
+ than issuing a code /token could never fulfil — the client would otherwise
240
+ show success and then fail. */
241
+ const rawToken = typeof pending.rawToken === 'string' && pending.rawToken.startsWith('vmt_')
242
+ ? pending.rawToken
243
+ : null;
244
+ if (pending.decision === 'allow' && rawToken === null) {
245
+ failToClient(res, pending.redirectUri, 'server_error', 'The access token could not be prepared.', pending.state);
246
+ return;
247
+ }
248
+ if (pending.decision !== 'allow' || rawToken === null
249
+ || typeof pending.userId !== 'number' || pending.userId <= 0) {
250
+ /* A refusal is a normal OAuth outcome, not an error page: the client is
251
+ waiting at its redirect_uri and can tell the user plainly. */
252
+ failToClient(res, pending.redirectUri, 'access_denied', 'The user declined the request.', pending.state);
253
+ return;
254
+ }
255
+ const code = await issueCode({
256
+ clientId: pending.clientId,
257
+ redirectUri: pending.redirectUri,
258
+ codeChallenge: pending.codeChallenge,
259
+ resource: pending.resource,
260
+ scope: pending.scope,
261
+ userId: pending.userId,
262
+ rawToken,
263
+ tokenRowId: pending.tokenRowId ?? 0,
264
+ });
265
+ const back = new URL(pending.redirectUri);
266
+ back.searchParams.set('code', code);
267
+ /* Echoed byte for byte when present, and omitted entirely when not. `state`
268
+ is the client's own CSRF protection for this flow; altering or inventing
269
+ one breaks the check it exists for. */
270
+ if (pending.state !== null)
271
+ back.searchParams.set('state', pending.state);
272
+ res.writeHead(302, { location: back.href });
273
+ res.end();
274
+ }
275
+ //# sourceMappingURL=authorize.js.map
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Who is asking — OAuth client identification by Client ID Metadata Document.
3
+ *
4
+ * An MCP client tells us who it is by putting a URL in `client_id` and hosting
5
+ * its own OAuth metadata there (draft-ietf-oauth-client-id-metadata-document).
6
+ * We fetch that URL and read the client's name and its permitted redirect_uris.
7
+ *
8
+ * WHY ONLY THIS MECHANISM
9
+ * -----------------------
10
+ * The spec offers three (Client ID Metadata Documents, pre-registration,
11
+ * Dynamic Client Registration) and calls DCR "deprecated, retained for backwards
12
+ * compatibility". We did not take that on faith: the connector advertised BOTH
13
+ * `registration_endpoint` and `client_id_metadata_document_supported` and logged
14
+ * what real hosts chose (2026-09-12, through a cloudflared tunnel):
15
+ *
16
+ * claude.ai client_id = https://claude.ai/oauth/mcp-oauth-client-metadata
17
+ * ChatGPT client_id = https://chatgpt.com/oauth/<per-connector>/client.json
18
+ * requests to /register = ZERO, from either
19
+ *
20
+ * So DCR is not implemented and `registration_endpoint` is not advertised. When
21
+ * a client appears that needs it, the log will show the call and we will know.
22
+ *
23
+ * ⚠ THIS FETCHES A URL THE CALLER CHOSE
24
+ * -------------------------------------
25
+ * `client_id` is attacker-controlled by definition — anyone can start an
26
+ * authorization request. ChatGPT's is per-connector and unguessable, so we
27
+ * cannot allow-list known URLs; the document really is arbitrary. That makes
28
+ * this a server-side request forgery surface: point `client_id` at
29
+ * http://169.254.169.254/ or http://127.0.0.1:6379/ and the fetch becomes a
30
+ * probe of our own network.
31
+ *
32
+ * The guards below mirror the same rules the site already applies to partner
33
+ * callback URLs, rather than inventing a second policy. They are reimplemented
34
+ * rather than called because the site's copy is PHP; where the two could drift,
35
+ * the site is the reference.
36
+ */
37
+ import type { LookupAddress, LookupAllOptions, LookupOneOptions } from 'node:dns';
38
+ /** What the authorization flow needs to know about the caller. */
39
+ export interface ClientInfo {
40
+ /** The URL that identifies it — echoed back as client_id. */
41
+ clientId: string;
42
+ /** Display name for the consent screen. Falls back to the host. */
43
+ clientName: string;
44
+ /** Every redirect_uri the client says it may use. */
45
+ redirectUris: string[];
46
+ /** For the consent screen's "this is who is asking" line. */
47
+ clientUri: string | null;
48
+ logoUri: string | null;
49
+ }
50
+ export declare class ClientError extends Error {
51
+ }
52
+ /**
53
+ * A refusal whose REASON must not reach the caller.
54
+ *
55
+ * The split it marks is the whole of the oracle fix, so it is worth stating as a
56
+ * rule rather than a habit:
57
+ *
58
+ * ClientError — decided from the caller's own string, with no network
59
+ * touched: not a URL, not https, has credentials, has a
60
+ * fragment, port not allowed. The caller already knows their
61
+ * own URL, so saying why tells them nothing they did not
62
+ * have, and it is exactly what a client developer needs.
63
+ *
64
+ * OpaqueClientError — decided by what happened when we reached out to a host the
65
+ * CALLER CHOSE: the address rules, the connection, the
66
+ * status, the size, whether the body parsed, whether the
67
+ * document declared what it must.
68
+ *
69
+ * Why the second group must be silent, and why that does not cost a legitimate
70
+ * developer anything: every one of those answers is already available to whoever
71
+ * owns the host. Their access log shows our request and their own response; their
72
+ * document is in their hands. They do not need us to tell them it returned 500.
73
+ *
74
+ * An attacker probing an internal address has none of that — which is precisely
75
+ * what made our message valuable to them. `document returned 403` versus
76
+ * `is not a JSON object` versus `could not be fetched`, over a range of addresses,
77
+ * is a port scanner with our egress IP and no credential.
78
+ *
79
+ * The real reason is logged on our side, always. Nothing is lost, it just stops
80
+ * being answered to a stranger.
81
+ */
82
+ export declare class OpaqueClientError extends ClientError {
83
+ }
84
+ /**
85
+ * The `lookup` hook `net.connect` calls to decide where the socket goes.
86
+ *
87
+ * For a HOSTNAME this is the only resolution on the connect path, so the address
88
+ * checked here is by construction the address connected to — which is what closes
89
+ * the rebinding window described in assertFetchable. The hostname still travels as
90
+ * SNI and in the Host header, so TLS and certificate validation are untouched;
91
+ * pinning by rewriting the URL to an IP would have broken both.
92
+ *
93
+ * ⚠ AND IT IS NEVER CALLED FOR AN IP LITERAL. Measured 2026-09-12: a request to
94
+ * `127.0.0.1` reached ECONNREFUSED without the hook being invoked once, while
95
+ * `localhost` did invoke it — `net.connect` has nothing to resolve when the host
96
+ * is already an address, so it skips the hook entirely. An earlier version of this
97
+ * comment claimed this was "the ONLY place the fetch resolves the host", full
98
+ * stop, which is false for literals.
99
+ *
100
+ * Nothing is exposed by that: a literal is refused in assertFetchable before any
101
+ * socket opens. But it means the two halves are not interchangeable — literals are
102
+ * guarded THERE and names are guarded HERE — and a test that only exercises this
103
+ * function is not testing the literal path at all.
104
+ *
105
+ * Fails CLOSED in every ambiguous case: a resolution error, an empty answer, or
106
+ * ANY blocked address among the answers refuses the whole connection rather than
107
+ * picking a surviving one. A host that answers with both a public and a private
108
+ * address has no business being a client_id.
109
+ *
110
+ * EXPORTED only so it can be tested directly, and that is not a formality: the
111
+ * check in assertFetchable cannot be reached past by a test without running a
112
+ * hostile DNS server, so the only way to prove this hook refuses what it claims
113
+ * to refuse is to call it. Nothing outside this module should use it.
114
+ */
115
+ export declare function safeLookup(hostname: string, options: LookupOneOptions | LookupAllOptions, callback: (err: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void): void;
116
+ /**
117
+ * Fetch and validate a client's metadata document.
118
+ *
119
+ * The public entry point, and the ONLY place the opaque/verbatim policy is applied
120
+ * — so there is one answer to "what does a stranger learn from a refusal" instead
121
+ * of one per throw site. Everything below it throws the truth; this decides what
122
+ * leaves the building, and logs the truth either way.
123
+ *
124
+ * @throws ClientError whose message is safe to surface, always.
125
+ */
126
+ export declare function fetchClientMetadata(clientId: string, opts?: {
127
+ allowPrivate?: boolean;
128
+ }): Promise<ClientInfo>;
129
+ /**
130
+ * Is this redirect_uri one the client declared?
131
+ *
132
+ * Exact string comparison, which is what OAuth 2.1 requires and what makes the
133
+ * check worth having: any normalisation (trailing slash, case, added query) is a
134
+ * place where "close enough" sends the authorization code somewhere the client
135
+ * never listed.
136
+ */
137
+ export declare function redirectUriAllowed(client: ClientInfo, redirectUri: string): boolean;