# -*- coding: utf-8 -*- # Kiro Gateway # https://github.com/jwadow/kiro-gateway # Copyright (C) 2025 Jwadow # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """ 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 # Supported SQLite token keys (searched in priority order) SQLITE_TOKEN_KEYS = [ "kirocli:social:token", # Social login (Google, GitHub, Microsoft, 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) 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 # AWS SSO OIDC specific fields self._client_id: Optional[str] = client_id self._client_secret: Optional[str] = client_secret self._scopes: Optional[list] = None # OAuth scopes for AWS SSO OIDC self._sso_region: Optional[str] = None # SSO region for OIDC token refresh (may differ from API region) # Enterprise Kiro IDE specific fields self._client_id_hash: Optional[str] = None # clientIdHash from Enterprise Kiro IDE # Track which SQLite key we loaded credentials from (for saving back to correct location) self._sqlite_token_key: Optional[str] = None self._access_token: Optional[str] = None self._expires_at: Optional[datetime] = None self._lock = asyncio.Lock() # Auth type will be determined after loading credentials self._auth_type: AuthType = AuthType.KIRO_DESKTOP # Dynamic URLs based on region self._refresh_url = get_kiro_refresh_url(region) self._api_host = get_kiro_api_host(region) self._q_host = get_kiro_q_host(region) # Fingerprint for User-Agent self._fingerprint = get_machine_fingerprint() # Load credentials from SQLite if specified (takes priority over JSON) if sqlite_db: self._load_credentials_from_sqlite(sqlite_db) # Load credentials from JSON file if specified elif creds_file: self._load_credentials_from_file(creds_file) # Determine auth type based on available credentials 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() # Try all possible token keys in priority order 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 # Remember which key we loaded from logger.debug(f"Loaded credentials from SQLite key: {key}") break if token_row: token_data = json.loads(token_row[0]) if token_data: # Load token fields (using snake_case as in Rust struct) 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: # Store SSO region for OIDC token refresh only # IMPORTANT: CodeWhisperer API is only available in us-east-1, # so we don't update _api_host and _q_host here. # The SSO region (e.g., ap-southeast-1) is only used for OIDC token refresh. self._sso_region = token_data['region'] logger.debug(f"SSO region from SQLite: {self._sso_region} (API stays at {self._region})") # Load scopes if available if 'scopes' in token_data: self._scopes = token_data['scopes'] # Parse expires_at (RFC3339 format) if 'expires_at' in token_data: try: expires_str = token_data['expires_at'] # Handle various ISO 8601 formats 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}") # Load device registration (client_id, client_secret) - try all possible keys 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'] # SSO region from registration (fallback if not in token data) 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) # Load common data from file 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'] # Update URLs for new 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) # Load clientIdHash and device registration for Enterprise Kiro IDE if 'clientIdHash' in data: self._client_id_hash = data['clientIdHash'] self._load_enterprise_device_registration(self._client_id_hash) # Load AWS SSO OIDC specific fields (if directly in credentials file) if 'clientId' in data: self._client_id = data['clientId'] if 'clientSecret' in data: self._client_secret = data['clientSecret'] # Parse expiresAt if 'expiresAt' in data: try: expires_str = data['expiresAt'] # Support for different date formats 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() # Read existing data existing_data = {} if path.exists(): with open(path, 'r', encoding='utf-8') as f: existing_data = json.load(f) # Update data 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 # Save 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 # Use timeout to avoid blocking if database is locked conn = sqlite3.connect(str(path), timeout=5.0) cursor = conn.cursor() # Prepare token data matching the structure from _load_credentials_from_sqlite 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) # Save back to the same key we loaded from (if known) 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") # Fallback: try all keys (for edge cases where source key is unknown) 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 # If we get here, no keys were updated 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 # If no expiration info available, assume refresh is needed 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 # If no expiration info available, assume expired 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}") # Update 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 # Calculate expiration time with buffer (minus 60 seconds) 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()}") # Save to file or SQLite depending on configuration 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: # 400 = invalid_request, likely stale token after kiro-cli re-login 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...") # AWS SSO OIDC CreateToken API uses JSON with camelCase parameters # Use SSO region for OIDC endpoint (may differ from API region) sso_region = self._sso_region or self._region url = get_aws_sso_oidc_url(sso_region) # IMPORTANT: AWS SSO OIDC CreateToken API requires: # 1. JSON payload (not form-urlencoded) # 2. camelCase parameter names (clientId, not client_id) payload = { "grantType": "refresh_token", "clientId": self._client_id, "clientSecret": self._client_secret, "refreshToken": self._refresh_token, } headers = { "Content-Type": "application/json", } # Log request details (without secrets) for debugging 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) # Log response details for debugging (especially on errors) 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 to parse AWS error for more details 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 # Body wasn't JSON, already logged as text response.raise_for_status() result = response.json() # AWS SSO OIDC CreateToken API returns camelCase fields 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}") # Update data self._access_token = new_access_token if new_refresh_token: self._refresh_token = new_refresh_token # Calculate expiration time with buffer (minus 60 seconds) 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()}") # Save to file or SQLite depending on configuration 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: # Token is valid and not expiring soon - just return it if self._access_token and not self.is_token_expiring_soon(): return self._access_token # SQLite mode: reload credentials first, kiro-cli might have updated them 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) # Check if reloaded token is now valid 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 to refresh the token try: await self._refresh_token_request() except httpx.HTTPStatusError as e: # Graceful degradation for SQLite mode when refresh fails twice # This happens when kiro-cli refreshed tokens in memory without persisting 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." ) # Check if access_token is still usable 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." ) # Non-SQLite mode or non-400 error - propagate the exception raise except Exception: # For any other exception, propagate it 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