Skip to content

Commit cad4f3d

Browse files
committed
Fix stateless mode OAuth infinite authentication loop
- Implement email-based credential lookup in stateless mode - Allow session-independent OAuth state validation - Handle OAuth callbacks when sessions are terminated Essential files only for WORKSPACE_MCP_STATELESS_MODE=true
1 parent 126ec85 commit cad4f3d

File tree

3 files changed

+71
-13
lines changed

3 files changed

+71
-13
lines changed

auth/google_auth.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -486,9 +486,19 @@ def handle_auth_callback(
486486
user_google_email = user_info["email"]
487487
logger.info(f"Identified user_google_email: {user_google_email}")
488488

489-
# Save the credentials
490-
credential_store = get_credential_store()
491-
credential_store.store_credential(user_google_email, credentials)
489+
# In stateless mode, if session_id is None or invalid, create a new one
490+
# This handles the case where the client terminated the session before OAuth completed
491+
if is_stateless_mode() and not session_id:
492+
import uuid
493+
session_id = str(uuid.uuid4())
494+
logger.info(f"Stateless mode: Created new session {session_id} for {user_google_email} after OAuth")
495+
496+
# Save the credentials to file (skip in stateless mode)
497+
if not is_stateless_mode():
498+
credential_store = get_credential_store()
499+
credential_store.store_credential(user_google_email, credentials)
500+
else:
501+
logger.info(f"Stateless mode: Skipping file storage for {user_google_email}")
492502

493503
# Always save to OAuth21SessionStore for centralized management
494504
store = get_oauth21_session_store()

auth/oauth21_session_store.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -262,21 +262,37 @@ def validate_and_consume_oauth_state(
262262
raise ValueError("Invalid or expired OAuth state parameter")
263263

264264
bound_session = state_info.get("session_id")
265-
if bound_session and session_id and bound_session != session_id:
266-
# Consume the state to prevent replay attempts
267-
del self._oauth_states[state]
268-
logger.error(
269-
"SECURITY: OAuth state session mismatch (expected %s, got %s)",
270-
bound_session,
271-
session_id,
272-
)
273-
raise ValueError("OAuth state does not match the initiating session")
265+
266+
# In stateless mode, don't require session to match
267+
# The state itself is cryptographically secure (32 bytes random)
268+
# and is one-time use, which provides sufficient security
269+
from auth.oauth_config import is_stateless_mode
270+
if not is_stateless_mode():
271+
# In non-stateless mode, enforce strict session binding
272+
if bound_session and session_id and bound_session != session_id:
273+
# Consume the state to prevent replay attempts
274+
del self._oauth_states[state]
275+
logger.error(
276+
"SECURITY: OAuth state session mismatch (expected %s, got %s)",
277+
bound_session,
278+
session_id,
279+
)
280+
raise ValueError("OAuth state does not match the initiating session")
281+
else:
282+
# In stateless mode, log but don't fail on session mismatch
283+
if bound_session and session_id and bound_session != session_id:
284+
logger.debug(
285+
"Stateless mode: OAuth state session mismatch (expected %s, got %s) - allowing",
286+
bound_session,
287+
session_id,
288+
)
274289

275290
# State is valid – consume it to prevent reuse
276291
del self._oauth_states[state]
277292
logger.debug(
278-
"Validated OAuth state %s",
293+
"Validated OAuth state %s (stateless mode: %s)",
279294
state[:8] if len(state) > 8 else state,
295+
is_stateless_mode()
280296
)
281297
return state_info
282298

auth/service_decorator.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ async def get_authenticated_google_service_oauth21(
210210
"""
211211
OAuth 2.1 authentication using the session store with security validation.
212212
"""
213+
from auth.oauth_config import is_stateless_mode
214+
213215
provider = get_auth_provider()
214216
access_token = get_access_token()
215217

@@ -254,6 +256,36 @@ async def get_authenticated_google_service_oauth21(
254256
return service, resolved_email
255257

256258
store = get_oauth21_session_store()
259+
260+
# In stateless mode with bearer token, use email-based lookup
261+
# This allows credentials to work across different session IDs
262+
if is_stateless_mode() and access_token:
263+
token_email = None
264+
if getattr(access_token, "claims", None):
265+
token_email = access_token.claims.get("email")
266+
267+
if token_email:
268+
# Security: Validate token email matches requested email
269+
if user_google_email and token_email != user_google_email:
270+
raise GoogleAuthenticationError(
271+
f"Token email {token_email} does not match requested user {user_google_email}."
272+
)
273+
274+
# Direct email-based lookup - works across sessions!
275+
logger.debug(f"[{tool_name}] Stateless mode: Using email-based credential lookup for {token_email}")
276+
credentials = store.get_credentials(token_email)
277+
278+
if credentials:
279+
# Validate scopes
280+
scopes_available = set(credentials.scopes or [])
281+
if not all(scope in scopes_available for scope in required_scopes):
282+
raise GoogleAuthenticationError(
283+
f"OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {sorted(scopes_available)}"
284+
)
285+
286+
service = build(service_name, version, credentials=credentials)
287+
logger.info(f"[{tool_name}] Stateless mode: Authenticated {service_name} for {token_email}")
288+
return service, token_email
257289

258290
# Use the validation method to ensure session can only access its own credentials
259291
credentials = store.get_credentials_with_validation(

0 commit comments

Comments
 (0)