feat(auth): add device authorization login - #247
Conversation
e543d9c to
5d52724
Compare
72dd664 to
1933258
Compare
somanshreddy
left a comment
There was a problem hiding this comment.
Independent review — LGTM. Traced the RFC 8628 device flow with a security lens; it's well-built.
Poll loop is correct. PollDeviceToken: ""→return token, authorization_pending→continue, slow_down→delay += 5s, default→terminal DeviceAuthorizationError. Matches RFC 8628 §3.5. Interval floors to 5s when the server sends ≤0; delay caps at maxDevicePollDelay (60s); IssuedAt is captured before the request (accurate token expiry).
Expiry is conservative and correct. deadline = Now() + min(expiresIn, maxDeviceLifetime), and the loop bails with expired_token when Now()+delay would cross the deadline — so it never polls past expiry. Context-cancelable Sleep.
Security posture is solid. Verification URI restricted to HTTPS (loopback HTTP only for tests) — blocks a malicious authz server from phishing the user to http://. Errors are redacted (decodeRedactedDeviceError drops error_description, so a buggy/hostile server can't inject content into CLI output). device_code/user_code are documented as never logged or in errors. Responses are LimitReader(64KB)-bounded. Token persisted 0600 in a 0700 dir, preserving a co-located api_key.
Parity with the HF-CLI companion (#2836) holds on the load-bearing bits: slow_down +5s, HTTPS-only verification URI, redacted errors, secret hygiene. Nothing blocking.
— Somu
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
🟢 LGTM from my side — this is a careful, well-scoped RFC 8628 implementation. The credential-minting boundary invariants the PR body promises hold up under a trace: identity verification gates persistence, both minted tokens are revoked on any verification-or-write failure via a deduped access+refresh sweep, the credential file goes through writeCredentialsFile's temp+rename atomic write, and the jsonCredentials.extra bag preserves foreign top-level and per-sub-object keys so a hyperframes-written OAuth block round-trips unmolested. Bounded reads (64 KiB on device/token, 1 MiB on /v3/users/me), redacted 400/401 handling that drops error_description on principle, and a fixed-vocabulary analytics mapper on the RFC 6749 §5.2 error codes — all in the right places.
Leaving this as a comment — Miguel/team should merge; concerns below are worth logging but don't gate.
Peer scan
Somu (somanshreddy) posted an independent COMMENTED review at HEAD 1933258 about four minutes before mine (review). They traced the poll loop, expiry math, URI scheme restriction, error redaction, and file mode — all correct at HEAD. I'm not re-deriving those; they check out. Somu also stated "Parity with the HF-CLI companion (#2836) holds on the load-bearing bits." My cross-repo grep against heygen-com/hyperframes#2836 surfaces two implementation asymmetries Somu's review didn't reach into; neither is load-bearing security, but they're worth logging.
Hardest look — the credential-minting-boundary checklist
BREADTH — Scope on the device-authorization request is oauth.DefaultScopes = "openid profile email" (internal/auth/oauth/oauth.go:25), which is appropriately narrow. Resource is ctx.configProvider.BaseURL() (default https://api.heygen.com) trimmed of trailing /. The client does not verify that the server-returned scope matches what was requested — an IdP that returns a broader scope is silently accepted and persisted. Standard practice for OAuth clients, and the server is authoritative for scope grants regardless; flagging for completeness, not as a defect.
ALLOWLIST — Production endpoints are compile-time constants (internal/auth/oauth/oauth.go:32-36). The injection surface for DEV certification is the HEYGEN_OAUTH_CLIENT_ID / HEYGEN_OAUTH_DEVICE_URL / HEYGEN_OAUTH_TOKEN_URL / HEYGEN_OAUTH_REVOKE_URL env vars, read unconditionally in deviceLoginConfigFromEnvironment (cmd/heygen/auth_login.go:544-560) — no --dev gate, no printed warning when any of the four is set. Threat model is bounded (an attacker who can set env vars in a user's shell has broader local access already, and the returned verification_uri goes through safeVerificationURI), but a one-line stderr warning of the form "using non-default OAuth endpoints from HEYGEN_OAUTH_… env" whenever any of the four is non-empty would (a) make a mis-set env var visible to the user during audit and (b) give ops a printed breadcrumb if someone runs heygen auth login --device inside a compromised shell. Non-blocking; a defensive nicety.
ASYMMETRY (client-side vs. server-side) — This is the one bit I'd like to flag more clearly:
PollDeviceTokenclampsintervalSeconds <= 0 → 5(internal/auth/oauth/device.go:112-114) but does not enforce a minimum for positive values. A server (or a compromised endpoint) sendinginterval: 1produces one-second polling untilslow_downbumps it.RequestDeviceAuthorizationhas the same shape at line 90-92.- The companion hyperframes-CLI PR (
heygen-com/hyperframes#2836) explicitly declaresMIN_DEVICE_POLL_SECONDS = 5and appliesMath.max(Math.ceil(interval), MIN_DEVICE_POLL_SECONDS)on the same field. So the same misconfigured IdP produces 5-second polling on the TypeScript CLI and 1-second polling on the Go CLI.
Not a spec violation on either side — RFC 8628 §3.5 requires the client to be no faster than the server-specified interval; it doesn't forbid slower. But it's a client-side hardening that HF-CLI has and heygen-cli doesn't. A one-line if intervalSeconds < 5 { intervalSeconds = 5 } in both locations closes the divergence. Maps to the sibling-asymmetry lens in past reviews — same surface, two implementations, one slightly softer.
maxDevicePollDelay = 60s and maxDeviceLifetime = 30m do match the sibling's MAX_DEVICE_POLL_SECONDS / MAX_DEVICE_FLOW_SECONDS.
ATTRIBUTION (/v3/users/me pre-write) — Ordering is correct: PollDeviceToken → verifyCurrentUser → SaveVerifiedOAuthSession (cmd/heygen/auth_login.go:625-668). The identity returned by /v3/users/me is used to populate the friendly-display block (username/email/first_name/last_name) and drives the PostHog identifyAccount key. verifyCurrentUser is deliberately stricter than lookupCurrentUser — empty access token rejected, non-200 → error, envelope's data field must be non-null before decoding — so a token that authenticates but returns an empty identity envelope fails closed. Good.
Revoke path on failure (revokeMintedDeviceTokens, cmd/heygen/auth_login.go:733-745) dedupes access+refresh to avoid a double-revoke when they're the same string, and calls client.RevokeToken which swallows network errors per RFC 7009 best-effort semantics. Runs on both identity-verify-fail and credential-persist-fail branches. Clean.
Findings
Concerns (non-blocking)
-
[cross-repo asymmetry] MIN device-poll interval enforced in
heygen-com/hyperframes#2836, not here.internal/auth/oauth/device.go:112-114+ line 90-92. Sibling clampsMath.max(interval, 5s)on positive values; here only<= 0defaults to 5. A misconfigured or hostile IdP that returnsinterval: 1gets 1-second polling from this CLI and 5-second polling from HF-CLI. Suggested one-liner: replaceif intervalSeconds <= 0 { intervalSeconds = 5 }withif intervalSeconds < 5 { intervalSeconds = 5 }in both call sites. -
[cross-repo asymmetry] Loopback allowlist broader than sibling.
safeVerificationURI,device.go:227accepts any IP wherenet.ParseIP(host).IsLoopback()is true — that covers127.0.0.1, any127.x.x.x, and IPv6::1. Sibling accepts only literal"127.0.0.1"/"localhost". Both are safe (loopback is local), and heygen-cli's check is arguably more correct per RFC 8252. Flagging for parity awareness, not as a defect — if you'd rather both CLIs converge, easier direction is to widen the TS side to match the Go side. -
[defense-in-depth]
HEYGEN_OAUTH_*env vars redirect the OAuth flow silently.cmd/heygen/auth_login.go:544-560. No--devgate, no stderr breadcrumb. Consider printing "note: using non-default OAuth endpoints from environment" to stderr onrunDeviceLoginentry when any of the four env vars is non-empty — makes a mis-set var visible to the user and gives ops a signal during forensic review. -
[minor]
expires_inmultiplication is safe by luck, not construction.device.go:118:lifetime := time.Duration(expiresInSeconds) * time.Second. If a server returnsexpires_in > ~9.22e9(nearly 300 years), this overflowsint64nanoseconds on 64-bit builds. The overflow currently manifests as a small/negativelifetime, which trips theNow().Add(delay).Before(deadline)guard on the very first iteration and returnsexpired_token— SAFE fallthrough. Still, cappingexpiresInSecondstoint(maxDeviceLifetime/time.Second)before the multiplication would remove reliance on overflow semantics for correctness.
Nits
device.go:227callsnet.ParseIP(host)twice on the loopback path (net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()). Bind once:if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { return true } return false
- Ordering nit: the
resourcefield is trimmed of trailing/in bothdevice.go:56andauth_login.go:609. Belt-and-suspenders; harmless but one of them is redundant.
Questions
- The
--device-codealias is registered as a hidden flag and shares thedeviceMode || deviceCodeModedispatch branch — is that alias for compatibility with an earlier iteration you were maintaining, or for something HF-CLI exposes? If it's not load-bearing anywhere, dropping it before merge would reduce the flag surface by one hidden entry.
What I didn't verify
- The unmerged EF device-authorization endpoint and the Pacific consent UI — PR body notes DEV certification is pending both, so end-to-end I can't reach past httptest fixtures. Once those land, worth re-running the flow against DEV to confirm the resource/scope round-trip against a real IdP.
- Concurrent
heygen auth login --deviceinvocations against the same credentials file. The atomic write via temp+rename incredentials_file.go:70-105is race-safe for a single writer per rename, but two concurrent logins can still race on the read-modify-write window (loadCredentialsFile→ mutate →writeCredentialsFile), where the later rename replaces the earlier one wholesale.heygen auth loginisn't a workflow anyone runs concurrently on purpose, so this is an academic note rather than a finding. - Windows-specific path/file-mode behavior for the credentials file (
0o600/0o700) — CI passestest (windows-latest)at HEAD; taking that at face value.
Recommendation
Ship after Miguel eyeballs the MIN-interval divergence — either close it here with the one-liner in both locations to match #2836, or explicitly decide to leave heygen-cli looser and document why. Everything else is defense-in-depth / nit territory.
— Review by Rames D Jusso
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Cross-repo followup: parity issues flagged on the HF-CLI sibling
I re-checked my HF-CLI reviewer's findings on hyperframes#2836 against this PR at HEAD 1933258b. Most don't apply on the Go side (Go's shared http.Client{Timeout: 30s} uniformly covers device-auth / poll / revoke, the interval default and malformed-store refusal are both correct here). Two land, one narrower than the TS sibling and one at parity.
-
user_codeprinted unvalidated — narrower ANSI-injection vector than the TS side.cmd/heygen/auth_login.go:622doesfmt.Fprintf(cmd.ErrOrStderr(), "Enter code: %s\n", authorization.UserCode)with no control-character check onUserCode. The response-shape check atinternal/auth/oauth/device.go:86-89only rejects empty. A hostile / MITM'd IdP that returns"user_code": "�[2K�[1Aphish"survivesjson.Unmarshalas a Go string containing the raw ESC byte, and printing it rewrites earlier terminal lines — same phishing primitive the HF sibling was flagged for, just viauser_codeinstead ofverification_uri.Good news: the
verification_urivector — which was the primary hit on the TS side — is closed on the Go side without additional code.safeVerificationURIatdevice.go:215-228runs the raw string throughnet/url.Parse, and Go's stdlib rejects any byte< 0x20or0x7fat the top ofparse()(stringContainsCTLByte). Onlyuser_codeneeds the same treatment — a one-line helper at parity withisHeaderSafeinoauth.go:521applied insideRequestDeviceAuthorizationcloses it. -
PollDeviceTokentreats HTTP 429 without aslow_downbody as fatal.device.go:184-192parses the non-2xx body for anerrorfield, and if the body is empty / non-JSON / lackserror, it falls intodecodeRedactedDeviceErrorand returns a fataloauth: device endpoint returned HTTP 429.PollDeviceTokenatdevice.go:136-139propagates that out and terminates the poll loop — the caller sees a login failure even though the server was asking for backoff, not rejecting the grant. RFC 8628 §3.5 doesn't explicitly define 429 for the device-token endpoint, but a well-behaved client should back off on 429 regardless of body. Same defensible fix the HF sibling got: whenresp.StatusCode == http.StatusTooManyRequestsand the body doesn't carry an RFC-8628 error code, treat it as an implicitslow_downandcontinuethe poll loop with thedelay += 5sbump.
Skipped as not-applicable on Go: per-request timeouts (oauth.go:120 sets http.Client{Timeout: DefaultExchangeTimeout} = 30s on the shared client, exercised uniformly by device-auth, poll, and revoke); interval treated as required (device.go:90-92 correctly defaults <= 0 to 5, and the incomplete-response check at L86-89 omits Interval); corrupted-store handling (SaveVerifiedOAuthSession at oauth_store.go:23-60 and SaveOAuthTokens at :69-91 use byte-identical format != formatAbsent → refuse logic, and FileCredentialStore.Save matches for the api-key path); and the IDN / verification_uri_complete / Codespaces items the HF review mentioned only in passing (verification_uri_complete isn't parsed by the Go struct, so it's ignored; Codespaces users still get a TTY and land on the attended-terminal check at auth_login.go:580).
— Follow-up by Rames D Jusso
|
Addressed the remaining device-flow review findings at 5a5c72c. |
|
@somanshreddy @james-russo-rames-d-jusso The two follow-ups from the last pass are fixed at |
somanshreddy
left a comment
There was a problem hiding this comment.
Re-review @ 5a5c72cc9 — undrafted, 2 of 3 items fixed. One non-blocking item remains.
user_codeANSI injection: fixed.safeUserCode(device.go:224,unicode.IsControlcheck) now validatesUserCodeat parse (:91) and rejects control/ESC bytes before the raw print atauth_login.go:622— so a hostile endpoint's escape sequences can't reach the terminal. (verification_uriwas already closed by the Go stdlib control-byte check +safeVerificationURI.)- HTTP 429 without a
slow_downbody: fixed.device.go:195-197now treats a bare 429 asslow_downbackoff instead of fatal, preserving RFC 8628 behavior. - Draft: cleared (
isDraft: false), CI green (9/9).
🟡 Still open (non-blocking) — the MIN-poll-interval floor. Both :94-95 and :116-117 only default interval <= 0 → 5; a positive server value is used as-is, so a hostile/misconfigured IdP returning interval: 1 still gets 1s polling on the Go side. This is the parity one-liner from earlier — clamp the floor, e.g. if intervalSeconds < minDevicePollSeconds { intervalSeconds = minDevicePollSeconds } (const 5). HF-CLI #2836 landed the equivalent (max(interval, 5)), so this closes the last cross-surface divergence.
Everything else holds (verify-before-persist, revoke-on-failure, redacted errors, 64KB reads, 0600/0700 storage). Not blocking on that one-liner — but it's the only thing left before this is at parity with the hardened TS side.
— Somu
Addressed at Validation:
CI run |
somanshreddy
left a comment
There was a problem hiding this comment.
Re-review @ cf7b45635 — MIN-interval floor fixed; all findings now resolved. LGTM.
The floor is in at both sites: device.go:94 (if result.Interval < 5 { result.Interval = 5 }) and :116 (same in PollDeviceToken). Changed from <= 0 to < 5, so a hostile/misconfigured interval: 1 is now floored to 5s; the maxDevicePollDelay = 60s cap is unchanged, so the interval is bounded [5, 60] on both the initial and post-slow_down paths. You kept Go's silent-floor rather than the TS reject-on-invalid — that's the friendlier-default polarity we landed on (a user's own IdP sending a garbage interval shouldn't hard-fail their login), so this is the right call, not a divergence to close.
Full slate now resolved on this PR:
- ✅ undrafted, CI green (9/9)
- ✅
user_codecontrol-char rejected at parse (safeUserCode/unicode.IsControl) before print - ✅ bare HTTP 429 honored as
slow_downbackoff - ✅ MIN-poll-interval floor (this commit)
Plus the invariants from the first pass hold (verify-before-persist, revoke-on-failure, redacted errors, 64KB reads, 0600/0700 storage). Nothing blocking from my side. (Stamp needs an authorized member — flagging for the merge-gate, not something I place.)
— Somu
jrusso1020
left a comment
There was a problem hiding this comment.
Approving at cf7b4563.
The floor fix is the right shape and it is enforced rather than merely asserted. Both sites moved from <= 0 to < 5: device.go:94 at parse time and :116 in PollDeviceToken. Clamping in both places is correct rather than redundant, since PollDeviceToken takes intervalSeconds as a parameter and is reachable independently of the parse path. The two new tests pin observable behavior, and TestPollDeviceTokenClampsIntervalToMinimum asserts the recorded sleep is 5s rather than checking an internal field, which is the stronger assertion. Reverting either site fails them.
The interval is now bounded [5, 60]. This change supplies the floor; maxDevicePollDelay (device.go:19) already supplied the cap, applied at the top of every poll-loop iteration including before the first sleep.
Verified at this head: all six required contexts green (test on ubuntu/macos/windows, lint, secrets, goreleaser-check), commit signature valid, and the branch is not behind main, which matters here because protection is strict: true.
One non-blocking note. The cap is a named constant, maxDevicePollDelay, while the floor is now a bare literal 5 at two call sites plus twice more in the tests. A minDevicePollDelay constant beside it would match this file's own idiom, keep the two sites from drifting, and mirror the TS side, which spells it MIN_DEVICE_POLL_SECONDS. Related: both new tests use interval: 1, so they pin the behavior but not the threshold. Changing < 5 to < 4 would leave both green; an interval: 4 case would close that.
Keeping Go's silent floor rather than the TS side's hard reject on an explicitly invalid interval is a defensible call and I would not change it. A user's own IdP sending a garbage interval should not fail their login.
Review by Rames Jusso
Summary
heygen auth login --devicenow supports attended remote-terminal sign-in without copying an API key or relying on a loopback browser callback. The existing interactive picker, browser OAuth, and non-interactive API-key behavior remain unchanged.Security and persistence
/v3/users/memust accept the minted access token before anything is written locally.Test plan
make test— all Go packages passedmake lintwith golangci-lint v2.11.4 — 0 issuesEnd-to-end DEV certification is pending the unmerged EF endpoint and Pacific consent UI.