Skip to content

Commit 800ded6

Browse files
alexkromanalexkroman-assemblyclaude
authored
feat(auth): browser-based Stytch OAuth login + environment switching (#6)
* feat(auth): add auth package skeleton and endpoint config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): build Stytch B2B OAuth discovery start URL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): add fixed-port loopback callback server * feat(auth): add AMS http client (discover/exchange/auth/projects/tokens) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(auth): satisfy ruff S/BLE lints in auth tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): orchestrate browser+AMS login into an API key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): aai login uses Stytch OAuth, keeps --api-key escape hatch * chore(packaging): rename distribution to aai-cli to avoid PyPI collision The PyPI name "assemblyai-cli" is already taken by a third party, which blocks the clean `uv tool install` / `pipx install` / Homebrew path. Rename the distribution to the available "aai-cli". The `aai` command and the `assemblyai_cli` import package are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: rename package assemblyai_cli → aai_cli Match the new distribution name (aai-cli). Renames the package directory, updates all imports, the `aai` console-script entry point, hatchling wheel packages, mypy files, scripts, and the uv.lock root package name. No behavior change; the keyring service name ("assemblyai-cli") is intentionally preserved to keep already-stored credentials readable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): sandbox Stytch/AMS config + OAuth state-cookie fix Fixes found during end-to-end sandbox testing: - Start B2B OAuth discovery on test.stytch.com (Stytch's API domain), not the vanity *.customers.stytch.dev CNAME, so the CSRF state cookie is set on the same domain as the OAuth callback — fixes `oauth_invalid_state`. - Point at the Stytch project that ams.sandbox000 is bound to (public-token-test-a161155e…) so AMS authenticates the discovery token. - Add env-overridable SIGNUP_URL (sandbox default) and surface it in the friendlier 0-org "no AssemblyAI account yet" message. Verified end-to-end against sandbox: browser login → AMS discover/exchange → find-or-create "AssemblyAI CLI" key → stored in keychain; second login reuses the same key (idempotent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: --env/--sandbox environment switching, bound to profile Add a named-environment registry (production, sandbox000) mapping the full backend host set: api_base (/v2 upload+transcript), streaming_host (/v3/ws), llm_gateway_base (/v1), AMS, and Stytch domain/public-token/signup URL. Select with the global --env <name> / --sandbox flags or AAI_ENV; `aai login` records the resolved env on the profile so a credential and its hosts always stay matched — this structurally prevents the sandbox-key-against-prod-host rejection (HTTP 1008 / 401). Precedence: --env > AAI_ENV > profile > default. client.py (SDK base_url + streaming api_host), llm.py (gateway base), and the auth modules now resolve hosts from the active environment instead of hardcoded prod literals; whoami reports the active env. Default is sandbox000 for now; flip DEFAULT_ENV to production once prod AMS/Stytch values are stood up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(llm): surface LLM Gateway access errors instead of "run aai login" The gateway returns 401/403 both for an invalid key and for a plan-entitlement block ("Your account does not have access to LLM Gateway"). Mapping that to auth_failure() told unpaid accounts to re-run `aai login`, which never helps. Surface the gateway's own message as APIError so the cause is actionable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): harden OAuth login flow and env handling against bad input Address code-review findings in the new browser-OAuth login + environment switching. All changes are defensive; the happy path is unchanged. - loopback: map a port-bind OSError (port 8585 already in use) to a clean APIError instead of letting a raw traceback escape run_login_flow. - flow: tolerate AMS responses that key the token name as `token_name` (not `name`) and skip tokens whose `api_key` the list omits, so an existing CLI token is reused rather than crashing or duplicated. - flow: note the chosen organization when an identity has more than one, instead of silently picking the first. - flow: wrap required response fields in `_require()` so an unexpected 200 shape surfaces as "Run 'aai login' again" rather than a KeyError. - flow: read the account id from the exchange() response instead of a second GET /v1/auth round-trip. - config: map a malformed config.toml (TOMLDecodeError) to a clean CLIError so every command no longer aborts with a traceback. - context/main: warn when an explicit --env contradicts the profile's stored env (the stored key won't authenticate against the other hosts). - ams: extract a shared _client() helper for the five httpx.Client blocks. Adds regression tests for each (TDD). 545 pass, 93% branch coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(packaging): refresh uv.lock after aai-cli rename `uv lock` relocates the editable root-package entry under its new `aai-cli` name; no dependency versions change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: apply ruff 0.15.16 formatting (matches uv.lock) CI runs `ruff format --check` with the locked ruff 0.15.16; reformat the files whose style differs under that version. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Alex Kroman <alex@assemblyai.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ffcf11b commit 800ded6

92 files changed

Lines changed: 1523 additions & 543 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from assemblyai_cli.main import run
1+
from aai_cli.main import run
22

33
if __name__ == "__main__":
44
run()
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
from collections.abc import Callable, Iterator
77
from typing import Any
88

9-
from assemblyai_cli.errors import CLIError
10-
from assemblyai_cli.microphone import _default_rate, _resample, audio_missing_error
9+
from aai_cli.errors import CLIError
10+
from aai_cli.microphone import _default_rate, _resample, audio_missing_error
1111

