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:
Nick Craig-Wood committed 2026-11-05 12:52:29 +00:00
1 parent 5ecc9f685b
commit c7a34b80d3
2 files changed
+95

No files matched your search

+40
View File
@@ -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)
+55
View File
@@ -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")
})
}
}