Add integration test for TURN auth failures (#4524)

* Add integration test for TURN auth failures

Covers four credential-corruption scenarios against the TURN server
embedded in a single-node server: unparseable username, wrong password,
expired username, and unknown API key. Each case drives a raw pion
turn.Client Allocate and asserts the server rejects with a TURN error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* TURN auth test: cover password expiry binding

The TURN password's hash includes the expiry along with the secret and
participant ID. Add two cases that exercise this binding: a password
generated for a different expiry than the username's, and a password
generated without any expiry component paired with a username that has
one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raja Subramanian
2026-05-14 10:48:27 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent ef2e5efe14
commit 4b8db3cfe5
2 changed files with 121 additions and 1 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ require (
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.2 // indirect
github.com/pion/stun/v3 v3.1.2
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
+120
View File
@@ -19,6 +19,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"reflect"
"strings"
@@ -27,6 +28,8 @@ import (
"time"
"github.com/pion/sdp/v3"
"github.com/pion/stun/v3"
"github.com/pion/turn/v4"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
"github.com/thoas/go-funk"
@@ -41,6 +44,7 @@ import (
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/rtc/types"
"github.com/livekit/livekit-server/pkg/service"
"github.com/livekit/livekit-server/pkg/sfu"
"github.com/livekit/livekit-server/pkg/sfu/datachannel"
"github.com/livekit/livekit-server/pkg/testutils"
@@ -1385,3 +1389,119 @@ func TestTurnRelay(t *testing.T) {
})
}
}
func TestTurnAuthFailure(t *testing.T) {
if testing.Short() {
t.SkipNow()
return
}
const turnUDPPort = 3478
s := createSingleNodeServer(func(c *config.Config) {
c.TURN.Enabled = true
c.TURN.UDPPort = turnUDPPort
})
go func() {
if err := s.Start(); err != nil {
logger.Errorw("server returned error", err)
}
}()
defer s.Stop(true)
waitForServerToStart(s)
// build a known-good username/password pair so individual cases can mutate
// only the part they are exercising.
pID := livekit.ParticipantID("PA_authfail")
authHandler := service.NewTURNAuthHandler(auth.NewSimpleKeyProvider(testApiKey, testApiSecret))
validUsername, validExpiry := authHandler.CreateUsername(testApiKey, pID, 300)
validPassword, err := authHandler.CreatePassword(testApiKey, pID, validExpiry)
require.NoError(t, err)
// username encoded with an already-expired timestamp.
expiredUsername, _ := authHandler.CreateUsername(testApiKey, pID, -10)
// username encoded with an api key the server does not know about.
unknownAPIKeyUsername, _ := authHandler.CreateUsername("unknown-api-key", pID, 300)
// password whose hash was generated for an expiry that doesn't match the
// one encoded in the username. The server reconstructs the password using
// the username's expiry, so the integrity check fails.
mismatchedExpiryPassword, err := authHandler.CreatePassword(testApiKey, pID, validExpiry+60)
require.NoError(t, err)
require.NotEqual(t, validPassword, mismatchedExpiryPassword)
// password whose hash was generated without an expiry component (the
// pre-expiry form of the username/password pair); should not authenticate
// against a username that carries an expiry.
passwordWithoutExpiry, err := authHandler.CreatePassword(testApiKey, pID, 0)
require.NoError(t, err)
require.NotEqual(t, validPassword, passwordWithoutExpiry)
testCases := []struct {
name string
username string
password string
}{
{
name: "unparseable-username",
username: "not-base62!!!",
password: validPassword,
},
{
name: "wrong-password",
username: validUsername,
password: "wrongpassword",
},
{
name: "expired-username",
username: expiredUsername,
password: validPassword,
},
{
name: "unknown-api-key",
username: unknownAPIKeyUsername,
password: validPassword,
},
{
name: "password-expiry-mismatch",
username: validUsername,
password: mismatchedExpiryPassword,
},
{
name: "password-missing-expiry",
username: validUsername,
password: passwordWithoutExpiry,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
conn, err := net.ListenPacket("udp4", "0.0.0.0:0")
require.NoError(t, err)
defer conn.Close()
client, err := turn.NewClient(&turn.ClientConfig{
TURNServerAddr: fmt.Sprintf("127.0.0.1:%d", turnUDPPort),
Username: tc.username,
Password: tc.password,
Realm: service.LivekitRealm,
Conn: conn,
})
require.NoError(t, err)
defer client.Close()
require.NoError(t, client.Listen())
_, allocErr := client.Allocate()
require.Error(t, allocErr)
// pion's TURN server replies with 400 Bad Request for any
// authenticated-allocate failure (unknown user or integrity check
// mismatch); the initial unauthenticated probe is what returns 401.
var turnErr *stun.TurnError
require.ErrorAs(t, allocErr, &turnErr)
require.Equal(t, stun.CodeBadRequest, turnErr.ErrorCodeAttr.Code)
})
}
}