# Conflicts:
#	uv.lock
This commit is contained in:
Taylor Wilsdon
2026-02-24 10:13:14 -04:00
40 changed files with 3515 additions and 335 deletions

View File

@@ -215,10 +215,13 @@ class LocalDirectoryCredentialStore(CredentialStore):
return []
users = []
non_credential_files = {"oauth_states"}
try:
for filename in os.listdir(self.base_dir):
if filename.endswith(".json"):
user_email = filename[:-5] # Remove .json extension
if user_email in non_credential_files or "@" not in user_email:
continue
users.append(user_email)
logger.debug(
f"Found {len(users)} users with credentials in {self.base_dir}"

View File

@@ -15,7 +15,7 @@ from google.auth.transport.requests import Request
from google.auth.exceptions import RefreshError
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from auth.scopes import SCOPES, get_current_scopes # noqa
from auth.scopes import SCOPES, get_current_scopes, has_required_scopes # noqa
from auth.oauth21_session_store import get_oauth21_session_store
from auth.credential_store import get_credential_store
from auth.oauth_config import get_oauth_config, is_stateless_mode
@@ -586,20 +586,8 @@ def get_credentials(
f"[get_credentials] Found OAuth 2.1 credentials for MCP session {session_id}"
)
# Check scopes
if not all(
scope in credentials.scopes for scope in required_scopes
):
logger.warning(
f"[get_credentials] OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}"
)
return None
# Return if valid
if credentials.valid:
return credentials
elif credentials.expired and credentials.refresh_token:
# Try to refresh
# Refresh expired credentials before checking scopes
if credentials.expired and credentials.refresh_token:
try:
credentials.refresh(Request())
logger.info(
@@ -612,9 +600,13 @@ def get_credentials(
user_email=user_email,
access_token=credentials.token,
refresh_token=credentials.refresh_token,
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=credentials.scopes,
expiry=credentials.expiry,
mcp_session_id=session_id,
issuer="https://accounts.google.com",
)
# Persist to file so rotated refresh tokens survive restarts
if not is_stateless_mode():
@@ -627,12 +619,23 @@ def get_credentials(
logger.warning(
f"[get_credentials] Failed to persist refreshed OAuth 2.1 credentials for user {user_email}: {persist_error}"
)
return credentials
except Exception as e:
logger.error(
f"[get_credentials] Failed to refresh OAuth 2.1 credentials: {e}"
)
return None
# Check scopes after refresh so stale metadata doesn't block valid tokens
if not has_required_scopes(credentials.scopes, required_scopes):
logger.warning(
f"[get_credentials] OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}"
)
return None
if credentials.valid:
return credentials
return None
except ImportError:
pass # OAuth 2.1 store not available
except Exception as e:
@@ -706,21 +709,14 @@ def get_credentials(
f"[get_credentials] Credentials found. Scopes: {credentials.scopes}, Valid: {credentials.valid}, Expired: {credentials.expired}"
)
if not all(scope in credentials.scopes for scope in required_scopes):
logger.warning(
f"[get_credentials] Credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}. User: '{user_google_email}', Session: '{session_id}'"
)
return None # Re-authentication needed for scopes
logger.debug(
f"[get_credentials] Credentials have sufficient scopes. User: '{user_google_email}', Session: '{session_id}'"
)
# Attempt refresh before checking scopes — the scope check validates against
# credentials.scopes which is set at authorization time and not updated by the
# google-auth library on refresh. Checking scopes first would block a valid
# refresh attempt when stored scope metadata is stale.
if credentials.valid:
logger.debug(
f"[get_credentials] Credentials are valid. User: '{user_google_email}', Session: '{session_id}'"
)
return credentials
elif credentials.expired and credentials.refresh_token:
logger.info(
f"[get_credentials] Credentials expired. Attempting refresh. User: '{user_google_email}', Session: '{session_id}'"
@@ -729,7 +725,6 @@ def get_credentials(
logger.debug(
"[get_credentials] Refreshing token using embedded client credentials"
)
# client_config = load_client_secrets(client_secrets_path) # Not strictly needed if creds have client_id/secret
credentials.refresh(Request())
logger.info(
f"[get_credentials] Credentials refreshed successfully. User: '{user_google_email}', Session: '{session_id}'"
@@ -762,7 +757,6 @@ def get_credentials(
if session_id: # Update session cache if it was the source or is active
save_credentials_to_session(session_id, credentials)
return credentials
except RefreshError as e:
logger.warning(
f"[get_credentials] RefreshError - token expired/revoked: {e}. User: '{user_google_email}', Session: '{session_id}'"
@@ -781,6 +775,19 @@ def get_credentials(
)
return None
# Check scopes after refresh so stale scope metadata doesn't block valid tokens.
# Uses hierarchy-aware check (e.g. gmail.modify satisfies gmail.readonly).
if not has_required_scopes(credentials.scopes, required_scopes):
logger.warning(
f"[get_credentials] Credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}. User: '{user_google_email}', Session: '{session_id}'"
)
return None # Re-authentication needed for scopes
logger.debug(
f"[get_credentials] Credentials have sufficient scopes. User: '{user_google_email}', Session: '{session_id}'"
)
return credentials
def get_user_info(
credentials: Credentials, *, skip_valid_check: bool = False

View File

@@ -41,6 +41,7 @@ GMAIL_SETTINGS_BASIC_SCOPE = "https://www.googleapis.com/auth/gmail.settings.bas
CHAT_READONLY_SCOPE = "https://www.googleapis.com/auth/chat.messages.readonly"
CHAT_WRITE_SCOPE = "https://www.googleapis.com/auth/chat.messages"
CHAT_SPACES_SCOPE = "https://www.googleapis.com/auth/chat.spaces"
CHAT_SPACES_READONLY_SCOPE = "https://www.googleapis.com/auth/chat.spaces.readonly"
# Google Sheets API scopes
SHEETS_READONLY_SCOPE = "https://www.googleapis.com/auth/spreadsheets.readonly"
@@ -80,6 +81,53 @@ SCRIPT_DEPLOYMENTS_READONLY_SCOPE = (
SCRIPT_PROCESSES_READONLY_SCOPE = "https://www.googleapis.com/auth/script.processes"
SCRIPT_METRICS_SCOPE = "https://www.googleapis.com/auth/script.metrics"
# Google scope hierarchy: broader scopes that implicitly cover narrower ones.
# See https://developers.google.com/gmail/api/auth/scopes,
# https://developers.google.com/drive/api/guides/api-specific-auth, etc.
SCOPE_HIERARCHY = {
GMAIL_MODIFY_SCOPE: {
GMAIL_READONLY_SCOPE,
GMAIL_SEND_SCOPE,
GMAIL_COMPOSE_SCOPE,
GMAIL_LABELS_SCOPE,
},
DRIVE_SCOPE: {DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE},
CALENDAR_SCOPE: {CALENDAR_READONLY_SCOPE, CALENDAR_EVENTS_SCOPE},
DOCS_WRITE_SCOPE: {DOCS_READONLY_SCOPE},
SHEETS_WRITE_SCOPE: {SHEETS_READONLY_SCOPE},
SLIDES_SCOPE: {SLIDES_READONLY_SCOPE},
TASKS_SCOPE: {TASKS_READONLY_SCOPE},
CONTACTS_SCOPE: {CONTACTS_READONLY_SCOPE},
CHAT_WRITE_SCOPE: {CHAT_READONLY_SCOPE},
CHAT_SPACES_SCOPE: {CHAT_SPACES_READONLY_SCOPE},
FORMS_BODY_SCOPE: {FORMS_BODY_READONLY_SCOPE},
SCRIPT_PROJECTS_SCOPE: {SCRIPT_PROJECTS_READONLY_SCOPE},
SCRIPT_DEPLOYMENTS_SCOPE: {SCRIPT_DEPLOYMENTS_READONLY_SCOPE},
}
def has_required_scopes(available_scopes, required_scopes):
"""
Check if available scopes satisfy all required scopes, accounting for
Google's scope hierarchy (e.g., gmail.modify covers gmail.readonly).
Args:
available_scopes: Scopes the credentials have (set, list, or frozenset).
required_scopes: Scopes that are required (set, list, or frozenset).
Returns:
True if all required scopes are satisfied.
"""
available = set(available_scopes or [])
required = set(required_scopes or [])
# Expand available scopes with implied narrower scopes
expanded = set(available)
for broad_scope, covered in SCOPE_HIERARCHY.items():
if broad_scope in available:
expanded.update(covered)
return all(scope in expanded for scope in required)
# Base OAuth scopes required for user identification
BASE_SCOPES = [USERINFO_EMAIL_SCOPE, USERINFO_PROFILE_SCOPE, OPENID_SCOPE]
@@ -104,7 +152,12 @@ GMAIL_SCOPES = [
GMAIL_SETTINGS_BASIC_SCOPE,
]
CHAT_SCOPES = [CHAT_READONLY_SCOPE, CHAT_WRITE_SCOPE, CHAT_SPACES_SCOPE]
CHAT_SCOPES = [
CHAT_READONLY_SCOPE,
CHAT_WRITE_SCOPE,
CHAT_SPACES_SCOPE,
CHAT_SPACES_READONLY_SCOPE,
]
SHEETS_SCOPES = [SHEETS_READONLY_SCOPE, SHEETS_WRITE_SCOPE, DRIVE_READONLY_SCOPE]
@@ -155,7 +208,7 @@ TOOL_READONLY_SCOPES_MAP = {
"calendar": [CALENDAR_READONLY_SCOPE],
"docs": [DOCS_READONLY_SCOPE, DRIVE_READONLY_SCOPE],
"sheets": [SHEETS_READONLY_SCOPE, DRIVE_READONLY_SCOPE],
"chat": [CHAT_READONLY_SCOPE],
"chat": [CHAT_READONLY_SCOPE, CHAT_SPACES_READONLY_SCOPE],
"forms": [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE],
"slides": [SLIDES_READONLY_SCOPE],
"tasks": [TASKS_READONLY_SCOPE],

View File

@@ -39,6 +39,7 @@ from auth.scopes import (
CHAT_READONLY_SCOPE,
CHAT_WRITE_SCOPE,
CHAT_SPACES_SCOPE,
CHAT_SPACES_READONLY_SCOPE,
FORMS_BODY_SCOPE,
FORMS_BODY_READONLY_SCOPE,
FORMS_RESPONSES_READONLY_SCOPE,
@@ -53,6 +54,7 @@ from auth.scopes import (
SCRIPT_PROJECTS_READONLY_SCOPE,
SCRIPT_DEPLOYMENTS_SCOPE,
SCRIPT_DEPLOYMENTS_READONLY_SCOPE,
has_required_scopes,
)
logger = logging.getLogger(__name__)
@@ -274,7 +276,7 @@ async def get_authenticated_google_service_oauth21(
if not scopes_available and getattr(access_token, "scopes", None):
scopes_available = set(access_token.scopes)
if not all(scope in scopes_available for scope in required_scopes):
if not has_required_scopes(scopes_available, required_scopes):
raise GoogleAuthenticationError(
f"OAuth credentials lack required scopes. Need: {required_scopes}, Have: {sorted(scopes_available)}"
)
@@ -304,7 +306,7 @@ async def get_authenticated_google_service_oauth21(
else:
scopes_available = set(credentials.scopes)
if not all(scope in scopes_available for scope in required_scopes):
if not has_required_scopes(scopes_available, required_scopes):
raise GoogleAuthenticationError(
f"OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {sorted(scopes_available)}"
)
@@ -439,6 +441,7 @@ SCOPE_GROUPS = {
"chat_read": CHAT_READONLY_SCOPE,
"chat_write": CHAT_WRITE_SCOPE,
"chat_spaces": CHAT_SPACES_SCOPE,
"chat_spaces_readonly": CHAT_SPACES_READONLY_SCOPE,
# Forms scopes
"forms": FORMS_BODY_SCOPE,
"forms_read": FORMS_BODY_READONLY_SCOPE,