mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-21 21:17:13 +02:00
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:
parent
74db362634
commit
2d3190c74e
4 changed files with 170 additions and 24 deletions
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
26
docs/docs/topics/single-sign-out.md
Normal file
26
docs/docs/topics/single-sign-out.md
Normal 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`).
|
Loading…
Add table
Add a link
Reference in a new issue