Compare commits

...

5 Commits

Author SHA1 Message Date
Zakhar Bessarab
fefe3e39a8 lib/jwt: fix tests after cb540aaa662511063af3171a370ea17f17121e60 2026-06-04 16:37:33 +04:00
Zakhar Bessarab
92ba12f0e8 app/vmauth/jwt: fix the comment 2026-06-04 16:37:33 +04:00
Zakhar Bessarab
3afcbb704c lib/jwt: enforce token type checks 2026-06-04 16:37:33 +04:00
Zakhar Bessarab
3e711231cb app/vmauth: make linter happy 2026-06-04 16:37:33 +04:00
Zakhar Bessarab
da456103b0 app/vmauth: allow skipping vm_access claim validation
Allow skipping "vm_access" claim validation in order to use claims match based routing. Previously, that required to
modify the token and add an artificial "vm_access: {}" value which is inconvinient.
2026-06-04 16:37:33 +04:00
9 changed files with 200 additions and 78 deletions

View File

@@ -906,14 +906,15 @@ func reloadAuthConfigData(data []byte) (bool, error) {
return false, fmt.Errorf("failed to parse auth config: %w", err)
}
jui, oidcDP, err := parseJWTUsers(ac)
jui, oidcDP, hasUsersWithSkipVMAccessValidation, err := parseJWTUsers(ac)
if err != nil {
return false, fmt.Errorf("failed to parse JWT users from auth config: %w", err)
}
oidcDP.startDiscovery()
jwtc := &jwtCache{
users: jui,
oidcDP: oidcDP,
users: jui,
oidcDP: oidcDP,
enforceVMAccessClaims: !hasUsersWithSkipVMAccessValidation,
}
m, err := parseAuthConfigUsers(ac)

View File

@@ -140,6 +140,17 @@ users:
- "ProjectID: {{.MetricsProjectID}}"
url_prefix: "http://vminsert:8480/insert/prometheus"
# JWT-based routing that relies solely on custom claims.
# skip_vm_access_validation accepts tokens that don't carry a `vm_access` claim,
# e.g. {"role": "admin"}.
- name: jwt-no-vm-access
jwt:
skip_vm_access_validation: true
skip_verify: true
match_claims:
role: admin
url_prefix: "http://vmselect-admin:8481/select/0/prometheus"
# Requests without Authorization header are proxied according to `unauthorized_user` section.
# Requests are proxied in round-robin fashion between `url_prefix` backends.
# The deny_partial_response query arg is added to all the proxied requests.

View File

