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 @@ -268,6 +269,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 docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ scripts and agents can handle it.
# 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
90 changes: 87 additions & 3 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,11 +163,22 @@ type LoginOptions struct {
Logger func(msg string)
}

// DeviceLoginOptions configures OAuth device authorization login.
type DeviceLoginOptions struct {
// Logger receives login progress messages (the verification URL and user
// code, the waiting notice). Nil keeps the default os.Stderr output.
Logger func(msg string)
}

func (o DeviceLoginOptions) log(msg string) { logProgress(o.Logger, 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) {
if o.Logger != nil {
o.Logger(msg)
func (o LoginOptions) log(msg string) { logProgress(o.Logger, msg) }

func logProgress(logger func(msg string), msg string) {
if logger != nil {
logger(msg)
return
}
fmt.Fprint(os.Stderr, msg)
Expand Down Expand Up @@ -228,6 +252,66 @@ 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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Leaving this as is. These values come from HEY's own OAuth server over TLS — the same server this CLI already trusts with its tokens — and the browser flow prints that server's authorization URL to the terminal the same way. A server that could put control characters here could do far worse, so a sanitizer at this one sink would guard against nobody in particular.

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)

for {
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)
}

// The wait never outlives the code, and an expired code is not polled again.
if remaining := time.Until(expiresAt); remaining > 0 {
if err := m.wait(ctx, min(interval, remaining)); err != nil {
return err
}
}
if !time.Now().Before(expiresAt) {
return errors.New("device authorization expired")
}
}
}

// LoginWithToken stores a pre-provided bearer token.
func (m *Manager) LoginWithToken(token string) error {
creds := &Credentials{
Expand Down
76 changes: 76 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,81 @@ 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)
}
}

// The server's expires_in bounds the polling: a wait never runs past it, and a
// device code that expired while waiting is not exchanged again.
func TestLoginDeviceStopsPollingAtExpiry(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":1,"interval":30}`)
case "/oauth/tokens":
polls++
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"authorization_pending"}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()

mgr := testManager(t, server)
var waits []time.Duration
mgr.wait = func(_ context.Context, d time.Duration) error {
waits = append(waits, d)
time.Sleep(d + 50*time.Millisecond) // a timer fires at or after d, never before
return nil
}
err := mgr.LoginDevice(t.Context(), DeviceLoginOptions{Logger: func(string) {}})
if err == nil || !strings.Contains(err.Error(), "expired") {
t.Fatalf("LoginDevice error = %v, want expiry", err)
}
if len(waits) != 1 || waits[0] > time.Second {
t.Errorf("waits = %v, want one wait capped at the remaining second", waits)
}
if polls != 1 {
t.Errorf("polls = %d, want one before expiry", polls)
}
}

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
48 changes: 48 additions & 0 deletions internal/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,54 @@ 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)
}
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("%s request = %s %s", r.URL.Path, r.Method, r.Header.Get("Content-Type"))
}
if r.Form.Get("client_id") != "client" || r.Form.Get("install_id") != "install" {
t.Errorf("%s form = %v", r.URL.Path, r.Form)
}
switch r.URL.Path {
case "/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" {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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