Skip to content
TDACorpIDENTITYSign in

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 tokenExchanged token
audthe caller (A)the target (B)
azpnot setthe caller (A)
cnf.jktA's DPoP keythe same key
Lifetimeat most 15 minutesat 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 service client linked to a service identity. The client_credentials step refuses a client with none with unauthorized_client, and the SDK always takes that step first. A service client may hold only client_credentials and token_exchange. TDACorp sets this up when it registers the client: see Get a client registered.
  • Both your client and the target hold the token_exchange grant. The target is a registered client even if it only serves an API, and an audience without the grant is refused with invalid_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_credentials token 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_credentials token must still be current. If the principal's authority has changed, the exchange answers invalid_grant.
  • If the token's organization enforces single sign-on (the sso_enforcement flag, which defaults OFF) and the session behind the subject token holds no recent enough assurance, the exchange answers 400 insufficient_user_authentication with acr_values and max_age. A plain service token has no session and is not affected.
  • Never send resource. The token endpoint refuses it on client_credentials and on exchange with invalid_target. The target is named by audience only.

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, OAuthEndpointError carries retryAfterSeconds for the 429.
  • exchangeToken is the bare, uncached exchange and accepts an optional actorToken. Actor tokens are not covered on this page.

Verified against commit ca46b57