diff --git a/README.md b/README.md index 169bfe7..f29d019 100644 --- a/README.md +++ b/README.md @@ -42,20 +42,21 @@ Wonderwall exposes and owns these endpoints (which means they will never be prox Endpoints that are available for use by applications: -| Path | Description | -|---------------------------|------------------------------------------------------------------------------------------------| -| `/oauth2/login` | Initiates the OpenID Connect Authorization Code flow | -| `/oauth2/logout` | Initiates local and global/single-logout | -| `/oauth2/session` | Returns the current user's session metadata | -| `/oauth2/session/refresh` | Refreshes the tokens for the user's session. Requires the `session.refresh` flag to be enabled | +| Path | Description | +|--------------------------------|------------------------------------------------------------------------------------------------| +| `GET /oauth2/login` | Initiates the OpenID Connect Authorization Code flow | +| `GET /oauth2/logout` | Performs local logout and redirects the user to global/single-logout | +| `GET /oauth2/logout/local` | Performs local logout only | +| `GET /oauth2/session` | Returns the current user's session metadata | +| `POST /oauth2/session/refresh` | Refreshes the tokens for the user's session. Requires the `session.refresh` flag to be enabled | Endpoints that should be registered at and only be triggered by identity providers: -| Path | Description | -|-------------------------------|--------------------------------------------------------------------------------------------| -| `/oauth2/callback` | Handles the callback from the identity provider | -| `/oauth2/logout/callback` | Handles the logout callback from the identity provider | -| `/oauth2/logout/frontchannel` | Handles global logout request (initiated by identity provider on behalf of another client) | +| Path | Description | +|-----------------------------------|--------------------------------------------------------------------------------------------| +| `GET /oauth2/callback` | Handles the callback from the identity provider | +| `GET /oauth2/logout/callback` | Handles the logout callback from the identity provider | +| `GET /oauth2/logout/frontchannel` | Handles global logout request (initiated by identity provider on behalf of another client) | ## Usage diff --git a/pkg/handler/api/logout/logout.go b/pkg/handler/api/logout/logout.go index ee51ee2..6cb0944 100644 --- a/pkg/handler/api/logout/logout.go +++ b/pkg/handler/api/logout/logout.go @@ -25,7 +25,11 @@ type Source interface { GetSessions() *session.Handler } -func Handler(src Source, w http.ResponseWriter, r *http.Request) { +type Options struct { + GlobalLogout bool +} + +func Handler(src Source, w http.ResponseWriter, r *http.Request, opts Options) { logger := logentry.LogEntryFrom(r) logout, err := src.GetClient().Logout(r) if err != nil { @@ -49,6 +53,7 @@ func Handler(src Source, w http.ResponseWriter, r *http.Request) { "jti": sessionData.IDTokenJwtID, } logger.WithFields(fields).Info("logout: successful local logout") + metrics.ObserveLogout(metrics.LogoutOperationLocal) } cookie.Clear(w, cookie.Session, src.GetCookieOptsPathAware(r)) @@ -57,7 +62,9 @@ func Handler(src Source, w http.ResponseWriter, r *http.Request) { src.GetLoginstatus().ClearCookie(w, src.GetCookieOptions()) } - logger.Debug("logout: redirecting to identity provider") - metrics.ObserveLogout(metrics.LogoutOperationSelfInitiated) - http.Redirect(w, r, logout.SingleLogoutURL(idToken), http.StatusTemporaryRedirect) + if opts.GlobalLogout { + logger.Debug("logout: redirecting to identity provider for global/single-logout") + metrics.ObserveLogout(metrics.LogoutOperationSelfInitiated) + http.Redirect(w, r, logout.SingleLogoutURL(idToken), http.StatusTemporaryRedirect) + } } diff --git a/pkg/handler/handler_standard.go b/pkg/handler/handler_standard.go index 0012603..66b8e3c 100644 --- a/pkg/handler/handler_standard.go +++ b/pkg/handler/handler_standard.go @@ -111,7 +111,17 @@ func (s *StandardHandler) LoginCallback(w http.ResponseWriter, r *http.Request) } func (s *StandardHandler) Logout(w http.ResponseWriter, r *http.Request) { - apilogout.Handler(s, w, r) + opts := apilogout.Options{ + GlobalLogout: true, + } + apilogout.Handler(s, w, r, opts) +} + +func (s *StandardHandler) LogoutLocal(w http.ResponseWriter, r *http.Request) { + opts := apilogout.Options{ + GlobalLogout: false, + } + apilogout.Handler(s, w, r, opts) } func (s *StandardHandler) LogoutCallback(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/handler/handler_test.go b/pkg/handler/handler_test.go index 91b17c3..f161924 100644 --- a/pkg/handler/handler_test.go +++ b/pkg/handler/handler_test.go @@ -73,7 +73,7 @@ func TestHandler_Logout(t *testing.T) { rpClient := idp.RelyingPartyClient() login(t, rpClient, idp) - resp := localLogout(t, rpClient, idp) + resp := selfInitiatedLogout(t, rpClient, idp) // Get endsession endpoint after local logout endsessionURL := resp.Location @@ -81,7 +81,7 @@ func TestHandler_Logout(t *testing.T) { idpserverURL, err := url.Parse(idp.ProviderServer.URL) assert.NoError(t, err) - req := idp.GetRequest(idp.RelyingPartyServer.URL + "/oauth2/logout") + req := idp.GetRequest(idp.RelyingPartyServer.URL + "/oauth2/logout/callback") expectedLogoutCallbackURL, err := urlpkg.LogoutCallbackURL(req) assert.NoError(t, err) @@ -139,6 +139,17 @@ func TestHandler_FrontChannelLogout(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestHandler_LogoutLocal(t *testing.T) { + cfg := mock.Config() + idp := mock.NewIdentityProvider(cfg) + defer idp.Close() + + rpClient := idp.RelyingPartyClient() + login(t, rpClient, idp) + + localLogout(t, rpClient, idp) +} + func TestHandler_SessionStateRequired(t *testing.T) { cfg := mock.Config() idp := mock.NewIdentityProvider(cfg) @@ -708,7 +719,7 @@ func login(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) *htt return callback(t, rpClient, resp) } -func localLogout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) response { +func selfInitiatedLogout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) response { // Request self-initiated logout logoutURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/logout") assert.NoError(t, err) @@ -726,7 +737,7 @@ func localLogout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider func logout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) { // Get endsession endpoint after local logout - resp := localLogout(t, rpClient, idp) + resp := selfInitiatedLogout(t, rpClient, idp) // Follow redirect to endsession endpoint at identity provider resp = get(t, rpClient, resp.Location.String()) @@ -755,6 +766,21 @@ func logout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) { assert.Nil(t, sessionCookie) } +func localLogout(t *testing.T, rpClient *http.Client, idp *mock.IdentityProvider) response { + logoutURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/logout/local") + assert.NoError(t, err) + + resp := get(t, rpClient, logoutURL.String()) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cookies := rpClient.Jar.Cookies(logoutURL) + sessionCookie := getCookieFromJar(cookie.Session, cookies) + + assert.Nil(t, sessionCookie) + + return resp +} + func sessionInfo(t *testing.T, idp *mock.IdentityProvider, rpClient *http.Client) response { sessionInfoURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/session") assert.NoError(t, err) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index aacd7f0..663f4f2 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -27,8 +27,9 @@ const ( type LogoutOperation = string const ( - LogoutOperationSelfInitiated = "self_initiated" LogoutOperationFrontChannel = "front_channel" + LogoutOperationLocal = "local" + LogoutOperationSelfInitiated = "self_initiated" ) type RedisOperation = string diff --git a/pkg/router/paths/paths.go b/pkg/router/paths/paths.go index 85b88e4..2f25baf 100644 --- a/pkg/router/paths/paths.go +++ b/pkg/router/paths/paths.go @@ -7,6 +7,7 @@ const ( Logout = "/logout" LogoutCallback = "/logout/callback" LogoutFrontChannel = "/logout/frontchannel" + LogoutLocal = "/logout/local" Session = "/session" SessionRefresh = "/session/refresh" ) diff --git a/pkg/router/router.go b/pkg/router/router.go index e923102..6782adb 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -21,12 +21,14 @@ type Handlers interface { Login(http.ResponseWriter, *http.Request) // LoginCallback handles the authentication response from the identity provider. LoginCallback(http.ResponseWriter, *http.Request) - // Logout triggers self-initiated logout for the current user. + // Logout triggers self-initiated logout for the current user, as well as single-logout at the identity provider. Logout(http.ResponseWriter, *http.Request) // LogoutCallback handles the callback initiated by the self-initiated logout after single-logout at the identity provider. LogoutCallback(http.ResponseWriter, *http.Request) // LogoutFrontChannel performs a local logout initiated by a third party in the SSO circle-of-trust. LogoutFrontChannel(http.ResponseWriter, *http.Request) + // LogoutLocal clears the current user's local session for logout, without triggering single-logout at the identity provider. + LogoutLocal(http.ResponseWriter, *http.Request) // Session returns metadata for the current user's session. Session(http.ResponseWriter, *http.Request) // SessionRefresh refreshes current user's session and returns the associated updated metadata. @@ -62,8 +64,9 @@ func New(src Source) chi.Router { r.Get(paths.Login, src.Login) r.Get(paths.LoginCallback, src.LoginCallback) r.Get(paths.Logout, src.Logout) - r.Get(paths.LogoutFrontChannel, src.LogoutFrontChannel) r.Get(paths.LogoutCallback, src.LogoutCallback) + r.Get(paths.LogoutFrontChannel, src.LogoutFrontChannel) + r.Get(paths.LogoutLocal, src.LogoutLocal) r.Get(paths.Session, src.Session) r.Get(paths.SessionRefresh, src.SessionRefresh) // TODO: for legacy purposes, remove after grace period r.Post(paths.SessionRefresh, src.SessionRefresh)