mirror of
https://github.com/hanzoai/metrics.git
synced 2026-10-30 20:03:39 +00:00
### Describe Your Changes Adds JWT authentication support to vmauth with signature verification and tenant-based access control. For now, public_keys have to set explisitly in the config, OIDC discovery will be added in upcoming PRs. Related to https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10445 Key Features - JWT Configuration: Added `jwt_token` field to user config supporting RSA/ECDSA public keys or skip_verify mode (for testing purposes). - Token Validation: Verifies JWT signatures, checks expiration, and extracts vm_access claims - Compatible with vmgateway: jwt tokens issued for vmgateway should work with vmauth too. Examples ```yaml users: - jwt_token: public_keys: - | -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY----- url_prefix: "http://victoria-metrics:8428/" ``` ```yaml users: - jwt_token: skip_verify: true url_prefix: "http://victoria-metrics:8428/" ``` Constraints - JWT tokens cannot be mixed with other auth methods (bearer_token, username, password) - Requires at least one public key OR skip_verify=true - Limited to single JWT user (multiple JWT users will be supported in the future) Next steps - Multiple `jwt_token` support. - Claim matching - Claim based routing - OIDC\JWKS support ### Checklist The following checks are **mandatory**: - [ ] My change adheres to [VictoriaMetrics contributing guidelines](https://docs.victoriametrics.com/victoriametrics/contributing/#pull-request-checklist). - [ ] My change adheres to [VictoriaMetrics development goals](https://docs.victoriametrics.com/victoriametrics/goals/). --------- Co-authored-by: Pablo (Tomas) Fernandez <[email protected]>
305 lines
7.7 KiB
Go
305 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestJWTParseAuthConfigFailure(t *testing.T) {
|
|
validRSAPublicKey := `-----BEGIN PUBLIC KEY-----
|
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiX7oPWKOWRQsGFEWvwZO
|
|
mL2PYsdYUsu9nr0qtPCjxQHUJgLfT3rdKlvKpPFYv7ZmKnqTncg36Wz9uiYmWJ7e
|
|
IB5Z+fko8kVIMzarCqVvpAJDzYF/pUii68xvuYoK3L9TIOAeyCXv+prwnr2IH+Mw
|
|
9AONzWbRrYoO74XyTE9vMU5qmI/L1VPk+PR8lqPOSptLvzsfoaIk2ED4yK2nRB+6
|
|
st+k4nccPqbErqHc8aiXnXfugfnr6b+NPFYUzKsDqkymGOokVijrI8B3jNw6c6Do
|
|
zphk+D3wgLsXYHfMcZbXIMqffqm/aB8Qg88OpFOkQ3rd2p6R9+hacnZkfkn3Phiw
|
|
yQIDAQAB
|
|
-----END PUBLIC KEY-----
|
|
`
|
|
// ECDSA with the P-521 curve
|
|
validECDSAPublicKey := `-----BEGIN PUBLIC KEY-----
|
|
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAU9RmtkCRuYTKCyvLlDn5DtBZOHSe
|
|
QTa5j9q/oQVpCKqcXVFrH5dgh0GL+P/ZhkeuowPzCZqntGf0+7wPt9OxSJcADVJm
|
|
dv92m540MXss8zdHf5qtE0gsu2Ved0R7Z8a8QwGZ/1mYZ+kFGGbdQTlSvRqDySTq
|
|
XOtclIk1uhc03oL9nOQ=
|
|
-----END PUBLIC KEY-----
|
|
`
|
|
|
|
f := func(s string, expErr string) {
|
|
t.Helper()
|
|
ac, err := parseAuthConfig([]byte(s))
|
|
if err != nil {
|
|
if expErr != err.Error() {
|
|
t.Fatalf("unexpected error; got %q; want %q", err.Error(), expErr)
|
|
}
|
|
return
|
|
}
|
|
users, err := parseJWTUsers(ac)
|
|
if err != nil {
|
|
if expErr != err.Error() {
|
|
t.Fatalf("unexpected error; got %q; want %q", err.Error(), expErr)
|
|
}
|
|
return
|
|
}
|
|
t.Fatalf("expecting non-nil error; got %v", users)
|
|
}
|
|
|
|
// unauthorized_user cannot be used with jwt
|
|
f(`
|
|
unauthorized_user:
|
|
jwt: {skip_verify: true}
|
|
url_prefix: http://foo.bar
|
|
`, `field jwt can't be specified for unauthorized_user section`)
|
|
|
|
// username and jwt in a single config
|
|
f(`
|
|
users:
|
|
- username: foo
|
|
jwt: {skip_verify: true}
|
|
url_prefix: http://foo.bar
|
|
`, `auth_token, bearer_token, username and password cannot be specified if jwt is set`)
|
|
// bearer_token and jwt in a single config
|
|
f(`
|
|
users:
|
|
- bearer_token: foo
|
|
jwt: {skip_verify: true}
|
|
url_prefix: http://foo.bar
|
|
`, `auth_token, bearer_token, username and password cannot be specified if jwt is set`)
|
|
// bearer_token and jwt in a single config
|
|
f(`
|
|
users:
|
|
- auth_token: "Foo token"
|
|
jwt: {skip_verify: true}
|
|
url_prefix: http://foo.bar
|
|
`, `auth_token, bearer_token, username and password cannot be specified if jwt is set`)
|
|
|
|
// jwt public_keys or skip_verify must be set, part 1
|
|
f(`
|
|
users:
|
|
- jwt: {}
|
|
url_prefix: http://foo.bar
|
|
`, `jwt must contain at least a single public key, public_key_files or have skip_verify=true`)
|
|
|
|
// jwt public_keys or skip_verify must be set, part 2
|
|
f(`
|
|
users:
|
|
- jwt: {public_keys: null}
|
|
url_prefix: http://foo.bar
|
|
`, `jwt must contain at least a single public key, public_key_files or have skip_verify=true`)
|
|
|
|
// jwt public_keys or skip_verify must be set, part 3
|
|
f(`
|
|
users:
|
|
- jwt: {public_keys: []}
|
|
url_prefix: http://foo.bar
|
|
`, `jwt must contain at least a single public key, public_key_files or have skip_verify=true`)
|
|
|
|
// jwt public_keys, public_key_files or skip_verify must be set
|
|
f(`
|
|
users:
|
|
- jwt: {public_key_files: []}
|
|
url_prefix: http://foo.bar
|
|
`, `jwt must contain at least a single public key, public_key_files or have skip_verify=true`)
|
|
|
|
// invalid public key, part 1
|
|
f(`
|
|
users:
|
|
- jwt: {public_keys: [""]}
|
|
url_prefix: http://foo.bar
|
|
`, `failed to parse key "": failed to decode PEM block containing public key`)
|
|
|
|
// invalid public key, part 2
|
|
f(`
|
|
users:
|
|
- jwt: {public_keys: ["invalid"]}
|
|
url_prefix: http://foo.bar
|
|
`, `failed to parse key "invalid": failed to decode PEM block containing public key`)
|
|
|
|
// invalid public key, part 2
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
- %q
|
|
- "invalid"
|
|
url_prefix: http://foo.bar
|
|
`, validRSAPublicKey, validECDSAPublicKey), `failed to parse key "invalid": failed to decode PEM block containing public key`)
|
|
|
|
// several jwt users
|
|
// invalid public key, part 2
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, validRSAPublicKey, validECDSAPublicKey), `multiple users with JWT tokens are not supported; found 2 users`)
|
|
|
|
// public key file doesn't exist
|
|
f(`
|
|
users:
|
|
- jwt:
|
|
public_key_files:
|
|
- /path/to/nonexistent/file.pem
|
|
url_prefix: http://foo.bar
|
|
`, "cannot read public key from file \"/path/to/nonexistent/file.pem\": open /path/to/nonexistent/file.pem: no such file or directory")
|
|
|
|
// public key file invalid
|
|
// auth with key from file
|
|
publicKeyFile := filepath.Join(t.TempDir(), "a_public_key.pem")
|
|
if err := os.WriteFile(publicKeyFile, []byte(`invalidPEM`), 0o644); err != nil {
|
|
t.Fatalf("failed to write public key file: %s", err)
|
|
}
|
|
f(`
|
|
users:
|
|
- jwt:
|
|
public_key_files:
|
|
- `+publicKeyFile+`
|
|
url_prefix: http://foo.bar
|
|
`, "cannot parse public key from file \""+publicKeyFile+"\": failed to parse key \"invalidPEM\": failed to decode PEM block containing public key")
|
|
}
|
|
|
|
func TestJWTParseAuthConfigSuccess(t *testing.T) {
|
|
validRSAPublicKey := `-----BEGIN PUBLIC KEY-----
|
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiX7oPWKOWRQsGFEWvwZO
|
|
mL2PYsdYUsu9nr0qtPCjxQHUJgLfT3rdKlvKpPFYv7ZmKnqTncg36Wz9uiYmWJ7e
|
|
IB5Z+fko8kVIMzarCqVvpAJDzYF/pUii68xvuYoK3L9TIOAeyCXv+prwnr2IH+Mw
|
|
9AONzWbRrYoO74XyTE9vMU5qmI/L1VPk+PR8lqPOSptLvzsfoaIk2ED4yK2nRB+6
|
|
st+k4nccPqbErqHc8aiXnXfugfnr6b+NPFYUzKsDqkymGOokVijrI8B3jNw6c6Do
|
|
zphk+D3wgLsXYHfMcZbXIMqffqm/aB8Qg88OpFOkQ3rd2p6R9+hacnZkfkn3Phiw
|
|
yQIDAQAB
|
|
-----END PUBLIC KEY-----
|
|
`
|
|
// ECDSA with the P-521 curve
|
|
validECDSAPublicKey := `-----BEGIN PUBLIC KEY-----
|
|
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQAU9RmtkCRuYTKCyvLlDn5DtBZOHSe
|
|
QTa5j9q/oQVpCKqcXVFrH5dgh0GL+P/ZhkeuowPzCZqntGf0+7wPt9OxSJcADVJm
|
|
dv92m540MXss8zdHf5qtE0gsu2Ved0R7Z8a8QwGZ/1mYZ+kFGGbdQTlSvRqDySTq
|
|
XOtclIk1uhc03oL9nOQ=
|
|
-----END PUBLIC KEY-----
|
|
`
|
|
|
|
f := func(s string) {
|
|
t.Helper()
|
|
ac, err := parseAuthConfig([]byte(s))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %s", err)
|
|
}
|
|
|
|
jui, err := parseJWTUsers(ac)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %s", err)
|
|
}
|
|
|
|
for _, ui := range jui {
|
|
if ui.JWT == nil {
|
|
t.Fatalf("unexpected nil JWTConfig")
|
|
}
|
|
|
|
if ui.JWT.SkipVerify {
|
|
if ui.JWT.verifierPool != nil {
|
|
t.Fatalf("unexpected non-nil verifier pool for skip_verify=true")
|
|
}
|
|
continue
|
|
}
|
|
|
|
if ui.JWT.verifierPool == nil {
|
|
t.Fatalf("unexpected nil verifier pool for non-empty public keys")
|
|
}
|
|
}
|
|
}
|
|
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, validRSAPublicKey))
|
|
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, validECDSAPublicKey))
|
|
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, validRSAPublicKey, validECDSAPublicKey))
|
|
|
|
f(`
|
|
users:
|
|
- jwt:
|
|
skip_verify: true
|
|
url_prefix: http://foo.bar
|
|
`)
|
|
|
|
// combined with other auth methods
|
|
f(`
|
|
users:
|
|
- username: foo
|
|
password: bar
|
|
url_prefix: http://foo.bar
|
|
|
|
- jwt:
|
|
skip_verify: true
|
|
url_prefix: http://foo.bar
|
|
|
|
- bearer_token: foo
|
|
url_prefix: http://foo.bar
|
|
`)
|
|
|
|
rsaKeyFile := filepath.Join(t.TempDir(), "rsa_public_key.pem")
|
|
if err := os.WriteFile(rsaKeyFile, []byte(validRSAPublicKey), 0o644); err != nil {
|
|
t.Fatalf("failed to write RSA key file: %s", err)
|
|
}
|
|
ecdsaKeyFile := filepath.Join(t.TempDir(), "ecdsa_public_key.pem")
|
|
if err := os.WriteFile(ecdsaKeyFile, []byte(validECDSAPublicKey), 0o644); err != nil {
|
|
t.Fatalf("failed to write ECDSA key file: %s", err)
|
|
}
|
|
|
|
// Test single public key file
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_key_files:
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, rsaKeyFile))
|
|
|
|
// Test multiple public key files
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_key_files:
|
|
- %q
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, rsaKeyFile, ecdsaKeyFile))
|
|
|
|
// Test combined inline keys and files
|
|
f(fmt.Sprintf(`
|
|
users:
|
|
- jwt:
|
|
public_keys:
|
|
- %q
|
|
public_key_files:
|
|
- %q
|
|
url_prefix: http://foo.bar
|
|
`, validECDSAPublicKey, rsaKeyFile))
|
|
}
|