@segmentstream-cloud/ontology 0.9.0 → 0.11.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/dist/action.d.ts +19 -0
- package/dist/action.js +1 -0
- package/dist/analyze.d.ts +3 -1
- package/dist/analyze.js +97 -2
- package/dist/bin/migrate.js +3 -2
- package/dist/blob.d.ts +39 -0
- package/dist/blob.js +49 -0
- package/dist/diagnostics.d.ts +3 -3
- package/dist/diagnostics.js +1 -0
- package/dist/entity.d.ts +5 -3
- package/dist/index.d.ts +9 -4
- package/dist/index.js +6 -1
- package/dist/manifest.d.ts +71 -7
- package/dist/manifest.js +18 -1
- package/dist/migrate/apply.d.ts +7 -0
- package/dist/migrate/apply.js +18 -1
- package/dist/migrate/index.d.ts +1 -1
- package/dist/migrate/index.js +1 -1
- package/dist/migrate/model.d.ts +15 -2
- package/dist/migrate/plan.d.ts +6 -0
- package/dist/migrate/plan.js +35 -3
- package/dist/migrate/storage.d.ts +14 -0
- package/dist/migrate/storage.js +95 -0
- package/dist/names.d.ts +9 -1
- package/dist/names.js +11 -0
- package/dist/nitro.d.ts +13 -0
- package/dist/nitro.js +18 -0
- package/dist/property.d.ts +10 -1
- package/dist/property.js +12 -1
- package/dist/query.js +6 -1
- package/dist/runtime/generated-actions.d.ts +3 -0
- package/dist/runtime/generated-actions.js +10 -5
- package/dist/runtime/history.d.ts +3 -0
- package/dist/runtime/oauth.d.ts +9 -0
- package/dist/runtime/oauth.js +165 -0
- package/dist/runtime/pipeline.d.ts +5 -1
- package/dist/runtime/pipeline.js +87 -3
- package/dist/runtime/principals.d.ts +5 -0
- package/dist/runtime/principals.js +15 -0
- package/dist/runtime/records.js +9 -0
- package/dist/runtime/service-binding.d.ts +2 -1
- package/dist/runtime/service-binding.js +15 -2
- package/dist/runtime/writes.js +17 -3
- package/dist/service/api.js +19 -10
- package/dist/service/authentication.d.ts +27 -1
- package/dist/service/authentication.js +48 -26
- package/dist/service/config.d.ts +8 -0
- package/dist/service/config.js +18 -0
- package/dist/service/descriptions.d.ts +8 -0
- package/dist/service/descriptions.js +15 -0
- package/dist/service/errors.d.ts +5 -1
- package/dist/service/errors.js +6 -2
- package/dist/service/handlers.d.ts +1 -4
- package/dist/service/handlers.js +59 -203
- package/dist/service/mcp/server.d.ts +24 -0
- package/dist/service/mcp/server.js +370 -0
- package/dist/service/mcp/tools.d.ts +47 -0
- package/dist/service/mcp/tools.js +218 -0
- package/dist/service/oauth/protocol.d.ts +52 -0
- package/dist/service/oauth/protocol.js +110 -0
- package/dist/service/oauth/server.d.ts +27 -0
- package/dist/service/oauth/server.js +576 -0
- package/dist/service/operations.d.ts +114 -0
- package/dist/service/operations.js +218 -0
- package/dist/service/runtime-contract.d.ts +98 -0
- package/dist/service/schemas.d.ts +18 -2
- package/dist/service/schemas.js +18 -2
- package/dist/service/service.d.ts +3 -3
- package/dist/service/service.js +27 -7
- package/dist/storage/api.d.ts +4 -0
- package/dist/storage/api.js +39 -0
- package/dist/storage/authorization.d.ts +8 -0
- package/dist/storage/authorization.js +30 -0
- package/dist/storage/maintenance-auth.d.ts +20 -0
- package/dist/storage/maintenance-auth.js +24 -0
- package/dist/storage/maintenance.d.ts +7 -0
- package/dist/storage/maintenance.js +50 -0
- package/dist/storage/neon.d.ts +3 -0
- package/dist/storage/neon.js +212 -0
- package/dist/storage/operator.d.ts +10 -0
- package/dist/storage/operator.js +30 -0
- package/dist/storage/provider.d.ts +80 -0
- package/dist/storage/provider.js +20 -0
- package/dist/storage/sessions.d.ts +87 -0
- package/dist/storage/sessions.js +341 -0
- package/dist/storage/tables.d.ts +2 -0
- package/dist/storage/tables.js +55 -0
- package/dist/storage/writes.d.ts +7 -0
- package/dist/storage/writes.js +88 -0
- package/package.json +8 -1
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
4
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
5
|
+
import { BASE_PATH } from "../api.js";
|
|
6
|
+
import { CurrentPrincipal, withPrincipal } from "../authentication.js";
|
|
7
|
+
import { RequestId, errorBodyForWire } from "../errors.js";
|
|
8
|
+
import { fixedWindowLimiter, clientAddress } from "../rate-limit.js";
|
|
9
|
+
import { OntologyBackend, } from "../runtime-contract.js";
|
|
10
|
+
import { ACCESS_TOKEN_PREFIX, ACCESS_TOKEN_TTL_MS, AUTHORIZATION_SERVER_PATH, CLIENT_ID_PREFIX, CLIENT_SECRET_PREFIX, CODE_PREFIX, CODE_TTL_MS, CONSENT_PATH, FULL_SCOPE, PROTECTED_RESOURCE_PATH, READ_SCOPE, REFRESH_TOKEN_PREFIX, REFRESH_TOKEN_TTL_MS, REQUEST_TTL_MS, SCOPES, grantedScope, isLoopback, newSecret, publicOrigin, sameSecret, secretHash, verifierMatches, } from "./protocol.js";
|
|
11
|
+
/** The authorization server's endpoints, under the ontology service's path. */
|
|
12
|
+
export const OAUTH_PATH = `${BASE_PATH}/oauth`;
|
|
13
|
+
const REGISTER_PATH = `${OAUTH_PATH}/register`;
|
|
14
|
+
const AUTHORIZE_PATH = `${OAUTH_PATH}/authorize`;
|
|
15
|
+
const TOKEN_PATH = `${OAUTH_PATH}/token`;
|
|
16
|
+
const REVOKE_PATH = `${OAUTH_PATH}/revoke`;
|
|
17
|
+
const REQUESTS_PATH = `${OAUTH_PATH}/requests`;
|
|
18
|
+
/** Registrations one client address may make per minute; each one is a row in the database. */
|
|
19
|
+
export const REGISTRATION_LIMIT = { limit: 20, windowMs: 60_000, maxKeys: 10_000 };
|
|
20
|
+
const MAX_REDIRECT_URIS = 10;
|
|
21
|
+
const MAX_URI_LENGTH = 2000;
|
|
22
|
+
const MAX_NAME_LENGTH = 100;
|
|
23
|
+
const MAX_STATE_LENGTH = 1000;
|
|
24
|
+
/** The metadata, registration, token and revocation endpoints are read by clients on any origin. */
|
|
25
|
+
const CORS = {
|
|
26
|
+
"access-control-allow-origin": "*",
|
|
27
|
+
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
28
|
+
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version",
|
|
29
|
+
};
|
|
30
|
+
const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" };
|
|
31
|
+
function json(body, status = 200, headers = {}) {
|
|
32
|
+
return HttpServerResponse.jsonUnsafe(body, { status, headers: { ...CORS, ...headers } });
|
|
33
|
+
}
|
|
34
|
+
/** An OAuth error (RFC 6749 §5.2), as the token, registration and revocation endpoints answer. */
|
|
35
|
+
function oauthError(error, description, status = 400) {
|
|
36
|
+
return json({ error, error_description: description }, status, error === "invalid_client"
|
|
37
|
+
? { "www-authenticate": 'Basic realm="ontology"', ...NO_STORE }
|
|
38
|
+
: NO_STORE);
|
|
39
|
+
}
|
|
40
|
+
/** Where the MCP server is, on the origin the client reached. */
|
|
41
|
+
export function mcpResource(origin) {
|
|
42
|
+
return `${origin}/mcp`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The `WWW-Authenticate` challenge of the MCP server: it names the protected resource metadata,
|
|
46
|
+
* which is how a client learns where members authorize it (RFC 9728).
|
|
47
|
+
*/
|
|
48
|
+
export function mcpChallenge(origin, missing) {
|
|
49
|
+
const metadata = `resource_metadata="${origin}${PROTECTED_RESOURCE_PATH}/mcp"`;
|
|
50
|
+
return missing
|
|
51
|
+
? `Bearer ${metadata}, scope="${FULL_SCOPE}"`
|
|
52
|
+
: `Bearer error="invalid_token", ${metadata}`;
|
|
53
|
+
}
|
|
54
|
+
function protectedResourceMetadata(origin) {
|
|
55
|
+
return {
|
|
56
|
+
resource: mcpResource(origin),
|
|
57
|
+
authorization_servers: [origin],
|
|
58
|
+
scopes_supported: SCOPES,
|
|
59
|
+
bearer_methods_supported: ["header"],
|
|
60
|
+
resource_name: "Ontology",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function authorizationServerMetadata(origin) {
|
|
64
|
+
const authMethods = [
|
|
65
|
+
"none",
|
|
66
|
+
"client_secret_post",
|
|
67
|
+
"client_secret_basic",
|
|
68
|
+
];
|
|
69
|
+
return {
|
|
70
|
+
issuer: origin,
|
|
71
|
+
authorization_endpoint: `${origin}${AUTHORIZE_PATH}`,
|
|
72
|
+
token_endpoint: `${origin}${TOKEN_PATH}`,
|
|
73
|
+
registration_endpoint: `${origin}${REGISTER_PATH}`,
|
|
74
|
+
revocation_endpoint: `${origin}${REVOKE_PATH}`,
|
|
75
|
+
scopes_supported: SCOPES,
|
|
76
|
+
response_types_supported: ["code"],
|
|
77
|
+
response_modes_supported: ["query"],
|
|
78
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
79
|
+
token_endpoint_auth_methods_supported: authMethods,
|
|
80
|
+
revocation_endpoint_auth_methods_supported: authMethods,
|
|
81
|
+
code_challenge_methods_supported: ["S256"],
|
|
82
|
+
authorization_response_iss_parameter_supported: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function searchParams(request) {
|
|
86
|
+
return Object.fromEntries(new URL(request.url, "http://ontology.invalid").searchParams);
|
|
87
|
+
}
|
|
88
|
+
/** A form body (RFC 6749 §4.1.3), or a JSON object from clients that send one. */
|
|
89
|
+
function bodyParams(request) {
|
|
90
|
+
return Effect.map(Effect.orDie(request.text), (text) => {
|
|
91
|
+
const contentType = request.headers["content-type"]?.toLowerCase() ?? "";
|
|
92
|
+
if (contentType.includes("application/json")) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(text);
|
|
95
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
96
|
+
? Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"))
|
|
97
|
+
: {};
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function jsonBody(request) {
|
|
107
|
+
return Effect.map(Effect.orDie(request.text), (text) => {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Why a redirect URI cannot be registered, or `undefined` when it can (RFC 8252 for native apps). */
|
|
117
|
+
function redirectUriProblem(value) {
|
|
118
|
+
if (typeof value !== "string" || value.length > MAX_URI_LENGTH || !URL.canParse(value)) {
|
|
119
|
+
return "is not an absolute URL";
|
|
120
|
+
}
|
|
121
|
+
const url = new URL(value);
|
|
122
|
+
if (url.hash !== "") {
|
|
123
|
+
return "has a fragment";
|
|
124
|
+
}
|
|
125
|
+
if (url.protocol === "https:") {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (url.protocol === "http:") {
|
|
129
|
+
return isLoopback(url.host) ? undefined : "uses http on a host other than this computer";
|
|
130
|
+
}
|
|
131
|
+
if (["javascript:", "data:", "file:", "vbscript:", "blob:", "about:"].includes(url.protocol)) {
|
|
132
|
+
return "uses a scheme that cannot receive a redirect";
|
|
133
|
+
}
|
|
134
|
+
// A native app's private-use scheme, such as cursor://.
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Whether a redirect URI is one the client registered. A loopback URI matches on any port, as
|
|
139
|
+
* native apps listen on whichever port is free (RFC 8252 §7.3).
|
|
140
|
+
*/
|
|
141
|
+
function registeredRedirect(client, value) {
|
|
142
|
+
return client.redirectUris.some((registered) => {
|
|
143
|
+
if (registered === value) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (!URL.canParse(registered) || !URL.canParse(value)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const expected = new URL(registered);
|
|
150
|
+
const actual = new URL(value);
|
|
151
|
+
return (expected.protocol === "http:" &&
|
|
152
|
+
isLoopback(expected.host) &&
|
|
153
|
+
actual.protocol === "http:" &&
|
|
154
|
+
expected.hostname === actual.hostname &&
|
|
155
|
+
expected.pathname === actual.pathname &&
|
|
156
|
+
expected.search === actual.search);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** The redirect URI with the response's parameters, and `iss` to name this server (RFC 9207). */
|
|
160
|
+
function redirectWith(redirectUri, params) {
|
|
161
|
+
const url = new URL(redirectUri);
|
|
162
|
+
for (const [key, value] of Object.entries(params)) {
|
|
163
|
+
if (value !== null) {
|
|
164
|
+
url.searchParams.set(key, value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return url.toString();
|
|
168
|
+
}
|
|
169
|
+
/** A page for problems the client cannot be told about, because its redirect URI is not trusted. */
|
|
170
|
+
function authorizeFailure(detail) {
|
|
171
|
+
return HttpServerResponse.text(`This authorization link cannot be used: ${detail}`, {
|
|
172
|
+
status: 400,
|
|
173
|
+
headers: NO_STORE,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function register(store, limiter) {
|
|
177
|
+
return (request) => Effect.gen(function* () {
|
|
178
|
+
if (limiter.take(clientAddress(request)) !== undefined) {
|
|
179
|
+
return oauthError("too_many_requests", "Too many registrations; try again in a minute.", 429);
|
|
180
|
+
}
|
|
181
|
+
const body = yield* jsonBody(request);
|
|
182
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
183
|
+
return oauthError("invalid_client_metadata", "Send the client metadata as a JSON object.");
|
|
184
|
+
}
|
|
185
|
+
const metadata = body;
|
|
186
|
+
const redirectUris = metadata.redirect_uris;
|
|
187
|
+
if (!Array.isArray(redirectUris) ||
|
|
188
|
+
redirectUris.length === 0 ||
|
|
189
|
+
redirectUris.length > MAX_REDIRECT_URIS) {
|
|
190
|
+
return oauthError("invalid_redirect_uri", `redirect_uris lists between 1 and ${MAX_REDIRECT_URIS} URIs.`);
|
|
191
|
+
}
|
|
192
|
+
for (const uri of redirectUris) {
|
|
193
|
+
const problem = redirectUriProblem(uri);
|
|
194
|
+
if (problem !== undefined) {
|
|
195
|
+
return oauthError("invalid_redirect_uri", `${String(uri)} ${problem}.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// RFC 7591 §2: without a method, the client authenticates with client_secret_basic.
|
|
199
|
+
const authMethod = metadata.token_endpoint_auth_method ?? "client_secret_basic";
|
|
200
|
+
if (authMethod !== "none" &&
|
|
201
|
+
authMethod !== "client_secret_post" &&
|
|
202
|
+
authMethod !== "client_secret_basic") {
|
|
203
|
+
return oauthError("invalid_client_metadata", "token_endpoint_auth_method is none, client_secret_post or client_secret_basic.");
|
|
204
|
+
}
|
|
205
|
+
const grantTypes = metadata.grant_types ?? ["authorization_code"];
|
|
206
|
+
const responseTypes = metadata.response_types ?? ["code"];
|
|
207
|
+
if (!Array.isArray(grantTypes) ||
|
|
208
|
+
!grantTypes.every((type) => type === "authorization_code" || type === "refresh_token") ||
|
|
209
|
+
!Array.isArray(responseTypes) ||
|
|
210
|
+
!responseTypes.every((type) => type === "code")) {
|
|
211
|
+
return oauthError("invalid_client_metadata", "This server grants authorization_code and refresh_token, with the code response type.");
|
|
212
|
+
}
|
|
213
|
+
const name = typeof metadata.client_name === "string" && metadata.client_name.trim() !== ""
|
|
214
|
+
? metadata.client_name.trim().slice(0, MAX_NAME_LENGTH)
|
|
215
|
+
: "An MCP client";
|
|
216
|
+
const clientUri = typeof metadata.client_uri === "string" &&
|
|
217
|
+
metadata.client_uri.length <= MAX_URI_LENGTH &&
|
|
218
|
+
URL.canParse(metadata.client_uri) &&
|
|
219
|
+
new URL(metadata.client_uri).protocol === "https:"
|
|
220
|
+
? metadata.client_uri
|
|
221
|
+
: null;
|
|
222
|
+
const secret = authMethod === "none" ? undefined : newSecret(CLIENT_SECRET_PREFIX);
|
|
223
|
+
const client = {
|
|
224
|
+
id: newSecret(CLIENT_ID_PREFIX),
|
|
225
|
+
name,
|
|
226
|
+
redirectUris: redirectUris,
|
|
227
|
+
authMethod,
|
|
228
|
+
secretHash: secret === undefined ? null : secretHash(secret),
|
|
229
|
+
clientUri,
|
|
230
|
+
};
|
|
231
|
+
yield* store.saveClient(client);
|
|
232
|
+
return json({
|
|
233
|
+
client_id: client.id,
|
|
234
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
235
|
+
...(secret === undefined ? {} : { client_secret: secret, client_secret_expires_at: 0 }),
|
|
236
|
+
client_name: client.name,
|
|
237
|
+
redirect_uris: client.redirectUris,
|
|
238
|
+
token_endpoint_auth_method: client.authMethod,
|
|
239
|
+
grant_types: grantTypes,
|
|
240
|
+
response_types: responseTypes,
|
|
241
|
+
...(clientUri === null ? {} : { client_uri: clientUri }),
|
|
242
|
+
}, 201, NO_STORE);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* `GET /api/ontology/oauth/authorize`: checks the client's request, keeps it for the member to
|
|
247
|
+
* decide, and sends the browser to the app's consent page, which signs the member in first.
|
|
248
|
+
*/
|
|
249
|
+
function authorize(store) {
|
|
250
|
+
return (request) => Effect.gen(function* () {
|
|
251
|
+
const params = searchParams(request);
|
|
252
|
+
const origin = publicOrigin(request);
|
|
253
|
+
const client = params.client_id === undefined ? undefined : yield* store.findClient(params.client_id);
|
|
254
|
+
if (client === undefined) {
|
|
255
|
+
return authorizeFailure("the client is not registered with this app.");
|
|
256
|
+
}
|
|
257
|
+
const redirectUri = params.redirect_uri ??
|
|
258
|
+
(client.redirectUris.length === 1 ? client.redirectUris[0] : undefined);
|
|
259
|
+
if (redirectUri === undefined || !registeredRedirect(client, redirectUri)) {
|
|
260
|
+
return authorizeFailure("the redirect URI is not one the client registered.");
|
|
261
|
+
}
|
|
262
|
+
const state = params.state ?? null;
|
|
263
|
+
const fail = (error, description) => HttpServerResponse.redirect(redirectWith(redirectUri, {
|
|
264
|
+
error,
|
|
265
|
+
error_description: description,
|
|
266
|
+
state,
|
|
267
|
+
iss: origin,
|
|
268
|
+
}), { status: 302, headers: NO_STORE });
|
|
269
|
+
if (params.response_type !== "code") {
|
|
270
|
+
return fail("unsupported_response_type", "Only the code response type is supported.");
|
|
271
|
+
}
|
|
272
|
+
if (params.code_challenge === undefined || params.code_challenge_method !== "S256") {
|
|
273
|
+
return fail("invalid_request", "PKCE with the S256 method is required.");
|
|
274
|
+
}
|
|
275
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(params.code_challenge)) {
|
|
276
|
+
return fail("invalid_request", "code_challenge is not an S256 challenge.");
|
|
277
|
+
}
|
|
278
|
+
if (state !== null && state.length > MAX_STATE_LENGTH) {
|
|
279
|
+
return fail("invalid_request", "state is too long.");
|
|
280
|
+
}
|
|
281
|
+
const resource = params.resource ?? mcpResource(origin);
|
|
282
|
+
if (resource !== mcpResource(origin)) {
|
|
283
|
+
return fail("invalid_target", `This server issues tokens for ${mcpResource(origin)} only.`);
|
|
284
|
+
}
|
|
285
|
+
const id = newSecret();
|
|
286
|
+
yield* store.saveRequest({
|
|
287
|
+
id,
|
|
288
|
+
clientId: client.id,
|
|
289
|
+
redirectUri,
|
|
290
|
+
state,
|
|
291
|
+
codeChallenge: params.code_challenge,
|
|
292
|
+
scope: grantedScope(params.scope),
|
|
293
|
+
resource,
|
|
294
|
+
expiresAt: new Date(Date.now() + REQUEST_TTL_MS),
|
|
295
|
+
});
|
|
296
|
+
return HttpServerResponse.redirect(`${origin}${CONSENT_PATH}?request=${id}`, {
|
|
297
|
+
status: 302,
|
|
298
|
+
headers: NO_STORE,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/** The member deciding a request: the requests endpoints answer members only. */
|
|
303
|
+
function requireMember() {
|
|
304
|
+
return Effect.gen(function* () {
|
|
305
|
+
const principal = yield* CurrentPrincipal;
|
|
306
|
+
return principal.kind === "member" ? principal : undefined;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function notMember(requestId) {
|
|
310
|
+
return HttpServerResponse.jsonUnsafe(errorBodyForWire({
|
|
311
|
+
error: "forbidden",
|
|
312
|
+
detail: "Only a signed-in member can authorize an agent.",
|
|
313
|
+
requestId,
|
|
314
|
+
}), { status: 403 });
|
|
315
|
+
}
|
|
316
|
+
function requestNotFound(requestId) {
|
|
317
|
+
return HttpServerResponse.jsonUnsafe(errorBodyForWire({
|
|
318
|
+
error: "not_found",
|
|
319
|
+
detail: "The authorization request expired or was already decided. Connect the agent again.",
|
|
320
|
+
requestId,
|
|
321
|
+
}), { status: 404 });
|
|
322
|
+
}
|
|
323
|
+
function routeId() {
|
|
324
|
+
return Effect.map(HttpRouter.params, (params) => params.id ?? "");
|
|
325
|
+
}
|
|
326
|
+
/** `GET /api/ontology/oauth/requests/:id`: what the consent page shows the member. */
|
|
327
|
+
function describeRequest(store) {
|
|
328
|
+
return Effect.gen(function* () {
|
|
329
|
+
const requestId = yield* RequestId;
|
|
330
|
+
const member = yield* requireMember();
|
|
331
|
+
if (member === undefined) {
|
|
332
|
+
return notMember(requestId);
|
|
333
|
+
}
|
|
334
|
+
const pending = yield* store.findRequest(yield* routeId());
|
|
335
|
+
const client = pending === undefined ? undefined : yield* store.findClient(pending.clientId);
|
|
336
|
+
if (pending === undefined || client === undefined) {
|
|
337
|
+
return requestNotFound(requestId);
|
|
338
|
+
}
|
|
339
|
+
return HttpServerResponse.jsonUnsafe({
|
|
340
|
+
id: pending.id,
|
|
341
|
+
client: {
|
|
342
|
+
name: client.name,
|
|
343
|
+
uri: client.clientUri,
|
|
344
|
+
redirect_uri: pending.redirectUri,
|
|
345
|
+
redirect_host: new URL(pending.redirectUri).host || pending.redirectUri,
|
|
346
|
+
},
|
|
347
|
+
scope: pending.scope,
|
|
348
|
+
permissions: member.permissions,
|
|
349
|
+
expires_at: pending.expiresAt.toISOString(),
|
|
350
|
+
}, { headers: NO_STORE });
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `POST /api/ontology/oauth/requests/:id/approve` and `/deny`: the member's decision, once. The
|
|
355
|
+
* answer is where the app sends the browser: the client's redirect URI with a code, or with
|
|
356
|
+
* `access_denied`. A member may narrow the scope to reading only, never widen it.
|
|
357
|
+
*/
|
|
358
|
+
function decideRequest(store, decision, request) {
|
|
359
|
+
return Effect.gen(function* () {
|
|
360
|
+
const requestId = yield* RequestId;
|
|
361
|
+
const member = yield* requireMember();
|
|
362
|
+
if (member === undefined) {
|
|
363
|
+
return notMember(requestId);
|
|
364
|
+
}
|
|
365
|
+
const body = decision === "approve" ? yield* jsonBody(request) : undefined;
|
|
366
|
+
const pending = yield* store.takeRequest(yield* routeId());
|
|
367
|
+
if (pending === undefined) {
|
|
368
|
+
return requestNotFound(requestId);
|
|
369
|
+
}
|
|
370
|
+
// The issuer the client discovered: the origin of the MCP server it asked for. The app calls this
|
|
371
|
+
// endpoint itself, possibly on another origin in development.
|
|
372
|
+
const origin = new URL(pending.resource).origin;
|
|
373
|
+
if (decision === "deny") {
|
|
374
|
+
return HttpServerResponse.jsonUnsafe({
|
|
375
|
+
redirect: redirectWith(pending.redirectUri, {
|
|
376
|
+
error: "access_denied",
|
|
377
|
+
error_description: "The member did not authorize the client.",
|
|
378
|
+
state: pending.state,
|
|
379
|
+
iss: origin,
|
|
380
|
+
}),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
const narrowed = typeof body === "object" &&
|
|
384
|
+
body !== null &&
|
|
385
|
+
body.scope === READ_SCOPE;
|
|
386
|
+
const grant = {
|
|
387
|
+
id: randomUUID(),
|
|
388
|
+
clientId: pending.clientId,
|
|
389
|
+
userId: member.id,
|
|
390
|
+
scope: narrowed ? READ_SCOPE : pending.scope,
|
|
391
|
+
resource: pending.resource,
|
|
392
|
+
};
|
|
393
|
+
const code = newSecret(CODE_PREFIX);
|
|
394
|
+
yield* store.saveGrant(grant, {
|
|
395
|
+
hash: secretHash(code),
|
|
396
|
+
grantId: grant.id,
|
|
397
|
+
kind: "code",
|
|
398
|
+
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
|
399
|
+
redirectUri: pending.redirectUri,
|
|
400
|
+
codeChallenge: pending.codeChallenge,
|
|
401
|
+
});
|
|
402
|
+
return HttpServerResponse.jsonUnsafe({
|
|
403
|
+
redirect: redirectWith(pending.redirectUri, { code, state: pending.state, iss: origin }),
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/** The client a token or revocation request authenticates as (RFC 6749 §2.3). */
|
|
408
|
+
function authenticateClient(store, request, params) {
|
|
409
|
+
return Effect.gen(function* () {
|
|
410
|
+
const authorization = request.headers.authorization ?? "";
|
|
411
|
+
let clientId = params.client_id;
|
|
412
|
+
let secret = params.client_secret;
|
|
413
|
+
let method = secret === undefined ? "none" : "client_secret_post";
|
|
414
|
+
if (authorization.toLowerCase().startsWith("basic ")) {
|
|
415
|
+
const decoded = Buffer.from(authorization.slice(6).trim(), "base64").toString("utf8");
|
|
416
|
+
const separator = decoded.indexOf(":");
|
|
417
|
+
if (separator > 0) {
|
|
418
|
+
clientId = decodeURIComponent(decoded.slice(0, separator));
|
|
419
|
+
secret = decodeURIComponent(decoded.slice(separator + 1));
|
|
420
|
+
method = "client_secret_basic";
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const client = clientId === undefined ? undefined : yield* store.findClient(clientId);
|
|
424
|
+
if (client === undefined) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
if (client.authMethod === "none") {
|
|
428
|
+
return client;
|
|
429
|
+
}
|
|
430
|
+
return method !== "none" &&
|
|
431
|
+
secret !== undefined &&
|
|
432
|
+
client.secretHash !== null &&
|
|
433
|
+
sameSecret(secret, client.secretHash)
|
|
434
|
+
? client
|
|
435
|
+
: undefined;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function issueTokens(store, grant) {
|
|
439
|
+
return Effect.gen(function* () {
|
|
440
|
+
const accessToken = newSecret(ACCESS_TOKEN_PREFIX);
|
|
441
|
+
const refreshToken = newSecret(REFRESH_TOKEN_PREFIX);
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
yield* store.saveTokens([
|
|
444
|
+
{
|
|
445
|
+
hash: secretHash(accessToken),
|
|
446
|
+
grantId: grant.id,
|
|
447
|
+
kind: "access",
|
|
448
|
+
expiresAt: new Date(now + ACCESS_TOKEN_TTL_MS),
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
hash: secretHash(refreshToken),
|
|
452
|
+
grantId: grant.id,
|
|
453
|
+
kind: "refresh",
|
|
454
|
+
expiresAt: new Date(now + REFRESH_TOKEN_TTL_MS),
|
|
455
|
+
},
|
|
456
|
+
]);
|
|
457
|
+
return json({
|
|
458
|
+
access_token: accessToken,
|
|
459
|
+
token_type: "Bearer",
|
|
460
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
|
|
461
|
+
refresh_token: refreshToken,
|
|
462
|
+
scope: grant.scope,
|
|
463
|
+
}, 200, NO_STORE);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* `POST /api/ontology/oauth/token`: a code with its PKCE verifier, or a refresh token, for an access
|
|
468
|
+
* token and a new refresh token. A code or refresh token presented twice revokes its grant: one of
|
|
469
|
+
* the two presenters stole it. A member who may no longer sign in gets nothing.
|
|
470
|
+
*/
|
|
471
|
+
function token(store) {
|
|
472
|
+
return (request) => Effect.gen(function* () {
|
|
473
|
+
const params = yield* bodyParams(request);
|
|
474
|
+
const client = yield* authenticateClient(store, request, params);
|
|
475
|
+
if (client === undefined) {
|
|
476
|
+
return oauthError("invalid_client", "The client is unknown or its secret is wrong.", 401);
|
|
477
|
+
}
|
|
478
|
+
const kind = params.grant_type === "authorization_code"
|
|
479
|
+
? "code"
|
|
480
|
+
: params.grant_type === "refresh_token"
|
|
481
|
+
? "refresh"
|
|
482
|
+
: undefined;
|
|
483
|
+
if (kind === undefined) {
|
|
484
|
+
return oauthError("unsupported_grant_type", "This server grants authorization_code and refresh_token.");
|
|
485
|
+
}
|
|
486
|
+
const presented = kind === "code" ? params.code : params.refresh_token;
|
|
487
|
+
if (presented === undefined) {
|
|
488
|
+
return oauthError("invalid_request", kind === "code" ? "code is required." : "refresh_token is required.");
|
|
489
|
+
}
|
|
490
|
+
const used = yield* store.useToken(secretHash(presented), kind);
|
|
491
|
+
if (used.status === "reused") {
|
|
492
|
+
yield* store.revokeGrant(used.grant.id);
|
|
493
|
+
yield* Effect.logWarning("ontology.oauth.token_reused").pipe(Effect.annotateLogs({ grantId: used.grant.id, kind }));
|
|
494
|
+
}
|
|
495
|
+
if (used.status !== "valid" || used.grant.clientId !== client.id) {
|
|
496
|
+
return oauthError("invalid_grant", "The grant is invalid, expired or revoked.");
|
|
497
|
+
}
|
|
498
|
+
const { grant, token: stored } = used;
|
|
499
|
+
if (kind === "code") {
|
|
500
|
+
if (params.redirect_uri !== undefined && params.redirect_uri !== stored.redirectUri) {
|
|
501
|
+
return oauthError("invalid_grant", "redirect_uri does not match the authorization.");
|
|
502
|
+
}
|
|
503
|
+
if (params.code_verifier === undefined ||
|
|
504
|
+
stored.codeChallenge === undefined ||
|
|
505
|
+
!verifierMatches(params.code_verifier, stored.codeChallenge)) {
|
|
506
|
+
return oauthError("invalid_grant", "code_verifier does not match the code challenge.");
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (params.resource !== undefined && params.resource !== grant.resource) {
|
|
510
|
+
return oauthError("invalid_target", `The grant is for ${grant.resource} only.`);
|
|
511
|
+
}
|
|
512
|
+
if ((yield* store.memberPrincipal(grant.userId)) === undefined) {
|
|
513
|
+
yield* store.revokeGrant(grant.id);
|
|
514
|
+
return oauthError("invalid_grant", "The member can no longer use this app.");
|
|
515
|
+
}
|
|
516
|
+
return yield* issueTokens(store, grant);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** `POST /api/ontology/oauth/revoke` (RFC 7009): revokes the grant of a token of the client's. */
|
|
520
|
+
function revoke(store) {
|
|
521
|
+
return (request) => Effect.gen(function* () {
|
|
522
|
+
const params = yield* bodyParams(request);
|
|
523
|
+
const client = yield* authenticateClient(store, request, params);
|
|
524
|
+
if (client === undefined) {
|
|
525
|
+
return oauthError("invalid_client", "The client is unknown or its secret is wrong.", 401);
|
|
526
|
+
}
|
|
527
|
+
if (params.token !== undefined) {
|
|
528
|
+
const grant = yield* store.findGrantOfToken(secretHash(params.token));
|
|
529
|
+
if (grant !== undefined && grant.clientId === client.id) {
|
|
530
|
+
yield* store.revokeGrant(grant.id);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Answered alike whether the token was known, so that revocation reveals nothing.
|
|
534
|
+
return json({}, 200, NO_STORE);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const PREFLIGHT = HttpServerResponse.empty({ status: 204, headers: CORS });
|
|
538
|
+
/**
|
|
539
|
+
* The MCP server's OAuth 2.1 authorization server, on the app's own origin: the protected resource
|
|
540
|
+
* and authorization server metadata, dynamic client registration, the authorization endpoint that
|
|
541
|
+
* hands the member to the app's consent page, the endpoints the consent page decides with, and the
|
|
542
|
+
* token and revocation endpoints. Without the OAuth tables, it serves nothing.
|
|
543
|
+
*/
|
|
544
|
+
export function oauthLayer(options) {
|
|
545
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
546
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
547
|
+
const backend = yield* OntologyBackend;
|
|
548
|
+
const store = backend.oauth;
|
|
549
|
+
if (store === undefined) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const limiter = fixedWindowLimiter(REGISTRATION_LIMIT);
|
|
553
|
+
const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
|
|
554
|
+
const asMember = (answer) => (request) => Effect.gen(function* () {
|
|
555
|
+
const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
|
|
556
|
+
return yield* withPrincipal(bearer, options.verifyToken, backend, answer(request));
|
|
557
|
+
});
|
|
558
|
+
const metadata = (document) => (request) => Effect.succeed(json(document(publicOrigin(request))));
|
|
559
|
+
for (const path of [PROTECTED_RESOURCE_PATH, `${PROTECTED_RESOURCE_PATH}/mcp`]) {
|
|
560
|
+
yield* router.add("GET", path, metadata(protectedResourceMetadata));
|
|
561
|
+
yield* router.add("OPTIONS", path, PREFLIGHT);
|
|
562
|
+
}
|
|
563
|
+
yield* router.add("GET", AUTHORIZATION_SERVER_PATH, metadata(authorizationServerMetadata));
|
|
564
|
+
yield* router.add("OPTIONS", AUTHORIZATION_SERVER_PATH, PREFLIGHT);
|
|
565
|
+
yield* router.add("POST", REGISTER_PATH, register(store, limiter));
|
|
566
|
+
yield* router.add("OPTIONS", REGISTER_PATH, PREFLIGHT);
|
|
567
|
+
yield* router.add("GET", AUTHORIZE_PATH, authorize(store));
|
|
568
|
+
yield* router.add("POST", TOKEN_PATH, token(store));
|
|
569
|
+
yield* router.add("OPTIONS", TOKEN_PATH, PREFLIGHT);
|
|
570
|
+
yield* router.add("POST", REVOKE_PATH, revoke(store));
|
|
571
|
+
yield* router.add("OPTIONS", REVOKE_PATH, PREFLIGHT);
|
|
572
|
+
yield* router.add("GET", `${REQUESTS_PATH}/:id`, asMember(() => describeRequest(store)));
|
|
573
|
+
yield* router.add("POST", `${REQUESTS_PATH}/:id/approve`, asMember((request) => decideRequest(store, "approve", request)));
|
|
574
|
+
yield* router.add("POST", `${REQUESTS_PATH}/:id/deny`, asMember((request) => decideRequest(store, "deny", request)));
|
|
575
|
+
}));
|
|
576
|
+
}
|