mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
[management, client] Fix SSH server audience validator (#5105)
* **New Features** * SSH server JWT validation now accepts multiple audiences with backward-compatible handling of the previous single-audience setting and a guard ensuring at least one audience is configured. * **Tests** * Test suites updated and new tests added to cover multiple-audience scenarios and compatibility with existing behavior. * **Other** * Startup logging enhanced to report configured audiences for JWT auth.
This commit is contained in:
@@ -132,7 +132,7 @@ func TestSSHProxy_Connect(t *testing.T) {
|
||||
HostKeyPEM: hostKey,
|
||||
JWT: &server.JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
Audiences: []string{audience},
|
||||
KeysLocation: jwksURL,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestJWTEnforcement(t *testing.T) {
|
||||
t.Run("blocks_without_jwt", func(t *testing.T) {
|
||||
jwtConfig := &JWTConfig{
|
||||
Issuer: "test-issuer",
|
||||
Audience: "test-audience",
|
||||
Audiences: []string{"test-audience"},
|
||||
KeysLocation: "test-keys",
|
||||
}
|
||||
serverConfig := &Config{
|
||||
@@ -202,7 +202,7 @@ func TestJWTDetection(t *testing.T) {
|
||||
|
||||
jwtConfig := &JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
Audiences: []string{audience},
|
||||
KeysLocation: jwksURL,
|
||||
}
|
||||
serverConfig := &Config{
|
||||
@@ -329,7 +329,7 @@ func TestJWTFailClose(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
jwtConfig := &JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
Audiences: []string{audience},
|
||||
KeysLocation: jwksURL,
|
||||
MaxTokenAge: 3600,
|
||||
}
|
||||
@@ -567,7 +567,7 @@ func TestJWTAuthentication(t *testing.T) {
|
||||
|
||||
jwtConfig := &JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
Audiences: []string{audience},
|
||||
KeysLocation: jwksURL,
|
||||
}
|
||||
serverConfig := &Config{
|
||||
@@ -646,3 +646,108 @@ func TestJWTAuthentication(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJWTMultipleAudiences tests JWT validation with multiple audiences (dashboard and CLI).
|
||||
func TestJWTMultipleAudiences(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping JWT multiple audiences tests in short mode")
|
||||
}
|
||||
|
||||
jwksServer, privateKey, jwksURL := setupJWKSServer(t)
|
||||
defer jwksServer.Close()
|
||||
|
||||
const (
|
||||
issuer = "https://test-issuer.example.com"
|
||||
dashboardAudience = "dashboard-audience"
|
||||
cliAudience = "cli-audience"
|
||||
)
|
||||
|
||||
hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
audience string
|
||||
wantAuthOK bool
|
||||
}{
|
||||
{
|
||||
name: "accepts_dashboard_audience",
|
||||
audience: dashboardAudience,
|
||||
wantAuthOK: true,
|
||||
},
|
||||
{
|
||||
name: "accepts_cli_audience",
|
||||
audience: cliAudience,
|
||||
wantAuthOK: true,
|
||||
},
|
||||
{
|
||||
name: "rejects_unknown_audience",
|
||||
audience: "unknown-audience",
|
||||
wantAuthOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
jwtConfig := &JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audiences: []string{dashboardAudience, cliAudience},
|
||||
KeysLocation: jwksURL,
|
||||
}
|
||||
serverConfig := &Config{
|
||||
HostKeyPEM: hostKey,
|
||||
JWT: jwtConfig,
|
||||
}
|
||||
server := New(serverConfig)
|
||||
server.SetAllowRootLogin(true)
|
||||
|
||||
testUserHash, err := sshuserhash.HashUserID("test-user")
|
||||
require.NoError(t, err)
|
||||
|
||||
currentUser := testutil.GetTestUsername(t)
|
||||
authConfig := &sshauth.Config{
|
||||
UserIDClaim: sshauth.DefaultUserIDClaim,
|
||||
AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
|
||||
MachineUsers: map[string][]uint32{
|
||||
currentUser: {0},
|
||||
},
|
||||
}
|
||||
server.UpdateSSHAuth(authConfig)
|
||||
|
||||
serverAddr := StartTestServer(t, server)
|
||||
defer require.NoError(t, server.Stop())
|
||||
|
||||
host, portStr, err := net.SplitHostPort(serverAddr)
|
||||
require.NoError(t, err)
|
||||
|
||||
token := generateValidJWT(t, privateKey, issuer, tc.audience)
|
||||
config := &cryptossh.ClientConfig{
|
||||
User: testutil.GetTestUsername(t),
|
||||
Auth: []cryptossh.AuthMethod{
|
||||
cryptossh.Password(token),
|
||||
},
|
||||
HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
|
||||
conn, err := cryptossh.Dial("tcp", net.JoinHostPort(host, portStr), config)
|
||||
if tc.wantAuthOK {
|
||||
require.NoError(t, err, "JWT authentication should succeed for audience %s", tc.audience)
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Logf("close connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
session, err := conn.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
err = session.Shell()
|
||||
require.NoError(t, err, "Shell should work with valid audience")
|
||||
} else {
|
||||
assert.Error(t, err, "JWT authentication should fail for unknown audience")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,9 +176,9 @@ type Server struct {
|
||||
|
||||
type JWTConfig struct {
|
||||
Issuer string
|
||||
Audience string
|
||||
KeysLocation string
|
||||
MaxTokenAge int64
|
||||
Audiences []string
|
||||
}
|
||||
|
||||
// Config contains all SSH server configuration options
|
||||
@@ -427,18 +427,25 @@ func (s *Server) ensureJWTValidator() error {
|
||||
return fmt.Errorf("JWT config not set")
|
||||
}
|
||||
|
||||
log.Debugf("Initializing JWT validator (issuer: %s, audience: %s)", config.Issuer, config.Audience)
|
||||
if len(config.Audiences) == 0 {
|
||||
return fmt.Errorf("JWT config has no audiences configured")
|
||||
}
|
||||
|
||||
log.Debugf("Initializing JWT validator (issuer: %s, audiences: %v)", config.Issuer, config.Audiences)
|
||||
validator := jwt.NewValidator(
|
||||
config.Issuer,
|
||||
[]string{config.Audience},
|
||||
config.Audiences,
|
||||
config.KeysLocation,
|
||||
true,
|
||||
)
|
||||
|
||||
// Use custom userIDClaim from authorizer if available
|
||||
audience := ""
|
||||
if len(config.Audiences) != 0 {
|
||||
audience = config.Audiences[0]
|
||||
}
|
||||
extractorOptions := []jwt.ClaimsExtractorOption{
|
||||
jwt.WithAudience(config.Audience),
|
||||
jwt.WithAudience(audience),
|
||||
}
|
||||
if authorizer.GetUserIDClaim() != "" {
|
||||
extractorOptions = append(extractorOptions, jwt.WithUserIDClaim(authorizer.GetUserIDClaim()))
|
||||
@@ -475,8 +482,8 @@ func (s *Server) validateJWTToken(tokenString string) (*gojwt.Token, error) {
|
||||
if err != nil {
|
||||
if jwtConfig != nil {
|
||||
if claims, parseErr := s.parseTokenWithoutValidation(tokenString); parseErr == nil {
|
||||
return nil, fmt.Errorf("validate token (expected issuer=%s, audience=%s, actual issuer=%v, audience=%v): %w",
|
||||
jwtConfig.Issuer, jwtConfig.Audience, claims["iss"], claims["aud"], err)
|
||||
return nil, fmt.Errorf("validate token (expected issuer=%s, audiences=%v, actual issuer=%v, audience=%v): %w",
|
||||
jwtConfig.Issuer, jwtConfig.Audiences, claims["iss"], claims["aud"], err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("validate token: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user