Skip to content

Commit ed65360

Browse files
authored
feat: disconnect upstream refreshing (#4703)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
2 parents 6cf5b52 + ec003f5 commit ed65360

2 files changed

Lines changed: 194 additions & 24 deletions

File tree

server/refreshhandlers.go

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ func newInternalServerError() *refreshError {
107107
return &refreshError{msg: errInvalidRequest, desc: "", code: http.StatusInternalServerError}
108108
}
109109

110+
func newUpstreamRefreshError() *refreshError {
111+
return &refreshError{msg: errInvalidGrant, desc: "Upstream identity provider refresh failed.", code: http.StatusBadGateway}
112+
}
113+
110114
func newBadRequestError(desc string) *refreshError {
111115
return &refreshError{msg: errInvalidRequest, desc: desc, code: http.StatusBadRequest}
112116
}
@@ -271,7 +275,7 @@ func (s *Server) refreshWithConnector(ctx context.Context, rCtx *refreshContext,
271275
newIdent, err := refreshConn.Refresh(ctx, parseScopes(rCtx.scopes), ident)
272276
if err != nil {
273277
s.logger.ErrorContext(ctx, "failed to refresh identity", "err", err)
274-
return ident, newInternalServerError()
278+
return ident, newUpstreamRefreshError()
275279
}
276280

277281
return newIdent, nil
@@ -308,7 +312,7 @@ func (s *Server) updateOfflineSession(ctx context.Context, refresh *storage.Refr
308312
}
309313

310314
// updateRefreshToken updates refresh token and offline session in the storage
311-
func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (*internal.RefreshToken, connector.Identity, *refreshError) {
315+
func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext, userIdent *storage.UserIdentity) (*internal.RefreshToken, connector.Identity, *refreshError) {
312316
var rerr *refreshError
313317

314318
newToken := &internal.RefreshToken{
@@ -327,6 +331,12 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (
327331
Groups: rCtx.storageToken.Claims.Groups,
328332
}
329333

334+
// When sessions are enabled, downstream token refresh is disconnected from the upstream
335+
// identity provider. Instead of calling the connector's Refresh method (which would contact
336+
// the upstream IdP and may fail if the upstream refresh token has expired), we use the claims
337+
// stored in UserIdentity at the time of the last interactive login. This aligns with the
338+
// behavior of other identity brokers (e.g., Keycloak, Auth0) that treat downstream sessions
339+
// independently from the upstream provider session lifetime.
330340
refreshTokenUpdater := func(old storage.RefreshToken) (storage.RefreshToken, error) {
331341
rotationEnabled := s.refreshTokenPolicy.RotationEnabled()
332342
reusingAllowed := s.refreshTokenPolicy.AllowedToReuse(old.LastUsed)
@@ -371,9 +381,21 @@ func (s *Server) updateRefreshToken(ctx context.Context, rCtx *refreshContext) (
371381
// Call only once if there is a request which is not in the reuse interval.
372382
// This is required to avoid multiple calls to the external IdP for concurrent requests.
373383
// Dex will call the connector's Refresh method only once if request is not in reuse interval.
374-
ident, rerr = s.refreshWithConnector(ctx, rCtx, ident)
375-
if rerr != nil {
376-
return old, rerr
384+
// When sessions are enabled, use cached identity instead of refreshing upstream.
385+
if userIdent != nil {
386+
ident = connector.Identity{
387+
UserID: userIdent.Claims.UserID,
388+
Username: userIdent.Claims.Username,
389+
PreferredUsername: userIdent.Claims.PreferredUsername,
390+
Email: userIdent.Claims.Email,
391+
EmailVerified: userIdent.Claims.EmailVerified,
392+
Groups: userIdent.Claims.Groups,
393+
}
394+
} else {
395+
ident, rerr = s.refreshWithConnector(ctx, rCtx, ident)
396+
if rerr != nil {
397+
return old, rerr
398+
}
377399
}
378400

379401
// Update the claims of the refresh token.
@@ -424,7 +446,24 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
424446
return
425447
}
426448

427-
newToken, ident, rerr := s.updateRefreshToken(r.Context(), rCtx)
449+
var userIdent *storage.UserIdentity
450+
451+
if s.sessionConfig != nil {
452+
ui, err := s.storage.GetUserIdentity(r.Context(), rCtx.storageToken.Claims.UserID, rCtx.storageToken.ConnectorID)
453+
if err != nil {
454+
s.logger.ErrorContext(r.Context(), "failed to get user identity", "err", err)
455+
s.refreshTokenErrHelper(w, newInternalServerError())
456+
return
457+
}
458+
userIdent = &ui
459+
}
460+
461+
authTime := time.Time{}
462+
if userIdent != nil {
463+
authTime = userIdent.LastLogin
464+
}
465+
466+
newToken, ident, rerr := s.updateRefreshToken(r.Context(), rCtx, userIdent)
428467
if rerr != nil {
429468
s.refreshTokenErrHelper(w, rerr)
430469
return
@@ -439,17 +478,6 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
439478
Groups: ident.Groups,
440479
}
441480

442-
authTime := time.Time{}
443-
if s.sessionConfig != nil {
444-
ui, err := s.storage.GetUserIdentity(r.Context(), ident.UserID, rCtx.storageToken.ConnectorID)
445-
if err != nil {
446-
s.logger.ErrorContext(r.Context(), "failed to get user identity", "err", err)
447-
s.refreshTokenErrHelper(w, newInternalServerError())
448-
return
449-
}
450-
authTime = ui.LastLogin
451-
}
452-
453481
accessToken, _, err := s.newAccessToken(r.Context(), client.ID, claims, rCtx.scopes, rCtx.storageToken.Nonce, rCtx.storageToken.ConnectorID, authTime)
454482
if err != nil {
455483
s.logger.ErrorContext(r.Context(), "failed to create new access token", "err", err)

server/refreshhandlers_test.go

Lines changed: 149 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package server
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/base64"
67
"encoding/json"
8+
"errors"
79
"log/slog"
810
"net/http"
911
"net/http/httptest"
@@ -16,6 +18,7 @@ import (
1618
"github.com/stretchr/testify/assert"
1719
"github.com/stretchr/testify/require"
1820

21+
"github.com/dexidp/dex/connector"
1922
"github.com/dexidp/dex/server/internal"
2023
"github.com/dexidp/dex/storage"
2124
)
@@ -276,17 +279,17 @@ func TestRefreshTokenAuthTime(t *testing.T) {
276279
mockRefreshTokenTestStorage(t, s.storage, false)
277280

278281
if tc.createUserIdentity {
279-
// The mock connector returns UserID "0-385-28089-0" on Refresh,
280-
// so the UserIdentity must use that ID to be found by handleRefreshToken.
282+
// UserIdentity must match the refresh token's Claims.UserID ("1")
283+
// because updateRefreshToken looks it up by that ID.
281284
err := s.storage.CreateUserIdentity(t.Context(), storage.UserIdentity{
282-
UserID: "0-385-28089-0",
285+
UserID: "1",
283286
ConnectorID: "test",
284287
Claims: storage.Claims{
285-
UserID: "0-385-28089-0",
286-
Username: "Kilgore Trout",
287-
Email: "kilgore@kilgore.trout",
288+
UserID: "1",
289+
Username: "jane",
290+
Email: "jane.doe@example.com",
288291
EmailVerified: true,
289-
Groups: []string{"authors"},
292+
Groups: []string{"a", "b"},
290293
},
291294
CreatedAt: loginTime,
292295
LastLogin: loginTime,
@@ -345,6 +348,145 @@ func TestRefreshTokenAuthTime(t *testing.T) {
345348
}
346349
}
347350

351+
// failingRefreshConnector implements connector.CallbackConnector and connector.RefreshConnector
352+
// but always returns an error on Refresh, proving that the upstream is not contacted.
353+
type failingRefreshConnector struct {
354+
identity connector.Identity
355+
}
356+
357+
func (f *failingRefreshConnector) LoginURL(_ connector.Scopes, callbackURL, state string) (string, []byte, error) {
358+
u, _ := url.Parse(callbackURL)
359+
v := u.Query()
360+
v.Set("state", state)
361+
u.RawQuery = v.Encode()
362+
return u.String(), nil, nil
363+
}
364+
365+
func (f *failingRefreshConnector) HandleCallback(_ connector.Scopes, _ []byte, _ *http.Request) (connector.Identity, error) {
366+
return f.identity, nil
367+
}
368+
369+
func (f *failingRefreshConnector) Refresh(_ context.Context, _ connector.Scopes, _ connector.Identity) (connector.Identity, error) {
370+
return connector.Identity{}, errors.New("upstream: refresh token expired")
371+
}
372+
373+
func TestRefreshDisconnectsUpstreamWhenSessionsEnabled(t *testing.T) {
374+
t0 := time.Now().UTC().Round(time.Second)
375+
loginTime := t0.Add(-10 * time.Minute)
376+
377+
tests := []struct {
378+
name string
379+
sessionsEnabled bool
380+
createUserIdentity bool
381+
wantOK bool
382+
}{
383+
{
384+
name: "sessions enabled - uses user identity, skips upstream",
385+
sessionsEnabled: true,
386+
createUserIdentity: true,
387+
wantOK: true,
388+
},
389+
{
390+
name: "sessions enabled without user identity - fails",
391+
sessionsEnabled: true,
392+
createUserIdentity: false,
393+
wantOK: false,
394+
},
395+
{
396+
name: "sessions disabled - upstream failure returns error",
397+
sessionsEnabled: false,
398+
createUserIdentity: false,
399+
wantOK: false,
400+
},
401+
}
402+
403+
for _, tc := range tests {
404+
t.Run(tc.name, func(t *testing.T) {
405+
httpServer, s := newTestServer(t, func(c *Config) {
406+
c.Now = func() time.Time { return t0 }
407+
})
408+
defer httpServer.Close()
409+
410+
if tc.sessionsEnabled {
411+
s.sessionConfig = &SessionConfig{
412+
CookieName: "dex_session",
413+
AbsoluteLifetime: 24 * time.Hour,
414+
}
415+
}
416+
417+
mockRefreshTokenTestStorage(t, s.storage, false)
418+
419+
// Replace the connector with one that always fails on Refresh.
420+
// When sessions are enabled this connector should never be called;
421+
// when sessions are disabled, the failure proves the error path works.
422+
s.mu.Lock()
423+
s.connectors["test"] = Connector{
424+
Connector: &failingRefreshConnector{
425+
identity: connector.Identity{
426+
UserID: "0-385-28089-0",
427+
Username: "Kilgore Trout",
428+
Email: "kilgore@kilgore.trout",
429+
},
430+
},
431+
}
432+
s.mu.Unlock()
433+
434+
if tc.createUserIdentity {
435+
err := s.storage.CreateUserIdentity(t.Context(), storage.UserIdentity{
436+
UserID: "1",
437+
ConnectorID: "test",
438+
Claims: storage.Claims{
439+
UserID: "1",
440+
Username: "jane",
441+
Email: "jane.doe@example.com",
442+
EmailVerified: true,
443+
Groups: []string{"a", "b"},
444+
},
445+
CreatedAt: loginTime,
446+
LastLogin: loginTime,
447+
})
448+
require.NoError(t, err)
449+
}
450+
451+
u, err := url.Parse(s.issuerURL.String())
452+
require.NoError(t, err)
453+
454+
tokenData, err := internal.Marshal(&internal.RefreshToken{RefreshId: "test", Token: "bar"})
455+
require.NoError(t, err)
456+
457+
u.Path = path.Join(u.Path, "/token")
458+
v := url.Values{}
459+
v.Add("grant_type", "refresh_token")
460+
v.Add("refresh_token", tokenData)
461+
462+
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode()))
463+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
464+
req.SetBasicAuth("test", "barfoo")
465+
466+
rr := httptest.NewRecorder()
467+
s.ServeHTTP(rr, req)
468+
469+
if tc.wantOK {
470+
require.Equal(t, http.StatusOK, rr.Code, "body: %s", rr.Body.String())
471+
472+
var resp struct {
473+
IDToken string `json:"id_token"`
474+
}
475+
err = json.Unmarshal(rr.Body.Bytes(), &resp)
476+
require.NoError(t, err)
477+
478+
// Verify the returned claims match UserIdentity, not the connector.
479+
claims := decodeJWTClaims(t, resp.IDToken)
480+
assert.Equal(t, "jane.doe@example.com", claims["email"])
481+
assert.Equal(t, "jane", claims["name"])
482+
} else {
483+
require.NotEqual(t, http.StatusOK, rr.Code,
484+
"expected error when sessions disabled or user identity missing")
485+
}
486+
})
487+
}
488+
}
489+
348490
func TestRefreshTokenPolicy(t *testing.T) {
349491
lastTime := time.Now()
350492
l := slog.New(slog.DiscardHandler)

0 commit comments

Comments
 (0)