diff --git a/.changeset/witty-donuts-nail.md b/.changeset/witty-donuts-nail.md new file mode 100644 index 000000000..20e300749 --- /dev/null +++ b/.changeset/witty-donuts-nail.md @@ -0,0 +1,5 @@ +--- +"github.com/livekit/protocol": patch +--- + +fix: ensure we don't reject tokens on unknown fields diff --git a/auth/grants.go b/auth/grants.go index df5e684bd..8fc0bf00a 100644 --- a/auth/grants.go +++ b/auth/grants.go @@ -35,6 +35,12 @@ var tokenMarshaler = protojson.MarshalOptions{ EmitDefaultValues: false, } +// tokenUnmarshaler discards unknown fields so that older servers can accept +// tokens issued by newer clients that include fields the server does not yet know about. +var tokenUnmarshaler = protojson.UnmarshalOptions{ + DiscardUnknown: true, +} + var ErrSensitiveCredentials = errors.New("room configuration should not contain sensitive credentials") func (c *RoomConfiguration) Clone() *RoomConfiguration { @@ -49,7 +55,7 @@ func (c *RoomConfiguration) MarshalJSON() ([]byte, error) { } func (c *RoomConfiguration) UnmarshalJSON(data []byte) error { - return protojson.Unmarshal(data, (*livekit.RoomConfiguration)(c)) + return tokenUnmarshaler.Unmarshal(data, (*livekit.RoomConfiguration)(c)) } // CheckCredentials checks if the room configuration contains sensitive credentials diff --git a/auth/verifier_test.go b/auth/verifier_test.go index 37d8f2ab3..c608f340f 100644 --- a/auth/verifier_test.go +++ b/auth/verifier_test.go @@ -18,7 +18,9 @@ import ( "testing" "time" + "github.com/go-jose/go-jose/v3" "github.com/go-jose/go-jose/v3/json" + "github.com/go-jose/go-jose/v3/jwt" "github.com/stretchr/testify/require" "github.com/livekit/protocol/auth" @@ -95,6 +97,51 @@ func TestVerifier(t *testing.T) { require.EqualValues(t, attrs, decoded.Attributes) }) + t.Run("unknown fields are ignored for forward compatibility", func(t *testing.T) { + // Simulate a token issued by a newer client whose claims include fields + // this server does not yet know about. The server should still accept the + // token rather than failing with `unknown field`. This guards against + // requiring server upgrades before client upgrades can roll out. + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: []byte(secret)}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + require.NoError(t, err) + + claims := map[string]interface{}{ + "iss": apiKey, + "sub": "me", + "nbf": jwt.NewNumericDate(time.Now()), + "exp": jwt.NewNumericDate(time.Now().Add(time.Minute)), + // unknown top-level claim grants field + "someFutureGrant": map[string]interface{}{"enabled": true}, + "video": map[string]interface{}{ + "roomJoin": true, + "room": "myroom", + // unknown field inside a known grant + "someFutureVideoField": "future-value", + }, + "roomConfig": map[string]interface{}{ + "name": "myroom", + // unknown field inside a protojson-decoded message + "someFutureRoomConfigField": "future-value", + }, + } + token, err := jwt.Signed(sig).Claims(claims).CompactSerialize() + require.NoError(t, err) + + v, err := auth.ParseAPIToken(token) + require.NoError(t, err) + + _, decoded, err := v.Verify(secret) + require.NoError(t, err) + require.NotNil(t, decoded.Video) + require.Equal(t, "myroom", decoded.Video.Room) + require.True(t, decoded.Video.RoomJoin) + require.NotNil(t, decoded.RoomConfig) + require.Equal(t, "myroom", decoded.RoomConfig.Name) + }) + t.Run("nil permissions are handled", func(t *testing.T) { grant := &auth.VideoGrant{ Room: "myroom",