Files
livekit/pkg/service/auth.go
Paul WellsandClaude Opus 5 d9a643243a agent endpoints: build the front and registration from params
Front is an http.Handler, so fallback, identity and singleKeyFallback were read
on every request with no synchronization while WithFallback, WithIdentity and
WithSingleKeyFallback wrote them on the live object. Each returned the same
pointer, so the chain read as though it built a value. Every call site happens
to run before the handler is mounted, so nothing races today, but nothing in
the type prevents it, and WithSingleKeyFallback took no argument and could only
be turned on. NewFront now takes FrontParams and configuration is fixed at
construction.

NewWorkerRegisterer takes its EndpointSettingsFunc directly, and
HandshakeAgentWorker takes one in place of a variadic of raw closures that
existed only to reach the setter it replaced.

Registration gains NewRegistration and RegistrationParams, moving the Draining
callback off an exported mutable field and folding SetSession into
construction. IsDraining absorbs the nil check at both call sites.

Access carried a three-state ladder as two bools, with "granted implies
credentialed" documented but unenforced. It is now an ordered AccessLevel, so
callers compare a rank rather than combining flags and the invariant holds by
construction.

Registry.Register returned an error that was always nil, with a dead branch at
each call site. Both registry maps and the per-registration session are
read-heavy, so they take RWMutex.

CopyBody returned two errors to separate a source failure from a destination
one. It returns one, wrapping a source failure in *SourceError, which is what
the caller discriminates on.

NewWebTransportServer took a callback to break the handler/server init cycle;
the caller assigns wt.H3.Handler after construction instead. StartWebTransport
reads Development off the service rather than taking a bool, and returns a nil
stop function where it starts no listener.

Smaller: slices.Sort for sort.Strings, for range for an unused counter, a nil
slice for regs[:0:0], p2c generic over its slice so the call site passes a
method expression rather than allocating a closure per request, and streamCode
deduplicated into wire.StreamCode so both peers map reset codes in one place.

Comments on the touched declarations drop remote behavior, migration narration
and contrastive framing, keeping the constraints and invariants.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 17:56:52 -07:00

266 lines
6.7 KiB
Go

