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/2] 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/2] 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