-
Notifications
You must be signed in to change notification settings - Fork 886
fix: always attempt external auth refresh when fetching #11762
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
Changes from all commits
3b6a39f
5d0489b
778184d
2281db2
189c8ae
531f45e
68b8792
0879fd6
3d28fe7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -138,7 +138,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu | |
retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second) | ||
defer retryCtxCancel() | ||
validate: | ||
valid, _, err := c.ValidateToken(ctx, token.AccessToken) | ||
valid, _, err := c.ValidateToken(ctx, token) | ||
if err != nil { | ||
return externalAuthLink, false, xerrors.Errorf("validate external auth token: %w", err) | ||
} | ||
|
@@ -179,7 +179,14 @@ validate: | |
|
||
// ValidateToken ensures the Git token provided is valid! | ||
// The user is optionally returned if the provider supports it. | ||
func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *codersdk.ExternalAuthUser, error) { | ||
func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, *codersdk.ExternalAuthUser, error) { | ||
if link == nil { | ||
return false, nil, xerrors.New("validate external auth token: token is nil") | ||
} | ||
if !link.Expiry.IsZero() && link.Expiry.Before(dbtime.Now()) { | ||
return false, nil, nil | ||
} | ||
|
||
if c.ValidateURL == "" { | ||
// Default that the token is valid if no validation URL is provided. | ||
return true, nil, nil | ||
|
@@ -189,7 +196,7 @@ func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *coders | |
return false, nil, err | ||
} | ||
|
||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) | ||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", link.AccessToken)) | ||
res, err := c.InstrumentedOAuth2Config.Do(ctx, promoauth.SourceValidateToken, req) | ||
if err != nil { | ||
return false, nil, err | ||
|
@@ -396,10 +403,15 @@ func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string) | |
if body.Error != "" { | ||
return nil, xerrors.New(body.Error) | ||
} | ||
// If expiresIn is 0, then the token never expires. | ||
expires := dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second) | ||
if body.ExpiresIn == 0 { | ||
expires = time.Time{} | ||
} | ||
Comment on lines
+406
to
+410
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was another bug I found. If zero time was passed in, the token was considered immediately expired on our end. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's probably a big one. IIRC GitHub often just gives you tokens with no expiry. |
||
return &oauth2.Token{ | ||
AccessToken: body.AccessToken, | ||
RefreshToken: body.RefreshToken, | ||
Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second), | ||
Expiry: expires, | ||
}, nil | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2031,78 +2031,26 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ | |
return | ||
} | ||
|
||
if listen { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I hope my new flow is a bit easier to understand. Basically there is 2 flows, Before |
||
// Since we're ticking frequently and this sign-in operation is rare, | ||
// we are OK with polling to avoid the complexity of pubsub. | ||
ticker, done := api.NewTicker(time.Second) | ||
defer done() | ||
var previousToken database.ExternalAuthLink | ||
for { | ||
select { | ||
case <-ctx.Done(): | ||
return | ||
case <-ticker: | ||
} | ||
externalAuthLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ | ||
ProviderID: externalAuthConfig.ID, | ||
UserID: workspace.OwnerID, | ||
}) | ||
if err != nil { | ||
if errors.Is(err, sql.ErrNoRows) { | ||
continue | ||
} | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to get external auth link.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
|
||
// Expiry may be unset if the application doesn't configure tokens | ||
// to expire. | ||
// See | ||
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app. | ||
if externalAuthLink.OAuthExpiry.Before(dbtime.Now()) && !externalAuthLink.OAuthExpiry.IsZero() { | ||
continue | ||
} | ||
|
||
// Only attempt to revalidate an oauth token if it has actually changed. | ||
// No point in trying to validate the same token over and over again. | ||
if previousToken.OAuthAccessToken == externalAuthLink.OAuthAccessToken && | ||
previousToken.OAuthRefreshToken == externalAuthLink.OAuthRefreshToken && | ||
previousToken.OAuthExpiry == externalAuthLink.OAuthExpiry { | ||
continue | ||
} | ||
|
||
valid, _, err := externalAuthConfig.ValidateToken(ctx, externalAuthLink.OAuthAccessToken) | ||
if err != nil { | ||
api.Logger.Warn(ctx, "failed to validate external auth token", | ||
slog.F("workspace_owner_id", workspace.OwnerID.String()), | ||
slog.F("validate_url", externalAuthConfig.ValidateURL), | ||
slog.Error(err), | ||
) | ||
} | ||
previousToken = externalAuthLink | ||
if !valid { | ||
continue | ||
} | ||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to create external auth response.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
httpapi.Write(ctx, rw, http.StatusOK, resp) | ||
var previousToken *database.ExternalAuthLink | ||
// handleRetrying will attempt to continually check for a new token | ||
// if listen is true. This is useful if an error is encountered in the | ||
// original single flow. | ||
// | ||
// By default, if no errors are encountered, then the single flow response | ||
// is returned. | ||
handleRetrying := func(code int, response any) { | ||
if !listen { | ||
httpapi.Write(ctx, rw, code, response) | ||
return | ||
} | ||
|
||
api.workspaceAgentsExternalAuthListen(ctx, rw, previousToken, externalAuthConfig, workspace) | ||
} | ||
|
||
// This is the URL that will redirect the user with a state token. | ||
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", externalAuthConfig.ID)) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
handleRetrying(http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to parse access URL.", | ||
Detail: err.Error(), | ||
}) | ||
|
@@ -2115,36 +2063,40 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ | |
}) | ||
if err != nil { | ||
if !errors.Is(err, sql.ErrNoRows) { | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
handleRetrying(http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to get external auth link.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
|
||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{ | ||
handleRetrying(http.StatusOK, agentsdk.ExternalAuthResponse{ | ||
URL: redirectURL.String(), | ||
}) | ||
return | ||
} | ||
|
||
externalAuthLink, updated, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink) | ||
externalAuthLink, valid, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
handleRetrying(http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to refresh external auth token.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
if !updated { | ||
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{ | ||
if !valid { | ||
// 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 | ||
handleRetrying(http.StatusOK, agentsdk.ExternalAuthResponse{ | ||
URL: redirectURL.String(), | ||
}) | ||
Comment on lines
+2087
to
2094
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Had to do this to prevent re-validating the same token after the refresh attempt |
||
return | ||
} | ||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
handleRetrying(http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to create external auth response.", | ||
Detail: err.Error(), | ||
}) | ||
|
@@ -2153,6 +2105,81 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ | |
httpapi.Write(ctx, rw, http.StatusOK, resp) | ||
} | ||
|
||
func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.ResponseWriter, previous *database.ExternalAuthLink, externalAuthConfig *externalauth.Config, workspace database.Workspace) { | ||
// Since we're ticking frequently and this sign-in operation is rare, | ||
// we are OK with polling to avoid the complexity of pubsub. | ||
ticker, done := api.NewTicker(time.Second) | ||
defer done() | ||
// If we have a previous token that is invalid, we should not check this again. | ||
// This serves to prevent doing excessive unauthorized requests to the external | ||
// auth provider. For github, this limit is 60 per hour, so saving a call | ||
// per invalid token can be significant. | ||
var previousToken database.ExternalAuthLink | ||
if previous != nil { | ||
previousToken = *previous | ||
} | ||
for { | ||
select { | ||
case <-ctx.Done(): | ||
return | ||
case <-ticker: | ||
} | ||
externalAuthLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{ | ||
ProviderID: externalAuthConfig.ID, | ||
UserID: workspace.OwnerID, | ||
}) | ||
if err != nil { | ||
if errors.Is(err, sql.ErrNoRows) { | ||
continue | ||
} | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to get external auth link.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
|
||
// Expiry may be unset if the application doesn't configure tokens | ||
// to expire. | ||
// See | ||
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app. | ||
if externalAuthLink.OAuthExpiry.Before(dbtime.Now()) && !externalAuthLink.OAuthExpiry.IsZero() { | ||
continue | ||
} | ||
|
||
// Only attempt to revalidate an oauth token if it has actually changed. | ||
// No point in trying to validate the same token over and over again. | ||
if previousToken.OAuthAccessToken == externalAuthLink.OAuthAccessToken && | ||
previousToken.OAuthRefreshToken == externalAuthLink.OAuthRefreshToken && | ||
previousToken.OAuthExpiry == externalAuthLink.OAuthExpiry { | ||
continue | ||
} | ||
|
||
valid, _, err := externalAuthConfig.ValidateToken(ctx, externalAuthLink.OAuthToken()) | ||
if err != nil { | ||
api.Logger.Warn(ctx, "failed to validate external auth token", | ||
slog.F("workspace_owner_id", workspace.OwnerID.String()), | ||
slog.F("validate_url", externalAuthConfig.ValidateURL), | ||
slog.Error(err), | ||
) | ||
} | ||
previousToken = externalAuthLink | ||
if !valid { | ||
continue | ||
} | ||
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra) | ||
if err != nil { | ||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ | ||
Message: "Failed to create external auth response.", | ||
Detail: err.Error(), | ||
}) | ||
return | ||
} | ||
httpapi.Write(ctx, rw, http.StatusOK, resp) | ||
return | ||
} | ||
} | ||
|
||
// createExternalAuthResponse creates an ExternalAuthResponse based on the | ||
// provider type. This is to support legacy `/workspaceagents/me/gitauth` | ||
// which uses `Username` and `Password`. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expired tokens should return
invalid