From 7368871efd0f8a3581fe300f04f480453ffac547 Mon Sep 17 00:00:00 2001 From: jp170na Date: Mon, 2 Nov 2026 03:12:10 +0100 Subject: [PATCH] =?UTF-8?q?OAuth=20prihl=C3=A1senie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 63 +++++- deployment/claude.Dockerfile | 4 +- deployment/docker-compose.yml | 5 + packages/claude/mcp_server.py | 47 +++- packages/claude/oauth_provider.py | 349 ++++++++++++++++++++++++++++++ scripts/check_claude_oauth.py | 141 ++++++++++++ test/test_claude_packages.py | 236 ++++++++++++++++++++ 7 files changed, 830 insertions(+), 15 deletions(-) create mode 100644 packages/claude/oauth_provider.py create mode 100644 scripts/check_claude_oauth.py diff --git a/README.md b/README.md index f5b43c6..f454597 100644 --- a/README.md +++ b/README.md @@ -654,6 +654,8 @@ ANSWER_MAX_TOKENS=4096 # Webový MCP konektor (samostatný kľúč, nikdy SEARCH_API_KEY): ZP_MCP_ACCESS_TOKEN=<ďalšia náhodná hodnota s minimálne 32 znakmi> ZP_MCP_PUBLIC_URL=https://mcp.example.com/mcp +ZP_MCP_AUTH_MODE=oauth +ZP_MCP_LOGIN_PASSWORD= # GraphRAG / Neo4j NEO4J_URI=bolt://localhost:7687 @@ -1007,7 +1009,7 @@ otázkach sa označuje ako regresia, nie ako nový nezávislý TEST. ## Claude Skills a webový MCP konektor -Táto vetva `feature/claude-skills` zachováva spoločný RAG/GraphRAG backend, +Táto vetva `claude-tools` zachováva spoločný RAG/GraphRAG backend, Docker, synchronizáciu, kontroly aj historické experimenty. Zdrojové Tools, Pipe a builder určené výhradne pre OpenWebUI sú odstránené. Zmienky o školskom OpenWebUI v starších výsledkoch a evaluačných klientoch sú historická metodika, @@ -1017,7 +1019,7 @@ Tok pre webový Claude: ```text Claude + dva Skills - -> HTTPS /mcp + samostatný konektorový token + -> HTTPS /mcp + OAuth s PKCE (lokálna diagnostika môže používať token) -> zpwiki_rag / zpwiki_graphrag / zpwiki_health -> súkromný backend /answer alebo /health -> SQLite / Neo4j -> zvolený generujúci model -> kontroly -> overený text @@ -1055,9 +1057,11 @@ zopakuj builder a commitni aktualizované ZIPy aj manifest. `openai_compatible` používa `ANSWER_API_KEY`, `ANSWER_LLM_URL`, `ANSWER_MODEL`. Historické `OPENWEBUI_API_KEY` zostáva podporované pre reprodukciu starých experimentov. Použitie Claude ako rozhrania samo osebe nemení poskytovateľa. -3. Vygeneruj oddelený `ZP_MCP_ACCESS_TOKEN` cez `openssl rand -hex 32` a nastav - `ZP_MCP_PUBLIC_URL=https:///mcp`. Kľúč zostáva v ignorovanom - `.env`; MCP sa s chýbajúcim, krátkym alebo rovnakým backendovým kľúčom nespustí. +3. Vygeneruj oddelený `ZP_MCP_ACCESS_TOKEN` aj `ZP_MCP_LOGIN_PASSWORD` cez + dva príkazy `openssl rand -hex 32`. Nastav `ZP_MCP_AUTH_MODE=oauth` a + `ZP_MCP_PUBLIC_URL=https:///mcp`. Prvý token slúži lokálnej + diagnostike; druhý kód zadáva vlastník projektu na prihlasovacej stránke. + Kľúče zostávajú v ignorovanom `.env`. Nesmú byť rovnaké ani zhodné s backendovým kľúčom. 4. Spusti MCP profil. Backend a Neo4j zostávajú rovnaké: ```bash @@ -1070,12 +1074,15 @@ zopakuj builder a commitni aktualizované ZIPy aj manifest. Proxy musí zachovať cestu `/mcp`, Host a Authorization a dovoliť dlhšie požiadavky. Doména musí byť dostupná z infraštruktúry Anthropic, nie iba z VPN. 6. V Claude otvor **Customize > Connectors > Add custom connector**, zadaj - meno `ZP Wiki` a HTTPS adresu `/mcp`. Pri autentizácii vyber **No sign in** - (nevykonávame OAuth) a v **Request headers** nastav **Authorization** na - **Bearer **. Tento tajný údaj vlož iba do nastavení - konektora, nikdy do chatu alebo skillu. Header stále zabezpečuje autentizáciu - a server bez neho odpovie 401. Ak účet nemá možnosť vlastných hlavičiek, - nepoužívaj verejný nechránený server: treba iný podporovaný spôsob autentizácie. + meno `ZP Agent` a HTTPS adresu `/mcp`. Vyber **Sign in now** a + **Streamable HTTP**. Ak sa zobrazuje výber OAuth klienta, použi + **Register automatically**. OAuth client ID a client secret nechaj prázdne: + server podporuje Dynamic Client Registration (DCR), nepotrebuje vlastné + hlavičky ani administrátorskú OAuth aplikáciu. Pri pripojení sa otvorí stránka + ZP Agenta; vlož hodnotu `ZP_MCP_LOGIN_PASSWORD` zo svojho lokálneho `.env` + do poľa **Prístupový kód ZP Agenta** a povoľ prístup. Nie je to heslo účtu Claude. + Kód nepatrí do chatu ani do polí OAuth client secret. Po potvrdení sa prehliadač + vráti na `https://claude.ai/api/mcp/auth_callback` a Claude dostane obmedzené tokeny. 7. V **Customize > Skills** nahraj oba ZIPy a zapni ich. Podľa účtu musí byť zapnuté aj Code execution and file creation. V organizácii môžu konektory vyžadovať zásah vlastníka; nejde o oprávnenia OpenWebUI. @@ -1092,6 +1099,31 @@ pôvodných modelov nie sú odhadom presnosti novej konfigurácie. Oficiálne návody: [Skills](https://support.claude.com/en/articles/12512180-use-skills-in-claude), [vzdialené konektory a Request headers](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). +### OAuth a uchovávanie prihlásení + +Používa sa OAuth Authorization Code so S256 PKCE, presným Claude callbackom, +viazaním tokenu na konkrétnu adresu `/mcp`, rozsahom `zpwiki:read` a voliteľným +`offline_access`. Discovery a registrácia sú verejné; samotné MCP a `/health` +sú chránené. Prístupový token platí najviac hodinu, refresh grant 30 dní. +Obnova rotuje tokeny; opätovné použitie starého refresh tokenu odvolá celý grant. +Kódy sú jednorazové, platné dve minúty. Prihlásenie vyžaduje súhlas a chráni +ho Secure/HttpOnly/SameSite cookie, CSRF, obmedzenie pokusov a veľkosti požiadaviek. + +Toto je prihlásenie jedným projektovým kódom pre vlastníka a poverených ľudí, +nie univerzitné SSO ani správa samostatných používateľských účtov. Vlastník môže +kód odovzdať profesorovi mimo chatu. Zmena prihlasovacieho kódu, statického tokenu +alebo verejnej adresy pri obnove kontajnera zneplatní predchádzajúce OAuth prihlásenia. + +Klienti a granty prežijú reštart kontajnera v pomenovanom Docker volume +`claude-oauth`, v súbore `/var/lib/zp-oauth/oauth.sqlite` s oprávneniami 0600. +Prístupové a refresh tokeny aj autorizačné kódy sa ukladajú pod SHA-256 odtlačkami. +Databáza ani prihlasovacie kódy nepatria do ZIPov alebo na Git. Voliteľný režim +`ZP_MCP_AUTH_MODE=bearer` zachováva predchádzajúce pripájanie pomocou +`Authorization: Bearer `, ak účet Claude sprístupňuje beta +funkciu Request headers. Predvolený Compose profil používa OAuth. + +Oficiálne požiadavky: [autentizácia konektorov Claude](https://claude.com/docs/connectors/building/authentication). + ### Lokálny klient a voliteľný Desktop / Claude Code Pre skripty nastav `ZP_AGENT_URL` a `ZP_AGENT_API_KEY`, alebo si skopíruj @@ -1129,12 +1161,19 @@ vyššie opísanú HTTPS službu. ```bash PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -q -p no:cacheprovider test/test_claude_packages.py +PYTHONDONTWRITEBYTECODE=1 .venv/bin/python scripts/check_claude_oauth.py PYTHONDONTWRITEBYTECODE=1 .venv/bin/python scripts/verify_project.py --no-save --git-network ``` Testy pokrývajú samostatné rozbalenie ZIPov, deterministické SHA-256, HTTP komunikáciu a autorizáciu backendu, odmietnutý výstup, metadáta úplnosti, -MCP stdio aj autentizovaný HTTP protokol, zlý token a kontrolu hostiteľa. +MCP stdio aj autentizovaný HTTP protokol, zlý token a kontrolu hostiteľa, +OAuth discovery, prihlásenie, PKCE, CSRF, presmerovania, scope a resource, +jednorazové kódy, expiráciu, rotáciu, opakovanie a odvolanie tokenov. +`check_claude_oauth.py` overí skutočnú verejnú adresu z `.env`: discovery, DCR, +prihlásenie, PKCE, odmietnutie opakovania kódu, MCP, spoločnú verziu indexov, +obnovu aj odvolanie tokenov. Kódy neukladá do reportu ani nevypisuje; testovací +grant po skončení odvolá. Finálny redirect na účet Claude nenasleduje a nevolá LLM. Adaptér Anthropic je testovaný s lokálne nasimulovanými odpoveďami; bez API kľúča sa nevykonáva platené volanie Anthropic. Verejné HTTPS a samotný účet Claude sa overia až pri skutočnom pripojení. diff --git a/deployment/claude.Dockerfile b/deployment/claude.Dockerfile index 59c435b..52ec6f7 100644 --- a/deployment/claude.Dockerfile +++ b/deployment/claude.Dockerfile @@ -3,7 +3,9 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 COPY deployment/requirements-claude.txt /app/requirements.txt RUN python -m pip install --no-cache-dir -r /app/requirements.txt -COPY packages/claude/client.py packages/claude/mcp_server.py /app/ +COPY packages/claude/client.py packages/claude/mcp_server.py packages/claude/oauth_provider.py /app/ +RUN mkdir -p /var/lib/zp-oauth && chown 65534:65534 /var/lib/zp-oauth && chmod 700 /var/lib/zp-oauth +ENV ZP_MCP_OAUTH_DB=/var/lib/zp-oauth/oauth.sqlite USER 65534:65534 EXPOSE 8001 CMD ["python", "/app/mcp_server.py", "--transport", "http"] diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index c05a5bc..709d44a 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -82,6 +82,10 @@ services: ZP_MCP_ACCESS_TOKEN: ${ZP_MCP_ACCESS_TOKEN:-} ZP_MCP_PUBLIC_URL: ${ZP_MCP_PUBLIC_URL:-} ZP_MCP_BIND: "0.0.0.0" + ZP_MCP_AUTH_MODE: ${ZP_MCP_AUTH_MODE:-oauth} + ZP_MCP_LOGIN_PASSWORD: ${ZP_MCP_LOGIN_PASSWORD:-} + volumes: + - claude-oauth:/var/lib/zp-oauth depends_on: zp-agent-api: condition: service_healthy @@ -131,6 +135,7 @@ services: restart: unless-stopped volumes: + claude-oauth: hf-cache: neo4j-data: neo4j-logs: diff --git a/packages/claude/mcp_server.py b/packages/claude/mcp_server.py index 83ec4a0..e558681 100644 --- a/packages/claude/mcp_server.py +++ b/packages/claude/mcp_server.py @@ -81,13 +81,56 @@ def create_http_app(): security = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=[url.netloc, '127.0.0.1:*', 'localhost:*'], allowed_origins=['https://' + url.netloc]) - server = create_server(stateless_http=True, json_response=True, transport_security=security) + mode = os.getenv('ZP_MCP_AUTH_MODE', 'bearer') + if mode not in {'bearer', 'oauth'}: + raise ValueError('ZP_MCP_AUTH_MODE must be bearer or oauth') + provider = None + options = {} + if mode == 'oauth': + from oauth_provider import OAuthProvider, SCOPES, SCOPE + from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions + password = os.getenv('ZP_MCP_LOGIN_PASSWORD', '') + if hmac.compare_digest(password.encode(), config.key.encode()): + raise ValueError('The OAuth login code must differ from the backend key') + provider = OAuthProvider(public_url, token, password, + Path(os.getenv('ZP_MCP_OAUTH_DB', '/var/lib/zp-oauth/oauth.sqlite'))) + options = {'auth_server_provider': provider, 'auth': AuthSettings( + issuer_url=provider.origin, resource_server_url=public_url, validate_token_resource=True, + required_scopes=[SCOPE], + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=SCOPES, default_scopes=SCOPES), + revocation_options=RevocationOptions(enabled=True))} + server = create_server(stateless_http=True, json_response=True, transport_security=security, **options) @server.custom_route('/health', methods=['GET']) async def bridge_health(request): + if provider: + authorization = request.headers.get('authorization', '') + scheme, _, credential = authorization.partition(' ') + if scheme.lower() != 'bearer' or not await provider.load_access_token(credential): + return JSONResponse({'error':'unauthorized'}, status_code=401) return JSONResponse({'status': 'ok', 'service': 'zpwiki-mcp'}) - return BearerGate(server.streamable_http_app(), token) + if not provider: + return BearerGate(server.streamable_http_app(), token) + server.custom_route('/oauth/login', methods=['GET','POST'])(provider.login) + app = server.streamable_http_app() + # SDK 1.x supports public clients but omits "none" from generated metadata. + # Advertise the actually supported methods; leaving CIMD unadvertised selects DCR. + from mcp.server.auth.routes import build_metadata + metadata = build_metadata(server.settings.auth.issuer_url, None, + server.settings.auth.client_registration_options, server.settings.auth.revocation_options) + metadata.token_endpoint_auth_methods_supported = ['none','client_secret_post','client_secret_basic'] + + async def discovery(request): + return JSONResponse(metadata.model_dump(mode='json', exclude_none=True), + headers={'Access-Control-Allow-Origin':'*'}) + for route in app.routes: + if getattr(route, 'path', None) == '/.well-known/oauth-authorization-server': + route.endpoint = discovery + from starlette.routing import request_response + route.app = request_response(discovery) + from oauth_provider import OAuthBoundary + return OAuthBoundary(app, provider) def main(): diff --git a/packages/claude/oauth_provider.py b/packages/claude/oauth_provider.py new file mode 100644 index 0000000..7b44413 --- /dev/null +++ b/packages/claude/oauth_provider.py @@ -0,0 +1,349 @@ +"""Project-local OAuth provider for the MCP SDK: consent, PKCE grants and rotating tokens. + +The SDK validates protocol requests, redirects, client credentials and S256 PKCE. +This module stores grants and authenticates the project owner on a separate consent page. +It implements a shared project access code, not a multi-user identity service. +""" +from __future__ import annotations + +from contextlib import contextmanager +import hashlib +import hmac +from html import escape +import json +import os +from pathlib import Path +import re +import secrets +import sqlite3 +import time +from urllib.parse import parse_qs, urlsplit + +from mcp.server.auth.provider import ( + AccessToken, AuthorizationCode, AuthorizationParams, AuthorizeError, + RefreshToken, RegistrationError, TokenError, construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse + +SCOPE = 'zpwiki:read' +SCOPES = [SCOPE, 'offline_access'] +CALLBACKS = {'https://claude.ai/api/mcp/auth_callback'} +COOKIE = '__Host-zp-oauth' +PENDING_TTL, CODE_TTL, ACCESS_TTL, REFRESH_TTL = 600, 120, 3600, 30 * 86400 + + +def digest(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +class Store: + def __init__(self, path: Path, namespace: str): + self.path, self.namespace = path, namespace + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if not path.exists(): + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(fd) + path.chmod(0o600) + with self.transaction() as conn: + conn.execute('''CREATE TABLE IF NOT EXISTS records ( + namespace TEXT NOT NULL, kind TEXT NOT NULL, key TEXT NOT NULL, + body TEXT NOT NULL, expires REAL NOT NULL, family TEXT NOT NULL DEFAULT '', + PRIMARY KEY(namespace,kind,key))''') + conn.execute('CREATE INDEX IF NOT EXISTS oauth_family ON records(namespace,family)') + conn.execute('CREATE INDEX IF NOT EXISTS oauth_expiry ON records(expires)') + + @contextmanager + def transaction(self): + conn = sqlite3.connect(self.path, timeout=5) + try: + conn.execute('BEGIN IMMEDIATE') + yield conn + conn.commit() + except BaseException: + conn.rollback() + raise + finally: + conn.close() + + def get(self, conn, kind, key): + row = conn.execute('SELECT body FROM records WHERE namespace=? AND kind=? AND key=? AND expires>?', + (self.namespace, kind, key, time.time())).fetchone() + return json.loads(row[0]) if row else None + + def put(self, conn, kind, key, body, expires, family=''): + conn.execute('INSERT OR REPLACE INTO records VALUES (?,?,?,?,?,?)', + (self.namespace, kind, key, json.dumps(body), expires, family)) + + def delete(self, conn, kind, key): + conn.execute('DELETE FROM records WHERE namespace=? AND kind=? AND key=?', + (self.namespace, kind, key)) + + def capacity(self, conn, kind, maximum): + conn.execute('DELETE FROM records WHERE expires<=?', (time.time(),)) + return conn.execute('SELECT count(*) FROM records WHERE namespace=? AND kind=?', + (self.namespace, kind)).fetchone()[0] < maximum + + def revoke_family(self, conn, family): + conn.execute('DELETE FROM records WHERE namespace=? AND family=?', (self.namespace, family)) + + +class OAuthProvider: + def __init__(self, resource: str, static_token: str, password: str, database: Path): + if (len(password) < 32 or not password.isascii() or any(c.isspace() for c in password) + or hmac.compare_digest(password, static_token)): + raise ValueError('Configure a separate ZP_MCP_LOGIN_PASSWORD with at least 32 characters') + self.resource = resource + url = urlsplit(resource) + self.origin = url.scheme + '://' + url.netloc + self.static_token, self.password_hash = static_token, digest(password) + # A changed public URL or rotated owner credential invalidates previous sessions. + self.store = Store(database, digest(resource + '\0' + static_token + '\0' + password)) + + async def get_client(self, client_id): + with self.store.transaction() as conn: + value = self.store.get(conn, 'client', client_id) + return OAuthClientInformationFull.model_validate(value) if value else None + + async def register_client(self, client_info): + redirects = [str(uri) for uri in client_info.redirect_uris or []] + if (not redirects or len(redirects) > 4 or any(uri not in CALLBACKS for uri in redirects) + or client_info.token_endpoint_auth_method not in {'none', 'client_secret_post', 'client_secret_basic'} + or set(client_info.grant_types) != {'authorization_code', 'refresh_token'} + or client_info.response_types != ['code'] + or not set((client_info.scope or '').split()) <= set(SCOPES)): + raise RegistrationError('invalid_client_metadata', 'Only the documented Claude web callback and code flow are supported') + with self.store.transaction() as conn: + if not self.store.capacity(conn, 'client', 1024): + raise RegistrationError('invalid_client_metadata', 'Client registration capacity reached') + self.store.put(conn, 'client', client_info.client_id, client_info.model_dump(mode='json'), + time.time() + 90 * 86400) + + async def authorize(self, client, params: AuthorizationParams): + if params.resource not in {None, self.resource}: + raise AuthorizeError('invalid_request', 'The requested resource does not match this MCP server') + scopes = params.scopes or (client.scope or SCOPE).split() + if SCOPE not in scopes or not set(scopes) <= set(SCOPES): + raise AuthorizeError('invalid_scope', 'The read scope is required') + if not re.fullmatch(r'[A-Za-z0-9_-]{43}', params.code_challenge): + raise AuthorizeError('invalid_request', 'S256 PKCE is required') + request_id = secrets.token_urlsafe(32) + value = {'client_id': client.client_id, 'client_name': (client.client_name or 'Claude')[:100], + 'params': params.model_copy(update={'scopes': scopes, 'resource': self.resource}).model_dump(mode='json'), + 'attempts': 0, 'expires': time.time() + PENDING_TTL} + with self.store.transaction() as conn: + if not self.store.capacity(conn, 'pending', 1024): + raise AuthorizeError('temporarily_unavailable', 'Please retry later') + self.store.put(conn, 'pending', digest(request_id), value, value['expires']) + return self.origin + '/oauth/login?request=' + request_id + + async def load_authorization_code(self, client, authorization_code): + with self.store.transaction() as conn: + value = self.store.get(conn, 'code', digest(authorization_code)) + if not value or value['client_id'] != client.client_id: + return None + return AuthorizationCode(code=authorization_code, **value) + + def _issue(self, conn, client_id, scopes, family, refresh_expiry=None): + now = int(time.time()) + access = secrets.token_urlsafe(48) + refresh_expiry = refresh_expiry or now + REFRESH_TTL + expiry = min(now + ACCESS_TTL, refresh_expiry) + common = {'client_id': client_id, 'scopes': scopes, 'resource': self.resource, 'subject': 'project-user'} + self.store.put(conn, 'access', digest(access), {**common, 'expires_at': expiry}, expiry, family) + refresh = None + if 'offline_access' in scopes: + refresh = secrets.token_urlsafe(48) + self.store.put(conn, 'refresh', digest(refresh), + {**common, 'expires_at': refresh_expiry, 'family': family, 'used': False}, refresh_expiry, family) + return OAuthToken(access_token=access, expires_in=expiry-now, refresh_token=refresh, scope=' '.join(scopes)) + + async def exchange_authorization_code(self, client, authorization_code): + with self.store.transaction() as conn: + value = self.store.get(conn, 'code', digest(authorization_code.code)) + if not value or value['client_id'] != client.client_id: + raise TokenError('invalid_grant', 'The code has expired or was already used') + self.store.delete(conn, 'code', digest(authorization_code.code)) + return self._issue(conn, client.client_id, value['scopes'], secrets.token_hex(24)) + + async def load_refresh_token(self, client, refresh_token): + with self.store.transaction() as conn: + value = self.store.get(conn, 'refresh', digest(refresh_token)) + if not value or value['client_id'] != client.client_id: + return None + return RefreshToken(token=refresh_token, **{k:v for k,v in value.items() if k not in {'family','used'}}) + + async def exchange_refresh_token(self, client, refresh_token, scopes): + replay = False + with self.store.transaction() as conn: + value = self.store.get(conn, 'refresh', digest(refresh_token.token)) + if not value or value['client_id'] != client.client_id: + raise TokenError('invalid_grant', 'Invalid refresh token') + if value['used']: + self.store.revoke_family(conn, value['family']) + replay = True + else: + if SCOPE not in scopes or not set(scopes) <= set(value['scopes']): + raise TokenError('invalid_scope', 'Requested scopes exceed the grant') + value['used'] = True + self.store.put(conn, 'refresh', digest(refresh_token.token), value, value['expires_at'], value['family']) + # Rotate both credentials; retire old access tokens in the same family. + conn.execute('DELETE FROM records WHERE namespace=? AND kind=? AND family=?', + (self.store.namespace, 'access', value['family'])) + issued = self._issue(conn, client.client_id, scopes, value['family'], value['expires_at']) + if replay: + raise TokenError('invalid_grant', 'Refresh token reuse revoked this grant') + return issued + + async def load_access_token(self, token): + if hmac.compare_digest(token.encode(), self.static_token.encode()): + return AccessToken(token=token, client_id='local-static', scopes=[SCOPE], resource=self.resource) + with self.store.transaction() as conn: + value = self.store.get(conn, 'access', digest(token)) + return AccessToken(token=token, **value) if value else None + + async def revoke_token(self, token): + with self.store.transaction() as conn: + value = self.store.get(conn, 'access', digest(token.token)) or self.store.get(conn, 'refresh', digest(token.token)) + row = conn.execute('SELECT family FROM records WHERE namespace=? AND key=? AND kind IN (?,?)', + (self.store.namespace, digest(token.token), 'access', 'refresh')).fetchone() + if value and row: + self.store.revoke_family(conn, row[0]) + + def _form(self, request_id, pending, csrf, message='', status=200): + target = escape(urlsplit(pending['params']['redirect_uri']).hostname or '') + name = escape(pending['client_name']) + document = f''' +Prihlásenie — ZP Agent + +

