From 224b3af8689116ef78e1d646850d90ba22879117 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Thu, 20 Aug 2026 14:33:31 +0530 Subject: [PATCH 1/2] Phase 2: OAuth 2.1 RFC compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PKCE (RFC 7636) as a standalone, fully-tested utility module — not wired into a local authorization-code exchange endpoint, since nitrostack is an OAuth resource server and never issues tokens itself in either SDK. Expand OAuthService: cache JWKS clients and introspection results (TTL-based), check the introspection endpoint before JWKS to match the TypeScript SDK's precedence, and validate the token's audience claim (RFC 8707) on both validation paths instead of only the JWKS one. Remove the fallback that treated any bearer token as valid when neither JWKS nor an introspection endpoint was configured. Unconfigured now means reject, matching the TypeScript reference, which has no such fallback. Expand the discovery server: full RFC 8414 authorization-server metadata instead of a 3-field stub, and a Dynamic Client Registration (RFC 7591) endpoint using the same simplified static-credential variant the TypeScript SDK ships — opt-in, hands back pre-configured credentials, no per-client storage. Add AuthContext.aud so guard code can see which audience a token was issued for, normalized to a list since the claim is legal as either a string or a list. tests/test_oauth.py grows from 1 test to 19, including a named regression test for the removed mock-fallback and coverage for the new caches, audience checks, discovery document shape, and registration gating. --- nitrostack/auth/oauth.py | 275 +++++++++++++++++------ nitrostack/auth/oauth_module.py | 104 +++++++++ nitrostack/auth/pkce.py | 109 +++++++++ nitrostack/core/context.py | 1 + nitrostack/core/pipeline.py | 8 +- tests/test_oauth.py | 385 +++++++++++++++++++++++++++++++- 6 files changed, 814 insertions(+), 68 deletions(-) create mode 100644 nitrostack/auth/oauth_module.py create mode 100644 nitrostack/auth/pkce.py diff --git a/nitrostack/auth/oauth.py b/nitrostack/auth/oauth.py index 6b8cbed..469b8e2 100644 --- a/nitrostack/auth/oauth.py +++ b/nitrostack/auth/oauth.py @@ -1,13 +1,20 @@ import os import sys +import time import json import urllib.request import urllib.parse -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Tuple from http.server import HTTPServer, BaseHTTPRequestHandler import threading from nitrostack.core.module import module from nitrostack.core.di import DIContainer +from nitrostack.auth.oauth_module import ( + build_authorization_server_metadata, + build_protected_resource_metadata, + build_registration_response, + is_client_registration_enabled, +) class OAuthService: def __init__( @@ -22,6 +29,10 @@ def __init__( jwks_uri: Optional[str] = None, audience: Optional[str] = None, issuer: Optional[str] = None, + token_cache_seconds: Optional[int] = None, + enable_client_registration: Optional[bool] = None, + static_client_id: Optional[str] = None, + static_client_secret: Optional[str] = None, ): self.resource_uri = resource_uri self.authorization_servers = authorization_servers @@ -30,12 +41,34 @@ def __init__( self.token_introspection_client_id = token_introspection_client_id self.token_introspection_client_secret = token_introspection_client_secret self.discovery_port = discovery_port - + # Environmental fallbacks self.jwks_uri = jwks_uri or os.environ.get("JWKS_URI") self.audience = audience or os.environ.get("TOKEN_AUDIENCE") or resource_uri self.issuer = issuer or os.environ.get("TOKEN_ISSUER") - + self.token_cache_seconds = ( + token_cache_seconds + if token_cache_seconds is not None + else int(os.environ.get("OAUTH_TOKEN_CACHE_SECONDS", "300")) + ) + + # Dynamic Client Registration (RFC 7591) — see oauth_module.py for why this is + # a simplified, static-credential variant rather than full per-client storage. + self.enable_client_registration = ( + enable_client_registration + if enable_client_registration is not None + else os.environ.get("OAUTH_ENABLE_CLIENT_REGISTRATION", "").lower() == "true" + ) + self.static_client_id = static_client_id or os.environ.get("OAUTH_CLIENT_ID") + self.static_client_secret = static_client_secret or os.environ.get("OAUTH_CLIENT_SECRET") + + # Caches: JWKS client objects (keyed by jwks_uri — PyJWKClient already does its + # own internal signing-key caching, this just avoids reconstructing the client + # itself on every call) and introspection *results* (keyed by token, so repeated + # calls with the same token skip both the HTTP round-trip and JWT verification). + self._jwks_clients: Dict[str, Any] = {} + self._token_cache: Dict[str, Tuple[Dict[str, Any], float]] = {} + self._server: Optional[HTTPServer] = None self._thread: Optional[threading.Thread] = None @@ -45,6 +78,13 @@ def start_discovery_server(self) -> None: return service_instance = self + registration_path = "/oauth/v2/register" + + def _write_json(handler: BaseHTTPRequestHandler, status: int, payload: Dict[str, Any]) -> None: + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.end_headers() + handler.wfile.write(json.dumps(payload).encode("utf-8")) class DiscoveryHandler(BaseHTTPRequestHandler): def log_message(self, format, *args): @@ -53,30 +93,43 @@ def log_message(self, format, *args): def do_GET(self): if self.path == "/.well-known/oauth-protected-resource": - response_data = { - "resource": service_instance.resource_uri, - "authorization_servers": service_instance.authorization_servers, - "scopes_supported": service_instance.scopes_supported - } - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(json.dumps(response_data).encode("utf-8")) + _write_json(self, 200, build_protected_resource_metadata(service_instance)) elif self.path == "/.well-known/oauth-authorization-server": - # Mock/basic authorization server metadata if query hits this resource - response_data = { - "issuer": service_instance.authorization_servers[0] if service_instance.authorization_servers else "http://localhost", - "token_endpoint": service_instance.token_introspection_endpoint or "", - "introspection_endpoint": service_instance.token_introspection_endpoint or "" - } - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(json.dumps(response_data).encode("utf-8")) + registration_endpoint = ( + registration_path if is_client_registration_enabled(service_instance) else None + ) + _write_json( + self, + 200, + build_authorization_server_metadata(service_instance, registration_endpoint), + ) else: self.send_response(404) self.end_headers() + def do_POST(self): + if self.path != registration_path: + self.send_response(404) + self.end_headers() + return + + if not is_client_registration_enabled(service_instance): + _write_json( + self, + 404, + {"error": "not_found", "error_description": "Client registration is not enabled"}, + ) + return + + length = int(self.headers.get("Content-Length", 0)) + raw_body = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw_body.decode("utf-8")) if raw_body else {} + except Exception: + body = {} + + _write_json(self, 200, build_registration_response(service_instance, body)) + def run_server(): # Try binding to OAUTH_DISCOVERY_PORT port = int(os.environ.get("OAUTH_DISCOVERY_PORT", self.discovery_port)) @@ -110,48 +163,80 @@ def stop_discovery_server(self) -> None: async def introspect_token(self, token: str) -> Dict[str, Any]: """ - Validates token using JWKS verification or RFC 7662 token introspection. + Validate a Bearer token and return its introspection result (RFC 7662 shape: + {"active": bool, ...claims}). + + Checks, in order: + 1. Cache (a prior successful result for this exact token, still within TTL). + 2. Token introspection endpoint (RFC 7662), if configured. + 3. JWKS/JWT signature verification, if configured. + 4. Neither configured -> {"active": False}. There is deliberately no "assume + valid" fallback: a server that hasn't been told how to validate tokens + must reject them, not accept everything. (This mirrors the TypeScript SDK, + which has no such fallback either.) + + Every successful result is passed through `_validate_audience` (RFC 8707) + before being trusted or cached — a token that's valid but wasn't issued for + this resource is still rejected. """ - # 1. JWKS Verification if configured - if self.jwks_uri: - try: - import jwt - # Parse JWT headers to get kid - unverified_headers = jwt.get_unverified_header(token) - jwks_client = jwt.PyJWKClient(self.jwks_uri) - signing_key = jwks_client.get_signing_key_from_jwt(token) - - # Verify token signature - data = jwt.decode( - token, - signing_key.key, - algorithms=["RS256"], - audience=self.audience, - issuer=self.issuer - ) - return { - "active": True, - "scope": data.get("scope", ""), - "sub": data.get("sub"), - "client_id": data.get("client_id") - } - except Exception as e: - # Log signature failure to stderr - sys.stderr.write(f"OAuth JWKS verification failed: {e}\n") - sys.stderr.flush() - return {"active": False} + cached = self._cache_get(token) + if cached is not None: + return cached - if not self.token_introspection_endpoint: - # If no introspection endpoint is configured, mock active check for local debugging - # A real deployment must provide an introspection endpoint. - sys.stderr.write("OAuth Warning: No token_introspection_endpoint configured. Assuming mock active.\n") - return {"active": True, "scope": " ".join(self.scopes_supported), "sub": "mock-user"} + if self.token_introspection_endpoint: + result = await self._introspect_via_endpoint(token) + elif self.jwks_uri: + result = self._introspect_via_jwks(token) + else: + return {"active": False} - # Perform HTTP POST request + if result.get("active") and not self._validate_audience(result): + sys.stderr.write( + f"OAuth: token rejected, audience mismatch (expected {self.audience!r})\n" + ) + sys.stderr.flush() + result = {"active": False} + + if result.get("active"): + self._cache_set(token, result) + return result + + def _introspect_via_jwks(self, token: str) -> Dict[str, Any]: + """JWT signature verification using a cached JWKS client (RFC 7517/7519).""" + try: + import jwt + jwks_client = self._get_jwks_client(self.jwks_uri) + signing_key = jwks_client.get_signing_key_from_jwt(token) + + data = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + audience=self.audience, + issuer=self.issuer, + ) + return { + "active": True, + "scope": data.get("scope", ""), + "sub": data.get("sub"), + "client_id": data.get("client_id"), + "aud": data.get("aud"), + "exp": data.get("exp"), + "iat": data.get("iat"), + "iss": data.get("iss"), + } + except Exception as e: + sys.stderr.write(f"OAuth JWKS verification failed: {e}\n") + sys.stderr.flush() + return {"active": False} + + async def _introspect_via_endpoint(self, token: str) -> Dict[str, Any]: + """RFC 7662 token introspection: POST the token to the authorization server + and ask whether it's active.""" data = urllib.parse.urlencode({"token": token}).encode("utf-8") req = urllib.request.Request(self.token_introspection_endpoint, data=data, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded") - + # Add basic auth if client credentials provided if self.token_introspection_client_id and self.token_introspection_client_secret: import base64 @@ -160,22 +245,76 @@ async def introspect_token(self, token: str) -> Dict[str, Any]: req.add_header("Authorization", f"Basic {encoded_auth}") try: - # We run in a threadpool or run_in_executor to avoid blocking async loop - # But standard library urllib.request is synchronous, so let's run it synchronously in context - # (or use asyncio loop.run_in_executor if we are in async method). + # urllib.request is synchronous; run it on a thread so it doesn't block + # the event loop this async method is running on. import asyncio loop = asyncio.get_event_loop() - + def do_request(): with urllib.request.urlopen(req, timeout=5) as response: return json.loads(response.read().decode("utf-8")) - + return await loop.run_in_executor(None, do_request) except Exception as e: sys.stderr.write(f"OAuth Introspection Request Failed: {e}\n") sys.stderr.flush() return {"active": False} + def _get_jwks_client(self, jwks_uri: str): + """Return a cached PyJWKClient for this URI, creating it on first use. + + PyJWKClient already caches individual signing keys internally; this cache + is one level up — it avoids reconstructing the client object itself (and + re-fetching the whole key set) on every single token check. + """ + import jwt + client = self._jwks_clients.get(jwks_uri) + if client is None: + client = jwt.PyJWKClient(jwks_uri) + self._jwks_clients[jwks_uri] = client + return client + + def _validate_audience(self, introspection: Dict[str, Any]) -> bool: + """ + RFC 8707 resource-indicator check: does this token's `aud` claim include + the resource it's being presented to? Without this, a token minted for a + *different* service could be replayed here and accepted — the token is + legitimately signed/active, just not meant for this resource. + + `aud` is legal as either a single string or a list of strings per JWT + conventions, so it's normalized to a list before comparing. + """ + expected = self.audience or self.resource_uri + if not expected: + # Nothing configured to check against — permissive, matching the + # TypeScript SDK's default when no audience is configured. + return True + + raw_aud = introspection.get("aud") + if raw_aud is None: + # No audience claim on the token at all — nothing to validate against. + # Treat as permissive rather than rejecting tokens from authorization + # servers that don't emit `aud`. + return True + + token_audiences = raw_aud if isinstance(raw_aud, list) else [raw_aud] + return expected in token_audiences + + def _cache_get(self, token: str) -> Optional[Dict[str, Any]]: + cached = self._token_cache.get(token) + if cached is None: + return None + result, expires_at = cached + if time.monotonic() >= expires_at: + del self._token_cache[token] + return None + return result + + def _cache_set(self, token: str, result: Dict[str, Any]) -> None: + if self.token_cache_seconds <= 0: + return + self._token_cache[token] = (result, time.monotonic() + self.token_cache_seconds) + @module(name="OAuthModule") class OAuthModule: @classmethod @@ -191,6 +330,10 @@ def for_root( jwks_uri: Optional[str] = None, audience: Optional[str] = None, issuer: Optional[str] = None, + token_cache_seconds: Optional[int] = None, + enable_client_registration: Optional[bool] = None, + static_client_id: Optional[str] = None, + static_client_secret: Optional[str] = None, ): service = OAuthService( resource_uri=resource_uri, @@ -202,7 +345,11 @@ def for_root( discovery_port=discovery_port, jwks_uri=jwks_uri, audience=audience, - issuer=issuer + issuer=issuer, + token_cache_seconds=token_cache_seconds, + enable_client_registration=enable_client_registration, + static_client_id=static_client_id, + static_client_secret=static_client_secret, ) DIContainer.get_instance().register_value(OAuthService, service) return cls diff --git a/nitrostack/auth/oauth_module.py b/nitrostack/auth/oauth_module.py new file mode 100644 index 0000000..31f8bab --- /dev/null +++ b/nitrostack/auth/oauth_module.py @@ -0,0 +1,104 @@ +""" +HTTP-facing OAuth discovery and registration document builders. + +Separated from `oauth.py`'s `OAuthService` (token validation logic) so the shape of +each well-known document is a plain function you can call and assert on directly, +without spinning up the `http.server` thread `OAuthService.start_discovery_server()` +runs. `OAuthService` imports these and wires them into its `DiscoveryHandler`. + +Three documents, three different jobs: +- RFC 8414 (`/.well-known/oauth-authorization-server`): "how do I talk to the + authorization server?" — issuer, token/introspection endpoints, supported flows. +- RFC 9728 (`/.well-known/oauth-protected-resource`): "what does *this* resource + server need, and which authorization server(s) does it trust?" +- RFC 7591 (`POST /oauth/v2/register`): Dynamic Client Registration — here, a + simplified/static variant (see `build_registration_response`), matching the + TypeScript SDK's behavior rather than full per-client credential issuance. +""" +from __future__ import annotations + +import time +from typing import Any, Dict, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from nitrostack.auth.oauth import OAuthService + + +def build_authorization_server_metadata( + service: "OAuthService", registration_endpoint: Optional[str] = None +) -> Dict[str, Any]: + """ + Build an RFC 8414 Authorization Server Metadata document. + + nitrostack is a resource server, not the authorization server itself, so this + document describes the *external* IdP configured via `authorization_servers`/ + `token_introspection_endpoint`/`jwks_uri` — it does not mean nitrostack serves + these endpoints itself. + """ + issuer = service.issuer or ( + service.authorization_servers[0] if service.authorization_servers else "http://localhost" + ) + auth_server_base = service.authorization_servers[0] if service.authorization_servers else issuer + + metadata: Dict[str, Any] = { + "issuer": issuer, + "authorization_endpoint": f"{auth_server_base}/authorize", + "token_endpoint": f"{auth_server_base}/token", + "introspection_endpoint": service.token_introspection_endpoint or f"{auth_server_base}/introspect", + "jwks_uri": service.jwks_uri or f"{auth_server_base}/.well-known/jwks.json", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"], + "subject_types_supported": ["public"], + "code_challenge_methods_supported": ["S256"], + } + if registration_endpoint: + metadata["registration_endpoint"] = registration_endpoint + return metadata + + +def build_protected_resource_metadata(service: "OAuthService") -> Dict[str, Any]: + """Build an RFC 9728 Protected Resource Metadata document describing this server.""" + return { + "resource": service.resource_uri, + "authorization_servers": service.authorization_servers, + "scopes_supported": service.scopes_supported, + } + + +def is_client_registration_enabled(service: "OAuthService") -> bool: + """ + Whether the static Dynamic Client Registration endpoint should be exposed. + + Requires BOTH an explicit opt-in (`enable_client_registration`, from config or + `OAUTH_ENABLE_CLIENT_REGISTRATION=true`) AND a configured client id — never a + literal default. Without a configured client id there is nothing to hand back. + """ + return bool(service.enable_client_registration and service.static_client_id) + + +def build_registration_response(service: "OAuthService", body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Build an RFC 7591 client-registration response. + + This is the simplified, static-credential variant the TypeScript SDK ships: + it always hands back the operator's own pre-configured client_id/client_secret + rather than generating and storing new per-registration credentials. It exists + only so MCP clients that require a `registration_endpoint` to be present don't + refuse to proceed — not as a general-purpose multi-tenant registration service. + """ + body = body or {} + client_id = service.static_client_id + client_secret = service.static_client_secret or "" + return { + "client_id": client_id, + "client_secret": client_secret, + "client_id_issued_at": int(time.time()), + "client_secret_expires_at": 0, # never expires + "grant_types": body.get("grant_types") or ["authorization_code", "refresh_token"], + "response_types": body.get("response_types") or ["code"], + # 'none' = public client authenticating via PKCE instead of a client secret + # (the standard OAuth 2.1 pattern for CLI/desktop apps that can't hold a secret). + "token_endpoint_auth_method": body.get("token_endpoint_auth_method") + or ("client_secret_post" if client_secret else "none"), + "redirect_uris": body.get("redirect_uris") or [], + } diff --git a/nitrostack/auth/pkce.py b/nitrostack/auth/pkce.py new file mode 100644 index 0000000..c64de01 --- /dev/null +++ b/nitrostack/auth/pkce.py @@ -0,0 +1,109 @@ +""" +PKCE (Proof Key for Code Exchange) utilities — RFC 7636. + +PKCE defends the OAuth "authorization code" flow against code-interception attacks: +a client generates a secret `code_verifier` it never discloses, derives a one-way +`code_challenge` from it, and sends only the challenge when starting the flow. When +later exchanging the authorization code for a token, it presents the original +verifier; the authorization server re-derives the challenge and checks it matches. +An attacker who only intercepted the authorization code (never the verifier) cannot +complete the exchange. OAuth 2.1 requires PKCE for all public clients. + +nitrostack is an OAuth *resource server* (it validates incoming Bearer tokens), never +an *authorization server* (it never issues codes or tokens itself) — so these are +provided as standalone, directly-portable utilities, not wired into a local +code-exchange endpoint that doesn't exist in this SDK or its TypeScript counterpart. +""" +from __future__ import annotations + +import base64 +import hashlib +import re +import secrets +from typing import Dict, List, Optional + +_VERIFIER_CHARSET_RE = re.compile(r"^[A-Za-z0-9\-._~]+$") + + +def _b64url_encode(data: bytes) -> str: + """Base64url without padding — the encoding RFC 7636 requires for both the + verifier and the challenge.""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def generate_code_verifier() -> str: + """ + Generate a cryptographically random code verifier. + + Per RFC 7636: a high-entropy random string, 43-128 characters, from the + unreserved URI character set. `secrets` (not `random`) is used because this + value must be unguessable — `random` is a statistical PRNG, not a + cryptographic one. + """ + # 32 random bytes -> 256 bits of entropy -> 43 base64url characters. + return _b64url_encode(secrets.token_bytes(32)) + + +def generate_code_challenge(verifier: str, method: str = "S256") -> str: + """ + Derive a code challenge from a code verifier. + + method="S256" (default, the only method OAuth 2.1 requires support for): + code_challenge = BASE64URL(SHA256(verifier)) + method="plain": + code_challenge = verifier, unchanged. NOT RECOMMENDED — offers no + protection beyond what a plain authorization code already has, since the + "challenge" sent up front is now just the verifier itself. Only exists + for constrained clients that can't compute SHA-256. + """ + if method == "plain": + return verifier + if method == "S256": + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return _b64url_encode(digest) + raise ValueError(f"Unsupported PKCE method: {method!r} (expected 'S256' or 'plain')") + + +def generate_pkce_params(method: str = "S256") -> Dict[str, str]: + """Generate a complete verifier/challenge pair for starting an authorization flow.""" + verifier = generate_code_verifier() + challenge = generate_code_challenge(verifier, method) + return { + "code_verifier": verifier, + "code_challenge": challenge, + "code_challenge_method": method, + } + + +def verify_pkce(verifier: str, challenge: str, method: str = "S256") -> bool: + """ + Verify that a code verifier matches a previously-issued code challenge. + + Plain string equality is used deliberately, not a constant-time comparison + like `hmac.compare_digest`. `code_challenge` is not a secret — it travels in + the (public) authorization request URL — so there is no secret value being + defended against a timing side-channel here, unlike e.g. an HMAC signature + check. This intentionally matches the TypeScript SDK's `verifyPKCE`. + """ + return generate_code_challenge(verifier, method) == challenge + + +def is_valid_code_verifier(verifier: str) -> bool: + """ + Check that a string is a well-formed RFC 7636 code verifier: 43-128 + characters from [A-Za-z0-9\\-._~]. + """ + if not (43 <= len(verifier) <= 128): + return False + return bool(_VERIFIER_CHARSET_RE.match(verifier)) + + +def validate_pkce_support(supported_methods: Optional[List[str]]) -> bool: + """ + Check whether an authorization server's advertised `code_challenge_methods_supported` + satisfies OAuth 2.1: S256 support is required. An authorization server that + doesn't advertise S256 (or advertises nothing) must be treated as not usable. + """ + if not supported_methods: + return False + return "S256" in supported_methods diff --git a/nitrostack/core/context.py b/nitrostack/core/context.py index 02e17c8..817b431 100644 --- a/nitrostack/core/context.py +++ b/nitrostack/core/context.py @@ -83,6 +83,7 @@ class AuthContext: exp: int | None = None # expiration timestamp iat: int | None = None # issued-at timestamp iss: str | None = None # issuer URL + aud: List[str] | None = None # audience(s) this token was issued for (RFC 8707) claims: Dict[str, Any] = field(default_factory=dict) # custom claims token_payload: Any = None # full decoded token diff --git a/nitrostack/core/pipeline.py b/nitrostack/core/pipeline.py index f529910..2742776 100644 --- a/nitrostack/core/pipeline.py +++ b/nitrostack/core/pipeline.py @@ -140,7 +140,12 @@ async def can_activate(self, context: ExecutionContext) -> bool: if not token_info.get("active"): return False - # Populate AuthContext + # Populate AuthContext. `aud` is legal as either a single string or a + # list of strings per JWT conventions, so it's normalized to a list + # here -- callers checking `"x" in context.auth.aud` should always get + # list-membership semantics, never accidental substring matching. + raw_aud = token_info.get("aud") + aud = raw_aud if isinstance(raw_aud, list) else ([raw_aud] if raw_aud else None) context.auth = AuthContext( subject=token_info.get("sub"), scopes=token_info.get("scope", "").split(" ") if token_info.get("scope") else [], @@ -148,6 +153,7 @@ async def can_activate(self, context: ExecutionContext) -> bool: exp=token_info.get("exp"), iat=token_info.get("iat"), iss=token_info.get("iss"), + aud=aud, claims=token_info, token_payload=token_info ) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 548da51..dc795b9 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -1,6 +1,7 @@ import asyncio import os import sys +import time from unittest.mock import MagicMock, patch # Ensure parent directory is in sys.path @@ -8,6 +9,21 @@ from nitrostack import DIContainer, ExecutionContext, OAuthGuard from nitrostack.auth.oauth import OAuthModule, OAuthService +from nitrostack.auth.oauth_module import ( + build_authorization_server_metadata, + build_protected_resource_metadata, + build_registration_response, + is_client_registration_enabled, +) +from nitrostack.auth.pkce import ( + generate_code_challenge, + generate_code_verifier, + generate_pkce_params, + is_valid_code_verifier, + validate_pkce_support, + verify_pkce, +) + async def _test_oauth_guard_validation(): print("Testing OAuthGuard and OAuth 2.1 validation flow...") @@ -52,10 +68,10 @@ async def _test_oauth_guard_validation(): logger=MagicMock(), metadata={"authorization": "Bearer invalid-token"} ) - + # Mock introspect_token to return inactive oauth_service = DIContainer.get_instance().resolve(OAuthService) - + with patch.object(oauth_service, 'introspect_token', return_value={"active": False}) as mock_introspect: res_invalid = await guard.can_activate(ctx_invalid) print("Invalid token check:", res_invalid) @@ -75,6 +91,7 @@ async def _test_oauth_guard_validation(): "scope": "flight:read flight:write", "sub": "user_12345", "client_id": "client_abc", + "aud": "http://localhost:8000/mcp", "exp": 1900000000 } @@ -83,18 +100,380 @@ async def _test_oauth_guard_validation(): print("Valid token check:", res_valid) assert res_valid is True mock_introspect.assert_called_once_with("valid-token") - + # Verify AuthContext properties populated on the context assert ctx_valid.auth is not None assert ctx_valid.auth.subject == "user_12345" assert "flight:read" in ctx_valid.auth.scopes assert "flight:write" in ctx_valid.auth.scopes assert ctx_valid.auth.client_id == "client_abc" + # A string `aud` claim must come out normalized to a list, not left as a + # bare string (which would give substring-match semantics downstream). + assert ctx_valid.auth.aud == ["http://localhost:8000/mcp"] print("Success! OAuthGuard and OAuth 2.1 token validations work perfectly.") + def test_oauth_guard_validation(): asyncio.run(_test_oauth_guard_validation()) + +# --------------------------------------------------------------------------- +# PKCE (RFC 7636) +# --------------------------------------------------------------------------- + +def test_pkce_round_trip(): + print("Testing PKCE verifier/challenge round-trip (S256)...") + params = generate_pkce_params() + assert params["code_challenge_method"] == "S256" + assert is_valid_code_verifier(params["code_verifier"]) + assert verify_pkce(params["code_verifier"], params["code_challenge"]) is True + print("Success! S256 verifier/challenge round-trip matches.") + + +def test_pkce_rejects_mismatched_verifier(): + print("Testing PKCE rejects a verifier that doesn't match the challenge...") + params = generate_pkce_params() + other_verifier = generate_code_verifier() + assert other_verifier != params["code_verifier"] + assert verify_pkce(other_verifier, params["code_challenge"]) is False + print("Success! Mismatched verifier is rejected.") + + +def test_pkce_plain_method(): + print("Testing PKCE 'plain' method (challenge == verifier, unhashed)...") + verifier = generate_code_verifier() + challenge = generate_code_challenge(verifier, method="plain") + assert challenge == verifier + assert verify_pkce(verifier, challenge, method="plain") is True + print("Success! 'plain' method behaves as challenge == verifier.") + + +def test_pkce_unknown_method_raises(): + print("Testing PKCE raises on an unsupported challenge method...") + try: + generate_code_challenge("x" * 43, method="bogus") + raise AssertionError("expected ValueError for an unknown PKCE method") + except ValueError: + pass + print("Success! Unknown method raises ValueError.") + + +def test_pkce_verifier_format_boundaries(): + print("Testing PKCE code_verifier format boundaries (RFC 7636: 43-128 chars)...") + assert is_valid_code_verifier("a" * 42) is False # too short + assert is_valid_code_verifier("a" * 43) is True # minimum length + assert is_valid_code_verifier("a" * 128) is True # maximum length + assert is_valid_code_verifier("a" * 129) is False # too long + assert is_valid_code_verifier("a" * 43 + "!") is False # invalid charset ('!' not allowed) + print("Success! Verifier length/charset boundaries enforced correctly.") + + +def test_pkce_support_requires_s256(): + print("Testing validate_pkce_support requires S256 (OAuth 2.1 mandate)...") + assert validate_pkce_support(["S256"]) is True + assert validate_pkce_support(["S256", "plain"]) is True + assert validate_pkce_support(["plain"]) is False + assert validate_pkce_support([]) is False + assert validate_pkce_support(None) is False + print("Success! S256 support is correctly required.") + + +# --------------------------------------------------------------------------- +# Audience / resource-indicator validation (RFC 8707) +# --------------------------------------------------------------------------- + +async def _test_audience_validation_endpoint_path(): + print("Testing RFC 8707 audience validation on the introspection-endpoint path...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + token_introspection_endpoint="https://idp.example.com/introspect", + ) + + with patch.object( + service, "_introspect_via_endpoint", + return_value={"active": True, "aud": "https://other-service.example.com", "scope": "read"}, + ): + result = await service.introspect_token("wrong-audience-token") + print("Wrong-audience token result:", result) + assert result == {"active": False} + + with patch.object( + service, "_introspect_via_endpoint", + return_value={"active": True, "aud": "https://api.example.com", "scope": "read", "sub": "u1"}, + ): + result = await service.introspect_token("correct-audience-token") + assert result["active"] is True + + print("Success! Audience mismatch rejected, audience match accepted (endpoint path).") + + +def test_audience_validation_endpoint_path(): + asyncio.run(_test_audience_validation_endpoint_path()) + + +def test_audience_validation_list_form(): + print("Testing RFC 8707 audience validation when `aud` is a list, not a string...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + assert service._validate_audience({"aud": ["https://api.example.com", "https://other.example.com"]}) is True + assert service._validate_audience({"aud": ["https://other.example.com"]}) is False + assert service._validate_audience({}) is True # no aud claim at all -> permissive + print("Success! List-form `aud` claims are validated by membership, not exact match.") + + +# --------------------------------------------------------------------------- +# Removed mock-active fallback -- explicit regression test +# --------------------------------------------------------------------------- + +async def _test_unconfigured_service_rejects_by_default(): + print("Testing an unconfigured OAuthService rejects tokens instead of mock-accepting them...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + # Deliberately no jwks_uri and no token_introspection_endpoint. + ) + result = await service.introspect_token("any-token-at-all") + assert result == {"active": False}, ( + "REGRESSION: an unconfigured OAuthService must reject tokens by default. " + "It must never fall back to treating every token as valid." + ) + print("Success! Unconfigured service correctly rejects (no mock-active fallback).") + + +def test_unconfigured_service_rejects_by_default(): + asyncio.run(_test_unconfigured_service_rejects_by_default()) + + +# --------------------------------------------------------------------------- +# Discovery metadata (RFC 8414 / RFC 9728) +# --------------------------------------------------------------------------- + +def test_discovery_document_has_required_fields(): + print("Testing RFC 8414 discovery document has all required fields...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read", "write"], + token_introspection_endpoint="https://idp.example.com/introspect", + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com", + ) + metadata = build_authorization_server_metadata(service) + + required_fields = { + "issuer", + "authorization_endpoint", + "token_endpoint", + "introspection_endpoint", + "jwks_uri", + "response_types_supported", + "grant_types_supported", + "subject_types_supported", + "code_challenge_methods_supported", + } + missing = required_fields - set(metadata.keys()) + assert not missing, f"RFC 8414 document is missing required fields: {missing}" + assert metadata["code_challenge_methods_supported"] == ["S256"] + assert "registration_endpoint" not in metadata # not passed in this call + print("Success! All RFC 8414 required fields present.") + + +def test_discovery_document_includes_registration_endpoint_when_given(): + print("Testing RFC 8414 document includes registration_endpoint when DCR is enabled...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + metadata = build_authorization_server_metadata(service, registration_endpoint="/oauth/v2/register") + assert metadata["registration_endpoint"] == "/oauth/v2/register" + print("Success! registration_endpoint included when supplied.") + + +def test_protected_resource_metadata_shape(): + print("Testing RFC 9728 protected-resource metadata shape...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read", "write"], + ) + metadata = build_protected_resource_metadata(service) + assert metadata == { + "resource": "https://api.example.com", + "authorization_servers": ["https://idp.example.com"], + "scopes_supported": ["read", "write"], + } + print("Success! RFC 9728 document matches expected shape.") + + +# --------------------------------------------------------------------------- +# Dynamic Client Registration (RFC 7591) -- simplified/static variant +# --------------------------------------------------------------------------- + +def test_client_registration_disabled_by_default(): + print("Testing Dynamic Client Registration is disabled by default...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + assert is_client_registration_enabled(service) is False + print("Success! DCR is disabled unless explicitly opted into.") + + +def test_client_registration_requires_both_flag_and_client_id(): + print("Testing DCR requires BOTH the opt-in flag AND a configured client id...") + flag_only = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + enable_client_registration=True, + # no static_client_id + ) + assert is_client_registration_enabled(flag_only) is False, "flag alone must not be enough" + + client_id_only = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + enable_client_registration=False, + static_client_id="configured-client", + ) + assert is_client_registration_enabled(client_id_only) is False, "client id alone must not be enough" + + both = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + enable_client_registration=True, + static_client_id="configured-client", + ) + assert is_client_registration_enabled(both) is True + print("Success! DCR requires both conditions, matching the TypeScript SDK.") + + +def test_client_registration_response_shape(): + print("Testing DCR response returns the operator's static credentials...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + enable_client_registration=True, + static_client_id="configured-client", + static_client_secret="configured-secret", + ) + response = build_registration_response(service, {"redirect_uris": ["myapp://callback"]}) + assert response["client_id"] == "configured-client" + assert response["client_secret"] == "configured-secret" + assert response["client_secret_expires_at"] == 0 + assert response["token_endpoint_auth_method"] == "client_secret_post" + assert response["redirect_uris"] == ["myapp://callback"] + + # A public client (no secret) must get 'none' as its auth method -- that's + # the OAuth 2.1 signal for "this client authenticates via PKCE, not a secret." + public_service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + enable_client_registration=True, + static_client_id="public-client", + ) + public_response = build_registration_response(public_service, {}) + assert public_response["token_endpoint_auth_method"] == "none" + print("Success! DCR response shape correct for both confidential and public clients.") + + +# --------------------------------------------------------------------------- +# Caching +# --------------------------------------------------------------------------- + +async def _test_jwks_client_is_cached(): + print("Testing the JWKS client is constructed once and reused across calls...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + jwks_uri="https://idp.example.com/.well-known/jwks.json", + ) + with patch("jwt.PyJWKClient") as mock_client_cls: + mock_client_cls.return_value.get_signing_key_from_jwt.side_effect = Exception("not a real token") + await service.introspect_token("token-1") + await service.introspect_token("token-2") + assert mock_client_cls.call_count == 1, ( + f"expected PyJWKClient to be constructed once and cached, got {mock_client_cls.call_count} calls" + ) + print("Success! PyJWKClient constructed once, reused on subsequent calls.") + + +def test_jwks_client_is_cached(): + asyncio.run(_test_jwks_client_is_cached()) + + +async def _test_token_result_is_cached(): + print("Testing a successful introspection result is cached and reused...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + token_introspection_endpoint="https://idp.example.com/introspect", + ) + with patch.object( + service, "_introspect_via_endpoint", + return_value={"active": True, "aud": "https://api.example.com", "sub": "u1"}, + ) as mock_call: + r1 = await service.introspect_token("same-token") + r2 = await service.introspect_token("same-token") + assert r1 == r2 + assert mock_call.call_count == 1, ( + f"expected the underlying introspection call to run once, got {mock_call.call_count}" + ) + print("Success! Token result cached, underlying introspection call only made once.") + + +def test_token_result_is_cached(): + asyncio.run(_test_token_result_is_cached()) + + +def test_token_cache_respects_ttl_expiry(): + print("Testing the token cache expires entries after token_cache_seconds...") + service = OAuthService( + resource_uri="https://api.example.com", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + token_cache_seconds=1, + ) + service._cache_set("t1", {"active": True, "sub": "u1"}) + assert service._cache_get("t1") is not None + with patch("time.monotonic", return_value=time.monotonic() + 2): + assert service._cache_get("t1") is None, "expected the cached entry to expire after its TTL" + print("Success! Cache entries expire after their TTL.") + + if __name__ == "__main__": test_oauth_guard_validation() + test_pkce_round_trip() + test_pkce_rejects_mismatched_verifier() + test_pkce_plain_method() + test_pkce_unknown_method_raises() + test_pkce_verifier_format_boundaries() + test_pkce_support_requires_s256() + test_audience_validation_endpoint_path() + test_audience_validation_list_form() + test_unconfigured_service_rejects_by_default() + test_discovery_document_has_required_fields() + test_discovery_document_includes_registration_endpoint_when_given() + test_protected_resource_metadata_shape() + test_client_registration_disabled_by_default() + test_client_registration_requires_both_flag_and_client_id() + test_client_registration_response_shape() + test_jwks_client_is_cached() + test_token_result_is_cached() + test_token_cache_respects_ttl_expiry() + print("\nAll OAuth tests passed successfully!") From 4b570abf9bdaf3ba5a5a1c2fa08b5550c99acdb1 Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 21 Aug 2026 17:17:28 +0530 Subject: [PATCH 2/2] Resolve the introspection endpoint env var and document the full OAuth env set The setup docs (OAUTH_SETUP.md, the CLI's generated guide, and the flight booking example) all tell users to set OAUTH_INTROSPECTION_ENDPOINT, but the generated app modules read INTROSPECTION_ENDPOINT. Following the documentation therefore left token_introspection_endpoint as None, so introspection was never configured. Before the mock-fallback removal that silently accepted every token; after it, every token is rejected -- so the same latent bug now presents as auth being broken rather than absent. OAuthService now resolves the endpoint from either spelling, preferring OAUTH_INTROSPECTION_ENDPOINT, and also falls back to INTROSPECTION_CLIENT_ID / INTROSPECTION_CLIENT_SECRET. Resolving it in the service rather than in each template fixes every caller at once, including app modules already written against either name. Explicit constructor arguments still win over the environment. The TypeScript SDK reads both variables too. Expands the OAuth setup guide's .env block into a complete reference covering every variable the SDK actually reads: the OAUTH_REQUIRED enforcement gate, server identity, both verification methods, claim validation, the RFC 7591 registration settings, and the cache/discovery-port tuning knobs. Several of these were supported but undocumented. Three regression tests; two of them fail without this change. --- nitrostack/auth/oauth.py | 24 ++++- nitrostack/cli/main.py | 47 ++++++++-- .../templates/flight-booking/OAUTH_SETUP.md | 45 +++++++-- tests/test_oauth.py | 94 +++++++++++++++++++ 4 files changed, 194 insertions(+), 16 deletions(-) diff --git a/nitrostack/auth/oauth.py b/nitrostack/auth/oauth.py index b6d82d1..54cabfc 100644 --- a/nitrostack/auth/oauth.py +++ b/nitrostack/auth/oauth.py @@ -63,11 +63,29 @@ def __init__( self.resource_uri = resource_uri self.authorization_servers = authorization_servers self.scopes_supported = scopes_supported - self.token_introspection_endpoint = token_introspection_endpoint - self.token_introspection_client_id = token_introspection_client_id - self.token_introspection_client_secret = token_introspection_client_secret self.discovery_port = discovery_port + # Introspection settings fall back to the environment when not passed + # explicitly. Both spellings of the endpoint variable are accepted: the + # setup docs (OAUTH_SETUP.md, the CLI's generated guide, and the flight + # booking example) all document `OAUTH_INTROSPECTION_ENDPOINT`, while the + # generated app modules read `INTROSPECTION_ENDPOINT` -- so following the + # documentation used to leave introspection silently unconfigured. + # Resolving both here fixes it for every caller at once, including app + # modules already written against either name. The TypeScript SDK reads + # both variables too. + self.token_introspection_endpoint = ( + token_introspection_endpoint + or os.environ.get("OAUTH_INTROSPECTION_ENDPOINT") + or os.environ.get("INTROSPECTION_ENDPOINT") + ) + self.token_introspection_client_id = ( + token_introspection_client_id or os.environ.get("INTROSPECTION_CLIENT_ID") + ) + self.token_introspection_client_secret = ( + token_introspection_client_secret or os.environ.get("INTROSPECTION_CLIENT_SECRET") + ) + # Environmental fallbacks self.jwks_uri = jwks_uri or os.environ.get("JWKS_URI") self.audience = audience or os.environ.get("TOKEN_AUDIENCE") or resource_uri diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1c6f9db..29ff74f 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -784,13 +784,46 @@ async def booking_guide(self, context: ExecutionContext) -> str: Add the following environment variables to your `.env` file to configure resource protection: ```env -# Introspection endpoint to validate access tokens -OAUTH_INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect - -# Or use JWKS (JSON Web Key Sets) to cryptographically verify signatures locally -# JWKS_URI=http://localhost:3000/oauth/jwks -# TOKEN_AUDIENCE=https://mcplocal -# TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com +# --- Enforcement gate ------------------------------------------------------- +# Unset / false (default): tokens are NOT enforced. Studio and Inspector can +# call tools without authenticating, against mock data. Best for local dev. +# true: Bearer tokens are enforced. If no verifier (JWKS_URI or an +# introspection endpoint) is configured, the server still starts but rejects +# every protected request -- fail closed, never fail open. +OAUTH_REQUIRED=true + +# --- Server identity -------------------------------------------------------- +RESOURCE_URI=http://localhost:3000/mcp +AUTH_SERVER_URL=https://your-tenant.us.auth0.com + +# --- Token verification: pick ONE of the two --------------------------------- +# 1) JWKS -- verifies signatures locally, no network call per request. +JWKS_URI=https://your-tenant.us.auth0.com/.well-known/jwks.json + +# 2) Or RFC 7662 introspection -- asks the authorization server per token. +# Both spellings are accepted; OAUTH_INTROSPECTION_ENDPOINT wins if both set. +# OAUTH_INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect +# INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect +# INTROSPECTION_CLIENT_ID=your-introspection-client-id +# INTROSPECTION_CLIENT_SECRET=your-introspection-client-secret + +# --- Token claim validation -------------------------------------------------- +# Audience must match, or the token is rejected (RFC 8707). Defaults to RESOURCE_URI. +TOKEN_AUDIENCE=http://localhost:3000/mcp +TOKEN_ISSUER=https://your-tenant.us.auth0.com/ + +# --- Dynamic Client Registration (RFC 7591, optional, off by default) -------- +# Serves only the statically configured client below. Requires BOTH the flag +# and OAUTH_CLIENT_ID -- without a client id it stays disabled. +# OAUTH_ENABLE_CLIENT_REGISTRATION=true +# OAUTH_CLIENT_ID=your-client-id +# OAUTH_CLIENT_SECRET=your-client-secret + +# --- Tuning ------------------------------------------------------------------ +# Seconds to cache a successful introspection result (default 300; 0 disables). +# OAUTH_TOKEN_CACHE_SECONDS=300 +# Port for the .well-known discovery server (default 3005). +# OAUTH_DISCOVERY_PORT=3005 ``` ## 2. Protected Routes diff --git a/nitrostack/templates/flight-booking/OAUTH_SETUP.md b/nitrostack/templates/flight-booking/OAUTH_SETUP.md index cbfcf66..1b6ed26 100644 --- a/nitrostack/templates/flight-booking/OAUTH_SETUP.md +++ b/nitrostack/templates/flight-booking/OAUTH_SETUP.md @@ -12,13 +12,46 @@ To run your flight booking MCP server with OAuth 2.1 protection, you need to con Add the following environment variables to your `.env` file to configure resource protection: ```env -# Introspection endpoint to validate access tokens -OAUTH_INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect +# --- Enforcement gate ------------------------------------------------------- +# Unset / false (default): tokens are NOT enforced. Studio and Inspector can +# call tools without authenticating, against mock data. Best for local dev. +# true: Bearer tokens are enforced. If no verifier (JWKS_URI or an +# introspection endpoint) is configured, the server still starts but rejects +# every protected request -- fail closed, never fail open. +OAUTH_REQUIRED=true -# Or use JWKS (JSON Web Key Sets) to cryptographically verify signatures locally -# JWKS_URI=http://localhost:3000/oauth/jwks -# TOKEN_AUDIENCE=https://mcplocal -# TOKEN_ISSUER=https://dev-5dt0utuk315713tjm.us.auth0.com +# --- Server identity -------------------------------------------------------- +RESOURCE_URI=http://localhost:3000/mcp +AUTH_SERVER_URL=https://your-tenant.us.auth0.com + +# --- Token verification: pick ONE of the two --------------------------------- +# 1) JWKS -- verifies signatures locally, no network call per request. +JWKS_URI=https://your-tenant.us.auth0.com/.well-known/jwks.json + +# 2) Or RFC 7662 introspection -- asks the authorization server per token. +# Both spellings are accepted; OAUTH_INTROSPECTION_ENDPOINT wins if both set. +# OAUTH_INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect +# INTROSPECTION_ENDPOINT=http://localhost:3000/oauth/introspect +# INTROSPECTION_CLIENT_ID=your-introspection-client-id +# INTROSPECTION_CLIENT_SECRET=your-introspection-client-secret + +# --- Token claim validation -------------------------------------------------- +# Audience must match, or the token is rejected (RFC 8707). Defaults to RESOURCE_URI. +TOKEN_AUDIENCE=http://localhost:3000/mcp +TOKEN_ISSUER=https://your-tenant.us.auth0.com/ + +# --- Dynamic Client Registration (RFC 7591, optional, off by default) -------- +# Serves only the statically configured client below. Requires BOTH the flag +# and OAUTH_CLIENT_ID -- without a client id it stays disabled. +# OAUTH_ENABLE_CLIENT_REGISTRATION=true +# OAUTH_CLIENT_ID=your-client-id +# OAUTH_CLIENT_SECRET=your-client-secret + +# --- Tuning ------------------------------------------------------------------ +# Seconds to cache a successful introspection result (default 300; 0 disables). +# OAUTH_TOKEN_CACHE_SECONDS=300 +# Port for the .well-known discovery server (default 3005). +# OAUTH_DISCOVERY_PORT=3005 ``` ## 2. Protected Routes diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 2134141..f8b8112 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -481,6 +481,97 @@ def test_token_cache_respects_ttl_expiry(): print("Success! Cache entries expire after their TTL.") + +# --------------------------------------------------------------------------- +# Introspection endpoint env-var resolution +# --------------------------------------------------------------------------- + +def _clear_introspection_env(): + for key in ( + "OAUTH_INTROSPECTION_ENDPOINT", + "INTROSPECTION_ENDPOINT", + "INTROSPECTION_CLIENT_ID", + "INTROSPECTION_CLIENT_SECRET", + "JWKS_URI", + ): + os.environ.pop(key, None) + + +def _service(): + return OAuthService( + resource_uri="http://localhost:3000/mcp", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + ) + + +def test_documented_introspection_env_var_is_honored(): + """OAUTH_SETUP.md, the CLI setup guide, and the flight-booking example all + document `OAUTH_INTROSPECTION_ENDPOINT`, but the generated app modules read + `INTROSPECTION_ENDPOINT`. Following the docs used to leave introspection + silently unconfigured, which after the mock-fallback removal means every + token is rejected. Both spellings must resolve.""" + print("Testing OAUTH_INTROSPECTION_ENDPOINT (the documented name) is honored...") + saved = dict(os.environ) + try: + _clear_introspection_env() + os.environ["OAUTH_INTROSPECTION_ENDPOINT"] = "https://idp.example.com/introspect" + assert _service().token_introspection_endpoint == "https://idp.example.com/introspect" + + _clear_introspection_env() + os.environ["INTROSPECTION_ENDPOINT"] = "https://legacy.example.com/introspect" + assert _service().token_introspection_endpoint == "https://legacy.example.com/introspect" + + # Both set: the OAUTH_-prefixed name wins. + _clear_introspection_env() + os.environ["OAUTH_INTROSPECTION_ENDPOINT"] = "https://a.example.com/i" + os.environ["INTROSPECTION_ENDPOINT"] = "https://b.example.com/i" + assert _service().token_introspection_endpoint == "https://a.example.com/i" + + # Neither set: stays None, so the service has no verifier and rejects. + _clear_introspection_env() + assert _service().token_introspection_endpoint is None + finally: + os.environ.clear() + os.environ.update(saved) + print("Success! Both introspection env var spellings resolve.") + + +def test_explicit_introspection_arg_beats_env(): + print("Testing an explicit introspection endpoint argument overrides the environment...") + saved = dict(os.environ) + try: + _clear_introspection_env() + os.environ["OAUTH_INTROSPECTION_ENDPOINT"] = "https://env.example.com/i" + service = OAuthService( + resource_uri="http://localhost:3000/mcp", + authorization_servers=["https://idp.example.com"], + scopes_supported=["read"], + token_introspection_endpoint="https://explicit.example.com/i", + ) + assert service.token_introspection_endpoint == "https://explicit.example.com/i" + finally: + os.environ.clear() + os.environ.update(saved) + print("Success! Explicit configuration takes precedence over the environment.") + + +def test_introspection_client_credentials_resolve_from_env(): + print("Testing introspection client credentials fall back to the environment...") + saved = dict(os.environ) + try: + _clear_introspection_env() + os.environ["INTROSPECTION_CLIENT_ID"] = "client-abc" + os.environ["INTROSPECTION_CLIENT_SECRET"] = "secret-xyz" + service = _service() + assert service.token_introspection_client_id == "client-abc" + assert service.token_introspection_client_secret == "secret-xyz" + finally: + os.environ.clear() + os.environ.update(saved) + print("Success! Introspection client credentials resolve from the environment.") + + if __name__ == "__main__": test_oauth_guard_validation() test_pkce_round_trip() @@ -501,4 +592,7 @@ def test_token_cache_respects_ttl_expiry(): test_jwks_client_is_cached() test_token_result_is_cached() test_token_cache_respects_ttl_expiry() + test_documented_introspection_env_var_is_honored() + test_explicit_introspection_arg_beats_env() + test_introspection_client_credentials_resolve_from_env() print("\nAll OAuth tests passed successfully!")