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:
kristianwind
2026-10-31 22:49:48 +01:00
co-authored by Claude Opus 5.5
parent 4a2aa81725
commit e7df876c4a
19 changed files with 1792 additions and 55 deletions
+76
View File
@@ -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();
});
+6
View File
@@ -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'),
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+5 -1
View File
@@ -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();
+224
View File
@@ -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}