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
@@ -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)