diff --git a/README.md b/README.md index c2bb478..936f6d7 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 with Google in the browser or with email and password (`hai login --email you@example.com` skips the question) and stores a key in `~/.config/hai/.env`. To use a key you already created at [platform.hcompany.ai/settings/api-keys](https://platform.hcompany.ai/settings/api-keys), 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..c5596f4 100644 --- a/src/hai_agents_cli/app.py +++ b/src/hai_agents_cli/app.py @@ -86,14 +86,20 @@ def configure( def login( ctx: typer.Context, force: bool = typer.Option(False, "--force", help="Re-authenticate and rotate the stored key."), + email: str | None = typer.Option( + None, + "--email", + metavar="EMAIL", + help="Sign in with this email and a password (asked on a hidden prompt) instead of the browser.", + ), key: bool = typer.Option( False, "--key", - help=f"Store an existing API key (from {credentials.API_KEYS_PAGE}) instead of signing in through the browser. " + help=f"Store an existing API key (from {credentials.API_KEYS_PAGE}) instead of signing in. " "Reads it from a hidden prompt, or from stdin when piped.", ), ) -> None: - """Sign in through the browser and store an API key in ~/.config/hai/.env.""" + """Sign in with Google in the browser or with email and password, and store an API key in ~/.config/hai/.env.""" state = _state(ctx) if key: _store_pasted_key(state.base_url) @@ -101,20 +107,44 @@ 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(): - _raise_cli_error(RuntimeError(f"login needs an interactive terminal and a browser. {auth.KEY_FALLBACK}")) + if not _interactive(): + _raise_cli_error(RuntimeError(f"login needs an interactive terminal. {auth.KEY_FALLBACK}")) + portal = credentials.portal_base(state.base_url) label = f"hai CLI ({socket.gethostname()})" try: - minted = auth.login_and_mint( - credentials.portal_base(state.base_url), - label, - lambda url: console.print(f"Opening your browser. If it does not open, visit:\n {url}", style="dim"), - ) + if email is None: + email = _ask_sign_in_method() + if email is None: + minted = auth.login_and_mint( + portal, + label, + lambda url: console.print(f"Opening your browser. If it does not open, visit:\n {url}", style="dim"), + ) + else: + password = typer.prompt("Password", hide_input=True) + minted = auth.login_with_password( + portal, label, email, password, ask_code=lambda: typer.prompt("Authentication code") + ) 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 _ask_sign_in_method() -> str | None: + """The email to sign in with, or None to sign in with Google in the browser.""" + console.print("How do you sign in to H?\n 1. Google account, in your browser\n 2. Email and password") + choice = typer.prompt("Sign-in method", default="1").strip() + if choice == "2": + return typer.prompt("Email").strip() + return None 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..9bf0510 100644 --- a/src/hai_agents_cli/auth.py +++ b/src/hai_agents_cli/auth.py @@ -1,8 +1,10 @@ -"""Browser sign-in: RFC 8252 loopback redirect + PKCE, then mint an API key.""" +"""Sign in to the portal (browser + PKCE, or email + password with optional TOTP), then mint an API key.""" from __future__ import annotations import base64 +import contextlib +import dataclasses import hashlib import http.server import secrets @@ -20,15 +22,24 @@ 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`." + "Browser sign-in needs a Google account; email and password accounts sign in with `hai login --email you@example.com`. " + f"Or create a key at {API_KEYS_PAGE} and run `hai login --key`." ) +SDK_AUTH_HEADERS = {"X-SDK-Auth": "true"} # tokens in the JSON body instead of cookies 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() @@ -51,17 +62,72 @@ 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 login_with_password( + portal: str, + label: str, + email: str, + password: str, + ask_code: typing.Callable[[], str], + transport: typing.Optional[httpx.BaseTransport] = None, +) -> SignedIn: + """Email + password login (TOTP code when asked), then mint a key.""" + with httpx.Client(timeout=20.0, transport=transport) as client: + credentials = {"email": email.strip(), "password": password} + body = _ok(client.post(f"{portal}/api/auth/token", json=credentials, headers=SDK_AUTH_HEADERS)).json() + if body.get("mfa_required"): + body = _ok( + client.post( + f"{portal}/api/auth/token-mfa", + json={**credentials, "code": ask_code().strip()}, + headers=SDK_AUTH_HEADERS, + ) + ).json() + client.headers["Authorization"] = f"Bearer {_body_field(body, 'access_token')}" + return _mint_for_signed_in_user(client, portal, label, session_id=_body_field(body, "session_id", None)) + + +def _body_field(body: typing.Mapping[str, typing.Any], name: str, default: typing.Any = ...) -> typing.Any: + """Body keys are cookie-prefixed per environment: ``access_token``, ``staging_access_token``, ...""" + for key, value in body.items(): + if key.endswith(name) and isinstance(value, str) and value: + return value + if default is not ...: + return default + raise PortalError(f"the portal did not return {name} for this login.") + + +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..cbe7608 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -125,3 +125,204 @@ 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 _portal_transport(calls: list, *, mfa: bool = False, wrong_password: bool = False) -> httpx.MockTransport: + """A fake portal: token (+ MFA) login, /me, and key minting. Records every request.""" + + def handle(request: httpx.Request) -> httpx.Response: + calls.append((request.method, request.url.path, request.headers.get("X-SDK-Auth"), request.content)) + if request.url.path == "/api/auth/token": + if wrong_password: + return httpx.Response( + 401, json={"title": "not_authorized", "detail": "Incorrect username or password."} + ) + if mfa: + return httpx.Response( + 200, json={"success": True, "mfa_required": True, "message": "MFA challenge required"} + ) + return httpx.Response( + 200, json={"success": True, "access_token": "jwt-plain", "refresh_token": "r", "session_id": "s"} + ) + if request.url.path == "/api/auth/token-mfa": + return httpx.Response(200, json={"success": True, "staging_access_token": "jwt-mfa"}) + 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.method == "DELETE" and request.url.path.startswith("/api/auth/sessions/"): + return httpx.Response(204) + if request.url.path == "/api/organizations/org-1/keys/": + return httpx.Response( + 200, json={"id": "k1", "key": "hk-minted-" + request.headers["Authorization"].split()[1]} + ) + return httpx.Response(404, json={"detail": "unexpected"}) + + return httpx.MockTransport(handle) + + +def test_password_login_mints_a_key_in_sdk_mode(): + calls: list = [] + + signed_in = auth.login_with_password( + "https://portal.test", + "lbl", + " me@example.com ", + "pw", + ask_code=lambda: "000000", + transport=_portal_transport(calls), + ) + + assert signed_in == auth.SignedIn(key="hk-minted-jwt-plain", email="me@example.com", organization="Acme") + method, path, sdk_header, body = calls[0] + assert (method, path, sdk_header) == ("POST", "/api/auth/token", "true") + assert b'"email":"me@example.com"' in body # trimmed + assert [c[1] for c in calls] == [ + "/api/auth/token", + "/api/auth/me", + "/api/organizations/org-1/keys/", + "/api/organizations/", + "/api/auth/sessions/s", # the web session is revoked once the key exists + ] + + +def test_password_login_answers_the_mfa_challenge(): + calls: list = [] + asked = [] + + def ask_code() -> str: + asked.append(True) + return " 123456 " + + signed_in = auth.login_with_password( + "https://portal.test", + "lbl", + "me@example.com", + "pw", + ask_code=ask_code, + transport=_portal_transport(calls, mfa=True), + ) + + assert signed_in.key == "hk-minted-jwt-mfa" # the env-prefixed token key is still found + assert asked == [True] + assert calls[1][1] == "/api/auth/token-mfa" and b'"code":"123456"' in calls[1][3] + + +def test_password_login_surfaces_the_portal_reason(): + with pytest.raises(auth.PortalError, match="Incorrect username or password"): + auth.login_with_password( + "https://portal.test", + "lbl", + "me@example.com", + "nope", + ask_code=lambda: "", + transport=_portal_transport([], wrong_password=True), + ) + + +def test_login_email_option_goes_straight_to_the_password_prompt(monkeypatch): + seen = {} + + def _fake(portal, label, email, password, ask_code): + seen.update(email=email, password=password) + return auth.SignedIn("hk-from-password", "me@example.com", "Acme") + + monkeypatch.setattr(app_module, "_interactive", lambda: True) + monkeypatch.setattr(app_module.auth, "login_with_password", _fake) + + result = runner.invoke(app, ["login", "--email", "me@example.com"], input="s3cret\n") + monkeypatch.delenv(credentials.API_KEY_VAR, raising=False) + + assert result.exit_code == 0, _error_text(result) + assert seen == {"email": "me@example.com", "password": "s3cret"} + assert "hk-from-password" in credentials.GLOBAL_ENV_PATH.read_text() + assert "me@example.com" in result.output and "Acme" in result.output + + +def test_login_asks_the_method_and_takes_email_and_password(monkeypatch): + seen = {} + monkeypatch.setattr(app_module, "_interactive", lambda: True) + monkeypatch.setattr( + app_module.auth, + "login_with_password", + lambda p, l, email, password, ask_code: seen.update(email=email) or auth.SignedIn("hk-x", email, "Acme"), + ) + monkeypatch.setattr(app_module.auth, "login_and_mint", lambda *a, **k: pytest.fail("browser flow must not run")) + + result = runner.invoke(app, ["login"], input="2\nme@example.com\npw\n") + monkeypatch.delenv(credentials.API_KEY_VAR, raising=False) + + assert result.exit_code == 0, _error_text(result) + assert "Email and password" in result.output + assert seen == {"email": "me@example.com"} + + +def test_login_default_choice_is_the_browser(monkeypatch): + monkeypatch.setattr(app_module, "_interactive", lambda: True) + monkeypatch.setattr( + app_module.auth, "login_and_mint", lambda *a, **k: auth.SignedIn("hk-from-browser", "me@example.com", "Acme") + ) + monkeypatch.setattr( + app_module.auth, "login_with_password", lambda *a, **k: pytest.fail("password flow must not run") + ) + + result = runner.invoke(app, ["login"], input="\n") + monkeypatch.delenv(credentials.API_KEY_VAR, raising=False) + + assert result.exit_code == 0, _error_text(result) + assert "hk-from-browser" in credentials.GLOBAL_ENV_PATH.read_text() + + +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 calls.index(("POST", "/api/organizations/org-1/keys/")) < calls.index( + ("DELETE", "/api/auth/sessions/web-session") + ) + + +def test_minting_survives_a_failed_session_revoke_and_unreadable_org_list(): + 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) + ) + monkeypatch.setattr(app_module.auth, "login_with_password", lambda *a, **k: pytest.fail("not this flow")) + + result = runner.invoke(app, ["login"], input="\n") + 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.split("Signed in as")[1]