@plitzi/sdk-server 0.32.12 → 0.32.14
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/CHANGELOG.md +20 -0
- package/dist/core/services/oauth.js +46 -1
- package/dist/core/services/registry.js +2 -1
- package/dist/modules/mcp/server.js +1 -1
- package/dist/modules/oauth/authorize.js +20 -4
- package/dist/modules/oauth/challenge.js +33 -0
- package/dist/modules/oauth/consentPage.js +10 -3
- package/dist/modules/oauth/metadata.js +19 -6
- package/dist/modules/oauth/records.js +3 -1
- package/dist/modules/oauth/token.js +11 -3
- package/dist/src/core/services/oauth.d.ts +10 -0
- package/dist/src/modules/mcp/e2e/oauthConnector.test.d.ts +1 -0
- package/dist/src/modules/oauth/challenge.d.ts +11 -0
- package/dist/src/modules/oauth/metadata.d.ts +12 -2
- package/dist/src/modules/oauth/records.d.ts +16 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @plitzi/sdk-server
|
|
2
2
|
|
|
3
|
+
## 0.32.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- v0.32.14
|
|
8
|
+
- Updated dependencies
|
|
9
|
+
- @plitzi/plitzi-sdk@0.32.14
|
|
10
|
+
- @plitzi/sdk-schema@0.32.14
|
|
11
|
+
- @plitzi/sdk-shared@0.32.14
|
|
12
|
+
|
|
13
|
+
## 0.32.13
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- v0.32.13
|
|
18
|
+
- Updated dependencies
|
|
19
|
+
- @plitzi/plitzi-sdk@0.32.13
|
|
20
|
+
- @plitzi/sdk-schema@0.32.13
|
|
21
|
+
- @plitzi/sdk-shared@0.32.13
|
|
22
|
+
|
|
3
23
|
## 0.32.12
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readRawBody } from "../requestParser.js";
|
|
2
2
|
import { authorizationServerMetadata, protectedResourceMetadata } from "../../modules/oauth/metadata.js";
|
|
3
|
+
import { getAccess } from "../../modules/oauth/records.js";
|
|
3
4
|
import { sendErrorJson, sendJson } from "../../modules/oauth/respond.js";
|
|
4
5
|
import { handleAuthorizeStart, handleAuthorizeSubmit } from "../../modules/oauth/authorize.js";
|
|
6
|
+
import { bearerOf, sendChallenge } from "../../modules/oauth/challenge.js";
|
|
5
7
|
import { handleRegister } from "../../modules/oauth/register.js";
|
|
6
8
|
import { handleToken } from "../../modules/oauth/token.js";
|
|
7
9
|
//#region src/core/services/oauth.ts
|
|
@@ -67,5 +69,48 @@ var oauthStage = async (ctx) => {
|
|
|
67
69
|
sendErrorJson(res, 405, "invalid_request", `${method} is not allowed on ${path}.`);
|
|
68
70
|
return true;
|
|
69
71
|
};
|
|
72
|
+
var CHALLENGE_DESCRIPTIONS = {
|
|
73
|
+
"no-credential": "Authorization is required to use this server.",
|
|
74
|
+
"unknown-credential": "The access token is invalid, expired or revoked.",
|
|
75
|
+
"store-unreachable": "The access token could not be verified right now.",
|
|
76
|
+
"adapter-failed": "The access token could not be verified right now."
|
|
77
|
+
};
|
|
78
|
+
var challengeReason = async (oauth, ctx, token) => {
|
|
79
|
+
if (!token) return "no-credential";
|
|
80
|
+
try {
|
|
81
|
+
const record = await getAccess(oauth.adapters.store, token);
|
|
82
|
+
if (record) {
|
|
83
|
+
const credential = record.credential ?? token;
|
|
84
|
+
ctx.req.headers["x-access-token"] = credential;
|
|
85
|
+
ctx.req.headers.authorization = `Bearer ${credential}`;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
return "store-unreachable";
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return await ctx.config.adapters.getSpaceId?.(ctx.req) === void 0 ? "unknown-credential" : void 0;
|
|
93
|
+
} catch {
|
|
94
|
+
return "adapter-failed";
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/** The protected-resource half of OAuth: an MCP call that presents no bearer this server can verify is refused
|
|
98
|
+
* with RFC 6750's challenge instead of being served the anonymous surface. That 401 is the whole handshake — it
|
|
99
|
+
* is how a host learns the server needs authorization, where its metadata lives and which scopes to ask for, and
|
|
100
|
+
* a 200 tells it none of that. Only the JSON-RPC POST is guarded: the CORS preflight, the GET 405 and the
|
|
101
|
+
* discovery probes carry no credential and must keep answering as they do.
|
|
102
|
+
*
|
|
103
|
+
* Mounted only when `oauth` is configured. A deployment that configures none keeps the open server it had, where
|
|
104
|
+
* the whole public surface — handshake, listings, the guide, plitzi_render — answers without a token; with OAuth
|
|
105
|
+
* on, the grant that carries no space is what covers that same ground. */
|
|
106
|
+
var oauthGuardStage = async (ctx) => {
|
|
107
|
+
const { oauth } = ctx.config;
|
|
108
|
+
if (!oauth || ctx.req.method !== "POST") return false;
|
|
109
|
+
const reason = await challengeReason(oauth, ctx, bearerOf(ctx.req));
|
|
110
|
+
if (!reason) return false;
|
|
111
|
+
ctx.operation = `oauth-challenge:${reason}`;
|
|
112
|
+
sendChallenge(oauth, ctx.req, ctx.res, CHALLENGE_DESCRIPTIONS[reason]);
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
70
115
|
//#endregion
|
|
71
|
-
export { oauthStage };
|
|
116
|
+
export { oauthGuardStage, oauthStage };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mcpOnlyStage, mcpStage } from "./mcp.js";
|
|
2
|
-
import { oauthStage } from "./oauth.js";
|
|
2
|
+
import { oauthGuardStage, oauthStage } from "./oauth.js";
|
|
3
3
|
import { previewStage } from "./preview.js";
|
|
4
4
|
import { rscStage } from "./rsc.js";
|
|
5
5
|
import { notFoundStage, ssrStage } from "./ssr.js";
|
|
@@ -30,6 +30,7 @@ var buildMCPPipeline = () => [
|
|
|
30
30
|
healthStage,
|
|
31
31
|
configStaticStage,
|
|
32
32
|
oauthStage,
|
|
33
|
+
oauthGuardStage,
|
|
33
34
|
mcpOnlyStage
|
|
34
35
|
];
|
|
35
36
|
//#endregion
|
|
@@ -43,7 +43,7 @@ var createMcpServer = ({ adapters, getSpaceId, preview, screenshot, logger }) =>
|
|
|
43
43
|
const getSpace = () => spacePromise ??= loadSpace();
|
|
44
44
|
const server = new McpServer({
|
|
45
45
|
name: "plitzi-mcp",
|
|
46
|
-
version: "0.32.
|
|
46
|
+
version: "0.32.14"
|
|
47
47
|
}, { instructions: serverInstructions });
|
|
48
48
|
registerResources(server, getSpace, MCP_ENV, log);
|
|
49
49
|
registerApps(server);
|
|
@@ -6,6 +6,15 @@ import { dropPending, getClient, getPending, putCode, putPending } from "./recor
|
|
|
6
6
|
import { redirectWithCode, redirectWithError, sendErrorPage, sendHtml } from "./respond.js";
|
|
7
7
|
//#region src/modules/oauth/authorize.ts
|
|
8
8
|
var DEFAULT_CODE_TTL_SECONDS = 60;
|
|
9
|
+
var DEFAULT_GUEST_LABEL = "Continue without an account";
|
|
10
|
+
var DEFAULT_GUEST_USER = {
|
|
11
|
+
id: "guest",
|
|
12
|
+
label: "Guest"
|
|
13
|
+
};
|
|
14
|
+
var guestView = (guest) => ({
|
|
15
|
+
label: guest.label ?? DEFAULT_GUEST_LABEL,
|
|
16
|
+
description: guest.target.description
|
|
17
|
+
});
|
|
9
18
|
var hiddenFieldsFor = (request, pendingId) => {
|
|
10
19
|
const hidden = {
|
|
11
20
|
client_id: request.clientId,
|
|
@@ -57,8 +66,8 @@ var resolveRequest = async (config, res, params) => {
|
|
|
57
66
|
};
|
|
58
67
|
/** Consent granted: mint the bearer now, park it behind a one-shot code and send the browser back. Minting here
|
|
59
68
|
* rather than at redemption keeps a failure the user can act on — no space, revoked access — on this screen. */
|
|
60
|
-
var completeGrant = async (config, res, request,
|
|
61
|
-
const issued = await config.adapters.issueToken(
|
|
69
|
+
var completeGrant = async (config, res, request, user, target) => {
|
|
70
|
+
const issued = await config.adapters.issueToken(user, target);
|
|
62
71
|
if (!issued) {
|
|
63
72
|
redirectWithError(res, request.redirectUri, "access_denied", "The account may not grant access to this resource.", request.state);
|
|
64
73
|
return;
|
|
@@ -71,7 +80,7 @@ var completeGrant = async (config, res, request, pending, target) => {
|
|
|
71
80
|
token: issued.token,
|
|
72
81
|
expiresInSeconds: issued.expiresInSeconds,
|
|
73
82
|
scope: request.scope,
|
|
74
|
-
user
|
|
83
|
+
user,
|
|
75
84
|
target
|
|
76
85
|
}, config.codeTtlSeconds ?? DEFAULT_CODE_TTL_SECONDS);
|
|
77
86
|
redirectWithCode(res, request.redirectUri, code, request.state);
|
|
@@ -85,6 +94,7 @@ var handleAuthorizeStart = async (config, res, params) => {
|
|
|
85
94
|
action: AUTHORIZE_PATH,
|
|
86
95
|
hidden: hiddenFieldsFor(request),
|
|
87
96
|
targets: [],
|
|
97
|
+
guest: config.guest ? guestView(config.guest) : void 0,
|
|
88
98
|
branding: config.branding ?? {}
|
|
89
99
|
});
|
|
90
100
|
};
|
|
@@ -115,7 +125,12 @@ var handleAuthorizeSubmit = async (config, res, params) => {
|
|
|
115
125
|
return;
|
|
116
126
|
}
|
|
117
127
|
await dropPending(config.adapters.store, pendingId);
|
|
118
|
-
await completeGrant(config, res, request, pending, chosen);
|
|
128
|
+
await completeGrant(config, res, request, pending.user, chosen);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const { guest } = config;
|
|
132
|
+
if (guest && optionalField(params, "guest")) {
|
|
133
|
+
await completeGrant(config, res, request, guest.user ?? DEFAULT_GUEST_USER, guest.target);
|
|
119
134
|
return;
|
|
120
135
|
}
|
|
121
136
|
const user = await config.adapters.authenticate({
|
|
@@ -128,6 +143,7 @@ var handleAuthorizeSubmit = async (config, res, params) => {
|
|
|
128
143
|
action: AUTHORIZE_PATH,
|
|
129
144
|
hidden: hiddenFieldsFor(request),
|
|
130
145
|
targets: [],
|
|
146
|
+
guest: guest ? guestView(guest) : void 0,
|
|
131
147
|
error: "Those credentials did not match an account.",
|
|
132
148
|
branding: config.branding ?? {}
|
|
133
149
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resourceMetadataUrl, scopesOf } from "./metadata.js";
|
|
2
|
+
import { sendJson } from "./respond.js";
|
|
3
|
+
//#region src/modules/oauth/challenge.ts
|
|
4
|
+
/** The credential on an MCP request. `Authorization: Bearer` is what RFC 6750 defines and what a remote host
|
|
5
|
+
* sends; `x-access-token` is the platform's own header, which the builder and the CLI already use — a request
|
|
6
|
+
* carrying either presents a credential, and the same verification decides whether it is a good one. */
|
|
7
|
+
var bearerOf = (req) => {
|
|
8
|
+
const header = req.headers["x-access-token"] ?? req.headers.authorization ?? "";
|
|
9
|
+
return (Array.isArray(header) ? header[0] ?? "" : header).replace(/^Bearer\s+/i, "").trim();
|
|
10
|
+
};
|
|
11
|
+
var parameter = (name, value) => `${name}="${value.replace(/"/gu, "")}"`;
|
|
12
|
+
/** RFC 6750 §3 — the answer to an MCP request that presents no usable credential, and the only thing that starts
|
|
13
|
+
* an authorization flow: a host runs OAuth off a 401 whose `WWW-Authenticate` names the resource metadata, and
|
|
14
|
+
* IGNORES the header on a 200. Answering such a request with the anonymous surface instead is what leaves a
|
|
15
|
+
* connector unable to attach the grant it just completed — the flow succeeds and the host still reports that
|
|
16
|
+
* authorization failed. `scope` states what to ask for, so consent is not widened to everything advertised. */
|
|
17
|
+
var sendChallenge = (config, req, res, description) => {
|
|
18
|
+
const params = [
|
|
19
|
+
parameter("error", "invalid_token"),
|
|
20
|
+
parameter("error_description", description),
|
|
21
|
+
parameter("resource_metadata", resourceMetadataUrl(config, req)),
|
|
22
|
+
parameter("scope", scopesOf(config).join(" "))
|
|
23
|
+
];
|
|
24
|
+
res.setHeader("WWW-Authenticate", `Bearer ${params.join(", ")}`);
|
|
25
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
26
|
+
res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate");
|
|
27
|
+
sendJson(res, 401, {
|
|
28
|
+
error: "invalid_token",
|
|
29
|
+
error_description: description
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { bearerOf, sendChallenge };
|
|
@@ -35,16 +35,23 @@ var STYLES = `
|
|
|
35
35
|
ul.targets span { color: var(--muted); font-size: 13px; }
|
|
36
36
|
button { width: 100%; padding: 11px 16px; border: 0; border-radius: 8px; background: var(--accent);
|
|
37
37
|
color: #fff; font: inherit; font-weight: 500; cursor: pointer; }
|
|
38
|
+
button.guest { margin-top: 10px; background: transparent; color: var(--accent);
|
|
39
|
+
border: 1px solid var(--line); }
|
|
38
40
|
p.error { margin: 0 0 16px; padding: 10px 12px; border-radius: 8px; color: var(--danger);
|
|
39
41
|
border: 1px solid currentColor; font-size: 14px; }
|
|
42
|
+
p.note { margin: 10px 0 0; color: var(--muted); font-size: 13px; text-align: center; }
|
|
40
43
|
`;
|
|
41
44
|
var hiddenFields = (hidden) => Object.entries(hidden).map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("\n ");
|
|
42
|
-
var
|
|
45
|
+
var guestButton = (guest) => {
|
|
46
|
+
const note = guest.description ? `\n <p class="note">${escapeHtml(guest.description)}</p>` : "";
|
|
47
|
+
return `\n <button class="guest" type="submit" name="guest" value="1" formnovalidate>${escapeHtml(guest.label)}</button>${note}`;
|
|
48
|
+
};
|
|
49
|
+
var credentialsFields = (view) => `<label for="username">Email or username</label>
|
|
43
50
|
<input id="username" name="username" type="text" autocomplete="username" autocapitalize="none"
|
|
44
51
|
spellcheck="false" required autofocus>
|
|
45
52
|
<label for="password">Password</label>
|
|
46
53
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
47
|
-
<button type="submit">Sign in</button
|
|
54
|
+
<button type="submit">Sign in</button>${view.guest ? guestButton(view.guest) : ""}`;
|
|
48
55
|
var targetFields = (view) => {
|
|
49
56
|
return `<ul class="targets">
|
|
50
57
|
${view.targets.map((target, index) => {
|
|
@@ -82,7 +89,7 @@ var renderConsentPage = (view) => {
|
|
|
82
89
|
${error}
|
|
83
90
|
<form method="post" action="${escapeHtml(view.action)}">
|
|
84
91
|
${hiddenFields(view.hidden)}
|
|
85
|
-
${view.step === "credentials" ? credentialsFields() : targetFields(view)}
|
|
92
|
+
${view.step === "credentials" ? credentialsFields(view) : targetFields(view)}
|
|
86
93
|
</form>
|
|
87
94
|
</main>
|
|
88
95
|
</body>
|
|
@@ -11,22 +11,35 @@ var DEFAULT_SCOPES = ["plitzi"];
|
|
|
11
11
|
* deployment correct across its dev, staging and production hosts without per-environment config. */
|
|
12
12
|
var issuerOf = (config, req) => config.issuer ?? requestOrigin(req);
|
|
13
13
|
var scopesOf = (config) => config.scopes ?? DEFAULT_SCOPES;
|
|
14
|
+
var canonicalPath = (path) => path.replace(/\/+$/, "");
|
|
15
|
+
/** Where the challenge points a host, for an MCP endpoint served at `req.path`. RFC 9728 §3.1 appends the
|
|
16
|
+
* resource's own path to the well-known path, and a host that reads the document back expects its `resource` to
|
|
17
|
+
* name the URL it was configured with — so a server mounted at /mcp must be pointed at the suffixed document,
|
|
18
|
+
* not the bare one. */
|
|
19
|
+
var resourceMetadataUrl = (config, req) => `${issuerOf(config, req)}${PROTECTED_RESOURCE_PATH}${canonicalPath(req.path)}`;
|
|
14
20
|
/** RFC 9728. The document Claude Desktop asks for FIRST, and the one whose absence ends the flow before anything
|
|
15
|
-
* else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
|
|
21
|
+
* else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
|
|
22
|
+
*
|
|
23
|
+
* `resource` echoes the path the document was asked for: Claude requires it to match the server URL the user
|
|
24
|
+
* typed, path included, and a dedicated MCP server answers JSON-RPC on every path — so `/.well-known/…/mcp`
|
|
25
|
+
* describes `https://host/mcp` while the bare path describes the origin. */
|
|
16
26
|
var protectedResourceMetadata = (config, req) => {
|
|
17
27
|
const issuer = issuerOf(config, req);
|
|
18
28
|
return {
|
|
19
|
-
resource: issuer
|
|
29
|
+
resource: `${issuer}${canonicalPath(req.path.slice(37))}`,
|
|
20
30
|
authorization_servers: [issuer],
|
|
21
31
|
scopes_supported: scopesOf(config),
|
|
22
32
|
bearer_methods_supported: ["header"]
|
|
23
33
|
};
|
|
24
34
|
};
|
|
25
35
|
/** RFC 8414. Public clients with PKCE only: a desktop host stores no secret, so `none` is the sole endpoint auth
|
|
26
|
-
* method and S256 the sole challenge method.
|
|
36
|
+
* method and S256 the sole challenge method. `offline_access` is advertised whenever refresh grants are issued,
|
|
37
|
+
* which is the signal a host looks for before asking for one. */
|
|
27
38
|
var authorizationServerMetadata = (config, req) => {
|
|
28
39
|
const issuer = issuerOf(config, req);
|
|
29
|
-
const
|
|
40
|
+
const refreshes = config.refreshTtlSeconds !== 0;
|
|
41
|
+
const grantTypes = refreshes ? ["authorization_code", "refresh_token"] : ["authorization_code"];
|
|
42
|
+
const scopes = refreshes ? [...scopesOf(config), "offline_access"] : scopesOf(config);
|
|
30
43
|
return {
|
|
31
44
|
issuer,
|
|
32
45
|
authorization_endpoint: `${issuer}${AUTHORIZE_PATH}`,
|
|
@@ -36,8 +49,8 @@ var authorizationServerMetadata = (config, req) => {
|
|
|
36
49
|
grant_types_supported: grantTypes,
|
|
37
50
|
code_challenge_methods_supported: ["S256"],
|
|
38
51
|
token_endpoint_auth_methods_supported: ["none"],
|
|
39
|
-
scopes_supported:
|
|
52
|
+
scopes_supported: scopes
|
|
40
53
|
};
|
|
41
54
|
};
|
|
42
55
|
//#endregion
|
|
43
|
-
export { AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, PROTECTED_RESOURCE_PATH, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, issuerOf, protectedResourceMetadata, scopesOf };
|
|
56
|
+
export { AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, PROTECTED_RESOURCE_PATH, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, issuerOf, protectedResourceMetadata, resourceMetadataUrl, scopesOf };
|
|
@@ -31,5 +31,7 @@ var getRefresh = (store, token) => readJson(store, "refresh", token);
|
|
|
31
31
|
var dropRefresh = async (store, token) => {
|
|
32
32
|
await store.drop(keyOf("refresh", token));
|
|
33
33
|
};
|
|
34
|
+
var putAccess = (store, token, record, ttlSeconds) => writeJson(store, "access", token, record, ttlSeconds);
|
|
35
|
+
var getAccess = (store, token) => readJson(store, "access", token);
|
|
34
36
|
//#endregion
|
|
35
|
-
export { dropCode, dropPending, dropRefresh, getClient, getCode, getPending, getRefresh, putClient, putCode, putPending, putRefresh };
|
|
37
|
+
export { dropCode, dropPending, dropRefresh, getAccess, getClient, getCode, getPending, getRefresh, putAccess, putClient, putCode, putPending, putRefresh };
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { scopesOf } from "./metadata.js";
|
|
2
2
|
import { field, optionalField } from "./params.js";
|
|
3
3
|
import { randomId, verifyChallenge } from "./pkce.js";
|
|
4
|
-
import { dropCode, dropRefresh, getCode, getRefresh, putRefresh } from "./records.js";
|
|
4
|
+
import { dropCode, dropRefresh, getCode, getRefresh, putAccess, putRefresh } from "./records.js";
|
|
5
5
|
import { sendErrorJson, sendJson } from "./respond.js";
|
|
6
6
|
//#region src/modules/oauth/token.ts
|
|
7
7
|
var DEFAULT_REFRESH_TTL_SECONDS = 3600 * 24 * 30;
|
|
8
|
+
var DEFAULT_ACCESS_TTL_SECONDS = 3600 * 24 * 30;
|
|
8
9
|
var refreshTtlOf = (config) => config.refreshTtlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS;
|
|
9
10
|
var scopeOf = (config, requested) => requested ?? scopesOf(config).join(" ");
|
|
10
11
|
/** The token response, plus a rotated refresh grant when refresh is enabled. Rotation is unconditional: a refresh
|
|
11
12
|
* token is a long-lived credential, so the one just used never stays valid. */
|
|
12
|
-
var sendTokens = async (config, res,
|
|
13
|
+
var sendTokens = async (config, res, credential, expiresInSeconds, grant) => {
|
|
13
14
|
const ttl = refreshTtlOf(config);
|
|
15
|
+
const bearer = randomId();
|
|
16
|
+
await putAccess(config.adapters.store, bearer, {
|
|
17
|
+
credential,
|
|
18
|
+
clientId: grant.clientId,
|
|
19
|
+
user: grant.user,
|
|
20
|
+
target: grant.target
|
|
21
|
+
}, Math.max(expiresInSeconds ?? DEFAULT_ACCESS_TTL_SECONDS, 1));
|
|
14
22
|
const body = {
|
|
15
|
-
access_token:
|
|
23
|
+
access_token: bearer,
|
|
16
24
|
token_type: "Bearer",
|
|
17
25
|
scope: scopeOf(config, grant.scope)
|
|
18
26
|
};
|
|
@@ -6,3 +6,13 @@ import { Stage } from '../http/types';
|
|
|
6
6
|
* It sits before the MCP stage because that one answers every path on a dedicated MCP server — these endpoints
|
|
7
7
|
* would otherwise be swallowed by the JSON-RPC transport, which is exactly the 406 a host hits on /register. */
|
|
8
8
|
export declare const oauthStage: Stage;
|
|
9
|
+
/** The protected-resource half of OAuth: an MCP call that presents no bearer this server can verify is refused
|
|
10
|
+
* with RFC 6750's challenge instead of being served the anonymous surface. That 401 is the whole handshake — it
|
|
11
|
+
* is how a host learns the server needs authorization, where its metadata lives and which scopes to ask for, and
|
|
12
|
+
* a 200 tells it none of that. Only the JSON-RPC POST is guarded: the CORS preflight, the GET 405 and the
|
|
13
|
+
* discovery probes carry no credential and must keep answering as they do.
|
|
14
|
+
*
|
|
15
|
+
* Mounted only when `oauth` is configured. A deployment that configures none keeps the open server it had, where
|
|
16
|
+
* the whole public surface — handshake, listings, the guide, plitzi_render — answers without a token; with OAuth
|
|
17
|
+
* on, the grant that carries no space is what covers that same ground. */
|
|
18
|
+
export declare const oauthGuardStage: Stage;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { OAuthConfig, SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
|
|
2
|
+
/** The credential on an MCP request. `Authorization: Bearer` is what RFC 6750 defines and what a remote host
|
|
3
|
+
* sends; `x-access-token` is the platform's own header, which the builder and the CLI already use — a request
|
|
4
|
+
* carrying either presents a credential, and the same verification decides whether it is a good one. */
|
|
5
|
+
export declare const bearerOf: (req: SSRRequest) => string;
|
|
6
|
+
/** RFC 6750 §3 — the answer to an MCP request that presents no usable credential, and the only thing that starts
|
|
7
|
+
* an authorization flow: a host runs OAuth off a 401 whose `WWW-Authenticate` names the resource metadata, and
|
|
8
|
+
* IGNORES the header on a 200. Answering such a request with the anonymous surface instead is what leaves a
|
|
9
|
+
* connector unable to attach the grant it just completed — the flow succeeds and the host still reports that
|
|
10
|
+
* authorization failed. `scope` states what to ask for, so consent is not widened to everything advertised. */
|
|
11
|
+
export declare const sendChallenge: (config: OAuthConfig, req: SSRRequest, res: SSRResponseHelpers, description: string) => void;
|
|
@@ -9,9 +9,19 @@ export declare const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorizati
|
|
|
9
9
|
* deployment correct across its dev, staging and production hosts without per-environment config. */
|
|
10
10
|
export declare const issuerOf: (config: OAuthConfig, req: SSRRequest) => string;
|
|
11
11
|
export declare const scopesOf: (config: OAuthConfig) => string[];
|
|
12
|
+
/** Where the challenge points a host, for an MCP endpoint served at `req.path`. RFC 9728 §3.1 appends the
|
|
13
|
+
* resource's own path to the well-known path, and a host that reads the document back expects its `resource` to
|
|
14
|
+
* name the URL it was configured with — so a server mounted at /mcp must be pointed at the suffixed document,
|
|
15
|
+
* not the bare one. */
|
|
16
|
+
export declare const resourceMetadataUrl: (config: OAuthConfig, req: SSRRequest) => string;
|
|
12
17
|
/** RFC 9728. The document Claude Desktop asks for FIRST, and the one whose absence ends the flow before anything
|
|
13
|
-
* else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
|
|
18
|
+
* else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
|
|
19
|
+
*
|
|
20
|
+
* `resource` echoes the path the document was asked for: Claude requires it to match the server URL the user
|
|
21
|
+
* typed, path included, and a dedicated MCP server answers JSON-RPC on every path — so `/.well-known/…/mcp`
|
|
22
|
+
* describes `https://host/mcp` while the bare path describes the origin. */
|
|
14
23
|
export declare const protectedResourceMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
|
|
15
24
|
/** RFC 8414. Public clients with PKCE only: a desktop host stores no secret, so `none` is the sole endpoint auth
|
|
16
|
-
* method and S256 the sole challenge method.
|
|
25
|
+
* method and S256 the sole challenge method. `offline_access` is advertised whenever refresh grants are issued,
|
|
26
|
+
* which is the signal a host looks for before asking for one. */
|
|
17
27
|
export declare const authorizationServerMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
|
|
@@ -36,6 +36,20 @@ export type RefreshRecord = {
|
|
|
36
36
|
user: OAuthUser;
|
|
37
37
|
target: OAuthGrantTarget;
|
|
38
38
|
};
|
|
39
|
+
/** A bearer this server handed out. The token a client holds is an opaque handle minted here, and this record is
|
|
40
|
+
* what it stands for — including `credential`, the thing the deployment's own adapters understand, which never
|
|
41
|
+
* leaves the server: a platform token is usually good against more than the MCP endpoint, and a remote host has
|
|
42
|
+
* no business holding one. The record expires with the bearer, which is what turns an expired one into the 401 a
|
|
43
|
+
* host answers by refreshing; dropping it early revokes the bearer outright. */
|
|
44
|
+
export type AccessRecord = {
|
|
45
|
+
/** What {@link OAuthAdapters.issueToken} returned — swapped back onto the request once the bearer checks out.
|
|
46
|
+
* Absent only in a record written before the bearer and the credential were separate things, where the bearer
|
|
47
|
+
* WAS the credential: a live connector must survive that upgrade, so the reader falls back to the token itself. */
|
|
48
|
+
credential?: string;
|
|
49
|
+
clientId: string;
|
|
50
|
+
user: OAuthUser;
|
|
51
|
+
target: OAuthGrantTarget;
|
|
52
|
+
};
|
|
39
53
|
export declare const putClient: (store: OAuthStore, client: ClientRecord) => Promise<void>;
|
|
40
54
|
export declare const getClient: (store: OAuthStore, clientId: string) => Promise<ClientRecord | undefined>;
|
|
41
55
|
export declare const putPending: (store: OAuthStore, id: string, pending: PendingRecord) => Promise<void>;
|
|
@@ -47,3 +61,5 @@ export declare const dropCode: (store: OAuthStore, code: string) => Promise<void
|
|
|
47
61
|
export declare const putRefresh: (store: OAuthStore, token: string, record: RefreshRecord, ttlSeconds: number) => Promise<void>;
|
|
48
62
|
export declare const getRefresh: (store: OAuthStore, token: string) => Promise<RefreshRecord | undefined>;
|
|
49
63
|
export declare const dropRefresh: (store: OAuthStore, token: string) => Promise<void>;
|
|
64
|
+
export declare const putAccess: (store: OAuthStore, token: string, record: AccessRecord, ttlSeconds: number) => Promise<void>;
|
|
65
|
+
export declare const getAccess: (store: OAuthStore, token: string) => Promise<AccessRecord | undefined>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plitzi/sdk-server",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.14",
|
|
4
4
|
"license": "AGPL-3.0",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
32
|
-
"@plitzi/plitzi-sdk": "0.32.
|
|
33
|
-
"@plitzi/sdk-schema": "0.32.
|
|
34
|
-
"@plitzi/sdk-shared": "0.32.
|
|
32
|
+
"@plitzi/plitzi-sdk": "0.32.14",
|
|
33
|
+
"@plitzi/sdk-schema": "0.32.14",
|
|
34
|
+
"@plitzi/sdk-shared": "0.32.14",
|
|
35
35
|
"ejs": "^6.0.1",
|
|
36
36
|
"esbuild": "^0.28.1",
|
|
37
37
|
"zod": "^4.4.3"
|