From 741f62386c604ad36f07abe9aff1f8847d7ca5b4 Mon Sep 17 00:00:00 2001 From: Dallin Stevens Date: Thu, 30 Apr 2026 13:58:04 -0600 Subject: [PATCH 1/3] fix(externalauth): preserve scopes on token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External-auth providers backed by Microsoft Entra ID's v1 token issuer silently narrow refreshed access tokens to the user's default consent set, dropping any custom resource scopes the original token carried. Symptom: external-auth-backed integrations work for ~1 hour after a fresh sign-in, then fail silently after the first refresh — the token is signature-valid, but resource servers reject it because the scope they need is gone. Hit this with an Entra ID external auth provider whose scopes include `api:///session:role-any` for Snowflake External OAuth: every refresh produced a token containing only User.Read, and Snowflake returned "requested role is not listed in the access token or was filtered". Root cause: golang.org/x/oauth2's TokenSource refresh path does not echo the original `scope` parameter on the refresh request, and Entra v1 treats the absence of `scope` as "fall back to default consent set". Mirrors the fix in stacklok/toolhive#5096, which patched the same upstream library bug for the same reason: replace TokenSource(...).Token() with a direct Exchange call carrying explicit grant_type/refresh_token/scope params. Also preserve the original refresh_token on the response when the authorization server omits a new one (per RFC 6749 §6). - Add Config.Scopes mirroring oauth2.Config.Scopes; populate from ExternalAuthConfig.Scopes in ConvertConfig. - Replicate TokenSource's not-expired-yet short-circuit in the new refresh path so we don't hit the IdP unnecessarily. --- coderd/externalauth/externalauth.go | 68 +++++++++-- coderd/externalauth/externalauth_test.go | 146 +++++++++++++++++++++++ 2 files changed, 202 insertions(+), 12 deletions(-) diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 9b11b7f3ed79d..14a6f8b9a3e4c 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -142,6 +142,13 @@ type Config struct { // defaultRefreshRetryTimeout. A negative value disables transient-failure // retries entirely, so exactly one refresh attempt is made. RefreshRetryTimeout time.Duration + + // Scopes mirrors *oauth2.Config.Scopes so the refresh path can echo them + // on the token-endpoint request. Without this, Entra v1's token issuer + // silently narrows refreshed tokens to the user's default consent set + // (golang.org/x/oauth2's TokenSource omits the scope parameter on + // refresh; Entra v1 treats absence of scope as "use default consent"). + Scopes []string } // Git returns a Provider for this config if the provider type is a @@ -216,10 +223,6 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu Expiry: externalAuthLink.OAuthExpiry, } - // Note: The TokenSource(...) method will make no remote HTTP requests if the - // token is expired and no refresh token is set. This is important to prevent - // spamming the API, consuming rate limits, when the token is known to fail. - // // External providers (GitHub in particular) intermittently fail token // refreshes with transient errors such as 5xx responses, network timeouts, // and rate-limited 429s. Retry with exponential backoff before surfacing @@ -229,9 +232,9 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu // will never succeed and retrying wastes the refresh quota. token, err := c.refreshTokenWithRetry(ctx, existingToken) if err != nil { - // TokenSource can fail for numerous reasons. If it fails because of - // a bad refresh token, then the refresh token is invalid, and we should - // get rid of it. Keeping it around will cause additional refresh + // A refresh attempt can fail for numerous reasons. If it fails because + // of a bad refresh token, then the refresh token is invalid, and we + // should get rid of it. Keeping it around will cause additional refresh // attempts that will fail and cost us api rate limits. // // The error message is saved for debugging purposes. @@ -305,8 +308,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried") } - // TokenSource(...).Token() will always return the current token if the token is not expired. - // So this error is only returned if a refresh of the token failed. + // Non-expired tokens are short-circuited above; reaching here means refresh failed. return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error())) } @@ -394,9 +396,18 @@ validate: // refresh token is set, and a negative RefreshRetryTimeout all bypass the // retry loop so a doomed or unwanted refresh is not repeatedly attempted. func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth2.Token) (*oauth2.Token, error) { + // TokenSource used to short-circuit non-expired tokens internally; the + // direct Exchange path in refreshTokenOnce does not, so replicate the + // check here. Return the existing token so the caller still runs its + // extra-token-key generation and ValidateToken step. + if existingToken.Valid() { + return existingToken, nil + } + // Without a refresh token the oauth2 library short-circuits with // "token expired and refresh token is not set". No retry can recover - // from that, so make a single attempt and return. + // from that, so delegate to TokenSource for a single attempt (test + // doubles also rely on this path being TokenSource-backed). if existingToken.RefreshToken == "" { return c.TokenSource(ctx, existingToken).Token() } @@ -429,7 +440,7 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth err error ) for { - token, err = c.TokenSource(ctx, existingToken).Token() + token, err = c.refreshTokenOnce(ctx, existingToken.RefreshToken) if err == nil || isFailedRefresh(existingToken, err) { return token, err } @@ -447,6 +458,38 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth } } +// refreshTokenOnce performs a single OAuth refresh via direct Exchange, +// carrying explicit grant_type/refresh_token/scope params. Bypasses the +// golang.org/x/oauth2 TokenSource path because it omits `scope` on refresh, +// which causes Entra v1 to silently narrow refreshed tokens to the user's +// default consent set. +// +// Exchange dispatches through InstrumentedOAuth2Config, which for some +// providers wraps the underlying oauth2.Config: +// - entraV1Oauth (Azure DevOps + Entra v1): appends resource=, +// which is also required on refresh for v1 tokens — correct for us. +// - jwtConfig (Azure DevOps non-Entra): overrides grant_type with +// urn:ietf:params:oauth:grant-type:jwt-bearer. JWT configs don't issue +// refresh tokens in practice, so we never reach this path with +// refreshToken != "" for jwtConfig. +func (c *Config) refreshTokenOnce(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + refreshOpts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", "refresh_token"), + oauth2.SetAuthURLParam("refresh_token", refreshToken), + } + if len(c.Scopes) > 0 { + refreshOpts = append(refreshOpts, oauth2.SetAuthURLParam("scope", strings.Join(c.Scopes, " "))) + } + token, err := c.Exchange(ctx, "", refreshOpts...) + // Per RFC 6749 §6, the AS MAY return a new refresh token and SHOULD treat + // the existing one as still valid otherwise. Preserve it so the next + // refresh has something to send. + if err == nil && token != nil && token.RefreshToken == "" { + token.RefreshToken = refreshToken + } + return token, err +} + // ValidateToken checks if the Git token provided is valid. // The user is optionally returned if the provider supports it. // Returns valid=true when: the provider confirmed the token, @@ -907,6 +950,7 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut ID: entry.ID, ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, + Scopes: entry.Scopes, Regex: regex, APIBaseURL: entry.APIBaseURL, Type: entry.Type, @@ -1427,7 +1471,7 @@ func isRateLimited(resp *http.Response) bool { return false } -// isFailedRefresh returns true if the error returned by the TokenSource.Token() +// isFailedRefresh returns true if the error returned by the refresh attempt // is due to a failed refresh. The failure being the refresh token itself. // If this returns true, no amount of retries will fix the issue. // diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 4221e7330903d..0fb5342a8a8b3 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -850,6 +851,151 @@ func TestRefreshToken(t *testing.T) { }) } +// TestRefreshTokenWithScopes verifies the refresh path echoes Config.Scopes on +// the token-endpoint request and preserves the prior refresh_token when the +// authorization server omits a new one (RFC 6749 §6). +func TestRefreshTokenWithScopes(t *testing.T) { + t.Parallel() + + // fakeAS returns an http.Client + a pointer the test can read after + // RefreshToken returns. The roundTripper captures the form body of every + // outbound request and replies with tokenJSON to refresh requests. + fakeAS := func(t *testing.T, tokenJSON []byte) (*http.Client, *url.Values) { + t.Helper() + captured := &url.Values{} + client := &http.Client{Transport: roundTripper(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + values, err := url.ParseQuery(string(body)) + require.NoError(t, err) + if values.Get("grant_type") == "refresh_token" { + *captured = values + } + rec := httptest.NewRecorder() + rec.Header().Set("Content-Type", "application/json") + rec.WriteHeader(http.StatusOK) + _, err = rec.Write(tokenJSON) + return rec.Result(), err + })} + return client, captured + } + + newConfig := func(t *testing.T, scopes []string) *externalauth.Config { + t.Helper() + instrument := promoauth.NewFactory(prometheus.NewRegistry()) + return &externalauth.Config{ + ID: "test", + InstrumentedOAuth2Config: instrument.New("test", &oauth2.Config{ + ClientID: "id", + ClientSecret: "secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.invalid/auth", + TokenURL: "https://example.invalid/token", + }, + Scopes: scopes, + }), + Scopes: scopes, + } + } + + expired := dbtime.Now().Add(-time.Hour) + + // mockDBPassthrough returns a mock store that echoes the + // UpdateExternalAuthLink params back as a populated ExternalAuthLink, + // letting the test read what RefreshToken decided to persist. + mockDBPassthrough := func(t *testing.T) database.Store { + t.Helper() + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + mDB.EXPECT().UpdateExternalAuthLink(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, p database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) { + return database.ExternalAuthLink{ + ProviderID: p.ProviderID, + UserID: p.UserID, + OAuthAccessToken: p.OAuthAccessToken, + OAuthRefreshToken: p.OAuthRefreshToken, + OAuthExpiry: p.OAuthExpiry, + }, nil + }).AnyTimes() + return mDB + } + + t.Run("EchoesConfiguredScopesOnRefresh", func(t *testing.T) { + t.Parallel() + client, captured := fakeAS(t, + []byte(`{"access_token":"new","refresh_token":"new-r","token_type":"bearer","expires_in":3600}`)) + cfg := newConfig(t, []string{"openid", "offline_access", "api://app/session:role-any"}) + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + _, err := cfg.RefreshToken(ctx, mockDBPassthrough(t), database.ExternalAuthLink{ + OAuthAccessToken: "old", + OAuthRefreshToken: "old-r", + OAuthExpiry: expired, + }) + require.NoError(t, err) + + require.Equal(t, "refresh_token", captured.Get("grant_type")) + require.Equal(t, "old-r", captured.Get("refresh_token")) + require.Equal(t, "openid offline_access api://app/session:role-any", captured.Get("scope"), + "refresh request must echo configured scopes joined by space") + }) + + t.Run("OmitsScopeParamWhenScopesEmpty", func(t *testing.T) { + t.Parallel() + client, captured := fakeAS(t, + []byte(`{"access_token":"new","refresh_token":"new-r","token_type":"bearer","expires_in":3600}`)) + cfg := newConfig(t, nil) + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + _, err := cfg.RefreshToken(ctx, mockDBPassthrough(t), database.ExternalAuthLink{ + OAuthAccessToken: "old", + OAuthRefreshToken: "old-r", + OAuthExpiry: expired, + }) + require.NoError(t, err) + + require.Equal(t, "refresh_token", captured.Get("grant_type")) + require.Equal(t, "old-r", captured.Get("refresh_token")) + require.Empty(t, captured.Get("scope"), + "refresh request must not send a scope param when Config.Scopes is empty") + }) + + t.Run("PreservesPriorRefreshTokenWhenASOmitsNewOne", func(t *testing.T) { + t.Parallel() + // Token response intentionally omits refresh_token. + client, _ := fakeAS(t, + []byte(`{"access_token":"new","token_type":"bearer","expires_in":3600}`)) + cfg := newConfig(t, nil) + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + link, err := cfg.RefreshToken(ctx, mockDBPassthrough(t), database.ExternalAuthLink{ + OAuthAccessToken: "old", + OAuthRefreshToken: "prior-r", + OAuthExpiry: expired, + }) + require.NoError(t, err) + require.Equal(t, "prior-r", link.OAuthRefreshToken, + "prior refresh_token must be preserved when AS omits a new one (RFC 6749 §6)") + }) + + t.Run("AcceptsRotatedRefreshTokenWhenASReturnsOne", func(t *testing.T) { + t.Parallel() + client, _ := fakeAS(t, + []byte(`{"access_token":"new","refresh_token":"rotated-r","token_type":"bearer","expires_in":3600}`)) + cfg := newConfig(t, nil) + + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + link, err := cfg.RefreshToken(ctx, mockDBPassthrough(t), database.ExternalAuthLink{ + OAuthAccessToken: "old", + OAuthRefreshToken: "prior-r", + OAuthExpiry: expired, + }) + require.NoError(t, err) + require.Equal(t, "rotated-r", link.OAuthRefreshToken, + "rotated refresh_token from AS must be persisted") + }) +} + func TestValidateToken(t *testing.T) { t.Parallel() From 07e3c41f849468fb303a3598fe20dff49c0cef15 Mon Sep 17 00:00:00 2001 From: Dallin Stevens Date: Tue, 7 Jul 2026 22:17:10 -0500 Subject: [PATCH 2/3] refactor(coderd/externalauth): move scope-preserving refresh into entraV1Oauth.TokenSource Address review feedback: instead of a generic (*Config).refreshTokenOnce helper backed by a new Config.Scopes field, override TokenSource on entraV1Oauth so the scope-preserving refresh applies only to the provider that needs it and stays transparent to callers. The custom token source wraps oauth2.ReuseTokenSource (preserving the not-expired short-circuit) and, on refresh, does a direct Exchange with explicit grant_type/refresh_token/scope params, reading scopes from the embedded oauth2.Config.Scopes. refreshTokenWithRetry is unchanged from main and keeps calling c.TokenSource(...).Token(). --- coderd/externalauth/externalauth.go | 94 +++++++++++------------- coderd/externalauth/externalauth_test.go | 24 +++--- 2 files changed, 54 insertions(+), 64 deletions(-) diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 14a6f8b9a3e4c..f503d5e4cb200 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -142,13 +142,6 @@ type Config struct { // defaultRefreshRetryTimeout. A negative value disables transient-failure // retries entirely, so exactly one refresh attempt is made. RefreshRetryTimeout time.Duration - - // Scopes mirrors *oauth2.Config.Scopes so the refresh path can echo them - // on the token-endpoint request. Without this, Entra v1's token issuer - // silently narrows refreshed tokens to the user's default consent set - // (golang.org/x/oauth2's TokenSource omits the scope parameter on - // refresh; Entra v1 treats absence of scope as "use default consent"). - Scopes []string } // Git returns a Provider for this config if the provider type is a @@ -396,18 +389,9 @@ validate: // refresh token is set, and a negative RefreshRetryTimeout all bypass the // retry loop so a doomed or unwanted refresh is not repeatedly attempted. func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth2.Token) (*oauth2.Token, error) { - // TokenSource used to short-circuit non-expired tokens internally; the - // direct Exchange path in refreshTokenOnce does not, so replicate the - // check here. Return the existing token so the caller still runs its - // extra-token-key generation and ValidateToken step. - if existingToken.Valid() { - return existingToken, nil - } - // Without a refresh token the oauth2 library short-circuits with // "token expired and refresh token is not set". No retry can recover - // from that, so delegate to TokenSource for a single attempt (test - // doubles also rely on this path being TokenSource-backed). + // from that, so make a single attempt and return. if existingToken.RefreshToken == "" { return c.TokenSource(ctx, existingToken).Token() } @@ -440,7 +424,7 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth err error ) for { - token, err = c.refreshTokenOnce(ctx, existingToken.RefreshToken) + token, err = c.TokenSource(ctx, existingToken).Token() if err == nil || isFailedRefresh(existingToken, err) { return token, err } @@ -458,38 +442,6 @@ func (c *Config) refreshTokenWithRetry(ctx context.Context, existingToken *oauth } } -// refreshTokenOnce performs a single OAuth refresh via direct Exchange, -// carrying explicit grant_type/refresh_token/scope params. Bypasses the -// golang.org/x/oauth2 TokenSource path because it omits `scope` on refresh, -// which causes Entra v1 to silently narrow refreshed tokens to the user's -// default consent set. -// -// Exchange dispatches through InstrumentedOAuth2Config, which for some -// providers wraps the underlying oauth2.Config: -// - entraV1Oauth (Azure DevOps + Entra v1): appends resource=, -// which is also required on refresh for v1 tokens — correct for us. -// - jwtConfig (Azure DevOps non-Entra): overrides grant_type with -// urn:ietf:params:oauth:grant-type:jwt-bearer. JWT configs don't issue -// refresh tokens in practice, so we never reach this path with -// refreshToken != "" for jwtConfig. -func (c *Config) refreshTokenOnce(ctx context.Context, refreshToken string) (*oauth2.Token, error) { - refreshOpts := []oauth2.AuthCodeOption{ - oauth2.SetAuthURLParam("grant_type", "refresh_token"), - oauth2.SetAuthURLParam("refresh_token", refreshToken), - } - if len(c.Scopes) > 0 { - refreshOpts = append(refreshOpts, oauth2.SetAuthURLParam("scope", strings.Join(c.Scopes, " "))) - } - token, err := c.Exchange(ctx, "", refreshOpts...) - // Per RFC 6749 §6, the AS MAY return a new refresh token and SHOULD treat - // the existing one as still valid otherwise. Preserve it so the next - // refresh has something to send. - if err == nil && token != nil && token.RefreshToken == "" { - token.RefreshToken = refreshToken - } - return token, err -} - // ValidateToken checks if the Git token provided is valid. // The user is optionally returned if the provider supports it. // Returns valid=true when: the provider confirmed the token, @@ -950,7 +902,6 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut ID: entry.ID, ClientID: entry.ClientID, ClientSecret: entry.ClientSecret, - Scopes: entry.Scopes, Regex: regex, APIBaseURL: entry.APIBaseURL, Type: entry.Type, @@ -1407,6 +1358,47 @@ func (c *entraV1Oauth) Exchange(ctx context.Context, code string, opts ...oauth2 ) } +func (c *entraV1Oauth) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource { + return oauth2.ReuseTokenSource(token, &entraV1TokenSource{ + ctx: ctx, + cfg: c, + token: token, + }) +} + +type entraV1TokenSource struct { + ctx context.Context + cfg *entraV1Oauth + token *oauth2.Token +} + +func (s *entraV1TokenSource) Token() (*oauth2.Token, error) { + var refreshToken string + if s.token != nil { + refreshToken = s.token.RefreshToken + } + if refreshToken == "" { + return s.cfg.Config.TokenSource(s.ctx, s.token).Token() + } + + refreshOpts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", "refresh_token"), + oauth2.SetAuthURLParam("refresh_token", refreshToken), + } + if len(s.cfg.Config.Scopes) > 0 { + refreshOpts = append(refreshOpts, oauth2.SetAuthURLParam("scope", strings.Join(s.cfg.Config.Scopes, " "))) + } + + token, err := s.cfg.Exchange(s.ctx, "", refreshOpts...) + if err != nil { + return nil, err + } + if token.RefreshToken == "" { + token.RefreshToken = refreshToken + } + return token, nil +} + // exchangeWithClientSecret wraps an OAuth config and adds the client secret // to the Exchange request as a Bearer header. This is used by JFrog Artifactory. type exchangeWithClientSecret struct { diff --git a/coderd/externalauth/externalauth_test.go b/coderd/externalauth/externalauth_test.go index 0fb5342a8a8b3..00b85f727daac 100644 --- a/coderd/externalauth/externalauth_test.go +++ b/coderd/externalauth/externalauth_test.go @@ -883,19 +883,17 @@ func TestRefreshTokenWithScopes(t *testing.T) { newConfig := func(t *testing.T, scopes []string) *externalauth.Config { t.Helper() instrument := promoauth.NewFactory(prometheus.NewRegistry()) - return &externalauth.Config{ - ID: "test", - InstrumentedOAuth2Config: instrument.New("test", &oauth2.Config{ - ClientID: "id", - ClientSecret: "secret", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://example.invalid/auth", - TokenURL: "https://example.invalid/token", - }, - Scopes: scopes, - }), - Scopes: scopes, - } + configs, err := externalauth.ConvertConfig(instrument, []codersdk.ExternalAuthConfig{{ + ID: "test", + Type: codersdk.EnhancedExternalAuthProviderAzureDevopsEntra.String(), + ClientID: "id", + ClientSecret: "secret", + AuthURL: "https://login.microsoftonline.com/tenant/oauth2/authorize", + TokenURL: "https://login.microsoftonline.com/tenant/oauth2/token", + Scopes: scopes, + }}, &url.URL{Scheme: "https", Host: "coder.example.com"}) + require.NoError(t, err) + return configs[0] } expired := dbtime.Now().Add(-time.Hour) From 6a89d3178379f2bd558a03ee80e9d402522bfd6f Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 9 Jul 2026 11:08:55 -0800 Subject: [PATCH 3/3] Add some comments on the token override --- coderd/externalauth/externalauth.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index f503d5e4cb200..6633ab1936e6c 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -216,6 +216,11 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu Expiry: externalAuthLink.OAuthExpiry, } + // NOTE: TokenSource(...).Token() will short-circuit if the token: + // - is not expired (returns original token) + // - is expired and has no refresh token (returns error) + // This means we will avoid making useless HTTP requests. + // // External providers (GitHub in particular) intermittently fail token // refreshes with transient errors such as 5xx responses, network timeouts, // and rate-limited 429s. Retry with exponential backoff before surfacing @@ -301,7 +306,8 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu return externalAuthLink, InvalidTokenError("token expired, refreshing is either disabled or refreshing failed and will not be retried") } - // Non-expired tokens are short-circuited above; reaching here means refresh failed. + // Non-expired tokens are short-circuited as noted above; reaching here + // means refresh failed. return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error())) } @@ -1338,8 +1344,16 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au ) } -// When authenticating via Entra ID ADO only supports v1 tokens that requires the 'resource' rather than scopes -// When ADO gets support for V2 Entra ID tokens this struct and functions can be removed +// The Entra wrapper accounts for two things: +// +// 1. When authenticating via Entra ID ADO only supports v1 tokens which +// require 'resource'. +// +// 2. When refreshing, Entra ID requires the original scopes or it will switch +// to using the default scopes. +// +// This struct and its functions might be removable once ADO gets support for +// Entra ID V2. type entraV1Oauth struct { *oauth2.Config }