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
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user