import asyncio import json import os import re import urllib.parse from enum import IntEnum, StrEnum from typing import Tuple import structlog import tldextract from pydantic import BaseModel from skyvern.config import settings from skyvern.exceptions import ( BitwardenAccessDeniedError, BitwardenCreateCollectionError, BitwardenCreateCreditCardItemError, BitwardenCreateLoginItemError, BitwardenGetItemError, BitwardenListItemsError, BitwardenLoginError, BitwardenLogoutError, BitwardenSecretError, BitwardenSyncError, BitwardenUnlockError, ) from skyvern.forge.sdk.api.aws import aws_client from skyvern.forge.sdk.core.aiohttp_helper import aiohttp_delete, aiohttp_get_json, aiohttp_post from skyvern.forge.sdk.schemas.credentials import ( CredentialItem, CredentialType, CreditCardCredential, PasswordCredential, ) LOG = structlog.get_logger() BITWARDEN_SERVER_BASE_URL = f"{settings.BITWARDEN_SERVER}:{settings.BITWARDEN_SERVER_PORT or 8002}" class BitwardenItemType(IntEnum): LOGIN = 1 SECURE_NOTE = 2 CREDIT_CARD = 3 IDENTITY = 4 def get_bitwarden_item_type_code(item_type: BitwardenItemType) -> int: if item_type == BitwardenItemType.LOGIN: return 1 elif item_type == BitwardenItemType.SECURE_NOTE: return 2 elif item_type == BitwardenItemType.CREDIT_CARD: return 3 elif item_type == BitwardenItemType.IDENTITY: return 4 def get_list_response_item_from_bitwarden_item(item: dict) -> CredentialItem: if item["type"] == BitwardenItemType.LOGIN: login = item["login"] totp = BitwardenService.extract_totp_secret(login.get("totp", "")) return CredentialItem( item_id=item["id"], credential=PasswordCredential( username=login["username"] or "", password=login["password"] or "", totp=totp, ), name=item["name"], credential_type=CredentialType.PASSWORD, ) elif item["type"] == BitwardenItemType.CREDIT_CARD: card = item["card"] return CredentialItem( item_id=item["id"], credential=CreditCardCredential( card_holder_name=card["cardholderName"], card_number=card["number"], card_exp_month=card["expMonth"], card_exp_year=card["expYear"], card_cvv=card["code"], card_brand=card["brand"], ), name=item["name"], credential_type=CredentialType.CREDIT_CARD, ) else: raise BitwardenGetItemError(f"Unsupported item type: {item['type']}") def is_valid_email(email: str | None) -> bool: if not email: return False pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return re.match(pattern, email) is not None class BitwardenConstants(StrEnum): BW_ORGANIZATION_ID = "BW_ORGANIZATION_ID" BW_COLLECTION_IDS = "BW_COLLECTION_IDS" CLIENT_ID = "BW_CLIENT_ID" CLIENT_SECRET = "BW_CLIENT_SECRET" MASTER_PASSWORD = "BW_MASTER_PASSWORD" URL = "BW_URL" BW_COLLECTION_ID = "BW_COLLECTION_ID" IDENTITY_KEY = "BW_IDENTITY_KEY" BW_ITEM_ID = "BW_ITEM_ID" USERNAME = "BW_USERNAME" PASSWORD = "BW_PASSWORD" TOTP = "BW_TOTP" CREDIT_CARD_HOLDER_NAME = "BW_CREDIT_CARD_HOLDER_NAME" CREDIT_CARD_NUMBER = "BW_CREDIT_CARD_NUMBER" CREDIT_CARD_EXPIRATION_MONTH = "BW_CREDIT_CARD_EXPIRATION_MONTH" CREDIT_CARD_EXPIRATION_YEAR = "BW_CREDIT_CARD_EXPIRATION_YEAR" CREDIT_CARD_CVV = "BW_CREDIT_CARD_CVV" CREDIT_CARD_BRAND = "BW_CREDIT_CARD_BRAND" SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID = "SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID" SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD = "SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD" SKYVERN_AUTH_BITWARDEN_CLIENT_ID = "SKYVERN_AUTH_BITWARDEN_CLIENT_ID" SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET = "SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET" class BitwardenQueryResult(BaseModel): credential: dict[str, str] uris: list[str] class RunCommandResult(BaseModel): stdout: str stderr: str returncode: int class BitwardenService: @staticmethod async def run_command( command: list[str], additional_env: dict[str, str] | None = None, timeout: int = 60 ) -> RunCommandResult: """ Run a CLI command with the specified additional environment variables and return the result. """ env = os.environ.copy() # Copy the current environment # Make sure node isn't returning warnings. Warnings are sent through stderr and we raise exceptions on stderr. env["NODE_NO_WARNINGS"] = "1" if additional_env: env.update(additional_env) # Update with any additional environment variables try: async with asyncio.timeout(timeout): shell_subprocess = await asyncio.create_subprocess_shell( " ".join(command), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, ) stdout, stderr = await shell_subprocess.communicate() return RunCommandResult( stdout=stdout.decode(), stderr=stderr.decode(), returncode=shell_subprocess.returncode, ) except asyncio.TimeoutError as e: LOG.error(f"Bitwarden command timed out after {timeout} seconds", exc_info=True) raise e @staticmethod def _extract_session_key(unlock_cmd_output: str) -> str | None: # Split the text by lines lines = unlock_cmd_output.split("\n") # Look for the line containing the BW_SESSION for line in lines: if 'BW_SESSION="' in line: # Find the start and end positions of the session key start = line.find('BW_SESSION="') + len('BW_SESSION="') end = line.rfind('"', start) return line[start:end] return None @staticmethod async def get_secret_value_from_url( client_id: str, client_secret: str, master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, url: str | None = None, collection_id: str | None = None, item_id: str | None = None, max_retries: int = settings.BITWARDEN_MAX_RETRIES, timeout: int = settings.BITWARDEN_TIMEOUT_SECONDS, ) -> dict[str, str]: """ Get the secret value from the Bitwarden CLI. """ fail_reasons: list[str] = [] if not bw_organization_id and bw_collection_ids and collection_id not in bw_collection_ids: raise BitwardenAccessDeniedError() for i in range(max_retries): # FIXME: just simply double the timeout for the second try. maybe a better backoff policy when needed timeout = (i + 1) * timeout try: async with asyncio.timeout(timeout): return await BitwardenService._get_secret_value_from_url( client_id=client_id, client_secret=client_secret, master_password=master_password, bw_organization_id=bw_organization_id, bw_collection_ids=bw_collection_ids, url=url, collection_id=collection_id, item_id=item_id, timeout=timeout, ) except BitwardenAccessDeniedError as e: raise e except Exception as e: LOG.info("Failed to get secret value from Bitwarden", tried_times=i + 1, exc_info=True) fail_reasons.append(f"{type(e).__name__}: {str(e)}") else: raise BitwardenListItemsError( f"Bitwarden CLI failed after all retry attempts. Fail reasons: {fail_reasons}" ) @staticmethod def extract_totp_secret(totp_value: str) -> str: """ Extract the TOTP secret from either a raw secret or a TOTP URI. Args: totp_value: Raw TOTP secret or URI (otpauth://totp/...) Returns: The extracted TOTP secret Example: >>> BitwardenService.extract_totp_secret("AAAAAABBBBBBB") "AAAAAABBBBBBB" >>> BitwardenService.extract_totp_secret("otpauth://totp/user@domain.com?secret=AAAAAABBBBBBB") "AAAAAABBBBBBB" """ if not totp_value: return "" # Handle TOTP URI format if totp_value.startswith("otpauth://"): try: # Parse the URI to extract the secret query = urllib.parse.urlparse(totp_value).query params = dict(urllib.parse.parse_qsl(query)) return params.get("secret", "") except Exception: LOG.error( "Failed to parse TOTP URI", totp_value=totp_value, exc_info=True, ) return "" return totp_value @staticmethod async def _get_secret_value_from_url( client_id: str, client_secret: str, master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, url: str | None = None, collection_id: str | None = None, item_id: str | None = None, timeout: int = 60, ) -> dict[str, str]: """ Get the secret value from the Bitwarden CLI. """ try: await BitwardenService.login(client_id, client_secret) await BitwardenService.sync() session_key = await BitwardenService.unlock(master_password) if item_id: # if item_id provided, get single item by item id command = ["bw", "get", "item", item_id, "--session", session_key] item_result = await BitwardenService.run_command(command) if item_result.stderr: raise BitwardenGetItemError( f"Failed to get the bitwarden item {item_id}. Error: {item_result.stderr}" ) try: item = json.loads(item_result.stdout) except json.JSONDecodeError: raise BitwardenGetItemError(f"Failed to parse item JSON for item ID: {item_id}") login = item["login"] totp = BitwardenService.extract_totp_secret(login.get("totp", "")) return { BitwardenConstants.USERNAME: login.get("username", ""), BitwardenConstants.PASSWORD: login.get("password", ""), BitwardenConstants.TOTP: totp, } elif not url: # if item_id is not provided, we need a url to search for items raise BitwardenGetItemError("No url or item ID provided") # Extract the domain from the URL and search for items in Bitwarden with that domain extract_url = tldextract.extract(url) domain = extract_url.domain list_command = [ "bw", "list", "items", "--search", domain, "--session", session_key, ] if bw_organization_id: LOG.info( "Organization ID is provided, filtering items by organization ID", bw_organization_id=bw_organization_id, ) list_command.extend(["--organizationid", bw_organization_id]) elif collection_id: LOG.info("Collection ID is provided, filtering items by collection ID", collection_id=collection_id) list_command.extend(["--collectionid", collection_id]) else: LOG.error("No collection ID or organization ID provided -- this is required") raise BitwardenListItemsError("No collection ID or organization ID provided -- this is required") items_result = await BitwardenService.run_command(list_command, timeout=timeout) if items_result.stderr and "Event post failed" not in items_result.stderr: raise BitwardenListItemsError(items_result.stderr) # Parse the items and extract credentials try: items = json.loads(items_result.stdout) except json.JSONDecodeError: raise BitwardenListItemsError("Failed to parse items JSON. Output: " + items_result.stdout) # Since Bitwarden can't AND multiple filters, we only use organization id in the list command # but we still need to filter the items by collection id here if bw_organization_id and collection_id: filtered_items = [] for item in items: if "collectionIds" in item and collection_id in item["collectionIds"]: filtered_items.append(item) items = filtered_items if not items: collection_id_str = f" in collection with ID: {collection_id}" if collection_id else "" raise BitwardenListItemsError(f"No items found in Bitwarden for URL: {url}{collection_id_str}") bitwarden_result: list[BitwardenQueryResult] = [] for item in items: if "login" not in item: continue login = item["login"] totp = BitwardenService.extract_totp_secret(login.get("totp", "")) bitwarden_result.append( BitwardenQueryResult( credential={ BitwardenConstants.USERNAME: login.get("username", ""), BitwardenConstants.PASSWORD: login.get("password", ""), BitwardenConstants.TOTP: totp, }, uris=[uri.get("uri") for uri in login.get("uris", []) if "uri" in uri], ) ) if len(bitwarden_result) == 0: return {} if len(bitwarden_result) == 1: return bitwarden_result[0].credential # Choose multiple credentials according to the defined rule, # if no cred matches the rule, return the first one. # TODO: For now hard code to choose the first matched result for single_result in bitwarden_result: # check the username is a valid email if is_valid_email(single_result.credential.get(BitwardenConstants.USERNAME)): for uri in single_result.uris: # check if the register_domain is the same if extract_url.registered_domain == tldextract.extract(uri).registered_domain: return single_result.credential LOG.warning("No credential in Bitwarden matches the rule, returning the first match") return bitwarden_result[0].credential finally: # Step 4: Log out await BitwardenService.logout() @staticmethod async def get_sensitive_information_from_identity( client_id: str, client_secret: str, master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, collection_id: str, identity_key: str, identity_fields: list[str], remaining_retries: int = settings.BITWARDEN_MAX_RETRIES, timeout: int = settings.BITWARDEN_TIMEOUT_SECONDS, fail_reasons: list[str] = [], ) -> dict[str, str]: """ Get the secret value from the Bitwarden CLI. """ if not bw_organization_id and bw_collection_ids and collection_id not in bw_collection_ids: raise BitwardenAccessDeniedError() try: async with asyncio.timeout(timeout): return await BitwardenService._get_sensitive_information_from_identity( client_id=client_id, client_secret=client_secret, master_password=master_password, bw_organization_id=bw_organization_id, bw_collection_ids=bw_collection_ids, collection_id=collection_id, identity_key=identity_key, identity_fields=identity_fields, ) except BitwardenAccessDeniedError as e: raise e except Exception as e: if remaining_retries <= 0: raise BitwardenListItemsError( f"Bitwarden CLI failed after all retry attempts. Fail reasons: {fail_reasons}" ) remaining_retries -= 1 LOG.info("Retrying to get sensitive information from Bitwarden", remaining_retries=remaining_retries) return await BitwardenService.get_sensitive_information_from_identity( client_id=client_id, client_secret=client_secret, master_password=master_password, bw_organization_id=bw_organization_id, bw_collection_ids=bw_collection_ids, collection_id=collection_id, identity_key=identity_key, identity_fields=identity_fields, remaining_retries=remaining_retries, # Double the timeout for the next retry timeout=timeout * 2, fail_reasons=fail_reasons + [f"{type(e).__name__}: {str(e)}"], ) @staticmethod async def _get_sensitive_information_from_identity( client_id: str, client_secret: str, master_password: str, collection_id: str, identity_key: str, identity_fields: list[str], bw_organization_id: str | None, bw_collection_ids: list[str] | None, ) -> dict[str, str]: """ Get the sensitive information from the Bitwarden CLI. """ try: await BitwardenService.login(client_id, client_secret) await BitwardenService.sync() session_key = await BitwardenService.unlock(master_password) if not bw_organization_id and not collection_id: raise BitwardenAccessDeniedError() # Step 3: Retrieve the items list_command = [ "bw", "list", "items", "--search", identity_key, "--session", session_key, "--collectionid", collection_id, ] if bw_organization_id: list_command.extend(["--organizationid", bw_organization_id]) items_result = await BitwardenService.run_command(list_command) # Parse the items and extract sensitive information try: items = json.loads(items_result.stdout) except json.JSONDecodeError: raise BitwardenListItemsError("Failed to parse items JSON. Output: " + items_result.stdout) if not items: raise BitwardenListItemsError( f"No items found in Bitwarden for identity key: {identity_key} in collection with ID: {collection_id}" ) # Filter the identity items # https://bitwarden.com/help/cli/#create lists the type of the identity items as 4 # We may want to filter it by type in the future, but for now we just take the first item and check its identity fields # identity_items = [item for item in items if item["type"] == 4] identity_item = items[0] sensitive_information: dict[str, str] = {} for field in identity_fields: # The identity item may store sensitive information in custom fields or default fields # Custom fields are prioritized over default fields # TODO (kerem): Make this case insensitive? for item in identity_item["fields"]: if item["name"] == field: sensitive_information[field] = item["value"] break if ( "identity" in identity_item and field in identity_item["identity"] and field not in sensitive_information ): sensitive_information[field] = identity_item["identity"][field] return sensitive_information finally: # Step 4: Log out await BitwardenService.logout() @staticmethod async def login(client_id: str, client_secret: str) -> None: """ Log in to the Bitwarden CLI. """ env = { "BW_CLIENTID": client_id, "BW_CLIENTSECRET": client_secret, } login_command = ["bw", "login", "--apikey"] login_result = await BitwardenService.run_command(login_command, env) # Validate the login result if login_result.stdout and "You are logged in!" not in login_result.stdout: raise BitwardenLoginError(f"Failed to log in. stdout: {login_result.stdout} stderr: {login_result.stderr}") if login_result.stderr and "You are already logged in as" not in login_result.stderr: raise BitwardenLoginError(f"Failed to log in. stdout: {login_result.stdout} stderr: {login_result.stderr}") LOG.info("Bitwarden login successful") @staticmethod async def unlock(master_password: str) -> str: """ Unlock the Bitwarden CLI. """ env = { "BW_PASSWORD": master_password, } unlock_command = ["bw", "unlock", "--passwordenv", "BW_PASSWORD"] unlock_result = await BitwardenService.run_command(unlock_command, env) # Validate the unlock result if unlock_result.stdout and "Your vault is now unlocked!" not in unlock_result.stdout: raise BitwardenUnlockError( f"Failed to unlock vault. stdout: {unlock_result.stdout} stderr: {unlock_result.stderr}" ) # Extract session key try: session_key = BitwardenService._extract_session_key(unlock_result.stdout) except Exception as e: raise BitwardenUnlockError(f"Unable to extract session key: {str(e)}. stderr: {unlock_result.stderr}") if not session_key: raise BitwardenUnlockError(f"Session key is empty. stderr: {unlock_result.stderr}") return session_key @staticmethod async def sync() -> None: """ Sync the Bitwarden CLI. """ sync_command = ["bw", "sync"] LOG.info("Bitwarden CLI sync started") sync_result = await BitwardenService.run_command(sync_command) LOG.info("Bitwarden CLI sync completed") if sync_result.stderr: raise BitwardenSyncError(sync_result.stderr) @staticmethod async def logout() -> None: """ Log out of the Bitwarden CLI. """ logout_command = ["bw", "logout"] logout_result = await BitwardenService.run_command(logout_command) if logout_result.stderr and "You are not logged in." not in logout_result.stderr: raise BitwardenLogoutError(logout_result.stderr) @staticmethod async def _get_credit_card_data( client_id: str, client_secret: str, master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, collection_id: str, item_id: str, ) -> dict[str, str]: """ Get the credit card data from the Bitwarden CLI. """ try: await BitwardenService.login(client_id, client_secret) await BitwardenService.sync() session_key = await BitwardenService.unlock(master_password) # Step 3: Get the item get_command = [ "bw", "get", "item", item_id, "--session", session_key, ] # Bitwarden CLI doesn't support filtering by organization ID or collection ID for credit card data so we just raise an error if no collection ID or organization ID is provided if not bw_organization_id and not collection_id: LOG.error("No collection ID or organization ID provided -- this is required") raise BitwardenAccessDeniedError() item_result = await BitwardenService.run_command(get_command) # Parse the item and extract credit card data try: item = json.loads(item_result.stdout) except json.JSONDecodeError: raise BitwardenListItemsError(f"Failed to parse item JSON for item ID: {item_id}") if not item: raise BitwardenListItemsError(f"No item found in Bitwarden for item ID: {item_id}") # Check if the bw_organization_id matches if bw_organization_id: item_organization_id = item.get("organizationId") if item_organization_id != bw_organization_id: raise BitwardenAccessDeniedError() if bw_collection_ids: item_collection_ids = item.get("collectionIds") if item_collection_ids and collection_id not in bw_collection_ids: raise BitwardenAccessDeniedError() # Check if the item is a credit card # https://bitwarden.com/help/cli/#create lists the type of the credit card items as 3 if item["type"] != 3: raise BitwardenListItemsError(f"Item with ID: {item_id} is not a credit card type") credit_card_data = item["card"] mapped_credit_card_data: dict[str, str] = { BitwardenConstants.CREDIT_CARD_HOLDER_NAME: credit_card_data["cardholderName"], BitwardenConstants.CREDIT_CARD_NUMBER: credit_card_data["number"], BitwardenConstants.CREDIT_CARD_EXPIRATION_MONTH: credit_card_data["expMonth"], BitwardenConstants.CREDIT_CARD_EXPIRATION_YEAR: credit_card_data["expYear"], BitwardenConstants.CREDIT_CARD_CVV: credit_card_data["code"], BitwardenConstants.CREDIT_CARD_BRAND: credit_card_data["brand"], } return mapped_credit_card_data finally: # Step 4: Log out await BitwardenService.logout() @staticmethod async def get_credit_card_data( client_id: str, client_secret: str, master_password: str, bw_organization_id: str | None, bw_collection_ids: list[str] | None, collection_id: str, item_id: str, remaining_retries: int = settings.BITWARDEN_MAX_RETRIES, fail_reasons: list[str] = [], ) -> dict[str, str]: """ Get the credit card data from the Bitwarden CLI. """ try: async with asyncio.timeout(settings.BITWARDEN_TIMEOUT_SECONDS): return await BitwardenService._get_credit_card_data( client_id=client_id, client_secret=client_secret, master_password=master_password, bw_organization_id=bw_organization_id, bw_collection_ids=bw_collection_ids, collection_id=collection_id, item_id=item_id, ) except BitwardenAccessDeniedError as e: raise e except Exception as e: if remaining_retries <= 0: raise BitwardenListItemsError( f"Bitwarden CLI failed after all retry attempts. Fail reasons: {fail_reasons}" ) remaining_retries -= 1 LOG.info("Retrying to get credit card data from Bitwarden", remaining_retries=remaining_retries) return await BitwardenService.get_credit_card_data( client_id=client_id, client_secret=client_secret, master_password=master_password, bw_organization_id=bw_organization_id, bw_collection_ids=bw_collection_ids, collection_id=collection_id, item_id=item_id, remaining_retries=remaining_retries, fail_reasons=fail_reasons + [f"{type(e).__name__}: {str(e)}"], ) @staticmethod async def _unlock_using_server(master_password: str) -> None: status_response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/status") status = status_response["data"]["template"]["status"] if status != "unlocked": await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/unlock", data={"password": master_password}) @staticmethod async def _get_login_item_by_id_using_server(item_id: str) -> PasswordCredential: response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") if not response or response.get("success") is False: raise BitwardenGetItemError(f"Failed to get login item by ID: {item_id}") login = response["data"]["login"] totp = BitwardenService.extract_totp_secret(login.get("totp", "")) if not login: raise BitwardenGetItemError(f"Item with ID: {item_id} is not a login item") return PasswordCredential( username=login["username"] or "", password=login["password"] or "", totp=totp, ) @staticmethod async def _create_login_item_using_server( bw_organization_id: str, collection_id: str, name: str, credential: PasswordCredential, ) -> str: item_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item") login_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item.login") item_template = item_template["data"]["template"] login_template = login_template["data"]["template"] login_template["username"] = credential.username login_template["password"] = credential.password login_template["totp"] = credential.totp item_template["type"] = get_bitwarden_item_type_code(BitwardenItemType.LOGIN) item_template["name"] = name item_template["login"] = login_template item_template["collectionIds"] = [collection_id] item_template["organizationId"] = bw_organization_id response = await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/object/item", data=item_template) if not response or response.get("success") is False: raise BitwardenCreateLoginItemError("Failed to create login item") return response["data"]["id"] @staticmethod async def _create_credit_card_item_using_server( bw_organization_id: str, collection_id: str, name: str, credential: CreditCardCredential, ) -> str: item_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item") credit_card_template = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/item.card") item_template = item_template["data"]["template"] credit_card_template = credit_card_template["data"]["template"] credit_card_template["cardholderName"] = credential.card_holder_name credit_card_template["number"] = credential.card_number credit_card_template["expMonth"] = credential.card_exp_month credit_card_template["expYear"] = credential.card_exp_year credit_card_template["code"] = credential.card_cvv credit_card_template["brand"] = credential.card_brand item_template["type"] = get_bitwarden_item_type_code(BitwardenItemType.CREDIT_CARD) item_template["name"] = name item_template["card"] = credit_card_template item_template["collectionIds"] = [collection_id] item_template["organizationId"] = bw_organization_id response = await aiohttp_post(f"{BITWARDEN_SERVER_BASE_URL}/object/item", data=item_template) if not response or response.get("success") is False: raise BitwardenCreateCreditCardItemError("Failed to create credit card item") return response["data"]["id"] @staticmethod async def create_credential_item( collection_id: str, name: str, credential: PasswordCredential | CreditCardCredential, ) -> str: try: master_password, bw_organization_id, _, _ = await BitwardenService._get_skyvern_auth_secrets() await BitwardenService._unlock_using_server(master_password) if isinstance(credential, PasswordCredential): return await BitwardenService._create_login_item_using_server( bw_organization_id=bw_organization_id, collection_id=collection_id, name=name, credential=credential, ) else: return await BitwardenService._create_credit_card_item_using_server( bw_organization_id=bw_organization_id, collection_id=collection_id, name=name, credential=credential, ) except Exception as e: raise e @staticmethod async def _get_skyvern_auth_master_password() -> str: master_password = settings.SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD if not master_password: master_password = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_MASTER_PASSWORD) if not master_password: raise BitwardenSecretError("Skyvern auth master password is not set") return master_password @staticmethod async def _get_skyvern_auth_organization_id() -> str: bw_organization_id = settings.SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID if not bw_organization_id: bw_organization_id = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_ORGANIZATION_ID) if not bw_organization_id: raise BitwardenSecretError("Skyvern auth organization ID is not set") return bw_organization_id @staticmethod async def _get_skyvern_auth_client_id() -> str: client_id = settings.SKYVERN_AUTH_BITWARDEN_CLIENT_ID if not client_id: client_id = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_CLIENT_ID) if not client_id: raise BitwardenSecretError("Skyvern auth client ID is not set") return client_id @staticmethod async def _get_skyvern_auth_client_secret() -> str: client_secret = settings.SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET if not client_secret: client_secret = await aws_client.get_secret(BitwardenConstants.SKYVERN_AUTH_BITWARDEN_CLIENT_SECRET) if not client_secret: raise BitwardenSecretError("Skyvern auth client secret is not set") return client_secret @staticmethod async def create_collection( name: str, ) -> str: """ Create a collection in Bitwarden and return the collection ID. """ try: master_password, bw_organization_id, _, _ = await BitwardenService._get_skyvern_auth_secrets() await BitwardenService._unlock_using_server(master_password) return await BitwardenService._create_collection_using_server(bw_organization_id, name) except Exception as e: raise e @staticmethod async def _create_collection_using_server(bw_organization_id: str, name: str) -> str: collection_template_response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/template/collection") collection_template = collection_template_response["data"]["template"] collection_template["name"] = name collection_template["organizationId"] = bw_organization_id response = await aiohttp_post( f"{BITWARDEN_SERVER_BASE_URL}/object/org-collection?organizationId={bw_organization_id}", data=collection_template, ) if not response or response.get("success") is False: raise BitwardenCreateCollectionError("Failed to create collection") return response["data"]["id"] @staticmethod async def _get_skyvern_auth_secrets() -> Tuple[str, str, str, str]: master_password, bw_organization_id, client_id, client_secret = await asyncio.gather( BitwardenService._get_skyvern_auth_master_password(), BitwardenService._get_skyvern_auth_organization_id(), BitwardenService._get_skyvern_auth_client_id(), BitwardenService._get_skyvern_auth_client_secret(), ) return master_password, bw_organization_id, client_id, client_secret @staticmethod async def get_items_by_item_ids( item_ids: list[str], ) -> list[CredentialItem]: try: master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() await BitwardenService._unlock_using_server(master_password) return await BitwardenService._get_items_by_item_ids_using_server(item_ids) except Exception as e: raise e @staticmethod async def _get_items_by_item_ids_using_server(item_ids: list[str]) -> list[CredentialItem]: responses = await asyncio.gather( *[aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") for item_id in item_ids] ) if not responses or any(response.get("success") is False for response in responses): raise BitwardenGetItemError("Failed to get collection items") return [get_list_response_item_from_bitwarden_item(response["data"]) for response in responses] @staticmethod async def get_collection_items( collection_id: str, ) -> list[CredentialItem]: try: master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() await BitwardenService._unlock_using_server(master_password) return await BitwardenService._get_collection_items_using_server(collection_id) except Exception as e: raise e @staticmethod async def _get_collection_items_using_server(collection_id: str) -> list[CredentialItem]: response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/list/object/items?collectionId={collection_id}") if not response or response.get("success") is False: raise BitwardenGetItemError("Failed to get collection items") items = response["data"]["data"] items = map(lambda item: get_list_response_item_from_bitwarden_item(item), items) return list(items) @staticmethod async def get_credential_item( item_id: str, ) -> CredentialItem: try: master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() await BitwardenService._unlock_using_server(master_password) return await BitwardenService._get_credential_item_by_id_using_server(item_id) except Exception as e: raise e @staticmethod async def _get_credential_item_by_id_using_server(item_id: str) -> CredentialItem: response = await aiohttp_get_json(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}") if not response or response.get("success") is False: raise BitwardenGetItemError(f"Failed to get credential item by ID: {item_id}") if response["data"]["type"] == BitwardenItemType.LOGIN: login_item = response["data"]["login"] name = response["data"]["name"] return CredentialItem( item_id=item_id, credential_type=CredentialType.PASSWORD, name=name, credential=PasswordCredential( username=login_item["username"] or "", password=login_item["password"] or "", totp=login_item["totp"], ), ) elif response["data"]["type"] == BitwardenItemType.CREDIT_CARD: credit_card_item = response["data"]["card"] name = response["data"]["name"] return CredentialItem( item_id=item_id, credential_type=CredentialType.CREDIT_CARD, name=name, credential=CreditCardCredential( card_holder_name=credit_card_item["cardholderName"], card_number=credit_card_item["number"], card_exp_month=credit_card_item["expMonth"], card_exp_year=credit_card_item["expYear"], card_cvv=credit_card_item["code"], card_brand=credit_card_item["brand"], ), ) else: raise BitwardenGetItemError(f"Unsupported item type: {response['data']['type']}") @staticmethod async def delete_credential_item( item_id: str, ) -> None: try: master_password, _, _, _ = await BitwardenService._get_skyvern_auth_secrets() await BitwardenService._unlock_using_server(master_password) await BitwardenService._delete_credential_item_using_server(item_id) except Exception as e: raise e @staticmethod async def _delete_credential_item_using_server(item_id: str) -> None: await aiohttp_delete(f"{BITWARDEN_SERVER_BASE_URL}/object/item/{item_id}")