optionally validate JWT iss and aud claims (#5569)

This commit is contained in:
Roman Sirokov
2026-03-13 22:38:40 +01:00
committed by GitHub
parent a6856e9e58
commit 9b36d50b8d
7 changed files with 259 additions and 1 deletions
+4
View File
@@ -122,6 +122,10 @@ components:
type: string
authJWTClaimKey:
type: string
authJWTIssuer:
type: string
authJWTAudience:
type: string
authJWTExclude:
type: array
items:
+12
View File
@@ -175,6 +175,18 @@ openssl s_client -connect my_identity_server:443 </dev/null 2>/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.
+11 -1
View File
@@ -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
}
+222
View File
@@ -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
+2
View File
@@ -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"`
+4
View File
@@ -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)
+4
View File
@@ -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