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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- 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
|
+
)
|