Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Python SDK Test Suite

on:
push:
branches: [ main, master ]
branches: [ main, master, develop ]
pull_request:
branches: [ main, master ]
branches: [ main, master, develop ]

jobs:
test:
Expand All @@ -14,28 +14,17 @@ jobs:
python-version: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
pip install -e .
pip install -e ".[dev]"

- name: Run Integration Tests
run: |
python tests/test_basic.py
python tests/test_tasks.py
python tests/test_initial_tool.py
python tests/test_oauth.py
python tests/test_production.py
python tests/test_widget_metadata.py
python tests/test_transports.py
python tests/test_cli.py
pytest tests/test_cli.py -v
python tests/test_tool_input_schema.py
- name: Run pytest with coverage
run: pytest tests --cov=nitrostack --cov-report=term-missing
36 changes: 36 additions & 0 deletions nitrostack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
TaskAlreadyTerminalError,
InvalidTaskTransitionError,
TaskExpiredError,
ConfigurationError,
DependencyResolutionError,
OAuthError,
TokenInactiveError,
AudienceMismatchError,
)
from nitrostack.core.pipeline import (
use_guards,
Expand Down Expand Up @@ -77,6 +82,21 @@
from nitrostack.auth.oauth import (
OAuthModule,
OAuthService,
generate_www_authenticate_header,
)
from nitrostack.auth.pkce import (
generate_code_challenge,
generate_code_verifier,
generate_pkce_params,
is_valid_code_verifier,
validate_pkce_support,
verify_pkce,
)
from nitrostack.auth.scopes import (
has_all_scopes,
has_any_scope,
has_scope,
require_scopes,
)
from nitrostack.auth.config import (
ConfigModule,
Expand Down Expand Up @@ -144,6 +164,22 @@
"JWTModule",
"OAuthModule",
"OAuthService",
"generate_www_authenticate_header",
"generate_code_challenge",
"generate_code_verifier",
"generate_pkce_params",
"is_valid_code_verifier",
"validate_pkce_support",
"verify_pkce",
"has_all_scopes",
"has_any_scope",
"has_scope",
"require_scopes",
"ConfigurationError",
"DependencyResolutionError",
"OAuthError",
"TokenInactiveError",
"AudienceMismatchError",
"ConfigModule",
"ConfigService",
"NitroTestingModule",
Expand Down
75 changes: 70 additions & 5 deletions nitrostack/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
build_registration_response,
is_client_registration_enabled,
)
from nitrostack.core.errors import AudienceMismatchError, ConfigurationError, TokenInactiveError


def is_oauth_required() -> bool:
Expand All @@ -29,6 +30,25 @@ def is_oauth_required() -> bool:
_oauth_fail_open_warned = False


def generate_www_authenticate_header(
*,
realm: str = "mcp",
resource_metadata: Optional[str] = None,
error: Optional[str] = None,
error_description: Optional[str] = None,
) -> str:
"""RFC 6750 / RFC 9728 WWW-Authenticate value for protected MCP resources."""
parts = [f'Bearer realm="{realm}"']
if resource_metadata:
parts.append(f'resource_metadata="{resource_metadata}"')
if error:
parts.append(f'error="{error}"')
if error_description:
escaped = error_description.replace('"', "'")
parts.append(f'error_description="{escaped}"')
return ", ".join(parts)


def warn_if_oauth_fail_open() -> None:
"""Loud warning when OAuth is wired but tokens are not enforced."""
global _oauth_fail_open_warned
Expand Down Expand Up @@ -125,16 +145,35 @@ def start_discovery_server(self) -> None:
registration_path = "/oauth/v2/register"

def _write_json(handler: BaseHTTPRequestHandler, status: int, payload: Dict[str, Any]) -> None:
raw = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(raw)))
handler.send_header("Connection", "close")
handler.end_headers()
handler.wfile.write(raw)

def _write_empty(handler: BaseHTTPRequestHandler, status: int) -> None:
handler.send_response(status)
handler.send_header("Content-Length", "0")
handler.send_header("Connection", "close")
handler.end_headers()
handler.wfile.write(json.dumps(payload).encode("utf-8"))

class DiscoveryHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Suppress server logging to stdout/stderr to keep stdio clean
pass

def do_OPTIONS(self):
# TS discovery handlers honor CORS preflight without leaking credentials.
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.send_header("Content-Length", "0")
self.send_header("Connection", "close")
self.end_headers()

def do_GET(self):
if self.path == "/.well-known/oauth-protected-resource":
_write_json(self, 200, build_protected_resource_metadata(service_instance))
Expand All @@ -148,13 +187,11 @@ def do_GET(self):
build_authorization_server_metadata(service_instance, registration_endpoint),
)
else:
self.send_response(404)
self.end_headers()
_write_empty(self, 404)

def do_POST(self):
if self.path != registration_path:
self.send_response(404)
self.end_headers()
_write_empty(self, 404)
return

if not is_client_registration_enabled(service_instance):
Expand Down Expand Up @@ -197,6 +234,28 @@ def run_server():
self._thread = threading.Thread(target=run_server, daemon=True)
self._thread.start()

def _audience_ok(self, token_info: Dict[str, Any]) -> bool:
"""RFC 8707 resource indicator / JWT ``aud`` must match configured audience."""
expected = self.audience
if not expected:
return True
actual = token_info.get("aud", token_info.get("resource"))
if actual is None:
return False
if isinstance(actual, list):
return expected in actual
return actual == expected

def raise_if_invalid(self, token_info: Dict[str, Any]) -> Dict[str, Any]:
"""Raise ``TokenInactiveError`` / ``AudienceMismatchError`` for filter tests."""
if token_info.get("error") == "audience_mismatch" or (
token_info.get("active") and not self._validate_audience(token_info)
):
raise AudienceMismatchError(self.audience, token_info.get("aud") or token_info.get("resource"))
if not token_info.get("active"):
raise TokenInactiveError()
return token_info

def stop_discovery_server(self) -> None:
if self._server:
self._server.shutdown()
Expand Down Expand Up @@ -379,6 +438,12 @@ def for_root(
static_client_id: Optional[str] = None,
static_client_secret: Optional[str] = None,
):
if not resource_uri or not str(resource_uri).strip():
raise ConfigurationError("OAuthModule.for_root requires resource_uri")
if not authorization_servers:
raise ConfigurationError(
"OAuthModule.for_root requires at least one authorization server"
)
service = OAuthService(
resource_uri=resource_uri,
authorization_servers=authorization_servers,
Expand Down
62 changes: 62 additions & 0 deletions nitrostack/auth/scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Scope helpers for AuthContext (TS ``hasScope`` / ``@RequireScopes``)."""
from __future__ import annotations

from functools import wraps
from typing import Any, Callable, Iterable, Sequence

from nitrostack.core.context import AuthContext, ExecutionContext


def _scope_list(auth: AuthContext | None) -> list:
if auth is None:
return []
scopes = getattr(auth, "scopes", None) or []
if isinstance(scopes, str):
return [part for part in scopes.split() if part]
return list(scopes)


def has_scope(auth: AuthContext | None, scope: str) -> bool:
return scope in _scope_list(auth)


def has_any_scope(auth: AuthContext | None, scopes: Iterable[str]) -> bool:
have = set(_scope_list(auth))
return any(scope in have for scope in scopes)


def has_all_scopes(auth: AuthContext | None, scopes: Iterable[str]) -> bool:
have = set(_scope_list(auth))
return all(scope in have for scope in scopes)


def require_scopes(*needed: str) -> Callable:
"""Reject the handler when ``context.auth`` is missing any of ``needed``."""

def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any):
context = kwargs.get("context")
if context is None:
for arg in args:
if isinstance(arg, ExecutionContext):
context = arg
break
auth = getattr(context, "auth", None) if context is not None else None
if auth is None:
raise PermissionError("Not authenticated")
if needed and not has_all_scopes(auth, needed):
raise PermissionError(f"Missing required scopes: {', '.join(needed)}")
return await func(*args, **kwargs)

return wrapper

return decorator


def scopes_from_sequence(values: Sequence[str] | str | None) -> list:
if values is None:
return []
if isinstance(values, str):
return [part for part in values.split() if part]
return list(values)
Loading
Loading