Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/basic_memory/cli/commands/cloud/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,26 @@ def get_cloud_config() -> tuple[str, str, str]:

async def get_authenticated_headers(auth: CLIAuth | None = None) -> dict[str, str]:
"""
Get authentication headers with JWT token.
handles jwt refresh if needed.
Get authentication headers for cloud API requests.

Credential priority mirrors async_client._resolve_cloud_token():
1. API key (config.cloud_api_key) — fast, no refresh needed
2. OAuth token via CLIAuth — handles JWT refresh automatically
"""
# --- API key (preferred) ---
config_manager = ConfigManager()
api_key = config_manager.config.cloud_api_key
if api_key:
return {"Authorization": f"Bearer {api_key}"}
Comment on lines +57 to +58

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor explicit OAuth auth before API-key fallback

The unconditional API-key early return means get_authenticated_headers(auth=...) ignores the caller-provided OAuth context whenever cloud_api_key is set. In practice this breaks OAuth recovery flows (for example the post-login health check in core_commands.py) when a saved API key is stale or mistyped, because requests keep using the bad key even after a successful OAuth login. This regression was introduced by the new priority logic and can block users from recovering via bm cloud login.

Useful? React with 👍 / 👎.


# --- OAuth fallback ---
client_id, domain, _ = get_cloud_config()
auth_obj = auth or CLIAuth(client_id=client_id, authkit_domain=domain)
token = await auth_obj.get_valid_token()
if not token:
console.print("[red]Not authenticated. Please run 'bm cloud login' first.[/red]")
console.print(
"[red]Not authenticated. Run 'bm cloud set-key <key>' or 'bm cloud login' first.[/red]"
)
raise typer.Exit(1)

return {"Authorization": f"Bearer {token}"}
Expand Down
68 changes: 68 additions & 0 deletions tests/cli/cloud/test_cloud_api_client_and_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,71 @@ async def api_request(**kwargs):
assert created.new_project["name"] == "My Project"
# Path should be permalink-like (kebab)
assert seen["create_payload"]["path"] == "my-project"


@pytest.mark.asyncio
async def test_make_api_request_prefers_api_key_over_oauth(config_home, config_manager):
"""API key in config should be used without needing an OAuth token on disk."""
# Arrange: set an API key in config, no OAuth token on disk
config = config_manager.load_config()
config.cloud_api_key = "bmc_test_key_12345"
config_manager.save_config(config)

async def handler(request: httpx.Request) -> httpx.Response:
# Verify the API key is sent as the Bearer token
assert request.headers.get("authorization") == "Bearer bmc_test_key_12345"
return httpx.Response(200, json={"ok": True})

transport = httpx.MockTransport(handler)

@asynccontextmanager
async def http_client_factory():
async with httpx.AsyncClient(transport=transport) as client:
yield client

# Act — no auth= parameter, no OAuth token file; should use API key from config
resp = await make_api_request(
method="GET",
url="https://cloud.example.test/proxy/health",
http_client_factory=http_client_factory,
)

# Assert
assert resp.json()["ok"] is True


@pytest.mark.asyncio
async def test_make_api_request_falls_back_to_oauth_when_no_api_key(config_home, config_manager):
"""When no API key is configured, should fall back to OAuth token."""
# Arrange: no API key, but OAuth token on disk
config = config_manager.load_config()
config.cloud_api_key = None
config_manager.save_config(config)

auth = CLIAuth(client_id="cid", authkit_domain="https://auth.example.test")
auth.token_file.parent.mkdir(parents=True, exist_ok=True)
auth.token_file.write_text(
'{"access_token":"oauth-token-456","refresh_token":null,'
'"expires_at":9999999999,"token_type":"Bearer"}',
encoding="utf-8",
)

async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers.get("authorization") == "Bearer oauth-token-456"
return httpx.Response(200, json={"ok": True})

transport = httpx.MockTransport(handler)

@asynccontextmanager
async def http_client_factory():
async with httpx.AsyncClient(transport=transport) as client:
yield client

resp = await make_api_request(
method="GET",
url="https://cloud.example.test/proxy/health",
auth=auth,
http_client_factory=http_client_factory,
)

assert resp.json()["ok"] is True
Loading