Skip to content
Draft
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

## Unreleased

### Added: opt-in support for expiring access tokens and refresh tokens

GitHub OAuth apps can issue access tokens that expire after 8 hours along with a refresh token valid
for 6 months. This release adds support for that flow. It is **opt-in and fully backwards
compatible** — existing applications continue to receive non-expiring tokens with no code changes.

- `Flow.RequestRefreshToken` opts an authorization into expiring tokens by requesting the
`offline_access` scope, in both Device flow and Web application flow. Also available as
`device.WithRefreshToken()` and `webapp.WithRefreshToken()` for callers using those packages
directly.
- `api.AccessToken` now records `ExpiresIn`, `ExpiresAt`, `RefreshTokenExpiresIn`, and
`RefreshTokenExpiresAt`, with `IsExpired()` and `CanRefresh()` helpers.
- `api.Refresh` exchanges a refresh token for a new token. A rejected refresh token is reported as
`api.ErrRefreshTokenInvalid`.
- `oauth.TokenSource` holds a token and refreshes it on demand, with an `OnRefresh` callback for
persisting rotated credentials.
- `oauth.NewHTTPClient` returns an `http.Client` that attaches the token and, on a rejected request,
refreshes once and retries once.

Servers that do not support expiring tokens ignore the request and return a non-expiring token with
no refresh token, so applications must not assume a refresh token was issued.

See the "Expiring access tokens" section of the README for an adoption guide.
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,98 @@ To transparently enable OAuth authorization on _any GitHub host_ (e.g. GHES inst
## Usage

- [OAuth Device flow with fallback](./examples_test.go)
- [OAuth flow with refresh token support](./examples_test.go)
- [manual OAuth Device flow](./device/examples_test.go)
- [manual OAuth web application flow](./webapp/examples_test.go)

Applications that need more control over the user experience around authentication should directly interface with `github.com/cli/oauth/device` and `github.com/cli/oauth/webapp` packages.

In theory, these packages would enable authorization on any OAuth-enabled host. In practice, however, this was only tested for authorizing with GitHub.

## Expiring access tokens

GitHub OAuth apps can issue access tokens that expire after 8 hours, accompanied by a refresh token that is valid for 6 months. Rotating tokens limits the damage a leaked token can do. See [Expiring access tokens][gh-expiring].

Support in this library is **opt-in and off by default**: existing code continues to receive non-expiring tokens and needs no changes.

### 1. Opt in

Set `RequestRefreshToken`, which requests the `offline_access` scope so that GitHub issues an expiring token even if your app isn't globally configured for them:

```go
flow := &oauth.Flow{
Host: host,
ClientID: clientID,
Scopes: []string{"repo", "read:org"},

RequestRefreshToken: true, // <- the only change needed to opt in
}

accessToken, err := flow.DetectFlow()
```

`offline_access` is not a normal scope: it doesn't widen the token's access and doesn't add anything to the authorization prompt.

### 2. Persist the new fields

Apps typically store only `accessToken.Token`. That is no longer enough — you must persist the refresh token and both expiration times, or the user will have to re-authorize every 8 hours:

```go
type storedCredentials struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at,omitempty"`
}
```

If `RefreshToken` is empty, the server does not support expiring tokens. This is expected on GitHub Enterprise Server. Store the token as you always have and skip the rest of this section — **never assume a refresh token was returned**.

### 3. Use a `TokenSource` instead of setting the header yourself

Wrap the token in a `TokenSource` and build an HTTP client from it. The client attaches the `Authorization` header, refreshes the token when it expires, and — if the server rejects a request anyway — refreshes once and retries the request exactly once:

```go
src := oauth.NewTokenSource(accessToken, clientID, clientSecret, host.TokenURL)
httpClient := oauth.NewHTTPClient(src)

