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

+9 -1
View File
@@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.51.0] - 2026-11-01
### Added
- MindSift lässt sich in **claude.ai** (auch in der Claude-App am Handy) und **ChatGPT** als Connector verbinden, ganz ohne API-Schlüssel: Adresse `…/api/v1/mcp` eintragen, in MindSift anmelden, auswählen, was der Agent darf, und erlauben. Vorbelegt sind Lesen und Ändern für Aufgaben und Listen. Braucht `BASE_URL`; im Demo-Modus nicht verfügbar.
- Einstellungen → API-Schlüssel: Verbindungen über claude.ai & Co. stehen mit dem Kennzeichen „OAuth“ in der Liste; Rechte ändern, umbenennen und trennen wie bei Schlüsseln. Unter „KI-Agenten (MCP)“ steht die Adresse für claude.ai / ChatGPT zum Kopieren.
- `OAUTH_IDLE_DAYS` (Standard 90): Verbindungen, die so lange nicht benutzt wurden, werden getrennt; `0` = nie.
## [0.50.0] - 2026-11-01
### Added
@@ -779,7 +786,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...dev
[0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0
[0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0
[0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2
[0.49.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.0...v0.49.1
+22 -1
View File
@@ -104,7 +104,28 @@ MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP,
Was ein Agent anlegt oder ändert, bekommt eine dauerhafte Herkunft (`origin`, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)). `create_task` geht auch mit `webhook:task`, `add_list_item` auch mit `webhook:list`.
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt.
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT verbinden sich per OAuth (nächster Abschnitt).
### OAuth für claude.ai und ChatGPT
Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `<BASE_URL>/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL` und im Demo-Modus antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist).
| Methode | Pfad (ohne `/api/v1`) | Zweck |
|---|---|---|
| GET | `/.well-known/oauth-protected-resource` (auch mit Anhang `/api/v1/mcp`) | Ressourcen-Beschreibung (RFC 9728): `resource`, `authorization_servers`, `scopes_supported` |
| GET | `/.well-known/oauth-authorization-server` | Server-Beschreibung (RFC 8414) |
| POST | `/oauth/register` | Dynamische Client-Registrierung (RFC 7591), JSON `client_name`, `redirect_uris` (nur `https://` oder `http://localhost`/`127.0.0.1`, höchstens 10). Alle Clients sind öffentlich (`token_endpoint_auth_method: none`); 20 Registrierungen pro Adresse und Viertelstunde |
| GET | `/oauth/authorize` | Zustimmungsseite in der App (Anmeldung, Rechte wählen, Erlauben/Ablehnen). Pflicht: `response_type=code`, `client_id`, `redirect_uri` (exakt registriert), `code_challenge` mit `code_challenge_method=S256`; optional `state`, `scope` (Leerzeichen-getrennte Rechte), `resource`. Die Antwort an den Client trägt `code`, `state` und `iss` bzw. `error` |
| POST | `/oauth/token` | Formular: `grant_type=authorization_code` (`code`, `client_id`, `redirect_uri`, `code_verifier`) oder `refresh_token` (`refresh_token`, `client_id`). Fehler als `{"error":"invalid_grant",…}` |
| POST | `/oauth/revoke` | Token widerrufen (RFC 7009); ein Refresh-Token beendet alle Tokens der Verbindung |
Die Zustimmungsseite nutzt `GET`/`POST /api/v1/oauth/authorize` (nur mit Anmeldung, nicht mit Schlüsseln).
- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- **Verbindung = Eintrag unter `/keys`** mit `kind: "oauth"` und dem Namen „<Client> (OAuth)“. Rechte ändern, umbenennen und löschen wie bei jedem Schlüssel; Herkunft (`origin`) mit diesem Namen. Erneutes Zustimmen desselben Clients verwendet den Eintrag weiter.
- **Tokens:** Code 10 Minuten und einmal gültig, Zugangs-Token (`mindsift_oat_…`) 1 Stunde, Refresh-Token (`mindsift_ort_…`) 90 Tage und bei jeder Erneuerung neu. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, enden alle Tokens der Verbindung. Zugangs-Tokens gelten nur für `POST /mcp`, nicht für REST oder Webhooks.
- **Verfall:** Eine Verbindung, die `OAUTH_IDLE_DAYS` (Standard 90, `0` = nie) nicht benutzt wurde, wird gelöscht.
- Ohne gültiges Token antwortet `/mcp` mit `401` und `WWW-Authenticate: Bearer resource_metadata="…"`.
### API-Schlüssel: Rechte und Herkunft
+1
View File
@@ -101,6 +101,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`; Notizen sind mit Keys nie erreichbar.
- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus.
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
+5 -2
View File
@@ -19,7 +19,10 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
| `api_keys` | Webhook-/API-Schlüssel und OAuth-Verbindungen | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`), `last_used_at`, `kind` (`key`/`oauth`, Migration 17), `oauth_client_id` |
| `oauth_clients` | Selbst registrierte OAuth-Clients (Migration 17); ohne Verbindung nach 24 Stunden gelöscht | `id` (`client_id`), `name`, `redirect_uris` (JSON), `created_at` |
| `oauth_codes` | Einmal-Codes der Zustimmung, 10 Minuten (Migration 17) | `code_hash` (SHA-256), `client_id`, `key_id` → `api_keys`, `redirect_uri`, `code_challenge`, `expires_at` |
| `oauth_tokens` | Zugangs- und Refresh-Tokens (Migration 17) | `token_hash` (SHA-256), `kind` (`access`/`refresh`), `key_id` → `api_keys` (löscht mit), `expires_at`, `replaced_at` (Refresh ersetzt) |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
### Gedanken und Aufgaben
@@ -88,7 +91,7 @@ teams 1─n team_members n─1 users
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“, 10 „Registrierung und Einladungslinks“, 11 „E-Mail-Bestätigung“, 12 „Markierung für Einträge von KI-Agenten“, 13 „Herkunft von Einträgen (API-Schlüssel)“, 14 „Erledigte Aufgaben aufräumen“, 15 „Spracheingabe pro Person“, 16 „Aufbewahrung und Papierkorb für Aufgaben“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“, 10 „Registrierung und Einladungslinks“, 11 „E-Mail-Bestätigung“, 12 „Markierung für Einträge von KI-Agenten“, 13 „Herkunft von Einträgen (API-Schlüssel)“, 14 „Erledigte Aufgaben aufräumen“, 15 „Spracheingabe pro Person“, 16 „Aufbewahrung und Papierkorb für Aufgaben“, 17 „OAuth für den MCP-Zugang“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
## Sicherung
+145
View File
@@ -0,0 +1,145 @@
# Plan: OAuth für den MCP-Zugang (Entwurf)
Stand 2026-11-01, **umgesetzt in v0.51.0**. Ziel: MindSift lässt sich in **claude.ai**, der Claude-App am Handy und **ChatGPT im Browser** als Connector eintragen. Diese Clients können keinen `X-API-Key`-Header setzen, sondern verlangen den OAuth-Ablauf aus der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). MindSift wird dafür selbst zum OAuth-Server für seinen eigenen MCP-Endpunkt.
Nicht Teil dieses Plans: Anmelden **bei** MindSift über fremde Anbieter (OIDC), siehe [registrierung-und-login.md](registrierung-und-login.md), Abschnitt 4.
## Heute
- MCP-Endpunkt `POST /api/v1/mcp`, Anmeldung über `authMiddleware`: API-Schlüssel (`mindsift_sec_…`) als `X-API-Key` oder `Authorization: Bearer`, alternativ eine Sitzung.
- Ein Schlüssel hat Rechte (`tasks:read`, `lists:write` …), `mcpToolAllowed` zeigt nur die erlaubten Werkzeuge, Änderungen bekommen die Herkunft `mcp` mit dem Namen des Schlüssels.
- Die Sitzung liegt im Frontend im `localStorage`, nicht in einem Cookie. Der Server sieht beim reinen Aufruf einer Seite also nicht, wer angemeldet ist.
## So sieht es für Nutzer aus
1. In claude.ai: *Einstellungen → Connectors → Eigenen Connector hinzufügen*, Adresse `https://<instanz>/api/v1/mcp`.
2. Der Browser öffnet MindSift. Wer nicht angemeldet ist, meldet sich wie gewohnt an.
3. Eine Zustimmungsseite: **„Claude möchte auf dein MindSift zugreifen“**, darunter die Rechte als Häkchen (Aufgaben lesen, Aufgaben ändern, Listen lesen, Listen ändern, in die Inbox schreiben), vorbelegt mit dem, was der Client anfragt. Knöpfe **Erlauben** / **Ablehnen**.
4. Zurück in claude.ai sind die Werkzeuge da.
5. In *Einstellungen → API-Schlüssel* steht der Zugang als eigener Eintrag „Claude (OAuth)“ mit Rechten, „zuletzt benutzt“ und **Widerrufen**. Rechte ändern geht dort wie bei jedem Schlüssel.
Was der Agent anlegt, trägt wie heute das Abzeichen mit dem Namen des Eintrags.
## Grundidee: ein OAuth-Zugang ist ein API-Schlüssel
Die Zustimmung legt einen normalen Eintrag in `api_keys` an (neue Spalte `kind = 'oauth'`, dazu `oauth_client_id`). Ein Schlüssel-Geheimnis zum Abtippen gibt es dafür nicht: `key_hash` bekommt einen zufälligen Wert, den niemand kennt. Die Tokens hängen an diesem Eintrag.
Damit bleiben Rechteprüfung (`mcpToolAllowed`, `apiKeyRouteDenied`), Herkunft (`origin`), „zuletzt benutzt“, Umbenennen, Rechte ändern und Widerrufen unverändert. Wird der Eintrag gelöscht, fallen die Tokens per Fremdschlüssel mit.
## Endpunkte
Alle ohne Anmeldung erreichbar, außer der Zustimmung.
| Endpunkt | Zweck |
|---|---|
| `GET /.well-known/oauth-protected-resource` (auch mit Pfad-Anhang `/api/v1/mcp`) | Beschreibung der Ressource (RFC 9728): `resource`, `authorization_servers`, `scopes_supported` |
| `GET /.well-known/oauth-authorization-server` | Beschreibung des OAuth-Servers (RFC 8414): Endpunkte, `code_challenge_methods_supported: ["S256"]`, `grant_types_supported`, `token_endpoint_auth_methods_supported: ["none"]` |
| `POST /oauth/register` | Dynamische Client-Registrierung (RFC 7591), wie sie claude.ai und ChatGPT benutzen |
| `GET /oauth/authorize` | Liefert die App aus (wie jede Seite); die App zeigt die Zustimmung |
| `GET /api/v1/oauth/authorize` | Prüft die Parameter und liefert Client-Name, Rücksprungadresse und angefragte Rechte für die Zustimmungsseite (angemeldet) |
| `POST /api/v1/oauth/authorize` | Erlauben oder Ablehnen; gibt die Rücksprung-URL mit `code` und `state` (bzw. `error=access_denied`) zurück, die App leitet weiter (angemeldet) |
| `POST /oauth/token` | `authorization_code` gegen Tokens tauschen, `refresh_token` erneuern |
| `POST /oauth/revoke` | Token widerrufen (RFC 7009), optional, schadet nicht |
Am MCP-Endpunkt antwortet MindSift ohne gültiges Token mit `401` und
`WWW-Authenticate: Bearer resource_metadata="https://<instanz>/.well-known/oauth-protected-resource"`. Daran erkennen die Clients, dass sie den Ablauf starten müssen. Heute fehlt dieser Header.
Kein eigenes CORS für diese Endpunkte: claude.ai und ChatGPT rufen sie vom Server aus auf. Browser-Clients wie der MCP Inspector erreichen ohnehin auch den MCP-Endpunkt nicht, solange `BASE_URL` die erlaubten Ursprünge festlegt.
## Ablauf im Detail
**Registrierung:** Der Client schickt `client_name` und `redirect_uris`. MindSift speichert ihn in `oauth_clients` und gibt eine `client_id` zurück. Es gibt nur öffentliche Clients (kein Client-Geheimnis); die Sicherheit kommt aus PKCE und den festen Rücksprungadressen.
- Rücksprungadressen nur `https://…` oder `http://localhost`/`127.0.0.1` (Desktop-Clients), keine Fragmente.
- Bremse pro IP wie beim Login. Clients, die 24 Stunden nach der Registrierung noch keine Zustimmung bekommen haben, werden beim Aufräumen gelöscht.
**Zustimmung:** `response_type=code`, `client_id`, `redirect_uri` (muss exakt registriert sein), `code_challenge` mit `code_challenge_method=S256` (Pflicht), `state`, `scope` (optional), `resource` (RFC 8707, falls angegeben gleich `<BASE_URL>/api/v1/mcp`).
- Passen `client_id` oder `redirect_uri` nicht, zeigt die App einen Fehler und leitet **nicht** weiter. Alle anderen Fehler gehen als `error=…` an die Rücksprungadresse.
- Bei **Erlauben** legt MindSift den Eintrag in `api_keys` an (Name = `client_name` + „ (OAuth)“, Rechte = gewählte Häkchen). Hat dieselbe Person demselben Client schon zugestimmt, wird der vorhandene Eintrag weiterverwendet, und die Rechte werden aktualisiert, damit keine Doppelten entstehen.
- Der Code ist 10 Minuten gültig, nur einmal verwendbar und wird nur als Hash gespeichert.
**Token:** Der Tausch prüft Code, `client_id`, `redirect_uri` und `code_verifier`. Ergebnis:
- `access_token` `mindsift_oat_…`, 1 Stunde gültig
- `refresh_token` `mindsift_ort_…`, 90 Tage gültig, wird bei jeder Erneuerung ausgetauscht. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, verfallen alle Tokens dieses Eintrags (Schutz bei Diebstahl).
- `scope` mit den tatsächlich erteilten Rechten
**Nutzung:** `authenticateToken` erkennt `mindsift_oat_` und liefert Nutzer und `apiKeyAuth` des zugehörigen Eintrags. Ab da läuft alles wie bei einem Schlüssel. OAuth-Tokens gelten **nur für den MCP-Endpunkt** (dafür wurden sie ausgestellt), nicht für die REST-Endpunkte.
## Rechte (Scopes)
Die vorhandenen Rechte werden unverändert als OAuth-Scopes verwendet (`tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `webhook:triage`; die schmalen `webhook:task`/`webhook:list` erscheinen auf der Zustimmungsseite nicht). Fragt ein Client kein `scope` an (claude.ai tut das meist nicht), sind auf der Zustimmungsseite **Lesen und Ändern für Aufgaben und Listen** vorbelegt; Inbox bleibt aus. Notizen bleiben wie heute unerreichbar.
## Datenbank
```sql
ALTER TABLE api_keys ADD COLUMN kind TEXT NOT NULL DEFAULT 'key'; -- key | oauth
ALTER TABLE api_keys ADD COLUMN oauth_client_id TEXT NOT NULL DEFAULT '';
CREATE TABLE oauth_clients (
id TEXT PRIMARY KEY, -- client_id
name TEXT NOT NULL,
redirect_uris TEXT NOT NULL, -- JSON-Array
created_at TEXT NOT NULL
);
CREATE TABLE oauth_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE oauth_tokens (
token_hash TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- access | refresh
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
replaced_at TEXT NOT NULL DEFAULT '' -- refresh: wann ausgetauscht (Wiederverwendung erkennen)
);
```
Abgelaufene Codes und Tokens räumt der vorhandene Aufräum-Lauf weg. Als Migration über `schema_migrations`.
## Voraussetzungen und Grenzen
- **`BASE_URL` muss gesetzt sein.** Sie ist `issuer` und Ressource; aus dem `Host`-Header gebaut wäre sie fälschbar. Ohne `BASE_URL` ist OAuth aus: kein `WWW-Authenticate`-Hinweis, und die Endpunkte antworten mit 404. Die Einstellungen sagen dann „OAuth braucht `BASE_URL`“.
- **Reverse-Proxy:** `/.well-known/` und `/oauth/` müssen bei MindSift ankommen. Mit der Beispielkonfiguration in [reverse-proxy.md](../reverse-proxy.md) (`location /`) ist das so; dort ergänze ich einen Hinweis.
- **Demo-Modus:** OAuth aus (Demo-Daten werden zurückgesetzt, Einträge wären sinnlos).
- **CSP:** Die Zustimmungsseite leitet per `window.location` weiter, nicht per Formular, also kein `form-action` nötig.
- **Kein eigener Admin-Schalter** für OAuth. Ein Schalter für den ganzen MCP-Zugang ist eine eigene Aufgabe; ist MCP aus, ist auch OAuth aus.
- **Unbenutzte Zugänge verfallen:** Ein OAuth-Eintrag, der 90 Tage nicht benutzt wurde, wird beim Aufräumen gelöscht (samt Tokens). Einstellbar mit `OAUTH_IDLE_DAYS` (Standard `90`, `0` = nie).
## Oberfläche
- Neue Ansicht **Zustimmung** (`/oauth/authorize`), Handy-tauglich: Logo, Client-Name, Rücksprung-Domain gut sichtbar („Weiterleitung zu claude.ai“), Rechte-Häkchen mit den vorhandenen Bezeichnungen aus `ApiKeysPanel`, Erlauben/Ablehnen (44 px). Nicht angemeldet → Anmeldung, danach zurück zur Zustimmung (Parameter bleiben erhalten).
- **API-Schlüssel-Liste:** OAuth-Einträge mit Kennzeichen „OAuth“ und Client-Name; „Schlüssel anzeigen/kopieren“ entfällt bei ihnen.
- **Hilfe-Text** in `ApiKeysPanel` ersetzen: statt „claude.ai … wird noch nicht unterstützt“ eine kurze Anleitung „In claude.ai als Connector eintragen: `<BASE_URL>/api/v1/mcp`“ mit Kopier-Knopf.
## Tests
- Go: kompletter Ablauf (registrieren → zustimmen → Code tauschen → MCP aufrufen → erneuern), falsche `redirect_uri`, falscher `code_verifier`, Code doppelt benutzt, abgelaufene Tokens, Refresh-Wiederverwendung, Widerruf des Eintrags, OAuth-Token an REST-Endpunkt (abgelehnt), ohne `BASE_URL` (aus), Demo (aus). Ablaufzeiten mit übergebener Zeit, Tests unter mehreren `TZ`.
- Frontend: Zustimmungsseite (Vorbelegung der Rechte, Ablehnen).
- Von Hand: Verbindung aus claude.ai gegen die `dev`-Instanz bzw. mit dem MCP Inspector.
## Doku und Release
- `Doku/api/rest.md` (Abschnitt MCP, neue OAuth-Endpunkte), `Doku/architecture.md`, `README.md` (Funktionsliste), `Doku/reverse-proxy.md`.
- Neue Funktion → Minor-Version, CHANGELOG „Added: MindSift lässt sich in claude.ai und ChatGPT als Connector verbinden“.
- Die Website erwähnt evtl. „claude.ai noch nicht unterstützt“; das melde ich, statt es zu ändern.
## Reihenfolge und Aufwand (grob)
1. Datenbank, Token-Speicher, `authenticateToken` für `mindsift_oat_`, `WWW-Authenticate` – klein.
2. Metadaten, Registrierung, Token-Endpunkt mit PKCE und Refresh – mittel.
3. Zustimmungsseite und API-Schlüssel-Liste – mittel.
4. Tests, Doku, Ausprobieren mit claude.ai – klein bis mittel.
Keine neue Go-Abhängigkeit nötig: Für den Server-Teil reicht die Standardbibliothek (SHA-256 für PKCE, `crypto/rand`), `golang.org/x/oauth2` ist eine Client-Bibliothek und hilft hier kaum.
## Entscheidungen (2026-11-01)
- Ohne `scope` sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- Kein OAuth-Schalter; MCP insgesamt abschaltbar kommt separat.
- Refresh-Token 90 Tage, bei jeder Erneuerung neu.
- Unbenutzte OAuth-Einträge verfallen nach 90 Tagen, `OAUTH_IDLE_DAYS`.
+1
View File
@@ -12,6 +12,7 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
| **OAuth (MCP)** | 443 (HTTPS) | `/.well-known/oauth-*` und `/oauth/*` an MindSift durchreichen (mit `location /` automatisch), `BASE_URL` gesetzt | claude.ai und ChatGPT als Connector |
### Client-IP und `TRUSTED_PROXIES`
+4 -3
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.50.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.51.0-16a34a?style=flat-square)](CHANGELOG.md)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
@@ -133,7 +133,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
* **API mit Schlüsseln:** Pro Schlüssel schaltbare Rechte (Aufgaben und Listen lesen, anlegen, ändern; Inbox füllen), gleich für beide Zugänge. Notizen und Einstellungen bleiben außen vor.
* **curl / Smart Home:** z. B. `GET /api/v1/tasks?due=today&status=open` für einen Home-Assistant-Sensor „Heute fällig“, abhaken per `PATCH`, Gedanken, Aufgaben und Listeneinträge einliefern per `POST /api/v1/webhooks/ingest`.
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen, legen an, ändern, haken ab und löschen Aufgaben, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen, legen an, ändern, haken ab und löschen Aufgaben, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`. **claude.ai und ChatGPT** verbinden sich ohne Schlüssel per OAuth: Adresse als Connector eintragen, in MindSift anmelden und die Rechte wählen (braucht `BASE_URL`).
* **Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen; Mouseover oder Tippen zeigt, wer wann angelegt und geändert hat.
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
@@ -195,6 +195,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
| `OAUTH_IDLE_DAYS` | `90` | Verbindungen von claude.ai, ChatGPT & Co. (OAuth), die so viele Tage nicht benutzt wurden, werden getrennt. `0` = nie. OAuth selbst gibt es nur mit `BASE_URL` |
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
| `REGISTRATION_MODE` | leer (Admin-UI entscheidet) | Registrierung ohne Einladung: `closed`, `approval` (Admin gibt frei), `domain` (nur erlaubte E-Mail-Endungen), `domain+approval` (Endungen sofort, andere nach Freigabe), `open`. Gesetzt ist es im Admin-UI gesperrt |
@@ -265,7 +266,7 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 📄 Lizenz
MindSift ist freie Software unter der [GNU Affero General Public License v3.0](LICENSE) (AGPLv3): Du darfst den Code nutzen, ändern und weitergeben, auch kommerziell. Wer eine geänderte Fassung weitergibt oder als Dienst im Netz anbietet, muss deren Quellcode unter derselben Lizenz zugänglich machen. Es gibt keine Gewährleistung. Versionen bis einschließlich 0.48.0 bleiben unter The Unlicense (gemeinfrei).
MindSift ist freie Software unter der [GNU Affero General Public License v3.0](LICENSE) (AGPLv3): Du darfst den Code nutzen, ändern und weitergeben, auch kommerziell. Wer eine geänderte Fassung weitergibt oder als Dienst im Netz anbietet, muss deren Quellcode unter derselben Lizenz zugänglich machen. Es gibt keine Gewährleistung.
<div align="center">
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.50.0",
"version": "0.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.50.0",
"version": "0.51.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.50.0",
"version": "0.51.0",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
+7
View File
@@ -58,6 +58,7 @@ import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OAuthConsentView } from './features/auth/OAuthConsentView';
import { OfflineBanner } from './components/OfflineBanner';
import { useServerOnline } from './lib/useServerOnline';
import { SharedImportToast } from './components/SharedImportToast';
@@ -869,6 +870,12 @@ export const App: React.FC = () => {
);
}
// Consent of the OAuth flow (claude.ai, ChatGPT connect over MCP) instead of the app; after logging in
// the address is still /oauth/authorize, so the consent follows the login
if (window.location.pathname === '/oauth/authorize') {
return <OAuthConsentView user={user} instanceName={authStatus.instanceName || 'MindSift'} onLogout={handleLogout} />;
}
const isServerAdmin = user.role === 'admin';
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
@@ -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();
});
});
+63 -32
View File
@@ -12,6 +12,7 @@ import {
Bot,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS, scopeChip } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
@@ -19,27 +20,8 @@ interface ApiKeysPanelProps {
onIngested?: () => void;
}
// 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.
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
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' },
];
const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
type McpClient = 'connector' | 'claude' | 'codex' | 'json';
type Access = 'rest' | 'mcp';
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
@@ -50,7 +32,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
const [mcpClient, setMcpClient] = useState<McpClient>('connector');
// Address for claude.ai / ChatGPT connectors; empty while the server has no OAuth (no BASE_URL)
const [connectorUrl, setConnectorUrl] = useState('');
const [copiedConnector, setCopiedConnector] = useState(false);
const [access, setAccess] = useState<Access>('rest');
// Live Webhook Simulator State
@@ -72,6 +57,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
api
.getAuthStatus()
.then((st) => setConnectorUrl(st.mcpConnectorUrl || ''))
.catch(() => {});
}, []);
// Editing an existing key: name and rights (the secret stays the same)
@@ -145,10 +134,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
const handleDeleteKey = async (k: APIKey) => {
if (!window.confirm(k.kind === 'oauth' ? `Die Verbindung „${k.name}“ wirklich trennen?` : 'Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(id);
await api.deleteAPIKey(k.id);
await loadKeys();
};
@@ -312,8 +301,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</div>
{renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)}
<p className="text-[11px] text-faint">
Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen
Rechte gelten ab sofort.
{k.kind === 'oauth'
? 'Die Verbindung bleibt bestehen; die neuen Rechte gelten ab sofort.'
: 'Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen Rechte gelten ab sofort.'}
</p>
{editError && <p className="text-[11px] text-status-err">{editError}</p>}
</div>
@@ -321,7 +311,13 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.kind === 'oauth' ? (
<span className="px-1.5 py-0.5 rounded-btn bg-accent/15 text-accent text-[10px] font-semibold" title="Verbunden über OAuth (claude.ai, ChatGPT …)">
OAuth
</span>
) : (
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
)}
{k.scopes
.split(',')
.filter(Boolean)
@@ -346,7 +342,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteKey(k.id)}
onClick={() => handleDeleteKey(k)}
className="btn-icon btn-icon-danger w-7 h-7"
aria-label={`API-Key ${k.name} löschen`}
>
@@ -561,7 +557,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten. claude.ai und ChatGPT verbinden sich ohne
Schlüssel: MindSift fragt beim Verbinden, was der Agent darf.
</p>
</div>
</div>
@@ -569,6 +566,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['connector', 'claude.ai / ChatGPT'],
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
@@ -588,7 +586,41 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
{mcpClient === 'connector' ? (
connectorUrl ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 p-3 bg-canvas border border-border font-mono text-xs text-main break-all">{connectorUrl}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(connectorUrl);
setCopiedConnector(true);
setTimeout(() => setCopiedConnector(false), 2000);
}}
className="btn-icon w-11 h-11 sm:w-9 sm:h-9 flex-shrink-0"
aria-label="Adresse kopieren"
title="Adresse kopieren"
>
{copiedConnector ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted">
In claude.ai unter <span className="text-main">Einstellungen → Connectors → Eigenen Connector hinzufügen</span>{' '}
diese Adresse eintragen (in ChatGPT entsprechend als Connector bzw. App). Beim Verbinden öffnet sich MindSift:
anmelden, Rechte wählen, erlauben. Die Verbindung erscheint oben in der Liste und lässt sich dort ändern oder
trennen; unbenutzt endet sie nach einiger Zeit von selbst.
</p>
</div>
) : (
<p className="p-3 bg-canvas border border-border text-xs text-muted">
Verbinden über claude.ai und ChatGPT braucht OAuth, und das setzt <code className="font-mono">BASE_URL</code> in
der Server-Konfiguration voraus. Bitte die Person, die MindSift betreibt, sie zu setzen.
</p>
)
) : (
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
)}
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
@@ -598,8 +630,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
</p>
</div>
)}
@@ -7,7 +7,9 @@ vi.mock('../../../lib/api', () => ({
api: {
getAPIKeys: vi.fn(async () => [
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
{ id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' },
]),
getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })),
getAllLists: vi.fn(async () => []),
getTaskLists: vi.fn(async () => []),
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
@@ -49,11 +51,21 @@ describe('ApiKeysPanel', () => {
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
expect(screen.queryByText(/claude mcp add/)).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
// claude.ai / ChatGPT first: the connector address, no key
expect(await screen.findByText('https://ms.example/api/v1/mcp')).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'Claude Code' }));
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
});
it('marks OAuth connections instead of showing a key prefix', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Claude (OAuth)')).toBeTruthy();
expect(screen.getByTitle(/Verbunden über OAuth/)).toBeTruthy();
expect(screen.queryByText('OAuth', { selector: '.font-mono' })).toBeNull();
});
it('renames a key and changes its rights', async () => {
render(<ApiKeysPanel />);
fireEvent.click(await screen.findByRole('button', { name: 'API-Key Rauchmelder bearbeiten' }));
+27
View File
@@ -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,
+20
View File
@@ -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;
+3 -2
View File
@@ -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));
+13
View File
@@ -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 {
+2
View File
@@ -17,6 +17,8 @@ import (
type apiKeyAuth struct {
ID string
Scopes []string
// OAuth: an access token of an OAuth connection (oauth.go), valid for the MCP endpoint only
OAuth bool
}
// apiActor: an API key acting on entries; via is storage.OriginAPI or storage.OriginMCP. Recorded as
+3 -1
View File
@@ -14,14 +14,16 @@ import (
// startDoneCleanupTicker runs the retention delays (storage/retention.go): completed tasks into the
// task trash, the trashes and ticked-off list items emptied; once shortly after the start and then
// every hour.
// every hour. Expired and unused OAuth connections go in the same pass (oauth.go).
func (s *Server) startDoneCleanupTicker() {
time.Sleep(2 * time.Minute)
s.runDoneCleanup(time.Now())
s.runOAuthCleanup(time.Now())
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for now := range ticker.C {
s.runDoneCleanup(now)
s.runOAuthCleanup(now)
}
}
+7
View File
@@ -48,6 +48,13 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"demoMode": s.cfg.DemoMode,
"sharingMode": s.store.SharingMode(),
"passwordReset": s.passwordResetAvailable(),
// Address to enter as connector in claude.ai / ChatGPT (OAuth); empty while OAuth is off
"mcpConnectorUrl": func() string {
if s.oauthEnabled() {
return s.oauthResource()
}
return ""
}(),
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
// browserAllowed: whether people may pick the browser's recognition instead of the Whisper server
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds, "browserAllowed": !s.cfg.Speech.NoBrowser},
+1 -1
View File
@@ -93,7 +93,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
user, key := s.authenticateToken(token)
if user == nil {
if user == nil || (key != nil && key.OAuth) {
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
return
}
+369
View File
@@ -0,0 +1,369 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
// OAuth for the MCP endpoint (Doku/plans/oauth-fuer-mcp.md), so that claude.ai, ChatGPT and other
// clients that cannot send an API key header can connect. MindSift is the authorization server for
// its own MCP endpoint: discovery (RFC 9728, RFC 8414), dynamic client registration (RFC 7591),
// authorization code with PKCE (S256 only, public clients) and rotating refresh tokens. The consent
// page is part of the app (/oauth/authorize), because the login lives in the browser's storage, not
// in a cookie the server could read. A grant is an api_keys entry (storage/oauth.go).
//
// Only with BASE_URL: it is the issuer and the resource, and building them from the Host header
// would let anyone choose them. Off in the demo.
const (
limitOAuthRegisterPerIP = 20
oauthMaxRedirectURIs = 10
)
// Rights offered on the consent page; the narrow "create only" ones stay with ordinary keys.
var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite, storage.ScopeTriage}
// Preselected when the client asks for nothing in particular (claude.ai usually does not)
var oauthDefaultScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite}
func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode }
func (s *Server) oauthIssuer() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
func (s *Server) oauthResource() string { return s.oauthIssuer() + mcpPath }
func (s *Server) oauthResourceMetadataURL() string {
return s.oauthIssuer() + "/.well-known/oauth-protected-resource"
}
// oauthOnly answers 404 while OAuth is off, as if the endpoint did not exist.
func (s *Server) oauthOnly(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.oauthEnabled() {
http.NotFound(w, r)
return
}
next(w, r)
}
}
// writeOAuthError: error body of the OAuth endpoints (RFC 6749 5.2)
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
func (s *Server) handleOAuthProtectedResource(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"resource": s.oauthResource(),
"authorization_servers": []string{s.oauthIssuer()},
"scopes_supported": oauthOfferedScopes,
"bearer_methods_supported": []string{"header"},
"resource_name": "MindSift",
})
}
func (s *Server) handleOAuthServerMetadata(w http.ResponseWriter, r *http.Request) {
issuer := s.oauthIssuer()
writeJSON(w, http.StatusOK, map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": issuer + "/oauth/authorize",
"token_endpoint": issuer + "/oauth/token",
"registration_endpoint": issuer + "/oauth/register",
"revocation_endpoint": issuer + "/oauth/revoke",
"response_types_supported": []string{"code"},
"grant_types_supported": []string{"authorization_code", "refresh_token"},
"code_challenge_methods_supported": []string{"S256"},
"token_endpoint_auth_methods_supported": []string{"none"},
"revocation_endpoint_auth_methods_supported": []string{"none"},
"scopes_supported": oauthOfferedScopes,
"authorization_response_iss_parameter_supported": true,
})
}
// validRedirectURI: https anywhere, plain http only back to this computer (desktop clients); no
// fragment (RFC 6749 3.1.2).
func validRedirectURI(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.Fragment != "" || u.User != nil {
return false
}
switch u.Scheme {
case "https":
return true
case "http":
h := u.Hostname()
return h == "localhost" || h == "127.0.0.1" || h == "::1"
}
return false
}
// handleOAuthRegister: POST /oauth/register (RFC 7591). Every client is public and authenticates
// with PKCE only, so a requested client secret method is answered with "none".
func (s *Server) handleOAuthRegister(w http.ResponseWriter, r *http.Request) {
if wait := s.loginGuard.throttle("oauth-register", clientIPFrom(r), limitOAuthRegisterPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
var req struct {
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_client_metadata", "Ungültiges JSON")
return
}
if len(req.RedirectURIs) == 0 || len(req.RedirectURIs) > oauthMaxRedirectURIs {
writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uris fehlt oder enthält zu viele Einträge")
return
}
for _, u := range req.RedirectURIs {
if !validRedirectURI(u) {
writeOAuthError(w, http.StatusBadRequest, "invalid_redirect_uri", "Nur https oder http://localhost erlaubt: "+u)
return
}
}
client, err := s.store.CreateOAuthClient(req.ClientName, req.RedirectURIs, time.Now())
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"client_id": client.ID,
"client_id_issued_at": time.Now().Unix(),
"client_name": client.Name,
"redirect_uris": client.RedirectURIs,
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"token_endpoint_auth_method": "none",
})
}
// oauthAuthorizeParams: the query of /oauth/authorize, handed on by the consent page
type oauthAuthorizeParams struct {
ResponseType string `json:"responseType"`
ClientID string `json:"clientId"`
RedirectURI string `json:"redirectUri"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
State string `json:"state"`
Scope string `json:"scope"`
Resource string `json:"resource"`
}
func oauthParamsFromQuery(q url.Values) oauthAuthorizeParams {
return oauthAuthorizeParams{
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"),
}
}
var errOAuthNoRedirect = errors.New("Die Anwendung oder ihre Rücksprungadresse ist MindSift nicht bekannt. Bitte starte die Verbindung in der Anwendung neu.")
// checkAuthorize validates an authorization request. Without a known client and redirect URI there is
// nowhere safe to send the person back (err); every other problem goes back to the client as
// errRedirect (RFC 6749 4.1.2.1).
func (s *Server) checkAuthorize(p oauthAuthorizeParams) (client *storage.OAuthClient, errRedirect string, err error) {
client, err = s.store.GetOAuthClient(p.ClientID)
if err != nil {
return nil, "", errOAuthNoRedirect
}
known := false
for _, u := range client.RedirectURIs {
known = known || u == p.RedirectURI
}
if !known {
return nil, "", errOAuthNoRedirect
}
switch {
case p.ResponseType != "code":
return client, s.oauthRedirect(p, url.Values{"error": {"unsupported_response_type"}}), nil
case p.CodeChallenge == "" || p.CodeChallengeMethod != "S256":
return client, s.oauthRedirect(p, url.Values{"error": {"invalid_request"}, "error_description": {"PKCE (S256) ist erforderlich"}}), nil
case p.Resource != "" && strings.TrimRight(p.Resource, "/") != s.oauthResource() && strings.TrimRight(p.Resource, "/") != s.oauthIssuer():
return client, s.oauthRedirect(p, url.Values{"error": {"invalid_target"}}), nil
}
return client, "", nil
}
// oauthRedirect: the client's redirect URI with the answer, its state and the issuer (RFC 9207)
func (s *Server) oauthRedirect(p oauthAuthorizeParams, values url.Values) string {
u, _ := url.Parse(p.RedirectURI)
q := u.Query()
for k, v := range values {
q[k] = v
}
if p.State != "" {
q.Set("state", p.State)
}
q.Set("iss", s.oauthIssuer())
u.RawQuery = q.Encode()
return u.String()
}
// oauthScopes keeps the offered rights out of a list, in display order.
func oauthScopes(requested []string) []string {
want := map[string]bool{}
for _, sc := range requested {
want[strings.TrimSpace(sc)] = true
}
out := []string{}
for _, sc := range oauthOfferedScopes {
if want[sc] {
out = append(out, sc)
}
}
return out
}
func redirectHost(raw string) string {
if u, err := url.Parse(raw); err == nil {
return u.Host
}
return raw
}
// handleOAuthAuthorizeInfo: GET /api/v1/oauth/authorize?<query of /oauth/authorize> – what the consent
// page shows. Only for a login (API keys are not allowed here, see keyRoutes).
func (s *Server) handleOAuthAuthorizeInfo(w http.ResponseWriter, r *http.Request) {
p := oauthParamsFromQuery(r.URL.Query())
client, errRedirect, err := s.checkAuthorize(p)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if errRedirect != "" {
writeJSON(w, http.StatusOK, map[string]string{"redirect": errRedirect})
return
}
scopes := oauthScopes(strings.Fields(p.Scope))
if len(scopes) == 0 {
scopes = oauthDefaultScopes
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"clientName": client.Name,
"redirectHost": redirectHost(p.RedirectURI),
"scopes": scopes,
"offeredScopes": oauthOfferedScopes,
})
}
// handleOAuthAuthorize: POST /api/v1/oauth/authorize – the person allows (with the chosen rights) or
// refuses; the answer is the URL the consent page sends the browser to.
func (s *Server) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
var req struct {
oauthAuthorizeParams
Approve bool `json:"approve"`
Scopes []string `json:"scopes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
p := req.oauthAuthorizeParams
client, errRedirect, err := s.checkAuthorize(p)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if errRedirect != "" {
writeJSON(w, http.StatusOK, map[string]string{"redirect": errRedirect})
return
}
if !req.Approve {
writeJSON(w, http.StatusOK, map[string]string{"redirect": s.oauthRedirect(p, url.Values{"error": {"access_denied"}})})
return
}
scopes := oauthScopes(req.Scopes)
if len(scopes) == 0 {
writeError(w, http.StatusBadRequest, "Wähle mindestens ein Recht aus.")
return
}
code, err := s.store.GrantOAuth(auth.GetUserID(r.Context()), client, scopes, p.RedirectURI, p.CodeChallenge, time.Now())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"redirect": s.oauthRedirect(p, url.Values{"code": {code}})})
}
// oauthClientID: public clients send client_id in the form; some use HTTP Basic with an empty secret.
func oauthClientID(r *http.Request) string {
if id := r.PostForm.Get("client_id"); id != "" {
return id
}
id, _, _ := r.BasicAuth()
return id
}
// handleOAuthToken: POST /oauth/token (form-encoded) – authorization_code and refresh_token.
func (s *Server) handleOAuthToken(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Ungültige Anfrage")
return
}
clientID := oauthClientID(r)
var tokens *storage.OAuthTokens
var err error
switch r.PostForm.Get("grant_type") {
case "authorization_code":
tokens, err = s.store.ExchangeOAuthCode(r.PostForm.Get("code"), clientID, r.PostForm.Get("redirect_uri"),
r.PostForm.Get("code_verifier"), time.Now())
case "refresh_token":
tokens, err = s.store.RefreshOAuth(r.PostForm.Get("refresh_token"), clientID, time.Now())
default:
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "Nur authorization_code und refresh_token")
return
}
if errors.Is(err, storage.ErrOAuthInvalidGrant) {
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "Code oder Token ungültig, abgelaufen oder widerrufen")
return
}
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"access_token": tokens.AccessToken,
"token_type": "Bearer",
"expires_in": tokens.ExpiresIn,
"refresh_token": tokens.RefreshToken,
"scope": strings.Join(tokens.Scopes, " "),
})
}
// handleOAuthRevoke: POST /oauth/revoke (RFC 7009); answers 200 even for unknown tokens.
func (s *Server) handleOAuthRevoke(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Ungültige Anfrage")
return
}
s.store.RevokeOAuthToken(r.PostForm.Get("token"), oauthClientID(r))
w.WriteHeader(http.StatusOK)
}
// runOAuthCleanup: expired codes and tokens, abandoned registrations and – after OAUTH_IDLE_DAYS –
// connections nobody used.
func (s *Server) runOAuthCleanup(now time.Time) {
n, err := s.store.PurgeOAuth(now, s.cfg.OAuthIdleDays)
if err != nil {
slog.Error("Aufräumen der OAuth-Zugänge fehlgeschlagen", "error", err)
return
}
if n > 0 {
slog.Info("Unbenutzte OAuth-Zugänge entfernt", "anzahl", n, "tage", s.cfg.OAuthIdleDays)
}
}
+222
View File
@@ -0,0 +1,222 @@
package api
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
const oauthTestRedirect = "https://claude.ai/api/mcp/auth_callback"
// oauthDo sends one request and returns status, headers and the decoded JSON body.
func oauthDo(t *testing.T, router http.Handler, method, path, bearer, contentType, body string) (int, http.Header, map[string]interface{}) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, rec.Header(), out
}
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// The whole connection as claude.ai makes it: discovery, registration, consent, code, MCP, refresh.
func TestOAuthFlowForMCP(t *testing.T) {
srv, store, session := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, session)
const jsonType = "application/json"
const formType = "application/x-www-form-urlencoded"
// Without BASE_URL there is no OAuth at all
if code, _, _ := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-protected-resource", "", "", ""); code != http.StatusNotFound {
t.Fatalf("OAuth must be off without BASE_URL, got %d", code)
}
if _, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", jsonType, `{}`); h.Get("WWW-Authenticate") != "" {
t.Fatalf("no OAuth hint without BASE_URL, got %q", h.Get("WWW-Authenticate"))
}
srv.cfg.BaseURL = "https://ms.example/"
srv.cfg.BaseURLSet = true
router = srv.Router()
code, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", jsonType, `{}`)
if code != http.StatusUnauthorized || !strings.Contains(h.Get("WWW-Authenticate"), `resource_metadata="https://ms.example/.well-known/oauth-protected-resource"`) {
t.Fatalf("MCP without token should point to the metadata: %d %q", code, h.Get("WWW-Authenticate"))
}
_, _, meta := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-protected-resource/api/v1/mcp", "", "", "")
if meta["resource"] != "https://ms.example/api/v1/mcp" {
t.Fatalf("resource metadata: %v", meta)
}
_, _, meta = oauthDo(t, router, http.MethodGet, "/.well-known/oauth-authorization-server", "", "", "")
if meta["issuer"] != "https://ms.example" || meta["token_endpoint"] != "https://ms.example/oauth/token" {
t.Fatalf("server metadata: %v", meta)
}
// Registration: only https or loopback
if code, _, _ := oauthDo(t, router, http.MethodPost, "/oauth/register", "", jsonType, `{"client_name":"X","redirect_uris":["http://evil.example/cb"]}`); code != http.StatusBadRequest {
t.Fatalf("plain http redirect must be refused, got %d", code)
}
code, _, reg := oauthDo(t, router, http.MethodPost, "/oauth/register", "", jsonType, `{"client_name":"Claude","redirect_uris":["`+oauthTestRedirect+`"],"token_endpoint_auth_method":"client_secret_post"}`)
if code != http.StatusCreated || reg["token_endpoint_auth_method"] != "none" {
t.Fatalf("register: %d %v", code, reg)
}
clientID := reg["client_id"].(string)
verifier := strings.Repeat("v", 50)
params := func(redirect, challenge string) url.Values {
return url.Values{"response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirect},
"code_challenge": {challenge}, "code_challenge_method": {"S256"}, "state": {"st8"}, "resource": {"https://ms.example/api/v1/mcp"}}
}
// Consent page info: unknown redirect is an error shown on the page, never a redirect
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params("https://evil.example/cb", pkceChallenge(verifier)).Encode(), session, "", ""); code != http.StatusBadRequest {
t.Fatalf("unknown redirect_uri must be refused, got %d", code)
}
_, _, info := oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params(oauthTestRedirect, "").Encode(), session, "", "")
if r, _ := info["redirect"].(string); !strings.Contains(r, "error=invalid_request") {
t.Fatalf("missing PKCE must go back as invalid_request: %v", info)
}
_, _, info = oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params(oauthTestRedirect, pkceChallenge(verifier)).Encode(), session, "", "")
if info["clientName"] != "Claude" || info["redirectHost"] != "claude.ai" || len(info["scopes"].([]interface{})) != 4 {
t.Fatalf("consent info: %v", info)
}
// API keys cannot agree on anyone's behalf
key, _ := store.CreateAPIKey(claims.UserID, "Skript", []string{"tasks:read"})
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/oauth/authorize?"+params(oauthTestRedirect, pkceChallenge(verifier)).Encode(), key.RawKey, "", ""); code != http.StatusForbidden {
t.Fatalf("API key must not reach the consent endpoint, got %d", code)
}
consent := func(approve bool, scopes string) string {
t.Helper()
body := `{"responseType":"code","clientId":"` + clientID + `","redirectUri":"` + oauthTestRedirect + `","codeChallenge":"` +
pkceChallenge(verifier) + `","codeChallengeMethod":"S256","state":"st8","approve":` + map[bool]string{true: "true", false: "false"}[approve] +
`,"scopes":` + scopes + `}`
code, _, out := oauthDo(t, router, http.MethodPost, "/api/v1/oauth/authorize", session, jsonType, body)
if code != http.StatusOK {
t.Fatalf("consent: %d %v", code, out)
}
return out["redirect"].(string)
}
if r := consent(false, `[]`); !strings.Contains(r, "error=access_denied") || !strings.Contains(r, "state=st8") {
t.Fatalf("refusing should report access_denied with state: %s", r)
}
codeOf := func(redirect string) string {
u, _ := url.Parse(redirect)
if u.Query().Get("state") != "st8" || u.Query().Get("iss") != "https://ms.example" {
t.Fatalf("redirect lacks state or iss: %s", redirect)
}
return u.Query().Get("code")
}
token := func(form url.Values) (int, map[string]interface{}) {
code, _, out := oauthDo(t, router, http.MethodPost, "/oauth/token", "", formType, form.Encode())
return code, out
}
exchange := func(code, redirect, verifier string) url.Values {
return url.Values{"grant_type": {"authorization_code"}, "code": {code}, "client_id": {clientID}, "redirect_uri": {redirect}, "code_verifier": {verifier}}
}
// A wrong verifier uses the code up
c1 := codeOf(consent(true, `["tasks:read"]`))
if code, out := token(exchange(c1, oauthTestRedirect, strings.Repeat("w", 50))); code != http.StatusBadRequest || out["error"] != "invalid_grant" {
t.Fatalf("wrong verifier: %d %v", code, out)
}
if code, _ := token(exchange(c1, oauthTestRedirect, verifier)); code != http.StatusBadRequest {
t.Fatalf("a code works only once, got %d", code)
}
c2 := codeOf(consent(true, `["tasks:read"]`))
if code, _ := token(exchange(c2, "https://claude.ai/other", verifier)); code != http.StatusBadRequest {
t.Fatalf("redirect_uri must match the code's, got %d", code)
}
c3 := codeOf(consent(true, `["tasks:read"]`))
code, tok := token(exchange(c3, oauthTestRedirect, verifier))
if code != http.StatusOK || tok["scope"] != "tasks:read" || tok["token_type"] != "Bearer" {
t.Fatalf("token: %d %v", code, tok)
}
access, refresh := tok["access_token"].(string), tok["refresh_token"].(string)
// The access token works at MCP with exactly the agreed rights, and nowhere else
_, res := rpc(t, router, access, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
names := strings.Join(toolNames(res), ",")
if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "get_list") {
t.Fatalf("tools for tasks:read: %s", names)
}
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/tasks", access, "", ""); code != http.StatusForbidden {
t.Fatalf("OAuth token must not reach REST, got %d", code)
}
if code, _, _ := oauthDo(t, router, http.MethodPost, "/api/v1/webhooks/ingest", access, jsonType, `{"content":"x"}`); code != http.StatusUnauthorized {
t.Fatalf("OAuth token must not deliver webhooks, got %d", code)
}
// Agent changes carry the connection's name, which is listed among the keys; agreeing again
// does not add a second entry
_, res = rpc(t, router, access, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
if _, isErr := callResult(t, res); isErr {
t.Fatalf("list_tasks via OAuth: %v", res)
}
codeOf(consent(true, `["tasks:read","lists:read"]`))
keys, _ := store.GetAPIKeys(claims.UserID)
var grant *domain.APIKey
for i := range keys {
if keys[i].Kind == "oauth" {
if grant != nil {
t.Fatalf("consenting again must reuse the entry: %v", keys)
}
grant = &keys[i]
}
}
if grant == nil || grant.Name != "Claude (OAuth)" || grant.Scopes != "tasks:read,lists:read" || grant.LastUsedAt == "" {
t.Fatalf("OAuth entry: %+v", grant)
}
// Refresh rotates; the old refresh token used again ends the whole connection
code, tok2 := token(url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}, "client_id": {clientID}})
if code != http.StatusOK || tok2["scope"] != "tasks:read lists:read" {
t.Fatalf("refresh: %d %v", code, tok2)
}
access2 := tok2["access_token"].(string)
if code, _ := rpc(t, router, access2, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`); code != http.StatusOK {
t.Fatalf("refreshed access token: %d", code)
}
if code, _ := token(url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}, "client_id": {clientID}}); code != http.StatusBadRequest {
t.Fatalf("reused refresh token must fail, got %d", code)
}
if code, _ := rpc(t, router, access2, `{"jsonrpc":"2.0","id":4,"method":"tools/list"}`); code != http.StatusUnauthorized {
t.Fatalf("reuse must end every token of the connection, got %d", code)
}
// Removing the entry in the settings ends a fresh connection right away
c4 := codeOf(consent(true, `["tasks:read"]`))
_, tok3 := token(exchange(c4, oauthTestRedirect, verifier))
access3 := tok3["access_token"].(string)
if code, _, _ := oauthDo(t, router, http.MethodDelete, "/api/v1/keys/"+grant.ID, session, "", ""); code != http.StatusOK {
t.Fatalf("delete entry: %d", code)
}
if code, _ := rpc(t, router, access3, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); code != http.StatusUnauthorized {
t.Fatalf("revoked connection must stop working, got %d", code)
}
// The demo has no OAuth
srv.cfg.DemoMode = true
if code, _, _ := oauthDo(t, srv.Router(), http.MethodPost, "/oauth/register", "", jsonType, `{"redirect_uris":["`+oauthTestRedirect+`"]}`); code != http.StatusNotFound {
t.Fatalf("OAuth must be off in the demo, got %d", code)
}
}
+29
View File
@@ -150,6 +150,14 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/events", s.handleSSE)
// OAuth for the MCP endpoint (oauth.go); the consent page /oauth/authorize is part of the app
r.Get("/.well-known/oauth-protected-resource", s.oauthOnly(s.handleOAuthProtectedResource))
r.Get("/.well-known/oauth-protected-resource/*", s.oauthOnly(s.handleOAuthProtectedResource))
r.Get("/.well-known/oauth-authorization-server", s.oauthOnly(s.handleOAuthServerMetadata))
r.Post("/oauth/register", s.oauthOnly(s.handleOAuthRegister))
r.Post("/oauth/token", s.oauthOnly(s.handleOAuthToken))
r.Post("/oauth/revoke", s.oauthOnly(s.handleOAuthRevoke))
// Authenticated API Routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
@@ -304,6 +312,8 @@ func (s *Server) Router() http.Handler {
r.Post(mcpPath, s.handleMCP)
r.Get(mcpPath, handleMCPNoStream)
r.Delete(mcpPath, handleMCPNoStream)
r.Get("/api/v1/oauth/authorize", s.oauthOnly(s.handleOAuthAuthorizeInfo))
r.Post("/api/v1/oauth/authorize", s.oauthOnly(s.handleOAuthAuthorize))
// API Keys Management
r.Get("/api/v1/keys", s.handleGetAPIKeys)
@@ -489,6 +499,16 @@ func (s *Server) authenticateToken(token string) (user *domain.User, key *apiKey
}
return u, &apiKeyAuth{ID: keyID, Scopes: scopes}
}
if strings.HasPrefix(token, storage.OAuthAccessPrefix) {
if !s.oauthEnabled() {
return nil, nil
}
u, scopes, keyID, err := s.store.ValidateOAuthAccessToken(token, time.Now())
if err != nil || u == nil || !u.IsActive {
return nil, nil
}
return u, &apiKeyAuth{ID: keyID, Scopes: scopes, OAuth: true}
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
if err != nil || claims == nil {
return nil, nil
@@ -517,9 +537,18 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
user, key := s.authenticateToken(token)
if user == nil {
// Tells MCP clients where to start the OAuth flow (RFC 9728)
if r.URL.Path == mcpPath && s.oauthEnabled() {
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+s.oauthResourceMetadataURL()+`"`)
}
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
// OAuth tokens were issued for the MCP endpoint only
if key != nil && key.OAuth && r.URL.Path != mcpPath {
writeError(w, http.StatusForbidden, "OAuth-Zugänge gelten nur für den MCP-Endpunkt")
return
}
ctx := auth.WithUserID(r.Context(), user.ID)
if key == nil {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
+4
View File
@@ -35,6 +35,9 @@ type Config struct {
DemoResetMinutes int
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
TrustedProxies string
// OAuthIdleDays: OAUTH_IDLE_DAYS, after this many unused days an OAuth connection (MCP) is
// removed (0 = never)
OAuthIdleDays int
// DisableLoginProtection: LOGIN_PROTECTION=false switches off locking logins after repeated
// failures (the zero value keeps it on)
DisableLoginProtection bool
@@ -259,6 +262,7 @@ func Load() *Config {
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"),
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
OAuthIdleDays: envInt("OAUTH_IDLE_DAYS", 90, 0, 36500),
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
+2
View File
@@ -634,6 +634,8 @@ type APIKey struct {
Scopes string `json:"scopes"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
CreatedAt string `json:"createdAt"`
// Kind: "key" (secret shown once) or "oauth" (a connection agreed to on the consent page)
Kind string `json:"kind"`
}
type WSMessage struct {
+4 -3
View File
@@ -59,6 +59,7 @@ func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIK
RawKey: rawKey,
Scopes: strings.Join(NormalizeScopes(scopes), ","),
CreatedAt: domain.NowISO(),
Kind: APIKeyKindKey,
}
_, err := s.db.Exec(
@@ -74,7 +75,7 @@ func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIK
func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
rows, err := s.db.Query(
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at
`SELECT id, user_id, name, key_prefix, scopes, last_used_at, created_at, kind
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`,
userID,
)
@@ -86,7 +87,7 @@ func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
keys := make([]domain.APIKey, 0)
for rows.Next() {
var k domain.APIKey
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt); err == nil {
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &k.Scopes, &k.LastUsedAt, &k.CreatedAt, &k.Kind); err == nil {
keys = append(keys, k)
}
}
@@ -143,7 +144,7 @@ func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, string, error) {
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
var keyID, userID, scopes string
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ? AND kind = ?`, hash, APIKeyKindKey).Scan(&keyID, &userID, &scopes)
if err != nil {
return nil, nil, "", errors.New("invalid API key")
}
+1
View File
@@ -37,6 +37,7 @@ var migrations = []migration{
{14, "Erledigte Aufgaben aufräumen", (*Store).migrateDoneCleanup},
{15, "Spracheingabe pro Person", (*Store).migrateVoiceInput},
{16, "Aufbewahrung und Papierkorb für Aufgaben", (*Store).migrateRetention},
{17, "OAuth für den MCP-Zugang", (*Store).migrateOAuth},
}
func (s *Store) migrate() error {
+302
View File
@@ -0,0 +1,302 @@
package storage
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
)
// OAuth for the MCP endpoint (Doku/plans/oauth-fuer-mcp.md). MindSift is its own authorization
// server: clients register themselves (public clients, PKCE), a person agrees on the consent page,
// and the grant becomes an entry in api_keys (kind 'oauth'). Rights, origin, "last used" and revoking
// therefore work as for every key; the tokens hang on that entry and disappear with it. Codes and
// tokens are stored only as SHA-256 hashes.
const (
APIKeyKindKey = "key"
APIKeyKindOAuth = "oauth"
OAuthAccessPrefix = "mindsift_oat_"
OAuthRefreshPrefix = "mindsift_ort_"
OAuthCodeTTL = 10 * time.Minute
OAuthAccessTTL = time.Hour
OAuthRefreshTTL = 90 * 24 * time.Hour
// Clients nobody agreed to within this time are removed again (registration is open to anyone)
oauthUnusedClientTTL = 24 * time.Hour
)
var (
ErrOAuthClientNotFound = errors.New("unknown client")
ErrOAuthInvalidGrant = errors.New("invalid grant")
ErrOAuthTokenInvalid = errors.New("invalid token")
)
type OAuthClient struct {
ID string
Name string
RedirectURIs []string
CreatedAt string
}
// OAuthTokens: what the token endpoint hands out
type OAuthTokens struct {
AccessToken string
RefreshToken string
ExpiresIn int
Scopes []string
}
func (s *Store) migrateOAuth() error {
if err := s.addColumnIfMissing("api_keys", "kind", "TEXT NOT NULL DEFAULT 'key'"); err != nil {
return err
}
if err := s.addColumnIfMissing("api_keys", "oauth_client_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
created_at TEXT NOT NULL
)`),
stmt(`CREATE TABLE IF NOT EXISTS oauth_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
key_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY(key_id) REFERENCES api_keys(id) ON DELETE CASCADE
)`),
stmt(`CREATE TABLE IF NOT EXISTS oauth_tokens (
token_hash TEXT PRIMARY KEY,
kind TEXT NOT NULL,
key_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
replaced_at TEXT NOT NULL DEFAULT '',
FOREIGN KEY(key_id) REFERENCES api_keys(id) ON DELETE CASCADE
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_key ON oauth_tokens(key_id)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_api_keys_oauth ON api_keys(user_id, oauth_client_id)`),
)
}
func randomToken(prefix string) string {
buf := make([]byte, 32)
_, _ = rand.Read(buf)
return prefix + hex.EncodeToString(buf)
}
func hashOAuthSecret(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func isoTime(t time.Time) string { return t.UTC().Format(time.RFC3339) }
// CreateOAuthClient stores a self-registered client (RFC 7591); the redirect URIs are checked by the caller.
func (s *Store) CreateOAuthClient(name string, redirectURIs []string, now time.Time) (*OAuthClient, error) {
name = strings.TrimSpace(name)
if name == "" {
name = "KI-Agent"
}
if r := []rune(name); len(r) > 60 {
name = string(r[:60])
}
uris, _ := json.Marshal(redirectURIs)
c := &OAuthClient{ID: domain.NewID("mcl"), Name: name, RedirectURIs: redirectURIs, CreatedAt: isoTime(now)}
_, err := s.db.Exec(`INSERT INTO oauth_clients (id, name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`,
c.ID, c.Name, string(uris), c.CreatedAt)
if err != nil {
return nil, err
}
return c, nil
}
func (s *Store) GetOAuthClient(id string) (*OAuthClient, error) {
var c OAuthClient
var uris string
err := s.db.QueryRow(`SELECT id, name, redirect_uris, created_at FROM oauth_clients WHERE id = ?`, id).
Scan(&c.ID, &c.Name, &uris, &c.CreatedAt)
if err != nil {
return nil, ErrOAuthClientNotFound
}
_ = json.Unmarshal([]byte(uris), &c.RedirectURIs)
return &c, nil
}
// GrantOAuth records a person's consent and returns a one-time authorization code. The grant is the
// api_keys entry of this person and client; agreeing again reuses it with the newly chosen rights, so
// reconnecting does not pile up entries.
func (s *Store) GrantOAuth(userID string, client *OAuthClient, scopes []string, redirectURI, codeChallenge string, now time.Time) (string, error) {
joined := strings.Join(NormalizeScopes(scopes), ",")
var keyID string
err := s.db.QueryRow(`SELECT id FROM api_keys WHERE user_id = ? AND kind = ? AND oauth_client_id = ?`,
userID, APIKeyKindOAuth, client.ID).Scan(&keyID)
switch {
case err == sql.ErrNoRows:
keyID = domain.NewID("key")
_, err = s.db.Exec(
`INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, scopes, created_at, kind, oauth_client_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
// Nobody knows this secret: OAuth entries are only reachable through their tokens
keyID, userID, client.Name+" (OAuth)", "OAuth", auth.HashAPIKey(randomToken("unused_")), joined, isoTime(now),
APIKeyKindOAuth, client.ID)
case err == nil:
_, err = s.db.Exec(`UPDATE api_keys SET scopes = ? WHERE id = ?`, joined, keyID)
}
if err != nil {
return "", err
}
code := randomToken("")
_, err = s.db.Exec(`INSERT INTO oauth_codes (code_hash, client_id, key_id, redirect_uri, code_challenge, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, hashOAuthSecret(code), client.ID, keyID, redirectURI, codeChallenge, isoTime(now.Add(OAuthCodeTTL)))
if err != nil {
return "", err
}
return code, nil
}
// pkceMatches: S256, base64url(SHA-256(verifier)) without padding (RFC 7636)
func pkceMatches(verifier, challenge string) bool {
if len(verifier) < 43 || len(verifier) > 128 {
return false
}
sum := sha256.Sum256([]byte(verifier))
return subtle.ConstantTimeCompare([]byte(base64.RawURLEncoding.EncodeToString(sum[:])), []byte(challenge)) == 1
}
// ExchangeOAuthCode trades an authorization code for tokens. The code works once, within its time, for
// the client and redirect URI it was issued to and with the matching PKCE verifier.
func (s *Store) ExchangeOAuthCode(code, clientID, redirectURI, verifier string, now time.Time) (*OAuthTokens, error) {
var codeClient, keyID, codeRedirect, challenge, expires string
err := s.db.QueryRow(`SELECT client_id, key_id, redirect_uri, code_challenge, expires_at FROM oauth_codes WHERE code_hash = ?`,
hashOAuthSecret(code)).Scan(&codeClient, &keyID, &codeRedirect, &challenge, &expires)
if err != nil {
return nil, ErrOAuthInvalidGrant
}
// Used up whatever happens next; a second attempt with the same code finds nothing
res, err := s.db.Exec(`DELETE FROM oauth_codes WHERE code_hash = ?`, hashOAuthSecret(code))
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, ErrOAuthInvalidGrant
}
if codeClient != clientID || codeRedirect != redirectURI || expires < isoTime(now) || !pkceMatches(verifier, challenge) {
return nil, ErrOAuthInvalidGrant
}
return s.issueOAuthTokens(keyID, now)
}
// RefreshOAuth hands out new tokens for a refresh token and retires the old one. A retired refresh
// token used again means it was copied: every token of the grant ends, the client has to ask again.
func (s *Store) RefreshOAuth(refresh, clientID string, now time.Time) (*OAuthTokens, error) {
var keyID, expires, replaced, grantClient string
err := s.db.QueryRow(`SELECT t.key_id, t.expires_at, t.replaced_at, k.oauth_client_id FROM oauth_tokens t
JOIN api_keys k ON k.id = t.key_id WHERE t.token_hash = ? AND t.kind = 'refresh'`,
hashOAuthSecret(refresh)).Scan(&keyID, &expires, &replaced, &grantClient)
if err != nil || grantClient != clientID || expires < isoTime(now) {
return nil, ErrOAuthInvalidGrant
}
if replaced != "" {
_, _ = s.db.Exec(`DELETE FROM oauth_tokens WHERE key_id = ?`, keyID)
return nil, ErrOAuthInvalidGrant
}
res, err := s.db.Exec(`UPDATE oauth_tokens SET replaced_at = ? WHERE token_hash = ? AND replaced_at = ''`,
isoTime(now), hashOAuthSecret(refresh))
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, ErrOAuthInvalidGrant // refreshed in parallel
}
return s.issueOAuthTokens(keyID, now)
}
func (s *Store) issueOAuthTokens(keyID string, now time.Time) (*OAuthTokens, error) {
var scopes string
if err := s.db.QueryRow(`SELECT scopes FROM api_keys WHERE id = ?`, keyID).Scan(&scopes); err != nil {
return nil, ErrOAuthInvalidGrant // revoked meanwhile
}
t := &OAuthTokens{
AccessToken: randomToken(OAuthAccessPrefix),
RefreshToken: randomToken(OAuthRefreshPrefix),
ExpiresIn: int(OAuthAccessTTL.Seconds()),
Scopes: NormalizeScopes(strings.Split(scopes, ",")),
}
err := s.execInTx(
stmt(`INSERT INTO oauth_tokens (token_hash, kind, key_id, expires_at) VALUES (?, 'access', ?, ?)`,
hashOAuthSecret(t.AccessToken), keyID, isoTime(now.Add(OAuthAccessTTL))),
stmt(`INSERT INTO oauth_tokens (token_hash, kind, key_id, expires_at) VALUES (?, 'refresh', ?, ?)`,
hashOAuthSecret(t.RefreshToken), keyID, isoTime(now.Add(OAuthRefreshTTL))),
stmt(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, isoTime(now), keyID),
)
if err != nil {
return nil, err
}
return t, nil
}
// ValidateOAuthAccessToken returns owner, rights and entry id of an access token, like ValidateAPIKey.
func (s *Store) ValidateOAuthAccessToken(raw string, now time.Time) (*domain.User, []string, string, error) {
var keyID, userID, scopes, expires string
err := s.db.QueryRow(`SELECT k.id, k.user_id, k.scopes, t.expires_at FROM oauth_tokens t
JOIN api_keys k ON k.id = t.key_id WHERE t.token_hash = ? AND t.kind = 'access'`,
hashOAuthSecret(strings.TrimSpace(raw))).Scan(&keyID, &userID, &scopes, &expires)
if err != nil || expires < isoTime(now) {
return nil, nil, "", ErrOAuthTokenInvalid
}
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, isoTime(now), keyID)
u, err := s.GetUserByID(userID)
return u, NormalizeScopes(strings.Split(scopes, ",")), keyID, err
}
// RevokeOAuthToken ends one token (RFC 7009). Revoking a refresh token ends the access tokens of the
// grant as well; the entry itself stays until the person removes it.
func (s *Store) RevokeOAuthToken(raw, clientID string) {
var keyID, kind, grantClient string
if s.db.QueryRow(`SELECT t.key_id, t.kind, k.oauth_client_id FROM oauth_tokens t JOIN api_keys k ON k.id = t.key_id
WHERE t.token_hash = ?`, hashOAuthSecret(raw)).Scan(&keyID, &kind, &grantClient) != nil || grantClient != clientID {
return
}
if kind == "refresh" {
_, _ = s.db.Exec(`DELETE FROM oauth_tokens WHERE key_id = ?`, keyID)
return
}
_, _ = s.db.Exec(`DELETE FROM oauth_tokens WHERE token_hash = ?`, hashOAuthSecret(raw))
}
// PurgeOAuth removes expired codes and tokens, clients nobody agreed to and – with idleDays > 0 –
// OAuth entries unused for that many days (their tokens go with them).
func (s *Store) PurgeOAuth(now time.Time, idleDays int) (removedGrants int, err error) {
if idleDays > 0 {
cutoff := isoTime(now.AddDate(0, 0, -idleDays))
res, err := s.db.Exec(`DELETE FROM api_keys WHERE kind = ? AND CASE WHEN last_used_at = '' THEN created_at ELSE last_used_at END < ?`,
APIKeyKindOAuth, cutoff)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
removedGrants = int(n)
}
// After the grants, so that a client whose last connection just went is removed in the same pass
return removedGrants, s.execInTx(
stmt(`DELETE FROM oauth_codes WHERE expires_at < ?`, isoTime(now)),
stmt(`DELETE FROM oauth_tokens WHERE expires_at < ?`, isoTime(now)),
stmt(`DELETE FROM oauth_clients WHERE created_at < ? AND id NOT IN (SELECT oauth_client_id FROM api_keys WHERE kind = ?)`,
isoTime(now.Add(-oauthUnusedClientTTL)), APIKeyKindOAuth),
)
}
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"crypto/sha256"
"encoding/base64"
"path/filepath"
"strings"
"testing"
"time"
)
// Lifetimes follow the time handed in, not the clock: code 10 minutes, access token 1 hour, unused
// connections after the configured days.
func TestOAuthLifetimes(t *testing.T) {
s, err := NewStore(filepath.Join(t.TempDir(), "oauth.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
u, err := s.EnsureDefaultUser()
if err != nil {
t.Fatal(err)
}
start := time.Date(2026, 3, 29, 1, 30, 0, 0, time.FixedZone("CET", 3600)) // around a DST switch
client, err := s.CreateOAuthClient("Claude", []string{"https://claude.ai/cb"}, start)
if err != nil {
t.Fatal(err)
}
verifier := strings.Repeat("a", 43)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
late, _ := s.GrantOAuth(u.ID, client, []string{ScopeTasksRead}, "https://claude.ai/cb", challenge, start)
if _, err := s.ExchangeOAuthCode(late, client.ID, "https://claude.ai/cb", verifier, start.Add(11*time.Minute)); err != ErrOAuthInvalidGrant {
t.Fatalf("code after 11 minutes must be refused, got %v", err)
}
code, _ := s.GrantOAuth(u.ID, client, []string{ScopeTasksRead}, "https://claude.ai/cb", challenge, start)
tok, err := s.ExchangeOAuthCode(code, client.ID, "https://claude.ai/cb", verifier, start.Add(9*time.Minute))
if err != nil {
t.Fatalf("exchange: %v", err)
}
issued := start.Add(9 * time.Minute)
if _, _, _, err := s.ValidateOAuthAccessToken(tok.AccessToken, issued.Add(59*time.Minute)); err != nil {
t.Fatalf("access token within the hour: %v", err)
}
if _, _, _, err := s.ValidateOAuthAccessToken(tok.AccessToken, issued.Add(61*time.Minute)); err == nil {
t.Fatal("access token after an hour must be refused")
}
if _, err := s.RefreshOAuth(tok.RefreshToken, client.ID, issued.Add(91*24*time.Hour)); err != ErrOAuthInvalidGrant {
t.Fatalf("refresh token after 90 days must be refused, got %v", err)
}
if _, err := s.RefreshOAuth(tok.RefreshToken, "mcl_other", issued.Add(time.Hour)); err != ErrOAuthInvalidGrant {
t.Fatalf("refresh token of another client must be refused, got %v", err)
}
// An ordinary key's secret check never finds OAuth entries
if _, _, _, err := s.ValidateAPIKey(tok.AccessToken); err == nil {
t.Fatal("OAuth token must not pass as API key")
}
// Last use was at issue time: 0 days keeps it, after 90 days it goes; tokens go with it
lastUse := issued.Add(59 * time.Minute)
if n, _ := s.PurgeOAuth(lastUse.AddDate(0, 0, 200), 0); n != 0 {
t.Fatalf("idle days 0 must keep connections, removed %d", n)
}
if n, _ := s.PurgeOAuth(lastUse.AddDate(0, 0, 89), 90); n != 0 {
t.Fatalf("used 89 days ago must stay, removed %d", n)
}
fresh, _ := s.GrantOAuth(u.ID, client, []string{ScopeTasksRead}, "https://claude.ai/cb", challenge, lastUse)
if n, _ := s.PurgeOAuth(lastUse.AddDate(0, 0, 91), 90); n != 1 {
t.Fatalf("unused for 91 days must go, removed %d", n)
}
if _, err := s.ExchangeOAuthCode(fresh, client.ID, "https://claude.ai/cb", verifier, lastUse); err == nil {
t.Fatal("codes of a removed connection must be gone")
}
// The client without any connection left is removed a day later
if _, err := s.GetOAuthClient(client.ID); err == nil {
t.Fatal("unused client should have been removed with the purge")
}
}