From 262ba38ab3876d96090db56cb99e4cca87c039a3 Mon Sep 17 00:00:00 2001 From: Gordy Date: Thu, 18 Jul 2024 14:27:22 +0100 Subject: [PATCH 1/4] Re-key JWT claim for mediamtx_permissions This change adds functionality to be able to choose the JWT clim key that is used to store the mediamtx_permissions. This is useful if you do not have unlimited access to your IdP and there is a policy that your JWT extension claims should be of a specific format - I am looking at you, Azure B2C. --- README.md | 7 ++++--- internal/auth/manager.go | 23 +++++++++++++++-------- internal/auth/manager_test.go | 5 +++-- internal/conf/conf.go | 9 +++++++++ internal/conf/conf_test.go | 14 ++++++++++++++ internal/core/core.go | 2 ++ mediamtx.yml | 5 ++++- 7 files changed, 51 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3e09e429893..d398552e59b 100644 --- a/README.md +++ b/README.md @@ -1147,7 +1147,7 @@ If the URL returns a status code that begins with `20` (i.e. `200`), authenticat ```json { "user": "", - "password": "", + "password": "" } ``` @@ -1171,9 +1171,10 @@ Authentication can be delegated to an external identity server, that is capable ```yml authMethod: jwt authJWTJWKS: http://my_identity_server/jwks_endpoint +authJWTClaimKey: mediamtx_permissions ``` -The JWT is expected to contain the `mediamtx_permissions` scope, with a list of permissions in the same format as the one of user permissions: +The JWT is expected to contain a claim, with a list of permissions in the same format as the one of user permissions. By default the key for this claim is `mediamtx_permissions`, however if required then it is possible to set this key to some other valid JSON string using `authJWTClaimKey`: ```json { @@ -1216,7 +1217,7 @@ Here's a tutorial on how to setup the [Keycloak identity server](https://www.key * Name: `mediamtx_permissions` * User Attribute: `mediamtx_permissions` - * Token Claim Name: `mediamtx_permissions` + * Token Claim Name: `mediamtx_permissions` (or key set in `authJWTClaimKey`) * Claim JSON Type: `JSON` * Multivalued: `On` diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 7a29bb638bd..b0c59787dd4 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -97,11 +97,6 @@ func matchesPermission(perms []conf.AuthInternalUserPermission, req *Request) bo return false } -type customClaims struct { - jwt.RegisteredClaims - MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"` -} - // Manager is the authentication manager. type Manager struct { Method conf.AuthMethod @@ -109,6 +104,7 @@ type Manager struct { HTTPAddress string HTTPExclude []conf.AuthInternalUserPermission JWTJWKS string + JWTClaimKey string ReadTimeout time.Duration RTSPAuthMethods []auth.ValidateMethod @@ -269,13 +265,24 @@ func (m *Manager) authenticateJWT(req *Request) error { return fmt.Errorf("JWT not provided") } - var cc customClaims - _, err = jwt.ParseWithClaims(v["jwt"][0], &cc, keyfunc) + token, err := jwt.Parse(v["jwt"][0], keyfunc) if err != nil { return err } + var MediaMTXPermissions []conf.AuthInternalUserPermission + claims := token.Claims.(jwt.MapClaims) + for _, permission := range claims[m.JWTClaimKey].([]interface{}) { + var MediaMTXPermission conf.AuthInternalUserPermission + MediaMTXPermission.Action = conf.AuthAction(permission.(map[string]interface{})["action"].(string)) + if permission.(map[string]interface{})["path"] != nil { + MediaMTXPermission.Path = permission.(map[string]interface{})["path"].(string) + } else { + MediaMTXPermission.Path = "" + } + MediaMTXPermissions = append(MediaMTXPermissions, MediaMTXPermission) + } - if !matchesPermission(cc.MediaMTXPermissions, req) { + if !matchesPermission(MediaMTXPermissions, req) { return fmt.Errorf("user doesn't have permission to perform action") } diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go index 9afcaeadfbe..594b1069155 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/manager_test.go @@ -351,8 +351,9 @@ func TestAuthJWT(t *testing.T) { require.NoError(t, err) m := Manager{ - Method: conf.AuthMethodJWT, - JWTJWKS: "http://localhost:4567/jwks", + Method: conf.AuthMethodJWT, + JWTJWKS: "http://localhost:4567/jwks", + JWTClaimKey: "mediamtx_permissions", } err = m.Authenticate(&Request{ diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 8516d2711d6..d89f8ca0d3f 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -9,6 +9,7 @@ import ( "net" "os" "reflect" + "regexp" "sort" "strings" "time" @@ -177,6 +178,7 @@ type Conf struct { ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated AuthHTTPExclude AuthInternalUserPermissions `json:"authHTTPExclude"` AuthJWTJWKS string `json:"authJWTJWKS"` + AuthJWTClaimKey string `json:"authJWTClaimKey"` // Control API API bool `json:"api"` @@ -323,6 +325,7 @@ func (conf *Conf) setDefaults() { Action: AuthActionPprof, }, } + conf.AuthJWTClaimKey = "mediamtx_permissions" // Control API conf.APIAddress = ":9997" @@ -562,6 +565,12 @@ func (conf *Conf) Validate() error { if conf.AuthJWTJWKS == "" { return fmt.Errorf("'authJWTJWKS' is empty") } + if conf.AuthJWTClaimKey == "" { + return fmt.Errorf("'authJWTClaimKey' is empty") + } + if conf.AuthJWTClaimKey != "" && !regexp.MustCompile(`^[a-zA-Z-_1-90]+$`).MatchString(conf.AuthJWTClaimKey) { + return fmt.Errorf("'authJWTClaimKey' must be a valid json key") + } } // RTSP diff --git a/internal/conf/conf_test.go b/internal/conf/conf_test.go index 0c26086cfa6..d6098085088 100644 --- a/internal/conf/conf_test.go +++ b/internal/conf/conf_test.go @@ -350,6 +350,20 @@ func TestConfErrors(t *testing.T) { `record path './recordings/%path/%Y-%m-%d_%H-%M-%S' is missing one of the` + ` mandatory elements for the playback server to work: %Y %m %d %H %M %S %f`, }, + { + "jwt claim key non alpha chars", + "authMethod: jwt\n" + + "authJWTJWKS: https://not-real.com\n" + + "authJWTClaimKey: non-alpha-numeric-chars-!£$%^&*()", + "'authJWTClaimKey' must be a valid json key", + }, + { + "jwt claim key empty", + "authMethod: jwt\n" + + "authJWTJWKS: https://not-real.com\n" + + "authJWTClaimKey: \"\"", + "'authJWTClaimKey' is empty", + }, } { t.Run(ca.name, func(t *testing.T) { tmpf, err := createTempFile([]byte(ca.conf)) diff --git a/internal/core/core.go b/internal/core/core.go index 2eeadcdb510..baf643ccfa9 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -287,6 +287,7 @@ func (p *Core) createResources(initial bool) error { HTTPAddress: p.conf.AuthHTTPAddress, HTTPExclude: p.conf.AuthHTTPExclude, JWTJWKS: p.conf.AuthJWTJWKS, + JWTClaimKey: p.conf.AuthJWTClaimKey, ReadTimeout: time.Duration(p.conf.ReadTimeout), RTSPAuthMethods: p.conf.RTSPAuthMethods, } @@ -674,6 +675,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress || !reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) || newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS || + newConf.AuthJWTClaimKey != p.conf.AuthJWTClaimKey || newConf.ReadTimeout != p.conf.ReadTimeout || !reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) { diff --git a/mediamtx.yml b/mediamtx.yml index f936a305d3e..fc1c43207c5 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -106,7 +106,9 @@ authHTTPExclude: # JWT-based authentication. # Users have to login through an external identity server and obtain a JWT. -# This JWT must contain the claim "mediamtx_permissions" with permissions, +# This JWT must contain a claim with permissions, the default claim key is +# "mediamtx_permissions", but can be reassigned if your IdP insists on some +# given pattern for claim keys. # for instance: # { # ... @@ -121,6 +123,7 @@ authHTTPExclude: # This is the JWKS URL that will be used to pull (once) the public key that allows # to validate JWTs. authJWTJWKS: +authJWTClaimKey: mediamtx_permissions ############################################### # Global settings -> Control API From ad104123dcfc7f1c3727239bd7235734cefb7e75 Mon Sep 17 00:00:00 2001 From: Gordy Date: Mon, 22 Jul 2024 09:53:18 +0100 Subject: [PATCH 2/4] Re-key JWT claim for mediamtx_permissions fixing a linting issue --- internal/auth/manager.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/auth/manager.go b/internal/auth/manager.go index b0c59787dd4..17559f89c27 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -269,9 +269,11 @@ func (m *Manager) authenticateJWT(req *Request) error { if err != nil { return err } - var MediaMTXPermissions []conf.AuthInternalUserPermission - claims := token.Claims.(jwt.MapClaims) - for _, permission := range claims[m.JWTClaimKey].([]interface{}) { + + claims := token.Claims.(jwt.MapClaims)[m.JWTClaimKey].([]interface{}) + MediaMTXPermissions := make([]conf.AuthInternalUserPermission, 0, len(claims)) + + for _, permission := range claims { var MediaMTXPermission conf.AuthInternalUserPermission MediaMTXPermission.Action = conf.AuthAction(permission.(map[string]interface{})["action"].(string)) if permission.(map[string]interface{})["path"] != nil { From 605c0fb936909aa2071f60ff48ecdce281295243 Mon Sep 17 00:00:00 2001 From: Gordy Date: Tue, 6 Aug 2024 11:10:19 +0100 Subject: [PATCH 3/4] Re-key JWT claim for mediamtx_permissions updating to catch panics --- internal/auth/manager.go | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 17559f89c27..97603faec99 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -251,7 +251,7 @@ func (m *Manager) authenticateHTTP(req *Request) error { } func (m *Manager) authenticateJWT(req *Request) error { - keyfunc, err := m.pullJWTJWKS() + tokenKeyfunc, err := m.pullJWTJWKS() if err != nil { return err } @@ -265,23 +265,28 @@ func (m *Manager) authenticateJWT(req *Request) error { return fmt.Errorf("JWT not provided") } - token, err := jwt.Parse(v["jwt"][0], keyfunc) + token, err := jwt.Parse(v["jwt"][0], tokenKeyfunc) if err != nil { return err } - claims := token.Claims.(jwt.MapClaims)[m.JWTClaimKey].([]interface{}) - MediaMTXPermissions := make([]conf.AuthInternalUserPermission, 0, len(claims)) + tokenClaimsMap := token.Claims.(jwt.MapClaims)[m.JWTClaimKey] + if tokenClaimsMap == nil { + return fmt.Errorf("JWT is missing the claim at " + m.JWTClaimKey) + } - for _, permission := range claims { - var MediaMTXPermission conf.AuthInternalUserPermission - MediaMTXPermission.Action = conf.AuthAction(permission.(map[string]interface{})["action"].(string)) - if permission.(map[string]interface{})["path"] != nil { - MediaMTXPermission.Path = permission.(map[string]interface{})["path"].(string) - } else { - MediaMTXPermission.Path = "" - } - MediaMTXPermissions = append(MediaMTXPermissions, MediaMTXPermission) + tokenClaimsJSON, err := json.Marshal(tokenClaimsMap) + if err != nil { + return err + } + if len(tokenClaimsJSON) == 0 { + return fmt.Errorf("JWT claim at " + m.JWTClaimKey + " is empty") + } + + MediaMTXPermissions := make([]conf.AuthInternalUserPermission, 0, len(tokenClaimsJSON)) + err = json.Unmarshal(tokenClaimsJSON, &MediaMTXPermissions) + if err != nil { + return err } if !matchesPermission(MediaMTXPermissions, req) { From 964a1f4efbd28a83051b4af0f6bd965c1dd89113 Mon Sep 17 00:00:00 2001 From: Gordy Date: Tue, 6 Aug 2024 12:41:07 +0100 Subject: [PATCH 4/4] Re-key JWT claim for mediamtx_permissions extending and correcting the openapi schema --- apidocs/openapi.yaml | 57 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/apidocs/openapi.yaml b/apidocs/openapi.yaml index 93802e49ca6..82f37551129 100644 --- a/apidocs/openapi.yaml +++ b/apidocs/openapi.yaml @@ -41,8 +41,6 @@ components: type: integer udpMaxPayloadSize: type: integer - externalAuthenticationURL: - type: string runOnConnect: type: string runOnConnectRestart: @@ -50,6 +48,61 @@ components: runOnDisconnect: type: string + # Authentication + authMethod: + type: string + enum: + - "internal" + - "http" + - "jwt" + authInternalUsers: + type: object + properties: + user: + type: string + pass: + type: string + ips: + type: array + items: + type: string + permissions: + type: array + items: + type: object + properties: + action: + type: string + enum: + - "read" + - "publish" + - "playback" + - "api" + - "metrics" + - "pprof" + path: + type: string + authHTTPAddress: + type: string + authHTTPExclude: + type: array + items: + type: object + properties: + action: + type: string + enum: + - "read" + - "publish" + - "playback" + - "api" + - "metrics" + - "pprof" + authJWTJWKS: + type: string + authJWTClaimKey: + type: string + # Control API api: type: boolean