Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configure the JWT Claim Key for holding MediaMTX Permissions #3560

Closed
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,7 @@ If the URL returns a status code that begins with `20` (i.e. `200`), authenticat
```json
{
"user": "",
"password": "",
"password": ""
}
```

Expand All @@ -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
{
Expand Down Expand Up @@ -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`

Expand Down
2 changes: 2 additions & 0 deletions apidocs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ components:
$ref: '#/components/schemas/AuthInternalUserPermission'
authJWTJWKS:
type: string
authJWTClaimKey:
type: string

# Control API
api:
Expand Down
32 changes: 23 additions & 9 deletions internal/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,14 @@ 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
InternalUsers []conf.AuthInternalUser
HTTPAddress string
HTTPExclude []conf.AuthInternalUserPermission
JWTJWKS string
JWTClaimKey string
ReadTimeout time.Duration
RTSPAuthMethods []auth.ValidateMethod

Expand Down Expand Up @@ -255,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
}
Expand All @@ -269,13 +265,31 @@ 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], tokenKeyfunc)
if err != nil {
return err
}

tokenClaimsMap := token.Claims.(jwt.MapClaims)[m.JWTClaimKey]
if tokenClaimsMap == nil {
return fmt.Errorf("JWT is missing the claim at " + m.JWTClaimKey)
}

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(cc.MediaMTXPermissions, req) {
if !matchesPermission(MediaMTXPermissions, req) {
return fmt.Errorf("user doesn't have permission to perform action")
}

Expand Down
5 changes: 3 additions & 2 deletions internal/auth/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
9 changes: 9 additions & 0 deletions internal/conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net"
"os"
"reflect"
"regexp"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -323,6 +325,7 @@ func (conf *Conf) setDefaults() {
Action: AuthActionPprof,
},
}
conf.AuthJWTClaimKey = "mediamtx_permissions"

// Control API
conf.APIAddress = ":9997"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/conf/conf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,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))
Expand Down
2 changes: 2 additions & 0 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion mediamtx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# {
# ...
Expand All @@ -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
Expand Down
Loading