diff --git a/README.md b/README.md index 793e1ec7e20..aaa7671301e 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: ```json { diff --git a/apidocs/openapi.yaml b/apidocs/openapi.yaml index 94fd864a909..b37a75fb71c 100644 --- a/apidocs/openapi.yaml +++ b/apidocs/openapi.yaml @@ -87,6 +87,8 @@ components: $ref: '#/components/schemas/AuthInternalUserPermission' authJWTJWKS: type: string + authJWTClaimKey: + type: string # Control API api: diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 7a29bb638bd..a8da42a4ddf 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -99,7 +99,33 @@ func matchesPermission(perms []conf.AuthInternalUserPermission, req *Request) bo type customClaims struct { jwt.RegisteredClaims - MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"` + permissionsKey string + permissions []conf.AuthInternalUserPermission +} + +func (c *customClaims) UnmarshalJSON(b []byte) error { + err := json.Unmarshal(b, &c.RegisteredClaims) + if err != nil { + return err + } + + var claimMap map[string]json.RawMessage + err = json.Unmarshal(b, &claimMap) + if err != nil { + return err + } + + rawPermissions, ok := claimMap[c.permissionsKey] + if !ok { + return fmt.Errorf("claim '%s' not found inside JWT", c.permissionsKey) + } + + err = json.Unmarshal(rawPermissions, &c.permissions) + if err != nil { + return err + } + + return nil } // Manager is the authentication manager. @@ -109,6 +135,7 @@ type Manager struct { HTTPAddress string HTTPExclude []conf.AuthInternalUserPermission JWTJWKS string + JWTClaimKey string ReadTimeout time.Duration RTSPAuthMethods []auth.ValidateMethod @@ -270,12 +297,13 @@ func (m *Manager) authenticateJWT(req *Request) error { } var cc customClaims + cc.permissionsKey = m.JWTClaimKey _, err = jwt.ParseWithClaims(v["jwt"][0], &cc, keyfunc) if err != nil { return err } - if !matchesPermission(cc.MediaMTXPermissions, req) { + if !matchesPermission(cc.permissions, 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..abb604c7d38 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/manager_test.go @@ -327,7 +327,7 @@ func TestAuthJWT(t *testing.T) { type customClaims struct { jwt.RegisteredClaims - MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"` + MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"` } claims := customClaims{ @@ -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: "my_permission_key", } err = m.Authenticate(&Request{ diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 8516d2711d6..a7599419aac 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -177,6 +177,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 +324,7 @@ func (conf *Conf) setDefaults() { Action: AuthActionPprof, }, } + conf.AuthJWTClaimKey = "mediamtx_permissions" // Control API conf.APIAddress = ":9997" @@ -562,6 +564,9 @@ func (conf *Conf) Validate() error { if conf.AuthJWTJWKS == "" { return fmt.Errorf("'authJWTJWKS' is empty") } + if conf.AuthJWTClaimKey == "" { + return fmt.Errorf("'authJWTClaimKey' is empty") + } } // RTSP diff --git a/internal/conf/conf_test.go b/internal/conf/conf_test.go index 88982317d57..4bb3fe678c8 100644 --- a/internal/conf/conf_test.go +++ b/internal/conf/conf_test.go @@ -357,6 +357,13 @@ 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 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 ba713a9d08c..c3aed76fa85 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -121,6 +121,8 @@ authHTTPExclude: # This is the JWKS URL that will be used to pull (once) the public key that allows # to validate JWTs. authJWTJWKS: +# name of the claim that contains permissions. +authJWTClaimKey: mediamtx_permissions ############################################### # Global settings -> Control API