OAuth prihlásenie
This commit is contained in:
parent
13c074df2e
commit
7368871efd
63
README.md
63
README.md
@ -654,6 +654,8 @@ ANSWER_MAX_TOKENS=4096
|
|||||||
# Webový MCP konektor (samostatný kľúč, nikdy SEARCH_API_KEY):
|
# 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_ACCESS_TOKEN=<ďalšia náhodná hodnota s minimálne 32 znakmi>
|
||||||
ZP_MCP_PUBLIC_URL=https://mcp.example.com/mcp
|
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
|
# GraphRAG / Neo4j
|
||||||
NEO4J_URI=bolt://localhost:7687
|
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
|
## 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,
|
Docker, synchronizáciu, kontroly aj historické experimenty. Zdrojové Tools,
|
||||||
Pipe a builder určené výhradne pre OpenWebUI sú odstránené. Zmienky o školskom
|
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,
|
OpenWebUI v starších výsledkoch a evaluačných klientoch sú historická metodika,
|
||||||
@ -1017,7 +1019,7 @@ Tok pre webový Claude:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
Claude + dva Skills
|
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
|
-> zpwiki_rag / zpwiki_graphrag / zpwiki_health
|
||||||
-> súkromný backend /answer alebo /health
|
-> súkromný backend /answer alebo /health
|
||||||
-> SQLite / Neo4j -> zvolený generujúci model -> kontroly -> overený text
|
-> 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`.
|
`openai_compatible` používa `ANSWER_API_KEY`, `ANSWER_LLM_URL`, `ANSWER_MODEL`.
|
||||||
Historické `OPENWEBUI_API_KEY` zostáva podporované pre reprodukciu starých
|
Historické `OPENWEBUI_API_KEY` zostáva podporované pre reprodukciu starých
|
||||||
experimentov. Použitie Claude ako rozhrania samo osebe nemení poskytovateľa.
|
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
|
3. Vygeneruj oddelený `ZP_MCP_ACCESS_TOKEN` aj `ZP_MCP_LOGIN_PASSWORD` cez
|
||||||
`ZP_MCP_PUBLIC_URL=https://<tvoja-doména>/mcp`. Kľúč zostáva v ignorovanom
|
dva príkazy `openssl rand -hex 32`. Nastav `ZP_MCP_AUTH_MODE=oauth` a
|
||||||
`.env`; MCP sa s chýbajúcim, krátkym alebo rovnakým backendovým kľúčom nespustí.
|
`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é:
|
4. Spusti MCP profil. Backend a Neo4j zostávajú rovnaké:
|
||||||
|
|
||||||
```bash
|
```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
|
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.
|
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
|
6. V Claude otvor **Customize > Connectors > Add custom connector**, zadaj
|
||||||
meno `ZP Wiki` a HTTPS adresu `/mcp`. Pri autentizácii vyber **No sign in**
|
meno `ZP Agent` a HTTPS adresu `/mcp`. Vyber **Sign in now** a
|
||||||
(nevykonávame OAuth) a v **Request headers** nastav **Authorization** na
|
**Streamable HTTP**. Ak sa zobrazuje výber OAuth klienta, použi
|
||||||
**Bearer <ZP_MCP_ACCESS_TOKEN>**. Tento tajný údaj vlož iba do nastavení
|
**Register automatically**. OAuth client ID a client secret nechaj prázdne:
|
||||||
konektora, nikdy do chatu alebo skillu. Header stále zabezpečuje autentizáciu
|
server podporuje Dynamic Client Registration (DCR), nepotrebuje vlastné
|
||||||
a server bez neho odpovie 401. Ak účet nemá možnosť vlastných hlavičiek,
|
hlavičky ani administrátorskú OAuth aplikáciu. Pri pripojení sa otvorí stránka
|
||||||
nepoužívaj verejný nechránený server: treba iný podporovaný spôsob autentizácie.
|
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ť
|
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
|
zapnuté aj Code execution and file creation. V organizácii môžu konektory
|
||||||
vyžadovať zásah vlastníka; nejde o oprávnenia OpenWebUI.
|
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),
|
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).
|
[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
|
### Lokálny klient a voliteľný Desktop / Claude Code
|
||||||
|
|
||||||
Pre skripty nastav `ZP_AGENT_URL` a `ZP_AGENT_API_KEY`, alebo si skopíruj
|
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
|
```bash
|
||||||
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -m pytest -q -p no:cacheprovider test/test_claude_packages.py
|
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
|
PYTHONDONTWRITEBYTECODE=1 .venv/bin/python scripts/verify_project.py --no-save --git-network
|
||||||
```
|
```
|
||||||
|
|
||||||
Testy pokrývajú samostatné rozbalenie ZIPov, deterministické SHA-256,
|
Testy pokrývajú samostatné rozbalenie ZIPov, deterministické SHA-256,
|
||||||
HTTP komunikáciu a autorizáciu backendu, odmietnutý výstup, metadáta úplnosti,
|
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
|
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 nevykonáva platené volanie Anthropic. Verejné HTTPS a samotný účet Claude
|
||||||
sa overia až pri skutočnom pripojení.
|
sa overia až pri skutočnom pripojení.
|
||||||
|
|||||||
@ -3,7 +3,9 @@ WORKDIR /app
|
|||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
COPY deployment/requirements-claude.txt /app/requirements.txt
|
COPY deployment/requirements-claude.txt /app/requirements.txt
|
||||||
RUN python -m pip install --no-cache-dir -r /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
|
USER 65534:65534
|
||||||
EXPOSE 8001
|
EXPOSE 8001
|
||||||
CMD ["python", "/app/mcp_server.py", "--transport", "http"]
|
CMD ["python", "/app/mcp_server.py", "--transport", "http"]
|
||||||
|
|||||||
@ -82,6 +82,10 @@ services:
|
|||||||
ZP_MCP_ACCESS_TOKEN: ${ZP_MCP_ACCESS_TOKEN:-}
|
ZP_MCP_ACCESS_TOKEN: ${ZP_MCP_ACCESS_TOKEN:-}
|
||||||
ZP_MCP_PUBLIC_URL: ${ZP_MCP_PUBLIC_URL:-}
|
ZP_MCP_PUBLIC_URL: ${ZP_MCP_PUBLIC_URL:-}
|
||||||
ZP_MCP_BIND: "0.0.0.0"
|
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:
|
depends_on:
|
||||||
zp-agent-api:
|
zp-agent-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -131,6 +135,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
claude-oauth:
|
||||||
hf-cache:
|
hf-cache:
|
||||||
neo4j-data:
|
neo4j-data:
|
||||||
neo4j-logs:
|
neo4j-logs:
|
||||||
|
|||||||
@ -81,13 +81,56 @@ def create_http_app():
|
|||||||
security = TransportSecuritySettings(enable_dns_rebinding_protection=True,
|
security = TransportSecuritySettings(enable_dns_rebinding_protection=True,
|
||||||
allowed_hosts=[url.netloc, '127.0.0.1:*', 'localhost:*'],
|
allowed_hosts=[url.netloc, '127.0.0.1:*', 'localhost:*'],
|
||||||
allowed_origins=['https://' + url.netloc])
|
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'])
|
@server.custom_route('/health', methods=['GET'])
|
||||||
async def bridge_health(request):
|
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 JSONResponse({'status': 'ok', 'service': 'zpwiki-mcp'})
|
||||||
|
|
||||||
|
if not provider:
|
||||||
return BearerGate(server.streamable_http_app(), token)
|
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():
|
def main():
|
||||||
|
|||||||
349
packages/claude/oauth_provider.py
Normal file
349
packages/claude/oauth_provider.py
Normal 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)
|
||||||
141
scripts/check_claude_oauth.py
Normal file
141
scripts/check_claude_oauth.py
Normal 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)
|
||||||
@ -29,6 +29,7 @@ KEY = 'a' * 64
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def backend(monkeypatch):
|
def backend(monkeypatch):
|
||||||
|
monkeypatch.setenv('ZP_MCP_AUTH_MODE', 'bearer')
|
||||||
monkeypatch.setattr(main, 'embed_query', lambda _: None)
|
monkeypatch.setattr(main, 'embed_query', lambda _: None)
|
||||||
evidence = {'status': 'ok', 'reason': None, 'complete': False, 'truncated': True,
|
evidence = {'status': 'ok', 'reason': None, 'complete': False, 'truncated': True,
|
||||||
'truncation_reasons': ['result_limit'], 'index_version': {'version': 'fixture'},
|
'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})
|
response = client.post('/graphrag', json={'query': ' ', 'limit': 0}, headers={'X-API-Key': KEY})
|
||||||
assert response.status_code == 422 and response.json()['status'] == 'error'
|
assert response.status_code == 422 and response.json()['status'] == 'error'
|
||||||
assert client.post('/graphrag', json={'query': 'Práca?'}).status_code == 401
|
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 '<test>' 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())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user