pomerium/authorize/check_response.go
Caleb Doxsey b9fd926618
authorize: support authenticating with idp tokens (#5484)
* identity: add support for verifying access and identity tokens

* allow overriding with policy option

* authenticate: add verify endpoints

* wip

* implement session creation

* add verify test

* implement idp token login

* fix tests

* add pr permission

* make session ids route-specific

* rename method

* add test

* add access token test

* test for newUserFromIDPClaims

* more tests

* make the session id per-idp

* use type for

* add test

* remove nil checks
2025-02-18 13:02:06 -07:00

412 lines
13 KiB
Go

package authorize
import (
"context"
"encoding/json"
"errors"
"io"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strings"
envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
"github.com/tniswong/go.rfcx/rfc7231"
"google.golang.org/genproto/googleapis/rpc/status"
"google.golang.org/grpc/codes"
"github.com/pomerium/pomerium/authorize/evaluator"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/policy/criteria"
"github.com/pomerium/pomerium/pkg/telemetry/requestid"
"github.com/pomerium/pomerium/pkg/webauthnutil"
)
func (a *Authorize) handleResult(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
request *evaluator.Request,
result *evaluator.Result,
) (*envoy_service_auth_v3.CheckResponse, error) {
// If a client certificate is required, but the client did not provide a
// valid certificate, deny right away. Do not redirect to authenticate.
if invalidClientCertReason(result.Deny.Reasons) {
return a.handleResultDenied(ctx, in, request, result, result.Deny.Reasons)
}
// when the user is unauthenticated it means they haven't
// logged in yet, so redirect to authenticate
if result.Allow.Reasons.Has(criteria.ReasonUserUnauthenticated) ||
result.Deny.Reasons.Has(criteria.ReasonUserUnauthenticated) {
return a.requireLoginResponse(ctx, in, request)
}
// when the user's device is unauthenticated it means they haven't
// registered a webauthn device yet, so redirect to the webauthn flow
if result.Allow.Reasons.Has(criteria.ReasonDeviceUnauthenticated) ||
result.Deny.Reasons.Has(criteria.ReasonDeviceUnauthenticated) {
return a.requireWebAuthnResponse(ctx, in, request, result)
}
// if there's a deny, the result is denied using the deny reasons.
if result.Deny.Value {
return a.handleResultDenied(ctx, in, request, result, result.Deny.Reasons)
}
// if there's an allow, the result is allowed.
if result.Allow.Value {
return a.handleResultAllowed(ctx, in, result)
}
// otherwise, the result is denied using the allow reasons.
return a.handleResultDenied(ctx, in, request, result, result.Allow.Reasons)
}
func (a *Authorize) handleResultAllowed(
_ context.Context,
_ *envoy_service_auth_v3.CheckRequest,
result *evaluator.Result,
) (*envoy_service_auth_v3.CheckResponse, error) {
return a.okResponse(result.Headers), nil
}
func (a *Authorize) handleResultDenied(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
request *evaluator.Request,
result *evaluator.Result,
reasons criteria.Reasons,
) (*envoy_service_auth_v3.CheckResponse, error) {
denyStatusCode := int32(http.StatusForbidden)
denyStatusText := http.StatusText(http.StatusForbidden)
switch {
case reasons.Has(criteria.ReasonDeviceUnauthenticated):
return a.requireWebAuthnResponse(ctx, in, request, result)
case reasons.Has(criteria.ReasonDeviceUnauthorized):
denyStatusCode = httputil.StatusDeviceUnauthorized
denyStatusText = httputil.DetailsText(httputil.StatusDeviceUnauthorized)
case reasons.Has(criteria.ReasonRouteNotFound):
denyStatusCode = http.StatusNotFound
denyStatusText = httputil.DetailsText(http.StatusNotFound)
case invalidClientCertReason(reasons):
denyStatusCode = httputil.StatusInvalidClientCertificate
denyStatusText = httputil.DetailsText(httputil.StatusInvalidClientCertificate)
}
return a.deniedResponse(ctx, in, denyStatusCode, denyStatusText, nil)
}
func invalidClientCertReason(reasons criteria.Reasons) bool {
return reasons.Has(criteria.ReasonClientCertificateRequired) ||
reasons.Has(criteria.ReasonInvalidClientCertificate)
}
func (a *Authorize) okResponse(headers http.Header) *envoy_service_auth_v3.CheckResponse {
return &envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: int32(codes.OK), Message: "OK"},
HttpResponse: &envoy_service_auth_v3.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v3.OkHttpResponse{
Headers: toEnvoyHeaders(headers),
},
},
}
}
func (a *Authorize) deniedResponse(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
code int32, reason string, headers http.Header,
) (*envoy_service_auth_v3.CheckResponse, error) {
if headers == nil {
headers = make(http.Header)
}
var respBody []byte
hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
userAgent := getHeader(hdrs, "User-Agent")
switch {
case strings.Contains(userAgent, "kubernetes/"):
message := reason
var statusReason string
switch code {
case http.StatusUnauthorized:
statusReason = "Unauthorized"
case http.StatusForbidden:
statusReason = "Forbidden"
case http.StatusNotFound:
statusReason = "NotFound"
case httputil.StatusDeviceUnauthorized, httputil.StatusInvalidClientCertificate:
statusReason = "Unauthorized"
message = httputil.DetailsText(int(code))
default:
statusReason = "" // StatusReasonUnknown
}
respBody, _ = json.Marshal(map[string]any{
"apiVersion": "v1",
"kind": "Status",
"status": "Failure", // one of "Success" or "Failure"
"message": message, // user-facing message
"reason": statusReason, // must correspond to k8s StatusReason strings
"code": code, // http code
})
headers.Set("Content-Type", "application/json")
case getCheckRequestURL(in).Path == "/robots.txt":
code = 200
respBody = []byte("User-agent: *\nDisallow: /")
headers.Set("Content-Type", "text/plain")
case isJSONWebRequest(in):
respBody, _ = json.Marshal(map[string]any{
"error": reason,
"request_id": requestid.FromContext(ctx),
})
headers.Set("Content-Type", "application/json")
case isGRPCRequest(in):
return deniedResponseForGRPC(code, reason, headers), nil
case isGRPCWebRequest(in):
return deniedResponseForGRPCWeb(code, reason, headers), nil
default:
// create a http response writer recorder
w := httptest.NewRecorder()
r := getHTTPRequestFromCheckRequest(in)
// build the user info / debug endpoint
debugEndpoint, _ := a.userInfoEndpointURL(in) // if there's an error, we just wont display it
// run the request through our go error handler
httpErr := httputil.HTTPError{
Status: int(code),
Err: errors.New(reason),
DebugURL: debugEndpoint,
RequestID: requestid.FromContext(ctx),
BrandingOptions: a.currentConfig.Load().Options.BrandingOptions,
}
httpErr.ErrorResponse(ctx, w, r)
// transpose the go http response writer into a envoy response
resp := w.Result()
defer resp.Body.Close()
var err error
respBody, err = io.ReadAll(resp.Body)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("error executing error template")
return nil, err
}
for k, vs := range resp.Header {
headers[k] = vs
}
}
return mkDeniedCheckResponse(code, headers, string(respBody)), nil
}
func (a *Authorize) requireLoginResponse(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
request *evaluator.Request,
) (*envoy_service_auth_v3.CheckResponse, error) {
options := a.currentConfig.Load().Options
state := a.state.Load()
if !a.shouldRedirect(in) {
return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
}
idp, err := options.GetIdentityProviderForPolicy(request.Policy)
if err != nil {
return nil, err
}
// always assume https scheme
checkRequestURL := getCheckRequestURL(in)
checkRequestURL.Scheme = "https"
var signInURLQuery url.Values
headers := http.Header{}
if id := in.GetAttributes().GetRequest().GetHttp().GetHeaders()["traceparent"]; id != "" {
signInURLQuery = url.Values{}
signInURLQuery.Add("pomerium_traceparent", id)
}
redirectTo, err := state.authenticateFlow.AuthenticateSignInURL(
ctx, signInURLQuery, &checkRequestURL, idp.GetId())
if err != nil {
return nil, err
}
headers["Location"] = []string{redirectTo}
return a.deniedResponse(ctx, in, http.StatusFound, "Login", headers)
}
func (a *Authorize) requireWebAuthnResponse(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
request *evaluator.Request,
result *evaluator.Result,
) (*envoy_service_auth_v3.CheckResponse, error) {
opts := a.currentConfig.Load().Options
state := a.state.Load()
// always assume https scheme
checkRequestURL := getCheckRequestURL(in)
checkRequestURL.Scheme = "https"
// If we're already on a webauthn route, return OK.
// https://github.com/pomerium/pomerium-console/issues/3210
if checkRequestURL.Path == urlutil.WebAuthnURLPath || checkRequestURL.Path == urlutil.DeviceEnrolledPath {
return a.okResponse(result.Headers), nil
}
if !a.shouldRedirect(in) {
return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
}
q := url.Values{}
if deviceType, ok := result.Allow.AdditionalData["device_type"].(string); ok {
q.Set(urlutil.QueryDeviceType, deviceType)
} else if deviceType, ok := result.Deny.AdditionalData["device_type"].(string); ok {
q.Set(urlutil.QueryDeviceType, deviceType)
} else {
q.Set(urlutil.QueryDeviceType, webauthnutil.DefaultDeviceType)
}
q.Set(urlutil.QueryRedirectURI, checkRequestURL.String())
idp, err := opts.GetIdentityProviderForPolicy(request.Policy)
if err != nil {
return nil, err
}
q.Set(urlutil.QueryIdentityProviderID, idp.GetId())
signinURL := urlutil.WebAuthnURL(getHTTPRequestFromCheckRequest(in), &checkRequestURL, state.sharedKey, q)
return a.deniedResponse(ctx, in, http.StatusFound, "Login", http.Header{
"Location": {signinURL},
})
}
func mkDeniedCheckResponse(httpStatusCode int32, headers http.Header, body string) *envoy_service_auth_v3.CheckResponse {
return &envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
HttpResponse: &envoy_service_auth_v3.CheckResponse_DeniedResponse{
DeniedResponse: &envoy_service_auth_v3.DeniedHttpResponse{
Status: &envoy_type_v3.HttpStatus{
Code: envoy_type_v3.StatusCode(httpStatusCode),
},
Headers: toEnvoyHeaders(headers),
Body: body,
},
},
}
}
func mkHeader(k, v string) *envoy_config_core_v3.HeaderValueOption {
return &envoy_config_core_v3.HeaderValueOption{
Header: &envoy_config_core_v3.HeaderValue{
Key: k,
Value: v,
},
AppendAction: envoy_config_core_v3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD,
}
}
func toEnvoyHeaders(headers http.Header) []*envoy_config_core_v3.HeaderValueOption {
envoyHeaders := make([]*envoy_config_core_v3.HeaderValueOption, 0, len(headers))
for k, vs := range maps.All(headers) {
envoyHeaders = append(envoyHeaders, mkHeader(k, strings.Join(vs, ",")))
}
sort.Slice(envoyHeaders, func(i, j int) bool {
return envoyHeaders[i].GetHeader().GetKey() < envoyHeaders[j].GetHeader().GetKey()
})
return envoyHeaders
}
// userInfoEndpointURL returns the user info endpoint url which can be used to debug the user's
// session that lives on the authenticate service.
func (a *Authorize) userInfoEndpointURL(in *envoy_service_auth_v3.CheckRequest) (*url.URL, error) {
opts := a.currentConfig.Load().Options
authenticateURL, err := opts.GetAuthenticateURL()
if err != nil {
return nil, err
}
debugEndpoint := authenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/"})
r := getHTTPRequestFromCheckRequest(in)
redirectURL := urlutil.GetAbsoluteURL(r).String()
if ref := r.Header.Get(httputil.HeaderReferrer); ref != "" {
redirectURL = ref
}
debugEndpoint = debugEndpoint.ResolveReference(&url.URL{
RawQuery: url.Values{
urlutil.QueryRedirectURI: {redirectURL},
}.Encode(),
})
return urlutil.NewSignedURL(a.state.Load().sharedKey, debugEndpoint).Sign(), nil
}
func (a *Authorize) shouldRedirect(in *envoy_service_auth_v3.CheckRequest) bool {
requestHeaders := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if requestHeaders == nil {
return true
}
if isGRPCRequest(in) {
return false
}
accept, err := rfc7231.ParseAccept(requestHeaders["accept"])
if err != nil {
return true
}
mediaType, ok := accept.MostAcceptable([]string{
"text/html",
"application/json",
"text/plain",
"application/grpc-web-text",
"application/grpc-web+proto",
"application/grpc+proto",
})
if !ok {
return true
}
return mediaType == "text/html"
}
func isJSONWebRequest(in *envoy_service_auth_v3.CheckRequest) bool {
hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if hdrs == nil {
return false
}
v := getHeader(hdrs, "Accept")
if v == "" {
return false
}
accept, err := rfc7231.ParseAccept(v)
if err != nil {
return false
}
mediaType, _ := accept.MostAcceptable([]string{
"text/html",
"application/json",
})
return mediaType == "application/json"
}
func getHeader(hdrs map[string]string, key string) string {
for k, v := range hdrs {
if strings.EqualFold(k, key) {
return v
}
}
return ""
}