fix(auth): address PR #758 review — XSS, CSRF, open redirect, JWKS cache
Addresses all 9 findings from the review on PR #758: Blocking: - _revoke_refresh_token_at_idp now reads config from oauth_ctx["config"] (the production-shaped nested dict). Previously read flat keys, causing IdP revocation to silently no-op in production. Test fixtures rebuilt to the realistic nested shape so the bug can't regress unnoticed. - HTML error responses in oauth_login_callback now wrap IdP-controlled error_body, str(e), and the attacker-controlled error/error_description query params in html_escape. New test_browser_oauth_xss.py pins this. Important: - New _safe_next_url helper validates the ?next= query param at write time (oauth_login), in oauth_logout, and on read from the session row in oauth_login_callback. Blocks https://, // (protocol-relative), and CRLF/whitespace injection. - verify_id_token now caches discovery + JWKS (5-min TTL) using the same pattern as oauth_routes._get_cached_discovery. New caching regression test pins to one fetch per URL across multiple calls. - /oauth/logout is now POST-only at the route layer (defeats passive CSRF via <img src>). oauth_logout also validates Origin/Referer against the configured mcp_server_url. Logout UI in user_info.html converted from <a href> to <form method="post">. - New storage.cleanup_expired_browser_sessions() called from the hourly cleanup loop in app.py — previously these rows accumulated for users who never explicitly logged out. Nits: - Demoted INFO logs that leaked oauth_config.keys() / client_id / token-storage state to DEBUG. Operator-relevant outcome lines (login successful, refresh token stored, logged out) stay INFO. - verify_id_token algorithms widened to RS256, PS256, ES256 — covers Azure AD (PS256) and Cognito/some Keycloak realms (ES256). Symmetric and "none" remain off the allowlist. - Migrated all Optional[X] usages in auth/storage.py to X | None per CLAUDE.md. Breaking change: GET /oauth/logout now returns 405. The in-tree logout UI was migrated to a POST form; any external bookmark or curl-based caller that relied on GET will need to switch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
15dbb26349
commit
931ee602eb
@@ -1354,13 +1354,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
storage = await get_shared_storage()
|
storage = await get_shared_storage()
|
||||||
count = await storage.delete_expired_login_flow_sessions()
|
count = await storage.delete_expired_login_flow_sessions()
|
||||||
if count:
|
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
|
# Also clean up expired AS proxy codes/sessions
|
||||||
_cleanup_expired_proxy_codes()
|
_cleanup_expired_proxy_codes()
|
||||||
# Clean up expired web provision sessions
|
# Clean up expired web provision sessions
|
||||||
_cleanup_expired_provision_sessions()
|
_cleanup_expired_provision_sessions()
|
||||||
except Exception as e:
|
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
|
await anyio.sleep(3600) # Every hour
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -2242,8 +2245,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
name="oauth_login_callback",
|
name="oauth_login_callback",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# POST-only: defends against passive CSRF (e.g. <img src="…/logout">)
|
||||||
|
# — see PR #758 finding 5.
|
||||||
routes.append(
|
routes.append(
|
||||||
Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
|
Route("/oauth/logout", oauth_logout, methods=["POST"], name="oauth_logout")
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback (legacy), /oauth/logout"
|
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback (legacy), /oauth/logout"
|
||||||
|
|||||||
@@ -32,6 +32,48 @@ from ..http import nextcloud_httpx_client
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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 finding 5). 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 netloc of the first present header must equal the
|
||||||
|
netloc of the configured ``mcp_server_url``.
|
||||||
|
"""
|
||||||
|
cfg = oauth_ctx.get("config") or oauth_ctx
|
||||||
|
mcp_server_url = cfg.get("mcp_server_url")
|
||||||
|
if not mcp_server_url:
|
||||||
|
# Mis-configured deployment — fail open rather than break logout.
|
||||||
|
return True
|
||||||
|
|
||||||
|
expected = parse_url(mcp_server_url).netloc.lower()
|
||||||
|
raw = request.headers.get("origin") or request.headers.get("referer")
|
||||||
|
if not raw:
|
||||||
|
return True
|
||||||
|
return parse_url(raw).netloc.lower() == 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:
|
def _should_use_secure_cookies() -> bool:
|
||||||
"""Determine if cookies should have secure flag.
|
"""Determine if cookies should have secure flag.
|
||||||
|
|
||||||
@@ -73,14 +115,17 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
oauth_client = oauth_ctx["oauth_client"]
|
oauth_client = oauth_ctx["oauth_client"]
|
||||||
oauth_config = oauth_ctx["config"]
|
oauth_config = oauth_ctx["config"]
|
||||||
|
|
||||||
# Debug: Log oauth_config contents
|
# Demoted to DEBUG (PR #758 nit a) — these previously leaked the
|
||||||
logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
|
# full set of config keys + the client_id at INFO on every login.
|
||||||
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
logger.debug("oauth_login called - oauth_config keys: %s", oauth_config.keys())
|
||||||
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
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)
|
# Get redirect URL from query params (default to /app). Validated at
|
||||||
next_url = request.query_params.get("next", "/app")
|
# write-time so we never store an attacker-controlled absolute URL on
|
||||||
logger.info(f"oauth_login - next_url: {next_url}")
|
# 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
|
# Generate state for CSRF protection
|
||||||
state = secrets.token_urlsafe(32)
|
state = secrets.token_urlsafe(32)
|
||||||
@@ -142,7 +187,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
}
|
}
|
||||||
|
|
||||||
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
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:
|
else:
|
||||||
# Integrated mode (Nextcloud OIDC)
|
# Integrated mode (Nextcloud OIDC)
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
@@ -199,11 +244,10 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
|
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
|
||||||
}
|
}
|
||||||
|
|
||||||
# Debug: Log full parameters
|
logger.debug("Building Nextcloud OIDC auth URL with params: %s", idp_params)
|
||||||
logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
|
|
||||||
|
|
||||||
auth_url = f"{authorization_endpoint}?{urlencode(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)
|
return RedirectResponse(auth_url, status_code=302)
|
||||||
|
|
||||||
@@ -228,8 +272,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
error_description = request.query_params.get(
|
error_description = request.query_params.get(
|
||||||
"error_description", "Authorization failed"
|
"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"))
|
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(
|
return HTMLResponse(
|
||||||
f"""
|
f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -237,9 +283,9 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
<head><title>Login Failed</title></head>
|
<head><title>Login Failed</title></head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Login Failed</h1>
|
<h1>Login Failed</h1>
|
||||||
<p>Error: {error}</p>
|
<p>Error: {html_escape(error)}</p>
|
||||||
<p>{error_description}</p>
|
<p>{html_escape(error_description)}</p>
|
||||||
<p><a href="{login_url}">Try again</a></p>
|
<p><a href="{html_escape(login_url)}">Try again</a></p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""",
|
""",
|
||||||
@@ -278,8 +324,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if oauth_session:
|
if oauth_session:
|
||||||
# code_verifier was stored in mcp_authorization_code field
|
# code_verifier was stored in mcp_authorization_code field
|
||||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||||
# next_url was stored in client_redirect_uri field
|
# next_url was stored in client_redirect_uri field — re-validate at
|
||||||
next_url = oauth_session.get("client_redirect_uri", "/app")
|
# 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")
|
||||||
# Clean up the temporary session
|
# Clean up the temporary session
|
||||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||||
|
|
||||||
@@ -347,8 +395,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
e.response.text if hasattr(e.response, "text") else str(e.response.content)
|
e.response.text if hasattr(e.response, "text") else str(e.response.content)
|
||||||
)
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
|
"Token exchange failed: HTTP %s - %s", e.response.status_code, error_body
|
||||||
)
|
)
|
||||||
|
# html_escape: error_body originates from the IdP and could contain
|
||||||
|
# markup that would be reflected into the failure page otherwise.
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
f"""
|
f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -357,14 +407,14 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
<body>
|
<body>
|
||||||
<h1>Login Failed</h1>
|
<h1>Login Failed</h1>
|
||||||
<p>Failed to exchange authorization code for tokens</p>
|
<p>Failed to exchange authorization code for tokens</p>
|
||||||
<p>HTTP {e.response.status_code}: {error_body}</p>
|
<p>HTTP {e.response.status_code}: {html_escape(error_body)}</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""",
|
""",
|
||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Token exchange failed: {e}")
|
logger.error("Token exchange failed: %s", e)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
f"""
|
f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -373,7 +423,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
<body>
|
<body>
|
||||||
<h1>Login Failed</h1>
|
<h1>Login Failed</h1>
|
||||||
<p>Failed to exchange authorization code for tokens</p>
|
<p>Failed to exchange authorization code for tokens</p>
|
||||||
<p>Error: {e}</p>
|
<p>Error: {html_escape(str(e))}</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""",
|
""",
|
||||||
@@ -383,9 +433,11 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
refresh_token = token_data.get("refresh_token")
|
refresh_token = token_data.get("refresh_token")
|
||||||
id_token = token_data.get("id_token")
|
id_token = token_data.get("id_token")
|
||||||
|
|
||||||
logger.info(f"Token exchange response keys: {token_data.keys()}")
|
# Demoted to DEBUG (PR #758 nit a) — these were previously logged at
|
||||||
logger.info(f"Refresh token present: {refresh_token is not None}")
|
# INFO on every login.
|
||||||
logger.info(f"ID token present: {id_token is not None}")
|
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
|
# Resolve the discovery URL + audience used for THIS auth request so
|
||||||
# we can verify the ID token signature + claims (issue #626 finding 1).
|
# we can verify the ID token signature + claims (issue #626 finding 1).
|
||||||
@@ -431,8 +483,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
refresh_expires_at = None
|
refresh_expires_at = None
|
||||||
if refresh_expires_in:
|
if refresh_expires_in:
|
||||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
"Refresh token expires in %ss (at timestamp %s)",
|
||||||
|
refresh_expires_in,
|
||||||
|
refresh_expires_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract granted scopes
|
# Extract granted scopes
|
||||||
@@ -442,10 +496,13 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
|
|
||||||
# Store refresh token (for background jobs ONLY)
|
# Store refresh token (for background jobs ONLY)
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
logger.debug(
|
||||||
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
|
"Storing refresh token for user_id=%s state=%s... scopes=%s expires_at=%s",
|
||||||
logger.info(f" Granted scopes: {granted_scopes}")
|
user_id,
|
||||||
logger.info(f" Expires at: {refresh_expires_at}")
|
state[:16],
|
||||||
|
granted_scopes,
|
||||||
|
refresh_expires_at,
|
||||||
|
)
|
||||||
await storage.store_refresh_token(
|
await storage.store_refresh_token(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
@@ -454,9 +511,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
provisioning_client_id=state, # Store state for unified session lookup
|
provisioning_client_id=state, # Store state for unified session lookup
|
||||||
scopes=granted_scopes,
|
scopes=granted_scopes,
|
||||||
)
|
)
|
||||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f" Token can now be found via provisioning_client_id={state[:16]}..."
|
"Refresh token stored for user %s (lookup key: %s...)",
|
||||||
|
user_id,
|
||||||
|
state[:16],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("No refresh token in token response - cannot store session")
|
logger.warning("No refresh token in token response - cannot store session")
|
||||||
@@ -478,13 +536,13 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if profile_data:
|
if profile_data:
|
||||||
# Cache profile for browser UI (no token needed to display)
|
# Cache profile for browser UI (no token needed to display)
|
||||||
await storage.store_user_profile(user_id, profile_data)
|
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:
|
else:
|
||||||
logger.warning(f"Failed to query userinfo endpoint for {user_id}")
|
logger.warning("Failed to query userinfo endpoint for %s", user_id)
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not determine userinfo endpoint")
|
logger.warning("Could not determine userinfo endpoint")
|
||||||
except Exception as e:
|
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
|
# Continue anyway - profile cache is optional for browser UI
|
||||||
|
|
||||||
# Create a server-side browser session: a random opaque session_id is
|
# Create a server-side browser session: a random opaque session_id is
|
||||||
@@ -510,7 +568,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def oauth_logout(request: Request) -> RedirectResponse:
|
async def oauth_logout(request: Request) -> RedirectResponse | JSONResponse:
|
||||||
"""Browser OAuth logout — invalidate session and revoke refresh token.
|
"""Browser OAuth logout — invalidate session and revoke refresh token.
|
||||||
|
|
||||||
Issue #626 finding 4: prior implementation only cleared the cookie,
|
Issue #626 finding 4: prior implementation only cleared the cookie,
|
||||||
@@ -523,13 +581,30 @@ async def oauth_logout(request: Request) -> RedirectResponse:
|
|||||||
if it leaks.
|
if it leaks.
|
||||||
5. Clears the cookie on the response.
|
5. Clears the cookie on the response.
|
||||||
|
|
||||||
|
Method is POST-only at the route layer to defeat passive CSRF (PR #758
|
||||||
|
finding 5). Origin / Referer headers are also validated against the
|
||||||
|
configured ``mcp_server_url`` when present, blocking same-method-but-
|
||||||
|
cross-origin form submissions.
|
||||||
|
|
||||||
Query parameters:
|
Query parameters:
|
||||||
next: Optional URL to redirect to after logout (default: /oauth/login)
|
next: Optional URL to redirect to after logout (default: /oauth/login)
|
||||||
"""
|
"""
|
||||||
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")
|
session_id = request.cookies.get("mcp_session")
|
||||||
|
|
||||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
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
|
storage = oauth_ctx.get("storage") if oauth_ctx else None
|
||||||
|
|
||||||
if session_id and storage and oauth_ctx:
|
if session_id and storage and oauth_ctx:
|
||||||
@@ -563,8 +638,12 @@ async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> N
|
|||||||
of deleting the local copy, and we don't want logout to error if the
|
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.
|
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
|
||||||
try:
|
try:
|
||||||
discovery_url = oauth_ctx.get("discovery_url") or os.getenv(
|
discovery_url = cfg.get("discovery_url") or os.getenv(
|
||||||
"OIDC_DISCOVERY_URL",
|
"OIDC_DISCOVERY_URL",
|
||||||
f"{os.getenv('NEXTCLOUD_HOST', '')}/.well-known/openid-configuration",
|
f"{os.getenv('NEXTCLOUD_HOST', '')}/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
@@ -580,10 +659,8 @@ async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> N
|
|||||||
logger.debug("IdP advertises no revocation_endpoint; skipping")
|
logger.debug("IdP advertises no revocation_endpoint; skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
client_id = oauth_ctx.get("client_id") or os.getenv("OIDC_CLIENT_ID")
|
client_id = cfg.get("client_id") or os.getenv("OIDC_CLIENT_ID")
|
||||||
client_secret = oauth_ctx.get("client_secret") or os.getenv(
|
client_secret = cfg.get("client_secret") or os.getenv("OIDC_CLIENT_SECRET")
|
||||||
"OIDC_CLIENT_SECRET"
|
|
||||||
)
|
|
||||||
if not (client_id and client_secret):
|
if not (client_id and client_secret):
|
||||||
logger.debug("No OIDC client credentials available for revocation")
|
logger.debug("No OIDC client credentials available for revocation")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import anyio
|
import anyio
|
||||||
@@ -205,11 +205,11 @@ class RefreshTokenStorage:
|
|||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
refresh_token: str,
|
refresh_token: str,
|
||||||
expires_at: Optional[int] = None,
|
expires_at: int | None = None,
|
||||||
flow_type: str = "hybrid",
|
flow_type: str = "hybrid",
|
||||||
token_audience: str = "nextcloud",
|
token_audience: str = "nextcloud",
|
||||||
provisioning_client_id: Optional[str] = None,
|
provisioning_client_id: str | None = None,
|
||||||
scopes: Optional[list[str]] = None,
|
scopes: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Store encrypted refresh token for user.
|
Store encrypted refresh token for user.
|
||||||
@@ -313,7 +313,7 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
logger.debug(f"Cached user profile for {user_id}")
|
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.
|
Retrieve cached user profile data.
|
||||||
|
|
||||||
@@ -351,7 +351,7 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
return profile_data
|
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.
|
Retrieve and decrypt refresh token for user.
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
async def get_refresh_token_by_provisioning_client_id(
|
async def get_refresh_token_by_provisioning_client_id(
|
||||||
self, provisioning_client_id: str
|
self, provisioning_client_id: str
|
||||||
) -> Optional[dict]:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Retrieve and decrypt refresh token by provisioning_client_id (state parameter).
|
Retrieve and decrypt refresh token by provisioning_client_id (state parameter).
|
||||||
|
|
||||||
@@ -617,8 +617,8 @@ class RefreshTokenStorage:
|
|||||||
client_id_issued_at: int,
|
client_id_issued_at: int,
|
||||||
client_secret_expires_at: int,
|
client_secret_expires_at: int,
|
||||||
redirect_uris: list[str],
|
redirect_uris: list[str],
|
||||||
registration_access_token: Optional[str] = None,
|
registration_access_token: str | None = None,
|
||||||
registration_client_uri: Optional[str] = None,
|
registration_client_uri: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Store encrypted OAuth client credentials.
|
Store encrypted OAuth client credentials.
|
||||||
@@ -689,7 +689,7 @@ class RefreshTokenStorage:
|
|||||||
auth_method="oauth",
|
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.
|
Retrieve and decrypt OAuth client credentials.
|
||||||
|
|
||||||
@@ -827,9 +827,9 @@ class RefreshTokenStorage:
|
|||||||
self,
|
self,
|
||||||
event: str,
|
event: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
resource_type: Optional[str] = None,
|
resource_type: str | None = None,
|
||||||
resource_id: Optional[str] = None,
|
resource_id: str | None = None,
|
||||||
auth_method: Optional[str] = None,
|
auth_method: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Log operation to audit log.
|
Log operation to audit log.
|
||||||
@@ -866,8 +866,8 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
async def get_audit_logs(
|
async def get_audit_logs(
|
||||||
self,
|
self,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
since: Optional[int] = None,
|
since: int | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -909,14 +909,14 @@ class RefreshTokenStorage:
|
|||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
client_redirect_uri: str,
|
client_redirect_uri: str,
|
||||||
state: Optional[str] = None,
|
state: str | None = None,
|
||||||
code_challenge: Optional[str] = None,
|
code_challenge: str | None = None,
|
||||||
code_challenge_method: Optional[str] = None,
|
code_challenge_method: str | None = None,
|
||||||
mcp_authorization_code: Optional[str] = None,
|
mcp_authorization_code: str | None = None,
|
||||||
client_id: Optional[str] = None,
|
client_id: str | None = None,
|
||||||
flow_type: str = "hybrid",
|
flow_type: str = "hybrid",
|
||||||
is_provisioning: bool = False,
|
is_provisioning: bool = False,
|
||||||
requested_scopes: Optional[str] = None,
|
requested_scopes: str | None = None,
|
||||||
ttl_seconds: int = 600, # 10 minutes
|
ttl_seconds: int = 600, # 10 minutes
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -969,7 +969,7 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)")
|
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.
|
Retrieve OAuth session by session ID.
|
||||||
|
|
||||||
@@ -1001,7 +1001,7 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
async def get_oauth_session_by_mcp_code(
|
async def get_oauth_session_by_mcp_code(
|
||||||
self, mcp_authorization_code: str
|
self, mcp_authorization_code: str
|
||||||
) -> Optional[dict]:
|
) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Retrieve OAuth session by MCP authorization code.
|
Retrieve OAuth session by MCP authorization code.
|
||||||
|
|
||||||
@@ -1037,9 +1037,9 @@ class RefreshTokenStorage:
|
|||||||
async def update_oauth_session(
|
async def update_oauth_session(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: str | None = None,
|
||||||
idp_access_token: Optional[str] = None,
|
idp_access_token: str | None = None,
|
||||||
idp_refresh_token: Optional[str] = None,
|
idp_refresh_token: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Update OAuth session with IdP token data.
|
Update OAuth session with IdP token data.
|
||||||
@@ -1174,7 +1174,7 @@ class RefreshTokenStorage:
|
|||||||
ttl_seconds,
|
ttl_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_browser_session_user(self, session_id: str) -> Optional[str]:
|
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.
|
"""Look up the user_id bound to a browser session_id, or None.
|
||||||
|
|
||||||
Returns None when the session is unknown or expired. Expired rows
|
Returns None when the session is unknown or expired. Expired rows
|
||||||
@@ -1217,6 +1217,31 @@ class RefreshTokenStorage:
|
|||||||
logger.debug("Deleted browser session %s", session_id[:8])
|
logger.debug("Deleted browser session %s", session_id[:8])
|
||||||
return deleted
|
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)
|
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -1396,7 +1421,7 @@ class RefreshTokenStorage:
|
|||||||
auth_method="app_password",
|
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.
|
Retrieve and decrypt app password for a user.
|
||||||
|
|
||||||
|
|||||||
@@ -285,14 +285,18 @@
|
|||||||
<ul class="app-navigation__settings">
|
<ul class="app-navigation__settings">
|
||||||
<li class="app-navigation-entry">
|
<li class="app-navigation-entry">
|
||||||
<div class="app-navigation-entry__wrapper">
|
<div class="app-navigation-entry__wrapper">
|
||||||
<a href="{{ logout_url }}" class="app-navigation-entry-link">
|
{# 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">
|
<span class="app-navigation-entry-icon">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="app-navigation-entry__name">Logout</span>
|
<span class="app-navigation-entry__name">Logout</span>
|
||||||
</a>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ between server/ and auth/ layers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
@@ -18,10 +19,37 @@ from ..http import nextcloud_httpx_client
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# OIDC discovery + JWKS caches keyed by URL → (expires_at, data). Mirrors the
|
||||||
|
# pattern in oauth_routes._get_cached_discovery so that ID-token verification
|
||||||
|
# during the OAuth callback doesn't make two extra round-trips per login (PR
|
||||||
|
# #758 finding 4). 5-minute TTL matches oauth_routes.
|
||||||
|
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
_OIDC_CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
class IdTokenVerificationError(Exception):
|
class IdTokenVerificationError(Exception):
|
||||||
"""Raised when an OIDC ID token fails signature or claim verification."""
|
"""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
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return cached JSON response for *url* or fetch + cache on miss/expiry."""
|
||||||
|
now = time.time()
|
||||||
|
entry = cache.get(url)
|
||||||
|
if entry is not None:
|
||||||
|
expires_at, data = entry
|
||||||
|
if now < expires_at:
|
||||||
|
return data
|
||||||
|
async with nextcloud_httpx_client() as http_client:
|
||||||
|
response = await http_client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
cache[url] = (now + _OIDC_CACHE_TTL, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def verify_id_token(
|
async def verify_id_token(
|
||||||
id_token: str,
|
id_token: str,
|
||||||
*,
|
*,
|
||||||
@@ -62,10 +90,7 @@ async def verify_id_token(
|
|||||||
raise IdTokenVerificationError("ID token missing from token response")
|
raise IdTokenVerificationError("ID token missing from token response")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with nextcloud_httpx_client() as http_client:
|
discovery = await _get_cached(_discovery_cache, discovery_url)
|
||||||
discovery_response = await http_client.get(discovery_url)
|
|
||||||
discovery_response.raise_for_status()
|
|
||||||
discovery = discovery_response.json()
|
|
||||||
|
|
||||||
issuer = discovery.get("issuer")
|
issuer = discovery.get("issuer")
|
||||||
jwks_uri = discovery.get("jwks_uri")
|
jwks_uri = discovery.get("jwks_uri")
|
||||||
@@ -74,9 +99,7 @@ async def verify_id_token(
|
|||||||
"OIDC discovery response missing issuer or jwks_uri"
|
"OIDC discovery response missing issuer or jwks_uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
jwks_response = await http_client.get(jwks_uri)
|
jwks_data = await _get_cached(_jwks_cache, jwks_uri)
|
||||||
jwks_response.raise_for_status()
|
|
||||||
jwks_data = jwks_response.json()
|
|
||||||
except IdTokenVerificationError:
|
except IdTokenVerificationError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -97,10 +120,18 @@ async def verify_id_token(
|
|||||||
f"No JWKS key matches ID token kid {kid!r}"
|
f"No JWKS key matches ID token kid {kid!r}"
|
||||||
) from 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(
|
payload: dict[str, Any] = jwt.decode(
|
||||||
id_token,
|
id_token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256", "PS256", "ES256"],
|
||||||
audience=expected_audience,
|
audience=expected_audience,
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
options={
|
options={
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Regression tests for HTML XSS in browser OAuth error responses.
|
||||||
|
|
||||||
|
The reviewer on PR #758 flagged that ``oauth_login_callback`` interpolated
|
||||||
|
IdP-controlled and query-parameter-controlled text into HTMLResponse bodies
|
||||||
|
without escaping. These tests pin the html_escape behavior so the
|
||||||
|
vulnerability cannot regress silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
XSS_PAYLOAD = "<script>alert(1)</script>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def storage():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "xss.db"
|
||||||
|
s = RefreshTokenStorage(
|
||||||
|
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
|
||||||
|
)
|
||||||
|
await s.initialize()
|
||||||
|
yield s
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(*, query_params: dict, oauth_context: dict | None = None):
|
||||||
|
request = MagicMock()
|
||||||
|
request.query_params = query_params
|
||||||
|
request.cookies = {}
|
||||||
|
request.app.state.oauth_context = oauth_context
|
||||||
|
request.url_for = MagicMock(return_value="/oauth/login")
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
async def test_callback_escapes_error_query_params(storage):
|
||||||
|
"""`error` and `error_description` are attacker-controlled — must be escaped."""
|
||||||
|
request = _build_request(
|
||||||
|
query_params={
|
||||||
|
"error": XSS_PAYLOAD,
|
||||||
|
"error_description": XSS_PAYLOAD,
|
||||||
|
},
|
||||||
|
oauth_context={"storage": storage, "config": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await oauth_login_callback(request)
|
||||||
|
body = response.body.decode()
|
||||||
|
|
||||||
|
assert XSS_PAYLOAD not in body
|
||||||
|
assert "<script>alert(1)</script>" in body
|
||||||
|
|
||||||
|
|
||||||
|
async def test_callback_escapes_idp_http_error_body(storage):
|
||||||
|
"""IdP-returned HTTPError body must be HTML-escaped before reflection."""
|
||||||
|
discovery = {"token_endpoint": "http://idp.example/token"}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path.endswith("/.well-known/openid-configuration"):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
content=json.dumps(discovery).encode(),
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
)
|
||||||
|
if str(request.url) == "http://idp.example/token":
|
||||||
|
return httpx.Response(400, content=XSS_PAYLOAD.encode())
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
def fake_client(**kwargs):
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
return httpx.AsyncClient(**kwargs)
|
||||||
|
|
||||||
|
# Pre-populate the oauth_session row that the callback expects
|
||||||
|
await storage.store_oauth_session(
|
||||||
|
session_id="state-xss",
|
||||||
|
client_id="browser-ui",
|
||||||
|
client_redirect_uri="/app",
|
||||||
|
state="state-xss",
|
||||||
|
code_challenge="cc",
|
||||||
|
code_challenge_method="S256",
|
||||||
|
mcp_authorization_code="cv",
|
||||||
|
flow_type="browser",
|
||||||
|
ttl_seconds=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = _build_request(
|
||||||
|
query_params={"code": "abc", "state": "state-xss"},
|
||||||
|
oauth_context={
|
||||||
|
"storage": storage,
|
||||||
|
"oauth_client": None,
|
||||||
|
"config": {
|
||||||
|
"discovery_url": "http://idp.example/.well-known/openid-configuration",
|
||||||
|
"client_id": "test",
|
||||||
|
"client_secret": "secret",
|
||||||
|
"mcp_server_url": "http://localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||||
|
side_effect=fake_client,
|
||||||
|
):
|
||||||
|
response = await oauth_login_callback(request)
|
||||||
|
|
||||||
|
body = response.body.decode()
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert XSS_PAYLOAD not in body
|
||||||
|
assert "<script>alert(1)</script>" in body
|
||||||
@@ -73,3 +73,27 @@ async def test_replace_existing_session_id(storage):
|
|||||||
await storage.create_browser_session(session_id=sid, user_id="bob")
|
await storage.create_browser_session(session_id=sid, user_id="bob")
|
||||||
|
|
||||||
assert await storage.get_browser_session_user(sid) == "bob"
|
assert await storage.get_browser_session_user(sid) == "bob"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cleanup_expired_browser_sessions(storage):
|
||||||
|
"""Periodic cleanup removes expired rows but leaves fresh ones (PR #758 finding 6)."""
|
||||||
|
fresh_sid = secrets.token_urlsafe(32)
|
||||||
|
expired_sid = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
await storage.create_browser_session(
|
||||||
|
session_id=fresh_sid, user_id="alice", ttl_seconds=3600
|
||||||
|
)
|
||||||
|
# ttl_seconds=-2 → expires_at strictly in the past (cleanup uses < now,
|
||||||
|
# so it must be actually less, not equal).
|
||||||
|
await storage.create_browser_session(
|
||||||
|
session_id=expired_sid, user_id="bob", ttl_seconds=-2
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await storage.cleanup_expired_browser_sessions()
|
||||||
|
assert deleted == 1
|
||||||
|
|
||||||
|
# Fresh row survives, expired row is gone
|
||||||
|
assert await storage.get_browser_session_user(fresh_sid) == "alice"
|
||||||
|
assert await storage.get_browser_session_user(expired_sid) is None
|
||||||
|
# Calling again should be a no-op
|
||||||
|
assert await storage.cleanup_expired_browser_sessions() == 0
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import pytest
|
|||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import token_utils
|
||||||
from nextcloud_mcp_server.auth.token_utils import (
|
from nextcloud_mcp_server.auth.token_utils import (
|
||||||
IdTokenVerificationError,
|
IdTokenVerificationError,
|
||||||
verify_id_token,
|
verify_id_token,
|
||||||
@@ -26,6 +27,16 @@ from nextcloud_mcp_server.auth.token_utils import (
|
|||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_oidc_caches():
|
||||||
|
"""Reset the discovery+JWKS caches so tests don't share fetched data."""
|
||||||
|
token_utils._discovery_cache.clear()
|
||||||
|
token_utils._jwks_cache.clear()
|
||||||
|
yield
|
||||||
|
token_utils._discovery_cache.clear()
|
||||||
|
token_utils._jwks_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
# Generated once per process — RSA keypair generation is slow.
|
# Generated once per process — RSA keypair generation is slow.
|
||||||
_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
_PRIVATE_PEM = _KEY.private_bytes(
|
_PRIVATE_PEM = _KEY.private_bytes(
|
||||||
@@ -231,3 +242,48 @@ async def test_verify_id_token_missing_token_rejected():
|
|||||||
await verify_id_token(
|
await verify_id_token(
|
||||||
"", discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
"", discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verify_id_token_caches_discovery_and_jwks():
|
||||||
|
"""Discovery + JWKS must be cached: two verifications, one fetch each.
|
||||||
|
|
||||||
|
Pins the fix for PR #758 finding 4 — every login previously made two
|
||||||
|
extra HTTP round-trips to the IdP for the same metadata.
|
||||||
|
"""
|
||||||
|
fetches: dict[str, int] = {}
|
||||||
|
|
||||||
|
def counting_handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
fetches[url] = fetches.get(url, 0) + 1
|
||||||
|
return _idp_handler(request)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(counting_handler)
|
||||||
|
|
||||||
|
def fake_client(**kwargs):
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
return httpx.AsyncClient(**kwargs)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
token = _sign(
|
||||||
|
{
|
||||||
|
"iss": ISSUER,
|
||||||
|
"aud": "test-client",
|
||||||
|
"sub": "alice",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 60,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
|
||||||
|
side_effect=fake_client,
|
||||||
|
):
|
||||||
|
await verify_id_token(
|
||||||
|
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||||
|
)
|
||||||
|
await verify_id_token(
|
||||||
|
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fetches.get(DISCOVERY_URL) == 1, "discovery fetched more than once"
|
||||||
|
assert fetches.get(JWKS_URI) == 1, "JWKS fetched more than once"
|
||||||
|
|||||||
@@ -46,12 +46,20 @@ async def storage():
|
|||||||
yield s
|
yield s
|
||||||
|
|
||||||
|
|
||||||
def _build_request(*, cookie: str | None, oauth_context: dict | None):
|
def _build_request(
|
||||||
|
*,
|
||||||
|
cookie: str | None,
|
||||||
|
oauth_context: dict | None,
|
||||||
|
headers: dict | None = None,
|
||||||
|
):
|
||||||
"""Build a minimal Starlette-style request stub for oauth_logout."""
|
"""Build a minimal Starlette-style request stub for oauth_logout."""
|
||||||
request = MagicMock()
|
request = MagicMock()
|
||||||
request.query_params = {}
|
request.query_params = {}
|
||||||
request.cookies = {"mcp_session": cookie} if cookie else {}
|
request.cookies = {"mcp_session": cookie} if cookie else {}
|
||||||
request.app.state.oauth_context = oauth_context
|
request.app.state.oauth_context = oauth_context
|
||||||
|
# Headers default to empty so the CSRF check sees neither Origin nor
|
||||||
|
# Referer (allowed by policy — see _origin_matches_self).
|
||||||
|
request.headers = headers or {}
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +77,7 @@ async def test_logout_deletes_refresh_token_and_session(storage):
|
|||||||
|
|
||||||
request = _build_request(
|
request = _build_request(
|
||||||
cookie="sid-1",
|
cookie="sid-1",
|
||||||
oauth_context={"storage": storage, "discovery_url": None},
|
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
@@ -93,7 +101,10 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
|
|||||||
revoke = AsyncMock()
|
revoke = AsyncMock()
|
||||||
request = _build_request(
|
request = _build_request(
|
||||||
cookie="sid-2",
|
cookie="sid-2",
|
||||||
oauth_context={"storage": storage, "discovery_url": "http://idp/.well-known"},
|
oauth_context={
|
||||||
|
"storage": storage,
|
||||||
|
"config": {"discovery_url": "http://idp/.well-known"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
@@ -111,7 +122,8 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
|
|||||||
async def test_logout_no_session_cookie_returns_302(storage):
|
async def test_logout_no_session_cookie_returns_302(storage):
|
||||||
"""Without a cookie, logout still 302s and doesn't touch storage."""
|
"""Without a cookie, logout still 302s and doesn't touch storage."""
|
||||||
request = _build_request(
|
request = _build_request(
|
||||||
cookie=None, oauth_context={"storage": storage, "discovery_url": None}
|
cookie=None,
|
||||||
|
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||||
)
|
)
|
||||||
response = await oauth_logout(request)
|
response = await oauth_logout(request)
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
@@ -128,12 +140,78 @@ async def test_logout_swallows_storage_errors(storage):
|
|||||||
|
|
||||||
request = _build_request(
|
request = _build_request(
|
||||||
cookie="sid-3",
|
cookie="sid-3",
|
||||||
oauth_context={"storage": broken_storage, "discovery_url": None},
|
oauth_context={
|
||||||
|
"storage": broken_storage,
|
||||||
|
"config": {"discovery_url": None},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
response = await oauth_logout(request)
|
response = await oauth_logout(request)
|
||||||
assert response.status_code == 302 # logout still succeeds
|
assert response.status_code == 302 # logout still succeeds
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_blocks_cross_origin_post(storage):
|
||||||
|
"""POST from a foreign Origin must be rejected with 403 (PR #758 finding 5)."""
|
||||||
|
await storage.create_browser_session(session_id="sid-X", user_id="alice")
|
||||||
|
|
||||||
|
request = _build_request(
|
||||||
|
cookie="sid-X",
|
||||||
|
oauth_context={
|
||||||
|
"storage": storage,
|
||||||
|
"config": {
|
||||||
|
"mcp_server_url": "https://mcp.example.com",
|
||||||
|
"discovery_url": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers={"origin": "https://evil.example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await oauth_logout(request)
|
||||||
|
assert response.status_code == 403
|
||||||
|
# Session row must NOT have been deleted.
|
||||||
|
assert await storage.get_browser_session_user("sid-X") == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_allows_same_origin_post(storage):
|
||||||
|
"""POST with matching Origin proceeds normally."""
|
||||||
|
await storage.create_browser_session(session_id="sid-Y", user_id="alice")
|
||||||
|
|
||||||
|
request = _build_request(
|
||||||
|
cookie="sid-Y",
|
||||||
|
oauth_context={
|
||||||
|
"storage": storage,
|
||||||
|
"config": {
|
||||||
|
"mcp_server_url": "https://mcp.example.com",
|
||||||
|
"discovery_url": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers={"origin": "https://mcp.example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await oauth_logout(request)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert await storage.get_browser_session_user("sid-Y") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_allows_referer_when_origin_missing(storage):
|
||||||
|
"""Some browsers strip Origin on POST; Referer is the fallback signal."""
|
||||||
|
await storage.create_browser_session(session_id="sid-Z", user_id="alice")
|
||||||
|
|
||||||
|
request = _build_request(
|
||||||
|
cookie="sid-Z",
|
||||||
|
oauth_context={
|
||||||
|
"storage": storage,
|
||||||
|
"config": {
|
||||||
|
"mcp_server_url": "https://mcp.example.com",
|
||||||
|
"discovery_url": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers={"referer": "https://mcp.example.com/app"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await oauth_logout(request)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
async def test_logout_handles_session_with_no_refresh_token(storage):
|
async def test_logout_handles_session_with_no_refresh_token(storage):
|
||||||
"""Cookie + session row exist but refresh token already gone — logout is idempotent."""
|
"""Cookie + session row exist but refresh token already gone — logout is idempotent."""
|
||||||
await storage.create_browser_session(session_id="sid-4", user_id="dave")
|
await storage.create_browser_session(session_id="sid-4", user_id="dave")
|
||||||
@@ -141,7 +219,7 @@ async def test_logout_handles_session_with_no_refresh_token(storage):
|
|||||||
revoke = AsyncMock()
|
revoke = AsyncMock()
|
||||||
request = _build_request(
|
request = _build_request(
|
||||||
cookie="sid-4",
|
cookie="sid-4",
|
||||||
oauth_context={"storage": storage, "discovery_url": None},
|
oauth_context={"storage": storage, "config": {"discovery_url": None}},
|
||||||
)
|
)
|
||||||
with patch(
|
with patch(
|
||||||
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
|
||||||
@@ -197,9 +275,11 @@ async def test_revoke_helper_posts_to_revocation_endpoint():
|
|||||||
):
|
):
|
||||||
await _revoke_refresh_token_at_idp(
|
await _revoke_refresh_token_at_idp(
|
||||||
{
|
{
|
||||||
|
"config": {
|
||||||
"discovery_url": discovery_url,
|
"discovery_url": discovery_url,
|
||||||
"client_id": "test-client",
|
"client_id": "test-client",
|
||||||
"client_secret": "test-secret",
|
"client_secret": "test-secret",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"rt-secret",
|
"rt-secret",
|
||||||
)
|
)
|
||||||
@@ -232,9 +312,11 @@ async def test_revoke_helper_skips_when_no_revocation_endpoint():
|
|||||||
# Returns None and does not raise
|
# Returns None and does not raise
|
||||||
result = await _revoke_refresh_token_at_idp(
|
result = await _revoke_refresh_token_at_idp(
|
||||||
{
|
{
|
||||||
|
"config": {
|
||||||
"discovery_url": discovery_url,
|
"discovery_url": discovery_url,
|
||||||
"client_id": "x",
|
"client_id": "x",
|
||||||
"client_secret": "y",
|
"client_secret": "y",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"rt",
|
"rt",
|
||||||
)
|
)
|
||||||
@@ -259,9 +341,11 @@ async def test_revoke_helper_silent_on_idp_error():
|
|||||||
):
|
):
|
||||||
result = await _revoke_refresh_token_at_idp(
|
result = await _revoke_refresh_token_at_idp(
|
||||||
{
|
{
|
||||||
|
"config": {
|
||||||
"discovery_url": "http://x/.well-known",
|
"discovery_url": "http://x/.well-known",
|
||||||
"client_id": "x",
|
"client_id": "x",
|
||||||
"client_secret": "y",
|
"client_secret": "y",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"rt",
|
"rt",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Tests for _safe_next_url, the open-redirect guard for ``?next=`` params.
|
||||||
|
|
||||||
|
Pins the contract that any non-path target falls back to the default,
|
||||||
|
preventing the open-redirect issue flagged on PR #758.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.browser_oauth_routes import _safe_next_url
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw, expected",
|
||||||
|
[
|
||||||
|
# Valid path-only targets pass through.
|
||||||
|
("/app", "/app"),
|
||||||
|
("/app/foo", "/app/foo"),
|
||||||
|
("/oauth/login", "/oauth/login"),
|
||||||
|
("/app?x=1&y=2", "/app?x=1&y=2"),
|
||||||
|
("/app#frag", "/app#frag"),
|
||||||
|
# Empty / missing → default.
|
||||||
|
("", "/default"),
|
||||||
|
(None, "/default"),
|
||||||
|
# Absolute URLs → default.
|
||||||
|
("https://evil.example.com", "/default"),
|
||||||
|
("http://evil.example.com/path", "/default"),
|
||||||
|
# Protocol-relative → default. Browser would treat as cross-origin.
|
||||||
|
("//evil.example.com", "/default"),
|
||||||
|
("//evil.example.com/path", "/default"),
|
||||||
|
# No leading slash → default.
|
||||||
|
("relative/path", "/default"),
|
||||||
|
("app", "/default"),
|
||||||
|
# Whitespace / control chars → default. Defends against tab/space
|
||||||
|
# injection that some browsers historically tolerated.
|
||||||
|
("/app\nfoo", "/default"),
|
||||||
|
("/app\tfoo", "/default"),
|
||||||
|
("/app\x00foo", "/default"),
|
||||||
|
("/app foo", "/default"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_safe_next_url(raw, expected):
|
||||||
|
assert _safe_next_url(raw, "/default") == expected
|
||||||
Reference in New Issue
Block a user