Class ClientCredentialsExternalTokenProvider

java.lang.Object
co.ankatech.ankasecure.sdk.auth.ClientCredentialsExternalTokenProvider
All Implemented Interfaces:
ExternalTokenProvider

public final class ClientCredentialsExternalTokenProvider extends Object implements ExternalTokenProvider
Obtains an external token by performing the OAuth 2.0 client-credentials grant against the workload's OWN authorization server, and hands it to the federated assertion path.

This is the one source kind where the SDK holds a credential at a server that is not ANKASecure. Everything else in this package reads a token something else already minted; this one mints it. That makes this the point at which a customer's secret leaves the process, and the rules below are all consequences of that single fact.

No provider is named anywhere in here

Auth0, Entra, Okta, Keycloak and Cognito differ in exactly two things this class can see: the path their token endpoint lives at, and whether they want an audience parameter or a scope. The first is never guessed - it is read from the discovery document's own token_endpoint member, so a deployment on a vendor nobody tested works for the same reason a tested one does. The second is configuration: audience is an Auth0/Okta extension and scope is what Entra and Cognito want, each is sent when set and omitted when not, and neither is a branch on a vendor. A hardcoded /oauth/token would have passed against Auth0 and failed against Keycloak, which is exactly the shape of defect this avoids.

Counterpart: IssuerTransport in ankasecure-integration-identity

The platform already reaches external issuers, server-side, through identity.issuer.IssuerTransport. This is the workspace's FOURTH discovery implementation and it cannot be the same code - the SDK is a standalone client-layer artifact with no internal dependencies, and importing the integration tier to reuse a fetch would put that whole tier on a customer's classpath. What is reused is its rules, each adopted or diverged from deliberately:

  • Adopted - the same /.well-known/openid-configuration constant, appended to the configured issuer and to nothing else, so no response can influence the URL.
  • Adopted - the socket opens to an address, never to a name. There, through InternalAddressRules.resolvePinnedFetchTargets; here, through ExternalIssuerAdmission resolving once and IssuerHttpClient dialling a Dns that answers with those addresses and no others. Same property, same reason: a check that resolves without pinning what it resolved is a time-of-check/time-of-use hole rather than a control.
  • Adopted - the bound is applied on the stream and the declared length is never consulted. The body is read into a buffer one byte larger than the bound and overflowing it is the refusal, so an endless body behind a small Content-Length is refused for what arrived rather than for what was announced.
  • Adopted - no logger on this path. Every value in this exchange is written by whoever answered the socket, and a class with no logger cannot leak one into an appender.
  • Diverged - this one carries a credential. IssuerTransport deliberately sends none, because a discovery document and a published key set are public. Here the credential is the point, which is why the origin of the token endpoint is ENFORCED below rather than trusted.
  • Diverged - the transport is OkHttp rather than a hand-driven socket. The SDK already ships OkHttp, so a raw-socket implementation would be a second transport inside an artifact that has one. The total budget IssuerTransport spends as its own deadline is IssuerHttpClient.CALL_TIMEOUT here.
  • Diverged - no per-lane fetch allowance. That bound exists because a server-side transport is shared by every tenant, and an outsider spending the allowance would starve the deployment's own refreshes. A provider instance in a customer's process serves one workload; there is no second caller to starve.

The token endpoint's origin is decided BEFORE a request object exists

token_endpoint arrives inside a document a remote party writes. Refusing it at the interceptor would refuse it after DNS, TCP and a full TLS handshake with whatever host it names - a blind connect primitive against any host a response can choose. So the URL is put to ConfiguredOriginInterceptor.permits(HttpUrl) - the same predicate the client's own pin uses, never a second hand-written comparison - and to the addresses admission pinned, before anything is built. A third-origin token endpoint costs zero connections.

Caching, and exactly how much of it there is

The four guarantees are CachingTokenSource's, for the same reasons: reuse within validity, a sixty-second refresh skew, single-flight under one lock, and no poisoning of a cached token by a failed refresh. Memory only, no disk, no refresh token. The skew is CachingTokenSource.REFRESH_SKEW itself rather than a second constant of the same value: two declarations of one number are two numbers as soon as anybody edits one of them, and the rationale for this one - the flight time of the operation the token was fetched for - is the same rationale in both places.

