From 52a6e01f0db30dbce18da1a4855dfff84eca09a7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Sep 2026 10:39:38 +0100 Subject: [PATCH 1/8] feat: add explicit OAuth token refresh support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f5c79efc-51d8-4913-8961-701522797f9e --- README.md | 39 ++++++++ api/access_token.go | 38 +++++-- api/access_token_test.go | 20 ++-- examples_test.go | 35 +++++++ oauth.go | 14 +++ oauth_device.go | 8 +- oauth_test.go | 38 +++++++ oauth_webapp.go | 7 +- refresh.go | 68 +++++++++++++ refresh_test.go | 210 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 458 insertions(+), 19 deletions(-) create mode 100644 oauth_test.go create mode 100644 refresh.go create mode 100644 refresh_test.go diff --git a/README.md b/README.md index 41cb35c..c556f98 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,45 @@ Applications that need more control over the user experience around authenticati In theory, these packages would enable authorization on any OAuth-enabled host. In practice, however, this was only tested for authorizing with GitHub. +## Refresh tokens + +Set `RequestRefreshToken` to request an expiring access token and refresh token: + +```go +flow := &oauth.Flow{ + Host: host, + ClientID: clientID, + Scopes: []string{"repo"}, + RequestRefreshToken: true, +} +``` + +Servers that do not support expiring tokens may ignore the request and return a token without expiration metadata +or a refresh token. + +Before using an expired token, exchange its refresh token and persist the returned token pair: + +```go +if token.IsExpired() && token.CanRefresh() { + token, err = oauth.Refresh(oauth.RefreshOptions{ + Host: host, + ClientID: clientID, + ClientSecret: clientSecret, + RefreshToken: token.RefreshToken, + HTTPClient: http.DefaultClient, + }) + if err != nil { + return err + } + if err := saveToken(token); err != nil { + return err + } +} +``` + +Refresh tokens are single use. A successful refresh invalidates the previous access token and refresh token, so +applications must persist the returned replacements. + [oauth-device]: https://oauth.net/2/device-flow/ [gh-device]: https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps#device-flow diff --git a/api/access_token.go b/api/access_token.go index 718d69d..cf161d4 100644 --- a/api/access_token.go +++ b/api/access_token.go @@ -1,5 +1,9 @@ package api +import ( + "strconv" +) + // AccessToken is an OAuth access token. type AccessToken struct { // The token value, typically a 40-character random string. @@ -10,18 +14,36 @@ type AccessToken struct { Type string // Space-separated list of OAuth scopes that this token grants. Scope string + // The number of seconds from issuance until Token expires. Zero means the server did not return + // expiration metadata. + ExpiresIn int + // The number of seconds from issuance until RefreshToken expires. Zero means the server did not + // return expiration metadata. + RefreshTokenExpiresIn int } // AccessToken extracts the access token information from a server response. func (f FormResponse) AccessToken() (*AccessToken, error) { - if accessToken := f.Get("access_token"); accessToken != "" { - return &AccessToken{ - Token: accessToken, - RefreshToken: f.Get("refresh_token"), - Type: f.Get("token_type"), - Scope: f.Get("scope"), - }, nil + accessToken := f.Get("access_token") + if accessToken == "" { + return nil, f.Err() + } + + token := &AccessToken{ + Token: accessToken, + RefreshToken: f.Get("refresh_token"), + Type: f.Get("token_type"), + Scope: f.Get("scope"), + } + + if expiresIn, err := strconv.Atoi(f.Get("expires_in")); err == nil && expiresIn > 0 { + token.ExpiresIn = expiresIn + } + if token.RefreshToken != "" { + if expiresIn, err := strconv.Atoi(f.Get("refresh_token_expires_in")); err == nil && expiresIn > 0 { + token.RefreshTokenExpiresIn = expiresIn + } } - return nil, f.Err() + return token, nil } diff --git a/api/access_token_test.go b/api/access_token_test.go index fda4f6e..842bebe 100644 --- a/api/access_token_test.go +++ b/api/access_token_test.go @@ -34,17 +34,21 @@ func TestFormResponse_AccessToken(t *testing.T) { name: "with refresh token", response: FormResponse{ values: url.Values{ - "access_token": []string{"ATOKEN"}, - "refresh_token": []string{"AREFRESHTOKEN"}, - "token_type": []string{"bearer"}, - "scope": []string{"repo gist"}, + "access_token": []string{"ATOKEN"}, + "refresh_token": []string{"AREFRESHTOKEN"}, + "expires_in": []string{"28800"}, + "refresh_token_expires_in": []string{"15897600"}, + "token_type": []string{"bearer"}, + "scope": []string{"repo gist"}, }, }, want: &AccessToken{ - Token: "ATOKEN", - RefreshToken: "AREFRESHTOKEN", - Type: "bearer", - Scope: "repo gist", + Token: "ATOKEN", + RefreshToken: "AREFRESHTOKEN", + Type: "bearer", + Scope: "repo gist", + ExpiresIn: 28800, + RefreshTokenExpiresIn: 15897600, }, wantErr: nil, }, diff --git a/examples_test.go b/examples_test.go index 975c8c9..b41fe47 100644 --- a/examples_test.go +++ b/examples_test.go @@ -2,6 +2,7 @@ package oauth_test import ( "fmt" + "net/http" "os" "github.com/cli/oauth" @@ -31,3 +32,37 @@ func ExampleFlow_DetectFlow() { fmt.Printf("Access token: %s\n", accessToken.Token) } + +func ExampleRefresh() { + host, err := oauth.NewGitHubHost("https://github.com") + if err != nil { + panic(err) + } + flow := &oauth.Flow{ + Host: host, + ClientID: os.Getenv("OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"), // only applicable to web app flow + Scopes: []string{"repo", "read:org", "gist"}, + RequestRefreshToken: true, + } + + accessToken, err := flow.DeviceFlow() + if err != nil { + panic(err) + } + + refreshedToken, err := oauth.Refresh(oauth.RefreshOptions{ + Host: host, + ClientID: flow.ClientID, + ClientSecret: flow.ClientSecret, // only applicable to web app flow + RefreshToken: accessToken.RefreshToken, + HTTPClient: http.DefaultClient, + }) + if err != nil { + panic(err) + } + + // Persist the complete refreshed token because the previous token pair is no longer usable. + fmt.Printf("Refreshed refresh token: %s\n", refreshedToken.RefreshToken) + fmt.Printf("Refreshed access token: %s\n", refreshedToken.Token) +} diff --git a/oauth.go b/oauth.go index 5b98c38..1740456 100644 --- a/oauth.go +++ b/oauth.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "slices" "strings" "github.com/cli/oauth/api" @@ -76,6 +77,12 @@ type Flow struct { ClientSecret string // The localhost URI for web application flow callback, e.g. "http://127.0.0.1/callback". CallbackURI string + // RequestRefreshToken opts this authorization into receiving an expiring access token and a + // refresh token by requesting the "offline_access" scope. + // + // Servers that do not support expiring tokens may ignore this request and return a token that + // does not expire and has no refresh token. + RequestRefreshToken bool // Display a one-time code to the user. Receives the code and the browser URL as arguments. Defaults to printing the // code to the user on Stdout with instructions to copy the code and to press Enter to continue in their browser. @@ -102,3 +109,10 @@ func (oa *Flow) DetectFlow() (*api.AccessToken, error) { } return accessToken, err } + +func withOfflineAccess(scopes []string) []string { + if slices.Contains(scopes, "offline_access") { + return scopes + } + return append(slices.Clone(scopes), "offline_access") +} diff --git a/oauth_device.go b/oauth_device.go index f993eaa..e514abb 100644 --- a/oauth_device.go +++ b/oauth_device.go @@ -39,8 +39,12 @@ func (oa *Flow) DeviceFlow() (*api.AccessToken, error) { host = parsedHost } - code, err := device.RequestCode(httpClient, host.DeviceCodeURL, - oa.ClientID, oa.Scopes, device.WithAudience(oa.Audience)) + scopes := oa.Scopes + if oa.RequestRefreshToken { + scopes = withOfflineAccess(scopes) + } + + code, err := device.RequestCode(httpClient, host.DeviceCodeURL, oa.ClientID, scopes, device.WithAudience(oa.Audience)) if err != nil { return nil, err } diff --git a/oauth_test.go b/oauth_test.go new file mode 100644 index 0000000..ca69909 --- /dev/null +++ b/oauth_test.go @@ -0,0 +1,38 @@ +package oauth + +import ( + "slices" + "testing" +) + +func TestWithOfflineAccess(t *testing.T) { + tests := []struct { + name string + scopes []string + want []string + }{ + { + name: "appends scope", + scopes: []string{"repo", "read:org"}, + want: []string{"repo", "read:org", "offline_access"}, + }, + { + name: "does not duplicate scope", + scopes: []string{"repo", "offline_access"}, + want: []string{"repo", "offline_access"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := slices.Clone(tt.scopes) + got := withOfflineAccess(tt.scopes) + if !slices.Equal(got, tt.want) { + t.Errorf("withOfflineAccess() = %v, want %v", got, tt.want) + } + if !slices.Equal(tt.scopes, original) { + t.Errorf("input scopes changed from %v to %v", original, tt.scopes) + } + }) + } +} diff --git a/oauth_webapp.go b/oauth_webapp.go index 360c53b..caad1ff 100644 --- a/oauth_webapp.go +++ b/oauth_webapp.go @@ -28,10 +28,15 @@ func (oa *Flow) WebAppFlow() (*api.AccessToken, error) { return nil, err } + scopes := oa.Scopes + if oa.RequestRefreshToken { + scopes = withOfflineAccess(scopes) + } + params := webapp.BrowserParams{ ClientID: oa.ClientID, RedirectURI: oa.CallbackURI, - Scopes: oa.Scopes, + Scopes: scopes, Audience: oa.Audience, AllowSignup: true, } diff --git a/refresh.go b/refresh.go new file mode 100644 index 0000000..3c1e0d7 --- /dev/null +++ b/refresh.go @@ -0,0 +1,68 @@ +package oauth + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/cli/oauth/api" +) + +// ErrRefreshTokenInvalid is returned when the server rejects a refresh token because it is invalid, +// expired, or already used. +var ErrRefreshTokenInvalid = errors.New("refresh token is invalid or expired") + +// RefreshOptions specifies parameters for exchanging a refresh token for a new token pair. +type RefreshOptions struct { + // Host contains the token endpoint used for the refresh request. + Host *Host + // ClientID is the OAuth application ID. + ClientID string + // ClientSecret is the OAuth application secret. It is not required for device flow tokens. + ClientSecret string + // RefreshToken is the refresh token issued with the current access token. + RefreshToken string + // HTTPClient is the client used for the refresh request. It defaults to http.DefaultClient. + HTTPClient httpClient +} + +// Refresh exchanges a refresh token for a new access token and refresh token. +func Refresh(opts RefreshOptions) (*api.AccessToken, error) { + if opts.Host == nil { + return nil, errors.New("host is required") + } + if opts.RefreshToken == "" { + return nil, fmt.Errorf("%w: refresh token is empty", ErrRefreshTokenInvalid) + } + + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + values := url.Values{ + "client_id": {opts.ClientID}, + "refresh_token": {opts.RefreshToken}, + "grant_type": {"refresh_token"}, + } + if opts.ClientSecret != "" { + values.Set("client_secret", opts.ClientSecret) + } + + resp, err := api.PostForm(httpClient, opts.Host.TokenURL, values) + if err != nil { + return nil, err + } + + token, err := resp.AccessToken() + if err != nil { + var apiError *api.Error + if errors.As(err, &apiError) && apiError.Code == "bad_refresh_token" { + return nil, fmt.Errorf("%w: %w", ErrRefreshTokenInvalid, err) + } + return nil, err + } + + return token, nil +} diff --git a/refresh_test.go b/refresh_test.go new file mode 100644 index 0000000..7e2916d --- /dev/null +++ b/refresh_test.go @@ -0,0 +1,210 @@ +package oauth + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/cli/oauth/api" +) + +type refreshClient struct { + status int + body string + contentType string + err error + + postCount int + lastURL string + lastForm url.Values +} + +func (c *refreshClient) PostForm(u string, params url.Values) (*http.Response, error) { + c.postCount++ + c.lastURL = u + c.lastForm = params + if c.err != nil { + return nil, c.err + } + return &http.Response{ + Body: io.NopCloser(bytes.NewBufferString(c.body)), + Header: http.Header{"Content-Type": {c.contentType}}, + StatusCode: c.status, + }, nil +} + +func TestRefresh(t *testing.T) { + transportErr := errors.New("network is unreachable") + host := &Host{TokenURL: "https://example.com/token"} + + tests := []struct { + name string + client *refreshClient + options RefreshOptions + wantToken string + wantForm url.Values + wantPosts int + wantInvalid bool + wantAPIError string + wantError error + wantErrorString string + }{ + { + name: "success with client secret", + client: &refreshClient{ + status: http.StatusOK, + contentType: "application/x-www-form-urlencoded", + body: "access_token=NEWTOKEN&refresh_token=NEWREFRESH&expires_in=28800", + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + ClientSecret: "CLIENTSECRET", + RefreshToken: "OLDREFRESH", + }, + wantToken: "NEWTOKEN", + wantForm: url.Values{ + "client_id": {"CLIENTID"}, + "client_secret": {"CLIENTSECRET"}, + "refresh_token": {"OLDREFRESH"}, + "grant_type": {"refresh_token"}, + }, + wantPosts: 1, + }, + { + name: "success without client secret", + client: &refreshClient{ + status: http.StatusOK, + contentType: "application/x-www-form-urlencoded", + body: "access_token=NEWTOKEN&refresh_token=NEWREFRESH", + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantToken: "NEWTOKEN", + wantForm: url.Values{ + "client_id": {"CLIENTID"}, + "refresh_token": {"OLDREFRESH"}, + "grant_type": {"refresh_token"}, + }, + wantPosts: 1, + }, + { + name: "missing host", + client: &refreshClient{}, + options: RefreshOptions{ + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantPosts: 0, + wantErrorString: "host is required", + }, + { + name: "empty refresh token", + client: &refreshClient{}, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + }, + wantInvalid: true, + wantPosts: 0, + wantErrorString: "refresh token is empty", + }, + { + name: "invalid refresh token", + client: &refreshClient{ + status: http.StatusBadRequest, + contentType: "application/x-www-form-urlencoded", + body: "error=bad_refresh_token&error_description=The+refresh+token+is+invalid.", + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantInvalid: true, + wantAPIError: "bad_refresh_token", + wantPosts: 1, + }, + { + name: "other API error", + client: &refreshClient{ + status: http.StatusBadRequest, + contentType: "application/x-www-form-urlencoded", + body: "error=incorrect_client_credentials", + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantAPIError: "incorrect_client_credentials", + wantPosts: 1, + }, + { + name: "transport error", + client: &refreshClient{ + err: transportErr, + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantError: transportErr, + wantPosts: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.options.HTTPClient = tt.client + token, err := Refresh(tt.options) + + if tt.wantToken != "" { + if err != nil { + t.Fatalf("Refresh() error = %v", err) + } + if token.Token != tt.wantToken { + t.Errorf("Token = %q, want %q", token.Token, tt.wantToken) + } + } else if err == nil { + t.Fatal("Refresh() error = nil") + } + + if errors.Is(err, ErrRefreshTokenInvalid) != tt.wantInvalid { + t.Errorf("errors.Is(ErrRefreshTokenInvalid) = %v, want %v", errors.Is(err, ErrRefreshTokenInvalid), tt.wantInvalid) + } + if tt.wantError != nil && !errors.Is(err, tt.wantError) { + t.Errorf("error = %v, want %v", err, tt.wantError) + } + if tt.wantErrorString != "" && !strings.Contains(err.Error(), tt.wantErrorString) { + t.Errorf("error = %q, want it to contain %q", err, tt.wantErrorString) + } + if tt.wantAPIError != "" { + var apiError *api.Error + if !errors.As(err, &apiError) { + t.Fatalf("error = %v, want *api.Error", err) + } + if apiError.Code != tt.wantAPIError { + t.Errorf("error code = %q, want %q", apiError.Code, tt.wantAPIError) + } + } + if tt.client.postCount != tt.wantPosts { + t.Errorf("post count = %d, want %d", tt.client.postCount, tt.wantPosts) + } + if tt.wantForm != nil && tt.client.lastForm.Encode() != tt.wantForm.Encode() { + t.Errorf("form = %v, want %v", tt.client.lastForm, tt.wantForm) + } + if tt.wantPosts > 0 && tt.client.lastURL != host.TokenURL { + t.Errorf("URL = %q, want %q", tt.client.lastURL, host.TokenURL) + } + }) + } +} From cde372eeffd4a6c1658ad7425df65cd5e08a6e28 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 13 Sep 2026 21:40:33 +0100 Subject: [PATCH 2/8] chore: bump workflow actions Signed-off-by: Babak K. Shandiz --- .github/workflows/ci.yml | 6 +++--- .github/workflows/lint.yml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc38607..352045e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: test: strategy: matrix: - go: [ '1.24', '1.25', '1.26' ] + go: [ '1.24', '1.25', '1.26', '1.27' ] os: [ ubuntu-latest, macos-latest, windows-latest ] fail-fast: false @@ -17,11 +17,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - name: Run tests diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c0d304..664e9d0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,12 +20,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: 'stable' @@ -35,6 +35,6 @@ jobs: go mod download - name: golangci-lint - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.11.0 + version: v2.13.2 From 4beee3af1f0da688f52ff79f363625c243d1dd1e Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 2 Oct 2026 12:12:10 +0200 Subject: [PATCH 3/8] Fix refresh token docs and errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 37 +++++++++++++++++++++++++++++++++---- refresh.go | 2 +- refresh_test.go | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c556f98..ed0c317 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,37 @@ flow := &oauth.Flow{ Scopes: []string{"repo"}, RequestRefreshToken: true, } + +requestedAt := time.Now().UTC() +token, err := flow.DetectFlow() +if err != nil { + return err +} ``` Servers that do not support expiring tokens may ignore the request and return a token without expiration metadata or a refresh token. -Before using an expired token, exchange its refresh token and persist the returned token pair: +The expiry fields are relative durations. Calculate and persist an absolute expiration time when a +token is issued: ```go -if token.IsExpired() && token.CanRefresh() { - token, err = oauth.Refresh(oauth.RefreshOptions{ +expiresAt := time.Time{} +if token.ExpiresIn > 0 { + expiresAt = requestedAt.Add(time.Duration(token.ExpiresIn) * time.Second) +} +if err := saveToken(token, expiresAt); err != nil { + return err +} +``` + +After loading the token and its persisted expiration time, exchange an expired token's refresh token +and persist the returned token pair: + +```go +if token.RefreshToken != "" && !expiresAt.IsZero() && !time.Now().Before(expiresAt) { + requestedAt := time.Now().UTC() + refreshedToken, err := oauth.Refresh(oauth.RefreshOptions{ Host: host, ClientID: clientID, ClientSecret: clientSecret, @@ -53,9 +74,17 @@ if token.IsExpired() && token.CanRefresh() { if err != nil { return err } - if err := saveToken(token); err != nil { + + refreshedExpiresAt := time.Time{} + if refreshedToken.ExpiresIn > 0 { + refreshedExpiresAt = requestedAt.Add(time.Duration(refreshedToken.ExpiresIn) * time.Second) + } + if err := saveToken(refreshedToken, refreshedExpiresAt); err != nil { return err } + + token = refreshedToken + expiresAt = refreshedExpiresAt } ``` diff --git a/refresh.go b/refresh.go index 3c1e0d7..c618987 100644 --- a/refresh.go +++ b/refresh.go @@ -58,7 +58,7 @@ func Refresh(opts RefreshOptions) (*api.AccessToken, error) { token, err := resp.AccessToken() if err != nil { var apiError *api.Error - if errors.As(err, &apiError) && apiError.Code == "bad_refresh_token" { + if errors.As(err, &apiError) && (apiError.Code == "bad_refresh_token" || apiError.Code == "invalid_grant") { return nil, fmt.Errorf("%w: %w", ErrRefreshTokenInvalid, err) } return nil, err diff --git a/refresh_test.go b/refresh_test.go index 7e2916d..5e566fc 100644 --- a/refresh_test.go +++ b/refresh_test.go @@ -132,6 +132,22 @@ func TestRefresh(t *testing.T) { wantAPIError: "bad_refresh_token", wantPosts: 1, }, + { + name: "standard invalid grant response", + client: &refreshClient{ + status: http.StatusBadRequest, + contentType: "application/x-www-form-urlencoded", + body: "error=invalid_grant&error_description=The+refresh+token+is+invalid+or+expired.", + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantInvalid: true, + wantAPIError: "invalid_grant", + wantPosts: 1, + }, { name: "other API error", client: &refreshClient{ From ee10dad78a2ef0a8f27bb3f336018c589f4d7902 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 20 Oct 2026 13:46:45 +0100 Subject: [PATCH 4/8] fix: keep the previous refresh token when the server omits one RFC 6749 section 6 lets a server omit refresh_token from a refresh response, meaning the previous refresh token stays valid. Returning an empty RefreshToken would make callers that persist the result lose a usable refresh token, so fall back to the one passed in RefreshOptions and leave enforcing rotation to the application. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46 --- refresh.go | 8 +++++++- refresh_test.go | 51 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/refresh.go b/refresh.go index c618987..cb31c32 100644 --- a/refresh.go +++ b/refresh.go @@ -27,7 +27,9 @@ type RefreshOptions struct { HTTPClient httpClient } -// Refresh exchanges a refresh token for a new access token and refresh token. +// Refresh exchanges a refresh token for a new access token and refresh token. If the server omits a refresh token +// from the response, the returned token carries the refresh token from opts, since RFC 6749 section 6 allows the +// server to keep the previous one valid. Applications that require rotation must enforce it themselves. func Refresh(opts RefreshOptions) (*api.AccessToken, error) { if opts.Host == nil { return nil, errors.New("host is required") @@ -64,5 +66,9 @@ func Refresh(opts RefreshOptions) (*api.AccessToken, error) { return nil, err } + if token.RefreshToken == "" { + token.RefreshToken = opts.RefreshToken + } + return token, nil } diff --git a/refresh_test.go b/refresh_test.go index 5e566fc..9d606e9 100644 --- a/refresh_test.go +++ b/refresh_test.go @@ -42,16 +42,17 @@ func TestRefresh(t *testing.T) { host := &Host{TokenURL: "https://example.com/token"} tests := []struct { - name string - client *refreshClient - options RefreshOptions - wantToken string - wantForm url.Values - wantPosts int - wantInvalid bool - wantAPIError string - wantError error - wantErrorString string + name string + client *refreshClient + options RefreshOptions + wantToken string + wantRefreshToken string + wantForm url.Values + wantPosts int + wantInvalid bool + wantAPIError string + wantError error + wantErrorString string }{ { name: "success with client secret", @@ -66,7 +67,8 @@ func TestRefresh(t *testing.T) { ClientSecret: "CLIENTSECRET", RefreshToken: "OLDREFRESH", }, - wantToken: "NEWTOKEN", + wantToken: "NEWTOKEN", + wantRefreshToken: "NEWREFRESH", wantForm: url.Values{ "client_id": {"CLIENTID"}, "client_secret": {"CLIENTSECRET"}, @@ -87,7 +89,29 @@ func TestRefresh(t *testing.T) { ClientID: "CLIENTID", RefreshToken: "OLDREFRESH", }, - wantToken: "NEWTOKEN", + wantToken: "NEWTOKEN", + wantRefreshToken: "NEWREFRESH", + wantForm: url.Values{ + "client_id": {"CLIENTID"}, + "refresh_token": {"OLDREFRESH"}, + "grant_type": {"refresh_token"}, + }, + wantPosts: 1, + }, + { + name: "omitted refresh token keeps the previous one", + client: &refreshClient{ + status: http.StatusOK, + contentType: "application/x-www-form-urlencoded", + body: "access_token=NEWTOKEN&expires_in=28800", + }, + options: RefreshOptions{ + Host: host, + ClientID: "CLIENTID", + RefreshToken: "OLDREFRESH", + }, + wantToken: "NEWTOKEN", + wantRefreshToken: "OLDREFRESH", wantForm: url.Values{ "client_id": {"CLIENTID"}, "refresh_token": {"OLDREFRESH"}, @@ -190,6 +214,9 @@ func TestRefresh(t *testing.T) { if token.Token != tt.wantToken { t.Errorf("Token = %q, want %q", token.Token, tt.wantToken) } + if token.RefreshToken != tt.wantRefreshToken { + t.Errorf("RefreshToken = %q, want %q", token.RefreshToken, tt.wantRefreshToken) + } } else if err == nil { t.Fatal("Refresh() error = nil") } From 75e2ae05b339e31832329426ba5fc2ae478ee4e1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 20 Oct 2026 13:46:52 +0100 Subject: [PATCH 5/8] docs: describe refresh token rotation as GitHub behavior Rotation is optional per the OAuth spec, so the README now attributes it to GitHub and notes that Refresh falls back to the previous refresh token when the server does not return a new one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46 --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ed0c317..a27e84b 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,7 @@ if token.RefreshToken != "" && !expiresAt.IsZero() && !time.Now().Before(expires } ``` -Refresh tokens are single use. A successful refresh invalidates the previous access token and refresh token, so -applications must persist the returned replacements. +GitHub rotates refresh tokens on every refresh, so always persist the refresh token returned by `Refresh`. Rotation is optional in OAuth, and when a server omits a new refresh token, `Refresh` returns the one that was passed in. [oauth-device]: https://oauth.net/2/device-flow/ From 29d88bca0ada744f1c983e5beab94a8c3201d320 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 20 Oct 2026 13:47:14 +0100 Subject: [PATCH 6/8] refactor: share scope selection between device and web flows Both flows added offline_access to the requested scopes in the same way, so move that logic onto Flow as requestedScopes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46 --- oauth.go | 8 ++++++++ oauth_device.go | 7 +------ oauth_webapp.go | 7 +------ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/oauth.go b/oauth.go index 1740456..04dcc12 100644 --- a/oauth.go +++ b/oauth.go @@ -110,6 +110,14 @@ func (oa *Flow) DetectFlow() (*api.AccessToken, error) { return accessToken, err } +// requestedScopes returns the scopes to request in either flow, adding offline_access when a refresh token is wanted. +func (oa *Flow) requestedScopes() []string { + if oa.RequestRefreshToken { + return withOfflineAccess(oa.Scopes) + } + return oa.Scopes +} + func withOfflineAccess(scopes []string) []string { if slices.Contains(scopes, "offline_access") { return scopes diff --git a/oauth_device.go b/oauth_device.go index e514abb..6490441 100644 --- a/oauth_device.go +++ b/oauth_device.go @@ -39,12 +39,7 @@ func (oa *Flow) DeviceFlow() (*api.AccessToken, error) { host = parsedHost } - scopes := oa.Scopes - if oa.RequestRefreshToken { - scopes = withOfflineAccess(scopes) - } - - code, err := device.RequestCode(httpClient, host.DeviceCodeURL, oa.ClientID, scopes, device.WithAudience(oa.Audience)) + code, err := device.RequestCode(httpClient, host.DeviceCodeURL, oa.ClientID, oa.requestedScopes(), device.WithAudience(oa.Audience)) if err != nil { return nil, err } diff --git a/oauth_webapp.go b/oauth_webapp.go index caad1ff..a6fb217 100644 --- a/oauth_webapp.go +++ b/oauth_webapp.go @@ -28,15 +28,10 @@ func (oa *Flow) WebAppFlow() (*api.AccessToken, error) { return nil, err } - scopes := oa.Scopes - if oa.RequestRefreshToken { - scopes = withOfflineAccess(scopes) - } - params := webapp.BrowserParams{ ClientID: oa.ClientID, RedirectURI: oa.CallbackURI, - Scopes: scopes, + Scopes: oa.requestedScopes(), Audience: oa.Audience, AllowSignup: true, } From c3f11974721966cbbdbf06ef49f699084d1aba80 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 20 Oct 2026 14:19:24 +0100 Subject: [PATCH 7/8] refactor(api): expose token expiry as time.Duration ExpiresIn and RefreshTokenExpiresIn were int seconds, so every caller had to convert them before doing time arithmetic. Both fields are new and unreleased, so switch them to time.Duration now. The wire format is still parsed as seconds, and device.DeviceCode.ExpiresIn is unchanged since it is already part of the released API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46 --- README.md | 4 ++-- api/access_token.go | 16 ++++++++-------- api/access_token_test.go | 5 +++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a27e84b..be1320d 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ token is issued: ```go expiresAt := time.Time{} if token.ExpiresIn > 0 { - expiresAt = requestedAt.Add(time.Duration(token.ExpiresIn) * time.Second) + expiresAt = requestedAt.Add(token.ExpiresIn) } if err := saveToken(token, expiresAt); err != nil { return err @@ -77,7 +77,7 @@ if token.RefreshToken != "" && !expiresAt.IsZero() && !time.Now().Before(expires refreshedExpiresAt := time.Time{} if refreshedToken.ExpiresIn > 0 { - refreshedExpiresAt = requestedAt.Add(time.Duration(refreshedToken.ExpiresIn) * time.Second) + refreshedExpiresAt = requestedAt.Add(refreshedToken.ExpiresIn) } if err := saveToken(refreshedToken, refreshedExpiresAt); err != nil { return err diff --git a/api/access_token.go b/api/access_token.go index cf161d4..f9fee1c 100644 --- a/api/access_token.go +++ b/api/access_token.go @@ -2,6 +2,7 @@ package api import ( "strconv" + "time" ) // AccessToken is an OAuth access token. @@ -14,12 +15,11 @@ type AccessToken struct { Type string // Space-separated list of OAuth scopes that this token grants. Scope string - // The number of seconds from issuance until Token expires. Zero means the server did not return - // expiration metadata. - ExpiresIn int - // The number of seconds from issuance until RefreshToken expires. Zero means the server did not - // return expiration metadata. - RefreshTokenExpiresIn int + // The time from issuance until Token expires. Zero means the server did not return expiration metadata. + ExpiresIn time.Duration + // The time from issuance until RefreshToken expires. Zero means the server did not return expiration + // metadata. + RefreshTokenExpiresIn time.Duration } // AccessToken extracts the access token information from a server response. @@ -37,11 +37,11 @@ func (f FormResponse) AccessToken() (*AccessToken, error) { } if expiresIn, err := strconv.Atoi(f.Get("expires_in")); err == nil && expiresIn > 0 { - token.ExpiresIn = expiresIn + token.ExpiresIn = time.Duration(expiresIn) * time.Second } if token.RefreshToken != "" { if expiresIn, err := strconv.Atoi(f.Get("refresh_token_expires_in")); err == nil && expiresIn > 0 { - token.RefreshTokenExpiresIn = expiresIn + token.RefreshTokenExpiresIn = time.Duration(expiresIn) * time.Second } } diff --git a/api/access_token_test.go b/api/access_token_test.go index 842bebe..e1c48bd 100644 --- a/api/access_token_test.go +++ b/api/access_token_test.go @@ -4,6 +4,7 @@ import ( "net/url" "reflect" "testing" + "time" ) func TestFormResponse_AccessToken(t *testing.T) { @@ -47,8 +48,8 @@ func TestFormResponse_AccessToken(t *testing.T) { RefreshToken: "AREFRESHTOKEN", Type: "bearer", Scope: "repo gist", - ExpiresIn: 28800, - RefreshTokenExpiresIn: 15897600, + ExpiresIn: 8 * time.Hour, + RefreshTokenExpiresIn: 15897600 * time.Second, }, wantErr: nil, }, From 81bd22f5c66490542cd679b48150b62bc6e91acd Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 20 Oct 2026 16:34:53 +0100 Subject: [PATCH 8/8] ci: bump golangci-lint to v2.14.0 for Go 1.27 The lint workflow installs the latest stable Go, now 1.27.3, whose export data golangci-lint v2.13.2 cannot decode, so every package failed typecheck. v2.14.0 is built with Go 1.27. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46 --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 664e9d0..14eb349 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,4 +37,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.13.2 + version: v2.14.0