diff --git a/authenticate/handlers.go b/authenticate/handlers.go index aaa638f71..8f30daede 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -63,6 +63,10 @@ func (a *Authenticate) Mount(r *mux.Router) { r.Path("/oauth2/callback").Handler(httputil.HandlerFunc(a.OAuthCallback)).Methods(http.MethodGet) // Proxy service endpoints + s := r.PathPrefix("/.pomerium/frontchannel-logout").Subrouter() + s.Use(a.RetrieveSession) + s.Path("/").Handler(httputil.HandlerFunc(a.FrontchannelLogout)).Methods(http.MethodGet) + v := r.PathPrefix("/.pomerium").Subrouter() c := cors.New(cors.Options{ AllowOriginRequestFunc: func(r *http.Request, _ string) bool { @@ -77,9 +81,7 @@ func (a *Authenticate) Mount(r *mux.Router) { AllowedHeaders: []string{"*"}, }) v.Use(c.Handler) - v.Use(func(h http.Handler) http.Handler { - return sessions.RetrieveSession(a.state.Load().sessionLoaders...)(h) - }) + v.Use(a.RetrieveSession) v.Use(a.VerifySession) v.Path("/").Handler(httputil.HandlerFunc(a.Dashboard)) v.Path("/sign_in").Handler(httputil.HandlerFunc(a.SignIn)) @@ -99,11 +101,13 @@ func (a *Authenticate) wellKnown(w http.ResponseWriter, r *http.Request) error { wellKnownURLS := struct { // URL string referencing the client's JSON Web Key (JWK) Set // RFC7517 document, which contains the client's public keys. - JSONWebKeySetURL string `json:"jwks_uri"` - OAuth2Callback string `json:"authentication_callback_endpoint"` + JSONWebKeySetURL string `json:"jwks_uri"` + OAuth2Callback string `json:"authentication_callback_endpoint"` + FrontchannelLogoutURI string `json:"frontchannel_logout_uri"` }{ state.redirectURL.ResolveReference(&url.URL{Path: "/.well-known/pomerium/jwks.json"}).String(), state.redirectURL.ResolveReference(&url.URL{Path: "/oauth2/callback"}).String(), + state.redirectURL.ResolveReference(&url.URL{Path: "/.pomerium/frontchannel-logout"}).String(), } httputil.RenderJSON(w, http.StatusOK, wellKnownURLS) return nil @@ -114,6 +118,11 @@ func (a *Authenticate) jwks(w http.ResponseWriter, r *http.Request) error { return nil } +// RetrieveSession is the middleware used retrieve session by the sessionLoaders +func (a *Authenticate) RetrieveSession(next http.Handler) http.Handler { + return sessions.RetrieveSession(a.state.Load().sessionLoaders...)(next) +} + // VerifySession is the middleware used to enforce a valid authentication // session state is attached to the users's request context. func (a *Authenticate) VerifySession(next http.Handler) http.Handler { @@ -237,25 +246,8 @@ func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error { ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut") defer span.End() - state := a.state.Load() + rawIDToken := a.revokeSession(ctx, w, r) - var rawIDToken string - sessionState, err := a.getSessionFromCtx(ctx) - if err == nil { - if s, _ := session.Get(ctx, state.dataBrokerClient, sessionState.ID); s != nil && s.OauthToken != nil { - rawIDToken = s.GetIdToken().GetRaw() - if err := a.provider.Load().Revoke(ctx, manager.FromOAuthToken(s.OauthToken)); err != nil { - log.Warn().Err(err).Msg("failed to revoke access token") - } - } - err = a.deleteSession(ctx, sessionState.ID) - if err != nil { - log.Warn().Err(err).Msg("failed to delete session from session store") - } - } - - // no matter what happens, we want to clear the session store - state.sessionStore.ClearSession(w, r) redirectString := "" if sru := a.options.Load().SignOutRedirectURL; sru != nil { redirectString = sru.String() @@ -603,3 +595,51 @@ func (a *Authenticate) saveSessionToDataBroker( return nil } + +// FrontchannelLogout uses HTTP GETs to Relying Party URLs (Pomerium) to clear a user's login state. +// This endpoint implements OpenID Connect Front-Channel Logout and reuses the Relying +// Party-initiated logout functionality specified in Section 5 of OpenID Connect Session Management +// 1.0 (RP-Initiated Logout). +// +// https://openid.net/specs/openid-connect-frontchannel-1_0.html +// https://ldapwiki.com/wiki/OpenID%20Connect%20Front-Channel%20Logout +func (a *Authenticate) FrontchannelLogout(w http.ResponseWriter, r *http.Request) error { + ctx, span := trace.StartSpan(r.Context(), "authenticate.FrontchannelLogout") + defer span.End() + + _ = a.revokeSession(ctx, w, r) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, http.StatusText(http.StatusOK)) + return nil +} + +// revokeSession always clears the local session and tries to revoke the associated session stored in the +// databroker. If successful, it returns the original `id_token` of the session, if failed, returns +// and empty string. +func (a *Authenticate) revokeSession(ctx context.Context, w http.ResponseWriter, r *http.Request) string { + state := a.state.Load() + // clear the user's local session no matter what + defer state.sessionStore.ClearSession(w, r) + + var rawIDToken string + sessionState, err := a.getSessionFromCtx(ctx) + if err != nil { + return rawIDToken + } + + if s, _ := session.Get(ctx, state.dataBrokerClient, sessionState.ID); s != nil && s.OauthToken != nil { + rawIDToken = s.GetIdToken().GetRaw() + if err := a.provider.Load().Revoke(ctx, manager.FromOAuthToken(s.OauthToken)); err != nil { + log.Ctx(ctx).Warn().Err(err).Msg("authenticate: failed to revoke access token") + } + } + if err := a.deleteSession(ctx, sessionState.ID); err != nil { + log.Ctx(ctx).Warn().Err(err).Msg("authenticate: failed to delete session from session store") + } + + return rawIDToken +} diff --git a/authenticate/handlers_test.go b/authenticate/handlers_test.go index 542945f56..81753434d 100644 --- a/authenticate/handlers_test.go +++ b/authenticate/handlers_test.go @@ -569,7 +569,7 @@ func TestWellKnownEndpoint(t *testing.T) { rr := httptest.NewRecorder() h.ServeHTTP(rr, req) body := rr.Body.String() - expected := "{\"jwks_uri\":\"https://auth.example.com/.well-known/pomerium/jwks.json\",\"authentication_callback_endpoint\":\"https://auth.example.com/oauth2/callback\"}\n" + expected := "{\"jwks_uri\":\"https://auth.example.com/.well-known/pomerium/jwks.json\",\"authentication_callback_endpoint\":\"https://auth.example.com/oauth2/callback\",\"frontchannel_logout_uri\":\"https://auth.example.com/.pomerium/frontchannel-logout\"}\n" assert.Equal(t, body, expected) } @@ -673,6 +673,85 @@ func TestAuthenticate_Dashboard(t *testing.T) { } } +func TestAuthenticate_FrontchannelLogout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + + logoutIssuer string + tokenIssuer string + widthSession bool + sessionStore sessions.SessionStore + provider identity.MockProvider + + wantCode int + }{ + {"good", "https://idp.pomerium.io", "https://idp.pomerium.io", true, &mstore.Store{}, identity.MockProvider{AuthenticateResponse: oauth2.Token{}}, http.StatusOK}, + {"good no session", "https://idp.pomerium.io", "https://idp.pomerium.io", false, &mstore.Store{SaveError: errors.New("error")}, identity.MockProvider{AuthenticateResponse: oauth2.Token{}}, http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + a := &Authenticate{ + state: newAtomicAuthenticateState(&authenticateState{ + sessionStore: tt.sessionStore, + encryptedEncoder: mock.Encoder{}, + sharedEncoder: mock.Encoder{}, + dataBrokerClient: mockDataBrokerServiceClient{ + delete: func(ctx context.Context, in *databroker.DeleteRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + return nil, nil + }, + get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) { + if !tt.widthSession { + + return nil, nil + } + + data, err := ptypes.MarshalAny(&session.Session{ + Id: "SESSION_ID", + IdToken: &session.IDToken{ + Issuer: tt.tokenIssuer, + }, + }) + if err != nil { + return nil, err + } + + return &databroker.GetResponse{ + Record: &databroker.Record{ + Version: "0001", + Type: data.GetTypeUrl(), + Id: "SESSION_ID", + Data: data, + }, + }, nil + }, + }, + directoryClient: new(mockDirectoryServiceClient), + }), + options: config.NewAtomicOptions(), + provider: identity.NewAtomicAuthenticator(), + } + + a.provider.Store(tt.provider) + u, _ := url.Parse("/.pomerium/frontchannel-logout") + params, _ := url.ParseQuery(u.RawQuery) + params.Add("iss", tt.logoutIssuer) + u.RawQuery = params.Encode() + r := httptest.NewRequest(http.MethodGet, u.String(), nil) + + w := httptest.NewRecorder() + httputil.HandlerFunc(a.FrontchannelLogout).ServeHTTP(w, r) + if status := w.Code; status != tt.wantCode { + t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantCode) + } + }) + } +} + type mockDataBrokerServiceClient struct { databroker.DataBrokerServiceClient diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 7a6b30340..478e16308 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -124,6 +124,7 @@ module.exports = { "topics/programmatic-access", "topics/tcp-support", "topics/impersonation", + "topics/single-sign-out", ], }, { diff --git a/docs/docs/topics/single-sign-out.md b/docs/docs/topics/single-sign-out.md new file mode 100644 index 000000000..1538c5d44 --- /dev/null +++ b/docs/docs/topics/single-sign-out.md @@ -0,0 +1,26 @@ +--- +title: Single Sign-out +description: This article describes Pomerium's support for Single Sign-out according to OpenID Connect Front-Channel Logout 1.0. +--- + +# Single Sign-out + +Single Sign-out enables session termination on multiple software systems via a single logout endpoint. + +## OIDC Front-Channel Logout + +Pomerium supports Front-Channel Logout as described in [OpenID Connect Front-Channel Logout 1.0 - draft 04](https://openid.net/specs/openid-connect-frontchannel-1_0.html). + +### Provider Support + +To find out if your identity provider (IdP) supports Front-Channel Logout, have a look at the at your IdP's `/.well-known/openid-configuration` endpoint. On standard compliant providers it would contain: + +```json +{ + "frontchannel_logout_session_supported": true +} +``` + +### Configuration + +You need to register a `frontchannel_logout_uri` in your OAuth 2.0 Client settings. The url gets handled by the Authenticate Service under the path `/.pomerium/frontchannel-logout` (e.g `https://authenticate.localhost.pomerium.io/.pomerium/frontchannel-logout`).