From ad76898f0351b5d2bfe336079ed6a5909034974b Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 29 Jun 2026 08:19:05 +0200 Subject: [PATCH] support auth checks with mock server (#4629) * support auth checks with mock server * simplify unit tests --- cmd/test-server/README.md | 37 ++++++- cmd/test-server/auth.go | 212 ++++++++++++++++++++++++++++++++++++ cmd/test-server/handlers.go | 50 ++++++--- cmd/test-server/main.go | 21 ++-- 4 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 cmd/test-server/auth.go diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md index 08dd75122..1c4eb247a 100644 --- a/cmd/test-server/README.md +++ b/cmd/test-server/README.md @@ -59,6 +59,7 @@ failover retry): | `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). | | `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. | | `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. | +| `X-Lk-Mock-Skip-Auth` | — | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). | Response headers: @@ -66,11 +67,45 @@ Response headers: |---|---| | `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. | +## Permission enforcement + +Every API method requires the same token grants the real LiveKit server checks +(see `pkg/service/auth.go`), so the mock doubles as a conformance check that an +SDK attaches the right permissions automatically. Tokens are parsed and verified +with the protocol's own `auth` helpers — the same code path the real server uses +— against the mock's configured API secret (default `secret`, matching +`livekit-server --dev`; override with `--api-secret` / `LK_TEST_SERVER_API_SECRET`). + +- Missing, malformed, or wrongly-signed `Authorization` → `401 unauthenticated`. +- Validly-signed token without the required grant → `403 permission_denied`. +- `roomAdmin`-scoped methods also require the token's `room` to match the + request's room; `ForwardParticipant`/`MoveParticipant` additionally require + `destinationRoom` to match. + +SDKs exercising permissions should sign tokens with the same API secret the mock +is configured with (`secret` by default). + +| Grant | Methods | +|---|---| +| `video.roomCreate` | `CreateRoom`, `DeleteRoom`, all `Connector` calls | +| `video.roomList` | `ListRooms` | +| `video.roomRecord` | all `Egress` methods | +| `video.ingressAdmin` | all `Ingress` methods | +| `video.roomAdmin` (+ `room`) | room participant/data/metadata methods, `AgentDispatchService` methods | +| `video.roomAdmin` (+ `room` + `destinationRoom`) | `ForwardParticipant`, `MoveParticipant` | +| `sip.admin` | SIP trunk & dispatch-rule CRUD | +| `sip.call` | `CreateSIPParticipant`; `TransferSIPParticipant` (also needs `roomAdmin`) | + +Send `X-Lk-Mock-Skip-Auth: true` to bypass enforcement for tests that aren't +about permissions. + ## Common recipes | Goal | Headers | |---|---| -| Happy path | _(none)_ → 200 from region `0` | +| Happy path | valid token with the method's grant → 200 from region `0` | +| Bypass auth (failover tests) | `X-Lk-Mock-Skip-Auth: true` | +| Missing-permission error | token without the required grant → 403 | | Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` | | Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` | | All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` | diff --git a/cmd/test-server/auth.go b/cmd/test-server/auth.go new file mode 100644 index 000000000..d526e6777 --- /dev/null +++ b/cmd/test-server/auth.go @@ -0,0 +1,212 @@ +// 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 main + +import ( + "net/http" + "strings" + + "github.com/livekit/protocol/auth" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// The mock enforces the same token permissions the real LiveKit server requires +// for each API method (see pkg/service/auth.go in livekit/livekit). The point is +// to verify that SDKs attach the correct grants automatically. Tokens are parsed +// and verified with the protocol's own auth helpers (the same code the server +// uses), against the mock's configured API secret (default "secret", matching +// `livekit-server --dev`); set --api-secret / LK_TEST_SERVER_API_SECRET to change it. +// +// Set X-Lk-Mock-Skip-Auth: true to bypass enforcement for tests that aren't +// about permissions (e.g. region-failover tests using a placeholder token). + +// perm describes the grants a method requires. roomAdmin additionally requires +// the token's room to match the request's room; destRoom further requires the +// token's destinationRoom to match the request's destination_room. +type perm struct { + roomCreate bool + roomList bool + roomRecord bool + ingressAdmin bool + roomAdmin bool + destRoom bool + sipAdmin bool + sipCall bool +} + +// methodPerms maps "./" to its required grants, +// matching the Ensure*Permission checks in the real server's services. +var methodPerms = map[string]perm{ + // RoomService + "livekit.RoomService/CreateRoom": {roomCreate: true}, + "livekit.RoomService/DeleteRoom": {roomCreate: true}, + "livekit.RoomService/ListRooms": {roomList: true}, + "livekit.RoomService/ListParticipants": {roomAdmin: true}, + "livekit.RoomService/GetParticipant": {roomAdmin: true}, + "livekit.RoomService/RemoveParticipant": {roomAdmin: true}, + "livekit.RoomService/MutePublishedTrack": {roomAdmin: true}, + "livekit.RoomService/UpdateParticipant": {roomAdmin: true}, + "livekit.RoomService/UpdateSubscriptions": {roomAdmin: true}, + "livekit.RoomService/SendData": {roomAdmin: true}, + "livekit.RoomService/UpdateRoomMetadata": {roomAdmin: true}, + "livekit.RoomService/ForwardParticipant": {destRoom: true}, + "livekit.RoomService/MoveParticipant": {destRoom: true}, + "livekit.RoomService/PerformRpc": {roomAdmin: true}, + + // Egress — all require record permission + "livekit.Egress/StartEgress": {roomRecord: true}, + "livekit.Egress/StartRoomCompositeEgress": {roomRecord: true}, + "livekit.Egress/StartWebEgress": {roomRecord: true}, + "livekit.Egress/StartParticipantEgress": {roomRecord: true}, + "livekit.Egress/StartTrackCompositeEgress": {roomRecord: true}, + "livekit.Egress/StartTrackEgress": {roomRecord: true}, + "livekit.Egress/UpdateLayout": {roomRecord: true}, + "livekit.Egress/UpdateStream": {roomRecord: true}, + "livekit.Egress/ListEgress": {roomRecord: true}, + "livekit.Egress/StopEgress": {roomRecord: true}, + + // Ingress — all require ingress admin + "livekit.Ingress/CreateIngress": {ingressAdmin: true}, + "livekit.Ingress/UpdateIngress": {ingressAdmin: true}, + "livekit.Ingress/ListIngress": {ingressAdmin: true}, + "livekit.Ingress/DeleteIngress": {ingressAdmin: true}, + + // SIP — trunk/dispatch administration requires sip.admin + "livekit.SIP/CreateSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/CreateSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/UpdateSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/UpdateSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/GetSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/GetSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/ListSIPTrunk": {sipAdmin: true}, + "livekit.SIP/ListSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/ListSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/DeleteSIPTrunk": {sipAdmin: true}, + "livekit.SIP/CreateSIPDispatchRule": {sipAdmin: true}, + "livekit.SIP/UpdateSIPDispatchRule": {sipAdmin: true}, + "livekit.SIP/ListSIPDispatchRule": {sipAdmin: true}, + "livekit.SIP/DeleteSIPDispatchRule": {sipAdmin: true}, + // Placing a call requires sip.call; transfer also requires room admin. + "livekit.SIP/CreateSIPParticipant": {sipCall: true}, + "livekit.SIP/TransferSIPParticipant": {sipCall: true, roomAdmin: true}, + + // AgentDispatch — room admin scoped to the dispatch's room + "livekit.AgentDispatchService/CreateDispatch": {roomAdmin: true}, + "livekit.AgentDispatchService/DeleteDispatch": {roomAdmin: true}, + "livekit.AgentDispatchService/ListDispatch": {roomAdmin: true}, + + // Connector (cloud) — initiating a call requires room create + "livekit.Connector/DialWhatsAppCall": {roomCreate: true}, + "livekit.Connector/DisconnectWhatsAppCall": {roomCreate: true}, + "livekit.Connector/ConnectWhatsAppCall": {roomCreate: true}, + "livekit.Connector/AcceptWhatsAppCall": {roomCreate: true}, + "livekit.Connector/ConnectTwilioCall": {roomCreate: true}, +} + +// authorize enforces the permissions a method requires. It returns the HTTP +// status and Twirp error code to send (0, "" means authorized / not enforced). +func (h *mockHandler) authorize(key string, r *http.Request, req proto.Message) (int, string) { + if strings.EqualFold(r.Header.Get(headerSkipAuth), "true") { + return 0, "" + } + p, known := methodPerms[key] + if !known { + return 0, "" // unknown/future method: don't enforce + } + + grants, err := h.verifyToken(r.Header.Get("Authorization")) + if err != nil { + // Missing, malformed, or improperly-signed token, like the real server. + return http.StatusUnauthorized, "unauthenticated" + } + if !p.satisfiedBy(grants, req) { + return http.StatusForbidden, "permission_denied" + } + return 0, "" +} + +// verifyToken parses and verifies a "Bearer " header using the protocol's +// own auth helpers (the same path the real server uses), returning the grants. +func (h *mockHandler) verifyToken(authorization string) (*auth.ClaimGrants, error) { + token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) + v, err := auth.ParseAPIToken(token) + if err != nil { + return nil, err + } + _, grants, err := v.Verify(h.apiSecret) + if err != nil { + return nil, err + } + return grants, nil +} + +func (p perm) satisfiedBy(g *auth.ClaimGrants, req proto.Message) bool { + v, s := g.Video, g.SIP + if p.roomCreate && (v == nil || !v.RoomCreate) { + return false + } + if p.roomList && (v == nil || !v.RoomList) { + return false + } + if p.roomRecord && (v == nil || !v.RoomRecord) { + return false + } + if p.ingressAdmin && (v == nil || !v.IngressAdmin) { + return false + } + if p.sipAdmin && (s == nil || !s.Admin) { + return false + } + if p.sipCall && (s == nil || !s.Call) { + return false + } + if p.roomAdmin || p.destRoom { + if v == nil || !v.RoomAdmin { + return false + } + if room := requestRoom(req); room != "" && v.Room != room { + return false + } + } + if p.destRoom { + if dest := requestString(req, "destination_room"); dest != "" && v.DestinationRoom != dest { + return false + } + } + return true +} + +// requestRoom reads the room name a request targets, trying the common "room" +// and "room_name" fields. +func requestRoom(req proto.Message) string { + if v := requestString(req, "room"); v != "" { + return v + } + return requestString(req, "room_name") +} + +func requestString(req proto.Message, field string) string { + if req == nil { + return "" + } + m := req.ProtoReflect() + fd := m.Descriptor().Fields().ByName(protoreflect.Name(field)) + if fd == nil || fd.Kind() != protoreflect.StringKind || fd.IsList() { + return "" + } + return m.Get(fd).String() +} diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go index f1568db95..7de083bc4 100644 --- a/cmd/test-server/handlers.go +++ b/cmd/test-server/handlers.go @@ -114,31 +114,55 @@ func init() { reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall") } +// serveAPI handles a Twirp call end to end: decode the request, enforce the +// method's required permissions (like the real server's auth middleware), apply +// any region-failure injection, then serve a populated response. +func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) { + json := strings.Contains(r.Header.Get("Content-Type"), "json") + key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") + spec, known := apiHandlers[key] + + // Decode the request up front — needed both to enforce room-scoped grants + // and to build the echoed response. + var req proto.Message + if known { + body, _ := io.ReadAll(r.Body) + req = spec.newReq() + if json { + _ = protojson.Unmarshal(body, req) + } else { + _ = proto.Unmarshal(body, req) + } + } + + // Permission enforcement comes first, mirroring the real server. + if status, code := h.authorize(key, r, req); status != 0 { + writeTwirpErrorCode(w, status, code, "mock: "+code) + return + } + + if h.shouldFail(r) { + h.fail(w, r) + return + } + + h.writeAPIResponse(w, r, json, known, req, spec) +} + // writeAPIResponse serves a populated, type-correct response for a known API // method. The response is the reflection-populated default unless the request // carries an X-Lk-Mock-Response header (protojson), which overrides it // entirely. Content type (protobuf vs JSON) mirrors the request. -func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request) { - json := strings.Contains(r.Header.Get("Content-Type"), "json") +func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request, json, known bool, req proto.Message, spec apiSpec) { w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) - key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") - spec, ok := apiHandlers[key] - if !ok { + if !known { // Unknown/future method: an empty body still decodes to a valid default // message in every Twirp client. writeEmptySuccess(w, json) return } - body, _ := io.ReadAll(r.Body) - req := spec.newReq() - if json { - _ = protojson.Unmarshal(body, req) - } else { - _ = proto.Unmarshal(body, req) - } - resp := spec.newResp() if override := r.Header.Get(headerResponse); override != "" { if err := protojson.Unmarshal([]byte(override), resp); err != nil { diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go index 66b756d47..07449539d 100644 --- a/cmd/test-server/main.go +++ b/cmd/test-server/main.go @@ -40,6 +40,8 @@ const ( headerDelayMs = "X-Lk-Mock-Delay-Ms" headerRegionsStatus = "X-Lk-Mock-Regions-Status" headerResponse = "X-Lk-Mock-Response" + // headerSkipAuth disables permission enforcement for a request. + headerSkipAuth = "X-Lk-Mock-Skip-Auth" // headerRegion is set on responses to the index of the region that served it. headerRegion = "X-Lk-Mock-Region" ) @@ -51,6 +53,9 @@ func main() { advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1") bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0") twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp") + // API secret used to verify request tokens for permission enforcement. + // Defaults to the `livekit-server --dev` secret. + apiSecret := flagValue("--api-secret", "LK_TEST_SERVER_API_SECRET", "secret") ports, err := parsePorts(portsFlag) if err != nil { @@ -72,7 +77,7 @@ func main() { for i, p := range ports { srv := &http.Server{ Addr: fmt.Sprintf("%s:%d", bindAddr, p), - Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix}, + Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix, apiSecret: apiSecret}, } go func() { errCh <- srv.ListenAndServe() }() fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p) @@ -93,6 +98,7 @@ type mockHandler struct { regionIndex int regions *livekit.RegionSettings twirpPrefix string + apiSecret string } func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -126,11 +132,7 @@ func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { } func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { - if h.shouldFail(r) { - h.fail(w, r) - return - } - h.writeAPIResponse(w, r) + h.serveAPI(w, r) } func (h *mockHandler) shouldFail(r *http.Request) bool { @@ -173,10 +175,15 @@ func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) { if code == "" { code = twirpCodeForStatus(status) } + writeTwirpErrorCode(w, status, code, fmt.Sprintf("mock failure (status %d)", status)) +} + +// writeTwirpErrorCode writes a Twirp JSON error with an explicit code and message. +func writeTwirpErrorCode(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.Header().Set(headerRegion, "") w.WriteHeader(status) - _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, fmt.Sprintf("mock failure (status %d)", status)) + _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, msg) } func twirpCodeForStatus(status int) string {