diff --git a/README.md b/README.md index c2bb478..df06332 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ hai sessions watch 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 diff --git a/src/hai_agents_cli/app.py b/src/hai_agents_cli/app.py index 196dc51..2d3dd7c 100644 --- a/src/hai_agents_cli/app.py +++ b/src/hai_agents_cli/app.py @@ -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()})" @@ -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: diff --git a/src/hai_agents_cli/auth.py b/src/hai_agents_cli/auth.py index d83e011..f8c1a2c 100644 --- a/src/hai_agents_cli/auth.py +++ b/src/hai_agents_cli/auth.py @@ -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 @@ -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) @@ -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")) + + +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: diff --git a/tests/test_credentials.py b/tests/test_credentials.py index d753833..ee4eeee 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -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