Skip to content
Merged
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
37 changes: 31 additions & 6 deletions internal/api/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
Expand Down Expand Up @@ -264,12 +265,17 @@ func (c *OAuthClient) PollToken(ctx context.Context, auth *DeviceAuth, sleep fun
// Refresh exchanges a refresh token for a new access token. The server may
// rotate the refresh token; callers must persist tr.RefreshToken if non-empty.
func (c *OAuthClient) Refresh(ctx context.Context, refreshToken string) (*TokenResponse, error) {
body, _ := json.Marshal(map[string]string{
"grant_type": grantTypeRefreshToken,
"refresh_token": refreshToken,
"client_id": ClientID,
})
status, raw, err := c.post(ctx, pathToken, body)
// The /token endpoint is the @node-oauth/oauth2-server token handler, which
// REQUIRES application/x-www-form-urlencoded (it rejects JSON with "content
// must be application/x-www-form-urlencoded"). Unlike the custom device-flow
// endpoints (/device, /device-token) which accept JSON via post(), the refresh
// grant must be form-encoded.
form := url.Values{
"grant_type": {grantTypeRefreshToken},
"refresh_token": {refreshToken},
"client_id": {ClientID},
}
status, raw, err := c.postForm(ctx, pathToken, form)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -308,6 +314,25 @@ func (c *OAuthClient) post(ctx context.Context, path string, body []byte) (int,
return resp.StatusCode, raw, nil
}

// postForm sends an application/x-www-form-urlencoded POST. Used for the OAuth
// /token endpoint (the refresh grant), whose @node-oauth/oauth2-server handler
// requires form encoding and rejects JSON.
func (c *OAuthClient) postForm(ctx context.Context, path string, form url.Values) (int, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, strings.NewReader(form.Encode()))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, raw, nil
}

// oauthMsg extracts a human message from an OAuth error body.
func oauthMsg(raw []byte) string {
var oe oauthErr
Expand Down
17 changes: 13 additions & 4 deletions internal/api/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,19 @@ func TestRefreshRotatesToken(t *testing.T) {
if r.URL.Path != pathToken {
t.Errorf("refresh hit wrong path %q", r.URL.Path)
}
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["grant_type"] != grantTypeRefreshToken || body["refresh_token"] != "old-rt" {
t.Errorf("refresh body = %v", body)
// The /token route (the @node-oauth/oauth2-server token handler) REQUIRES
// application/x-www-form-urlencoded and rejects JSON with "content must be
// application/x-www-form-urlencoded". Assert the CLI sends form encoding —
// this is the regression guard for the bug where Refresh() posted JSON and
// the real server rejected every refresh after the 1h access-token TTL.
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("refresh Content-Type = %q, want application/x-www-form-urlencoded", ct)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
if r.PostFormValue("grant_type") != grantTypeRefreshToken || r.PostFormValue("refresh_token") != "old-rt" {
t.Errorf("refresh form = %v", r.PostForm)
}
_ = json.NewEncoder(w).Encode(TokenResponse{
AccessToken: "new-at", ExpiresIn: 3600, RefreshToken: "new-rt", Scope: DeviceScope,
Expand Down
Loading