diff --git a/docs/2-features/06-authentication.md b/docs/2-features/06-authentication.md index b9a40cc7..b4baf721 100644 --- a/docs/2-features/06-authentication.md +++ b/docs/2-features/06-authentication.md @@ -247,7 +247,7 @@ Here's a tutorial on how to setup the [Keycloak identity server](https://www.key http://localhost:8080/realms/mediamtx/protocol/openid-connect/token ``` - The JWT is inside the `access_token` key of the response: + This results in: ```json { @@ -262,6 +262,8 @@ Here's a tutorial on how to setup the [Keycloak identity server](https://www.key } ``` + The JWT is inside the `access_token` key. + ## Providing username and password ### RTSP @@ -285,7 +287,7 @@ rtmp://localhost/mystream?user=myuser&pass=mypass Append username and password to `streamid`: ``` -srt://localhost:8890?streamid=publish:mystream:user:pass&pkt_size=1316 +srt://localhost:8890?streamid=publish:mystream:myuser:mypass&pkt_size=1316 ``` ### HLS and WebRTC @@ -293,7 +295,7 @@ srt://localhost:8890?streamid=publish:mystream:user:pass&pkt_size=1316 Username and password can be passed through the `Authorization: Basic` HTTP header: ``` -Authorization: Basic base64(user:pass) +Authorization: Basic base64(myuser:mypass) ``` When using a web browser, a dialog is first shown to users, asking for credentials, and then the header is automatically inserted into every request. If you need to automatically fill credentials from a parent web page, read [Embed streams in a website](17-embed-streams-in-a-website.md). @@ -301,7 +303,7 @@ When using a web browser, a dialog is first shown to users, asking for credentia If the `Authorization: Basic` header cannot be used (for instance, in software like OBS Studio, which only allows to provide a "Bearer Token"), credentials can be passed through the `Authorization: Bearer` header (i.e. the "Bearer Token" in OBS), where the value is the concatenation of username and password, separated by a colon: ``` -Authorization: Bearer username:password +Authorization: Bearer myuser:mypass ``` ## Providing tokens / JWTs @@ -311,7 +313,7 @@ Authorization: Bearer username:password Pass the token as a query parameter: ``` -rtsp://localhost:8554/mystream?jwt=jwt +rtsp://localhost:8554/mystream?token=mytoken ``` WARNING: FFmpeg implementation of RTSP does not support URLs that are longer than 4096 characters (this is the [MAX_URL_SIZE constant](https://github.com/FFmpeg/FFmpeg/blob/f951aa9ef382d6bb517e05d04d52710f751de427/libavformat/internal.h#L30)), therefore you have to configure your identity server in order to produce JWTs that are shorter than this threshold. @@ -321,7 +323,7 @@ WARNING: FFmpeg implementation of RTSP does not support URLs that are longer tha Pass the token as a query parameter: ``` -rtmp://localhost/mystream?jwt=jwt +rtmp://localhost/mystream?token=mytoken ``` WARNING: FFmpeg implementation of RTMP does not support URLs that are longer than 1024 characters (this is the [TCURL_MAX_LENGTH constant](https://github.com/FFmpeg/FFmpeg/blob/f951aa9ef382d6bb517e05d04d52710f751de427/libavformat/rtmpproto.c#L55)), therefore you have to configure your identity server in order to produce JWTs that are shorter than this threshold. @@ -331,7 +333,7 @@ WARNING: FFmpeg implementation of RTMP does not support URLs that are longer tha Pass the token as password, with an arbitrary user: ``` -srt://localhost:8890?streamid=publish:mystream:user:jwt&pkt_size=1316 +srt://localhost:8890?streamid=publish:mystream:user:mytoken&pkt_size=1316 ``` WARNING: SRT does not support Stream IDs that are longer than 512 characters, therefore you have to configure your identity server in order to produce JWTs that are shorter than this threshold. @@ -341,7 +343,7 @@ WARNING: SRT does not support Stream IDs that are longer than 512 characters, th The token can be passed through the `Authorization: Bearer` header: ``` -Authorization: Bearer MY_JWT +Authorization: Bearer mytoken ``` In OBS Studio, this is the "Bearer Token" field. diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 6ac25642..f2c2bdac 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -63,6 +63,32 @@ func matchesPermission(perms []conf.AuthInternalUserPermission, req *Request) bo return false } +func getToken(jwtInHTTPQuery bool, req *Request) (string, bool) { + switch { + case req.Credentials.Token != "": + return req.Credentials.Token, true + + case req.Credentials.Pass != "": + return req.Credentials.Pass, true + + // always allow passing tokens through query parameters with RTSP and RTMP since there's no alternative. + case req.Protocol == ProtocolRTSP || req.Protocol == ProtocolRTMP || (jwtInHTTPQuery && isHTTP(req)): + v, err := url.ParseQuery(req.Query) + if err == nil { + if len(v["token"]) == 1 { + return v["token"][0], true + } + + // legacy query key + if len(v["jwt"]) == 1 { + return v["jwt"][0], true + } + } + } + + return "", false +} + // Manager is the authentication manager. type Manager struct { Method conf.AuthMethod @@ -222,30 +248,8 @@ func (m *Manager) authenticateJWT(req *Request) (string, error) { return "", err } - var encodedJWT string - - switch { - case req.Credentials.Token != "": - encodedJWT = req.Credentials.Token - - case req.Credentials.Pass != "": - encodedJWT = req.Credentials.Pass - - // always allow passing JWT through query parameters with RTSP and RTMP since there's no alternative. - case req.Protocol == ProtocolRTSP || req.Protocol == ProtocolRTMP || (isHTTP(req) && m.JWTInHTTPQuery): - var v url.Values - v, err = url.ParseQuery(req.Query) - if err != nil { - return "", err - } - - if len(v["jwt"]) != 1 || len(v["jwt"][0]) == 0 { - return "", fmt.Errorf("JWT not provided") - } - - encodedJWT = v["jwt"][0] - - default: + token, ok := getToken(m.JWTInHTTPQuery, req) + if !ok { return "", fmt.Errorf("JWT not provided") } @@ -259,7 +263,7 @@ func (m *Manager) authenticateJWT(req *Request) (string, error) { var cc jwtClaims cc.permissionsKey = m.JWTClaimKey - _, err = jwt.ParseWithClaims(encodedJWT, &cc, keyfunc, opts...) + _, err = jwt.ParseWithClaims(token, &cc, keyfunc, opts...) if err != nil { return "", err } diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go index 0816b885..2e260aa7 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/manager_test.go @@ -595,6 +595,89 @@ func TestAuthJWT(t *testing.T) { } } +func TestAuthJWTQueryParameter(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:4570") + require.NoError(t, err) + + go httpServ.Serve(ln) + defer httpServ.Shutdown(context.Background()) + + for _, ca := range []string{"token", "jwt"} { + t.Run(ca, func(t *testing.T) { + type customClaims struct { + jwt.RegisteredClaims + MediaMTXPermissions []conf.AuthInternalUserPermission `json:"mediamtx_permissions"` + } + + 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, err2 := token.SignedString(key) + require.NoError(t, err2) + + m := Manager{ + Method: conf.AuthMethodJWT, + JWTJWKS: "http://localhost:4570/jwks", + JWTClaimKey: "mediamtx_permissions", + } + + user, err2 := m.Authenticate(&Request{ + Action: conf.AuthActionPublish, + Path: "mypath", + Query: ca + "=" + ss, + Protocol: ProtocolRTSP, + Credentials: &Credentials{ + Token: "", + }, + IP: net.ParseIP("127.0.0.1"), + }) + require.Nil(t, err2) + require.Equal(t, "somebody", user) + }) + } +} + func TestAuthJWTExclude(t *testing.T) { m := Manager{ Method: conf.AuthMethodJWT, diff --git a/mediamtx.yml b/mediamtx.yml index bc98a2a2..9a164ba0 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -149,7 +149,7 @@ authJWTClaimKey: mediamtx_permissions # Actions to exclude from JWT-based authentication. # Format is the same as the one of user permissions. authJWTExclude: [] -# Allow passing the JWT through query parameters of HTTP requests (i.e. ?jwt=JWT). +# Allow passing the JWT through query parameters of HTTP requests (i.e. ?token=JWT). # This is a security risk and will be disabled in the future. # RTSP and RTMP always allow JWT in query even if disabled, since there is no alternative. authJWTInHTTPQuery: true