Merge pull request #758 from cbcoutinho/security/oauth-session-hardening-626

fix(auth): harden OAuth/session for hosted multi-tenant deployment (#626)
This commit is contained in:
Chris Coutinho
2026-05-03 14:49:59 +02:00
committed by GitHub
29 changed files with 3549 additions and 554 deletions
+14
View File
@@ -129,6 +129,18 @@ jobs:
# npm ci
# npm run build
# Generate an ephemeral Fernet key per CI run. docker-compose.yml
# requires TOKEN_ENCRYPTION_KEY (PR #758 finding 5 removed the
# hardcoded default), but the CI tokens.db is destroyed at the end of
# the job so there is no value in persisting the key as a repo secret.
# ``openssl rand -base64 32`` produces 32 bytes encoded as 44 base64
# chars; ``tr '+/' '-_'`` converts to URL-safe base64, which is
# exactly what Fernet expects.
- name: Generate ephemeral TOKEN_ENCRYPTION_KEY
run: |
KEY=$(openssl rand -base64 32 | tr '+/' '-_')
echo "TOKEN_ENCRYPTION_KEY=${KEY}" >> "$GITHUB_ENV"
# Start services with the appropriate profile
- name: Run docker compose
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
@@ -139,6 +151,8 @@ jobs:
env:
MCP_SERVER_URL: ${{ matrix.mcp-internal-url }}
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
# Inherited from $GITHUB_ENV via the previous step.
TOKEN_ENCRYPTION_KEY: ${{ env.TOKEN_ENCRYPTION_KEY }}
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
+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
+1
View File
@@ -5,6 +5,7 @@ This guide covers installing the Nextcloud MCP server on your system.
## Prerequisites
- **Python 3.11+** - Check with `python3 --version`
- **SQLite 3.35+** - Check with `python3 -c "import sqlite3; print(sqlite3.sqlite_version)"`. The OAuth session storage uses `DELETE ... RETURNING`, which is only available from SQLite 3.35 (March 2021). Ubuntu 20.04 ships SQLite 3.31 and is **not** supported; upgrade the host or run from the Docker image, which bundles a newer libsqlite3.
- **Access to a Nextcloud instance** - Self-hosted or cloud-hosted
- **Administrator access** *(optional)* - Only needed to customise app-password policies in Nextcloud settings; not required for any deployment mode (single-user, multi-user BasicAuth, or Login Flow v2)
+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")
@@ -0,0 +1,29 @@
"""Add nonce column to oauth_sessions for OIDC ID-token binding.
PR #758 finding 2: the browser OAuth flow generated PKCE + state but no
``nonce``. Without a nonce, an attacker who obtains a valid ID token for
another user (e.g. from a parallel auth request) could replay it inside
this flow because the token isn't cryptographically tied to the
authorization request. The nonce is generated in ``oauth_login``,
forwarded to the IdP in the auth URL, and verified on the way back.
Revision ID: 006
Revises: 005
Create Date: 2026-05-02 16:00:00.000000
"""
from alembic import op
revision = "006"
down_revision = "005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE oauth_sessions ADD COLUMN nonce TEXT")
def downgrade() -> None:
# SQLite < 3.35 cannot DROP COLUMN; leave the column on downgrade.
pass
+8 -3
View File
@@ -1354,13 +1354,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
storage = await get_shared_storage()
count = await storage.delete_expired_login_flow_sessions()
if count:
logger.info(f"Cleaned up {count} expired login flow sessions")
logger.info("Cleaned up %s expired login flow sessions", count)
# Browser session rows are otherwise only cleaned up lazily
# when a user revisits — PR #758 finding 6.
await storage.cleanup_expired_browser_sessions()
# Also clean up expired AS proxy codes/sessions
_cleanup_expired_proxy_codes()
# Clean up expired web provision sessions
_cleanup_expired_provision_sessions()
except Exception as e:
logger.warning(f"Login flow cleanup error: {e}")
logger.warning("Login flow cleanup error: %s", e)
await anyio.sleep(3600) # Every hour
@asynccontextmanager
@@ -2242,8 +2245,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
name="oauth_login_callback",
)
)
# POST-only: defends against passive CSRF (e.g. <img src="…/logout">)
# — see PR #758 finding 5.
routes.append(
Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
Route("/oauth/logout", oauth_logout, methods=["POST"], name="oauth_logout")
)
logger.info(
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback (legacy), /oauth/logout"
+418 -108
View File
@@ -9,14 +9,19 @@ import logging
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,
get_oidc_discovery,
verify_id_token,
)
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_userinfo_endpoint,
_query_idp_userinfo,
@@ -28,23 +33,99 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
def _normalise_origin(raw: str) -> tuple[str, str, int | None]:
"""Return (scheme, hostname, port) with default HTTP/HTTPS ports stripped.
Browsers omit default ports in Origin headers (RFC 6454 §6.2), so a
raw netloc string comparison falsely rejects requests whenever
``mcp_server_url`` is configured with an explicit ``:443`` / ``:80``
(or vice versa).
"""
parsed = parse_url(raw)
scheme = parsed.scheme.lower()
hostname = (parsed.hostname or "").lower()
port = parsed.port
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
port = None
return (scheme, hostname, port)
def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
"""Return True when Origin/Referer is missing or matches our own host.
Used to gate POST /oauth/logout against cross-origin form submissions
(PR #758 round-3 review hardening). Per OWASP CSRF cheat sheet, the
policy is:
- If neither Origin nor Referer is set, allow (same-origin POST in
privacy-conscious browsers may strip both).
- Otherwise, the (scheme, hostname, port) tuple of the first present
header must equal the same tuple of the configured
``mcp_server_url``. Default ports (80/443) are normalised away
before comparison so RFC-6454-compliant browsers — which omit
default ports in Origin — aren't rejected.
"""
cfg = oauth_ctx.get("config") or oauth_ctx
mcp_server_url = cfg.get("mcp_server_url")
if not mcp_server_url:
# Fail closed (PR #758 round-3 finding 2): a future code path that
# leaves ``mcp_server_url`` unset would otherwise silently disable
# CSRF protection on /oauth/logout. Blocking the logout is
# recoverable — the user just re-logs-in once the misconfiguration
# is fixed — and the error log makes the cause monitorable.
logger.error(
"CSRF check failed on /oauth/logout: mcp_server_url not "
"configured in oauth_context — set NEXTCLOUD_MCP_SERVER_URL"
)
return False
expected = _normalise_origin(mcp_server_url)
raw = request.headers.get("origin") or request.headers.get("referer")
if not raw:
return True
return _normalise_origin(raw) == expected
def _safe_next_url(raw: str | None, default: str) -> str:
"""Return a path-only redirect target, falling back to *default*.
Blocks open-redirect abuse via the ``?next=`` query parameter on
``/oauth/login`` and ``/oauth/logout`` (and the round-tripped
``client_redirect_uri`` stored on the oauth_session). A safe target:
- starts with a single ``/`` (so it's a path on this server)
- does NOT start with ``//`` (which would be protocol-relative)
- has no whitespace or control characters that could trick browsers
Anything else returns *default*.
"""
if not raw or not raw.startswith("/") or raw.startswith("//"):
return default
if any(c.isspace() or ord(c) < 0x20 for c in raw):
return default
return raw
def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have the Secure flag.
Reads ``settings.cookie_secure`` first (set via the ``COOKIE_SECURE``
env var). Falls back to auto-detect from the ``nextcloud_host`` scheme
when unset.
Returns:
True if cookies should be secure (HTTPS), False otherwise
env var). Falls back to auto-detecting from the MCP server's own URL
scheme — the cookie is issued by THIS server, so the Secure flag must
reflect THIS server's transport, not Nextcloud's. (Split-scheme
deployments — HTTPS Nextcloud + plain-HTTP MCP sidecar, or vice
versa — would otherwise get the wrong answer.)
"""
settings = get_settings()
if settings.cookie_secure is not None:
# Dynaconf auto-coerces "true"/"false" → bool but "1"/"0" → int;
# bool() normalises both.
return bool(settings.cookie_secure)
nextcloud_host = settings.nextcloud_host or ""
return nextcloud_host.startswith("https://")
raw = settings.cookie_secure
if raw is not None:
# Dynaconf normally coerces "true"/"false"/"1"/"0", but tests or
# direct ``settings.set`` calls can bypass that — bool("false") is
# True. Normalise explicitly so an unexpected string never flips
# cookies to Secure on plain HTTP (round-6 review).
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() not in ("0", "false", "no", "off", "")
mcp_server_url = settings.nextcloud_mcp_server_url or ""
return mcp_server_url.startswith("https://")
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
@@ -68,18 +149,27 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Debug: Log oauth_config contents
logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
# Demoted to DEBUG (PR #758 nit a) — these previously leaked the
# full set of config keys + the client_id at INFO on every login.
logger.debug("oauth_login called - oauth_config keys: %s", oauth_config.keys())
logger.debug("oauth_login called - client_id: %s", oauth_config.get("client_id"))
logger.debug("oauth_login called - oauth_client: %s", oauth_client is not None)
# Get redirect URL from query params (default to /app)
next_url = request.query_params.get("next", "/app")
logger.info(f"oauth_login - next_url: {next_url}")
# Get redirect URL from query params (default to /app). Validated at
# write-time so we never store an attacker-controlled absolute URL on
# the oauth_session row (issue #758 finding 3).
next_url = _safe_next_url(request.query_params.get("next"), "/app")
logger.debug("oauth_login - next_url: %s", next_url)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
# Generate OIDC nonce so the ID token returned on callback can be bound
# to THIS auth request (PR #758 finding 2). Without a nonce, an attacker
# who acquired a separate valid ID token could replay it inside this
# flow.
nonce = secrets.token_urlsafe(32)
# Build OAuth authorization URL
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback"
@@ -95,7 +185,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
# Store code_verifier in session for retrieval during callback (using state as key)
# Store code_verifier + nonce in session for retrieval during callback
# (using state as key)
await storage.store_oauth_session(
session_id=state, # Use state as session ID
client_id="browser-ui",
@@ -103,7 +194,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
# `mcp_authorization_code` field reused to store the PKCE
# code_verifier (one-time-use). Renaming the column requires a
# schema migration.
mcp_authorization_code=code_verifier,
nonce=nonce,
flow_type="browser",
ttl_seconds=600, # 10 minutes
)
@@ -130,6 +225,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"response_type": "code",
"scope": scopes,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token
@@ -137,7 +233,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
}
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to external IdP login: {auth_url.split('?')[0]}")
logger.debug("Redirecting to external IdP login: %s", auth_url.split("?")[0])
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
@@ -150,12 +246,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
status_code=500,
)
# Fetch authorization endpoint
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Fetch authorization endpoint via the shared 5-minute discovery
# cache (PR #758 nit 5) so each browser login doesn't hit the IdP's
# discovery endpoint.
discovery = await get_oidc_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Include offline_access only if the IdP advertises it (or if
# scopes_supported is absent from the discovery document).
@@ -188,17 +283,17 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"response_type": "code",
"scope": scopes,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
# Debug: Log full parameters
logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
logger.debug("Building Nextcloud OIDC auth URL with params: %s", idp_params)
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to Nextcloud OIDC login: {auth_url}")
logger.debug("Redirecting to Nextcloud OIDC login: %s", auth_url)
return RedirectResponse(auth_url, status_code=302)
@@ -223,8 +318,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"OAuth login error: {error} - {error_description}")
logger.error("OAuth login error: %s - %s", error, error_description)
login_url = str(request.url_for("oauth_login"))
# html_escape: error / error_description come from attacker-controlled
# query parameters and would otherwise reflect into the failure page.
return HTMLResponse(
f"""
<!DOCTYPE html>
@@ -232,9 +329,9 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<head><title>Login Failed</title></head>
<body>
<h1>Login Failed</h1>
<p>Error: {error}</p>
<p>{error_description}</p>
<p><a href="{login_url}">Try again</a></p>
<p>Error: {html_escape(error)}</p>
<p>{html_escape(error_description)}</p>
<p><a href="{html_escape(login_url)}">Try again</a></p>
</body>
</html>
""",
@@ -266,17 +363,30 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Retrieve code_verifier and redirect URL from session storage
code_verifier = ""
next_url = "/app" # Default redirect
# Retrieve code_verifier, nonce, and redirect URL from session storage.
# Fail closed when the row is missing/expired: otherwise PKCE +
# nonce verification silently degrade to no-ops (round-6 review).
oauth_session = await storage.get_oauth_session(state)
if oauth_session:
# code_verifier was stored in mcp_authorization_code field
code_verifier = oauth_session.get("mcp_authorization_code", "")
# next_url was stored in client_redirect_uri field
next_url = oauth_session.get("client_redirect_uri", "/app")
# Clean up the temporary session
# Note: We don't have delete_oauth_session method, but it will expire after TTL
if not oauth_session:
logger.warning("OAuth callback received unknown/expired state=%s", state[:16])
return HTMLResponse(
"Unknown or expired session — please try logging in again.",
status_code=400,
)
# `mcp_authorization_code` field reused to store the PKCE code_verifier
# (one-time-use). Renaming the column requires a schema migration.
code_verifier = oauth_session.get("mcp_authorization_code", "")
# nonce bound to this auth request — verified against the ID token
# below (PR #758 finding 2).
nonce = oauth_session.get("nonce")
# next_url was stored in client_redirect_uri field — re-validate at
# read-time as defense-in-depth (issue #758 finding 3). The session
# row could have been written by an older code path or reused.
next_url = _safe_next_url(oauth_session.get("client_redirect_uri"), "/app")
# One-time-use session: delete eagerly so a replayed callback can't
# be processed and so the oauth_sessions table doesn't accumulate
# completed-but-not-yet-expired browser-login rows.
await storage.delete_oauth_session(state)
# Exchange authorization code for tokens
mcp_server_url = oauth_config["mcp_server_url"]
@@ -311,11 +421,11 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
token_endpoint = discovery["token_endpoint"]
# Use the shared 5-minute discovery cache; oauth_login() above
# has already populated it for this discovery_url so the
# callback should hit the cache rather than re-fetching.
discovery = await get_oidc_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
token_params = {
"grant_type": "authorization_code",
@@ -338,11 +448,18 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
token_data = response.json()
except httpx.HTTPStatusError as e:
# Correlation IDs let the user reference a specific failure in the
# server logs without us having to reflect raw exception/IdP text
# back into the HTML page (PR #758 round-3 nit 6).
correlation_id = secrets.token_hex(8)
error_body = (
e.response.text if hasattr(e.response, "text") else str(e.response.content)
)
logger.error(
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
"Token exchange failed (correlation_id=%s): HTTP %s - %s",
correlation_id,
e.response.status_code,
error_body,
)
return HTMLResponse(
f"""
@@ -351,15 +468,17 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<head><title>Login Failed</title></head>
<body>
<h1>Login Failed</h1>
<p>Failed to exchange authorization code for tokens</p>
<p>HTTP {e.response.status_code}: {error_body}</p>
<p>An internal error occurred while exchanging the authorization code.</p>
<p>Correlation ID: <code>{html_escape(correlation_id)}</code></p>
<p>Please try again, or contact your administrator if the problem persists.</p>
</body>
</html>
""",
status_code=500,
)
except Exception as e:
logger.error(f"Token exchange failed: {e}")
correlation_id = secrets.token_hex(8)
logger.error("Token exchange failed (correlation_id=%s): %s", correlation_id, e)
return HTMLResponse(
f"""
<!DOCTYPE html>
@@ -367,8 +486,9 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<head><title>Login Failed</title></head>
<body>
<h1>Login Failed</h1>
<p>Failed to exchange authorization code for tokens</p>
<p>Error: {e}</p>
<p>An internal error occurred while exchanging the authorization code.</p>
<p>Correlation ID: <code>{html_escape(correlation_id)}</code></p>
<p>Please try again, or contact your administrator if the problem persists.</p>
</body>
</html>
""",
@@ -378,28 +498,69 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
refresh_token = token_data.get("refresh_token")
id_token = token_data.get("id_token")
logger.info(f"Token exchange response keys: {token_data.keys()}")
logger.info(f"Refresh token present: {refresh_token is not None}")
logger.info(f"ID token present: {id_token is not None}")
# Demoted to DEBUG (PR #758 nit a) — these were previously logged at
# INFO on every login.
logger.debug("Token exchange response keys: %s", token_data.keys())
logger.debug("Refresh token present: %s", refresh_token is not None)
logger.debug("ID token present: %s", id_token is not None)
# 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,
)
# Decode ID token to get user info
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,
expected_nonce=nonce,
)
except IdTokenVerificationError as e:
# Same correlation-ID pattern as token-exchange failures
# (PR #758 round-3 nit 6) — log the detail server-side and only
# show a generic message + correlation ID in the browser.
correlation_id = secrets.token_hex(8)
logger.error(
"ID token verification failed (correlation_id=%s): %s",
correlation_id,
e,
)
return HTMLResponse(
f"<h1>Login Failed</h1>"
f"<p>The ID token failed verification.</p>"
f"<p>Correlation ID: <code>{html_escape(correlation_id)}</code></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")
refresh_expires_at = None
if refresh_expires_in:
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
# Some IdPs (e.g. AWS Cognito) return refresh_expires_in as a JSON
# string rather than an int; coerce to be safe.
refresh_expires_at = int(time.time()) + int(refresh_expires_in)
logger.debug(
"Refresh token expires in %ss (at timestamp %s)",
refresh_expires_in,
refresh_expires_at,
)
# Extract granted scopes
@@ -407,26 +568,47 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
token_data.get("scope", "").split() if token_data.get("scope") else None
)
# Store refresh token (for background jobs ONLY)
if refresh_token:
logger.info(f"Storing refresh token for user_id: {user_id}")
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
logger.info(f" Granted scopes: {granted_scopes}")
logger.info(f" Expires at: {refresh_expires_at}")
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
expires_at=refresh_expires_at,
flow_type="browser", # Browser-based login flow
provisioning_client_id=state, # Store state for unified session lookup
scopes=granted_scopes,
# Store refresh token (for background jobs ONLY). The browser session
# itself is gated on this — without a refresh token, ``SessionAuthBackend``
# would reject every subsequent request and silently bounce the user back
# to ``/oauth/login`` (PR #758 round-7 medium 1).
if not refresh_token:
correlation_id = secrets.token_urlsafe(8)
logger.error(
"No refresh token in token response — cannot establish browser "
"session (correlation_id=%s, user_id=%s)",
correlation_id,
user_id,
)
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
logger.info(
f" Token can now be found via provisioning_client_id={state[:16]}..."
return HTMLResponse(
f"<h1>Login Failed</h1>"
f"<p>The identity provider did not return a refresh token, so a "
f"persistent session could not be established. Make sure "
f"<code>offline_access</code> is granted in the IdP configuration.</p>"
f"<p>Correlation ID: <code>{html_escape(correlation_id)}</code></p>",
status_code=400,
)
else:
logger.warning("No refresh token in token response - cannot store session")
logger.debug(
"Storing refresh token for user_id=%s state=%s... scopes=%s expires_at=%s",
user_id,
state[:16],
granted_scopes,
refresh_expires_at,
)
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
expires_at=refresh_expires_at,
flow_type="browser", # Browser-based login flow
provisioning_client_id=state, # Store state for unified session lookup
scopes=granted_scopes,
)
logger.info(
"Refresh token stored for user %s (lookup key: %s...)",
user_id,
state[:16],
)
# Query and cache user profile (for browser UI display)
access_token = token_data.get("access_token")
@@ -445,49 +627,177 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if profile_data:
# Cache profile for browser UI (no token needed to display)
await storage.store_user_profile(user_id, profile_data)
logger.info(f"User profile cached for {user_id}")
logger.debug("User profile cached for %s", user_id)
else:
logger.warning(f"Failed to query userinfo endpoint for {user_id}")
logger.warning("Failed to query userinfo endpoint for %s", user_id)
else:
logger.warning("Could not determine userinfo endpoint")
except Exception as e:
logger.error(f"Error caching user profile: {e}")
logger.error("Error caching user profile: %s", 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)
# CSRF protection is layered: ``SameSite=Lax`` blocks cross-site POSTs
# in modern browsers; ``oauth_logout`` is POST-only with an Origin /
# Referer check (``_origin_matches_self``) to cover older browsers and
# non-browser clients. ``HttpOnly`` blocks JS exfiltration on XSS;
# ``Secure`` is gated to non-HTTP hosts in dev (PR #758 round-4 review
# nit 6).
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.
async def oauth_logout(request: Request) -> RedirectResponse | JSONResponse:
"""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.
Method is POST-only at the route layer to defeat passive CSRF (PR #758
round-3 review hardening). Origin / Referer headers are also validated
against the configured ``mcp_server_url`` when present, blocking
same-method-but-cross-origin form submissions.
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")
next_url = _safe_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)
# CSRF check: when Origin or Referer is present, host must match the
# MCP server's own host. Per OWASP CSRF cheat sheet, we allow the
# request through when neither header is present (some user agents
# strip both for privacy on same-origin POST).
if oauth_ctx and not _origin_matches_self(request, oauth_ctx):
logger.warning(
"Logout blocked: cross-origin request from %s",
request.headers.get("origin") or request.headers.get("referer"),
)
return JSONResponse({"error": "forbidden"}, status_code=403)
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)
except Exception as e:
# Logout must always succeed locally; log and continue.
logger.warning("Logout cleanup failed (continuing): %s", e)
finally:
# Always drop the browser_sessions row, even when the
# refresh-token cleanup above failed — otherwise an orphan
# row lingers until the hourly cleanup cron (PR #758 round-5
# review medium 1). Not exploitable (SessionAuthBackend
# already rejects sessions without a live refresh token), but
# a correctness gap worth closing here.
try:
await storage.delete_browser_session(session_id)
except Exception as e:
logger.warning(
"Failed to delete browser session %s…: %s", session_id[:8], e
)
response = RedirectResponse(next_url, status_code=302)
response.delete_cookie("mcp_session")
# Match the attributes from set_cookie so browsers reliably evict the
# cookie even on edge-case implementations that consider security flags
# when matching for deletion.
response.delete_cookie(
"mcp_session",
httponly=True,
secure=_should_use_secure_cookies(),
samesite="lax",
)
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.
"""
# Production oauth_context nests config under "config" (see app.py
# starlette_lifespan). A flat shape is also accepted for tests and
# historical callers.
cfg = oauth_ctx.get("config") or oauth_ctx
settings = get_settings()
try:
discovery_url = cfg.get("discovery_url") or settings.oidc_discovery_url
if not discovery_url and settings.nextcloud_host:
# Strip trailing slash so a host configured as
# ``https://cloud.example.com/`` doesn't produce a double-slash
# in the well-known URL (PR #758 round-4 review nit 5).
discovery_url = (
f"{settings.nextcloud_host.rstrip('/')}"
"/.well-known/openid-configuration"
)
if not discovery_url:
return
# Re-use the shared 5-minute discovery cache (PR #758 nit 6) so a
# burst of logouts doesn't hammer the IdP's discovery endpoint.
discovery = await get_oidc_discovery(discovery_url)
revocation_endpoint = discovery.get("revocation_endpoint")
if not revocation_endpoint:
logger.debug("IdP advertises no revocation_endpoint; skipping")
return
client_id = cfg.get("client_id") or settings.oidc_client_id
client_secret = cfg.get("client_secret") or settings.oidc_client_secret
if not (client_id and client_secret):
logger.debug("No OIDC client credentials available for revocation")
return
async with nextcloud_httpx_client() as http_client:
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)
+146 -85
View File
@@ -30,13 +30,17 @@ 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,
get_oidc_discovery,
verify_id_token,
)
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
@@ -88,6 +92,7 @@ class ASProxySession:
code_challenge: str
code_challenge_method: str
requested_scopes: str
nonce: str
created_at: float = field(default_factory=time.time)
expires_at: float = field(default_factory=lambda: time.time() + 600)
@@ -100,10 +105,6 @@ class ASProxySession:
_proxy_codes: dict[str, ProxyCodeEntry] = {}
_as_proxy_sessions: dict[str, ASProxySession] = {}
# OIDC discovery document cache (URL → (expires_at, data))
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_DISCOVERY_CACHE_TTL = 300 # 5 minutes
# DCR rate limiting (IP → [timestamps])
_dcr_rate_limit: dict[str, list[float]] = {}
_DCR_RATE_LIMIT_MAX = 10 # max requests
@@ -135,26 +136,6 @@ def _transform_scopes_for_idp(scopes: str, resource_server_id: str) -> str:
)
async def _get_cached_discovery(url: str) -> dict[str, Any]:
"""Fetch OIDC discovery document with caching (5-minute TTL).
Follows redirects so the configured discovery URL works against Nextcloud
instances without pretty URLs enabled, where ``/.well-known/openid-configuration``
issues a 301 to ``/index.php/.well-known/openid-configuration``.
"""
now = time.time()
if url in _discovery_cache:
expires_at, data = _discovery_cache[url]
if now < expires_at:
return data
async with nextcloud_httpx_client(follow_redirects=True) as http_client:
response = await http_client.get(url)
response.raise_for_status()
data = response.json()
_discovery_cache[url] = (now + _DISCOVERY_CACHE_TTL, data)
return data
def _cleanup_expired_proxy_codes() -> None:
"""Remove expired proxy codes and sessions."""
now = time.time()
@@ -286,7 +267,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
)
if not is_valid:
logger.warning(f"Client validation failed: {error_msg}")
logger.warning("Client validation failed: %s", error_msg)
return JSONResponse(
{
"error": "unauthorized_client",
@@ -313,6 +294,10 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
# We do NOT forward PKCE to Nextcloud — the MCP server is a confidential client.
server_state = secrets.token_urlsafe(32)
# OIDC nonce binds the IdP's ID token to THIS authorization request,
# blocking ID-token replay across flows (PR #758 round-2 finding 2).
server_nonce = secrets.token_urlsafe(32)
requested_scope = request.query_params.get("scope", "")
default_scopes = "openid profile email"
resource_scopes = oauth_config.get("scopes", "")
@@ -330,6 +315,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
requested_scopes=scopes,
nonce=server_nonce,
)
# Use MCP server's own client_id with Nextcloud
@@ -340,10 +326,10 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
callback_uri = f"{mcp_server_url}/oauth/callback"
logger.info("AS Proxy: Intermediary authorization flow")
logger.info(f" Client: {client_id}")
logger.info(f" MCP server client_id: {mcp_server_client_id}")
logger.info(f" Server callback: {callback_uri}")
logger.info(f" Scopes: {scopes}")
logger.info(" Client: %s", client_id)
logger.info(" MCP server client_id: %s", mcp_server_client_id)
logger.info(" Server callback: %s", callback_uri)
logger.info(" Scopes: %s", scopes)
# Discover Nextcloud authorization endpoint
discovery_url = oauth_config.get("discovery_url")
@@ -356,7 +342,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
discovery = await get_oidc_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access
@@ -383,7 +369,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
)
idp_scope_str = _transform_scopes_for_idp(scopes, resource_server_id)
if resource_server_id:
logger.info(f" IdP scopes (prefixed): {idp_scope_str}")
logger.info(" IdP scopes (prefixed): %s", idp_scope_str)
# Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client)
idp_params = {
@@ -392,12 +378,13 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
"response_type": "code",
"scope": idp_scope_str,
"state": server_state,
"nonce": server_nonce,
"prompt": "consent",
"resource": f"{mcp_server_url}/mcp", # MCP server audience
}
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
logger.info("Redirecting to Nextcloud OIDC: %s", auth_url.split("?")[0])
return RedirectResponse(auth_url, status_code=302)
@@ -466,7 +453,7 @@ async def oauth_authorize_nextcloud(
# supporting the offline_access scope.
discovery_url = oauth_config.get("discovery_url")
if discovery_url:
disc = await _get_cached_discovery(discovery_url)
disc = await get_oidc_discovery(discovery_url)
scopes_supported = disc.get("scopes_supported")
if scopes_supported is None or "offline_access" in scopes_supported:
scopes += " offline_access"
@@ -478,7 +465,12 @@ async def oauth_authorize_nextcloud(
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
# Store code_verifier in session for retrieval during callback
# OIDC nonce binds the IdP-returned ID token to THIS auth request
# (PR #758 round-3 finding 1). Browser flow + AS proxy already do
# this; Flow 2 is the third path and was missing it.
nonce = secrets.token_urlsafe(32)
# Store code_verifier + nonce in session for retrieval during callback
storage = oauth_ctx["storage"]
await storage.store_oauth_session(
session_id=state,
@@ -487,7 +479,11 @@ async def oauth_authorize_nextcloud(
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
# `mcp_authorization_code` field reused to store the PKCE
# code_verifier (one-time-use). Renaming the column requires a
# schema migration.
mcp_authorization_code=code_verifier,
nonce=nonce,
flow_type="flow2",
ttl_seconds=600, # 10 minutes
)
@@ -503,7 +499,7 @@ async def oauth_authorize_nextcloud(
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
discovery = await get_oidc_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access
@@ -525,6 +521,7 @@ async def oauth_authorize_nextcloud(
"response_type": "code",
"scope": scopes,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Force consent to show resource access
@@ -559,7 +556,7 @@ async def oauth_callback_nextcloud(request: Request):
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"Flow 2 authorization error: {error} - {error_description}")
logger.error("Flow 2 authorization error: %s - %s", error, error_description)
return JSONResponse(
{
"error": error,
@@ -585,15 +582,32 @@ async def oauth_callback_nextcloud(request: Request):
storage: RefreshTokenStorage = oauth_ctx["storage"]
oauth_config = oauth_ctx["config"]
# Retrieve code_verifier from session storage (PKCE required by Nextcloud OIDC)
code_verifier = ""
# Retrieve code_verifier + nonce from session storage (PKCE + OIDC
# nonce binding both required for Flow 2 — round-3 finding 1). Fail
# closed when the row is missing/expired so PKCE + nonce verification
# are not silently bypassed (round-6 review).
oauth_session = await storage.get_oauth_session(state)
if oauth_session:
# code_verifier was stored in mcp_authorization_code field
code_verifier = oauth_session.get("mcp_authorization_code", "")
logger.info(
f"Retrieved code_verifier for Flow 2 callback (state={state[:16]}...)"
if not oauth_session:
logger.warning("Flow 2 callback received unknown/expired state=%s", state[:16])
return JSONResponse(
{
"error": "invalid_request",
"error_description": (
"Unknown or expired session — please retry the OAuth flow"
),
},
status_code=400,
)
# `mcp_authorization_code` field reused to store the PKCE code_verifier
# (one-time-use). Renaming the column requires a schema migration.
code_verifier = oauth_session.get("mcp_authorization_code", "")
nonce = oauth_session.get("nonce")
logger.info("Retrieved code_verifier for Flow 2 callback (state=%s…)", state[:16])
# One-time-use session: delete eagerly so the stored code_verifier
# can't be replayed for the remainder of the oauth_sessions TTL.
# Mirrors browser_oauth_routes.oauth_login_callback (PR #758
# follow-up review).
await storage.delete_oauth_session(state)
# Exchange code for tokens
mcp_server_client_id = os.getenv(
@@ -615,7 +629,7 @@ async def oauth_callback_nextcloud(request: Request):
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
discovery = await get_oidc_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Build token exchange params
@@ -643,23 +657,36 @@ 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).
# ``expected_nonce`` is the per-request nonce stored on the
# oauth_session row (PR #758 round-3 finding 1). ``nonce`` is already
# ``str | None`` and ``secrets.token_urlsafe`` never produces an empty
# string, so passing it directly is correct — pre-migration-006 rows
# surface as ``None`` from ``oauth_session.get("nonce")``, which
# ``verify_id_token`` already treats as "skip the check".
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,
expected_nonce=nonce,
)
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:
@@ -672,17 +699,22 @@ async def oauth_callback_nextcloud(request: Request):
refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None
if refresh_expires_in:
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
logger.info(f" refresh_expires_at: {refresh_expires_at}")
# Some IdPs (e.g. AWS Cognito) return refresh_expires_in as a JSON
# string rather than an int; coerce to be safe.
refresh_expires_at = int(time.time()) + int(refresh_expires_in)
logger.debug(" refresh_expires_in: %ss", refresh_expires_in)
logger.debug(" refresh_expires_at: %s", refresh_expires_at)
logger.info("Storing refresh token:")
logger.info(f" user_id: {user_id}")
logger.info(" flow_type: flow2")
logger.info(" token_audience: nextcloud")
logger.info(f" provisioning_client_id: {state[:16]}...")
logger.info(f" scopes: {granted_scopes}")
logger.info(f" expires_at: {refresh_expires_at}")
# Identity-bearing fields stay at DEBUG so they don't reach
# multi-tenant log aggregation on every Flow 2 provision (PR #758
# round-7 minor).
logger.debug("Storing refresh token:")
logger.debug(" user_id: %s", user_id)
logger.debug(" flow_type: flow2")
logger.debug(" token_audience: nextcloud")
logger.debug(" provisioning_client_id: %s...", state[:16])
logger.debug(" scopes: %s", granted_scopes)
logger.debug(" expires_at: %s", refresh_expires_at)
await storage.store_refresh_token(
user_id=user_id,
@@ -693,8 +725,8 @@ async def oauth_callback_nextcloud(request: Request):
scopes=granted_scopes,
expires_at=refresh_expires_at,
)
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
logger.info("=" * 60)
logger.debug("✓ Stored Flow 2 master refresh token for user %s", user_id)
logger.debug("=" * 60)
# Return success HTML page
success_html = """
@@ -775,7 +807,7 @@ async def oauth_callback(request: Request):
oauth_session.get("flow_type", "browser") if oauth_session else "browser"
)
logger.info(f"Unified callback: flow_type={flow_type} (from session lookup)")
logger.info("Unified callback: flow_type=%s (from session lookup)", flow_type)
if flow_type == "flow2":
# Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
@@ -789,7 +821,7 @@ async def oauth_callback(request: Request):
else:
# Unknown flow type
logger.warning(f"Unknown flow_type in OAuth session: {flow_type}")
logger.warning("Unknown flow_type in OAuth session: %s", flow_type)
return JSONResponse(
{
"error": "invalid_request",
@@ -819,7 +851,7 @@ async def _oauth_callback_as_proxy(
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"AS proxy callback error: {error} - {error_description}")
logger.error("AS proxy callback error: %s - %s", error, error_description)
# Retrieve session to redirect back to client with error
session = _as_proxy_sessions.pop(server_state, None)
@@ -903,7 +935,7 @@ async def _oauth_callback_as_proxy(
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
discovery = await get_oidc_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
@@ -940,6 +972,35 @@ async def _oauth_callback_as_proxy(
f"(token_type={nc_token_response.get('token_type')})"
)
# Verify the ID token signature + claims before caching the response
# (PR #758 finding 1). Without this, a compromised IdP or tampered
# transport could plant arbitrary identity claims into the proxy code
# entry that gets handed back to the MCP client. Mirrors the
# verification done in oauth_callback_nextcloud.
#
# ``expected_nonce`` is the per-request nonce we forwarded to the IdP
# in oauth_authorize (PR #758 round-2 finding 2). ASProxySession is
# in-memory only and ``nonce`` is now a required field, so for any
# session created via the current code path this is always set; the
# ``or None`` is defence-in-depth and a no-op in practice.
id_token = nc_token_response.get("id_token")
try:
await verify_id_token(
id_token,
discovery_url=discovery_url,
expected_audience=mcp_server_client_id,
expected_nonce=session.nonce or None,
)
except IdTokenVerificationError as e:
logger.error("AS proxy: ID token verification failed: %s", e)
return JSONResponse(
{
"error": "invalid_token",
"error_description": "ID token failed verification",
},
status_code=400,
)
# Generate a proxy authorization code for the client
proxy_code = secrets.token_urlsafe(32)
_proxy_codes[proxy_code] = ProxyCodeEntry(
@@ -1145,7 +1206,7 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
)
if not _verify_pkce_s256(code_verifier, entry.code_challenge):
logger.warning(f"PKCE verification failed for client {entry.client_id}")
logger.warning("PKCE verification failed for client %s", entry.client_id)
return JSONResponse(
{
"error": "invalid_grant",
@@ -1155,7 +1216,7 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
)
logger.info(
f"AS proxy token: Returning Nextcloud token for client {entry.client_id}"
"AS proxy token: Returning Nextcloud token for client %s", entry.client_id
)
# Return the stored Nextcloud token response directly
@@ -1216,7 +1277,7 @@ async def _token_refresh(request: Request, form) -> JSONResponse:
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
discovery = await get_oidc_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Proxy refresh request to Nextcloud
@@ -1288,7 +1349,7 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
# Remove timestamps outside the window
timestamps = [t for t in timestamps if now - t < _DCR_RATE_LIMIT_WINDOW]
if len(timestamps) >= _DCR_RATE_LIMIT_MAX:
logger.warning(f"DCR rate limit exceeded for {client_ip}")
logger.warning("DCR rate limit exceeded for %s", client_ip)
return JSONResponse(
{
"error": "too_many_requests",
@@ -1305,7 +1366,7 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
registration_endpoint = None
if discovery_url:
try:
discovery = await _get_cached_discovery(discovery_url)
discovery = await get_oidc_discovery(discovery_url)
registration_endpoint = discovery.get("registration_endpoint")
except Exception:
logger.warning("Failed to fetch OIDC discovery for DCR endpoint")
@@ -1324,7 +1385,7 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
status_code=400,
)
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
logger.info("DCR proxy: Forwarding registration to %s", registration_endpoint)
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
@@ -1360,7 +1421,7 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
redirect_uris=redirect_uris,
name=client_name,
)
logger.info(f"DCR proxy: Registered client {new_client_id} in local registry")
logger.info("DCR proxy: Registered client %s in local registry", new_client_id)
return JSONResponse(nc_response, status_code=response.status_code)
@@ -9,12 +9,12 @@ 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
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.storage import get_shared_storage
logger = logging.getLogger(__name__)
@@ -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(
@@ -84,9 +80,9 @@ def require_provisioning(func: Callable) -> Callable:
)
)
# Check provisioning status
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Check provisioning status — share the process-wide singleton
# rather than initialising a new sqlite handle per tool call.
storage = await get_shared_storage()
refresh_data = await storage.get_refresh_token(user_id)
@@ -149,17 +145,12 @@ 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
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Check provisioning status using the shared singleton.
storage = await get_shared_storage()
refresh_data = await storage.get_refresh_token(user_id)
+44 -19
View File
@@ -22,6 +22,18 @@ class SessionAuthBackend(AuthenticationBackend):
For BasicAuth mode: Always authenticates as the configured user.
For OAuth mode: Checks for valid session cookie with stored refresh token.
Behavior note — silent invalidation on refresh-token TTL expiry:
The OAuth path requires *both* a live ``browser_sessions`` row and a
live ``refresh_tokens`` row for the resolved user. Logout deletes
both atomically, so a logged-out user always fails closed here.
However, if the refresh token expires by TTL (without an explicit
logout) the row is removed by ``get_refresh_token`` and the browser
session becomes unusable — the user simply gets redirected to
``/oauth/login``. This is intentional defense-in-depth: the
refresh-token check is what makes a leaked or stale browser cookie
unusable after revocation. Do not relax this without first removing
the cleanup invariant on logout (PR #758 round-4 review medium 2).
"""
def __init__(self, oauth_enabled: bool = False):
@@ -52,45 +64,58 @@ 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,
)
# Proactively evict the orphan so the table doesn't accumulate
# rows that the auth check will keep rejecting until TTL
# cleanup (PR #758 round-7 minor).
try:
await storage.delete_browser_session(session_id)
except Exception as e:
logger.warning(
"Failed to delete orphaned browser session %s…: %s",
session_id[:8],
e,
)
return None
return AuthCredentials(["authenticated"]), SimpleUser(username)
return AuthCredentials(["authenticated"]), SimpleUser(user_id)
except Exception as e:
logger.warning(f"Session validation error: {e}")
logger.warning("Session validation error: %s", e)
return None
+223 -39
View File
@@ -29,9 +29,10 @@ import json
import logging
import os
import socket
import sqlite3
import time
from pathlib import Path
from typing import Any, Optional
from typing import Any
import aiosqlite
import anyio
@@ -139,10 +140,25 @@ class RefreshTokenStorage:
1. New database: Run migrations from scratch
2. Pre-Alembic database: Stamp with initial revision (no changes)
3. Alembic-managed database: Upgrade to latest version
Raises:
RuntimeError: when the underlying SQLite library is older than
3.35, which is required for ``DELETE ... RETURNING`` used by
``delete_browser_session`` (PR #758 round-5 review low 2).
Ubuntu 20.04 ships SQLite 3.31, so deployers on that
baseline must upgrade or use a newer Python image.
"""
if self._initialized:
return
if sqlite3.sqlite_version_info < (3, 35):
raise RuntimeError(
"SQLite >= 3.35 is required (DELETE ... RETURNING is used "
"by delete_browser_session); detected "
f"{sqlite3.sqlite_version}. Upgrade SQLite or use a Python "
"image with a newer bundled libsqlite3."
)
# Ensure directory exists
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
@@ -205,11 +221,11 @@ class RefreshTokenStorage:
self,
user_id: str,
refresh_token: str,
expires_at: Optional[int] = None,
expires_at: int | None = None,
flow_type: str = "hybrid",
token_audience: str = "nextcloud",
provisioning_client_id: Optional[str] = None,
scopes: Optional[list[str]] = None,
provisioning_client_id: str | None = None,
scopes: list[str] | None = None,
) -> None:
"""
Store encrypted refresh token for user.
@@ -227,8 +243,14 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
encrypted_token = self.cipher.encrypt(refresh_token.encode())
now = int(time.time())
scopes_json = json.dumps(scopes) if scopes else None
@@ -313,7 +335,7 @@ class RefreshTokenStorage:
logger.debug(f"Cached user profile for {user_id}")
async def get_user_profile(self, user_id: str) -> Optional[dict[str, Any]]:
async def get_user_profile(self, user_id: str) -> dict[str, Any] | None:
"""
Retrieve cached user profile data.
@@ -351,7 +373,7 @@ class RefreshTokenStorage:
return profile_data
async def get_refresh_token(self, user_id: str) -> Optional[dict]:
async def get_refresh_token(self, user_id: str) -> dict | None:
"""
Retrieve and decrypt refresh token for user.
@@ -374,8 +396,14 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
start_time = time.time()
try:
@@ -444,7 +472,7 @@ class RefreshTokenStorage:
async def get_refresh_token_by_provisioning_client_id(
self, provisioning_client_id: str
) -> Optional[dict]:
) -> dict | None:
"""
Retrieve and decrypt refresh token by provisioning_client_id (state parameter).
@@ -461,8 +489,14 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
@@ -617,8 +651,8 @@ class RefreshTokenStorage:
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: Optional[str] = None,
registration_client_uri: Optional[str] = None,
registration_access_token: str | None = None,
registration_client_uri: str | None = None,
) -> None:
"""
Store encrypted OAuth client credentials.
@@ -635,8 +669,14 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
# Encrypt sensitive data
encrypted_secret = self.cipher.encrypt(client_secret.encode())
@@ -689,7 +729,7 @@ class RefreshTokenStorage:
auth_method="oauth",
)
async def get_oauth_client(self) -> Optional[dict]:
async def get_oauth_client(self) -> dict | None:
"""
Retrieve and decrypt OAuth client credentials.
@@ -708,8 +748,14 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# ``assert`` is stripped under ``python -O``, which would silently
# turn a missing TOKEN_ENCRYPTION_KEY into an ``AttributeError`` on
# the next ``self.cipher.encrypt(...)``. Raise explicitly instead
# (PR #758 round-4 review medium 1).
if self.cipher is None:
raise RuntimeError(
"TOKEN_ENCRYPTION_KEY is not set — token storage operations unavailable"
)
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
@@ -827,9 +873,9 @@ class RefreshTokenStorage:
self,
event: str,
user_id: str,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
auth_method: Optional[str] = None,
resource_type: str | None = None,
resource_id: str | None = None,
auth_method: str | None = None,
) -> None:
"""
Log operation to audit log.
@@ -866,8 +912,8 @@ class RefreshTokenStorage:
async def get_audit_logs(
self,
user_id: Optional[str] = None,
since: Optional[int] = None,
user_id: str | None = None,
since: int | None = None,
limit: int = 100,
) -> list[dict]:
"""
@@ -909,14 +955,15 @@ class RefreshTokenStorage:
self,
session_id: str,
client_redirect_uri: str,
state: Optional[str] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
mcp_authorization_code: Optional[str] = None,
client_id: Optional[str] = None,
state: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
mcp_authorization_code: str | None = None,
client_id: str | None = None,
flow_type: str = "hybrid",
is_provisioning: bool = False,
requested_scopes: Optional[str] = None,
requested_scopes: str | None = None,
nonce: str | None = None,
ttl_seconds: int = 600, # 10 minutes
) -> None:
"""
@@ -933,6 +980,8 @@ class RefreshTokenStorage:
flow_type: Type of flow ('hybrid', 'flow1', 'flow2')
is_provisioning: Whether this is a Flow 2 provisioning session
requested_scopes: Requested OAuth scopes
nonce: OIDC ``nonce`` value bound to this auth request, returned
in the ID token and verified on callback (PR #758 finding 2).
ttl_seconds: Session TTL in seconds
"""
if not self._initialized:
@@ -947,8 +996,8 @@ class RefreshTokenStorage:
INSERT INTO oauth_sessions
(session_id, client_id, client_redirect_uri, state, code_challenge,
code_challenge_method, mcp_authorization_code, flow_type,
is_provisioning, requested_scopes, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_provisioning, requested_scopes, nonce, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id,
@@ -961,6 +1010,7 @@ class RefreshTokenStorage:
flow_type,
is_provisioning,
requested_scopes,
nonce,
now,
expires_at,
),
@@ -969,7 +1019,7 @@ class RefreshTokenStorage:
logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)")
async def get_oauth_session(self, session_id: str) -> Optional[dict]:
async def get_oauth_session(self, session_id: str) -> dict | None:
"""
Retrieve OAuth session by session ID.
@@ -1001,7 +1051,7 @@ class RefreshTokenStorage:
async def get_oauth_session_by_mcp_code(
self, mcp_authorization_code: str
) -> Optional[dict]:
) -> dict | None:
"""
Retrieve OAuth session by MCP authorization code.
@@ -1037,9 +1087,9 @@ class RefreshTokenStorage:
async def update_oauth_session(
self,
session_id: str,
user_id: Optional[str] = None,
idp_access_token: Optional[str] = None,
idp_refresh_token: Optional[str] = None,
user_id: str | None = None,
idp_access_token: str | None = None,
idp_refresh_token: str | None = None,
) -> bool:
"""
Update OAuth session with IdP token data.
@@ -1133,6 +1183,140 @@ 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,
)
# Audit log to match the pattern used by the other security-relevant
# storage operations (PR #758 round-3 nit 5). Browser session
# establishment is a security-relevant event.
await self._audit_log(
event="create_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
async def get_browser_session_user(self, session_id: str) -> str | None:
"""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()
# DELETE ... RETURNING (SQLite ≥ 3.35) reads ``user_id`` atomically
# with the delete itself, so the audit log can't race against a
# concurrent delete that empties the row between SELECT and DELETE
# (PR #758 round-3 review).
user_id: str | None = None
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"DELETE FROM browser_sessions WHERE session_id = ? RETURNING user_id",
(session_id,),
) as cursor:
row = await cursor.fetchone()
await db.commit()
deleted = row is not None
if deleted:
user_id = row[0]
logger.debug("Deleted browser session %s", session_id[:8])
if user_id:
await self._audit_log(
event="delete_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
return deleted
async def cleanup_expired_browser_sessions(self) -> int:
"""Remove expired ``browser_sessions`` rows.
Returns the number of rows deleted. Called by the periodic cleanup
task in ``app.py``. Without this users who never explicitly log out
leave session rows behind that only get deleted lazily on lookup
(PR #758 finding 6).
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM browser_sessions WHERE expires_at < ?", (now,)
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info("Cleaned up %s expired browser session(s)", deleted)
return deleted
# ============================================================================
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
# ============================================================================
@@ -1312,7 +1496,7 @@ class RefreshTokenStorage:
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> Optional[str]:
async def get_app_password(self, user_id: str) -> str | None:
"""
Retrieve and decrypt app password for a user.
@@ -285,14 +285,18 @@
<ul class="app-navigation__settings">
<li class="app-navigation-entry">
<div class="app-navigation-entry__wrapper">
<a href="{{ logout_url }}" class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Logout</span>
</a>
{# Logout is POST-only to defeat CSRF (PR #758 finding 5).
Style this <button> like the surrounding link entries. #}
<form method="post" action="{{ logout_url }}" class="app-navigation-entry-link" style="display:contents;">
<button type="submit" class="app-navigation-entry-link" style="background:none;border:0;padding:0;font:inherit;color:inherit;cursor:pointer;display:flex;align-items:center;width:100%;">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Logout</span>
</button>
</form>
</div>
</li>
</ul>
-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).
+265 -55
View File
@@ -5,81 +5,291 @@ between server/ and auth/ layers.
"""
import logging
import os
import secrets
import time
from typing import Any
import anyio
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 mcp.shared.exceptions import McpError
from mcp.types import ErrorData
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).
# OIDC discovery + JWKS caches keyed by URL → (expires_at, data). Single
# source of truth for the codebase: oauth_routes / browser_oauth_routes both
# go through ``get_oidc_discovery`` which reads/writes _discovery_cache, so
# the first discovery fetch primes the cache for all later callers (PR #758
# round-2 nit 3). 5-minute TTL.
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_OIDC_CACHE_TTL = 300
Handles both JWT and opaque tokens:
- JWT: Decode and extract 'sub' claim
- Opaque: Call userinfo endpoint to get 'sub'
# Per-URL fetch locks coalesce concurrent cache misses into a single HTTP
# request, preventing thundering-herd against the IdP at cache expiry
# (PR #758 round-3 review). Mirrors the lock-dict + meta-lock idiom from
# token_broker.py.
_fetch_locks: dict[str, anyio.Lock] = {}
_fetch_locks_lock = anyio.Lock()
async def _get_fetch_lock(url: str) -> anyio.Lock:
"""Return the per-URL lock used to serialise cache-miss fetches."""
async with _fetch_locks_lock:
lock = _fetch_locks.get(url)
if lock is None:
lock = anyio.Lock()
_fetch_locks[url] = lock
return lock
class IdTokenVerificationError(Exception):
"""Raised when an OIDC ID token fails signature or claim verification."""
async def _get_cached(
cache: dict[str, tuple[float, dict[str, Any]]],
url: str,
*,
follow_redirects: bool = False,
) -> dict[str, Any]:
"""Return cached JSON response for *url* or fetch + cache on miss/expiry.
``follow_redirects`` is forwarded to ``nextcloud_httpx_client``: discovery
fetches against Nextcloud without pretty URLs need it (the configured
``/.well-known/openid-configuration`` path issues a 301), but JWKS
fetches deliberately stay strict — the URL came from the discovery
document we already trust, so a redirect there would be suspicious.
Concurrent callers seeing the same cache miss are coalesced via a
per-URL ``anyio.Lock``: only one fetch runs, the rest wait and read the
populated cache.
"""
entry = cache.get(url)
if entry is not None and time.time() < entry[0]:
return entry[1]
lock = await _get_fetch_lock(url)
try:
async with lock:
# Re-check inside the lock — a concurrent waiter may have already
# populated the cache before we acquired it.
entry = cache.get(url)
if entry is not None and time.time() < entry[0]:
return entry[1]
async with nextcloud_httpx_client(
follow_redirects=follow_redirects
) as http_client:
response = await http_client.get(url)
response.raise_for_status()
data = response.json()
cache[url] = (time.time() + _OIDC_CACHE_TTL, data)
return data
finally:
# Drop the dict entry so a misconfigured deployment hitting
# arbitrary URLs can't grow ``_fetch_locks`` without bound (PR #758
# round-4 review nit 4). Already-queued waiters share our local
# ``lock`` reference and remain coalesced; new arrivals lazily
# recreate a lock — by which time the cache is populated, so they
# short-circuit before reaching the lock anyway.
async with _fetch_locks_lock:
if _fetch_locks.get(url) is lock:
del _fetch_locks[url]
async def get_oidc_discovery(discovery_url: str) -> dict[str, Any]:
"""Return the cached OIDC discovery document for *discovery_url*.
Shares the 5-minute discovery cache used by `verify_id_token`, so a
callback that does discovery → token-exchange → ID-token verification
reuses one HTTP round-trip instead of three. The fetch follows
redirects because Nextcloud without pretty URLs returns 301 from
``/.well-known/openid-configuration`` to ``/index.php/.well-known/...``.
Single source of truth for OIDC discovery in the codebase
(PR #758 round-2 nit 3).
"""
return await _get_cached(_discovery_cache, discovery_url, follow_redirects=True)
async def verify_id_token(
id_token: str | None,
*,
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.
"""
if not id_token:
raise IdTokenVerificationError("ID token missing from token response")
try:
discovery = await get_oidc_discovery(discovery_url)
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_data = await _get_cached(_jwks_cache, jwks_uri)
except IdTokenVerificationError:
raise
except Exception as e:
raise IdTokenVerificationError(
f"Failed to fetch OIDC discovery / JWKS: {e}"
) from e
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:
# Cache miss may indicate IdP key rotation. Refresh JWKS once
# before giving up, per OIDC core §10.1.1: when an unrecognised
# `kid` arrives the relying party should refetch the JWKS rather
# than waiting for cache TTL to elapse.
_jwks_cache.pop(jwks_uri, None)
try:
jwks_data = await _get_cached(_jwks_cache, jwks_uri)
jwks = PyJWKSet.from_dict(jwks_data)
signing_key = jwks[kid]
except KeyError as e:
raise IdTokenVerificationError(
f"No JWKS key matches ID token kid {kid!r}"
) from e
except Exception as e:
raise IdTokenVerificationError(
f"Failed to refresh JWKS after kid miss: {e}"
) from e
# PyJWT verifies the JWT with the algorithm declared in its header,
# cross-checked against this allowlist (so an attacker can't downgrade
# to ``none`` or HMAC). The allowlist covers the OIDC algorithms
# most cloud IdPs ship by default:
# - RS256: Nextcloud user_oidc, Keycloak default, Auth0, Google.
# - PS256: Azure AD on newer keys.
# - ES256: some Keycloak realms, AWS Cognito user pools.
# Symmetric (HSxxx) and ``none`` are intentionally absent.
payload: dict[str, Any] = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256", "PS256", "ES256"],
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
# Constant-time comparison mirrors the PKCE verifier check
# (oauth_routes.py:1029) — short-circuit `!=` is avoided in
# security-sensitive equality even when the secret is server-generated
# (round-6 review).
if expected_nonce is not None and not secrets.compare_digest(
payload.get("nonce", "") or "", 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. Intentionally unused — kept on
the public signature so call sites can pass the FastMCP Context
they already hold without rewriting; identity is read from the
verifier-populated AccessToken via get_access_token().
Returns:
user_id from the verified token, or ``"default_user"`` when no
access token is present at all (BasicAuth mode — there is no
OAuth identity to extract, so the sentinel is returned and the
caller's BasicAuth branch handles it).
Raises:
McpError: An access token was present but had no ``sub`` claim
(``access_token.resource`` empty). Failing closed prevents a
malformed IdP token from silently bucketing every request
under the ``"default_user"`` key in SQLite, which would risk
cross-tenant data exposure (PR #758 follow-up review).
"""
# Use MCP SDK's get_access_token() which uses contextvars
access_token: AccessToken | None = get_access_token()
if not access_token or not access_token.token:
logger.warning("No access token found via get_access_token()")
if not access_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",
user_id = access_token.resource
if not user_id:
logger.error(
"Access token has no resource (sub) claim — verifier should have rejected it"
)
raise McpError(
ErrorData(
# JSON-RPC 2.0 reserves -32000..-32099 for application errors.
code=-32001,
message="Cannot determine user identity from access token",
)
)
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
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")
except Exception as e:
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
# Fallback
logger.warning(" Using fallback user_id: default_user")
return "default_user"
return user_id
+116 -146
View File
@@ -9,7 +9,6 @@ import logging
import os
import secrets
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode
from mcp.server.fastmcp import Context
@@ -18,7 +17,7 @@ from pydantic import BaseModel, Field
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Re-export for backward compatibility — canonical location is auth.token_utils
@@ -34,17 +33,17 @@ class ProvisioningStatus(BaseModel):
"""Status of Nextcloud provisioning for a user."""
is_provisioned: bool = Field(description="Whether Nextcloud access is provisioned")
provisioned_at: Optional[str] = Field(
provisioned_at: str | None = Field(
None, description="ISO timestamp when provisioned"
)
credential_type: Optional[str] = Field(
credential_type: str | None = Field(
None, description="Type of credential ('refresh_token' or 'app_password')"
)
client_id: Optional[str] = Field(
client_id: str | None = Field(
None, description="Client ID that initiated the original Flow 1"
)
scopes: Optional[list[str]] = Field(None, description="Granted scopes")
flow_type: Optional[str] = Field(
scopes: list[str] | None = Field(None, description="Granted scopes")
flow_type: str | None = Field(
None, description="Type of flow used ('hybrid', 'flow1', 'flow2')"
)
@@ -53,7 +52,7 @@ class ProvisioningResult(BaseModel):
"""Result of provisioning attempt."""
success: bool = Field(description="Whether provisioning was initiated")
provisioning_url: Optional[str] = Field(
provisioning_url: str | None = Field(
None, description="URL to Astrolabe settings for provisioning background sync"
)
message: str = Field(description="Status message for the user")
@@ -78,10 +77,15 @@ class LoginConfirmation(BaseModel):
)
async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
async def _get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
"""
Check the provisioning status for Nextcloud access.
Internal helper — leading underscore signals that ``user_id`` is a
trusted identity claim that callers MUST derive from the verified
access token. The MCP tool wrappers in ``register_oauth_tools`` are
the only legitimate callers (PR #758 round-3 finding 3).
Checks for both credential types:
1. App password from Astrolabe (works today)
2. OAuth refresh token from storage (for future)
@@ -106,8 +110,12 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
status = await astrolabe.get_background_sync_status(user_id)
if status.get("has_access"):
logger.info(
f" get_provisioning_status: ✓ App password FOUND for user_id={user_id}"
# Demoted to debug (PR #758 round-2 nit 4): user_id ends up
# in log aggregation on every call, which is noise in a
# multi-tenant deployment.
logger.debug(
" get_provisioning_status: app password FOUND for user_id=%s",
user_id,
)
provisioned_at_str = status.get("provisioned_at")
return ProvisioningStatus(
@@ -116,29 +124,28 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
credential_type="app_password",
)
except Exception as e:
logger.debug(f" App password check failed for {user_id}: {e}")
logger.debug(" App password check failed for %s: %s", user_id, e)
# Check for OAuth refresh token (fallback)
logger.info(
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
logger.debug(
" get_provisioning_status: looking up refresh token for user_id=%s", user_id
)
storage = RefreshTokenStorage.from_env()
await storage.initialize()
storage = await get_shared_storage()
token_data = await storage.get_refresh_token(user_id)
if not token_data:
logger.info(
f" get_provisioning_status: ✗ No credentials found for user_id={user_id}"
logger.debug(
" get_provisioning_status: no credentials found for user_id=%s", user_id
)
return ProvisioningStatus(is_provisioned=False)
logger.info(
f" get_provisioning_status: ✓ Refresh token FOUND for user_id={user_id}"
)
logger.info(f" flow_type: {token_data.get('flow_type')}")
logger.info(
f" provisioning_client_id: {token_data.get('provisioning_client_id', 'N/A')}"
logger.debug(
" get_provisioning_status: refresh token FOUND for user_id=%s "
"flow_type=%s provisioning_client_id=%s",
user_id,
token_data.get("flow_type"),
token_data.get("provisioning_client_id", "N/A"),
)
# Convert timestamp to ISO format if present
@@ -198,11 +205,9 @@ 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.
Internal helper for the ``provision_nextcloud_access`` MCP tool.
Returns URL to Astrolabe settings page where users can provision background
sync access using either:
@@ -211,18 +216,15 @@ 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)
status = await _get_provisioning_status(ctx, user_id)
if status.is_provisioned:
return ProvisioningResult(
success=True,
@@ -271,31 +273,24 @@ 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.
Internal helper for the ``revoke_nextcloud_access`` MCP tool.
This tool removes the stored refresh token and revokes 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)
status = await _get_provisioning_status(ctx, user_id)
if not status.is_provisioned:
return RevocationResult(
success=True,
@@ -303,8 +298,7 @@ async def revoke_nextcloud_access(
)
# Initialize Token Broker to handle revocation
storage = RefreshTokenStorage.from_env()
await storage.initialize()
storage = await get_shared_storage()
# Get OAuth client credentials from storage
client_creds = await storage.get_oauth_client()
@@ -350,36 +344,27 @@ 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.
Internal helper for the ``check_provisioning_status`` MCP tool.
This tool allows users to check whether they have provisioned
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)
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.
Internal helper for the ``check_logged_in`` MCP tool.
This tool checks whether the user has completed Flow 2 (resource provisioning)
to grant offline access to Nextcloud. If not logged in, it uses MCP elicitation
@@ -387,35 +372,29 @@ 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)
logger.info(f" Provisioning status: is_provisioned={status.is_provisioned}")
# Demoted to debug (PR #758 round-2 nit 4): per-user logging at INFO
# ends up in log aggregation on every check_logged_in call, which is
# noise in a hosted multi-tenant deployment.
logger.debug("Checking provisioning status for user_id=%s", user_id)
status = await _get_provisioning_status(ctx, user_id)
logger.debug(
" Provisioning status for %s: is_provisioned=%s",
user_id,
status.is_provisioned,
)
if status.is_provisioned:
logger.info(f"User {user_id} is already logged in - returning 'yes'")
logger.info("=" * 60)
logger.debug("User %s already logged in", user_id)
return "yes"
logger.info(f"User {user_id} is NOT logged in - triggering elicitation")
logger.info("=" * 60)
logger.debug("User %s NOT logged in triggering elicitation", user_id)
# Not logged in - generate OAuth URL for Flow 2
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
@@ -451,22 +430,14 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
state = secrets.token_urlsafe(32)
# Store state in session for validation on callback
storage = RefreshTokenStorage.from_env()
await storage.initialize()
storage = await get_shared_storage()
# Create OAuth session for Flow 2
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
# The canonical Flow 2 oauth_session row is written inside
# generate_oauth_url_for_flow2 (keyed by `state`, with the PKCE
# verifier and nonce); the unified callback looks it up by `state`.
# No additional row is needed here.
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback"
await storage.store_oauth_session(
session_id=session_id,
client_redirect_uri="", # No client redirect for Flow 2
state=state,
flow_type="flow2",
is_provisioning=True,
ttl_seconds=600, # 10 minute TTL
)
# Define scopes for Nextcloud access
# Note: offline_access is only included when enabled in settings.
# The actual scope sent to the IdP is determined by
@@ -497,8 +468,11 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
scopes=scopes,
)
# Use elicitation to prompt user to login
logger.info(f"Eliciting login for user {user_id} with URL: {auth_url}")
# Use elicitation to prompt user to login. Logged at debug (PR #758
# round-2 nit 4): the auth URL contains the per-request ``state``
# token, which is sensitive enough that it shouldn't land in
# multi-tenant log aggregation by default.
logger.debug("Eliciting login for user %s (URL omitted)", user_id)
result = await ctx.elicit(
message=f"Please log in to Nextcloud at the following URL:\n\n{auth_url}\n\nAfter completing the login, check the box below and click OK.",
@@ -507,10 +481,15 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
if result.action == "accept":
# Check if login was successful by looking for refresh token
# Strategy: Try multiple lookup methods to handle both flows
logger.info("User accepted login prompt, checking for refresh token")
logger.info(f" State parameter: {state[:16]}...")
logger.info(f" User ID: {user_id}")
# Strategy: Try multiple lookup methods to handle both flows.
# Demoted to debug (PR #758 round-2 nit 4): user_id + state
# appear here on every elicitation accept.
logger.debug(
"User accepted login prompt; looking up refresh token "
"(user_id=%s state=%s...)",
user_id,
state[:16],
)
# First, try to find token by provisioning_client_id (Flow 2 from elicitation)
refresh_token_data = (
@@ -518,45 +497,39 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
)
if refresh_token_data:
logger.info("✓ Refresh token found via provisioning_client_id lookup")
logger.info(
f" Flow type: {refresh_token_data.get('flow_type', 'unknown')}"
)
logger.info(
f" Provisioned at: {refresh_token_data.get('provisioned_at', 'unknown')}"
logger.debug(
"Refresh token found via provisioning_client_id lookup "
"(flow_type=%s provisioned_at=%s)",
refresh_token_data.get("flow_type", "unknown"),
refresh_token_data.get("provisioned_at", "unknown"),
)
return "yes"
# Fallback: Try to find token by user_id (browser login or any other flow)
logger.info(f"✗ No token found with provisioning_client_id={state[:16]}...")
logger.info(f" Trying fallback lookup by user_id: {user_id}")
logger.debug(
"No token via provisioning_client_id=%s...; falling back to user_id=%s",
state[:16],
user_id,
)
refresh_token_data = await storage.get_refresh_token(user_id)
if refresh_token_data:
logger.info("✓ Refresh token found via user_id lookup")
logger.info(
f" Flow type: {refresh_token_data.get('flow_type', 'unknown')}"
)
logger.info(
f" Provisioned at: {refresh_token_data.get('provisioned_at', 'unknown')}"
)
logger.info(
f" Provisioning client ID: {refresh_token_data.get('provisioning_client_id', 'NULL')}"
)
logger.info(
" Note: This token was created via browser login or different flow"
logger.debug(
"Refresh token found via user_id lookup "
"(flow_type=%s provisioned_at=%s provisioning_client_id=%s)",
refresh_token_data.get("flow_type", "unknown"),
refresh_token_data.get("provisioned_at", "unknown"),
refresh_token_data.get("provisioning_client_id", "NULL"),
)
return "yes"
# No token found by either method
logger.warning(f"✗ No refresh token found for user {user_id}")
logger.warning(
f" Checked provisioning_client_id={state[:16]}... - NOT FOUND"
)
logger.warning(f" Checked user_id={user_id} - NOT FOUND")
logger.warning(
" This may indicate the user completed login but token wasn't stored"
"No refresh token found for user_id=%s (checked provisioning_client_id=%s... and user_id) — "
"user completed elicitation but token wasn't stored",
user_id,
state[:16],
)
return (
@@ -591,11 +564,9 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_provision_access(
ctx: Context,
user_id: Optional[str] = None,
) -> ProvisioningResult:
return await provision_nextcloud_access(ctx, user_id)
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(
name="revoke_nextcloud_access",
@@ -608,10 +579,9 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_revoke_access(
ctx: Context, user_id: Optional[str] = None
) -> RevocationResult:
return await revoke_nextcloud_access(ctx, user_id)
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(
name="check_provisioning_status",
@@ -623,10 +593,9 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_check_status(
ctx: Context, user_id: Optional[str] = None
) -> ProvisioningStatus:
return await check_provisioning_status(ctx, user_id)
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(
name="check_logged_in",
@@ -641,5 +610,6 @@ def register_oauth_tools(mcp):
),
)
@require_scopes("openid")
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
return await check_logged_in(ctx, user_id)
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)
+220
View File
@@ -0,0 +1,220 @@
"""Unit tests for ``browser_oauth_routes`` helpers.
Pins the round-6 review fix that ``_should_use_secure_cookies`` must not
trust ``bool(settings.cookie_secure)`` — Dynaconf normally coerces but
tests / direct ``settings.set`` calls can leave the raw string in place,
and ``bool("false")`` is ``True``.
"""
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth import browser_oauth_routes, token_utils
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
def _fake_settings(*, cookie_secure, mcp_server_url=""):
return type(
"S",
(),
{
"cookie_secure": cookie_secure,
"nextcloud_mcp_server_url": mcp_server_url,
},
)()
@pytest.mark.parametrize(
"value,expected",
[
(True, True),
(False, False),
("true", True),
("false", False),
("True", True),
("FALSE", False),
("0", False),
("1", True),
("no", False),
("yes", True),
("off", False),
("on", True),
("", False),
],
)
def test_should_use_secure_cookies_string_coercion(monkeypatch, value, expected):
monkeypatch.setattr(
browser_oauth_routes,
"get_settings",
lambda: _fake_settings(cookie_secure=value),
)
assert browser_oauth_routes._should_use_secure_cookies() is expected
def test_should_use_secure_cookies_falls_back_to_https_scheme(monkeypatch):
monkeypatch.setattr(
browser_oauth_routes,
"get_settings",
lambda: _fake_settings(
cookie_secure=None, mcp_server_url="https://mcp.example.com"
),
)
assert browser_oauth_routes._should_use_secure_cookies() is True
def test_should_use_secure_cookies_falls_back_to_http_scheme(monkeypatch):
monkeypatch.setattr(
browser_oauth_routes,
"get_settings",
lambda: _fake_settings(
cookie_secure=None, mcp_server_url="http://localhost:8000"
),
)
assert browser_oauth_routes._should_use_secure_cookies() is False
# ---------------------------------------------------------------------------
# oauth_login_callback: missing refresh_token must NOT create a session
# ---------------------------------------------------------------------------
#
# Pins PR #758 round-7 medium 1: when the IdP returns no refresh_token,
# ``SessionAuthBackend`` would silently reject every subsequent request
# (because ``get_refresh_token`` returns None), bouncing the user back to
# ``/oauth/login`` in a loop. The callback now bails with a 400 error page
# *before* any browser_sessions row or Set-Cookie header is created.
@pytest.fixture
def _clear_oidc_caches():
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
token_utils._fetch_locks.clear()
yield
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
token_utils._fetch_locks.clear()
@pytest.fixture
async def _no_refresh_storage():
with tempfile.TemporaryDirectory() as tmpdir:
s = RefreshTokenStorage(
db_path=str(Path(tmpdir) / "norefresh.db"),
encryption_key=Fernet.generate_key().decode(),
)
await s.initialize()
yield s
async def test_callback_rejects_token_response_without_refresh_token(
_clear_oidc_caches, _no_refresh_storage
):
storage = _no_refresh_storage
state = "state-norefresh"
await storage.store_oauth_session(
session_id=state,
client_id="browser-ui",
client_redirect_uri="/app",
state=state,
code_challenge="cc",
code_challenge_method="S256",
mcp_authorization_code="cv",
flow_type="browser",
ttl_seconds=600,
)
discovery = {
"issuer": "http://idp.example",
"token_endpoint": "http://idp.example/token",
}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
content=json.dumps(discovery).encode(),
headers={"content-type": "application/json"},
)
if str(request.url) == "http://idp.example/token":
# Successful token exchange but no refresh_token (e.g. IdP
# config without offline_access).
return httpx.Response(
200,
content=json.dumps(
{
"access_token": "at",
"id_token": "id-token-stub",
"token_type": "Bearer",
}
).encode(),
headers={"content-type": "application/json"},
)
return httpx.Response(404)
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
request = MagicMock()
request.query_params = {"code": "abc", "state": state}
request.cookies = {}
request.app.state.oauth_context = {
"storage": storage,
"oauth_client": None,
"config": {
"discovery_url": "http://idp.example/.well-known/openid-configuration",
"client_id": "test",
"client_secret": "secret",
"mcp_server_url": "http://localhost",
},
}
request.url_for = MagicMock(return_value="/oauth/login")
fake_userinfo = {"sub": "alice", "preferred_username": "alice"}
with (
patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.verify_id_token",
new=AsyncMock(return_value=fake_userinfo),
),
patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._get_userinfo_endpoint",
new=AsyncMock(return_value=None),
),
):
response = await oauth_login_callback(request)
assert response.status_code == 400
body = response.body.decode()
assert "Login Failed" in body
assert "refresh token" in body.lower()
# No browser session row may have been created.
assert await storage.get_browser_session_user("ignored") is None
# Nothing under the verified user_id either.
assert await storage.get_refresh_token("alice") is None
# No Set-Cookie header — the user must not walk away with an unusable
# session cookie.
set_cookie = response.headers.get("set-cookie", "")
assert "mcp_session" not in set_cookie
+150
View File
@@ -0,0 +1,150 @@
"""Regression tests for HTML XSS in browser OAuth error responses.
The reviewer on PR #758 flagged that ``oauth_login_callback`` interpolated
IdP-controlled and query-parameter-controlled text into HTMLResponse bodies
without escaping. These tests pin the html_escape behavior so the
vulnerability cannot regress silently.
"""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth import token_utils
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
XSS_PAYLOAD = "<script>alert(1)</script>"
@pytest.fixture(autouse=True)
def _clear_oidc_discovery_cache():
"""Reset the shared discovery cache between tests."""
token_utils._discovery_cache.clear()
yield
token_utils._discovery_cache.clear()
@pytest.fixture
async def storage():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "xss.db"
s = RefreshTokenStorage(
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
)
await s.initialize()
yield s
def _build_request(*, query_params: dict, oauth_context: dict | None = None):
request = MagicMock()
request.query_params = query_params
request.cookies = {}
request.app.state.oauth_context = oauth_context
request.url_for = MagicMock(return_value="/oauth/login")
return request
async def test_callback_escapes_error_query_params(storage):
"""`error` and `error_description` are attacker-controlled — must be escaped."""
request = _build_request(
query_params={
"error": XSS_PAYLOAD,
"error_description": XSS_PAYLOAD,
},
oauth_context={"storage": storage, "config": {}},
)
response = await oauth_login_callback(request)
body = response.body.decode()
assert XSS_PAYLOAD not in body
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body
async def test_callback_does_not_reflect_idp_http_error_body(storage):
"""IdP-returned HTTPError body must not appear in the user-visible HTML.
Updated for PR #758 round-3 nit 6: the callback now logs the IdP
response server-side and shows the user only a generic message + a
correlation ID, eliminating reflection of attacker-controllable text
into the error page entirely.
"""
discovery = {"token_endpoint": "http://idp.example/token"}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/.well-known/openid-configuration"):
return httpx.Response(
200,
content=json.dumps(discovery).encode(),
headers={"content-type": "application/json"},
)
if str(request.url) == "http://idp.example/token":
return httpx.Response(400, content=XSS_PAYLOAD.encode())
return httpx.Response(404)
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
# Pre-populate the oauth_session row that the callback expects
await storage.store_oauth_session(
session_id="state-xss",
client_id="browser-ui",
client_redirect_uri="/app",
state="state-xss",
code_challenge="cc",
code_challenge_method="S256",
mcp_authorization_code="cv",
flow_type="browser",
ttl_seconds=600,
)
request = _build_request(
query_params={"code": "abc", "state": "state-xss"},
oauth_context={
"storage": storage,
"oauth_client": None,
"config": {
"discovery_url": "http://idp.example/.well-known/openid-configuration",
"client_id": "test",
"client_secret": "secret",
"mcp_server_url": "http://localhost",
},
},
)
# Discovery now goes through token_utils.get_oidc_discovery (PR #758
# round-2 nit 3); token-exchange POST still uses browser_oauth_routes'
# httpx client.
with (
patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
):
response = await oauth_login_callback(request)
body = response.body.decode()
assert response.status_code == 500
# Strict: neither the raw payload nor an HTML-escaped form of the
# IdP body should appear — the page must show only the generic
# message + correlation ID.
assert XSS_PAYLOAD not in body
assert "&lt;script&gt;alert(1)&lt;/script&gt;" not in body
assert "An internal error occurred" in body
assert "Correlation ID" in body
+99
View File
@@ -0,0 +1,99 @@
"""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"
async def test_cleanup_expired_browser_sessions(storage):
"""Periodic cleanup removes expired rows but leaves fresh ones (PR #758 finding 6)."""
fresh_sid = secrets.token_urlsafe(32)
expired_sid = secrets.token_urlsafe(32)
await storage.create_browser_session(
session_id=fresh_sid, user_id="alice", ttl_seconds=3600
)
# ttl_seconds=-2 → expires_at strictly in the past (cleanup uses < now,
# so it must be actually less, not equal).
await storage.create_browser_session(
session_id=expired_sid, user_id="bob", ttl_seconds=-2
)
deleted = await storage.cleanup_expired_browser_sessions()
assert deleted == 1
# Fresh row survives, expired row is gone
assert await storage.get_browser_session_user(fresh_sid) == "alice"
assert await storage.get_browser_session_user(expired_sid) is None
# Calling again should be a no-op
assert await storage.cleanup_expired_browser_sessions() == 0
+1 -1
View File
@@ -42,7 +42,7 @@ async def test_registration_not_supported_when_no_endpoint():
}
with patch(
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
new_callable=AsyncMock,
return_value=discovery_doc,
):
+537
View File
@@ -0,0 +1,537 @@
"""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 anyio
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from nextcloud_mcp_server.auth import token_utils
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
)
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def _clear_oidc_caches():
"""Reset the discovery+JWKS caches so tests don't share fetched data."""
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
token_utils._fetch_locks.clear()
yield
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
token_utils._fetch_locks.clear()
# 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"
)
async def test_verify_id_token_recovers_after_kid_rotation():
"""Unknown kid → JWKS is refetched once and verification succeeds.
Pins the fix for the PR #758 follow-up review: previously a kid-miss
raised immediately, so every login failed for up to _OIDC_CACHE_TTL
after the IdP rotated its signing key.
"""
rotated_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
rotated_pem = rotated_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
def _build_rotated_jwks() -> dict:
pub = rotated_key.public_key().public_numbers()
return {
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "rotated-key",
"alg": "RS256",
"n": _b64u_uint(pub.n),
"e": _b64u_uint(pub.e),
}
]
}
jwks_fetches = {"count": 0}
def rotation_handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url == DISCOVERY_URL:
return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI})
if url == JWKS_URI:
jwks_fetches["count"] += 1
# First fetch: stale JWKS (without rotated kid).
# Subsequent fetches: post-rotation JWKS (with rotated kid).
jwks = (
_build_jwks() if jwks_fetches["count"] == 1 else _build_rotated_jwks()
)
return httpx.Response(
200,
content=json.dumps(jwks).encode(),
headers={"content-type": "application/json"},
)
return httpx.Response(404)
transport = httpx.MockTransport(rotation_handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
now = int(time.time())
token = jwt.encode(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
},
rotated_pem,
algorithm="RS256",
headers={"kid": "rotated-key"},
)
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
# Prime the cache with the stale JWKS by triggering a verification
# that misses on the rotated kid.
payload = await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
assert payload["sub"] == "alice"
assert jwks_fetches["count"] == 2, (
"JWKS should be refetched once on kid miss "
f"(actual fetches: {jwks_fetches['count']})"
)
async def test_verify_id_token_rotation_retry_still_misses():
"""Refresh that still doesn't include the kid surfaces the original error."""
fetches = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url == DISCOVERY_URL:
return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI})
if url == JWKS_URI:
fetches["count"] += 1
return httpx.Response(
200,
content=json.dumps(_build_jwks()).encode(),
headers={"content-type": "application/json"},
)
return httpx.Response(404)
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
},
kid="never-existed",
)
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
with pytest.raises(IdTokenVerificationError, match="No JWKS key matches"):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
assert fetches["count"] == 2, "JWKS should be refetched once before raising"
async def test_verify_id_token_rotation_retry_network_error_wraps():
"""A 500 on the kid-miss refresh fetch surfaces as IdTokenVerificationError.
Pins the fail-closed branch in the new refresh block: a network error
during JWKS refetch must not bubble out as a bare exception — it has
to be wrapped in IdTokenVerificationError so the caller's existing
error handling stays correct.
"""
fetches = {"jwks": 0}
def handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url == DISCOVERY_URL:
return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI})
if url == JWKS_URI:
fetches["jwks"] += 1
# First fetch: stale-but-valid JWKS. Second (refresh): 500.
if fetches["jwks"] == 1:
return httpx.Response(
200,
content=json.dumps(_build_jwks()).encode(),
headers={"content-type": "application/json"},
)
return httpx.Response(500, content=b"upstream broke")
return httpx.Response(404)
transport = httpx.MockTransport(handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
},
kid="not-cached-yet",
)
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
with pytest.raises(
IdTokenVerificationError, match="Failed to refresh JWKS after kid miss"
):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
assert fetches["jwks"] == 2
async def test_verify_id_token_caches_discovery_and_jwks():
"""Discovery + JWKS must be cached: two verifications, one fetch each.
Pins the fix for PR #758 finding 4 — every login previously made two
extra HTTP round-trips to the IdP for the same metadata.
"""
fetches: dict[str, int] = {}
def counting_handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
fetches[url] = fetches.get(url, 0) + 1
return _idp_handler(request)
transport = httpx.MockTransport(counting_handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
}
)
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
assert fetches.get(DISCOVERY_URL) == 1, "discovery fetched more than once"
assert fetches.get(JWKS_URI) == 1, "JWKS fetched more than once"
async def test_get_cached_coalesces_concurrent_misses():
"""Concurrent cache misses must collapse into a single HTTP fetch.
PR #758 round-3 review: without the per-URL lock in ``_get_cached``,
N simultaneous callers at cache expiry would each fire their own
request to the IdP, potentially tripping rate limits. The async
handler yields with ``anyio.sleep(0.01)`` so all 10 callers reach
the cache-miss branch concurrently — without coalescing the count
would be 10.
"""
fetch_count = {"n": 0}
async def slow_handler(request: httpx.Request) -> httpx.Response:
fetch_count["n"] += 1
# Yield so concurrent waiters all reach the lock acquisition
# while the first holder is still mid-fetch.
await anyio.sleep(0.01)
return _idp_handler(request)
transport = httpx.MockTransport(slow_handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
results: list[dict] = []
async def fetch_once():
results.append(await token_utils._get_cached(token_utils._jwks_cache, JWKS_URI))
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
async with anyio.create_task_group() as tg:
for _ in range(10):
tg.start_soon(fetch_once)
assert fetch_count["n"] == 1, (
f"expected exactly one fetch via lock coalescing, got {fetch_count['n']}"
)
assert len(results) == 10
assert all(r == results[0] for r in results), (
"concurrent callers received divergent cached data"
)
# Pin the round-4 cleanup invariant: _fetch_locks must drain after the
# fetch completes so a probed deployment can't accumulate locks for
# arbitrary URLs.
assert len(token_utils._fetch_locks) == 0, (
"expected _fetch_locks to be empty after fetch, "
f"found {list(token_utils._fetch_locks)}"
)
@@ -0,0 +1,330 @@
"""Pin one-time-use semantics on the Flow-2 callback's oauth_session row.
The PR #758 follow-up review flagged that
``oauth_callback_nextcloud`` reads ``code_verifier`` from the
``oauth_sessions`` table but never deletes the row, leaving the verifier
valid for the rest of the 10-minute TTL. This test exercises the real
storage layer to confirm the row is gone after the callback runs.
We mock everything *after* the deletion (discovery + token exchange +
ID token verification) so the test focuses on the cleanup contract,
not the OAuth wire protocol.
Also pins the AS-proxy callback's ID-token verification rejection path
introduced in PR #758 finding 1 (auto-review): a forged or unsigned
id_token must surface as a 400 ``invalid_token`` JSONResponse and must
not register a proxy code.
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
from nextcloud_mcp_server.auth.oauth_routes import (
ASProxySession,
_as_proxy_sessions,
_oauth_callback_as_proxy,
_proxy_codes,
oauth_callback_nextcloud,
)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_utils import IdTokenVerificationError
pytestmark = pytest.mark.unit
@pytest.fixture
async def storage():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_callback_cleanup.db"
s = RefreshTokenStorage(
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
)
await s.initialize()
yield s
def _build_request(*, code: str, state: str, storage: RefreshTokenStorage):
request = MagicMock()
request.query_params = {"code": code, "state": state}
request.app.state.oauth_context = {
"storage": storage,
"config": {
"discovery_url": "https://idp.example.com/.well-known/openid-configuration",
"mcp_server_url": "https://mcp.example.com",
"client_id": "mcp-server",
"client_secret": "mcp-secret",
},
}
return request
async def test_callback_deletes_oauth_session_after_reading_verifier(storage):
"""After a successful callback exchange the row is gone.
Pins the PR #758 follow-up review fix: previously the row stayed
until the 10-minute TTL elapsed, leaving the stored ``code_verifier``
valid for replay if ``state`` leaked.
"""
state = "state-abc-123"
await storage.store_oauth_session(
session_id=state,
client_redirect_uri="http://localhost:9999/callback",
state=state,
mcp_authorization_code="verifier-pkce-secret",
flow_type="flow2",
)
# Sanity check: row exists before the callback runs.
assert await storage.get_oauth_session(state) is not None
request = _build_request(code="idp-auth-code", state=state, storage=storage)
# Stub everything after the deletion: discovery, token exchange, ID
# token verification, and the user_oidc UserInfo round-trip. The
# exact responses don't matter — we only care that the deletion has
# happened by the time these are invoked.
fake_discovery = {
"token_endpoint": "https://idp.example.com/token",
"userinfo_endpoint": "https://idp.example.com/userinfo",
"issuer": "https://idp.example.com",
}
fake_userinfo = {"sub": "alice", "email": "alice@example.com"}
fake_token_response = MagicMock()
fake_token_response.json.return_value = {
"access_token": "ac-tok",
"refresh_token": "rf-tok",
"id_token": "id-tok",
"expires_in": 3600,
}
fake_token_response.raise_for_status = MagicMock()
fake_http = MagicMock()
fake_http.post = AsyncMock(return_value=fake_token_response)
fake_http.__aenter__ = AsyncMock(return_value=fake_http)
fake_http.__aexit__ = AsyncMock(return_value=None)
with (
patch(
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
new=AsyncMock(return_value=fake_discovery),
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
return_value=fake_http,
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.verify_id_token",
new=AsyncMock(return_value=fake_userinfo),
),
):
# The callback may go on to do extra work (storing tokens, redirecting,
# rendering HTML); we don't care about the response body, only the
# storage-level side effect.
try:
await oauth_callback_nextcloud(request)
except Exception:
# Any error past the deletion point is fine for this test.
pass
assert await storage.get_oauth_session(state) is None, (
"oauth_callback_nextcloud must delete the oauth_sessions row "
"after reading code_verifier (PR #758 follow-up review)"
)
async def test_callback_unknown_state_returns_400(storage):
"""Unknown/expired state must fail closed with 400.
Pins the PR #758 round-6 review fix: previously the callback fell
through with empty ``code_verifier`` / ``expected_nonce=None``,
silently bypassing the PKCE + nonce protections introduced in earlier
rounds. The handler now returns 400 before any token exchange.
"""
state = "state-missing"
# No store_oauth_session call — the row never existed.
request = _build_request(code="idp-auth-code", state=state, storage=storage)
response = await oauth_callback_nextcloud(request)
assert response.status_code == 400
assert await storage.get_oauth_session(state) is None
async def test_browser_callback_unknown_state_returns_400(storage):
"""Symmetric unknown-state contract for the browser-flow callback.
Mirrors ``test_callback_unknown_state_returns_400`` for
``oauth_login_callback`` — both callbacks must fail closed when the
oauth_session row is missing/expired (PR #758 round-6 review).
"""
state = "state-missing-browser"
request = MagicMock()
request.query_params = {"code": "idp-auth-code", "state": state}
request.cookies = {}
request.url_for = MagicMock(return_value="/oauth/login")
request.app.state.oauth_context = {
"storage": storage,
"oauth_client": None, # Nextcloud-integrated mode
"config": {
"mcp_server_url": "https://mcp.example.com",
"client_id": "mcp-server",
"client_secret": "mcp-secret",
},
}
response = await oauth_login_callback(request)
assert response.status_code == 400
assert await storage.get_oauth_session(state) is None
# ---------------------------------------------------------------------------
# AS proxy callback (PR #758 finding 1): ID-token verification rejection
# ---------------------------------------------------------------------------
def _build_as_proxy_request(*, code: str, state: str):
request = MagicMock()
request.query_params = {"code": code, "state": state}
request.app.state.oauth_context = {
"config": {
"discovery_url": "https://idp.example.com/.well-known/openid-configuration",
"mcp_server_url": "https://mcp.example.com",
"client_id": "mcp-server",
"client_secret": "mcp-secret",
}
}
return request
async def test_as_proxy_rejects_invalid_id_token():
"""Forged/unsigned id_token in the IdP token response → 400 invalid_token.
Pins PR #758 finding 1. Without verification a compromised IdP or
tampered transport could plant arbitrary identity claims into the
cached ProxyCodeEntry that downstream clients pick up.
"""
server_state = "as-proxy-state-rejected"
_as_proxy_sessions[server_state] = ASProxySession(
client_id="mcp-client",
client_redirect_uri="http://127.0.0.1:9999/callback",
client_state="client-state-xyz",
code_challenge="challenge",
code_challenge_method="S256",
requested_scopes="openid",
nonce="nonce-rejected",
)
_proxy_codes.clear()
request = _build_as_proxy_request(code="auth-code", state=server_state)
fake_discovery = {
"token_endpoint": "https://idp.example.com/token",
"issuer": "https://idp.example.com",
}
fake_token_response = MagicMock(status_code=200)
fake_token_response.json.return_value = {
"access_token": "ac-tok",
"refresh_token": "rf-tok",
"id_token": "forged.id.token",
"token_type": "Bearer",
}
fake_http = MagicMock()
fake_http.post = AsyncMock(return_value=fake_token_response)
fake_http.__aenter__ = AsyncMock(return_value=fake_http)
fake_http.__aexit__ = AsyncMock(return_value=None)
with (
patch(
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
new=AsyncMock(return_value=fake_discovery),
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
return_value=fake_http,
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.verify_id_token",
new=AsyncMock(side_effect=IdTokenVerificationError("bad signature")),
),
):
response = await _oauth_callback_as_proxy(request, server_state)
assert response.status_code == 400
body = bytes(response.body).decode()
assert "invalid_token" in body
# Critical: the proxy code store must not have grown — a rejected
# callback must not be turned into a redeemable proxy code.
assert _proxy_codes == {}
# And the session has been popped (one-time use).
assert server_state not in _as_proxy_sessions
async def test_as_proxy_passes_session_nonce_to_verify_id_token():
"""The session-bound nonce must be forwarded as ``expected_nonce``.
Pins PR #758 round-2 finding 2: ``oauth_authorize`` generates a nonce
and stores it on the ``ASProxySession``; the callback must pass it to
``verify_id_token`` so an ID token harvested from a parallel auth
request can't be replayed inside the AS-proxy flow.
"""
server_state = "as-proxy-state-with-nonce"
server_nonce = "nonce-bound-to-this-request"
_as_proxy_sessions[server_state] = ASProxySession(
client_id="mcp-client",
client_redirect_uri="http://127.0.0.1:9999/callback",
client_state="client-state",
code_challenge="challenge",
code_challenge_method="S256",
requested_scopes="openid",
nonce=server_nonce,
)
_proxy_codes.clear()
request = _build_as_proxy_request(code="auth-code", state=server_state)
fake_discovery = {
"token_endpoint": "https://idp.example.com/token",
"issuer": "https://idp.example.com",
}
fake_token_response = MagicMock(status_code=200)
fake_token_response.json.return_value = {
"access_token": "ac",
"id_token": "id-tok",
"token_type": "Bearer",
}
fake_http = MagicMock()
fake_http.post = AsyncMock(return_value=fake_token_response)
fake_http.__aenter__ = AsyncMock(return_value=fake_http)
fake_http.__aexit__ = AsyncMock(return_value=None)
verify_mock = AsyncMock(return_value={"sub": "alice"})
with (
patch(
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
new=AsyncMock(return_value=fake_discovery),
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
return_value=fake_http,
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.verify_id_token",
new=verify_mock,
),
):
await _oauth_callback_as_proxy(request, server_state)
verify_mock.assert_awaited_once()
kwargs = verify_mock.await_args.kwargs
assert kwargs.get("expected_nonce") == server_nonce, (
"AS-proxy callback must forward session.nonce to verify_id_token"
)
+620
View File
@@ -0,0 +1,620 @@
"""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 import token_utils
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
@pytest.fixture(autouse=True)
def _clear_oidc_discovery_cache():
"""Reset the shared discovery cache so tests don't see each other's fetches.
``_revoke_refresh_token_at_idp`` was changed (PR #758 nit 6) to use
``token_utils.get_oidc_discovery`` which caches for 5 minutes — without
this clear, the second test in the file would see the first test's
discovery doc and skip the MockTransport call.
"""
token_utils._discovery_cache.clear()
yield
token_utils._discovery_cache.clear()
# ---------------------------------------------------------------------------
# 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,
headers: dict | None = 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
# Headers default to empty so the CSRF check sees neither Origin nor
# Referer (allowed by policy — see _origin_matches_self).
request.headers = headers or {}
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,
"config": {
"mcp_server_url": "https://mcp.example.com",
"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,
"config": {
"mcp_server_url": "https://mcp.example.com",
"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,
"config": {
"mcp_server_url": "https://mcp.example.com",
"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,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
response = await oauth_logout(request)
assert response.status_code == 302 # logout still succeeds
async def test_logout_deletes_session_when_refresh_token_delete_fails(storage):
"""Browser session row must be removed even if delete_refresh_token raises.
Pins PR #758 round-5 review medium 1: previously the two deletes lived
in the same try-block, so an error on ``delete_refresh_token`` left an
orphan ``browser_sessions`` row that lingered until the cleanup cron.
"""
await storage.create_browser_session(session_id="sid-orphan", user_id="dave")
await storage.store_refresh_token(
user_id="dave", refresh_token="rt-dave", flow_type="browser"
)
real_delete_refresh_token = storage.delete_refresh_token
real_delete_browser_session = storage.delete_browser_session
storage.delete_refresh_token = AsyncMock(side_effect=RuntimeError("boom"))
delete_browser_session_calls: list[str] = []
async def tracking_delete_browser_session(session_id: str) -> bool:
delete_browser_session_calls.append(session_id)
return await real_delete_browser_session(session_id)
storage.delete_browser_session = tracking_delete_browser_session
request = _build_request(
cookie="sid-orphan",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
try:
response = await oauth_logout(request)
finally:
storage.delete_refresh_token = real_delete_refresh_token
storage.delete_browser_session = real_delete_browser_session
assert response.status_code == 302
assert delete_browser_session_calls == ["sid-orphan"], (
"delete_browser_session must run even after delete_refresh_token raised"
)
assert await storage.get_browser_session_user("sid-orphan") is None, (
"browser_sessions row must be gone — finally branch failed to fire"
)
async def test_logout_blocks_cross_origin_post(storage):
"""POST from a foreign Origin must be rejected with 403 (PR #758 finding 5)."""
await storage.create_browser_session(session_id="sid-X", user_id="alice")
request = _build_request(
cookie="sid-X",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"origin": "https://evil.example.com"},
)
response = await oauth_logout(request)
assert response.status_code == 403
# Session row must NOT have been deleted.
assert await storage.get_browser_session_user("sid-X") == "alice"
async def test_logout_allows_same_origin_post(storage):
"""POST with matching Origin proceeds normally."""
await storage.create_browser_session(session_id="sid-Y", user_id="alice")
request = _build_request(
cookie="sid-Y",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"origin": "https://mcp.example.com"},
)
response = await oauth_logout(request)
assert response.status_code == 302
assert await storage.get_browser_session_user("sid-Y") is None
async def test_logout_allows_same_origin_post_with_explicit_default_port(storage):
"""mcp_server_url has explicit :443; browser Origin omits the port.
RFC 6454 §6.2: browsers omit default ports in Origin headers. The
netloc string ``mcp.example.com:443`` would never match ``mcp.example.com``
without port normalisation, blocking every legitimate logout.
"""
await storage.create_browser_session(session_id="sid-PE", user_id="alice")
request = _build_request(
cookie="sid-PE",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com:443",
"discovery_url": None,
},
},
headers={"origin": "https://mcp.example.com"},
)
response = await oauth_logout(request)
assert response.status_code == 302
assert await storage.get_browser_session_user("sid-PE") is None
async def test_logout_allows_same_origin_post_with_default_port_in_origin(storage):
"""Symmetric case: config omits port, Origin includes :443."""
await storage.create_browser_session(session_id="sid-PI", user_id="alice")
request = _build_request(
cookie="sid-PI",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"origin": "https://mcp.example.com:443"},
)
response = await oauth_logout(request)
assert response.status_code == 302
assert await storage.get_browser_session_user("sid-PI") is None
async def test_logout_blocks_scheme_mismatch(storage):
"""Same hostname but different scheme must be treated as cross-origin."""
await storage.create_browser_session(session_id="sid-SC", user_id="alice")
request = _build_request(
cookie="sid-SC",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"origin": "http://mcp.example.com"},
)
response = await oauth_logout(request)
assert response.status_code == 403
assert await storage.get_browser_session_user("sid-SC") == "alice"
async def test_logout_allows_referer_when_origin_missing(storage):
"""Some browsers strip Origin on POST; Referer is the fallback signal."""
await storage.create_browser_session(session_id="sid-Z", user_id="alice")
request = _build_request(
cookie="sid-Z",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"referer": "https://mcp.example.com/app"},
)
response = await oauth_logout(request)
assert response.status_code == 302
async def test_logout_blocked_when_mcp_server_url_missing(storage):
"""Fail-closed CSRF (PR #758 round-3 finding 2): missing ``mcp_server_url``
in oauth_ctx must reject the logout, not allow it.
A future code path that leaves ``mcp_server_url`` unset would
otherwise silently disable CSRF protection. Blocking is recoverable.
"""
await storage.create_browser_session(session_id="sid-MM", user_id="alice")
request = _build_request(
cookie="sid-MM",
oauth_context={"storage": storage, "config": {"discovery_url": None}},
)
response = await oauth_logout(request)
assert response.status_code == 403
# Session must NOT have been deleted.
assert await storage.get_browser_session_user("sid-MM") == "alice"
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,
"config": {
"mcp_server_url": "https://mcp.example.com",
"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)
# Discovery now goes through token_utils.get_oidc_discovery (PR #758 nit
# 6); revocation POST still uses browser_oauth_routes' httpx client.
with (
patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
):
await _revoke_refresh_token_at_idp(
{
"config": {
"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,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
):
# Returns None and does not raise
result = await _revoke_refresh_token_at_idp(
{
"config": {
"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,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
):
result = await _revoke_refresh_token_at_idp(
{
"config": {
"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.
PR #758 round-7 minor: rejection now also evicts the orphaned
``browser_sessions`` row so the table doesn't accumulate dead entries
that the auth check will keep rejecting until TTL cleanup.
"""
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
# Orphan must be evicted on rejection.
assert await storage.get_browser_session_user("sid-B") 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
+9 -8
View File
@@ -1,12 +1,12 @@
"""Unit tests for OIDC discovery fetch in oauth_routes."""
"""Unit tests for the shared OIDC discovery fetch in token_utils."""
from unittest.mock import patch
import httpx
import pytest
from nextcloud_mcp_server.auth import oauth_routes
from nextcloud_mcp_server.auth.oauth_routes import _get_cached_discovery
from nextcloud_mcp_server.auth import token_utils
from nextcloud_mcp_server.auth.token_utils import get_oidc_discovery
pytestmark = pytest.mark.unit
@@ -14,9 +14,9 @@ pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def _clear_discovery_cache():
"""Reset the in-memory discovery cache between tests."""
oauth_routes._discovery_cache.clear()
token_utils._discovery_cache.clear()
yield
oauth_routes._discovery_cache.clear()
token_utils._discovery_cache.clear()
async def test_discovery_follows_redirect_to_index_php():
@@ -26,7 +26,8 @@ async def test_discovery_follows_redirect_to_index_php():
redirect ``/.well-known/openid-configuration`` to
``/index.php/.well-known/openid-configuration``. Without follow_redirects
the OAuth authorize handler raises HTTPStatusError and returns 500
(see oauth_routes._get_cached_discovery).
(PR #758 round-2 nit 3 consolidated the discovery cache; see
``token_utils.get_oidc_discovery``).
"""
pretty_url = "https://nx.example.com/.well-known/openid-configuration"
@@ -51,10 +52,10 @@ async def test_discovery_follows_redirect_to_index_php():
return httpx.AsyncClient(**kwargs)
with patch(
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
) as factory:
result = await _get_cached_discovery(pretty_url)
result = await get_oidc_discovery(pretty_url)
assert result == discovery_doc
factory.assert_called_once()
+41
View File
@@ -0,0 +1,41 @@
"""Tests for ``_normalise_origin`` port + scheme + host normalisation.
The CSRF guard on POST /oauth/logout (PR #758 round-3 review hardening)
compares ``Origin`` / ``Referer`` against the configured ``mcp_server_url``
via ``_normalise_origin``. RFC 6454 §6.2 says browsers omit default ports
(80 for http, 443 for https) from Origin headers, so the function strips
those before comparison. These tests pin that behaviour so it can't
silently regress.
"""
import pytest
from nextcloud_mcp_server.auth.browser_oauth_routes import _normalise_origin
pytestmark = pytest.mark.unit
@pytest.mark.parametrize(
"left, right, equal",
[
# Default ports are stripped — these MUST compare equal.
("https://example.com", "https://example.com:443", True),
("https://example.com:443", "https://example.com", True),
("http://example.com", "http://example.com:80", True),
("http://example.com:80", "http://example.com", True),
# Non-default ports are preserved.
("https://example.com:8443", "https://example.com", False),
("http://example.com:8080", "http://example.com", False),
("https://example.com:8443", "https://example.com:443", False),
# Cross-scheme defaults don't collapse (https:443 != http:80 even
# though both ports get stripped, because the scheme differs).
("https://example.com", "http://example.com", False),
("https://example.com:443", "http://example.com:80", False),
# Hostname matters and is case-insensitive.
("https://example.com", "https://other.com", False),
("https://example.com", "https://EXAMPLE.COM", True),
("https://Example.Com:443", "https://example.com", True),
],
)
def test_normalise_origin_equivalence(left: str, right: str, equal: bool):
assert (_normalise_origin(left) == _normalise_origin(right)) is equal
+44
View File
@@ -0,0 +1,44 @@
"""Tests for _safe_next_url, the open-redirect guard for ``?next=`` params.
Pins the contract that any non-path target falls back to the default,
preventing the open-redirect issue flagged on PR #758.
"""
import pytest
from nextcloud_mcp_server.auth.browser_oauth_routes import _safe_next_url
pytestmark = pytest.mark.unit
@pytest.mark.parametrize(
"raw, expected",
[
# Valid path-only targets pass through.
("/app", "/app"),
("/app/foo", "/app/foo"),
("/oauth/login", "/oauth/login"),
("/app?x=1&y=2", "/app?x=1&y=2"),
("/app#frag", "/app#frag"),
# Empty / missing → default.
("", "/default"),
(None, "/default"),
# Absolute URLs → default.
("https://evil.example.com", "/default"),
("http://evil.example.com/path", "/default"),
# Protocol-relative → default. Browser would treat as cross-origin.
("//evil.example.com", "/default"),
("//evil.example.com/path", "/default"),
# No leading slash → default.
("relative/path", "/default"),
("app", "/default"),
# Whitespace / control chars → default. Defends against tab/space
# injection that some browsers historically tolerated.
("/app\nfoo", "/default"),
("/app\tfoo", "/default"),
("/app\x00foo", "/default"),
("/app foo", "/default"),
],
)
def test_safe_next_url(raw, expected):
assert _safe_next_url(raw, "/default") == expected
-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
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for ``extract_user_id_from_token`` (PR #758 follow-up review).
The function used to silently fall back to ``"default_user"`` whenever the
verified access token had no ``sub`` claim. In a multi-tenant deployment
that would let a malformed IdP token bucket every request under a single
sentinel user, risking cross-tenant data exposure. The fix is to keep the
no-token fallback (BasicAuth mode legitimately calls this without an
OAuth identity) but raise ``McpError`` whenever an access token is
present and ``resource`` is empty.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from mcp.server.auth.provider import AccessToken
from mcp.shared.exceptions import McpError
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
pytestmark = pytest.mark.unit
def _token(resource: str | None = "alice") -> AccessToken:
return AccessToken(
token="t",
client_id="test-client",
scopes=["openid"],
expires_at=int(time.time() + 3600),
resource=resource,
)
async def test_returns_user_id_when_token_has_sub():
"""Happy path: verified access token with sub → returns the sub."""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=_token("alice"),
):
user_id = await extract_user_id_from_token(MagicMock())
assert user_id == "alice"
async def test_returns_default_user_when_no_access_token():
"""BasicAuth mode: get_access_token() returns None → sentinel.
BasicAuth deployments don't issue OAuth tokens; the sentinel lets
BasicAuth-aware callers branch on it. Removing this fallback would
break the BasicAuth path.
"""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=None,
):
user_id = await extract_user_id_from_token(MagicMock())
assert user_id == "default_user"
async def test_raises_when_token_present_but_resource_empty():
"""Token present but ``resource`` empty → fail closed with McpError.
Pins the PR #758 follow-up review fix: a malformed IdP token must
not silently funnel users into a shared ``"default_user"`` SQLite
bucket.
"""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=_token(""),
):
with pytest.raises(McpError, match="Cannot determine user identity"):
await extract_user_id_from_token(MagicMock())
async def test_raises_when_resource_is_none():
"""Same fail-closed behaviour when ``resource`` is None rather than ''."""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=_token(None),
):
with pytest.raises(McpError, match="Cannot determine user identity"):
await extract_user_id_from_token(MagicMock())