@plitzi/sdk-server 0.33.2 → 0.34.1
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 +54 -0
- package/README.md +283 -8
- package/dist/actions.d.ts +62 -0
- package/dist/actions.js +12 -0
- package/dist/adapters/cloudAdapters.d.ts +72 -0
- package/dist/adapters/cloudAdapters.js +303 -0
- package/dist/adapters/space/index.d.ts +93 -0
- package/dist/adapters/space/index.js +122 -0
- package/dist/adapters/space/resolvers.d.ts +69 -0
- package/dist/adapters/space/resolvers.js +79 -0
- package/dist/adapters/space/space.test.d.ts +1 -0
- package/dist/adapters/space/types.d.ts +64 -0
- package/dist/adapters/space/types.js +41 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +19 -0
- package/dist/core/auth/api.d.ts +400 -11
- package/dist/core/auth/api.js +1044 -50
- package/dist/core/auth/createAuth.d.ts +135 -30
- package/dist/core/auth/createAuth.js +96 -24
- package/dist/core/auth/credentials.d.ts +9 -3
- package/dist/core/auth/credentials.js +10 -4
- package/dist/core/auth/csrf.d.ts +89 -0
- package/dist/core/auth/csrf.js +234 -0
- package/dist/core/auth/csrf.test.d.ts +1 -0
- package/dist/core/auth/identity.d.ts +6 -0
- package/dist/core/auth/identity.js +37 -8
- package/dist/core/auth/lifecycle.test.d.ts +1 -0
- package/dist/core/auth/oauth/index.d.ts +8 -0
- package/dist/core/auth/oauth/index.js +13 -15
- package/dist/core/auth/passwords.d.ts +15 -0
- package/dist/core/auth/passwords.js +32 -0
- package/dist/core/auth/redirects.d.ts +18 -0
- package/dist/core/auth/redirects.js +31 -0
- package/dist/core/auth/redirects.test.d.ts +1 -0
- package/dist/core/auth/routes.d.ts +4 -2
- package/dist/core/auth/routes.js +234 -9
- package/dist/core/auth/session.d.ts +11 -0
- package/dist/core/auth/session.js +1 -1
- package/dist/core/auth/spaceTokens.d.ts +38 -2
- package/dist/core/auth/spaceTokens.js +54 -2
- package/dist/core/auth/throttle.d.ts +5 -0
- package/dist/core/auth/throttle.js +81 -0
- package/dist/core/auth/throttle.test.d.ts +1 -0
- package/dist/core/auth/tokens.d.ts +83 -12
- package/dist/core/auth/tokens.js +51 -9
- package/dist/core/auth/totp.d.ts +48 -0
- package/dist/core/auth/totp.js +118 -0
- package/dist/core/auth/totp.test.d.ts +1 -0
- package/dist/core/createServer.d.ts +0 -6
- package/dist/core/createServer.js +35 -3
- package/dist/core/fontStore.d.ts +33 -0
- package/dist/core/fontStore.js +53 -0
- package/dist/core/fontStore.test.d.ts +1 -0
- package/dist/core/handlers/authMiddleware.js +1 -1
- package/dist/core/handlers/authRouteHandlers.d.ts +9 -1
- package/dist/core/handlers/authRouteHandlers.js +4 -3
- package/dist/core/handlers/csrfMiddleware.d.ts +20 -0
- package/dist/core/handlers/csrfMiddleware.js +32 -0
- package/dist/core/handlers/oauthRouteHandlers.d.ts +46 -0
- package/dist/core/handlers/oauthRouteHandlers.js +159 -0
- package/dist/core/handlers/socialRouteHandlers.d.ts +40 -0
- package/dist/core/handlers/socialRouteHandlers.js +129 -0
- package/dist/core/handlers/socialRouteHandlers.test.d.ts +1 -0
- package/dist/core/handlers/types.d.ts +22 -5
- package/dist/core/health.d.ts +11 -1
- package/dist/core/health.js +27 -3
- package/dist/core/http/dispatcher.d.ts +1 -1
- package/dist/core/http/dispatcher.js +20 -1
- package/dist/core/http/stages/authApi.js +1 -1
- package/dist/core/http/stages/fontAssets.d.ts +9 -0
- package/dist/core/http/stages/fontAssets.js +27 -0
- package/dist/core/http/stages/oauth.d.ts +19 -0
- package/dist/core/http/stages/oauth.js +118 -0
- package/dist/core/http/types.d.ts +12 -0
- package/dist/core/oauth/authorize.d.ts +22 -0
- package/dist/core/oauth/authorize.js +254 -0
- package/dist/core/oauth/authorize.test.d.ts +1 -0
- package/dist/core/oauth/challenge.d.ts +11 -0
- package/dist/core/oauth/challenge.js +33 -0
- package/dist/core/oauth/consentPage.d.ts +12 -0
- package/dist/core/oauth/consentPage.js +131 -0
- package/dist/core/oauth/metadata.d.ts +41 -0
- package/dist/core/oauth/metadata.js +71 -0
- package/dist/core/oauth/params.d.ts +5 -0
- package/dist/core/oauth/params.js +5 -0
- package/dist/core/oauth/pkce.d.ts +6 -0
- package/dist/core/oauth/pkce.js +15 -0
- package/dist/core/oauth/records.d.ts +72 -0
- package/dist/core/oauth/records.js +49 -0
- package/dist/core/oauth/register.d.ts +5 -0
- package/dist/core/oauth/register.js +55 -0
- package/dist/core/oauth/respond.d.ts +21 -0
- package/dist/core/oauth/respond.js +59 -0
- package/dist/core/oauth/token.d.ts +14 -0
- package/dist/core/oauth/token.js +130 -0
- package/dist/core/previewToken.d.ts +20 -0
- package/dist/core/previewToken.js +29 -1
- package/dist/core/server/pageServer.js +4 -1
- package/dist/core/services/action.d.ts +6 -4
- package/dist/core/services/action.js +103 -10
- package/dist/core/services/registry.js +2 -0
- package/dist/core/services/resolve.d.ts +8 -0
- package/dist/core/services/resolve.js +12 -1
- package/dist/core/staticFiles.js +1 -2
- package/dist/handlers.d.ts +7 -1
- package/dist/handlers.js +4 -1
- package/dist/helpers/buildResponseHelpers.d.ts +8 -0
- package/dist/helpers/buildResponseHelpers.js +9 -1
- package/dist/helpers/buildServerInfo.d.ts +8 -3
- package/dist/helpers/buildServerInfo.js +18 -14
- package/dist/helpers/cache/keys.d.ts +13 -0
- package/dist/helpers/cache/keys.js +12 -1
- package/dist/helpers/onAbort.d.ts +12 -0
- package/dist/helpers/onAbort.js +22 -0
- package/dist/helpers/onAbort.test.d.ts +1 -0
- package/dist/helpers/outboundGuard.d.ts +27 -0
- package/dist/helpers/outboundGuard.js +49 -0
- package/dist/helpers/outboundGuard.test.d.ts +1 -0
- package/dist/helpers/serverLog.d.ts +6 -3
- package/dist/helpers/serverLog.js +20 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -4
- package/dist/kernel.d.ts +5 -22
- package/dist/kernel.js +1 -14
- package/dist/modules/actions/{handler.js → connectorWrite.js} +5 -2
- package/dist/modules/actions/connectorWrite.test.d.ts +1 -0
- package/dist/modules/actions/index.d.ts +40 -0
- package/dist/modules/actions/index.js +46 -0
- package/dist/modules/actions/moduleFor.d.ts +3 -0
- package/dist/modules/actions/moduleFor.js +35 -0
- package/dist/modules/actions/moduleFor.test.d.ts +1 -0
- package/dist/modules/actions/runtime/check.d.ts +24 -0
- package/dist/modules/actions/runtime/check.js +111 -0
- package/dist/modules/actions/runtime/check.test.d.ts +1 -0
- package/dist/modules/actions/runtime/errors.d.ts +11 -0
- package/dist/modules/actions/runtime/errors.js +17 -0
- package/dist/modules/actions/runtime/guards.d.ts +126 -0
- package/dist/modules/actions/runtime/guards.js +246 -0
- package/dist/modules/actions/runtime/guards.test.d.ts +1 -0
- package/dist/modules/actions/runtime/kvStore.d.ts +27 -0
- package/dist/modules/actions/runtime/kvStore.js +41 -0
- package/dist/modules/actions/runtime/kvStore.test.d.ts +1 -0
- package/dist/modules/actions/runtime/limits.d.ts +11 -0
- package/dist/modules/actions/runtime/limits.js +35 -0
- package/dist/modules/actions/runtime/memoryKv.d.ts +2 -0
- package/dist/modules/actions/runtime/memoryKv.js +77 -0
- package/dist/modules/actions/runtime/namespaceKv.d.ts +9 -0
- package/dist/modules/actions/runtime/namespaceKv.js +19 -0
- package/dist/modules/actions/runtime/precheck.d.ts +27 -0
- package/dist/modules/actions/runtime/precheck.js +45 -0
- package/dist/modules/actions/runtime/renderResolver.d.ts +15 -0
- package/dist/modules/actions/runtime/renderResolver.js +130 -0
- package/dist/modules/actions/runtime/renderResolver.test.d.ts +1 -0
- package/dist/modules/actions/runtime/renderShare.d.ts +29 -0
- package/dist/modules/actions/runtime/renderShare.js +36 -0
- package/dist/modules/actions/runtime/renderShare.test.d.ts +1 -0
- package/dist/modules/actions/runtime/report.d.ts +14 -0
- package/dist/modules/actions/runtime/report.js +22 -0
- package/dist/modules/actions/runtime/runAction.d.ts +12 -0
- package/dist/modules/actions/runtime/runAction.js +405 -0
- package/dist/modules/actions/runtime/runAction.test.d.ts +1 -0
- package/dist/modules/actions/runtime/runLogger.d.ts +31 -0
- package/dist/modules/actions/runtime/runLogger.js +66 -0
- package/dist/modules/actions/runtime/runLogger.test.d.ts +1 -0
- package/dist/modules/actions/runtime/schedule.d.ts +35 -0
- package/dist/modules/actions/runtime/schedule.js +76 -0
- package/dist/modules/actions/runtime/schedule.test.d.ts +1 -0
- package/dist/modules/actions/runtime/scope.d.ts +40 -0
- package/dist/modules/actions/runtime/scope.js +103 -0
- package/dist/modules/actions/runtime/triggers.d.ts +12 -0
- package/dist/modules/actions/runtime/triggers.js +14 -0
- package/dist/modules/actions/taskCatalog.d.ts +26 -0
- package/dist/modules/actions/taskCatalog.js +24 -0
- package/dist/modules/actions/taskCatalog.test.d.ts +1 -0
- package/dist/modules/actions/tasks/auth.d.ts +2 -0
- package/dist/modules/actions/tasks/auth.js +40 -0
- package/dist/modules/actions/tasks/builtins.d.ts +8 -0
- package/dist/modules/actions/tasks/builtins.js +24 -0
- package/dist/modules/actions/tasks/connector.d.ts +2 -0
- package/dist/modules/actions/tasks/connector.js +141 -0
- package/dist/modules/actions/tasks/db.d.ts +2 -0
- package/dist/modules/actions/tasks/db.js +59 -0
- package/dist/modules/actions/tasks/db.test.d.ts +1 -0
- package/dist/modules/actions/tasks/flow.d.ts +3 -0
- package/dist/modules/actions/tasks/flow.js +109 -0
- package/dist/modules/actions/tasks/helpers.d.ts +16 -0
- package/dist/modules/actions/tasks/helpers.js +30 -0
- package/dist/modules/actions/tasks/http.d.ts +2 -0
- package/dist/modules/actions/tasks/http.js +111 -0
- package/dist/modules/actions/tasks/http.test.d.ts +1 -0
- package/dist/modules/actions/tasks/kv.d.ts +2 -0
- package/dist/modules/actions/tasks/kv.js +102 -0
- package/dist/modules/actions/tasks/kv.test.d.ts +1 -0
- package/dist/modules/actions/tasks/registry.d.ts +10 -0
- package/dist/modules/actions/tasks/registry.js +36 -0
- package/dist/modules/actions/tasks/transform.d.ts +2 -0
- package/dist/modules/actions/tasks/transform.js +34 -0
- package/dist/modules/actions/transport/callHandler.d.ts +24 -0
- package/dist/modules/actions/transport/callHandler.js +233 -0
- package/dist/modules/actions/transport/callHandler.test.d.ts +1 -0
- package/dist/modules/actions/transport/cancelHandler.d.ts +23 -0
- package/dist/modules/actions/transport/cancelHandler.js +23 -0
- package/dist/modules/actions/transport/catalogHandler.d.ts +18 -0
- package/dist/modules/actions/transport/catalogHandler.js +21 -0
- package/dist/modules/actions/transport/stream.d.ts +23 -0
- package/dist/modules/actions/transport/stream.js +100 -0
- package/dist/modules/actions/transport/verifySignature.d.ts +16 -0
- package/dist/modules/actions/transport/verifySignature.js +72 -0
- package/dist/modules/actions/transport/webhookHandler.d.ts +25 -0
- package/dist/modules/actions/transport/webhookHandler.js +206 -0
- package/dist/modules/actions/transport/webhookHandler.test.d.ts +1 -0
- package/dist/modules/actions/types.d.ts +234 -0
- package/dist/modules/connectors/engine.js +4 -1
- package/dist/modules/connectors/resolver.d.ts +10 -2
- package/dist/modules/connectors/resolver.js +8 -3
- package/dist/modules/mysql/accounts.d.ts +5 -0
- package/dist/modules/mysql/accounts.js +365 -0
- package/dist/modules/mysql/accounts.test.d.ts +1 -0
- package/dist/modules/mysql/admin.d.ts +58 -0
- package/dist/modules/mysql/admin.js +133 -0
- package/dist/modules/mysql/config.d.ts +82 -0
- package/dist/modules/mysql/config.js +30 -0
- package/dist/modules/mysql/identities.d.ts +35 -0
- package/dist/modules/mysql/identities.js +43 -0
- package/dist/modules/mysql/index.d.ts +67 -0
- package/dist/modules/mysql/index.js +62 -0
- package/dist/modules/mysql/pool.d.ts +3 -0
- package/dist/modules/mysql/pool.js +88 -0
- package/dist/modules/mysql/query.d.ts +12 -0
- package/dist/modules/mysql/query.js +21 -0
- package/dist/modules/mysql/schema.d.ts +43 -0
- package/dist/modules/mysql/schema.js +340 -0
- package/dist/modules/mysql/schema.test.d.ts +1 -0
- package/dist/modules/mysql/sessions.d.ts +33 -0
- package/dist/modules/mysql/sessions.js +46 -0
- package/dist/modules/mysql/spaceTokens.d.ts +11 -0
- package/dist/modules/mysql/spaceTokens.js +59 -0
- package/dist/modules/mysql/store.integration.test.d.ts +1 -0
- package/dist/modules/rsc/connectorRscData.d.ts +25 -0
- package/dist/modules/rsc/connectorRscData.js +51 -0
- package/dist/modules/rsc/connectorRscData.test.d.ts +1 -0
- package/dist/modules/rsc/handler.js +16 -3
- package/dist/modules/rsc/matchRscPage.d.ts +14 -0
- package/dist/modules/rsc/matchRscPage.js +26 -0
- package/dist/modules/rsc/resolveRscData.d.ts +14 -1
- package/dist/modules/rsc/resolveRscData.js +23 -34
- package/dist/modules/ssr/Component.d.ts +6 -2
- package/dist/modules/ssr/Component.js +3 -1
- package/dist/modules/ssr/draftSession.test.d.ts +1 -0
- package/dist/modules/ssr/handler.js +15 -2
- package/dist/modules/ssr/loadPluginComponents.js +22 -5
- package/dist/modules/ssr/prepareRender.js +87 -8
- package/dist/modules/ssr/prepareRender.test.d.ts +1 -0
- package/dist/modules/ssr/preview.d.ts +21 -8
- package/dist/modules/ssr/preview.js +38 -14
- package/dist/modules/ssr/registerExternalPlugins.js +2 -2
- package/dist/modules/ssr/resolvePageSeo.d.ts +18 -0
- package/dist/modules/ssr/resolvePageSeo.js +26 -0
- package/dist/modules/ssr/resolvePageSeo.test.d.ts +1 -0
- package/dist/modules/ssr/template.test.d.ts +1 -0
- package/dist/modules/ssr/views/template.ejs +71 -5
- package/dist/mysql.d.ts +26 -0
- package/dist/mysql.js +4 -0
- package/dist/oauth.d.ts +24 -0
- package/dist/oauth.js +10 -0
- package/dist/plugins/compile.d.ts +1 -0
- package/dist/plugins/compile.js +37 -3
- package/dist/plugins/manager.d.ts +40 -0
- package/dist/plugins/manager.js +113 -13
- package/dist/plugins/manager.test.d.ts +1 -0
- package/package.json +43 -14
- package/skills/plitzi-authoring/SKILL.md +259 -0
- /package/dist/{modules/actions/handler.test.d.ts → adapters/cloudAdapters.test.d.ts} +0 -0
- /package/dist/modules/actions/{handler.d.ts → connectorWrite.d.ts} +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readRawBody } from "../../requestParser.js";
|
|
2
|
+
import { authorizationServerMetadata, protectedResourceMetadata } from "../../oauth/metadata.js";
|
|
3
|
+
import { getAccess } from "../../oauth/records.js";
|
|
4
|
+
import { sendErrorJson, sendJson } from "../../oauth/respond.js";
|
|
5
|
+
import { handleAuthorizeStart, handleAuthorizeSubmit } from "../../oauth/authorize.js";
|
|
6
|
+
import { bearerOf, sendChallenge } from "../../oauth/challenge.js";
|
|
7
|
+
import { handleRegister } from "../../oauth/register.js";
|
|
8
|
+
import { handleRevoke, handleToken } from "../../oauth/token.js";
|
|
9
|
+
//#region src/core/http/stages/oauth.ts
|
|
10
|
+
var matchesWellKnown = (path, wellKnown) => path === wellKnown || path.startsWith(`${wellKnown}/`);
|
|
11
|
+
var isOAuthPath = (path) => matchesWellKnown(path, "/.well-known/oauth-protected-resource") || matchesWellKnown(path, "/.well-known/oauth-authorization-server") || path === "/register" || path === "/authorize" || path === "/revoke" || path === "/token";
|
|
12
|
+
var formParams = (req, body) => {
|
|
13
|
+
const params = { ...req.query };
|
|
14
|
+
for (const [key, value] of new URLSearchParams(body).entries()) params[key] = value;
|
|
15
|
+
return params;
|
|
16
|
+
};
|
|
17
|
+
var parseJson = (body) => {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(body);
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** OAuth 2.1 authorization for the MCP server, mounted ONLY when a deployment configures `oauth`. Without it the
|
|
25
|
+
* stage falls straight through and the server keeps its anonymous contract: discovery 404s, the public surface
|
|
26
|
+
* (handshake, listings, the guide, plitzi_render) answers without a token, and nothing below changes.
|
|
27
|
+
*
|
|
28
|
+
* It sits before the MCP stage because that one answers every path on a dedicated MCP server — these endpoints
|
|
29
|
+
* would otherwise be swallowed by the JSON-RPC transport, which is exactly the 406 a host hits on /register. */
|
|
30
|
+
var createOAuthStage = (oauth) => async (ctx) => {
|
|
31
|
+
const { path, method } = ctx.req;
|
|
32
|
+
if (!oauth || !isOAuthPath(path)) return false;
|
|
33
|
+
const { res } = ctx;
|
|
34
|
+
if (path !== "/authorize") {
|
|
35
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
36
|
+
res.setHeader("Access-Control-Allow-Headers", "*");
|
|
37
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
38
|
+
}
|
|
39
|
+
if (method === "OPTIONS") {
|
|
40
|
+
res.setStatus(204);
|
|
41
|
+
res.end();
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (method === "GET" && matchesWellKnown(path, "/.well-known/oauth-protected-resource")) {
|
|
45
|
+
sendJson(res, 200, protectedResourceMetadata(oauth, ctx.req));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (method === "GET" && matchesWellKnown(path, "/.well-known/oauth-authorization-server")) {
|
|
49
|
+
sendJson(res, 200, authorizationServerMetadata(oauth, ctx.req));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (path === "/authorize" && method === "GET") {
|
|
53
|
+
await handleAuthorizeStart(oauth, res, ctx.req.query, ctx.req);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (path === "/authorize" && method === "POST") {
|
|
57
|
+
await handleAuthorizeSubmit(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)), ctx.req);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (path === "/revoke" && method === "POST") {
|
|
61
|
+
await handleRevoke(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (path === "/token" && method === "POST") {
|
|
65
|
+
await handleToken(oauth, res, formParams(ctx.req, await readRawBody(ctx.raw)));
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (path === "/register" && method === "POST") {
|
|
69
|
+
await handleRegister(oauth, res, parseJson(await readRawBody(ctx.raw)));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
sendErrorJson(res, 405, "invalid_request", `${method} is not allowed on ${path}.`);
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
var CHALLENGE_DESCRIPTIONS = {
|
|
76
|
+
"no-credential": "Authorization is required to use this server.",
|
|
77
|
+
"unknown-credential": "The access token is invalid, expired or revoked.",
|
|
78
|
+
"store-unreachable": "The access token could not be verified right now.",
|
|
79
|
+
"adapter-failed": "The access token could not be verified right now."
|
|
80
|
+
};
|
|
81
|
+
var challengeReason = async (oauth, ctx, token) => {
|
|
82
|
+
if (!token) return "no-credential";
|
|
83
|
+
try {
|
|
84
|
+
const record = await getAccess(oauth.adapters.store, token);
|
|
85
|
+
if (record) {
|
|
86
|
+
const credential = record.credential ?? token;
|
|
87
|
+
ctx.req.headers["x-access-token"] = credential;
|
|
88
|
+
ctx.req.headers.authorization = `Bearer ${credential}`;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
return "store-unreachable";
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return await ctx.config.adapters.getGrant?.(ctx.req) === void 0 ? "unknown-credential" : void 0;
|
|
96
|
+
} catch {
|
|
97
|
+
return "adapter-failed";
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
/** The protected-resource half of OAuth: an MCP call that presents no bearer this server can verify is refused
|
|
101
|
+
* with RFC 6750's challenge instead of being served the anonymous surface. That 401 is the whole handshake — it
|
|
102
|
+
* is how a host learns the server needs authorization, where its metadata lives and which scopes to ask for, and
|
|
103
|
+
* a 200 tells it none of that. Only the JSON-RPC POST is guarded: the CORS preflight, the GET 405 and the
|
|
104
|
+
* discovery probes carry no credential and must keep answering as they do.
|
|
105
|
+
*
|
|
106
|
+
* Mounted only when `oauth` is configured. A deployment that configures none keeps the open server it had, where
|
|
107
|
+
* the whole public surface — handshake, listings, the guide, plitzi_render — answers without a token; with OAuth
|
|
108
|
+
* on, the grant that carries no space is what covers that same ground. */
|
|
109
|
+
var createOAuthGuardStage = (oauth) => async (ctx) => {
|
|
110
|
+
if (!oauth || ctx.req.method !== "POST") return false;
|
|
111
|
+
const reason = await challengeReason(oauth, ctx, bearerOf(ctx.req));
|
|
112
|
+
if (!reason) return false;
|
|
113
|
+
ctx.operation = `oauth-challenge:${reason}`;
|
|
114
|
+
sendChallenge(oauth, ctx.req, ctx.res, CHALLENGE_DESCRIPTIONS[reason]);
|
|
115
|
+
return true;
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
export { createOAuthGuardStage, createOAuthStage };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { RawResponse } from '../../helpers/buildResponseHelpers';
|
|
2
2
|
import { ServerCaches } from '../../helpers/cache';
|
|
3
|
+
import { ActionsModule } from '../../modules/actions';
|
|
3
4
|
import { PluginManager } from '../../plugins/manager';
|
|
4
5
|
import { SSRPageServerConfig, SSRRequest, SSRResponseHelpers, SSRServerConfig, SSRTemplateFn } from '@plitzi/sdk-shared';
|
|
5
6
|
import { IncomingMessage } from 'node:http';
|
|
@@ -13,9 +14,20 @@ export interface BaseContext {
|
|
|
13
14
|
/** Set by the stage that answers when the path alone does not identify the work (the MCP endpoint serves every
|
|
14
15
|
* JSON-RPC method on the same URL). The dispatcher folds it into the access log. */
|
|
15
16
|
operation?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Aborted when the client goes away.
|
|
19
|
+
*
|
|
20
|
+
* The pipeline had no request lifecycle at all before this: a stage that kept working after the socket closed
|
|
21
|
+
* had no way to find out, which is affordable for a page render and is not for anything long-lived. Stages that
|
|
22
|
+
* spend real work on behalf of a caller should hand this to whatever they call.
|
|
23
|
+
*/
|
|
24
|
+
signal: AbortSignal;
|
|
16
25
|
}
|
|
17
26
|
export interface SSRContext extends BaseContext {
|
|
18
27
|
config: SSRPageServerConfig;
|
|
28
|
+
/** Built at boot when the deployment configured `action.lookups`, so its task registry is validated once and
|
|
29
|
+
* its guards are a single set for the process. Absent means this server runs no actions. */
|
|
30
|
+
actions?: ActionsModule;
|
|
19
31
|
renderFn: SSRTemplateFn;
|
|
20
32
|
caches: ServerCaches;
|
|
21
33
|
pluginManager: PluginManager;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { OAuthParams } from './params';
|
|
2
|
+
import { OAuthConfig, SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
|
|
3
|
+
/**
|
|
4
|
+
* GET /authorize — the entry point a host opens in the user's browser.
|
|
5
|
+
*
|
|
6
|
+
* This server does not ask for a password. It reads whoever the browser already is and, from there:
|
|
7
|
+
*
|
|
8
|
+
* - **signed in** — straight to the grant screen, which is the only page this module still renders. Choosing what
|
|
9
|
+
* to connect is authorization, and authorization is this server's job; who somebody is is not.
|
|
10
|
+
* - **not signed in, guest allowed** — the grant screen with nothing to grant and the guest button on it. It is
|
|
11
|
+
* deliberately NOT a redirect: a guest has no session and never will, so bouncing them to sign in would take
|
|
12
|
+
* the option away from the only people it exists for.
|
|
13
|
+
* - **not signed in, no guest** — off to `signInUrl`, carrying this whole request as where to come back to.
|
|
14
|
+
*/
|
|
15
|
+
export declare const handleAuthorizeStart: (config: OAuthConfig, res: SSRResponseHelpers, params: OAuthParams, req: SSRRequest) => Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* POST /authorize — the grant screen coming back, either with a chosen target or with the guest button.
|
|
18
|
+
*
|
|
19
|
+
* There is no credentials branch any more: nothing here reads a username or a password, and the only way to reach
|
|
20
|
+
* this with an identity is to carry a `pending` id that was minted after {@link OAuthAdapters.identify} succeeded.
|
|
21
|
+
*/
|
|
22
|
+
export declare const handleAuthorizeSubmit: (config: OAuthConfig, res: SSRResponseHelpers, params: OAuthParams, req: SSRRequest) => Promise<void>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { renderConsentPage } from "./consentPage.js";
|
|
2
|
+
import { AUTHORIZE_PATH } from "./metadata.js";
|
|
3
|
+
import { field, optionalField } from "./params.js";
|
|
4
|
+
import { randomId } from "./pkce.js";
|
|
5
|
+
import { dropPending, getClient, getPending, putCode, putPending } from "./records.js";
|
|
6
|
+
import { redirectToSignIn, redirectWithCode, redirectWithError, sendErrorPage, sendHtml } from "./respond.js";
|
|
7
|
+
//#region src/core/oauth/authorize.ts
|
|
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
|
+
});
|
|
18
|
+
var hiddenFieldsFor = (request, pendingId) => {
|
|
19
|
+
const hidden = {
|
|
20
|
+
client_id: request.clientId,
|
|
21
|
+
redirect_uri: request.redirectUri,
|
|
22
|
+
code_challenge: request.challenge,
|
|
23
|
+
code_challenge_method: "S256"
|
|
24
|
+
};
|
|
25
|
+
if (request.state !== void 0) hidden["state"] = request.state;
|
|
26
|
+
if (request.scope !== void 0) hidden["scope"] = request.scope;
|
|
27
|
+
if (pendingId !== void 0) hidden["pending"] = pendingId;
|
|
28
|
+
return hidden;
|
|
29
|
+
};
|
|
30
|
+
var renderConsent = (res, view) => {
|
|
31
|
+
sendHtml(res, 200, renderConsentPage(view));
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* This request, as the address to come back to after signing in.
|
|
35
|
+
*
|
|
36
|
+
* Rebuilt from the params rather than read off `req.url` because the two can differ — a proxy may rewrite the
|
|
37
|
+
* path — and what has to survive the round trip is the QUERY, which is the authorization request itself.
|
|
38
|
+
*/
|
|
39
|
+
var authorizeUrl = (config, req, params) => {
|
|
40
|
+
const base = config.issuer ?? `https://${req.headers.host ?? ""}`;
|
|
41
|
+
const url = new URL(AUTHORIZE_PATH, base);
|
|
42
|
+
for (const [key, value] of Object.entries(params)) if (typeof value === "string") url.searchParams.set(key, value);
|
|
43
|
+
return url.toString();
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The sign-in screen's own address, carrying `guest=1` when this server would take somebody without an account.
|
|
47
|
+
*
|
|
48
|
+
* The hint is the only thing that tells that screen so. It is shared by every client now, most of which require an
|
|
49
|
+
* account, so it cannot offer a way past itself by default — and without the hint, leaving this screen for it is a
|
|
50
|
+
* ONE-WAY door: whoever went to see what signing in involves has no way back to the guest button except the
|
|
51
|
+
* browser's own history, on a page they arrived at from another application entirely.
|
|
52
|
+
*
|
|
53
|
+
* Every hand-off to that screen goes through here, which is the point: the door is one-way from the grant screen's
|
|
54
|
+
* "sign in" link and from "use another account" alike, and the second one is where it was missed.
|
|
55
|
+
*/
|
|
56
|
+
var signInBase = (config) => {
|
|
57
|
+
if (!config.guest) return config.signInUrl;
|
|
58
|
+
const url = new URL(config.signInUrl);
|
|
59
|
+
url.searchParams.set("guest", "1");
|
|
60
|
+
return url.toString();
|
|
61
|
+
};
|
|
62
|
+
/** The sign-in address with this request as the destination — what the grant screen's "sign in" link points at. */
|
|
63
|
+
var signInWithReturn = (config, req, params) => {
|
|
64
|
+
const url = new URL(signInBase(config));
|
|
65
|
+
url.searchParams.set("redirect", authorizeUrl(config, req, params));
|
|
66
|
+
return url.toString();
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The same address again, rebuilt from the request rather than from the query it arrived as.
|
|
70
|
+
*
|
|
71
|
+
* The grant screen is reached by a GET with the parameters in the URL and by a POST with them in hidden fields, and
|
|
72
|
+
* the "use another account" link has to work on both — so it is built from the parts the request is MADE of, which
|
|
73
|
+
* both paths already carry, instead of from whichever shape they came in.
|
|
74
|
+
*/
|
|
75
|
+
var authorizeUrlFor = (config, req, request) => {
|
|
76
|
+
const base = config.issuer ?? `https://${req.headers.host ?? ""}`;
|
|
77
|
+
const url = new URL(AUTHORIZE_PATH, base);
|
|
78
|
+
url.searchParams.set("response_type", "code");
|
|
79
|
+
url.searchParams.set("client_id", request.clientId);
|
|
80
|
+
url.searchParams.set("redirect_uri", request.redirectUri);
|
|
81
|
+
url.searchParams.set("code_challenge", request.challenge);
|
|
82
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
83
|
+
if (request.state !== void 0) url.searchParams.set("state", request.state);
|
|
84
|
+
if (request.scope !== void 0) url.searchParams.set("scope", request.scope);
|
|
85
|
+
return url.toString();
|
|
86
|
+
};
|
|
87
|
+
/** Validates the parts of an authorization request that decide WHERE a failure may be reported. Until the client
|
|
88
|
+
* and its redirect target check out, nothing may be sent back to the client. */
|
|
89
|
+
var resolveRequest = async (config, res, params) => {
|
|
90
|
+
const clientId = field(params, "client_id");
|
|
91
|
+
const redirectUri = field(params, "redirect_uri");
|
|
92
|
+
const client = clientId ? await getClient(config.adapters.store, clientId) : void 0;
|
|
93
|
+
if (!client) {
|
|
94
|
+
sendErrorPage(res, "Unknown client", "This application is not registered with the server, or its registration expired.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!redirectUri || !client.redirectUris.includes(redirectUri)) {
|
|
98
|
+
sendErrorPage(res, "Invalid redirect", "The application asked to be sent back to an address it did not register.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const state = optionalField(params, "state");
|
|
102
|
+
const responseType = field(params, "response_type");
|
|
103
|
+
if (responseType && responseType !== "code") {
|
|
104
|
+
redirectWithError(res, redirectUri, "unsupported_response_type", "Only the authorization code flow is supported.", state);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const challenge = field(params, "code_challenge");
|
|
108
|
+
const method = field(params, "code_challenge_method");
|
|
109
|
+
if (!challenge || method !== "S256") {
|
|
110
|
+
redirectWithError(res, redirectUri, "invalid_request", "PKCE with code_challenge_method=S256 is required.", state);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
clientId,
|
|
115
|
+
redirectUri,
|
|
116
|
+
challenge,
|
|
117
|
+
state,
|
|
118
|
+
scope: optionalField(params, "scope")
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
/** Consent granted: mint the bearer now, park it behind a one-shot code and send the browser back. Minting here
|
|
122
|
+
* rather than at redemption keeps a failure the user can act on — no space, revoked access — on this screen. */
|
|
123
|
+
var completeGrant = async (config, res, request, user, target) => {
|
|
124
|
+
const issued = await config.adapters.issueToken(user, target);
|
|
125
|
+
if (!issued) {
|
|
126
|
+
redirectWithError(res, request.redirectUri, "access_denied", "The account may not grant access to this resource.", request.state);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const code = randomId();
|
|
130
|
+
await putCode(config.adapters.store, code, {
|
|
131
|
+
clientId: request.clientId,
|
|
132
|
+
redirectUri: request.redirectUri,
|
|
133
|
+
challenge: request.challenge,
|
|
134
|
+
token: issued.token,
|
|
135
|
+
expiresInSeconds: issued.expiresInSeconds,
|
|
136
|
+
scope: request.scope,
|
|
137
|
+
user,
|
|
138
|
+
target
|
|
139
|
+
}, config.codeTtlSeconds ?? DEFAULT_CODE_TTL_SECONDS);
|
|
140
|
+
redirectWithCode(res, request.redirectUri, code, request.state);
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Show a signed-in account what it may grant, and end the flow if there is nothing.
|
|
144
|
+
*
|
|
145
|
+
* `pendingId` is minted here because the screen it renders is the one that POSTs back, and the record is what
|
|
146
|
+
* proves — on that POST — that identity was established before anything was granted.
|
|
147
|
+
*/
|
|
148
|
+
var askForTarget = async (config, res, request, user, error) => {
|
|
149
|
+
const targets = await config.adapters.grantTargets(user);
|
|
150
|
+
if (targets.length === 0) {
|
|
151
|
+
redirectWithError(res, request.redirectUri, "access_denied", "This account has nothing to grant access to.", request.state);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const pendingId = randomId();
|
|
155
|
+
await putPending(config.adapters.store, pendingId, {
|
|
156
|
+
clientId: request.clientId,
|
|
157
|
+
redirectUri: request.redirectUri,
|
|
158
|
+
challenge: request.challenge,
|
|
159
|
+
state: request.state,
|
|
160
|
+
scope: request.scope,
|
|
161
|
+
user
|
|
162
|
+
});
|
|
163
|
+
renderConsent(res, {
|
|
164
|
+
action: AUTHORIZE_PATH,
|
|
165
|
+
hidden: hiddenFieldsFor(request, pendingId),
|
|
166
|
+
targets,
|
|
167
|
+
user,
|
|
168
|
+
canSwitchUser: config.adapters.signOut !== void 0,
|
|
169
|
+
error,
|
|
170
|
+
branding: config.branding ?? {}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* GET /authorize — the entry point a host opens in the user's browser.
|
|
175
|
+
*
|
|
176
|
+
* This server does not ask for a password. It reads whoever the browser already is and, from there:
|
|
177
|
+
*
|
|
178
|
+
* - **signed in** — straight to the grant screen, which is the only page this module still renders. Choosing what
|
|
179
|
+
* to connect is authorization, and authorization is this server's job; who somebody is is not.
|
|
180
|
+
* - **not signed in, guest allowed** — the grant screen with nothing to grant and the guest button on it. It is
|
|
181
|
+
* deliberately NOT a redirect: a guest has no session and never will, so bouncing them to sign in would take
|
|
182
|
+
* the option away from the only people it exists for.
|
|
183
|
+
* - **not signed in, no guest** — off to `signInUrl`, carrying this whole request as where to come back to.
|
|
184
|
+
*/
|
|
185
|
+
var handleAuthorizeStart = async (config, res, params, req) => {
|
|
186
|
+
const request = await resolveRequest(config, res, params);
|
|
187
|
+
if (!request) return;
|
|
188
|
+
const user = await config.adapters.identify(req);
|
|
189
|
+
if (user) {
|
|
190
|
+
await askForTarget(config, res, request, user);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (config.guest) {
|
|
194
|
+
renderConsent(res, {
|
|
195
|
+
action: AUTHORIZE_PATH,
|
|
196
|
+
hidden: hiddenFieldsFor(request),
|
|
197
|
+
targets: [],
|
|
198
|
+
guest: guestView(config.guest),
|
|
199
|
+
signInUrl: signInWithReturn(config, req, params),
|
|
200
|
+
branding: config.branding ?? {}
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
redirectToSignIn(res, signInBase(config), authorizeUrl(config, req, params));
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* POST /authorize — the grant screen coming back, either with a chosen target or with the guest button.
|
|
208
|
+
*
|
|
209
|
+
* There is no credentials branch any more: nothing here reads a username or a password, and the only way to reach
|
|
210
|
+
* this with an identity is to carry a `pending` id that was minted after {@link OAuthAdapters.identify} succeeded.
|
|
211
|
+
*/
|
|
212
|
+
var handleAuthorizeSubmit = async (config, res, params, req) => {
|
|
213
|
+
const request = await resolveRequest(config, res, params);
|
|
214
|
+
if (!request) return;
|
|
215
|
+
/**
|
|
216
|
+
* "Use another account": end the session and start this same request over.
|
|
217
|
+
*
|
|
218
|
+
* Before the pending record is read, because there is nothing to grant — the point is that whoever is signed in
|
|
219
|
+
* is not who should be. The request goes back out through `signInUrl` exactly as an unidentified visitor's would,
|
|
220
|
+
* so the sign-in screen brings them here again with the new session in place.
|
|
221
|
+
*/
|
|
222
|
+
if (config.adapters.signOut && optionalField(params, "switch")) {
|
|
223
|
+
const pendingId = optionalField(params, "pending");
|
|
224
|
+
if (pendingId) await dropPending(config.adapters.store, pendingId);
|
|
225
|
+
await config.adapters.signOut(req, res);
|
|
226
|
+
redirectToSignIn(res, signInBase(config), authorizeUrlFor(config, req, request));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const { guest } = config;
|
|
230
|
+
if (guest && optionalField(params, "guest")) {
|
|
231
|
+
await completeGrant(config, res, request, guest.user ?? DEFAULT_GUEST_USER, guest.target);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const pendingId = optionalField(params, "pending");
|
|
235
|
+
if (!pendingId) {
|
|
236
|
+
redirectWithError(res, request.redirectUri, "invalid_request", "The grant was submitted without a session.", request.state);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const pending = await getPending(config.adapters.store, pendingId);
|
|
240
|
+
if (!pending || pending.clientId !== request.clientId) {
|
|
241
|
+
redirectWithError(res, request.redirectUri, "access_denied", "The sign-in expired. Try connecting again.", request.state);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const chosen = (await config.adapters.grantTargets(pending.user)).find((target) => target.value === field(params, "target"));
|
|
245
|
+
if (!chosen) {
|
|
246
|
+
await dropPending(config.adapters.store, pendingId);
|
|
247
|
+
await askForTarget(config, res, request, pending.user, "Choose what to grant access to.");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await dropPending(config.adapters.store, pendingId);
|
|
251
|
+
await completeGrant(config, res, request, pending.user, chosen);
|
|
252
|
+
};
|
|
253
|
+
//#endregion
|
|
254
|
+
export { handleAuthorizeStart, handleAuthorizeSubmit };
|
|
@@ -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;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resourceMetadataUrl, scopesOf } from "./metadata.js";
|
|
2
|
+
import { sendJson } from "./respond.js";
|
|
3
|
+
//#region src/core/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 };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { OAuthConsentView } from '@plitzi/sdk-shared';
|
|
2
|
+
/**
|
|
3
|
+
* The grant screen — the ONE page this server renders, and it never asks who anybody is.
|
|
4
|
+
*
|
|
5
|
+
* It used to be two steps, the first of which took a username and a password. That is gone: signing in happens on
|
|
6
|
+
* the deployment's own sign-in screen (`OAuthConfig.signInUrl`), and this page is reached either already signed in
|
|
7
|
+
* — pick what to connect — or as a visitor who may only take the guest connection.
|
|
8
|
+
*/
|
|
9
|
+
export declare const renderConsentPage: (view: OAuthConsentView) => string;
|
|
10
|
+
/** The dead end for a request that cannot be redirected back — a bad `redirect_uri` or an unknown client. Bouncing
|
|
11
|
+
* those to the client would make this server an open redirector, so the user is told here instead. */
|
|
12
|
+
export declare const renderErrorPage: (title: string, detail: string) => string;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//#region src/core/oauth/consentPage.ts
|
|
2
|
+
var escapeHtml = (value) => value.replace(/[&<>"']/gu, (character) => {
|
|
3
|
+
switch (character) {
|
|
4
|
+
case "&": return "&";
|
|
5
|
+
case "<": return "<";
|
|
6
|
+
case ">": return ">";
|
|
7
|
+
case "\"": return """;
|
|
8
|
+
default: return "'";
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
var STYLES = `
|
|
12
|
+
:root { color-scheme: light dark; --bg: #f6f7f9; --panel: #ffffff; --ink: #16181d; --muted: #6b7280;
|
|
13
|
+
--line: #e2e5ea; --accent: #2563eb; --danger: #b91c1c; }
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
:root { --bg: #0f1115; --panel: #171a21; --ink: #e8eaee; --muted: #9aa1ad; --line: #262b35;
|
|
16
|
+
--accent: #60a5fa; --danger: #f87171; }
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
|
20
|
+
background: var(--bg); color: var(--ink);
|
|
21
|
+
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
22
|
+
main { width: 100%; max-width: 420px; background: var(--panel); border: 1px solid var(--line);
|
|
23
|
+
border-radius: 14px; padding: 32px; }
|
|
24
|
+
img.logo { display: block; height: 32px; margin-bottom: 20px; }
|
|
25
|
+
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
|
|
26
|
+
p.lede { margin: 0 0 24px; color: var(--muted); font-size: 14px; }
|
|
27
|
+
input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
28
|
+
ul.targets { list-style: none; margin: 0 0 20px; padding: 0; display: grid; gap: 8px; }
|
|
29
|
+
ul.targets label { display: flex; gap: 10px; align-items: flex-start; margin: 0; padding: 12px;
|
|
30
|
+
border: 1px solid var(--line); border-radius: 8px; font-weight: 400; cursor: pointer; }
|
|
31
|
+
ul.targets strong { display: block; font-weight: 500; }
|
|
32
|
+
ul.targets span { color: var(--muted); font-size: 13px; }
|
|
33
|
+
button { width: 100%; padding: 11px 16px; border: 0; border-radius: 8px; background: var(--accent);
|
|
34
|
+
color: #fff; font: inherit; font-weight: 500; cursor: pointer; }
|
|
35
|
+
button.guest { margin-top: 10px; background: transparent; color: var(--accent);
|
|
36
|
+
border: 1px solid var(--line); }
|
|
37
|
+
p.error { margin: 0 0 16px; padding: 10px 12px; border-radius: 8px; color: var(--danger);
|
|
38
|
+
border: 1px solid currentColor; font-size: 14px; }
|
|
39
|
+
p.note { margin: 10px 0 0; color: var(--muted); font-size: 13px; text-align: center; }
|
|
40
|
+
p.note a { color: var(--accent); }
|
|
41
|
+
`;
|
|
42
|
+
var hiddenFields = (hidden) => Object.entries(hidden).map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("\n ");
|
|
43
|
+
var guestButton = (guest) => {
|
|
44
|
+
const note = guest.description ? `\n <p class="note">${escapeHtml(guest.description)}</p>` : "";
|
|
45
|
+
return `\n <button class="guest" type="submit" name="guest" value="1" formnovalidate>${escapeHtml(guest.label)}</button>${note}`;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The way out for somebody signed in as the wrong account.
|
|
49
|
+
*
|
|
50
|
+
* Under the grant button rather than beside the account name, because it is the escape hatch and not the action:
|
|
51
|
+
* almost everybody who reaches this screen is who they meant to be, and the ones who are not have no other route
|
|
52
|
+
* back — they arrived from another application, and the deployment's sign-out page is somewhere they cannot get to
|
|
53
|
+
* from here without abandoning the connection.
|
|
54
|
+
*
|
|
55
|
+
* `formnovalidate` for the same reason the guest button carries it: this submit is not the form's own answer.
|
|
56
|
+
*/
|
|
57
|
+
var switchUserButton = () => "\n <button class=\"guest\" type=\"submit\" name=\"switch\" value=\"1\" formnovalidate>Use another account</button>";
|
|
58
|
+
var targetFields = (view) => {
|
|
59
|
+
return `<ul class="targets">
|
|
60
|
+
${view.targets.map((target, index) => {
|
|
61
|
+
const description = target.description ? `<span>${escapeHtml(target.description)}</span>` : "";
|
|
62
|
+
return `<li><label>
|
|
63
|
+
<input type="radio" name="target" value="${escapeHtml(target.value)}"${index === 0 ? " checked" : ""}>
|
|
64
|
+
<span><strong>${escapeHtml(target.label)}</strong>${description}</span>
|
|
65
|
+
</label></li>`;
|
|
66
|
+
}).join("\n ")}
|
|
67
|
+
</ul>
|
|
68
|
+
<button type="submit">Allow access</button>${view.canSwitchUser ? switchUserButton() : ""}`;
|
|
69
|
+
};
|
|
70
|
+
/** What a visitor who has not signed in is offered: the guest connection, and a way to go and sign in. */
|
|
71
|
+
var anonymousFields = (view) => {
|
|
72
|
+
const signIn = view.signInUrl ? `\n <p class="note"><a href="${escapeHtml(view.signInUrl)}">Sign in to connect a space instead</a></p>` : "";
|
|
73
|
+
return `${view.guest ? guestButton(view.guest) : ""}${signIn}`;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The grant screen — the ONE page this server renders, and it never asks who anybody is.
|
|
77
|
+
*
|
|
78
|
+
* It used to be two steps, the first of which took a username and a password. That is gone: signing in happens on
|
|
79
|
+
* the deployment's own sign-in screen (`OAuthConfig.signInUrl`), and this page is reached either already signed in
|
|
80
|
+
* — pick what to connect — or as a visitor who may only take the guest connection.
|
|
81
|
+
*/
|
|
82
|
+
var renderConsentPage = (view) => {
|
|
83
|
+
const productName = view.branding.productName ?? "Plitzi";
|
|
84
|
+
const logo = view.branding.logoUrl ? `<img class="logo" src="${escapeHtml(view.branding.logoUrl)}" alt="${escapeHtml(productName)}">` : "";
|
|
85
|
+
const error = view.error ? `<p class="error">${escapeHtml(view.error)}</p>` : "";
|
|
86
|
+
const lede = view.user ? `Signed in as ${escapeHtml(view.user.label)}. Choose what to grant access to.` : `Connect ${escapeHtml(productName)} without an account, or sign in first.`;
|
|
87
|
+
return `<!doctype html>
|
|
88
|
+
<html lang="en">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="utf-8">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
92
|
+
<meta name="robots" content="noindex">
|
|
93
|
+
<link rel="icon" href="data:,">
|
|
94
|
+
<title>${escapeHtml(productName)}</title>
|
|
95
|
+
<style>${STYLES}${view.branding.css ?? ""}</style>
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<main>
|
|
99
|
+
${logo}
|
|
100
|
+
<h1>${escapeHtml(productName)}</h1>
|
|
101
|
+
<p class="lede">${lede}</p>
|
|
102
|
+
${error}
|
|
103
|
+
<form method="post" action="${escapeHtml(view.action)}">
|
|
104
|
+
${hiddenFields(view.hidden)}
|
|
105
|
+
${view.user ? targetFields(view) : anonymousFields(view)}
|
|
106
|
+
</form>
|
|
107
|
+
</main>
|
|
108
|
+
</body>
|
|
109
|
+
</html>`;
|
|
110
|
+
};
|
|
111
|
+
/** The dead end for a request that cannot be redirected back — a bad `redirect_uri` or an unknown client. Bouncing
|
|
112
|
+
* those to the client would make this server an open redirector, so the user is told here instead. */
|
|
113
|
+
var renderErrorPage = (title, detail) => `<!doctype html>
|
|
114
|
+
<html lang="en">
|
|
115
|
+
<head>
|
|
116
|
+
<meta charset="utf-8">
|
|
117
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
118
|
+
<meta name="robots" content="noindex">
|
|
119
|
+
<link rel="icon" href="data:,">
|
|
120
|
+
<title>${escapeHtml(title)}</title>
|
|
121
|
+
<style>${STYLES}</style>
|
|
122
|
+
</head>
|
|
123
|
+
<body>
|
|
124
|
+
<main>
|
|
125
|
+
<h1>${escapeHtml(title)}</h1>
|
|
126
|
+
<p class="lede">${escapeHtml(detail)}</p>
|
|
127
|
+
</main>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`;
|
|
130
|
+
//#endregion
|
|
131
|
+
export { renderConsentPage, renderErrorPage };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { OAuthConfig, SSRRequest } from '@plitzi/sdk-shared';
|
|
2
|
+
export declare const AUTHORIZE_PATH = "/authorize";
|
|
3
|
+
export declare const TOKEN_PATH = "/token";
|
|
4
|
+
export declare const REVOKE_PATH = "/revoke";
|
|
5
|
+
export declare const REGISTER_PATH = "/register";
|
|
6
|
+
export declare const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
|
7
|
+
export declare const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
|
|
8
|
+
/** The identifier the two documents agree on. This server is both the protected resource and its own
|
|
9
|
+
* authorization server, so one origin names both. Derived from the request unless pinned, which keeps a
|
|
10
|
+
* deployment correct across its dev, staging and production hosts without per-environment config. */
|
|
11
|
+
export declare const issuerOf: (config: OAuthConfig, req: SSRRequest) => string;
|
|
12
|
+
export declare const scopesOf: (config: OAuthConfig) => string[];
|
|
13
|
+
/** Where the challenge points a host, for an MCP endpoint served at `req.path`. RFC 9728 §3.1 appends the
|
|
14
|
+
* resource's own path to the well-known path, and a host that reads the document back expects its `resource` to
|
|
15
|
+
* name the URL it was configured with — so a server mounted at /mcp must be pointed at the suffixed document,
|
|
16
|
+
* not the bare one. */
|
|
17
|
+
export declare const resourceMetadataUrl: (config: OAuthConfig, req: SSRRequest) => string;
|
|
18
|
+
/** RFC 9728. The document Claude Desktop asks for FIRST, and the one whose absence ends the flow before anything
|
|
19
|
+
* else is tried — a 404 here is what a host reports as `mcp_auth_start_failed`.
|
|
20
|
+
*
|
|
21
|
+
* `resource` echoes the path the document was asked for: Claude requires it to match the server URL the user
|
|
22
|
+
* typed, path included, and a dedicated MCP server answers JSON-RPC on every path — so `/.well-known/…/mcp`
|
|
23
|
+
* describes `https://host/mcp` while the bare path describes the origin.
|
|
24
|
+
*
|
|
25
|
+
* A server mounted at the ORIGIN is `https://host/`, WITH the trailing slash: a host parses the URL the user
|
|
26
|
+
* typed before it does anything with it, and an empty path renders as `/` — Claude's connector was observed
|
|
27
|
+
* asking for exactly `resource=https%3A%2F%2Fhost%2F`, so that is the form the document must claim.
|
|
28
|
+
*
|
|
29
|
+
* It does NOT make a bare-origin connector work, and nothing here can. Claude's remote-connector service will
|
|
30
|
+
* not open a session against an MCP endpoint whose URL has no path: the whole grant succeeds — discovery,
|
|
31
|
+
* registration, consent, a 200 from /token with the scope it asked for — and then it reports
|
|
32
|
+
* `McpAuthorizationError` ("the integration rejected the credentials it just issued") without ever presenting
|
|
33
|
+
* the bearer. Verified against the same deployment, minutes apart: at /mcp the token is followed by
|
|
34
|
+
* `initialize`, at / nothing follows it, in the ingress log as well as this server's. Claiming `https://host/mcp`
|
|
35
|
+
* from the bare document would only fail sooner — a client checks that the resource it is handed is no MORE
|
|
36
|
+
* specific than the URL it holds. Publish the connector URL WITH its path. */
|
|
37
|
+
export declare const protectedResourceMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
|
|
38
|
+
/** RFC 8414. Public clients with PKCE only: a desktop host stores no secret, so `none` is the sole endpoint auth
|
|
39
|
+
* method and S256 the sole challenge method. `offline_access` is advertised whenever refresh grants are issued,
|
|
40
|
+
* which is the signal a host looks for before asking for one. */
|
|
41
|
+
export declare const authorizationServerMetadata: (config: OAuthConfig, req: SSRRequest) => Record<string, unknown>;
|