Skip to content

chore: return failed refresh errors on external auth as string (was boolean) #13402

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 3, 2024
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
8 changes: 5 additions & 3 deletions coderd/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,15 +351,17 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) {
if link.OAuthAccessToken != "" {
cfg, ok := configs[link.ProviderID]
if ok {
newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link)
newLink, err := cfg.RefreshToken(ctx, api.Database, link)
meta := db2sdk.ExternalAuthMeta{
Authenticated: valid,
Authenticated: err == nil,
}
if err != nil {
meta.ValidateError = err.Error()
}
linkMeta[link.ProviderID] = meta

// Update the link if it was potentially refreshed.
if err == nil && valid {
if err == nil {
links[i] = newLink
}
}
Expand Down
38 changes: 27 additions & 11 deletions coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,31 @@ func (c *Config) GenerateTokenExtra(token *oauth2.Token) (pqtype.NullRawMessage,
}, nil
}

// InvalidTokenError is a case where the "RefreshToken" failed to complete
// as a result of invalid credentials. Error contains the reason of the failure.
type InvalidTokenError string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: might wanna make this a struct instead, just so we can do e.ValidationMessage or something!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's more an InvalidationMessage, but more specifically it could be related to refreshing, a deployment configuration (disabled refreshes), or a validation error.

In all cases, it is an error. And the error is related to being invalid for w/e reason. I think continuing to use it as an err.Error() makes sense


func (e InvalidTokenError) Error() string {
return string(e)
}

func IsInvalidTokenError(err error) bool {
var invalidTokenError InvalidTokenError
return xerrors.As(err, &invalidTokenError)
}

// RefreshToken automatically refreshes the token if expired and permitted.
// It returns the token and a bool indicating if the token is valid.
func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, bool, error) {
// If an error is returned, the token is either invalid, or an error occurred.
// Use 'IsInvalidTokenError(err)' to determine the difference.
func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAuthLink database.ExternalAuthLink) (database.ExternalAuthLink, error) {
// If the token is expired and refresh is disabled, we prompt
// the user to authenticate again.
if c.NoRefresh &&
// If the time is set to 0, then it should never expire.
// This is true for github, which has no expiry.
!externalAuthLink.OAuthExpiry.IsZero() &&
externalAuthLink.OAuthExpiry.Before(dbtime.Now()) {
return externalAuthLink, false, nil
return externalAuthLink, InvalidTokenError("token expired, refreshing is disabled")
}

// This is additional defensive programming. Because TokenSource is an interface,
Expand All @@ -123,14 +137,16 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
Expiry: externalAuthLink.OAuthExpiry,
}).Token()
if err != nil {
// Even if the token fails to be obtained, we still return false because
// we aren't trying to surface an error, we're just trying to obtain a valid token.
return externalAuthLink, false, nil
// Even if the token fails to be obtained, do not return the error as an error.
// TokenSource(...).Token() will always return the current token if the token is not expired.
// If it is expired, it will attempt to refresh the token, and if it cannot, it will fail with
// an error. This error is a reason the token is invalid.
return externalAuthLink, InvalidTokenError(fmt.Sprintf("refresh token: %s", err.Error()))
}

extra, err := c.GenerateTokenExtra(token)
if err != nil {
return externalAuthLink, false, xerrors.Errorf("generate token extra: %w", err)
return externalAuthLink, xerrors.Errorf("generate token extra: %w", err)
}

r := retry.New(50*time.Millisecond, 200*time.Millisecond)
Expand All @@ -140,7 +156,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
validate:
valid, _, err := c.ValidateToken(ctx, token)
if err != nil {
return externalAuthLink, false, xerrors.Errorf("validate external auth token: %w", err)
return externalAuthLink, xerrors.Errorf("validate external auth token: %w", err)
}
if !valid {
// A customer using GitHub in Australia reported that validating immediately
Expand All @@ -154,7 +170,7 @@ validate:
goto validate
}
// The token is no longer valid!
return externalAuthLink, false, nil
return externalAuthLink, InvalidTokenError("token failed to validate")
}

if token.AccessToken != externalAuthLink.OAuthAccessToken {
Expand All @@ -170,11 +186,11 @@ validate:
OAuthExtra: extra,
})
if err != nil {
return updatedAuthLink, false, xerrors.Errorf("update external auth link: %w", err)
return updatedAuthLink, xerrors.Errorf("update external auth link: %w", err)
}
externalAuthLink = updatedAuthLink
}
return externalAuthLink, true, nil
return externalAuthLink, nil
}

// ValidateToken ensures the Git token provided is valid!
Expand Down
42 changes: 22 additions & 20 deletions coderd/externalauth/externalauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ func TestRefreshToken(t *testing.T) {
// Expire the link
link.OAuthExpiry = expired

_, refreshed, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.False(t, refreshed)
_, err := config.RefreshToken(ctx, nil, link)
require.Error(t, err)
require.True(t, externalauth.IsInvalidTokenError(err))
require.Contains(t, err.Error(), "refreshing is disabled")
})

// NoRefreshNoExpiry tests that an oauth token without an expiry is always valid.
Expand Down Expand Up @@ -90,9 +91,8 @@ func TestRefreshToken(t *testing.T) {

// Zero time used
link.OAuthExpiry = time.Time{}
_, refreshed, err := config.RefreshToken(ctx, nil, link)
_, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.True(t, refreshed, "token without expiry is always valid")
require.True(t, validated, "token should have been validated")
})

