mirror of
https://github.com/livekit/livekit.git
synced 2026-10-23 10:52:46 +00:00
support auth checks with mock server (#4629)
* support auth checks with mock server * simplify unit tests
This commit is contained in:
1 parent
2aec61c11b
commit
ad76898f03
4 files changed
+299
-21
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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 "<package>.<Service>/<Method>" 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 <jwt>" 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()
|
||||
}
|
||||
+37
-13
@@ -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 {
|
||||
|
||||
+14
-7
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user