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 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:
1 parent
994f6dc554
commit
75aea4a7d4
31 files changed
+1581
-49
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
APIKey,
|
||||
OAuthAuthorizeInfo,
|
||||
AuthStatus,
|
||||
DashboardStats,
|
||||
Document,
|
||||
@@ -1322,6 +1323,32 @@ export const api = {
|
||||
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- OAUTH CONSENT (claude.ai, ChatGPT connect to MCP) ---
|
||||
/** query: the search string of /oauth/authorize as the client sent it */
|
||||
async getOAuthAuthorizeInfo(query: string): Promise<OAuthAuthorizeInfo> {
|
||||
return request<OAuthAuthorizeInfo>(`/api/v1/oauth/authorize${query}`);
|
||||
},
|
||||
|
||||
/** Allow (with the chosen rights) or refuse; returns where to send the browser */
|
||||
async answerOAuthAuthorize(query: string, approve: boolean, scopes: ApiKeyScope[]): Promise<{ redirect: string }> {
|
||||
const q = new URLSearchParams(query);
|
||||
return request<{ redirect: string }>('/api/v1/oauth/authorize', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
responseType: q.get('response_type') || '',
|
||||
clientId: q.get('client_id') || '',
|
||||
redirectUri: q.get('redirect_uri') || '',
|
||||
codeChallenge: q.get('code_challenge') || '',
|
||||
codeChallengeMethod: q.get('code_challenge_method') || '',
|
||||
state: q.get('state') || '',
|
||||
scope: q.get('scope') || '',
|
||||
resource: q.get('resource') || '',
|
||||
approve,
|
||||
scopes,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
/** Simulator: sends like an external system would, with the given key or else the own session. */
|
||||
async testWebhookIngest(
|
||||
apiKey: string | null,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiKeyScope } from './types';
|
||||
|
||||
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
|
||||
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
|
||||
export type ScopeGroup = 'tasks' | 'lists' | 'inbox';
|
||||
export const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
|
||||
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
|
||||
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
|
||||
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
|
||||
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
|
||||
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
|
||||
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
|
||||
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
|
||||
];
|
||||
export const SCOPE_GROUPS: [ScopeGroup, string][] = [
|
||||
['tasks', '📋 Aufgaben'],
|
||||
['lists', '🛒 Listen'],
|
||||
['inbox', '📥 Inbox'],
|
||||
];
|
||||
export const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
|
||||
@@ -20,8 +20,9 @@ export const isOffline = () => typeof navigator !== 'undefined' && navigator.onL
|
||||
const DB_NAME = 'mindsift-offline';
|
||||
const STORE = 'responses';
|
||||
|
||||
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
|
||||
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
|
||||
// Endpoints whose answers are worth keeping; streams, long polls, note sync, downloads and the OAuth
|
||||
// consent are not
|
||||
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/, /^\/api\/v1\/oauth/];
|
||||
|
||||
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AuthStatus {
|
||||
sharingMode?: SharingMode;
|
||||
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
|
||||
passwordReset?: boolean;
|
||||
// MCP address for claude.ai / ChatGPT connectors (OAuth); empty when the server has no BASE_URL
|
||||
mcpConnectorUrl?: string;
|
||||
// Demo mode with periodic reset: next reset (ISO) and seconds until then
|
||||
demoNextReset?: string;
|
||||
demoResetInSeconds?: number;
|
||||
@@ -674,6 +676,17 @@ export interface APIKey {
|
||||
scopes: string; // comma separated ApiKeyScope values
|
||||
lastUsedAt?: string;
|
||||
createdAt: string;
|
||||
// 'oauth': a connection agreed to on the consent page (claude.ai, ChatGPT), no secret to show
|
||||
kind?: 'key' | 'oauth';
|
||||
}
|
||||
|
||||
// What the consent page (/oauth/authorize) shows; redirect alone means "go back to the client"
|
||||
export interface OAuthAuthorizeInfo {
|
||||
redirect?: string;
|
||||
clientName?: string;
|
||||
redirectHost?: string;
|
||||
scopes?: ApiKeyScope[];
|
||||
offeredScopes?: ApiKeyScope[];
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
|
||||
Reference in new issue
Block a user