mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
mcp: personal tokens — landing copy, smoke leg, facts in CLAUDE.md and the handoff
The landing page tells Responses API / Messages API users where to mint a token; the smoke script now also signs in on /connections, creates a token, uses it at /mcp, revokes it and confirms the 401 (verified against wrangler dev + the fake). CLAUDE.md records the verified provider dispatch rule, the storage design, and the creation rules; the handoff notes that revoking a token cannot log its session out upstream either. Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
+35
-7
@@ -187,7 +187,7 @@ type Principal = {
|
||||
|
||||
An Artcraft session cannot be refreshed — there is no endpoint — only held or deleted. So:
|
||||
access tokens 1 h; refresh tokens rotate, 30 days idle, **90 days absolute**, then re-consent.
|
||||
Personal tokens (M2) inherit the same 90-day ceiling. On expiry or revoke, always call the
|
||||
Personal tokens inherit the same 90-day ceiling. On expiry or revoke, always call the
|
||||
credential's `revoke()`. The 90-day window is also the runway for swapping `UpstreamCredential`
|
||||
to a stronger backend token system if one appears: grants naturally roll over.
|
||||
|
||||
@@ -280,6 +280,31 @@ Cite these; re-verify if the file references stop matching.
|
||||
(the repo's Unknown(String) pattern) — normalisers must accept the object form.
|
||||
- `/v1/jobs/batch` takes repeated `tokens=` params (openapi-fetch's default serialisation).
|
||||
|
||||
## Personal tokens (built 2026-08-25)
|
||||
|
||||
For the two integrations that cannot run OAuth and take a fixed token instead: the OpenAI
|
||||
Responses API `mcp` tool (`authorization`) and the Anthropic Messages API connector
|
||||
(`authorization_token`, beta `mcp-client-2025-11-20`). Gemini CLI does its own OAuth
|
||||
discovery, so it does not need one. Both APIs say "obtain and refresh the access token
|
||||
yourself", which our 1 h access / rotating refresh makes hostile — hence a long-lived secret.
|
||||
|
||||
- Format `artcraft_pat_` + 43 base64url chars (32 random bytes). The provider routes any
|
||||
bearer that is not `a:b:c`-shaped straight to `resolveExternalToken` (verified in 0.10.3's
|
||||
`dist`), so the hook's first move is the prefix check and every foreign bearer is a cheap
|
||||
`null` → standard `401 invalid_token` challenge.
|
||||
- At rest: `mcppat:token:<sha256(secret)>` holds the record AES-GCM-sealed with an HKDF key
|
||||
derived from the secret; `mcppat:user:<userToken>` is the index (labels, hints, dates,
|
||||
record keys). KV alone yields no session. Record TTL = token lifetime.
|
||||
- The hook returns the same `GrantProps` shape as consent (`grantIssuedAt` = createdAt) with
|
||||
`audience` = this origin + `/mcp`, so handler, `Principal`, and tools are unchanged.
|
||||
- Created on `/connections` only, after a fresh password/Google confirmation that must be the
|
||||
page's account; that sign-in becomes the token's own upstream session (never the browser's,
|
||||
never the page's). Secret rendered once in the POST response — never in a redirect URL.
|
||||
Fixed read-only scopes, lifetime 30 or 90 days, at most 10 live tokens per account.
|
||||
- When upstream stops accepting the token's session the handler revokes the token, as it
|
||||
does a grant. Revoke from the page cannot log the session out upstream (same limitation as
|
||||
grants, `docs/backend-handoff.md`).
|
||||
|
||||
## Client requirements that bite
|
||||
|
||||
- Unauthenticated `/mcp` → **401** with
|
||||
@@ -362,6 +387,8 @@ Manual: MCP Inspector against `wrangler dev`.
|
||||
Dependency-free Node script that does exactly what a client does: PRM → AS metadata (CIMD flag)
|
||||
→ DCR → consent page + CSRF → password sign-in → code → PKCE exchange → initialize → tools/list
|
||||
(must equal the 7 tool names) → one call per read-only tool → revoke → revoked token is 401.
|
||||
Then the header-only leg: sign in on `/connections`, create a personal token, initialize and
|
||||
call a tool with it, revoke it from the page, confirm the 401, sign out of the page.
|
||||
Prints step outcomes only, never tokens/bodies/credentials; exit 1 on the first failure. Runs
|
||||
after every production deploy (`mcp.yml`) and weekly (`mcp-smoke.yml`, Mondays 09:00 UTC, also
|
||||
`workflow_dispatch` with a `base_url`), both skipped with a notice until `SMOKE_USERNAME` /
|
||||
@@ -389,9 +416,6 @@ tool or flow changes, and keep the fake honest so the local run stays a real reh
|
||||
|
||||
Keep these possible; do not build them in M1.
|
||||
|
||||
- Personal tokens for header-based clients (Gemini API/Vertex, OpenAI Responses API,
|
||||
Anthropic Messages API connector): `resolveExternalToken` + `/connections` UI. Read-only
|
||||
scopes only, 90-day max, shown once, hashed at rest.
|
||||
- `WebappRedirectAuthenticator` (`app.getartcraft.com/connect`) replacing the password form.
|
||||
Investigated 2026-08-25: without a backend hand-off code endpoint the only credential a
|
||||
webapp page can pass is the user's own browser session, which breaks per-grant disconnect —
|
||||
@@ -421,23 +445,27 @@ mcp/
|
||||
│ ├── index.ts # Worker entry: environment invariant first, then the OAuthProvider
|
||||
│ ├── runtime.ts # memoized Config + Hono app per isolate (failure memoized too)
|
||||
│ ├── oauth-env.d.ts # adds env.OAUTH_PROVIDER (OAuthHelpers) to the generated Env
|
||||
│ ├── encoding.ts # base64url helpers shared by ids, secrets, and sealed records
|
||||
│ ├── app.ts # unprotected Hono routes: /healthz, / (landing), /authorize, /connections, /sse (405 pointer)
|
||||
│ ├── auth/
|
||||
│ │ ├── oauth.ts # provider options: endpoints, scopes, TTLs, CIMD+DCR, error logging
|
||||
│ │ ├── resource.ts # MCP_ROUTES, SCOPES, RESOURCE_NAME — importable without the provider wiring
|
||||
│ │ ├── oauth.ts # provider options: endpoints, TTLs, CIMD+DCR, resolveExternalToken, error logging
|
||||
│ │ ├── authorize.ts # /authorize GET (render) + POST (CSRF → re-validate → deny | sign in → finish)
|
||||
│ │ ├── authenticator.ts # Authenticator seam (swap point 1); Artcraft impl proxies /v1/login, google_sso
|
||||
│ │ ├── consent-page.tsx # pure render of the sign-in + consent page (Hono JSX; everything escaped)
|
||||
│ │ ├── csrf.ts # double-submit token: cookie + hidden field, constant-time compare
|
||||
│ │ ├── finish-authorization.ts # requested ∩ supported scopes, completeAuthorization, grant props
|
||||
│ │ └── grant-age.ts # 90-day absolute grant lifetime, enforced in tokenExchangeCallback
|
||||
│ ├── tokens/ # TokenResolver, Principal, GrantStore adapter
|
||||
│ ├── tokens/
|
||||
│ │ ├── principal.ts # Principal: the one shape every token resolves to (from grant props)
|
||||
│ │ ├── personal-token-store.ts # artcraft_pat_ secrets: hash-keyed, secret-sealed records, per-user index
|
||||
│ │ └── resolve-personal-token.ts # the provider's resolveExternalToken hook → GrantProps + audience
|
||||
│ ├── upstream/
|
||||
│ │ ├── allowlist.json # the ONLY upstream routes, with `use: auth | read`; also drives the generator
|
||||
│ │ ├── allowlist.ts # validation + lookups by template path and by concrete pathname
|
||||
│ │ ├── credential.ts # UpstreamCredential interface; session implementation (swap point 2)
|
||||
│ │ ├── client.ts # openapi-fetch client; middleware enforces allowlist, use, origin, credential
|
||||
│ │ └── schema.d.ts # GENERATED from the spec snapshot by `pnpm gen:api`; do not edit
|
||||
│ ├── tokens/principal.ts # Principal: the one shape every token resolves to (from grant props)
|
||||
│ ├── mcp/
|
||||
│ │ ├── handler.ts # protected route: props → Principal → per-request McpServer + Streamable HTTP
|
||||
│ │ ├── server.ts # registers tools the principal's scopes allow; error → isError results
|
||||
|
||||
@@ -41,7 +41,9 @@ but we cannot call `POST /v1/logout` for it — the props can no longer be decry
|
||||
`user_sessions` row therefore lives until its (unchecked) one-year expiry.
|
||||
|
||||
**Our side.** Documented on the page; sessions that fail upstream are logged out from the
|
||||
handler path whenever a token is still live.
|
||||
handler path whenever a token is still live. Personal tokens (`/connections` → Personal
|
||||
tokens) have the same shape: revoking one deletes its sealed record, and the session inside
|
||||
is unreachable but not logged out.
|
||||
|
||||
**Backend fix.** A session-scoped endpoint to end *other* sessions of the same user (or a
|
||||
"revoke all sessions created via API key/MCP" action) would let the connections page finish
|
||||
|
||||
+96
-1
@@ -1,6 +1,7 @@
|
||||
// Read-only smoke test against a deployed MCP server — the one place this project touches a
|
||||
// real Artcraft backend. Drives exactly what a client does: register, consent (password),
|
||||
// exchange the code, initialize, list tools, call every read-only tool, then revoke the grant.
|
||||
// exchange the code, initialize, list tools, call every read-only tool; then the header-only
|
||||
// path — a personal token created on /connections, used, and revoked; then revoke the grant.
|
||||
//
|
||||
// SMOKE_BASE_URL=https://mcp.getartcraft.com SMOKE_USERNAME=… SMOKE_PASSWORD=… node scripts/smoke.mjs
|
||||
//
|
||||
@@ -137,6 +138,8 @@ async function main() {
|
||||
Array.isArray(s.jobs),
|
||||
);
|
||||
|
||||
await smokePersonalToken();
|
||||
|
||||
const revoke = await fetchWithTimeout(new URL(as.revocation_endpoint, BASE_URL).href, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
@@ -152,6 +155,98 @@ async function main() {
|
||||
console.log("smoke: OK");
|
||||
}
|
||||
|
||||
/**
|
||||
* The header-only path (Responses API, Messages API connector): sign in on /connections,
|
||||
* create a personal token, use it at /mcp, revoke it from the page, sign out of the page.
|
||||
*/
|
||||
async function smokePersonalToken() {
|
||||
const page = await fetchWithTimeout(`${BASE_URL}/connections`);
|
||||
check(page.status === 200, "connections page renders");
|
||||
let csrf = consentCsrf(page);
|
||||
check(csrf.length === 43, "connections CSRF issued");
|
||||
|
||||
const signIn = await fetchWithTimeout(`${BASE_URL}/connections/sign-in`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
cookie: `artcraft_consent=${csrf}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
csrf,
|
||||
method: "password",
|
||||
username_or_email: USERNAME,
|
||||
password: PASSWORD,
|
||||
}).toString(),
|
||||
});
|
||||
check(signIn.status === 303, `connections sign-in redirects (got ${signIn.status})`);
|
||||
const uiSession =
|
||||
/artcraft_connections=([^;]+)/.exec(signIn.headers.get("set-cookie") ?? "")?.[1] ?? "";
|
||||
check(uiSession.length === 43, "connections page session issued");
|
||||
const cookies = () => `artcraft_consent=${csrf}; artcraft_connections=${uiSession}`;
|
||||
|
||||
const created = await fetchWithTimeout(`${BASE_URL}/connections/tokens/create`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookies() },
|
||||
body: new URLSearchParams({
|
||||
csrf,
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
token_label: "smoke",
|
||||
token_lifetime: "30",
|
||||
}).toString(),
|
||||
});
|
||||
check(created.status === 200, `personal token created (got ${created.status})`);
|
||||
const pat = /artcraft_pat_[A-Za-z0-9_-]{43}/.exec(await created.text())?.[0] ?? "";
|
||||
check(pat.length > 0, "personal token issued");
|
||||
csrf = consentCsrf(created) || csrf;
|
||||
|
||||
const init = await rpc(pat, 20, "initialize", {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "artcraft-mcp-smoke", version: "0" },
|
||||
});
|
||||
check(init.result?.serverInfo?.name === "artcraft", "personal token initializes");
|
||||
await tool(pat, 21, "get_credit_balance", {}, (s) => Number.isInteger(s.total_credits));
|
||||
|
||||
const listed = await fetchWithTimeout(`${BASE_URL}/connections`, {
|
||||
headers: { cookie: cookies() },
|
||||
});
|
||||
csrf = consentCsrf(listed) || csrf;
|
||||
const item =
|
||||
(await listed.text())
|
||||
.split("<li>")
|
||||
.find((chunk) => chunk.includes(`artcraft_pat_\u2026${pat.slice(-4)}`)) ?? "";
|
||||
const tokenId = /name="token_id" value="([^"]+)"/.exec(item)?.[1] ?? "";
|
||||
check(tokenId.length > 0, "personal token listed by its hint");
|
||||
|
||||
const revoked = await fetchWithTimeout(`${BASE_URL}/connections/tokens/revoke`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookies() },
|
||||
body: new URLSearchParams({ csrf, token_id: tokenId }).toString(),
|
||||
});
|
||||
check(revoked.status === 303, `personal token revoked (got ${revoked.status})`);
|
||||
const after = await fetchWithTimeout(`${BASE_URL}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${pat}` },
|
||||
});
|
||||
check(after.status === 401, "revoked personal token is refused");
|
||||
|
||||
const signOut = await fetchWithTimeout(`${BASE_URL}/connections/sign-out`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookies() },
|
||||
body: new URLSearchParams({ csrf }).toString(),
|
||||
});
|
||||
check(signOut.status === 303, "signed out of the connections page");
|
||||
}
|
||||
|
||||
/** The CSRF cookie a page response (re)issues; empty when it did not set one. */
|
||||
function consentCsrf(response) {
|
||||
return /artcraft_consent=([^;]+)/.exec(response.headers.get("set-cookie") ?? "")?.[1] ?? "";
|
||||
}
|
||||
|
||||
async function tool(bearer, id, name, args, predicate) {
|
||||
const response = await rpc(bearer, id, "tools/call", { name, arguments: args });
|
||||
const result = response.result;
|
||||
|
||||
@@ -126,6 +126,21 @@ function LandingPage({ view }: { view: LandingView }) {
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Integrations that take a token</h2>
|
||||
<p>
|
||||
The OpenAI Responses API and the Anthropic Messages API connector cannot sign you in;
|
||||
they take a fixed token. Create a personal token at{" "}
|
||||
<a href={CONNECTIONS_PATHS.page}>
|
||||
{view.origin}
|
||||
{CONNECTIONS_PATHS.page}
|
||||
</a>{" "}
|
||||
and pass it where the API expects an OAuth access token — Responses API{" "}
|
||||
<code>authorization</code>, Messages API <code>authorization_token</code>. Personal
|
||||
tokens are read-only and expire after at most 90 days.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Manage connections</h2>
|
||||
<p>
|
||||
|
||||
Reference in New Issue
Block a user