@@ -52,18 +52,22 @@ var urlPathPlaceHolders = []string{
type jwtCache struct {
// users contain UserInfo`s from AuthConfig with JWTConfig set
users []*UserInfo
users []*UserInfo
oidcDP *oidcDiscovererPool
// enforcement of vm_access claim is enabled if there are no users with "skip_vm_access_validation=true"
// used for fast rejection path in case of missing "vm_access" claim
enforceVMAccessClaims bool
}
type JWTConfig struct {
PublicKeys []string `yaml:"public_keys,omitempty"`
PublicKeyFiles []string `yaml:"public_key_files,omitempty"`
SkipVerify bool `yaml:"skip_verify,omitempty"`
OIDC *oidcConfig `yaml:"oidc,omitempty"`
MatchClaims map[string]string `yaml:"match_claims,omitempty"`
parsedMatchClaims []*jwt.Claim
PublicKeys []string `yaml:"public_keys,omitempty"`
PublicKeyFiles []string `yaml:"public_key_files,omitempty"`
SkipVerify bool `yaml:"skip_verify,omitempty"`
SkipVMAccessValidation bool `yaml:"skip_vm_access_validation,omitempty"`
OIDC *oidcConfig `yaml:"oidc,omitempty"`
MatchClaims map[string]string `yaml:"match_claims,omitempty"`
parsedMatchClaims []*jwt.Claim
// verifierPool is used to verify JWT tokens.
// It is initialized from PublicKeys and/or PublicKeyFiles.
@@ -72,9 +76,10 @@ type JWTConfig struct {
verifierPool atomic.Pointer[jwt.VerifierPool]
}
func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, bool, error) {
jui := make([]*UserInfo, 0, len(ac.Users))
oidcDP := &oidcDiscovererPool{}
hasUsersWithSkipVMAccessValidation := false
uniqClaims := make(map[string]*UserInfo)
var sortedClaims []string
@@ -85,10 +90,10 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
}
if ui.AuthToken != "" || ui.BearerToken != "" || ui.Username != "" || ui.Password != "" {
return nil, nil, fmt.Errorf("auth_token, bearer_token, username and password cannot be specified if jwt is set")
return nil, nil, false, fmt.Errorf("auth_token, bearer_token, username and password cannot be specified if jwt is set")
}
if len(jwtToken.PublicKeys) == 0 && len(jwtToken.PublicKeyFiles) == 0 && !jwtToken.SkipVerify && jwtToken.OIDC == nil {
return nil, nil, fmt.Errorf("jwt must contain at least a single public key, public_key_files, oidc or have skip_verify=true")
return nil, nil, false, fmt.Errorf("jwt must contain at least a single public key, public_key_files, oidc or have skip_verify=true")
}
var claimsString string
sortedClaims = sortedClaims[:0]
@@ -97,7 +102,7 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
sortedClaims = append(sortedClaims, fmt.Sprintf("%s=%s", ck, cv))
pc, err := jwt.NewClaim(ck, cv)
if err != nil {
return nil, nil, fmt.Errorf("incorrect match claim, key=%q, value regex=%q: %w", ck, cv, err)
return nil, nil, false, fmt.Errorf("incorrect match claim, key=%q, value regex=%q: %w", ck, cv, err)
}
parsedClaims = append(parsedClaims, pc)
}
@@ -106,7 +111,7 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
claimsString = strings.Join(sortedClaims, ",")
if oldUI, ok := uniqClaims[claimsString]; ok {
return nil, nil, fmt.Errorf("duplicate match claims=%q found for name=%q at idx=%d; the previous one is set for name=%q", claimsString, ui.Name, idx, oldUI.Name)
return nil, nil, false, fmt.Errorf("duplicate match claims=%q found for name=%q at idx=%d; the previous one is set for name=%q", claimsString, ui.Name, idx, oldUI.Name)
}
uniqClaims[claimsString] = &ui
if len(jwtToken.PublicKeys) > 0 || len(jwtToken.PublicKeyFiles) > 0 {
@@ -115,7 +120,7 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
for i := range jwtToken.PublicKeys {
k, err := jwt.ParseKey([]byte(jwtToken.PublicKeys[i]))
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
keys = append(keys, k)
}
@@ -123,52 +128,56 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
for _, filePath := range jwtToken.PublicKeyFiles {
keyData, err := os.ReadFile(filePath)
if err != nil {
return nil, nil, fmt.Errorf("cannot read public key from file %q: %w", filePath, err)
return nil, nil, false, fmt.Errorf("cannot read public key from file %q: %w", filePath, err)
}
k, err := jwt.ParseKey(keyData)
if err != nil {
return nil, nil, fmt.Errorf("cannot parse public key from file %q: %w", filePath, err)
return nil, nil, false, fmt.Errorf("cannot parse public key from file %q: %w", filePath, err)
}
keys = append(keys, k)
}
vp, err := jwt.NewVerifierPool(keys)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
jwtToken.verifierPool.Store(vp)
}
if jwtToken.OIDC != nil {
if len(jwtToken.PublicKeys) > 0 || len(jwtToken.PublicKeyFiles) > 0 || jwtToken.SkipVerify {
return nil, nil, fmt.Errorf("jwt with oidc cannot contain public keys or have skip_verify=true")
return nil, nil, false, fmt.Errorf("jwt with oidc cannot contain public keys or have skip_verify=true")
}
if jwtToken.OIDC.Issuer == "" {
return nil, nil, fmt.Errorf("oidc issuer cannot be empty")
return nil, nil, false, fmt.Errorf("oidc issuer cannot be empty")
}
isserURL, err := url.Parse(jwtToken.OIDC.Issuer)
if err != nil {
return nil, nil, fmt.Errorf("oidc issuer %q must be a valid URL", jwtToken.OIDC.Issuer)
return nil, nil, false, fmt.Errorf("oidc issuer %q must be a valid URL", jwtToken.OIDC.Issuer)
}
if isserURL.Scheme != "https" && isserURL.Scheme != "http" {
return nil, nil, fmt.Errorf("oidc issuer %q must have http or https scheme", jwtToken.OIDC.Issuer)
return nil, nil, false, fmt.Errorf("oidc issuer %q must have http or https scheme", jwtToken.OIDC.Issuer)
}
oidcDP.createOrAdd(ui.JWT.OIDC.Issuer, &ui.JWT.verifierPool)
}
if err := parseJWTPlaceholdersForUserInfo(&ui, true); err != nil {
return nil, nil, err
return nil, nil, false, err
}
if err := ui.initURLs(); err != nil {
return nil, nil, err
return nil, nil, false, err
}
if ui.JWT.SkipVMAccessValidation {
hasUsersWithSkipVMAccessValidation = true
}
metricLabels, err := ui.getMetricLabels()
if err != nil {
return nil, nil, fmt.Errorf("cannot parse metric_labels: %w", err)
return nil, nil, false, fmt.Errorf("cannot parse metric_labels: %w", err)
}
ui.requests = ac.ms.GetOrCreateCounter(`vmauth_user_requests_total` + metricLabels)
ui.requestErrors = ac.ms.GetOrCreateCounter(`vmauth_user_request_errors_total` + metricLabels)
@@ -187,7 +196,7 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
rt, err := newRoundTripper(ui.TLSCAFile, ui.TLSCertFile, ui.TLSKeyFile, ui.TLSServerName, ui.TLSInsecureSkipVerify)
if err != nil {
return nil, nil, fmt.Errorf("cannot initialize HTTP RoundTripper: %w", err)
return nil, nil, false, fmt.Errorf("cannot initialize HTTP RoundTripper: %w", err)
}
ui.rt = rt
@@ -200,7 +209,7 @@ func parseJWTUsers(ac *AuthConfig) ([]*UserInfo, *oidcDiscovererPool, error) {
return len(jui[i].JWT.MatchClaims) > len(jui[j].JWT.MatchClaims)
})
return jui, oidcDP, nil
return jui, oidcDP, hasUsersWithSkipVMAccessValidation, nil
}
var tokenPool sync.Pool
@@ -239,6 +248,12 @@ func getJWTUserInfo(ats []string) (*UserInfo, *jwt.Token) {
}
continue
}
if js.enforceVMAccessClaims && !tkn.HasVMAccess() {
if *logInvalidAuthTokens {
logger.Infof("cannot parse jwt token: %s", jwt.ErrVMAccessFieldMissing)
}
continue
}
if tkn.IsExpired(time.Now()) {
if *logInvalidAuthTokens {
// TODO: add more context:
@@ -259,6 +274,10 @@ func getJWTUserInfo(ats []string) (*UserInfo, *jwt.Token) {
func getUserInfoByJWTToken(tkn *jwt.Token, users []*UserInfo) *UserInfo {
for _, ui := range users {
if !ui.JWT.SkipVMAccessValidation && !tkn.HasVMAccess() {
continue
}
if !tkn.MatchClaims(ui.JWT.parsedMatchClaims) {
continue
}
@@ -433,7 +452,6 @@ func validateJWTPlaceholdersForURL(up *URLPrefix, isAllowed bool) error {
}
if strings.Contains(p, placeholderPrefix) {
return fmt.Errorf("invalid placeholder found in URL request path: %q, supported values are: %s", bu.Path, strings.Join(allPlaceholders, ", "))
}
}
for param, values := range bu.Query() {
@@ -488,7 +506,6 @@ func hasAnyPlaceholders(u *url.URL) bool {
return true
}
}
}
return false
}

View File

@@ -39,7 +39,7 @@ XOtclIk1uhc03oL9nOQ=
}
return
}
users, oidcDP, err := parseJWTUsers(ac)
users, oidcDP, _, err := parseJWTUsers(ac)
if err == nil {
t.Fatalf("expecting non-nil error; got %v", users)
}
@@ -326,7 +326,7 @@ XOtclIk1uhc03oL9nOQ=
t.Fatalf("unexpected error: %s", err)
}
jui, oidcDP, err := parseJWTUsers(ac)
jui, oidcDP, _, err := parseJWTUsers(ac)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}

