Class ClientCredentialsExternalTokenProvider
- All Implemented Interfaces:
ExternalTokenProvider
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-configurationconstant, 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, throughExternalIssuerAdmissionresolving once andIssuerHttpClientdialling aDnsthat 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-Lengthis 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.
IssuerTransportdeliberately 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
IssuerTransportspends as its own deadline isIssuerHttpClient.CALL_TIMEOUThere. - 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 Summary
ConstructorsConstructorDescriptionClientCredentialsExternalTokenProvider(IssuerHttpClient issuerClient, String clientId, SecretChars clientSecret, String audience, String scope, Clock clock) -
Method Summary
Modifier and TypeMethodDescriptionvoidclose()Zeroizes the external client secret and any cached token, and releases the issuer client.describe()The issuer, which is the diagnostic an operator needs; never the client id or the secret.Produces the current external token.booleanWhether a token is currently cached.voidDiscards the cached token so the next call fetches a fresh one.kind()The source kind this provider presents, as a declared mapping rather than a name.toString()
-
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 benullclientId- the client id registered at the EXTERNAL authorization server - not the ANKASecure actor id; must not benullor blankclientSecret- the secret for that registration. A defensive copy is taken, so the caller keeps ownership of theirs and this provider zeroizes its own onclose(); must not benullor emptyaudience- theaudienceparameter, ornullwhen the authorization server does not use onescope- thescopeparameter, ornullwhen none is configuredclock- the clock expiry is evaluated against; the system UTC clock in production- Throws:
NullPointerException- if a required argument isnullIllegalArgumentException- if the client id is blank or the secret is empty
-
-
Method Details
-
fetchExternalToken
Description copied from interface:ExternalTokenProviderProduces 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:
fetchExternalTokenin interfaceExternalTokenProvider- Returns:
- the token; the caller takes ownership and closes it
-
kind
The source kind this provider presents, as a declared mapping rather than a name.- Specified by:
kindin interfaceExternalTokenProvider- Returns:
ExternalTokenSourceKind.CLIENT_CREDENTIALS; nevernull
-
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:
invalidatein interfaceExternalTokenProvider
-
close
public void close()Zeroizes the external client secret and any cached token, and releases the issuer client. Idempotent; afterwardsfetchExternalToken()throws.It can block. The lock it takes is the one an in-flight exchange holds, and an exchange is bounded by
IssuerHttpClient.CALL_TIMEOUTper call with at most two calls in it - a discovery fetch and a token POST - so aclose()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:
closein interfaceExternalTokenProvider
-
describe
The issuer, which is the diagnostic an operator needs; never the client id or the secret.- Specified by:
describein interfaceExternalTokenProvider- Returns:
- a description naming the issuer alone; never
null
-
toString
-
hasCachedToken
public boolean hasCachedToken()Whether a token is currently cached. Reveals no token value.- Returns:
truewhen a token is held
-