Source code for aiohttp_tiny_mcp.client.callback

"""OAuth callbacks for an existing aiohttp application."""

from __future__ import annotations

import asyncio
import hmac
from collections.abc import Sequence

from aiohttp import web
from yarl import URL

from .oauth import AuthorizationAttempt, OAuth
from .oauth_helpers import OAuthError

SUCCESS_HTML = """<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Authorization complete</title>
<h1>Authorization complete</h1><p>You can close this window.</p></html>"""


[docs] class OAuthCallback: """Mount a local or remote callback for one OAuth account context. The application must authenticate the user before calling ``begin`` and keep each user's context separate. The external HTTPS ``auth.redirect_uri`` can differ from the listener address behind a trusted reverse proxy. """ def __init__(self, auth: OAuth, *, success_html: str = SUCCESS_HTML) -> None: self.auth = auth self.success_html = success_html self.attempt: AuthorizationAttempt | None = None self.outcome: asyncio.Future[Exception | None] | None = None
[docs] def routes(self) -> list[web.RouteDef]: """Return the GET route at the path of the configured redirect URI.""" return [web.get(URL(self.auth.redirect_uri).path, self.handle)]
[docs] def setup(self, app: web.Application) -> None: """Mount the callback on an existing application.""" app.add_routes(self.routes())
[docs] async def begin(self, *, scopes: Sequence[str] | None = None) -> AuthorizationAttempt: """Prepare the URL to which the application redirects its signed-in user.""" self.cancel() self.attempt = await self.auth.begin(scopes=scopes) self.outcome = asyncio.get_running_loop().create_future() return self.attempt
[docs] async def wait(self) -> None: """Wait for token acquisition and persistence, or raise their error.""" if self.outcome is None: raise RuntimeError("Start an authorization attempt first.") error = await asyncio.shield(self.outcome) if error is not None: raise error
[docs] def cancel(self) -> None: """Cancel a pending callback without changing existing credentials.""" if self.attempt is not None: self.auth.cancel(self.attempt) self.attempt = None if self.outcome is not None and not self.outcome.done(): self.outcome.set_result(OAuthError("Authorization was cancelled."))
[docs] async def handle(self, request: web.Request) -> web.Response: """Exchange the callback code before sending the result page.""" attempt, outcome = self.attempt, self.outcome states = request.query.getall("state", []) expected_state = URL(attempt.url).query["state"] if attempt is not None else "" if ( attempt is None or outcome is None or outcome.done() or len(states) != 1 or not hmac.compare_digest(states[0].encode(), expected_state.encode()) ): return self.response(False, "No matching authorization attempt.") self.attempt = None # Use the configured public URI, not an untrusted Host or Forwarded header. callback_url = self.auth.redirect_uri + "?" + request.rel_url.raw_query_string try: await self.auth.complete(attempt, callback_url) except asyncio.CancelledError: if not outcome.done(): outcome.set_result(OAuthError("The callback request was cancelled.")) raise except Exception as error: if not outcome.done(): outcome.set_result(error) return self.response(False, "Authorization failed. Return to the application.") if outcome.done(): return self.response(False, "Authorization was cancelled.") outcome.set_result(None) return self.response(True, self.success_html)
@staticmethod def response(success: bool, text: str) -> web.Response: return web.Response( status=200 if success else 400, text=text, content_type="text/html" if success else "text/plain", headers={ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": ( "default-src 'none'; style-src 'unsafe-inline'; " "img-src data: https:; frame-ancestors 'none'" ), }, )
__all__ = ["OAuthCallback", "SUCCESS_HTML"]