diff --git a/nitrostack/auth/oauth.py b/nitrostack/auth/oauth.py index 4b5f42a..54cabfc 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, +) def is_oauth_required() -> bool: @@ -48,20 +55,64 @@ 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 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 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 @@ -71,6 +122,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): @@ -79,30 +137,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)) @@ -136,48 +207,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} + + 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 - # Perform HTTP POST request + 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 @@ -186,22 +289,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 @@ -217,6 +374,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, @@ -228,7 +389,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) warn_if_oauth_fail_open() 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/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/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 ae499ae..337fa51 100644 --- a/nitrostack/core/pipeline.py +++ b/nitrostack/core/pipeline.py @@ -166,6 +166,12 @@ async def can_activate(self, context: ExecutionContext) -> bool: return True return False + # 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 [], @@ -173,6 +179,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/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 b6d9ee0..f8b8112 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...") @@ -69,10 +85,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 (auth optional):", res_invalid) @@ -100,6 +116,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 } @@ -108,18 +125,474 @@ 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.") + + + +# --------------------------------------------------------------------------- +# 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() + 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() + 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!")