Skip to content

Commit 0befc08

Browse files
authored
fix: always attempt external auth refresh when fetching (#11762)
* fix: always attempt external auth refresh when fetching * refactor validate to check expiry when considering "valid"
1 parent fd7f85b commit 0befc08

File tree

6 files changed

+129
-80
lines changed

6 files changed

+129
-80
lines changed

coderd/database/modelmethods.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"golang.org/x/exp/maps"
9+
"golang.org/x/oauth2"
910

1011
"github.com/coder/coder/v2/coderd/database/dbtime"
1112
"github.com/coder/coder/v2/coderd/rbac"
@@ -268,6 +269,14 @@ func (u ExternalAuthLink) RBACObject() rbac.Object {
268269
return rbac.ResourceUserData.WithID(u.UserID).WithOwner(u.UserID.String())
269270
}
270271

272+
func (u ExternalAuthLink) OAuthToken() *oauth2.Token {
273+
return &oauth2.Token{
274+
AccessToken: u.OAuthAccessToken,
275+
RefreshToken: u.OAuthRefreshToken,
276+
Expiry: u.OAuthExpiry,
277+
}
278+
}
279+
271280
func (u UserLink) RBACObject() rbac.Object {
272281
// I assume UserData is ok?
273282
return rbac.ResourceUserData.WithOwner(u.UserID.String()).WithID(u.UserID)

coderd/externalauth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
5757
}
5858
var eg errgroup.Group
5959
eg.Go(func() (err error) {
60-
res.Authenticated, res.User, err = config.ValidateToken(ctx, link.OAuthAccessToken)
60+
res.Authenticated, res.User, err = config.ValidateToken(ctx, link.OAuthToken())
6161
return err
6262
})
6363
eg.Go(func() (err error) {

coderd/externalauth/externalauth.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ func (c *Config) RefreshToken(ctx context.Context, db database.Store, externalAu
138138
retryCtx, retryCtxCancel := context.WithTimeout(ctx, time.Second)
139139
defer retryCtxCancel()
140140
validate:
141-
valid, _, err := c.ValidateToken(ctx, token.AccessToken)
141+
valid, _, err := c.ValidateToken(ctx, token)
142142
if err != nil {
143143
return externalAuthLink, false, xerrors.Errorf("validate external auth token: %w", err)
144144
}
@@ -179,7 +179,14 @@ validate:
179179

180180
// ValidateToken ensures the Git token provided is valid!
181181
// The user is optionally returned if the provider supports it.
182-
func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *codersdk.ExternalAuthUser, error) {
182+
func (c *Config) ValidateToken(ctx context.Context, link *oauth2.Token) (bool, *codersdk.ExternalAuthUser, error) {
183+
if link == nil {
184+
return false, nil, xerrors.New("validate external auth token: token is nil")
185+
}
186+
if !link.Expiry.IsZero() && link.Expiry.Before(dbtime.Now()) {
187+
return false, nil, nil
188+
}
189+
183190
if c.ValidateURL == "" {
184191
// Default that the token is valid if no validation URL is provided.
185192
return true, nil, nil
@@ -189,7 +196,7 @@ func (c *Config) ValidateToken(ctx context.Context, token string) (bool, *coders
189196
return false, nil, err
190197
}
191198

192-
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
199+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", link.AccessToken))
193200
res, err := c.InstrumentedOAuth2Config.Do(ctx, promoauth.SourceValidateToken, req)
194201
if err != nil {
195202
return false, nil, err
@@ -396,10 +403,15 @@ func (c *DeviceAuth) ExchangeDeviceCode(ctx context.Context, deviceCode string)
396403
if body.Error != "" {
397404
return nil, xerrors.New(body.Error)
398405
}
406+
// If expiresIn is 0, then the token never expires.
407+
expires := dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second)
408+
if body.ExpiresIn == 0 {
409+
expires = time.Time{}
410+
}
399411
return &oauth2.Token{
400412
AccessToken: body.AccessToken,
401413
RefreshToken: body.RefreshToken,
402-
Expiry: dbtime.Now().Add(time.Duration(body.ExpiresIn) * time.Second),
414+
Expiry: expires,
403415
}, nil
404416
}
405417

coderd/promoauth/oauth2_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func TestInstrument(t *testing.T) {
7575
require.Equal(t, count("TokenSource"), 1)
7676

7777
// Try a validate
78-
valid, _, err := cfg.ValidateToken(ctx, refreshed.AccessToken)
78+
valid, _, err := cfg.ValidateToken(ctx, refreshed)
7979
require.NoError(t, err)
8080
require.True(t, valid)
8181
require.Equal(t, count("ValidateToken"), 1)

coderd/workspaceagents.go

Lines changed: 99 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,78 +2031,26 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
20312031
return
20322032
}
20332033

2034-
if listen {
2035-
// Since we're ticking frequently and this sign-in operation is rare,
2036-
// we are OK with polling to avoid the complexity of pubsub.
2037-
ticker, done := api.NewTicker(time.Second)
2038-
defer done()
2039-
var previousToken database.ExternalAuthLink
2040-
for {
2041-
select {
2042-
case <-ctx.Done():
2043-
return
2044-
case <-ticker:
2045-
}
2046-
externalAuthLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
2047-
ProviderID: externalAuthConfig.ID,
2048-
UserID: workspace.OwnerID,
2049-
})
2050-
if err != nil {
2051-
if errors.Is(err, sql.ErrNoRows) {
2052-
continue
2053-
}
2054-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2055-
Message: "Failed to get external auth link.",
2056-
Detail: err.Error(),
2057-
})
2058-
return
2059-
}
2060-
2061-
// Expiry may be unset if the application doesn't configure tokens
2062-
// to expire.
2063-
// See
2064-
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app.
2065-
if externalAuthLink.OAuthExpiry.Before(dbtime.Now()) && !externalAuthLink.OAuthExpiry.IsZero() {
2066-
continue
2067-
}
2068-
2069-
// Only attempt to revalidate an oauth token if it has actually changed.
2070-
// No point in trying to validate the same token over and over again.
2071-
if previousToken.OAuthAccessToken == externalAuthLink.OAuthAccessToken &&
2072-
previousToken.OAuthRefreshToken == externalAuthLink.OAuthRefreshToken &&
2073-
previousToken.OAuthExpiry == externalAuthLink.OAuthExpiry {
2074-
continue
2075-
}
2076-
2077-
valid, _, err := externalAuthConfig.ValidateToken(ctx, externalAuthLink.OAuthAccessToken)
2078-
if err != nil {
2079-
api.Logger.Warn(ctx, "failed to validate external auth token",
2080-
slog.F("workspace_owner_id", workspace.OwnerID.String()),
2081-
slog.F("validate_url", externalAuthConfig.ValidateURL),
2082-
slog.Error(err),
2083-
)
2084-
}
2085-
previousToken = externalAuthLink
2086-
if !valid {
2087-
continue
2088-
}
2089-
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra)
2090-
if err != nil {
2091-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2092-
Message: "Failed to create external auth response.",
2093-
Detail: err.Error(),
2094-
})
2095-
return
2096-
}
2097-
httpapi.Write(ctx, rw, http.StatusOK, resp)
2034+
var previousToken *database.ExternalAuthLink
2035+
// handleRetrying will attempt to continually check for a new token
2036+
// if listen is true. This is useful if an error is encountered in the
2037+
// original single flow.
2038+
//
2039+
// By default, if no errors are encountered, then the single flow response
2040+
// is returned.
2041+
handleRetrying := func(code int, response any) {
2042+
if !listen {
2043+
httpapi.Write(ctx, rw, code, response)
20982044
return
20992045
}
2046+
2047+
api.workspaceAgentsExternalAuthListen(ctx, rw, previousToken, externalAuthConfig, workspace)
21002048
}
21012049

