This commit is contained in:
Caleb Doxsey 2025-02-11 16:01:35 -07:00
parent 229ef72e58
commit a8650b1749
13 changed files with 465 additions and 25 deletions

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -95,6 +96,8 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
// routes that don't need a session:
sr.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut))
sr.Path("/signed_out").Handler(httputil.HandlerFunc(a.signedOut)).Methods(http.MethodGet)
sr.Path("/verify-access-token").Handler(httputil.HandlerFunc(a.verifyAccessToken)).Methods(http.MethodPost)
sr.Path("/verify-identity-token").Handler(httputil.HandlerFunc(a.verifyIdentityToken)).Methods(http.MethodPost)
// routes that need a session:
sr = sr.NewRoute().Subrouter()
@ -568,3 +571,83 @@ func (a *Authenticate) getIdentityProviderIDForRequest(r *http.Request) string {
}
return a.state.Load().flow.GetIdentityProviderIDForURLValues(r.Form)
}
type VerifyAccessTokenRequest struct {
AccessToken string `json:"accessToken"`
IdentityProviderID string `json:"identityProviderId,omitempty"`
}
type VerifyIdentityTokenRequest struct {
IdentityToken string `json:"identityToken"`
IdentityProviderID string `json:"identityProviderId,omitempty"`
}
type VerifyTokenResponse struct {
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Claims map[string]any `json:"claims,omitempty"`
}
func (a *Authenticate) verifyAccessToken(w http.ResponseWriter, r *http.Request) error {
// TODO: implement authorization
var req VerifyAccessTokenRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return httputil.NewError(http.StatusBadRequest, err)
}
authenticator, err := a.cfg.getIdentityProvider(r.Context(), a.tracerProvider, a.options.Load(), req.IdentityProviderID)
if err != nil {
return err
}
var res VerifyTokenResponse
claims, err := authenticator.VerifyAccessToken(r.Context(), req.AccessToken)
if err == nil {
res.Valid = true
res.Claims = claims
} else {
res.Valid = false
res.Error = err.Error()
}
err = json.NewEncoder(w).Encode(&res)
if err != nil {
return err
}
return nil
}
func (a *Authenticate) verifyIdentityToken(w http.ResponseWriter, r *http.Request) error {
// TODO: implement authorization
var req VerifyIdentityTokenRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return httputil.NewError(http.StatusBadRequest, err)
}
authenticator, err := a.cfg.getIdentityProvider(r.Context(), a.tracerProvider, a.options.Load(), req.IdentityProviderID)
if err != nil {
return err
}
var res VerifyTokenResponse
claims, err := authenticator.VerifyIdentityToken(r.Context(), req.IdentityToken)
if err == nil {
res.Valid = true
res.Claims = claims
} else {
res.Valid = false
res.Error = err.Error()
}
err = json.NewEncoder(w).Encode(&res)
if err != nil {
return err
}
return nil
}

View file

@ -3,15 +3,17 @@ package authorize
import (
"context"
"google.golang.org/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/storage"
"google.golang.org/grpc"
)
type sessionOrServiceAccount interface {
GetId() string
GetUserId() string
Validate() error
}

View file

