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
@@ -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