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:

  1. 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).
  2. It POSTs both to your Salesforce org’s token endpoint with grant_type=urn:ietf:params:oauth:grant-type:token-exchange.
  3. 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.
  4. 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 required mode 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: preferred falls back to the owner’s token, required refuses 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 tokenactor_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:

  1. 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.validateJWTWithKeysEndpoint does the kid lookup for you, but checking alg yourself costs three lines and closes that door.
  2. Do not hardcode a single key. Fetch the set and select by kid (or let validateJWTWithKeysEndpoint do 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:

  1. Algorithm: ES256, and nothing to decide. DataGrout signs assertions for Salesforce with ECDSA P-256 (ES256), because Apex’s Crypto.verify supports RSA and ECDSA and not EdDSA — an Ed25519 assertion could not be verified in a handler at all. ES256 is the default for the assertion_alg setting, 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 kid as described above.

    A smoke test before rolling out, in Anonymous Apex, with a fresh assertion (DataGrout.Auth.Assertions.subject_assertion/6 in IEx, or the delegation settings in preferred mode and one agent call):

    Auth.JWT jwt = Auth.JWTUtil.validateJWTWithKeysEndpoint(
        assertion, 'https://gateway.datagrout.ai/.well-known/jwks.json', true);
    System.debug(jwt.getSub());
    
  2. 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 is ExtlClntAppGlobalOauthSettings.isTokenExchangeEnabled, isSecretRequiredForTokenExchange, and permittedUsersPolicyType=AdminApprovedPreAuthorized. Use the app’s consumer key and secret as client_id / client_secret in DataGrout — or, if this is the same app that owns the Salesforce connection, leave them blank.

  3. 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 send token_handler. Metadata type: OauthTokenExchangeHandler (developerName, isJwtSupported, isEnabled, isUserCreationAllowed, tokenHandlerApex, plus an enablements block with apexExecutionUser, connectedApp, isDefault); API version 60.0 or later.

  4. DataGrout. Set the integration’s delegation config (mode, token_endpoint, subject_claim, and token_handler unless 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 an actor_token — hence the act claim.
  • 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 reads result.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.JWT getters. getIss() and getSub() appear in working third-party code; there is no documented getter for custom claims, which is why the template decodes the payload itself for aud, token_use, and act. Whether custom claims are reachable through Auth.JWT at all is still verify against your org.
  • User creation. The template returns null when 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 new User, Salesforce inserts it; assign permission sets in a @future method to avoid mixed DML.
  • Audit. System.debug is the minimum. For a durable record, insert a custom object row (subject, actor, integration, server uuid, timestamp) from getUserForTokenSubject, or emit a Platform Event.

Debugging

  • invalid_grant from the token endpoint usually means the handler returned false or null: check the handler’s debug log for the DataGrout assertion rejected / refusing lines.
  • invalid_client means 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_handler was not set, or the handler is not enabled for this app.
  • Every assertion rejected on signature, with nothing else changed: check assertion_alg is ES256 (an EdDSA assertion cannot be verified in Apex at all), and that the handler resolves the JWKS entry by the header’s kid rather than assuming a single key.
  • assertion_alg_unavailable in 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’s error and error_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).