mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 03:59:56 +00:00
* [management] Fetch complete user data in ValidateTunnelPeer Previously the `ValidateTunnelPeer` method used by the ProxyService would fetch user information from the database if the connected peer was associated with a user ID, but it would not consult the IdP data for cached info from JWT claims like email. This caused the value of the injected `X-Netbird-User` header to always display the peer ID and never the user email associated with the peer as expected. This change adds an optional IdP manager to the ProxyService and fetches the complete user data from it if present. * [management] Refactor ValidateTunnelPeer principal info gathering This refactors the gathering of info on proxy tunnel peer principals into its own method to keep the complexity down and make Sonar happy.
576 lines
16 KiB
Go
576 lines
16 KiB
Go
//go:build integration
|
|
|
|
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/gorilla/mux"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
|
nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
|
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/management/server/users"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// fakeOIDCServer creates a minimal OIDC provider for testing.
|
|
type fakeOIDCServer struct {
|
|
server *httptest.Server
|
|
issuer string
|
|
signingKey ed25519.PrivateKey
|
|
publicKey ed25519.PublicKey
|
|
keyID string
|
|
tokenSubject string
|
|
tokenExpiry time.Duration
|
|
failExchange bool
|
|
}
|
|
|
|
func newFakeOIDCServer() *fakeOIDCServer {
|
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
|
f := &fakeOIDCServer{
|
|
signingKey: priv,
|
|
publicKey: pub,
|
|
keyID: "test-key-1",
|
|
tokenExpiry: time.Hour,
|
|
}
|
|
f.server = httptest.NewServer(f)
|
|
f.issuer = f.server.URL
|
|
return f
|
|
}
|
|
|
|
func (f *fakeOIDCServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/.well-known/openid-configuration":
|
|
f.handleDiscovery(w, r)
|
|
case "/token":
|
|
f.handleToken(w, r)
|
|
case "/keys":
|
|
f.handleJWKS(w, r)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func (f *fakeOIDCServer) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
|
|
discovery := map[string]interface{}{
|
|
"issuer": f.issuer,
|
|
"authorization_endpoint": f.issuer + "/auth",
|
|
"token_endpoint": f.issuer + "/token",
|
|
"jwks_uri": f.issuer + "/keys",
|
|
"response_types_supported": []string{
|
|
"code",
|
|
"id_token",
|
|
"token id_token",
|
|
},
|
|
"subject_types_supported": []string{"public"},
|
|
"id_token_signing_alg_values_supported": []string{"EdDSA"},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(discovery)
|
|
}
|
|
|
|
func (f *fakeOIDCServer) handleToken(w http.ResponseWriter, r *http.Request) {
|
|
if f.failExchange {
|
|
http.Error(w, "invalid_grant", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
idToken := f.createIDToken()
|
|
|
|
response := map[string]interface{}{
|
|
"access_token": "test-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"id_token": idToken,
|
|
"refresh_token": "test-refresh-token",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (f *fakeOIDCServer) createIDToken() string {
|
|
now := time.Now()
|
|
claims := jwt.MapClaims{
|
|
"iss": f.issuer,
|
|
"sub": f.tokenSubject,
|
|
"aud": "test-client-id",
|
|
"exp": now.Add(f.tokenExpiry).Unix(),
|
|
"iat": now.Unix(),
|
|
"nbf": now.Unix(),
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
token.Header["kid"] = f.keyID
|
|
signed, _ := token.SignedString(f.signingKey)
|
|
return signed
|
|
}
|
|
|
|
func (f *fakeOIDCServer) handleJWKS(w http.ResponseWriter, _ *http.Request) {
|
|
jwks := map[string]interface{}{
|
|
"keys": []map[string]interface{}{
|
|
{
|
|
"kty": "OKP",
|
|
"crv": "Ed25519",
|
|
"kid": f.keyID,
|
|
"x": base64.RawURLEncoding.EncodeToString(f.publicKey),
|
|
"use": "sig",
|
|
},
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(jwks)
|
|
}
|
|
|
|
func (f *fakeOIDCServer) Close() {
|
|
f.server.Close()
|
|
}
|
|
|
|
// testSetup contains all test dependencies.
|
|
type testSetup struct {
|
|
store store.Store
|
|
oidcServer *fakeOIDCServer
|
|
proxyService *nbgrpc.ProxyServiceServer
|
|
handler *AuthCallbackHandler
|
|
router *mux.Router
|
|
cleanup func()
|
|
}
|
|
|
|
// testAccessLogManager is a minimal mock for accesslogs.Manager.
|
|
type testAccessLogManager struct{}
|
|
|
|
func (m *testAccessLogManager) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *testAccessLogManager) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) {
|
|
return
|
|
}
|
|
|
|
func (m *testAccessLogManager) StopPeriodicCleanup() {
|
|
return
|
|
}
|
|
|
|
func (m *testAccessLogManager) SaveAccessLog(_ context.Context, _ *accesslogs.AccessLogEntry) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, _ *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func setupAuthCallbackTest(t *testing.T) *testSetup {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
|
|
testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
|
require.NoError(t, err)
|
|
|
|
createTestAccountsAndUsers(t, ctx, testStore)
|
|
createTestReverseProxies(t, ctx, testStore)
|
|
|
|
oidcServer := newFakeOIDCServer()
|
|
|
|
cacheStore, err := nbcache.NewStore(ctx, 30*time.Minute, 10*time.Minute, 100)
|
|
require.NoError(t, err)
|
|
|
|
tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore)
|
|
pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore)
|
|
|
|
usersManager := users.NewManager(testStore)
|
|
|
|
oidcConfig := nbgrpc.ProxyOIDCConfig{
|
|
Issuer: oidcServer.issuer,
|
|
ClientID: "test-client-id",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
CallbackURL: "https://management.example.com/reverse-proxy/callback",
|
|
HMACKey: []byte("test-hmac-key-for-state-signing"),
|
|
}
|
|
|
|
proxyService := nbgrpc.NewProxyServiceServer(
|
|
&testAccessLogManager{},
|
|
tokenStore,
|
|
pkceStore,
|
|
oidcConfig,
|
|
nil,
|
|
usersManager,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
proxyService.SetServiceManager(&testServiceManager{store: testStore})
|
|
|
|
handler := NewAuthCallbackHandler(proxyService, nil)
|
|
|
|
router := mux.NewRouter()
|
|
handler.RegisterEndpoints(router)
|
|
|
|
return &testSetup{
|
|
store: testStore,
|
|
oidcServer: oidcServer,
|
|
proxyService: proxyService,
|
|
handler: handler,
|
|
router: router,
|
|
cleanup: func() {
|
|
cleanup()
|
|
oidcServer.Close()
|
|
},
|
|
}
|
|
}
|
|
|
|
func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) {
|
|
t.Helper()
|
|
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
require.NoError(t, err)
|
|
|
|
pubKey := base64.StdEncoding.EncodeToString(pub)
|
|
privKey := base64.StdEncoding.EncodeToString(priv)
|
|
|
|
testProxy := &service.Service{
|
|
ID: "testProxyId",
|
|
AccountID: "testAccountId",
|
|
Name: "Test Proxy",
|
|
Domain: "test-proxy.example.com",
|
|
Targets: []*service.Target{{
|
|
Path: strPtr("/"),
|
|
Host: "localhost",
|
|
Port: 8080,
|
|
Protocol: "http",
|
|
TargetId: "peer1",
|
|
TargetType: "peer",
|
|
Enabled: true,
|
|
}},
|
|
Enabled: true,
|
|
Auth: service.AuthConfig{
|
|
BearerAuth: &service.BearerAuthConfig{
|
|
Enabled: true,
|
|
DistributionGroups: []string{"allowedGroupId"},
|
|
},
|
|
},
|
|
SessionPrivateKey: privKey,
|
|
SessionPublicKey: pubKey,
|
|
}
|
|
require.NoError(t, testStore.CreateService(ctx, testProxy))
|
|
|
|
restrictedProxy := &service.Service{
|
|
ID: "restrictedProxyId",
|
|
AccountID: "testAccountId",
|
|
Name: "Restricted Proxy",
|
|
Domain: "restricted-proxy.example.com",
|
|
Targets: []*service.Target{{
|
|
Path: strPtr("/"),
|
|
Host: "localhost",
|
|
Port: 8080,
|
|
Protocol: "http",
|
|
TargetId: "peer1",
|
|
TargetType: "peer",
|
|
Enabled: true,
|
|
}},
|
|
Enabled: true,
|
|
Auth: service.AuthConfig{
|
|
BearerAuth: &service.BearerAuthConfig{
|
|
Enabled: true,
|
|
DistributionGroups: []string{"restrictedGroupId"},
|
|
},
|
|
},
|
|
SessionPrivateKey: privKey,
|
|
SessionPublicKey: pubKey,
|
|
}
|
|
require.NoError(t, testStore.CreateService(ctx, restrictedProxy))
|
|
|
|
noAuthProxy := &service.Service{
|
|
ID: "noAuthProxyId",
|
|
AccountID: "testAccountId",
|
|
Name: "No Auth Proxy",
|
|
Domain: "no-auth-proxy.example.com",
|
|
Targets: []*service.Target{{
|
|
Path: strPtr("/"),
|
|
Host: "localhost",
|
|
Port: 8080,
|
|
Protocol: "http",
|
|
TargetId: "peer1",
|
|
TargetType: "peer",
|
|
Enabled: true,
|
|
}},
|
|
Enabled: true,
|
|
Auth: service.AuthConfig{
|
|
BearerAuth: &service.BearerAuthConfig{
|
|
Enabled: false,
|
|
},
|
|
},
|
|
SessionPrivateKey: privKey,
|
|
SessionPublicKey: pubKey,
|
|
}
|
|
require.NoError(t, testStore.CreateService(ctx, noAuthProxy))
|
|
}
|
|
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
func createTestAccountsAndUsers(t *testing.T, ctx context.Context, testStore store.Store) {
|
|
t.Helper()
|
|
|
|
testAccount := &types.Account{
|
|
Id: "testAccountId",
|
|
Domain: "test.com",
|
|
DomainCategory: "private",
|
|
IsDomainPrimaryAccount: true,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
require.NoError(t, testStore.SaveAccount(ctx, testAccount))
|
|
|
|
allowedGroup := &types.Group{
|
|
ID: "allowedGroupId",
|
|
AccountID: "testAccountId",
|
|
Name: "Allowed Group",
|
|
Issued: "api",
|
|
}
|
|
require.NoError(t, testStore.CreateGroup(ctx, allowedGroup))
|
|
|
|
allowedUser := &types.User{
|
|
Id: "allowedUserId",
|
|
AccountID: "testAccountId",
|
|
Role: types.UserRoleUser,
|
|
AutoGroups: []string{"allowedGroupId"},
|
|
CreatedAt: time.Now(),
|
|
Issued: "api",
|
|
}
|
|
require.NoError(t, testStore.SaveUser(ctx, allowedUser))
|
|
}
|
|
|
|
// testServiceManager is a minimal implementation for testing.
|
|
type testServiceManager struct {
|
|
store store.Store
|
|
}
|
|
|
|
func (m *testServiceManager) DeleteAllServices(ctx context.Context, accountID, userID string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) GetAllServices(_ context.Context, _, _ string) ([]*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testServiceManager) DeleteService(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) SetCertificateIssuedAt(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) SetStatus(_ context.Context, _, _ string, _ service.Status) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) ReloadAllServicesForAccount(_ context.Context, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) ReloadService(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) {
|
|
return m.store.GetServices(ctx, store.LockingStrengthNone)
|
|
}
|
|
|
|
func (m *testServiceManager) GetServiceByID(ctx context.Context, accountID, proxyID string) (*service.Service, error) {
|
|
return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, proxyID)
|
|
}
|
|
|
|
func (m *testServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) {
|
|
return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
|
|
}
|
|
|
|
func (m *testServiceManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
func (m *testServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *testServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
func (m *testServiceManager) StartExposeReaper(_ context.Context) {}
|
|
|
|
func (m *testServiceManager) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) {
|
|
return m.store.GetServiceByDomain(ctx, domain)
|
|
}
|
|
|
|
func (m *testServiceManager) GetClusters(_ context.Context, _, _ string) ([]nbproxy.Cluster, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL string) string {
|
|
t.Helper()
|
|
|
|
resp, err := ps.GetOIDCURL(context.Background(), &proto.GetOIDCURLRequest{
|
|
RedirectUrl: redirectURL,
|
|
AccountId: "testAccountId",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
parsedURL, err := url.Parse(resp.Url)
|
|
require.NoError(t, err)
|
|
|
|
return parsedURL.Query().Get("state")
|
|
}
|
|
|
|
func TestAuthCallback_UserAllowedToLogin(t *testing.T) {
|
|
setup := setupAuthCallbackTest(t)
|
|
defer setup.cleanup()
|
|
|
|
setup.oidcServer.tokenSubject = "allowedUserId"
|
|
|
|
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
setup.router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
|
|
location := rec.Header().Get("Location")
|
|
require.NotEmpty(t, location)
|
|
|
|
parsedLocation, err := url.Parse(location)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "test-proxy.example.com", parsedLocation.Host)
|
|
require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token")
|
|
require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter")
|
|
}
|
|
|
|
func TestAuthCallback_ProxyNotFound(t *testing.T) {
|
|
setup := setupAuthCallbackTest(t)
|
|
defer setup.cleanup()
|
|
|
|
setup.oidcServer.tokenSubject = "allowedUserId"
|
|
|
|
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/")
|
|
|
|
require.NoError(t, setup.store.DeleteService(context.Background(), "testAccountId", "testProxyId"))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
setup.router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
|
|
location := rec.Header().Get("Location")
|
|
parsedLocation, err := url.Parse(location)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "access_denied", parsedLocation.Query().Get("error"))
|
|
}
|
|
|
|
func TestAuthCallback_InvalidToken(t *testing.T) {
|
|
setup := setupAuthCallbackTest(t)
|
|
defer setup.cleanup()
|
|
|
|
setup.oidcServer.failExchange = true
|
|
|
|
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=invalid-code&state="+url.QueryEscape(state), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
setup.router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "Failed to exchange code")
|
|
}
|
|
|
|
func TestAuthCallback_ExpiredToken(t *testing.T) {
|
|
setup := setupAuthCallbackTest(t)
|
|
defer setup.cleanup()
|
|
|
|
setup.oidcServer.tokenSubject = "allowedUserId"
|
|
setup.oidcServer.tokenExpiry = -time.Hour
|
|
|
|
state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
setup.router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "Failed to validate token")
|
|
}
|
|
|
|
func TestAuthCallback_InvalidState(t *testing.T) {
|
|
setup := setupAuthCallbackTest(t)
|
|
defer setup.cleanup()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state=invalid-state", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
setup.router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Contains(t, rec.Body.String(), "Invalid state")
|
|
}
|
|
|
|
func TestAuthCallback_MissingState(t *testing.T) {
|
|
setup := setupAuthCallbackTest(t)
|
|
defer setup.cleanup()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
setup.router.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
}
|