@ -3,6 +3,7 @@ package authorize
import (
"context"
"encoding/pem"
"errors"
"io"
"net/http"
"net/url"
@ -44,26 +45,29 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v3.CheckRe
requestID := requestid.FromHTTPHeader(hreq.Header)
ctx = requestid.WithValue(ctx, requestID)
sessionState, _ := state.sessionStore.LoadSessionStateAndCheckIDP(hreq)
var s sessionOrServiceAccount
var u *user.User
var err error
if sessionState != nil {
s, err = a.getDataBrokerSessionOrServiceAccount(ctx, sessionState.ID, sessionState.DatabrokerRecordVersion)
if status.Code(err) == codes.Unavailable {
log.Ctx(ctx).Debug().Str("request-id", requestID).Err(err).Msg("temporary error checking authorization: data broker unavailable")
return nil, err
} else if err != nil {
log.Ctx(ctx).Info().Err(err).Str("request-id", requestID).Msg("clearing session due to missing or invalid session or service account")
sessionState = nil
if sess, err := a.state.Load().idpTokensLoader.LoadSession(hreq); err == nil {
s = sess
} else if !errors.Is(err, sessions.ErrNoSessionFound) {
log.Ctx(ctx).Info().Err(err).Str("request-id", requestID).Msg("error verifying idp tokens")
} else {
sessionState, _ := state.sessionStore.LoadSessionStateAndCheckIDP(hreq)
if sessionState != nil {
s, err = a.getDataBrokerSessionOrServiceAccount(ctx, sessionState.ID, sessionState.DatabrokerRecordVersion)
if status.Code(err) == codes.Unavailable {
log.Ctx(ctx).Debug().Str("request-id", requestID).Err(err).Msg("temporary error checking authorization: data broker unavailable")
return nil, err
} else if err != nil {
log.Ctx(ctx).Info().Err(err).Str("request-id", requestID).Msg("clearing session due to missing or invalid session or service account")
}
}
}
if sessionState != nil && s != nil {
if s != nil {
u, _ = a.getDataBrokerUser(ctx, s.GetUserId()) // ignore any missing user error
}
req, err := a.getEvaluatorRequestFromCheckRequest(ctx, in, sessionState)
req, err := a.getEvaluatorRequestFromCheckRequest(ctx, in, s.GetId())
if err != nil {
log.Ctx(ctx).Error().Err(err).Str("request-id", requestID).Msg("error building evaluator request")
return nil, err
@ -91,7 +95,7 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v3.CheckRe
func (a *Authorize) getEvaluatorRequestFromCheckRequest(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
sessionState *sessions.State,
sessionID string,
) (*evaluator.Request, error) {
requestURL := getCheckRequestURL(in)
attrs := in.GetAttributes()
@ -106,9 +110,9 @@ func (a *Authorize) getEvaluatorRequestFromCheckRequest(
attrs.GetSource().GetAddress().GetSocketAddress().GetAddress(),
),
}
if sessionState != nil {
if sessionID != "" {
req.Session = evaluator.RequestSession{
ID: sessionState.ID,
ID: sessionID,
}
}
req.Policy = a.getMatchingPolicy(envoyconfig.ExtAuthzContextExtensionsRouteID(attrs.GetContextExtensions()))

View file

@ -18,7 +18,6 @@ import (
"github.com/pomerium/pomerium/authorize/evaluator"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/atomicutil"
"github.com/pomerium/pomerium/internal/sessions"
"github.com/pomerium/pomerium/internal/testutil"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/storage"
@ -88,9 +87,7 @@ func Test_getEvaluatorRequest(t *testing.T) {
},
},
},
&sessions.State{
ID: "SESSION_ID",
},
"SESSION_ID",
)
require.NoError(t, err)
expect := &evaluator.Request{
@ -145,7 +142,7 @@ func Test_getEvaluatorRequestWithPortInHostHeader(t *testing.T) {
},
},
},
}, nil)
}, "")
require.NoError(t, err)
expect := &evaluator.Request{
Policy: &a.currentOptions.Load().Policies[0],

View file

@ -13,6 +13,7 @@ import (
"github.com/pomerium/pomerium/authorize/internal/store"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/authenticateflow"
"github.com/pomerium/pomerium/internal/sessions/idptokens"
"github.com/pomerium/pomerium/pkg/grpc"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
)
@ -30,6 +31,7 @@ type authorizeState struct {
dataBrokerClient databroker.DataBrokerServiceClient
sessionStore *config.SessionStore
authenticateFlow authenticateFlow
idpTokensLoader *idptokens.Loader
}
func newAuthorizeStateFromConfig(
@ -88,5 +90,7 @@ func newAuthorizeStateFromConfig(
return nil, err
}
state.idpTokensLoader = idptokens.NewLoader(cfg.Options, state.dataBrokerClient)
return state, nil
}

View file

@ -1,7 +1,11 @@
package httputil
// AuthorizationTypePomerium is for Authorization: Pomerium JWT... headers
const AuthorizationTypePomerium = "Pomerium"
// Authorization types
const (
AuthorizationTypePomerium = "Pomerium"
AuthorizationTypePomeriumIDPAccessToken = "Pomerium-IDP-Access-Token" //nolint: gosec
AuthorizationTypePomeriumIDPIdentityToken = "Pomerium-IDP-Identity-Token" //nolint: gosec
)
// Standard headers
const (
@ -32,6 +36,10 @@ const (
HeaderPomeriumReproxyPolicyHMAC = "x-pomerium-reproxy-policy-hmac"
// HeaderPomeriumRoutingKey is a string used for routing user requests to a consistent upstream server.
HeaderPomeriumRoutingKey = "x-pomerium-routing-key"
// HeaderPomeriumIDPAccessToken is the header key for a pomerium access token.
HeaderPomeriumIDPAccessToken = "x-pomerium-idp-access-token"
// HeaderPomeriumIDPIdentityToken is the header key for a pomerium identity token.
HeaderPomeriumIDPIdentityToken = "x-pomerium-idp-identity-token"
)
// HeadersContentSecurityPolicy are the content security headers added to the service's handlers

View file

@ -0,0 +1,107 @@
package idptokens
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/pomerium/pomerium/internal/urlutil"
)
// endpoints
const (
VerifyAccessTokenEndpoint = "verify-access-token"
VerifyIdentityTokenEndpoint = "verify-identity-token"
)
// A VerifyTokenResponse is the response to verifying an access token or identity token.
type VerifyTokenResponse struct {
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
Claims map[string]any `json:"claims,omitempty"`
}
// VerifyAccessTokenRequest is the data for verifying an access token.
type VerifyAccessTokenRequest struct {
AccessToken string `json:"accessToken"`
IdentityProviderID string `json:"identityProviderId,omitempty"`
}
// VerifyIdentityTokenRequest is the data for verifying an identity token.
type VerifyIdentityTokenRequest struct {
IdentityToken string `json:"identityToken"`
IdentityProviderID string `json:"identityProviderId,omitempty"`
}
func apiVerifyAccessToken(
ctx context.Context,
authenticateServiceURL string,
request *VerifyAccessTokenRequest,
) (*VerifyTokenResponse, error) {
var response VerifyTokenResponse
err := api(ctx, authenticateServiceURL, "verify-access-token", request, &response)
if err != nil {
return nil, err
}
return &response, nil
}
func apiVerifyIdentityToken(
ctx context.Context,
authenticateServiceURL string,
request *VerifyIdentityTokenRequest,
) (*VerifyTokenResponse, error) {
var response VerifyTokenResponse
err := api(ctx, authenticateServiceURL, "verify-identity-token", request, &response)
if err != nil {
return nil, err
}
return &response, nil
}
func api(
ctx context.Context,
authenticateServiceURL string,
endpoint string,
request, response any,
) error {
u, err := urlutil.ParseAndValidateURL(authenticateServiceURL)
if err != nil {
return fmt.Errorf("invalid authenticate service url: %w", err)
}
u = u.ResolveReference(&url.URL{
Path: "/.pomerium/" + endpoint,
})
body, err := json.Marshal(request)
if err != nil {
return fmt.Errorf("error marshaling %s http request: %w", endpoint, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("error creating %s http request: %w", endpoint, err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("error executing %s http request: %w", endpoint, err)
}
defer res.Body.Close()
body, err = io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("error reading %s http response: %w", endpoint, err)
}
err = json.Unmarshal(body, &response)
if err != nil {
return fmt.Errorf("error reading %s http response (body=%s): %w", endpoint, body, err)
}
return nil
}

View file

@ -0,0 +1,162 @@
package idptokens
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/sessions"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/grpc/databroker"
"github.com/pomerium/pomerium/pkg/grpc/identity"
"github.com/pomerium/pomerium/pkg/grpc/session"
)
var (
accessTokenUUIDNamespace = uuid.MustParse("0194f6f8-e760-76a0-8917-e28ac927a34d")
identityTokenUUIDNamespace = uuid.MustParse("0194f6f9-aec0-704e-bb4a-51054f17ad17")
)
// A Loader loads sessions from IdP access and identity tokens.
type Loader struct {
options *config.Options
dataBrokerServiceClient databroker.DataBrokerServiceClient
}
// NewLoader creates a new Loader.
func NewLoader(options *config.Options, dataBrokerServiceClient databroker.DataBrokerServiceClient) *Loader {
return &Loader{
options: options,
dataBrokerServiceClient: dataBrokerServiceClient,
}
}
// LoadSession loads sessions from IdP access and identity tokens.
func (l *Loader) LoadSession(r *http.Request) (*session.Session, error) {
ctx := r.Context()
idp, err := l.options.GetIdentityProviderForRequestURL(urlutil.GetAbsoluteURL(r).String())
if err != nil {
return nil, err
}
if v := r.Header.Get(httputil.HeaderPomeriumIDPAccessToken); v != "" {
return l.loadSessionFromAccessToken(ctx, idp, v)
} else if v := r.Header.Get(httputil.HeaderAuthorization); v != "" {
prefix := httputil.AuthorizationTypePomeriumIDPAccessToken + " "
if strings.HasPrefix(strings.ToLower(v), strings.ToLower(prefix)) {
return l.loadSessionFromAccessToken(ctx, idp, v[len(prefix):])
}
prefix = "Bearer " + httputil.AuthorizationTypePomeriumIDPAccessToken + "-"
if strings.HasPrefix(strings.ToLower(v), strings.ToLower(prefix)) {
return l.loadSessionFromAccessToken(ctx, idp, v[len(prefix):])
}
}
if v := r.Header.Get(httputil.HeaderPomeriumIDPIdentityToken); v != "" {
return l.loadSessionFromIdentityToken(ctx, idp, v)
} else if v := r.Header.Get(httputil.HeaderAuthorization); v != "" {
prefix := httputil.AuthorizationTypePomeriumIDPIdentityToken + " "
if strings.HasPrefix(strings.ToLower(v), strings.ToLower(prefix)) {
return l.loadSessionFromIdentityToken(ctx, idp, v[len(prefix):])
}
prefix = "Bearer " + httputil.AuthorizationTypePomeriumIDPIdentityToken + "-"
if strings.HasPrefix(strings.ToLower(v), strings.ToLower(prefix)) {
return l.loadSessionFromIdentityToken(ctx, idp, v[len(prefix):])
}
}
return nil, sessions.ErrNoSessionFound
}
func (l *Loader) loadSessionFromAccessToken(ctx context.Context, idp *identity.Provider, rawAccessToken string) (*session.Session, error) {
sessionID := uuid.NewSHA1(accessTokenUUIDNamespace, []byte(rawAccessToken)).String()
s, err := session.Get(ctx, l.dataBrokerServiceClient, sessionID)
if err == nil {
return s, nil
} else if status.Code(err) != codes.NotFound {
return nil, err
}
res, err := apiVerifyAccessToken(ctx, idp.GetAuthenticateServiceUrl(), &VerifyAccessTokenRequest{
AccessToken: rawAccessToken,
IdentityProviderID: idp.GetId(),
})
if err != nil {
return nil, err
} else if !res.Valid {
return nil, fmt.Errorf("invalid access token: %s", res.Error)
}
// no session yet, create one
s = newSession(sessionID, res.Claims)
s.OauthToken = &session.OAuthToken{
TokenType: "Bearer",
AccessToken: rawAccessToken,
ExpiresAt: s.ExpiresAt,
}
_, err = session.Put(ctx, l.dataBrokerServiceClient, s)
if err != nil {
return nil, err
}
return s, nil
}
func (l *Loader) loadSessionFromIdentityToken(ctx context.Context, idp *identity.Provider, rawIdentityToken string) (*session.Session, error) {
sessionID := uuid.NewSHA1(identityTokenUUIDNamespace, []byte(rawIdentityToken)).String()
s, err := session.Get(ctx, l.dataBrokerServiceClient, sessionID)
if err == nil {
return s, nil
} else if status.Code(err) != codes.NotFound {
return nil, err
}
res, err := apiVerifyIdentityToken(ctx, idp.GetAuthenticateServiceUrl(), &VerifyIdentityTokenRequest{
IdentityToken: rawIdentityToken,
IdentityProviderID: idp.GetId(),
})
if err != nil {
return nil, err
} else if !res.Valid {
return nil, fmt.Errorf("invalid access token: %s", res.Error)
}
// no session yet, create one
s = newSession(sessionID, nil)
s.IdToken = &session.IDToken{
Subject: s.UserId,
ExpiresAt: s.ExpiresAt,
IssuedAt: s.IssuedAt,
Raw: rawIdentityToken,
}
_, err = session.Put(ctx, l.dataBrokerServiceClient, s)
if err != nil {
return nil, err
}
return s, nil
}
func newSession(sessionID string, claims map[string]any) *session.Session {
s := &session.Session{
Id: sessionID,
}
if v, ok := claims["oid"].(string); ok {
s.UserId = v
} else if v, ok := claims["sub"].(string); ok {
s.UserId = v
}
return s
}

View file

@ -2,6 +2,7 @@ package identity
import (
"context"
"fmt"
"net/http"
"golang.org/x/oauth2"
@ -55,3 +56,11 @@ func (mp MockProvider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ s
func (mp MockProvider) SignIn(_ http.ResponseWriter, _ *http.Request, _ string) error {
return mp.SignInError
}
func (mp MockProvider) VerifyAccessToken(_ context.Context, _ string) (map[string]any, error) {
return nil, fmt.Errorf("not implemented")
}
func (mp MockProvider) VerifyIdentityToken(_ context.Context, _ string) (map[string]any, error) {
return nil, fmt.Errorf("not implemented")
}

View file

@ -182,3 +182,11 @@ func (p *Provider) SignIn(w http.ResponseWriter, r *http.Request, state string)
func (p *Provider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ string) error {
return oidc.ErrSignoutNotImplemented
}
func (p *Provider) VerifyAccessToken(ctx context.Context, rawAccessToken string) (claims map[string]any, err error) {
return nil, fmt.Errorf("VerifyAccessToken not implemented")
}
func (p *Provider) VerifyIdentityToken(ctx context.Context, rawIdentityToken string) (claims map[string]any, err error) {
return nil, fmt.Errorf("VerifyIdentityToken not implemented")
}

View file

@ -256,3 +256,11 @@ func (p *Provider) SignIn(w http.ResponseWriter, r *http.Request, state string)
func (p *Provider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ string) error {
return oidc.ErrSignoutNotImplemented
}
func (p *Provider) VerifyAccessToken(ctx context.Context, rawAccessToken string) (claims map[string]any, err error) {
return nil, fmt.Errorf("VerifyAccessToken not implemented")
}
func (p *Provider) VerifyIdentityToken(ctx context.Context, rawIdentityToken string) (claims map[string]any, err error) {
return nil, fmt.Errorf("VerifyIdentityToken not implemented")
}

View file

@ -360,3 +360,48 @@ func (p *Provider) SignOut(w http.ResponseWriter, r *http.Request, idTokenHint,
httputil.Redirect(w, r, endSessionURL.String(), http.StatusFound)
return nil
}
// VerifyAccessToken verifies a raw access token using the oidc UserInfo endpoint.
func (p *Provider) VerifyAccessToken(ctx context.Context, rawAccessToken string) (claims map[string]any, err error) {
pp, err := p.GetProvider()
if err != nil {
return nil, err
}
userInfo, err := pp.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: rawAccessToken,
TokenType: "Bearer",
}))
if err != nil {
return nil, err
}
claims = map[string]any{}
err = userInfo.Claims(claims)
if err != nil {
return nil, err
}
return claims, nil
}
// VerifyIdentityToken verifies a raw identity token using the oidc ID Token Verifier.
func (p *Provider) VerifyIdentityToken(ctx context.Context, rawIdentityToken string) (claims map[string]any, err error) {
verifier, err := p.GetVerifier()
if err != nil {
return nil, err
}
token, err := verifier.Verify(ctx, rawIdentityToken)
if err != nil {
return nil, err
}
claims = map[string]any{}
err = token.Claims(claims)
if err != nil {
return nil, err
}
return claims, nil
}