1212
SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate
1313

@@ -291,5 +291,5 @@ def __iter__(self) -> Iterator[bytes]:
291291
return self._duplex.capture_frames()
292292

293293

294-
# Microphone capture (MicrophoneSource) lives in assemblyai_cli.microphone and is
294+
# Microphone capture (MicrophoneSource) lives in aai_cli.microphone and is
295295
# shared with `aai stream`; the agent's live mic+speaker run through DuplexAudio.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from rich.text import Text
66

7-
from assemblyai_cli.render import BaseRenderer
7+
from aai_cli.render import BaseRenderer
88

99

1010
def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text:
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import threading
77
from typing import Any
88

9-
from assemblyai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure
9+
from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure
1010

1111
WS_URL = "wss://agents.assemblyai.com/v1/ws"
1212

aai_cli/auth/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
from aai_cli.auth.flow import run_login_flow
4+
5+
__all__ = ["run_login_flow"]

aai_cli/auth/ams.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, cast
4+
5+
import httpx
6+
7+
from aai_cli.auth import endpoints
8+
from aai_cli.errors import APIError, NotAuthenticated
9+
10+
_TIMEOUT = 30.0
11+
12+
13+
def _detail(resp: httpx.Response) -> str:
14+
try:
15+
body = resp.json()
16+
if isinstance(body, dict) and "detail" in body:
17+
return str(body["detail"])
18+
except Exception: # noqa: BLE001,S110 - non-JSON error body; pass is intentional
19+
pass
20+
return resp.text or f"HTTP {resp.status_code}"
21+
22+
23+
def _json_or_raise(resp: httpx.Response) -> Any:
24+
if resp.status_code in (401, 403):
25+
raise NotAuthenticated(f"AMS rejected the login ({resp.status_code}): {_detail(resp)}")
26+
if resp.status_code >= 400:
27+
raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}")
28+
return resp.json()
29+
30+
31+
def _client(session_jwt: str | None = None) -> httpx.Client:
32+
"""An AMS HTTP client; pass a session JWT to send the authenticated cookie."""
33+
cookies = {"stytch_session_jwt": session_jwt} if session_jwt else None
34+
return httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies=cookies)
35+
36+
37+
def discover(token: str) -> dict[str, Any]:
38+
"""POST /v2/auth/discover with a discovery_oauth token -> {orgs, email, IST}."""
39+
with _client() as client:
40+
resp = client.post(
41+
"/v2/auth/discover",
42+
json={"token": token, "token_type": "discovery_oauth"},
43+
)
44+
return cast(dict[str, Any], _json_or_raise(resp))
45+
46+
47+
def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]:
48+
"""POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}."""
49+
with _client() as client:
50+
resp = client.post(
51+
"/v2/auth/exchange",
52+
json={
53+
"intermediate_session_token": intermediate_session_token,
54+
"organization_id": organization_id,
55+
},
56+
)
57+
return cast(dict[str, Any], _json_or_raise(resp))
58+
59+
60+
def get_auth(session_jwt: str) -> dict[str, Any]:
61+
"""GET /v1/auth (session cookie) -> account incl. `id`."""
62+
with _client(session_jwt) as client:
63+
resp = client.get("/v1/auth")
64+
return cast(dict[str, Any], _json_or_raise(resp))
65+
66+
67+
def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]:
68+
"""GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}]."""
69+
with _client(session_jwt) as client:
70+
resp = client.get(f"/v1/users/accounts/{account_id}/projects")
71+
return cast(list[dict[str, Any]], _json_or_raise(resp))
72+
73+
74+
def create_token(
75+
account_id: int, project_id: int, token_name: str, session_jwt: str
76+
) -> dict[str, Any]:
77+
"""POST /v1/users/accounts/{id}/tokens -> TokenSchema incl. `api_key`."""
78+
with _client(session_jwt) as client:
79+
resp = client.post(
80+
f"/v1/users/accounts/{account_id}/tokens",
81+
json={"project_id": project_id, "token_name": token_name},
82+
)
83+
return cast(dict[str, Any], _json_or_raise(resp))

aai_cli/auth/discovery.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from urllib.parse import urlencode
4+
5+
from aai_cli.auth import endpoints
6+
7+
8+
def build_start_url() -> str:
9+
"""The Stytch B2B OAuth discovery *start* URL the browser opens.
10+
11+
Client-side endpoint authenticated by the project's public token. After the
12+
user authenticates with the provider, Stytch redirects to our loopback
13+
`discovery_redirect_url` with `?stytch_token_type=discovery_oauth&token=...`.
14+
No custom state is appended: the redirect is exact-match validated and the
15+
server is loopback-only, single-shot.
16+
"""
17+
base = (
18+
f"{endpoints.stytch_domain()}"
19+
f"/v1/b2b/public/oauth/{endpoints.STYTCH_OAUTH_PROVIDER}/discovery/start"
20+
)
21+
params = {
22+
"public_token": endpoints.stytch_public_token(),
23+
"discovery_redirect_url": endpoints.redirect_uri(),
24+
}
25+
return f"{base}?{urlencode(params)}"

0 commit comments

Comments
 (0)