Expand All @@ -105,11 +105,12 @@ func TestRefreshToken(t *testing.T) {
},
},
}
_, refreshed, err := config.RefreshToken(context.Background(), nil, database.ExternalAuthLink{
_, err := config.RefreshToken(context.Background(), nil, database.ExternalAuthLink{
OAuthExpiry: expired,
})
require.NoError(t, err)
require.False(t, refreshed)
require.Error(t, err)
require.True(t, externalauth.IsInvalidTokenError(err))
require.Contains(t, err.Error(), "failure")
})

t.Run("ValidateServerError", func(t *testing.T) {
Expand All @@ -131,8 +132,12 @@ func TestRefreshToken(t *testing.T) {
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
link.OAuthExpiry = expired

_, _, err := config.RefreshToken(ctx, nil, link)
_, err := config.RefreshToken(ctx, nil, link)
require.ErrorContains(t, err, staticError)
// Unsure if this should be the correct behavior. It's an invalid token because
// 'ValidateToken()' failed with a runtime error. This was the previous behavior,
// so not going to change it.
require.False(t, externalauth.IsInvalidTokenError(err))
require.True(t, validated, "token should have been attempted to be validated")
})

Expand All @@ -156,9 +161,9 @@ func TestRefreshToken(t *testing.T) {
ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))
link.OAuthExpiry = expired

_, refreshed, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err, staticError)
require.False(t, refreshed)
_, err := config.RefreshToken(ctx, nil, link)
require.ErrorContains(t, err, "token failed to validate")
require.True(t, externalauth.IsInvalidTokenError(err))
require.True(t, validated, "token should have been attempted to be validated")
})

Expand Down Expand Up @@ -191,9 +196,8 @@ func TestRefreshToken(t *testing.T) {
// Unlimited lifetime, this is what GitHub returns tokens as
link.OAuthExpiry = time.Time{}

_, ok, err := config.RefreshToken(ctx, nil, link)
_, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, 2, validateCalls, "token should have been attempted to be validated more than once")
})

Expand All @@ -219,9 +223,8 @@ func TestRefreshToken(t *testing.T) {

ctx := oidc.ClientContext(context.Background(), fake.HTTPClient(nil))

_, ok, err := config.RefreshToken(ctx, nil, link)
_, err := config.RefreshToken(ctx, nil, link)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, 1, validateCalls, "token is validated")
})

Expand Down Expand Up @@ -253,9 +256,8 @@ func TestRefreshToken(t *testing.T) {
// Force a refresh
link.OAuthExpiry = expired

updated, ok, err := config.RefreshToken(ctx, db, link)
updated, err := config.RefreshToken(ctx, db, link)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, 1, validateCalls, "token is validated")
require.Equal(t, 1, refreshCalls, "token is refreshed")
require.NotEqualf(t, link.OAuthAccessToken, updated.OAuthAccessToken, "token is updated")
Expand Down Expand Up @@ -292,9 +294,9 @@ func TestRefreshToken(t *testing.T) {
// Force a refresh
link.OAuthExpiry = expired

updated, ok, err := config.RefreshToken(ctx, db, link)
updated, err := config.RefreshToken(ctx, db, link)
require.NoError(t, err)
require.True(t, ok)

require.True(t, updated.OAuthExtra.Valid)
extra := map[string]interface{}{}
require.NoError(t, json.Unmarshal(updated.OAuthExtra.RawMessage, &extra))
Expand Down
9 changes: 5 additions & 4 deletions coderd/provisionerdserver/provisionerdserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,16 +559,17 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo
continue
}

link, valid, err := config.RefreshToken(ctx, s.Database, link)
if err != nil {
refreshed, err := config.RefreshToken(ctx, s.Database, link)
if err != nil && !externalauth.IsInvalidTokenError(err) {
return nil, failJob(fmt.Sprintf("refresh external auth link %q: %s", p.ID, err))
}
if !valid {
if err != nil {
// Invalid tokens are skipped
continue
}
externalAuthProviders = append(externalAuthProviders, &sdkproto.ExternalAuthProvider{
Id: p.ID,
AccessToken: link.OAuthAccessToken,
AccessToken: refreshed.OAuthAccessToken,
})
}

Expand Down
13 changes: 4 additions & 9 deletions coderd/templateversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,21 +353,16 @@ func (api *API) templateVersionExternalAuth(rw http.ResponseWriter, r *http.Requ
return
}

_, updated, err := config.RefreshToken(ctx, api.Database, authLink)
if err != nil {
_, err = config.RefreshToken(ctx, api.Database, authLink)
if err != nil && !externalauth.IsInvalidTokenError(err) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to refresh external auth token.",
Detail: err.Error(),
})
return
}
// If the token couldn't be validated, then we assume the user isn't
// authenticated and return early.
if !updated {
providers = append(providers, provider)
continue
}
provider.Authenticated = true

provider.Authenticated = err == nil
providers = append(providers, provider)
}

Expand Down
10 changes: 5 additions & 5 deletions coderd/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -1912,25 +1912,25 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
return
}

externalAuthLink, valid, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink)
if err != nil {
refreshedLink, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink)
if err != nil && !externalauth.IsInvalidTokenError(err) {
handleRetrying(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to refresh external auth token.",
Detail: err.Error(),
})
return
}
if !valid {
if err != nil {
// Set the previous token so the retry logic will skip validating the
// same token again. This should only be set if the token is invalid and there
// was no error. If it is invalid because of an error, then we should recheck.
previousToken = &externalAuthLink
previousToken = &refreshedLink
handleRetrying(http.StatusOK, agentsdk.ExternalAuthResponse{
URL: redirectURL.String(),
})
return
}
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra)
resp, err := createExternalAuthResponse(externalAuthConfig.Type, refreshedLink.OAuthAccessToken, refreshedLink.OAuthExtra)
if err != nil {
handleRetrying(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to create external auth response.",
Expand Down
Loading