Enforce reverse proxy group access before minting and when honouring a session cookie

This commit is contained in:
Viktor Liu
2026-08-18 15:04:16 +02:00
parent 6210399e65
commit b64080400d
13 changed files with 809 additions and 449 deletions
@@ -388,6 +388,7 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
auth.Oidc = true
auth.AllowedGroupIds = append([]string(nil), s.Auth.BearerAuth.DistributionGroups...)
}
for _, h := range s.Auth.HeaderAuths {
@@ -250,6 +250,44 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
assert.Equal(t, int64(30), opts.RequestTimeout.Seconds)
}
// TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session
// cookies on: without it the proxy can only check a cookie's signature, which
// makes a token minted for a user outside the groups a bearer credential.
func TestToProtoMapping_AllowedGroupIds(t *testing.T) {
t.Run("distribution groups reach the proxy", func(t *testing.T) {
rp := &Service{
ID: "svc-1",
AccountID: "acc-1",
Domain: "example.com",
Auth: AuthConfig{
BearerAuth: &BearerAuthConfig{
Enabled: true,
DistributionGroups: []string{"grp-1", "grp-2"},
},
},
}
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
assert.True(t, pm.GetAuth().GetOidc())
assert.Equal(t, []string{"grp-1", "grp-2"}, pm.GetAuth().GetAllowedGroupIds())
})
t.Run("a service open to the account carries no groups", func(t *testing.T) {
rp := &Service{
ID: "svc-1",
AccountID: "acc-1",
Domain: "example.com",
Auth: AuthConfig{
BearerAuth: &BearerAuthConfig{Enabled: true},
},
}
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
assert.True(t, pm.GetAuth().GetOidc())
assert.Empty(t, pm.GetAuth().GetAllowedGroupIds(), "an empty list must not restrict access")
})
}
func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
rp := &Service{
ID: "svc-1",
+16 -2
View File
@@ -1651,6 +1651,10 @@ var (
// ErrUserBlocked reports a blocked user, who may not hold a proxy session.
ErrUserBlocked = errors.New("user blocked")
// ErrUserNotInGroup reports a user outside the service's distribution
// groups, who may not hold a proxy session for it.
ErrUserNotInGroup = errors.New("user not in allowed groups")
errUserUnresolved = errors.New("user could not be resolved")
)
@@ -1689,8 +1693,10 @@ func sameAccount(userAccountID, serviceAccountID string) bool {
// GenerateSessionToken creates a signed session JWT for the given domain and
// user. The user's group memberships are embedded in the token so policy-aware
// middlewares on the proxy can authorise without an extra management round-trip.
// A user the store cannot resolve, or whose account is pending approval or
// blocked, gets no token at all, so the browser never receives a session cookie.
// A user the store cannot resolve, whose account is pending approval or blocked,
// or who is outside the service's distribution groups, gets no token at all: the
// token is a bearer credential for the service, so authorisation has to run
// before it is signed rather than only when the proxy presents it back.
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
service, err := s.getServiceByDomain(ctx, domain)
if err != nil {
@@ -1726,6 +1732,14 @@ func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, u
return "", fmt.Errorf("session token for user %s: %w", userID, err)
}
if err := s.checkGroupAccess(service, user); err != nil {
log.WithContext(ctx).WithFields(log.Fields{
"domain": domain,
"user_id": userID,
}).Debug("GenerateSessionToken: user not in the service's distribution groups")
return "", fmt.Errorf("session token for user %s: %w", userID, ErrUserNotInGroup)
}
groupIDs, groupNames := pairGroupIDsAndNames(userGroups)
token, err := sessionkey.SignToken(
@@ -431,6 +431,57 @@ func TestValidateSession_MissingToken(t *testing.T) {
assert.Contains(t, resp.DeniedReason, "missing")
}
// TestGenerateSessionToken_UserNotInAllowedGroupGetsNoToken is the regression
// guard for the group-authorisation bypass: the callback used to hand a signed
// token to a user the service denies, and the proxy honoured that token as soon
// as the user moved it into the nb_session cookie themselves. Authorisation has
// to run before the token is signed.
func TestGenerateSessionToken_UserNotInAllowedGroupGetsNoToken(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
token, err := setup.proxyService.GenerateSessionToken(context.Background(), "restricted-proxy.example.com", "nonGroupUserId", auth.MethodOIDC)
require.Error(t, err, "a user outside the distribution groups must not receive a token")
assert.ErrorIs(t, err, ErrUserNotInGroup, "the callback maps this sentinel onto the access denied page")
assert.Empty(t, token, "no token may reach the browser")
}
func TestGenerateSessionToken_UserInAllowedGroupGetsTokenWithGroups(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
ctx := context.Background()
svc, err := setup.store.GetServiceByID(ctx, store.LockingStrengthNone, "testAccountId", "restrictedProxyId")
require.NoError(t, err)
token, err := setup.proxyService.GenerateSessionToken(ctx, "restricted-proxy.example.com", "allowedUserId", auth.MethodOIDC)
require.NoError(t, err)
require.NotEmpty(t, token)
pubKey, err := base64.StdEncoding.DecodeString(svc.SessionPublicKey)
require.NoError(t, err)
userID, _, method, groups, _, err := auth.ValidateSessionJWT(token, "restricted-proxy.example.com", pubKey)
require.NoError(t, err)
assert.Equal(t, "allowedUserId", userID)
assert.Equal(t, auth.MethodOIDC.String(), method)
assert.Equal(t, []string{"allowedGroupId"}, groups, "the proxy gates the cookie on this claim, so it must carry the matched group")
}
// TestGenerateSessionToken_UnrestrictedServiceAllowsAnyAccountUser keeps the new
// gate scoped: a service without distribution groups is open to every user of
// its account, as before.
func TestGenerateSessionToken_UnrestrictedServiceAllowsAnyAccountUser(t *testing.T) {
setup := setupValidateSessionTest(t)
defer setup.cleanup()
token, err := setup.proxyService.GenerateSessionToken(context.Background(), "test-proxy.example.com", "nonGroupUserId", auth.MethodOIDC)
require.NoError(t, err, "an unrestricted service must keep working for any user of the account")
assert.NotEmpty(t, token)
}
type testValidateSessionServiceManager struct {
store store.Store
}
@@ -100,9 +100,10 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ
return
}
// Group validation is performed by the proxy via ValidateSession gRPC call.
// This allows the proxy to show 403 pages directly without redirect dance.
// GenerateSessionToken applies the service's group and account-status gates,
// so a user without access never receives a token. The proxy re-checks the
// installed cookie against the service's allowed groups, and renders the
// denial page from the error carried back in the redirect.
sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC)
if err != nil {
log.WithError(err).Error("Failed to create session token")
@@ -136,6 +137,9 @@ func sessionTokenErrorDescription(err error) string {
if errors.Is(err, nbgrpc.ErrUserBlocked) {
return "Your account is blocked"
}
if errors.Is(err, nbgrpc.ErrUserNotInGroup) {
return "You are not authorized to access this service"
}
return "Service configuration error"
}