Skip to content

feat(auth): browser-based Stytch OAuth login + environment switching#6

Merged
alexkroman merged 15 commits into
mainfrom
stytch-oauth-cli-login
Jun 4, 2026
Merged

feat(auth): browser-based Stytch OAuth login + environment switching#6
alexkroman merged 15 commits into
mainfrom
stytch-oauth-cli-login

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

Summary

Replaces the paste-your-API-key login with a browser-based Stytch B2B OAuth
flow, and adds --env/--sandbox environment switching bound to each profile.
aai login now opens the browser, captures the OAuth callback on a fixed
loopback port, discovers the account via AMS, and mints/reuses an
AssemblyAI CLI API key — with --api-key retained as a non-interactive
escape hatch for CI.

Also renames the package/distribution assemblyai_cli → aai_cli / aai-cli
(the PyPI name assemblyai-cli is squatted).

What's new

  • aai_cli/auth/ — OAuth login flow:
    • discovery.py builds the Stytch B2B OAuth discovery start URL
    • loopback.py binds a fixed-port loopback server to capture one callback
    • ams.py is the Accounts Management Service client (discover / exchange / auth / projects / tokens)
    • flow.py orchestrates browser → AMS → API key
  • aai_cli/environments.py — per-environment host bundles (production / sandbox000), selected via --env, --sandbox, AAI_ENV, or the profile's stored env; default is sandbox000 until prod AMS/Stytch is stood up.
  • aai_cli/context.pyAppState + environment/profile resolution; warns when an explicit --env contradicts the profile's bound env.
  • LLM Gateway 401/403 now surfaces the gateway's own message (e.g. plan entitlement) instead of a misleading "run aai login".

Hardening (from code review)

  • Port-in-use on the callback server → clean APIError, not a raw traceback.
  • AMS responses: tolerate token_name vs name, skip tokens without an exposed api_key, and map unexpected 200 shapes to a clean "run login again" error instead of KeyError.
  • Multi-org identities note which organization is selected.
  • Malformed config.toml → clean CLIError instead of a TOMLDecodeError traceback on every command.

Note

Loopback OAuth CSRF (a state nonce) was intentionally deferred: the only robust fix appends a query param to discovery_redirect_url, which depends on Stytch preserving extra query params rather than strict-exact-matching the registered redirect. To confirm before implementing.

Test plan

  • scripts/check.sh — 545 unit tests pass, 93% branch coverage (gate 90%), ruff + mypy clean.
  • New regression tests cover the auth flow, loopback, environments, config error handling, and env/profile binding.
  • E2E (-m e2e) require a live key + kokoro and are not run here.

🤖 Generated with Claude Code

alexkroman-assembly and others added 15 commits June 4, 2026 11:50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
`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>
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>
@alexkroman alexkroman merged commit 800ded6 into main Jun 4, 2026
5 checks passed
@alexkroman alexkroman deleted the stytch-oauth-cli-login branch June 4, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants