Delegated Access for Salesforce
Delegated access lets Salesforce see who is really calling through DataGrout.
By default, every upstream call an integration makes uses the OAuth token the server owner stored when they connected Salesforce. Salesforce therefore sees the owner on every request, whatever agent triggered it — impersonation, from the backend’s point of view, with nothing in the request that names the agent.
With delegated access switched on for an integration, DataGrout instead performs an RFC 8693 token exchange before each upstream call:
- DataGrout mints a short-lived, signed subject assertion naming the server owner (the person the call is for), and a signed actor assertion naming the authenticated MCP principal (the agent acting on their behalf).
- It POSTs both to your Salesforce org’s token endpoint with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange. - Your org runs its Apex token exchange handler, which validates the assertions against DataGrout’s published JWKS, maps the subject to a Salesforce user, records the actor, and issues an access token for that user.
- DataGrout makes the upstream call with the token Salesforce returned — never with the owner’s stored token.
Where delegation is not configured, nothing changes.
Modes
| Mode | Behaviour |
|---|---|
off |
Default. The owner’s stored token is used. No exchange is attempted. |
preferred |
Exchange is attempted. If it fails, the call falls back to the owner’s token and a warning is logged. |
required |
Exchange is attempted. If it fails, the call fails with a structured error. The owner’s token is never used. |
Two cases are worth knowing in every mode:
- No agent on the call. A direct human run (the Sandbox, the JSON-RPC
inspector, a script) carries no MCP principal. There is no actor to assert, so
the owner’s token is used, in
requiredmode too. Delegation describes how agents reach Salesforce; it does not stop the owner using their own connection. - Unauthenticated agent. On a server with authentication disabled the
principal is
none. That is not an identifiable actor:preferredfalls back to the owner’s token,requiredrefuses the call.
Configuration
Delegation is configured per server, per integration, alongside the integration’s OAuth client credentials (encrypted at rest). The settings:
{
"mode": "required",
"token_endpoint": "https://login.salesforce.com/services/oauth2/token",
"client_id": "3MVG9...",
"client_secret": "...",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"subject_claim": "email",
"assertion_alg": "ES256",
"send_actor": true,
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"scope": "api",
"audience": "https://yourorg.my.salesforce.com"
}
| Key | Meaning |
|---|---|
mode |
off, preferred or required (see above). |
token_endpoint |
Your org’s token endpoint. Use your My Domain URL (https://yourorg.my.salesforce.com/services/oauth2/token) or https://login.salesforce.com/services/oauth2/token; https://test.salesforce.com/... for sandboxes. Must be https://. |
client_id / client_secret |
The connected app (or External Client App) that performs the exchange. Optional here: when omitted, the integration’s own OAuth client credentials are used — normally the same app that owns the Salesforce connection. |
subject_token_type |
Always urn:ietf:params:oauth:token-type:jwt for DataGrout assertions. |
subject_claim |
Which attribute of the server owner becomes the subject’s sub: email, user_id (DataGrout’s numeric id) or federation_id (from the owner’s profile metadata). Your Apex handler maps this to a Salesforce user, so pick the one your org can look up (Email or FederationIdentifier on User). |
assertion_alg |
Which key signs the assertions: ES256 (ECDSA P-256 — the default, and the only choice that works with Salesforce, because Apex’s Crypto.verify supports RSA and ECDSA but not EdDSA) or EdDSA (Ed25519, for a non-Salesforce RFC 8693 server that prefers it). There is no fallback between them: if ES256 is configured and DataGrout has no assertion key, the exchange fails like any other failure — preferred falls back to the owner token, required refuses. A relying party that pins ES256 is never handed an EdDSA token. |
send_actor |
Send the actor assertion (actor_token / actor_token_type). Leave on; that is the point. Turn off only if your handler cannot accept an actor token yet. |
requested_token_type |
urn:ietf:params:oauth:token-type:access_token. |
scope |
Optional OAuth scopes to request (space separated). Salesforce requires it to be a subset of the app’s assigned scopes. |
audience |
Optional. Sets the aud of both assertions and is sent as the audience parameter. Defaults to the token endpoint URL. |
token_handler |
Optional, Salesforce-specific. The API name of the Apex token exchange handler to run. Omitted, Salesforce uses the app’s default handler (one must exist). |
cache_ttl_seconds |
Optional. Salesforce’s token response carries no expires_in, so by default an exchanged token is not cached and every call exchanges. Set this (e.g. 1800) to reuse the token for that long. Keep it below your org’s session timeout. |
Programmatically, the settings live in
DataGrout.UserServers.Delegation:
alias DataGrout.UserServers.Delegation
{:ok, cfg} =
Delegation.put_config(scope, server_uuid, "salesforce", %{
"mode" => "required",
"token_endpoint" => "https://yourorg.my.salesforce.com/services/oauth2/token",
"subject_claim" => "email"
})
Delegation.get_config(server_uuid, "salesforce")
What DataGrout sends
A form-encoded POST to token_endpoint:
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<subject assertion JWT>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
actor_token=<actor assertion JWT> (when send_actor)
actor_token_type=urn:ietf:params:oauth:token-type:jwt
requested_token_type=urn:ietf:params:oauth:token-type:access_token
client_id=<client_id>
client_secret=<client_secret> (when set)
scope=<scope> (when set)
audience=<audience> (when set)
token_handler=<handler API name> (when set; Salesforce)
Salesforce documents grant_type, subject_token, subject_token_type,
client_id, client_secret, scope and token_handler. The other parameters
are standard RFC 8693 and are ignored by Salesforce. Salesforce caps
subject_token at 10,000 characters; DataGrout’s assertions are well under 1 KB.
Both assertions are ES256 (ECDSA P-256) JWTs by default, signed with
DataGrout’s dedicated assertion key, valid for 300 seconds, each with a
unique jti. They are never stored. Setting assertion_alg to EdDSA signs
them with the Ed25519 gateway key instead — usable by a standards-compliant
RFC 8693 server, but not by a Salesforce Apex handler.
Subject assertion claims:
{
"iss": "https://gateway.datagrout.ai/servers/<server uuid>",
"sub": "owner@example.com",
"aud": "<audience or token endpoint>",
"iat": 1757347200,
"exp": 1757347500,
"jti": "…",
"token_use": "subject",
"subject_claim": "email",
"email": "owner@example.com",
"integration": "salesforce",
"server_uuid": "<server uuid>",
"act": { "sub": "<agent id>", "kind": "oauth_client" }
}
The act claim is RFC 8693 §4.1’s nested actor. It is there because
Salesforce’s handler only receives the subject token — actor_token is not
among the parameters its token endpoint documents — so this is how your Apex
handler learns which agent made the call.
Actor assertion claims (sent as actor_token; standards-compliant RFC 8693
servers use it, Salesforce ignores it):
{
"iss": "https://gateway.datagrout.ai/servers/<server uuid>",
"sub": "<agent id — machine client_id, mTLS substrate id, or token label>",
"aud": "<audience or token endpoint>",
"iat": 1757347200,
"exp": 1757347500,
"jti": "…",
"token_use": "actor",
"kind": "oauth_client",
"auth_method": "oauth",
"name": "<label, when known>",
"server_uuid": "<server uuid>"
}
The iss is the same per-server issuer DataGrout uses for its machine-client
access tokens, so one issuer prefix (https://gateway.datagrout.ai/servers/)
covers everything DataGrout signs. The verification keys are published at:
https://gateway.datagrout.ai/.well-known/jwks.json
Picking the key: the JWKS carries two
The key set holds two entries, because DataGrout signs two different things with two deliberately separate keys:
kty |
crv |
alg |
Signs |
|---|---|---|---|
EC |
P-256 |
ES256 |
Upstream assertions — this is the one your handler needs |
OKP |
Ed25519 |
EdDSA |
Machine-client access tokens, and EdDSA assertions if chosen |
Each entry has its own kid, and that kid is stamped on the header of every
token it signs, so selection is unambiguous: match the JWT header’s kid
against the JWKS entry’s kid, and use only that key. kid values are stable
for the life of a key.
Two rules for a handler reading a multi-key JWKS:
- Pin the algorithm. Require
alg == "ES256"in the JWT header before validating. A verifier that accepts whatever the header names, against whichever key in the set matches, is the classic algorithm-confusion bug.Auth.JWTUtil.validateJWTWithKeysEndpointdoes thekidlookup for you, but checkingalgyourself costs three lines and closes that door. - Do not hardcode a single key. Fetch the set and select by
kid(or letvalidateJWTWithKeysEndpointdo it). Hardcoding one key means a DataGrout key rotation, which publishes the new key alongside the old, breaks your org.
The kid selection is also why the two keys must stay separate: DataGrout’s CA
signs X.509 certificates with a third P-256 key that is never published here,
so a certificate-signing compromise cannot forge an assertion and vice versa.
Response
Salesforce answers with its usual token response: access_token, token_type
(Bearer), scope, instance_url, id, issued_at, signature, and an
id_token when the openid scope was granted. DataGrout reads access_token,
token_type, and — when present — expires_in, issued_token_type and scope.
The token is cached in memory (per server, integration, actor and subject) until
60 seconds before expires_in, or for cache_ttl_seconds when the response has
no expires_in, and is never written to the database. A delegated credential
carries no refresh token: if Salesforce rejects it, the call fails as the agent,
rather than quietly retrying as the owner.
Errors
In required mode a failed exchange returns a structured error to the agent:
{
"code": -32003,
"message": "Delegated access is required for salesforce on this server, but the upstream token exchange failed (invalid_grant: …). The owner's credential was not used. …",
"data": {
"reason": "delegation_required",
"integration": "salesforce",
"mode": "required",
"exchange_error": "invalid_grant",
"exchange_error_description": "…",
"status": 400
}
}
Every decision — exchanged, served from cache, fell back, refused — emits the
telemetry event [:data_grout, :upstream, :token_exchange] with
server_uuid, integration, mode, exchanged, actor, source and
reason, and is attached to the call’s log lines as upstream_delegation.
Salesforce setup
Available in Enterprise, Performance, Unlimited and Developer editions. Four pieces, all in your org:
-
Algorithm: ES256, and nothing to decide. DataGrout signs assertions for Salesforce with ECDSA P-256 (
ES256), because Apex’sCrypto.verifysupports RSA and ECDSA and not EdDSA — an Ed25519 assertion could not be verified in a handler at all.ES256is the default for theassertion_algsetting, so there is nothing to configure and no open question here; earlier revisions of this guide flagged EdDSA support as an unknown, and the answer was to sign ES256 instead.ES256 is signed with a dedicated assertion key: separate from the Ed25519 key that signs machine-client tokens, and separate again from the P-256 CA key that issues X.509 certificates, so each rotates on its own schedule and a compromise of one does not reach the others. Both public keys DataGrout signs assertions with are published in the JWKS; select by
kidas described above.A smoke test before rolling out, in Anonymous Apex, with a fresh assertion (
DataGrout.Auth.Assertions.subject_assertion/6in IEx, or the delegation settings inpreferredmode and one agent call):Auth.JWT jwt = Auth.JWTUtil.validateJWTWithKeysEndpoint( assertion, 'https://gateway.datagrout.ai/.well-known/jwks.json', true); System.debug(jwt.getSub()); -
Connected app / External Client App. In the app’s OAuth settings enable Enable Token Exchange Flow; enable Require Secret for Token Exchange Flow if you want DataGrout to send
client_secret(it does whenever one is configured). Under OAuth policies set Permitted Users to Admin approved users are pre-authorized and assign the profiles / permission sets of the users the handler will map to. For an External Client App the equivalent metadata isExtlClntAppGlobalOauthSettings.isTokenExchangeEnabled,isSecretRequiredForTokenExchange, andpermittedUsersPolicyType=AdminApprovedPreAuthorized. Use the app’s consumer key and secret asclient_id/client_secretin DataGrout — or, if this is the same app that owns the Salesforce connection, leave them blank. -
Token Exchange Handler. Setup → Token Exchange Handlers → New. Give it a name and API name (that API name is what goes in
token_handler), tick the JWT token type, decide whether it may create users, and point it at the Apex class below (or let Salesforce generate a template and replace its body). Save and Enable, then Enable New App for your connected app, choosing an execution user (an integration user is recommended — the handler runs as this user) and marking it the default if you don’t want to sendtoken_handler. Metadata type:OauthTokenExchangeHandler(developerName,isJwtSupported,isEnabled,isUserCreationAllowed,tokenHandlerApex, plus anenablementsblock withapexExecutionUser,connectedApp,isDefault); API version 60.0 or later. -
DataGrout. Set the integration’s delegation config (
mode,token_endpoint,subject_claim, andtoken_handlerunless the handler is the default), then make a call from an authenticated agent and check the handler’s debug log for the actor.
Template Apex handler
The class validates the subject assertion against DataGrout’s JWKS, pins the
issuer prefix and audience, maps sub to a Salesforce user, and logs the agent
from the act claim. Every line marked // verify against your org depends on
something we could not confirm from Salesforce’s published reference (see the
notes below the class).
/**
* DataGrout delegated access: RFC 8693 token exchange handler.
*
* DataGrout sends a subject assertion (an ES256 / ECDSA P-256 JWT) naming the
* DataGrout server owner, with the calling agent in the nested `act` claim.
* This handler pins the algorithm, verifies the signature against DataGrout's
* JWKS, maps the subject to a Salesforce user, and records which agent acted.
*
* Salesforce's official example and the abstract class print the two methods
* with these exact signatures; the class itself may be `public`.
*/
public class DataGroutTokenExchangeHandler extends Auth.Oauth2TokenExchangeHandler {
// DataGrout's published verification keys. Cached by Salesforce per the endpoint's
// cache headers. The set holds TWO keys — an EC/P-256 one for assertions and an
// OKP/Ed25519 one for machine tokens — each under its own `kid`, which is also in
// the JWT header. Do not pin a single key: DataGrout publishes a new one alongside
// the old during rotation.
private static final String DG_JWKS_URL = 'https://gateway.datagrout.ai/.well-known/jwks.json';
// The only JWS algorithm this handler accepts. Apex's Crypto.verify supports RSA and
// ECDSA, not EdDSA, which is exactly why DataGrout signs ES256 for Salesforce.
// Pinning it here means a token cannot pick a different key from the set by naming a
// different `alg` (algorithm confusion).
private static final String DG_EXPECTED_ALG = 'ES256';
// Every DataGrout assertion's `iss` is this prefix plus the DataGrout server uuid.
private static final String DG_ISSUER_PREFIX = 'https://gateway.datagrout.ai/servers/';
// Must equal the `audience` configured in DataGrout, or the token endpoint URL when
// no audience is configured. Use your My Domain host if that is what DataGrout calls.
private static final String EXPECTED_AUDIENCE = 'https://yourorg.my.salesforce.com/services/oauth2/token';
// Which User field the subject's `sub` maps to. Match DataGrout's `subject_claim`:
// "email" -> Email
// "federation_id" -> FederationIdentifier
private static final String SUBJECT_USER_FIELD = 'Email';
public override Auth.TokenValidationResult validateIncomingToken(
String appDeveloperName,
Auth.IntegratingAppType appType,
String incomingToken,
Auth.OAuth2TokenExchangeType tokenType
) {
if (tokenType != Auth.OAuth2TokenExchangeType.JWT) {
return new Auth.TokenValidationResult(false);
}
// Pin the algorithm BEFORE verifying. The JWKS carries two keys; accepting
// whatever `alg` the header names would let a caller steer verification at the
// wrong one. `kid` selection itself is left to validateJWTWithKeysEndpoint.
Map<String, Object> jwtHeader = decodeSegment(incomingToken, 0);
if (!DG_EXPECTED_ALG.equals(String.valueOf(jwtHeader.get('alg')))) {
System.debug(LoggingLevel.WARN,
'DataGrout assertion rejected: alg=' + jwtHeader.get('alg') +
' (expected ' + DG_EXPECTED_ALG + ')');
return new Auth.TokenValidationResult(false);
}
// ES256 signature + expiry check against DataGrout's JWKS. The key is chosen by
// the header's `kid`.
// The third argument's meaning is not documented on any page we could fetch;
// Salesforce's own example passes `true`. // verify against your org
Auth.JWT jwt;
try {
jwt = Auth.JWTUtil.validateJWTWithKeysEndpoint(incomingToken, DG_JWKS_URL, true);
} catch (Exception e) {
System.debug(LoggingLevel.WARN, 'DataGrout assertion rejected: ' + e.getMessage());
return new Auth.TokenValidationResult(false);
}
// Decode the payload ourselves for the claims Auth.JWT may not expose
// (aud, token_use, integration, act). Signature was verified above.
Map<String, Object> claims = decodeSegment(incomingToken, 1);
String iss = jwt.getIss(); // verify against your org
String sub = jwt.getSub(); // verify against your org
if (iss == null || !iss.startsWith(DG_ISSUER_PREFIX)) {
return new Auth.TokenValidationResult(false);
}
if (!audienceMatches(claims.get('aud'))) {
return new Auth.TokenValidationResult(false);
}
if (claims.get('token_use') != 'subject') {
// Only the subject assertion may become a session; never an actor assertion.
return new Auth.TokenValidationResult(false);
}
if (String.isBlank(sub)) {
return new Auth.TokenValidationResult(false);
}
// Who is acting. DataGrout puts the agent in RFC 8693's nested `act` claim.
String actor = 'unknown';
String actorKind = null;
Object act = claims.get('act');
if (act instanceof Map<String, Object>) {
Map<String, Object> actMap = (Map<String, Object>) act;
actor = String.valueOf(actMap.get('sub'));
actorKind = actMap.get('kind') == null ? null : String.valueOf(actMap.get('kind'));
}
System.debug(LoggingLevel.INFO,
'DataGrout delegated access: subject=' + sub +
' actor=' + actor + (actorKind == null ? '' : ' (' + actorKind + ')') +
' integration=' + claims.get('integration') +
' dg_server=' + claims.get('server_uuid') +
' app=' + appDeveloperName);
// Carry what getUserForTokenSubject needs. Auth.UserData's positional constructor
// is taken from Salesforce's example; the 5th argument is the email and the 7th
// the remote username. // verify against your org
Map<String, String> attrs = new Map<String, String>{
'actor' => actor,
'actor_kind' => actorKind == null ? '' : actorKind,
'subject_claim' => String.valueOf(claims.get('subject_claim')),
'dg_server_uuid' => String.valueOf(claims.get('server_uuid'))
};
String email = claims.get('email') == null ? null : String.valueOf(claims.get('email'));
Auth.UserData userData = new Auth.UserData(
sub, // identifier
null, // firstName
null, // lastName
null, // fullName
email, // email
null, // link
sub, // remote username
null, // locale
'DataGrout', // provider
null, // site login url
attrs
);
return new Auth.TokenValidationResult(true, (Object) attrs, userData, incomingToken, tokenType, null);
}
public override User getUserForTokenSubject(
Id networkId,
Auth.TokenValidationResult result,
Boolean canCreateUser,
String appDeveloperName,
Auth.IntegratingAppType appType
) {
Auth.UserData userData = result.userData; // verify against your org
String subjectValue = userData.identifier; // verify against your org
Map<String, String> attrs = (Map<String, String>) result.data; // verify against your org
// Map the DataGrout subject to exactly one active Salesforce user.
// Never create users for delegated access: the subject is a person who must already exist.
String soql = 'SELECT Id, Username FROM User WHERE IsActive = true AND ' +
String.escapeSingleQuotes(SUBJECT_USER_FIELD) + ' = :subjectValue LIMIT 2';
List<User> matches = Database.query(soql);
if (matches.size() != 1) {
System.debug(LoggingLevel.WARN,
'DataGrout delegated access: ' + matches.size() + ' users match ' +
SUBJECT_USER_FIELD + '=' + subjectValue + '; refusing');
return null;
}
System.debug(LoggingLevel.INFO,
'DataGrout delegated access: issuing token for ' + matches[0].Username +
' on behalf of agent ' + attrs.get('actor'));
return matches[0];
}
// --- helpers -------------------------------------------------------------
// Decode one base64url segment of a compact JWS: 0 = header, 1 = payload.
// Reading a segment is not trusting it — the signature is checked separately.
private static Map<String, Object> decodeSegment(String compactJwt, Integer index) {
List<String> parts = compactJwt.split('\\.');
if (parts.size() != 3 || index < 0 || index > 1) {
return new Map<String, Object>();
}
String b64 = parts[index].replace('-', '+').replace('_', '/');
while (Math.mod(b64.length(), 4) != 0) {
b64 += '=';
}
String json = EncodingUtil.base64Decode(b64).toString();
return (Map<String, Object>) JSON.deserializeUntyped(json);
}
private static Boolean audienceMatches(Object aud) {
if (aud == null) {
return false;
}
if (aud instanceof String) {
return EXPECTED_AUDIENCE.equals((String) aud);
}
if (aud instanceof List<Object>) {
for (Object a : (List<Object>) aud) {
if (EXPECTED_AUDIENCE.equals(String.valueOf(a))) {
return true;
}
}
}
return false;
}
}
Notes on the template:
- Signatures.
Auth.Oauth2TokenExchangeHandler‘s two methods and their parameter lists are exactly as printed in Salesforce’s help article; both must be overridden. There is no third method, no input/output object, and nothing that receives anactor_token— hence theactclaim. Auth.TokenValidationResult. Salesforce’s example constructs it as(Boolean isValid)on failure and(Boolean, Object data, Auth.UserData, String token, Auth.OAuth2TokenExchangeType, String message)on success, and readsresult.data,result.userData,result.token. The full property list was not reachable — verify against your org.Auth.JWTUtil.validateJWTWithKeysEndpoint. Used exactly as in Salesforce’s example, and now with an algorithm the Apex crypto surface documents (ECDSA), so the EdDSA question that used to sit here is closed.validateJWTWithCert(jwt, certDeveloperName)exists as an alternative for a key uploaded as a certificate; it pins one key, so it does not survive a DataGrout key rotation without a manual re-upload.Auth.JWTgetters.getIss()andgetSub()appear in working third-party code; there is no documented getter for custom claims, which is why the template decodes the payload itself foraud,token_use, andact. Whether custom claims are reachable throughAuth.JWTat all is still verify against your org.- User creation. The template returns
nullwhen the subject does not map to exactly one active user, so Salesforce refuses the exchange. If you enable Allow this handler to create users and return a newUser, Salesforce inserts it; assign permission sets in a@futuremethod to avoid mixed DML. - Audit.
System.debugis the minimum. For a durable record, insert a custom object row (subject, actor, integration, server uuid, timestamp) fromgetUserForTokenSubject, or emit a Platform Event.
Debugging
invalid_grantfrom the token endpoint usually means the handler returnedfalseornull: check the handler’s debug log for theDataGrout assertion rejected/refusinglines.invalid_clientmeans the connected app is wrong, token exchange is not enabled on it, or a secret was required and not sent.- No handler ran at all: there is no default handler and
token_handlerwas not set, or the handler is not enabled for this app. - Every assertion rejected on signature, with nothing else changed: check
assertion_algisES256(anEdDSAassertion cannot be verified in Apex at all), and that the handler resolves the JWKS entry by the header’skidrather than assuming a single key. assertion_alg_unavailablein the DataGrout error or logs is a DataGrout-side misconfiguration, not yours: the gateway has no assertion signing key for the algorithm requested. Nothing was sent to your org.- On the DataGrout side, every attempt is logged (
DelegatedCredentials: ...) with the endpoint’serroranderror_description.
Sources
Official Salesforce help (fetched via the legacy renderer; the corresponding developer.salesforce.com “atlas” reference pages returned 403 and could not be read, so Apex type property lists are marked verify against your org):
- Create a Token Exchange Handler Apex Class —
https://help.salesforce.com/apex/HTViewHelpDoc?id=sf.remoteaccess_token_exchange_create_apex.htm&language=en_US - Configure the OAuth 2.0 Token Exchange Flow (request parameters, response) —
https://help.salesforce.com/apex/HTViewHelpDoc?id=sf.remoteaccess_token_exchange_configure.htm&language=en_US - Define a Token Exchange Handler —
https://help.salesforce.com/apex/HTViewHelpDoc?id=sf.remoteaccess_token_exchange_handler_define.htm&language=en_US - Integrate an App with the Token Exchange Flow (connected app / ECA settings) —
https://help.salesforce.com/apex/HTViewHelpDoc?id=sf.remoteaccess_token_exchange_integrate.htm&language=en_US - Enable a Token Exchange Handler for an App —
https://help.salesforce.com/apex/HTViewHelpDoc?id=sf.remoteaccess_token_exchange_enable.htm&language=en_US - Token Exchange Flow overview and diagram —
https://help.salesforce.com/apex/HTViewHelpDoc?id=sf.remoteaccess_token_exchange_overview.htm&language=en_US,...?id=sf.remoteaccess_token_exchange_diagram.htm&language=en_US
Not reachable (403), cited so you know what to check in your org’s Apex
reference: apex_class_Auth_Oauth2TokenExchangeHandler.htm,
apex_class_Auth_JWTUtil.htm, apex_class_Auth_TokenValidationResult.htm,
apex_enum_Auth_OAuth2TokenExchangeType.htm under
https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/.
Working third-party reference implementation (deployed metadata, handler class,
package.xml at API 60.0): https://github.com/lekkimworld/salesforce-tokenexchange-poc.
RFC 8693, OAuth 2.0 Token Exchange: https://www.rfc-editor.org/rfc/rfc8693
(§2.1 request parameters, §4.1 the act claim).