mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 07:27:51 +00:00
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:
1 parent
76269441fe
commit
4f2f082403
4 files changed
+457
-36
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("<script>alert(1)</script>");
|
||||
});
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user