Skip to content

uhttp: allow OAuth2JWT.CreateJWTConfig to be re-invoked per token fetch - #1100

Draft
sergiocorral-conductorone wants to merge 1 commit into
mainfrom
sergiocorral/oauth2jwt-per-fetch-config
Draft

uhttp: allow OAuth2JWT.CreateJWTConfig to be re-invoked per token fetch#1100
sergiocorral-conductorone wants to merge 1 commit into
mainfrom
sergiocorral/oauth2jwt-per-fetch-config

Conversation

@sergiocorral-conductorone

Copy link
Copy Markdown

Motivation

OAuth2JWT.GetClient (RFC 7523 two-legged JWT-bearer OAuth) invoked
CreateJWTConfig exactly once and baked the resulting *jwt.Config
including its PrivateClaims map — into an oauth2.ReuseTokenSource for the
client's whole lifetime. Any connector whose token endpoint requires a fresh
per-request claim in PrivateClaims — most commonly a jti nonce for
replay protection, a real recurring pattern for RFC 7523 JWT-bearer APIs — got
the same static claim on every token refresh after the first, defeating the
nonce.

This was discovered blocking a real connector (baton-oracle-fccs) from
using OAuth2JWT as designed.

Fix

Wrap the token source in a small reCreatingJWTSource that rebuilds the
*jwt.Config via CreateJWTConfig on each Token() call, then wrap that in
oauth2.ReuseTokenSource. The result: CreateJWTConfig is re-invoked on each
token acquisition (when the cached token has expired), not once at
GetClient-time — while normal token caching between expirations is preserved.

Backward compatibility

  • CreateJWTConfig and NewOAuth2JWT signatures are unchanged — no
    exported surface change.
  • Callers whose CreateJWTConfig returns a static config behave identically
    (idempotent rebuild = same result), and still get cached tokens between
    expirations.
  • Callers can now emit per-fetch dynamic claims (e.g. a fresh jti) simply by
    generating them inside their existing callback.

One minor behavior note: an error from CreateJWTConfig now surfaces on the
first token fetch rather than at GetClient time (the token source is lazy).
The error is still wrapped with the same "creating JWT config failed" message.

Tests

Added three unit tests in pkg/uhttp/authcredentials_test.go:

  • ReinvokesCreateJWTConfig — with a short-lived token, CreateJWTConfig
    is invoked more than once across multiple fetches.
  • FreshJTIPerFetch — a callback that stamps a fresh PrivateClaims["jti"]
    produces a distinct jti in each assertion the token endpoint receives
    (decoded from the signed JWT).
  • CachesTokenForStaticConfig — with a long-lived token, CreateJWTConfig
    runs exactly once and the token endpoint is hit once across several requests,
    proving ReuseTokenSource caching is intact for the common case.

go build ./..., go vet ./pkg/uhttp/..., gofmt, and the full pkg/uhttp
suite all pass. (An unrelated pre-existing flake, TestC1ZConcurrentClose in
pkg/dotc1z, passes on isolated re-run and is untouched by this change.)


⚠️ baton-sdk is human-merge-only — this PR needs human review and merge; it
was opened by an automated assistant and must not be self-merged or
auto-approved.

🤖 Generated with Claude Code

Previously OAuth2JWT.GetClient called CreateJWTConfig exactly once and baked
the resulting *jwt.Config (including its PrivateClaims map) into the
oauth2.ReuseTokenSource for the client's whole lifetime. Any connector whose
token endpoint requires a fresh per-request claim in PrivateClaims -- most
commonly a jti nonce for RFC 7523 replay protection -- got the SAME static
claim on every token refresh after the first, defeating the nonce.

Wrap the token source in a reCreatingJWTSource that rebuilds the *jwt.Config
via CreateJWTConfig on each Token() call, then wrap that in
oauth2.ReuseTokenSource so normal caching is preserved: CreateJWTConfig is
only re-invoked when the cached token has expired, not on every HTTP request.

Backward compatible: the CreateJWTConfig and NewOAuth2JWT signatures are
unchanged; callers returning a static config behave identically (idempotent
rebuild = same result), while callers can now emit per-fetch dynamic claims
(e.g. a fresh jti) from inside their callback.

