From 98a1587eb26941f18e4f34c86c1aa7fe26517a44 Mon Sep 17 00:00:00 2001 From: Ivan Kuznetsov Date: Tue, 1 Sep 2026 18:07:38 +0100 Subject: [PATCH] Add OAuth device authorization login --- .surface | 2 + README.md | 3 + internal/auth/auth.go | 87 ++++++++++++++++++++++++++++ internal/auth/auth_test.go | 39 +++++++++++++ internal/auth/oauth.go | 91 ++++++++++++++++++++++++++++++ internal/auth/oauth_test.go | 45 +++++++++++++++ internal/cmd/auth.go | 24 +++++++- internal/cmd/auth_commands_test.go | 9 +++ skills/hey/SKILL.md | 1 + 9 files changed, 300 insertions(+), 1 deletion(-) diff --git a/.surface b/.surface index ae0963fa..76223bff 100644 --- a/.surface +++ b/.surface @@ -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 @@ -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 diff --git a/README.md b/README.md index 561c78f4..10d11c67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b50e7e1a..9cde9ae6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -31,6 +31,7 @@ type Manager struct { httpClient *http.Client callbackWait callbackWaiter listen listenerFactory + wait func(context.Context, time.Duration) error mu sync.Mutex } @@ -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 } } @@ -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) { + 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) { @@ -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)) + 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 { + 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{ diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 488d18c1..54a225d5 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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 { @@ -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 diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 199a3710..7dede20e 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -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{ diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go index 245313b6..4e473da2 100644 --- a/internal/auth/oauth_test.go +++ b/internal/auth/oauth_test.go @@ -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" { + 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 diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 50c2403d..0fb75f59 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -51,6 +51,7 @@ func buildLoginCommand(path string) *cobra.Command { token string cookie string noBrowser bool + device bool ) cmd := &cobra.Command{ @@ -59,14 +60,24 @@ func buildLoginCommand(path string) *cobra.Command { Long: `Authenticate with the HEY server. Opens a browser for OAuth authentication against HEY's own OAuth server, using PKCE. -Use --token or --cookie for non-interactive login.`, +Use --device on a headless machine, or --token/--cookie with an existing credential.`, Example: strings.Join([]string{ " " + path, " " + path + " --token YOUR_BEARER_TOKEN", " " + path + " --cookie SESSION_COOKIE_VALUE", " " + path + " --no-browser", + " " + path + " --device", }, "\n"), RunE: func(cmd *cobra.Command, args []string) error { + selected := 0 + 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") + } if token != "" { if err := authMgr.LoginWithToken(token); err != nil { return apierr.ErrAuth(fmt.Sprintf("could not save token: %v", err)) @@ -85,6 +96,16 @@ Use --token or --cookie for non-interactive login.`, return writeMutation(cmd, "Logged in with session cookie", map[string]string{"method": "cookie"}) } + if device { + ctx, cancel := context.WithTimeout(cmd.Context(), 16*time.Minute) + defer cancel() + if err := authMgr.LoginDevice(ctx, auth.DeviceLoginOptions{}); err != nil { + return apierr.ErrAuth(fmt.Sprintf("login failed: %v", err)) + } + clearHTTPCache(cmd.ErrOrStderr()) + return writeMutation(cmd, "Logged in successfully", map[string]string{"method": "device"}) + } + ctx, cancel := context.WithTimeout(cmd.Context(), 6*time.Minute) defer cancel() @@ -112,6 +133,7 @@ Use --token or --cookie for non-interactive login.`, cmd.Flags().StringVar(&token, "token", "", "Pre-generated Bearer token") cmd.Flags().StringVar(&cookie, "cookie", "", "Session cookie value from browser (session_token)") cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Don't open browser, print URL instead") + cmd.Flags().BoolVar(&device, "device", false, "Use device authorization for headless sign-in") return cmd } diff --git a/internal/cmd/auth_commands_test.go b/internal/cmd/auth_commands_test.go index 1cd7819b..e7515fb8 100644 --- a/internal/cmd/auth_commands_test.go +++ b/internal/cmd/auth_commands_test.go @@ -119,6 +119,15 @@ func TestAuthTokenLoginAndStoredTokenOutput(t *testing.T) { } } +func TestAuthLoginRejectsConflictingMethods(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + _, _, err := runAuthCommand(t, t.TempDir(), server.URL, "", true, "auth", "login", "--device", "--no-browser") + if err == nil || !strings.Contains(err.Error(), "choose only one") { + t.Fatalf("error = %v, want conflicting method error", err) + } +} + // A session cookie is sent as a Cookie header, so printing it as a bearer token gave // the caller something that 401s with nothing to explain it -- and put the cookie in // their shell history. diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index a66d410a..e522dd0b 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -150,6 +150,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Task | Command | |------|---------| | List linked mail accounts | `hey account list --json` | +| Sign in on a headless machine | `hey auth login --device` | | Set default mail account | `hey account use ` | | Run once for one account | `hey --account box list --json` | | Review trusted local settings | `hey config trusted-locals --json` |