| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| """ |
| Authentication manager for Kiro API. |
| |
| Manages the lifecycle of access tokens: |
| - Loading credentials from .env or JSON file |
| - Automatic token refresh on expiration |
| - Thread-safe refresh using asyncio.Lock |
| - Support for both Kiro Desktop Auth and AWS SSO OIDC (kiro-cli) |
| """ |
|
|
| import asyncio |
| import json |
| import sqlite3 |
| from datetime import datetime, timezone, timedelta |
| from enum import Enum |
| from pathlib import Path |
| from typing import Optional |
|
|
| import httpx |
| from loguru import logger |
|
|
| from kiro.config import ( |
| TOKEN_REFRESH_THRESHOLD, |
| get_kiro_refresh_url, |
| get_kiro_api_host, |
| get_kiro_q_host, |
| get_aws_sso_oidc_url, |
| ) |
| from kiro.utils import get_machine_fingerprint |
|
|
|
|
| |
| SQLITE_TOKEN_KEYS = [ |
| "kirocli:social:token", |
| "kirocli:odic:token", |
| "codewhisperer:odic:token", |
| ] |
|
|
| |
| SQLITE_REGISTRATION_KEYS = [ |
| "kirocli:odic:device-registration", |
| "codewhisperer:odic:device-registration", |
| ] |
|
|
|
|
| class AuthType(Enum): |
| """ |
| Type of authentication mechanism. |
| |
| KIRO_DESKTOP: Kiro IDE credentials (default) |
| - Uses https://prod.{region}.auth.desktop.kiro.dev/refreshToken |
| - JSON body: {"refreshToken": "..."} |
| |
| AWS_SSO_OIDC: AWS SSO credentials from kiro-cli |
| - Uses https://oidc.{region}.amazonaws.com/token |
| - Form body: grant_type=refresh_token&client_id=...&client_secret=...&refresh_token=... |
| - Requires clientId and clientSecret from credentials file |
| """ |
| KIRO_DESKTOP = "kiro_desktop" |
| AWS_SSO_OIDC = "aws_sso_oidc" |
|
|
|
|
| class KiroAuthManager: |
| """ |
| Manages the token lifecycle for accessing Kiro API. |
| |
| Supports: |
| - Loading credentials from .env or JSON file |
| - Automatic token refresh on expiration |
| - Expiration time validation (expiresAt) |
| - Saving updated tokens to file |
| - Both Kiro Desktop Auth and AWS SSO OIDC (kiro-cli) authentication |
| |
| Attributes: |
| profile_arn: AWS CodeWhisperer profile ARN |
| region: AWS region |
| api_host: API host for the current region |
| q_host: Q API host for the current region |
| fingerprint: Unique machine fingerprint |
| auth_type: Type of authentication (KIRO_DESKTOP or AWS_SSO_OIDC) |
| |
| Example: |
| >>> # Kiro Desktop Auth (default) |
| >>> auth_manager = KiroAuthManager( |
| ... refresh_token="your_refresh_token", |
| ... region="us-east-1" |
| ... ) |
| >>> token = await auth_manager.get_access_token() |
| |
| >>> # AWS SSO OIDC (kiro-cli) - auto-detected from credentials file |
| >>> auth_manager = KiroAuthManager( |
| ... creds_file="~/.aws/sso/cache/your-cache.json" |
| ... ) |
| >>> token = await auth_manager.get_access_token() |
| """ |
| |
| def __init__( |
| self, |
| refresh_token: Optional[str] = None, |
| profile_arn: Optional[str] = None, |
| region: str = "us-east-1", |
| creds_file: Optional[str] = None, |
| client_id: Optional[str] = None, |
| client_secret: Optional[str] = None, |
| sqlite_db: Optional[str] = None, |
| ): |
| """ |
| Initializes the authentication manager. |
| |
| Args: |
| refresh_token: Refresh token for obtaining access token |
| profile_arn: AWS CodeWhisperer profile ARN |
| region: AWS region (default: us-east-1) |
| creds_file: Path to JSON file with credentials (optional) |
| client_id: OAuth client ID (for AWS SSO OIDC, optional) |
| client_secret: OAuth client secret (for AWS SSO OIDC, optional) |
| sqlite_db: Path to kiro-cli SQLite database (optional) |
| Default location: ~/.local/share/kiro-cli/data.sqlite3 |
| """ |
| self._refresh_token = refresh_token |
| self._profile_arn = profile_arn |
| self._region = region |
| self._creds_file = creds_file |
| self._sqlite_db = sqlite_db |
| |
| |
| self._client_id: Optional[str] = client_id |
| self._client_secret: Optional[str] = client_secret |
| self._scopes: Optional[list] = None |
| self._sso_region: Optional[str] = None |
| |
| |
| self._client_id_hash: Optional[str] = None |
| |
| |
| self._sqlite_token_key: Optional[str] = None |
| |
| self._access_token: Optional[str] = None |
| self._expires_at: Optional[datetime] = None |
| self._lock = asyncio.Lock() |
| |
| |
| self._auth_type: AuthType = AuthType.KIRO_DESKTOP |
| |
| |
| self._refresh_url = get_kiro_refresh_url(region) |
| self._api_host = get_kiro_api_host(region) |
| self._q_host = get_kiro_q_host(region) |
| |
| |
| self._fingerprint = get_machine_fingerprint() |
| |
| |
| if sqlite_db: |
| self._load_credentials_from_sqlite(sqlite_db) |
| |
| elif creds_file: |
| self._load_credentials_from_file(creds_file) |
| |
| |
| self._detect_auth_type() |
| |
| def _detect_auth_type(self) -> None: |
| """ |
| Detects authentication type based on available credentials. |
| |
| AWS SSO OIDC credentials contain clientId and clientSecret. |
| Kiro Desktop credentials do not contain these fields. |
| """ |
| if self._client_id and self._client_secret: |
| self._auth_type = AuthType.AWS_SSO_OIDC |
| logger.info("Detected auth type: AWS SSO OIDC (kiro-cli)") |
| else: |
| self._auth_type = AuthType.KIRO_DESKTOP |
| logger.info("Detected auth type: Kiro Desktop") |
| |
| def _load_credentials_from_sqlite(self, db_path: str) -> None: |
| """ |
| Loads credentials from kiro-cli SQLite database. |
| |
| The database contains an auth_kv table with key-value pairs. |
| Supports multiple authentication types: |
| |
| Token keys (searched in priority order): |
| - 'kirocli:social:token': Social login (Google, GitHub, etc.) |
| - 'kirocli:odic:token': AWS SSO OIDC (kiro-cli corporate) |
| - 'codewhisperer:odic:token': Legacy AWS SSO OIDC |
| |
| Device registration keys (for AWS SSO OIDC only): |
| - 'kirocli:odic:device-registration': Client ID and secret |
| - 'codewhisperer:odic:device-registration': Legacy format |
| |
| The method remembers which key was used for loading, so credentials |
| can be saved back to the correct location after refresh. |
| |
| Args: |
| db_path: Path to SQLite database file |
| """ |
| try: |
| path = Path(db_path).expanduser() |
| if not path.exists(): |
| logger.warning(f"SQLite database not found: {db_path}") |
| return |
| |
| conn = sqlite3.connect(str(path)) |
| cursor = conn.cursor() |
| |
| |
| token_row = None |
| for key in SQLITE_TOKEN_KEYS: |
| cursor.execute("SELECT value FROM auth_kv WHERE key = ?", (key,)) |
| token_row = cursor.fetchone() |
| if token_row: |
| self._sqlite_token_key = key |
| logger.debug(f"Loaded credentials from SQLite key: {key}") |
| break |
| |
| if token_row: |
| token_data = json.loads(token_row[0]) |
| if token_data: |
| |
| if 'access_token' in token_data: |
| self._access_token = token_data['access_token'] |
| if 'refresh_token' in token_data: |
| self._refresh_token = token_data['refresh_token'] |
| if 'profile_arn' in token_data: |
| self._profile_arn = token_data['profile_arn'] |
| if 'region' in token_data: |
| |
| |
| |
| |
| self._sso_region = token_data['region'] |
| logger.debug(f"SSO region from SQLite: {self._sso_region} (API stays at {self._region})") |
| |
| |
| if 'scopes' in token_data: |
| self._scopes = token_data['scopes'] |
| |
| |
| if 'expires_at' in token_data: |
| try: |
| expires_str = token_data['expires_at'] |
| |
| if expires_str.endswith('Z'): |
| self._expires_at = datetime.fromisoformat(expires_str.replace('Z', '+00:00')) |
| else: |
| self._expires_at = datetime.fromisoformat(expires_str) |
| except Exception as e: |
| logger.warning(f"Failed to parse expires_at from SQLite: {e}") |
| |
| |
| registration_row = None |
| for key in SQLITE_REGISTRATION_KEYS: |
| cursor.execute("SELECT value FROM auth_kv WHERE key = ?", (key,)) |
| registration_row = cursor.fetchone() |
| if registration_row: |
| logger.debug(f"Loaded device registration from SQLite key: {key}") |
| break |
| |
| if registration_row: |
| registration_data = json.loads(registration_row[0]) |
| if registration_data: |
| if 'client_id' in registration_data: |
| self._client_id = registration_data['client_id'] |
| if 'client_secret' in registration_data: |
| self._client_secret = registration_data['client_secret'] |
| |
| if 'region' in registration_data and not self._sso_region: |
| self._sso_region = registration_data['region'] |
| logger.debug(f"SSO region from device-registration: {self._sso_region}") |
| |
| conn.close() |
| logger.info(f"Credentials loaded from SQLite database: {db_path}") |
| |
| except sqlite3.Error as e: |
| logger.error(f"SQLite error loading credentials: {e}") |
| except json.JSONDecodeError as e: |
| logger.error(f"JSON decode error in SQLite data: {e}") |
| except Exception as e: |
| logger.error(f"Error loading credentials from SQLite: {e}") |
| |
| def _load_credentials_from_file(self, file_path: str) -> None: |
| """ |
| Loads credentials from a JSON file. |
| |
| Supported JSON fields (Kiro Desktop): |
| - refreshToken: Refresh token |
| - accessToken: Access token (if already available) |
| - profileArn: Profile ARN |
| - region: AWS region |
| - expiresAt: Token expiration time (ISO 8601) |
| |
| Additional fields for AWS SSO OIDC (kiro-cli): |
| - clientId: OAuth client ID |
| - clientSecret: OAuth client secret |
| |
| For Enterprise Kiro IDE: |
| - clientIdHash: Hash of client ID (Enterprise Kiro IDE) |
| - When clientIdHash is present, automatically loads clientId and clientSecret |
| from ~/.aws/sso/cache/{clientIdHash}.json (device registration file) |
| |
| Args: |
| file_path: Path to JSON file |
| """ |
| try: |
| path = Path(file_path).expanduser() |
| if not path.exists(): |
| logger.warning(f"Credentials file not found: {file_path}") |
| return |
| |
| with open(path, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| |
| |
| if 'refreshToken' in data: |
| self._refresh_token = data['refreshToken'] |
| if 'accessToken' in data: |
| self._access_token = data['accessToken'] |
| if 'profileArn' in data: |
| self._profile_arn = data['profileArn'] |
| if 'region' in data: |
| self._region = data['region'] |
| |
| self._refresh_url = get_kiro_refresh_url(self._region) |
| self._api_host = get_kiro_api_host(self._region) |
| self._q_host = get_kiro_q_host(self._region) |
| |
| |
| if 'clientIdHash' in data: |
| self._client_id_hash = data['clientIdHash'] |
| self._load_enterprise_device_registration(self._client_id_hash) |
| |
| |
| if 'clientId' in data: |
| self._client_id = data['clientId'] |
| if 'clientSecret' in data: |
| self._client_secret = data['clientSecret'] |
| |
| |
| if 'expiresAt' in data: |
| try: |
| expires_str = data['expiresAt'] |
| |
| if expires_str.endswith('Z'): |
| self._expires_at = datetime.fromisoformat(expires_str.replace('Z', '+00:00')) |
| else: |
| self._expires_at = datetime.fromisoformat(expires_str) |
| except Exception as e: |
| logger.warning(f"Failed to parse expiresAt: {e}") |
| |
| logger.info(f"Credentials loaded from {file_path}") |
| |
| except Exception as e: |
| logger.error(f"Error loading credentials from file: {e}") |
| |
| def _load_enterprise_device_registration(self, client_id_hash: str) -> None: |
| """ |
| Loads clientId and clientSecret from Enterprise Kiro IDE device registration file. |
| |
| Enterprise Kiro IDE uses AWS SSO OIDC authentication. Device registration is stored at: |
| ~/.aws/sso/cache/{clientIdHash}.json |
| |
| Args: |
| client_id_hash: Client ID hash used to locate the device registration file |
| """ |
| try: |
| device_reg_path = Path.home() / ".aws" / "sso" / "cache" / f"{client_id_hash}.json" |
| |
| if not device_reg_path.exists(): |
| logger.warning(f"Enterprise device registration file not found: {device_reg_path}") |
| return |
| |
| with open(device_reg_path, 'r', encoding='utf-8') as f: |
| device_data = json.load(f) |
| |
| if 'clientId' in device_data: |
| self._client_id = device_data['clientId'] |
| |
| if 'clientSecret' in device_data: |
| self._client_secret = device_data['clientSecret'] |
| |
| logger.info(f"Enterprise device registration loaded from {device_reg_path}") |
| |
| except Exception as e: |
| logger.error(f"Error loading enterprise device registration: {e}") |
| |
| def _save_credentials_to_file(self) -> None: |
| """ |
| Saves updated credentials to a JSON file. |
| |
| Updates the existing file while preserving other fields. |
| """ |
| if not self._creds_file: |
| return |
| |
| try: |
| path = Path(self._creds_file).expanduser() |
| |
| |
| existing_data = {} |
| if path.exists(): |
| with open(path, 'r', encoding='utf-8') as f: |
| existing_data = json.load(f) |
| |
| |
| existing_data['accessToken'] = self._access_token |
| existing_data['refreshToken'] = self._refresh_token |
| if self._expires_at: |
| existing_data['expiresAt'] = self._expires_at.isoformat() |
| if self._profile_arn: |
| existing_data['profileArn'] = self._profile_arn |
| |
| |
| with open(path, 'w', encoding='utf-8') as f: |
| json.dump(existing_data, f, indent=2, ensure_ascii=False) |
| |
| logger.debug(f"Credentials saved to {self._creds_file}") |
| |
| except Exception as e: |
| logger.error(f"Error saving credentials: {e}") |
| |
| def _save_credentials_to_sqlite(self) -> None: |
| """ |
| Saves updated credentials back to SQLite database. |
| |
| This ensures that tokens refreshed by the gateway are persisted |
| and available after gateway restart or for other processes reading |
| the same SQLite database. |
| |
| Strategy: |
| 1. If we know which key we loaded from (_sqlite_token_key), save to that key |
| 2. If that fails or key is unknown, try all supported keys as fallback |
| |
| This approach ensures credentials are saved to the correct location |
| regardless of authentication type (social login, AWS SSO OIDC, legacy). |
| |
| Updates the auth_kv table with fresh access_token, refresh_token, |
| and expires_at values after successful token refresh. |
| """ |
| if not self._sqlite_db: |
| return |
| |
| try: |
| path = Path(self._sqlite_db).expanduser() |
| if not path.exists(): |
| logger.warning(f"SQLite database not found for writing: {self._sqlite_db}") |
| return |
| |
| |
| conn = sqlite3.connect(str(path), timeout=5.0) |
| cursor = conn.cursor() |
| |
| |
| token_data = { |
| "access_token": self._access_token, |
| "refresh_token": self._refresh_token, |
| "expires_at": self._expires_at.isoformat() if self._expires_at else None, |
| "region": self._sso_region or self._region, |
| } |
| if self._scopes: |
| token_data["scopes"] = self._scopes |
| |
| token_json = json.dumps(token_data) |
| |
| |
| if self._sqlite_token_key: |
| cursor.execute( |
| "UPDATE auth_kv SET value = ? WHERE key = ?", |
| (token_json, self._sqlite_token_key) |
| ) |
| if cursor.rowcount > 0: |
| conn.commit() |
| conn.close() |
| logger.debug(f"Credentials saved to SQLite key: {self._sqlite_token_key}") |
| return |
| else: |
| logger.warning(f"Failed to update SQLite key: {self._sqlite_token_key}, trying fallback") |
| |
| |
| for key in SQLITE_TOKEN_KEYS: |
| cursor.execute( |
| "UPDATE auth_kv SET value = ? WHERE key = ?", |
| (token_json, key) |
| ) |
| if cursor.rowcount > 0: |
| conn.commit() |
| conn.close() |
| logger.debug(f"Credentials saved to SQLite key: {key} (fallback)") |
| return |
| |
| |
| conn.close() |
| logger.warning(f"Failed to save credentials to SQLite: no matching keys found") |
| |
| except sqlite3.Error as e: |
| logger.error(f"SQLite error saving credentials: {e}") |
| except Exception as e: |
| logger.error(f"Error saving credentials to SQLite: {e}") |
| |
| def is_token_expiring_soon(self) -> bool: |
| """ |
| Checks if the token is expiring soon. |
| |
| Returns: |
| True if the token expires within TOKEN_REFRESH_THRESHOLD seconds |
| or if expiration time information is not available |
| """ |
| if not self._expires_at: |
| return True |
| |
| now = datetime.now(timezone.utc) |
| threshold = now.timestamp() + TOKEN_REFRESH_THRESHOLD |
| |
| return self._expires_at.timestamp() <= threshold |
| |
| def is_token_expired(self) -> bool: |
| """ |
| Checks if the token is actually expired (not just expiring soon). |
| |
| This is used for graceful degradation when refresh fails but |
| the access token might still be valid for a short time. |
| |
| Returns: |
| True if the token has already expired or if expiration time |
| information is not available |
| """ |
| if not self._expires_at: |
| return True |
| |
| now = datetime.now(timezone.utc) |
| return now >= self._expires_at |
| |
| async def _refresh_token_request(self) -> None: |
| """ |
| Performs a token refresh request. |
| |
| Routes to appropriate refresh method based on auth type: |
| - KIRO_DESKTOP: Uses Kiro Desktop Auth endpoint |
| - AWS_SSO_OIDC: Uses AWS SSO OIDC endpoint |
| |
| Raises: |
| ValueError: If refresh token is not set or response doesn't contain accessToken |
| httpx.HTTPError: On HTTP request error |
| """ |
| if self._auth_type == AuthType.AWS_SSO_OIDC: |
| await self._refresh_token_aws_sso_oidc() |
| else: |
| await self._refresh_token_kiro_desktop() |
| |
| async def _refresh_token_kiro_desktop(self) -> None: |
| """ |
| Refreshes token using Kiro Desktop Auth endpoint. |
| |
| Endpoint: https://prod.{region}.auth.desktop.kiro.dev/refreshToken |
| Method: POST |
| Content-Type: application/json |
| Body: {"refreshToken": "..."} |
| |
| Raises: |
| ValueError: If refresh token is not set or response doesn't contain accessToken |
| httpx.HTTPError: On HTTP request error |
| """ |
| if not self._refresh_token: |
| raise ValueError("Refresh token is not set") |
| |
| logger.info("Refreshing Kiro token via Kiro Desktop Auth...") |
| |
| payload = {'refreshToken': self._refresh_token} |
| headers = { |
| "Content-Type": "application/json", |
| "User-Agent": f"KiroIDE-0.7.45-{self._fingerprint}", |
| } |
| |
| async with httpx.AsyncClient(timeout=30) as client: |
| response = await client.post(self._refresh_url, json=payload, headers=headers) |
| response.raise_for_status() |
| data = response.json() |
| |
| new_access_token = data.get("accessToken") |
| new_refresh_token = data.get("refreshToken") |
| expires_in = data.get("expiresIn", 3600) |
| new_profile_arn = data.get("profileArn") |
| |
| if not new_access_token: |
| raise ValueError(f"Response does not contain accessToken: {data}") |
| |
| |
| self._access_token = new_access_token |
| if new_refresh_token: |
| self._refresh_token = new_refresh_token |
| if new_profile_arn: |
| self._profile_arn = new_profile_arn |
| |
| |
| self._expires_at = datetime.now(timezone.utc).replace(microsecond=0) |
| self._expires_at = datetime.fromtimestamp( |
| self._expires_at.timestamp() + expires_in - 60, |
| tz=timezone.utc |
| ) |
| |
| logger.info(f"Token refreshed via Kiro Desktop Auth, expires: {self._expires_at.isoformat()}") |
| |
| |
| if self._sqlite_db: |
| self._save_credentials_to_sqlite() |
| else: |
| self._save_credentials_to_file() |
| |
| async def _refresh_token_aws_sso_oidc(self) -> None: |
| """ |
| Refreshes token using AWS SSO OIDC endpoint. |
| |
| Used by kiro-cli which authenticates via AWS IAM Identity Center. |
| |
| Strategy: Try with current in-memory token first. If it fails with 400 |
| (invalid_request - token was invalidated by kiro-cli re-login), reload |
| credentials from SQLite and retry once. |
| |
| This approach handles both scenarios: |
| 1. Container successfully refreshed token (uses in-memory token) |
| 2. kiro-cli re-login invalidated token (reloads from SQLite on failure) |
| |
| Endpoint: https://oidc.{region}.amazonaws.com/token |
| Method: POST |
| Content-Type: application/x-www-form-urlencoded |
| Body: grant_type=refresh_token&client_id=...&client_secret=...&refresh_token=... |
| |
| Raises: |
| ValueError: If required credentials are not set |
| httpx.HTTPError: On HTTP request error |
| """ |
| try: |
| await self._do_aws_sso_oidc_refresh() |
| except httpx.HTTPStatusError as e: |
| |
| if e.response.status_code == 400 and self._sqlite_db: |
| logger.warning("Token refresh failed with 400, reloading credentials from SQLite and retrying...") |
| self._load_credentials_from_sqlite(self._sqlite_db) |
| await self._do_aws_sso_oidc_refresh() |
| else: |
| raise |
| |
| async def _do_aws_sso_oidc_refresh(self) -> None: |
| """ |
| Performs the actual AWS SSO OIDC token refresh. |
| |
| This is the internal implementation called by _refresh_token_aws_sso_oidc(). |
| It performs a single refresh attempt with current in-memory credentials. |
| |
| Uses AWS SSO OIDC CreateToken API format: |
| - Content-Type: application/json (not form-urlencoded) |
| - Parameter names: camelCase (clientId, not client_id) |
| - Payload: JSON object |
| |
| Raises: |
| ValueError: If required credentials are not set |
| httpx.HTTPStatusError: On HTTP error (including 400 for invalid token) |
| """ |
| if not self._refresh_token: |
| raise ValueError("Refresh token is not set") |
| if not self._client_id: |
| raise ValueError("Client ID is not set (required for AWS SSO OIDC)") |
| if not self._client_secret: |
| raise ValueError("Client secret is not set (required for AWS SSO OIDC)") |
| |
| logger.info("Refreshing Kiro token via AWS SSO OIDC...") |
| |
| |
| |
| sso_region = self._sso_region or self._region |
| url = get_aws_sso_oidc_url(sso_region) |
| |
| |
| |
| |
| payload = { |
| "grantType": "refresh_token", |
| "clientId": self._client_id, |
| "clientSecret": self._client_secret, |
| "refreshToken": self._refresh_token, |
| } |
| |
| headers = { |
| "Content-Type": "application/json", |
| } |
| |
| |
| logger.debug(f"AWS SSO OIDC refresh request: url={url}, sso_region={sso_region}, " |
| f"api_region={self._region}, client_id={self._client_id[:8]}...") |
| |
| async with httpx.AsyncClient(timeout=30) as client: |
| response = await client.post(url, json=payload, headers=headers) |
| |
| |
| if response.status_code != 200: |
| error_body = response.text |
| logger.error(f"AWS SSO OIDC refresh failed: status={response.status_code}, " |
| f"body={error_body}") |
| |
| try: |
| error_json = response.json() |
| error_code = error_json.get("error", "unknown") |
| error_desc = error_json.get("error_description", "no description") |
| logger.error(f"AWS SSO OIDC error details: error={error_code}, " |
| f"description={error_desc}") |
| except Exception: |
| pass |
| response.raise_for_status() |
| |
| result = response.json() |
| |
| |
| new_access_token = result.get("accessToken") |
| new_refresh_token = result.get("refreshToken") |
| expires_in = result.get("expiresIn", 3600) |
| |
| if not new_access_token: |
| raise ValueError(f"AWS SSO OIDC response does not contain accessToken: {result}") |
| |
| |
| self._access_token = new_access_token |
| if new_refresh_token: |
| self._refresh_token = new_refresh_token |
| |
| |
| self._expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in - 60) |
| |
| logger.info(f"Token refreshed via AWS SSO OIDC, expires: {self._expires_at.isoformat()}") |
| |
| |
| if self._sqlite_db: |
| self._save_credentials_to_sqlite() |
| else: |
| self._save_credentials_to_file() |
| |
| async def get_access_token(self) -> str: |
| """ |
| Returns a valid access_token, refreshing it if necessary. |
| |
| Thread-safe method using asyncio.Lock. |
| Automatically refreshes the token if it has expired or is about to expire. |
| |
| For SQLite mode (kiro-cli): implements graceful degradation when refresh fails. |
| If kiro-cli has been running and refreshing tokens in memory (without persisting |
| to SQLite), the refresh_token in SQLite becomes stale. In this case, we fall back |
| to using the access_token directly until it actually expires. |
| |
| Returns: |
| Valid access token |
| |
| Raises: |
| ValueError: If unable to obtain access token |
| """ |
| async with self._lock: |
| |
| if self._access_token and not self.is_token_expiring_soon(): |
| return self._access_token |
| |
| |
| if self._sqlite_db and self.is_token_expiring_soon(): |
| logger.debug("SQLite mode: reloading credentials before refresh attempt") |
| self._load_credentials_from_sqlite(self._sqlite_db) |
| |
| if self._access_token and not self.is_token_expiring_soon(): |
| logger.debug("SQLite reload provided fresh token, no refresh needed") |
| return self._access_token |
| |
| |
| try: |
| await self._refresh_token_request() |
| except httpx.HTTPStatusError as e: |
| |
| |
| if e.response.status_code == 400 and self._sqlite_db: |
| logger.warning( |
| "Token refresh failed with 400 after SQLite reload. " |
| "This may happen if kiro-cli refreshed tokens in memory without persisting." |
| ) |
| |
| if self._access_token and not self.is_token_expired(): |
| logger.warning( |
| "Using existing access_token until it expires. " |
| "Run 'kiro-cli login' when convenient to refresh credentials." |
| ) |
| return self._access_token |
| else: |
| raise ValueError( |
| "Token expired and refresh failed. " |
| "Please run 'kiro-cli login' to refresh your credentials." |
| ) |
| |
| raise |
| except Exception: |
| |
| raise |
| |
| if not self._access_token: |
| raise ValueError("Failed to obtain access token") |
| |
| return self._access_token |
| |
| async def force_refresh(self) -> str: |
| """ |
| Forces a token refresh. |
| |
| Used when receiving a 403 error from the API. |
| |
| Returns: |
| New access token |
| """ |
| async with self._lock: |
| await self._refresh_token_request() |
| return self._access_token |
| |
| @property |
| def profile_arn(self) -> Optional[str]: |
| """AWS CodeWhisperer profile ARN.""" |
| return self._profile_arn |
| |
| @property |
| def region(self) -> str: |
| """AWS region.""" |
| return self._region |
| |
| @property |
| def api_host(self) -> str: |
| """API host for the current region.""" |
| return self._api_host |
| |
| @property |
| def q_host(self) -> str: |
| """Q API host for the current region.""" |
| return self._q_host |
| |
| @property |
| def fingerprint(self) -> str: |
| """Unique machine fingerprint.""" |
| return self._fingerprint |
| |
| @property |
| def auth_type(self) -> AuthType: |
| """Authentication type (KIRO_DESKTOP or AWS_SSO_OIDC).""" |
| return self._auth_type |