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
4 changes: 3 additions & 1 deletion cli/apiconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ func initAPIConfig() {
"client_id": buildCfg.ClientID,
"client_secret": "",
"redirect_url": "",
"scopes": "openid profile email",
// offline_access asks Auth0 for a refresh token, so an expired
// access token is renewed silently instead of via the browser.
"scopes": "openid profile email offline_access",
},
}

Expand Down
12 changes: 12 additions & 0 deletions cli/serviceaccount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,18 @@ func TestLogoutClearsOnlyServiceAccountToken(t *testing.T) {
assert.Equal(t, "browser-token", Cache.GetString("apimetrics:default.token"))
}

func TestBrowserLoginRequestsOfflineAccess(t *testing.T) {
defer func(sa *ServiceAccount) { activeServiceAccount = sa }(activeServiceAccount)
activeServiceAccount = nil

initAPIConfig()
profile := configs["apimetrics"].Profiles["default"]

// Without offline_access Auth0 issues no refresh token, and every expired
// access token sends the user back through the browser.
assert.Contains(t, strings.Fields(profile.Auth.Params["scopes"]), "offline_access")
}

// recordingAuth records which handler a login used and with what.
type recordingAuth struct {
key string
Expand Down
7 changes: 4 additions & 3 deletions oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ func TokenHandler(source oauth2.TokenSource, key string, request *http.Request)
cli.Cache.Set(tokenKey, token.AccessToken)

if token.RefreshToken != "" {
// Only set the refresh token if present. This prevents overwriting it
// after using a refresh token, because the newly returned token won't
// have another refresh token set on it (you keep using the same one).
// Only set the refresh token if present. With refresh token
// rotation the server returns a new one on every refresh, which
// replaces the old; without it the response has none, and keeping
// the existing one avoids wiping it out.
cli.Cache.Set(refreshKey, token.RefreshToken)
}

Expand Down
4 changes: 3 additions & 1 deletion oauth/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ func (ts *RefreshTokenSource) Token() (*oauth2.Token, error) {
return token, err
}

// Couldn't refresh, try the original source again.
// Couldn't refresh (e.g. the refresh token expired or was revoked),
// so fall back to the original source, which means a browser login.
cli.LogDebug("Refresh token rejected, logging in again: %v", err)
}

token, err := ts.TokenSource.Token()
Expand Down
70 changes: 70 additions & 0 deletions oauth/refresh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package oauth

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
)

// fallbackSource stands in for the browser login.
type fallbackSource struct {
token *oauth2.Token
calls int
}

func (s *fallbackSource) Token() (*oauth2.Token, error) {
s.calls++
return s.token, nil
}

func TestRefreshTokenSourceUsesRefreshToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
assert.Equal(t, "refresh_token", r.PostForm.Get("grant_type"))
assert.Equal(t, "client-1", r.PostForm.Get("client_id"))
assert.Equal(t, "old-refresh", r.PostForm.Get("refresh_token"))
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"new-access","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated-refresh"}`))
}))
defer srv.Close()

fallback := &fallbackSource{}
ts := &RefreshTokenSource{
ClientID: "client-1",
TokenURL: srv.URL,
RefreshToken: "old-refresh",
TokenSource: fallback,
}

token, err := ts.Token()
require.NoError(t, err)
assert.Equal(t, "new-access", token.AccessToken)
assert.Equal(t, "rotated-refresh", token.RefreshToken)
assert.Zero(t, fallback.calls, "a working refresh token must not trigger a browser login")
}

func TestRefreshTokenSourceFallsBackWhenRejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"invalid_grant"}`))
}))
defer srv.Close()

fallback := &fallbackSource{token: &oauth2.Token{AccessToken: "login-access", RefreshToken: "login-refresh"}}
ts := &RefreshTokenSource{
ClientID: "client-1",
TokenURL: srv.URL,
RefreshToken: "expired-refresh",
TokenSource: fallback,
}

token, err := ts.Token()
require.NoError(t, err)
assert.Equal(t, "login-access", token.AccessToken)
assert.Equal(t, 1, fallback.calls)
assert.Equal(t, "login-refresh", ts.RefreshToken)
}
Loading