From 9b36d50b8db51fa19ce67e490b87cea92c395917 Mon Sep 17 00:00:00 2001 From: Roman Sirokov Date: Fri, 13 Mar 2026 22:38:40 +0100 Subject: [PATCH] optionally validate JWT iss and aud claims (#5569) --- api/openapi.yaml | 4 + docs/4-other/03-authentication.md | 12 ++ internal/auth/manager.go | 12 +- internal/auth/manager_test.go | 222 ++++++++++++++++++++++++++++++ internal/conf/conf.go | 2 + internal/core/core.go | 4 + mediamtx.yml | 4 + 7 files changed, 259 insertions(+), 1 deletion(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index d27627be..44ecfba6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -122,6 +122,10 @@ components: type: string authJWTClaimKey: type: string + authJWTIssuer: + type: string + authJWTAudience: + type: string authJWTExclude: type: array items: diff --git a/docs/4-other/03-authentication.md b/docs/4-other/03-authentication.md index ae53b74e..7e064d27 100644 --- a/docs/4-other/03-authentication.md +++ b/docs/4-other/03-authentication.md @@ -175,6 +175,18 @@ openssl s_client -connect my_identity_server:443 /dev/null | sed -n openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':' ``` +Optionally, the JWT `iss` (issuer) and `aud` (audience) claims can be validated by setting `authJWTIssuer` and `authJWTAudience`. When set, tokens that don't contain the expected values will be rejected: + +```yml +authMethod: jwt +authJWTJWKS: http://my_identity_server/jwks_endpoint +authJWTClaimKey: mediamtx_permissions +authJWTIssuer: http://my_identity_server +authJWTAudience: mediamtx +``` + +Leave these fields empty to skip validation of the respective claims. + #### Keycloak setup Here's a tutorial on how to setup the [Keycloak identity server](https://www.keycloak.org/) in order to provide JWTs. diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 0ce5bb56..71a67ab5 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -75,6 +75,8 @@ type Manager struct { JWTClaimKey string JWTExclude []conf.AuthInternalUserPermission JWTInHTTPQuery bool + JWTIssuer string + JWTAudience string ReadTimeout time.Duration mutex sync.RWMutex @@ -250,9 +252,17 @@ func (m *Manager) authenticateJWT(req *Request) error { return fmt.Errorf("JWT not provided") } + var opts []jwt.ParserOption + if m.JWTIssuer != "" { + opts = append(opts, jwt.WithIssuer(m.JWTIssuer)) + } + if m.JWTAudience != "" { + opts = append(opts, jwt.WithAudience(m.JWTAudience)) + } + var cc jwtClaims cc.permissionsKey = m.JWTClaimKey - _, err = jwt.ParseWithClaims(encodedJWT, &cc, keyfunc) + _, err = jwt.ParseWithClaims(encodedJWT, &cc, keyfunc, opts...) if err != nil { return err } diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go index c52bbed3..651d31d9 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/manager_test.go @@ -606,6 +606,228 @@ func TestAuthJWTExclude(t *testing.T) { require.Nil(t, err) } +func TestAuthJWTIssuer(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + httpServ := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwk, err2 := jwkset.NewJWKFromKey(key, jwkset.JWKOptions{ + Metadata: jwkset.JWKMetadataOptions{ + KID: "test-key-id", + }, + }) + require.NoError(t, err2) + + jwkSet := jwkset.NewMemoryStorage() + err2 = jwkSet.KeyWrite(context.Background(), jwk) + require.NoError(t, err2) + + response, err2 := jwkSet.JSONPublic(r.Context()) + if err2 != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + }), + } + + ln, err := net.Listen("tcp", "localhost:4568") + require.NoError(t, err) + + go httpServ.Serve(ln) + defer httpServ.Shutdown(context.Background()) + + signToken := func(issuer string) string { + type customClaims struct { + jwt.RegisteredClaims + MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"` + } + + claims := customClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: issuer, + }, + MediaMTXPermissions: []conf.AuthInternalUserPermission{{ + Action: conf.AuthActionPublish, + Path: "mypath", + }}, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header[jwkset.HeaderKID] = "test-key-id" + ss, err2 := token.SignedString(key) + require.NoError(t, err2) + return ss + } + + for _, ca := range []struct { + name string + jwtIssuer string + tokenIss string + expectErr bool + }{ + { + name: "matching", + jwtIssuer: "my-issuer", + tokenIss: "my-issuer", + expectErr: false, + }, + { + name: "mismatched", + jwtIssuer: "my-issuer", + tokenIss: "wrong-issuer", + expectErr: true, + }, + } { + t.Run(ca.name, func(t *testing.T) { + ss := signToken(ca.tokenIss) + + m := Manager{ + Method: conf.AuthMethodJWT, + JWTJWKS: "http://localhost:4568/jwks", + JWTClaimKey: "my_permission_key", + JWTIssuer: ca.jwtIssuer, + } + + err2 := m.Authenticate(&Request{ + Action: conf.AuthActionPublish, + Path: "mypath", + Protocol: ProtocolRTSP, + Credentials: &Credentials{ + Token: ss, + }, + IP: net.ParseIP("127.0.0.1"), + }) + + if ca.expectErr { + require.NotNil(t, err2) + } else { + require.Nil(t, err2) + } + }) + } +} + +func TestAuthJWTAudience(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 1024) + require.NoError(t, err) + + httpServ := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwk, err2 := jwkset.NewJWKFromKey(key, jwkset.JWKOptions{ + Metadata: jwkset.JWKMetadataOptions{ + KID: "test-key-id", + }, + }) + require.NoError(t, err2) + + jwkSet := jwkset.NewMemoryStorage() + err2 = jwkSet.KeyWrite(context.Background(), jwk) + require.NoError(t, err2) + + response, err2 := jwkSet.JSONPublic(r.Context()) + if err2 != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(response) + }), + } + + ln, err := net.Listen("tcp", "localhost:4569") + require.NoError(t, err) + + go httpServ.Serve(ln) + defer httpServ.Shutdown(context.Background()) + + signToken := func(audience jwt.ClaimStrings) string { + type customClaims struct { + jwt.RegisteredClaims + MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"` + } + + claims := customClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Audience: audience, + }, + MediaMTXPermissions: []conf.AuthInternalUserPermission{{ + Action: conf.AuthActionPublish, + Path: "mypath", + }}, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header[jwkset.HeaderKID] = "test-key-id" + ss, err2 := token.SignedString(key) + require.NoError(t, err2) + return ss + } + + for _, ca := range []struct { + name string + jwtAudience string + tokenAud jwt.ClaimStrings + expectErr bool + }{ + { + name: "matching", + jwtAudience: "my-audience", + tokenAud: jwt.ClaimStrings{"my-audience"}, + expectErr: false, + }, + { + name: "mismatched", + jwtAudience: "my-audience", + tokenAud: jwt.ClaimStrings{"wrong-audience"}, + expectErr: true, + }, + { + name: "present in list", + jwtAudience: "my-audience", + tokenAud: jwt.ClaimStrings{"other", "my-audience"}, + expectErr: false, + }, + } { + t.Run(ca.name, func(t *testing.T) { + ss := signToken(ca.tokenAud) + + m := Manager{ + Method: conf.AuthMethodJWT, + JWTJWKS: "http://localhost:4569/jwks", + JWTClaimKey: "my_permission_key", + JWTAudience: ca.jwtAudience, + } + + err2 := m.Authenticate(&Request{ + Action: conf.AuthActionPublish, + Path: "mypath", + Protocol: ProtocolRTSP, + Credentials: &Credentials{ + Token: ss, + }, + IP: net.ParseIP("127.0.0.1"), + }) + + if ca.expectErr { + require.NotNil(t, err2) + } else { + require.Nil(t, err2) + } + }) + } +} + func TestAuthJWTRefresh(t *testing.T) { // reference: // https://github.com/MicahParks/jwkset/blob/master/examples/http_server/main.go diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 37c4490f..98431525 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -269,6 +269,8 @@ type Conf struct { AuthJWTClaimKey string `json:"authJWTClaimKey"` AuthJWTExclude []AuthInternalUserPermission `json:"authJWTExclude"` AuthJWTInHTTPQuery bool `json:"authJWTInHTTPQuery"` + AuthJWTIssuer string `json:"authJWTIssuer"` + AuthJWTAudience string `json:"authJWTAudience"` // Control API API bool `json:"api"` diff --git a/internal/core/core.go b/internal/core/core.go index 59d3c5fd..58234642 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -344,6 +344,8 @@ func (p *Core) createResources(initial bool) error { JWTClaimKey: p.conf.AuthJWTClaimKey, JWTExclude: p.conf.AuthJWTExclude, JWTInHTTPQuery: p.conf.AuthJWTInHTTPQuery, + JWTIssuer: p.conf.AuthJWTIssuer, + JWTAudience: p.conf.AuthJWTAudience, ReadTimeout: time.Duration(p.conf.ReadTimeout), } } @@ -738,6 +740,8 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { newConf.AuthJWTClaimKey != p.conf.AuthJWTClaimKey || !reflect.DeepEqual(newConf.AuthJWTExclude, p.conf.AuthJWTExclude) || newConf.AuthJWTInHTTPQuery != p.conf.AuthJWTInHTTPQuery || + newConf.AuthJWTIssuer != p.conf.AuthJWTIssuer || + newConf.AuthJWTAudience != p.conf.AuthJWTAudience || newConf.ReadTimeout != p.conf.ReadTimeout if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) { p.authManager.ReloadInternalUsers(newConf.AuthInternalUsers) diff --git a/mediamtx.yml b/mediamtx.yml index 409a1834..95637078 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -152,6 +152,10 @@ authJWTExclude: [] # allow passing the JWT through query parameters of HTTP requests (i.e. ?jwt=JWT). # This is a security risk and will be disabled in the future. authJWTInHTTPQuery: true +# Expected issuer (iss) claim in the JWT. Leave empty to skip validation. +authJWTIssuer: +# Expected audience (aud) claim in the JWT. Leave empty to skip validation. +authJWTAudience: ############################################### # Global settings -> Control API