mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-04 01:09:36 +02:00
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:
parent
2b84111058
commit
014824b525
12 changed files with 225 additions and 19 deletions
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue