mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-03 04:16:03 +02:00
The identity manager expects to be able to read session ID and user ID from any deleted databroker session records. The session.Delete() wrapper method is not compatible with this expectation, as it calls Put() with a record containing an empty session. The stateful authentication flow currently calls session.Delete() from its RevokeSession() method. The result is that the identity manager will not correctly track sessions deleted by the the stateful authentication flow, and will still try to use them during session refresh and user info refresh. Instead, let's change the stateful authentication flow RevokeSession() method to perform deletions in a way that is compatible with the current identity manager code. That is, include the existing session data in the Put() call to delete the revoked session.
391 lines
12 KiB
Go
391 lines
12 KiB
Go
package authenticateflow
|
|
|
|
import (
|
|
"context"
|
|
"crypto/cipher"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"github.com/pomerium/pomerium/config"
|
|
"github.com/pomerium/pomerium/internal/encoding"
|
|
"github.com/pomerium/pomerium/internal/encoding/jws"
|
|
"github.com/pomerium/pomerium/internal/handlers"
|
|
"github.com/pomerium/pomerium/internal/httputil"
|
|
"github.com/pomerium/pomerium/internal/identity"
|
|
"github.com/pomerium/pomerium/internal/identity/manager"
|
|
"github.com/pomerium/pomerium/internal/log"
|
|
"github.com/pomerium/pomerium/internal/sessions"
|
|
"github.com/pomerium/pomerium/internal/urlutil"
|
|
"github.com/pomerium/pomerium/pkg/cryptutil"
|
|
"github.com/pomerium/pomerium/pkg/grpc"
|
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
|
"github.com/pomerium/pomerium/pkg/grpc/session"
|
|
"github.com/pomerium/pomerium/pkg/grpc/user"
|
|
"github.com/pomerium/pomerium/pkg/grpcutil"
|
|
)
|
|
|
|
// Stateful implements the stateful authentication flow. In this flow, the
|
|
// authenticate service has direct access to the databroker.
|
|
type Stateful struct {
|
|
signatureVerifier
|
|
|
|
// sharedEncoder is the encoder to use to serialize data to be consumed
|
|
// by other services
|
|
sharedEncoder encoding.MarshalUnmarshaler
|
|
// sharedKey is the secret to encrypt and authenticate data shared between services
|
|
sharedKey []byte
|
|
// sharedCipher is the cipher to use to encrypt/decrypt data shared between services
|
|
sharedCipher cipher.AEAD
|
|
// sessionDuration is the maximum Pomerium session duration
|
|
sessionDuration time.Duration
|
|
// sessionStore is the session store used to persist a user's session
|
|
sessionStore sessions.SessionStore
|
|
|
|
defaultIdentityProviderID string
|
|
|
|
authenticateURL *url.URL
|
|
|
|
dataBrokerClient databroker.DataBrokerServiceClient
|
|
}
|
|
|
|
// NewStateful initializes the authentication flow for the given configuration
|
|
// and session store.
|
|
func NewStateful(cfg *config.Config, sessionStore sessions.SessionStore) (*Stateful, error) {
|
|
s := &Stateful{
|
|
sessionDuration: cfg.Options.CookieExpire,
|
|
sessionStore: sessionStore,
|
|
}
|
|
|
|
var err error
|
|
s.authenticateURL, err = cfg.Options.GetAuthenticateURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// shared cipher to encrypt data before passing data between services
|
|
s.sharedKey, err = cfg.Options.GetSharedKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.sharedCipher, err = cryptutil.NewAEADCipher(s.sharedKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// shared state encoder setup
|
|
s.sharedEncoder, err = jws.NewHS256Signer(s.sharedKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.signatureVerifier = signatureVerifier{cfg.Options, s.sharedKey}
|
|
|
|
idp, err := cfg.Options.GetIdentityProviderForPolicy(nil)
|
|
if err == nil {
|
|
s.defaultIdentityProviderID = idp.GetId()
|
|
}
|
|
|
|
dataBrokerConn, err := outboundGRPCConnection.Get(context.Background(),
|
|
&grpc.OutboundOptions{
|
|
OutboundPort: cfg.OutboundPort,
|
|
InstallationID: cfg.Options.InstallationID,
|
|
ServiceName: cfg.Options.Services,
|
|
SignedJWTKey: s.sharedKey,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.dataBrokerClient = databroker.NewDataBrokerServiceClient(dataBrokerConn)
|
|
return s, nil
|
|
}
|
|
|
|
// SignIn redirects to a route callback URL, if the provided request and
|
|
// session state are valid.
|
|
func (s *Stateful) SignIn(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
sessionState *sessions.State,
|
|
) error {
|
|
if err := s.VerifyAuthenticateSignature(r); err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
idpID := r.FormValue(urlutil.QueryIdentityProviderID)
|
|
|
|
// start over if this is a different identity provider
|
|
if sessionState == nil || sessionState.IdentityProviderID != idpID {
|
|
sessionState = sessions.NewState(idpID)
|
|
}
|
|
|
|
redirectURL, err := urlutil.ParseAndValidateURL(r.FormValue(urlutil.QueryRedirectURI))
|
|
if err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
jwtAudience := []string{s.authenticateURL.Host, redirectURL.Host}
|
|
|
|
// if the callback is explicitly set, set it and add an additional audience
|
|
if callbackStr := r.FormValue(urlutil.QueryCallbackURI); callbackStr != "" {
|
|
callbackURL, err := urlutil.ParseAndValidateURL(callbackStr)
|
|
if err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
jwtAudience = append(jwtAudience, callbackURL.Host)
|
|
}
|
|
|
|
newSession := sessionState.WithNewIssuer(s.authenticateURL.Host, jwtAudience)
|
|
|
|
// re-persist the session, useful when session was evicted from session store
|
|
if err := s.sessionStore.SaveSession(w, r, sessionState); err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
// sign the route session, as a JWT
|
|
signedJWT, err := s.sharedEncoder.Marshal(newSession)
|
|
if err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
// encrypt our route-scoped JWT to avoid accidental logging of queryparams
|
|
encryptedJWT := cryptutil.Encrypt(s.sharedCipher, signedJWT, nil)
|
|
// base64 our encrypted payload for URL-friendlyness
|
|
encodedJWT := base64.URLEncoding.EncodeToString(encryptedJWT)
|
|
|
|
callbackURL, err := urlutil.GetCallbackURL(r, encodedJWT)
|
|
if err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
// build our hmac-d redirect URL with our session, pointing back to the
|
|
// proxy's callback URL which is responsible for setting our new route-session
|
|
uri := urlutil.NewSignedURL(s.sharedKey, callbackURL)
|
|
httputil.Redirect(w, r, uri.String(), http.StatusFound)
|
|
return nil
|
|
}
|
|
|
|
// PersistSession stores session and user data in the databroker.
|
|
func (s *Stateful) PersistSession(
|
|
ctx context.Context,
|
|
_ http.ResponseWriter,
|
|
sessionState *sessions.State,
|
|
claims identity.SessionClaims,
|
|
accessToken *oauth2.Token,
|
|
) error {
|
|
sessionExpiry := timestamppb.New(time.Now().Add(s.sessionDuration))
|
|
idTokenIssuedAt := timestamppb.New(sessionState.IssuedAt.Time())
|
|
|
|
sess := &session.Session{
|
|
Id: sessionState.ID,
|
|
UserId: sessionState.UserID(),
|
|
IssuedAt: timestamppb.Now(),
|
|
AccessedAt: timestamppb.Now(),
|
|
ExpiresAt: sessionExpiry,
|
|
IdToken: &session.IDToken{
|
|
Issuer: sessionState.Issuer, // todo(bdd): the issuer is not authN but the downstream IdP from the claims
|
|
Subject: sessionState.Subject,
|
|
ExpiresAt: sessionExpiry,
|
|
IssuedAt: idTokenIssuedAt,
|
|
},
|
|
OauthToken: manager.ToOAuthToken(accessToken),
|
|
Audience: sessionState.Audience,
|
|
}
|
|
sess.SetRawIDToken(claims.RawIDToken)
|
|
sess.AddClaims(claims.Flatten())
|
|
|
|
var managerUser manager.User
|
|
managerUser.User, _ = user.Get(ctx, s.dataBrokerClient, sess.GetUserId())
|
|
if managerUser.User == nil {
|
|
// if no user exists yet, create a new one
|
|
managerUser.User = &user.User{
|
|
Id: sess.GetUserId(),
|
|
}
|
|
}
|
|
populateUserFromClaims(managerUser.User, claims.Claims)
|
|
_, err := databroker.Put(ctx, s.dataBrokerClient, managerUser.User)
|
|
if err != nil {
|
|
return fmt.Errorf("authenticate: error saving user: %w", err)
|
|
}
|
|
|
|
res, err := session.Put(ctx, s.dataBrokerClient, sess)
|
|
if err != nil {
|
|
return fmt.Errorf("authenticate: error saving session: %w", err)
|
|
}
|
|
sessionState.DatabrokerServerVersion = res.GetServerVersion()
|
|
sessionState.DatabrokerRecordVersion = res.GetRecord().GetVersion()
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUserInfoData returns user info data associated with the given request (if
|
|
// any).
|
|
func (s *Stateful) GetUserInfoData(
|
|
r *http.Request, sessionState *sessions.State,
|
|
) handlers.UserInfoData {
|
|
var isImpersonated bool
|
|
pbSession, err := session.Get(r.Context(), s.dataBrokerClient, sessionState.ID)
|
|
if sid := pbSession.GetImpersonateSessionId(); sid != "" {
|
|
pbSession, err = session.Get(r.Context(), s.dataBrokerClient, sid)
|
|
isImpersonated = true
|
|
}
|
|
if err != nil {
|
|
pbSession = &session.Session{
|
|
Id: sessionState.ID,
|
|
}
|
|
}
|
|
|
|
pbUser, err := user.Get(r.Context(), s.dataBrokerClient, pbSession.GetUserId())
|
|
if err != nil {
|
|
pbUser = &user.User{
|
|
Id: pbSession.GetUserId(),
|
|
}
|
|
}
|
|
return handlers.UserInfoData{
|
|
IsImpersonated: isImpersonated,
|
|
Session: pbSession,
|
|
User: pbUser,
|
|
}
|
|
}
|
|
|
|
// RevokeSession revokes the session associated with the provided request,
|
|
// returning the ID token from the revoked session.
|
|
func (s *Stateful) RevokeSession(
|
|
ctx context.Context,
|
|
_ *http.Request,
|
|
authenticator identity.Authenticator,
|
|
sessionState *sessions.State,
|
|
) string {
|
|
if sessionState == nil {
|
|
return ""
|
|
}
|
|
|
|
// Note: session.Delete() cannot be used safely, because the identity
|
|
// manager expects to be able to read both session ID and user ID from
|
|
// deleted session records. Instead, we match the behavior used in the
|
|
// identity manager itself: fetch the existing databroker session record,
|
|
// explicitly set the DeletedAt timestamp, and Put() that record back.
|
|
|
|
res, err := s.dataBrokerClient.Get(ctx, &databroker.GetRequest{
|
|
Type: grpcutil.GetTypeURL(new(session.Session)),
|
|
Id: sessionState.ID,
|
|
})
|
|
if err != nil {
|
|
err = fmt.Errorf("couldn't get session to be revoked: %w", err)
|
|
log.Ctx(ctx).Warn().Err(err).Msg("authenticate: failed to revoke access token")
|
|
return ""
|
|
}
|
|
|
|
record := res.GetRecord()
|
|
|
|
var sess session.Session
|
|
if err := record.GetData().UnmarshalTo(&sess); err != nil {
|
|
err = fmt.Errorf("couldn't unmarshal data of session to be revoked: %w", err)
|
|
log.Ctx(ctx).Warn().Err(err).Msg("authenticate: failed to revoke access token")
|
|
return ""
|
|
}
|
|
|
|
var rawIDToken string
|
|
if sess.OauthToken != nil {
|
|
rawIDToken = sess.GetIdToken().GetRaw()
|
|
if err := authenticator.Revoke(ctx, manager.FromOAuthToken(sess.OauthToken)); err != nil {
|
|
log.Ctx(ctx).Warn().Err(err).Msg("authenticate: failed to revoke access token")
|
|
}
|
|
}
|
|
|
|
record.DeletedAt = timestamppb.Now()
|
|
_, err = s.dataBrokerClient.Put(ctx, &databroker.PutRequest{
|
|
Records: []*databroker.Record{record},
|
|
})
|
|
if err != nil {
|
|
log.Ctx(ctx).Warn().Err(err).
|
|
Msg("authenticate: failed to delete session from session store")
|
|
}
|
|
return rawIDToken
|
|
}
|
|
|
|
// VerifySession checks that an existing session is still valid.
|
|
func (s *Stateful) VerifySession(
|
|
ctx context.Context, _ *http.Request, sessionState *sessions.State,
|
|
) error {
|
|
sess, err := session.Get(ctx, s.dataBrokerClient, sessionState.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("session not found in databroker: %w", err)
|
|
}
|
|
return sess.Validate()
|
|
}
|
|
|
|
// LogAuthenticateEvent is a no-op for the stateful authentication flow.
|
|
func (s *Stateful) LogAuthenticateEvent(*http.Request) {}
|
|
|
|
// AuthenticateSignInURL returns a URL to redirect the user to the authenticate
|
|
// domain.
|
|
func (s *Stateful) AuthenticateSignInURL(
|
|
_ context.Context, queryParams url.Values, redirectURL *url.URL, idpID string,
|
|
) (string, error) {
|
|
signinURL := s.authenticateURL.ResolveReference(&url.URL{
|
|
Path: "/.pomerium/sign_in",
|
|
})
|
|
|
|
if queryParams == nil {
|
|
queryParams = url.Values{}
|
|
}
|
|
queryParams.Set(urlutil.QueryRedirectURI, redirectURL.String())
|
|
queryParams.Set(urlutil.QueryIdentityProviderID, idpID)
|
|
signinURL.RawQuery = queryParams.Encode()
|
|
redirectTo := urlutil.NewSignedURL(s.sharedKey, signinURL).String()
|
|
|
|
return redirectTo, nil
|
|
}
|
|
|
|
// GetIdentityProviderIDForURLValues returns the identity provider ID
|
|
// associated with the given URL values.
|
|
func (s *Stateful) GetIdentityProviderIDForURLValues(vs url.Values) string {
|
|
if id := vs.Get(urlutil.QueryIdentityProviderID); id != "" {
|
|
return id
|
|
}
|
|
return s.defaultIdentityProviderID
|
|
}
|
|
|
|
// Callback handles a redirect to a route domain once signed in.
|
|
func (s *Stateful) Callback(w http.ResponseWriter, r *http.Request) error {
|
|
if err := s.VerifySignature(r); err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
redirectURLString := r.FormValue(urlutil.QueryRedirectURI)
|
|
encryptedSession := r.FormValue(urlutil.QuerySessionEncrypted)
|
|
|
|
redirectURL, err := urlutil.ParseAndValidateURL(redirectURLString)
|
|
if err != nil {
|
|
return httputil.NewError(http.StatusBadRequest, err)
|
|
}
|
|
|
|
encryptedJWT, err := base64.URLEncoding.DecodeString(encryptedSession)
|
|
if err != nil {
|
|
return fmt.Errorf("proxy: malfromed callback token: %w", err)
|
|
}
|
|
|
|
rawJWT, err := cryptutil.Decrypt(s.sharedCipher, encryptedJWT, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("proxy: callback token decrypt error: %w", err)
|
|
}
|
|
|
|
// save the session state
|
|
if err = s.sessionStore.SaveSession(w, r, rawJWT); err != nil {
|
|
return httputil.NewError(http.StatusInternalServerError, fmt.Errorf("proxy: error saving session state: %w", err))
|
|
}
|
|
|
|
// if programmatic, encode the session jwt as a query param
|
|
if isProgrammatic := r.FormValue(urlutil.QueryIsProgrammatic); isProgrammatic == "true" {
|
|
q := redirectURL.Query()
|
|
q.Set(urlutil.QueryPomeriumJWT, string(rawJWT))
|
|
redirectURL.RawQuery = q.Encode()
|
|
}
|
|
|
|
// redirect
|
|
httputil.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
|
return nil
|
|
}
|