mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-21 04:57:18 +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)
|
||||
|
||||
// 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))
|
||||
|
@ -101,9 +103,11 @@ func (a *Authenticate) wellKnown(w http.ResponseWriter, r *http.Request) error {
|
|||
// RFC7517 document, which contains the client's public keys.
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -124,6 +124,7 @@ module.exports = {
|
|||
"topics/programmatic-access",
|
||||
"topics/tcp-support",
|
||||
"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