Thanks to visit codestin.com
Credit goes to github.com

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
72 changes: 61 additions & 11 deletions coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,10 @@ 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.
// 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,
Expand All @@ -229,9 +230,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.
Expand Down Expand Up @@ -305,8 +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")
}

// 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 as noted above; reaching here
// means refresh failed.
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error()))
}

Expand Down Expand Up @@ -1343,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
}
Expand All @@ -1363,6 +1372,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 {
Expand Down Expand Up @@ -1427,7 +1477,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.
//
Expand Down
144 changes: 144 additions & 0 deletions coderd/externalauth/externalauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -850,6 +851,149 @@ 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())
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)

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

Expand Down
Loading