feat(mcp): configuration authlib (grants + PKCE + DCR, #114)
- AuthorizationCodeGrant avec PKCE requis (CodeChallenge), RefreshTokenGrant avec rotation + re-vérification d'abonnement, DCR (ClientRegistrationEndpoint) et révocation (RevocationEndpoint) câblés sur le store sqlite (Tasks 1-3), metadata (Task 5) et consent.subscription_ok (Task 6). - _Token (dict + check_client/get_scope) fait le pont entre les rows sqlite bruts et le protocole TokenMixin qu'authlib 1.7.2 attend pour le refresh et la révocation.
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from authlib.integrations.flask_oauth2 import AuthorizationServer
|
||||||
|
from authlib.oauth2.rfc6749 import grants
|
||||||
|
from authlib.oauth2.rfc7009 import RevocationEndpoint
|
||||||
|
from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint
|
||||||
|
from authlib.oauth2.rfc7636 import CodeChallenge
|
||||||
|
|
||||||
|
from src.mcp.oauth import metadata, store
|
||||||
|
|
||||||
|
ACCESS_TTL = 3600 # 1 h
|
||||||
|
REFRESH_TTL = 5184000 # 60 j
|
||||||
|
|
||||||
|
|
||||||
|
def _db() -> str:
|
||||||
|
return os.environ["USERS_DB_PATH"]
|
||||||
|
|
||||||
|
|
||||||
|
def _base() -> str:
|
||||||
|
return os.getenv("APP_BASE_URL", "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(epoch: float) -> str:
|
||||||
|
return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _expired(iso_ts: str) -> bool:
|
||||||
|
return datetime.fromisoformat(iso_ts) < datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, row: dict):
|
||||||
|
self.client_id = row["client_id"]
|
||||||
|
self.client_metadata = row["client_metadata"]
|
||||||
|
|
||||||
|
def get_client_id(self):
|
||||||
|
return self.client_id
|
||||||
|
|
||||||
|
def get_default_redirect_uri(self):
|
||||||
|
uris = self.client_metadata.get("redirect_uris") or []
|
||||||
|
return uris[0] if uris else None
|
||||||
|
|
||||||
|
def get_allowed_scope(self, scope):
|
||||||
|
if not scope:
|
||||||
|
return ""
|
||||||
|
allowed = set((self.client_metadata.get("scope") or "").split())
|
||||||
|
return " ".join(s for s in scope.split() if s in allowed)
|
||||||
|
|
||||||
|
def check_redirect_uri(self, redirect_uri):
|
||||||
|
return redirect_uri in (self.client_metadata.get("redirect_uris") or [])
|
||||||
|
|
||||||
|
def check_client_secret(self, client_secret):
|
||||||
|
return False # client public
|
||||||
|
|
||||||
|
def check_endpoint_auth_method(self, method, endpoint):
|
||||||
|
return method == "none"
|
||||||
|
|
||||||
|
def check_response_type(self, response_type):
|
||||||
|
return response_type == "code"
|
||||||
|
|
||||||
|
def check_grant_type(self, grant_type):
|
||||||
|
return grant_type in ("authorization_code", "refresh_token")
|
||||||
|
|
||||||
|
|
||||||
|
def query_client(client_id):
|
||||||
|
row = store.get_client(_db(), client_id)
|
||||||
|
return _Client(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_token(token, request):
|
||||||
|
resource = (
|
||||||
|
request.form.get("resource")
|
||||||
|
or request.args.get("resource")
|
||||||
|
or metadata.mcp_resource(_base())
|
||||||
|
)
|
||||||
|
now = time.time()
|
||||||
|
store.save_token(
|
||||||
|
_db(),
|
||||||
|
access_token=token["access_token"],
|
||||||
|
refresh_token=token.get("refresh_token"),
|
||||||
|
client_id=request.client.get_client_id(),
|
||||||
|
user_id=int(request.user),
|
||||||
|
scope=token.get("scope", ""),
|
||||||
|
resource=resource,
|
||||||
|
access_expires_at=_iso(now + token.get("expires_in", ACCESS_TTL)),
|
||||||
|
refresh_expires_at=_iso(now + REFRESH_TTL),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AuthCode:
|
||||||
|
def __init__(self, row: dict):
|
||||||
|
self._row = row
|
||||||
|
self._raw_code = None
|
||||||
|
self.user_id = row["user_id"]
|
||||||
|
self.code_challenge = row["code_challenge"]
|
||||||
|
self.code_challenge_method = row["code_challenge_method"]
|
||||||
|
|
||||||
|
def get_redirect_uri(self):
|
||||||
|
return self._row["redirect_uri"]
|
||||||
|
|
||||||
|
def get_scope(self):
|
||||||
|
return self._row["scope"] or ""
|
||||||
|
|
||||||
|
|
||||||
|
class _Token(dict):
|
||||||
|
"""Wraps an ``oauth_tokens`` row (a plain dict from ``store``) so it also
|
||||||
|
satisfies authlib's TokenMixin-ish protocol (``check_client``/
|
||||||
|
``get_scope``) needed by RefreshTokenGrant/RevocationEndpoint, while
|
||||||
|
still supporting the dict-style ``row["..."]`` access used elsewhere in
|
||||||
|
this module (e.g. ``credential["user_id"]``, ``token["id"]``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check_client(self, client):
|
||||||
|
return self["client_id"] == client.get_client_id()
|
||||||
|
|
||||||
|
def get_scope(self):
|
||||||
|
return self["scope"] or ""
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
|
||||||
|
TOKEN_ENDPOINT_AUTH_METHODS = ["none"]
|
||||||
|
|
||||||
|
def save_authorization_code(self, code, request):
|
||||||
|
store.save_code(
|
||||||
|
_db(),
|
||||||
|
code,
|
||||||
|
client_id=request.client.get_client_id(),
|
||||||
|
user_id=int(request.user),
|
||||||
|
redirect_uri=request.payload.redirect_uri,
|
||||||
|
code_challenge=request.payload.data.get("code_challenge"),
|
||||||
|
code_challenge_method=request.payload.data.get("code_challenge_method"),
|
||||||
|
scope=request.scope,
|
||||||
|
resource=request.payload.data.get("resource")
|
||||||
|
or metadata.mcp_resource(_base()),
|
||||||
|
expires_at=_iso(time.time() + 300),
|
||||||
|
)
|
||||||
|
|
||||||
|
def query_authorization_code(self, code, client):
|
||||||
|
row = store.get_code(_db(), code)
|
||||||
|
if (
|
||||||
|
row
|
||||||
|
and row["client_id"] == client.get_client_id()
|
||||||
|
and not _expired(row["expires_at"])
|
||||||
|
):
|
||||||
|
obj = _AuthCode(row)
|
||||||
|
obj._raw_code = code
|
||||||
|
return obj
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_authorization_code(self, authorization_code):
|
||||||
|
# code_challenge est unique par code ; on supprime par le hash côté store
|
||||||
|
# via le code d'origine reconstruit impossible → on stocke le code clair
|
||||||
|
# dans l'objet. Ici authorization_code provient de query ; on supprime par
|
||||||
|
# la ligne complète.
|
||||||
|
store.delete_code(_db(), authorization_code._raw_code)
|
||||||
|
|
||||||
|
def authenticate_user(self, authorization_code):
|
||||||
|
return authorization_code.user_id
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenGrant(grants.RefreshTokenGrant):
|
||||||
|
TOKEN_ENDPOINT_AUTH_METHODS = ["none"]
|
||||||
|
INCLUDE_NEW_REFRESH_TOKEN = True # rotation
|
||||||
|
|
||||||
|
def authenticate_refresh_token(self, refresh_token):
|
||||||
|
row = store.get_token_by_refresh(_db(), refresh_token)
|
||||||
|
if not row or row["revoked_at"] is not None:
|
||||||
|
return None
|
||||||
|
if row["refresh_expires_at"] and _expired(row["refresh_expires_at"]):
|
||||||
|
return None
|
||||||
|
return _Token(row)
|
||||||
|
|
||||||
|
def authenticate_user(self, credential):
|
||||||
|
from src.mcp.oauth.consent import subscription_ok
|
||||||
|
|
||||||
|
uid = credential["user_id"]
|
||||||
|
# Re-vérifie l'abonnement au refresh : perdu → invalid_grant.
|
||||||
|
if not subscription_ok(uid):
|
||||||
|
return None
|
||||||
|
return uid
|
||||||
|
|
||||||
|
def revoke_old_credential(self, credential):
|
||||||
|
store.revoke_token(_db(), credential["id"])
|
||||||
|
|
||||||
|
|
||||||
|
class _RegistrationEndpoint(ClientRegistrationEndpoint):
|
||||||
|
def authenticate_token(self, request):
|
||||||
|
# DCR ouverte (client public) : pas de jeton d'enregistrement requis.
|
||||||
|
return True
|
||||||
|
|
||||||
|
def resolve_public_key(self, request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_server_metadata(self):
|
||||||
|
return metadata.authorization_server_metadata(_base())
|
||||||
|
|
||||||
|
def save_client(self, client_info, client_metadata, request):
|
||||||
|
client_id = client_info["client_id"]
|
||||||
|
store.create_client(_db(), client_id, dict(client_metadata))
|
||||||
|
return _Client(
|
||||||
|
{"client_id": client_id, "client_metadata": dict(client_metadata)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RevocationEndpoint(RevocationEndpoint):
|
||||||
|
# Clients publics (DCR, pas de secret) : authentification "none", comme
|
||||||
|
# pour les grants authorization_code/refresh_token ci-dessus.
|
||||||
|
CLIENT_AUTH_METHODS = ["none"]
|
||||||
|
|
||||||
|
def query_token(self, token_string, token_type_hint):
|
||||||
|
row = store.get_token_by_access(
|
||||||
|
_db(), token_string
|
||||||
|
) or store.get_token_by_refresh(_db(), token_string)
|
||||||
|
return _Token(row) if row else None
|
||||||
|
|
||||||
|
def revoke_token(self, token, request):
|
||||||
|
store.revoke_token(_db(), token["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def create_authorization_server(app) -> AuthorizationServer:
|
||||||
|
server = AuthorizationServer(app, query_client=query_client, save_token=save_token)
|
||||||
|
server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
|
||||||
|
server.register_grant(RefreshTokenGrant)
|
||||||
|
server.register_endpoint(_RegistrationEndpoint)
|
||||||
|
server.register_endpoint(_RevocationEndpoint)
|
||||||
|
return server
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from src.mcp.oauth import server
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_authorization_server(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "u.sqlite"))
|
||||||
|
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SECRET_KEY"] = "x"
|
||||||
|
srv = server.create_authorization_server(app)
|
||||||
|
assert srv is not None
|
||||||
Reference in New Issue
Block a user