fix(auth): address PR #758 auto-review (id-token verify, nonce, CI key)

Blocking:
- AS proxy callback now calls verify_id_token before caching the proxy
  code so a tampered IdP response can't smuggle identity claims.

Important:
- Browser OAuth flow generates and verifies an OIDC nonce; new alembic
  migration 006 adds the nonce column to oauth_sessions.
- _origin_matches_self logs a warning when CSRF check is bypassed.
- oauth_tools.py uses get_shared_storage instead of fresh handles.

Nits:
- New token_utils.get_oidc_discovery shares the 5-minute cache with
  verify_id_token; oauth_login (integrated) and _revoke_refresh_token_at_idp
  now use it instead of issuing fresh discovery fetches.
- Drop typing.Optional from oauth_tools.py in favour of X | None.

CI:
- test.yml generates an ephemeral Fernet TOKEN_ENCRYPTION_KEY per run
  with openssl, removing the dependency on a missing repo secret.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 20:48:25 +02:00
co-authored by Claude Opus 4.7
parent 2ef4bfc4af
commit 4c84d82984
9 changed files with 277 additions and 54 deletions
+14 -5
View File
@@ -129,6 +129,18 @@ jobs:
# npm ci # npm ci
# npm run build # npm run build
# Generate an ephemeral Fernet key per CI run. docker-compose.yml
# requires TOKEN_ENCRYPTION_KEY (PR #758 finding 5 removed the
# hardcoded default), but the CI tokens.db is destroyed at the end of
# the job so there is no value in persisting the key as a repo secret.
# ``openssl rand -base64 32`` produces 32 bytes encoded as 44 base64
# chars; ``tr '+/' '-_'`` converts to URL-safe base64, which is
# exactly what Fernet expects.
- name: Generate ephemeral TOKEN_ENCRYPTION_KEY
run: |
KEY=$(openssl rand -base64 32 | tr '+/' '-_')
echo "TOKEN_ENCRYPTION_KEY=${KEY}" >> "$GITHUB_ENV"
# Start services with the appropriate profile # Start services with the appropriate profile
- name: Run docker compose - name: Run docker compose
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
@@ -139,11 +151,8 @@ jobs:
env: env:
MCP_SERVER_URL: ${{ matrix.mcp-internal-url }} MCP_SERVER_URL: ${{ matrix.mcp-internal-url }}
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }} NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
# Required by docker-compose.yml since PR #758 finding 5 (no more # Inherited from $GITHUB_ENV via the previous step.
# hardcoded Fernet keys). Generated once and stored as a repo TOKEN_ENCRYPTION_KEY: ${{ env.TOKEN_ENCRYPTION_KEY }}
# secret; the CI tokens.db is ephemeral so a single shared key
# across services is acceptable.
TOKEN_ENCRYPTION_KEY: ${{ secrets.TOKEN_ENCRYPTION_KEY }}
- name: Install the latest version of uv - name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
@@ -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
@@ -19,6 +19,7 @@ from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.token_utils import ( from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError, IdTokenVerificationError,
get_oidc_discovery,
verify_id_token, verify_id_token,
) )
from nextcloud_mcp_server.auth.userinfo_routes import ( from nextcloud_mcp_server.auth.userinfo_routes import (
@@ -65,7 +66,15 @@ def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
cfg = oauth_ctx.get("config") or oauth_ctx cfg = oauth_ctx.get("config") or oauth_ctx
mcp_server_url = cfg.get("mcp_server_url") mcp_server_url = cfg.get("mcp_server_url")
if not mcp_server_url: if not mcp_server_url:
# Mis-configured deployment — fail open rather than break logout. # Mis-configured deployment — fail open rather than break logout, but
# log loudly so the operator can see this is happening (PR #758
# finding 3). Other OAuth code paths require ``mcp_server_url`` and
# KeyError if it's absent, so this branch should never fire in a
# correctly configured deployment.
logger.warning(
"CSRF check bypassed on /oauth/logout: mcp_server_url not "
"configured in oauth_context — set NEXTCLOUD_MCP_SERVER_URL"
)
return True return True
expected = _normalise_origin(mcp_server_url) expected = _normalise_origin(mcp_server_url)
@@ -149,6 +158,12 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
# Generate state for CSRF protection # Generate state for CSRF protection
state = secrets.token_urlsafe(32) 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 # Build OAuth authorization URL
mcp_server_url = oauth_config["mcp_server_url"] mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback" callback_uri = f"{mcp_server_url}/oauth/callback"
@@ -164,7 +179,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
digest = hashlib.sha256(code_verifier.encode()).digest() digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=") 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( await storage.store_oauth_session(
session_id=state, # Use state as session ID session_id=state, # Use state as session ID
client_id="browser-ui", client_id="browser-ui",
@@ -173,6 +189,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
code_challenge=code_challenge, code_challenge=code_challenge,
code_challenge_method="S256", code_challenge_method="S256",
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
nonce=nonce,
flow_type="browser", flow_type="browser",
ttl_seconds=600, # 10 minutes ttl_seconds=600, # 10 minutes
) )
@@ -199,6 +216,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"response_type": "code", "response_type": "code",
"scope": scopes, "scope": scopes,
"state": state, "state": state,
"nonce": nonce,
"code_challenge": code_challenge, "code_challenge": code_challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token "prompt": "consent", # Ensure refresh token
@@ -219,12 +237,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
status_code=500, status_code=500,
) )
# Fetch authorization endpoint # Fetch authorization endpoint via the shared 5-minute discovery
async with nextcloud_httpx_client() as http_client: # cache (PR #758 nit 5) so each browser login doesn't hit the IdP's
response = await http_client.get(discovery_url) # discovery endpoint.
response.raise_for_status() discovery = await get_oidc_discovery(discovery_url)
discovery = response.json() authorization_endpoint = discovery["authorization_endpoint"]
authorization_endpoint = discovery["authorization_endpoint"]
# Include offline_access only if the IdP advertises it (or if # Include offline_access only if the IdP advertises it (or if
# scopes_supported is absent from the discovery document). # scopes_supported is absent from the discovery document).
@@ -257,6 +274,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"response_type": "code", "response_type": "code",
"scope": scopes, "scope": scopes,
"state": state, "state": state,
"nonce": nonce,
"code_challenge": code_challenge, "code_challenge": code_challenge,
"code_challenge_method": "S256", "code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token "prompt": "consent", # Ensure refresh token
@@ -336,13 +354,17 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
oauth_client = oauth_ctx["oauth_client"] oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"] oauth_config = oauth_ctx["config"]
# Retrieve code_verifier and redirect URL from session storage # Retrieve code_verifier, nonce, and redirect URL from session storage
code_verifier = "" code_verifier = ""
nonce: str | None = None
next_url = "/app" # Default redirect next_url = "/app" # Default redirect
oauth_session = await storage.get_oauth_session(state) oauth_session = await storage.get_oauth_session(state)
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", "")
# 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 # next_url was stored in client_redirect_uri field — re-validate at
# read-time as defense-in-depth (issue #758 finding 3). The session # read-time as defense-in-depth (issue #758 finding 3). The session
# row could have been written by an older code path or reused. # row could have been written by an older code path or reused.
@@ -483,6 +505,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
id_token, id_token,
discovery_url=verification_discovery_url, discovery_url=verification_discovery_url,
expected_audience=verification_audience, expected_audience=verification_audience,
expected_nonce=nonce,
) )
except IdTokenVerificationError as e: except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e) logger.error("ID token verification failed: %s", e)
@@ -673,21 +696,21 @@ async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> N
if not discovery_url: if not discovery_url:
return 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: async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(discovery_url)
discovery_response.raise_for_status()
discovery = discovery_response.json()
revocation_endpoint = discovery.get("revocation_endpoint")
if not revocation_endpoint:
logger.debug("IdP advertises no revocation_endpoint; skipping")
return
client_id = 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
response = await http_client.post( response = await http_client.post(
revocation_endpoint, revocation_endpoint,
data={ data={
+22
View File
@@ -954,6 +954,28 @@ async def _oauth_callback_as_proxy(
f"(token_type={nc_token_response.get('token_type')})" 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.
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,
)
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 # Generate a proxy authorization code for the client
proxy_code = secrets.token_urlsafe(32) proxy_code = secrets.token_urlsafe(32)
_proxy_codes[proxy_code] = ProxyCodeEntry( _proxy_codes[proxy_code] = ProxyCodeEntry(
+6 -2
View File
@@ -917,6 +917,7 @@ class RefreshTokenStorage:
flow_type: str = "hybrid", flow_type: str = "hybrid",
is_provisioning: bool = False, is_provisioning: bool = False,
requested_scopes: str | None = None, requested_scopes: str | None = None,
nonce: str | None = None,
ttl_seconds: int = 600, # 10 minutes ttl_seconds: int = 600, # 10 minutes
) -> None: ) -> None:
""" """
@@ -933,6 +934,8 @@ class RefreshTokenStorage:
flow_type: Type of flow ('hybrid', 'flow1', 'flow2') flow_type: Type of flow ('hybrid', 'flow1', 'flow2')
is_provisioning: Whether this is a Flow 2 provisioning session is_provisioning: Whether this is a Flow 2 provisioning session
requested_scopes: Requested OAuth scopes 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 ttl_seconds: Session TTL in seconds
""" """
if not self._initialized: if not self._initialized:
@@ -947,8 +950,8 @@ class RefreshTokenStorage:
INSERT INTO oauth_sessions INSERT INTO oauth_sessions
(session_id, client_id, client_redirect_uri, state, code_challenge, (session_id, client_id, client_redirect_uri, state, code_challenge,
code_challenge_method, mcp_authorization_code, flow_type, code_challenge_method, mcp_authorization_code, flow_type,
is_provisioning, requested_scopes, created_at, expires_at) is_provisioning, requested_scopes, nonce, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
session_id, session_id,
@@ -961,6 +964,7 @@ class RefreshTokenStorage:
flow_type, flow_type,
is_provisioning, is_provisioning,
requested_scopes, requested_scopes,
nonce,
now, now,
expires_at, expires_at,
), ),
+11
View File
@@ -52,6 +52,17 @@ async def _get_cached(
return data return data
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. Public alias for `_get_cached`
against `_discovery_cache` (PR #758 nits 5 & 6).
"""
return await _get_cached(_discovery_cache, discovery_url)
async def verify_id_token( async def verify_id_token(
id_token: str, id_token: str,
*, *,
+10 -14
View File
@@ -9,7 +9,6 @@ import logging
import os import os
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
from mcp.server.fastmcp import Context 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 import require_scopes
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient 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 from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
# Re-export for backward compatibility — canonical location is auth.token_utils # 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.""" """Status of Nextcloud provisioning for a user."""
is_provisioned: bool = Field(description="Whether Nextcloud access is provisioned") 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" 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')" 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" None, description="Client ID that initiated the original Flow 1"
) )
scopes: Optional[list[str]] = Field(None, description="Granted scopes") scopes: list[str] | None = Field(None, description="Granted scopes")
flow_type: Optional[str] = Field( flow_type: str | None = Field(
None, description="Type of flow used ('hybrid', 'flow1', 'flow2')" None, description="Type of flow used ('hybrid', 'flow1', 'flow2')"
) )
@@ -53,7 +52,7 @@ class ProvisioningResult(BaseModel):
"""Result of provisioning attempt.""" """Result of provisioning attempt."""
success: bool = Field(description="Whether provisioning was initiated") 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" None, description="URL to Astrolabe settings for provisioning background sync"
) )
message: str = Field(description="Status message for the user") message: str = Field(description="Status message for the user")
@@ -122,8 +121,7 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
logger.info( logger.info(
f" get_provisioning_status: Looking up refresh token for user_id={user_id}" f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
) )
storage = RefreshTokenStorage.from_env() storage = await get_shared_storage()
await storage.initialize()
token_data = await storage.get_refresh_token(user_id) token_data = await storage.get_refresh_token(user_id)
@@ -291,8 +289,7 @@ async def revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResul
) )
# Initialize Token Broker to handle revocation # Initialize Token Broker to handle revocation
storage = RefreshTokenStorage.from_env() storage = await get_shared_storage()
await storage.initialize()
# Get OAuth client credentials from storage # Get OAuth client credentials from storage
client_creds = await storage.get_oauth_client() client_creds = await storage.get_oauth_client()
@@ -420,8 +417,7 @@ async def check_logged_in(ctx: Context, user_id: str) -> str:
state = secrets.token_urlsafe(32) state = secrets.token_urlsafe(32)
# Store state in session for validation on callback # Store state in session for validation on callback
storage = RefreshTokenStorage.from_env() storage = await get_shared_storage()
await storage.initialize()
# Create OAuth session for Flow 2 # Create OAuth session for Flow 2
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}" session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
@@ -9,6 +9,11 @@ storage layer to confirm the row is gone after the callback runs.
We mock everything *after* the deletion (discovery + token exchange + We mock everything *after* the deletion (discovery + token exchange +
ID token verification) so the test focuses on the cleanup contract, ID token verification) so the test focuses on the cleanup contract,
not the OAuth wire protocol. not the OAuth wire protocol.
Also pins the AS-proxy callback's ID-token verification rejection path
introduced in PR #758 finding 1 (auto-review): a forged or unsigned
id_token must surface as a 400 ``invalid_token`` JSONResponse and must
not register a proxy code.
""" """
import tempfile import tempfile
@@ -19,8 +24,15 @@ import httpx
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.oauth_routes import oauth_callback_nextcloud from nextcloud_mcp_server.auth.oauth_routes import (
ASProxySession,
_as_proxy_sessions,
_oauth_callback_as_proxy,
_proxy_codes,
oauth_callback_nextcloud,
)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_utils import IdTokenVerificationError
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -170,3 +182,85 @@ async def test_callback_no_session_row_does_not_crash(storage):
# No crash, no row, no surprises. # No crash, no row, no surprises.
assert await storage.get_oauth_session(state) is None assert await storage.get_oauth_session(state) is None
# ---------------------------------------------------------------------------
# AS proxy callback (PR #758 finding 1): ID-token verification rejection
# ---------------------------------------------------------------------------
def _build_as_proxy_request(*, code: str, state: str):
request = MagicMock()
request.query_params = {"code": code, "state": state}
request.app.state.oauth_context = {
"config": {
"discovery_url": "https://idp.example.com/.well-known/openid-configuration",
"mcp_server_url": "https://mcp.example.com",
"client_id": "mcp-server",
"client_secret": "mcp-secret",
}
}
return request
async def test_as_proxy_rejects_invalid_id_token():
"""Forged/unsigned id_token in the IdP token response → 400 invalid_token.
Pins PR #758 finding 1. Without verification a compromised IdP or
tampered transport could plant arbitrary identity claims into the
cached ProxyCodeEntry that downstream clients pick up.
"""
server_state = "as-proxy-state-rejected"
_as_proxy_sessions[server_state] = ASProxySession(
client_id="mcp-client",
client_redirect_uri="http://127.0.0.1:9999/callback",
client_state="client-state-xyz",
code_challenge="challenge",
code_challenge_method="S256",
requested_scopes="openid",
)
_proxy_codes.clear()
request = _build_as_proxy_request(code="auth-code", state=server_state)
fake_discovery = {
"token_endpoint": "https://idp.example.com/token",
"issuer": "https://idp.example.com",
}
fake_token_response = MagicMock(status_code=200)
fake_token_response.json.return_value = {
"access_token": "ac-tok",
"refresh_token": "rf-tok",
"id_token": "forged.id.token",
"token_type": "Bearer",
}
fake_http = MagicMock()
fake_http.post = AsyncMock(return_value=fake_token_response)
fake_http.__aenter__ = AsyncMock(return_value=fake_http)
fake_http.__aexit__ = AsyncMock(return_value=None)
with (
patch(
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
new=AsyncMock(return_value=fake_discovery),
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
return_value=fake_http,
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.verify_id_token",
new=AsyncMock(side_effect=IdTokenVerificationError("bad signature")),
),
):
response = await _oauth_callback_as_proxy(request, server_state)
assert response.status_code == 400
body = bytes(response.body).decode()
assert "invalid_token" in body
# Critical: the proxy code store must not have grown — a rejected
# callback must not be turned into a redeemable proxy code.
assert _proxy_codes == {}
# And the session has been popped (one-time use).
assert server_state not in _as_proxy_sessions
+44 -9
View File
@@ -20,6 +20,7 @@ import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from starlette.requests import HTTPConnection from starlette.requests import HTTPConnection
from nextcloud_mcp_server.auth import token_utils
from nextcloud_mcp_server.auth.browser_oauth_routes import ( from nextcloud_mcp_server.auth.browser_oauth_routes import (
_revoke_refresh_token_at_idp, _revoke_refresh_token_at_idp,
oauth_logout, oauth_logout,
@@ -30,6 +31,20 @@ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def _clear_oidc_discovery_cache():
"""Reset the shared discovery cache so tests don't see each other's fetches.
``_revoke_refresh_token_at_idp`` was changed (PR #758 nit 6) to use
``token_utils.get_oidc_discovery`` which caches for 5 minutes — without
this clear, the second test in the file would see the first test's
discovery doc and skip the MockTransport call.
"""
token_utils._discovery_cache.clear()
yield
token_utils._discovery_cache.clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# storage fixture (real SQLite backend; lighter than mocking every call) # storage fixture (real SQLite backend; lighter than mocking every call)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -337,9 +352,17 @@ async def test_revoke_helper_posts_to_revocation_endpoint():
kwargs["transport"] = transport kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs) return httpx.AsyncClient(**kwargs)
with patch( # Discovery now goes through token_utils.get_oidc_discovery (PR #758 nit
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client", # 6); revocation POST still uses browser_oauth_routes' httpx client.
side_effect=fake_client, with (
patch(
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
): ):
await _revoke_refresh_token_at_idp( await _revoke_refresh_token_at_idp(
{ {
@@ -373,9 +396,15 @@ async def test_revoke_helper_skips_when_no_revocation_endpoint():
kwargs["transport"] = transport kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs) return httpx.AsyncClient(**kwargs)
with patch( with (
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client", patch(
side_effect=fake_client, "nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
): ):
# Returns None and does not raise # Returns None and does not raise
result = await _revoke_refresh_token_at_idp( result = await _revoke_refresh_token_at_idp(
@@ -403,9 +432,15 @@ async def test_revoke_helper_silent_on_idp_error():
kwargs["transport"] = transport kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs) return httpx.AsyncClient(**kwargs)
with patch( with (
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client", patch(
side_effect=fake_client, "nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
side_effect=fake_client,
),
patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
),
): ):
result = await _revoke_refresh_token_at_idp( result = await _revoke_refresh_token_at_idp(
{ {