@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,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;
|