The discovery document is cached for the life of this provider instance, which is not the same as once per process, and the difference is worth stating because the stronger claim is the easy one to assume. TokenSourceChain builds a provider per TokenSource and AnkaSecureSdk.authenticate() one per AuthenticatedSdk, so a script that starts a fresh JVM per file makes one discovery request and one token request per run - a thousand files are two thousand requests to the customer's own tenant, which is rate-limited and on some plans billed. A disk cache would remove that and is deliberately not built: it would put a document describing where credentials are posted into a file another process can write.

The cache is per credential, not per class

There is no static state here. Issuer, client id, audience and scope are final fields, so two providers differing only in audience are two caches. A cache keyed on anything less than the full tuple would hand a token minted for one audience to a call that asked for another.

  • Constructor Details

    • ClientCredentialsExternalTokenProvider

      public ClientCredentialsExternalTokenProvider(IssuerHttpClient issuerClient, String clientId, SecretChars clientSecret, String audience, String scope, Clock clock)
      Parameters:
      issuerClient - the pinned client for the admitted issuer. It carries the issuer as well, so the URL this provider fetches and the origin it is pinned to cannot disagree; must not be null
      clientId - the client id registered at the EXTERNAL authorization server - not the ANKASecure actor id; must not be null or blank
      clientSecret - the secret for that registration. A defensive copy is taken, so the caller keeps ownership of theirs and this provider zeroizes its own on close(); must not be null or empty
      audience - the audience parameter, or null when the authorization server does not use one
      scope - the scope parameter, or null when none is configured
      clock - the clock expiry is evaluated against; the system UTC clock in production
      Throws:
      NullPointerException - if a required argument is null
      IllegalArgumentException - if the client id is blank or the secret is empty
  • Method Details

    • fetchExternalToken

      public SecretChars fetchExternalToken()
      Description copied from interface: ExternalTokenProvider
      Produces the current external token.

      How current it is belongs to the implementation, and the two answers are both correct for their case. A provider that READS an out-of-band token re-reads on every call, because something else refreshes it and a copy held here is a copy that expires without anyone noticing. A provider that OBTAINS the token itself knows the lifetime its issuer stated for it, so it may reuse one inside that lifetime - and when the issuer states no lifetime, it falls back to fetching per call rather than guessing one.

      Specified by:
      fetchExternalToken in interface ExternalTokenProvider
      Returns:
      the token; the caller takes ownership and closes it
    • kind

      public ExternalTokenSourceKind kind()
      The source kind this provider presents, as a declared mapping rather than a name.
      Specified by:
      kind in interface ExternalTokenProvider
      Returns:
      ExternalTokenSourceKind.CLIENT_CREDENTIALS; never null
    • invalidate

      public void invalidate()
      Discards the cached token so the next call fetches a fresh one.

      Called when ANKASecure refuses the assertion this token produced. The discovery document is kept: it is not a credential, and it did not become wrong because a token did.

      Specified by:
      invalidate in interface ExternalTokenProvider
    • close

      public void close()
      Zeroizes the external client secret and any cached token, and releases the issuer client. Idempotent; afterwards fetchExternalToken() throws.

      It can block. The lock it takes is the one an in-flight exchange holds, and an exchange is bounded by IssuerHttpClient.CALL_TIMEOUT per call with at most two calls in it - a discovery fetch and a token POST - so a close() racing an exchange against an unresponsive authorization server waits up to roughly forty seconds. That is deliberate: returning early would mean returning while another thread still holds the secret, which is the one thing this method exists to end.

      The issuer client is closed too. It exists for this provider and for nothing else - the chain builds one per resolved credential - and its connection pool holds a keep-alive TLS connection to a THIRD-PARTY authorization server, so a session that ended while the pool survived would leave that connection, and the dispatcher's non-daemon threads, behind for the life of the JVM.

      Specified by:
      close in interface ExternalTokenProvider
    • describe

      public String describe()
      The issuer, which is the diagnostic an operator needs; never the client id or the secret.
      Specified by:
      describe in interface ExternalTokenProvider
      Returns:
      a description naming the issuer alone; never null
    • toString

      public String toString()
      Overrides:
      toString in class Object
    • hasCachedToken

      public boolean hasCachedToken()
      Whether a token is currently cached. Reveals no token value.
      Returns:
      true when a token is held