|
| 1 | +"""OAuth 2.1 for AI assistants connecting to /mcp, the way the MCP |
| 2 | +authorization spec and claude.ai's connectors use it. The server is |
| 3 | +both the resource (/mcp) and its own authorization server, signing users |
| 4 | +in with their existing access tokens: |
| 5 | +
|
| 6 | +1. /mcp answers 401 with a pointer to the protected resource metadata |
| 7 | + (RFC 9728), which names this server as the authorization server, and |
| 8 | + its metadata (RFC 8414) lists the endpoints below. |
| 9 | +2. The client registers itself (RFC 7591): public clients only, no |
| 10 | + secret. A Client ID Metadata Document isn't supported, so Claude |
| 11 | + falls back to registering. |
| 12 | +3. /oauth/authorize shows a consent page (routes/oauth.py); allowing it |
| 13 | + sends the browser back to the client with a one-use code, bound to |
| 14 | + the PKCE challenge (S256 only). |
| 15 | +4. /oauth/token trades the code and the PKCE verifier for an access |
| 16 | + token (an hour) and a refresh token (30 days), and a refresh token |
| 17 | + for new ones. Refresh tokens rotate: one used twice revokes the |
| 18 | + grant. The tokens and grants are kept by gcm/store/oauth.py. |
| 19 | +
|
| 20 | +Redirect URIs must be https, or http to a loopback address (a native |
| 21 | +client such as Claude Code), where the port is ignored when matching |
| 22 | +(RFC 8252 7.3): the client picks a free one each time.""" |
| 23 | + |
| 24 | +import base64 |
| 25 | +import hashlib |
| 26 | +import hmac |
| 27 | +import ipaddress |
| 28 | +from urllib.parse import urlencode, urlsplit, urlunsplit |
| 29 | + |
| 30 | +from flask import request |
| 31 | + |
| 32 | +from gcm import config |
| 33 | + |
| 34 | +MAX_REDIRECT_URIS = 10 |
| 35 | +MAX_URI_LENGTH = 2000 |
| 36 | +MAX_CLIENT_NAME = 100 |
| 37 | + |
| 38 | + |
| 39 | +def base_url(): |
| 40 | + """This server's address as clients see it: PUBLIC_URL when set (it |
| 41 | + must be, behind a proxy that doesn't forward the host), else the |
| 42 | + request's own.""" |
| 43 | + return config.PUBLIC_URL or request.host_url.rstrip("/") |
| 44 | + |
| 45 | + |
| 46 | +def resource_url(): |
| 47 | + return base_url() + "/mcp" |
| 48 | + |
| 49 | + |
| 50 | +def protected_resource_metadata(): |
| 51 | + return { |
| 52 | + "resource": resource_url(), |
| 53 | + "authorization_servers": [base_url()], |
| 54 | + "bearer_methods_supported": ["header"], |
| 55 | + "resource_name": "GTNH Craft Monitor", |
| 56 | + } |
| 57 | + |
| 58 | + |
| 59 | +def authorization_server_metadata(): |
| 60 | + base = base_url() |
| 61 | + return { |
| 62 | + "issuer": base, |
| 63 | + "authorization_endpoint": base + "/oauth/authorize", |
| 64 | + "token_endpoint": base + "/oauth/token", |
| 65 | + "registration_endpoint": base + "/oauth/register", |
| 66 | + "response_types_supported": ["code"], |
| 67 | + "grant_types_supported": ["authorization_code", "refresh_token"], |
| 68 | + "code_challenge_methods_supported": ["S256"], |
| 69 | + "token_endpoint_auth_methods_supported": ["none"], |
| 70 | + "authorization_response_iss_parameter_supported": True, |
| 71 | + } |
| 72 | + |
| 73 | + |
| 74 | +def _is_loopback(host): |
| 75 | + if host == "localhost": |
| 76 | + return True |
| 77 | + try: |
| 78 | + return ipaddress.ip_address(host.strip("[]")).is_loopback |
| 79 | + except ValueError: |
| 80 | + return False |
| 81 | + |
| 82 | + |
| 83 | +def redirect_uri_error(uri): |
| 84 | + """Why a redirect URI can't be registered, or None.""" |
| 85 | + if not isinstance(uri, str) or not uri or len(uri) > MAX_URI_LENGTH: |
| 86 | + return "each redirect URI must be a non-empty string" |
| 87 | + try: |
| 88 | + parts = urlsplit(uri) |
| 89 | + hostname = parts.hostname |
| 90 | + except ValueError: |
| 91 | + return f"not a URL: {uri}" |
| 92 | + if parts.fragment: |
| 93 | + return "a redirect URI can't have a fragment" |
| 94 | + if not hostname: |
| 95 | + return f"not a URL: {uri}" |
| 96 | + if parts.scheme == "https": |
| 97 | + return None |
| 98 | + if parts.scheme == "http" and _is_loopback(hostname): |
| 99 | + return None |
| 100 | + return "redirect URIs must be https, or http to localhost" |
| 101 | + |
| 102 | + |
| 103 | +def _without_port(uri): |
| 104 | + parts = urlsplit(uri) |
| 105 | + return urlunsplit((parts.scheme, parts.hostname or "", parts.path, parts.query, "")) |
| 106 | + |
| 107 | + |
| 108 | +def redirect_uri_allowed(client, uri): |
| 109 | + """Whether a client may be sent back to this URI: exactly one it |
| 110 | + registered, or a loopback one that differs only in its port.""" |
| 111 | + if not isinstance(uri, str): |
| 112 | + return False |
| 113 | + if uri in client["redirect_uris"]: |
| 114 | + return True |
| 115 | + try: |
| 116 | + parts = urlsplit(uri) |
| 117 | + if parts.scheme != "http" or not parts.hostname or not _is_loopback(parts.hostname): |
| 118 | + return False |
| 119 | + loose = _without_port(uri) |
| 120 | + except ValueError: |
| 121 | + return False |
| 122 | + return any( |
| 123 | + urlsplit(registered).scheme == "http" and _without_port(registered) == loose |
| 124 | + for registered in client["redirect_uris"] |
| 125 | + ) |
| 126 | + |
| 127 | + |
| 128 | +def redirect_target(uri, params): |
| 129 | + """The redirect URI with params added to its query.""" |
| 130 | + parts = urlsplit(uri) |
| 131 | + query = parts.query + ("&" if parts.query else "") + urlencode(params) |
| 132 | + return urlunsplit((parts.scheme, parts.netloc, parts.path, query, "")) |
| 133 | + |
| 134 | + |
| 135 | +def redirect_origin(uri): |
| 136 | + """scheme://host[:port] of a URI, for the consent page's form-action.""" |
| 137 | + parts = urlsplit(uri) |
| 138 | + return f"{parts.scheme}://{parts.netloc}" |
| 139 | + |
| 140 | + |
| 141 | +def pkce_matches(verifier, challenge): |
| 142 | + """RFC 7636 S256: BASE64URL(SHA256(verifier)) == challenge.""" |
| 143 | + if not isinstance(verifier, str) or not 43 <= len(verifier) <= 128: |
| 144 | + return False |
| 145 | + digest = hashlib.sha256(verifier.encode("ascii", "replace")).digest() |
| 146 | + expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() |
| 147 | + return hmac.compare_digest(expected, challenge) |
| 148 | + |
| 149 | + |
| 150 | +def resource_ok(resource): |
| 151 | + """A resource indicator (RFC 8707), if the client sent one, must name |
| 152 | + this server's /mcp.""" |
| 153 | + if resource in (None, ""): |
| 154 | + return True |
| 155 | + return resource.rstrip("/") == resource_url() |
0 commit comments