Skip to content

Configurable JWT authentication to ateapi - #757

Open
Eitan Yarmush (EItanya) wants to merge 4 commits into
agent-substrate:mainfrom
kagent-dev:issue-733-human-jwt-auth
Open

Configurable JWT authentication to ateapi#757
Eitan Yarmush (EItanya) wants to merge 4 commits into
agent-substrate:mainfrom
kagent-dev:issue-733-human-jwt-auth

Conversation

@EItanya

Copy link
Copy Markdown
Collaborator

Summary

Fixes #733 by adding configurable JWT authentication to ateapi.

  • Supports multiple OIDC/JWT providers through a YAML configuration file.

  • Validates issuer, audience, signature, and token lifetime.

  • Supports custom CAs and authenticated OIDC discovery for Kubernetes
    ServiceAccount JWTs.

  • Restricts MintJWT to the configured actor-identity provider.

  • Replaces the existing --client-jwt-* flags.

RBAC remains follow-up work.

Testing

  • go test ./...
  • Kubernetes ServiceAccount JWT E2E
  • Real Google-issued JWT E2E
  • golangci-lint and repository verification checks

@SHRUTI6991

Copy link
Copy Markdown
Collaborator

I overall really like the idea of your PR and it honestly supersedes mine: #759 which is a much simpler version and focusses only on Human based authentication + K8 (only OIDC).

Some comments:

  1. In terms of developer experience, it is much easier for developers to run kubectl-ate --token $(gcloud auth print-identity-token) instead of taking a config file. Can you refactor this?

  2. Before routing to the correct provider, may be validate the JWT claims.

  3. Not related to the current PR. What's your thought on making a chain of authenticators? For standard OIDC JWTs, matching directly on the iss claim works great. If Substrate expands to support non-OIDC tokens (like static API keys or tokens without an iss claim), wrapping authenticators in an Authenticator chain interface might be a nice extensibility pattern down the road.

cc: Taahir Ahmed (@ahmedtd)

@EItanya
Eitan Yarmush (EItanya) marked this pull request as ready for review August 5, 2026 20:06
@EItanya

Eitan Yarmush (EItanya) commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

I overall really like the idea of your PR and it honestly supersedes mine: #759 which is a much simpler version and focusses only on Human based authentication + K8 (only OIDC).

Okie dokie, I'll post this a ready for review.

In terms of developer experience, it is much easier for developers to run kubectl-ate --token $(gcloud auth print-identity-token) instead of taking a config file. Can you refactor this?

This can leak tokens into the shell so isn't a great idea, what about stdin or something like that?

Before routing to the correct provider, may be validate the JWT claims.

👍

Not related to the current PR. What's your thought on making a chain of authenticators? For standard OIDC JWTs, matching directly on the iss claim works great. If Substrate expands to support non-OIDC tokens (like static API keys or tokens without an iss claim), wrapping authenticators in an Authenticator chain interface might be a nice extensibility pattern down the road.

Absolutely. Do you think we should do that in a follow-up?

@SHRUTI6991

Copy link
Copy Markdown
Collaborator

Eitan Yarmush (@EItanya) yes, STDIN sounds good to me.

Absolutely. Do you think we should do that in a follow-up?

I would consider this a P2 since we aren't currently working towards having any other authentication mechanisms except the JWT for now. Let me know Taahir Ahmed (@ahmedtd) if you think otherwise.

Comment on lines 102 to 109
ServiceAccountName string
ServiceAccountUID string
PodName string
PodUID string
SecretName string
SecretUID string
NodeName string
NodeUID string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Is it worthwhile to logically separate out the k8s-specific claims by placing them in a separate, inner struct, or just indicating their group with a comment?

if key == nil && len(v.keys) > 0 {
v.lastUnknownKeyRefresh = now
}
keys, err := discoverKeysForIssuer(ctx, v.httpClient, v.issuer)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once jwksRefreshInterval elapses, a token whose key may already be in the cache only verifies if this refresh succeeds. So a transient issuer outage 5+ minutes after the last refresh fails all JWT auth, even for keys we already hold.

I believe k8s' OIDC authenticator serves stale keys when refresh fails, should we do the same here?

Comment thread cmd/ateapi/internal/oidcjwt/oidcjwt.go Outdated
if key != nil && now.Sub(lastRefresh) < jwksRefreshInterval {
return key.PublicKey, nil
}
if key == nil && hasKeys && !lastUnknownKeyRefresh.IsZero() && now.Sub(lastUnknownKeyRefresh) < unknownKeyRefreshInterval {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we check hasKeys here, I believe this check does not guard against the case where the initial set of keys has not yet been fetched (possibly delayed due to an issuer outage), so each token validation would trigger a request to fetch keys unthrottled until the first set of keys is successfully fetched.

Should we implement a similar throttling mechanism to prevent unthrottled fetches before the initial set of keys is fetched?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed initial fetches are now rate-limited as well.

Comment on lines +171 to +173
if provider.Issuer != issuer {
continue
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Is it worth providing a log if no issuers match to make it easier to debug that case?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a debug-level unmatched-issuer log to avoid unauthenticated warning spam.

Comment thread cmd/ateapi/internal/oidcjwt/oidcjwt.go Outdated
Comment on lines +151 to +153
func Verify(ctx context.Context, httpClient *http.Client, jwt string, expectedIssuer, expectedAudience string, now time.Time) (*Claims, error) {
return NewVerifier(expectedIssuer, []string{expectedAudience}, httpClient).Verify(ctx, jwt, now)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC this is dead code now, since we call verifier.Verify in cmd/ateapi/main.go. Should we remove?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the unused package-level helper and updated tests to use the cached verifier.

return fmt.Errorf("at least one JWT provider is required")
}
for i, provider := range cfg.JWTProviders {
if provider.Name == "" || provider.Issuer == "" || provider.Verify == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also validate uniqueness of Issuer in the list of JWTProviders?

@SHRUTI6991

Copy link
Copy Markdown
Collaborator

Hi Eitan Yarmush (@EItanya), are you working on this PR?

@EItanya

Eitan Yarmush (EItanya) commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Hi Eitan Yarmush (Eitan Yarmush (@EItanya)), are you working on this PR?

Yup! I was focused on #708, but that’s in so I’ll go full steam on this now.

The GitHub CI situation yesterday really messed me up

…-auth

# Conflicts:
#	cmd/ateapi/internal/actoridentity/actoridentity.go
#	cmd/ateapi/internal/actoridentity/actoridentity_test.go
#	cmd/ateapi/main.go
@EItanya

Copy link
Copy Markdown
Collaborator Author

Addressed in the latest push: --token-file=- now reads one token from stdin. The unverified issuer only selects a configured verifier and grants no authority; signature, issuer, audience, subject, and time validation happen immediately afterward, since cryptographic validation requires first selecting the issuer keys. Agreed that authenticator chaining should remain a P2 follow-up when we add a non-JWT mechanism.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Human JWT Auth: Human user of substrate can authenticate to ate-api-server with a JWT issued by an external IDP

3 participants