Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ hey attachment save --output
hey auth
hey auth login
hey auth login --cookie
hey auth login --device
hey auth login --no-browser
hey auth login --token
hey auth logout
Expand Down Expand Up @@ -255,6 +256,7 @@ hey label view --limit
hey label view --page
hey login
hey login --cookie
hey login --device
hey login --no-browser
hey login --token
hey logout
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ What happens depends on how hey was installed:
# Browser-based OAuth against HEY's own OAuth server (primary method)
hey auth login

# Or sign in from a headless machine using a code on another device
hey auth login --device

# Or use a pre-generated token
hey auth login --token TOKEN

Expand Down
87 changes: 87 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Manager struct {
httpClient *http.Client
callbackWait callbackWaiter
listen listenerFactory
wait func(context.Context, time.Duration) error
mu sync.Mutex
}

Expand All @@ -42,6 +43,18 @@ func NewManager(baseURL string, httpClient *http.Client, configDir string) *Mana
store: NewStore(configDir),
httpClient: httpClient,
listen: listenConfig.Listen,
wait: waitForDuration,
}
}

func waitForDuration(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

Expand Down Expand Up @@ -150,6 +163,19 @@ type LoginOptions struct {
Logger func(msg string)
}

// DeviceLoginOptions configures OAuth device authorization login.
type DeviceLoginOptions struct {
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>

if o.Logger != nil {
o.Logger(msg)
return
}
fmt.Fprint(os.Stderr, msg)
}

// log routes a progress message to the configured Logger, or to os.Stderr
// verbatim when none is set so `hey auth login` output stays as it was.
func (o LoginOptions) log(msg string) {
Expand Down Expand Up @@ -228,6 +254,67 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) error {
return m.store.Save(m.baseURL, creds)
}

// LoginDevice authenticates using the OAuth 2.0 Device Authorization Grant (RFC 8628).
func (m *Manager) LoginDevice(ctx context.Context, opts DeviceLoginOptions) error {
deviceEndpoint := m.baseURL + "/oauth/device_authorizations"
tokenEndpoint := m.baseURL + "/oauth/tokens"
installID, err := m.store.InstallID()
if err != nil {
return fmt.Errorf("install id: %w", err)
}

authorization, err := requestDeviceAuthorization(ctx, m.httpClient, deviceEndpoint, oauthClientID, installID)
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>

if authorization.VerificationURIComplete != "" {
opts.log(fmt.Sprintf("Direct link: %s\n", authorization.VerificationURIComplete))
}
opts.log("Waiting for authorization...\n")

interval := time.Duration(authorization.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
expiresAt := time.Now().Add(time.Duration(authorization.ExpiresIn) * time.Second)
firstPoll := true

for {
if time.Now().After(expiresAt) {
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>

return err
}
}
firstPoll = false

token, oauthErr, err := exchangeDeviceCode(ctx, m.httpClient, tokenEndpoint, authorization.DeviceCode, oauthClientID, installID)
if err != nil {
return err
}
switch oauthErr {
case "":
creds := &Credentials{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, OAuthType: "oauth", TokenEndpoint: tokenEndpoint}
if !token.ExpiresAt.IsZero() {
creds.ExpiresAt = token.ExpiresAt.Unix()
}
return m.store.Save(m.baseURL, creds)
case "authorization_pending":
case "slow_down":
interval += 5 * time.Second
case "access_denied":
return errors.New("device authorization was denied")
case "expired_token":
return errors.New("device authorization expired")
default:
return fmt.Errorf("device authorization failed: %s", oauthErr)
}
}
}

// LoginWithToken stores a pre-provided bearer token.
func (m *Manager) LoginWithToken(token string) error {
creds := &Credentials{
Expand Down
39 changes: 39 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestHEYTokenPrecedence(t *testing.T) {

t.Setenv("HEY_TOKEN", "env-token-123")
mgr := testManager(t, server)
mgr.wait = func(context.Context, time.Duration) error { return nil }

token, err := mgr.AccessToken(context.Background())
if err != nil {
Expand Down Expand Up @@ -229,6 +230,44 @@ func TestLoginDoesNotSaveCredentialsOnFailure(t *testing.T) {
}
}

func TestLoginDevice(t *testing.T) {
var polls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth/device_authorizations":
_, _ = io.WriteString(w, `{"device_code":"secret","user_code":"ABCD-EFGH","verification_uri":"https://example.test/device","expires_in":60,"interval":0}`)
case "/oauth/tokens":
polls++
if polls == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"authorization_pending"}`)
return
}
_, _ = io.WriteString(w, `{"access_token":"device-access","refresh_token":"device-refresh","expires_in":3600}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()

mgr := testManager(t, server)
mgr.wait = func(context.Context, time.Duration) error { return nil }
var messages strings.Builder
if err := mgr.LoginDevice(t.Context(), DeviceLoginOptions{Logger: func(msg string) { messages.WriteString(msg) }}); err != nil {
t.Fatalf("LoginDevice: %v", err)
}
if !strings.Contains(messages.String(), "ABCD-EFGH") || strings.Contains(messages.String(), "secret") {
t.Errorf("login messages = %q", messages.String())
}
creds, err := mgr.GetStore().Load(mgr.CredentialKey())
if err != nil {
t.Fatalf("Load: %v", err)
}
if creds.AccessToken != "device-access" || creds.RefreshToken != "device-refresh" {
t.Errorf("credentials = %#v", creds)
}
}

func TestWaitForCallback(t *testing.T) {
tests := []struct {
name string
Expand Down
91 changes: 91 additions & 0 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -25,6 +26,96 @@ type OAuthToken struct {
ExpiresAt time.Time `json:"-"`
}

// DeviceAuthorization represents an RFC 8628 device authorization response.
type DeviceAuthorization struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval"`
}

type deviceTokenError struct {
Code string `json:"error"`
}

func requestDeviceAuthorization(ctx context.Context, httpClient *http.Client, endpoint, clientID, installID string) (*DeviceAuthorization, error) {
data := url.Values{"client_id": {clientID}, "install_id": {installID}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("creating device authorization request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", version.UserAgent())

resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("device authorization request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return nil, fmt.Errorf("reading device authorization response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device authorization failed (status %d): %s", resp.StatusCode, string(body))
}

var authorization DeviceAuthorization
if err := json.Unmarshal(body, &authorization); err != nil {
return nil, fmt.Errorf("parsing device authorization response: %w", err)
}
if authorization.DeviceCode == "" || authorization.UserCode == "" || authorization.VerificationURI == "" || authorization.ExpiresIn <= 0 {
return nil, errors.New("device authorization response is missing required fields")
}
return &authorization, nil
}

func exchangeDeviceCode(ctx context.Context, httpClient *http.Client, tokenEndpoint, deviceCode, clientID, installID string) (*OAuthToken, string, error) {
data := url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {deviceCode},
"client_id": {clientID},
"install_id": {installID},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, "", fmt.Errorf("creating device token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", version.UserAgent())

resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("device token request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return nil, "", fmt.Errorf("reading device token response: %w", err)
}
if resp.StatusCode == http.StatusOK {
var token OAuthToken
if err := json.Unmarshal(body, &token); err != nil {
return nil, "", fmt.Errorf("parsing device token response: %w", err)
}
if token.AccessToken == "" {
return nil, "", errors.New("device token response is missing access_token")
}
if token.ExpiresIn > 0 {
token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
}
return &token, "", nil
}

var oauthErr deviceTokenError
if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.Code != "" && (resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusForbidden) {
return nil, oauthErr.Code, nil
}
return nil, "", fmt.Errorf("device token exchange failed (status %d): %s", resp.StatusCode, string(body))
}

// exchangeCode exchanges an authorization code for tokens using PKCE.
func exchangeCode(ctx context.Context, httpClient *http.Client, tokenEndpoint, code, redirectURI, clientID, codeVerifier, installID string) (*OAuthToken, error) {
data := url.Values{
Expand Down
45 changes: 45 additions & 0 deletions internal/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,51 @@ func TestRefreshOAuthTokenRequest(t *testing.T) {
}
}

func TestDeviceAuthorizationAndTokenRequests(t *testing.T) {
var polls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
switch r.URL.Path {
case "/device":
if r.Form.Get("client_id") != "client" || r.Form.Get("install_id") != "install" {
t.Errorf("device form = %v", r.Form)
}
_, _ = 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>

t.Errorf("token form = %v", r.Form)
}
if polls == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"authorization_pending"}`)
return
}
_, _ = io.WriteString(w, `{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()

authorization, err := requestDeviceAuthorization(t.Context(), server.Client(), server.URL+"/device", "client", "install")
if err != nil {
t.Fatalf("requestDeviceAuthorization: %v", err)
}
if authorization.UserCode != "ABCD-EFGH" || authorization.Interval != 5 {
t.Errorf("authorization = %#v", authorization)
}
if _, pending, err := exchangeDeviceCode(t.Context(), server.Client(), server.URL+"/token", authorization.DeviceCode, "client", "install"); err != nil || pending != "authorization_pending" {
t.Fatalf("pending exchange = %q, %v", pending, err)
}
token, pending, err := exchangeDeviceCode(t.Context(), server.Client(), server.URL+"/token", authorization.DeviceCode, "client", "install")
if err != nil || pending != "" || token.AccessToken != "access" {
t.Fatalf("token exchange = %#v, %q, %v", token, pending, err)
}
}

func TestOAuthTokenResponseFailures(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading