lib/oauthutil: add ScopedTokenSource to get tokens for other scopes
ScopedTokenSource returns a token source which fetches tokens for different scopes using the same credentials. Without the client credentials flow it redeems the current refresh token for the new scopes, which Microsoft Entra ID allows for other resources such as SharePoint. The tokens are cached but never stored in the config file.
This commit is contained in:
1 parent
5ecc9f685b
commit
c7a34b80d3
2 files changed
+95
No files matched your search
@@ -444,6 +444,46 @@ func (ts *TokenSource) OnExpiry() <-chan time.Time {
|
||||
// Check interface satisfied
|
||||
var _ oauth2.TokenSource = (*TokenSource)(nil)
|
||||
|
||||
// ScopedTokenSource returns a TokenSource which gets tokens for scopes
|
||||
// instead of the configured ones, using the same credentials as ts.
|
||||
//
|
||||
// Without the client credentials flow this redeems the current
|
||||
// refresh token of ts for the new scopes, which providers such as
|
||||
// Microsoft Entra ID allow for other resources the user has consented
|
||||
// to. The tokens are cached until they expire but are never saved in
|
||||
// the config file.
|
||||
func (ts *TokenSource) ScopedTokenSource(scopes []string) oauth2.TokenSource {
|
||||
return oauth2.ReuseTokenSource(nil, scopedTokenSource{ts: ts, scopes: scopes})
|
||||
}
|
||||
|
||||
// scopedTokenSource fetches tokens for scopes using the credentials in ts
|
||||
type scopedTokenSource struct {
|
||||
ts *TokenSource
|
||||
scopes []string
|
||||
}
|
||||
|
||||
// Token fetches a new token for the scopes
|
||||
func (s scopedTokenSource) Token() (*oauth2.Token, error) {
|
||||
conf := s.ts.config.MakeClientCredentialsConfig()
|
||||
conf.Scopes = s.scopes
|
||||
if !s.ts.config.ClientCredentialFlow {
|
||||
token, err := s.ts.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
return nil, errors.New("no refresh token to get a token for other scopes")
|
||||
}
|
||||
// clientcredentials allows grant_type to be overridden, which
|
||||
// makes it a refresh request which can set the scopes
|
||||
conf.EndpointParams = url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {token.RefreshToken},
|
||||
}
|
||||
}
|
||||
return conf.Token(s.ts.ctx)
|
||||
}
|
||||
|
||||
// Context returns a context with our HTTP Client baked in for oauth2
|
||||
func Context(ctx context.Context, client *http.Client) context.Context {
|
||||
return context.WithValue(ctx, oauth2.HTTPClient, client)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/rclone/rclone/fs/config/configmap"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// TestAuthServerHandleAuth checks that only a callback carrying the
|
||||
@@ -146,3 +147,57 @@ func TestConfigOauthPKCE(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedTokenSource checks that a token for other scopes is
|
||||
// fetched with the current refresh token, or with client credentials,
|
||||
// and that it is cached without touching the stored token.
|
||||
func TestScopedTokenSource(t *testing.T) {
|
||||
for _, clientCredentials := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("clientCredentials=%v", clientCredentials), func(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var forms []url.Values
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
mu.Lock()
|
||||
forms = append(forms, r.PostForm)
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"access_token":"scoped_access","token_type":"Bearer","refresh_token":"new_refresh","expires_in":3600}`)
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
storedToken := `{"access_token":"access","token_type":"Bearer","refresh_token":"refresh","expiry":"2099-01-01T00:00:00Z"}`
|
||||
m := configmap.Simple{"token": storedToken}
|
||||
ctx := Context(context.Background(), tokenServer.Client())
|
||||
_, ts, err := NewClientWithBaseClient(ctx, "remote", m, &Config{
|
||||
ClientID: "client_id",
|
||||
ClientSecret: "client_secret",
|
||||
TokenURL: tokenServer.URL + "/token",
|
||||
Scopes: []string{"main"},
|
||||
ClientCredentialFlow: clientCredentials,
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
}, tokenServer.Client())
|
||||
require.NoError(t, err)
|
||||
|
||||
src := ts.ScopedTokenSource([]string{"https://other/.default"})
|
||||
for range 2 {
|
||||
token, err := src.Token()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "scoped_access", token.AccessToken)
|
||||
}
|
||||
|
||||
require.Len(t, forms, 1, "the scoped token should be cached")
|
||||
form := forms[0]
|
||||
assert.Equal(t, "https://other/.default", form.Get("scope"))
|
||||
assert.Equal(t, "client_id", form.Get("client_id"))
|
||||
if clientCredentials {
|
||||
assert.Equal(t, "client_credentials", form.Get("grant_type"))
|
||||
assert.Empty(t, form.Get("refresh_token"))
|
||||
} else {
|
||||
assert.Equal(t, "refresh_token", form.Get("grant_type"))
|
||||
assert.Equal(t, "refresh", form.Get("refresh_token"))
|
||||
}
|
||||
assert.Equal(t, storedToken, m["token"], "the stored token must not change")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user