mcp: /connections manages personal tokens — create (re-confirmed), show once, list, revoke

A signed-in user names a token, picks 30 or 90 days, and confirms with their password
or Google; that sign-in becomes the token's own upstream session and must be the account
on the page. The secret is rendered once in the POST response (never a redirect URL);
the list shows label, hint, and dates only. Revoke is scoped to the user.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
Tanish Manku committed 2026-08-25 00:38:15 -07:00
1 parent 76269441fe
commit 4f2f082403
4 files changed
+457 -36

No files matched your search

+162 -26
View File
@@ -2,8 +2,9 @@ import { isValidElement } from "hono/jsx";
/**
* `/connections` — where a user sees which AI apps hold a grant to their Artcraft account and
* disconnects them. The app itself has no sessions screen, so this page is the only place that
* can show "Claude is connected". A pure function of its view model; everything is escaped.
* disconnects them, and where they mint personal tokens for integrations that cannot run the
* OAuth flow themselves. The app itself has no sessions screen, so this page is the only place
* that can show "Claude is connected". A pure function of its view model; everything is escaped.
*/
export interface ConnectionView {
@@ -13,6 +14,21 @@ export interface ConnectionView {
readonly createdAt: string;
}
export interface PersonalTokenView {
readonly id: string;
readonly label: string;
readonly hint: string;
readonly createdAt: string;
readonly expiresAt: string;
}
/** Shown exactly once, in the response to the request that created it. */
export interface NewTokenView {
readonly label: string;
readonly secret: string;
readonly expiresAt: string;
}
export interface ConnectionsView {
readonly csrfToken: string;
readonly scriptNonce: string;
@@ -24,6 +40,8 @@ export interface ConnectionsView {
readonly signedIn?: {
readonly username: string;
readonly connections: readonly ConnectionView[];
readonly personalTokens: readonly PersonalTokenView[];
readonly newToken?: NewTokenView;
};
}
@@ -34,6 +52,9 @@ export const CONNECTIONS_FORM_FIELDS = {
password: "password",
googleCredential: "google_credential",
grantId: "grant_id",
tokenLabel: "token_label",
tokenLifetime: "token_lifetime",
tokenId: "token_id",
} as const;
export const CONNECTIONS_PATHS = {
@@ -41,8 +62,14 @@ export const CONNECTIONS_PATHS = {
signIn: "/connections/sign-in",
signOut: "/connections/sign-out",
revoke: "/connections/revoke",
createToken: "/connections/tokens/create",
revokeToken: "/connections/tokens/revoke",
} as const;
/** The lifetimes a user can pick, in days; the form value is the key. */
export const TOKEN_LIFETIME_DAYS = ["30", "90"] as const;
export type TokenLifetimeDays = (typeof TOKEN_LIFETIME_DAYS)[number];
export function renderConnectionsPage(view: ConnectionsView): string {
const element: unknown = <ConnectionsPage view={view} />;
if (!isValidElement(element)) throw new Error("connections page did not render to a JSX node");
@@ -77,7 +104,7 @@ function ConnectionsPage({ view }: { view: ConnectionsView }) {
<SignIn view={view} />
)}
</main>
{view.googleClientId && !view.signedIn ? (
{view.googleClientId ? (
<>
<script nonce={view.scriptNonce}>{GOOGLE_CALLBACK_SCRIPT}</script>
<script src="https://accounts.google.com/gsi/client" async></script>
@@ -98,25 +125,9 @@ function SignIn({ view }: { view: ConnectionsView }) {
</p>
<form method="post" action={CONNECTIONS_PATHS.signIn} id="sign-in-form">
<input type="hidden" name={f.csrf} value={view.csrfToken} />
<input type="hidden" name={f.method} value="password" id="method" />
<input type="hidden" name={f.googleCredential} value="" id="google-credential" />
{view.googleClientId ? (
<div class="google">
<div
id="g_id_onload"
data-client_id={view.googleClientId}
data-callback="onGoogleCredential"
data-auto_prompt="false"
></div>
<div
class="g_id_signin"
data-type="standard"
data-text="continue_with"
data-size="large"
></div>
<p class="or">or sign in with your password</p>
</div>
) : null}
<input type="hidden" name={f.method} value="password" />
<input type="hidden" name={f.googleCredential} value="" />
{view.googleClientId ? <GoogleButton clientId={view.googleClientId} /> : null}
<label>
Username or email
<input
@@ -186,10 +197,127 @@ function SignedIn({
<p class="note small">
Disconnecting stops the app immediately; it will ask you to sign in again if you reconnect.
</p>
<PersonalTokens view={view} signedIn={signedIn} />
</>
);
}
function PersonalTokens({
view,
signedIn,
}: {
view: ConnectionsView;
signedIn: NonNullable<ConnectionsView["signedIn"]>;
}) {
const f = CONNECTIONS_FORM_FIELDS;
return (
<section class="tokens">
<h2>Personal tokens</h2>
<p class="note">
For integrations that take a fixed token instead of signing you in — the OpenAI Responses
API (<code>authorization</code>) and the Anthropic Messages API connector (
<code>authorization_token</code>). A token can only read: your account and credits, your
generations, and models and prices.
</p>
{signedIn.newToken ? (
<div class="new-token" role="status">
<p>
<strong>{signedIn.newToken.label}</strong> is ready. Copy it now — it will not be shown
again.
</p>
<code class="secret">{signedIn.newToken.secret}</code>
<p class="meta">
Send it as <code>Authorization: Bearer …</code>. Expires {signedIn.newToken.expiresAt}.
</p>
</div>
) : null}
{signedIn.personalTokens.length === 0 ? null : (
<ul class="connections">
{signedIn.personalTokens.map((token) => (
<li>
<div>
<strong>{token.label}</strong>
<span class="meta">
{token.hint} · created {token.createdAt} · expires {token.expiresAt}
</span>
</div>
<form method="post" action={CONNECTIONS_PATHS.revokeToken}>
<input type="hidden" name={f.csrf} value={view.csrfToken} />
<input type="hidden" name={f.tokenId} value={token.id} />
<button type="submit" class="secondary">
Revoke
</button>
</form>
</li>
))}
</ul>
)}
<form method="post" action={CONNECTIONS_PATHS.createToken} id="create-token-form">
<input type="hidden" name={f.csrf} value={view.csrfToken} />
<input type="hidden" name={f.method} value="password" />
<input type="hidden" name={f.googleCredential} value="" />
<h3>New token</h3>
<label>
Name
<input
type="text"
name={f.tokenLabel}
placeholder="e.g. OpenAI Responses"
maxlength={64}
autocomplete="off"
required
/>
</label>
<label>
Lifetime
<select name={f.tokenLifetime}>
{TOKEN_LIFETIME_DAYS.map((days) => (
<option value={days}>{days} days</option>
))}
</select>
</label>
<p class="note small">
Confirm it is you: the token gets its own sign-in, separate from this page and from your
browser.
</p>
{view.googleClientId ? <GoogleButton clientId={view.googleClientId} /> : null}
<label>
Password
<input type="password" name={f.password} autocomplete="current-password" />
</label>
<div class="actions">
<button type="submit" class="primary">
Create token
</button>
</div>
</form>
<p class="note small">
Revoking a token stops it immediately. Tokens expire on their own after at most 90 days.
</p>
</section>
);
}
function GoogleButton({ clientId }: { clientId: string }) {
return (
<div class="google">
<div
id="g_id_onload"
data-client_id={clientId}
data-callback="onGoogleCredential"
data-auto_prompt="false"
></div>
<div
class="g_id_signin"
data-type="standard"
data-text="continue_with"
data-size="large"
></div>
<p class="or">or use your password</p>
</div>
);
}
function describeScopes(scopes: readonly string[]): string {
const words = scopes
.map((scope) => SCOPE_WORDS[scope] ?? scope)
@@ -203,11 +331,12 @@ const SCOPE_WORDS: Record<string, string> = {
"read:catalog": "see models and prices",
};
/** One Google button per page; it submits whichever form is on it (sign-in or create-token). */
const GOOGLE_CALLBACK_SCRIPT = `
function onGoogleCredential(response) {
document.getElementById("method").value = "google";
document.getElementById("google-credential").value = response.credential;
var form = document.getElementById("sign-in-form");
var form = document.getElementById("sign-in-form") || document.getElementById("create-token-form");
form.querySelector('[name="method"]').value = "google";
form.querySelector('[name="google_credential"]').value = response.credential;
form.noValidate = true;
form.requestSubmit ? form.requestSubmit() : form.submit();
}
@@ -221,6 +350,8 @@ body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.5 system
.card { width:100%; max-width:520px; border:1px solid var(--line); border-radius:8px; padding:28px; }
.eyebrow { font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:var(--accent); margin:0 0 8px; font-weight:600; }
h1 { font-size:22px; line-height:1.25; margin:0 0 16px; font-weight:600; }
h2 { font-size:17px; margin:28px 0 8px; font-weight:600; }
h3 { font-size:15px; margin:18px 0 0; font-weight:600; }
.note { margin:0 0 12px; font-size:14px; opacity:.85; } .note.small { font-size:13px; opacity:.7; margin-top:16px; }
.notice { border-left:3px solid var(--accent); padding:8px 12px; margin:12px 0; font-size:14px; }
.error { border-left:3px solid var(--risk); padding:8px 12px; margin:12px 0; font-size:14px; }
@@ -228,8 +359,13 @@ h1 { font-size:22px; line-height:1.25; margin:0 0 16px; font-weight:600; }
.connections { list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:10px; }
.connections li { display:flex; justify-content:space-between; align-items:center; gap:12px; border:1px solid var(--line); border-radius:6px; padding:12px 14px; }
.connections .meta { display:block; font-size:13px; opacity:.7; }
.tokens { border-top:1px solid var(--line); margin-top:24px; }
.new-token { border:1px solid var(--accent); border-radius:6px; padding:12px 14px; margin:12px 0; font-size:14px; }
.new-token p { margin:0 0 8px; } .new-token .meta { font-size:13px; opacity:.7; margin:8px 0 0; }
.secret { display:block; font:13px/1.5 ui-monospace, monospace; word-break:break-all; user-select:all; padding:8px 10px; border:1px dashed var(--line); border-radius:4px; }
code { font:13px ui-monospace, monospace; }
label { display:block; margin:14px 0 0; font-size:14px; font-weight:600; }
input[type=text], input[type=password] { display:block; width:100%; margin-top:6px; padding:10px 12px; border:1px solid var(--line); border-radius:6px; background:transparent; color:inherit; font:inherit; }
input[type=text], input[type=password], select { display:block; width:100%; margin-top:6px; padding:10px 12px; border:1px solid var(--line); border-radius:6px; background:transparent; color:inherit; font:inherit; }
.actions { display:flex; gap:10px; margin-top:20px; }
button { padding:10px 14px; border-radius:6px; border:1px solid var(--line); font:inherit; font-weight:600; cursor:pointer; background:transparent; color:inherit; }
button.primary { background:var(--accent); border-color:var(--accent); color:#fff; flex:1; }
+137 -8
View File
@@ -2,13 +2,22 @@ import { type Context, Hono } from "hono";
import type { Authenticator, SignInSubmission } from "../auth/authenticator";
import { csrfCookieHeader, csrfTokensMatch, generateCsrfToken, readCsrfCookie } from "../auth/csrf";
import {
createPersonalTokenStore,
PersonalTokenError,
type PersonalTokenSummary,
} from "../tokens/personal-token-store";
import { signOutUpstream } from "../upstream/sign-out";
import {
CONNECTIONS_FORM_FIELDS,
CONNECTIONS_PATHS,
type ConnectionsView,
type ConnectionView,
type NewTokenView,
type PersonalTokenView,
renderConnectionsPage,
TOKEN_LIFETIME_DAYS,
type TokenLifetimeDays,
} from "./connections-page";
import {
clearedUiSessionCookieHeader,
@@ -20,12 +29,16 @@ import {
/**
* `/connections`: sign in (through the same Authenticator as consent), see the grants held
* against your Artcraft account, disconnect any of them. The upstream session obtained to
* identify the visitor is ended straight away — the page keeps only who they are.
* against your Artcraft account, disconnect any of them, and manage personal tokens. The
* upstream session obtained to identify the visitor is ended straight away — the page keeps
* only who they are.
*
* Revoking a grant kills its tokens; its encrypted upstream session becomes unreachable (the
* key material is wrapped by those tokens) but is not itself logged out — the props cannot be
* decrypted without a token. Recorded in docs/backend-handoff.md.
* Creating a personal token asks for the password (or Google) again: the token must hold a
* session of its own, obtained for it, and the account signed in must be the one on the page.
*
* Revoking a grant or a token kills it; its encrypted upstream session becomes unreachable
* (the key material is wrapped by the token) but is not itself logged out — the record cannot
* be decrypted without the token. Recorded in docs/backend-handoff.md.
*/
export interface ConnectionsDeps {
@@ -39,8 +52,11 @@ type PageContext = Context<{ Bindings: Cloudflare.Env }>;
const NOTICES: Record<string, string> = {
disconnected: "Disconnected. That app will have to ask you to sign in again.",
signed_out: "Signed out of this page.",
token_revoked: "Token revoked. Anything still using it will be refused.",
};
const DAY_SECONDS = 24 * 60 * 60;
export function connectionsRoutes(deps: ConnectionsDeps): Hono<{ Bindings: Cloudflare.Env }> {
const app = new Hono<{ Bindings: Cloudflare.Env }>();
@@ -96,6 +112,83 @@ export function connectionsRoutes(deps: ConnectionsDeps): Hono<{ Bindings: Cloud
return c.redirect(`${CONNECTIONS_PATHS.page}?notice=disconnected`, 303);
});
app.post(CONNECTIONS_PATHS.createToken, async (c) => {
const form = await readForm(c);
if (!csrfTokensMatch(readCsrfCookie(c.req.raw), form[CONNECTIONS_FORM_FIELDS.csrf])) {
return c.text("This form has expired. Reload the page and try again.", 403);
}
const session = await currentSession(c);
if (!session) {
return render(c, undefined, { status: 401, error: "Sign in again to create a token." });
}
const label = form[CONNECTIONS_FORM_FIELDS.tokenLabel]?.trim() ?? "";
const lifetime = readLifetime(form[CONNECTIONS_FORM_FIELDS.tokenLifetime]);
if (!label || !lifetime) {
return render(c, session, { status: 400, error: "Give the token a name and a lifetime." });
}
const submission = readSubmission(form, session.username);
if (!submission) {
return render(c, session, { status: 400, error: "Confirm your password to create a token." });
}
const outcome = await deps.authenticator.authenticate(submission);
if (!outcome.ok) {
return render(c, session, {
status: outcome.reason === "upstream_unavailable" ? 502 : 401,
error: outcome.message,
});
}
if (outcome.user.userToken !== session.userToken) {
// Someone confirmed with a different account: drop that session, keep this page's.
c.executionCtx.waitUntil(signOutUpstream(deps.upstreamApiHost, outcome.user.credential));
return render(c, session, {
status: 403,
error: `Confirm with the account signed in on this page (${session.username}).`,
});
}
try {
const { secret, summary } = await createPersonalTokenStore(c.env.OAUTH_KV).create({
user: {
userToken: outcome.user.userToken,
username: outcome.user.username,
displayName: outcome.user.displayName,
credential: outcome.user.credential.toProps(),
},
label,
ttlSeconds: Number(lifetime) * DAY_SECONDS,
nowMs: Date.now(),
});
// Rendered in this response, never redirected: the secret must not appear in a URL.
return await render(c, session, {
newToken: { label: summary.label, secret, expiresAt: formatDate(summary.expiresAt) },
});
} catch (error) {
if (error instanceof PersonalTokenError) {
// The session obtained for the token is not needed after all.
c.executionCtx.waitUntil(signOutUpstream(deps.upstreamApiHost, outcome.user.credential));
return render(c, session, {
status: 400,
error: `Could not create the token: ${error.message}.`,
});
}
throw error;
}
});
app.post(CONNECTIONS_PATHS.revokeToken, async (c) => {
const form = await readForm(c);
if (!csrfTokensMatch(readCsrfCookie(c.req.raw), form[CONNECTIONS_FORM_FIELDS.csrf])) {
return c.text("This form has expired. Reload the page and try again.", 403);
}
const session = await currentSession(c);
if (!session) {
return render(c, undefined, { status: 401, error: "Sign in again to manage tokens." });
}
const id = form[CONNECTIONS_FORM_FIELDS.tokenId] ?? "";
// Scoped to the signed-in user: someone else's token id is a no-op, never an error.
if (id) await createPersonalTokenStore(c.env.OAUTH_KV).revoke(session.userToken, id);
return c.redirect(`${CONNECTIONS_PATHS.page}?notice=token_revoked`, 303);
});
app.post(CONNECTIONS_PATHS.signOut, async (c) => {
const form = await readForm(c);
if (!csrfTokensMatch(readCsrfCookie(c.req.raw), form[CONNECTIONS_FORM_FIELDS.csrf])) {
@@ -112,10 +205,11 @@ export function connectionsRoutes(deps: ConnectionsDeps): Hono<{ Bindings: Cloud
c: PageContext,
session: UiSession | undefined,
options: {
status?: 200 | 400 | 401 | 502;
status?: 200 | 400 | 401 | 403 | 502;
error?: string;
notice?: string;
usernameOrEmail?: string | undefined;
newToken?: NewTokenView;
},
): Promise<Response> {
const csrfToken = generateCsrfToken();
@@ -132,6 +226,8 @@ export function connectionsRoutes(deps: ConnectionsDeps): Hono<{ Bindings: Cloud
signedIn: {
username: session.username,
connections: await listConnections(c, session),
personalTokens: await listPersonalTokens(c, session),
...(options.newToken ? { newToken: options.newToken } : {}),
},
}
: {}),
@@ -159,6 +255,17 @@ export function connectionsRoutes(deps: ConnectionsDeps): Hono<{ Bindings: Cloud
}));
}
async function listPersonalTokens(
c: PageContext,
session: UiSession,
): Promise<PersonalTokenView[]> {
const tokens = await createPersonalTokenStore(c.env.OAUTH_KV).list(
session.userToken,
Date.now(),
);
return tokens.map(tokenView);
}
async function currentSession(c: PageContext): Promise<UiSession | undefined> {
return createUiSessionStore(c.env.OAUTH_KV).read(readUiSessionCookie(c.req.raw));
}
@@ -173,18 +280,40 @@ async function readForm(c: PageContext): Promise<Record<string, string | undefin
return fields;
}
function readSubmission(form: Record<string, string | undefined>): SignInSubmission | undefined {
/**
* The sign-in fields of a form. With `usernameOverride` (token creation) the account is the
* page's, not the form's: a user confirms with their password, they do not pick an account.
*/
function readSubmission(
form: Record<string, string | undefined>,
usernameOverride?: string,
): SignInSubmission | undefined {
if (form[CONNECTIONS_FORM_FIELDS.method] === "google") {
const credential = form[CONNECTIONS_FORM_FIELDS.googleCredential] ?? "";
return credential ? { method: "google", credential } : undefined;
}
const usernameOrEmail = form[CONNECTIONS_FORM_FIELDS.usernameOrEmail]?.trim() ?? "";
const usernameOrEmail =
usernameOverride ?? form[CONNECTIONS_FORM_FIELDS.usernameOrEmail]?.trim() ?? "";
const password = form[CONNECTIONS_FORM_FIELDS.password] ?? "";
return usernameOrEmail && password
? { method: "password", usernameOrEmail, password }
: undefined;
}
function readLifetime(value: string | undefined): TokenLifetimeDays | undefined {
return TOKEN_LIFETIME_DAYS.find((days) => days === value);
}
function tokenView(token: PersonalTokenSummary): PersonalTokenView {
return {
id: token.id,
label: token.label,
hint: token.hint,
createdAt: formatDate(token.createdAt),
expiresAt: formatDate(token.expiresAt),
};
}
function clientNameOf(metadata: unknown): string | undefined {
if (typeof metadata !== "object" || metadata === null) return undefined;
const name = (metadata as { clientName?: unknown }).clientName;
+58 -2
View File
@@ -44,6 +44,7 @@ describe("connections page, signed out", () => {
describe("connections page, signed in", () => {
const signedIn = {
username: "localdev1",
personalTokens: [],
connections: [
{
grantId: "grant_1",
@@ -78,7 +79,10 @@ describe("connections page, signed in", () => {
});
it("says so when nothing is connected", () => {
const html = renderConnectionsPage({ ...BASE, signedIn: { username: "u", connections: [] } });
const html = renderConnectionsPage({
...BASE,
signedIn: { username: "u", connections: [], personalTokens: [] },
});
expect(html).toContain("No AI apps are connected");
expect(html).not.toContain(CONNECTIONS_PATHS.revoke);
});
@@ -87,8 +91,60 @@ describe("connections page, signed in", () => {
const html = renderConnectionsPage({
...BASE,
notice: "Disconnected Claude.",
signedIn: { username: "u", connections: [] },
signedIn: { username: "u", connections: [], personalTokens: [] },
});
expect(html).toContain("Disconnected Claude.");
});
});
describe("connections page, personal tokens", () => {
const signedIn = {
username: "localdev1",
connections: [],
personalTokens: [
{
id: "tok_1",
label: `<script>alert(1)</script>`,
hint: "artcraft_pat_…abcd",
createdAt: "2026-08-25",
expiresAt: "2026-11-23",
},
],
};
it("lists tokens with their hint and a revoke form, and offers the create form", () => {
const html = renderConnectionsPage({ ...BASE, signedIn });
expect(html).toContain("artcraft_pat_…abcd");
expect(html).toContain(`action="${CONNECTIONS_PATHS.revokeToken}"`);
expect(html).toContain(`name="${CONNECTIONS_FORM_FIELDS.tokenId}" value="tok_1"`);
expect(html).toContain(`action="${CONNECTIONS_PATHS.createToken}"`);
expect(html).toContain(`name="${CONNECTIONS_FORM_FIELDS.tokenLabel}"`);
expect(html).toContain(`name="${CONNECTIONS_FORM_FIELDS.tokenLifetime}"`);
expect(html).toContain('<option value="30">30 days</option>');
expect(html).toContain('<option value="90">90 days</option>');
expect(html).toContain("authorization_token");
expect(html).not.toContain("<script>alert");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});
it("shows a newly created secret exactly where the user can copy it", () => {
const html = renderConnectionsPage({
...BASE,
signedIn: {
...signedIn,
newToken: { label: "OpenAI", secret: "artcraft_pat_SECRET", expiresAt: "2026-09-24" },
},
});
expect(html).toContain('<code class="secret">artcraft_pat_SECRET</code>');
expect(html).toContain("Copy it now");
expect(html).toContain("Expires 2026-09-24");
});
it("puts the Google button on the create form only when configured", () => {
const plain = renderConnectionsPage({ ...BASE, signedIn });
expect(plain).not.toContain("accounts.google.com");
const withGoogle = renderConnectionsPage({ ...BASE, signedIn, googleClientId: "1.apps" });
expect(withGoogle).toContain('data-client_id="1.apps"');
expect(withGoogle).toContain('id="create-token-form"');
});
});
+100
View File
@@ -16,6 +16,7 @@ import {
call,
callMcp,
exchangeCode,
mcpInitialize,
ORIGIN,
pkce,
type TokenResponse,
@@ -272,3 +273,102 @@ describe("/connections", () => {
expect(after.html).toContain(`action="${CONNECTIONS_PATHS.signIn}"`);
});
});
describe("/connections personal tokens", () => {
const SECRET_PATTERN = /artcraft_pat_[A-Za-z0-9_-]{43}/;
async function createToken(
sessionCookie: string,
fields: Partial<Record<string, string>> = {},
): Promise<{ response: Response; html: string }> {
const page = await openPage(sessionCookie);
const response = await post(
CONNECTIONS_PATHS.createToken,
{
[CONNECTIONS_FORM_FIELDS.csrf]: page.csrf,
[CONNECTIONS_FORM_FIELDS.method]: "password",
[CONNECTIONS_FORM_FIELDS.password]: SEEDED_USER.password,
[CONNECTIONS_FORM_FIELDS.tokenLabel]: "Responses API",
[CONNECTIONS_FORM_FIELDS.tokenLifetime]: "30",
...fields,
},
page.cookies,
);
return { response, html: await response.text() };
}
it("creates a token after a password confirmation, shows it once, and it works at /mcp", async () => {
const sessionCookie = await signIn();
const { response, html } = await createToken(sessionCookie);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
const secret = SECRET_PATTERN.exec(html)?.[0] ?? "";
expect(secret).toMatch(SECRET_PATTERN);
expect(html).toContain("Copy it now");
expect((await mcpInitialize(secret)).status).toBe(200);
const again = await openPage(sessionCookie);
expect(again.html).toContain("<strong>Responses API</strong>");
expect(again.html).toContain(`artcraft_pat_…${secret.slice(-4)}`);
expect(again.html).not.toContain(secret);
});
it("refuses to create a token on a wrong password, a missing name, or without a session", async () => {
const sessionCookie = await signIn();
const wrong = await createToken(sessionCookie, { [CONNECTIONS_FORM_FIELDS.password]: "nope" });
expect(wrong.response.status).toBe(401);
expect(wrong.html).not.toMatch(SECRET_PATTERN);
const unnamed = await createToken(sessionCookie, { [CONNECTIONS_FORM_FIELDS.tokenLabel]: " " });
expect(unnamed.response.status).toBe(400);
const badLifetime = await createToken(sessionCookie, {
[CONNECTIONS_FORM_FIELDS.tokenLifetime]: "365",
});
expect(badLifetime.response.status).toBe(400);
const anonymous = await createToken("");
expect(anonymous.response.status).toBe(401);
expect(anonymous.html).not.toMatch(SECRET_PATTERN);
});
it("revokes a token from the page so it stops working", async () => {
const sessionCookie = await signIn();
const { html } = await createToken(sessionCookie, {
[CONNECTIONS_FORM_FIELDS.tokenLabel]: "Revoke me",
});
const secret = SECRET_PATTERN.exec(html)?.[0] ?? "";
expect((await mcpInitialize(secret)).status).toBe(200);
const page = await openPage(sessionCookie);
const id = new RegExp(
`name="${CONNECTIONS_FORM_FIELDS.tokenId}" value="([^"]+)"[^]{0,200}Revoke</button>`,
);
// The token id sits in the list item labelled "Revoke me"; find that item, then its id.
const item = page.html
.split("<li>")
.find((chunk) => chunk.includes("<strong>Revoke me</strong>"));
const tokenId = /name="token_id" value="([^"]+)"/.exec(item ?? "")?.[1] ?? "";
expect(tokenId).toMatch(/.+/);
expect(id.test(page.html)).toBe(true);
const revoked = await post(
CONNECTIONS_PATHS.revokeToken,
{ [CONNECTIONS_FORM_FIELDS.csrf]: page.csrf, [CONNECTIONS_FORM_FIELDS.tokenId]: tokenId },
page.cookies,
);
expect(revoked.status).toBe(303);
expect(revoked.headers.get("location")).toContain("notice=token_revoked");
expect((await mcpInitialize(secret)).status).toBe(401);
expect((await openPage(sessionCookie)).html).not.toContain("<strong>Revoke me</strong>");
});
it("requires the CSRF token on both token forms", async () => {
const sessionCookie = await signIn();
for (const path of [CONNECTIONS_PATHS.createToken, CONNECTIONS_PATHS.revokeToken]) {
const response = await post(path, { [CONNECTIONS_FORM_FIELDS.csrf]: "nope" }, sessionCookie);
expect(response.status).toBe(403);
}
});
});