allow to set the JWT claim key that contains permissions (#3560) (#3692)

This commit is contained in:
Alessandro Ros 2024-08-26 12:43:28 +02:00 committed by GitHub
parent 6da35c8041
commit 0d1da6bd5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 55 additions and 7 deletions

View File

@ -1147,7 +1147,7 @@ If the URL returns a status code that begins with `20` (i.e. `200`), authenticat
```json ```json
{ {
"user": "", "user": "",
"password": "", "password": ""
} }
``` ```
@ -1171,9 +1171,10 @@ Authentication can be delegated to an external identity server, that is capable
```yml ```yml
authMethod: jwt authMethod: jwt
authJWTJWKS: http://my_identity_server/jwks_endpoint 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 ```json
{ {

View File

@ -87,6 +87,8 @@ components:
$ref: '#/components/schemas/AuthInternalUserPermission' $ref: '#/components/schemas/AuthInternalUserPermission'
authJWTJWKS: authJWTJWKS:
type: string type: string
authJWTClaimKey:
type: string
# Control API # Control API
api: api:

View File

@ -99,7 +99,33 @@ func matchesPermission(perms []conf.AuthInternalUserPermission, req *Request) bo
type customClaims struct { type customClaims struct {
jwt.RegisteredClaims 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. // Manager is the authentication manager.
@ -109,6 +135,7 @@ type Manager struct {
HTTPAddress string HTTPAddress string
HTTPExclude []conf.AuthInternalUserPermission HTTPExclude []conf.AuthInternalUserPermission
JWTJWKS string JWTJWKS string
JWTClaimKey string
ReadTimeout time.Duration ReadTimeout time.Duration
RTSPAuthMethods []auth.ValidateMethod RTSPAuthMethods []auth.ValidateMethod
@ -270,12 +297,13 @@ func (m *Manager) authenticateJWT(req *Request) error {
} }
var cc customClaims var cc customClaims
cc.permissionsKey = m.JWTClaimKey
_, err = jwt.ParseWithClaims(v["jwt"][0], &cc, keyfunc) _, err = jwt.ParseWithClaims(v["jwt"][0], &cc, keyfunc)
if err != nil { if err != nil {
return err return err
} }
if !matchesPermission(cc.MediaMTXPermissions, req) { if !matchesPermission(cc.permissions, req) {
return fmt.Errorf("user doesn't have permission to perform action") return fmt.Errorf("user doesn't have permission to perform action")
} }

View File

@ -327,7 +327,7 @@ func TestAuthJWT(t *testing.T) {
type customClaims struct { type customClaims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"` MediaMTXPermissions []conf.AuthInternalUserPermission `json:"my_permission_key"`
} }
claims := customClaims{ claims := customClaims{
@ -351,8 +351,9 @@ func TestAuthJWT(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
m := Manager{ m := Manager{
Method: conf.AuthMethodJWT, Method: conf.AuthMethodJWT,
JWTJWKS: "http://localhost:4567/jwks", JWTJWKS: "http://localhost:4567/jwks",
JWTClaimKey: "my_permission_key",
} }
err = m.Authenticate(&Request{ err = m.Authenticate(&Request{

View File

@ -177,6 +177,7 @@ type Conf struct {
ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated
AuthHTTPExclude AuthInternalUserPermissions `json:"authHTTPExclude"` AuthHTTPExclude AuthInternalUserPermissions `json:"authHTTPExclude"`
AuthJWTJWKS string `json:"authJWTJWKS"` AuthJWTJWKS string `json:"authJWTJWKS"`
AuthJWTClaimKey string `json:"authJWTClaimKey"`
// Control API // Control API
API bool `json:"api"` API bool `json:"api"`
@ -323,6 +324,7 @@ func (conf *Conf) setDefaults() {
Action: AuthActionPprof, Action: AuthActionPprof,
}, },
} }
conf.AuthJWTClaimKey = "mediamtx_permissions"
// Control API // Control API
conf.APIAddress = ":9997" conf.APIAddress = ":9997"
@ -562,6 +564,9 @@ func (conf *Conf) Validate() error {
if conf.AuthJWTJWKS == "" { if conf.AuthJWTJWKS == "" {
return fmt.Errorf("'authJWTJWKS' is empty") return fmt.Errorf("'authJWTJWKS' is empty")
} }
if conf.AuthJWTClaimKey == "" {
return fmt.Errorf("'authJWTClaimKey' is empty")
}
} }
// RTSP // RTSP

View File

@ -357,6 +357,13 @@ func TestConfErrors(t *testing.T) {
`record path './recordings/%path/%Y-%m-%d_%H-%M-%S' is missing one of the` + `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`, ` 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) { t.Run(ca.name, func(t *testing.T) {
tmpf, err := createTempFile([]byte(ca.conf)) tmpf, err := createTempFile([]byte(ca.conf))

View File

@ -287,6 +287,7 @@ func (p *Core) createResources(initial bool) error {
HTTPAddress: p.conf.AuthHTTPAddress, HTTPAddress: p.conf.AuthHTTPAddress,
HTTPExclude: p.conf.AuthHTTPExclude, HTTPExclude: p.conf.AuthHTTPExclude,
JWTJWKS: p.conf.AuthJWTJWKS, JWTJWKS: p.conf.AuthJWTJWKS,
JWTClaimKey: p.conf.AuthJWTClaimKey,
ReadTimeout: time.Duration(p.conf.ReadTimeout), ReadTimeout: time.Duration(p.conf.ReadTimeout),
RTSPAuthMethods: p.conf.RTSPAuthMethods, RTSPAuthMethods: p.conf.RTSPAuthMethods,
} }
@ -674,6 +675,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) {
newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress || newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress ||
!reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) || !reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) ||
newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS || newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS ||
newConf.AuthJWTClaimKey != p.conf.AuthJWTClaimKey ||
newConf.ReadTimeout != p.conf.ReadTimeout || newConf.ReadTimeout != p.conf.ReadTimeout ||
!reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods) !reflect.DeepEqual(newConf.RTSPAuthMethods, p.conf.RTSPAuthMethods)
if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) { if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) {

View File

@ -121,6 +121,8 @@ authHTTPExclude:
# This is the JWKS URL that will be used to pull (once) the public key that allows # This is the JWKS URL that will be used to pull (once) the public key that allows
# to validate JWTs. # to validate JWTs.
authJWTJWKS: authJWTJWKS:
# name of the claim that contains permissions.
authJWTClaimKey: mediamtx_permissions
############################################### ###############################################
# Global settings -> Control API # Global settings -> Control API