From f1e4cb22456afeb42d6d16d29a37231ad99224db Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 30 Oct 2024 09:44:31 +0100 Subject: [PATCH] feat(OP): add back channel logout support (#671) * feat: add configuration support for back channel logout * logout token * indicate back channel logout support in discovery endpoint --- README.md | 28 ++++++++++++----------- pkg/oidc/discovery.go | 8 +++++++ pkg/oidc/token.go | 37 +++++++++++++++++++++++++++++++ pkg/oidc/token_test.go | 36 ++++++++++++++++++++++++++++++ pkg/op/config.go | 3 +++ pkg/op/discovery.go | 4 ++++ pkg/op/mock/configuration.mock.go | 28 +++++++++++++++++++++++ pkg/op/op.go | 30 ++++++++++++++++--------- 8 files changed, 151 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c1ff0aa5..b1028151 100644 --- a/README.md +++ b/README.md @@ -102,19 +102,20 @@ Here is json equivalent for one of the default users ## Features -| | Relying party | OpenID Provider | Specification | -| -------------------- | ------------- | --------------- | ----------------------------------------- | -| Code Flow | yes | yes | OpenID Connect Core 1.0, [Section 3.1][1] | -| Implicit Flow | no[^1] | yes | OpenID Connect Core 1.0, [Section 3.2][2] | -| Hybrid Flow | no | not yet | OpenID Connect Core 1.0, [Section 3.3][3] | -| Client Credentials | yes | yes | OpenID Connect Core 1.0, [Section 9][4] | -| Refresh Token | yes | yes | OpenID Connect Core 1.0, [Section 12][5] | -| Discovery | yes | yes | OpenID Connect [Discovery][6] 1.0 | -| JWT Profile | yes | yes | [RFC 7523][7] | -| PKCE | yes | yes | [RFC 7636][8] | -| Token Exchange | yes | yes | [RFC 8693][9] | -| Device Authorization | yes | yes | [RFC 8628][10] | -| mTLS | not yet | not yet | [RFC 8705][11] | +| | Relying party | OpenID Provider | Specification | +|----------------------| ------------- | --------------- |----------------------------------------------| +| Code Flow | yes | yes | OpenID Connect Core 1.0, [Section 3.1][1] | +| Implicit Flow | no[^1] | yes | OpenID Connect Core 1.0, [Section 3.2][2] | +| Hybrid Flow | no | not yet | OpenID Connect Core 1.0, [Section 3.3][3] | +| Client Credentials | yes | yes | OpenID Connect Core 1.0, [Section 9][4] | +| Refresh Token | yes | yes | OpenID Connect Core 1.0, [Section 12][5] | +| Discovery | yes | yes | OpenID Connect [Discovery][6] 1.0 | +| JWT Profile | yes | yes | [RFC 7523][7] | +| PKCE | yes | yes | [RFC 7636][8] | +| Token Exchange | yes | yes | [RFC 8693][9] | +| Device Authorization | yes | yes | [RFC 8628][10] | +| mTLS | not yet | not yet | [RFC 8705][11] | +| Back-Channel Logout | not yet | yes | OpenID Connect [Back-Channel Logout][12] 1.0 | [1]: "3.1. Authentication using the Authorization Code Flow" [2]: "3.2. Authentication using the Implicit Flow" @@ -127,6 +128,7 @@ Here is json equivalent for one of the default users [9]: "OAuth 2.0 Token Exchange" [10]: "OAuth 2.0 Device Authorization Grant" [11]: "OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens" +[12]: "OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1" ## Contributors diff --git a/pkg/oidc/discovery.go b/pkg/oidc/discovery.go index 14fce5e4..62288d1b 100644 --- a/pkg/oidc/discovery.go +++ b/pkg/oidc/discovery.go @@ -145,6 +145,14 @@ type DiscoveryConfiguration struct { // OPTermsOfServiceURI is a URL the OpenID Provider provides to the person registering the Client to read about OpenID Provider's terms of service. OPTermsOfServiceURI string `json:"op_tos_uri,omitempty"` + + // BackChannelLogoutSupported specifies whether the OP supports back-channel logout (https://openid.net/specs/openid-connect-backchannel-1_0.html), + // with true indicating support. If omitted, the default value is false. + BackChannelLogoutSupported bool `json:"backchannel_logout_supported,omitempty"` + + // BackChannelLogoutSessionSupported specifies whether the OP can pass a sid (session ID) Claim in the Logout Token to identify the RP session with the OP. + // If supported, the sid Claim is also included in ID Tokens issued by the OP. If omitted, the default value is false. + BackChannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported,omitempty"` } type AuthMethod string diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index a829df4f..e57d91e5 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -382,3 +382,40 @@ type TokenExchangeResponse struct { // if the requested_token_type was Access Token and scope contained openid. IDToken string `json:"id_token,omitempty"` } + +type LogoutTokenClaims struct { + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience Audience `json:"aud,omitempty"` + IssuedAt Time `json:"iat,omitempty"` + Expiration Time `json:"exp,omitempty"` + JWTID string `json:"jti,omitempty"` + Events map[string]any `json:"events,omitempty"` + SessionID string `json:"sid,omitempty"` + Claims map[string]any `json:"-"` +} + +type ltcAlias LogoutTokenClaims + +func (i *LogoutTokenClaims) MarshalJSON() ([]byte, error) { + return mergeAndMarshalClaims((*ltcAlias)(i), i.Claims) +} + +func (i *LogoutTokenClaims) UnmarshalJSON(data []byte) error { + return unmarshalJSONMulti(data, (*ltcAlias)(i), &i.Claims) +} + +func NewLogoutTokenClaims(issuer, subject string, audience Audience, expiration time.Time, jwtID, sessionID string, skew time.Duration) *LogoutTokenClaims { + return &LogoutTokenClaims{ + Issuer: issuer, + Subject: subject, + Audience: audience, + IssuedAt: FromTime(time.Now().Add(-skew)), + Expiration: FromTime(expiration), + JWTID: jwtID, + Events: map[string]any{ + "http://schemas.openid.net/event/backchannel-logout": struct{}{}, + }, + SessionID: sessionID, + } +} diff --git a/pkg/oidc/token_test.go b/pkg/oidc/token_test.go index 7847cb5d..621cdbc0 100644 --- a/pkg/oidc/token_test.go +++ b/pkg/oidc/token_test.go @@ -242,3 +242,39 @@ func TestIDTokenClaims_GetUserInfo(t *testing.T) { got := idTokenData.GetUserInfo() assert.Equal(t, want, got) } + +func TestNewLogoutTokenClaims(t *testing.T) { + want := &LogoutTokenClaims{ + Issuer: "zitadel", + Subject: "hello@me.com", + Audience: Audience{"foo", "just@me.com"}, + Expiration: 12345, + JWTID: "jwtID", + Events: map[string]any{ + "http://schemas.openid.net/event/backchannel-logout": struct{}{}, + }, + SessionID: "sessionID", + Claims: nil, + } + + got := NewLogoutTokenClaims( + want.Issuer, + want.Subject, + want.Audience, + want.Expiration.AsTime(), + want.JWTID, + want.SessionID, + 1*time.Second, + ) + + // test if the dynamic timestamp is around now, + // allowing for a delta of 1, just in case we flip on + // either side of a second boundry. + nowMinusSkew := NowTime() - 1 + assert.InDelta(t, int64(nowMinusSkew), int64(got.IssuedAt), 1) + + // Make equal not fail on dynamic timestamp + got.IssuedAt = 0 + + assert.Equal(t, want, got) +} diff --git a/pkg/op/config.go b/pkg/op/config.go index 9fec7ccf..2fcede0f 100644 --- a/pkg/op/config.go +++ b/pkg/op/config.go @@ -49,6 +49,9 @@ type Configuration interface { SupportedUILocales() []language.Tag DeviceAuthorization() DeviceAuthorizationConfig + + BackChannelLogoutSupported() bool + BackChannelLogoutSessionSupported() bool } type IssuerFromRequest func(r *http.Request) string diff --git a/pkg/op/discovery.go b/pkg/op/discovery.go index cd08580d..5a79a09c 100644 --- a/pkg/op/discovery.go +++ b/pkg/op/discovery.go @@ -61,6 +61,8 @@ func CreateDiscoveryConfig(ctx context.Context, config Configuration, storage Di CodeChallengeMethodsSupported: CodeChallengeMethods(config), UILocalesSupported: config.SupportedUILocales(), RequestParameterSupported: config.RequestObjectSupported(), + BackChannelLogoutSupported: config.BackChannelLogoutSupported(), + BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(), } } @@ -92,6 +94,8 @@ func createDiscoveryConfigV2(ctx context.Context, config Configuration, storage CodeChallengeMethodsSupported: CodeChallengeMethods(config), UILocalesSupported: config.SupportedUILocales(), RequestParameterSupported: config.RequestObjectSupported(), + BackChannelLogoutSupported: config.BackChannelLogoutSupported(), + BackChannelLogoutSessionSupported: config.BackChannelLogoutSessionSupported(), } } diff --git a/pkg/op/mock/configuration.mock.go b/pkg/op/mock/configuration.mock.go index f392a455..137c09d5 100644 --- a/pkg/op/mock/configuration.mock.go +++ b/pkg/op/mock/configuration.mock.go @@ -78,6 +78,34 @@ func (mr *MockConfigurationMockRecorder) AuthorizationEndpoint() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationEndpoint", reflect.TypeOf((*MockConfiguration)(nil).AuthorizationEndpoint)) } +// BackChannelLogoutSessionSupported mocks base method. +func (m *MockConfiguration) BackChannelLogoutSessionSupported() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BackChannelLogoutSessionSupported") + ret0, _ := ret[0].(bool) + return ret0 +} + +// BackChannelLogoutSessionSupported indicates an expected call of BackChannelLogoutSessionSupported. +func (mr *MockConfigurationMockRecorder) BackChannelLogoutSessionSupported() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackChannelLogoutSessionSupported", reflect.TypeOf((*MockConfiguration)(nil).BackChannelLogoutSessionSupported)) +} + +// BackChannelLogoutSupported mocks base method. +func (m *MockConfiguration) BackChannelLogoutSupported() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BackChannelLogoutSupported") + ret0, _ := ret[0].(bool) + return ret0 +} + +// BackChannelLogoutSupported indicates an expected call of BackChannelLogoutSupported. +func (mr *MockConfigurationMockRecorder) BackChannelLogoutSupported() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BackChannelLogoutSupported", reflect.TypeOf((*MockConfiguration)(nil).BackChannelLogoutSupported)) +} + // CodeMethodS256Supported mocks base method. func (m *MockConfiguration) CodeMethodS256Supported() bool { m.ctrl.T.Helper() diff --git a/pkg/op/op.go b/pkg/op/op.go index 61c24491..22480983 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -158,16 +158,18 @@ func authCallbackPath(o OpenIDProvider) string { } type Config struct { - CryptoKey [32]byte - DefaultLogoutRedirectURI string - CodeMethodS256 bool - AuthMethodPost bool - AuthMethodPrivateKeyJWT bool - GrantTypeRefreshToken bool - RequestObjectSupported bool - SupportedUILocales []language.Tag - SupportedClaims []string - DeviceAuthorization DeviceAuthorizationConfig + CryptoKey [32]byte + DefaultLogoutRedirectURI string + CodeMethodS256 bool + AuthMethodPost bool + AuthMethodPrivateKeyJWT bool + GrantTypeRefreshToken bool + RequestObjectSupported bool + SupportedUILocales []language.Tag + SupportedClaims []string + DeviceAuthorization DeviceAuthorizationConfig + BackChannelLogoutSupported bool + BackChannelLogoutSessionSupported bool } // Endpoints defines endpoint routes. @@ -411,6 +413,14 @@ func (o *Provider) DeviceAuthorization() DeviceAuthorizationConfig { return o.config.DeviceAuthorization } +func (o *Provider) BackChannelLogoutSupported() bool { + return o.config.BackChannelLogoutSupported +} + +func (o *Provider) BackChannelLogoutSessionSupported() bool { + return o.config.BackChannelLogoutSessionSupported +} + func (o *Provider) Storage() Storage { return o.storage }