From 7534964c131f8e84f6aa6b084f36d4089e742e3e Mon Sep 17 00:00:00 2001 From: TSYS Group COO Date: Thu, 13 Aug 2026 12:14:16 -0500 Subject: [PATCH] fix: handle 2FA on the Bitwarden account during API login [#442] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bw login --apikey prompts for a TOTP code when 2FA is enabled on the BW account. The previous code didn't pass one, so it would hang or fail. Now generates a TOTP from BW_TOTP_SECRET and passes via --code. Changes: - BitwardenHelper.__init__ accepts totp_secret param - login() generates a pyotp code and passes --code when secret is set - provision-agent.py passes BW_TOTP_SECRET from environment - docker-compose.yml and .env.example updated for the new var - BW_PASSWORD removed from the login env (only needed for unlock via stdin) The BW account's own TOTP secret lives in ~/.config/bw/env alongside the other BW access info — the one exception (can't store BW's 2FA in BW itself). 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- .env.example | 3 +++ bw-helper.py | 21 +++++++++++++++++---- docker-compose.yml | 1 + provision-agent.py | 1 + 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 028d760..e9ddc6e 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,8 @@ BW_CLIENTID= BW_CLIENTSECRET= BW_PASSWORD= +# TOTP secret for the BW account's own 2FA (required if 2FA is enabled) +BW_TOTP_SECRET= + # Set to true for debugging (shows browser window — requires display) HEADFUL=false diff --git a/bw-helper.py b/bw-helper.py index 22cb24e..ee4b537 100644 --- a/bw-helper.py +++ b/bw-helper.py @@ -22,10 +22,11 @@ from typing import Optional class BitwardenHelper: """Wrapper around the Bitwarden CLI for credential management.""" - def __init__(self, client_id: str, client_secret: str, password: str): + def __init__(self, client_id: str, client_secret: str, password: str, totp_secret: str = ""): self.client_id = client_id self.client_secret = client_secret self.password = password + self.totp_secret = totp_secret self.session: Optional[str] = None def _run_bw(self, args: list[str], capture: bool = True) -> str: @@ -46,17 +47,29 @@ class BitwardenHelper: return result.stdout.strip() if capture else "" def login(self) -> None: - """Authenticate via API key and unlock the vault.""" + """Authenticate via API key and unlock the vault. + + If 2FA is enabled on the account, generates a TOTP code from + self.totp_secret and passes it via --code. + """ env = os.environ.copy() env["BW_CLIENTID"] = self.client_id env["BW_CLIENTSECRET"] = self.client_secret - env["BW_PASSWORD"] = self.password + + login_cmd = ["bw", "login", "--apikey"] + login_input = "" + + if self.totp_secret: + import pyotp + totp_code = pyotp.TOTP(self.totp_secret).now() + login_cmd += ["--code", totp_code] result = subprocess.run( - ["bw", "login", "--apikey"], + login_cmd, capture_output=True, text=True, env=env, + input=login_input, ) if result.returncode != 0 and "already" not in result.stderr.lower(): raise RuntimeError(f"BW login failed: {result.stderr.strip()}") diff --git a/docker-compose.yml b/docker-compose.yml index 2ea034a..66e15d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: - BW_CLIENTID=${BW_CLIENTID} - BW_CLIENTSECRET=${BW_CLIENTSECRET} - BW_PASSWORD=${BW_PASSWORD} + - BW_TOTP_SECRET=${BW_TOTP_SECRET:-} - HEADFUL=${HEADFUL:-false} volumes: - ./agents.yaml:/app/agents.yaml:ro diff --git a/provision-agent.py b/provision-agent.py index 1621c3b..c9d9712 100644 --- a/provision-agent.py +++ b/provision-agent.py @@ -619,6 +619,7 @@ def main(): client_id=os.environ["BW_CLIENTID"], client_secret=os.environ["BW_CLIENTSECRET"], password=os.environ["BW_PASSWORD"], + totp_secret=os.environ.get("BW_TOTP_SECRET", ""), ) log.info("Connecting to Bitwarden...") bw.login()