-
Notifications
You must be signed in to change notification settings - Fork 36
Add OAuth device authorization login #382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Prompt for AI agents |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a pending poll leaves less than Prompt for AI agents |
||
| 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{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3:
DeviceLoginOptions.logis an exact copy of the existingLoginOptions.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