View File

@@ -739,6 +739,12 @@ users:
"vm_access": map[string]any{},
}, false)
// token without vm_access claim, but with a custom claim usable for routing
roleToken := genToken(t, map[string]any{
"exp": time.Now().Add(10 * time.Minute).Unix(),
"role": "admin",
}, true)
fullToken := genToken(t, map[string]any{
"exp": time.Now().Add(10 * time.Minute).Unix(),
"vm_access": map[string]any{
@@ -779,6 +785,39 @@ statusCode=401
Unauthorized`
f(simpleCfgStr, request, responseExpected)
// token without vm_access claim is rejected even with a matching custom claim
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+roleToken)
responseExpected = `
statusCode=401
Unauthorized`
f(fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
match_claims:
role: admin
url_prefix: {BACKEND}/foo`, string(publicKeyPEM)), request, responseExpected)
// token without vm_access claim is accepted when skip_vm_access_validation is set
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+roleToken)
responseExpected = `
statusCode=200
path: /foo/abc
query:
headers:`
f(fmt.Sprintf(`
users:
- jwt:
public_keys:
- %q
skip_vm_access_validation: true
match_claims:
role: admin
url_prefix: {BACKEND}/foo`, string(publicKeyPEM)), request, responseExpected)
// expired token
request = httptest.NewRequest(`GET`, "http://some-host.com/abc", nil)
request.Header.Set(`Authorization`, `Bearer `+expiredToken)

