Skip to content

Commit 155557b

Browse files
authored
feat(server): let a client tie its refresh tokens to the session (#4950)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
1 parent 533d177 commit 155557b

43 files changed

Lines changed: 1584 additions & 816 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/v2/api.pb.go

Lines changed: 638 additions & 597 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v2/api.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ message Client {
2323
// Where the browser may be sent after an RP-initiated logout. A
2424
// post_logout_redirect_uri that is not listed here is refused.
2525
repeated string post_logout_redirect_uris = 11;
26+
// Whether this client's refresh tokens outlive the browser session that
27+
// issued them: "standalone" (the default) or "session".
28+
string refresh_token_lifetime = 12;
2629
}
2730

2831
// ClientInfo represents an OAuth2 client without sensitive information.
@@ -37,6 +40,7 @@ message ClientInfo {
3740
repeated string sso_shared_with = 8;
3841
string backchannel_logout_uri = 9;
3942
repeated string post_logout_redirect_uris = 10;
43+
string refresh_token_lifetime = 11;
4044
}
4145

4246
// GetClientReq is a request to retrieve client details.
@@ -86,6 +90,10 @@ message UpdateClientReq {
8690
// leaving dex posting logout tokens at something that no longer exists.
8791
optional string backchannel_logout_uri = 8;
8892
repeated string post_logout_redirect_uris = 9;
93+
// Optional for the same reason as backchannel_logout_uri: an empty value has
94+
// to be tellable apart from "leave it alone" to put a client back on the
95+
// default lifetime.
96+
optional string refresh_token_lifetime = 10;
8997
}
9098

9199
// UpdateClientResp returns the response from updating a client.

cmd/dex/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ func (c Config) Validate() error {
131131
return err
132132
}
133133

134+
for _, client := range c.StaticClients {
135+
if err := storage.ValidateRefreshTokenLifetime(client.RefreshTokenLifetime); err != nil {
136+
return fmt.Errorf("staticClients: client %q: %w", client.ID, err)
137+
}
138+
}
139+
134140
return nil
135141
}
136142

cmd/dex/config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/ghodss/yaml"
1111
"github.com/go-jose/go-jose/v4"
1212
"github.com/kylelemons/godebug/pretty"
13+
"github.com/stretchr/testify/require"
1314

1415
"github.com/dexidp/dex/connector/mock"
1516
"github.com/dexidp/dex/connector/oidc"
@@ -68,6 +69,23 @@ func TestInvalidConfiguration(t *testing.T) {
6869
}
6970
}
7071