resp, err := httpClient.Get("https://api.github.com/user")
```

Replace any code that sets `Authorization` manually. Share one `TokenSource` across your app so a refresh performed for one request is seen by all the others.

### 4. Save rotated tokens with `OnRefresh`

**Refresh tokens are single-use.** A successful refresh immediately invalidates both the old access token and the old refresh token, so a rotated token that you fail to save is a token you have lost:

```go
src.OnRefresh = func(token *api.AccessToken) error {
return saveCredentials(token)
}
```

If `OnRefresh` returns an error, that error is returned to the caller so the failure is visible, but the `TokenSource` keeps the refreshed token — discarding it would not bring back the old one. The callback runs without the `TokenSource` lock held, so it is safe for it to use a client built from the same source.

### 5. Handle the failure cases

| Situation | How to detect it | What to do |
| --- | --- | --- |
| Refresh token expired or already used | `errors.Is(err, api.ErrRefreshTokenInvalid)` | Send the user through the flow again |
| Token expired, no refresh token available | `errors.Is(err, oauth.ErrNotRefreshable)` | Send the user through the flow again |
| Server doesn't support expiring tokens | `accessToken.RefreshToken == ""` | Nothing — behaves exactly as before |

### Adoption checklist

1. Set `RequestRefreshToken: true` on your `Flow`.
2. Extend your credential storage with `RefreshToken`, `ExpiresAt`, and `RefreshTokenExpiresAt`.
3. Build a `TokenSource` from the stored token and replace manual `Authorization` headers with `oauth.NewHTTPClient`.
4. Set `OnRefresh` to persist rotated tokens.
5. Handle `api.ErrRefreshTokenInvalid` and `oauth.ErrNotRefreshable` by restarting the authorization flow.
6. Confirm your app still works against a server that returns no refresh token.

See [the complete example](./examples_test.go).


[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
[gh-expiring]: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#expiring-access-tokens
77 changes: 69 additions & 8 deletions api/access_token.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
package api

import (
"strconv"
"time"
)

// timeNow is swappable in tests.
var timeNow = time.Now

// expiryLeeway is subtracted from a token's expiration time when determining whether it is expired,
// so that a token is not considered valid moments before the server would reject it.
const expiryLeeway = 60 * time.Second

// AccessToken is an OAuth access token.
type AccessToken struct {
// The token value, typically a 40-character random string.
Expand All @@ -10,18 +22,67 @@ type AccessToken struct {
Type string
// Space-separated list of OAuth scopes that this token grants.
Scope string

// The number of seconds from the time of issue until Token expires. Zero if the server issued a
// non-expiring token.
ExpiresIn int
// The number of seconds from the time of issue until RefreshToken expires. Zero if the server did
// not issue a refresh token.
RefreshTokenExpiresIn int
// The absolute time at which Token expires. Zero if the server issued a non-expiring token.
ExpiresAt time.Time
// The absolute time at which RefreshToken expires. Zero if the server did not issue a refresh token.
RefreshTokenExpiresAt time.Time
}

// IsExpired reports whether the access token has expired. Tokens that never expire are never
// reported as expired. A small leeway is applied to guard against clock skew.
func (t *AccessToken) IsExpired() bool {
if t == nil || t.ExpiresAt.IsZero() {
return false
}
return !timeNow().Before(t.ExpiresAt.Add(-expiryLeeway))
}

// CanRefresh reports whether the token carries a refresh token that has not itself expired. If it
// returns false, obtaining a new token requires sending the user through an authorization flow again.
func (t *AccessToken) CanRefresh() bool {
if t == nil || t.RefreshToken == "" {
return false
}
if t.RefreshTokenExpiresAt.IsZero() {
return true
Comment on lines +53 to +54

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems inverted - if the RT_expires_at is 0 that seems like there's no RT and therefore it can't be refreshed.

}
return timeNow().Before(t.RefreshTokenExpiresAt.Add(-expiryLeeway))
}

// 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()
}

now := timeNow()
token := &AccessToken{
Token: accessToken,
RefreshToken: f.Get("refresh_token"),
Type: f.Get("token_type"),
Scope: f.Get("scope"),
}

// Servers that do not support expiring tokens omit these values entirely. Unparseable values are
// treated the same as missing ones so that a usable token is never discarded over metadata.
if expiresIn, err := strconv.Atoi(f.Get("expires_in")); err == nil && expiresIn > 0 {
token.ExpiresIn = expiresIn
token.ExpiresAt = now.Add(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.RefreshTokenExpiresAt = now.Add(time.Duration(expiresIn) * time.Second)
}
}

return nil, f.Err()
return token, nil
}
168 changes: 168 additions & 0 deletions api/access_token_expiry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package api

import (
"net/url"
"testing"
"time"
)

func TestFormResponse_AccessToken_expiry(t *testing.T) {
now := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC)
timeNow = func() time.Time { return now }
t.Cleanup(func() { timeNow = time.Now })

tests := []struct {
name string
values url.Values
wantExpiresIn int
wantExpiresAt time.Time
wantRefreshExpiresIn int
wantRefreshTokenExpiresAt time.Time
}{
{
name: "expiring token with refresh token",
values: url.Values{
"access_token": []string{"ATOKEN"},
"refresh_token": []string{"RTOKEN"},
"expires_in": []string{"28800"},
"refresh_token_expires_in": []string{"15897600"},
},
wantExpiresIn: 28800,
wantExpiresAt: now.Add(28800 * time.Second),
wantRefreshExpiresIn: 15897600,
wantRefreshTokenExpiresAt: now.Add(15897600 * time.Second),
},
{
name: "server without expiring token support",
values: url.Values{
"access_token": []string{"ATOKEN"},
"token_type": []string{"bearer"},
},
},
{
name: "unparseable expiry is ignored",
values: url.Values{
"access_token": []string{"ATOKEN"},
"expires_in": []string{"soon"},
},
},
{
name: "refresh expiry ignored without refresh token",
values: url.Values{
"access_token": []string{"ATOKEN"},
"refresh_token_expires_in": []string{"15897600"},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := FormResponse{values: tt.values}.AccessToken()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.ExpiresIn != tt.wantExpiresIn {
t.Errorf("ExpiresIn = %d, want %d", got.ExpiresIn, tt.wantExpiresIn)
}
if !got.ExpiresAt.Equal(tt.wantExpiresAt) {
t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, tt.wantExpiresAt)
}
if got.RefreshTokenExpiresIn != tt.wantRefreshExpiresIn {
t.Errorf("RefreshTokenExpiresIn = %d, want %d", got.RefreshTokenExpiresIn, tt.wantRefreshExpiresIn)
}
if !got.RefreshTokenExpiresAt.Equal(tt.wantRefreshTokenExpiresAt) {
t.Errorf("RefreshTokenExpiresAt = %v, want %v", got.RefreshTokenExpiresAt, tt.wantRefreshTokenExpiresAt)
}
})
}
}

func TestAccessToken_IsExpired(t *testing.T) {
now := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC)
timeNow = func() time.Time { return now }
t.Cleanup(func() { timeNow = time.Now })

tests := []struct {
name string
token *AccessToken
want bool
}{
{name: "nil token", token: nil, want: false},
{name: "non-expiring token", token: &AccessToken{Token: "A"}, want: false},
{name: "valid token", token: &AccessToken{ExpiresAt: now.Add(time.Hour)}, want: false},
{name: "expired token", token: &AccessToken{ExpiresAt: now.Add(-time.Second)}, want: true},
{name: "within leeway counts as expired", token: &AccessToken{ExpiresAt: now.Add(30 * time.Second)}, want: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.token.IsExpired(); got != tt.want {
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
}
})
}
}

func TestAccessToken_CanRefresh(t *testing.T) {
now := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC)
timeNow = func() time.Time { return now }
t.Cleanup(func() { timeNow = time.Now })

tests := []struct {
name string
token *AccessToken
want bool
}{
{name: "nil token", token: nil, want: false},
{name: "no refresh token", token: &AccessToken{Token: "A"}, want: false},
{name: "refresh token without expiry", token: &AccessToken{RefreshToken: "R"}, want: true},
{name: "unexpired refresh token", token: &AccessToken{RefreshToken: "R", RefreshTokenExpiresAt: now.Add(time.Hour)}, want: true},
{name: "expired refresh token", token: &AccessToken{RefreshToken: "R", RefreshTokenExpiresAt: now.Add(-time.Hour)}, want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.token.CanRefresh(); got != tt.want {
t.Errorf("CanRefresh() = %v, want %v", got, tt.want)
}
})
}
}

func TestAppendOfflineAccess(t *testing.T) {
tests := []struct {
name string
input []string
want []string
}{
{name: "empty", input: nil, want: []string{"offline_access"}},
{name: "appends", input: []string{"repo"}, want: []string{"repo", "offline_access"}},
{name: "dedupes", input: []string{"repo", "offline_access"}, want: []string{"repo", "offline_access"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AppendOfflineAccess(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}

func TestAppendOfflineAccess_doesNotMutateInput(t *testing.T) {
// A caller's Scopes slice with spare capacity must not be written through.
input := make([]string, 1, 4)
input[0] = "repo"

_ = AppendOfflineAccess(input)

if got := input[:cap(input)]; got[1] != "" {
t.Errorf("input slice was mutated: %q", got[1])
}
}
Loading