Skip to content

Add OAuth device authorization login - #382

Open
ivankuznetsov wants to merge 1 commit into
basecamp:mainfrom
ivankuznetsov:feat/oauth-device-flow
Open

Add OAuth device authorization login#382
ivankuznetsov wants to merge 1 commit into
basecamp:mainfrom
ivankuznetsov:feat/oauth-device-flow

Conversation

@ivankuznetsov

@ivankuznetsov ivankuznetsov commented Sep 1, 2026

Copy link
Copy Markdown

Summary

  • add hey auth login --device and the matching hey login --device shortcut
  • request and display an RFC 8628 user code without exposing the secret device code
  • poll the token endpoint with the server interval, slow_down backoff, expiry, cancellation, denial, and refresh-token persistence
  • document the headless sign-in command and update the committed CLI surface
  • cover request shapes, pending/success behavior, credential storage, and conflicting login flags with unit tests

Closes #381.

Server dependency

This PR implements and tests only the CLI side. It expects HEY to provide:

  • POST /oauth/device_authorizations
  • a HEY-hosted verification_uri page for user code entry/approval
  • device-code grant support at POST /oauth/tokens

Until those server/web pieces exist, --device will return the device-authorization endpoint error. Endpoint names and response details can be adjusted to match the HEY implementation during review.

Verification

  • make check
  • go test ./internal/auth ./internal/cmd
  • make fmt-check
  • make lint
  • git diff --check

Summary by cubic

Adds hey auth login --device (and the hey login --device shortcut) for signing in on headless machines using the OAuth device authorization grant (RFC 8628). Users see a short user code to enter at a verification URL; the secret device code is never displayed.

  • Polls the token endpoint at the server's interval and handles slow_down, expired_token, and access_denied; success saves access and refresh tokens.
  • Combining --device with --token, --cookie, or --no-browser now fails with a usage error.
  • Requires HEY to provide POST /oauth/device_authorizations and device-code grant support at POST /oauth/tokens; until those exist, --device returns the endpoint error. Closes Support OAuth device authorization for headless CLI sign-in #381.

Written for commit 98a1587. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings September 1, 2026 17:07
@ivankuznetsov
ivankuznetsov requested a review from a team as a code owner September 1, 2026 17:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/auth/auth.go">

<violation number="1" location="internal/auth/auth.go:171">
P3: `DeviceLoginOptions.log` is an exact copy of the existing `LoginOptions.log` (same Logger-or-stderr routing). Extract the shared routing into one helper and have both option types call it, so the two login flows can't drift.</violation>

<violation number="2" location="internal/auth/auth.go:270">
P2: When the device endpoint returns control characters in a verification URI or user code, `LoginDevice` writes them verbatim to the terminal. Sanitize server-provided values before passing these messages to the logger.</violation>

<violation number="3" location="internal/auth/auth.go:288">
P2: When a pending poll leaves less than `interval` before `expiresAt`, this sleeps the full interval and issues another request after the device code has expired. Cap the wait at the remaining lifetime and check expiry before polling again.</violation>
</file>

<file name="internal/auth/oauth_test.go">

<violation number="1" location="internal/auth/oauth_test.go:108">
P3: The new /token and /device handlers never assert the request method or Content-Type, and the /token case omits the client_id/install_id checks, unlike the sibling TestExchangeCodeRequest and TestRefreshOAuthTokenRequest which assert both method, encoding, and every form field. As written, the test passes even if the client used the wrong method or encoding, or dropped client_id/install_id on the token exchange, so it does not fully cover the PR's stated request-shapes intent.</violation>
</file>

<file name="internal/cmd/auth.go">

<violation number="1" location="internal/cmd/auth.go:73">
P2: The new conflict check counts `--no-browser` as a login method, so `hey auth login --token X --no-browser` and `--cookie C --no-browser` — both valid before this change, where the token/cookie branch ran and `--no-browser` was ignored — now fail with a usage error. `--no-browser` only modifies the browser flow; it is not a method. Count only token/cookie/device as methods, and reject `--device --no-browser` separately so the pinned test still passes.</violation>

