proxy: deprecate the /.pomerium/jwt endpoint (#5254)

Disable the /.pomerium/jwt endpoint by default. Add a runtime flag to
temporarily opt out of the deprecation.
This commit is contained in:
Kenneth Jenkins 2024-09-04 11:22:18 -07:00 committed by GitHub
parent 2b84111058
commit 014824b525
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 225 additions and 19 deletions

View file

@ -18,6 +18,7 @@ import (
"github.com/pomerium/pomerium/internal/errgrouputil"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/sets"
"github.com/pomerium/pomerium/internal/telemetry/trace"
"github.com/pomerium/pomerium/pkg/contextutil"
"github.com/pomerium/pomerium/pkg/cryptutil"
@ -238,9 +239,15 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error)
return res, nil
}
// Internal endpoints that require a logged-in user.
var internalPathsNeedingLogin = sets.NewHash(
"/.pomerium/jwt",
"/.pomerium/user",
"/.pomerium/webauthn",
)
func (e *Evaluator) evaluateInternal(_ context.Context, req *Request) (*PolicyResponse, error) {
// these endpoints require a logged-in user
if req.HTTP.Path == "/.pomerium/webauthn" || req.HTTP.Path == "/.pomerium/jwt" {
if internalPathsNeedingLogin.Has(req.HTTP.Path) {
if req.Session.ID == "" {
return &PolicyResponse{
Allow: NewRuleResult(false, criteria.ReasonUserUnauthenticated),

View file

@ -554,6 +554,72 @@ func TestEvaluator(t *testing.T) {
})
}
func TestEvaluator_EvaluateInternal(t *testing.T) {
ctx := context.Background()
store := store.New()
evaluator, err := New(ctx, store, nil)
require.NoError(t, err)
// Internal paths that do not require login.
for _, path := range []string{
"/.pomerium/",
"/.pomerium/device-enrolled",
"/.pomerium/sign_out",
} {
t.Run(path, func(t *testing.T) {
req := Request{
IsInternal: true,
HTTP: RequestHTTP{
Path: path,
},
}
result, err := evaluator.Evaluate(ctx, &req)
require.NoError(t, err)
assert.Equal(t, RuleResult{
Value: true,
Reasons: criteria.NewReasons(criteria.ReasonPomeriumRoute),
AdditionalData: map[string]any{},
}, result.Allow)
assert.Equal(t, RuleResult{}, result.Deny)
})
}
// Internal paths that do require login.
for _, path := range []string{
"/.pomerium/jwt",
"/.pomerium/user",
"/.pomerium/webauthn",
} {
t.Run(path, func(t *testing.T) {
req := Request{
IsInternal: true,
HTTP: RequestHTTP{
Path: path,
},
}
result, err := evaluator.Evaluate(ctx, &req)
require.NoError(t, err)
assert.Equal(t, RuleResult{
Value: false,
Reasons: criteria.NewReasons(criteria.ReasonUserUnauthenticated),
AdditionalData: map[string]any{},
}, result.Allow)
assert.Equal(t, RuleResult{}, result.Deny)
// Simulate a logged-in user by setting a non-empty session ID.
req.Session.ID = "123456"
result, err = evaluator.Evaluate(ctx, &req)
require.NoError(t, err)
assert.Equal(t, RuleResult{
Value: true,
Reasons: criteria.NewReasons(criteria.ReasonPomeriumRoute),
AdditionalData: map[string]any{},
}, result.Allow)
assert.Equal(t, RuleResult{}, result.Deny)
})
}
}
func TestPolicyEvaluatorReuse(t *testing.T) {
ctx := context.Background()

View file

@ -19,6 +19,12 @@ var (
// RuntimeFlagMatchAnyIncomingPort enables ignoring the incoming port when matching routes
RuntimeFlagMatchAnyIncomingPort = runtimeFlag("match_any_incoming_port", true)
// RuntimeFlagPomeriumJWTEndpoint enables the /.pomerium/jwt endpoint, for retrieving
// signed user info claims from an upstream single-page web application. This endpoint
// is deprecated pending removal in a future release, but this flag allows a temporary
// opt-out from the deprecation.
RuntimeFlagPomeriumJWTEndpoint = runtimeFlag("pomerium_jwt_endpoint", false)
)
// RuntimeFlag is a runtime flag that can flip on/off certain features

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -95,6 +95,7 @@ local Environment(mode, idp, authentication_flow, dns_suffix) =
JWT_CLAIMS_HEADERS: 'email,groups,user',
LOG_LEVEL: 'info',
POLICY: std.base64(std.manifestJsonEx(Routes(mode, idp, dns_suffix), '')),
RUNTIME_FLAGS: '{"pomerium_jwt_endpoint": true}',
SHARED_SECRET: 'UYgnt8bxxK5G2sFaNzyqi5Z+OgF8m2akNc0xdQx718w=',
SIGNING_KEY: std.base64(importstr '../files/signing-key.pem'),
SIGNING_KEY_ALGORITHM: 'ES256',

View file

@ -1,6 +1,7 @@
package proxy
import (
"encoding/json"
"errors"
"io"
"net/http"
@ -9,6 +10,7 @@ import (
"github.com/go-jose/go-jose/v3/jwt"
"github.com/gorilla/mux"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/handlers"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/middleware"
@ -16,15 +18,18 @@ import (
)
// registerDashboardHandlers returns the proxy service's ServeMux
func (p *Proxy) registerDashboardHandlers(r *mux.Router) *mux.Router {
func (p *Proxy) registerDashboardHandlers(r *mux.Router, opts *config.Options) *mux.Router {
h := httputil.DashboardSubrouter(r)
h.Use(middleware.SetHeaders(httputil.HeadersContentSecurityPolicy))
// special pomerium endpoints for users to view their session
h.Path("/").Handler(httputil.HandlerFunc(p.userInfo)).Methods(http.MethodGet)
h.Path("/device-enrolled").Handler(httputil.HandlerFunc(p.deviceEnrolled))
h.Path("/jwt").Handler(httputil.HandlerFunc(p.jwtAssertion)).Methods(http.MethodGet)
if opts.IsRuntimeFlagSet(config.RuntimeFlagPomeriumJWTEndpoint) {
h.Path("/jwt").Handler(httputil.HandlerFunc(p.jwtAssertion)).Methods(http.MethodGet)
}
h.Path("/sign_out").Handler(httputil.HandlerFunc(p.SignOut)).Methods(http.MethodGet, http.MethodPost)
h.Path("/user").Handler(httputil.HandlerFunc(p.jsonUserInfo)).Methods(http.MethodGet)
h.Path("/webauthn").Handler(p.webauthn)
// called following authenticate auth flow to grab a new or existing session
@ -139,24 +144,54 @@ func (p *Proxy) ProgrammaticLogin(w http.ResponseWriter, r *http.Request) error
// jwtAssertion returns the current request's JWT assertion (rfc7519#section-10.3.1).
func (p *Proxy) jwtAssertion(w http.ResponseWriter, r *http.Request) error {
rawAssertionJWT := r.Header.Get(httputil.HeaderPomeriumJWTAssertion)
if rawAssertionJWT == "" {
if info := userInfoFromJWT(rawAssertionJWT); info == nil {
return httputil.NewError(http.StatusNotFound, errors.New("jwt not found"))
}
assertionJWT, err := jwt.ParseSigned(rawAssertionJWT)
if err != nil {
return httputil.NewError(http.StatusNotFound, errors.New("jwt not found"))
}
var dst struct {
Subject string `json:"sub"`
}
if assertionJWT.UnsafeClaimsWithoutVerification(&dst) != nil || dst.Subject == "" {
return httputil.NewError(http.StatusUnauthorized, errors.New("jwt not found"))
}
w.Header().Set("Content-Type", "application/jwt")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, rawAssertionJWT)
return nil
}
// jsonUserInfo serves the same user info as in the Pomerium JWT, but as a plain JSON object.
// Note that this is a subset of the full IdP user info from the main HTML user info page.
func (p *Proxy) jsonUserInfo(w http.ResponseWriter, r *http.Request) error {
userInfo := userInfoFromJWT(r.Header.Get(httputil.HeaderPomeriumJWTAssertion))
if userInfo == nil {
return httputil.NewError(http.StatusNotFound, errors.New("not found"))
}
b, err := json.Marshal(userInfo)
if err != nil {
return httputil.NewError(http.StatusNotFound, errors.New("not found"))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
return nil
}
// userInfoFromJWT extracts user info claims from the Pomerium JWT. Returns nil
// if the JWT could not be parsed or if it does not contain a subject.
func userInfoFromJWT(rawJWT string) map[string]any {
parsed, err := jwt.ParseSigned(rawJWT)
if err != nil {
return nil
}
var payload map[string]any
if parsed.UnsafeClaimsWithoutVerification(&payload) != nil {
return nil
} else if sub, ok := payload["sub"].(string); !ok || sub == "" {
return nil
}
// Remove claims pertaining to the JWT itself (not the user info).
for _, claim := range []string{"iss", "aud", "exp", "iat", "jti"} {
delete(payload, claim)
}
return payload
}

View file

@ -2,13 +2,16 @@ package proxy
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/atomicutil"
@ -140,7 +143,7 @@ func TestProxy_ProgrammaticLogin(t *testing.T) {
w := httptest.NewRecorder()
router := httputil.NewRouter()
router = p.registerDashboardHandlers(router)
router = p.registerDashboardHandlers(router, config.NewDefaultOptions())
router.ServeHTTP(w, r)
if status := w.Code; status != tt.wantStatus {
@ -183,3 +186,77 @@ func TestProxy_jwt(t *testing.T) {
assert.Equal(t, "application/jwt", w.Header().Get("Content-Type"))
assert.Equal(t, w.Body.String(), rawJWT)
}
func TestProxy_jsonUserInfo(t *testing.T) {
proxy := &Proxy{
state: atomicutil.NewValue(&proxyState{}),
}
t.Run("no_jwt", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.pomerium/user", nil)
w := httptest.NewRecorder()
err := proxy.jsonUserInfo(w, req)
assert.ErrorContains(t, err, "not found")
})
t.Run("no_sub_claim", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.pomerium/user", nil)
req.Header.Set("X-Pomerium-Jwt-Assertion", "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJmb28iOiJiYXIifQ.")
w := httptest.NewRecorder()
err := proxy.jsonUserInfo(w, req)
assert.ErrorContains(t, err, "not found")
})
t.Run("valid_jwt", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.pomerium/user", nil)
req.Header.Set("X-Pomerium-Jwt-Assertion",
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTY3MDg4OTI0MSwiZXhwIjoxNjcwODkyODQxfQ.YoROB12_-a8VxikPqrYOA576pLYoLFeGwXAOWCGpXgM")
w := httptest.NewRecorder()
err := proxy.jsonUserInfo(w, req)
require.NoError(t, err)
result := w.Result()
assert.Equal(t, http.StatusOK, result.StatusCode)
assert.Equal(t, "application/json", result.Header.Get("Content-Type"))
b, _ := io.ReadAll(result.Body)
assert.Equal(t, `{"admin":true,"name":"John Doe","sub":"1234567890"}`, string(b))
})
}
// The /.pomerium/jwt endpoint should be registered only if explicitly enabled.
func TestProxy_registerDashboardHandlers_jwtEndpoint(t *testing.T) {
proxy := &Proxy{
state: atomicutil.NewValue(&proxyState{}),
}
req := httptest.NewRequest(http.MethodGet, "/.pomerium/jwt", nil)
rawJWT := "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0."
req.Header.Set("X-Pomerium-Jwt-Assertion", rawJWT)
t.Run("disabled", func(t *testing.T) {
opts := config.NewDefaultOptions()
opts.RuntimeFlags[config.RuntimeFlagPomeriumJWTEndpoint] = false
m := mux.NewRouter()
proxy.registerDashboardHandlers(m, opts)
w := httptest.NewRecorder()
m.ServeHTTP(w, req)
result := w.Result()
assert.Equal(t, http.StatusNotFound, result.StatusCode)
assert.Equal(t, "text/plain; charset=utf-8", result.Header.Get("Content-Type"))
b, _ := io.ReadAll(result.Body)
assert.Equal(t, "404 page not found\n", string(b))
})
t.Run("enabled", func(t *testing.T) {
opts := config.NewDefaultOptions()
opts.RuntimeFlags[config.RuntimeFlagPomeriumJWTEndpoint] = true
m := mux.NewRouter()
proxy.registerDashboardHandlers(m, opts)
w := httptest.NewRecorder()
m.ServeHTTP(w, req)
result := w.Result()
assert.Equal(t, http.StatusOK, result.StatusCode)
assert.Equal(t, "application/jwt", result.Header.Get("Content-Type"))
b, _ := io.ReadAll(result.Body)
assert.Equal(t, rawJWT, string(b))
})
}

View file

@ -113,7 +113,7 @@ func (p *Proxy) setHandlers(opts *config.Options) error {
r.SkipClean(true)
r.StrictSlash(true)
// dashboard handlers are registered to all routes
r = p.registerDashboardHandlers(r)
r = p.registerDashboardHandlers(r, opts)
p.currentRouter.Store(r)
return nil