claude-mpm 5.6.1__py3-none-any.whl → 5.6.76__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.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/PM_INSTRUCTIONS.md +8 -3
- claude_mpm/auth/__init__.py +35 -0
- claude_mpm/auth/callback_server.py +328 -0
- claude_mpm/auth/models.py +104 -0
- claude_mpm/auth/oauth_manager.py +266 -0
- claude_mpm/auth/providers/__init__.py +12 -0
- claude_mpm/auth/providers/base.py +165 -0
- claude_mpm/auth/providers/google.py +261 -0
- claude_mpm/auth/token_storage.py +252 -0
- claude_mpm/cli/commands/commander.py +174 -4
- claude_mpm/cli/commands/mcp.py +29 -17
- claude_mpm/cli/commands/mcp_command_router.py +39 -0
- claude_mpm/cli/commands/mcp_service_commands.py +304 -0
- claude_mpm/cli/commands/oauth.py +481 -0
- claude_mpm/cli/commands/skill_source.py +51 -2
- claude_mpm/cli/commands/skills.py +5 -3
- claude_mpm/cli/executor.py +9 -0
- claude_mpm/cli/helpers.py +1 -1
- claude_mpm/cli/parsers/base_parser.py +13 -0
- claude_mpm/cli/parsers/commander_parser.py +43 -10
- claude_mpm/cli/parsers/mcp_parser.py +79 -0
- claude_mpm/cli/parsers/oauth_parser.py +165 -0
- claude_mpm/cli/parsers/skill_source_parser.py +4 -0
- claude_mpm/cli/parsers/skills_parser.py +5 -0
- claude_mpm/cli/startup.py +300 -33
- claude_mpm/cli/startup_display.py +4 -2
- claude_mpm/cli/startup_migrations.py +236 -0
- claude_mpm/commander/__init__.py +6 -0
- claude_mpm/commander/adapters/__init__.py +32 -3
- claude_mpm/commander/adapters/auggie.py +260 -0
- claude_mpm/commander/adapters/base.py +98 -1
- claude_mpm/commander/adapters/claude_code.py +32 -1
- claude_mpm/commander/adapters/codex.py +237 -0
- claude_mpm/commander/adapters/example_usage.py +310 -0
- claude_mpm/commander/adapters/mpm.py +389 -0
- claude_mpm/commander/adapters/registry.py +204 -0
- claude_mpm/commander/api/app.py +32 -16
- claude_mpm/commander/api/errors.py +21 -0
- claude_mpm/commander/api/routes/messages.py +11 -11
- claude_mpm/commander/api/routes/projects.py +20 -20
- claude_mpm/commander/api/routes/sessions.py +37 -26
- claude_mpm/commander/api/routes/work.py +86 -50
- claude_mpm/commander/api/schemas.py +4 -0
- claude_mpm/commander/chat/cli.py +47 -5
- claude_mpm/commander/chat/commands.py +44 -16
- claude_mpm/commander/chat/repl.py +1729 -82
- claude_mpm/commander/config.py +5 -3
- claude_mpm/commander/core/__init__.py +10 -0
- claude_mpm/commander/core/block_manager.py +325 -0
- claude_mpm/commander/core/response_manager.py +323 -0
- claude_mpm/commander/daemon.py +215 -10
- claude_mpm/commander/env_loader.py +59 -0
- claude_mpm/commander/events/manager.py +61 -1
- claude_mpm/commander/frameworks/base.py +91 -1
- claude_mpm/commander/frameworks/mpm.py +9 -14
- claude_mpm/commander/git/__init__.py +5 -0
- claude_mpm/commander/git/worktree_manager.py +212 -0
- claude_mpm/commander/instance_manager.py +546 -15
- claude_mpm/commander/memory/__init__.py +45 -0
- claude_mpm/commander/memory/compression.py +347 -0
- claude_mpm/commander/memory/embeddings.py +230 -0
- claude_mpm/commander/memory/entities.py +310 -0
- claude_mpm/commander/memory/example_usage.py +290 -0
- claude_mpm/commander/memory/integration.py +325 -0
- claude_mpm/commander/memory/search.py +381 -0
- claude_mpm/commander/memory/store.py +657 -0
- claude_mpm/commander/models/events.py +6 -0
- claude_mpm/commander/persistence/state_store.py +95 -1
- claude_mpm/commander/registry.py +10 -4
- claude_mpm/commander/runtime/monitor.py +32 -2
- claude_mpm/commander/tmux_orchestrator.py +3 -2
- claude_mpm/commander/work/executor.py +38 -20
- claude_mpm/commander/workflow/event_handler.py +25 -3
- claude_mpm/config/skill_sources.py +16 -0
- claude_mpm/constants.py +5 -0
- claude_mpm/core/claude_runner.py +152 -0
- claude_mpm/core/config.py +30 -22
- claude_mpm/core/config_constants.py +74 -9
- claude_mpm/core/constants.py +56 -12
- claude_mpm/core/hook_manager.py +2 -1
- claude_mpm/core/interactive_session.py +5 -4
- claude_mpm/core/logger.py +16 -2
- claude_mpm/core/logging_utils.py +40 -16
- claude_mpm/core/network_config.py +148 -0
- claude_mpm/core/oneshot_session.py +7 -6
- claude_mpm/core/output_style_manager.py +37 -7
- claude_mpm/core/socketio_pool.py +47 -15
- claude_mpm/core/unified_paths.py +68 -80
- claude_mpm/hooks/claude_hooks/auto_pause_handler.py +30 -31
- claude_mpm/hooks/claude_hooks/event_handlers.py +285 -194
- claude_mpm/hooks/claude_hooks/hook_handler.py +115 -32
- claude_mpm/hooks/claude_hooks/installer.py +222 -54
- claude_mpm/hooks/claude_hooks/memory_integration.py +52 -32
- claude_mpm/hooks/claude_hooks/response_tracking.py +40 -59
- claude_mpm/hooks/claude_hooks/services/__init__.py +21 -0
- claude_mpm/hooks/claude_hooks/services/connection_manager.py +25 -30
- claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +24 -28
- claude_mpm/hooks/claude_hooks/services/container.py +326 -0
- claude_mpm/hooks/claude_hooks/services/protocols.py +328 -0
- claude_mpm/hooks/claude_hooks/services/state_manager.py +25 -38
- claude_mpm/hooks/claude_hooks/services/subagent_processor.py +49 -75
- claude_mpm/hooks/session_resume_hook.py +22 -18
- claude_mpm/hooks/templates/pre_tool_use_simple.py +6 -6
- claude_mpm/hooks/templates/pre_tool_use_template.py +16 -8
- claude_mpm/init.py +21 -14
- claude_mpm/mcp/__init__.py +9 -0
- claude_mpm/mcp/google_workspace_server.py +610 -0
- claude_mpm/scripts/claude-hook-handler.sh +10 -9
- claude_mpm/services/agents/agent_selection_service.py +2 -2
- claude_mpm/services/agents/single_tier_deployment_service.py +4 -4
- claude_mpm/services/command_deployment_service.py +44 -26
- claude_mpm/services/hook_installer_service.py +77 -8
- claude_mpm/services/mcp_config_manager.py +99 -19
- claude_mpm/services/mcp_service_registry.py +294 -0
- claude_mpm/services/monitor/server.py +6 -1
- claude_mpm/services/pm_skills_deployer.py +5 -3
- claude_mpm/services/skills/git_skill_source_manager.py +79 -8
- claude_mpm/services/skills/selective_skill_deployer.py +28 -0
- claude_mpm/services/skills/skill_discovery_service.py +17 -1
- claude_mpm/services/skills_deployer.py +31 -5
- claude_mpm/skills/__init__.py +2 -1
- claude_mpm/skills/bundled/pm/mpm-session-pause/SKILL.md +170 -0
- claude_mpm/skills/registry.py +295 -90
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/METADATA +28 -3
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/RECORD +131 -93
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/WHEEL +1 -1
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/entry_points.txt +2 -0
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {claude_mpm-5.6.1.dist-info → claude_mpm-5.6.76.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
|