pomerium/internal/authenticateflow/stateful.go
Joe Kralicky fe31799eb5
Fix many instances of contexts and loggers not being propagated (#5340)
This also replaces instances where we manually write "return ctx.Err()"
with "return context.Cause(ctx)" which is functionally identical, but
will also correctly propagate cause errors if present.
2024-10-25 14:50:56 -04:00

384 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/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"
"github.com/pomerium/pomerium/pkg/identity"
"github.com/pomerium/pomerium/pkg/identity/manager"
)
// 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(ctx context.Context, 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(ctx,
&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 {
now := timeNow()
sessionExpiry := timestamppb.New(now.Add(s.sessionDuration))
sess := &session.Session{
Id: sessionState.ID,
UserId: sessionState.UserID(),
IssuedAt: timestamppb.New(now),
AccessedAt: timestamppb.New(now),
ExpiresAt: sessionExpiry,
OauthToken: manager.ToOAuthToken(accessToken),
Audience: sessionState.Audience,
}
sess.SetRawIDToken(claims.RawIDToken)
sess.AddClaims(claims.Flatten())
u, _ := user.Get(ctx, s.dataBrokerClient, sess.GetUserId())
if u == nil {
// if no user exists yet, create a new one
u = &user.User{
Id: sess.GetUserId(),
}
}
populateUserFromClaims(u, claims.Claims)
_, err := databroker.Put(ctx, s.dataBrokerClient, u)
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).Error().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).Error().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).Error().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).Error().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
}