fix(auth): harden OAuth/session for hosted multi-tenant deployment (#626)
Pre-launch hardening for the hosted Astrolabe Cloud offering. Addresses all five findings raised in #626 (Tim Kaufmann, code review of v0.65.0). Re-verified against master before fixing. Finding 3 (LLM-controllable user_id) — drop user_id from the public signatures of provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status, check_logged_in. Tool wrappers now always derive identity from the verified AccessToken; user_id is no longer accepted as MCP input. Adds parameterized CI-guard test that locks the schema. Finding 2 (predictable session cookie) — replace mcp_session=<user_id> cookie with a cryptographically random session_id mapped server-side (new browser_sessions table, alembic 005). Cookie value is opaque, expires, revocable. SessionAuthBackend looks up user_id via the new mapping and additionally requires a refresh token to fail closed. Finding 4 (logout doesn't revoke refresh token) — oauth_logout now calls the IdP revocation_endpoint (RFC 7009) when advertised, deletes the stored refresh token regardless, and clears the browser_sessions row. Cleanup is best-effort: logout always 302s. Finding 1 (unverified ID token decodes) — verify_id_token helper does JWKS signature + issuer + audience + exp + nonce checks per OIDC core 3.1.3.7. Used by both OAuth callback handlers (browser + MCP). Removes the four "verify_signature: False" decodes that previously trusted IdP claims unconditionally. Drops dead-code _validate_token_audience in token_broker. Refactors token_utils + provisioning_decorator to read user_id from the verified AccessToken instead of re-decoding the JWT. Finding 5 (hardcoded Fernet keys in docker-compose.yml) — replace the three inline TOKEN_ENCRYPTION_KEY values with required env var interpolation; document in env.sample. Test coverage: 4 new unit test modules (signature pinning, browser sessions, ID-token verification, logout + revoke + session backend). 693 unit tests pass; ruff/format/ty clean. Migration note: existing browser admin-UI sessions become invalid on rollout (cookies are looked up against the new browser_sessions table, which starts empty). Users re-login. MCP API access is unaffected. Tracked on Astrolabe Cloud POC board card #37. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
83f2e88d2c
commit
15dbb26349
@@ -5,81 +5,155 @@ between server/ and auth/ layers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKSet
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"""Extract user_id from the MCP access token (Flow 1).
|
||||
class IdTokenVerificationError(Exception):
|
||||
"""Raised when an OIDC ID token fails signature or claim verification."""
|
||||
|
||||
Handles both JWT and opaque tokens:
|
||||
- JWT: Decode and extract 'sub' claim
|
||||
- Opaque: Call userinfo endpoint to get 'sub'
|
||||
|
||||
async def verify_id_token(
|
||||
id_token: str,
|
||||
*,
|
||||
discovery_url: str,
|
||||
expected_audience: str,
|
||||
expected_nonce: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify an OIDC ID token's signature and standard claims.
|
||||
|
||||
Implements the verification steps required by OIDC core spec section
|
||||
3.1.3.7 (ID Token Validation) for the authorization-code flow:
|
||||
- Signature against JWKS (RS256)
|
||||
- Issuer matches the OP that issued the token
|
||||
- Audience contains the expected client_id
|
||||
- Token is not expired (`exp`)
|
||||
- `iat` is well-formed (PyJWT default)
|
||||
- `nonce` matches when one was included in the auth request
|
||||
|
||||
Replaces the prior `jwt.decode(id_token, options={"verify_signature": False})`
|
||||
pattern (issue #626 finding 1) on the OAuth callback paths.
|
||||
|
||||
Args:
|
||||
ctx: MCP context with access token
|
||||
id_token: Raw ID token (JWT) string.
|
||||
discovery_url: OIDC `.well-known/openid-configuration` URL of the IdP.
|
||||
expected_audience: The MCP-server-side OAuth client_id used for this
|
||||
authorization request.
|
||||
expected_nonce: When the auth request included a nonce, the same value
|
||||
so it can be checked here. None disables the nonce check (callers
|
||||
that didn't bind a nonce in the auth request).
|
||||
|
||||
Returns:
|
||||
user_id extracted from token, or "default_user" as fallback
|
||||
Decoded, verified ID-token claims.
|
||||
|
||||
Raises:
|
||||
IdTokenVerificationError: On any verification failure.
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
if not id_token:
|
||||
raise IdTokenVerificationError("ID token missing from token response")
|
||||
|
||||
if not access_token or not access_token.token:
|
||||
logger.warning(" ✗ No access token found via get_access_token()")
|
||||
return "default_user"
|
||||
|
||||
token = access_token.token
|
||||
is_jwt = "." in token and token.count(".") >= 2
|
||||
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
||||
|
||||
# Try JWT decode first
|
||||
if is_jwt:
|
||||
try:
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Opaque token - call userinfo endpoint
|
||||
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
||||
try:
|
||||
# Get userinfo endpoint from OIDC discovery
|
||||
oidc_discovery_uri = os.getenv(
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response = await http_client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
||||
if userinfo:
|
||||
user_id = userinfo.get("sub", "unknown")
|
||||
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
||||
return user_id
|
||||
else:
|
||||
logger.error(" ✗ Userinfo query failed")
|
||||
else:
|
||||
logger.error(" ✗ No userinfo_endpoint available")
|
||||
issuer = discovery.get("issuer")
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
if not issuer or not jwks_uri:
|
||||
raise IdTokenVerificationError(
|
||||
"OIDC discovery response missing issuer or jwks_uri"
|
||||
)
|
||||
|
||||
jwks_response = await http_client.get(jwks_uri)
|
||||
jwks_response.raise_for_status()
|
||||
jwks_data = jwks_response.json()
|
||||
except IdTokenVerificationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
||||
raise IdTokenVerificationError(
|
||||
f"Failed to fetch OIDC discovery / JWKS: {e}"
|
||||
) from e
|
||||
|
||||
# Fallback
|
||||
logger.warning(" Using fallback user_id: default_user")
|
||||
return "default_user"
|
||||
try:
|
||||
jwks = PyJWKSet.from_dict(jwks_data)
|
||||
unverified_header = jwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get("kid")
|
||||
if not kid:
|
||||
raise IdTokenVerificationError("ID token header missing 'kid'")
|
||||
try:
|
||||
signing_key = jwks[kid]
|
||||
except KeyError as e:
|
||||
raise IdTokenVerificationError(
|
||||
f"No JWKS key matches ID token kid {kid!r}"
|
||||
) from e
|
||||
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=expected_audience,
|
||||
issuer=issuer,
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_aud": True,
|
||||
"verify_iss": True,
|
||||
"require": ["sub", "iss", "aud", "exp", "iat"],
|
||||
},
|
||||
)
|
||||
except IdTokenVerificationError:
|
||||
raise
|
||||
except jwt.PyJWTError as e:
|
||||
raise IdTokenVerificationError(f"ID token verification failed: {e}") from e
|
||||
except Exception as e:
|
||||
raise IdTokenVerificationError(
|
||||
f"Unexpected error verifying ID token: {e}"
|
||||
) from e
|
||||
|
||||
if expected_nonce is not None and payload.get("nonce") != expected_nonce:
|
||||
raise IdTokenVerificationError("ID token nonce does not match request nonce")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"""Extract user_id from the verified MCP access token.
|
||||
|
||||
Reads the `sub` claim from `AccessToken.resource`, which is populated by
|
||||
`UnifiedTokenVerifier` after JWT signature verification (or token
|
||||
introspection for opaque tokens). We never re-decode the raw token here:
|
||||
the verifier has already validated the signature and extracted the
|
||||
identity claim.
|
||||
|
||||
Args:
|
||||
ctx: MCP context with access token (unused — kept for the public API)
|
||||
|
||||
Returns:
|
||||
user_id from the verified token, or "default_user" when no token is
|
||||
present (e.g. BasicAuth mode where this should not be called).
|
||||
"""
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if not access_token:
|
||||
logger.warning("No access token found via get_access_token()")
|
||||
return "default_user"
|
||||
|
||||
user_id = access_token.resource
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"Access token has no resource (sub) claim — verifier should have rejected it"
|
||||
)
|
||||
return "default_user"
|
||||
|
||||
return user_id
|
||||
|
||||
Reference in New Issue
Block a user