feat(mcp): forbind med kontoen i stedet for med et token
Claude's forbindelsesdialog prøver OAuth først. Indtil nu var svaret 404, og eneste vej ind var at lave et token og sætte det ind i en adresse. Nu er instansen sin egen autorisationsserver for MCP-klienter: - /.well-known/oauth-protected-resource(/mcp) og /.well-known/oauth-authorization-server peger på login'et; en 401 fra /mcp bærer WWW-Authenticate med resource_metadata - /oauth/register (RFC 7591), kun offentlige klienter; registreringer, ingen godkender, ryddes efter et døgn - /oauth/authorize sender til /forbind, som logger ind med det almindelige login og spørger "Claude vil forbinde til din konto" — og viser, hvor adgangen sendes hen, fordi navnet er klientens eget - /oauth/token: authorization code + S256 PKCE, koden gælder ét minut og én gang; ud kommer et almindeligt API-token, listet under Tokens med klientens navn, og en ny forbindelse fra samme klient erstatter det - /mcp tager nu også Authorization: Bearer; ?key= virker stadig Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4a2aa81725
commit
e7df876c4a
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { USER } from './user.js';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
@@ -6168,3 +6169,78 @@ test('en titel, der rettes i opgaveruden, tager gentagelse, tid, projekt og alar
|
||||
const { reminders } = await apiCall(page, 'GET', `/tasks/${task.id}/reminders`);
|
||||
expect(reminders.map((r) => r.offset_min)).toEqual([-60]);
|
||||
});
|
||||
|
||||
/**
|
||||
* At forbinde Claude med kontoen: hele dansen, som Claude danser den, med den
|
||||
* eneste del, en Go-prøve ikke kan se — at godkendelsessiden findes, logger én
|
||||
* ind, når det trænges, og at "Tillad" faktisk sender browseren tilbage med en
|
||||
* kode, der kan veksles til et token, som /mcp tager imod.
|
||||
*
|
||||
* Tilbageadressen er en fremmed vært, som prøven selv svarer for. En rigtig
|
||||
* claude.ai ville gøre prøven afhængig af nettet og af en side, vi ikke ejer.
|
||||
*/
|
||||
test('Claude kan forbinde med kontoen i stedet for med et token', async ({ page, browser }) => {
|
||||
const callback = 'https://claude.example/api/mcp/auth_callback';
|
||||
const registered = await page.request.post('/oauth/register', {
|
||||
data: { client_name: 'Claude', redirect_uris: [callback], token_endpoint_auth_method: 'none' }
|
||||
});
|
||||
expect(registered.status()).toBe(201);
|
||||
const { client_id } = await registered.json();
|
||||
|
||||
// S256 af en fast nøgle. Regnet i Node: en tom side er ikke en sikker
|
||||
// kontekst, så browserens crypto.subtle findes ikke der.
|
||||
const verifier = 'v'.repeat(43);
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
|
||||
const query = new URLSearchParams({
|
||||
response_type: 'code', client_id, redirect_uri: callback, state: 'tilstand',
|
||||
code_challenge: challenge, code_challenge_method: 'S256'
|
||||
}).toString();
|
||||
|
||||
// Uden en session: login-skærmen, på godkendelsessidens adresse, så man
|
||||
// lander tilbage på spørgsmålet bagefter.
|
||||
// Tom storageState: browser.newContext() i en prøve arver projektets, og
|
||||
// projektets er en logget-ind konto.
|
||||
const stranger = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const signedOut = await stranger.newPage();
|
||||
await signedOut.goto('/oauth/authorize?' + query);
|
||||
await expect(signedOut.getByLabel('E-mail')).toBeVisible();
|
||||
expect(new URL(signedOut.url()).pathname).toBe('/forbind');
|
||||
await stranger.close();
|
||||
|
||||
let returned = null;
|
||||
await page.route('https://claude.example/**', (route) => {
|
||||
returned = new URL(route.request().url());
|
||||
return route.fulfill({ status: 200, contentType: 'text/plain', body: 'ok' });
|
||||
});
|
||||
|
||||
await page.goto('/oauth/authorize?' + query);
|
||||
await expect(page.getByRole('heading', { name: 'Claude vil forbinde til din konto' })).toBeVisible();
|
||||
// Hvor koden sendes hen er det, en klient ikke kan lyve om — det skal stå der.
|
||||
await expect(page.getByText('claude.example', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Tillad' }).click();
|
||||
await expect.poll(() => returned?.searchParams.get('code') ?? null).not.toBeNull();
|
||||
expect(returned.searchParams.get('state')).toBe('tilstand');
|
||||
|
||||
const exchanged = await page.request.post('/oauth/token', {
|
||||
form: {
|
||||
grant_type: 'authorization_code', code: returned.searchParams.get('code'),
|
||||
client_id, redirect_uri: callback, code_verifier: verifier
|
||||
}
|
||||
});
|
||||
expect(exchanged.status()).toBe(200);
|
||||
const { access_token } = await exchanged.json();
|
||||
expect(access_token).toMatch(/^vrd_/);
|
||||
|
||||
const mcp = await page.request.post('/mcp', {
|
||||
headers: { Authorization: 'Bearer ' + access_token },
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }
|
||||
});
|
||||
expect(mcp.status()).toBe(200);
|
||||
expect((await mcp.json()).result.protocolVersion).toBeTruthy();
|
||||
|
||||
// Og den står under Tokens, hvor den kan tilbagekaldes.
|
||||
await page.goto('/indstillinger/tokens');
|
||||
await expect(page.getByText('forbundet med din konto')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -445,6 +445,12 @@ export const api = {
|
||||
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
|
||||
deleteToken: (id) => del(`/tokens/${id}`),
|
||||
|
||||
// --- signing an MCP client in with the account (routes/forbind)
|
||||
// The query goes over unchanged: the server reads it with the same function
|
||||
// that checked it on the way in, so the two cannot disagree about it.
|
||||
oauthRequest: (query) => get(`/oauth/request${query}`),
|
||||
oauthApprove: (query, approve) => post('/oauth/approve', { query, approve }),
|
||||
|
||||
// --- live sync, for the app (see stores.svelte.js)
|
||||
wsTicket: () => post('/ws-ticket'),
|
||||
|
||||
|
||||
@@ -498,6 +498,20 @@ export const da = {
|
||||
'ai.keyNewHint': 'Gemmes på serveren og vises aldrig igen.',
|
||||
'tokens.lastUsed': 'sidst brugt {when}',
|
||||
'tokens.neverUsed': 'aldrig brugt',
|
||||
'connect.title': 'Forbind',
|
||||
'connect.heading': '{name} vil forbinde til din konto',
|
||||
'connect.what': 'Den kan læse og ændre dine opgaver og noter — med præcis dine rettigheder, ikke flere.',
|
||||
'connect.whatNotes': 'Den kan læse og ændre dine noter — med præcis dine rettigheder, ikke flere.',
|
||||
'tokens.signInHint': 'Til Claude og andre MCP-programmer behøver du ikke et token: tilføj adressen nedenfor som forbindelse, og log ind, når programmet beder om det.',
|
||||
'connect.account': 'Konto',
|
||||
'connect.sentTo': 'Sendes til',
|
||||
'connect.revoke': 'Du kan afbryde forbindelsen når som helst under Indstillinger → Tokens.',
|
||||
'connect.allow': 'Tillad',
|
||||
'connect.deny': 'Afvis',
|
||||
'connect.brokenTitle': 'Forbindelsen kan ikke laves',
|
||||
'connect.broken': 'Programmet, der sendte dig hertil, bad om noget, der ikke kan godkendes. Fjern forbindelsen i programmet, og tilføj den igen.',
|
||||
'connect.toApp': 'Til appen',
|
||||
'tokens.connected': 'forbundet med din konto',
|
||||
'tokens.expired': 'udløbet {when}',
|
||||
'tokens.expiresOn': 'udløber {when}',
|
||||
'tokens.revokeNamed': 'Tilbagekald {name}',
|
||||
|
||||
@@ -492,6 +492,20 @@ export const en = {
|
||||
'ai.keyNewHint': 'Stored on the server and never shown again.',
|
||||
'tokens.lastUsed': 'last used {when}',
|
||||
'tokens.neverUsed': 'never used',
|
||||
'connect.title': 'Connect',
|
||||
'connect.heading': '{name} wants to connect to your account',
|
||||
'connect.what': 'It can read and change your tasks and notes — with exactly your permissions, no more.',
|
||||
'connect.whatNotes': 'It can read and change your notes — with exactly your permissions, no more.',
|
||||
'tokens.signInHint': 'Claude and other MCP programs do not need a token: add the address below as a connector, and sign in when it asks.',
|
||||
'connect.account': 'Account',
|
||||
'connect.sentTo': 'Sent to',
|
||||
'connect.revoke': 'You can disconnect it at any time under Settings → Tokens.',
|
||||
'connect.allow': 'Allow',
|
||||
'connect.deny': 'Deny',
|
||||
'connect.brokenTitle': 'This connection cannot be made',
|
||||
'connect.broken': 'The program that sent you here asked for something that cannot be approved. Remove the connection in that program and add it again.',
|
||||
'connect.toApp': 'To the app',
|
||||
'tokens.connected': 'connected with your account',
|
||||
'tokens.expired': 'expired {when}',
|
||||
'tokens.expiresOn': 'expires {when}',
|
||||
'tokens.revokeNamed': 'Revoke {name}',
|
||||
|
||||
@@ -52,8 +52,12 @@
|
||||
* screen. Without this the invite link showed a login form to a person who had
|
||||
* no account yet, and the reset link showed one to a person who had forgotten
|
||||
* the password it was asking for.
|
||||
*
|
||||
* `/forbind` is the third: the screen an MCP client sends somebody to. It
|
||||
* signs them in itself and then asks one question, and the shell around it
|
||||
* would be a way to wander off before answering it.
|
||||
*/
|
||||
let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname));
|
||||
let standalone = $derived(['/invite', '/reset', '/forbind'].includes($page.url.pathname));
|
||||
|
||||
$effect(() => {
|
||||
app.load();
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script>
|
||||
/**
|
||||
* The approval screen an MCP client sends somebody to: "Claude wants to
|
||||
* connect to your account".
|
||||
*
|
||||
* The server's /oauth/authorize checks the request and hands the browser
|
||||
* here with the query unchanged. Signing in, if it is needed, is the ordinary
|
||||
* sign-in screen — passkeys and second factor included — and this page takes
|
||||
* over once there is somebody to ask.
|
||||
*
|
||||
* Two things are shown, and only one of them can be trusted. The name is
|
||||
* whatever the client called itself when it registered: any program can call
|
||||
* itself Claude. Where the browser goes afterwards is the address the client
|
||||
* registered, and that is what decides who receives the access — so it is
|
||||
* shown, and shown as plainly as the name.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
const query = typeof location === 'undefined' ? '' : location.search;
|
||||
|
||||
/** @type {{ client_name: string, redirect_to: string } | null} */
|
||||
let request = $state(null);
|
||||
let problem = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!app.user || request || problem) return;
|
||||
api
|
||||
.oauthRequest(query)
|
||||
.then((r) => (request = r))
|
||||
.catch((e) => (problem = e.message || humanMessage(e)));
|
||||
});
|
||||
|
||||
async function answer(approve) {
|
||||
busy = true;
|
||||
try {
|
||||
const { redirect_to } = await api.oauthApprove(query, approve);
|
||||
// The server only ever answers with an address the client registered,
|
||||
// and refuses the schemes a browser would run. Checked again here
|
||||
// because this line is what hands it to the browser.
|
||||
const scheme = new URL(redirect_to).protocol;
|
||||
if (['javascript:', 'data:', 'vbscript:', 'blob:'].includes(scheme)) throw new Error('bad redirect');
|
||||
location.assign(redirect_to);
|
||||
} catch (e) {
|
||||
busy = false;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('connect.title')} · {app.productSlug}</title></svelte:head>
|
||||
|
||||
{#if !app.user}
|
||||
<SignIn />
|
||||
{:else}
|
||||
<div class="screen">
|
||||
<div class="card">
|
||||
<div class="brand">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<span class="slug">{app.productSlug}</span>
|
||||
</div>
|
||||
|
||||
{#if problem}
|
||||
<h1>{t('connect.brokenTitle')}</h1>
|
||||
<p class="lede">{t('connect.broken')}</p>
|
||||
<p class="detail mono">{problem}</p>
|
||||
<a class="link" href="/">{t('connect.toApp')}</a>
|
||||
{:else if !request}
|
||||
<p class="lede">…</p>
|
||||
{:else}
|
||||
<h1>{t('connect.heading', { name: request.client_name })}</h1>
|
||||
<p class="lede">{t(app.notesOnly ? 'connect.whatNotes' : 'connect.what')}</p>
|
||||
|
||||
<dl>
|
||||
<dt>{t('connect.account')}</dt>
|
||||
<dd>{app.user.name} · {app.user.email}</dd>
|
||||
<dt>{t('connect.sentTo')}</dt>
|
||||
<dd class="mono">{request.redirect_to}</dd>
|
||||
</dl>
|
||||
|
||||
<p class="hint">{t('connect.revoke')}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="allow" disabled={busy} onclick={() => answer(true)}>{t('connect.allow')}</button>
|
||||
<button class="deny" disabled={busy} onclick={() => answer(false)}>{t('connect.deny')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s4);
|
||||
background: var(--ground);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.rune {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.slug {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
padding: var(--s3) var(--s4);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--s2) var(--s4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hint,
|
||||
.detail {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.allow {
|
||||
padding: var(--s3);
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 550;
|
||||
transition: background var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.allow:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.deny {
|
||||
padding: var(--s2) var(--s4);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -99,6 +99,10 @@
|
||||
<p class="hint">
|
||||
{t('tokens.hint')}
|
||||
</p>
|
||||
<!-- The way most people want: no token at all. The address is the same
|
||||
for everybody on this instance, so it can be shown without a secret. -->
|
||||
<p class="hint">{t('tokens.signInHint')}</p>
|
||||
<p class="mono value">{location.origin}/mcp</p>
|
||||
</header>
|
||||
|
||||
{#if fresh}
|
||||
@@ -175,6 +179,7 @@
|
||||
<span class="token-name">{token.name}</span>
|
||||
<span class="meta mono">
|
||||
{token.prefix}…
|
||||
{#if token.connected}· {t('tokens.connected')}{/if}
|
||||
{#if token.last_used_at}
|
||||
· {t('tokens.lastUsed', { when: date(token.last_used_at) })}
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user