Found via baton-oracle-fccs needing a per-request jti nonce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +153 to +165
func (o *OAuth2JWT) GetClient(ctx context.Context, options ...Option) (*http.Client, error) {
httpClient, err := getHttpClient(ctx, options...)
if err != nil {
return nil, fmt.Errorf("creating JWT config failed: %w", err)
return nil, err
}

ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
ts := jwt.TokenSource(ctx)
ts := oauth2.ReuseTokenSource(nil, reCreatingJWTSource{
ctx: ctx,
createfn: o.CreateJWTConfig,
credentials: o.Credentials,
scopes: o.Scopes,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: GetClient no longer surfaces CreateJWTConfig failures — the callback is now only reached lazily from Token(), so malformed credentials produce a non-nil client and a nil error, and a nil CreateJWTConfig panics inside client.Do instead of at construction. Connectors that call GetClient in New() and rely on it to reject bad config lose their fail-fast check; a connector whose Validate() makes no HTTP call would now report invalid credentials as valid. Consider invoking the callback once eagerly to keep the old contract while still rebuilding per fetch:

	if _, err := o.CreateJWTConfig(o.Credentials, o.Scopes...); err != nil {
		return nil, fmt.Errorf("creating JWT config failed: %w", err)
	}

	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)

(Confidence: high that the behavior changed; medium on downstream impact.)

Comment on lines +132 to +137
// reCreatingJWTSource is an oauth2.TokenSource that rebuilds the *jwt.Config via
// CreateJWTConfig on every token fetch. This lets callers inject per-request
// claims (e.g. a fresh jti nonce in PrivateClaims for RFC 7523 replay
// protection) that must change on each token acquisition. Wrapping this in an
// oauth2.ReuseTokenSource preserves normal token caching: CreateJWTConfig is
// only re-invoked when the cached token has expired, not on every HTTP request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: the new "invoked per token acquisition" contract is documented on the unexported reCreatingJWTSource, where callers won't see it. The exported CreateJWTConfig type (line 114) silently changed from called-exactly-once to called-once-per-token-acquisition; a callback that fetches a signing key from a secret manager, rotates a key, or otherwise has side effects now runs repeatedly for the life of the client. Please move/duplicate a // Deprecated-style contract note onto type CreateJWTConfig and OAuth2JWT.CreateJWTConfig stating that it must be safe to call repeatedly, and flag the default-behavior change in the release notes / pkg/sdk/version.go bump per the repo's compatibility criteria. (Confidence: medium.)

Comment on lines +213 to +225
func jwtBearerServer(t *testing.T, expiresIn int) (*httptest.Server, *[]string) {
t.Helper()
assertions := make([]string, 0)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
require.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", r.FormValue("grant_type"))
assertions = append(assertions, r.FormValue("assertion"))

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{"access_token": "test-access-token", "token_type": "bearer", "expires_in": %d}`, expiresIn)))
}))
return server, &assertions
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Suggestion: assertions is appended from the httptest handler goroutine and read from the test goroutine (require.Len(t, *assertions, ...)) with no synchronization — there's no formal happens-before edge across the socket, so this can trip -race. Also, require.* inside a handler goroutine calls t.FailNow()/runtime.Goexit() off the test goroutine, which testify documents as unsupported (it kills the handler and leaves the test hanging on a truncated response rather than failing cleanly). Guarding the slice with a sync.Mutex and returning a func() []string snapshot accessor, plus switching the in-handler checks to assert.*, makes the helper race-free. (Confidence: medium on the race tripping -race, high on the require-in-goroutine issue.)

@github-actions

Copy link
Copy Markdown
Contributor

General PR Review: uhttp: allow OAuth2JWT.CreateJWTConfig to be re-invoked per token fetch

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 5c68d27c8480.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (pkg/uhttp/authcredentials.go + its test file; no dependency, proto, or generated-artifact changes) for security and correctness. The core mechanism is sound: oauth2.ReuseTokenSource(nil, reCreatingJWTSource{...}) is behaviorally equivalent per-fetch to the previous jwt.Config.TokenSource(ctx) (which is itself a ReuseTokenSource(nil, jwtSource{...})), so token caching is genuinely preserved and the three new tests exercise the re-invoke, fresh-jti, and static-config-caching paths correctly. No blocking issues; three suggestions concern the deferred CreateJWTConfig error, the undocumented contract change on the exported callback type, and a test-helper data race.

Risk triage (per docs/BUG_CATCHING.md §2) — MEDIUM, no escalation requested:

  • Silence: mostly no — a bad token fetch is a loud auth error. One quiet edge: the deferred CreateJWTConfig error means a connector with a no-op Validate() could report malformed credentials as valid.
  • Durability: no — nothing is persisted; no c1z, sync-token, or wire-format effect.
  • Uncontrolled dimensions: mild — token-expiry timing only; concurrency is serialized by ReuseTokenSource's mutex, and there is no cost-curve change (no grant expansion, compaction, checkpoint loop, or open-time migration touched).
  • Consumer distance: yes — every downstream connector using OAuth2JWT.
  • Consequence: remediation rung 1–2 (redeploy). Two escape axes at most, non-durable, no version-pair dependence → MEDIUM, not HIGH.

Security Issues

None found. No credential material is logged; the jti/assertion decoding in tests is unverified-payload parsing of self-generated JWTs, which is fine for test assertions.

Correctness Issues

None found. Verified against vendored golang.org/x/oauth2: defaultExpiryDelta is 10s (vendor/golang.org/x/oauth2/token.go:22), so the expires_in: 1 fixtures make every cached token deterministically stale, and jwt.Config.PrivateClaims does flow into the signed assertion (vendor/golang.org/x/oauth2/jwt/jwt.go:110) — both tests test what they claim to.

Suggestions

  • pkg/uhttp/authcredentials.go:153-165GetClient no longer fails fast on CreateJWTConfig errors (or on a nil callback, which now panics inside client.Do); consider one eager invocation to preserve the old construction-time contract.
  • pkg/uhttp/authcredentials.go:114,132-137 — the "called once per token acquisition" contract is documented only on the unexported reCreatingJWTSource; the exported CreateJWTConfig type should say it must be safe to call repeatedly, and this default-behavior change warrants a pkg/sdk/version.go bump / release note.
  • pkg/uhttp/authcredentials_test.go:213-225jwtBearerServer appends to assertions from the handler goroutine while the test reads it unsynchronized (-race risk), and uses require.* off the test goroutine.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Security Issues

None.

## Correctness Issues

None.

## Suggestions

In `pkg/uhttp/authcredentials.go`:
- Around line 153-165: `GetClient` previously called `o.CreateJWTConfig` eagerly and
  returned its error; now the callback is only reached lazily from
  `reCreatingJWTSource.Token()`, so malformed credentials yield a non-nil client with a
  nil error, and a nil `CreateJWTConfig` panics inside `client.Do` rather than at
  construction. Restore fail-fast by invoking the callback once at the top of
  `GetClient` (right after `getHttpClient` succeeds) and returning
  `fmt.Errorf("creating JWT config failed: %w", err)` on failure, while still installing
  the `reCreatingJWTSource` for per-fetch rebuilds. Add a test asserting that
  `GetClient` returns an error when `CreateJWTConfig` returns one.
- Around line 114 and 132-137: the new "invoked once per token acquisition" contract is
  documented only on the unexported `reCreatingJWTSource`. Add a doc comment on the
  exported `type CreateJWTConfig func(...)` (and on the `OAuth2JWT.CreateJWTConfig`
  field) stating that the function may be called repeatedly — once per token
  acquisition — and must therefore be cheap and free of side effects. Also bump
  `pkg/sdk/version.go` (0.x minor, currently v0.24.6) and add a migration note, since
  this is a default-behavior change on an exported SDK surface used by downstream
  connectors.

In `pkg/uhttp/authcredentials_test.go`:
- Around line 213-225: `jwtBearerServer` appends to the `assertions` slice from the
  httptest handler goroutine while the test goroutine reads it via the returned
  `*[]string`, with no synchronization — this can trip `go test -race`. Guard the slice
  with a `sync.Mutex` and return a snapshot accessor (`func() []string` that locks and
  copies) instead of `*[]string`, then update the three new tests to call it. Also
  replace the `require.Equal` / `require.NoError` calls inside the handler with
  `assert.*`, since `require` calls `t.FailNow()` -> `runtime.Goexit()` on a non-test
  goroutine, which testify documents as unsupported and which truncates the HTTP
  response instead of failing the test cleanly.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

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.

1 participant