72+
// TestInvalidRefreshTokenLifetime: a misspelled lifetime must not read as the
73+
// default, leaving tokens the client wanted bound outliving the session.
74+
func TestInvalidRefreshTokenLifetime(t *testing.T) {
75+
configuration := Config{
76+
Issuer: "http://127.0.0.1:5556/dex",
77+
Storage: Storage{Type: "sqlite3", Config: &sql.SQLite3{File: "examples/dex.db"}},
78+
Web: Web{HTTP: "127.0.0.1:5556"},
79+
StaticClients: []storage.Client{
80+
{ID: "proxy", RefreshTokenLifetime: "sessions"},
81+
},
82+
}
83+
84+
err := configuration.Validate()
85+
require.Error(t, err)
86+
require.Contains(t, err.Error(), `client "proxy"`)
87+
}
88+
7189
func TestUnmarshalConfig(t *testing.T) {
7290
rawConfig := []byte(`
7391
issuer: http://127.0.0.1:5556/dex

cmd/dex/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ func runServe(options serveOptions) error {
614614
}
615615

616616
grpcSrv := grpc.NewServer(grpcOptions...)
617-
api.RegisterDexServer(grpcSrv, apiserver.NewAPI(serverConfig.Storage, logger, version, serv.Connectors(), serv.Discovery()))
617+
api.RegisterDexServer(grpcSrv, apiserver.NewAPI(serverConfig.Storage, logger, version, serv.Connectors(), serv.Discovery(), serv.Backchannel()))
618618

619619
grpcMetrics.InitializeMetrics(grpcSrv)
620620
if c.GRPC.Reflection {

examples/example-app/server/admin.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
166166
SSOSharedWith: c.SsoSharedWith,
167167
BackchannelLogoutURI: c.BackchannelLogoutUri,
168168
PostLogoutRedirectURIs: c.PostLogoutRedirectUris,
169+
RefreshTokenLifetime: c.RefreshTokenLifetime,
169170
})
170171
}
171172
} else {
@@ -279,6 +280,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
279280
SSOSharedWith: c.SsoSharedWith,
280281
BackchannelLogoutURI: c.BackchannelLogoutUri,
281282
PostLogoutRedirectURIs: c.PostLogoutRedirectUris,
283+
RefreshTokenLifetime: c.RefreshTokenLifetime,
282284
}
283285
} else if err != nil {
284286
fail(err)
@@ -328,6 +330,7 @@ func (s *Server) handleAdminCreateClient(w http.ResponseWriter, r *http.Request)
328330
SsoSharedWith: r.Form["sso_shared_with"],
329331
BackchannelLogoutUri: r.FormValue("backchannel_logout_uri"),
330332
PostLogoutRedirectUris: r.Form["post_logout_redirect_uris"],
333+
RefreshTokenLifetime: r.FormValue("refresh_token_lifetime"),
331334
Public: r.FormValue("public") != "",
332335
},
333336
}
@@ -503,6 +506,7 @@ func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request)
503506
// would leave the old one in place, and the box would fill itself back in on
504507
// the next load.
505508
backchannelLogoutURI := r.FormValue("backchannel_logout_uri")
509+
refreshTokenLifetime := r.FormValue("refresh_token_lifetime")
506510

507511
resp, err := s.admin.api.UpdateClient(ctx, &api.UpdateClientReq{
508512
Id: id,
@@ -514,6 +518,7 @@ func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request)
514518
SsoSharedWith: r.Form["sso_shared_with"],
515519
BackchannelLogoutUri: &backchannelLogoutURI,
516520
PostLogoutRedirectUris: r.Form["post_logout_redirect_uris"],
521+
RefreshTokenLifetime: &refreshTokenLifetime,
517522
})
518523
switch {
519524
case err != nil:

examples/example-app/server/admindetail.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func (s *Server) handleAdminClientDetail(w http.ResponseWriter, r *http.Request)
5050
SSOSharedWith: c.SsoSharedWith,
5151
BackchannelLogoutURI: c.BackchannelLogoutUri,
5252
PostLogoutRedirectURIs: c.PostLogoutRedirectUris,
53+
RefreshTokenLifetime: c.RefreshTokenLifetime,
5354
}
5455
}
5556

examples/example-app/server/render.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ type AdminClient struct {
234234
SSOSharedWith []string
235235
BackchannelLogoutURI string
236236
PostLogoutRedirectURIs []string
237+
RefreshTokenLifetime string
237238
}
238239

239240
// AdminPassword is one local password entry as the API reports it.

examples/example-app/server/templates/admin.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@
166166
<small class="hint">Where dex POSTs a logout token when a session ends. Empty means this client is never told.</small>
167167
</div>
168168
</div>
169+
<div class="form-row">
170+
<label for="c_refresh_token_lifetime">Refresh token lifetime</label>
171+
<div class="form-control">
172+
<select id="c_refresh_token_lifetime" name="refresh_token_lifetime">
173+
<option value="standalone">standalone</option>
174+
<option value="session">session</option>
175+
</select>
176+
<small class="hint">standalone tokens outlive the browser session, which is what keeps a CLI signed in. session tokens stop refreshing the moment that session ends.</small>
177+
</div>
178+
</div>
169179
<div class="form-row">
170180
<label for="c_public">Public</label>
171181
<div class="form-control">
@@ -267,6 +277,16 @@
267277
<small class="hint">Clearing the box removes it, and dex stops sending this client logout tokens.</small>
268278
</div>
269279
</div>
280+
<div class="form-row">
281+
<label for="e_refresh_token_lifetime">Refresh token lifetime</label>
282+
<div class="form-control">
283+
<select id="e_refresh_token_lifetime" name="refresh_token_lifetime">
284+
<option value="standalone"{{if ne .RefreshTokenLifetime "session"}} selected{{end}}>standalone</option>
285+
<option value="session"{{if eq .RefreshTokenLifetime "session"}} selected{{end}}>session</option>
286+
</select>
287+
<small class="hint">Switching to session ends this client's refresh tokens with the browser session they came from.</small>
288+
</div>
289+
</div>
270290
<p class="note">
271291
UpdateClient carries no secret and no public flag, so neither can be
272292
changed: dex expects a client that needs a new secret to be replaced.

examples/example-app/server/templates/detail.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
<div class="v mono">{{range .PostLogoutRedirectURIs}}{{.}}<br>{{else}}<span class="hint">none — logout cannot redirect back</span>{{end}}</div>
3232
<div class="k">Back-channel logout URI</div>
3333
<div class="v mono">{{if .BackchannelLogoutURI}}{{.BackchannelLogoutURI}}{{else}}<span class="hint">not set — this client is not notified when a session ends</span>{{end}}</div>
34+
<div class="k">Refresh token lifetime</div>
35+
<div class="v mono">{{if eq .RefreshTokenLifetime "session"}}session — refreshing stops when the browser session ends{{else}}standalone — refresh tokens outlive the browser session{{end}}</div>
3436
</div>
3537
<div class="form-actions">
3638
<a href="/admin?section=clients&amp;mode=edit&amp;edit={{.ID}}" class="button button-primary">Edit</a>

0 commit comments

Comments
 (0)