claude-mpm 5.6.23__py3-none-any.whl → 5.6.73__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of claude-mpm might be problematic. Click here for more details.

Files changed (82) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/auth/__init__.py +35 -0
  3. claude_mpm/auth/callback_server.py +328 -0
  4. claude_mpm/auth/models.py +104 -0
  5. claude_mpm/auth/oauth_manager.py +266 -0
  6. claude_mpm/auth/providers/__init__.py +12 -0
  7. claude_mpm/auth/providers/base.py +165 -0
  8. claude_mpm/auth/providers/google.py +261 -0
  9. claude_mpm/auth/token_storage.py +252 -0
  10. claude_mpm/cli/commands/commander.py +6 -6
  11. claude_mpm/cli/commands/mcp.py +29 -17
  12. claude_mpm/cli/commands/mcp_command_router.py +39 -0
  13. claude_mpm/cli/commands/mcp_service_commands.py +304 -0
  14. claude_mpm/cli/commands/oauth.py +481 -0
  15. claude_mpm/cli/executor.py +9 -0
  16. claude_mpm/cli/helpers.py +1 -1
  17. claude_mpm/cli/parsers/base_parser.py +13 -0
  18. claude_mpm/cli/parsers/mcp_parser.py +79 -0
  19. claude_mpm/cli/parsers/oauth_parser.py +165 -0
  20. claude_mpm/cli/startup.py +150 -33
  21. claude_mpm/cli/startup_display.py +3 -2
  22. claude_mpm/commander/chat/cli.py +5 -2
  23. claude_mpm/commander/chat/commands.py +42 -16
  24. claude_mpm/commander/chat/repl.py +1581 -70
  25. claude_mpm/commander/events/manager.py +61 -1
  26. claude_mpm/commander/frameworks/base.py +87 -0
  27. claude_mpm/commander/frameworks/mpm.py +9 -14
  28. claude_mpm/commander/git/__init__.py +5 -0
  29. claude_mpm/commander/git/worktree_manager.py +212 -0
  30. claude_mpm/commander/instance_manager.py +428 -13
  31. claude_mpm/commander/models/events.py +6 -0
  32. claude_mpm/commander/persistence/state_store.py +95 -1
  33. claude_mpm/commander/tmux_orchestrator.py +3 -2
  34. claude_mpm/constants.py +5 -0
  35. claude_mpm/core/hook_manager.py +2 -1
  36. claude_mpm/core/logging_utils.py +4 -2
  37. claude_mpm/core/output_style_manager.py +5 -2
  38. claude_mpm/core/socketio_pool.py +34 -10
  39. claude_mpm/hooks/claude_hooks/auto_pause_handler.py +1 -1
  40. claude_mpm/hooks/claude_hooks/event_handlers.py +206 -94
  41. claude_mpm/hooks/claude_hooks/hook_handler.py +115 -32
  42. claude_mpm/hooks/claude_hooks/installer.py +175 -51
  43. claude_mpm/hooks/claude_hooks/memory_integration.py +1 -1
  44. claude_mpm/hooks/claude_hooks/response_tracking.py +1 -1
  45. claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
  46. claude_mpm/hooks/claude_hooks/services/connection_manager.py +2 -2
  47. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +2 -2
  48. claude_mpm/hooks/claude_hooks/services/container.py +326 -0
  49. claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
  50. claude_mpm/hooks/claude_hooks/services/state_manager.py +2 -2
  51. claude_mpm/hooks/claude_hooks/services/subagent_processor.py +2 -2
  52. claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
  53. claude_mpm/hooks/templates/pre_tool_use_template.py +6 -6
  54. claude_mpm/init.py +21 -14
  55. claude_mpm/mcp/__init__.py +9 -0
  56. claude_mpm/mcp/google_workspace_server.py +610 -0
  57. claude_mpm/scripts/claude-hook-handler.sh +3 -3
  58. claude_mpm/services/command_deployment_service.py +44 -26
  59. claude_mpm/services/hook_installer_service.py +77 -8
  60. claude_mpm/services/mcp_config_manager.py +99 -19
  61. claude_mpm/services/mcp_service_registry.py +294 -0
  62. claude_mpm/services/monitor/server.py +6 -1
  63. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/METADATA +24 -1
  64. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/RECORD +69 -64
  65. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/WHEEL +1 -1
  66. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/entry_points.txt +2 -0
  67. claude_mpm/hooks/claude_hooks/__pycache__/__init__.cpython-311.pyc +0 -0
  68. claude_mpm/hooks/claude_hooks/__pycache__/auto_pause_handler.cpython-311.pyc +0 -0
  69. claude_mpm/hooks/claude_hooks/__pycache__/correlation_manager.cpython-311.pyc +0 -0
  70. claude_mpm/hooks/claude_hooks/__pycache__/event_handlers.cpython-311.pyc +0 -0
  71. claude_mpm/hooks/claude_hooks/__pycache__/hook_handler.cpython-311.pyc +0 -0
  72. claude_mpm/hooks/claude_hooks/__pycache__/memory_integration.cpython-311.pyc +0 -0
  73. claude_mpm/hooks/claude_hooks/__pycache__/response_tracking.cpython-311.pyc +0 -0
  74. claude_mpm/hooks/claude_hooks/__pycache__/tool_analysis.cpython-311.pyc +0 -0
  75. claude_mpm/hooks/claude_hooks/services/__pycache__/__init__.cpython-311.pyc +0 -0
  76. claude_mpm/hooks/claude_hooks/services/__pycache__/connection_manager_http.cpython-311.pyc +0 -0
  77. claude_mpm/hooks/claude_hooks/services/__pycache__/duplicate_detector.cpython-311.pyc +0 -0
  78. claude_mpm/hooks/claude_hooks/services/__pycache__/state_manager.cpython-311.pyc +0 -0
  79. claude_mpm/hooks/claude_hooks/services/__pycache__/subagent_processor.cpython-311.pyc +0 -0
  80. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/licenses/LICENSE +0 -0
  81. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/licenses/LICENSE-FAQ.md +0 -0
  82. {claude_mpm-5.6.23.dist-info → claude_mpm-5.6.73.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,266 @@
1
+ """OAuth manager for orchestrating complete OAuth2 authentication flows.
2
+
3
+ This module provides a high-level interface for OAuth authentication,
4
+ coordinating providers, callback servers, and token storage.
5
+ """
6
+
7
+ import webbrowser
8
+ from typing import Optional
9
+
10
+ from claude_mpm.auth.callback_server import OAuthCallbackServer
11
+ from claude_mpm.auth.models import OAuthToken, StoredToken, TokenMetadata, TokenStatus
12
+ from claude_mpm.auth.providers.base import OAuthProvider
13
+ from claude_mpm.auth.providers.google import GoogleOAuthProvider, OAuthError
14
+ from claude_mpm.auth.token_storage import TokenStorage
15
+
16
+ # Mapping of provider names to provider classes
17
+ PROVIDERS: dict[str, type[OAuthProvider]] = {
18
+ "google": GoogleOAuthProvider,
19
+ }
20
+
21
+
22
+ class OAuthManager:
23
+ """High-level OAuth authentication manager.
24
+
25
+ Orchestrates the complete OAuth2 flow including authorization,
26
+ token exchange, storage, refresh, and revocation.
27
+
28
+ Attributes:
29
+ storage: Token storage instance for persisting credentials.
30
+
31
+ Example:
32
+ ```python
33
+ manager = OAuthManager()
34
+
35
+ # Authenticate with Google
36
+ token = await manager.authenticate(
37
+ service_name="gmail-mcp",
38
+ provider_name="google",
39
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
40
+ )
41
+
42
+ # Check token status
43
+ status, stored = manager.get_status("gmail-mcp")
44
+ if status == TokenStatus.EXPIRED:
45
+ token = await manager.refresh_if_needed("gmail-mcp")
46
+
47
+ # Revoke when done
48
+ await manager.revoke("gmail-mcp")
49
+ ```
50
+ """
51
+
52
+ def __init__(self, storage: Optional[TokenStorage] = None) -> None:
53
+ """Initialize OAuth manager.
54
+
55
+ Args:
56
+ storage: Token storage instance. Creates default if not provided.
57
+ """
58
+ self.storage = storage or TokenStorage()
59
+
60
+ def get_provider(self, provider_name: str) -> OAuthProvider:
61
+ """Get an OAuth provider instance by name.
62
+
63
+ Args:
64
+ provider_name: Name of the provider (e.g., "google").
65
+
66
+ Returns:
67
+ Configured provider instance.
68
+
69
+ Raises:
70
+ ValueError: If provider name is not recognized.
71
+ """
72
+ provider_class = PROVIDERS.get(provider_name.lower())
73
+ if provider_class is None:
74
+ available = ", ".join(PROVIDERS.keys())
75
+ raise ValueError(
76
+ f"Unknown provider: {provider_name}. Available providers: {available}"
77
+ )
78
+ return provider_class()
79
+
80
+ async def authenticate(
81
+ self,
82
+ service_name: str,
83
+ provider_name: str,
84
+ scopes: Optional[list[str]] = None,
85
+ open_browser: bool = True,
86
+ ) -> OAuthToken:
87
+ """Perform complete OAuth2 authentication flow.
88
+
89
+ This method orchestrates the full OAuth flow:
90
+ 1. Start callback server
91
+ 2. Generate PKCE and state
92
+ 3. Build authorization URL
93
+ 4. Open browser for user authorization
94
+ 5. Wait for callback with authorization code
95
+ 6. Exchange code for tokens
96
+ 7. Store tokens securely
97
+
98
+ Args:
99
+ service_name: Unique identifier for this service/credential.
100
+ provider_name: Name of the OAuth provider (e.g., "google").
101
+ scopes: OAuth scopes to request. Uses provider defaults if not specified.
102
+ open_browser: Whether to automatically open the authorization URL.
103
+
104
+ Returns:
105
+ OAuthToken containing access and refresh tokens.
106
+
107
+ Raises:
108
+ ValueError: If provider is not recognized.
109
+ OAuthError: If authentication fails at any step.
110
+ """
111
+ # Get provider instance
112
+ provider = self.get_provider(provider_name)
113
+
114
+ # Use provider's default scopes if none specified
115
+ if scopes is None:
116
+ if hasattr(provider, "DEFAULT_SCOPES"):
117
+ scopes = provider.DEFAULT_SCOPES
118
+ else:
119
+ scopes = []
120
+
121
+ # Step 1: Start callback server
122
+ callback_server = OAuthCallbackServer()
123
+
124
+ # Step 2: Generate PKCE and state
125
+ pkce = OAuthProvider.generate_pkce()
126
+ state = callback_server.generate_state()
127
+
128
+ # Step 3: Build authorization URL
129
+ auth_url = provider.get_authorization_url(
130
+ redirect_uri=callback_server.callback_url,
131
+ scopes=scopes,
132
+ state=state,
133
+ code_challenge=pkce.code_challenge,
134
+ )
135
+
136
+ # Step 4: Open browser for user authorization
137
+ if open_browser:
138
+ webbrowser.open(auth_url)
139
+
140
+ # Step 5: Wait for callback
141
+ result = await callback_server.wait_for_callback(
142
+ expected_state=state,
143
+ timeout=300.0,
144
+ )
145
+
146
+ if not result.success:
147
+ raise OAuthError(
148
+ f"Authorization failed: {result.error_description or result.error}",
149
+ error_code=result.error,
150
+ )
151
+
152
+ if result.code is None:
153
+ raise OAuthError("No authorization code received")
154
+
155
+ # Step 6: Exchange code for tokens
156
+ token = await provider.exchange_code(
157
+ code=result.code,
158
+ redirect_uri=callback_server.callback_url,
159
+ code_verifier=pkce.code_verifier,
160
+ )
161
+
162
+ # Step 7: Store tokens
163
+ metadata = TokenMetadata(
164
+ service_name=service_name,
165
+ provider=provider_name,
166
+ )
167
+ self.storage.store(service_name, token, metadata)
168
+
169
+ return token
170
+
171
+ async def refresh_if_needed(self, service_name: str) -> Optional[OAuthToken]:
172
+ """Refresh token if expired or about to expire.
173
+
174
+ Checks if the stored token is expired and refreshes it using
175
+ the refresh token if available.
176
+
177
+ Args:
178
+ service_name: Unique identifier for the service.
179
+
180
+ Returns:
181
+ New OAuthToken if refreshed, existing token if still valid,
182
+ None if no token exists or refresh failed.
183
+
184
+ Raises:
185
+ OAuthError: If token refresh fails.
186
+ """
187
+ stored = self.storage.retrieve(service_name)
188
+ if stored is None:
189
+ return None
190
+
191
+ # Check if token is still valid (with 60 second buffer)
192
+ if not stored.token.is_expired():
193
+ return stored.token
194
+
195
+ # Need to refresh
196
+ if stored.token.refresh_token is None:
197
+ return None
198
+
199
+ # Get provider and refresh
200
+ provider = self.get_provider(stored.metadata.provider)
201
+ new_token = await provider.refresh_token(stored.token.refresh_token)
202
+
203
+ # Update stored token
204
+ self.storage.store(service_name, new_token, stored.metadata)
205
+
206
+ return new_token
207
+
208
+ async def revoke(self, service_name: str) -> bool:
209
+ """Revoke tokens and delete stored credentials.
210
+
211
+ Revokes the token with the OAuth provider and removes
212
+ the stored credentials.
213
+
214
+ Args:
215
+ service_name: Unique identifier for the service.
216
+
217
+ Returns:
218
+ True if revocation and deletion succeeded, False otherwise.
219
+ """
220
+ stored = self.storage.retrieve(service_name)
221
+ if stored is None:
222
+ return False
223
+
224
+ # Get provider and revoke
225
+ provider = self.get_provider(stored.metadata.provider)
226
+
227
+ # Try to revoke the refresh token first (more thorough)
228
+ revoked = False
229
+ if stored.token.refresh_token:
230
+ revoked = await provider.revoke_token(stored.token.refresh_token)
231
+
232
+ # If no refresh token or revocation failed, try access token
233
+ if not revoked:
234
+ revoked = await provider.revoke_token(stored.token.access_token)
235
+
236
+ # Delete stored credentials regardless of revocation result
237
+ self.storage.delete(service_name)
238
+
239
+ return revoked
240
+
241
+ def get_status(
242
+ self, service_name: str
243
+ ) -> tuple[TokenStatus, Optional[StoredToken]]:
244
+ """Get the status of a stored token.
245
+
246
+ Args:
247
+ service_name: Unique identifier for the service.
248
+
249
+ Returns:
250
+ Tuple of (TokenStatus, StoredToken or None).
251
+ """
252
+ status = self.storage.get_status(service_name)
253
+ stored = (
254
+ self.storage.retrieve(service_name)
255
+ if status != TokenStatus.MISSING
256
+ else None
257
+ )
258
+ return (status, stored)
259
+
260
+ def list_authenticated_services(self) -> list[str]:
261
+ """List all services with stored tokens.
262
+
263
+ Returns:
264
+ List of service names that have stored credentials.
265
+ """
266
+ return self.storage.list_services()
@@ -0,0 +1,12 @@
1
+ """OAuth providers for authentication.
2
+
3
+ This module exports OAuth provider implementations for various services.
4
+ """
5
+
6
+ from claude_mpm.auth.providers.base import OAuthProvider
7
+ from claude_mpm.auth.providers.google import GoogleOAuthProvider
8
+
9
+ __all__ = [
10
+ "GoogleOAuthProvider",
11
+ "OAuthProvider",
12
+ ]
@@ -0,0 +1,165 @@
1
+ """Abstract base class for OAuth providers.
2
+
3
+ This module defines the interface that all OAuth providers must implement,
4
+ providing a consistent API for OAuth2 authentication flows with PKCE support.
5
+ """
6
+
7
+ import base64
8
+ import hashlib
9
+ import secrets
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+
13
+ from claude_mpm.auth.models import OAuthToken
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PKCEChallenge:
18
+ """PKCE code verifier and challenge pair.
19
+
20
+ Attributes:
21
+ code_verifier: Random string used to generate the challenge.
22
+ code_challenge: SHA256 hash of the verifier, base64url encoded.
23
+ """
24
+
25
+ code_verifier: str
26
+ code_challenge: str
27
+
28
+
29
+ class OAuthProvider(ABC):
30
+ """Abstract base class for OAuth2 providers.
31
+
32
+ Defines the interface for OAuth authentication flows including
33
+ authorization URL generation, token exchange, refresh, and revocation.
34
+ All implementations should support PKCE (Proof Key for Code Exchange).
35
+
36
+ Attributes:
37
+ name: Human-readable name of the OAuth provider.
38
+ authorization_url: URL for user authorization.
39
+ token_url: URL for token exchange.
40
+ """
41
+
42
+ @property
43
+ @abstractmethod
44
+ def name(self) -> str:
45
+ """Human-readable name of the OAuth provider."""
46
+ ...
47
+
48
+ @property
49
+ @abstractmethod
50
+ def authorization_url(self) -> str:
51
+ """URL for initiating user authorization."""
52
+ ...
53
+
54
+ @property
55
+ @abstractmethod
56
+ def token_url(self) -> str:
57
+ """URL for exchanging authorization code for tokens."""
58
+ ...
59
+
60
+ @abstractmethod
61
+ def get_authorization_url(
62
+ self,
63
+ redirect_uri: str,
64
+ scopes: list[str],
65
+ state: str,
66
+ code_challenge: str,
67
+ ) -> str:
68
+ """Build the authorization URL with PKCE support.
69
+
70
+ Constructs the full authorization URL including all required
71
+ parameters for the OAuth2 flow with PKCE.
72
+
73
+ Args:
74
+ redirect_uri: URL to redirect to after authorization.
75
+ scopes: List of OAuth scopes to request.
76
+ state: Random state string for CSRF protection.
77
+ code_challenge: PKCE code challenge (S256 hash of verifier).
78
+
79
+ Returns:
80
+ Complete authorization URL for user redirect.
81
+ """
82
+ ...
83
+
84
+ @abstractmethod
85
+ async def exchange_code(
86
+ self,
87
+ code: str,
88
+ redirect_uri: str,
89
+ code_verifier: str,
90
+ ) -> OAuthToken:
91
+ """Exchange authorization code for tokens.
92
+
93
+ Completes the OAuth2 flow by exchanging the authorization code
94
+ for access and refresh tokens.
95
+
96
+ Args:
97
+ code: Authorization code received from the provider.
98
+ redirect_uri: Same redirect URI used in authorization.
99
+ code_verifier: PKCE code verifier used to generate the challenge.
100
+
101
+ Returns:
102
+ OAuthToken containing access token and optional refresh token.
103
+
104
+ Raises:
105
+ OAuthError: If token exchange fails.
106
+ """
107
+ ...
108
+
109
+ @abstractmethod
110
+ async def refresh_token(self, refresh_token: str) -> OAuthToken:
111
+ """Refresh an expired access token.
112
+
113
+ Uses the refresh token to obtain a new access token without
114
+ requiring user interaction.
115
+
116
+ Args:
117
+ refresh_token: Valid refresh token from previous authentication.
118
+
119
+ Returns:
120
+ OAuthToken with new access token (may include new refresh token).
121
+
122
+ Raises:
123
+ OAuthError: If token refresh fails or refresh token is invalid.
124
+ """
125
+ ...
126
+
127
+ @abstractmethod
128
+ async def revoke_token(self, token: str) -> bool:
129
+ """Revoke an access or refresh token.
130
+
131
+ Invalidates the token with the OAuth provider, preventing
132
+ further use.
133
+
134
+ Args:
135
+ token: Access token or refresh token to revoke.
136
+
137
+ Returns:
138
+ True if revocation succeeded, False otherwise.
139
+ """
140
+ ...
141
+
142
+ @staticmethod
143
+ def generate_pkce() -> PKCEChallenge:
144
+ """Generate PKCE code verifier and challenge.
145
+
146
+ Creates a cryptographically secure code verifier and derives
147
+ the S256 challenge from it per RFC 7636.
148
+
149
+ Returns:
150
+ PKCEChallenge containing verifier and challenge strings.
151
+ """
152
+ # Generate 32 bytes of random data (256 bits)
153
+ code_verifier_bytes = secrets.token_bytes(32)
154
+ # Base64url encode without padding
155
+ code_verifier = (
156
+ base64.urlsafe_b64encode(code_verifier_bytes).rstrip(b"=").decode("ascii")
157
+ )
158
+
159
+ # Create S256 challenge: BASE64URL(SHA256(code_verifier))
160
+ challenge_digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
161
+ code_challenge = (
162
+ base64.urlsafe_b64encode(challenge_digest).rstrip(b"=").decode("ascii")
163
+ )
164
+
165
+ return PKCEChallenge(code_verifier=code_verifier, code_challenge=code_challenge)
@@ -0,0 +1,261 @@
1
+ """Google OAuth2 provider implementation.
2
+
3
+ This module provides OAuth2 authentication for Google services including
4
+ Gmail, Calendar, Drive, Docs, and Sheets with PKCE support.
5
+ """
6
+
7
+ import os
8
+ from datetime import datetime, timedelta, timezone
9
+ from urllib.parse import urlencode
10
+
11
+ import aiohttp
12
+
13
+ from claude_mpm.auth.models import OAuthToken
14
+ from claude_mpm.auth.providers.base import OAuthProvider
15
+
16
+
17
+ class OAuthError(Exception):
18
+ """Exception raised for OAuth-related errors."""
19
+
20
+ def __init__(self, message: str, error_code: str | None = None) -> None:
21
+ """Initialize OAuthError.
22
+
23
+ Args:
24
+ message: Human-readable error message.
25
+ error_code: Optional OAuth error code from provider.
26
+ """
27
+ super().__init__(message)
28
+ self.error_code = error_code
29
+
30
+
31
+ class GoogleOAuthProvider(OAuthProvider):
32
+ """Google OAuth2 provider with PKCE support.
33
+
34
+ Implements OAuth2 authentication for Google services with support
35
+ for offline access (refresh tokens) and PKCE security.
36
+
37
+ Attributes:
38
+ AUTHORIZATION_ENDPOINT: Google OAuth2 authorization URL.
39
+ TOKEN_ENDPOINT: Google OAuth2 token exchange URL.
40
+ REVOKE_ENDPOINT: Google OAuth2 token revocation URL.
41
+ DEFAULT_SCOPES: Default OAuth scopes for common Google services.
42
+
43
+ Environment Variables:
44
+ GOOGLE_OAUTH_CLIENT_ID: Google OAuth client ID (required).
45
+ GOOGLE_OAUTH_CLIENT_SECRET: Google OAuth client secret (required).
46
+ """
47
+
48
+ AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
49
+ TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" # nosec B105 - public OAuth2 endpoint
50
+ REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke"
51
+
52
+ DEFAULT_SCOPES: list[str] = [
53
+ "https://www.googleapis.com/auth/gmail.modify",
54
+ "https://www.googleapis.com/auth/calendar",
55
+ "https://www.googleapis.com/auth/drive",
56
+ "https://www.googleapis.com/auth/documents",
57
+ "https://www.googleapis.com/auth/spreadsheets",
58
+ ]
59
+
60
+ def __init__(
61
+ self,
62
+ client_id: str | None = None,
63
+ client_secret: str | None = None,
64
+ ) -> None:
65
+ """Initialize Google OAuth provider.
66
+
67
+ Args:
68
+ client_id: Google OAuth client ID. Defaults to GOOGLE_OAUTH_CLIENT_ID env var.
69
+ client_secret: Google OAuth client secret. Defaults to GOOGLE_OAUTH_CLIENT_SECRET env var.
70
+
71
+ Raises:
72
+ ValueError: If client ID or secret is not provided and not in environment.
73
+ """
74
+ self._client_id = client_id or os.environ.get("GOOGLE_OAUTH_CLIENT_ID")
75
+ self._client_secret = client_secret or os.environ.get(
76
+ "GOOGLE_OAUTH_CLIENT_SECRET"
77
+ )
78
+
79
+ if not self._client_id:
80
+ raise ValueError(
81
+ "Google OAuth client ID is required. "
82
+ "Set GOOGLE_OAUTH_CLIENT_ID environment variable or pass client_id parameter."
83
+ )
84
+ if not self._client_secret:
85
+ raise ValueError(
86
+ "Google OAuth client secret is required. "
87
+ "Set GOOGLE_OAUTH_CLIENT_SECRET environment variable or pass client_secret parameter."
88
+ )
89
+
90
+ @property
91
+ def name(self) -> str:
92
+ """Human-readable name of the OAuth provider."""
93
+ return "Google"
94
+
95
+ @property
96
+ def authorization_url(self) -> str:
97
+ """URL for initiating user authorization."""
98
+ return self.AUTHORIZATION_ENDPOINT
99
+
100
+ @property
101
+ def token_url(self) -> str:
102
+ """URL for exchanging authorization code for tokens."""
103
+ return self.TOKEN_ENDPOINT
104
+
105
+ def get_authorization_url(
106
+ self,
107
+ redirect_uri: str,
108
+ scopes: list[str],
109
+ state: str,
110
+ code_challenge: str,
111
+ ) -> str:
112
+ """Build the Google authorization URL with PKCE support.
113
+
114
+ Constructs the authorization URL with offline access and consent
115
+ prompt to ensure a refresh token is issued.
116
+
117
+ Args:
118
+ redirect_uri: URL to redirect to after authorization.
119
+ scopes: List of OAuth scopes to request.
120
+ state: Random state string for CSRF protection.
121
+ code_challenge: PKCE code challenge (S256 hash of verifier).
122
+
123
+ Returns:
124
+ Complete authorization URL for user redirect.
125
+ """
126
+ params = {
127
+ "client_id": self._client_id,
128
+ "redirect_uri": redirect_uri,
129
+ "response_type": "code",
130
+ "scope": " ".join(scopes),
131
+ "state": state,
132
+ "code_challenge": code_challenge,
133
+ "code_challenge_method": "S256",
134
+ "access_type": "offline",
135
+ "prompt": "consent",
136
+ }
137
+ return f"{self.AUTHORIZATION_ENDPOINT}?{urlencode(params)}"
138
+
139
+ async def exchange_code(
140
+ self,
141
+ code: str,
142
+ redirect_uri: str,
143
+ code_verifier: str,
144
+ ) -> OAuthToken:
145
+ """Exchange authorization code for Google tokens.
146
+
147
+ Args:
148
+ code: Authorization code received from Google.
149
+ redirect_uri: Same redirect URI used in authorization.
150
+ code_verifier: PKCE code verifier used to generate the challenge.
151
+
152
+ Returns:
153
+ OAuthToken containing access token and refresh token.
154
+
155
+ Raises:
156
+ OAuthError: If token exchange fails.
157
+ """
158
+ data = {
159
+ "client_id": self._client_id,
160
+ "client_secret": self._client_secret,
161
+ "code": code,
162
+ "code_verifier": code_verifier,
163
+ "grant_type": "authorization_code",
164
+ "redirect_uri": redirect_uri,
165
+ }
166
+
167
+ async with aiohttp.ClientSession() as session:
168
+ async with session.post(
169
+ self.TOKEN_ENDPOINT,
170
+ data=data,
171
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
172
+ ) as response:
173
+ result = await response.json()
174
+
175
+ if response.status != 200:
176
+ error_msg = result.get(
177
+ "error_description", result.get("error", "Unknown error")
178
+ )
179
+ raise OAuthError(
180
+ f"Token exchange failed: {error_msg}",
181
+ error_code=result.get("error"),
182
+ )
183
+
184
+ # Calculate expiration time
185
+ expires_in = result.get("expires_in", 3600)
186
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
187
+
188
+ return OAuthToken(
189
+ access_token=result["access_token"],
190
+ refresh_token=result.get("refresh_token"),
191
+ expires_at=expires_at,
192
+ scopes=result.get("scope", "").split(),
193
+ token_type=result.get("token_type", "Bearer"),
194
+ )
195
+
196
+ async def refresh_token(self, refresh_token: str) -> OAuthToken:
197
+ """Refresh an expired Google access token.
198
+
199
+ Args:
200
+ refresh_token: Valid refresh token from previous authentication.
201
+
202
+ Returns:
203
+ OAuthToken with new access token.
204
+
205
+ Raises:
206
+ OAuthError: If token refresh fails.
207
+ """
208
+ data = {
209
+ "client_id": self._client_id,
210
+ "client_secret": self._client_secret,
211
+ "refresh_token": refresh_token,
212
+ "grant_type": "refresh_token",
213
+ }
214
+
215
+ async with aiohttp.ClientSession() as session:
216
+ async with session.post(
217
+ self.TOKEN_ENDPOINT,
218
+ data=data,
219
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
220
+ ) as response:
221
+ result = await response.json()
222
+
223
+ if response.status != 200:
224
+ error_msg = result.get(
225
+ "error_description", result.get("error", "Unknown error")
226
+ )
227
+ raise OAuthError(
228
+ f"Token refresh failed: {error_msg}",
229
+ error_code=result.get("error"),
230
+ )
231
+
232
+ # Calculate expiration time
233
+ expires_in = result.get("expires_in", 3600)
234
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
235
+
236
+ return OAuthToken(
237
+ access_token=result["access_token"],
238
+ # Google may or may not return a new refresh token
239
+ refresh_token=result.get("refresh_token", refresh_token),
240
+ expires_at=expires_at,
241
+ scopes=result.get("scope", "").split(),
242
+ token_type=result.get("token_type", "Bearer"),
243
+ )
244
+
245
+ async def revoke_token(self, token: str) -> bool:
246
+ """Revoke a Google access or refresh token.
247
+
248
+ Args:
249
+ token: Access token or refresh token to revoke.
250
+
251
+ Returns:
252
+ True if revocation succeeded, False otherwise.
253
+ """
254
+ async with aiohttp.ClientSession() as session:
255
+ async with session.post(
256
+ self.REVOKE_ENDPOINT,
257
+ data={"token": token},
258
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
259
+ ) as response:
260
+ # Google returns 200 on success, various error codes on failure
261
+ return response.status == 200