stacklok / stacklok/toolhive

OIDC proxy does not enforce scope claims from incoming JWTs (--oidc-scopes only advertises, not validates)

Open Beginner friendly
#5,284 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

authentication enhancement go security
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

Summary

The --oidc-scopes flag on thv run is documented (and named) to suggest it enforces required scopes on incoming JWT tokens. In practice it only advertises scopes in the OAuth2 discovery document (RFC 9728 /.well-known/oauth-protected-resource) and in the WWW-Authenticate response header. It does not validate that incoming tokens carry those scopes.

Expected behaviour

When a proxy is started with --oidc-scopes required-scope, any JWT that does not contain required-scope in its scope claim should be rejected with 401 Unauthorized (or 403 Forbidden).

Actual behaviour

validateClaims in pkg/auth/token.go only checks issuer, audience, and expiry. The v.scopes field is never consulted during token validation — it is only used to populate:

  • The scope field in the WWW-Authenticate response header (buildWWWAuthenticate)
  • The scopes_supported field in the OAuth2 resource metadata document (NewAuthInfoHandler)

Any token with valid issuer, audience, and expiry is accepted regardless of its scope claim.

Impact

A downstream application that depends on thv enforcing OAuth scopes for access control is silently unprotected. Tokens issued without the required scope are accepted as if they were fully authorized.

Relevant code

pkg/auth/token.govalidateClaims function:

func (v *TokenValidator) validateClaims(claims jwt.MapClaims) error {
    // Validates: issuer, audience, expiry only
    // v.scopes is never checked here
    ...
    return nil
}

Suggested fix

Add scope claim validation to validateClaims:

if len(v.scopes) > 0 {
    scopeClaim, _ := claims["scope"].(string)
    tokenScopes := strings.Fields(scopeClaim)
    for _, required := range v.scopes {
        found := slices.Contains(tokenScopes, required)
        if !found {
            return ErrInsufficientScope
        }
    }
}

Discovery context

Found while implementing DAST adversarial tests for scope validation in the enterprise platform. The test (missing_scope in the token variation matrix) cannot be automated end-to-end because the proxy accepts all valid OIDC tokens regardless of scope.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in pkg/auth/token.go at TokenValidator.validateClaims and review how issuer, audience, and expiry are currently checked. Use the missing_scope case from the token variation matrix as the acceptance scenario; done means tokens lacking any configured scope are rejected while valid tokens with the required scopes continue to pass.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
authentication, authorization, security
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.