codetether 1.2.2__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.
Files changed (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,750 @@
1
+ """
2
+ Keycloak Authentication Service for A2A Server.
3
+
4
+ Provides OAuth2/OIDC authentication with Keycloak for:
5
+ - User authentication and session management
6
+ - Cross-device session persistence
7
+ - User-scoped agent and codebase management
8
+ """
9
+
10
+ import os
11
+ import logging
12
+ import asyncio
13
+ from datetime import datetime, timedelta
14
+ from typing import Optional, Dict, Any, List
15
+ from dataclasses import dataclass, field, asdict
16
+ import json
17
+ import uuid
18
+ import hashlib
19
+
20
+ import httpx
21
+ from jose import jwt, JWTError, jwk
22
+ from jose.utils import base64url_decode
23
+ from pydantic import BaseModel
24
+ from fastapi import HTTPException, Security, Depends, Request
25
+ from fastapi.security import (
26
+ HTTPBearer,
27
+ HTTPAuthorizationCredentials,
28
+ OAuth2AuthorizationCodeBearer,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Keycloak Configuration from environment
34
+ KEYCLOAK_URL = os.environ.get('KEYCLOAK_URL', 'https://auth.quantum-forge.io')
35
+ KEYCLOAK_REALM = os.environ.get('KEYCLOAK_REALM', 'quantum-forge')
36
+ KEYCLOAK_CLIENT_ID = os.environ.get('KEYCLOAK_CLIENT_ID', 'a2a-monitor')
37
+ KEYCLOAK_CLIENT_SECRET = os.environ.get(
38
+ 'KEYCLOAK_CLIENT_SECRET', 'Boog6oMQhr6dlF5tebfQ2FuLMhAOU4i1'
39
+ )
40
+ KEYCLOAK_ADMIN_USERNAME = os.environ.get(
41
+ 'KEYCLOAK_ADMIN_USERNAME', 'info@evolvingsoftware.io'
42
+ )
43
+ KEYCLOAK_ADMIN_PASSWORD = os.environ.get(
44
+ 'KEYCLOAK_ADMIN_PASSWORD', 'Spr!ng20@4'
45
+ )
46
+
47
+ # JWT Configuration
48
+ JWT_ALGORITHM = 'RS256'
49
+ JWT_ISSUER = f'{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}'
50
+
51
+ # Security schemes
52
+ security = HTTPBearer(auto_error=False)
53
+
54
+
55
+ @dataclass
56
+ class TenantContext:
57
+ """Context for the current tenant (realm)."""
58
+
59
+ tenant_id: str
60
+ realm_name: str
61
+ plan: Optional[str] = None
62
+
63
+
64
+ @dataclass
65
+ class UserSession:
66
+ """Represents an authenticated user session."""
67
+
68
+ user_id: str
69
+ email: str
70
+ username: str
71
+ name: str
72
+ session_id: str
73
+ access_token: str
74
+ refresh_token: Optional[str]
75
+ expires_at: datetime
76
+ created_at: datetime = field(default_factory=datetime.utcnow)
77
+ last_activity: datetime = field(default_factory=datetime.utcnow)
78
+ device_info: Dict[str, Any] = field(default_factory=dict)
79
+ roles: List[str] = field(default_factory=list)
80
+ tenant_id: Optional[str] = None
81
+ realm_name: Optional[str] = None
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ return {
85
+ 'user_id': self.user_id,
86
+ 'email': self.email,
87
+ 'username': self.username,
88
+ 'name': self.name,
89
+ 'session_id': self.session_id,
90
+ 'expires_at': self.expires_at.isoformat(),
91
+ 'created_at': self.created_at.isoformat(),
92
+ 'last_activity': self.last_activity.isoformat(),
93
+ 'device_info': self.device_info,
94
+ 'roles': self.roles,
95
+ 'tenant_id': self.tenant_id,
96
+ 'realm_name': self.realm_name,
97
+ }
98
+
99
+ def is_valid(self) -> bool:
100
+ return datetime.utcnow() < self.expires_at
101
+
102
+
103
+ @dataclass
104
+ class UserCodebaseAssociation:
105
+ """Tracks which codebases belong to which user."""
106
+
107
+ user_id: str
108
+ codebase_id: str
109
+ codebase_name: str
110
+ codebase_path: str
111
+ role: str = 'owner' # owner, collaborator, viewer
112
+ created_at: datetime = field(default_factory=datetime.utcnow)
113
+ last_accessed: datetime = field(default_factory=datetime.utcnow)
114
+
115
+ def to_dict(self) -> Dict[str, Any]:
116
+ return {
117
+ 'user_id': self.user_id,
118
+ 'codebase_id': self.codebase_id,
119
+ 'codebase_name': self.codebase_name,
120
+ 'codebase_path': self.codebase_path,
121
+ 'role': self.role,
122
+ 'created_at': self.created_at.isoformat(),
123
+ 'last_accessed': self.last_accessed.isoformat(),
124
+ }
125
+
126
+
127
+ @dataclass
128
+ class UserAgentSession:
129
+ """Tracks agent sessions per user across devices."""
130
+
131
+ user_id: str
132
+ session_id: str
133
+ codebase_id: str
134
+ agent_type: str
135
+ opencode_session_id: Optional[str] = None
136
+ created_at: datetime = field(default_factory=datetime.utcnow)
137
+ last_activity: datetime = field(default_factory=datetime.utcnow)
138
+ device_id: Optional[str] = None
139
+ messages: List[Dict[str, Any]] = field(default_factory=list)
140
+
141
+ def to_dict(self) -> Dict[str, Any]:
142
+ return {
143
+ 'user_id': self.user_id,
144
+ 'session_id': self.session_id,
145
+ 'codebase_id': self.codebase_id,
146
+ 'agent_type': self.agent_type,
147
+ 'opencode_session_id': self.opencode_session_id,
148
+ 'created_at': self.created_at.isoformat(),
149
+ 'last_activity': self.last_activity.isoformat(),
150
+ 'device_id': self.device_id,
151
+ 'message_count': len(self.messages),
152
+ }
153
+
154
+
155
+ class KeycloakAuthService:
156
+ """Manages Keycloak authentication and user sessions."""
157
+
158
+ def __init__(self):
159
+ self.keycloak_url = KEYCLOAK_URL
160
+ self.realm = KEYCLOAK_REALM
161
+ self.client_id = KEYCLOAK_CLIENT_ID
162
+ self.client_secret = KEYCLOAK_CLIENT_SECRET
163
+
164
+ # Token endpoints (default realm)
165
+ self.token_url = f'{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/token'
166
+ self.auth_url = f'{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/auth'
167
+ self.userinfo_url = f'{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/userinfo'
168
+ self.jwks_url = f'{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/certs'
169
+ self.logout_url = f'{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/logout'
170
+
171
+ # Caches - keyed by realm for multi-tenant support
172
+ self._jwks_cache: Dict[str, Dict[str, Any]] = {}
173
+ self._jwks_cache_time: Dict[str, datetime] = {}
174
+
175
+ # Session storage (in-memory, can be backed by Redis/SQLite)
176
+ self._sessions: Dict[str, UserSession] = {}
177
+ self._user_codebases: Dict[str, List[UserCodebaseAssociation]] = {}
178
+ self._agent_sessions: Dict[str, UserAgentSession] = {}
179
+
180
+ logger.info(
181
+ f'KeycloakAuthService initialized for {self.keycloak_url}/realms/{self.realm}'
182
+ )
183
+
184
+ def get_realm_from_token(self, token: str) -> str:
185
+ """Extract realm from token's 'iss' claim.
186
+
187
+ Returns the default realm if extraction fails or realm is not recognized.
188
+ """
189
+ try:
190
+ # Decode without verification to extract issuer
191
+ unverified = jwt.get_unverified_claims(token)
192
+ issuer = unverified.get('iss', '')
193
+
194
+ # Expected format: https://auth.example.com/realms/{realm_name}
195
+ if '/realms/' in issuer:
196
+ realm = issuer.split('/realms/')[-1].rstrip('/')
197
+ if realm:
198
+ return realm
199
+ except Exception as e:
200
+ logger.warning(f'Failed to extract realm from token: {e}')
201
+
202
+ # Fall back to default realm
203
+ return self.realm
204
+
205
+ async def get_jwks(self, realm: Optional[str] = None) -> Dict[str, Any]:
206
+ """Fetch and cache JWKS from Keycloak for a specific realm.
207
+
208
+ Args:
209
+ realm: The Keycloak realm to fetch JWKS for. Defaults to self.realm.
210
+
211
+ Returns:
212
+ The JWKS dictionary containing public keys.
213
+ """
214
+ target_realm = realm or self.realm
215
+
216
+ # Return cached JWKS if still valid (5 minutes)
217
+ if (
218
+ target_realm in self._jwks_cache
219
+ and target_realm in self._jwks_cache_time
220
+ ):
221
+ if datetime.utcnow() - self._jwks_cache_time[
222
+ target_realm
223
+ ] < timedelta(minutes=5):
224
+ return self._jwks_cache[target_realm]
225
+
226
+ # Build JWKS URL dynamically for the target realm
227
+ jwks_url = f'{self.keycloak_url}/realms/{target_realm}/protocol/openid-connect/certs'
228
+
229
+ try:
230
+ async with httpx.AsyncClient() as client:
231
+ response = await client.get(jwks_url, timeout=10.0)
232
+ response.raise_for_status()
233
+ self._jwks_cache[target_realm] = response.json()
234
+ self._jwks_cache_time[target_realm] = datetime.utcnow()
235
+ return self._jwks_cache[target_realm]
236
+ except Exception as e:
237
+ logger.error(f'Failed to fetch JWKS for realm {target_realm}: {e}')
238
+ if target_realm in self._jwks_cache:
239
+ return self._jwks_cache[target_realm]
240
+ raise HTTPException(
241
+ status_code=503, detail='Authentication service unavailable'
242
+ )
243
+
244
+ async def validate_token(self, token: str) -> Dict[str, Any]:
245
+ """Validate a JWT token from Keycloak.
246
+
247
+ Extracts the realm from the token's issuer claim and validates
248
+ against the appropriate realm's JWKS.
249
+ """
250
+ try:
251
+ # Extract realm from token BEFORE validating
252
+ token_realm = self.get_realm_from_token(token)
253
+
254
+ # Get JWKS for the extracted realm
255
+ jwks = await self.get_jwks(realm=token_realm)
256
+
257
+ # Decode header to get key ID
258
+ header = jwt.get_unverified_header(token)
259
+ kid = header.get('kid')
260
+
261
+ # Find matching key
262
+ public_key = None
263
+ for key in jwks.get('keys', []):
264
+ if key.get('kid') == kid:
265
+ public_key = jwk.construct(key)
266
+ break
267
+
268
+ if not public_key:
269
+ raise HTTPException(
270
+ status_code=401, detail='Invalid token: key not found'
271
+ )
272
+
273
+ # Build expected issuer for the token's realm
274
+ expected_issuer = f'{self.keycloak_url}/realms/{token_realm}'
275
+
276
+ # Verify and decode token
277
+ payload = jwt.decode(
278
+ token,
279
+ public_key.to_pem().decode('utf-8'),
280
+ algorithms=[JWT_ALGORITHM],
281
+ issuer=expected_issuer,
282
+ audience=self.client_id,
283
+ options={
284
+ 'verify_aud': False
285
+ }, # Keycloak sometimes uses different audience
286
+ )
287
+
288
+ # Add realm_name to the payload for downstream use
289
+ payload['realm_name'] = token_realm
290
+
291
+ return payload
292
+
293
+ except JWTError as e:
294
+ logger.warning(f'Token validation failed: {e}')
295
+ raise HTTPException(
296
+ status_code=401, detail=f'Invalid token: {str(e)}'
297
+ )
298
+
299
+ async def authenticate_password(
300
+ self,
301
+ username: str,
302
+ password: str,
303
+ device_info: Optional[Dict[str, Any]] = None,
304
+ ) -> UserSession:
305
+ """Authenticate user with username/password and create session."""
306
+ try:
307
+ async with httpx.AsyncClient() as client:
308
+ response = await client.post(
309
+ self.token_url,
310
+ data={
311
+ 'grant_type': 'password',
312
+ 'client_id': self.client_id,
313
+ 'client_secret': self.client_secret,
314
+ 'username': username,
315
+ 'password': password,
316
+ 'scope': 'openid profile email',
317
+ },
318
+ timeout=10.0,
319
+ )
320
+
321
+ if response.status_code != 200:
322
+ error_data = response.json() if response.content else {}
323
+ error_msg = error_data.get(
324
+ 'error_description', 'Authentication failed'
325
+ )
326
+ raise HTTPException(status_code=401, detail=error_msg)
327
+
328
+ token_data = response.json()
329
+
330
+ except httpx.HTTPError as e:
331
+ logger.error(f'Keycloak authentication error: {e}')
332
+ raise HTTPException(
333
+ status_code=503, detail='Authentication service unavailable'
334
+ )
335
+
336
+ # Validate and decode access token
337
+ payload = await self.validate_token(token_data['access_token'])
338
+
339
+ # Get user ID - prefer 'sub', then generate stable ID from email
340
+ # Note: We use email hash as fallback because Keycloak 'sid' changes per session
341
+ user_id = payload.get('sub')
342
+ if not user_id:
343
+ # Generate a stable ID from email for cross-session consistency
344
+ email = payload.get('email', username)
345
+ user_id = 'u-' + hashlib.sha256(email.encode()).hexdigest()[:30]
346
+
347
+ # Create session
348
+ session = UserSession(
349
+ user_id=user_id,
350
+ email=payload.get('email', ''),
351
+ username=payload.get('preferred_username', username),
352
+ name=payload.get('name', ''),
353
+ session_id=str(uuid.uuid4()),
354
+ access_token=token_data['access_token'],
355
+ refresh_token=token_data.get('refresh_token'),
356
+ expires_at=datetime.utcnow()
357
+ + timedelta(seconds=token_data.get('expires_in', 300)),
358
+ device_info=device_info or {},
359
+ roles=payload.get('realm_access', {}).get('roles', []),
360
+ )
361
+
362
+ # Store session
363
+ self._sessions[session.session_id] = session
364
+
365
+ logger.info(
366
+ f'User authenticated: {session.username} (session: {session.session_id})'
367
+ )
368
+ return session
369
+
370
+ async def refresh_session(self, refresh_token: str) -> UserSession:
371
+ """Refresh an existing session using refresh token."""
372
+ try:
373
+ async with httpx.AsyncClient() as client:
374
+ response = await client.post(
375
+ self.token_url,
376
+ data={
377
+ 'grant_type': 'refresh_token',
378
+ 'client_id': self.client_id,
379
+ 'client_secret': self.client_secret,
380
+ 'refresh_token': refresh_token,
381
+ },
382
+ timeout=10.0,
383
+ )
384
+
385
+ if response.status_code != 200:
386
+ raise HTTPException(
387
+ status_code=401, detail='Session expired'
388
+ )
389
+
390
+ token_data = response.json()
391
+
392
+ except httpx.HTTPError as e:
393
+ logger.error(f'Session refresh error: {e}')
394
+ raise HTTPException(
395
+ status_code=503, detail='Authentication service unavailable'
396
+ )
397
+
398
+ # Validate new token
399
+ payload = await self.validate_token(token_data['access_token'])
400
+
401
+ # Get user ID - prefer 'sub', then generate stable ID from email
402
+ user_id = payload.get('sub')
403
+ if not user_id:
404
+ email = payload.get('email', '')
405
+ user_id = (
406
+ 'u-' + hashlib.sha256(email.encode()).hexdigest()[:30]
407
+ if email
408
+ else str(uuid.uuid4())
409
+ )
410
+
411
+ # Find and update existing session or create new
412
+ old_session = None
413
+ for sid, session in self._sessions.items():
414
+ if session.refresh_token == refresh_token:
415
+ old_session = session
416
+ break
417
+
418
+ new_session = UserSession(
419
+ user_id=user_id,
420
+ email=payload.get('email', ''),
421
+ username=payload.get('preferred_username', ''),
422
+ name=payload.get('name', ''),
423
+ session_id=old_session.session_id
424
+ if old_session
425
+ else str(uuid.uuid4()),
426
+ access_token=token_data['access_token'],
427
+ refresh_token=token_data.get('refresh_token'),
428
+ expires_at=datetime.utcnow()
429
+ + timedelta(seconds=token_data.get('expires_in', 300)),
430
+ device_info=old_session.device_info if old_session else {},
431
+ roles=payload.get('realm_access', {}).get('roles', []),
432
+ )
433
+
434
+ self._sessions[new_session.session_id] = new_session
435
+
436
+ return new_session
437
+
438
+ async def get_session(self, session_id: str) -> Optional[UserSession]:
439
+ """Get a session by ID."""
440
+ session = self._sessions.get(session_id)
441
+ if session and session.is_valid():
442
+ session.last_activity = datetime.utcnow()
443
+ return session
444
+ return None
445
+
446
+ async def get_session_by_token(self, token: str) -> Optional[UserSession]:
447
+ """Get a session by access token."""
448
+ for session in self._sessions.values():
449
+ if session.access_token == token and session.is_valid():
450
+ session.last_activity = datetime.utcnow()
451
+ return session
452
+ return None
453
+
454
+ async def logout(self, session_id: str):
455
+ """Logout and invalidate session."""
456
+ session = self._sessions.pop(session_id, None)
457
+ if session and session.refresh_token:
458
+ try:
459
+ async with httpx.AsyncClient() as client:
460
+ await client.post(
461
+ self.logout_url,
462
+ data={
463
+ 'client_id': self.client_id,
464
+ 'client_secret': self.client_secret,
465
+ 'refresh_token': session.refresh_token,
466
+ },
467
+ timeout=5.0,
468
+ )
469
+ except Exception as e:
470
+ logger.warning(f'Keycloak logout failed: {e}')
471
+
472
+ logger.info(f'Session logged out: {session_id}')
473
+
474
+ # User-Codebase Association Management
475
+
476
+ def associate_codebase(
477
+ self,
478
+ user_id: str,
479
+ codebase_id: str,
480
+ codebase_name: str,
481
+ codebase_path: str,
482
+ role: str = 'owner',
483
+ ) -> UserCodebaseAssociation:
484
+ """Associate a codebase with a user."""
485
+ association = UserCodebaseAssociation(
486
+ user_id=user_id,
487
+ codebase_id=codebase_id,
488
+ codebase_name=codebase_name,
489
+ codebase_path=codebase_path,
490
+ role=role,
491
+ )
492
+
493
+ if user_id not in self._user_codebases:
494
+ self._user_codebases[user_id] = []
495
+
496
+ # Check if already associated
497
+ for existing in self._user_codebases[user_id]:
498
+ if existing.codebase_id == codebase_id:
499
+ existing.last_accessed = datetime.utcnow()
500
+ return existing
501
+
502
+ self._user_codebases[user_id].append(association)
503
+ logger.info(f'Codebase {codebase_name} associated with user {user_id}')
504
+ return association
505
+
506
+ def get_user_codebases(self, user_id: str) -> List[UserCodebaseAssociation]:
507
+ """Get all codebases for a user."""
508
+ return self._user_codebases.get(user_id, [])
509
+
510
+ def can_access_codebase(self, user_id: str, codebase_id: str) -> bool:
511
+ """Check if user can access a codebase."""
512
+ associations = self._user_codebases.get(user_id, [])
513
+ for assoc in associations:
514
+ if assoc.codebase_id == codebase_id:
515
+ assoc.last_accessed = datetime.utcnow()
516
+ return True
517
+ return False
518
+
519
+ def remove_codebase_association(
520
+ self, user_id: str, codebase_id: str
521
+ ) -> bool:
522
+ """Remove a codebase association."""
523
+ if user_id in self._user_codebases:
524
+ original_len = len(self._user_codebases[user_id])
525
+ self._user_codebases[user_id] = [
526
+ a
527
+ for a in self._user_codebases[user_id]
528
+ if a.codebase_id != codebase_id
529
+ ]
530
+ return len(self._user_codebases[user_id]) < original_len
531
+ return False
532
+
533
+ # Agent Session Management
534
+
535
+ def create_agent_session(
536
+ self,
537
+ user_id: str,
538
+ codebase_id: str,
539
+ agent_type: str,
540
+ device_id: Optional[str] = None,
541
+ ) -> UserAgentSession:
542
+ """Create a new agent session for a user."""
543
+ session = UserAgentSession(
544
+ user_id=user_id,
545
+ session_id=str(uuid.uuid4()),
546
+ codebase_id=codebase_id,
547
+ agent_type=agent_type,
548
+ device_id=device_id,
549
+ )
550
+
551
+ self._agent_sessions[session.session_id] = session
552
+ logger.info(
553
+ f'Agent session created: {session.session_id} for user {user_id}'
554
+ )
555
+ return session
556
+
557
+ def get_agent_session(self, session_id: str) -> Optional[UserAgentSession]:
558
+ """Get an agent session by ID."""
559
+ return self._agent_sessions.get(session_id)
560
+
561
+ def get_user_agent_sessions(self, user_id: str) -> List[UserAgentSession]:
562
+ """Get all agent sessions for a user."""
563
+ return [
564
+ s for s in self._agent_sessions.values() if s.user_id == user_id
565
+ ]
566
+
567
+ def get_codebase_sessions(self, codebase_id: str) -> List[UserAgentSession]:
568
+ """Get all agent sessions for a codebase."""
569
+ return [
570
+ s
571
+ for s in self._agent_sessions.values()
572
+ if s.codebase_id == codebase_id
573
+ ]
574
+
575
+ def update_agent_session(
576
+ self,
577
+ session_id: str,
578
+ opencode_session_id: Optional[str] = None,
579
+ message: Optional[Dict[str, Any]] = None,
580
+ ):
581
+ """Update an agent session."""
582
+ session = self._agent_sessions.get(session_id)
583
+ if session:
584
+ session.last_activity = datetime.utcnow()
585
+ if opencode_session_id:
586
+ session.opencode_session_id = opencode_session_id
587
+ if message:
588
+ session.messages.append(
589
+ {**message, 'timestamp': datetime.utcnow().isoformat()}
590
+ )
591
+ # Keep only last 100 messages in memory
592
+ if len(session.messages) > 100:
593
+ session.messages = session.messages[-100:]
594
+
595
+ def close_agent_session(self, session_id: str):
596
+ """Close an agent session."""
597
+ self._agent_sessions.pop(session_id, None)
598
+ logger.info(f'Agent session closed: {session_id}')
599
+
600
+ # Session Sync Across Devices
601
+
602
+ def get_active_sessions_for_user(self, user_id: str) -> List[UserSession]:
603
+ """Get all active sessions for a user across devices."""
604
+ return [
605
+ s
606
+ for s in self._sessions.values()
607
+ if s.user_id == user_id and s.is_valid()
608
+ ]
609
+
610
+ def sync_session_state(self, user_id: str) -> Dict[str, Any]:
611
+ """Get synchronized state for a user across all sessions."""
612
+ user_sessions = self.get_active_sessions_for_user(user_id)
613
+ agent_sessions = self.get_user_agent_sessions(user_id)
614
+ codebases = self.get_user_codebases(user_id)
615
+
616
+ return {
617
+ 'user_id': user_id,
618
+ 'active_devices': len(user_sessions),
619
+ 'sessions': [s.to_dict() for s in user_sessions],
620
+ 'agent_sessions': [s.to_dict() for s in agent_sessions],
621
+ 'codebases': [c.to_dict() for c in codebases],
622
+ 'synced_at': datetime.utcnow().isoformat(),
623
+ }
624
+
625
+
626
+ # Global auth service instance
627
+ keycloak_auth = KeycloakAuthService()
628
+
629
+
630
+ # FastAPI Dependencies
631
+
632
+
633
+ async def get_current_user(
634
+ credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
635
+ ) -> Optional[UserSession]:
636
+ """Dependency to get current authenticated user.
637
+
638
+ Extracts realm from token, looks up tenant, and populates
639
+ tenant_id and realm_name on the UserSession.
640
+ """
641
+ if not credentials:
642
+ return None
643
+
644
+ token = credentials.credentials
645
+
646
+ # First try to find existing session
647
+ session = await keycloak_auth.get_session_by_token(token)
648
+ if session:
649
+ return session
650
+
651
+ # Otherwise validate token and create temporary session info
652
+ try:
653
+ payload = await keycloak_auth.validate_token(token)
654
+
655
+ # Get user ID - prefer 'sub', then generate stable ID from email
656
+ user_id = payload.get('sub')
657
+ if not user_id:
658
+ email = payload.get('email', '')
659
+ user_id = (
660
+ 'u-' + hashlib.sha256(email.encode()).hexdigest()[:30]
661
+ if email
662
+ else 'temp-' + str(uuid.uuid4())
663
+ )
664
+
665
+ # Extract realm_name from validated token payload
666
+ realm_name = payload.get('realm_name')
667
+
668
+ # Look up tenant from database by realm
669
+ tenant_id = None
670
+ if realm_name:
671
+ try:
672
+ from .database import get_tenant_by_realm
673
+
674
+ tenant = await get_tenant_by_realm(realm_name)
675
+ if tenant:
676
+ tenant_id = tenant.get('id') or tenant.get('tenant_id')
677
+ except ImportError:
678
+ logger.debug('database module not available for tenant lookup')
679
+ except Exception as e:
680
+ logger.warning(
681
+ f'Failed to look up tenant for realm {realm_name}: {e}'
682
+ )
683
+
684
+ return UserSession(
685
+ user_id=user_id,
686
+ email=payload.get('email', ''),
687
+ username=payload.get('preferred_username', ''),
688
+ name=payload.get('name', ''),
689
+ session_id='temp-' + str(uuid.uuid4()),
690
+ access_token=token,
691
+ refresh_token=None,
692
+ expires_at=datetime.fromtimestamp(payload.get('exp', 0)),
693
+ roles=payload.get('realm_access', {}).get('roles', []),
694
+ tenant_id=tenant_id,
695
+ realm_name=realm_name,
696
+ )
697
+ except HTTPException:
698
+ return None
699
+
700
+
701
+ async def require_auth(
702
+ user: Optional[UserSession] = Depends(get_current_user),
703
+ ) -> UserSession:
704
+ """Dependency that requires authentication."""
705
+ if not user:
706
+ raise HTTPException(status_code=401, detail='Authentication required')
707
+ return user
708
+
709
+
710
+ async def require_admin(
711
+ user: UserSession = Depends(require_auth),
712
+ ) -> UserSession:
713
+ """Dependency that requires admin role."""
714
+ if 'admin' not in user.roles and 'a2a-admin' not in user.roles:
715
+ raise HTTPException(status_code=403, detail='Admin access required')
716
+ return user
717
+
718
+
719
+ async def get_current_tenant(
720
+ user: UserSession = Depends(get_current_user),
721
+ ) -> Optional[TenantContext]:
722
+ """Dependency to get current tenant context from authenticated user.
723
+
724
+ Returns TenantContext with tenant_id, realm_name, and plan if available.
725
+ Returns None if user is not authenticated or has no tenant association.
726
+ """
727
+ if not user:
728
+ return None
729
+
730
+ if not user.tenant_id or not user.realm_name:
731
+ return None
732
+
733
+ # Look up tenant plan from database
734
+ plan = None
735
+ try:
736
+ from .database import get_tenant_by_realm
737
+
738
+ tenant = await get_tenant_by_realm(user.realm_name)
739
+ if tenant:
740
+ plan = tenant.get('plan')
741
+ except ImportError:
742
+ logger.debug('database module not available for tenant plan lookup')
743
+ except Exception as e:
744
+ logger.warning(f'Failed to look up tenant plan: {e}')
745
+
746
+ return TenantContext(
747
+ tenant_id=user.tenant_id,
748
+ realm_name=user.realm_name,
749
+ plan=plan,
750
+ )