View File

@@ -33,6 +33,7 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
* FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent/) : introduce `vmagent_remotewrite_kafka_outbuf_latency_seconds` and `vmagent_remotewrite_kafka_rtt_seconds` metrics for [kafka integration](https://docs.victoriametrics.com/victoriametrics/integrations/kafka/). The metrics could help identify throughput bottlenecks. See [#10730](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10730).
* FEATURE: [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/): properly log user information when a missing route error occurs. See [#11052](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/11052).
* FEATURE: [vmctl](https://docs.victoriametrics.com/vmctl/): add the ability to migrate data from Mimir object storage to VictoriaMetrics. See [#7717](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7717).
* FEATURE: [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/): add `skip_vm_access_validation` option for [JWT authorization](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-token-auth-proxy) to accept tokens without the mandatory `vm_access` claim. This is useful when routing is built solely on [JWT claim matching](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-matching) using other token claims. See [#11054](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11054).
* BUGFIX: [vmalert](https://docs.victoriametrics.com/victoriametrics/vmalert/): fix the `Notifiers` page in web UI appearing blank despite the API returning notifier data correctly. See [#11035](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11035).
* BUGFIX: [vmalert](https://docs.victoriametrics.com/victoriametrics/vmalert/): reset the group evaluation timestamp if it exceeds the current host time. Previously, vmalert could use future timestamps for evaluations if the system clock was shifted backward. See [#10985](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10985).

View File

@@ -270,7 +270,8 @@ users:
url_prefix: "http://victoria-metrics:8428/"
```
JWT tokens must contain a `"vm_access": {}` claim, more on that in [JWT claim-based request templating](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-based-request-templating)
JWT tokens must contain a `"vm_access": {}` claim, more on that in [JWT claim-based request templating](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-based-request-templating).
This requirement can be relaxed per user with `skip_vm_access_validation`, see [Optional vm_access claim](https://docs.victoriametrics.com/victoriametrics/vmauth/#optional-vm_access-claim).
For testing, skip signature verification with `skip_verify: true` (not recommended for production).
@@ -311,6 +312,33 @@ If the OIDC provider is temporarily unavailable during a key refresh, `vmauth` c
If no keys have been fetched yet (e.g., on startup when the provider is unreachable), the config section is skipped during authentication.
#### Optional vm_access claim
By default, `vmauth` rejects JWT tokens that don't contain a `vm_access` claim. When routing is built solely on
[JWT claim matching](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-matching) using other token claims,
the `vm_access` claim is redundant. Set `skip_vm_access_validation: true`{{% available_from "#" %}} on the `jwt` user
to accept tokens without a `vm_access` claim:
```yaml
users:
- jwt:
public_keys:
- |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----
skip_vm_access_validation: true
match_claims:
role: admin
url_prefix: "http://victoria-metrics-admin:8428/"
```
`skip_vm_access_validation` only relaxes the requirement that the claim is present - the token signature is still verified,
and a `vm_access` claim is still parsed and applied when present (e.g. for [request templating](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-based-request-templating)).
The setting is per user, so tokens without `vm_access` are accepted only for the matched user that opts in.
When the claim is absent, the default tenant `0:0` is assumed for any `vm_access`-based placeholders. See [#11054](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/11054).
#### JWT claim matching
`vmauth` can route requests to different backends depending on the claims contained

View File

@@ -105,6 +105,10 @@ type body struct {
Scope string `json:"scope,omitempty"`
vmAccessClaim VMAccessClaim
// hasVMAccess is set to true when the token body contains a `vm_access` claim.
// Presence enforcement is left to the caller via Token.HasVMAccess.
hasVMAccess bool
buf []byte
p *fastjson.Parser
@@ -121,7 +125,6 @@ type body struct {
}
func (b *body) parse(src string) error {
var err error
b.buf, err = decodeB64(b.buf[:0], src)
if err != nil {
@@ -132,6 +135,9 @@ func (b *body) parse(src string) error {
if err != nil {
return err
}
if jv.Type() != fastjson.TypeObject {
return fmt.Errorf("unexpected non json object; type: %q", jv.Type())
}
if expObject := jv.Get("exp"); expObject != nil {
b.Exp, err = expObject.Int64()
if err != nil {
@@ -153,30 +159,31 @@ func (b *body) parse(src string) error {
}
vaObject := jv.Get("vm_access")
if vaObject == nil {
return ErrVMAccessFieldMissing
}
// some IDPs encode custom claims as a string
// try parsing as an object and fallback to a string
switch vaObject.Type() {
case fastjson.TypeObject:
if err := b.vmAccessClaim.parseFrom(vaObject); err != nil {
return err
}
case fastjson.TypeString:
b.claimsParser = parserPool.Get()
va, err := b.claimsParser.ParseBytes(vaObject.GetStringBytes())
if err != nil {
return fmt.Errorf("cannot parse `vm_access` string json: %w", err)
}
if err := b.vmAccessClaim.parseFrom(va); err != nil {
return fmt.Errorf("cannot parse `vm_access` values from string json: %w", err)
}
b.vmAccessClaimObject = va
case fastjson.TypeNull:
return ErrVMAccessFieldMissing
switch {
case vaObject == nil || vaObject.Type() == fastjson.TypeNull:
b.hasVMAccess = false
default:
return fmt.Errorf("unexpected type for `vm_access` field; got: %q, want object {}", vaObject.Type())
// some IDPs encode custom claims as a string
// try parsing as an object and fallback to a string
switch vaObject.Type() {
case fastjson.TypeObject:
if err := b.vmAccessClaim.parseFrom(vaObject); err != nil {
return err
}
case fastjson.TypeString:
b.claimsParser = parserPool.Get()
va, err := b.claimsParser.ParseBytes(vaObject.GetStringBytes())
if err != nil {
return fmt.Errorf("cannot parse `vm_access` string json: %w", err)
}
if err := b.vmAccessClaim.parseFrom(va); err != nil {
return fmt.Errorf("cannot parse `vm_access` values from string json: %w", err)
}
b.vmAccessClaimObject = va
default:
return fmt.Errorf("unexpected type for `vm_access` field; got: %q, want object {}", vaObject.Type())
}
b.hasVMAccess = true
}
b.Jti = bytesutil.ToUnsafeString(jv.GetStringBytes("jti"))
@@ -218,6 +225,7 @@ func (b *body) reset() {
b.buf = b.buf[:0]
b.allClaims = nil
b.vmAccessClaim.reset()
b.hasVMAccess = false
if b.p != nil {
parserPool.Put(b.p)
b.p = nil
@@ -229,11 +237,9 @@ func (b *body) reset() {
if b.vmAccessClaimObject != nil {
b.vmAccessClaimObject = nil
}
}
// Parse parses JWT token from given source string
//
// Token field is valid until src is reachable
func (t *Token) Parse(src string, enforceAuthPrefix bool) error {
if enforceAuthPrefix && (len(src) < len(prefix) || !strings.EqualFold(src[:len(prefix)], prefix)) {
@@ -268,6 +274,11 @@ func (t *Token) Parse(src string, enforceAuthPrefix bool) error {
return nil
}
// HasVMAccess reports whether the parsed token contains a `vm_access` claim.
func (t *Token) HasVMAccess() bool {
return t.body.hasVMAccess
}
// Issuer returns `iss` claim value from token body
func (t *Token) Issuer() string {
return t.body.Iss
@@ -425,7 +436,6 @@ func (vac *VMAccessClaim) reset() {
}
func (vac *VMAccessClaim) parseFrom(jv *fastjson.Value) error {
if err := vac.Tenant.parseFrom(jv); err != nil {
return err
}
@@ -569,6 +579,9 @@ func NewToken(auth string, enforceAuthPrefix bool) (*Token, error) {
if err := t.parse(jwt[0], jwt[1], jwt[2]); err != nil {
return nil, err
}
if !t.body.hasVMAccess {
return nil, ErrVMAccessFieldMissing
}
return &t, nil
}

View File

@@ -168,17 +168,10 @@ func TestParseJWTBody_Failure(t *testing.T) {
true,
)
// invalid body type json
// non-object body type
f(
`[]`,
"missing `vm_access` claim",
true,
)
// missing vm_access claim
f(
`{}`,
"missing `vm_access` claim",
`unexpected non json object; type: "array"`,
true,
)
@@ -189,13 +182,6 @@ func TestParseJWTBody_Failure(t *testing.T) {
true,
)
// vm_access claim null
f(
`{"vm_access": null}`,
"missing `vm_access` claim",
true,
)
// invalid vm_access: account_id type mismatch
f(
`{"vm_access": {"tenant_id": {"account_id": "1", "project_id": 5}}}`,
@@ -555,6 +541,33 @@ func TestParseJWTBody_Success(t *testing.T) {
)
}
func TestParseJWTBody_VMAccessPresence(t *testing.T) {
f := func(data string, wantHasVMAccess bool) {
t.Helper()
encodedLen := base64.RawURLEncoding.EncodedLen(len(data))
encoded := make([]byte, encodedLen)
base64.RawURLEncoding.Encode(encoded, []byte(data))
var b body
if err := b.parse(string(encoded)); err != nil {
t.Fatalf("unexpected error: %s", err)
}
if b.hasVMAccess != wantHasVMAccess {
t.Fatalf("unexpected hasVMAccess; got %v; want %v", b.hasVMAccess, wantHasVMAccess)
}
}
// vm_access claim is present
f(`{"vm_access": {}}`, true)
f(`{"vm_access": {"metrics_account_id": 1}}`, true)
// vm_access claim is absent or null - parsing must succeed with hasVMAccess=false
f(`{}`, false)
f(`{"vm_access": null}`, false)
f(`{"role": "admin"}`, false)
}
func TestNewTokenFromRequest_Failure(t *testing.T) {
f := func(r *http.Request) {
t.Helper()
@@ -866,7 +879,6 @@ func TestNewTokenFromRequest_Success(t *testing.T) {
}
func TestTokenMatchClaims(t *testing.T) {
/*
{
"iss": "https://login.microsoftonline.com/-6691-4868-a77b-1b0f9bbe5f43/v2.0",