workspace-mcp 1.1.7__py3-none-any.whl → 1.1.9__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.
- auth/google_auth.py +1 -1
- auth/oauth21/__init__.py +108 -0
- auth/oauth21/compat.py +422 -0
- auth/oauth21/config.py +380 -0
- auth/oauth21/discovery.py +232 -0
- auth/oauth21/example_config.py +303 -0
- auth/oauth21/handler.py +440 -0
- auth/oauth21/http.py +270 -0
- auth/oauth21/jwt.py +438 -0
- auth/oauth21/middleware.py +426 -0
- auth/oauth21/oauth2.py +353 -0
- auth/oauth21/sessions.py +519 -0
- auth/oauth21/tokens.py +392 -0
- auth/oauth_callback_server.py +1 -1
- auth/service_decorator.py +2 -5
- core/comments.py +0 -3
- core/server.py +35 -36
- core/utils.py +3 -4
- gcalendar/calendar_tools.py +4 -5
- gchat/chat_tools.py +0 -1
- gdocs/docs_tools.py +73 -16
- gdrive/drive_tools.py +1 -3
- gforms/forms_tools.py +0 -1
- gmail/gmail_tools.py +184 -70
- gsheets/sheets_tools.py +0 -2
- gslides/slides_tools.py +1 -3
- gtasks/tasks_tools.py +1 -2
- main.py +2 -2
- {workspace_mcp-1.1.7.dist-info → workspace_mcp-1.1.9.dist-info}/METADATA +3 -2
- workspace_mcp-1.1.9.dist-info/RECORD +48 -0
- workspace_mcp-1.1.7.dist-info/RECORD +0 -36
- {workspace_mcp-1.1.7.dist-info → workspace_mcp-1.1.9.dist-info}/WHEEL +0 -0
- {workspace_mcp-1.1.7.dist-info → workspace_mcp-1.1.9.dist-info}/entry_points.txt +0 -0
- {workspace_mcp-1.1.7.dist-info → workspace_mcp-1.1.9.dist-info}/licenses/LICENSE +0 -0
- {workspace_mcp-1.1.7.dist-info → workspace_mcp-1.1.9.dist-info}/top_level.txt +0 -0
auth/oauth21/config.py
ADDED
@@ -0,0 +1,380 @@
|
|
1
|
+
"""
|
2
|
+
OAuth 2.1 Configuration Schema
|
3
|
+
|
4
|
+
Configuration classes and validation for OAuth 2.1 authentication setup.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import os
|
8
|
+
import logging
|
9
|
+
from dataclasses import dataclass, field
|
10
|
+
from typing import Dict, Any, Optional, List, Union
|
11
|
+
from pathlib import Path
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class OAuth2Config:
|
18
|
+
"""OAuth 2.1 configuration."""
|
19
|
+
|
20
|
+
# Authorization Server Configuration
|
21
|
+
authorization_server_url: Optional[str] = None
|
22
|
+
client_id: Optional[str] = None
|
23
|
+
client_secret: Optional[str] = None
|
24
|
+
|
25
|
+
# Token Configuration
|
26
|
+
supported_token_types: List[str] = field(default_factory=lambda: ["jwt", "opaque"])
|
27
|
+
token_validation_method: str = "introspection" # or "local"
|
28
|
+
expected_audience: Optional[str] = None
|
29
|
+
|
30
|
+
# Session Management
|
31
|
+
session_timeout: int = 3600 # 1 hour
|
32
|
+
max_sessions_per_user: int = 10
|
33
|
+
session_cleanup_interval: int = 300 # 5 minutes
|
34
|
+
enable_session_persistence: bool = False
|
35
|
+
session_persistence_file: Optional[str] = None
|
36
|
+
|
37
|
+
# Security Settings
|
38
|
+
enable_pkce: bool = True
|
39
|
+
required_scopes: List[str] = field(default_factory=list)
|
40
|
+
enable_bearer_passthrough: bool = True
|
41
|
+
enable_dynamic_registration: bool = True
|
42
|
+
|
43
|
+
# Discovery Settings
|
44
|
+
resource_url: Optional[str] = None
|
45
|
+
discovery_cache_ttl: int = 3600 # 1 hour
|
46
|
+
jwks_cache_ttl: int = 3600 # 1 hour
|
47
|
+
|
48
|
+
# HTTP Settings
|
49
|
+
exempt_paths: List[str] = field(default_factory=lambda: [
|
50
|
+
"/health", "/oauth2callback", "/.well-known/"
|
51
|
+
])
|
52
|
+
|
53
|
+
# Development/Debug Settings
|
54
|
+
enable_debug_logging: bool = False
|
55
|
+
allow_insecure_transport: bool = False
|
56
|
+
|
57
|
+
def __post_init__(self):
|
58
|
+
"""Validate configuration after initialization."""
|
59
|
+
self._load_from_environment()
|
60
|
+
self._validate_config()
|
61
|
+
|
62
|
+
def _load_from_environment(self):
|
63
|
+
"""Load configuration from environment variables."""
|
64
|
+
# Authorization server settings
|
65
|
+
if not self.authorization_server_url:
|
66
|
+
self.authorization_server_url = os.getenv("OAUTH2_AUTHORIZATION_SERVER_URL")
|
67
|
+
|
68
|
+
if not self.client_id:
|
69
|
+
self.client_id = os.getenv("OAUTH2_CLIENT_ID") or os.getenv("GOOGLE_OAUTH_CLIENT_ID")
|
70
|
+
|
71
|
+
if not self.client_secret:
|
72
|
+
self.client_secret = os.getenv("OAUTH2_CLIENT_SECRET") or os.getenv("GOOGLE_OAUTH_CLIENT_SECRET")
|
73
|
+
|
74
|
+
# Token settings
|
75
|
+
if env_audience := os.getenv("OAUTH2_EXPECTED_AUDIENCE"):
|
76
|
+
self.expected_audience = env_audience
|
77
|
+
|
78
|
+
if env_validation := os.getenv("OAUTH2_TOKEN_VALIDATION_METHOD"):
|
79
|
+
self.token_validation_method = env_validation
|
80
|
+
|
81
|
+
# Session settings
|
82
|
+
if env_timeout := os.getenv("OAUTH2_SESSION_TIMEOUT"):
|
83
|
+
try:
|
84
|
+
self.session_timeout = int(env_timeout)
|
85
|
+
except ValueError:
|
86
|
+
logger.warning(f"Invalid OAUTH2_SESSION_TIMEOUT value: {env_timeout}")
|
87
|
+
|
88
|
+
if env_max_sessions := os.getenv("OAUTH2_MAX_SESSIONS_PER_USER"):
|
89
|
+
try:
|
90
|
+
self.max_sessions_per_user = int(env_max_sessions)
|
91
|
+
except ValueError:
|
92
|
+
logger.warning(f"Invalid OAUTH2_MAX_SESSIONS_PER_USER value: {env_max_sessions}")
|
93
|
+
|
94
|
+
# Security settings
|
95
|
+
if env_pkce := os.getenv("OAUTH2_ENABLE_PKCE"):
|
96
|
+
self.enable_pkce = env_pkce.lower() in ("true", "1", "yes", "on")
|
97
|
+
|
98
|
+
if env_passthrough := os.getenv("OAUTH2_ENABLE_BEARER_PASSTHROUGH"):
|
99
|
+
self.enable_bearer_passthrough = env_passthrough.lower() in ("true", "1", "yes", "on")
|
100
|
+
|
101
|
+
if env_scopes := os.getenv("OAUTH2_REQUIRED_SCOPES"):
|
102
|
+
self.required_scopes = [scope.strip() for scope in env_scopes.split(",")]
|
103
|
+
|
104
|
+
# Development settings
|
105
|
+
if env_debug := os.getenv("OAUTH2_ENABLE_DEBUG"):
|
106
|
+
self.enable_debug_logging = env_debug.lower() in ("true", "1", "yes", "on")
|
107
|
+
|
108
|
+
if env_insecure := os.getenv("OAUTH2_ALLOW_INSECURE_TRANSPORT"):
|
109
|
+
self.allow_insecure_transport = env_insecure.lower() in ("true", "1", "yes", "on")
|
110
|
+
|
111
|
+
def _validate_config(self):
|
112
|
+
"""Validate configuration values."""
|
113
|
+
errors = []
|
114
|
+
|
115
|
+
# Validate token types
|
116
|
+
valid_token_types = {"jwt", "opaque"}
|
117
|
+
invalid_types = set(self.supported_token_types) - valid_token_types
|
118
|
+
if invalid_types:
|
119
|
+
errors.append(f"Invalid token types: {invalid_types}")
|
120
|
+
|
121
|
+
# Validate token validation method
|
122
|
+
if self.token_validation_method not in ("introspection", "local"):
|
123
|
+
errors.append(f"Invalid token_validation_method: {self.token_validation_method}")
|
124
|
+
|
125
|
+
# Validate session settings
|
126
|
+
if self.session_timeout <= 0:
|
127
|
+
errors.append("session_timeout must be positive")
|
128
|
+
|
129
|
+
if self.max_sessions_per_user <= 0:
|
130
|
+
errors.append("max_sessions_per_user must be positive")
|
131
|
+
|
132
|
+
# Validate URLs
|
133
|
+
if self.authorization_server_url:
|
134
|
+
if not self.authorization_server_url.startswith(("http://", "https://")):
|
135
|
+
errors.append("authorization_server_url must be a valid HTTP/HTTPS URL")
|
136
|
+
|
137
|
+
if errors:
|
138
|
+
raise ValueError(f"OAuth2 configuration errors: {'; '.join(errors)}")
|
139
|
+
|
140
|
+
def is_enabled(self) -> bool:
|
141
|
+
"""Check if OAuth 2.1 authentication is enabled."""
|
142
|
+
return bool(self.client_id)
|
143
|
+
|
144
|
+
def get_session_persistence_path(self) -> Optional[Path]:
|
145
|
+
"""Get session persistence file path."""
|
146
|
+
if not self.enable_session_persistence:
|
147
|
+
return None
|
148
|
+
|
149
|
+
if self.session_persistence_file:
|
150
|
+
return Path(self.session_persistence_file)
|
151
|
+
|
152
|
+
# Default to user home directory
|
153
|
+
home_dir = Path.home()
|
154
|
+
return home_dir / ".google_workspace_mcp" / "oauth21_sessions.json"
|
155
|
+
|
156
|
+
def to_dict(self) -> Dict[str, Any]:
|
157
|
+
"""Convert configuration to dictionary."""
|
158
|
+
return {
|
159
|
+
"authorization_server_url": self.authorization_server_url,
|
160
|
+
"client_id": self.client_id,
|
161
|
+
"client_secret": "***" if self.client_secret else None, # Redact secret
|
162
|
+
"supported_token_types": self.supported_token_types,
|
163
|
+
"token_validation_method": self.token_validation_method,
|
164
|
+
"expected_audience": self.expected_audience,
|
165
|
+
"session_timeout": self.session_timeout,
|
166
|
+
"max_sessions_per_user": self.max_sessions_per_user,
|
167
|
+
"enable_pkce": self.enable_pkce,
|
168
|
+
"required_scopes": self.required_scopes,
|
169
|
+
"enable_bearer_passthrough": self.enable_bearer_passthrough,
|
170
|
+
"enable_dynamic_registration": self.enable_dynamic_registration,
|
171
|
+
"resource_url": self.resource_url,
|
172
|
+
"exempt_paths": self.exempt_paths,
|
173
|
+
"enable_debug_logging": self.enable_debug_logging,
|
174
|
+
"allow_insecure_transport": self.allow_insecure_transport,
|
175
|
+
}
|
176
|
+
|
177
|
+
|
178
|
+
@dataclass
|
179
|
+
class AuthConfig:
|
180
|
+
"""Complete authentication configuration including OAuth 2.1 and legacy settings."""
|
181
|
+
|
182
|
+
# OAuth 2.1 Configuration
|
183
|
+
oauth2: Optional[OAuth2Config] = None
|
184
|
+
|
185
|
+
# Legacy Authentication Settings (for backward compatibility)
|
186
|
+
enable_legacy_auth: bool = True
|
187
|
+
legacy_credentials_dir: Optional[str] = None
|
188
|
+
|
189
|
+
# Global Settings
|
190
|
+
single_user_mode: bool = False
|
191
|
+
default_user_email: Optional[str] = None
|
192
|
+
|
193
|
+
def __post_init__(self):
|
194
|
+
"""Initialize configuration."""
|
195
|
+
self._load_global_settings()
|
196
|
+
|
197
|
+
# Initialize OAuth2 config if not provided but environment suggests it's needed
|
198
|
+
if not self.oauth2 and self._should_enable_oauth2():
|
199
|
+
self.oauth2 = OAuth2Config()
|
200
|
+
|
201
|
+
def _load_global_settings(self):
|
202
|
+
"""Load global authentication settings."""
|
203
|
+
# Single user mode
|
204
|
+
if env_single_user := os.getenv("MCP_SINGLE_USER_MODE"):
|
205
|
+
self.single_user_mode = env_single_user.lower() in ("true", "1", "yes", "on")
|
206
|
+
|
207
|
+
# Default user email
|
208
|
+
if not self.default_user_email:
|
209
|
+
self.default_user_email = os.getenv("USER_GOOGLE_EMAIL")
|
210
|
+
|
211
|
+
# Legacy settings
|
212
|
+
if env_legacy := os.getenv("OAUTH2_ENABLE_LEGACY_AUTH"):
|
213
|
+
self.enable_legacy_auth = env_legacy.lower() in ("true", "1", "yes", "on")
|
214
|
+
|
215
|
+
if not self.legacy_credentials_dir:
|
216
|
+
self.legacy_credentials_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR")
|
217
|
+
|
218
|
+
def _should_enable_oauth2(self) -> bool:
|
219
|
+
"""Check if OAuth 2.1 should be enabled based on environment."""
|
220
|
+
oauth2_env_vars = [
|
221
|
+
"OAUTH2_CLIENT_ID",
|
222
|
+
"GOOGLE_OAUTH_CLIENT_ID",
|
223
|
+
"OAUTH2_AUTHORIZATION_SERVER_URL",
|
224
|
+
"OAUTH2_ENABLE_BEARER_PASSTHROUGH",
|
225
|
+
]
|
226
|
+
|
227
|
+
return any(os.getenv(var) for var in oauth2_env_vars)
|
228
|
+
|
229
|
+
def is_oauth2_enabled(self) -> bool:
|
230
|
+
"""Check if OAuth 2.1 is enabled."""
|
231
|
+
return self.oauth2 is not None and self.oauth2.is_enabled()
|
232
|
+
|
233
|
+
def get_effective_auth_mode(self) -> str:
|
234
|
+
"""Get the effective authentication mode."""
|
235
|
+
if self.single_user_mode:
|
236
|
+
return "single_user"
|
237
|
+
elif self.is_oauth2_enabled():
|
238
|
+
if self.enable_legacy_auth:
|
239
|
+
return "oauth2_with_legacy_fallback"
|
240
|
+
else:
|
241
|
+
return "oauth2_only"
|
242
|
+
elif self.enable_legacy_auth:
|
243
|
+
return "legacy_only"
|
244
|
+
else:
|
245
|
+
return "disabled"
|
246
|
+
|
247
|
+
def to_dict(self) -> Dict[str, Any]:
|
248
|
+
"""Convert configuration to dictionary."""
|
249
|
+
result = {
|
250
|
+
"oauth2": self.oauth2.to_dict() if self.oauth2 else None,
|
251
|
+
"enable_legacy_auth": self.enable_legacy_auth,
|
252
|
+
"legacy_credentials_dir": self.legacy_credentials_dir,
|
253
|
+
"single_user_mode": self.single_user_mode,
|
254
|
+
"default_user_email": self.default_user_email,
|
255
|
+
"effective_auth_mode": self.get_effective_auth_mode(),
|
256
|
+
}
|
257
|
+
|
258
|
+
return result
|
259
|
+
|
260
|
+
|
261
|
+
def create_auth_config(
|
262
|
+
oauth2_config: Optional[Dict[str, Any]] = None,
|
263
|
+
**kwargs
|
264
|
+
) -> AuthConfig:
|
265
|
+
"""
|
266
|
+
Create authentication configuration.
|
267
|
+
|
268
|
+
Args:
|
269
|
+
oauth2_config: OAuth 2.1 configuration dictionary
|
270
|
+
**kwargs: Additional configuration options
|
271
|
+
|
272
|
+
Returns:
|
273
|
+
Authentication configuration
|
274
|
+
"""
|
275
|
+
oauth2 = None
|
276
|
+
if oauth2_config:
|
277
|
+
oauth2 = OAuth2Config(**oauth2_config)
|
278
|
+
|
279
|
+
return AuthConfig(oauth2=oauth2, **kwargs)
|
280
|
+
|
281
|
+
|
282
|
+
def create_default_oauth2_config() -> OAuth2Config:
|
283
|
+
"""
|
284
|
+
Create default OAuth 2.1 configuration for Google Workspace.
|
285
|
+
|
286
|
+
Returns:
|
287
|
+
OAuth 2.1 configuration with Google Workspace defaults
|
288
|
+
"""
|
289
|
+
return OAuth2Config(
|
290
|
+
authorization_server_url="https://accounts.google.com",
|
291
|
+
supported_token_types=["jwt"],
|
292
|
+
token_validation_method="local", # Use local JWT validation for Google
|
293
|
+
expected_audience=None, # Will be set based on client_id
|
294
|
+
required_scopes=[
|
295
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
296
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
297
|
+
],
|
298
|
+
enable_pkce=True,
|
299
|
+
enable_bearer_passthrough=True,
|
300
|
+
resource_url="https://www.googleapis.com",
|
301
|
+
)
|
302
|
+
|
303
|
+
|
304
|
+
def load_config_from_file(config_path: Union[str, Path]) -> AuthConfig:
|
305
|
+
"""
|
306
|
+
Load authentication configuration from file.
|
307
|
+
|
308
|
+
Args:
|
309
|
+
config_path: Path to configuration file (JSON or TOML)
|
310
|
+
|
311
|
+
Returns:
|
312
|
+
Authentication configuration
|
313
|
+
|
314
|
+
Raises:
|
315
|
+
FileNotFoundError: If config file doesn't exist
|
316
|
+
ValueError: If config format is invalid
|
317
|
+
"""
|
318
|
+
config_path = Path(config_path)
|
319
|
+
|
320
|
+
if not config_path.exists():
|
321
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
322
|
+
|
323
|
+
try:
|
324
|
+
if config_path.suffix.lower() == ".json":
|
325
|
+
import json
|
326
|
+
with open(config_path) as f:
|
327
|
+
config_data = json.load(f)
|
328
|
+
elif config_path.suffix.lower() in (".toml", ".tml"):
|
329
|
+
import tomlkit
|
330
|
+
with open(config_path) as f:
|
331
|
+
config_data = tomlkit.load(f)
|
332
|
+
else:
|
333
|
+
raise ValueError(f"Unsupported configuration file format: {config_path.suffix}")
|
334
|
+
|
335
|
+
# Extract OAuth 2.1 config if present
|
336
|
+
oauth2_config = None
|
337
|
+
if "oauth2" in config_data:
|
338
|
+
oauth2_config = config_data.pop("oauth2")
|
339
|
+
|
340
|
+
return create_auth_config(oauth2_config=oauth2_config, **config_data)
|
341
|
+
|
342
|
+
except Exception as e:
|
343
|
+
raise ValueError(f"Failed to load configuration from {config_path}: {e}")
|
344
|
+
|
345
|
+
|
346
|
+
def get_config_summary(config: AuthConfig) -> str:
|
347
|
+
"""
|
348
|
+
Get a human-readable summary of the authentication configuration.
|
349
|
+
|
350
|
+
Args:
|
351
|
+
config: Authentication configuration
|
352
|
+
|
353
|
+
Returns:
|
354
|
+
Configuration summary string
|
355
|
+
"""
|
356
|
+
lines = [
|
357
|
+
"Authentication Configuration Summary:",
|
358
|
+
f" Mode: {config.get_effective_auth_mode()}",
|
359
|
+
f" Single User Mode: {config.single_user_mode}",
|
360
|
+
]
|
361
|
+
|
362
|
+
if config.oauth2:
|
363
|
+
lines.extend([
|
364
|
+
" OAuth 2.1 Settings:",
|
365
|
+
f" Authorization Server: {config.oauth2.authorization_server_url or 'Not configured'}",
|
366
|
+
f" Client ID: {config.oauth2.client_id or 'Not configured'}",
|
367
|
+
f" Token Types: {', '.join(config.oauth2.supported_token_types)}",
|
368
|
+
f" Session Timeout: {config.oauth2.session_timeout}s",
|
369
|
+
f" Bearer Passthrough: {config.oauth2.enable_bearer_passthrough}",
|
370
|
+
f" PKCE Enabled: {config.oauth2.enable_pkce}",
|
371
|
+
])
|
372
|
+
|
373
|
+
if config.enable_legacy_auth:
|
374
|
+
lines.extend([
|
375
|
+
" Legacy Authentication:",
|
376
|
+
f" Enabled: {config.enable_legacy_auth}",
|
377
|
+
f" Credentials Dir: {config.legacy_credentials_dir or 'Default'}",
|
378
|
+
])
|
379
|
+
|
380
|
+
return "\n".join(lines)
|
@@ -0,0 +1,232 @@
|
|
1
|
+
"""
|
2
|
+
Authorization Server Discovery Module
|
3
|
+
|
4
|
+
Implements RFC9728 Protected Resource Metadata and RFC8414 Authorization Server Metadata discovery
|
5
|
+
for OAuth 2.1 compliance.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import logging
|
9
|
+
from typing import Dict, Any, Optional, List
|
10
|
+
|
11
|
+
import aiohttp
|
12
|
+
from cachetools import TTLCache
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class AuthorizationServerDiscovery:
|
18
|
+
"""Implements RFC9728 Protected Resource Metadata and RFC8414 AS Metadata discovery."""
|
19
|
+
|
20
|
+
def __init__(
|
21
|
+
self,
|
22
|
+
resource_url: Optional[str] = None,
|
23
|
+
cache_ttl: int = 3600,
|
24
|
+
max_cache_size: int = 100,
|
25
|
+
):
|
26
|
+
"""
|
27
|
+
Initialize the discovery service.
|
28
|
+
|
29
|
+
Args:
|
30
|
+
resource_url: The protected resource URL for this server
|
31
|
+
cache_ttl: Cache time-to-live in seconds
|
32
|
+
max_cache_size: Maximum number of cached entries
|
33
|
+
"""
|
34
|
+
self.resource_url = resource_url or "https://www.googleapis.com"
|
35
|
+
self.cache = TTLCache(maxsize=max_cache_size, ttl=cache_ttl)
|
36
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
37
|
+
|
38
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
39
|
+
"""Get or create HTTP session."""
|
40
|
+
if self._session is None or self._session.closed:
|
41
|
+
self._session = aiohttp.ClientSession(
|
42
|
+
timeout=aiohttp.ClientTimeout(total=30),
|
43
|
+
headers={"User-Agent": "MCP-OAuth2.1-Client/1.0"},
|
44
|
+
)
|
45
|
+
return self._session
|
46
|
+
|
47
|
+
async def close(self):
|
48
|
+
"""Clean up resources."""
|
49
|
+
if self._session and not self._session.closed:
|
50
|
+
await self._session.close()
|
51
|
+
|
52
|
+
async def get_protected_resource_metadata(self) -> Dict[str, Any]:
|
53
|
+
"""
|
54
|
+
Return Protected Resource Metadata per RFC9728.
|
55
|
+
|
56
|
+
Returns:
|
57
|
+
Protected resource metadata including authorization_servers list
|
58
|
+
"""
|
59
|
+
cache_key = f"prm:{self.resource_url}"
|
60
|
+
if cache_key in self.cache:
|
61
|
+
logger.debug(f"Using cached protected resource metadata for {self.resource_url}")
|
62
|
+
return self.cache[cache_key]
|
63
|
+
|
64
|
+
metadata = {
|
65
|
+
"resource": self.resource_url,
|
66
|
+
"authorization_servers": [
|
67
|
+
"https://accounts.google.com",
|
68
|
+
"https://oauth2.googleapis.com",
|
69
|
+
],
|
70
|
+
"scopes_supported": [
|
71
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
72
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
73
|
+
"https://www.googleapis.com/auth/calendar",
|
74
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
75
|
+
"https://www.googleapis.com/auth/drive",
|
76
|
+
"https://www.googleapis.com/auth/drive.readonly",
|
77
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
78
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
79
|
+
"https://www.googleapis.com/auth/gmail.send",
|
80
|
+
"https://www.googleapis.com/auth/documents",
|
81
|
+
"https://www.googleapis.com/auth/documents.readonly",
|
82
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
83
|
+
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
84
|
+
"https://www.googleapis.com/auth/presentations",
|
85
|
+
"https://www.googleapis.com/auth/presentations.readonly",
|
86
|
+
"https://www.googleapis.com/auth/chat.spaces",
|
87
|
+
"https://www.googleapis.com/auth/chat.messages",
|
88
|
+
"https://www.googleapis.com/auth/forms.body",
|
89
|
+
"https://www.googleapis.com/auth/forms.responses.readonly",
|
90
|
+
"https://www.googleapis.com/auth/tasks",
|
91
|
+
"https://www.googleapis.com/auth/tasks.readonly",
|
92
|
+
],
|
93
|
+
"bearer_methods_supported": ["header"],
|
94
|
+
"resource_documentation": "https://developers.google.com/workspace",
|
95
|
+
}
|
96
|
+
|
97
|
+
self.cache[cache_key] = metadata
|
98
|
+
logger.info(f"Generated protected resource metadata for {self.resource_url}")
|
99
|
+
return metadata
|
100
|
+
|
101
|
+
async def get_authorization_server_metadata(self, as_url: str) -> Dict[str, Any]:
|
102
|
+
"""
|
103
|
+
Fetch and cache Authorization Server metadata per RFC8414.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
as_url: Authorization server URL
|
107
|
+
|
108
|
+
Returns:
|
109
|
+
Authorization server metadata dictionary
|
110
|
+
|
111
|
+
Raises:
|
112
|
+
aiohttp.ClientError: If the metadata cannot be fetched
|
113
|
+
"""
|
114
|
+
cache_key = f"asm:{as_url}"
|
115
|
+
if cache_key in self.cache:
|
116
|
+
logger.debug(f"Using cached authorization server metadata for {as_url}")
|
117
|
+
return self.cache[cache_key]
|
118
|
+
|
119
|
+
# Try standard discovery endpoints
|
120
|
+
discovery_urls = [
|
121
|
+
f"{as_url}/.well-known/oauth-authorization-server",
|
122
|
+
f"{as_url}/.well-known/openid_configuration",
|
123
|
+
]
|
124
|
+
|
125
|
+
session = await self._get_session()
|
126
|
+
|
127
|
+
for discovery_url in discovery_urls:
|
128
|
+
try:
|
129
|
+
logger.debug(f"Attempting to fetch metadata from {discovery_url}")
|
130
|
+
async with session.get(discovery_url) as response:
|
131
|
+
if response.status == 200:
|
132
|
+
metadata = await response.json()
|
133
|
+
|
134
|
+
# Validate required fields per RFC8414
|
135
|
+
required_fields = ["issuer", "authorization_endpoint"]
|
136
|
+
if all(field in metadata for field in required_fields):
|
137
|
+
# Ensure OAuth 2.1 compliance fields
|
138
|
+
metadata.setdefault("code_challenge_methods_supported", ["S256"])
|
139
|
+
metadata.setdefault("pkce_required", True)
|
140
|
+
|
141
|
+
self.cache[cache_key] = metadata
|
142
|
+
logger.info(f"Fetched authorization server metadata from {discovery_url}")
|
143
|
+
return metadata
|
144
|
+
else:
|
145
|
+
logger.warning(f"Invalid metadata from {discovery_url}: missing required fields")
|
146
|
+
|
147
|
+
except Exception as e:
|
148
|
+
logger.debug(f"Failed to fetch from {discovery_url}: {e}")
|
149
|
+
continue
|
150
|
+
|
151
|
+
# If discovery fails, return default Google metadata
|
152
|
+
logger.warning(f"Could not discover metadata for {as_url}, using defaults")
|
153
|
+
default_metadata = self._get_default_google_metadata(as_url)
|
154
|
+
self.cache[cache_key] = default_metadata
|
155
|
+
return default_metadata
|
156
|
+
|
157
|
+
def _get_default_google_metadata(self, as_url: str) -> Dict[str, Any]:
|
158
|
+
"""Return default Google OAuth 2.0 metadata."""
|
159
|
+
return {
|
160
|
+
"issuer": as_url,
|
161
|
+
"authorization_endpoint": f"{as_url}/o/oauth2/v2/auth",
|
162
|
+
"token_endpoint": f"{as_url}/token",
|
163
|
+
"userinfo_endpoint": f"{as_url}/oauth2/v2/userinfo",
|
164
|
+
"revocation_endpoint": f"{as_url}/revoke",
|
165
|
+
"jwks_uri": f"{as_url}/oauth2/v3/certs",
|
166
|
+
"introspection_endpoint": f"{as_url}/introspect",
|
167
|
+
"response_types_supported": ["code"],
|
168
|
+
"response_modes_supported": ["query", "fragment"],
|
169
|
+
"grant_types_supported": ["authorization_code", "refresh_token"],
|
170
|
+
"code_challenge_methods_supported": ["S256"],
|
171
|
+
"pkce_required": True,
|
172
|
+
"scopes_supported": [
|
173
|
+
"openid",
|
174
|
+
"email",
|
175
|
+
"profile",
|
176
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
177
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
178
|
+
],
|
179
|
+
"token_endpoint_auth_methods_supported": [
|
180
|
+
"client_secret_basic",
|
181
|
+
"client_secret_post",
|
182
|
+
],
|
183
|
+
"claims_supported": ["iss", "sub", "aud", "exp", "iat", "email", "email_verified"],
|
184
|
+
"request_uri_parameter_supported": False,
|
185
|
+
"require_request_uri_registration": False,
|
186
|
+
}
|
187
|
+
|
188
|
+
async def discover_authorization_servers(self) -> List[Dict[str, Any]]:
|
189
|
+
"""
|
190
|
+
Discover all authorization servers for this protected resource.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
List of authorization server metadata dictionaries
|
194
|
+
"""
|
195
|
+
prm = await self.get_protected_resource_metadata()
|
196
|
+
servers = []
|
197
|
+
|
198
|
+
for as_url in prm.get("authorization_servers", []):
|
199
|
+
try:
|
200
|
+
as_metadata = await self.get_authorization_server_metadata(as_url)
|
201
|
+
servers.append(as_metadata)
|
202
|
+
except Exception as e:
|
203
|
+
logger.error(f"Failed to discover authorization server {as_url}: {e}")
|
204
|
+
continue
|
205
|
+
|
206
|
+
return servers
|
207
|
+
|
208
|
+
def is_valid_authorization_server(self, as_url: str) -> bool:
|
209
|
+
"""
|
210
|
+
Check if the given URL is a valid authorization server for this resource.
|
211
|
+
|
212
|
+
Args:
|
213
|
+
as_url: Authorization server URL to validate
|
214
|
+
|
215
|
+
Returns:
|
216
|
+
True if the server is valid for this resource
|
217
|
+
"""
|
218
|
+
try:
|
219
|
+
# Get cached metadata without making network calls
|
220
|
+
cache_key = f"prm:{self.resource_url}"
|
221
|
+
if cache_key in self.cache:
|
222
|
+
prm = self.cache[cache_key]
|
223
|
+
return as_url in prm.get("authorization_servers", [])
|
224
|
+
except Exception:
|
225
|
+
pass
|
226
|
+
|
227
|
+
# Default to allowing Google servers
|
228
|
+
google_servers = [
|
229
|
+
"https://accounts.google.com",
|
230
|
+
"https://oauth2.googleapis.com",
|
231
|
+
]
|
232
|
+
return as_url in google_servers
|