Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ hai sessions watch <session-id>
hai mcp install
```

`hai login` signs in through the browser with Google and stores a key in `~/.config/hai/.env`. Without a Google account or a browser, create a key at [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys) and run `hai login --key`. `hai mcp install` adds the hai-agents MCP server to Cursor, VS Code, Claude Code, and other MCP clients. Credentials resolve from `--api-key`, then `HAI_API_KEY`, then a local `.env`, then `~/.config/hai/.env`. Run `hai --help` for the full command set.
`hai login` signs in through the browser, with Google or with email and password, and stores a key in `~/.config/hai/.env`. Without a browser, create a key at [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys) and run `hai login --key`. `hai mcp install` adds the hai-agents MCP server to Cursor, VS Code, Claude Code, and other MCP clients. Credentials resolve from `--api-key`, then `HAI_API_KEY`, then a local `.env`, then `~/.config/hai/.env`. Run `hai --help` for the full command set.

## Documentation

Expand Down
12 changes: 9 additions & 3 deletions src/hai_agents_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def login(
if credentials.current_api_key() and not force:
console.print("Already signed in. Pass --force to rotate the key.")
return
if not sys.stdin.isatty():
if not _interactive():
_raise_cli_error(RuntimeError(f"login needs an interactive terminal and a browser. {auth.KEY_FALLBACK}"))

label = f"hai CLI ({socket.gethostname()})"
Expand All @@ -113,8 +113,14 @@ def login(
)
except Exception as exc:
_raise_cli_error(exc)
path = credentials.save_api_key(minted)
console.print(f"Signed in. Wrote {credentials.API_KEY_VAR} to {path}.")
path = credentials.save_api_key(minted.key)
# Names only, never ids: a key minted into the wrong account must be visible at a glance.
where = f" in organization {escape(minted.organization)}" if minted.organization else ""
console.print(f"Signed in as {escape(minted.email)}{where}. Wrote {credentials.API_KEY_VAR} to {path}.")


def _interactive() -> bool:
return sys.stdin.isatty()


def _store_pasted_key(base_url: str | None) -> None:
Expand Down
68 changes: 50 additions & 18 deletions src/hai_agents_cli/auth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
"""Browser sign-in: RFC 8252 loopback redirect + PKCE, then mint an API key."""
"""Browser sign-in: RFC 8252 loopback redirect + PKCE, then mint an API key.

No provider is named: the portal signs our PKCE state and sends the browser to
the platform login page, so any account type finishes back on our loopback.
"""

from __future__ import annotations

import base64
import contextlib
import dataclasses
import hashlib
import http.server
import secrets
Expand All @@ -19,22 +25,27 @@
from .login_pages import ERROR_HTML, SUCCESS_HTML

SIGN_IN_TIMEOUT_S = 180
KEY_FALLBACK = (
f"Browser sign-in works with Google accounts. Otherwise create a key at {API_KEYS_PAGE} and run `hai login --key`."
)
KEY_FALLBACK = f"Without a browser, create a key at {API_KEYS_PAGE} and run `hai login --key`."


class PortalError(RuntimeError):
"""A portal request failed; the message is the portal's own explanation."""


def login_and_mint(portal: str, label: str, on_open: typing.Callable[[str], None]) -> str:
@dataclasses.dataclass(frozen=True)
class SignedIn:
key: str
email: str
organization: typing.Optional[str] # name only, never an id


def login_and_mint(portal: str, label: str, on_open: typing.Callable[[str], None]) -> SignedIn:
"""Run the full browser sign-in and return a freshly minted API key."""
verifier, challenge = _pkce_pair()
redirect_uri = _free_redirect_uri()
authorize_url = (
f"{portal}/api/auth/authorize?provider=google"
f"&redirect_uri={urllib.parse.quote(redirect_uri, safe='')}"
f"{portal}/api/auth/authorize"
f"?redirect_uri={urllib.parse.quote(redirect_uri, safe='')}"
f"&code_challenge={challenge}&code_challenge_method=S256"
)
on_open(authorize_url)
Expand All @@ -51,17 +62,38 @@ def login_and_mint(portal: str, label: str, on_open: typing.Callable[[str], None
)
except PortalError as exc:
raise PortalError(f"sign-in failed: {exc} {KEY_FALLBACK}") from None
client.headers["Authorization"] = f"Bearer {token.json()['access_token']}"

me = _ok(client.get(f"{portal}/api/auth/me")).json()
org_id = me.get("org_id") or (me.get("organization") or {}).get("id")
if not org_id:
owned = _ok(client.get(f"{portal}/api/organizations/owned")).json()
if not owned:
raise RuntimeError("no organization is available to mint a key against.")
org_id = owned[0]["id"]

return _mint_key(client, portal, org_id, label)["key"]
body = token.json()
client.headers["Authorization"] = f"Bearer {body['access_token']}"
return _mint_for_signed_in_user(client, portal, label, session_id=body.get("session_id"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Session revoke never receives session id

High Severity

The exchange POST never sends X-SDK-Auth, so portal session_id stays on the cookie and body.get("session_id") is empty. The later session DELETE is skipped, and the web session created by sign-in remains valid even though the API key is supposed to be the only leftover credential.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 89b1346. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Quoting Fable:

Re "Session revoke never receives session id": checked against portal-h domains/auth/controller.py, desktop_exchange returns DesktopExchangeOutput(access_token, refresh_token, session_id) as the JSON body; it is not the cookie-mode login response, so X-SDK-Auth does not apply and body.get("session_id") is populated. The revoke runs. Covered by test_minting_names_the_identity_and_revokes_the_web_session. No change.



def _mint_for_signed_in_user(
client: httpx.Client, portal: str, label: str, session_id: typing.Optional[str] = None
) -> SignedIn:
"""Mint a key for the signed-in user, then revoke the web session: the key is the credential."""
me = _ok(client.get(f"{portal}/api/auth/me")).json()
email = me.get("email") or (me.get("user") or {}).get("email") or "unknown"
org_id = me.get("org_id") or (me.get("organization") or {}).get("id")
if not org_id:
owned = _ok(client.get(f"{portal}/api/organizations/owned")).json()
if not owned:
raise RuntimeError("no organization is available to mint a key against.")
org_id = owned[0]["id"]
key = _mint_key(client, portal, org_id, label)["key"]
organization = _organization_name(client, portal, org_id)
if session_id:
with contextlib.suppress(httpx.HTTPError):
client.delete(f"{portal}/api/auth/sessions/{session_id}")
return SignedIn(key=key, email=str(email), organization=organization)


def _organization_name(client: httpx.Client, portal: str, org_id: str) -> typing.Optional[str]:
with contextlib.suppress(httpx.HTTPError, ValueError, TypeError):
orgs = client.get(f"{portal}/api/organizations/").json()
for org in orgs if isinstance(orgs, list) else []:
if isinstance(org, dict) and str(org.get("id")) == str(org_id) and org.get("name"):
return str(org["name"])
return None


def _ok(response: httpx.Response) -> httpx.Response:
Expand Down
74 changes: 74 additions & 0 deletions tests/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,77 @@ def get_session_quota(self):

def _error_text(result) -> str:
return "\n".join(part for part in (result.output, result.stderr, str(result.exception)) if part)


def test_browser_sign_in_lets_the_platform_pick_the_method(monkeypatch):
"""No provider hint: the portal sends the browser to the platform login page, where every method works."""
opened: list[str] = []
monkeypatch.setattr(auth.webbrowser, "open", lambda url: opened.append(url))

def _abort(redirect_uri, *args):
raise RuntimeError("stop before serving the loopback")

monkeypatch.setattr(auth, "_await_code", _abort)
with pytest.raises(RuntimeError, match="stop before"):
auth.login_and_mint("https://portal.test", "lbl", lambda url: None)

assert len(opened) == 1
query = httpx.URL(opened[0]).params
assert httpx.URL(opened[0]).path == "/api/auth/authorize"
assert "provider" not in query
assert query["code_challenge_method"] == "S256" and query["redirect_uri"].startswith("http://127.0.0.1:")


def test_minting_names_the_identity_and_revokes_the_web_session():
calls: list = []

def handle(request: httpx.Request) -> httpx.Response:
calls.append((request.method, request.url.path))
if request.url.path == "/api/auth/me":
return httpx.Response(200, json={"user": {"email": "me@example.com"}, "org_id": "org-1"})
if request.url.path == "/api/organizations/":
return httpx.Response(200, json=[{"id": "org-1", "name": "Acme"}])
if request.url.path == "/api/organizations/org-1/keys/":
return httpx.Response(200, json={"id": "k1", "key": "hk-minted"})
if request.url.path == "/api/auth/sessions/web-session":
return httpx.Response(204)
return httpx.Response(404, json={"detail": "unexpected"})

with httpx.Client(transport=httpx.MockTransport(handle), headers={"Authorization": "Bearer jwt"}) as client:
signed_in = auth._mint_for_signed_in_user(client, "https://portal.test", "lbl", session_id="web-session")

assert signed_in == auth.SignedIn(key="hk-minted", email="me@example.com", organization="Acme")
assert ("DELETE", "/api/auth/sessions/web-session") in calls
assert calls.index(("POST", "/api/organizations/org-1/keys/")) < calls.index(
("DELETE", "/api/auth/sessions/web-session")
)


def test_minting_survives_a_failed_session_revoke():
def handle(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/auth/me":
return httpx.Response(200, json={"email": "me@example.com", "org_id": "org-1"})
if request.url.path == "/api/organizations/org-1/keys/":
return httpx.Response(200, json={"id": "k1", "key": "hk-minted"})
if request.url.path == "/api/organizations/":
return httpx.Response(404, json={"detail": "nope"})
raise httpx.ConnectError("portal gone")

with httpx.Client(transport=httpx.MockTransport(handle)) as client:
signed_in = auth._mint_for_signed_in_user(client, "https://portal.test", "lbl", session_id="s")

assert signed_in.key == "hk-minted" and signed_in.organization is None


def test_identity_line_never_shows_an_org_id(monkeypatch):
monkeypatch.setattr(app_module, "_interactive", lambda: True)
monkeypatch.setattr(
app_module.auth, "login_and_mint", lambda *a, **k: auth.SignedIn("hk-x", "me@example.com", None)
)

result = runner.invoke(app, ["login"])
monkeypatch.delenv(credentials.API_KEY_VAR, raising=False)

assert result.exit_code == 0, _error_text(result)
assert "Signed in as me@example.com." in result.output
assert "organization" not in result.output
Loading