feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s

MindSift becomes the OAuth authorization server for its own MCP
endpoint: discovery (RFC 9728/8414), dynamic client registration,
authorization code with PKCE (S256), rotating refresh tokens with reuse
detection. The consent page lives in the app at /oauth/authorize. A
grant is an api_keys entry (kind 'oauth'), so scopes, origin badges,
renaming and revoking work as for keys; tokens are valid for /mcp only.
Unused connections expire after OAUTH_IDLE_DAYS (default 90). Requires
BASE_URL; off in demo mode. Release 0.51.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 20:01:32 +01:00
1 parent 994f6dc554
commit 75aea4a7d4
31 files changed
+1581 -49

No files matched your search

@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { Brain, Bot, AlertCircle, ExternalLink } from 'lucide-react';
import { ApiKeyScope, OAuthAuthorizeInfo, User } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface OAuthConsentViewProps {
user: User;
instanceName: string;
onLogout: () => void;
}
// Consent page of the OAuth flow (/oauth/authorize): an AI client such as claude.ai wants to use
// MindSift over MCP. The person picks the rights; the server answers with the URL to return to the
// client (with a code or access_denied). Shown instead of the app while the address is /oauth/authorize.
export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instanceName, onLogout }) => {
const query = window.location.search;
const [info, setInfo] = useState<OAuthAuthorizeInfo | null>(null);
const [scopes, setScopes] = useState<ApiKeyScope[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api
.getOAuthAuthorizeInfo(query)
.then((res) => {
// Invalid request the client must hear about: straight back
if (res.redirect) {
window.location.replace(res.redirect);
return;
}
setInfo(res);
setScopes(res.scopes || []);
})
.catch((err) => setError(err?.message || 'Die Anfrage ist ungültig.'));
}, [query]);
const toggle = (id: ApiKeyScope) =>
setScopes((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
const answer = async (approve: boolean) => {
triggerHaptic(15);
setBusy(true);
setError(null);
try {
const res = await api.answerOAuthAuthorize(query, approve, scopes);
window.location.replace(res.redirect);
} catch (err: any) {
setError(err?.message || 'Das hat nicht geklappt.');
setBusy(false);
}
};
const offered = info?.offeredScopes || [];
const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id)));
return (
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
<div className="p-4 bg-canvas border-b border-border flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<h1 className="text-sm font-bold text-main tracking-tight leading-none">{instanceName}</h1>
</div>
<div className="p-4 space-y-4">
{!info && !error && <p className="text-xs text-muted">Anfrage wird geprüft…</p>}
{error && (
<div className="flex items-start gap-2 text-xs text-status-err">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-px" />
<span>{error}</span>
</div>
)}
{info && (
<>
<div className="flex items-start gap-3">
<div className="w-9 h-9 bg-accent/15 text-accent rounded-btn flex items-center justify-center flex-shrink-0">
<Bot className="w-5 h-5" />
</div>
<div className="min-w-0">
<h2 className="text-sm font-bold text-main">{info.clientName} möchte auf dein MindSift zugreifen</h2>
<p className="text-xs text-muted mt-0.5">
Angemeldet als <span className="text-main">{user.displayName || user.email}</span>. Freigaben gelten wie
für dich selbst; Notizen und Einstellungen bleiben gesperrt.
</p>
</div>
</div>
<div className="space-y-2" role="group" aria-label="Rechte">
{groups.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group && offered.includes(sc.id)).map((sc) => (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={scopes.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
</div>
))}
</div>
))}
</div>
<p className="text-[11px] text-faint flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5 flex-shrink-0" />
<span>
Danach geht es zurück zu <span className="font-medium text-main">{info.redirectHost}</span>. Die Verbindung
steht unter Einstellungen → API-Schlüssel und lässt sich dort jederzeit widerrufen.
</span>
</p>
<div className="flex gap-2">
<button
type="button"
disabled={busy}
onClick={() => answer(false)}
className="btn-action h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
>
Ablehnen
</button>
<button
type="button"
disabled={busy || scopes.length === 0}
onClick={() => answer(true)}
className="btn-primary h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
>
Erlauben
</button>
</div>
</>
)}
<button type="button" onClick={onLogout} className="text-[11px] text-muted hover:text-main underline h-11 sm:h-auto">
Mit einem anderen Konto anmelden
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const getOAuthAuthorizeInfo = vi.fn();
const answerOAuthAuthorize = vi.fn(async () => ({ redirect: 'https://claude.ai/cb?code=c' }));
vi.mock('../../../lib/api', () => ({
api: {
getOAuthAuthorizeInfo: (...args: unknown[]) => getOAuthAuthorizeInfo(...(args as [])),
answerOAuthAuthorize: (...args: unknown[]) => answerOAuthAuthorize(...(args as [])),
},
triggerHaptic: vi.fn(),
}));
import { OAuthConsentView } from '../OAuthConsentView';
const user = { id: 'u', email: 'a@b.de', displayName: 'Anna', role: 'member', isActive: true } as never;
const replace = vi.fn();
describe('OAuthConsentView', () => {
beforeEach(() => {
replace.mockClear();
answerOAuthAuthorize.mockClear();
getOAuthAuthorizeInfo.mockReset();
Object.defineProperty(window, 'location', {
configurable: true,
value: { search: '?client_id=mcl_1&state=s', replace },
});
});
it('preselects the requested rights and sends the chosen ones', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({
clientName: 'Claude',
redirectHost: 'claude.ai',
scopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write'],
offeredScopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write', 'webhook:triage'],
});
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
expect(await screen.findByText('Claude möchte auf dein MindSift zugreifen')).toBeTruthy();
expect(screen.getByText('claude.ai')).toBeTruthy();
expect(screen.getByRole('switch', { name: 'Inbox' }).getAttribute('aria-checked')).toBe('false');
// Narrow "create only" rights are not offered here
expect(screen.queryByRole('switch', { name: 'Aufgaben anlegen' })).toBeNull();
fireEvent.click(screen.getByRole('switch', { name: 'Listen ändern' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Erlauben' }));
});
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', true, ['tasks:read', 'tasks:write', 'lists:read']);
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?code=c');
});
it('refuses, and cannot allow without any right', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({ clientName: 'X', redirectHost: 'x.example', scopes: ['tasks:read'], offeredScopes: ['tasks:read'] });
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
fireEvent.click(await screen.findByRole('switch', { name: 'Aufgaben lesen' }));
expect((screen.getByRole('button', { name: 'Erlauben' }) as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' }));
});
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', false, []);
});
it('goes straight back when the server says the request is invalid for the client', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({ redirect: 'https://claude.ai/cb?error=invalid_request' });
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
await act(async () => {});
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?error=invalid_request');
});
it('shows the error when there is nowhere safe to return to', async () => {
getOAuthAuthorizeInfo.mockRejectedValue(new Error('Die Anwendung ist MindSift nicht bekannt.'));
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
expect(await screen.findByText('Die Anwendung ist MindSift nicht bekannt.')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Erlauben' })).toBeNull();
});
});