21022050
// This is the URL that will redirect the user with a state token.
21032051
redirectURL, err := api.AccessURL.Parse(fmt.Sprintf("/external-auth/%s", externalAuthConfig.ID))
21042052
if err != nil {
2105-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2053+
handleRetrying(http.StatusInternalServerError, codersdk.Response{
21062054
Message: "Failed to parse access URL.",
21072055
Detail: err.Error(),
21082056
})
@@ -2115,36 +2063,40 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
21152063
})
21162064
if err != nil {
21172065
if !errors.Is(err, sql.ErrNoRows) {
2118-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2066+
handleRetrying(http.StatusInternalServerError, codersdk.Response{
21192067
Message: "Failed to get external auth link.",
21202068
Detail: err.Error(),
21212069
})
21222070
return
21232071
}
21242072

2125-
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{
2073+
handleRetrying(http.StatusOK, agentsdk.ExternalAuthResponse{
21262074
URL: redirectURL.String(),
21272075
})
21282076
return
21292077
}
21302078

2131-
externalAuthLink, updated, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink)
2079+
externalAuthLink, valid, err := externalAuthConfig.RefreshToken(ctx, api.Database, externalAuthLink)
21322080
if err != nil {
2133-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2081+
handleRetrying(http.StatusInternalServerError, codersdk.Response{
21342082
Message: "Failed to refresh external auth token.",
21352083
Detail: err.Error(),
21362084
})
21372085
return
21382086
}
2139-
if !updated {
2140-
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ExternalAuthResponse{
2087+
if !valid {
2088+
// Set the previous token so the retry logic will skip validating the
2089+
// same token again. This should only be set if the token is invalid and there
2090+
// was no error. If it is invalid because of an error, then we should recheck.
2091+
previousToken = &externalAuthLink
2092+
handleRetrying(http.StatusOK, agentsdk.ExternalAuthResponse{
21412093
URL: redirectURL.String(),
21422094
})
21432095
return
21442096
}
21452097
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra)
21462098
if err != nil {
2147-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2099+
handleRetrying(http.StatusInternalServerError, codersdk.Response{
21482100
Message: "Failed to create external auth response.",
21492101
Detail: err.Error(),
21502102
})
@@ -2153,6 +2105,81 @@ func (api *API) workspaceAgentsExternalAuth(rw http.ResponseWriter, r *http.Requ
21532105
httpapi.Write(ctx, rw, http.StatusOK, resp)
21542106
}
21552107

2108+
func (api *API) workspaceAgentsExternalAuthListen(ctx context.Context, rw http.ResponseWriter, previous *database.ExternalAuthLink, externalAuthConfig *externalauth.Config, workspace database.Workspace) {
2109+
// Since we're ticking frequently and this sign-in operation is rare,
2110+
// we are OK with polling to avoid the complexity of pubsub.
2111+
ticker, done := api.NewTicker(time.Second)
2112+
defer done()
2113+
// If we have a previous token that is invalid, we should not check this again.
2114+
// This serves to prevent doing excessive unauthorized requests to the external
2115+
// auth provider. For github, this limit is 60 per hour, so saving a call
2116+
// per invalid token can be significant.
2117+
var previousToken database.ExternalAuthLink
2118+
if previous != nil {
2119+
previousToken = *previous
2120+
}
2121+
for {
2122+
select {
2123+
case <-ctx.Done():
2124+
return
2125+
case <-ticker:
2126+
}
2127+
externalAuthLink, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
2128+
ProviderID: externalAuthConfig.ID,
2129+
UserID: workspace.OwnerID,
2130+
})
2131+
if err != nil {
2132+
if errors.Is(err, sql.ErrNoRows) {
2133+
continue
2134+
}
2135+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2136+
Message: "Failed to get external auth link.",
2137+
Detail: err.Error(),
2138+
})
2139+
return
2140+
}
2141+
2142+
// Expiry may be unset if the application doesn't configure tokens
2143+
// to expire.
2144+
// See
2145+
// https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app.
2146+
if externalAuthLink.OAuthExpiry.Before(dbtime.Now()) && !externalAuthLink.OAuthExpiry.IsZero() {
2147+
continue
2148+
}
2149+
2150+
// Only attempt to revalidate an oauth token if it has actually changed.
2151+
// No point in trying to validate the same token over and over again.
2152+
if previousToken.OAuthAccessToken == externalAuthLink.OAuthAccessToken &&
2153+
previousToken.OAuthRefreshToken == externalAuthLink.OAuthRefreshToken &&
2154+
previousToken.OAuthExpiry == externalAuthLink.OAuthExpiry {
2155+
continue
2156+
}
2157+
2158+
valid, _, err := externalAuthConfig.ValidateToken(ctx, externalAuthLink.OAuthToken())
2159+
if err != nil {
2160+
api.Logger.Warn(ctx, "failed to validate external auth token",
2161+
slog.F("workspace_owner_id", workspace.OwnerID.String()),
2162+
slog.F("validate_url", externalAuthConfig.ValidateURL),
2163+
slog.Error(err),
2164+
)
2165+
}
2166+
previousToken = externalAuthLink
2167+
if !valid {
2168+
continue
2169+
}
2170+
resp, err := createExternalAuthResponse(externalAuthConfig.Type, externalAuthLink.OAuthAccessToken, externalAuthLink.OAuthExtra)
2171+
if err != nil {
2172+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
2173+
Message: "Failed to create external auth response.",
2174+
Detail: err.Error(),
2175+
})
2176+
return
2177+
}
2178+
httpapi.Write(ctx, rw, http.StatusOK, resp)
2179+
return
2180+
}
2181+
}
2182+
21562183
// createExternalAuthResponse creates an ExternalAuthResponse based on the
21572184
// provider type. This is to support legacy `/workspaceagents/me/gitauth`
21582185
// which uses `Username` and `Password`.

coderd/workspaceagents_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,7 +1577,7 @@ func TestWorkspaceAgentExternalAuthListen(t *testing.T) {
15771577
},
15781578
ExternalAuthConfigs: []*externalauth.Config{
15791579
fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) {
1580-
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
1580+
cfg.Type = codersdk.EnhancedExternalAuthProviderGitLab.String()
15811581
}),
15821582
},
15831583
})
@@ -1623,7 +1623,8 @@ func TestWorkspaceAgentExternalAuthListen(t *testing.T) {
16231623
ticks <- time.Now()
16241624
}
16251625
cancel()
1626-
// We expect only 1
1626+
// We expect only 1. One from the initial "Refresh" attempt, and the
1627+
// other should be skipped.
16271628
// In a failed test, you will likely see 9, as the last one
16281629
// gets canceled.
16291630
require.Equal(t, 1, validateCalls, "validate calls duplicated on same token")

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy