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:
Chris Coutinho
2026-05-02 17:03:57 +02:00
co-authored by Claude Opus 4.7
parent 83f2e88d2c
commit 15dbb26349
16 changed files with 1184 additions and 242 deletions
+10 -10
View File
@@ -153,10 +153,10 @@ services:
- ENABLE_MULTI_USER_BASIC_AUTH=true
- ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=fqqI4G51yBCOcu9cvv6wCUJB7sf_CK2za5ClC6b86yY=
# Token storage (required for middleware initialization).
# Source the key from .env — see env.sample. To generate a fresh key:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY:?TOKEN_ENCRYPTION_KEY must be set in .env (see env.sample)}
- TOKEN_STORAGE_DB=/app/data/tokens.db
- ENABLE_SEMANTIC_SEARCH=true
@@ -230,9 +230,9 @@ services:
- NEXTCLOUD_RESOURCE_URI=nextcloud # ADR-005: Keycloak uses client IDs as audiences, not URLs
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
# Refresh token storage (ADR-002 Tier 1 & 2). Source from .env.
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY:?TOKEN_ENCRYPTION_KEY must be set in .env (see env.sample)}
- TOKEN_STORAGE_DB=/app/data/tokens.db
# ADR-005: Token exchange mode (RFC 8693)
@@ -278,10 +278,10 @@ services:
# Login Flow v2 (ADR-022)
- ENABLE_LOGIN_FLOW=true
# Token storage (required for app password + session persistence)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=rxJvkBf7ZBjZZDL4a1sSqjhmjawhmbRMSOGfK8HDyKU=
# Token storage (required for app password + session persistence).
# Source the key from .env — see env.sample. To generate a fresh key:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY:?TOKEN_ENCRYPTION_KEY must be set in .env (see env.sample)}
- TOKEN_STORAGE_DB=/app/data/tokens.db
# Semantic search
+9
View File
@@ -15,6 +15,15 @@
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=
# Fernet key for encrypting refresh tokens / app passwords / browser
# sessions in SQLite. Required by every docker-compose profile that runs
# the MCP server (single-user, multi-user-basic, keycloak, login-flow).
# Generate one with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# NEVER commit a real key. Each environment (dev / staging / prod) needs
# its own key.
TOKEN_ENCRYPTION_KEY=
# ============================================
# SINGLE-USER BASICAUTH MODE
# ============================================
@@ -0,0 +1,49 @@
"""Add browser_sessions table for random-id browser cookie auth.
Replaces the prior `mcp_session=<user_id>` cookie pattern (issue #626
finding 2) with a server-side mapping from a cryptographically random
session id to the authenticated user_id. The cookie value is now opaque
and revocable.
Revision ID: 005
Revises: 004
Create Date: 2026-05-02 15:00:00.000000
"""
from alembic import op
revision = "005"
down_revision = "004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS browser_sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_browser_sessions_user
ON browser_sessions(user_id)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_browser_sessions_expires
ON browser_sessions(expires_at)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_browser_sessions_expires")
op.execute("DROP INDEX IF EXISTS idx_browser_sessions_user")
op.execute("DROP TABLE IF EXISTS browser_sessions")
+133 -23
View File
@@ -10,14 +10,18 @@ import os
import secrets
import time
from base64 import urlsafe_b64encode
from html import escape as html_escape
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
import httpx
import jwt
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
)
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_userinfo_endpoint,
_query_idp_userinfo,
@@ -383,16 +387,44 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
logger.info(f"Refresh token present: {refresh_token is not None}")
logger.info(f"ID token present: {id_token is not None}")
# Decode ID token to get user info
# Resolve the discovery URL + audience used for THIS auth request so
# we can verify the ID token signature + claims (issue #626 finding 1).
if oauth_client:
# External IdP path
verification_audience = oauth_client.client_id
verification_discovery_url = getattr(oauth_client, "discovery_url", None)
else:
# Integrated Nextcloud OIDC path
verification_audience = oauth_config["client_id"]
verification_discovery_url = oauth_config.get("discovery_url")
if not verification_discovery_url:
logger.error("Cannot verify ID token: no discovery_url available")
return HTMLResponse(
"<h1>Login Failed</h1><p>OIDC discovery URL not configured</p>",
status_code=500,
)
try:
userinfo = jwt.decode(id_token, options={"verify_signature": False})
user_id = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info(f"Browser login successful: {username} (sub={user_id})")
except Exception as e:
logger.warning(f"Failed to decode ID token: {e}")
user_id = f"user-{secrets.token_hex(8)}"
username = "unknown"
userinfo = await verify_id_token(
id_token,
discovery_url=verification_discovery_url,
expected_audience=verification_audience,
)
except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e)
# html_escape: defense-in-depth. The exception text is currently
# server-constructed, but escape on the success path too so any
# future error wrapping that includes IdP response text can't
# smuggle markup into the login-failure page.
return HTMLResponse(
f"<h1>Login Failed</h1><p>ID token failed verification: {html_escape(str(e))}</p>",
status_code=400,
)
user_id = userinfo["sub"]
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info("Browser login successful: %s (sub=%s)", username, user_id)
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
@@ -455,40 +487,118 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
logger.error(f"Error caching user profile: {e}")
# Continue anyway - profile cache is optional for browser UI
# Create response and set session cookie
# Redirect to stored next_url (from OAuth session) or /app as default
# Create a server-side browser session: a random opaque session_id is
# mapped to the verified user_id in `browser_sessions`. The cookie value
# is the session_id (never the raw user_id — see issue #626 finding 2).
session_id = secrets.token_urlsafe(32)
session_ttl = 86400 * 30 # 30 days
await storage.create_browser_session(
session_id=session_id, user_id=user_id, ttl_seconds=session_ttl
)
response = RedirectResponse(next_url, status_code=302)
response.set_cookie(
key="mcp_session",
value=user_id,
max_age=86400 * 30, # 30 days
value=session_id,
max_age=session_ttl,
httponly=True,
secure=_should_use_secure_cookies(),
samesite="lax",
)
logger.info(f"Session cookie set for user: {username}")
logger.info("Session cookie set for user %s (sid=%s…)", username, session_id[:8])
return response
async def oauth_logout(request: Request) -> RedirectResponse:
"""Browser OAuth logout - clears session cookie.
"""Browser OAuth logout — invalidate session and revoke refresh token.
Issue #626 finding 4: prior implementation only cleared the cookie,
leaving the refresh token in storage (valid up to 90 days). This now:
1. Resolves the user_id for the current browser session_id.
2. Calls the IdP `revocation_endpoint` for the stored refresh token
when the IdP advertises one.
3. Deletes the stored refresh token regardless of revocation success.
4. Deletes the browser_sessions row so the cookie is unusable even
if it leaks.
5. Clears the cookie on the response.
Query parameters:
next: Optional URL to redirect to after logout (default: /oauth/login)
Returns:
302 redirect with cleared session cookie
"""
next_url = request.query_params.get("next", "/oauth/login")
session_id = request.cookies.get("mcp_session")
# TODO: Optionally revoke refresh token from storage
# session_id = request.cookies.get("mcp_session")
# if session_id:
# await storage.delete_refresh_token(session_id)
oauth_ctx = getattr(request.app.state, "oauth_context", None)
storage = oauth_ctx.get("storage") if oauth_ctx else None
if session_id and storage and oauth_ctx:
try:
user_id = await storage.get_browser_session_user(session_id)
if user_id:
token_data = await storage.get_refresh_token(user_id)
refresh_token = token_data.get("refresh_token") if token_data else None
if refresh_token:
await _revoke_refresh_token_at_idp(oauth_ctx, refresh_token)
await storage.delete_refresh_token(user_id)
logger.info("Refresh token revoked + deleted for user %s", user_id)
await storage.delete_browser_session(session_id)
except Exception as e:
# Logout must always succeed locally; log and continue.
logger.warning("Logout cleanup failed (continuing): %s", e)
response = RedirectResponse(next_url, status_code=302)
response.delete_cookie("mcp_session")
logger.info("User logged out, session cookie cleared")
return response
async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> None:
"""Best-effort RFC 7009 revocation against the IdP.
Silent on failure: revoking remotely is a defense-in-depth step on top
of deleting the local copy, and we don't want logout to error if the
IdP is unreachable or doesn't advertise a revocation endpoint.
"""
try:
discovery_url = oauth_ctx.get("discovery_url") or os.getenv(
"OIDC_DISCOVERY_URL",
f"{os.getenv('NEXTCLOUD_HOST', '')}/.well-known/openid-configuration",
)
if not discovery_url:
return
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(discovery_url)
discovery_response.raise_for_status()
discovery = discovery_response.json()
revocation_endpoint = discovery.get("revocation_endpoint")
if not revocation_endpoint:
logger.debug("IdP advertises no revocation_endpoint; skipping")
return
client_id = oauth_ctx.get("client_id") or os.getenv("OIDC_CLIENT_ID")
client_secret = oauth_ctx.get("client_secret") or os.getenv(
"OIDC_CLIENT_SECRET"
)
if not (client_id and client_secret):
logger.debug("No OIDC client credentials available for revocation")
return
response = await http_client.post(
revocation_endpoint,
data={
"token": refresh_token,
"token_type_hint": "refresh_token",
},
auth=(client_id, client_secret),
)
if response.status_code >= 400:
logger.warning(
"Refresh token revocation returned HTTP %s", response.status_code
)
except Exception as e:
logger.warning("Refresh token revocation failed: %s", e)
+26 -17
View File
@@ -30,13 +30,16 @@ from typing import Any
from urllib.parse import unquote, urlencode
from urllib.parse import urlparse as parse_url
import jwt
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
from nextcloud_mcp_server.auth.client_registry import get_client_registry
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
)
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
@@ -643,23 +646,29 @@ async def oauth_callback_nextcloud(request: Request):
refresh_token = token_data.get("refresh_token")
id_token = token_data.get("id_token")
# Decode ID token to get user info
logger.info("=" * 60)
logger.info("oauth_callback_nextcloud: Extracting user_id from ID token")
logger.info("=" * 60)
# Verify ID token signature + claims (issue #626 finding 1).
logger.info("oauth_callback_nextcloud: Verifying ID token")
try:
userinfo = jwt.decode(id_token, options={"verify_signature": False})
user_id = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info(" ✓ ID token decode SUCCESSFUL")
logger.info(f" Extracted user_id: {user_id}")
logger.info(f" Username: {username}")
logger.info(f" ID token payload keys: {list(userinfo.keys())}")
logger.info(f"Flow 2: User {username} provisioned resource access")
except Exception as e:
logger.error(f" ✗ ID token decode FAILED: {type(e).__name__}: {e}")
user_id = "unknown"
logger.error(f" Using fallback user_id: {user_id}")
userinfo = await verify_id_token(
id_token,
discovery_url=discovery_url,
expected_audience=mcp_server_client_id,
)
except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e)
return JSONResponse(
{
"error": "invalid_token",
"error_description": "ID token failed verification",
},
status_code=400,
)
user_id = userinfo["sub"]
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info(
"Flow 2: User %s (sub=%s) provisioned resource access", username, user_id
)
# Store master refresh token for Flow 2
if refresh_token:
@@ -9,7 +9,7 @@ import functools
import logging
from typing import Callable
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
@@ -65,16 +65,12 @@ def require_provisioning(func: Callable) -> Callable:
return await func(*args, **kwargs)
# Offline access mode - check if user has completed Flow 2 provisioning
# Get user_id from authorization token
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
try:
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
logger.debug(f"Checking provisioning for user: {user_id}")
except Exception as e:
logger.warning(f"Failed to extract user_id from token: {e}")
# Read user_id from the verified AccessToken populated by
# UnifiedTokenVerifier; no second decode of the raw JWT here.
access_token = get_access_token()
user_id = access_token.resource if access_token else None
if user_id:
logger.debug("Checking provisioning for user: %s", user_id)
if not user_id:
raise McpError(
@@ -149,12 +145,8 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
if ctx:
# Try to check provisioning status
try:
# Get user_id from authorization token
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
access_token = get_access_token()
user_id = access_token.resource if access_token else None
if user_id:
# Check provisioning status
+20 -18
View File
@@ -52,44 +52,46 @@ class SessionAuthBackend(AuthenticationBackend):
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
return AuthCredentials(["authenticated", "admin"]), SimpleUser(username)
# OAuth mode: Check for session cookie
# OAuth mode: opaque random session_id cookie -> user_id mapping.
# Replaces the prior `mcp_session=<user_id>` cookie pattern (issue
# #626 finding 2). The cookie value is no longer the user identity;
# we look it up server-side and reject unknown / expired sessions.
session_id = conn.cookies.get("mcp_session")
logger.info(
f"Session authentication check - cookie present: {session_id is not None}, path: {conn.url.path}"
)
if not session_id:
logger.info("No session cookie found - redirecting to login")
return None
logger.info(f"Found session cookie: {session_id[:16]}...")
# Get OAuth context from app state
oauth_context = getattr(conn.app.state, "oauth_context", None)
if not oauth_context:
logger.warning("OAuth context not available in app state")
return None
# Validate session
storage = oauth_context.get("storage")
if not storage:
logger.warning("OAuth storage not available")
return None
try:
# Check if user has refresh token (indicates logged-in session)
logger.info(f"Looking up refresh token for session: {session_id[:16]}...")
token_data = await storage.get_refresh_token(session_id)
if not token_data:
logger.warning(
f"No refresh token found for session {session_id[:16]}..."
user_id = await storage.get_browser_session_user(session_id)
if not user_id:
logger.info(
"Browser session not found or expired (sid=%s…)", session_id[:8]
)
return None
# Session is valid - use session_id (which is user_id from ID token) as username
username = session_id
logger.info(f"✓ Session authenticated successfully: {username[:16]}...")
# Defense-in-depth: only authenticate sessions for users that
# actually have a refresh token persisted. Logout deletes both,
# so an expired/revoked user state will fail closed here.
token_data = await storage.get_refresh_token(user_id)
if not token_data:
logger.warning(
"Session %s… has no refresh token for user %s; rejecting",
session_id[:8],
user_id,
)
return None
return AuthCredentials(["authenticated"]), SimpleUser(username)
return AuthCredentials(["authenticated"]), SimpleUser(user_id)
except Exception as e:
logger.warning(f"Session validation error: {e}")
+84
View File
@@ -1133,6 +1133,90 @@ class RefreshTokenStorage:
return deleted
# ============================================================================
# Browser Sessions (OAuth admin UI)
# ============================================================================
#
# Maps a cryptographically random `session_id` (cookie value) to the
# authenticated user_id. Replaces the prior `mcp_session=<user_id>`
# cookie pattern (issue #626 finding 2). Cookie value is opaque, expires,
# and can be revoked server-side without forcing the user to roll their
# IdP `sub`.
async def create_browser_session(
self,
session_id: str,
user_id: str,
ttl_seconds: int = 86400 * 30,
) -> None:
"""Persist a random session_id → user_id mapping for browser auth."""
if not self._initialized:
await self.initialize()
now = int(time.time())
expires_at = now + ttl_seconds
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO browser_sessions
(session_id, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?)
""",
(session_id, user_id, now, expires_at),
)
await db.commit()
logger.debug(
"Stored browser session %s for user %s (expires in %ss)",
session_id[:8],
user_id,
ttl_seconds,
)
async def get_browser_session_user(self, session_id: str) -> Optional[str]:
"""Look up the user_id bound to a browser session_id, or None.
Returns None when the session is unknown or expired. Expired rows
are deleted on encounter to keep the table small.
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT user_id, expires_at FROM browser_sessions WHERE session_id = ?",
(session_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
return None
if row["expires_at"] < time.time():
logger.debug("Browser session %s expired", session_id[:8])
await self.delete_browser_session(session_id)
return None
return row["user_id"]
async def delete_browser_session(self, session_id: str) -> bool:
"""Delete a browser session row. Returns True when a row was removed."""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM browser_sessions WHERE session_id = ?", (session_id,)
)
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.debug("Deleted browser session %s", session_id[:8])
return deleted
# ============================================================================
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
# ============================================================================
-30
View File
@@ -20,7 +20,6 @@ from typing import Dict, Optional, Tuple
import anyio
import httpx
import jwt
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
@@ -489,35 +488,6 @@ class TokenBrokerService:
)
return access_token, expires_in
async def _validate_token_audience(self, token: str, expected_audience: str):
"""
Validate that token has correct audience claim.
Args:
token: JWT token to validate
expected_audience: Expected audience value
Raises:
ValueError: If audience doesn't match
"""
try:
# Decode without verification to check claims
# In production, should verify signature
claims = jwt.decode(token, options={"verify_signature": False})
audience = claims.get("aud", [])
if isinstance(audience, str):
audience = [audience]
if expected_audience not in audience:
raise ValueError(
f"Token audience {audience} doesn't include {expected_audience}"
)
except jwt.DecodeError as e:
# Token might be opaque, skip validation
logger.debug(f"Cannot decode token for audience validation: {e}")
async def refresh_master_token(self, user_id: str) -> bool:
"""
Refresh the master refresh token (periodic rotation).
+127 -53
View File
@@ -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
+22 -56
View File
@@ -198,9 +198,7 @@ def generate_oauth_url_for_flow2(
return f"{auth_endpoint}?{urlencode(params)}"
async def provision_nextcloud_access(
ctx: Context, user_id: Optional[str] = None
) -> ProvisioningResult:
async def provision_nextcloud_access(ctx: Context, user_id: str) -> ProvisioningResult:
"""
MCP Tool: Provision offline access to Nextcloud resources.
@@ -211,16 +209,13 @@ async def provision_nextcloud_access(
Args:
ctx: MCP context with user's Flow 1 token
user_id: Optional user identifier (extracted from token if not provided)
user_id: Authenticated user identifier (must be derived from the
verified access token by the caller; never accept from MCP input).
Returns:
ProvisioningResult with Astrolabe settings URL or status
"""
try:
# Extract user ID from the MCP access token (Flow 1 token)
if not user_id:
user_id = await extract_user_id_from_token(ctx)
# Check if already provisioned
status = await get_provisioning_status(ctx, user_id)
if status.is_provisioned:
@@ -271,9 +266,7 @@ async def provision_nextcloud_access(
)
async def revoke_nextcloud_access(
ctx: Context, user_id: Optional[str] = None
) -> RevocationResult:
async def revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResult:
"""
MCP Tool: Revoke offline access to Nextcloud resources.
@@ -281,19 +274,14 @@ async def revoke_nextcloud_access(
that was granted via Flow 2.
Args:
mcp: MCP context
user_id: Optional user identifier
ctx: MCP context
user_id: Authenticated user identifier (must be derived from the
verified access token by the caller; never accept from MCP input).
Returns:
RevocationResult with status
"""
try:
# Get user ID from token if not provided
if not user_id:
logger.info("Extracting user_id from access token for revoke...")
user_id = await extract_user_id_from_token(ctx)
logger.info(f" Revoke using user_id: {user_id}")
# Check current status
status = await get_provisioning_status(ctx, user_id)
if not status.is_provisioned:
@@ -350,9 +338,7 @@ async def revoke_nextcloud_access(
)
async def check_provisioning_status(
ctx: Context, user_id: Optional[str] = None
) -> ProvisioningStatus:
async def check_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
"""
MCP Tool: Check the current provisioning status.
@@ -360,24 +346,17 @@ async def check_provisioning_status(
Nextcloud access and see details about their current authorization.
Args:
mcp: MCP context
user_id: Optional user identifier
ctx: MCP context
user_id: Authenticated user identifier (must be derived from the
verified access token by the caller; never accept from MCP input).
Returns:
ProvisioningStatus with current state
"""
# Get user ID from context if not provided
if not user_id:
user_id = (
ctx.context.get("user_id", "default_user") # type: ignore
if hasattr(ctx, "context")
else "default_user"
)
return await get_provisioning_status(ctx, user_id)
async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
async def check_logged_in(ctx: Context, user_id: str) -> str:
"""
MCP Tool: Check if user is logged in and elicit login if needed.
@@ -387,23 +366,13 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
Args:
ctx: MCP context with user's Flow 1 token
user_id: Optional user identifier (extracted from token if not provided)
user_id: Authenticated user identifier (must be derived from the
verified access token by the caller; never accept from MCP input).
Returns:
"yes" if logged in, or elicitation prompting for login
"""
try:
# Extract user ID from the MCP access token (Flow 1 token)
logger.info("=" * 60)
logger.info("check_logged_in: Starting user_id extraction")
logger.info("=" * 60)
if not user_id:
user_id = await extract_user_id_from_token(ctx)
logger.info(f" Final user_id for check_logged_in: {user_id}")
else:
logger.info(f" user_id provided as argument: {user_id}")
# Check if already logged in
logger.info(f"Checking provisioning status for user_id: {user_id}")
status = await get_provisioning_status(ctx, user_id)
@@ -591,10 +560,8 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_provision_access(
ctx: Context,
user_id: Optional[str] = None,
) -> ProvisioningResult:
async def tool_provision_access(ctx: Context) -> ProvisioningResult:
user_id = await extract_user_id_from_token(ctx)
return await provision_nextcloud_access(ctx, user_id)
@mcp.tool(
@@ -608,9 +575,8 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_revoke_access(
ctx: Context, user_id: Optional[str] = None
) -> RevocationResult:
async def tool_revoke_access(ctx: Context) -> RevocationResult:
user_id = await extract_user_id_from_token(ctx)
return await revoke_nextcloud_access(ctx, user_id)
@mcp.tool(
@@ -623,9 +589,8 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_check_status(
ctx: Context, user_id: Optional[str] = None
) -> ProvisioningStatus:
async def tool_check_status(ctx: Context) -> ProvisioningStatus:
user_id = await extract_user_id_from_token(ctx)
return await check_provisioning_status(ctx, user_id)
@mcp.tool(
@@ -641,5 +606,6 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
async def tool_check_logged_in(ctx: Context) -> str:
user_id = await extract_user_id_from_token(ctx)
return await check_logged_in(ctx, user_id)
+75
View File
@@ -0,0 +1,75 @@
"""Unit tests for browser_sessions storage (issue #626 finding 2).
The browser admin UI no longer uses the raw user_id as the cookie value
— it uses a cryptographically random session_id mapped server-side to
user_id. These tests pin the storage contract.
"""
import secrets
import tempfile
import time
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
@pytest.fixture
async def storage():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_browser_sessions.db"
s = RefreshTokenStorage(
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
)
await s.initialize()
yield s
async def test_create_and_get_browser_session(storage):
sid = secrets.token_urlsafe(32)
await storage.create_browser_session(session_id=sid, user_id="alice")
user_id = await storage.get_browser_session_user(sid)
assert user_id == "alice"
async def test_get_browser_session_unknown_returns_none(storage):
assert await storage.get_browser_session_user("does-not-exist") is None
async def test_delete_browser_session(storage):
sid = secrets.token_urlsafe(32)
await storage.create_browser_session(session_id=sid, user_id="alice")
deleted = await storage.delete_browser_session(sid)
assert deleted is True
assert await storage.get_browser_session_user(sid) is None
async def test_expired_browser_session_rejected_and_deleted(storage):
sid = secrets.token_urlsafe(32)
# ttl_seconds=0 so the row is immediately expired (now == expires_at)
await storage.create_browser_session(session_id=sid, user_id="alice", ttl_seconds=0)
# Make sure clock advances past expires_at
time.sleep(0.01)
assert await storage.get_browser_session_user(sid) is None
# Expired row should be deleted on encounter
assert await storage.delete_browser_session(sid) is False
async def test_replace_existing_session_id(storage):
"""INSERT OR REPLACE so re-using a session_id rebinds the user.
Not a recommended call pattern (session_ids are random), but the
storage layer must not raise UNIQUE constraint errors if it happens.
"""
sid = secrets.token_urlsafe(32)
await storage.create_browser_session(session_id=sid, user_id="alice")
await storage.create_browser_session(session_id=sid, user_id="bob")
assert await storage.get_browser_session_user(sid) == "bob"
+233
View File
@@ -0,0 +1,233 @@
"""Unit tests for OIDC ID token verification (issue #626 finding 1).
The OAuth callback handlers used to call
`jwt.decode(id_token, options={"verify_signature": False})` and trust the
result. They now go through `verify_id_token`, which checks signature
against JWKS and validates issuer / audience / exp / nonce per OIDC core
spec §3.1.3.7.
"""
import json
import time
from base64 import urlsafe_b64encode
from unittest.mock import patch
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
)
pytestmark = pytest.mark.unit
# Generated once per process — RSA keypair generation is slow.
_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_PRIVATE_PEM = _KEY.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
_OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_OTHER_PRIVATE_PEM = _OTHER_KEY.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
ISSUER = "https://idp.example.com"
DISCOVERY_URL = f"{ISSUER}/.well-known/openid-configuration"
JWKS_URI = f"{ISSUER}/jwks"
def _b64u_uint(n: int) -> str:
raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _build_jwks() -> dict:
pub = _KEY.public_key().public_numbers()
return {
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "test-key-1",
"alg": "RS256",
"n": _b64u_uint(pub.n),
"e": _b64u_uint(pub.e),
}
]
}
def _sign(
claims: dict, *, kid: str = "test-key-1", key_pem: bytes = _PRIVATE_PEM
) -> str:
return jwt.encode(claims, key_pem, algorithm="RS256", headers={"kid": kid})
def _idp_handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == DISCOVERY_URL:
return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI})
if str(request.url) == JWKS_URI:
return httpx.Response(
200,
content=json.dumps(_build_jwks()).encode(),
headers={"content-type": "application/json"},
)
return httpx.Response(404)
@pytest.fixture
def mock_idp():
"""Patch nextcloud_httpx_client used inside token_utils.verify_id_token."""
transport = httpx.MockTransport(_idp_handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
yield
async def test_verify_id_token_accepts_valid_token(mock_idp):
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
}
)
payload = await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
assert payload["sub"] == "alice"
async def test_verify_id_token_rejects_wrong_audience(mock_idp):
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "other-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
}
)
with pytest.raises(IdTokenVerificationError):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
async def test_verify_id_token_rejects_expired_token(mock_idp):
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now - 120,
"exp": now - 60,
}
)
with pytest.raises(IdTokenVerificationError):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
async def test_verify_id_token_rejects_wrong_issuer(mock_idp):
now = int(time.time())
token = _sign(
{
"iss": "https://evil.example.com",
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
}
)
with pytest.raises(IdTokenVerificationError):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
async def test_verify_id_token_rejects_wrong_signature(mock_idp):
"""Token signed with a different key but matching kid header must fail."""
now = int(time.time())
forged = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
},
key_pem=_OTHER_PRIVATE_PEM,
)
with pytest.raises(IdTokenVerificationError):
await verify_id_token(
forged, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
async def test_verify_id_token_rejects_unknown_kid(mock_idp):
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
},
kid="not-in-jwks",
)
with pytest.raises(IdTokenVerificationError, match="No JWKS key matches"):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
async def test_verify_id_token_nonce_mismatch_rejected(mock_idp):
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
"nonce": "actual",
}
)
with pytest.raises(IdTokenVerificationError, match="nonce"):
await verify_id_token(
token,
discovery_url=DISCOVERY_URL,
expected_audience="test-client",
expected_nonce="expected",
)
async def test_verify_id_token_missing_token_rejected():
with pytest.raises(IdTokenVerificationError, match="missing"):
await verify_id_token(
"", discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
+331
View File
@@ -0,0 +1,331 @@
"""Unit tests for OAuth logout (issue #626 finding 4) and the
SessionAuthBackend (finding 2).
These cover the new server-side session lifecycle:
- logout deletes refresh token + browser session
- logout calls IdP revocation_endpoint when available
- logout still succeeds when IdP/storage errors
- SessionAuthBackend resolves random session_id -> user_id, fails
closed when the session is unknown / expired / has no refresh token
"""
import json
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from cryptography.fernet import Fernet
from starlette.requests import HTTPConnection
from nextcloud_mcp_server.auth.browser_oauth_routes import (
_revoke_refresh_token_at_idp,
oauth_logout,
)
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
# ---------------------------------------------------------------------------
# storage fixture (real SQLite backend; lighter than mocking every call)
# ---------------------------------------------------------------------------
@pytest.fixture
async def storage():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_logout.db"
s = RefreshTokenStorage(
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
)
await s.initialize()
yield s
def _build_request(*, cookie: str | None, oauth_context: dict | None):
"""Build a minimal Starlette-style request stub for oauth_logout."""
request = MagicMock()
request.query_params = {}
request.cookies = {"mcp_session": cookie} if cookie else {}
request.app.state.oauth_context = oauth_context
return request
# ---------------------------------------------------------------------------
# oauth_logout
# ---------------------------------------------------------------------------
async def test_logout_deletes_refresh_token_and_session(storage):
"""Happy path: logout removes the refresh token and the browser session."""
await storage.create_browser_session(session_id="sid-1", user_id="alice")
await storage.store_refresh_token(
user_id="alice", refresh_token="rt-abc", flow_type="browser"
)
request = _build_request(
cookie="sid-1",
oauth_context={"storage": storage, "discovery_url": None},
)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
new=AsyncMock(),
):
response = await oauth_logout(request)
assert response.status_code == 302
assert await storage.get_refresh_token("alice") is None
assert await storage.get_browser_session_user("sid-1") is None
async def test_logout_calls_revocation_when_refresh_token_present(storage):
"""The IdP revocation helper is called with the stored refresh token."""
await storage.create_browser_session(session_id="sid-2", user_id="bob")
await storage.store_refresh_token(
user_id="bob", refresh_token="rt-xyz", flow_type="browser"
)
revoke = AsyncMock()
request = _build_request(
cookie="sid-2",
oauth_context={"storage": storage, "discovery_url": "http://idp/.well-known"},
)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
new=revoke,
):
await oauth_logout(request)
revoke.assert_awaited_once()
args = revoke.await_args.args
# Second arg is the refresh token string
assert args[1] == "rt-xyz"
async def test_logout_no_session_cookie_returns_302(storage):
"""Without a cookie, logout still 302s and doesn't touch storage."""
request = _build_request(
cookie=None, oauth_context={"storage": storage, "discovery_url": None}
)
response = await oauth_logout(request)
assert response.status_code == 302
async def test_logout_swallows_storage_errors(storage):
"""Logout is best-effort — a storage failure must not 500 the response."""
await storage.create_browser_session(session_id="sid-3", user_id="carol")
broken_storage = MagicMock()
broken_storage.get_browser_session_user = AsyncMock(
side_effect=RuntimeError("db down")
)
broken_storage.delete_browser_session = AsyncMock()
request = _build_request(
cookie="sid-3",
oauth_context={"storage": broken_storage, "discovery_url": None},
)
response = await oauth_logout(request)
assert response.status_code == 302 # logout still succeeds
async def test_logout_handles_session_with_no_refresh_token(storage):
"""Cookie + session row exist but refresh token already gone — logout is idempotent."""
await storage.create_browser_session(session_id="sid-4", user_id="dave")
revoke = AsyncMock()
request = _build_request(
cookie="sid-4",
oauth_context={"storage": storage, "discovery_url": None},
)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
new=revoke,
):
await oauth_logout(request)
# Revoke not called — no token to revoke
revoke.assert_not_called()
# Browser session still cleared
assert await storage.get_browser_session_user("sid-4") is None
# ---------------------------------------------------------------------------
# _revoke_refresh_token_at_idp
# ---------------------------------------------------------------------------
def _httpx_handler(routes: dict[str, httpx.Response]):
def handler(request: httpx.Request) -> httpx.Response:
return routes.get(str(request.url), httpx.Response(404))
return handler
async def test_revoke_helper_posts_to_revocation_endpoint():
discovery_url = "http://idp.example/.well-known"
revocation_url = "http://idp.example/revoke"
received: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == discovery_url:
return httpx.Response(
200,
content=json.dumps({"revocation_endpoint": revocation_url}).encode(),
headers={"content-type": "application/json"},
)
if str(request.url) == revocation_url:
received.append(request)
return httpx.Response(200)
return httpx.Response(404)
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
):
await _revoke_refresh_token_at_idp(
{
"discovery_url": discovery_url,
"client_id": "test-client",
"client_secret": "test-secret",
},
"rt-secret",
)
assert len(received) == 1
body = received[0].content.decode()
assert "token=rt-secret" in body
assert "token_type_hint=refresh_token" in body
async def test_revoke_helper_skips_when_no_revocation_endpoint():
"""IdPs without a revocation_endpoint advertised: helper must no-op silently."""
discovery_url = "http://idp.example/.well-known"
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == discovery_url:
return httpx.Response(200, json={}) # no revocation_endpoint
return httpx.Response(404)
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
):
# Returns None and does not raise
result = await _revoke_refresh_token_at_idp(
{
"discovery_url": discovery_url,
"client_id": "x",
"client_secret": "y",
},
"rt",
)
assert result is None
async def test_revoke_helper_silent_on_idp_error():
"""If the IdP 500s, the helper must not raise — caller treats it as best-effort."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, content=b"boom")
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
):
result = await _revoke_refresh_token_at_idp(
{
"discovery_url": "http://x/.well-known",
"client_id": "x",
"client_secret": "y",
},
"rt",
)
assert result is None
# ---------------------------------------------------------------------------
# SessionAuthBackend
# ---------------------------------------------------------------------------
def _build_conn(*, cookie: str | None, oauth_context: dict | None):
conn = MagicMock(spec=HTTPConnection)
conn.cookies = {"mcp_session": cookie} if cookie else {}
conn.url = SimpleNamespace(path="/app")
conn.app = MagicMock()
conn.app.state.oauth_context = oauth_context
return conn
async def test_session_backend_authenticates_known_session_with_token(storage):
await storage.create_browser_session(session_id="sid-A", user_id="alice")
await storage.store_refresh_token(
user_id="alice", refresh_token="rt", flow_type="browser"
)
backend = SessionAuthBackend(oauth_enabled=True)
conn = _build_conn(cookie="sid-A", oauth_context={"storage": storage})
result = await backend.authenticate(conn)
assert result is not None
creds, user = result
assert "authenticated" in creds.scopes
assert user.username == "alice"
async def test_session_backend_rejects_unknown_session(storage):
backend = SessionAuthBackend(oauth_enabled=True)
conn = _build_conn(cookie="not-a-real-sid", oauth_context={"storage": storage})
assert await backend.authenticate(conn) is None
async def test_session_backend_rejects_session_without_refresh_token(storage):
"""Defense-in-depth: session row exists but user has no refresh token."""
await storage.create_browser_session(session_id="sid-B", user_id="bob")
# Note: NO refresh token stored for bob
backend = SessionAuthBackend(oauth_enabled=True)
conn = _build_conn(cookie="sid-B", oauth_context={"storage": storage})
assert await backend.authenticate(conn) is None
async def test_session_backend_rejects_when_no_cookie(storage):
backend = SessionAuthBackend(oauth_enabled=True)
conn = _build_conn(cookie=None, oauth_context={"storage": storage})
assert await backend.authenticate(conn) is None
async def test_session_backend_basicauth_mode_short_circuits(monkeypatch, storage):
"""In BasicAuth mode (oauth_enabled=False) the backend never touches storage."""
monkeypatch.setenv("NEXTCLOUD_USERNAME", "admin-user")
backend = SessionAuthBackend(oauth_enabled=False)
conn = _build_conn(cookie=None, oauth_context=None)
result = await backend.authenticate(conn)
assert result is not None
_, user = result
assert user.username == "admin-user"
+56
View File
@@ -0,0 +1,56 @@
"""Unit tests for OAuth tool input-schema hardening (issue #626 finding 3).
These tools must derive `user_id` from the verified MCP access token and
must never accept it as an MCP-level input. Otherwise an LLM (or any MCP
client) could supply an arbitrary user_id and reach cross-user revoke or
status-disclosure operations.
"""
import pytest
from mcp.server.fastmcp import FastMCP
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
pytestmark = pytest.mark.unit
HARDENED_TOOLS = (
"provision_nextcloud_access",
"revoke_nextcloud_access",
"check_provisioning_status",
"check_logged_in",
)
@pytest.fixture
def registered_tools():
"""Register the OAuth tools against a fresh FastMCP and return them by name.
Uses FastMCP's `_tool_manager.list_tools()`; flagged as internal and may
break on SDK upgrades, but this is the supported way to inspect a tool's
JSON input schema in unit tests (see tests/unit/test_stdio.py).
"""
mcp = FastMCP("test-oauth-tools")
register_oauth_tools(mcp)
tools = mcp._tool_manager.list_tools()
return {t.name: t for t in tools}
def test_oauth_tools_registered(registered_tools):
for name in HARDENED_TOOLS:
assert name in registered_tools, f"{name} should be registered"
@pytest.mark.parametrize("tool_name", HARDENED_TOOLS)
def test_oauth_tool_schema_does_not_accept_user_id(tool_name, registered_tools):
"""user_id must not appear in the tool's JSON input schema."""
tool = registered_tools[tool_name]
properties = tool.parameters.get("properties", {})
required = tool.parameters.get("required", [])
assert "user_id" not in properties, (
f"{tool_name} accepts user_id as an MCP input — must be derived from "
f"the verified access token (issue #626 finding 3). "
f"properties={list(properties.keys())}"
)
assert "user_id" not in required
-18
View File
@@ -10,7 +10,6 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import jwt
import pytest
from cryptography.fernet import Fernet
@@ -283,23 +282,6 @@ class TestTokenBrokerService:
# Verify cache was cleared
assert await token_broker.cache.get("user1") is None
async def test_validate_token_audience(self, token_broker):
"""Test token audience validation."""
# Create test token with audience
test_payload = {
"sub": "user1",
"aud": ["nextcloud", "other-service"],
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
}
test_token = jwt.encode(test_payload, "secret", algorithm="HS256")
# Should not raise for correct audience
await token_broker._validate_token_audience(test_token, "nextcloud")
# Should raise for wrong audience
with pytest.raises(ValueError, match="doesn't include wrong-audience"):
await token_broker._validate_token_audience(test_token, "wrong-audience")
async def test_token_refresh_with_network_error(self, token_broker, mock_storage):
"""Test handling network errors during token refresh."""
# Storage returns already-decrypted refresh token