@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.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/backend.d.ts +133 -0
- package/dist/backend.js +455 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +207 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +70 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +885 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +661 -0
- package/dist/log.d.ts +19 -0
- package/dist/log.js +22 -0
- package/dist/map/b2c.d.ts +139 -0
- package/dist/map/b2c.js +236 -0
- package/dist/media-duration.d.ts +33 -0
- package/dist/media-duration.js +137 -0
- package/dist/oauth/authorize.d.ts +61 -0
- package/dist/oauth/authorize.js +275 -0
- package/dist/oauth/clients.d.ts +137 -0
- package/dist/oauth/clients.js +621 -0
- package/dist/oauth/ratelimit.d.ts +92 -0
- package/dist/oauth/ratelimit.js +116 -0
- package/dist/oauth/store.d.ts +135 -0
- package/dist/oauth/store.js +277 -0
- package/dist/oauth/token.d.ts +29 -0
- package/dist/oauth/token.js +165 -0
- package/dist/schema.d.ts +147 -0
- package/dist/schema.js +629 -0
- package/dist/tools/account.d.ts +28 -0
- package/dist/tools/account.js +130 -0
- package/dist/tools/generation.d.ts +102 -0
- package/dist/tools/generation.js +1194 -0
- package/dist/tools/info.d.ts +50 -0
- package/dist/tools/info.js +95 -0
- package/dist/ui/generation-card.html +869 -0
- package/package.json +79 -0
- package/server.json +36 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting for the unauthenticated OAuth endpoints.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS — what one request to /mcp-app/authorize actually costs:
|
|
5
|
+
*
|
|
6
|
+
* • an outbound HTTPS request to a URL THE CALLER CHOSE (client_id is a URL and
|
|
7
|
+
* we fetch it), preceded by a DNS lookup, bounded at 2s and 8 KB
|
|
8
|
+
* • a socket and its buffers in the same process that serves every MCP tool
|
|
9
|
+
* • a Redis key that lives 600 seconds
|
|
10
|
+
*
|
|
11
|
+
* And it takes no credential at all. So a loop from one machine turns this server
|
|
12
|
+
* into three different problems at once: our egress IP hammering a third party
|
|
13
|
+
* who will rightly block us, a Node process out of sockets while real users wait
|
|
14
|
+
* for a generation, and Redis filling with pending records nobody will redeem.
|
|
15
|
+
*
|
|
16
|
+
* WHO ACTUALLY CALLS /authorize, because it decides the shape of the limit:
|
|
17
|
+
* the USER'S BROWSER, not the AI host's servers. That is not an inference from
|
|
18
|
+
* the probe log — it is the authorization-code flow: the endpoint carries `state`
|
|
19
|
+
* and ends at a consent screen the person has to read, which a server-to-server
|
|
20
|
+
* caller cannot complete. The measured chain agrees (the discovery documents and
|
|
21
|
+
* the first POST /mcp-app come from Anthropic's cloud; the authorize redirect does
|
|
22
|
+
* not). So a per-IP bucket is a per-USER bucket here, and the shared-egress worry
|
|
23
|
+
* that would make per-IP limiting wrong for a machine-to-machine endpoint does not
|
|
24
|
+
* apply.
|
|
25
|
+
*
|
|
26
|
+
* A FIXED WINDOW, not a sliding one. It lets a caller spend the whole budget at
|
|
27
|
+
* the end of one window and again at the start of the next — twice the nominal
|
|
28
|
+
* rate across a window boundary — which is the known cost of the cheap algorithm
|
|
29
|
+
* and is irrelevant at these limits. What it buys is an EXACT `Retry-After`:
|
|
30
|
+
* seconds until this window resets, computed rather than guessed, which is what
|
|
31
|
+
* the Partners API already does (see its Retry-After note). A sliding window
|
|
32
|
+
* cannot answer that question honestly.
|
|
33
|
+
*/
|
|
34
|
+
export interface RateVerdict {
|
|
35
|
+
allowed: boolean;
|
|
36
|
+
/** Seconds until the window resets. Exact, not advisory. */
|
|
37
|
+
retryAfter: number;
|
|
38
|
+
/** How many hits are in this window, including this one. */
|
|
39
|
+
count: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Count one hit against a bucket.
|
|
43
|
+
*
|
|
44
|
+
* @param bucket Anything that identifies the caller for this limit, e.g.
|
|
45
|
+
* `authorize:ip:1.2.3.4`. Hashed into the key as-is, so callers
|
|
46
|
+
* must not put anything unbounded in it.
|
|
47
|
+
* @param limit Hits allowed per window.
|
|
48
|
+
* @param windowSec Window length.
|
|
49
|
+
*
|
|
50
|
+
* FAILS CLOSED on a Redis error, and that is deliberate rather than defensive
|
|
51
|
+
* habit: every endpoint this guards needs Redis to do its job anyway — /authorize
|
|
52
|
+
* cannot park a pending record without it — so a Redis outage means the request was
|
|
53
|
+
* going to fail regardless. Refusing early with an honest message beats doing the
|
|
54
|
+
* expensive outbound fetch first and failing after. The alternative, failing open,
|
|
55
|
+
* would remove the limiter precisely when the system is least able to absorb a
|
|
56
|
+
* flood.
|
|
57
|
+
*/
|
|
58
|
+
export declare function hitLimit(bucket: string, limit: number, windowSec: number): Promise<RateVerdict>;
|
|
59
|
+
/** Starts of the flow, per IP. */
|
|
60
|
+
export declare const AUTHORIZE_PER_IP: {
|
|
61
|
+
limit: number;
|
|
62
|
+
windowSec: number;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Starts of the flow per client_id, across all IPs.
|
|
66
|
+
*
|
|
67
|
+
* The second layer, and the one that survives a distributed attacker: an attack
|
|
68
|
+
* from a thousand addresses defeats a per-IP limit entirely, but every request
|
|
69
|
+
* still has to name a client_id, and it is the client_id that drives the outbound
|
|
70
|
+
* fetch this endpoint exists to protect. Deliberately much higher than the per-IP
|
|
71
|
+
* limit — claude.ai is ONE client_id for every one of our users, so this bucket
|
|
72
|
+
* must never be the thing that throttles legitimate traffic.
|
|
73
|
+
*
|
|
74
|
+
* It is not a complete answer on its own: an attacker rotating client_id values
|
|
75
|
+
* gets a fresh bucket each time. What stops THAT is the concurrency cap on the
|
|
76
|
+
* fetch itself, in clients.ts — a limit on how many outbound requests can be in
|
|
77
|
+
* flight, which no amount of key rotation gets around.
|
|
78
|
+
*/
|
|
79
|
+
export declare const AUTHORIZE_PER_CLIENT: {
|
|
80
|
+
limit: number;
|
|
81
|
+
windowSec: number;
|
|
82
|
+
};
|
|
83
|
+
/** The consent hand-off. Cheap (one Redis read) but not free, and unauthenticated. */
|
|
84
|
+
export declare const DECIDE_PER_IP: {
|
|
85
|
+
limit: number;
|
|
86
|
+
windowSec: number;
|
|
87
|
+
};
|
|
88
|
+
/** The code exchange. One per completed flow; a flood here is guessing codes. */
|
|
89
|
+
export declare const TOKEN_PER_IP: {
|
|
90
|
+
limit: number;
|
|
91
|
+
windowSec: number;
|
|
92
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting for the unauthenticated OAuth endpoints.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS — what one request to /mcp-app/authorize actually costs:
|
|
5
|
+
*
|
|
6
|
+
* • an outbound HTTPS request to a URL THE CALLER CHOSE (client_id is a URL and
|
|
7
|
+
* we fetch it), preceded by a DNS lookup, bounded at 2s and 8 KB
|
|
8
|
+
* • a socket and its buffers in the same process that serves every MCP tool
|
|
9
|
+
* • a Redis key that lives 600 seconds
|
|
10
|
+
*
|
|
11
|
+
* And it takes no credential at all. So a loop from one machine turns this server
|
|
12
|
+
* into three different problems at once: our egress IP hammering a third party
|
|
13
|
+
* who will rightly block us, a Node process out of sockets while real users wait
|
|
14
|
+
* for a generation, and Redis filling with pending records nobody will redeem.
|
|
15
|
+
*
|
|
16
|
+
* WHO ACTUALLY CALLS /authorize, because it decides the shape of the limit:
|
|
17
|
+
* the USER'S BROWSER, not the AI host's servers. That is not an inference from
|
|
18
|
+
* the probe log — it is the authorization-code flow: the endpoint carries `state`
|
|
19
|
+
* and ends at a consent screen the person has to read, which a server-to-server
|
|
20
|
+
* caller cannot complete. The measured chain agrees (the discovery documents and
|
|
21
|
+
* the first POST /mcp-app come from Anthropic's cloud; the authorize redirect does
|
|
22
|
+
* not). So a per-IP bucket is a per-USER bucket here, and the shared-egress worry
|
|
23
|
+
* that would make per-IP limiting wrong for a machine-to-machine endpoint does not
|
|
24
|
+
* apply.
|
|
25
|
+
*
|
|
26
|
+
* A FIXED WINDOW, not a sliding one. It lets a caller spend the whole budget at
|
|
27
|
+
* the end of one window and again at the start of the next — twice the nominal
|
|
28
|
+
* rate across a window boundary — which is the known cost of the cheap algorithm
|
|
29
|
+
* and is irrelevant at these limits. What it buys is an EXACT `Retry-After`:
|
|
30
|
+
* seconds until this window resets, computed rather than guessed, which is what
|
|
31
|
+
* the Partners API already does (see its Retry-After note). A sliding window
|
|
32
|
+
* cannot answer that question honestly.
|
|
33
|
+
*/
|
|
34
|
+
import { redisClient } from './store.js';
|
|
35
|
+
/**
|
|
36
|
+
* `cache:` — and the prefix differs from `mcp_oauth:` on purpose.
|
|
37
|
+
*
|
|
38
|
+
* The pending records deliberately avoid `cache:` because the admin "Clear cache"
|
|
39
|
+
* button would destroy an authorization that exists nowhere else (see store.ts).
|
|
40
|
+
* A rate-limit counter is the opposite: it is safe to lose. Clearing it just
|
|
41
|
+
* refills everyone's budget, and the platform's own convention puts rate limits
|
|
42
|
+
* under `cache:` for exactly that reason, stating the trade-off outright.
|
|
43
|
+
* Kept visually distinct from the pending keys so nobody
|
|
44
|
+
* reading a Redis dump mistakes one for the other.
|
|
45
|
+
*/
|
|
46
|
+
const PREFIX = 'cache:mcp:rl';
|
|
47
|
+
/**
|
|
48
|
+
* Count one hit against a bucket.
|
|
49
|
+
*
|
|
50
|
+
* @param bucket Anything that identifies the caller for this limit, e.g.
|
|
51
|
+
* `authorize:ip:1.2.3.4`. Hashed into the key as-is, so callers
|
|
52
|
+
* must not put anything unbounded in it.
|
|
53
|
+
* @param limit Hits allowed per window.
|
|
54
|
+
* @param windowSec Window length.
|
|
55
|
+
*
|
|
56
|
+
* FAILS CLOSED on a Redis error, and that is deliberate rather than defensive
|
|
57
|
+
* habit: every endpoint this guards needs Redis to do its job anyway — /authorize
|
|
58
|
+
* cannot park a pending record without it — so a Redis outage means the request was
|
|
59
|
+
* going to fail regardless. Refusing early with an honest message beats doing the
|
|
60
|
+
* expensive outbound fetch first and failing after. The alternative, failing open,
|
|
61
|
+
* would remove the limiter precisely when the system is least able to absorb a
|
|
62
|
+
* flood.
|
|
63
|
+
*/
|
|
64
|
+
export async function hitLimit(bucket, limit, windowSec) {
|
|
65
|
+
const now = Math.floor(Date.now() / 1000);
|
|
66
|
+
const windowIndex = Math.floor(now / windowSec);
|
|
67
|
+
const retryAfter = (windowIndex + 1) * windowSec - now;
|
|
68
|
+
const key = `${PREFIX}:${bucket}:${windowIndex}`;
|
|
69
|
+
try {
|
|
70
|
+
const client = await redisClient();
|
|
71
|
+
const count = await client.incr(key);
|
|
72
|
+
/* Expire only on the first hit. Re-setting it on every hit would turn the
|
|
73
|
+
fixed window into a sliding one that never resets under sustained load,
|
|
74
|
+
so a blocked caller could never recover. */
|
|
75
|
+
if (count === 1)
|
|
76
|
+
await client.expire(key, windowSec + 1);
|
|
77
|
+
return { allowed: count <= limit, retryAfter, count };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { allowed: false, retryAfter: windowSec, count: -1 };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/* ── the limits ──────────────────────────────────────────────────────────────
|
|
84
|
+
*
|
|
85
|
+
* Chosen against what a HUMAN does, then multiplied generously, because the cost
|
|
86
|
+
* of being wrong is asymmetric: too loose still stops the flood, too tight locks a
|
|
87
|
+
* real person out of connecting their account with no way to tell why.
|
|
88
|
+
*
|
|
89
|
+
* A person connecting a client does this ONCE. Twice if they make a mistake and
|
|
90
|
+
* start again. Ten times in a minute is already someone testing; thirty is not a
|
|
91
|
+
* person. An attacker needs thousands per minute for any of the three costs above
|
|
92
|
+
* to matter.
|
|
93
|
+
*/
|
|
94
|
+
/** Starts of the flow, per IP. */
|
|
95
|
+
export const AUTHORIZE_PER_IP = { limit: 30, windowSec: 60 };
|
|
96
|
+
/**
|
|
97
|
+
* Starts of the flow per client_id, across all IPs.
|
|
98
|
+
*
|
|
99
|
+
* The second layer, and the one that survives a distributed attacker: an attack
|
|
100
|
+
* from a thousand addresses defeats a per-IP limit entirely, but every request
|
|
101
|
+
* still has to name a client_id, and it is the client_id that drives the outbound
|
|
102
|
+
* fetch this endpoint exists to protect. Deliberately much higher than the per-IP
|
|
103
|
+
* limit — claude.ai is ONE client_id for every one of our users, so this bucket
|
|
104
|
+
* must never be the thing that throttles legitimate traffic.
|
|
105
|
+
*
|
|
106
|
+
* It is not a complete answer on its own: an attacker rotating client_id values
|
|
107
|
+
* gets a fresh bucket each time. What stops THAT is the concurrency cap on the
|
|
108
|
+
* fetch itself, in clients.ts — a limit on how many outbound requests can be in
|
|
109
|
+
* flight, which no amount of key rotation gets around.
|
|
110
|
+
*/
|
|
111
|
+
export const AUTHORIZE_PER_CLIENT = { limit: 600, windowSec: 60 };
|
|
112
|
+
/** The consent hand-off. Cheap (one Redis read) but not free, and unauthenticated. */
|
|
113
|
+
export const DECIDE_PER_IP = { limit: 60, windowSec: 60 };
|
|
114
|
+
/** The code exchange. One per completed flow; a flood here is guessing codes. */
|
|
115
|
+
export const TOKEN_PER_IP = { limit: 60, windowSec: 60 };
|
|
116
|
+
//# sourceMappingURL=ratelimit.js.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state an authorization flow leaves behind between requests.
|
|
3
|
+
*
|
|
4
|
+
* Two short-lived records, both in Redis:
|
|
5
|
+
*
|
|
6
|
+
* pending /authorize → the consent page → back (the user has not decided yet)
|
|
7
|
+
* code the redirect to the client → /token (a one-time authorization code)
|
|
8
|
+
*
|
|
9
|
+
* WHY REDIS AND NOT THIS PROCESS'S MEMORY
|
|
10
|
+
* ---------------------------------------
|
|
11
|
+
* Owner decision 2026-09-12, and it buys two things memory cannot:
|
|
12
|
+
*
|
|
13
|
+
* 1. It survives a restart. A Map would mean every deploy cancels whoever is
|
|
14
|
+
* mid-sign-in, and they would see a failure with no cause.
|
|
15
|
+
* 2. PHP can read and write it. The consent screen lives on vidofy.ai because
|
|
16
|
+
* that is where the session cookie is — so approval is recorded by a
|
|
17
|
+
* different language in a different process. Shared storage is what lets
|
|
18
|
+
* that happen without inventing a signed side-channel between them.
|
|
19
|
+
*
|
|
20
|
+
* It is also how the rest of the project already works, which matters more than
|
|
21
|
+
* elegance: one place to look when something is stuck.
|
|
22
|
+
*
|
|
23
|
+
* KEY NAMING — deliberately NOT under `cache:`
|
|
24
|
+
* --------------------------------------------
|
|
25
|
+
* The platform's convention is that every cache key starts with `cache:` so an
|
|
26
|
+
* operator's "Clear cache" action can wipe them with one SCAN. These keys must
|
|
27
|
+
* NOT carry that prefix, for the same reason the platform exempts its other
|
|
28
|
+
* write-once secrets. They are not a copy of anything. A pending
|
|
29
|
+
* authorization exists ONLY here, and an authorization code is a single-use
|
|
30
|
+
* secret with no source to rebuild it from. An admin pressing Clear Cache while
|
|
31
|
+
* someone is signing in would destroy it, and the user would be bounced back to
|
|
32
|
+
* their client with an error nobody could explain.
|
|
33
|
+
*
|
|
34
|
+
* So: `mcp_oauth:pending:<id>` and `mcp_oauth:code:<code>`, alongside
|
|
35
|
+
* `mcp_flash:` which is there for the same reason.
|
|
36
|
+
*
|
|
37
|
+
* ⚠ AND THAT PROTECTS THESE KEYS FROM *ONE* ACTION, NOT FROM AN OPERATOR. There
|
|
38
|
+
* is a second, coarser one that flushes Redis outright. That erases every
|
|
39
|
+
* database, so it takes `mcp_oauth:` with it along with every session — which is
|
|
40
|
+
* why its own confirmation warns that all users are signed out. The paragraph
|
|
41
|
+
* above is about the prefix-scoped clear and was true of it; read alone it
|
|
42
|
+
* implied a safety these keys do not have.
|
|
43
|
+
*
|
|
44
|
+
* The consequence is small and worth stating so nobody hunts it: an admin
|
|
45
|
+
* pressing that button during someone's consent flow ends that flow with an
|
|
46
|
+
* error, and the user starts again. A ten-minute window, not a lost credential.
|
|
47
|
+
*/
|
|
48
|
+
import { type RedisClientType } from 'redis';
|
|
49
|
+
/** An authorization request, parked while the user decides. */
|
|
50
|
+
export interface PendingAuthorization {
|
|
51
|
+
clientId: string;
|
|
52
|
+
clientName: string;
|
|
53
|
+
redirectUri: string;
|
|
54
|
+
codeChallenge: string;
|
|
55
|
+
/** The resource the token will be bound to — RFC 8707, sent by both hosts. */
|
|
56
|
+
resource: string;
|
|
57
|
+
scope: string;
|
|
58
|
+
/** Echoed back to the client untouched; absent when the client sent none. */
|
|
59
|
+
state: string | null;
|
|
60
|
+
/** Consent-screen language hint. ChatGPT sends it, Claude does not. */
|
|
61
|
+
uiLocales: string | null;
|
|
62
|
+
createdAt: number;
|
|
63
|
+
decision?: 'allow' | 'deny';
|
|
64
|
+
/** The approving account. 0 on deny. */
|
|
65
|
+
userId?: number;
|
|
66
|
+
decidedAt?: number;
|
|
67
|
+
/** The minted `vmt_` token — see IssuedCode.rawToken. Present only on allow. */
|
|
68
|
+
rawToken?: string;
|
|
69
|
+
tokenRowId?: number;
|
|
70
|
+
}
|
|
71
|
+
/** An issued authorization code, waiting to be exchanged exactly once. */
|
|
72
|
+
export interface IssuedCode {
|
|
73
|
+
clientId: string;
|
|
74
|
+
redirectUri: string;
|
|
75
|
+
codeChallenge: string;
|
|
76
|
+
resource: string;
|
|
77
|
+
scope: string;
|
|
78
|
+
/** Who approved it. The whole point of the flow. */
|
|
79
|
+
userId: number;
|
|
80
|
+
/**
|
|
81
|
+
* The `vmt_` token itself, minted by PHP at consent time.
|
|
82
|
+
*
|
|
83
|
+
* It travels here rather than being created at /token because
|
|
84
|
+
* The site owns the credential format and the row, and a second
|
|
85
|
+
* implementation in TypeScript would be a second definition of one
|
|
86
|
+
* credential. Same pattern as `mcp_flash:` on the tokens page.
|
|
87
|
+
* Lives at most CODE_TTL_SEC, and the
|
|
88
|
+
* record is destroyed by GETDEL the moment it is exchanged.
|
|
89
|
+
*/
|
|
90
|
+
rawToken: string;
|
|
91
|
+
/** The row id, so a failed exchange can be traced back to what was created. */
|
|
92
|
+
tokenRowId: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The same connection, for code in this folder that is not about pending records.
|
|
96
|
+
*
|
|
97
|
+
* Exported for the rate limiter, which needs Redis and must not open a second
|
|
98
|
+
* connection to get it: two clients means two reconnect loops, two error handlers
|
|
99
|
+
* and twice the file descriptors, for one process that already has one working
|
|
100
|
+
* client. The `.env` reading and the NOAUTH problem below are also things a second
|
|
101
|
+
* connection would have to get right a second time.
|
|
102
|
+
*/
|
|
103
|
+
export declare function redisClient(): Promise<RedisClientType>;
|
|
104
|
+
export declare function savePending(p: Omit<PendingAuthorization, 'createdAt'>): Promise<string>;
|
|
105
|
+
/**
|
|
106
|
+
* Read a parked request.
|
|
107
|
+
*
|
|
108
|
+
* Returns null when it is missing OR expired — the two are indistinguishable and
|
|
109
|
+
* should be: the caller's answer is the same, and telling a caller which one it
|
|
110
|
+
* was lets them probe for ids that exist.
|
|
111
|
+
*/
|
|
112
|
+
export declare function readPending(id: string): Promise<PendingAuthorization | null>;
|
|
113
|
+
export declare function deletePending(id: string): Promise<void>;
|
|
114
|
+
/**
|
|
115
|
+
* Read a parked request and destroy it in ONE step.
|
|
116
|
+
*
|
|
117
|
+
* The same GETDEL reasoning as consumeCode, for the same reason: an approved
|
|
118
|
+
* request is worth exactly one authorization code. With a read followed by a
|
|
119
|
+
* delete, two simultaneous hits on /authorize/decide both see the approval
|
|
120
|
+
* before either removes it, and two codes come out of one consent — which
|
|
121
|
+
* multiplies a single "Allow" into more grants than the user agreed to.
|
|
122
|
+
*/
|
|
123
|
+
export declare function consumePending(id: string): Promise<PendingAuthorization | null>;
|
|
124
|
+
export declare function issueCode(data: IssuedCode): Promise<string>;
|
|
125
|
+
/**
|
|
126
|
+
* Consume a code — read it and destroy it in ONE atomic step.
|
|
127
|
+
*
|
|
128
|
+
* GETDEL, not GET-then-DEL. With two commands, two simultaneous exchanges of the
|
|
129
|
+
* same stolen code both read it before either deletes it, and both get a token:
|
|
130
|
+
* the single-use rule becomes a race. OAuth 2.1 requires the code be usable once,
|
|
131
|
+
* and this is where that is enforced.
|
|
132
|
+
*/
|
|
133
|
+
export declare function consumeCode(code: string): Promise<IssuedCode | null>;
|
|
134
|
+
/** Close the connection — for tests and a clean shutdown. */
|
|
135
|
+
export declare function closeStore(): Promise<void>;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state an authorization flow leaves behind between requests.
|
|
3
|
+
*
|
|
4
|
+
* Two short-lived records, both in Redis:
|
|
5
|
+
*
|
|
6
|
+
* pending /authorize → the consent page → back (the user has not decided yet)
|
|
7
|
+
* code the redirect to the client → /token (a one-time authorization code)
|
|
8
|
+
*
|
|
9
|
+
* WHY REDIS AND NOT THIS PROCESS'S MEMORY
|
|
10
|
+
* ---------------------------------------
|
|
11
|
+
* Owner decision 2026-09-12, and it buys two things memory cannot:
|
|
12
|
+
*
|
|
13
|
+
* 1. It survives a restart. A Map would mean every deploy cancels whoever is
|
|
14
|
+
* mid-sign-in, and they would see a failure with no cause.
|
|
15
|
+
* 2. PHP can read and write it. The consent screen lives on vidofy.ai because
|
|
16
|
+
* that is where the session cookie is — so approval is recorded by a
|
|
17
|
+
* different language in a different process. Shared storage is what lets
|
|
18
|
+
* that happen without inventing a signed side-channel between them.
|
|
19
|
+
*
|
|
20
|
+
* It is also how the rest of the project already works, which matters more than
|
|
21
|
+
* elegance: one place to look when something is stuck.
|
|
22
|
+
*
|
|
23
|
+
* KEY NAMING — deliberately NOT under `cache:`
|
|
24
|
+
* --------------------------------------------
|
|
25
|
+
* The platform's convention is that every cache key starts with `cache:` so an
|
|
26
|
+
* operator's "Clear cache" action can wipe them with one SCAN. These keys must
|
|
27
|
+
* NOT carry that prefix, for the same reason the platform exempts its other
|
|
28
|
+
* write-once secrets. They are not a copy of anything. A pending
|
|
29
|
+
* authorization exists ONLY here, and an authorization code is a single-use
|
|
30
|
+
* secret with no source to rebuild it from. An admin pressing Clear Cache while
|
|
31
|
+
* someone is signing in would destroy it, and the user would be bounced back to
|
|
32
|
+
* their client with an error nobody could explain.
|
|
33
|
+
*
|
|
34
|
+
* So: `mcp_oauth:pending:<id>` and `mcp_oauth:code:<code>`, alongside
|
|
35
|
+
* `mcp_flash:` which is there for the same reason.
|
|
36
|
+
*
|
|
37
|
+
* ⚠ AND THAT PROTECTS THESE KEYS FROM *ONE* ACTION, NOT FROM AN OPERATOR. There
|
|
38
|
+
* is a second, coarser one that flushes Redis outright. That erases every
|
|
39
|
+
* database, so it takes `mcp_oauth:` with it along with every session — which is
|
|
40
|
+
* why its own confirmation warns that all users are signed out. The paragraph
|
|
41
|
+
* above is about the prefix-scoped clear and was true of it; read alone it
|
|
42
|
+
* implied a safety these keys do not have.
|
|
43
|
+
*
|
|
44
|
+
* The consequence is small and worth stating so nobody hunts it: an admin
|
|
45
|
+
* pressing that button during someone's consent flow ends that flow with an
|
|
46
|
+
* error, and the user starts again. A ten-minute window, not a lost credential.
|
|
47
|
+
*/
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { dirname, join } from 'node:path';
|
|
50
|
+
import { fileURLToPath } from 'node:url';
|
|
51
|
+
import { createClient } from 'redis';
|
|
52
|
+
/** How long a user has to finish the consent screen. */
|
|
53
|
+
const PENDING_TTL_SEC = 600;
|
|
54
|
+
/**
|
|
55
|
+
* How long the client has to exchange the code.
|
|
56
|
+
*
|
|
57
|
+
* OAuth 2.1 says a code SHOULD be short-lived and single-use, and recommends a
|
|
58
|
+
* maximum of 10 minutes; 60 seconds is enough for a redirect and one POST, and
|
|
59
|
+
* every second beyond that is a window for a leaked code to be replayed.
|
|
60
|
+
*/
|
|
61
|
+
const CODE_TTL_SEC = 60;
|
|
62
|
+
const PREFIX = 'mcp_oauth';
|
|
63
|
+
let client = null;
|
|
64
|
+
/**
|
|
65
|
+
* The shared connection, opened on first use.
|
|
66
|
+
*
|
|
67
|
+
* `redis` reconnects on its own; what it does NOT do is queue commands forever
|
|
68
|
+
* while down, so a caller still has to handle a rejection — see the note on
|
|
69
|
+
* readPending.
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* The site's `.env`, parsed once — or an empty object when there is none.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately minimal: `KEY=value`, `#` comments, optional surrounding quotes.
|
|
75
|
+
* It is not a dotenv replacement and must not become one; the site's own parser
|
|
76
|
+
* is the authority on this file's format, and anything it supports that this
|
|
77
|
+
* does not is a reason to pass the value through the real environment instead.
|
|
78
|
+
*
|
|
79
|
+
* Found by walking UP from this module rather than from `process.cwd()`, because
|
|
80
|
+
* the working directory depends on who started the process — a manager, a shell
|
|
81
|
+
* in `vidofy-mcp/`, or a shell in the repo root — while the module's own position
|
|
82
|
+
* relative to the repo never changes. `VIDOFY_ENV_FILE` overrides it outright for
|
|
83
|
+
* a deployment that puts the file somewhere else.
|
|
84
|
+
*/
|
|
85
|
+
let cachedFileEnv = null;
|
|
86
|
+
function siteEnvFile() {
|
|
87
|
+
if (cachedFileEnv !== null)
|
|
88
|
+
return cachedFileEnv;
|
|
89
|
+
cachedFileEnv = {};
|
|
90
|
+
const explicit = (process.env['VIDOFY_ENV_FILE'] ?? '').trim();
|
|
91
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
92
|
+
// dist/oauth → dist → vidofy-mcp → repo root. Four, to survive src/ vs dist/.
|
|
93
|
+
const candidates = explicit !== ''
|
|
94
|
+
? [explicit]
|
|
95
|
+
: [1, 2, 3, 4].map((up) => join(here, ...Array(up).fill('..'), '.env'));
|
|
96
|
+
for (const path of candidates) {
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = readFileSync(path, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue; // absent is normal — the npm package has no .env
|
|
103
|
+
}
|
|
104
|
+
for (const line of text.split('\n')) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
107
|
+
continue;
|
|
108
|
+
const eq = trimmed.indexOf('=');
|
|
109
|
+
if (eq <= 0)
|
|
110
|
+
continue;
|
|
111
|
+
const k = trimmed.slice(0, eq).trim();
|
|
112
|
+
let v = trimmed.slice(eq + 1).trim();
|
|
113
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
114
|
+
v = v.slice(1, -1);
|
|
115
|
+
}
|
|
116
|
+
cachedFileEnv[k] = v;
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
return cachedFileEnv;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The same connection, for code in this folder that is not about pending records.
|
|
124
|
+
*
|
|
125
|
+
* Exported for the rate limiter, which needs Redis and must not open a second
|
|
126
|
+
* connection to get it: two clients means two reconnect loops, two error handlers
|
|
127
|
+
* and twice the file descriptors, for one process that already has one working
|
|
128
|
+
* client. The `.env` reading and the NOAUTH problem below are also things a second
|
|
129
|
+
* connection would have to get right a second time.
|
|
130
|
+
*/
|
|
131
|
+
export async function redisClient() {
|
|
132
|
+
return await redis();
|
|
133
|
+
}
|
|
134
|
+
async function redis() {
|
|
135
|
+
if (client !== null && client.isOpen)
|
|
136
|
+
return client;
|
|
137
|
+
/* Where the connection details come from, in order: the process environment
|
|
138
|
+
* under `VIDOFY_REDIS_*`, then under the site's own `REDIS_*`, then the site's
|
|
139
|
+
* `.env` FILE.
|
|
140
|
+
*
|
|
141
|
+
* That third source is the one that matters, and it was missing. Only the
|
|
142
|
+
* `VIDOFY_`-prefixed variables were read, and they appear in no deploy artifact
|
|
143
|
+
* anywhere — no process-manager config, no unit file, not `.env`. The
|
|
144
|
+
* first fix for that read `REDIS_*` too, which looked right and was still
|
|
145
|
+
* incomplete: **Node does not read `.env`.** That file is parsed by the
|
|
146
|
+
* site's own loader, so `process.env.REDIS_PASSWORD` is empty unless a
|
|
147
|
+
* human happened to export it in the shell that started this process.
|
|
148
|
+
*
|
|
149
|
+
* And it is not a production-only problem, which is what the first version of
|
|
150
|
+
* this comment assumed. Measured 2026-09-13: local Redis answers
|
|
151
|
+
* `NOAUTH Authentication required` without a password. So with none of these
|
|
152
|
+
* variables exported, every call here fails — locally too — and /authorize
|
|
153
|
+
* answers a bare 500 with no explanation.
|
|
154
|
+
*
|
|
155
|
+
* Reading the file is done here rather than by adding dotenv: one dependency
|
|
156
|
+
* for fifteen lines, on a package that also ships to npm as a standalone stdio
|
|
157
|
+
* server where the file does not exist and Redis is never used at all. Absent
|
|
158
|
+
* is therefore silent, not an error. */
|
|
159
|
+
const fileEnv = siteEnvFile();
|
|
160
|
+
const env = (a, b, fallback) => {
|
|
161
|
+
const first = (process.env[a] ?? '').trim();
|
|
162
|
+
if (first !== '')
|
|
163
|
+
return first;
|
|
164
|
+
const second = (process.env[b] ?? '').trim();
|
|
165
|
+
if (second !== '')
|
|
166
|
+
return second;
|
|
167
|
+
return (fileEnv[b] ?? '').trim() || fallback;
|
|
168
|
+
};
|
|
169
|
+
const host = env('VIDOFY_REDIS_HOST', 'REDIS_HOST', '127.0.0.1');
|
|
170
|
+
const port = Number(env('VIDOFY_REDIS_PORT', 'REDIS_PORT', '6379'));
|
|
171
|
+
const password = env('VIDOFY_REDIS_PASSWORD', 'REDIS_PASSWORD', '');
|
|
172
|
+
/* The same logical database the site uses. Sharing it is intentional: the
|
|
173
|
+
site reads these keys, and a different database would mean the consent
|
|
174
|
+
page writing where this process never looks. */
|
|
175
|
+
const database = Number(env('VIDOFY_REDIS_DB', 'REDIS_DB', '1'));
|
|
176
|
+
const c = createClient({
|
|
177
|
+
socket: { host, port },
|
|
178
|
+
...(password !== '' ? { password } : {}),
|
|
179
|
+
database,
|
|
180
|
+
});
|
|
181
|
+
/* Without a listener, an error event on a node-redis client is an unhandled
|
|
182
|
+
'error' and takes the process down — which would turn a Redis blip into an
|
|
183
|
+
outage of every tool, not just the ones that need storage. */
|
|
184
|
+
c.on('error', () => { });
|
|
185
|
+
await c.connect();
|
|
186
|
+
client = c;
|
|
187
|
+
return c;
|
|
188
|
+
}
|
|
189
|
+
/** 32 bytes of URL-safe randomness — the id and code format. */
|
|
190
|
+
function token() {
|
|
191
|
+
return Buffer.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
192
|
+
.toString('base64url');
|
|
193
|
+
}
|
|
194
|
+
/* ── pending authorizations ───────────────────────────────────────────────── */
|
|
195
|
+
export async function savePending(p) {
|
|
196
|
+
const id = token();
|
|
197
|
+
const c = await redis();
|
|
198
|
+
await c.set(`${PREFIX}:pending:${id}`, JSON.stringify({ ...p, createdAt: Date.now() }), {
|
|
199
|
+
expiration: { type: 'EX', value: PENDING_TTL_SEC },
|
|
200
|
+
});
|
|
201
|
+
return id;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Read a parked request.
|
|
205
|
+
*
|
|
206
|
+
* Returns null when it is missing OR expired — the two are indistinguishable and
|
|
207
|
+
* should be: the caller's answer is the same, and telling a caller which one it
|
|
208
|
+
* was lets them probe for ids that exist.
|
|
209
|
+
*/
|
|
210
|
+
export async function readPending(id) {
|
|
211
|
+
const raw = await (await redis()).get(`${PREFIX}:pending:${id}`);
|
|
212
|
+
if (raw === null)
|
|
213
|
+
return null;
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(raw);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export async function deletePending(id) {
|
|
222
|
+
await (await redis()).del(`${PREFIX}:pending:${id}`);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Read a parked request and destroy it in ONE step.
|
|
226
|
+
*
|
|
227
|
+
* The same GETDEL reasoning as consumeCode, for the same reason: an approved
|
|
228
|
+
* request is worth exactly one authorization code. With a read followed by a
|
|
229
|
+
* delete, two simultaneous hits on /authorize/decide both see the approval
|
|
230
|
+
* before either removes it, and two codes come out of one consent — which
|
|
231
|
+
* multiplies a single "Allow" into more grants than the user agreed to.
|
|
232
|
+
*/
|
|
233
|
+
export async function consumePending(id) {
|
|
234
|
+
const raw = await (await redis()).getDel(`${PREFIX}:pending:${id}`);
|
|
235
|
+
if (raw === null)
|
|
236
|
+
return null;
|
|
237
|
+
try {
|
|
238
|
+
return JSON.parse(raw);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/* ── authorization codes ──────────────────────────────────────────────────── */
|
|
245
|
+
export async function issueCode(data) {
|
|
246
|
+
const code = token();
|
|
247
|
+
await (await redis()).set(`${PREFIX}:code:${code}`, JSON.stringify(data), {
|
|
248
|
+
expiration: { type: 'EX', value: CODE_TTL_SEC },
|
|
249
|
+
});
|
|
250
|
+
return code;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Consume a code — read it and destroy it in ONE atomic step.
|
|
254
|
+
*
|
|
255
|
+
* GETDEL, not GET-then-DEL. With two commands, two simultaneous exchanges of the
|
|
256
|
+
* same stolen code both read it before either deletes it, and both get a token:
|
|
257
|
+
* the single-use rule becomes a race. OAuth 2.1 requires the code be usable once,
|
|
258
|
+
* and this is where that is enforced.
|
|
259
|
+
*/
|
|
260
|
+
export async function consumeCode(code) {
|
|
261
|
+
const raw = await (await redis()).getDel(`${PREFIX}:code:${code}`);
|
|
262
|
+
if (raw === null)
|
|
263
|
+
return null;
|
|
264
|
+
try {
|
|
265
|
+
return JSON.parse(raw);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Close the connection — for tests and a clean shutdown. */
|
|
272
|
+
export async function closeStore() {
|
|
273
|
+
if (client !== null && client.isOpen)
|
|
274
|
+
await client.quit();
|
|
275
|
+
client = null;
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /mcp-app/token` — the authorization code becomes an access token.
|
|
3
|
+
*
|
|
4
|
+
* This is the only place a credential leaves the server, so everything it checks
|
|
5
|
+
* is a precondition for handing one over:
|
|
6
|
+
*
|
|
7
|
+
* 1. the code exists and has not been used (GETDEL — one code, one token)
|
|
8
|
+
* 2. the client asking is the client the code was issued to
|
|
9
|
+
* 3. the redirect_uri matches the one bound to the code
|
|
10
|
+
* 4. PKCE: SHA256(code_verifier) equals the challenge recorded at /authorize
|
|
11
|
+
*
|
|
12
|
+
* PKCE IS THE WHOLE PROTECTION HERE
|
|
13
|
+
* ---------------------------------
|
|
14
|
+
* The code travels through the user's browser — in a URL, through history, past
|
|
15
|
+
* whatever extensions are installed. Anyone who captures it could exchange it,
|
|
16
|
+
* and the client authenticates with `none` (a public client has no secret to
|
|
17
|
+
* prove itself with). What stops the exchange is that the thief does not have
|
|
18
|
+
* the verifier: it never left the client. So this check is not defence in depth,
|
|
19
|
+
* it is the door.
|
|
20
|
+
*
|
|
21
|
+
* WHY THERE IS NO TOKEN GENERATION IN THIS FILE
|
|
22
|
+
* ---------------------------------------------
|
|
23
|
+
* The `vmt_` token was minted by the site when the user approved, and rides in
|
|
24
|
+
* the code record. The site owns the credential format, the hash, the scope and
|
|
25
|
+
* the expiry; a second implementation here would be a second definition of one
|
|
26
|
+
* credential.
|
|
27
|
+
*/
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
29
|
+
export declare function handleToken(req: IncomingMessage, res: ServerResponse): Promise<void>;
|