OAuth prihlásenie

This commit is contained in:
Ján Pták 2026-11-02 03:12:10 +01:00
parent 13c074df2e
commit 7368871efd
7 changed files with 830 additions and 15 deletions

View File

@ -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=<samostatný náhodný prihlasovací kód, minimálne 32 znakov>
# 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://<tvoja-doména>/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://<tvoja-doména>/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 <ZP_MCP_ACCESS_TOKEN>**. 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 <ZP_MCP_ACCESS_TOKEN>`, 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í.

View File

@ -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"]

View File

@ -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:

View File

@ -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():

View File

@ -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'''<!doctype html><html lang="sk"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Prihlásenie — ZP Agent</title>
<style>body{{font:17px system-ui;max-width:520px;margin:8vh auto;padding:24px;line-height:1.5;background:#f5f5f5;color:#222}}
form{{background:white;padding:24px;border-radius:12px}}input,button{{box-sizing:border-box;width:100%;padding:12px;margin:8px 0;font:inherit}}
.error{{color:#a00}}small{{display:block}}</style>
<h1>Pripojiť ZP Agenta</h1><p>Aplikácia <strong>{name}</strong> žiada prístup k vyhľadávaniu RAG a GraphRAG.</p>
<p>Po potvrdení sa vrátiš na <strong>{target}</strong>.</p><form method="post" action="/oauth/login">
<input type="hidden" name="request" value="{escape(request_id)}"><input type="hidden" name="csrf" value="{escape(csrf)}">
<label for="password">Prístupový kód ZP Agenta</label><input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
<small>Kód ti poskytne správca ZP Agenta. Nezadávaj sem heslo svojho účtu Claude.</small>
<p class="error">{escape(message)}</p><button name="action" value="allow">Prihlásiť a povoliť prístup</button>
<button name="action" value="deny" formnovalidate>Zrušiť</button></form></html>'''
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)

View File

@ -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)

View File

@ -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 <test>', '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 '&lt;test&gt;' in page.text and 'Claude <test>' 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())