View file

@ -10,6 +10,8 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"golang.org/x/oauth2"
oteltrace "go.opentelemetry.io/otel/trace"
"github.com/pomerium/pomerium/pkg/identity/identity"
"github.com/pomerium/pomerium/pkg/identity/oauth"
"github.com/pomerium/pomerium/pkg/identity/oauth/apple"
@ -23,7 +25,6 @@ import (
"github.com/pomerium/pomerium/pkg/identity/oidc/okta"
"github.com/pomerium/pomerium/pkg/identity/oidc/onelogin"
"github.com/pomerium/pomerium/pkg/identity/oidc/ping"
oteltrace "go.opentelemetry.io/otel/trace"
)
// State is the identity state.
@ -36,6 +37,8 @@ type Authenticator interface {
Revoke(context.Context, *oauth2.Token) error
Name() string
UpdateUserInfo(ctx context.Context, t *oauth2.Token, v any) error
VerifyAccessToken(ctx context.Context, rawAccessToken string) (claims map[string]any, err error)
VerifyIdentityToken(ctx context.Context, rawIdentityToken string) (claims map[string]any, err error)
SignIn(w http.ResponseWriter, r *http.Request, state string) error
SignOut(w http.ResponseWriter, r *http.Request, idTokenHint, authenticateSignedOutURL, redirectToURL string) error