fix(auth): fail closed on missing sub claim, delete Flow 2 callback session
Addresses the two remaining 🟡 findings from the PR #758 follow-up review: 1. extract_user_id_from_token previously fell back to "default_user" when the verified access token had no sub claim. In a multi-tenant deployment a malformed IdP token could have bucketed every request under a single sentinel user, risking cross-tenant data exposure. The function now raises McpError on that branch; the BasicAuth no-token sentinel path is preserved. 2. oauth_callback_nextcloud (Flow 2) read the PKCE code_verifier from oauth_sessions but never deleted the row, leaving the verifier valid for the full 10-minute TTL. The row is now deleted eagerly inside the same branch, mirroring oauth_login_callback in browser_oauth_routes. Also wires TOKEN_ENCRYPTION_KEY through the docker-compose step in the CI test workflow so the integration matrix can boot — every job had been failing fast on the ${TOKEN_ENCRYPTION_KEY:?...} interpolation guard added in PR #758 finding 5. Tests pin both fixes (test_token_utils_user_id.py, test_oauth_callback_session_cleanup.py). 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
2d340a5a6b
commit
2ef4bfc4af
@@ -597,6 +597,11 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
logger.info(
|
||||
f"Retrieved code_verifier for Flow 2 callback (state={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(
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
@@ -188,8 +190,17 @@ async def extract_user_id_from_token(_ctx: Context) -> str:
|
||||
verifier-populated AccessToken via get_access_token().
|
||||
|
||||
Returns:
|
||||
user_id from the verified token, or "default_user" when no token is
|
||||
present (e.g. BasicAuth mode where this should not be called).
|
||||
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).
|
||||
"""
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
@@ -202,6 +213,11 @@ async def extract_user_id_from_token(_ctx: Context) -> str:
|
||||
logger.error(
|
||||
"Access token has no resource (sub) claim — verifier should have rejected it"
|
||||
)
|
||||
return "default_user"
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Cannot determine user identity from access token",
|
||||
)
|
||||
)
|
||||
|
||||
return user_id
|
||||
|
||||
Reference in New Issue
Block a user