Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ 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

name: Test suite
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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 30 additions & 8 deletions api/access_token.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
}
20 changes: 12 additions & 8 deletions api/access_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
35 changes: 35 additions & 0 deletions examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package oauth_test

import (
"fmt"
"net/http"
"os"

"github.com/cli/oauth"
Expand Down Expand Up @@ -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)
}
14 changes: 14 additions & 0 deletions oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"slices"
"strings"

"github.com/cli/oauth/api"
Expand Down Expand Up @@ -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.
Expand All @@ -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")
}
8 changes: 6 additions & 2 deletions oauth_device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
38 changes: 38 additions & 0 deletions oauth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
7 changes: 6 additions & 1 deletion oauth_webapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
68 changes: 68 additions & 0 deletions refresh.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading