authenticate: oidc frontchannel-logout endpoint (#1586)

* authenticate: oidc frontchannel-logout endpoint
* move frontchannellogout route and extract logout process
* add frontchannel_logout_uri to wellknown handler
* authenticate: add context to logs in signout process
* docs: single sign-out topic
* gofmt, wording, refactoring method names

Signed-off-by: Bobby DeSimone <bobbydesimone@gmail.com>
Co-authored-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
Philip Wassermann 2020-12-24 23:30:48 +01:00 committed by GitHub
parent 74db362634
commit 2d3190c74e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 170 additions and 24 deletions

View file

@ -63,6 +63,10 @@ func (a *Authenticate) Mount(r *mux.Router) {
r.Path("/oauth2/callback").Handler(httputil.HandlerFunc(a.OAuthCallback)).Methods(http.MethodGet) r.Path("/oauth2/callback").Handler(httputil.HandlerFunc(a.OAuthCallback)).Methods(http.MethodGet)
// Proxy service endpoints // 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() v := r.PathPrefix("/.pomerium").Subrouter()
c := cors.New(cors.Options{ c := cors.New(cors.Options{
AllowOriginRequestFunc: func(r *http.Request, _ string) bool { AllowOriginRequestFunc: func(r *http.Request, _ string) bool {
@ -77,9 +81,7 @@ func (a *Authenticate) Mount(r *mux.Router) {
AllowedHeaders: []string{"*"}, AllowedHeaders: []string{"*"},
}) })
v.Use(c.Handler) v.Use(c.Handler)
v.Use(func(h http.Handler) http.Handler { v.Use(a.RetrieveSession)
return sessions.RetrieveSession(a.state.Load().sessionLoaders...)(h)
})
v.Use(a.VerifySession) v.Use(a.VerifySession)
v.Path("/").Handler(httputil.HandlerFunc(a.Dashboard)) v.Path("/").Handler(httputil.HandlerFunc(a.Dashboard))
v.Path("/sign_in").Handler(httputil.HandlerFunc(a.SignIn)) v.Path("/sign_in").Handler(httputil.HandlerFunc(a.SignIn))
@ -101,9 +103,11 @@ func (a *Authenticate) wellKnown(w http.ResponseWriter, r *http.Request) error {
// RFC7517 document, which contains the client's public keys. // RFC7517 document, which contains the client's public keys.
JSONWebKeySetURL string `json:"jwks_uri"` JSONWebKeySetURL string `json:"jwks_uri"`
OAuth2Callback string `json:"authentication_callback_endpoint"` 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: "/.well-known/pomerium/jwks.json"}).String(),
state.redirectURL.ResolveReference(&url.URL{Path: "/oauth2/callback"}).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) httputil.RenderJSON(w, http.StatusOK, wellKnownURLS)
return nil return nil
@ -114,6 +118,11 @@ func (a *Authenticate) jwks(w http.ResponseWriter, r *http.Request) error {
return nil 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 // VerifySession is the middleware used to enforce a valid authentication
// session state is attached to the users's request context. // session state is attached to the users's request context.
func (a *Authenticate) VerifySession(next http.Handler) http.Handler { 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") ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut")
defer span.End() 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 := "" redirectString := ""
if sru := a.options.Load().SignOutRedirectURL; sru != nil { if sru := a.options.Load().SignOutRedirectURL; sru != nil {
redirectString = sru.String() redirectString = sru.String()
@ -603,3 +595,51 @@ func (a *Authenticate) saveSessionToDataBroker(
return nil 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
}

View file

@ -569,7 +569,7 @@ func TestWellKnownEndpoint(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.ServeHTTP(rr, req) h.ServeHTTP(rr, req)
body := rr.Body.String() 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) 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 { type mockDataBrokerServiceClient struct {
databroker.DataBrokerServiceClient databroker.DataBrokerServiceClient

View file

@ -124,6 +124,7 @@ module.exports = {
"topics/programmatic-access", "topics/programmatic-access",
"topics/tcp-support", "topics/tcp-support",
"topics/impersonation", "topics/impersonation",
"topics/single-sign-out",
], ],
}, },
{ {

View file

@ -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`).