diff --git a/.env.example b/.env.example index 592d14a..c68d636 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ BODY_SIZE_LIMIT=6M # Generate this locally and insert it here as described in README.md. Never commit .env. SENDEWERK_SETUP_TOKEN= +# Public production origins must use HTTPS; loopback HTTP remains available locally. SENDEWERK_APP_ORIGIN=http://localhost:5173 # "local" writes messages to the outbox and never contacts a mail server. diff --git a/CloudronManifest.json b/CloudronManifest.json index 9498423..77c87ad 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -3,7 +3,7 @@ "title": "Sendewerk", "description": "Redaktionelle Planung und Durchführung von heinzlive-Sendungen.", "tagline": "Vom Fundstück bis zur Livesendung", - "version": "0.7.2", + "version": "0.7.3", "healthCheckPath": "/healthz", "httpPort": 3000, "addons": { diff --git a/README.md b/README.md index 2c323c5..3c32fa2 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Sendewerk ist die redaktionelle Webanwendung für den Weg vom vorbereiteten Inhalt bis zur moderierten und archivierten Sendung. Der aktuelle Stand umfasst -Etappe 1 bis 5: technisches Grundgerüst, Anmeldung und Rollen, einen -persistenten Fundus, den Sendungsbaukasten sowie Live-Steuerung und eine -abgesicherte Präsentationsausgabe. Abschluss und Archivierung folgen in Etappe 6. +Etappe 1 bis 7: technisches Grundgerüst, Anmeldung und Rollen, Fundus, +Sendungsbau, Live-Steuerung, Präsentationsausgabe, Abschluss, Archiv, +Freigaben, RSS sowie technische REST- und MCP-Zugänge. ## Voraussetzungen @@ -62,6 +62,9 @@ enthält bewusst kein Konto und kein Standardpasswort. Relative Sendewerk-Pfade werden vom Projektverzeichnis aus aufgelöst. In Cloudron liegen Datenbank und Uploads unter `/app/data`. +Eine öffentliche Produktions-Origin muss HTTPS verwenden. Nur für lokale +Loopback-Adressen bleibt HTTP zulässig. + Der Vorgabemodus `local` baut keine Netzwerkverbindung auf. Einladungen und Passwort-Resets landen als JSON-Dateien in `.data/mail-outbox`; die enthaltenen Links sind Geheimnisse und dürfen nicht veröffentlicht werden. Echter Versand @@ -151,6 +154,53 @@ DNS-, Redirect-, Zeit- und Größenlimits abgerufen. Private und lokale Netzwerkziele sind gesperrt. Eingebettetes HTML oder Skripte einer Quellseite werden nicht übernommen. +## MCP und ChatGPT + +Der lesende Streamable-HTTP-Endpunkt liegt unter `/mcp`. Seine konkrete +Adresse ist die konfigurierte Anwendungs-Origin mit angehängtem Pfad: + +```text +https:///mcp +``` + +Beispiel: Bei `SENDEWERK_APP_ORIGIN=https://sendewerk.example.org` lautet die +MCP-URL `https://sendewerk.example.org/mcp`. Niemals einen technischen +REST-Token an die URL hängen oder in ChatGPT eintragen. MCP verwendet einen +eigenen OAuth-2.1-Flow mit Authorization Code, PKCE und kurzlebigen Tokens. + +Die Verbindung stellt ausschließlich lesende Werkzeuge bereit: + +- `get_profile` identifiziert das verbundene Sendewerk-Konto ohne E-Mail-Adresse. +- `list_pieces` und `get_piece` lesen Fundus-Pieces. +- `list_shows` und `get_show_rundown` lesen Sendungen und deren Abläufe. + +Die Detailwerkzeuge können private Recherche, Moderationsnotizen, Quellen, +eigene Notizen und Reserveinhalte liefern. Der angemeldete Nutzer muss diesem +Umfang ausdrücklich zustimmen. Sperrung oder Passwortwechsel widerrufen +bestehende OAuth-Tokens; jede MCP-Anfrage prüft Konto, Scope, Ablauf und +Zielressource erneut. MCP kann keine Inhalte schreiben. + +### In ChatGPT einrichten + +1. Zuerst Version `0.7.3` unter der endgültigen öffentlichen HTTPS-Origin + bereitstellen. Lokalhost ohne öffentliches HTTPS ist für eine direkte + ChatGPT-Verbindung nicht erreichbar. +2. In [ChatGPT Plugins](https://chatgpt.com/plugins) das Plus wählen und + **Add custom MCP server** öffnen. +3. Einen Namen und eine Beschreibung vergeben und unter **Connection** die + vollständige URL `https:///mcp` eintragen. +4. OAuth als Authentifizierung verwenden, den Risikohinweis prüfen und + **Create as a plugin** wählen. ChatGPT entdeckt die OAuth-Endpunkte und + registriert einen öffentlichen PKCE-Client dynamisch. +5. Im geöffneten Sendewerk-Fenster mit dem eigenen Konto anmelden, Clientname + und Rücksprung-Domain prüfen und den lesenden Zugriff ausdrücklich erlauben. +6. Die gefundenen fünf Werkzeuge kontrollieren, das Plugin installieren und in + einer neuen Unterhaltung über `@` auswählen. + +Eine erfolgreich lokale OAuth- und MCP-Prüfung beweist noch keine reale +ChatGPT-Verbindung. Diese muss nach dem Cloudron-Update separat in der +jeweiligen ChatGPT-Workspace-Konfiguration durchgeführt werden. + ## Befehle | Befehl | Zweck | @@ -210,6 +260,12 @@ Umgebungsvariable gesetzt werden. `CLOUDRON_APP_ORIGIN` liefert dort die feste HTTPS-Origin. SMTP-Zugangsdaten werden ebenfalls ausschließlich als Laufzeitvariablen gesetzt, nicht in Manifest oder Image geschrieben. +Für MCP sind keine zusätzlichen Geheimnisse erforderlich. Nach einem Update +auf `0.7.3` führt der bestehende Startprozess Migration `0020_oauth_mcp.sql` +aus. Anschließend muss die von Cloudron gelieferte HTTPS-Origin mit der in +ChatGPT eingetragenen MCP-Domain übereinstimmen. Reverse Proxy und öffentliche +Erreichbarkeit wurden ohne Cloudron-Zugang nicht geprüft. + Das ersetzt keinen Test auf einem echten Cloudron. Insbesondere Addon-Backup, Restore, Proxy-Origin und das read-only Laufzeitdateisystem können erst dort abschließend geprüft werden. diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a81bd5c..9d0ca4f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -1,5 +1,28 @@ # Abnahme +## Lesender MCP-Zugang 0.7.3 + +| Bereich | Nachweis | Stand | +| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------- | +| OAuth-Discovery und anonymer Schutz | Reale lokale HTTP-Anfrage erhält 401 mit `resource_metadata`; Discovery-Routen sind im Produktionsbuild enthalten | erfüllt am 27.10.2026 | +| DCR, Zustimmung und PKCE | Isolierter HTTP-Ablauf registriert Client, lädt Zustimmung, erhält Code mit `state`/`iss` und tauscht per S256 | erfüllt am 27.10.2026 | +| Nutzer-, Scope- und Ressourcenbindung | Ausgestellter Token ist gehasht persistiert und wird bei jedem Aufruf gegen Nutzer, Scope, Ablauf und `/mcp` geprüft | erfüllt am 27.10.2026 | +| MCP-Kompatibilität | Legacy-Initialisierung `2025-11-25` sowie `server/discover` und `tools/list` mit `2026-07-28` über HTTP | erfüllt am 27.10.2026 | +| Tatsächliche Leseanfrage | `tools/call` auf `list_pieces` liefert aus einer temporären Seed-Datenbank reale Pieces | erfüllt am 27.10.2026 | +| Schreibschutz und Datenminimierung | Nur fünf Lese-Tools; Positivprojektionen ohne E-Mail, Uploadnamen, Dateipfade und Werkzeug-Arbeitsdaten | erfüllt am 27.10.2026 | +| ChatGPT- und Cloudron-Verbindung | Kein Deployment und keine externe Clientumgebung vorhanden | offen | + +- `npm run check`: 0 Fehler und 0 Warnungen. +- Gezielter isolierter HTTP-Ablauf: OAuth Code Flow, PKCE, anonymer Challenge, + Legacy-Initialisierung, aktuelle Discovery, Toolliste und echte + `list_pieces`-Anfrage erfolgreich. +- `npm run build`: erfolgreich mit `@sveltejs/adapter-node`. +- Keine weitere Verhaltenstest- oder Browsermatrix gemäß Auftrag ausgeführt; + die Zustimmungsseite wurde im HTTP-Ablauf geladen, aber nicht visuell geprüft. +- Echte ChatGPT-Verbindung, öffentliche HTTPS-Erreichbarkeit und Cloudron- + Reverse-Proxy-Verhalten bleiben bis zu einem ausdrücklich freigegebenen + Deployment offen. + ## Wartungsrelease 0.7.2 | Bereich | Nachweis | Stand | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5d7bbfb..e905dca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -481,3 +481,36 @@ Name und Umfang des bestehenden gehashten Tokens und wird auditiert. Die Live-Route projiziert zusätzlich nur, ob der feste Ausgabekanal getrennt, mit dieser oder mit einer anderen Sendung verbunden ist. Das Öffnen von Menü und Ausgabedetails führt selbst keine Mutation aus. + +## Lesender MCP-Zugang 0.7.3 + +`/mcp` ist ein stateless Streamable-HTTP-Adapter über `content.ts` und +`shows.ts`. Er unterstützt die aktuelle per Request deklarierte MCP-Fassung +`2026-07-28` einschließlich `server/discover`, Header-/Body-Abgleich und +`resultType`. Für derzeitige OpenAI-Clients bleiben die Initialisierungsfassungen +`2025-11-25`, `2025-06-18` und `2025-03-26` verfügbar. Es gibt keine +MCP-Sitzung und keinen unabhängigen SSE-Strom; jede Anfrage trägt erneut ihre +Authentifizierung. + +Die fünf Werkzeuge sind ausschließlich lesend. Eigene Positivprojektionen +lassen Mediennamen, interne Speicherpfade und Werkzeug-Arbeitsdaten aus. Die +Detailprojektionen dürfen nach ausdrücklicher Zustimmung redaktionell private +Recherche, Moderation, eigene Notizen, Quellen und Reserveinhalte enthalten. +Keine MCP-Methode ruft Mutationsdienste auf. + +Sendewerk ist zugleich OAuth-Autorisierungs- und Ressourcenserver. Geschützte +Ressourcen- und Authorization-Server-Metadaten verweisen ausschließlich auf die +konfigurierte Origin. Öffentliche PKCE-Clients werden per begrenztem DCR +registriert; die Autorisierung verlangt exakte Redirect-URI, `S256`, den Scope +`sendewerk:read` und den exakten Ressourcenindikator `/mcp`. Codes sind +fünf Minuten, Zugriffstokens eine Stunde gültig. SQLite speichert nur SHA-256- +Hashes. Tokens sind direkt an Nutzer, Client, Scope und Ressource gebunden; +Kontosperre und Passwortwechsel widerrufen sie, und jeder MCP-Aufruf verbindet +erneut den aktuellen Nutzerzustand. + +DCR ist für die von OpenAI weiterhin unterstützte Kompatibilität vorhanden, +aber pro Quelladresse und insgesamt begrenzt. Clientnamen sind nicht verifiziert; +der Zustimmungsdialog kennzeichnet dies und zeigt die genaue Rücksprung-Domain. +Öffentliche Produktion verlangt HTTPS, während HTTP nur an Loopback-Adressen +zulässig bleibt. Der bestehende technische REST-Token wird weder gelesen noch +weitergereicht. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5682335..4d55189 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -532,3 +532,41 @@ seinen ausschließlich gehasht gespeicherten Token und erzeugt ein Auditereignis Das Live-Menü zeigt einen rein gelesenen Verbindungsstatus des Ausgabekanals. Nur die bereits vorhandenen expliziten Verbinden-, Rotieren- und Trennenaktionen verändern öffentliche Ausgabe. Navigation zur Planung bleibt davon getrennt. + +## D-034: Nutzergebundener OAuth-Zugang statt Token-Passthrough + +Der bestehende technische REST-Zugang kann und darf nicht für ChatGPT +weitergereicht werden: Sein Klartexttoken ist nach der Erstellung nicht mehr +verfügbar, und MCP verbietet Token-Passthrough. Sendewerk stellt deshalb einen +eigenen OAuth-2.1-Authorization-Code-Flow für öffentliche Clients bereit. PKCE +`S256`, exakte Redirect-URIs und der auf `/mcp` gebundene +Ressourcenindikator sind verpflichtend. Codes und einstündige Zugriffstokens +liegen ausschließlich gehasht vor und sind direkt an ein aktives Nutzerkonto +gebunden. + +Die Autorisierungsserver-Metadaten bieten das von OpenAI unterstützte Dynamic +Client Registration als Kompatibilitätsweg an. Die aktuelle MCP-Fassung stuft +DCR zugunsten von Client ID Metadata Documents als veraltet ein. Ein allgemeiner +CIMD-Abruf würde jedoch eine neue SSRF- und Cache-Sicherheitsgrenze eröffnen; +für diese private Anwendung bleibt DCR deshalb eng mengen- und ratenbegrenzt. +Der Zustimmungsdialog behandelt den selbst gemeldeten Clientnamen ausdrücklich +als unverifiziert und zeigt dessen Rücksprung-Domain. + +Der MCP-Adapter unterstützt sowohl die aktuelle stateless Fassung `2026-07-28` +als auch die von OpenAI weiterhin dokumentierten Initialisierungsfassungen bis +`2025-03-26`. Tooldaten werden aus bestehenden Diensten positiv projiziert. +Der einzige Scope `sendewerk:read` ist im Dialog ausdrücklich einschließlich +privater redaktioneller Recherche beschrieben; E-Mail, Uploadnamen, +Speicherpfade und Werkzeug-Arbeitsdaten werden nicht ausgegeben. Alle Werkzeuge +sind als lesend markiert und haben keinen Pfad zu Mutationsdiensten. + +Quellen, geprüft am 27. Oktober 2026: + +- +- +- +- +- +- +- +- diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index a49fb97..5a9e539 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -1,5 +1,36 @@ # Release Notes +## 0.7.3 - Lesender MCP-Zugang mit OAuth 2.1 + +Veröffentlicht am 27. Oktober 2026 + +- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` bietet fünf ausschließlich + lesende Werkzeuge für Kontoidentität, Pieces, Sendungen und vollständige + Abläufe. Er verwendet die bestehenden Inhalts- und Sendungsdienste und keine + parallele Datenhaltung. +- OAuth-2.1-Discovery, begrenzte dynamische Clientregistrierung, + Authorization Code, PKCE `S256`, exakte Redirect-URI und Ressourcenbindung + ermöglichen die von OpenAI dokumentierte Kontoverknüpfung. Codes und + einstündige Tokens werden nur gehasht gespeichert. +- Nutzerzustand, Sperren und Rollen werden bei jedem MCP-Aufruf erneut gelesen. + Passwortwechsel und Kontosperre widerrufen bestehende OAuth-Tokens. Der + vorhandene technische REST-Token wird weder offengelegt noch weitergereicht. +- MCP `2026-07-28` und die weiterhin von OpenAI verwendeten älteren + Initialisierungsfassungen werden am selben stateless Endpunkt unterstützt. + Private redaktionelle Details sind erst nach ausdrücklicher Zustimmung + lesbar; E-Mail, Uploadnamen, interne Pfade und Werkzeug-Arbeitsdaten bleiben + ausgeschlossen. +- Die Produktions-URL lautet `https:///mcp`. README und + Betriebsdokumentation beschreiben die Einrichtung über **Add custom MCP + server**. Das Release wurde nicht auf Cloudron ausgerollt und eine reale + ChatGPT-Verbindung wird nicht als geprüft ausgegeben. +- Paket, Lockdatei und Cloudron-Manifest tragen Version `0.7.3`. Migration + `0020_oauth_mcp.sql` ergänzt additive OAuth- und Ratenbegrenzungstabellen. +- Typprüfung und Produktionsbuild sind erfolgreich. Ein isolierter lokaler + HTTP-Rundlauf hat OAuth, beide MCP-Protokollgenerationen und eine echte + `list_pieces`-Anfrage geprüft; eine reale ChatGPT-/Cloudron-Verbindung bleibt + ausdrücklich offen. + ## 0.7.2 - RSS-Auswahl, Live-Menü und Schnittstellenverwaltung Veröffentlicht am 26. Oktober 2026 diff --git a/docs/STATUS.md b/docs/STATUS.md index 21faa80..c18f34e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -8,6 +8,31 @@ Live-Steuerung, Präsentationsausgabe, unveränderliche Sendungshistorie, Archiv, sichere Lesefreigaben, RSS-Import und technische REST-Zugänge sind dokumentiert. +## Lesender MCP-Zugang 0.7.3 + +- Der öffentliche Streamable-HTTP-Endpunkt `/mcp` liest Pieces, Sendungen und + Abläufe über dieselben Dienste wie Browser und REST-API. Fünf deklarierte + Werkzeuge besitzen nur Leseannotationen und keine Mutationspfade. +- OAuth 2.1 verwendet Discovery-Metadaten, begrenzte dynamische + Clientregistrierung, Authorization Code, PKCE `S256`, exakte Redirect-URIs + und Ressourcenbindung. Codes und einstündige Tokens liegen nur gehasht vor. +- OAuth-Tokens sind an den zustimmenden Nutzer gebunden. Sperrung und + Passwortwechsel widerrufen sie; jeder Zugriff prüft erneut Nutzerstatus, + Scope, Ablauf und Zielressource. +- Aktuelles MCP `2026-07-28` mit `server/discover` und per-Request-Metadaten + läuft neben den OpenAI-kompatiblen Initialisierungsfassungen. Ausgaben sind + Positivlisten ohne E-Mail, Uploadnamen, interne Pfade oder Werkzeug-Arbeitsdaten. +- Die konkrete Produktionsadresse ist + `https:///mcp`. Ein Cloudron-Deployment und eine reale + ChatGPT-Verbindung wurden nicht durchgeführt. + +Geprüft wurden Typen, Produktionsbuild und ein isolierter tatsächlicher +HTTP-Ablauf über eine temporäre Datenbank: anonymer OAuth-Challenge, DCR, +Zustimmung, PKCE-Tokenaustausch, Legacy-Initialisierung, aktuelle +`server/discover`-Abfrage, Toolliste und `list_pieces` mit Seed-Daten waren +erfolgreich. Eine weitere Verhaltenstest- oder Browsermatrix wurde gemäß +Auftrag nicht ausgeführt. + ## Bedienkorrekturen 0.7.2 - Der RSS-Importeingang ist eine kompakte Katalogfläche. Die eigentliche Auswahl diff --git a/migrations/0020_oauth_mcp.sql b/migrations/0020_oauth_mcp.sql new file mode 100644 index 0000000..fa45144 --- /dev/null +++ b/migrations/0020_oauth_mcp.sql @@ -0,0 +1,49 @@ +CREATE TABLE oauth_clients ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + redirect_uris_json TEXT NOT NULL CHECK (json_valid(redirect_uris_json)), + created_at_utc TEXT NOT NULL +); + +CREATE TABLE oauth_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + code_challenge TEXT NOT NULL, + created_at_utc TEXT NOT NULL, + expires_at_utc TEXT NOT NULL, + consumed_at_utc TEXT +); + +CREATE INDEX oauth_authorization_codes_expiry_idx + ON oauth_authorization_codes(expires_at_utc); + +CREATE TABLE oauth_access_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scope TEXT NOT NULL, + resource TEXT NOT NULL, + created_at_utc TEXT NOT NULL, + expires_at_utc TEXT NOT NULL, + last_used_at_utc TEXT, + request_window_started_at_utc TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0 CHECK (request_count >= 0), + revoked_at_utc TEXT +); + +CREATE INDEX oauth_access_tokens_user_idx ON oauth_access_tokens(user_id); +CREATE INDEX oauth_access_tokens_expiry_idx ON oauth_access_tokens(expires_at_utc); + +CREATE TABLE oauth_rate_limit_attempts ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('registration', 'token')), + address_hash TEXT NOT NULL, + attempted_at_utc TEXT NOT NULL +); + +CREATE INDEX oauth_rate_limit_attempts_lookup_idx + ON oauth_rate_limit_attempts(kind, address_hash, attempted_at_utc); diff --git a/package-lock.json b/package-lock.json index 6d5efd1..41e31d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sendewerk", - "version": "0.7.2", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sendewerk", - "version": "0.7.2", + "version": "0.7.3", "dependencies": { "@node-rs/argon2": "^2.2.2", "better-sqlite3": "^13.0.3", diff --git a/package.json b/package.json index 0a93654..99edf0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sendewerk", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "engines": { "node": ">=22.17 <25" diff --git a/src/app.css b/src/app.css index 7141345..11c2b25 100644 --- a/src/app.css +++ b/src/app.css @@ -3193,6 +3193,32 @@ textarea:focus { font-size: 0.85rem; } +.oauth-consent { + margin: clamp(1.25rem, 6vw, 4rem) auto; +} + +.oauth-permission { + display: grid; + gap: 0.2rem; + margin: 1.25rem 0; + padding: 0.9rem; + border: 1px solid var(--line); + border-radius: var(--radius-medium); + background: var(--panel-soft); +} + +.oauth-permission span, +.oauth-redirect { + color: var(--text-muted); + font-size: 0.8rem; +} + +.oauth-actions { + display: flex; + justify-content: flex-end; + gap: 0.7rem; +} + .auth-form, .compact-form { display: grid; diff --git a/src/app.d.ts b/src/app.d.ts index f230758..d1029da 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -8,6 +8,7 @@ declare global { interface PageData { user?: AuthUser | null; presentationMode?: boolean; + oauthMode?: boolean; } } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9f0d6df..b0f54e2 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -20,6 +20,10 @@ export const handle: Handle = async ({ event, resolve }) => { const sharePath = event.url.pathname.startsWith('/freigabe/'); const shareMediaPath = /^\/freigabe\/[^/]+\/media\/[^/]+$/.test(event.url.pathname); const apiPath = event.url.pathname.startsWith('/api/v1/'); + const oauthPath = + event.url.pathname.startsWith('/oauth/') || + event.url.pathname.startsWith('/.well-known/oauth-'); + const mcpPath = event.url.pathname === '/mcp'; const presentationTestPath = /^\/fundus\/[^/]+\/praesentation(?:\/|$)/.test(event.url.pathname); const token = event.cookies.get(SESSION_COOKIE); event.locals.user = getSessionUser(token); @@ -30,6 +34,8 @@ export const handle: Handle = async ({ event, resolve }) => { !sharePath && !publicPaths.has(event.url.pathname) && !apiPath && + !oauthPath && + !mcpPath && !event.locals.user ) { redirect(303, hasUsers() ? '/login' : '/setup'); @@ -66,7 +72,12 @@ export const handle: Handle = async ({ event, resolve }) => { ); } } - if (event.url.pathname === '/einladung' || event.url.pathname === '/passwort-zuruecksetzen') { + if ( + event.url.pathname === '/einladung' || + event.url.pathname === '/passwort-zuruecksetzen' || + oauthPath || + mcpPath + ) { response.headers.set('Cache-Control', 'no-store'); } return response; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index d02e531..21d50ff 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -564,7 +564,13 @@ export async function resetPassword(token: string, password: string): Promise { database.close(); expect(counts).toEqual({ - migrations: 19, + migrations: 20, pieces: 6, shows: 2, usages: 5, diff --git a/src/lib/server/mcp.ts b/src/lib/server/mcp.ts new file mode 100644 index 0000000..f7a7df1 --- /dev/null +++ b/src/lib/server/mcp.ts @@ -0,0 +1,431 @@ +import type { AuthUser } from './auth.js'; +import { PIECE_STATUSES, getPiece, listPieces, type PieceStatus } from './content.js'; +import { SHOW_STATUSES, getShow, listShows, type ShowStatus } from './shows.js'; +import { MCP_SCOPE } from './oauth.js'; + +const MAX_PAGE_SIZE = 100; + +type JsonRpcId = string | number | null; +type JsonRpcRequest = { + jsonrpc: '2.0'; + id?: JsonRpcId; + method: string; + params?: Record; +}; + +const oauthSecurity = [{ type: 'oauth2', scopes: [MCP_SCOPE] }]; +const readOnlyAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false +}; + +const profileOutputSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + id: { type: 'string', minLength: 1, pattern: '\\S' }, + name: { type: 'string' }, + email: { type: 'string' }, + nickname: { type: 'string' } + }, + required: ['id'], + additionalProperties: false +}; + +const paginationSchema = { + type: 'object', + properties: { + limit: { type: 'integer' }, + offset: { type: 'integer' }, + total: { type: 'integer' } + }, + required: ['limit', 'offset', 'total'], + additionalProperties: false +}; + +const tools = [ + { + name: 'get_profile', + title: 'Sendewerk-Profil', + description: 'Gibt das mit dieser Verbindung verknüpfte Sendewerk-Konto zurück.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + outputSchema: profileOutputSchema, + annotations: readOnlyAnnotations, + securitySchemes: oauthSecurity, + _meta: { 'openai/profile': true, securitySchemes: oauthSecurity } + }, + { + name: 'list_pieces', + title: 'Pieces durchsuchen', + description: 'Listet und durchsucht redaktionelle Pieces im Sendewerk-Fundus.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', maxLength: 200 }, + statuses: { + type: 'array', + items: { type: 'string', enum: PIECE_STATUSES }, + uniqueItems: true + }, + limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 }, + offset: { type: 'integer', minimum: 0, default: 0 } + }, + additionalProperties: false + }, + outputSchema: { + type: 'object', + properties: { + pieces: { type: 'array', items: { type: 'object' } }, + pagination: paginationSchema + }, + required: ['pieces', 'pagination'], + additionalProperties: false + }, + annotations: readOnlyAnnotations, + securitySchemes: oauthSecurity, + _meta: { securitySchemes: oauthSecurity } + }, + { + name: 'get_piece', + title: 'Piece lesen', + description: 'Liest die redaktionellen Texte und Quellen eines einzelnen Pieces.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string', minLength: 1, maxLength: 100 } }, + required: ['id'], + additionalProperties: false + }, + outputSchema: { + type: 'object', + properties: { id: { type: 'string' }, title: { type: 'string' } }, + required: ['id', 'title'] + }, + annotations: readOnlyAnnotations, + securitySchemes: oauthSecurity, + _meta: { securitySchemes: oauthSecurity } + }, + { + name: 'list_shows', + title: 'Sendungen auflisten', + description: 'Listet Sendungen mit Status, Termin, Dauer und Abschnittszahl.', + inputSchema: { + type: 'object', + properties: { + statuses: { + type: 'array', + items: { type: 'string', enum: SHOW_STATUSES }, + uniqueItems: true + }, + limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 20 }, + offset: { type: 'integer', minimum: 0, default: 0 } + }, + additionalProperties: false + }, + outputSchema: { + type: 'object', + properties: { + shows: { type: 'array', items: { type: 'object' } }, + pagination: paginationSchema + }, + required: ['shows', 'pagination'], + additionalProperties: false + }, + annotations: readOnlyAnnotations, + securitySchemes: oauthSecurity, + _meta: { securitySchemes: oauthSecurity } + }, + { + name: 'get_show_rundown', + title: 'Sendungsablauf lesen', + description: 'Liest Metadaten, Abschnitte, eingeplante Pieces und Reserve einer Sendung.', + inputSchema: { + type: 'object', + properties: { id: { type: 'string', minLength: 1, maxLength: 100 } }, + required: ['id'], + additionalProperties: false + }, + outputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + title: { type: 'string' }, + sections: { type: 'array', items: { type: 'object' } }, + reserve: { type: 'array', items: { type: 'object' } } + }, + required: ['id', 'title', 'sections', 'reserve'] + }, + annotations: readOnlyAnnotations, + securitySchemes: oauthSecurity, + _meta: { securitySchemes: oauthSecurity } + } +]; + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function integer( + value: unknown, + fallback: number, + minimum: number, + maximum = Number.MAX_SAFE_INTEGER +) { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || Number(value) < minimum || Number(value) > maximum) { + throw new Error('INVALID_ARGUMENTS'); + } + return Number(value); +} + +function stringArgument(value: unknown, maximum: number, required = false): string { + if (value === undefined && !required) return ''; + if (typeof value !== 'string' || (required && !value) || value.length > maximum) { + throw new Error('INVALID_ARGUMENTS'); + } + return value; +} + +function enumArray(value: unknown, allowed: readonly T[], fallback: T[]): T[] { + if (value === undefined) return fallback; + if (!Array.isArray(value) || value.some((item) => !allowed.includes(item as T))) { + throw new Error('INVALID_ARGUMENTS'); + } + return [...new Set(value as T[])]; +} + +function exactKeys(input: Record, allowed: string[]): void { + if (Object.keys(input).some((key) => !allowed.includes(key))) + throw new Error('INVALID_ARGUMENTS'); +} + +function toolResult(value: unknown) { + return { + content: [{ type: 'text', text: JSON.stringify(value) }], + structuredContent: value, + isError: false + }; +} + +function toolError(message: string) { + return { content: [{ type: 'text', text: message }], isError: true }; +} + +function projectPieceUsage( + usage: NonNullable>['sections'][number]['usages'][number] +) { + return { + id: usage.id, + originalPieceId: usage.originalPieceId, + position: usage.position, + status: usage.status, + preparationStatus: usage.preparationStatus, + preparationEffort: usage.preparationEffort, + durationSeconds: usage.durationSeconds, + title: usage.title, + outputTitle: usage.outputTitle, + intro: usage.intro, + research: usage.research, + moderation: usage.moderation, + interaction: usage.interaction, + transition: usage.transition, + presentation: usage.presentation, + ownNotes: usage.ownNotes, + sources: usage.sources.map(({ label, url, timecodeSeconds }) => ({ + label, + url, + timecodeSeconds + })), + topics: usage.topics.map(({ name }) => name) + }; +} + +function callTool(name: string, input: unknown, user: AuthUser) { + if (!isObject(input)) throw new Error('INVALID_ARGUMENTS'); + switch (name) { + case 'get_profile': { + exactKeys(input, []); + const profile = { id: user.id, name: user.displayName }; + return { + content: [{ type: 'text', text: JSON.stringify(profile) }], + structuredContent: profile, + isError: false + }; + } + case 'list_pieces': { + exactKeys(input, ['query', 'statuses', 'limit', 'offset']); + const statuses = enumArray(input.statuses, PIECE_STATUSES, [ + 'inbox', + 'preparing', + 'ready' + ]); + const query = stringArgument(input.query, 200); + const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE); + const offset = integer(input.offset, 0, 0); + const all = listPieces({ statuses, query }); + return toolResult({ + pieces: all + .slice(offset, offset + limit) + .map(({ mediaId: _mediaId, formatId: _formatId, ...piece }) => piece), + pagination: { limit, offset, total: all.length } + }); + } + case 'get_piece': { + exactKeys(input, ['id']); + const id = stringArgument(input.id, 100, true); + const piece = getPiece(id); + if (!piece) return toolError('Piece nicht gefunden.'); + const listEntry = listPieces({ statuses: [...PIECE_STATUSES] }).find( + (entry) => entry.id === id + ); + return toolResult({ + id: piece.id, + title: piece.title, + outputTitle: piece.outputTitle, + summary: piece.summary, + status: piece.status, + preparationEffort: piece.preparationEffort, + durationSeconds: piece.durationSeconds, + format: listEntry?.format ?? '', + categories: listEntry?.categories.map(({ name }) => name) ?? [], + topics: listEntry?.topics.map(({ name }) => name) ?? [], + intro: piece.intro, + research: piece.researchText, + moderation: piece.moderationNotes, + interaction: piece.interactionText, + transition: piece.transitionText, + presentation: piece.presentationText, + sources: piece.sources, + createdAtUtc: piece.createdAtUtc, + updatedAtUtc: piece.updatedAtUtc + }); + } + case 'list_shows': { + exactKeys(input, ['statuses', 'limit', 'offset']); + const statuses = enumArray(input.statuses, SHOW_STATUSES, [...SHOW_STATUSES]); + const limit = integer(input.limit, 20, 1, MAX_PAGE_SIZE); + const offset = integer(input.offset, 0, 0); + const all = listShows(statuses); + return toolResult({ + shows: all.slice(offset, offset + limit), + pagination: { limit, offset, total: all.length } + }); + } + case 'get_show_rundown': { + exactKeys(input, ['id']); + const show = getShow(stringArgument(input.id, 100, true)); + if (!show) return toolError('Sendung nicht gefunden.'); + return toolResult({ + id: show.id, + title: show.title, + startsAtUtc: show.startsAtUtc, + twitchTitle: show.twitchTitle, + announcementText: show.announcementText, + status: show.status, + totalDurationSeconds: show.totalDurationSeconds, + sections: show.sections.map((section) => ({ + id: section.id, + name: section.name, + category: section.category, + position: section.position, + explicitDurationSeconds: section.explicitDurationSeconds, + durationSeconds: section.durationSeconds, + twitchTags: section.twitchTags, + pieces: section.usages.map(projectPieceUsage) + })), + reserve: show.reserve.map(projectPieceUsage), + createdAtUtc: show.createdAtUtc, + updatedAtUtc: show.updatedAtUtc, + completedAtUtc: show.completedAtUtc + }); + } + default: + throw new Error('UNKNOWN_TOOL'); + } +} + +export function validJsonRpcRequest(value: unknown): value is JsonRpcRequest { + if (!isObject(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string') return false; + if ( + 'id' in value && + value.id !== null && + typeof value.id !== 'string' && + typeof value.id !== 'number' + ) { + return false; + } + return value.params === undefined || isObject(value.params); +} + +export function handleMcpRequest(request: JsonRpcRequest, user: AuthUser) { + const id = request.id ?? null; + try { + switch (request.method) { + case 'server/discover': + return { + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + supportedVersions: ['2026-07-28', '2025-11-25', '2025-06-18', '2025-03-26'], + capabilities: { tools: { listChanged: false } }, + _meta: { + 'io.modelcontextprotocol/serverInfo': { name: 'Sendewerk', version: '0.7.3' } + }, + instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.', + ttlMs: 3_600_000, + cacheScope: 'private' + } + }; + case 'initialize': { + const requestedVersion = request.params?.protocolVersion; + const supported = ['2025-11-25', '2025-06-18', '2025-03-26']; + const protocolVersion = + typeof requestedVersion === 'string' && supported.includes(requestedVersion) + ? requestedVersion + : supported[0]; + return { + jsonrpc: '2.0', + id, + result: { + protocolVersion, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'Sendewerk', version: '0.7.3' }, + instructions: 'Lesezugriff auf Pieces, Sendungen und redaktionelle Abläufe.' + } + }; + } + case 'ping': + return { jsonrpc: '2.0', id, result: { resultType: 'complete' } }; + case 'tools/list': + return { jsonrpc: '2.0', id, result: { resultType: 'complete', tools } }; + case 'tools/call': { + const name = request.params?.name; + if (typeof name !== 'string') throw new Error('INVALID_ARGUMENTS'); + return { + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ...callTool(name, request.params?.arguments ?? {}, user) + } + }; + } + default: + return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } }; + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : ''; + return { + jsonrpc: '2.0', + id, + error: + message === 'INVALID_ARGUMENTS' + ? { code: -32602, message: 'Invalid params' } + : message === 'UNKNOWN_TOOL' + ? { code: -32602, message: 'Unknown tool' } + : { code: -32603, message: 'Internal error' } + }; + } +} diff --git a/src/lib/server/oauth.ts b/src/lib/server/oauth.ts new file mode 100644 index 0000000..b0d21b2 --- /dev/null +++ b/src/lib/server/oauth.ts @@ -0,0 +1,425 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import type { AuthUser } from './auth.js'; +import { getAuthConfig } from './config.js'; +import { getDatabase } from './database.js'; + +export const MCP_SCOPE = 'sendewerk:read'; +const AUTHORIZATION_CODE_DURATION_MS = 5 * 60 * 1000; +const ACCESS_TOKEN_DURATION_SECONDS = 60 * 60; +const MAX_REGISTERED_CLIENTS = 1000; +const MCP_REQUEST_LIMIT_PER_HOUR = 600; + +type OAuthClientRow = { + id: string; + name: string; + redirect_uris_json: string; +}; + +type AuthorizationCodeRow = { + client_id: string; + user_id: string; + redirect_uri: string; + scope: string; + resource: string; + code_challenge: string; + expires_at_utc: string; + consumed_at_utc: string | null; +}; + +export type OAuthClient = { + id: string; + name: string; + redirectUris: string[]; +}; + +export type OAuthAuthorizationRequest = { + client: OAuthClient; + redirectUri: string; + state: string; + scope: typeof MCP_SCOPE; + resource: string; + codeChallenge: string; +}; + +export class OAuthError extends Error { + constructor( + public code: string, + message: string, + public status = 400 + ) { + super(message); + } +} + +function nowUtc(): string { + return new Date().toISOString(); +} + +function futureUtc(durationMs: number): string { + return new Date(Date.now() + durationMs).toISOString(); +} + +function tokenHash(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function randomToken(): string { + return randomBytes(32).toString('base64url'); +} + +export function oauthIssuer(): string { + return getAuthConfig().origin.origin; +} + +export function mcpResource(): string { + return new URL('/mcp', getAuthConfig().origin).toString(); +} + +export function protectedResourceMetadataUrl(): string { + return new URL('/.well-known/oauth-protected-resource/mcp', getAuthConfig().origin).toString(); +} + +function validRedirectUri(value: string): boolean { + try { + const url = new URL(value); + if (url.hash || url.username || url.password) return false; + if (url.protocol === 'https:') return true; + return ( + url.protocol === 'http:' && + (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') + ); + } catch { + return false; + } +} + +function clientFromRow(row: OAuthClientRow): OAuthClient { + return { id: row.id, name: row.name, redirectUris: JSON.parse(row.redirect_uris_json) }; +} + +export function registerOAuthClient(input: unknown): OAuthClient { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new OAuthError('invalid_client_metadata', 'Client-Metadaten fehlen.'); + } + const metadata = input as Record; + const name = typeof metadata.client_name === 'string' ? metadata.client_name.trim() : ''; + const redirectUris = Array.isArray(metadata.redirect_uris) + ? [...new Set(metadata.redirect_uris.filter((uri): uri is string => typeof uri === 'string'))] + : []; + if ( + !name || + name.length > 120 || + /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name) || + !redirectUris.length || + redirectUris.length > 10 + ) { + throw new OAuthError('invalid_client_metadata', 'Name und Redirect-URIs sind erforderlich.'); + } + if (redirectUris.some((uri) => uri.length > 1000 || !validRedirectUri(uri))) { + throw new OAuthError( + 'invalid_redirect_uri', + 'Redirect-URIs müssen HTTPS oder lokale HTTP-Adressen sein.' + ); + } + if ( + metadata.token_endpoint_auth_method !== undefined && + metadata.token_endpoint_auth_method !== 'none' + ) { + throw new OAuthError( + 'invalid_client_metadata', + 'Nur öffentliche PKCE-Clients werden unterstützt.' + ); + } + if ( + (Array.isArray(metadata.grant_types) && !metadata.grant_types.includes('authorization_code')) || + (Array.isArray(metadata.response_types) && !metadata.response_types.includes('code')) + ) { + throw new OAuthError('invalid_client_metadata', 'Nur Authorization Code wird unterstützt.'); + } + const client: OAuthClient = { id: randomToken(), name, redirectUris }; + const database = getDatabase(); + const timestamp = nowUtc(); + database + .prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?') + .run(timestamp); + database + .prepare( + 'DELETE FROM oauth_access_tokens WHERE expires_at_utc <= ? OR revoked_at_utc IS NOT NULL' + ) + .run(timestamp); + database + .prepare( + `DELETE FROM oauth_clients WHERE created_at_utc < ? + AND NOT EXISTS (SELECT 1 FROM oauth_authorization_codes c WHERE c.client_id = oauth_clients.id) + AND NOT EXISTS (SELECT 1 FROM oauth_access_tokens t WHERE t.client_id = oauth_clients.id)` + ) + .run(new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()); + const count = database.prepare('SELECT count(*) AS count FROM oauth_clients').get() as { + count: number; + }; + if (count.count >= MAX_REGISTERED_CLIENTS) { + throw new OAuthError( + 'temporarily_unavailable', + 'Derzeit sind keine weiteren Registrierungen möglich.', + 503 + ); + } + database + .prepare( + 'INSERT INTO oauth_clients (id, name, redirect_uris_json, created_at_utc) VALUES (?, ?, ?, ?)' + ) + .run(client.id, client.name, JSON.stringify(client.redirectUris), timestamp); + return client; +} + +export function reserveOAuthAttempt(kind: 'registration' | 'token', address: string): boolean { + const database = getDatabase(); + const since = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const limit = kind === 'registration' ? 20 : 120; + const addressHash = tokenHash(address || 'unknown'); + return database.transaction(() => { + database.prepare('DELETE FROM oauth_rate_limit_attempts WHERE attempted_at_utc < ?').run(since); + const row = database + .prepare( + `SELECT count(*) AS count FROM oauth_rate_limit_attempts + WHERE kind = ? AND address_hash = ? AND attempted_at_utc >= ?` + ) + .get(kind, addressHash, since) as { count: number }; + if (row.count >= limit) return false; + database + .prepare( + `INSERT INTO oauth_rate_limit_attempts (id, kind, address_hash, attempted_at_utc) + VALUES (?, ?, ?, ?)` + ) + .run(randomUUID(), kind, addressHash, nowUtc()); + return true; + })(); +} + +export function getOAuthClient(clientId: string): OAuthClient | null { + if (!clientId || clientId.length > 200) return null; + const row = getDatabase() + .prepare('SELECT id, name, redirect_uris_json FROM oauth_clients WHERE id = ?') + .get(clientId) as OAuthClientRow | undefined; + return row ? clientFromRow(row) : null; +} + +export function parseAuthorizationRequest(url: URL): OAuthAuthorizationRequest { + const client = getOAuthClient(url.searchParams.get('client_id') ?? ''); + if (!client) throw new OAuthError('invalid_client', 'Der OAuth-Client ist nicht registriert.'); + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + if (!client.redirectUris.includes(redirectUri)) { + throw new OAuthError('invalid_request', 'Die Redirect-URI ist nicht registriert.'); + } + if (url.searchParams.get('response_type') !== 'code') { + throw new OAuthError('unsupported_response_type', 'Nur response_type=code wird unterstützt.'); + } + const scope = url.searchParams.get('scope') ?? ''; + if (scope !== MCP_SCOPE) + throw new OAuthError('invalid_scope', 'Der angeforderte Scope ist ungültig.'); + const resource = url.searchParams.get('resource') ?? ''; + if (resource !== mcpResource()) { + throw new OAuthError('invalid_target', 'Die angeforderte MCP-Ressource ist ungültig.'); + } + const codeChallenge = url.searchParams.get('code_challenge') ?? ''; + if ( + url.searchParams.get('code_challenge_method') !== 'S256' || + !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge) + ) { + throw new OAuthError('invalid_request', 'PKCE mit S256 ist erforderlich.'); + } + const state = url.searchParams.get('state') ?? ''; + if (state.length > 1000) throw new OAuthError('invalid_request', 'Der OAuth-State ist zu lang.'); + return { client, redirectUri, state, scope: MCP_SCOPE, resource, codeChallenge }; +} + +export function authorizationErrorRedirect(url: URL, problem: OAuthError): string | null { + const client = getOAuthClient(url.searchParams.get('client_id') ?? ''); + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + if (!client?.redirectUris.includes(redirectUri)) return null; + const target = new URL(redirectUri); + target.searchParams.set('error', problem.code); + target.searchParams.set('error_description', problem.message); + const state = url.searchParams.get('state') ?? ''; + if (state && state.length <= 1000) target.searchParams.set('state', state); + target.searchParams.set('iss', oauthIssuer()); + return target.toString(); +} + +export function authorizationRedirect( + request: OAuthAuthorizationRequest, + values: Record +): string { + const target = new URL(request.redirectUri); + for (const [key, value] of Object.entries(values)) target.searchParams.set(key, value); + if (request.state) target.searchParams.set('state', request.state); + target.searchParams.set('iss', oauthIssuer()); + return target.toString(); +} + +export function createAuthorizationCode( + request: OAuthAuthorizationRequest, + user: AuthUser +): string { + const code = randomToken(); + const timestamp = nowUtc(); + const database = getDatabase(); + const store = database.transaction(() => { + database + .prepare('DELETE FROM oauth_authorization_codes WHERE expires_at_utc <= ?') + .run(timestamp); + database + .prepare( + `INSERT INTO oauth_authorization_codes ( + code_hash, client_id, user_id, redirect_uri, scope, resource, code_challenge, + created_at_utc, expires_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + tokenHash(code), + request.client.id, + user.id, + request.redirectUri, + request.scope, + request.resource, + request.codeChallenge, + timestamp, + futureUtc(AUTHORIZATION_CODE_DURATION_MS) + ); + }); + store(); + return code; +} + +function pkceChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url'); +} + +export function exchangeAuthorizationCode(input: { + code: string; + clientId: string; + redirectUri: string; + codeVerifier: string; + resource: string; +}) { + if (!/^[A-Za-z0-9._~-]{43,128}$/.test(input.codeVerifier)) { + throw new OAuthError('invalid_grant', 'Der PKCE-Verifier ist ungültig.'); + } + const database = getDatabase(); + const exchange = database.transaction(() => { + const timestamp = nowUtc(); + const row = database + .prepare( + `SELECT client_id, user_id, redirect_uri, scope, resource, code_challenge, + expires_at_utc, consumed_at_utc + FROM oauth_authorization_codes WHERE code_hash = ?` + ) + .get(tokenHash(input.code)) as AuthorizationCodeRow | undefined; + if ( + !row || + row.consumed_at_utc || + row.expires_at_utc <= timestamp || + row.client_id !== input.clientId || + row.redirect_uri !== input.redirectUri || + row.resource !== input.resource || + row.code_challenge !== pkceChallenge(input.codeVerifier) + ) { + throw new OAuthError('invalid_grant', 'Der Autorisierungscode ist ungültig oder abgelaufen.'); + } + const user = database + .prepare('SELECT 1 FROM users WHERE id = ? AND locked_at_utc IS NULL') + .get(row.user_id); + if (!user) throw new OAuthError('invalid_grant', 'Das zugehörige Konto ist nicht aktiv.'); + const token = randomToken(); + database + .prepare('UPDATE oauth_authorization_codes SET consumed_at_utc = ? WHERE code_hash = ?') + .run(timestamp, tokenHash(input.code)); + database + .prepare( + `INSERT INTO oauth_access_tokens ( + token_hash, client_id, user_id, scope, resource, created_at_utc, expires_at_utc, + request_window_started_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + tokenHash(token), + row.client_id, + row.user_id, + row.scope, + row.resource, + timestamp, + futureUtc(ACCESS_TOKEN_DURATION_SECONDS * 1000), + timestamp + ); + return { accessToken: token, scope: row.scope }; + }); + return exchange(); +} + +export function authenticateOAuth(request: Request): AuthUser { + const authorization = request.headers.get('authorization') ?? ''; + const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : ''; + if (!token || token.length > 100) + throw new OAuthError('invalid_token', 'OAuth-Token fehlt.', 401); + const timestamp = nowUtc(); + const database = getDatabase(); + const row = database.transaction(() => { + const current = database + .prepare( + `SELECT u.id, u.email, u.display_name AS displayName, u.role, + u.locked_at_utc AS lockedAtUtc, t.token_hash, + t.request_window_started_at_utc, t.request_count + FROM oauth_access_tokens t JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ? AND t.revoked_at_utc IS NULL AND t.expires_at_utc > ? + AND t.scope = ? AND t.resource = ? AND u.locked_at_utc IS NULL` + ) + .get(tokenHash(token), timestamp, MCP_SCOPE, mcpResource()) as + | (AuthUser & { + token_hash: string; + request_window_started_at_utc: string; + request_count: number; + }) + | undefined; + if (!current) { + throw new OAuthError('invalid_token', 'OAuth-Token ist ungültig oder abgelaufen.', 401); + } + const windowExpired = + current.request_window_started_at_utc <= new Date(Date.now() - 60 * 60 * 1000).toISOString(); + if (!windowExpired && current.request_count >= MCP_REQUEST_LIMIT_PER_HOUR) { + throw new OAuthError('temporarily_unavailable', 'Zu viele MCP-Anfragen.', 429); + } + database + .prepare( + `UPDATE oauth_access_tokens SET last_used_at_utc = ?, + request_window_started_at_utc = ?, request_count = ? WHERE token_hash = ?` + ) + .run( + timestamp, + windowExpired ? timestamp : current.request_window_started_at_utc, + windowExpired ? 1 : current.request_count + 1, + current.token_hash + ); + return current; + })(); + return { + id: row.id, + email: row.email, + displayName: row.displayName, + role: row.role, + lockedAtUtc: row.lockedAtUtc + }; +} + +export function revokeOAuthToken(token: string, clientId: string): void { + if (!token || token.length > 200 || !clientId || clientId.length > 200) return; + getDatabase() + .prepare( + `UPDATE oauth_access_tokens SET revoked_at_utc = ? + WHERE token_hash = ? AND client_id = ? AND revoked_at_utc IS NULL` + ) + .run(nowUtc(), tokenHash(token), clientId); +} + +export function oauthTokenDurationSeconds(): number { + return ACCESS_TOKEN_DURATION_SECONDS; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 2f5d4f2..b06b9ae 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -7,5 +7,6 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({ : locals.user, presentationMode: url.pathname.startsWith('/heinzlive/'), guestMode: url.pathname.startsWith('/freigabe/'), + oauthMode: url.pathname === '/oauth/authorize', liveControllerMode: url.pathname === '/live' }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index def5ada..05d58e4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -34,7 +34,7 @@ {#if data.presentationMode} {@render children()} -{:else if data.guestMode} +{:else if data.guestMode || data.oauthMode}
{ + const issuer = oauthIssuer(); + return json( + { + issuer, + authorization_endpoint: `${issuer}/oauth/authorize`, + token_endpoint: `${issuer}/oauth/token`, + registration_endpoint: `${issuer}/oauth/register`, + revocation_endpoint: `${issuer}/oauth/revoke`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + token_endpoint_auth_methods_supported: ['none'], + revocation_endpoint_auth_methods_supported: ['none'], + code_challenge_methods_supported: ['S256'], + scopes_supported: [MCP_SCOPE], + authorization_response_iss_parameter_supported: true + }, + { headers: { 'Cache-Control': 'public, max-age=3600' } } + ); +}; diff --git a/src/routes/.well-known/oauth-protected-resource/+server.ts b/src/routes/.well-known/oauth-protected-resource/+server.ts new file mode 100644 index 0000000..0012836 --- /dev/null +++ b/src/routes/.well-known/oauth-protected-resource/+server.ts @@ -0,0 +1 @@ +export { GET } from './mcp/+server.js'; diff --git a/src/routes/.well-known/oauth-protected-resource/mcp/+server.ts b/src/routes/.well-known/oauth-protected-resource/mcp/+server.ts new file mode 100644 index 0000000..30ed5ad --- /dev/null +++ b/src/routes/.well-known/oauth-protected-resource/mcp/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { MCP_SCOPE, mcpResource, oauthIssuer } from '#lib/server/oauth.js'; + +export const GET: RequestHandler = () => + json( + { + resource: mcpResource(), + authorization_servers: [oauthIssuer()], + scopes_supported: [MCP_SCOPE], + bearer_methods_supported: ['header'] + }, + { headers: { 'Cache-Control': 'public, max-age=3600' } } + ); diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 23c3751..08467a1 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -8,13 +8,26 @@ import { validEmail } from '#lib/server/auth.js'; -export const load: PageServerLoad = ({ locals }) => { +function safeReturnTo(value: string | null): string { + if (!value || value.length > 4000) return '/fundus'; + try { + const target = new URL(value, 'http://sendewerk.local'); + if (target.origin === 'http://sendewerk.local' && target.pathname === '/oauth/authorize') { + return `${target.pathname}${target.search}`; + } + } catch { + // Fall back to the normal authenticated landing page. + } + return '/fundus'; +} + +export const load: PageServerLoad = ({ locals, url }) => { if (!hasUsers()) redirect(303, '/setup'); - if (locals.user) redirect(303, '/fundus'); + if (locals.user) redirect(303, safeReturnTo(url.searchParams.get('returnTo'))); }; export const actions = { - default: async ({ request, cookies, getClientAddress }) => { + default: async ({ request, cookies, getClientAddress, url }) => { const data = await request.formData(); const email = String(data.get('email') || '').trim(); const password = String(data.get('password') || ''); @@ -31,6 +44,6 @@ export const actions = { const user = await authenticate(email, password); if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' }); createSession(user.id, cookies); - redirect(303, '/fundus'); + redirect(303, safeReturnTo(url.searchParams.get('returnTo'))); } } satisfies Actions; diff --git a/src/routes/mcp/+server.ts b/src/routes/mcp/+server.ts new file mode 100644 index 0000000..34e5192 --- /dev/null +++ b/src/routes/mcp/+server.ts @@ -0,0 +1,154 @@ +import { json, text } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { handleMcpRequest, validJsonRpcRequest } from '#lib/server/mcp.js'; +import { getAuthConfig } from '#lib/server/config.js'; +import { + authenticateOAuth, + MCP_SCOPE, + OAuthError, + protectedResourceMetadataUrl +} from '#lib/server/oauth.js'; + +const MODERN_VERSION = '2026-07-28'; +const supportedProtocolVersions = [MODERN_VERSION, '2025-11-25', '2025-06-18', '2025-03-26']; + +function challenge(errorCode?: string) { + const fields = [`resource_metadata="${protectedResourceMetadataUrl()}"`, `scope="${MCP_SCOPE}"`]; + if (errorCode) fields.push(`error="${errorCode}"`); + return `Bearer ${fields.join(', ')}`; +} + +function authenticate(request: Request) { + try { + return authenticateOAuth(request); + } catch (cause) { + if (cause instanceof OAuthError) { + const headers: Record = { 'Cache-Control': 'no-store' }; + if (cause.status === 401) headers['WWW-Authenticate'] = challenge(cause.code); + return json( + { error: cause.code, error_description: cause.message }, + { status: cause.status, headers } + ); + } + throw cause; + } +} + +function originAllowed(request: Request): boolean { + const origin = request.headers.get('origin'); + return !origin || origin === getAuthConfig().origin.origin; +} + +function protocolError(id: string | number | null, code: number, message: string, data?: unknown) { + return { + jsonrpc: '2.0', + id, + error: { code, message, ...(data === undefined ? {} : { data }) } + }; +} + +function modernValidation( + request: Request, + body: { + id?: string | number | null; + method: string; + params?: Record; + } +): Response | null { + const id = typeof body.id === 'string' || typeof body.id === 'number' ? body.id : null; + const metadata = body.params?._meta; + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return json(protocolError(id, -32602, 'Required MCP request metadata is missing.'), { + status: 400 + }); + } + const meta = metadata as Record; + const bodyVersion = meta['io.modelcontextprotocol/protocolVersion']; + if (typeof bodyVersion !== 'string' || !supportedProtocolVersions.includes(bodyVersion)) { + return json( + protocolError(id, -32022, 'Unsupported protocol version', { + supported: supportedProtocolVersions, + requested: bodyVersion ?? null + }), + { status: 400 } + ); + } + if ( + request.headers.get('mcp-protocol-version') !== bodyVersion || + request.headers.get('mcp-method') !== body.method || + (body.method === 'tools/call' && request.headers.get('mcp-name') !== body.params?.name) + ) { + return json(protocolError(id, -32020, 'MCP request headers do not match the body.'), { + status: 400 + }); + } + const capabilities = meta['io.modelcontextprotocol/clientCapabilities']; + if ( + body.id === null || + !capabilities || + typeof capabilities !== 'object' || + Array.isArray(capabilities) + ) { + return json(protocolError(id, -32602, 'Required MCP request metadata is invalid.'), { + status: 400 + }); + } + return null; +} + +export const POST: RequestHandler = async ({ request }) => { + if (!originAllowed(request)) return text('Forbidden', { status: 403 }); + const authenticated = authenticate(request); + if (authenticated instanceof Response) return authenticated; + if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { + return text('JSON wird erwartet.', { status: 415 }); + } + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); + } + if (!validJsonRpcRequest(body)) { + return json({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } }); + } + const metadata = body.params?._meta; + const bodyVersion = + metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record)['io.modelcontextprotocol/protocolVersion'] + : undefined; + const headerVersion = request.headers.get('mcp-protocol-version'); + const modern = headerVersion === MODERN_VERSION || bodyVersion === MODERN_VERSION; + if (modern) { + const validationFailure = modernValidation(request, body); + if (validationFailure) return validationFailure; + } else if (headerVersion && !supportedProtocolVersions.includes(headerVersion)) { + return json( + protocolError(body.id ?? null, -32022, 'Unsupported protocol version', { + supported: supportedProtocolVersions, + requested: headerVersion + }), + { status: 400 } + ); + } + if (body.id === undefined) return new Response(null, { status: 202 }); + const response = handleMcpRequest(body, authenticated); + const status = modern && 'error' in response && response.error?.code === -32601 ? 404 : 200; + return json(response, { + status, + headers: { 'Cache-Control': 'no-store' } + }); +}; + +export const GET: RequestHandler = ({ request }) => { + if (!originAllowed(request)) return text('Forbidden', { status: 403 }); + const authenticated = authenticate(request); + if (authenticated instanceof Response) return authenticated; + return text('Dieser MCP-Endpunkt bietet keinen serverseitigen Ereignisstrom.', { + status: 405, + headers: { Allow: 'POST' } + }); +}; + +export const DELETE: RequestHandler = () => + text('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } }); diff --git a/src/routes/oauth/authorize/+page.server.ts b/src/routes/oauth/authorize/+page.server.ts new file mode 100644 index 0000000..e3baa01 --- /dev/null +++ b/src/routes/oauth/authorize/+page.server.ts @@ -0,0 +1,53 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { + authorizationErrorRedirect, + authorizationRedirect, + createAuthorizationCode, + OAuthError, + parseAuthorizationRequest +} from '#lib/server/oauth.js'; + +function authorization(url: URL) { + try { + return parseAuthorizationRequest(url); + } catch (cause) { + if (cause instanceof OAuthError) { + const target = authorizationErrorRedirect(url, cause); + if (target) redirect(303, target, { external: true }); + error(cause.status, cause.message); + } + throw cause; + } +} + +export const load: PageServerLoad = ({ locals, url }) => { + const request = authorization(url); + if (!locals.user) { + const returnTo = `${url.pathname}${url.search}`; + redirect(303, `/login?returnTo=${encodeURIComponent(returnTo)}`); + } + return { + clientName: request.client.name, + redirectHost: new URL(request.redirectUri).host, + scope: request.scope, + user: locals.user + }; +}; + +export const actions = { + default: ({ request: browserRequest, locals, url }) => { + if (!locals.user) + redirect(303, `/login?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`); + const oauthRequest = authorization(url); + return browserRequest.formData().then((data) => { + if (data.get('decision') !== 'approve') { + redirect(303, authorizationRedirect(oauthRequest, { error: 'access_denied' }), { + external: true + }); + } + const code = createAuthorizationCode(oauthRequest, locals.user!); + redirect(303, authorizationRedirect(oauthRequest, { code }), { external: true }); + }); + } +} satisfies Actions; diff --git a/src/routes/oauth/authorize/+page.svelte b/src/routes/oauth/authorize/+page.svelte new file mode 100644 index 0000000..a28b1b5 --- /dev/null +++ b/src/routes/oauth/authorize/+page.svelte @@ -0,0 +1,30 @@ + + +Zugriff erlauben | Sendewerk + + diff --git a/src/routes/oauth/register/+server.ts b/src/routes/oauth/register/+server.ts new file mode 100644 index 0000000..4242dbb --- /dev/null +++ b/src/routes/oauth/register/+server.ts @@ -0,0 +1,36 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { OAuthError, registerOAuthClient, reserveOAuthAttempt } from '#lib/server/oauth.js'; + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + try { + if (!reserveOAuthAttempt('registration', getClientAddress())) { + throw new OAuthError('temporarily_unavailable', 'Zu viele Registrierungsversuche.', 429); + } + if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { + throw new OAuthError('invalid_client_metadata', 'JSON wird erwartet.'); + } + const client = registerOAuthClient(await request.json()); + return json( + { + client_id: client.id, + client_id_issued_at: Math.floor(Date.now() / 1000), + client_name: client.name, + redirect_uris: client.redirectUris, + grant_types: ['authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + }, + { status: 201, headers: { 'Cache-Control': 'no-store' } } + ); + } catch (cause) { + const problem = + cause instanceof OAuthError + ? cause + : new OAuthError('invalid_client_metadata', 'Ungültige Client-Metadaten.'); + return json( + { error: problem.code, error_description: problem.message }, + { status: problem.status, headers: { 'Cache-Control': 'no-store' } } + ); + } +}; diff --git a/src/routes/oauth/revoke/+server.ts b/src/routes/oauth/revoke/+server.ts new file mode 100644 index 0000000..089c79d --- /dev/null +++ b/src/routes/oauth/revoke/+server.ts @@ -0,0 +1,16 @@ +import { text } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { revokeOAuthToken } from '#lib/server/oauth.js'; + +export const POST: RequestHandler = async ({ request }) => { + if ( + request.headers + .get('content-type') + ?.toLowerCase() + .startsWith('application/x-www-form-urlencoded') + ) { + const data = await request.formData(); + revokeOAuthToken(String(data.get('token') ?? ''), String(data.get('client_id') ?? '')); + } + return text('', { headers: { 'Cache-Control': 'no-store' } }); +}; diff --git a/src/routes/oauth/token/+server.ts b/src/routes/oauth/token/+server.ts new file mode 100644 index 0000000..5f2419d --- /dev/null +++ b/src/routes/oauth/token/+server.ts @@ -0,0 +1,57 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + exchangeAuthorizationCode, + oauthTokenDurationSeconds, + OAuthError, + reserveOAuthAttempt +} from '#lib/server/oauth.js'; + +function failure(cause: unknown) { + const problem = + cause instanceof OAuthError + ? cause + : new OAuthError('invalid_request', 'Ungültige Token-Anfrage.'); + return json( + { error: problem.code, error_description: problem.message }, + { status: problem.status, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ); +} + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + try { + if (!reserveOAuthAttempt('token', getClientAddress())) { + throw new OAuthError('temporarily_unavailable', 'Zu viele Token-Anfragen.', 429); + } + if ( + !request.headers + .get('content-type') + ?.toLowerCase() + .startsWith('application/x-www-form-urlencoded') + ) { + throw new OAuthError('invalid_request', 'Formulardaten werden erwartet.'); + } + const data = await request.formData(); + if (data.get('grant_type') !== 'authorization_code') { + throw new OAuthError('unsupported_grant_type', 'Nur Authorization Code wird unterstützt.'); + } + const result = exchangeAuthorizationCode({ + code: String(data.get('code') ?? ''), + clientId: String(data.get('client_id') ?? ''), + redirectUri: String(data.get('redirect_uri') ?? ''), + codeVerifier: String(data.get('code_verifier') ?? ''), + resource: String(data.get('resource') ?? '') + }); + return json( + { + access_token: result.accessToken, + token_type: 'Bearer', + expires_in: oauthTokenDurationSeconds(), + scope: result.scope + }, + { headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ); + } catch (cause) { + return failure(cause); + } +};