package jwtverify import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/base64" "encoding/binary" "encoding/json" "errors" "net/http" "net/http/httptest" "reflect" "testing" "time" "github.com/centrifugal/centrifugo/v6/internal/config" "github.com/centrifugal/centrifuge" "github.com/cristalhq/jwt/v5" "github.com/stretchr/testify/require" ) // Use https://jwt.io to look at token contents. // noinspection ALL const ( jwtValid = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn19.m-TaS80RxkAiP9jH_s_h2NrKS_TDuPxJ8-z6gI7UewI" jwtExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn0sImV4cCI6MTU4ODM1MTcwNH0.LTc0p5YlrwJcxXPETrjhm9qyYUBKCR5fSROmfCE4TD8" jwtNotBefore = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn0sImV4cCI6MzE3NjgyMDU3MCwibmJmIjozMTc2ODIwNTYwfQ.gfsQeznFw6g44OEnCTSBW7AkmLy92GBfXL_Bdvzs7vc" jwtInvalidSignature = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn0sImV4cCI6MTU4ODQxOTY5MywibmJmIjoxNTg4NDE4NjkzfQ.05Xj9adbLukdhSJFyiVUEgbxCHTajXuotmalFgYviCo" jwtArrayAud = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn0sImF1ZCI6WyJmb28iLCJiYXIiXX0.iY4pCPEQwstfNmPkLr7r7DrLZDo42q3E9jMc-TefI6g" jwtStringAud = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn0sImF1ZCI6ImZvbyJ9.jym6CG5haHME3ZQbb9jlnV1E0hSwwEjZycBZSygRzO0" jwtValidCustomUserClaim = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoidGVzdCJ9.Mdh4PGRnqKD-8_cKCJOYKfi9KNLJz2PCKl3qEi0n0-w" subJWTValidCustomUserClaim = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoidGVzdCIsImNoYW5uZWwiOiJjaGFubmVsIn0.vMA6Ee2eq3d8ApAhbXmVv5LmArbrjFZgU2FUbK93EnQ" emptyObjectClaimsJWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.e30.DMCAvRgzrcf5w0Z879BsqzcrnDFKBY_GN6c3qKOUFtQ" // // Generated with: https://github.com/lestrrat-go/jwx/tree/main/cmd/jwx // // ``` // jwx jwk generate --type RSA --keysize 2048 --template '{"kid":"testrsa"}' > rsa.jwk // jwx jwk generate --type EC --curve P-384 --template '{"kid":"testec"}' > ec.jwk // jwx jwk generate --type OKP --curve Ed25519 --template '{"kid":"tested"}' > okp.jwk // jwx jwk generate --type EC --curve P-384 --template '{"kid":"fakeid"}' > fakeid.jwk // printf '{"sub":"2694","info":{"first_name":"Alexander","last_name":"Emelin"}}' > payload.txt // ``` // JWK Set with RSA, EC and OKP keys: // ``` // jwx jwk fmt --public-key rsa.jwk // jwx jwk fmt --public-key ec.jwk // jwx jwk fmt --public-key okp.jwk // ``` jwksSet = `{ "keys": [ { "e": "AQAB", "kid": "testrsa", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "4Wp1fHDDOGN7rH357ofNfK26LDOA36ZtQ0H2x0uo12VsAxbGOfl67gES28ClWon9dSwGLR-urfAmX7DcCgffLMTgwCwvsPYCKsVIWMPvlGEPyAG90d55GVqJGpAYirfIVyjKkzJKIjqdmPx12XnjnrhWdTLl09Ja4E6SF5m1Ff4mkfavigrnuh_SaB1QKkMKj--ie0rH3VV9MAiQTnYkVuNPEEkz9h2SCyOMUmYLJMLIHIpWBZ4fI-XlCmFx_kGUgiU85m9lSoKFSl7zmvYvy-uxCteO_28COu-wLnhcN4uumnQKN13ESPXLtR7_fkP-Z-xlXoKMdZQfuWY6zc6AsQ" }, { "crv": "P-384", "kid": "testec", "kty": "EC", "use": "sig", "alg": "ES384", "x": "W0A0VvKCnxs0trdgchvdkrEVfdjDYOeTdu_f0l3GE94LXBvVF_2O1Ng7vKZPE3cu", "y": "thDvOCDapgLR4krw5KKzp9HrkzTVgVwmwP37aTSc20EXw3R2fZ7tSh1ws3V7NV5n" }, { "crv": "Ed25519", "d": "hgIMcVff-mdWy5xYFBqrkleEGVSiQu81GQwNxGxhj9k", "kid": "tested", "kty": "OKP", "use": "sig", "alg" : "EdDSA", "x": "KBbdGhSAMLXMh6zLMfGi4_4-npVhnEVnGVYdjSrOreI" } ] }` jwksSetNoAlg = `{ "keys": [ { "e": "AQAB", "kid": "testrsa", "kty": "RSA", "use": "sig", "n": "4Wp1fHDDOGN7rH357ofNfK26LDOA36ZtQ0H2x0uo12VsAxbGOfl67gES28ClWon9dSwGLR-urfAmX7DcCgffLMTgwCwvsPYCKsVIWMPvlGEPyAG90d55GVqJGpAYirfIVyjKkzJKIjqdmPx12XnjnrhWdTLl09Ja4E6SF5m1Ff4mkfavigrnuh_SaB1QKkMKj--ie0rH3VV9MAiQTnYkVuNPEEkz9h2SCyOMUmYLJMLIHIpWBZ4fI-XlCmFx_kGUgiU85m9lSoKFSl7zmvYvy-uxCteO_28COu-wLnhcN4uumnQKN13ESPXLtR7_fkP-Z-xlXoKMdZQfuWY6zc6AsQ" }, { "crv": "P-384", "kid": "testec", "kty": "EC", "use": "sig", "x": "W0A0VvKCnxs0trdgchvdkrEVfdjDYOeTdu_f0l3GE94LXBvVF_2O1Ng7vKZPE3cu", "y": "thDvOCDapgLR4krw5KKzp9HrkzTVgVwmwP37aTSc20EXw3R2fZ7tSh1ws3V7NV5n" }, { "crv": "Ed25519", "d": "hgIMcVff-mdWy5xYFBqrkleEGVSiQu81GQwNxGxhj9k", "kid": "tested", "kty": "OKP", "use": "sig", "x": "KBbdGhSAMLXMh6zLMfGi4_4-npVhnEVnGVYdjSrOreI" } ] }` // JWT using `testrsa` key ID // ``` // jwx jws sign --key rsa.jwk --alg RS256 payload.txt // ``` jwtRSAWithKID = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Ryc2EifQ.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn19.Kq6upfpD3WdTpupDH0PL9lgjAr5OUO0RMB_w6F6M67qWH36noeXXZ1G-_BlOow-a9Lwrdtiy5-WVXC3-wbADaNsOoMSLWWGH-9lxqQ5Df9cJoUu4Ocvm9S0vSB9G2e0yRqwKXL-UC_M1BZonHIwBPs5lCmU_1LynkGJ0uZSvVI8Ke5VQwLFeBsxjNpCbkVc0mAlES4uoD49nh0rTqXmyNoPcriFtxEyUIvdVHe8bKkEBC4yEhg7IevApD8BjY4M6btcOlZYmXxdVSHnNsShQS1WhVhTyqxYDj4ad7rNOZYz4_PE0hEKqSyNxNuJpYimc7aBImCDTgcewJh43Vygcdw" // JWT using `testec` key ID // ``` // jwx jws sign --key ec.jwk --alg ES384 payload.txt // ``` jwtECWithKID = "eyJhbGciOiJFUzM4NCIsImtpZCI6InRlc3RlYyJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn19.MFqUDsu8JjS99oROB_93JEwuGlWOnrmMK455UIjWDlSS62uwED7hE04ZcWyQClxnH88Z-oiU_qGnpTyi7zojMN9r3zBxXqdq-wyTqUH6P9NA5PIlXhh6pugLpGKwyfQG" // JWT using `tested` key ID // ``` // jwx jws sign --key okp.jwk --alg EdDSA payload.txt // ``` jwtOKPWithKID = "eyJhbGciOiJFZERTQSIsImtpZCI6InRlc3RlZCJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn19.upmjjFmTOk9A-gtkA8fPqfj0YAsETvXYotLNFrf86AyHBpQNRMw2ScKbRx6BHnG0klCbJ0C2dfJt501zx5WhCA" // JWT using `fakekid` key ID // ``` // jwx jws sign --key ec.jwk --alg ES384 payload.txt // ``` jwtECWithKIDMiss = "eyJhbGciOiJFUzM4NCIsImtpZCI6ImZha2VpZCJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn19.bmsj2WNZK1wnbzXs8pKRjvljHIgQK9g4N7_M2-tD5PAXpHqiP7ttqsyflIOOAbUVdIix4JOL_Dtv4GQsEof87AOsi--fPFaXkVXFQzx5MggshXn02FbwHQ0Mj6LOw1qC" // JWT using `testec` key ID but wrong alg // ``` // jwx jws sign --key ec.jwk --alg ES256 payload.txt // ``` jwtECWithKIDWrongAlg = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3RlYyJ9.eyJzdWIiOiIyNjk0IiwiaW5mbyI6eyJmaXJzdF9uYW1lIjoiQWxleGFuZGVyIiwibGFzdF9uYW1lIjoiRW1lbGluIn19.P7vuso8O-x7yzobSlfbyA7JiscnViLl303-5kAO7_6CZ3jWAOaZqllOrZfCTJgCoZlX5lNGgW6naJ3zKuOkO434osi6VeN1oaUl0-IHysmlP7Za5QNXzfUoKN6aCuJx7" ) const nullByte = 0x0 func encodeToString(src []byte) string { return base64.RawURLEncoding.EncodeToString(src) } func encodeUint64ToString(v uint64) string { data := make([]byte, 8) binary.BigEndian.PutUint64(data, v) i := 0 for ; i < len(data); i++ { if data[i] != nullByte { break } } return encodeToString(data[i:]) } func generateTestRSAKeys(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) { reader := rand.Reader bitSize := 2048 key, err := rsa.GenerateKey(reader, bitSize) require.NoError(t, err) return key, &key.PublicKey } func generateTestECDSAKeys(t *testing.T) (*ecdsa.PrivateKey, *ecdsa.PublicKey) { reader := rand.Reader key, err := ecdsa.GenerateKey(elliptic.P256(), reader) require.NoError(t, err) return key, &key.PublicKey } func getRSATokenBuilder(rsaPrivateKey *rsa.PrivateKey, opts ...jwt.BuilderOption) *jwt.Builder { var signer jwt.Signer if rsaPrivateKey != nil { signer, _ = jwt.NewSignerRS(jwt.RS256, rsaPrivateKey) } else { // For HS we do everything in tests with key `secret`. key := []byte(`secret`) signer, _ = jwt.NewSignerHS(jwt.HS256, key) } return jwt.NewBuilder(signer, opts...) } func getECDSATokenBuilder(ecdsaPrivateKey *ecdsa.PrivateKey, opts ...jwt.BuilderOption) *jwt.Builder { var signer jwt.Signer if ecdsaPrivateKey != nil { signer, _ = jwt.NewSignerES(jwt.ES256, ecdsaPrivateKey) } else { // For HS we do everything in tests with key `secret`. key := []byte(`secret`) signer, _ = jwt.NewSignerHS(jwt.HS256, key) } return jwt.NewBuilder(signer, opts...) } func getRSAConnToken(user string, exp int64, rsaPrivateKey *rsa.PrivateKey, opts ...jwt.BuilderOption) string { builder := getRSATokenBuilder(rsaPrivateKey, opts...) claims := &ConnectTokenClaims{ Base64Info: "e30=", RegisteredClaims: jwt.RegisteredClaims{ Subject: user, Audience: []string{"test"}, Issuer: "test", }, } if exp > 0 { claims.ExpiresAt = jwt.NewNumericDate(time.Unix(exp, 0)) } token, err := builder.Build(claims) if err != nil { panic(err) } return token.String() } func getECDSAConnToken(user string, exp int64, ecdsaPrivateKey *ecdsa.PrivateKey, opts ...jwt.BuilderOption) string { builder := getECDSATokenBuilder(ecdsaPrivateKey, opts...) claims := &ConnectTokenClaims{ Base64Info: "e30=", RegisteredClaims: jwt.RegisteredClaims{ Subject: user, }, } if exp > 0 { claims.ExpiresAt = jwt.NewNumericDate(time.Unix(exp, 0)) } token, err := builder.Build(claims) if err != nil { panic(err) } return token.String() } func getRSASubscribeToken(channel string, client string, exp int64, rsaPrivateKey *rsa.PrivateKey) string { builder := getRSATokenBuilder(rsaPrivateKey) claims := &SubscribeTokenClaims{ SubscribeOptions: SubscribeOptions{ Base64Info: "e30=", }, Channel: channel, Client: client, RegisteredClaims: jwt.RegisteredClaims{}, } if exp > 0 { claims.ExpiresAt = jwt.NewNumericDate(time.Unix(exp, 0)) } token, err := builder.Build(claims) if err != nil { panic(err) } return token.String() } func getECDSASubscribeToken(channel string, client string, exp int64, ecdsaPrivateKey *ecdsa.PrivateKey) string { builder := getECDSATokenBuilder(ecdsaPrivateKey) claims := &SubscribeTokenClaims{ SubscribeOptions: SubscribeOptions{ Base64Info: "e30=", }, Channel: channel, Client: client, RegisteredClaims: jwt.RegisteredClaims{}, } if exp > 0 { claims.ExpiresAt = jwt.NewNumericDate(time.Unix(exp, 0)) } token, err := builder.Build(claims) if err != nil { panic(err) } return token.String() } func getJWKServer(pubKey *rsa.PublicKey, kty, use, kid string) *httptest.Server { return httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ "keys": []map[string]string{ { "alg": "RS256", "kty": kty, "use": use, "kid": kid, "n": encodeToString(pubKey.N.Bytes()), "e": encodeUint64ToString(uint64(pubKey.E)), }, }} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) } func Test_tokenVerifierJWT_Signer(t *testing.T) { _, rsaPubKey := generateTestRSAKeys(t) _, ecdsaPubKey := generateTestECDSAKeys(t) signer, err := newAlgorithms("secret", rsaPubKey, ecdsaPubKey) require.NoError(t, err) require.NotNil(t, signer) } func Test_tokenVerifierJWT_Valid(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) ct, err := verifier.VerifyConnectToken(jwtValid, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) } func Test_tokenVerifierJWT_Valid_CustomClaim(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) // Test that by default `user_id` claim is ignored. verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) ct, err := verifier.VerifyConnectToken(jwtValidCustomUserClaim, false) require.NoError(t, err) require.Equal(t, "", ct.UserID) // Now test that custom `user_id` claim works for connection token. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", UserIDClaim: "user_id"}, cfgContainer) require.NoError(t, err) ct, err = verifier.VerifyConnectToken(jwtValidCustomUserClaim, false) require.NoError(t, err) require.Equal(t, "test", ct.UserID) // And the same for subscription token. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) st, err := verifier.VerifySubscribeToken(subJWTValidCustomUserClaim, false) require.NoError(t, err) require.Equal(t, "", st.UserID) require.Equal(t, "channel", st.Channel) verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", UserIDClaim: "user_id"}, cfgContainer) require.NoError(t, err) st, err = verifier.VerifySubscribeToken(subJWTValidCustomUserClaim, false) require.NoError(t, err) require.Equal(t, "test", st.UserID) // Also make sure custom claim returns empty user ID from empty object claims token. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", UserIDClaim: "user_id"}, cfgContainer) require.NoError(t, err) ct, err = verifier.VerifyConnectToken(emptyObjectClaimsJWT, false) require.NoError(t, err) require.Equal(t, "", ct.UserID) } func Test_tokenVerifierJWT_Audience(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", Audience: "test2"}, cfgContainer) require.NoError(t, err) // Token without aud. _, err = verifier.VerifyConnectToken(jwtValid, false) require.ErrorIs(t, err, ErrInvalidToken) // Generate token with aud. token := getRSAConnToken("user", time.Now().Add(time.Hour).Unix(), nil) // Verifier with audience which does not match aud in token. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", Audience: "test2"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.ErrorIs(t, err, ErrInvalidToken) // Verifier with token audience. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", Audience: "test"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.NoError(t, err) // Verifier with token audience - valid. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", AudienceRegex: "test"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.NoError(t, err) // Verifier with token audience - invalid. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", AudienceRegex: "test2"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.Error(t, err) } func Test_tokenVerifierJWT_Issuer(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", Issuer: "test2"}, cfgContainer) require.NoError(t, err) // Token without iss. _, err = verifier.VerifyConnectToken(jwtValid, false) require.ErrorIs(t, err, ErrInvalidToken) // Generate token with iss. token := getRSAConnToken("user", time.Now().Add(time.Hour).Unix(), nil) // Verifier with issuer which does not match token iss. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", Issuer: "test2"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.ErrorIs(t, err, ErrInvalidToken) // Verifier with token issuer. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", Issuer: "test"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.NoError(t, err) // Verifier with token issuer regex - valid. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", IssuerRegex: "test"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.NoError(t, err) // Verifier with token issuer regex - invalid. verifier, err = NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", IssuerRegex: "test2"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(token, false) require.Error(t, err) } func Test_tokenVerifierJWT_Expired(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(jwtExpired, false) require.Error(t, err) require.Equal(t, ErrTokenExpired, err) } func Test_tokenVerifierJWT_DisabledAlgorithm(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(jwtExpired, false) require.Error(t, err) require.True(t, errors.Is(err, ErrInvalidToken), err.Error()) } func Test_tokenVerifierJWT_InvalidSignature(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(jwtInvalidSignature, false) require.Error(t, err) // Test that skipVerify results into accepted token. ct, err := verifier.VerifyConnectToken(jwtValid+"xxx", true) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) } func Test_tokenVerifierJWT_InvalidSignature_SkipVerify(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) ct, err := verifier.VerifyConnectToken(jwtValid+"xxx", true) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) } func Test_tokenVerifierJWT_WithNotBefore(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) _, err = verifier.VerifyConnectToken(jwtNotBefore, false) require.Error(t, err) // Test that skipVerify still results into unaccepted token if it's expired. _, err = verifier.VerifyConnectToken(jwtNotBefore, true) require.Error(t, err) } func Test_tokenVerifierJWT_StringAudience(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) ct, err := verifier.VerifyConnectToken(jwtStringAud, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) } func Test_tokenVerifierJWT_ArrayAudience(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(t, err) ct, err := verifier.VerifyConnectToken(jwtArrayAud, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) } func Test_tokenVerifierJWT_VerifyConnectToken(t *testing.T) { type args struct { token string } rsaPrivateKey, rsaPubKey := generateTestRSAKeys(t) ecdsaPrivateKey, ecdsaPubKey := generateTestECDSAKeys(t) cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifierJWT, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", RSAPublicKey: rsaPubKey, ECDSAPublicKey: ecdsaPubKey}, cfgContainer) require.NoError(t, err) _time := time.Now() tests := []struct { name string verifier Verifier args args want ConnectToken wantErr bool expired bool }{ { name: "Valid JWT HS", verifier: verifierJWT, args: args{ token: getRSAConnToken("user1", _time.Add(24*time.Hour).Unix(), nil), }, want: ConnectToken{ UserID: "user1", ExpireAt: _time.Add(24 * time.Hour).Unix(), Info: []byte("{}"), Subs: map[string]centrifuge.SubscribeOptions{}, }, wantErr: false, }, { name: "Valid JWT RS", verifier: verifierJWT, args: args{ token: getRSAConnToken("user1", _time.Add(24*time.Hour).Unix(), rsaPrivateKey), }, want: ConnectToken{ UserID: "user1", ExpireAt: _time.Add(24 * time.Hour).Unix(), Info: []byte("{}"), Subs: map[string]centrifuge.SubscribeOptions{}, }, wantErr: false, }, { name: "Valid JWT ES", verifier: verifierJWT, args: args{ token: getECDSAConnToken("user1", _time.Add(24*time.Hour).Unix(), ecdsaPrivateKey), }, want: ConnectToken{ UserID: "user1", ExpireAt: _time.Add(24 * time.Hour).Unix(), Info: []byte("{}"), Subs: map[string]centrifuge.SubscribeOptions{}, }, wantErr: false, }, { name: "Invalid JWT", verifier: verifierJWT, args: args{ token: "Invalid jwt", }, want: ConnectToken{}, wantErr: true, expired: false, }, { name: "Expired JWT", verifier: verifierJWT, args: args{ token: getRSAConnToken("user1", _time.Add(-24*time.Hour).Unix(), nil), }, want: ConnectToken{}, wantErr: true, expired: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.verifier.VerifyConnectToken(tt.args.token, false) if tt.wantErr && err == nil { t.Errorf("VerifyConnectToken() should return error") } if !tt.wantErr && err != nil { t.Errorf("VerifyConnectToken() should not return error") } if tt.expired && !errors.Is(err, ErrTokenExpired) { t.Errorf("VerifyConnectToken() should return token expired error") } require.Equal(t, tt.want, got) }) } } func Test_tokenVerifierJWT_VerifyConnectTokenWithJWK(t *testing.T) { type token struct { user string exp int64 } type jwk struct { kty string use string kid string } now := time.Now() testCases := []struct { name string token token jwk jwk want ConnectToken wantErr bool expired bool }{ { name: "OK", token: token{user: "user1", exp: now.Add(24 * time.Hour).Unix()}, jwk: jwk{kty: "RSA", use: "sig", kid: "ok"}, want: ConnectToken{ UserID: "user1", ExpireAt: now.Add(24 * time.Hour).Unix(), Info: []byte("{}"), Subs: map[string]centrifuge.SubscribeOptions{}, }, wantErr: false, }, { name: "ExpiredToken", token: token{user: "user2", exp: now.Add(-24 * time.Hour).Unix()}, jwk: jwk{kty: "RSA", use: "sig", kid: "ok"}, want: ConnectToken{}, wantErr: false, expired: true, }, { name: "InvalidKeyUsage", token: token{user: "user3", exp: now.Add(24 * time.Hour).Unix()}, jwk: jwk{kty: "RSA", use: "enc", kid: "invalidkeyusage"}, want: ConnectToken{}, wantErr: true, }, { name: "InvalidKeyType", token: token{user: "user4", exp: now.Add(24 * time.Hour).Unix()}, jwk: jwk{kty: "HS", use: "sig", kid: "invalidkeytype"}, want: ConnectToken{}, wantErr: true, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { r := require.New(t) privKey, pubKey := generateTestRSAKeys(t) ts := getJWKServer(pubKey, tt.jwk.kty, tt.jwk.use, tt.jwk.kid) ts.Start() defer ts.Close() cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{JWKSPublicEndpoint: ts.URL}, cfgContainer) require.NoError(t, err) token := getRSAConnToken(tt.token.user, tt.token.exp, privKey, jwt.WithKeyID(tt.jwk.kid)) got, err := verifier.VerifyConnectToken(token, false) if tt.wantErr { r.Error(err) return } if tt.expired { r.EqualError(err, ErrTokenExpired.Error()) return } r.NoError(err) r.EqualValues(got, tt.want) }) } } func Test_tokenVerifierJWT_VerifySubscribeToken(t *testing.T) { type args struct { token string } rsaPrivateKey, rsaPubKey := generateTestRSAKeys(t) ecdsaPrivateKey, ecdsaPubKey := generateTestECDSAKeys(t) cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifierJWT, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret", RSAPublicKey: rsaPubKey, ECDSAPublicKey: ecdsaPubKey}, cfgContainer) require.NoError(t, err) _time := time.Now() tests := []struct { name string verifier Verifier args args want SubscribeToken wantErr bool expired bool skipVerify bool }{ { name: "Empty JWT", verifier: verifierJWT, args: args{}, want: SubscribeToken{}, wantErr: true, expired: false, }, { name: "Invalid JWT", verifier: verifierJWT, args: args{ token: "randomToken", }, want: SubscribeToken{}, wantErr: true, expired: false, }, { name: "Expired JWT", verifier: verifierJWT, args: args{ token: getRSASubscribeToken("channel1", "client1", _time.Add(-24*time.Hour).Unix(), nil), }, want: SubscribeToken{}, wantErr: true, expired: true, }, { name: "Valid JWT HS", verifier: verifierJWT, args: args{ token: getRSASubscribeToken("channel1", "client1", _time.Add(24*time.Hour).Unix(), nil), }, want: SubscribeToken{ Client: "client1", Channel: "channel1", Options: centrifuge.SubscribeOptions{ ExpireAt: _time.Add(24 * time.Hour).Unix(), ChannelInfo: []byte("{}"), }, }, wantErr: false, }, { name: "Invalid JWT HS", verifier: verifierJWT, args: args{ token: getRSASubscribeToken("channel1", "client1", _time.Add(24*time.Hour).Unix(), nil) + "xxx", }, want: SubscribeToken{}, wantErr: true, skipVerify: false, }, { name: "Invalid JWT HS but verify skipped", verifier: verifierJWT, args: args{ token: getRSASubscribeToken("channel1", "client1", _time.Add(24*time.Hour).Unix(), nil) + "xxx", }, want: SubscribeToken{ Client: "client1", Channel: "channel1", Options: centrifuge.SubscribeOptions{ ExpireAt: _time.Add(24 * time.Hour).Unix(), ChannelInfo: []byte("{}"), }, }, wantErr: false, skipVerify: true, }, { name: "Valid JWT RS", verifier: verifierJWT, args: args{ token: getRSASubscribeToken("channel1", "client1", _time.Add(24*time.Hour).Unix(), rsaPrivateKey), }, want: SubscribeToken{ Client: "client1", Channel: "channel1", Options: centrifuge.SubscribeOptions{ ExpireAt: _time.Add(24 * time.Hour).Unix(), ChannelInfo: []byte("{}"), }, }, wantErr: false, }, { name: "Valid JWT ES", verifier: verifierJWT, args: args{ token: getECDSASubscribeToken("channel1", "client1", _time.Add(24*time.Hour).Unix(), ecdsaPrivateKey), }, want: SubscribeToken{ Client: "client1", Channel: "channel1", Options: centrifuge.SubscribeOptions{ ExpireAt: _time.Add(24 * time.Hour).Unix(), ChannelInfo: []byte("{}"), }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.verifier.VerifySubscribeToken(tt.args.token, tt.skipVerify) if tt.wantErr && err == nil { t.Errorf("VerifySubscribeToken() should return error") } if !tt.wantErr && err != nil { t.Errorf("VerifySubscribeToken() should not return error, but returned: %v", err) } if tt.expired && !errors.Is(err, ErrTokenExpired) { t.Errorf("VerifySubscribeToken() should return token expired error") } if !reflect.DeepEqual(got, tt.want) { t.Errorf("VerifySubscribeToken() got = %v, want %v", got, tt.want) } }) } } func jwksHandler(json string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(json)) }) } func TestJWKS(t *testing.T) { // Create a test JWKS server. ts := httptest.NewServer(jwksHandler(jwksSet)) defer ts.Close() // Setup our token verifier, using the test JWKS endpoint cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{JWKSPublicEndpoint: ts.URL}, cfgContainer) require.NoError(t, err) // Validate an RSA token ct, err := verifier.VerifyConnectToken(jwtRSAWithKID, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) // Validate an EC token ct, err = verifier.VerifyConnectToken(jwtECWithKID, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) // Validate OKP (based on EdDSA) token. ct, err = verifier.VerifyConnectToken(jwtOKPWithKID, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) // Make sure a token with an unknown KID fails to verify _, err = verifier.VerifyConnectToken(jwtECWithKIDMiss, false) require.ErrorContains(t, err, "invalid token: jwks: public key not found") // Make sure a token with an unknown KID fails to verify _, err = verifier.VerifyConnectToken(jwtECWithKIDWrongAlg, false) require.ErrorContains(t, err, "invalid token: token is signed by another algorithm") } func TestJWKS_NoAlg(t *testing.T) { // Create a test JWKS server. ts := httptest.NewServer(jwksHandler(jwksSetNoAlg)) defer ts.Close() // Setup our token verifier, using the test JWKS endpoint cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{JWKSPublicEndpoint: ts.URL}, cfgContainer) require.NoError(t, err) // Validate an RSA token ct, err := verifier.VerifyConnectToken(jwtRSAWithKID, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) // Validate an EC token ct, err = verifier.VerifyConnectToken(jwtECWithKID, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) // Validate OKP (based on EdDSA) token. ct, err = verifier.VerifyConnectToken(jwtOKPWithKID, false) require.NoError(t, err) require.Equal(t, "2694", ct.UserID) require.NotNil(t, ct.Info) require.Equal(t, `{"first_name":"Alexander","last_name":"Emelin"}`, string(ct.Info)) // Make sure a token with an unknown KID fails to verify _, err = verifier.VerifyConnectToken(jwtECWithKIDMiss, false) require.ErrorContains(t, err, "invalid token: jwks: public key not found") // Make sure a token with an unknown KID fails to verify _, err = verifier.VerifyConnectToken(jwtECWithKIDWrongAlg, false) require.ErrorContains(t, err, "invalid token: unsupported JWT algorithm: ES256") } func getHMACConnToken(user string, exp int64, secret string) string { signer, _ := jwt.NewSignerHS(jwt.HS256, []byte(secret)) builder := jwt.NewBuilder(signer) claims := &ConnectTokenClaims{ Base64Info: "e30=", RegisteredClaims: jwt.RegisteredClaims{ Subject: user, }, } if exp > 0 { claims.ExpiresAt = jwt.NewNumericDate(time.Unix(exp, 0)) } token, err := builder.Build(claims) if err != nil { panic(err) } return token.String() } func Test_tokenVerifierJWT_PreviousHMACKey(t *testing.T) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(t, err) newSecret := "new-secret" oldSecret := "old-secret" t.Run("token signed with previous key verifies", func(t *testing.T) { verifier, err := NewTokenVerifierJWT(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: oldSecret, }, cfgContainer) require.NoError(t, err) token := getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), oldSecret) ct, err := verifier.VerifyConnectToken(token, false) require.NoError(t, err) require.Equal(t, "user1", ct.UserID) }) t.Run("token signed with current key still verifies", func(t *testing.T) { verifier, err := NewTokenVerifierJWT(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: oldSecret, }, cfgContainer) require.NoError(t, err) token := getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), newSecret) ct, err := verifier.VerifyConnectToken(token, false) require.NoError(t, err) require.Equal(t, "user1", ct.UserID) }) t.Run("token signed with previous key fails after valid_until expired", func(t *testing.T) { verifier, err := NewTokenVerifierJWT(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: oldSecret, HMACPreviousSecretKeyValidUntil: time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago. }, cfgContainer) require.NoError(t, err) token := getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), oldSecret) _, err = verifier.VerifyConnectToken(token, false) require.Error(t, err) require.ErrorIs(t, err, ErrInvalidToken) }) t.Run("token signed with previous key works before valid_until", func(t *testing.T) { verifier, err := NewTokenVerifierJWT(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: oldSecret, HMACPreviousSecretKeyValidUntil: time.Now().Add(time.Hour).Unix(), // Still valid. }, cfgContainer) require.NoError(t, err) token := getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), oldSecret) ct, err := verifier.VerifyConnectToken(token, false) require.NoError(t, err) require.Equal(t, "user1", ct.UserID) }) t.Run("token signed with unknown key fails", func(t *testing.T) { verifier, err := NewTokenVerifierJWT(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: oldSecret, }, cfgContainer) require.NoError(t, err) token := getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), "unknown-secret") _, err = verifier.VerifyConnectToken(token, false) require.Error(t, err) require.ErrorIs(t, err, ErrInvalidToken) }) t.Run("reload with new previous key works", func(t *testing.T) { verifier, err := NewTokenVerifierJWT(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: oldSecret, }, cfgContainer) require.NoError(t, err) anotherOldSecret := "another-old-secret" err = verifier.Reload(VerifierConfig{ HMACSecretKey: newSecret, HMACPreviousSecretKey: anotherOldSecret, }) require.NoError(t, err) // Token signed with original old secret should no longer verify. token := getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), oldSecret) _, err = verifier.VerifyConnectToken(token, false) require.Error(t, err) // Token signed with new previous secret should verify. token = getHMACConnToken("user1", time.Now().Add(time.Hour).Unix(), anotherOldSecret) ct, err := verifier.VerifyConnectToken(token, false) require.NoError(t, err) require.Equal(t, "user1", ct.UserID) }) } func BenchmarkConnectTokenVerify_Valid(b *testing.B) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(b, err) verifierJWT, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(b, err) b.ResetTimer() for i := 0; i < b.N; i++ { _, err := verifierJWT.VerifyConnectToken(jwtValid, false) if err != nil { b.Fatal(err) } } b.StopTimer() b.ReportAllocs() } func BenchmarkConnectTokenVerify_Expired(b *testing.B) { cfg := config.DefaultConfig() cfgContainer, err := config.NewContainer(cfg) require.NoError(b, err) verifier, err := NewTokenVerifierJWT(VerifierConfig{HMACSecretKey: "secret"}, cfgContainer) require.NoError(b, err) for i := 0; i < b.N; i++ { _, err := verifier.VerifyConnectToken(jwtExpired, false) if err != ErrTokenExpired { panic(err) } } }