Class: KnoxCall::WorkloadCredentialProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/knoxcall/workload_provider.rb

Overview

Workload-identity credential provider — WIF plan Phase 4.3.

Mirrors auth/workload-provider.ts in the Node SDK; sdk/PARITY.md is the authoritative contract for every language.

exchange_token is one-shot: it trades one OIDC assertion for one capability token and hands the caller an expires_in to manage. That is fine for a script that makes one call and exits, and wrong for anything long-lived — a Sidekiq worker, a long CI job, an agent process — where the token silently expires mid-run and the caller discovers it as a 401 they then have to interpret.

This provider owns that lifecycle: cache the token, refresh it before it dies, and never hand out one that is about to expire.

The part that is not like other refresh loops

A KnoxCall workload assertion is SINGLE-USE. The exchange spends the whole assertion — the server claims a hash of it before minting (WIF Phase 1.2), so presenting the same bytes twice is refused with "subject_token has already been exchanged". A refresh therefore cannot re-send the assertion it used last time; it needs a FRESH one from the platform every single time.

That makes the obvious implementation — capture the assertion once, reuse it on refresh — not merely suboptimal but broken, and broken in a way that only shows up when the first refresh fires, i.e. minutes into production rather than in anyone's smoke test. So the provider takes a SOURCE it calls before every exchange, and refuses to send an assertion whose bytes it has already spent (StaleAssertionError). It fails loudly at the real cause rather than forwarding a doomed request and surfacing the server's replay refusal, which reads as "my credentials were rejected".

The two-tier schedule

ADVISORY (expiry − 120s): refresh opportunistically. If it fails, the token in hand is still valid, so the caller is served and the failure is a warning, not an exception. A transient blip near a refresh boundary must not take down a worker that has two minutes of perfectly good credential left.

MANDATORY (expiry − 30s): refresh or raise. Below this line the token may die in flight — between the provider handing it over and the request reaching the server — and a 401 from an expired capability token is exactly the confusing failure this provider exists to prevent.

The gap between the two tiers is the whole point: it buys 90 seconds in which a failing token source or a flaky network is survivable rather than fatal.

provider = KnoxCall::WorkloadCredentialProvider.new(
assertion: -> { File.read(ENV.fetch("AWS_WEB_IDENTITY_TOKEN_FILE")) },
tenant: "acme"
)
headers["Authorization"] = "Bearer #{provider.access_token}"

Thread-safe: a Mutex gives single-flight semantics, which matters more here than in an ordinary refresh loop — each exchange spends an assertion, and a thundering herd would burn N of them and have N−1 refused.

Constant Summary collapse

ADVISORY_REFRESH_SECONDS =

Refresh opportunistically below this much remaining life; failure is survivable.

120
MANDATORY_REFRESH_SECONDS =

Refresh or raise below this much remaining life; the token may die in flight.

30

Instance Method Summary collapse

Constructor Details

#initialize(assertion:, resource: nil, audience: KNOXCALL_AUDIENCE, tenant: nil, sandbox: false, base_url: nil, timeout: 30, clock: -> { Time.now.to_f }) ⇒ WorkloadCredentialProvider

Returns a new instance of WorkloadCredentialProvider.

Parameters:

  • called before EVERY exchange; must return a FRESH assertion each time. On GitHub Actions a fetch of ACTIONS_ID_TOKEN_REQUEST_URL; on EKS a read of the projected token file. Returning a value captured once at startup is the failure this provider detects rather than tolerates.

  • (defaults to: nil)

    RFC 8707 resource indicator. nil means "not asked for"; an EMPTY string is sent through and refused invalid_target, because dropping it silently would mint an UNCONFINED token while the caller believes it is confined.

  • (defaults to: KNOXCALL_AUDIENCE)

    defaults to KNOXCALL_AUDIENCE

  • (defaults to: nil)

    tenant slug — one of tenant or base_url is REQUIRED: /v1/oauth/token is served only on the tenant data-plane host.

  • (defaults to: false)

    the Test data space. Carried through verbatim: dropping it would send a Test-mode workload's assertion to the Live host, where it matches no binding.

  • (defaults to: nil)

    full data-plane origin; wins over tenant

  • (defaults to: 30)

    per-request timeout in seconds

  • (defaults to: -> { Time.now.to_f })

    test seam returning epoch seconds as a Float



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/knoxcall/workload_provider.rb', line 89

def initialize(assertion:, resource: nil, audience: KNOXCALL_AUDIENCE,
               tenant: nil, sandbox: false, base_url: nil, timeout: 30,
               clock: -> { Time.now.to_f })
  unless assertion.respond_to?(:call)
    raise ArgumentError, "WorkloadCredentialProvider needs an `assertion:` that responds to " \
                         "#call and returns the workload's CURRENT OIDC id_token. KnoxCall " \
                         "assertions are single-use, so it is called before every exchange."
  end
  # Fail at construction rather than at the first refresh, which may be
  # minutes into a long-running process.
  KnoxCall.exchange_base_url(tenant, sandbox, base_url)

  @assertion = assertion
  @resource = resource
  @audience = audience
  @tenant = tenant
  @sandbox = sandbox
  @base_url = base_url
  @timeout = timeout
  @clock = clock
  @mutex = Mutex.new
  @token = nil
  @expires_at = 0.0
  # SHA-256 of every assertion this provider has spent. Never the assertion.
  @spent = {}
end

Instance Method Details

#access_token ⇒ String

A capability token with more than MANDATORY_REFRESH_SECONDS of life left.

Returns:

  • the capability token

Raises:

  • when the source returns spent or empty bytes

  • on a refusal inside the mandatory window

  • on a transport failure inside the mandatory window



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/knoxcall/workload_provider.rb', line 122

def access_token
  @mutex.synchronize do
    remaining = @token ? @expires_at - @clock.call : -1.0

    return @token if @token && remaining > ADVISORY_REFRESH_SECONDS

    if @token && remaining > MANDATORY_REFRESH_SECONDS
      # ADVISORY tier: try, but the token in hand is still good.
      begin
        return refresh
      rescue StandardError => e
        # best-effort: the caller still has a valid credential, and raising
        # here would convert a survivable blip into an outage. The MANDATORY
        # tier raises for real if the condition persists.
        Warnings.warn_once(
          "KNOXCALL_WORKLOAD_ADVISORY_REFRESH",
          "KnoxCall: advisory token refresh failed (#{e.message}); continuing with the " \
          "current token, which expires in #{remaining.round}s"
        )
        return @token
      end
    end

    # MANDATORY tier, or nothing cached at all.
    refresh
  end
end