Pripojiť ZP Agenta

Aplikácia {name} žiada prístup k vyhľadávaniu RAG a GraphRAG.

+

Po potvrdení sa vrátiš na {target}.

+ + +Kód ti poskytne správca ZP Agenta. Nezadávaj sem heslo svojho účtu Claude. +

{escape(message)}

+
''' + return HTMLResponse(document, status_code=status) + + async def login(self, request): + if request.method == 'GET': + request_id = request.query_params.get('request', '') + with self.store.transaction() as conn: + pending = self.store.get(conn, 'pending', digest(request_id)) + if not pending: + return HTMLResponse('Prihlásenie vypršalo. Začni pripojenie znova v Claude.', status_code=400) + csrf = secrets.token_urlsafe(32) + pending['csrf'] = digest(csrf) + self.store.put(conn, 'pending', digest(request_id), pending, pending['expires']) + response = self._form(request_id, pending, csrf) + response.set_cookie(COOKIE, csrf, max_age=PENDING_TTL, secure=True, httponly=True, samesite='lax', path='/') + return response + if request.headers.get('origin') not in {None, self.origin}: + return JSONResponse({'error':'invalid_origin'}, status_code=403) + if request.headers.get('content-type','').split(';')[0] != 'application/x-www-form-urlencoded': + return JSONResponse({'error':'invalid_request'}, status_code=415) + form = parse_qs((await request.body()).decode('utf-8', errors='replace'), keep_blank_values=True) + if any(len(values) != 1 for values in form.values()): + return JSONResponse({'error':'invalid_request'}, status_code=400) + get = lambda name: form.get(name, [''])[0] + request_id, csrf = get('request'), get('csrf') + with self.store.transaction() as conn: + pending = self.store.get(conn, 'pending', digest(request_id)) + if (not pending or not csrf or not request.cookies.get(COOKIE) + or not hmac.compare_digest(csrf.encode(), request.cookies[COOKIE].encode()) + or not hmac.compare_digest(digest(csrf), pending.get('csrf', ''))): + return JSONResponse({'error':'invalid_csrf_or_expired_login'}, status_code=403) + if get('action') == 'deny': + self.store.delete(conn, 'pending', digest(request_id)) + response = RedirectResponse(construct_redirect_uri(pending['params']['redirect_uri'], + error='access_denied', state=pending['params'].get('state')), status_code=303) + response.delete_cookie(COOKIE, secure=True, httponly=True, samesite='lax') + return response + bucket = str(int(time.time()) // 60) + rate = self.store.get(conn, 'login_rate', bucket) or {'count': 0} + rate['count'] += 1 + self.store.put(conn, 'login_rate', bucket, rate, time.time()+120) + if rate['count'] > 30 or pending['attempts'] >= 5: + return self._form(request_id, pending, csrf, 'Príliš veľa pokusov. Skús to neskôr.', 429) + if get('action') != 'allow' or not hmac.compare_digest(digest(get('password')), self.password_hash): + pending['attempts'] += 1 + self.store.put(conn, 'pending', digest(request_id), pending, pending['expires']) + return self._form(request_id, pending, csrf, 'Nesprávny prístupový kód.', 401) + code = secrets.token_urlsafe(32) + params = pending['params'] + value = {k:params[k] for k in ['scopes','code_challenge','redirect_uri', + 'redirect_uri_provided_explicitly','resource']} + value.update(client_id=pending['client_id'], expires_at=time.time()+CODE_TTL, subject='project-user') + self.store.put(conn, 'code', digest(code), value, value['expires_at']) + self.store.delete(conn, 'pending', digest(request_id)) + response = RedirectResponse(construct_redirect_uri(params['redirect_uri'], code=code, state=params.get('state')), status_code=303) + response.delete_cookie(COOKIE, secure=True, httponly=True, samesite='lax') + return response + + +class OAuthBoundary: + """Bound auth request bodies, enforce resource binding and harden the consent page.""" + def __init__(self, app, provider): + self.app, self.provider = app, provider + + async def __call__(self, scope, receive, send): + if scope['type'] != 'http': + return await self.app(scope, receive, send) + headers = dict(scope['headers']) + public_host = urlsplit(self.provider.origin).netloc + host = headers.get(b'host', b'').decode('latin1') + if host != public_host and not re.fullmatch(r'(?:127\.0\.0\.1|localhost)(?::\d+)?', host): + return await JSONResponse({'error':'invalid_host'}, status_code=421)(scope, receive, send) + path = scope['path'] + if scope['method'] == 'POST' and path in {'/token','/register','/authorize','/revoke','/oauth/login'}: + body = bytearray() + while True: + message = await receive() + if message['type'] == 'http.disconnect': + return + body.extend(message.get('body', b'')) + if len(body) > 16384: + return await JSONResponse({'error':'request_too_large'}, status_code=413)(scope, receive, send) + if not message.get('more_body'): + break + if path in {'/token', '/revoke'}: + if headers.get(b'content-type',b'').split(b';')[0] != b'application/x-www-form-urlencoded': + return await JSONResponse({'error':'invalid_request'}, status_code=415)(scope, receive, send) + fields = parse_qs(body.decode('utf-8', errors='replace'), keep_blank_values=True) + if any(len(v)!=1 for v in fields.values()) or fields.get('resource',[self.provider.resource]) != [self.provider.resource]: + return await JSONResponse({'error':'invalid_request'}, status_code=400)(scope, receive, send) + # SDK 1.x models an optional revocation secret as a required nullable field. + # Public clients have no secret; normalize that omission before its parser. + if path == '/revoke' and 'client_secret' not in fields: + body.extend(b'&client_secret=') + if path == '/register': + try: + value = json.loads(body) + if not isinstance(value, dict): + raise ValueError() + except (ValueError, UnicodeError): + return await JSONResponse({'error':'invalid_client_metadata'}, status_code=400)(scope, receive, send) + original_receive = receive + sent = False + + async def replay(): + nonlocal sent + if not sent: + sent = True + return {'type':'http.request','body':bytes(body),'more_body':False} + return await original_receive() + receive = replay + + async def secured(message): + if message['type'] == 'http.response.start': + existing = list(message.get('headers', [])) + names = {k.lower() for k,v in existing} + defaults = {b'cache-control':b'no-store', b'pragma':b'no-cache', b'referrer-policy':b'no-referrer', + b'x-content-type-options':b'nosniff', b'x-frame-options':b'DENY'} + if path == '/oauth/login': + defaults[b'content-security-policy'] = b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" + message = {**message,'headers':existing+[(k,v) for k,v in defaults.items() if k not in names]} + await send(message) + return await self.app(scope, receive, secured) diff --git a/scripts/check_claude_oauth.py b/scripts/check_claude_oauth.py new file mode 100644 index 0000000..025646d --- /dev/null +++ b/scripts/check_claude_oauth.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Verify the live OAuth+MCP flow without opening Claude or calling a language model. + +Reads the owner code from local .env/environment, never prints credentials and +never follows the final redirect to the Claude account. Test grants are revoked. +""" +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import os +from pathlib import Path +import re +import secrets +import sys +from urllib.parse import parse_qs, urlsplit + +import httpx +from dotenv import dotenv_values +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + +ROOT = Path(__file__).resolve().parents[1] +CALLBACK = 'https://claude.ai/api/mcp/auth_callback' + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +async def check(): + values = dotenv_values(ROOT / '.env') + resource = os.getenv('ZP_MCP_PUBLIC_URL') or values.get('ZP_MCP_PUBLIC_URL', '') + password = os.getenv('ZP_MCP_LOGIN_PASSWORD') or values.get('ZP_MCP_LOGIN_PASSWORD', '') + url = urlsplit(resource) + require(url.scheme=='https' and url.hostname and url.path=='/mcp' and not url.query + and not url.fragment and url.username is None and url.password is None, + 'Configure the HTTPS MCP URL') + require(password and len(password)>=32, 'Configure the local OAuth login code') + origin = url.scheme + '://' + url.netloc + async with httpx.AsyncClient(timeout=30, follow_redirects=False) as http: + response = await http.post(resource, json={}) + require(response.status_code==401 and 'resource_metadata=' in response.headers.get('www-authenticate',''), + 'OAuth discovery challenge failed') + metadata_url = re.search(r'resource_metadata="([^"]+)"', response.headers['www-authenticate'])[1] + require(metadata_url==origin+'/.well-known/oauth-protected-resource/mcp', 'Unexpected metadata destination') + response = await http.get(metadata_url) + require(response.status_code==200 and response.json().get('resource')==resource, 'Resource metadata failed') + response = await http.get(origin+'/.well-known/oauth-authorization-server') + require(response.status_code==200, 'Authorization metadata failed') + metadata = response.json() + require(all(metadata.get(key)==origin+path for key,path in { + 'authorization_endpoint':'/authorize','registration_endpoint':'/register','token_endpoint':'/token'}.items()), + 'Unexpected authorization endpoint destination') + require('S256' in metadata.get('code_challenge_methods_supported',[]) and + 'none' in metadata.get('token_endpoint_auth_methods_supported',[]), 'Missing public-client PKCE support') + print('OAuth discovery and S256 PKCE metadata: OK',flush=True) + + response = await http.post(origin+'/register',json={'client_name':'ZP Agent connection verification', + 'redirect_uris':[CALLBACK], 'token_endpoint_auth_method':'none', + 'grant_types':['authorization_code','refresh_token'],'response_types':['code'], + 'scope':'zpwiki:read offline_access'}) + require(response.status_code==201, 'Dynamic client registration failed') + client_id = response.json()['client_id'] + verifier, state = secrets.token_urlsafe(48), secrets.token_urlsafe(24) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=') + response = await http.get(origin+'/authorize',params={'response_type':'code','client_id':client_id, + 'redirect_uri':CALLBACK,'code_challenge':challenge,'code_challenge_method':'S256', + 'scope':'zpwiki:read offline_access','state':state,'resource':resource}) + require(response.status_code==302, 'Authorization request failed') + login = urlsplit(response.headers['location']) + require(login.scheme+'://'+login.netloc==origin and login.path=='/oauth/login', 'Unexpected login destination') + response = await http.get(response.headers['location']) + require(response.status_code==200 and 'HttpOnly' in response.headers.get('set-cookie','') + and 'Secure' in response.headers.get('set-cookie',''), 'Secure login page failed') + csrf = re.search(r'name="csrf" value="([^"]+)"', response.text) + require(csrf is not None, 'Missing login CSRF field') + form = {'request':parse_qs(login.query)['request'][0],'csrf':csrf[1],'action':'allow'} + rejected = await http.post(origin+'/oauth/login',data={**form,'password':'invalid-test-code'},headers={'Origin':origin}) + require(rejected.status_code==401, 'Invalid owner code was not rejected') + response = await http.post(origin+'/oauth/login',data={**form,'password':password},headers={'Origin':origin}) + require(response.status_code==303, 'Owner login and consent failed') + callback = urlsplit(response.headers['location']) + require(callback.scheme+'://'+callback.netloc+callback.path==CALLBACK, 'Unexpected callback destination') + query = parse_qs(callback.query) + require(query.get('state')==[state] and query.get('code'), 'OAuth state or code failed') + body = {'grant_type':'authorization_code','client_id':client_id,'code':query['code'][0], + 'code_verifier':verifier,'redirect_uri':CALLBACK,'resource':resource} + wrong = await http.post(origin+'/token',data={**body,'code_verifier':'incorrect-verifier'}) + require(wrong.status_code==400 and wrong.json().get('error')=='invalid_grant','Invalid PKCE verifier accepted') + response = await http.post(origin+'/token',data=body) + require(response.status_code==200, 'Authorization code exchange failed') + token = response.json() + refresh = token.get('refresh_token') + require(refresh and 0 < token.get('expires_in',0) <= 3600, 'Missing limited token or refresh grant') + try: + replay = await http.post(origin+'/token',data=body) + require(replay.status_code==400, 'Authorization code replay accepted') + print('DCR, owner login, consent, state, code exchange and replay rejection: OK',flush=True) + async with httpx.AsyncClient(timeout=60,headers={'Authorization':'Bearer '+token['access_token']}) as authorized: + async with streamable_http_client(resource,http_client=authorized) as (read,write,_): + async with ClientSession(read,write) as session: + await session.initialize() + names={item.name for item in (await session.list_tools()).tools} + require(names=={'zpwiki_rag','zpwiki_graphrag','zpwiki_health'},'MCP tool discovery failed') + result=await session.call_tool('zpwiki_health') + health=result.structuredContent or {} + require(not result.isError and health.get('ready'),'Backend readiness failed') + versions=[health['tools'][engine].get('index_version') for engine in ['rag','graphrag']] + require(versions[0] is not None and versions[0]==versions[1]==health.get('index_version'), + 'Different tool data versions') + for engine in ['rag','graphrag']: + invalid=await session.call_tool('zpwiki_'+engine,{'query':''}) + require(invalid.isError,'Invalid tool query accepted') + print('OAuth-authenticated MCP, both tools and common index version: OK',flush=True) + renewed=await http.post(origin+'/token',data={'grant_type':'refresh_token','client_id':client_id, + 'refresh_token':refresh,'resource':resource}) + require(renewed.status_code==200,'Refresh failed') + new=renewed.json() + require(new['refresh_token']!=refresh,'Refresh token did not rotate') + retired=await http.get(origin+'/health',headers={'Authorization':'Bearer '+token['access_token']}) + require(retired.status_code==401,'Old access token still active after refresh') + refresh=new['refresh_token'] + revoked=await http.post(origin+'/revoke',data={'client_id':client_id,'token':refresh}) + require(revoked.status_code==200,'Revocation failed') + denied=await http.get(origin+'/health',headers={'Authorization':'Bearer '+new['access_token']}) + require(denied.status_code==401,'Revoked grant still active') + print('Refresh rotation and grant revocation: OK; external LLM calls: 0',flush=True) + finally: + await http.post(origin+'/revoke',data={'client_id':client_id,'token':refresh}) + + +if __name__=='__main__': + try: + asyncio.run(check()) + except Exception as error: + # Never include HTTP bodies, credentials, token responses or callback URLs. + print('OAuth verification failed: '+(str(error) if type(error) is RuntimeError else type(error).__name__),file=sys.stderr) + raise SystemExit(1) diff --git a/test/test_claude_packages.py b/test/test_claude_packages.py index eba7bce..b93454f 100644 --- a/test/test_claude_packages.py +++ b/test/test_claude_packages.py @@ -29,6 +29,7 @@ KEY = 'a' * 64 @pytest.fixture def backend(monkeypatch): + monkeypatch.setenv('ZP_MCP_AUTH_MODE', 'bearer') monkeypatch.setattr(main, 'embed_query', lambda _: None) evidence = {'status': 'ok', 'reason': None, 'complete': False, 'truncated': True, 'truncation_reasons': ['result_limit'], 'index_version': {'version': 'fixture'}, @@ -344,3 +345,238 @@ def test_graph_api_uses_shared_error_contract_and_validates_query(monkeypatch): response = client.post('/graphrag', json={'query': ' ', 'limit': 0}, headers={'X-API-Key': KEY}) assert response.status_code == 422 and response.json()['status'] == 'error' assert client.post('/graphrag', json={'query': 'Práca?'}).status_code == 401 + + +@pytest.fixture +def oauth_app(backend, monkeypatch, tmp_path): + from packages.claude.mcp_server import create_http_app + monkeypatch.setenv('ZP_MCP_AUTH_MODE', 'oauth') + monkeypatch.setenv('ZP_MCP_ACCESS_TOKEN', 'm' * 64) + monkeypatch.setenv('ZP_MCP_LOGIN_PASSWORD', 'p' * 64) + monkeypatch.setenv('ZP_MCP_PUBLIC_URL', 'https://mcp.example.test/mcp') + monkeypatch.setenv('ZP_MCP_OAUTH_DB', str(tmp_path/'oauth'/'oauth.sqlite')) + return create_http_app() + + +async def oauth_start(http, *, state='state-123', resource='https://mcp.example.test/mcp', auth_method='none'): + import base64 + from urllib.parse import urlsplit,parse_qs + verifier='v' * 64 + challenge=base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('=') + registered=await http.post('/register',json={'redirect_uris':['https://claude.ai/api/mcp/auth_callback'], + 'client_name':'Claude ', 'grant_types':['authorization_code','refresh_token'], + 'response_types':['code'],'token_endpoint_auth_method':auth_method,'scope':'zpwiki:read offline_access'}) + assert registered.status_code==201,registered.text + info=registered.json() + auth=await http.get('/authorize',params={'response_type':'code','client_id':info['client_id'], + 'redirect_uri':'https://claude.ai/api/mcp/auth_callback','scope':'zpwiki:read offline_access', + 'code_challenge':challenge,'code_challenge_method':'S256','state':state,'resource':resource}) + assert auth.status_code==302,auth.text + if resource!='https://mcp.example.test/mcp': + return info,auth,None,verifier + location=auth.headers['location'] + page=await http.get(location) + assert page.status_code==200 + assert '<test>' in page.text and 'Claude ' not in page.text + assert '__Host-zp-oauth=' in page.headers['set-cookie'] + assert 'Secure' in page.headers['set-cookie'] and 'HttpOnly' in page.headers['set-cookie'] + import re + csrf=re.search(r'name="csrf" value="([^"]+)"',page.text)[1] + request_id=parse_qs(urlsplit(location).query)['request'][0] + return info,{'request':request_id,'csrf':csrf,'action':'allow'},page,verifier + + +async def oauth_grant(http, *, auth_method='none'): + from urllib.parse import parse_qs,urlsplit + info,form,page,verifier=await oauth_start(http,auth_method=auth_method) + granted=await http.post('/oauth/login',data={**form,'password':'p'*64},headers={'Origin':'https://mcp.example.test'}) + assert granted.status_code==303,granted.text + query=parse_qs(urlsplit(granted.headers['location']).query) + assert query['state']==['state-123'] + assert granted.headers['location'].startswith('https://claude.ai/api/mcp/auth_callback?') + body={'grant_type':'authorization_code','client_id':info['client_id'],'code':query['code'][0], + 'redirect_uri':'https://claude.ai/api/mcp/auth_callback','code_verifier':verifier, + 'resource':'https://mcp.example.test/mcp'} + if info.get('client_secret'): + body['client_secret']=info['client_secret'] + response=await http.post('/token',data=body) + assert response.status_code==200,response.text + return info,response.json(),body + + +def test_oauth_discovery_and_full_flow_with_mcp(oauth_app): + import httpx + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + denied=await http.post('/mcp',json={}) + assert denied.status_code==401 + assert 'resource_metadata=' in denied.headers['www-authenticate'] + prm=await http.get('/.well-known/oauth-protected-resource/mcp') + assert prm.status_code==200 and prm.json()['resource']=='https://mcp.example.test/mcp' + metadata=(await http.get('/.well-known/oauth-authorization-server')).json() + assert 'none' in metadata['token_endpoint_auth_methods_supported'] + assert metadata['code_challenge_methods_supported']==['S256'] + assert metadata['registration_endpoint'].endswith('/register') + info,token,body=await oauth_grant(http) + assert token['expires_in']>0 and token['refresh_token'] + assert token['access_token'] not in oauth_app.provider.store.path.read_bytes().decode(errors='ignore') + assert (await http.post('/token',data=body)).json()['error']=='invalid_grant' + http.headers['Authorization']='Bearer '+token['access_token'] + async with streamable_http_client('https://mcp.example.test/mcp',http_client=http) as (read,write,_): + async with ClientSession(read,write) as session: + await session.initialize() + assert len((await session.list_tools()).tools)==3 + health=await session.call_tool('zpwiki_health') + assert not health.isError and health.structuredContent['ready'] + result=await session.call_tool('zpwiki_rag',{'query':'Práca Evy Novej?'}) + assert not result.isError and result.structuredContent['accepted_for_delivery'] + asyncio.run(run()) + + +def test_oauth_rejects_bad_login_csrf_and_redirect(oauth_app): + import httpx + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + bad=await http.post('/register',json={'redirect_uris':['https://evil.test/steal'],'token_endpoint_auth_method':'none'}) + assert bad.status_code==400 + info,form,page,verifier=await oauth_start(http) + assert page.headers['referrer-policy']=='no-referrer' + assert "frame-ancestors 'none'" in page.headers['content-security-policy'] + assert (await http.post('/oauth/login',data={**form,'csrf':'wrong','password':'p'*64})).status_code==403 + assert (await http.post('/oauth/login',data={**form,'password':'p'*64},headers={'Origin':'https://evil.test'})).status_code==403 + wrong=await http.post('/oauth/login',data={**form,'password':'wrong'}) + assert wrong.status_code==401 and 'Nesprávny' in wrong.text + for _ in range(4): + assert (await http.post('/oauth/login',data={**form,'password':'wrong'})).status_code==401 + assert (await http.post('/oauth/login',data={**form,'password':'p'*64})).status_code==429 + assert (await http.post('/register',content=b'not json')).status_code==400 + assert (await http.post('/token',content=b'x'*16385)).status_code==413 + asyncio.run(run()) + + +def test_oauth_pkce_redirect_resource_and_expiry(oauth_app): + import httpx + from urllib.parse import parse_qs,urlsplit + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + info,auth,_,verifier=await oauth_start(http,resource='https://evil.test/mcp') + assert parse_qs(urlsplit(auth.headers['location']).query)['error']==['invalid_request'] + info,form,page,verifier=await oauth_start(http) + approved=await http.post('/oauth/login',data={**form,'password':'p'*64}) + code=parse_qs(urlsplit(approved.headers['location']).query)['code'][0] + request={'client_id':info['client_id'],'grant_type':'authorization_code','code':code, + 'redirect_uri':'https://claude.ai/api/mcp/auth_callback','code_verifier':verifier} + bad=await http.post('/token',data={**request,'code_verifier':'bad-verifier'}) + assert bad.json()['error']=='invalid_grant' + assert (await http.post('/token',data={**request,'redirect_uri':'https://evil.test'})).status_code==400 + assert (await http.post('/token',data={**request,'resource':'https://evil.test/mcp'})).status_code==400 + with oauth_app.provider.store.transaction() as conn: + conn.execute("UPDATE records SET expires=0 WHERE kind='code'") + assert (await http.post('/token',data=request)).json()['error']=='invalid_grant' + asyncio.run(run()) + + +def test_oauth_refresh_replay_revocation_and_persistence(oauth_app): + import httpx + from packages.claude.oauth_provider import OAuthProvider + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + info,token,body=await oauth_grant(http) + saved=OAuthProvider('https://mcp.example.test/mcp','m'*64,'p'*64,oauth_app.provider.store.path) + assert await saved.load_access_token(token['access_token']) + refresh={'grant_type':'refresh_token','client_id':info['client_id'], + 'refresh_token':token['refresh_token']} + response=await http.post('/token',data=refresh) + assert response.status_code==200,response.text + new=response.json() + assert new['refresh_token']!=token['refresh_token'] + assert not await saved.load_access_token(token['access_token']) + assert await saved.load_access_token(new['access_token']) + assert (await http.post('/token',data=refresh)).json()['error']=='invalid_grant' + assert not await saved.load_access_token(new['access_token']) + info,token,body=await oauth_grant(http) + revoked=await http.post('/revoke',data={'client_id':info['client_id'],'token':token['refresh_token']}) + assert revoked.status_code==200 + assert not await saved.load_access_token(token['access_token']) + assert not await saved.load_refresh_token(await saved.get_client(info['client_id']),token['refresh_token']) + rotated=OAuthProvider('https://mcp.example.test/mcp','m'*64,'q'*64,oauth_app.provider.store.path) + assert not await rotated.get_client(info['client_id']) + asyncio.run(run()) + + +@pytest.mark.parametrize('method',['client_secret_post','client_secret_basic']) +def test_oauth_confidential_clients(oauth_app,method): + import httpx,base64 + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + if method=='client_secret_post': + info,token,_=await oauth_grant(http,auth_method=method) + else: + from urllib.parse import parse_qs,urlsplit + info,form,_,verifier=await oauth_start(http,auth_method=method) + granted=await http.post('/oauth/login',data={**form,'password':'p'*64}) + code=parse_qs(urlsplit(granted.headers['location']).query)['code'][0] + body={'client_id':info['client_id'],'grant_type':'authorization_code','code':code, + 'redirect_uri':'https://claude.ai/api/mcp/auth_callback','code_verifier':verifier} + basic=base64.b64encode((info['client_id']+':'+info['client_secret']).encode()).decode() + response=await http.post('/token',data=body,headers={'Authorization':'Basic '+basic}) + assert response.status_code==200,response.text + token=response.json() + assert token['access_token'] + asyncio.run(run()) + + +def test_oauth_client_binding_scope_and_access_expiry(oauth_app): + import httpx + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + info,token,_=await oauth_grant(http) + other,_,_,_=await oauth_start(http) + refresh={'grant_type':'refresh_token','client_id':info['client_id'], + 'refresh_token':token['refresh_token']} + wrong=await http.post('/token',data={**refresh,'client_id':other['client_id']}) + assert wrong.json()['error']=='invalid_grant' + escalated=await http.post('/token',data={**refresh,'scope':'zpwiki:write'}) + assert escalated.json()['error']=='invalid_scope' + assert (await http.post('/token',data={**refresh,'resource':'https://evil.test/mcp'})).status_code==400 + with oauth_app.provider.store.transaction() as conn: + conn.execute("UPDATE records SET expires=0 WHERE kind='access'") + denied=await http.post('/mcp',json={},headers={'Authorization':'Bearer '+token['access_token']}) + assert denied.status_code==401 + renewed=await http.post('/token',data=refresh) + assert renewed.status_code==200 + assert await oauth_app.provider.load_access_token(renewed.json()['access_token']) + static=await http.get('/health',headers={'Authorization':'Bearer '+'m'*64}) + assert static.status_code==200 + asyncio.run(run()) + + +def test_oauth_cancel_expired_pending_and_missing_cookie(oauth_app): + import httpx + from urllib.parse import parse_qs,urlsplit + async def run(): + async with oauth_app.app.router.lifespan_context(oauth_app.app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=oauth_app),base_url='https://mcp.example.test') as http: + _,form,_,_=await oauth_start(http) + http.cookies.clear() + assert (await http.post('/oauth/login',data={**form,'password':'p'*64})).status_code==403 + _,form,_,_=await oauth_start(http) + cancelled=await http.post('/oauth/login',data={**form,'action':'deny'}) + assert cancelled.status_code==303 + assert parse_qs(urlsplit(cancelled.headers['location']).query)['error']==['access_denied'] + assert (await http.post('/oauth/login',data={**form,'password':'p'*64})).status_code==403 + _,form,_,_=await oauth_start(http) + with oauth_app.provider.store.transaction() as conn: + conn.execute("UPDATE records SET expires=0 WHERE kind='pending'") + assert (await http.get('/oauth/login',params={'request':form['request']})).status_code==400 + assert (await http.post('/oauth/login',data={**form,'password':'p'*64})).status_code==403 + asyncio.run(run())