Service to service
How one service calls another with a client_credentials token and an RFC 8693 exchange, and what each side must be registered for.
On this page
A service has no browser and no person. It proves who it is with its key, receives a token for itself, and then trades that token for one addressed to the service it wants to call. The SDK does both steps behind one call.
Why there are two steps#
A client_credentials token is addressed to its own client: aud is the caller's client id. A resource server must refuse a token that is not addressed to it, so service B refuses A's client_credentials token. To call B, A exchanges it (RFC 8693) for a token whose aud is B.
client_credentials token | Exchanged token | |
|---|---|---|
aud | the caller (A) | the target (B) |
azp | not set | the caller (A) |
cnf.jkt | A's DPoP key | the same key |
| Lifetime | at most 15 minutes | at most 15 minutes, and never longer than the token it was made from |
Call another service#
createServiceClient takes the same client key and DPoP key as a relying party. tokenFor(audience) mints and exchanges, caches the result and shares one in-flight request between concurrent callers. headersFor builds Authorization and, for a bound token, DPoP.
import { createServiceClient } from '@tdacorp/identity-sdk/service'import { importDpopKeyPair } from '@tdacorp/identity-sdk/rp'const clientKey = JSON.parse(process.env.TDAID_CLIENT_KEY_JWK ?? '{}')const dpopKey = JSON.parse(process.env.TDAID_DPOP_KEY_JWK ?? '{}')const service = createServiceClient({ issuer: 'https://identity.tdacorp.in', clientId: process.env.TDAID_CLIENT_ID ?? '', key: { kid: process.env.TDAID_CLIENT_KID ?? '', privateKey: clientKey }, dpop: await importDpopKeyPair(dpopKey),})// Calls service B. `audience` is B's client id.export async function callServiceB(audienceClientId: string): Promise<Response> { const token = await service.tokenFor(audienceClientId) // client_credentials, then an RFC 8693 exchange; cached const url = 'https://b.example.com/things' return fetch(url, { headers: await service.headersFor(token, { method: 'GET', url }) })}tokenFor('self'), or your own client id, returns the client_credentials token itself. A cached token is replaced 30 seconds before it expires (skewSeconds). If the exchange answers invalid_grant because the principal's authority changed since the cached token was minted, tokenFor drops the cached token and retries once.
What must be registered#
A refused exchange almost always means a registration is missing. Each rule below is enforced by the token endpoint.
- Your client is a
serviceclient linked to a service identity. Theclient_credentialsstep refuses a client with none withunauthorized_client, and the SDK always takes that step first. Aserviceclient may hold onlyclient_credentialsandtoken_exchange. TDACorp sets this up when it registers the client: see Get a client registered. - Both your client and the target hold the
token_exchangegrant. The target is a registered client even if it only serves an API, and an audience without the grant is refused withinvalid_target. Ask for the grant on both registrations. - A service client's tokens are always bound to a key. A service client that does not prove a DPoP key at the token endpoint is refused with
invalid_client, so give the SDK a DPoP key: see DPoP. - The target client can enter the token's organization: it belongs to no organization, it is that organization's own client, or its product is installed there. Anything else is refused with
invalid_target. A token that carries no organization reaches only a target that belongs to none. - The exchange is signed by the same DPoP key the
client_credentialstoken is bound to. The SDK uses one key for both steps. - Scope never widens. The exchanged token carries at most the scopes of your token that the target allows. A request outside that is
invalid_scope. - The
client_credentialstoken must still be current. If the principal's authority has changed, the exchange answersinvalid_grant. - If the token's organization enforces single sign-on (the
sso_enforcementflag, which defaults OFF) and the session behind the subject token holds no recent enough assurance, the exchange answers400 insufficient_user_authenticationwithacr_valuesandmax_age. A plain service token has no session and is not affected. - Never send
resource. The token endpoint refuses it onclient_credentialsand on exchange withinvalid_target. The target is named byaudienceonly.
If you omit scope on client_credentials, you receive every scope the client is allowed.
The receiving side#
The target service verifies the exchanged token with createResourceServer, using its own client id as the audience. See Protect your API.
Limits#
- Caching is per instance. On a serverless platform every instance keeps its own cache, which is correct and a little wasteful.
- The SDK retries a 429 a bounded number of times and never a 5xx. When the attempts run out,
OAuthEndpointErrorcarriesretryAfterSecondsfor the 429. exchangeTokenis the bare, uncached exchange and accepts an optionalactorToken. Actor tokens are not covered on this page.
Related#
- The two steps one at a time: Client credentials and Token exchange.
- Rate limits and retries for what to do when identity answers 429.