feat(cli): add hyperframes auth OAuth (PKCE + loopback + refresh)#1084
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
2605d59 to
733ea2e
Compare
|
Self-review pass on the OAuth work — addressed 12 of 15 findings. Force-push above includes the fixes. Fixed (high severity):
Fixed (medium severity): Deferred (won't fix in this PR):
Tests: 89 unit tests, all green. Lint/format/typecheck/fallow clean. |
a72b6ac to
5c81d96
Compare
733ea2e to
fa489bf
Compare
5c81d96 to
da2c2ea
Compare
fa489bf to
057c373
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
PKCE implementation looks correct — S256, proper verifier length, timing-safe state comparison, loopback bound to 127.0.0.1 only. The refresh/revocation flows are well thought out, especially preserving the RT on no-rotation refresh. Good stuff.
Two things worth thinking about:
Expired auth code error messaging — In exchangeCodeForTokens, a non-2xx response throws ErrApi which surfaces as a generic "HeyGen API error (400)". If the authorization code expires during the 120s loopback window (or was already used), the user gets a cryptic message after waiting a while. refreshTokens already special-cases 400/401 into REFRESH_FAILED — doing something similar for code exchange would make the failure a lot less confusing.
Concurrent refresh race — Two simultaneous CLI invocations hitting 401 will both try to refresh. The second one will likely get invalid_grant if the server invalidates the old RT on first use. Totally acceptable for a CLI tool, but if token rotation is ever enabled server-side, the second process silently loses its session. A file advisory lock would prevent it, but that's probably overkill for now — just worth being aware of.
Minor observation: assertOAuthConfiguredOrExit can never actually fire in production since DEFAULT_CLIENT_ID is always non-empty. The guard only protects against someone explicitly setting HYPERFRAMES_OAUTH_CLIENT_ID="". Not a problem, just means the OAUTH_NOT_CONFIGURED error path is effectively dead code.
Looks good to me.

What
Adds OAuth 2.0 + PKCE login as the default for
hyperframes auth login,plus refresh-token + 401 auto-retry +
auth refresh. Stacks on top ofPR #1081 (the API-key + shared store work).
hyperframes auth login(no flags) — opens the user's browser to/v1/oauth/authorize, captures the code on an ephemeral127.0.0.1:<port>/oauth/callback, exchanges it for tokens withPKCE S256, and persists.
--api-keyopts back into the legacylong-lived-key path from PR feat(cli): add hyperframes auth login --api-key, status, logout #1081.
hyperframes auth refresh— force-refresh the OAuth access tokenusing the stored refresh_token. Mostly useful for testing the path.
hyperframes auth logout— best-effort revokes viaPOST /v1/oauth/revoke(RFC 7009) before wiping local state.AuthClientnow refreshes-and-retries once on a 401 when thecaller wires
onUnauthenticatedRefresh.auth statuswires it.Internals added in
packages/cli/src/auth/:pkce.ts— RFC 7636 code_verifier + S256 code_challenge.loopback.ts— ephemeral 127.0.0.1 HTTP server; state validation,120s timeout, styled success/error page.
browser.ts— wrapsopenwith aBROWSER=none/HF_NO_BROWSER=1fallback that prints the URL.oauth.ts—startAuthorizationCodeFlow,refreshTokens,revokeTokens,requireOAuthConfigured,parseTokenResponse.Why
This is the foundation OAuth flow that lets free-tier users authenticate
without managing a long-lived key. Refresh + auto-retry means CLI
commands keep working past the access_token lifetime without bugging
the user.
The OAuth client_id (
q2A2QRSke2LrFTPJhoDbHtXh) is the one Jamescreated in the
oauth2_clienttable. Baked in as a build-time default;override via
HYPERFRAMES_OAUTH_CLIENT_IDfor dev/test.How
client_secret. Backend alreadyrequires PKCE (
movio/logic/oauth2.py:638).server.listen(0)) — the backendwildcards localhost ports for public clients
(
movio/model/oauth2.py:check_redirect_uri), so the registeredredirect URI's port is a placeholder.
prevent CSRF.
expires_intype (someservers return it as a string) but strict on
access_tokenpresence.AuthClient.fetchUserlayer, not thecommand layer — so future endpoints inherit it for free.
persistOAuthmerges into the existing store (preserves co-locatedapi_key).auth login(API-key path) does the symmetric thing.Test plan
vitest run src/auth/.method, distinct outputs each call.
404 non-callback paths all rejected; success path captures
code.refreshTokensposts correct body, persists, throwsREFRESH_FAILEDon 400/401 andAPI_ERRORon 5xx. Existingapi_key preserved on refresh.
NOT retry for api_key, returns 401 if refresh hook fails.
bunx oxlint/bunx oxfmt --check/bunx tscclean.bunx fallow audit --base origin/main --fail-on-issues— onlyinherited
help.ts:showUsagefinding (from main, not this PR).HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth loginthen
hyperframes auth statusthenhyperframes auth refresh.Out of scope