// Copyright 2023 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 (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/twitchtv/twirp"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
)
const (
authorizationHeader = "Authorization"
bearerPrefix = "Bearer "
accessTokenParam = "access_token"
)
type grantsKey struct{}
type grantsValue struct {
claims *auth.ClaimGrants
apiKey string
expiresAt time.Time
}
var (
ErrPermissionDenied = errors.New("permissions denied")
ErrMissingAuthorization = errors.New("invalid authorization header. Must start with " + bearerPrefix)
ErrInvalidAuthorizationToken = errors.New("invalid authorization token")
ErrInvalidAPIKey = errors.New("invalid API key")
)
// authentication middleware
type APIKeyAuthMiddleware struct {
provider auth.KeyProvider
}
func NewAPIKeyAuthMiddleware(provider auth.KeyProvider) *APIKeyAuthMiddleware {
return &APIKeyAuthMiddleware{
provider: provider,
}
}
func (m *APIKeyAuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
if r.URL != nil && (r.URL.Path == "/rtc/validate" || r.URL.Path == "/rtc/v1/validate") {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
authHeader := r.Header.Get(authorizationHeader)
var authToken string
if authHeader != "" {
if !strings.HasPrefix(authHeader, bearerPrefix) {
HandleError(w, r, http.StatusUnauthorized, ErrMissingAuthorization)
return
}
authToken = authHeader[len(bearerPrefix):]
} else {
// the body must survive for requests proxied further (agent HTTP
// endpoints), so the token comes from the query string alone. URL is nil
// on hand-built requests.
if r.URL != nil {
authToken = r.URL.Query().Get(accessTokenParam)
}
}
if authToken != "" {
v, err := auth.ParseAPIToken(authToken)
if err != nil {
HandleError(w, r, http.StatusUnauthorized, ErrInvalidAuthorizationToken)
return
}
secret := m.provider.GetSecret(v.APIKey())
if secret == "" {
HandleError(w, r, http.StatusUnauthorized, ErrInvalidAPIKey, "apiKey", v.APIKey())
return
}
claims, grants, err := v.Verify(secret)
if err != nil {
HandleError(w, r, http.StatusUnauthorized, fmt.Errorf("%w: %s", ErrInvalidAuthorizationToken, err.Error()))
return
}
var expiresAt time.Time
if claims != nil && claims.ExpiresAt != nil {
expiresAt = claims.ExpiresAt.Time
}
// set grants in context
ctx := r.Context()
r = r.WithContext(context.WithValue(ctx, grantsKey{}, &grantsValue{
claims: grants,
apiKey: v.APIKey(),
expiresAt: expiresAt,
}))
}
next.ServeHTTP(w, r)
}
func WithAPIKey(ctx context.Context, grants *auth.ClaimGrants, apiKey string) context.Context {
return context.WithValue(ctx, grantsKey{}, &grantsValue{
claims: grants,
apiKey: apiKey,
})
}
func GetGrants(ctx context.Context) *auth.ClaimGrants {
val := ctx.Value(grantsKey{})
v, ok := val.(*grantsValue)
if !ok {
return nil
}
return v.claims
}
func GetTokenExpiresAt(ctx context.Context) time.Time {
val := ctx.Value(grantsKey{})
v, ok := val.(*grantsValue)
if !ok {
return time.Time{}
}
return v.expiresAt
}
func GetAPIKey(ctx context.Context) string {
val := ctx.Value(grantsKey{})
v, ok := val.(*grantsValue)
if !ok {
return ""
}
return v.apiKey
}
func WithGrants(ctx context.Context, grants *auth.ClaimGrants, apiKey string) context.Context {
return WithGrantsExpiry(ctx, grants, apiKey, time.Time{})
}
func WithGrantsExpiry(ctx context.Context, grants *auth.ClaimGrants, apiKey string, expiresAt time.Time) context.Context {
return context.WithValue(ctx, grantsKey{}, &grantsValue{
claims: grants,
apiKey: apiKey,
expiresAt: expiresAt,
})
}
func SetAuthorizationToken(r *http.Request, token string) {
r.Header.Set(authorizationHeader, bearerPrefix+token)
}
func EnsureJoinPermission(ctx context.Context) (name livekit.RoomName, err error) {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil {
err = ErrPermissionDenied
return
}
if claims.Video.RoomJoin {
name = livekit.RoomName(claims.Video.Room)
} else {
err = ErrPermissionDenied
}
return
}
func EnsureAdminPermission(ctx context.Context, room livekit.RoomName) error {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil {
return ErrPermissionDenied
}
if !claims.Video.RoomAdmin || room != livekit.RoomName(claims.Video.Room) {
return ErrPermissionDenied
}
return nil
}
func EnsureCreatePermission(ctx context.Context) error {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil || !claims.Video.RoomCreate {
return ErrPermissionDenied
}
return nil
}
func EnsureListPermission(ctx context.Context) error {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil || !claims.Video.RoomList {
return ErrPermissionDenied
}
return nil
}
func EnsureRecordPermission(ctx context.Context) error {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil || !claims.Video.RoomRecord {
return ErrPermissionDenied
}
return nil
}
func EnsureIngressAdminPermission(ctx context.Context) error {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil || !claims.Video.IngressAdmin {
return ErrPermissionDenied
}
return nil
}
func EnsureSIPAdminPermission(ctx context.Context) error {
claims := GetGrants(ctx)
if claims == nil || claims.SIP == nil || !claims.SIP.Admin {
return ErrPermissionDenied
}
return nil
}
func EnsureSIPCallPermission(ctx context.Context) error {
claims := GetGrants(ctx)
if claims == nil || claims.SIP == nil || !claims.SIP.Call {
return ErrPermissionDenied
}
return nil
}
func EnsureDestRoomPermission(ctx context.Context, source livekit.RoomName, destination livekit.RoomName) error {
claims := GetGrants(ctx)
if claims == nil || claims.Video == nil {
return ErrPermissionDenied
}
if !claims.Video.RoomAdmin || source != livekit.RoomName(claims.Video.Room) || destination != livekit.RoomName(claims.Video.DestinationRoom) {
return ErrPermissionDenied
}
return nil
}
// wraps authentication errors around Twirp
func twirpAuthError(err error) error {
return twirp.NewError(twirp.Unauthenticated, err.Error())
}