optionally validate JWT iss and aud claims (#5569)
This commit is contained in:
@@ -122,6 +122,10 @@ components:
|
||||
type: string
|
||||
authJWTClaimKey:
|
||||
type: string
|
||||
authJWTIssuer:
|
||||
type: string
|
||||
authJWTAudience:
|
||||
type: string
|
||||
authJWTExclude:
|
||||
type: array
|
||||
items:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user