<violation number="2" location="internal/cmd/auth.go:100">
P2: When the device endpoint grants an authorization lifetime longer than 16 minutes, this command cancels `LoginDevice` while the user code is still valid. Let the server-provided expiry and the caller's cancellation control the wait instead of imposing a shorter fixed deadline.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread internal/auth/auth.go
return errors.New("device authorization expired")
}
if !firstPoll {
if err := m.wait(ctx, interval); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a pending poll leaves less than interval before expiresAt, this sleeps the full interval and issues another request after the device code has expired. Cap the wait at the remaining lifetime and check expiry before polling again.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/auth/auth.go, line 288:

<comment>When a pending poll leaves less than `interval` before `expiresAt`, this sleeps the full interval and issues another request after the device code has expired. Cap the wait at the remaining lifetime and check expiry before polling again.</comment>

<file context>
@@ -228,6 +254,67 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
+			return errors.New("device authorization expired")
+		}
+		if !firstPoll {
+			if err := m.wait(ctx, interval); err != nil {
+				return err
+			}
</file context>

Comment thread internal/auth/auth.go
if err != nil {
return err
}
opts.log(fmt.Sprintf("Open %s and enter code: %s\n", authorization.VerificationURI, authorization.UserCode))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the device endpoint returns control characters in a verification URI or user code, LoginDevice writes them verbatim to the terminal. Sanitize server-provided values before passing these messages to the logger.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/auth/auth.go, line 270:

<comment>When the device endpoint returns control characters in a verification URI or user code, `LoginDevice` writes them verbatim to the terminal. Sanitize server-provided values before passing these messages to the logger.</comment>

<file context>
@@ -228,6 +254,67 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
+	if err != nil {
+		return err
+	}
+	opts.log(fmt.Sprintf("Open %s and enter code: %s\n", authorization.VerificationURI, authorization.UserCode))
+	if authorization.VerificationURIComplete != "" {
+		opts.log(fmt.Sprintf("Direct link: %s\n", authorization.VerificationURIComplete))
</file context>

Comment thread internal/cmd/auth.go
}

if device {
ctx, cancel := context.WithTimeout(cmd.Context(), 16*time.Minute)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the device endpoint grants an authorization lifetime longer than 16 minutes, this command cancels LoginDevice while the user code is still valid. Let the server-provided expiry and the caller's cancellation control the wait instead of imposing a shorter fixed deadline.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/cmd/auth.go, line 100:

<comment>When the device endpoint grants an authorization lifetime longer than 16 minutes, this command cancels `LoginDevice` while the user code is still valid. Let the server-provided expiry and the caller's cancellation control the wait instead of imposing a shorter fixed deadline.</comment>

<file context>
@@ -85,6 +96,16 @@ Use --token or --cookie for non-interactive login.`,
 			}
 
+			if device {
+				ctx, cancel := context.WithTimeout(cmd.Context(), 16*time.Minute)
+				defer cancel()
+				if err := authMgr.LoginDevice(ctx, auth.DeviceLoginOptions{}); err != nil {
</file context>
Suggested change
ctx, cancel := context.WithTimeout(cmd.Context(), 16*time.Minute)
ctx, cancel := context.WithCancel(cmd.Context())

Comment thread internal/cmd/auth.go
Comment on lines +73 to +79
for _, active := range []bool{token != "", cookie != "", noBrowser, device} {
if active {
selected++
}
}
if selected > 1 {
return apierr.ErrUsage("choose only one of --device, --no-browser, --token, or --cookie")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new conflict check counts --no-browser as a login method, so hey auth login --token X --no-browser and --cookie C --no-browser — both valid before this change, where the token/cookie branch ran and --no-browser was ignored — now fail with a usage error. --no-browser only modifies the browser flow; it is not a method. Count only token/cookie/device as methods, and reject --device --no-browser separately so the pinned test still passes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/cmd/auth.go, line 73:

<comment>The new conflict check counts `--no-browser` as a login method, so `hey auth login --token X --no-browser` and `--cookie C --no-browser` — both valid before this change, where the token/cookie branch ran and `--no-browser` was ignored — now fail with a usage error. `--no-browser` only modifies the browser flow; it is not a method. Count only token/cookie/device as methods, and reject `--device --no-browser` separately so the pinned test still passes.</comment>

<file context>
@@ -59,14 +60,24 @@ func buildLoginCommand(path string) *cobra.Command {
 		}, "\n"),
 		RunE: func(cmd *cobra.Command, args []string) error {
+			selected := 0
+			for _, active := range []bool{token != "", cookie != "", noBrowser, device} {
+				if active {
+					selected++
</file context>
Suggested change
for _, active := range []bool{token != "", cookie != "", noBrowser, device} {
if active {
selected++
}
}
if selected > 1 {
return apierr.ErrUsage("choose only one of --device, --no-browser, --token, or --cookie")
selected := 0
for _, active := range []bool{token != "", cookie != "", device} {
if active {
selected++
}
}
if selected > 1 {
return apierr.ErrUsage("choose only one of --device, --token, or --cookie")
}
if device && noBrowser {
return apierr.ErrUsage("--no-browser cannot be combined with --device")
}

_, _ = io.WriteString(w, `{"device_code":"device-secret","user_code":"ABCD-EFGH","verification_uri":"https://example.test/device","verification_uri_complete":"https://example.test/device?code=ABCD-EFGH","expires_in":600,"interval":5}`)
case "/token":
polls++
if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" || r.Form.Get("device_code") != "device-secret" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new /token and /device handlers never assert the request method or Content-Type, and the /token case omits the client_id/install_id checks, unlike the sibling TestExchangeCodeRequest and TestRefreshOAuthTokenRequest which assert both method, encoding, and every form field. As written, the test passes even if the client used the wrong method or encoding, or dropped client_id/install_id on the token exchange, so it does not fully cover the PR's stated request-shapes intent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/auth/oauth_test.go, line 108:

<comment>The new /token and /device handlers never assert the request method or Content-Type, and the /token case omits the client_id/install_id checks, unlike the sibling TestExchangeCodeRequest and TestRefreshOAuthTokenRequest which assert both method, encoding, and every form field. As written, the test passes even if the client used the wrong method or encoding, or dropped client_id/install_id on the token exchange, so it does not fully cover the PR's stated request-shapes intent.</comment>

<file context>
@@ -91,6 +91,51 @@ func TestRefreshOAuthTokenRequest(t *testing.T) {
+			_, _ = io.WriteString(w, `{"device_code":"device-secret","user_code":"ABCD-EFGH","verification_uri":"https://example.test/device","verification_uri_complete":"https://example.test/device?code=ABCD-EFGH","expires_in":600,"interval":5}`)
+		case "/token":
+			polls++
+			if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" || r.Form.Get("device_code") != "device-secret" {
+				t.Errorf("token form = %v", r.Form)
+			}
</file context>

Comment thread internal/auth/auth.go
Logger func(msg string)
}

func (o DeviceLoginOptions) log(msg string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: DeviceLoginOptions.log is an exact copy of the existing LoginOptions.log (same Logger-or-stderr routing). Extract the shared routing into one helper and have both option types call it, so the two login flows can't drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/auth/auth.go, line 171:

<comment>`DeviceLoginOptions.log` is an exact copy of the existing `LoginOptions.log` (same Logger-or-stderr routing). Extract the shared routing into one helper and have both option types call it, so the two login flows can't drift.</comment>

<file context>
@@ -150,6 +163,19 @@ type LoginOptions struct {
+	Logger func(msg string)
+}
+
+func (o DeviceLoginOptions) log(msg string) {
+	if o.Logger != nil {
+		o.Logger(msg)
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support OAuth device authorization for headless CLI sign-in

2 participants