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
299 changes: 232 additions & 67 deletions nitrostack/auth/oauth.py

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions nitrostack/auth/oauth_module.py
Original file line number Diff line number Diff line change
@@ -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 [],
}
109 changes: 109 additions & 0 deletions nitrostack/auth/pkce.py
Original file line number Diff line number Diff line change
@@ -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
47 changes: 40 additions & 7 deletions nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions nitrostack/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions nitrostack/core/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,20 @@ 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 [],
client_id=token_info.get("client_id"),
exp=token_info.get("exp"),
iat=token_info.get("iat"),
iss=token_info.get("iss"),
aud=aud,
claims=token_info,
token_payload=token_info
)
Expand Down
45 changes: 39 additions & 6 deletions nitrostack/templates/flight-booking/OAUTH_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading