From 4f859fb40b1e73d5482bdefb272a8822d12fc209 Mon Sep 17 00:00:00 2001 From: Alessandro Ros Date: Thu, 5 Feb 2026 16:51:29 +0100 Subject: [PATCH] add authHTTPFingerprint (#5413) (#5422) --- api/openapi.yaml | 2 + docs/2-usage/05-authentication.md | 31 +++++ internal/auth/manager.go | 18 ++- internal/auth/manager_test.go | 192 ++++++++++++++++++++++++++++++ internal/conf/conf.go | 1 + internal/core/core.go | 2 + mediamtx.yml | 6 + 7 files changed, 251 insertions(+), 1 deletion(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 19dd07e4..c547110b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -108,6 +108,8 @@ components: $ref: '#/components/schemas/AuthInternalUser' authHTTPAddress: type: string + authHTTPFingerprint: + type: string authHTTPExclude: type: array items: diff --git a/docs/2-usage/05-authentication.md b/docs/2-usage/05-authentication.md index e971c9fd..69447c2b 100644 --- a/docs/2-usage/05-authentication.md +++ b/docs/2-usage/05-authentication.md @@ -121,6 +121,21 @@ authHTTPExclude: - action: pprof ``` +If the authentication server uses HTTPS and has a self-signed or invalid TLS certificate, you can provide the fingerprint of the certificate to validate it anyway: + +```yml +authMethod: http +authHTTPAddress: https://myauthserver/auth +authHTTPFingerprint: 33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739 +``` + +The fingerprint can be obtained with: + +```sh +openssl s_client -connect myauthserver:443 /dev/null | sed -n '/BEGIN/,/END/p' > server.crt +openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':' +``` + ### External JWT provider Authentication can be delegated to an external identity server, that is capable of generating JWTs and provides a JWKS endpoint. With respect to the HTTP-based method, this has the advantage that the external server is contacted once, and not for every request, greatly improving performance. In order to use the JWT-based authentication method, set `authMethod` and `authJWTJWKS`: @@ -146,6 +161,22 @@ The JWT is expected to contain a claim, with a list of permissions in the same f } ``` +If the JWKS server uses TLS and has a self-signed or invalid TLS certificate, you can provide the fingerprint of the certificate to validate it anyway: + +```yml +authMethod: jwt +authJWTJWKS: https://my_identity_server/jwks_endpoint +authJWTJWKSFingerprint: 33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739 +authJWTClaimKey: mediamtx_permissions +``` + +The fingerprint can be obtained with: + +```sh +openssl s_client -connect my_identity_server:443 /dev/null | sed -n '/BEGIN/,/END/p' > server.crt +openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':' +``` + #### Keycloak setup Here's a tutorial on how to setup the [Keycloak identity server](https://www.keycloak.org/) in order to provide JWTs. diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 55643b8b..c554baf1 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -60,6 +60,7 @@ type Manager struct { Method conf.AuthMethod InternalUsers []conf.AuthInternalUser HTTPAddress string + HTTPFingerprint string HTTPExclude []conf.AuthInternalUserPermission JWTJWKS string JWTJWKSFingerprint string @@ -172,7 +173,22 @@ func (m *Manager) authenticateHTTP(req *Request) error { Query: req.Query, }) - res, err := http.Post(m.HTTPAddress, "application/json", bytes.NewReader(enc)) + u, err := url.Parse(m.HTTPAddress) + if err != nil { + return err + } + + tr := &http.Transport{ + TLSClientConfig: tls.MakeConfig(u.Hostname(), m.HTTPFingerprint), + } + defer tr.CloseIdleConnections() + + httpClient := &http.Client{ + Timeout: m.ReadTimeout, + Transport: tr, + } + + res, err := httpClient.Post(m.HTTPAddress, "application/json", bytes.NewReader(enc)) if err != nil { return fmt.Errorf("HTTP request failed: %w", err) } diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go index 72d24db0..c52bbed3 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/manager_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "crypto/tls" "encoding/json" "net" "net/http" @@ -16,6 +17,58 @@ import ( "github.com/stretchr/testify/require" ) +var testTLSCertPub = []byte(`-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUXw1hEC3LFpTsllv7D3ARJyEq7sIwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDEyMTMxNzQ0NThaFw0zMDEy +MTExNzQ0NThaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDG8DyyS51810GsGwgWr5rjJK7OE1kTTLSNEEKax8Bj +zOyiaz8rA2JGl2VUEpi2UjDr9Cm7nd+YIEVs91IIBOb7LGqObBh1kGF3u5aZxLkv +NJE+HrLVvUhaDobK2NU+Wibqc/EI3DfUkt1rSINvv9flwTFu1qHeuLWhoySzDKEp +OzYxpFhwjVSokZIjT4Red3OtFz7gl2E6OAWe2qoh5CwLYVdMWtKR0Xuw3BkDPk9I +qkQKx3fqv97LPEzhyZYjDT5WvGrgZ1WDAN3booxXF3oA1H3GHQc4m/vcLatOtb8e +nI59gMQLEbnp08cl873bAuNuM95EZieXTHNbwUnq5iybAgMBAAGjUzBRMB0GA1Ud +DgQWBBQBKhJh8eWu0a4au9X/2fKhkFX2vjAfBgNVHSMEGDAWgBQBKhJh8eWu0a4a +u9X/2fKhkFX2vjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBj +3aCW0YPKukYgVK9cwN0IbVy/D0C1UPT4nupJcy/E0iC7MXPZ9D/SZxYQoAkdptdO +xfI+RXkpQZLdODNx9uvV+cHyZHZyjtE5ENu/i5Rer2cWI/mSLZm5lUQyx+0KZ2Yu +tEI1bsebDK30msa8QSTn0WidW9XhFnl3gRi4wRdimcQapOWYVs7ih+nAlSvng7NI +XpAyRs8PIEbpDDBMWnldrX4TP6EWYUi49gCp8OUDRREKX3l6Ls1vZ02F34yHIt/7 +7IV/XSKG096bhW+icKBWV0IpcEsgTzPK1J1hMxgjhzIMxGboAeUU+kidthOob6Sd +XQxaORfgM//NzX9LhUPk +-----END CERTIFICATE----- +`) + +var testTLSCertKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAxvA8skudfNdBrBsIFq+a4ySuzhNZE0y0jRBCmsfAY8zsoms/ +KwNiRpdlVBKYtlIw6/Qpu53fmCBFbPdSCATm+yxqjmwYdZBhd7uWmcS5LzSRPh6y +1b1IWg6GytjVPlom6nPxCNw31JLda0iDb7/X5cExbtah3ri1oaMkswyhKTs2MaRY +cI1UqJGSI0+EXndzrRc+4JdhOjgFntqqIeQsC2FXTFrSkdF7sNwZAz5PSKpECsd3 +6r/eyzxM4cmWIw0+Vrxq4GdVgwDd26KMVxd6ANR9xh0HOJv73C2rTrW/HpyOfYDE +CxG56dPHJfO92wLjbjPeRGYnl0xzW8FJ6uYsmwIDAQABAoIBACi0BKcyQ3HElSJC +kaAao+Uvnzh4yvPg8Nwf5JDIp/uDdTMyIEWLtrLczRWrjGVZYbsVROinP5VfnPTT +kYwkfKINj2u+gC6lsNuPnRuvHXikF8eO/mYvCTur1zZvsQnF5kp4GGwIqr+qoPUP +bB0UMndG1PdpoMryHe+JcrvTrLHDmCeH10TqOwMsQMLHYLkowvxwJWsmTY7/Qr5S +Wm3PPpOcW2i0uyPVuyuv4yD1368fqnqJ8QFsQp1K6QtYsNnJ71Hut1/IoxK/e6hj +5Z+byKtHVtmcLnABuoOT7BhleJNFBksX9sh83jid4tMBgci+zXNeGmgqo2EmaWAb +agQslkECgYEA8B1rzjOHVQx/vwSzDa4XOrpoHQRfyElrGNz9JVBvnoC7AorezBXQ +M9WTHQIFTGMjzD8pb+YJGi3gj93VN51r0SmJRxBaBRh1ZZI9kFiFzngYev8POgD3 +ygmlS3kTHCNxCK/CJkB+/jMBgtPj5ygDpCWVcTSuWlQFphePkW7jaaECgYEA1Blz +ulqgAyJHZaqgcbcCsI2q6m527hVr9pjzNjIVmkwu38yS9RTCgdlbEVVDnS0hoifl ++jVMEGXjF3xjyMvL50BKbQUH+KAa+V4n1WGlnZOxX9TMny8MBjEuSX2+362vQ3BX +4vOlX00gvoc+sY+lrzvfx/OdPCHQGVYzoKCxhLsCgYA07HcviuIAV/HsO2/vyvhp +xF5gTu+BqNUHNOZDDDid+ge+Jre2yfQLCL8VPLXIQW3Jff53IH/PGl+NtjphuLvj +7UDJvgvpZZuymIojP6+2c3gJ3CASC9aR3JBnUzdoE1O9s2eaoMqc4scpe+SWtZYf +3vzSZ+cqF6zrD/Rf/M35IQKBgHTU4E6ShPm09CcoaeC5sp2WK8OevZw/6IyZi78a +r5Oiy18zzO97U/k6xVMy6F+38ILl/2Rn31JZDVJujniY6eSkIVsUHmPxrWoXV1HO +y++U32uuSFiXDcSLarfIsE992MEJLSAynbF1Rsgsr3gXbGiuToJRyxbIeVy7gwzD +94TpAoGAY4/PejWQj9psZfAhyk5dRGra++gYRQ/gK1IIc1g+Dd2/BxbT/RHr05GK +6vwrfjsoRyMWteC1SsNs/CurjfQ/jqCfHNP5XPvxgd5Ec8sRJIiV7V5RTuWJsPu1 ++3K6cnKEyg+0ekYmLertRFIY6SwWmY1fyKgTvxudMcsBY7dC4xs= +-----END RSA PRIVATE KEY----- +`) + func mustParseCIDR(v string) conf.IPNetwork { _, ne, err := net.ParseCIDR(v) if err != nil { @@ -309,6 +362,62 @@ func TestAuthHTTP(t *testing.T) { } } +func TestAuthHTTPFingerprint(t *testing.T) { + httpServ := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/auth", r.URL.Path) + + var in struct { + IP string `json:"ip"` + User string `json:"user"` + Password string `json:"password"` + Path string `json:"path"` + Protocol string `json:"protocol"` + ID string `json:"id"` + Action string `json:"action"` + Query string `json:"query"` + } + err := json.NewDecoder(r.Body).Decode(&in) + require.NoError(t, err) + + if in.User != "testuser" || in.Password != "testpass" { + w.WriteHeader(http.StatusBadRequest) + return + } + }), + } + + ln, err := net.Listen("tcp", "localhost:9121") + require.NoError(t, err) + + cert, err := tls.X509KeyPair(testTLSCertPub, testTLSCertKey) + require.NoError(t, err) + + httpServ.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + + go httpServ.ServeTLS(ln, "", "") + defer httpServ.Shutdown(context.Background()) + + m := Manager{ + Method: conf.AuthMethodHTTP, + HTTPAddress: "https://localhost:9121/auth", + HTTPFingerprint: "33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739", + } + + err2 := m.Authenticate(&Request{ + Action: conf.AuthActionPublish, + Path: "teststream", + Protocol: ProtocolRTSP, + Credentials: &Credentials{ + User: "testuser", + Pass: "testpass", + }, + IP: net.ParseIP("127.0.0.1"), + }) + require.Nil(t, err2) +} + func TestAuthHTTPExclude(t *testing.T) { m := Manager{ Method: conf.AuthMethodHTTP, @@ -584,3 +693,86 @@ func TestAuthJWTRefresh(t *testing.T) { m.RefreshJWTJWKS() } } + +func TestAuthJWTFingerprint(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) + + cert, err := tls.X509KeyPair(testTLSCertPub, testTLSCertKey) + require.NoError(t, err) + + httpServ.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + + go httpServ.ServeTLS(ln, "", "") + defer httpServ.Shutdown(context.Background()) + + 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: "test", + Subject: "somebody", + ID: "1", + }, + MediaMTXPermissions: []conf.AuthInternalUserPermission{{ + Action: conf.AuthActionPublish, + Path: "mypath", + }}, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header[jwkset.HeaderKID] = "test-key-id" + ss, err := token.SignedString(key) + require.NoError(t, err) + + m := Manager{ + Method: conf.AuthMethodJWT, + JWTJWKS: "https://localhost:4568/jwks", + JWTJWKSFingerprint: "33949e05fffb5ff3e8aa16f8213a6251b4d9363804ba53233c4da9a46d6f2739", + JWTClaimKey: "my_permission_key", + } + + err2 := m.Authenticate(&Request{ + Action: conf.AuthActionPublish, + Path: "mypath", + Protocol: ProtocolRTSP, + Credentials: &Credentials{ + Token: ss, + }, + IP: net.ParseIP("127.0.0.1"), + }) + require.Nil(t, err2) +} diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 31099e9d..2bd74aa5 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -262,6 +262,7 @@ type Conf struct { AuthInternalUsers []AuthInternalUser `json:"authInternalUsers"` AuthHTTPAddress string `json:"authHTTPAddress"` ExternalAuthenticationURL *string `json:"externalAuthenticationURL,omitempty"` // deprecated + AuthHTTPFingerprint string `json:"authHTTPFingerprint"` AuthHTTPExclude []AuthInternalUserPermission `json:"authHTTPExclude"` AuthJWTJWKS string `json:"authJWTJWKS"` AuthJWTJWKSFingerprint string `json:"authJWTJWKSFingerprint"` diff --git a/internal/core/core.go b/internal/core/core.go index fa24d883..f1b4de7a 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -337,6 +337,7 @@ func (p *Core) createResources(initial bool) error { Method: p.conf.AuthMethod, InternalUsers: p.conf.AuthInternalUsers, HTTPAddress: p.conf.AuthHTTPAddress, + HTTPFingerprint: p.conf.AuthHTTPFingerprint, HTTPExclude: p.conf.AuthHTTPExclude, JWTJWKS: p.conf.AuthJWTJWKS, JWTJWKSFingerprint: p.conf.AuthJWTJWKSFingerprint, @@ -719,6 +720,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { closeAuthManager := newConf == nil || newConf.AuthMethod != p.conf.AuthMethod || newConf.AuthHTTPAddress != p.conf.AuthHTTPAddress || + newConf.AuthHTTPFingerprint != p.conf.AuthHTTPFingerprint || !reflect.DeepEqual(newConf.AuthHTTPExclude, p.conf.AuthHTTPExclude) || newConf.AuthJWTJWKS != p.conf.AuthJWTJWKS || newConf.AuthJWTJWKSFingerprint != p.conf.AuthJWTJWKSFingerprint || diff --git a/mediamtx.yml b/mediamtx.yml index 4135804f..ad08426c 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -107,6 +107,12 @@ authInternalUsers: # If the response code is 20x, authentication is accepted, otherwise # it is discarded. authHTTPAddress: +# If the HTTP authentication URL has a self-signed or invalid certificate, +# you can provide the fingerprint of the certificate in order to +# validate it anyway. It can be obtained by running: +# openssl s_client -connect auth_http_domain:443 /dev/null | sed -n '/BEGIN/,/END/p' > server.crt +# openssl x509 -in server.crt -noout -fingerprint -sha256 | cut -d "=" -f2 | tr -d ':' +authHTTPFingerprint: # Actions to exclude from HTTP-based authentication. # Format is the same as the one of user permissions. authHTTPExclude: