File size: 16,297 Bytes
494c89b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f27a3
494c89b
 
 
 
 
 
c7f27a3
494c89b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f27a3
494c89b
 
 
c7f27a3
494c89b
 
 
 
 
c7f27a3
 
494c89b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f27a3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""
Token Service - управление токенами
"""

import json
import hashlib
import time
import requests
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from dataclasses import dataclass

import sys
sys.path.insert(0, str(Path(__file__).parent.parent))

from core.paths import get_paths
from core.config import get_config
from core.kiro_config import get_machine_id, get_kiro_user_agent
from core.exceptions import (
    TokenError, TokenExpiredError, TokenRefreshError, TokenNotFoundError
)
from core.constants import MAX_RETRIES, RETRY_DELAY_SEC


@dataclass
class TokenInfo:
    """Информация о токене"""
    path: Path
    account_name: str
    email: Optional[str]
    provider: str
    auth_method: str
    region: str
    expires_at: Optional[datetime]
    is_expired: bool
    has_refresh_token: bool
    needs_refresh: bool
    
    # Raw data
    raw_data: Dict[str, Any] = None


class TokenService:
    """Сервис для работы с токенами"""
    
    # API endpoints
    DESKTOP_AUTH_API = "https://prod.{region}.auth.desktop.kiro.dev"
    OIDC_API = "https://oidc.{region}.amazonaws.com"
    
    def __init__(self):
        self.paths = get_paths()
        self.config = get_config()
    
    # =========================================================================
    # Token CRUD
    # =========================================================================
    
    def list_tokens(self) -> List[TokenInfo]:
        """Список всех токенов"""
        tokens = []
        
        for token_file in self.paths.list_tokens():
            try:
                info = self._parse_token_file(token_file)
                if info:
                    tokens.append(info)
            except Exception as e:
                print(f"[!] Error reading {token_file.name}: {e}")
        
        return tokens
    
    def get_token(self, name: str) -> Optional[TokenInfo]:
        """Получить токен по имени"""
        # Ищем по имени аккаунта или имени файла
        for token in self.list_tokens():
            if name.lower() in token.account_name.lower() or name in token.path.name:
                return token
        return None
    
    def get_current_token(self) -> Optional[TokenInfo]:
        """Получить текущий активный токен Kiro"""
        if not self.paths.kiro_token_file.exists():
            return None
        
        return self._parse_token_file(self.paths.kiro_token_file)
    
    def save_token(self, data: Dict[str, Any], name: str = None) -> Path:
        """
        Сохранить токен.
        
        ВАЖНО: Автоматически добавляет idp и _machineId если их нет.
        """
        if name is None:
            name = data.get('accountName', 'unknown')
        
        # Генерируем имя файла
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filename = f"token-{name}-{timestamp}.json"
        filepath = self.paths.tokens_dir / filename
        
        # Добавляем метаданные
        data['_savedAt'] = datetime.now().isoformat()
        data['_filename'] = filename
        
        # ВАЖНО: Добавляем idp если его нет (для Web Portal API)
        if 'idp' not in data:
            # Определяем idp по provider
            provider = data.get('provider', '').lower()
            if 'google' in provider:
                data['idp'] = 'Google'
            elif 'github' in provider:
                data['idp'] = 'Github'
            else:
                data['idp'] = 'Google'  # По умолчанию
        
        # ANTI-BAN: Добавляем _machineId если его нет
        if '_machineId' not in data:
            data['_machineId'] = get_machine_id()
        
        filepath.write_text(json.dumps(data, indent=2, ensure_ascii=False))
        return filepath
    
    def delete_token(self, name: str) -> bool:
        """Удалить токен"""
        token = self.get_token(name)
        if token and token.path.exists():
            token.path.unlink()
            return True
        return False
    
    # =========================================================================
    # Token Refresh
    # =========================================================================
    
    def refresh_token(self, token: TokenInfo) -> Dict[str, Any]:
        """
        Обновить токен
        
        Returns:
            Обновлённые данные токена
        
        Raises:
            TokenRefreshError: если не удалось обновить
        """
        if not token.has_refresh_token:
            raise TokenRefreshError("No refresh token available")
        
        data = token.raw_data
        refresh_token = data.get('refreshToken')
        region = token.region
        
        if token.auth_method == 'social':
            return self._refresh_social(refresh_token, region)
        else:
            return self._refresh_idc(
                refresh_token,
                data.get('_clientId', data.get('clientId', '')),
                data.get('_clientSecret', data.get('clientSecret', '')),
                region
            )
    
    def _refresh_social(self, refresh_token: str, region: str) -> Dict[str, Any]:
        """
        Обновить Social токен через Web Portal API (CBOR).
        
        ВАЖНО: Использует Web Portal вместо Desktop Auth API!
        """
        from .webportal_client import KiroWebPortalClient
        
        # Получаем idp из raw_data (должен быть сохранён)
        # Если нет - используем Google по умолчанию
        idp = 'Google'  # Будет переопределён в refresh_token()
        
        # Для Web Portal refresh нужны access_token, csrf_token, session_token
        # Но у нас есть только refresh_token...
        # Поэтому используем старый Desktop Auth API как fallback
        url = f"{self.DESKTOP_AUTH_API.format(region=region)}/refreshToken"
        
        # Headers как в Kiro IDE
        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": get_kiro_user_agent(),
        }
        
        last_error = ""
        for attempt in range(MAX_RETRIES):
            if attempt > 0:
                time.sleep(RETRY_DELAY_SEC)
            
            try:
                resp = requests.post(
                    url,
                    json={"refreshToken": refresh_token},
                    headers=headers,
                    timeout=self.config.timeouts.api_request
                )
                
                if resp.status_code == 401:
                    raise TokenRefreshError("Refresh token expired or invalid")
                
                if resp.status_code != 200:
                    last_error = f"Refresh failed ({resp.status_code})"
                    continue
                
                data = resp.json()
                expires_at = datetime.utcnow() + timedelta(seconds=data.get('expiresIn', 3600))
                
                return {
                    'accessToken': data.get('accessToken'),
                    'refreshToken': data.get('refreshToken', refresh_token),
                    'expiresAt': expires_at.isoformat() + 'Z',
                    'expiresIn': data.get('expiresIn', 3600),
                    'profileArn': data.get('profileArn'),
                    'csrfToken': data.get('csrfToken'),
                    'idp': idp  # Сохраняем idp
                }
            except requests.RequestException as e:
                last_error = f"Network error: {e}"
                continue
        
        raise TokenRefreshError(last_error)
    
    def _refresh_idc(self, refresh_token: str, client_id: str, 
                     client_secret: str, region: str) -> Dict[str, Any]:
        """Обновить IdC токен через AWS OIDC API (с retry)"""
        url = f"{self.OIDC_API.format(region=region)}/token"
        
        # Headers как в Kiro IDE
        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": get_kiro_user_agent(),
        }
        
        # JSON формат (как в kiro-account-manager)
        payload = {
            "clientId": client_id,
            "clientSecret": client_secret,
            "grantType": "refresh_token",
            "refreshToken": refresh_token
        }
        
        last_error = ""
        for attempt in range(MAX_RETRIES):
            if attempt > 0:
                time.sleep(RETRY_DELAY_SEC)
            
            try:
                resp = requests.post(
                    url,
                    json=payload,
                    headers=headers,
                    timeout=self.config.timeouts.api_request
                )
                
                if resp.status_code == 401:
                    raise TokenRefreshError("Refresh token expired or invalid")
                
                if resp.status_code != 200:
                    last_error = f"Refresh failed ({resp.status_code})"
                    continue
                
                data = resp.json()
                expires_at = datetime.utcnow() + timedelta(seconds=data.get('expiresIn', 3600))
                
                return {
                    'accessToken': data.get('accessToken'),
                    'refreshToken': data.get('refreshToken', refresh_token),
                    'expiresAt': expires_at.isoformat() + 'Z',
                    'expiresIn': data.get('expiresIn', 3600),
                    'idToken': data.get('idToken'),
                    'ssoSessionId': data.get('aws_sso_app_session_id')
                }
            except requests.RequestException as e:
                last_error = f"Network error: {e}"
                continue
        
        raise TokenRefreshError(last_error)
    
    def refresh_and_save(self, token: TokenInfo) -> TokenInfo:
        """Обновить токен и сохранить"""
        new_data = self.refresh_token(token)
        
        # Мержим с существующими данными
        updated_data = token.raw_data.copy()
        updated_data.update(new_data)
        updated_data['_refreshedAt'] = datetime.now().isoformat()
        
        # Сохраняем
        token.path.write_text(json.dumps(updated_data, indent=2, ensure_ascii=False))
        
        return self._parse_token_file(token.path)
    
    # =========================================================================
    # Token Activation (switch to Kiro)
    # =========================================================================
    
    def activate_token(self, token: TokenInfo, force_refresh: bool = False) -> bool:
        """
        Активировать токен в Kiro (записать в AWS SSO cache)
        
        Args:
            token: Токен для активации
            force_refresh: Принудительно обновить перед активацией
        
        Returns:
            True если успешно
        """
        data = token.raw_data
        
        # Обновляем если нужно
        if token.is_expired or force_refresh:
            try:
                new_data = self.refresh_token(token)
                data = token.raw_data.copy()
                data.update(new_data)
                
                # Сохраняем обновлённый токен
                token.path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
            except TokenRefreshError as e:
                print(f"[X] Failed to refresh token: {e}")
                return False
        
        # Генерируем clientIdHash
        client_id = data.get('_clientId', data.get('clientId', ''))
        client_id_hash = hashlib.sha1(client_id.encode()).hexdigest() if client_id else ''
        
        # Формат для Kiro
        kiro_data = {
            "accessToken": data.get('accessToken'),
            "refreshToken": data.get('refreshToken'),
            "expiresAt": data.get('expiresAt'),
            "clientIdHash": client_id_hash,
            "authMethod": data.get('authMethod', 'IdC'),
            "provider": data.get('provider', 'BuilderId'),
            "region": data.get('region', 'us-east-1'),
            "idp": data.get('idp', 'Google')  # ВАЖНО: Сохраняем idp для Web Portal API
        }
        
        # Бэкапим старый токен
        if self.paths.kiro_token_file.exists():
            backup_name = f"kiro-auth-token.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
            backup_path = self.paths.aws_sso_cache / backup_name
            self.paths.kiro_token_file.rename(backup_path)
        
        # Atomic write: пишем во временный файл, потом rename
        temp_file = self.paths.kiro_token_file.with_suffix('.json.tmp')
        temp_file.write_text(json.dumps(kiro_data, indent=2))
        temp_file.rename(self.paths.kiro_token_file)
        
        # Также сохраняем client registration для IdC
        if client_id_hash and data.get('_clientId'):
            client_reg = {
                "clientId": data.get('_clientId'),
                "clientSecret": data.get('_clientSecret'),
                "expiresAt": (datetime.utcnow() + timedelta(days=90)).isoformat() + 'Z'
            }
            client_file = self.paths.get_client_registration_file(client_id_hash)
            client_file.write_text(json.dumps(client_reg, indent=2))
        
        return True
    
    # =========================================================================
    # Helpers
    # =========================================================================
    
    def _parse_token_file(self, path: Path) -> Optional[TokenInfo]:
        """Парсит файл токена в TokenInfo"""
        try:
            data = json.loads(path.read_text())
            
            expires_at = None
            is_expired = True
            
            if data.get('expiresAt'):
                try:
                    expires_at = datetime.fromisoformat(
                        data['expiresAt'].replace('Z', '+00:00')
                    )
                    is_expired = expires_at <= datetime.now(expires_at.tzinfo)
                except:
                    pass
            
            has_refresh = bool(data.get('refreshToken'))
            return TokenInfo(
                path=path,
                account_name=data.get('accountName', path.stem),
                email=data.get('email') or data.get('accountEmail') or data.get('userEmail'),
                provider=data.get('provider', 'Unknown'),
                auth_method=data.get('authMethod', 'Unknown'),
                region=data.get('region', 'us-east-1'),
                expires_at=expires_at,
                is_expired=is_expired,
                has_refresh_token=has_refresh,
                needs_refresh=bool(is_expired and has_refresh),
                raw_data=data
            )
        except Exception:
            return None
    
    def get_best_token(self) -> Optional[TokenInfo]:
        """Получить лучший доступный токен (не истёкший, с refresh)"""
        tokens = self.list_tokens()
        
        # Сначала ищем не истёкшие
        valid_tokens = [t for t in tokens if not t.is_expired and t.has_refresh_token]
        if valid_tokens:
            return valid_tokens[0]
        
        # Потом с refresh token
        refreshable = [t for t in tokens if t.has_refresh_token]
        if refreshable:
            return refreshable[0]
        
        # Любой
        return tokens[0] if tokens else None