diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 7f81f0c89..d0b2d8089 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -197,6 +197,7 @@ type ParticipantParams struct { SimTracks map[uint32]interceptor.SimulcastTrackInfo Grants *auth.ClaimGrants TokenExpiresAt time.Time + APIKey string InitialVersion uint32 ClientConf *livekit.ClientConfiguration ClientInfo ClientInfo @@ -859,6 +860,10 @@ func (p *ParticipantImpl) TokenExpiresAt() time.Time { return p.params.TokenExpiresAt } +func (p *ParticipantImpl) APIKey() string { + return p.params.APIKey +} + func (p *ParticipantImpl) SetPermission(permission *livekit.ParticipantPermission) bool { if permission == nil { return false diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index 1d7734f1d..7f2f4b9c3 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -525,6 +525,7 @@ type LocalParticipant interface { // permissions ClaimGrants() *auth.ClaimGrants TokenExpiresAt() time.Time + APIKey() string SetPermission(permission *livekit.ParticipantPermission) bool CanPublish() bool CanPublishSource(source livekit.TrackSource) bool diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index 6c11e6bf6..e9c0aea0c 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -23,6 +23,16 @@ import ( ) type FakeLocalParticipant struct { + APIKeyStub func() string + aPIKeyMutex sync.RWMutex + aPIKeyArgsForCall []struct { + } + aPIKeyReturns struct { + result1 string + } + aPIKeyReturnsOnCall map[int]struct { + result1 string + } ActiveAtStub func() time.Time activeAtMutex sync.RWMutex activeAtArgsForCall []struct { @@ -1655,6 +1665,58 @@ type FakeLocalParticipant struct { invocationsMutex sync.RWMutex } +func (fake *FakeLocalParticipant) APIKey() string { + fake.aPIKeyMutex.Lock() + ret, specificReturn := fake.aPIKeyReturnsOnCall[len(fake.aPIKeyArgsForCall)] + fake.aPIKeyArgsForCall = append(fake.aPIKeyArgsForCall, struct { + }{}) + stub := fake.APIKeyStub + fakeReturns := fake.aPIKeyReturns + fake.recordInvocation("APIKey", []interface{}{}) + fake.aPIKeyMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) APIKeyCallCount() int { + fake.aPIKeyMutex.RLock() + defer fake.aPIKeyMutex.RUnlock() + return len(fake.aPIKeyArgsForCall) +} + +func (fake *FakeLocalParticipant) APIKeyCalls(stub func() string) { + fake.aPIKeyMutex.Lock() + defer fake.aPIKeyMutex.Unlock() + fake.APIKeyStub = stub +} + +func (fake *FakeLocalParticipant) APIKeyReturns(result1 string) { + fake.aPIKeyMutex.Lock() + defer fake.aPIKeyMutex.Unlock() + fake.APIKeyStub = nil + fake.aPIKeyReturns = struct { + result1 string + }{result1} +} + +func (fake *FakeLocalParticipant) APIKeyReturnsOnCall(i int, result1 string) { + fake.aPIKeyMutex.Lock() + defer fake.aPIKeyMutex.Unlock() + fake.APIKeyStub = nil + if fake.aPIKeyReturnsOnCall == nil { + fake.aPIKeyReturnsOnCall = make(map[int]struct { + result1 string + }) + } + fake.aPIKeyReturnsOnCall[i] = struct { + result1 string + }{result1} +} func (fake *FakeLocalParticipant) ActiveAt() time.Time { fake.activeAtMutex.Lock() ret, specificReturn := fake.activeAtReturnsOnCall[len(fake.activeAtArgsForCall)] diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index f36829f36..ee49eaea2 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -506,6 +506,7 @@ func (r *RoomManager) StartSession( SubscribeEnabledCodecs: enabledCodecs, Grants: pi.Grants, TokenExpiresAt: pi.TokenExpiresAt, + APIKey: pi.APIKey, Reconnect: pi.Reconnect, Logger: pLogger, Reporter: roomobs.NewNoopParticipantSessionReporter(), @@ -1156,7 +1157,7 @@ func (r *RoomManager) iceServersForParticipant(apiKey string, participant types. } func (r *RoomManager) refreshToken(participant types.LocalParticipant) error { - key, secret, err := r.getFirstKeyPair() + key, secret, err := r.getSigningKeyPair(participant.APIKey()) if err != nil { return err } @@ -1202,6 +1203,14 @@ func (r *RoomManager) getIceConfig(roomName livekit.RoomName, participant types. return r.iceConfigCache.Get(iceConfigCacheKey{roomName, participant.Identity()}) } +// getSigningKeyPair prefers the key that signed the join token so refreshed tokens stay valid when other keys are rotated. +func (r *RoomManager) getSigningKeyPair(apiKey string) (string, string, error) { + if secret := r.config.Keys[apiKey]; apiKey != "" && secret != "" { + return apiKey, secret, nil + } + return r.getFirstKeyPair() +} + func (r *RoomManager) getFirstKeyPair() (string, string, error) { for key, secret := range r.config.Keys { return key, secret, nil diff --git a/pkg/service/roommanager_token_test.go b/pkg/service/roommanager_token_test.go new file mode 100644 index 000000000..9598e568e --- /dev/null +++ b/pkg/service/roommanager_token_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/auth" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" +) + +func TestRefreshTokenSigningKey(t *testing.T) { + keys := map[string]string{ + "key1": "secret1-secret1-secret1-secret1-secret1", + "key2": "secret2-secret2-secret2-secret2-secret2", + } + r := &RoomManager{config: &config.Config{Keys: keys}} + + refresh := func(t *testing.T, apiKey string) *auth.APIKeyTokenVerifier { + p := &typesfakes.FakeLocalParticipant{} + p.APIKeyReturns(apiKey) + p.ClaimGrantsReturns(&auth.ClaimGrants{Video: &auth.VideoGrant{RoomJoin: true}}) + require.NoError(t, r.refreshToken(p)) + require.Equal(t, 1, p.SendRefreshTokenCallCount()) + + v, err := auth.ParseAPIToken(p.SendRefreshTokenArgsForCall(0)) + require.NoError(t, err) + _, _, err = v.Verify(keys[v.APIKey()]) + require.NoError(t, err) + return v + } + + t.Run("join key", func(t *testing.T) { + for apiKey := range keys { + require.Equal(t, apiKey, refresh(t, apiKey).APIKey()) + } + }) + + t.Run("unknown key falls back", func(t *testing.T) { + require.Contains(t, keys, refresh(t, "unknown").APIKey()) + }) +}