Skip to content

Commit c004fee

Browse files
authored
feat: propagate logout to identity provider (#3596)
* feat: propagate logout to identity provider This commit improves the integration between Hydra and Kratos when logging out the user. This adds a new configuration key for configuring a Kratos admin URL. Additionally, Kratos can send a session ID when accepting a login request. If a session ID was specified and a Kratos admin URL was configured, Hydra will disable the corresponding Kratos session through the admin API if a frontchannel or backchannel logout was triggered. * fix: add special case for MySQL * chore: update sdk * chore: consistent naming * fix: cleanup persister
1 parent dc878b8 commit c004fee

File tree

63 files changed

+645
-61
lines changed

Some content is hidden

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

63 files changed

+645
-61
lines changed

consent/manager.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ type (
4141
// Cookie management
4242
GetRememberedLoginSession(ctx context.Context, loginSessionFromCookie *flow.LoginSession, id string) (*flow.LoginSession, error)
4343
CreateLoginSession(ctx context.Context, session *flow.LoginSession) error
44-
DeleteLoginSession(ctx context.Context, id string) error
44+
DeleteLoginSession(ctx context.Context, id string) (deletedSession *flow.LoginSession, err error)
4545
RevokeSubjectLoginSession(ctx context.Context, user string) error
4646
ConfirmLoginSession(ctx context.Context, session *flow.LoginSession, id string, authTime time.Time, subject string, remember bool) error
4747

consent/manager_test_helpers.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,12 @@ func TestHelperNID(r interface {
324324
require.NoError(t, err)
325325
require.Error(t, t2InvalidNID.ConfirmLoginSession(ctx, &testLS, testLS.ID, time.Now(), testLS.Subject, true))
326326
require.NoError(t, t1ValidNID.ConfirmLoginSession(ctx, &testLS, testLS.ID, time.Now(), testLS.Subject, true))
327-
require.Error(t, t2InvalidNID.DeleteLoginSession(ctx, testLS.ID))
328-
require.NoError(t, t1ValidNID.DeleteLoginSession(ctx, testLS.ID))
327+
ls, err := t2InvalidNID.DeleteLoginSession(ctx, testLS.ID)
328+
require.Error(t, err)
329+
assert.Nil(t, ls)
330+
ls, err = t1ValidNID.DeleteLoginSession(ctx, testLS.ID)
331+
require.NoError(t, err)
332+
assert.Equal(t, testLS.ID, ls.ID)
329333
}
330334
}
331335

@@ -429,8 +433,9 @@ func ManagerTests(deps Deps, m Manager, clientManager client.Manager, fositeMana
429433
},
430434
} {
431435
t.Run("case=delete-get-"+tc.id, func(t *testing.T) {
432-
err := m.DeleteLoginSession(ctx, tc.id)
436+
ls, err := m.DeleteLoginSession(ctx, tc.id)
433437
require.NoError(t, err)
438+
assert.EqualValues(t, tc.id, ls.ID)
434439

435440
_, err = m.GetRememberedLoginSession(ctx, nil, tc.id)
436441
require.Error(t, err)
@@ -1083,7 +1088,8 @@ func ManagerTests(deps Deps, m Manager, clientManager client.Manager, fositeMana
10831088
require.NoError(t, err)
10841089
assert.EqualValues(t, expected.ID, result.ID)
10851090

1086-
require.NoError(t, m.DeleteLoginSession(ctx, s.ID))
1091+
_, err = m.DeleteLoginSession(ctx, s.ID)
1092+
require.NoError(t, err)
10871093

10881094
result, err = m.GetConsentRequest(ctx, expected.ID)
10891095
require.NoError(t, err)

consent/registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/ory/fosite/handler/openid"
1010
"github.com/ory/hydra/v2/aead"
1111
"github.com/ory/hydra/v2/client"
12+
"github.com/ory/hydra/v2/internal/kratos"
1213
"github.com/ory/hydra/v2/x"
1314
)
1415

@@ -17,6 +18,7 @@ type InternalRegistry interface {
1718
x.RegistryCookieStore
1819
x.RegistryLogger
1920
x.HTTPClientProvider
21+
kratos.Provider
2022
Registry
2123
client.Registry
2224

consent/strategy_default.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,9 @@ func (s *DefaultStrategy) revokeAuthenticationSession(ctx context.Context, w htt
313313
return nil
314314
}
315315

316-
return s.r.ConsentManager().DeleteLoginSession(r.Context(), sid)
316+
_, err = s.r.ConsentManager().DeleteLoginSession(r.Context(), sid)
317+
318+
return err
317319
}
318320

319321
func (s *DefaultStrategy) revokeAuthenticationCookie(w http.ResponseWriter, r *http.Request, ss sessions.Store) (string, error) {
@@ -458,6 +460,7 @@ func (s *DefaultStrategy) verifyAuthentication(
458460
return nil, fosite.ErrAccessDenied.WithHint("The login session cookie was not found or malformed.")
459461
}
460462

463+
loginSession.IdentityProviderSessionID = f.IdentityProviderSessionID
461464
if err := s.r.ConsentManager().ConfirmLoginSession(ctx, loginSession, sessionID, time.Time(session.AuthenticatedAt), session.Subject, session.Remember); err != nil {
462465
return nil, err
463466
}
@@ -731,7 +734,8 @@ func (s *DefaultStrategy) generateFrontChannelLogoutURLs(ctx context.Context, su
731734
return urls, nil
732735
}
733736

734-
func (s *DefaultStrategy) executeBackChannelLogout(ctx context.Context, r *http.Request, subject, sid string) error {
737+
func (s *DefaultStrategy) executeBackChannelLogout(r *http.Request, subject, sid string) error {
738+
ctx := r.Context()
735739
clients, err := s.r.ConsentManager().ListUserAuthenticatedClientsWithBackChannelLogout(ctx, subject, sid)
736740
if err != nil {
737741
return err
@@ -1000,8 +1004,9 @@ func (s *DefaultStrategy) issueLogoutVerifier(ctx context.Context, w http.Respon
10001004
return nil, errorsx.WithStack(ErrAbortOAuth2Request)
10011005
}
10021006

1003-
func (s *DefaultStrategy) performBackChannelLogoutAndDeleteSession(_ context.Context, r *http.Request, subject string, sid string) error {
1004-
if err := s.executeBackChannelLogout(r.Context(), r, subject, sid); err != nil {
1007+
func (s *DefaultStrategy) performBackChannelLogoutAndDeleteSession(r *http.Request, subject string, sid string) error {
1008+
ctx := r.Context()
1009+
if err := s.executeBackChannelLogout(r, subject, sid); err != nil {
10051010
return err
10061011
}
10071012

@@ -1010,10 +1015,16 @@ func (s *DefaultStrategy) performBackChannelLogoutAndDeleteSession(_ context.Con
10101015
//
10111016
// executeBackChannelLogout only fails on system errors so not on URL errors, so this should be fine
10121017
// even if an upstream URL fails!
1013-
if err := s.r.ConsentManager().DeleteLoginSession(r.Context(), sid); errors.Is(err, sqlcon.ErrNoRows) {
1018+
if session, err := s.r.ConsentManager().DeleteLoginSession(ctx, sid); errors.Is(err, sqlcon.ErrNoRows) {
10141019
// This is ok (session probably already revoked), do nothing!
10151020
} else if err != nil {
10161021
return err
1022+
} else {
1023+
innerErr := s.r.Kratos().DisableSession(ctx, session.IdentityProviderSessionID.String())
1024+
if innerErr != nil {
1025+
s.r.Logger().WithError(innerErr).WithField("sid", sid).Error("Unable to revoke session in ORY Kratos.")
1026+
}
1027+
// We don't return the error here because we don't want to break the logout flow if Kratos is down.
10171028
}
10181029

10191030
return nil
@@ -1068,7 +1079,7 @@ func (s *DefaultStrategy) completeLogout(ctx context.Context, w http.ResponseWri
10681079
return nil, err
10691080
}
10701081

1071-
if err := s.performBackChannelLogoutAndDeleteSession(r.Context(), r, lr.Subject, lr.SessionID); err != nil {
1082+
if err := s.performBackChannelLogoutAndDeleteSession(r, lr.Subject, lr.SessionID); err != nil {
10721083
return nil, err
10731084
}
10741085

@@ -1105,7 +1116,7 @@ func (s *DefaultStrategy) HandleHeadlessLogout(ctx context.Context, _ http.Respo
11051116
return lsErr
11061117
}
11071118

1108-
if err := s.performBackChannelLogoutAndDeleteSession(r.Context(), r, loginSession.Subject, sid); err != nil {
1119+
if err := s.performBackChannelLogoutAndDeleteSession(r, loginSession.Subject, sid); err != nil {
11091120
return err
11101121
}
11111122

consent/strategy_default_test.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,21 @@ import (
88
"net/http"
99
"net/http/cookiejar"
1010
"net/http/httptest"
11-
"testing"
12-
13-
hydra "github.com/ory/hydra-client-go/v2"
14-
15-
"github.com/stretchr/testify/require"
16-
17-
"github.com/ory/fosite/token/jwt"
18-
"github.com/ory/x/urlx"
19-
2011
"net/url"
12+
"testing"
2113

2214
"github.com/google/uuid"
15+
"github.com/stretchr/testify/require"
2316
"github.com/tidwall/gjson"
2417

18+
"github.com/ory/fosite/token/jwt"
19+
hydra "github.com/ory/hydra-client-go/v2"
2520
"github.com/ory/hydra/v2/client"
2621
. "github.com/ory/hydra/v2/consent"
2722
"github.com/ory/hydra/v2/driver"
2823
"github.com/ory/hydra/v2/internal/testhelpers"
2924
"github.com/ory/x/ioutilx"
25+
"github.com/ory/x/urlx"
3026
)
3127

3228
func checkAndAcceptLoginHandler(t *testing.T, apiClient *hydra.APIClient, subject string, cb func(*testing.T, *hydra.OAuth2LoginRequest, error) hydra.AcceptOAuth2LoginRequest) http.HandlerFunc {

consent/strategy_logout_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"testing"
1717
"time"
1818

19+
"github.com/ory/hydra/v2/internal/kratos"
1920
"github.com/ory/x/pointerx"
2021

2122
"github.com/stretchr/testify/assert"
@@ -35,9 +36,11 @@ import (
3536

3637
func TestLogoutFlows(t *testing.T) {
3738
ctx := context.Background()
39+
fakeKratos := kratos.NewFake()
3840
reg := internal.NewMockedRegistry(t, &contextx.Default{})
3941
reg.Config().MustSet(ctx, config.KeyAccessTokenStrategy, "opaque")
4042
reg.Config().MustSet(ctx, config.KeyConsentRequestMaxAge, time.Hour)
43+
reg.WithKratos(fakeKratos)
4144

4245
defaultRedirectedMessage := "redirected to default server"
4346
postLogoutCallback := func(w http.ResponseWriter, r *http.Request) {
@@ -181,7 +184,10 @@ func TestLogoutFlows(t *testing.T) {
181184
checkAndAcceptLoginHandler(t, adminApi, subject, func(t *testing.T, res *hydra.OAuth2LoginRequest, err error) hydra.AcceptOAuth2LoginRequest {
182185
require.NoError(t, err)
183186
//res.Payload.SessionID
184-
return hydra.AcceptOAuth2LoginRequest{Remember: pointerx.Bool(true)}
187+
return hydra.AcceptOAuth2LoginRequest{
188+
Remember: pointerx.Ptr(true),
189+
IdentityProviderSessionId: pointerx.Ptr(kratos.FakeSessionID),
190+
}
185191
}),
186192
checkAndAcceptConsentHandler(t, adminApi, func(t *testing.T, res *hydra.OAuth2ConsentRequest, err error) hydra.AcceptOAuth2ConsentRequest {
187193
require.NoError(t, err)
@@ -476,6 +482,7 @@ func TestLogoutFlows(t *testing.T) {
476482
})
477483

478484
t.Run("case=should return to default post logout because session was revoked in browser context", func(t *testing.T) {
485+
fakeKratos.Reset()
479486
c := createSampleClient(t)
480487
sid := make(chan string)
481488
acceptLoginAsAndWatchSid(t, subject, sid)
@@ -518,9 +525,13 @@ func TestLogoutFlows(t *testing.T) {
518525
assert.NotEmpty(t, res.Request.URL.Query().Get("code"))
519526

520527
wg.Wait()
528+
529+
assert.True(t, fakeKratos.DisableSessionWasCalled)
530+
assert.Equal(t, fakeKratos.LastDisabledSession, kratos.FakeSessionID)
521531
})
522532

523533
t.Run("case=should execute backchannel logout in headless flow with sid", func(t *testing.T) {
534+
fakeKratos.Reset()
524535
numSidConsumers := 2
525536
sid := make(chan string, numSidConsumers)
526537
acceptLoginAsAndWatchSidForConsumers(t, subject, sid, true, numSidConsumers)
@@ -535,22 +546,31 @@ func TestLogoutFlows(t *testing.T) {
535546
logoutViaHeadlessAndExpectNoContent(t, createBrowserWithSession(t, c), url.Values{"sid": {<-sid}})
536547

537548
backChannelWG.Wait() // we want to ensure that all back channels have been called!
549+
assert.True(t, fakeKratos.DisableSessionWasCalled)
550+
assert.Equal(t, fakeKratos.LastDisabledSession, kratos.FakeSessionID)
538551
})
539552

540553
t.Run("case=should logout in headless flow with non-existing sid", func(t *testing.T) {
554+
fakeKratos.Reset()
541555
logoutViaHeadlessAndExpectNoContent(t, browserWithoutSession, url.Values{"sid": {"non-existing-sid"}})
556+
assert.False(t, fakeKratos.DisableSessionWasCalled)
542557
})
543558

544559
t.Run("case=should logout in headless flow with session that has remember=false", func(t *testing.T) {
560+
fakeKratos.Reset()
545561
sid := make(chan string)
546562
acceptLoginAsAndWatchSidForConsumers(t, subject, sid, false, 1)
547563

548564
c := createSampleClient(t)
549565

550566
logoutViaHeadlessAndExpectNoContent(t, createBrowserWithSession(t, c), url.Values{"sid": {<-sid}})
567+
assert.True(t, fakeKratos.DisableSessionWasCalled)
568+
assert.Equal(t, fakeKratos.LastDisabledSession, kratos.FakeSessionID)
551569
})
552570

553571
t.Run("case=should fail headless logout because neither sid nor subject were provided", func(t *testing.T) {
572+
fakeKratos.Reset()
554573
logoutViaHeadlessAndExpectError(t, browserWithoutSession, url.Values{}, `Either 'subject' or 'sid' query parameters need to be defined.`)
574+
assert.False(t, fakeKratos.DisableSessionWasCalled)
555575
})
556576
}

driver/config/provider.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const (
8080
KeyPublicURL = "urls.self.public"
8181
KeyAdminURL = "urls.self.admin"
8282
KeyIssuerURL = "urls.self.issuer"
83+
KeyIdentityProviderAdminURL = "urls.identity_provider.admin_base_url"
8384
KeyAccessTokenStrategy = "strategies.access_token"
8485
KeyJWTScopeClaimStrategy = "strategies.jwt.scope_claim"
8586
KeyDBIgnoreUnknownTableColumns = "db.ignore_unknown_table_columns"
@@ -104,8 +105,10 @@ const (
104105

105106
const DSNMemory = "memory"
106107

107-
var _ hasherx.PBKDF2Configurator = (*DefaultProvider)(nil)
108-
var _ hasherx.BCryptConfigurator = (*DefaultProvider)(nil)
108+
var (
109+
_ hasherx.PBKDF2Configurator = (*DefaultProvider)(nil)
110+
_ hasherx.BCryptConfigurator = (*DefaultProvider)(nil)
111+
)
109112

110113
type DefaultProvider struct {
111114
l *logrusx.Logger
@@ -393,6 +396,12 @@ func (p *DefaultProvider) IssuerURL(ctx context.Context) *url.URL {
393396
)
394397
}
395398

399+
func (p *DefaultProvider) KratosAdminURL(ctx context.Context) (*url.URL, bool) {
400+
u := p.getProvider(ctx).RequestURIF(KeyIdentityProviderAdminURL, nil)
401+
402+
return u, u != nil
403+
}
404+
396405
func (p *DefaultProvider) OAuth2ClientRegistrationURL(ctx context.Context) *url.URL {
397406
return p.getProvider(ctx).RequestURIF(KeyOAuth2ClientRegistrationURL, new(url.URL))
398407
}

driver/registry.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"go.opentelemetry.io/otel/trace"
1212

13+
"github.com/ory/hydra/v2/internal/kratos"
1314
"github.com/ory/x/httprouterx"
1415
"github.com/ory/x/popx"
1516

@@ -54,6 +55,7 @@ type Registry interface {
5455
WithLogger(l *logrusx.Logger) Registry
5556
WithTracer(t trace.Tracer) Registry
5657
WithTracerWrapper(TracerWrapper) Registry
58+
WithKratos(k kratos.Client) Registry
5759
x.HTTPClientProvider
5860
GetJWKSFetcherStrategy() fosite.JWKSFetcherStrategy
5961

@@ -72,6 +74,8 @@ type Registry interface {
7274
x.TracingProvider
7375
FlowCipher() *aead.XChaCha20Poly1305
7476

77+
kratos.Provider
78+
7579
RegisterRoutes(ctx context.Context, admin *httprouterx.RouterAdmin, public *httprouterx.RouterPublic)
7680
ClientHandler() *client.Handler
7781
KeyHandler() *jwk.Handler

driver/registry_base.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/ory/hydra/v2/driver/config"
2929
"github.com/ory/hydra/v2/fositex"
3030
"github.com/ory/hydra/v2/hsm"
31+
"github.com/ory/hydra/v2/internal/kratos"
3132
"github.com/ory/hydra/v2/jwk"
3233
"github.com/ory/hydra/v2/oauth2"
3334
"github.com/ory/hydra/v2/oauth2/trust"
@@ -88,6 +89,7 @@ type RegistryBase struct {
8889
hmacs *foauth2.HMACSHAStrategy
8990
fc *fositex.Config
9091
publicCORS *cors.Cors
92+
kratos kratos.Client
9193
}
9294

9395
func (m *RegistryBase) GetJWKSFetcherStrategy() fosite.JWKSFetcherStrategy {
@@ -201,6 +203,11 @@ func (m *RegistryBase) WithTracerWrapper(wrapper TracerWrapper) Registry {
201203
return m.r
202204
}
203205

206+
func (m *RegistryBase) WithKratos(k kratos.Client) Registry {
207+
m.kratos = k
208+
return m.r
209+
}
210+
204211
func (m *RegistryBase) Logger() *logrusx.Logger {
205212
if m.l == nil {
206213
m.l = logrusx.New("Ory Hydra", m.BuildVersion())
@@ -552,3 +559,10 @@ func (m *RegistryBase) HSMContext() hsm.Context {
552559
func (m *RegistrySQL) ClientAuthenticator() x.ClientAuthenticator {
553560
return m.OAuth2Provider().(*fosite.Fosite)
554561
}
562+
563+
func (m *RegistryBase) Kratos() kratos.Client {
564+
if m.kratos == nil {
565+
m.kratos = kratos.New(m)
566+
}
567+
return m.kratos
568+
}

flow/consent_types.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ type OAuth2RedirectTo struct {
4242

4343
// swagger:ignore
4444
type LoginSession struct {
45-
ID string `db:"id"`
46-
NID uuid.UUID `db:"nid"`
47-
AuthenticatedAt sqlxx.NullTime `db:"authenticated_at"`
48-
Subject string `db:"subject"`
49-
Remember bool `db:"remember"`
45+
ID string `db:"id"`
46+
NID uuid.UUID `db:"nid"`
47+
AuthenticatedAt sqlxx.NullTime `db:"authenticated_at"`
48+
Subject string `db:"subject"`
49+
IdentityProviderSessionID sqlxx.NullString `db:"identity_provider_session_id"`
50+
Remember bool `db:"remember"`
5051
}
5152

5253
func (LoginSession) TableName() string {
@@ -292,6 +293,12 @@ type HandledLoginRequest struct {
292293
// required: true
293294
Subject string `json:"subject"`
294295

296+
// IdentityProviderSessionID is the session ID of the end-user that authenticated.
297+
// If specified, we will use this value to propagate the logout.
298+
//
299+
// required: false
300+
IdentityProviderSessionID string `json:"identity_provider_session_id,omitempty"`
301+
295302
// ForceSubjectIdentifier forces the "pairwise" user ID of the end-user that authenticated. The "pairwise" user ID refers to the
296303
// (Pairwise Identifier Algorithm)[http://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg] of the OpenID
297304
// Connect specification. It allows you to set an obfuscated subject ("user") identifier that is unique to the client.

0 commit comments

Comments
 (0)