Tutoriel · Salesforce · Intégration API

Authentifier une intégration Salesforce avec OAuth 2.0

JWT Bearer, Client Credentials ou Authorization Code avec PKCE : choisir le flux, configurer l’application externe et écrire un fournisseur de jeton Symfony avec cache et reprise sur 401.

Quel flux pour quelle intégration

Toute intégration avec l’API Salesforce commence par un jeton d’accès OAuth 2.0. Salesforce propose plusieurs façons de l’obtenir ; le bon choix dépend de qui agit : un serveur seul, ou un utilisateur humain.

FluxCas d’usageSecret à protéger
JWT BearerServeur à serveur : batch, synchronisation, middlewareClé privée
Client CredentialsServeur à serveur, configuration plus simpleConsumer secret
Authorization Code + PKCEApplication web ou mobile où l’utilisateur se connecteAucun dans l’application mobile
Username-PasswordÀ éviter : mot de passe stocké, pas de MFAMot de passe

Fil rouge : une application Symfony d’une compagnie fictive, Acme Assurances, qui lit et écrit des données dans son org Salesforce acme-demo.

L’application externe

Côté Salesforce, l’intégration est déclarée comme une application externe (External Client App, ou Connected App sur les organisations plus anciennes). On y règle :

  • les portées (scopes) : api suffit pour l’API REST ; ajouter refresh_token seulement pour un flux utilisateur ;
  • les flux autorisés : n’activer que celui qui sert ;
  • la politique d’accès : « utilisateurs pré-autorisés par un administrateur », avec un permission set qui liste les utilisateurs d’intégration ;
  • les restrictions IP et la durée de session, si l’intégration tourne depuis des adresses connues.

L’intégration utilise un utilisateur d’intégration dédié, avec le strict nécessaire en droits d’objet et de champ. Ses actions sont ainsi identifiables dans l’historique, et ses droits sont limités même si le jeton fuit.

JWT Bearer : serveur à serveur

Le serveur prouve son identité en signant un court jeton JWT avec une clé privée ; Salesforce le vérifie avec le certificat déposé dans l’application. Aucun mot de passe, aucun secret partagé.

# Clé privée (reste sur le serveur d'intégration) et certificat (déposé dans Salesforce)
openssl req -x509 -newkey rsa:2048 -sha256 -days 730 -nodes \
  -keyout salesforce-integration.key -out salesforce-integration.crt \
  -subj "/CN=acme-integration"

Le certificat (.crt) est déposé dans l’application externe ; la clé privée reste dans le coffre de secrets du serveur. L’échange se fait ensuite en un appel :

En-tête   : { "alg": "RS256" }
Charge    : {
  "iss": "<consumer key de l'application>",
  "sub": "integration@acme-demo.example",
  "aud": "https://login.salesforce.com",     // https://test.salesforce.com pour une sandbox
  "exp": <maintenant + 3 minutes au plus>
}

POST https://login.salesforce.com/services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<JWT signé>

→ { "access_token": "00D...", "instance_url": "https://acme-demo.my.salesforce.com", "token_type": "Bearer", ... }

Pas de jeton de rafraîchissement : quand le jeton expire, on signe simplement un nouveau JWT.

Client Credentials

Plus simple à mettre en place : l’application s’authentifie avec son identifiant et son secret, et agit au nom d’un utilisateur « run as » choisi dans sa configuration.

POST https://acme-demo.my.salesforce.com/services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=<consumer key>&client_secret=<consumer secret>

L’appel se fait obligatoirement sur l’URL My Domain de l’org, pas sur login.salesforce.com. Le secret est une chaîne à protéger et à faire tourner : JWT Bearer reste préférable quand on peut gérer une paire de clés.

Un fournisseur de jeton Symfony

Obtenir un jeton à chaque appel serait lent et consommerait des connexions. Le jeton est mis en cache, et renouvelé quand Salesforce le refuse :

<?php

namespace App\Salesforce;

use Firebase\JWT\JWT;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SalesforceTokenProvider
{
    private const CACHE_KEY = 'salesforce.access_token';

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly CacheInterface $cache,
        private readonly string $loginUrl,      // https://login.salesforce.com
        private readonly string $clientId,      // consumer key
        private readonly string $username,      // utilisateur d'intégration
        private readonly string $privateKey,    // contenu PEM, injecté depuis un secret
    ) {
    }

    /** @return array{access_token: string, instance_url: string} */
    public function token(): array
    {
        return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): array {
            // Salesforce ne renvoie pas de durée de vie : rester sous le délai de session de l'org.
            $item->expiresAfter(1800);

            $assertion = JWT::encode([
                'iss' => $this->clientId,
                'sub' => $this->username,
                'aud' => $this->loginUrl,
                'exp' => time() + 180,
            ], $this->privateKey, 'RS256');

            return $this->http->request('POST', $this->loginUrl . '/services/oauth2/token', [
                'body' => [
                    'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
                    'assertion' => $assertion,
                ],
            ])->toArray();
        });
    }

    public function invalidate(): void
    {
        $this->cache->delete(self::CACHE_KEY);
    }
}
<?php

namespace App\Salesforce;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class SalesforceApi
{
    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly SalesforceTokenProvider $tokens,
        private readonly string $apiVersion = 'v62.0',
    ) {
    }

    public function query(string $soql): array
    {
        return $this->get('/query', ['q' => $soql]);
    }

    private function get(string $path, array $query, bool $retry = true): array
    {
        $token = $this->tokens->token();
        $response = $this->http->request('GET', $token['instance_url'] . '/services/data/' . $this->apiVersion . $path, [
            'auth_bearer' => $token['access_token'],
            'query' => $query,
        ]);

        // Session expirée ou révoquée (INVALID_SESSION_ID) : un nouveau jeton, une seule nouvelle tentative.
        if ($response->getStatusCode() === 401 && $retry) {
            $this->tokens->invalidate();
            return $this->get($path, $query, false);
        }

        return $response->toArray();
    }
}

La clé privée arrive par un secret Symfony (bin/console secrets:set) ou une variable d’environnement, jamais par un fichier versionné. Dans un worker Messenger qui tourne longtemps, la reprise sur 401 évite l’échec après l’expiration de la session.

Applications web et mobiles : PKCE

Quand c’est un utilisateur qui se connecte (portail, application mobile), on utilise le flux Authorization Code : l’utilisateur s’identifie sur la page Salesforce (MFA comprise), puis l’application échange un code contre un jeton.

Une application mobile ou une application web en JavaScript ne peut pas garder de secret : tout ce qu’elle contient peut être extrait. PKCE remplace ce secret par une preuve à usage unique :

  1. l’application génère un code_verifier aléatoire et en calcule l’empreinte SHA-256, le code_challenge ;
  2. elle ouvre la page d’autorisation avec ce code_challenge ;
  3. au retour, elle échange le code en envoyant le code_verifier d’origine : un code intercepté seul est inutilisable.

Sur mobile, la page de connexion s’ouvre dans le navigateur système (pas dans une vue web intégrée), et le jeton de rafraîchissement se range dans le stockage sécurisé du téléphone (Keychain, Keystore).

Bonnes pratiques

  • Une application par intégration : on révoque ou on fait tourner un accès sans couper les autres.
  • Moindre privilège : portées minimales, utilisateur d’intégration dédié, permission set ciblé.
  • Secrets hors du code : coffre de secrets, rotation planifiée de la clé et du certificat avant leur expiration.
  • Sandbox d’abord : test.salesforce.com (ou l’URL My Domain de la sandbox) comme audience et comme URL de connexion.
  • Surveillance : l’historique de connexion de l’utilisateur d’intégration montre chaque obtention de jeton ; une hausse brutale signale un cache qui ne fonctionne pas.

Le jeton obtenu sert ensuite à toutes les API : Bulk API 2.0, Pub/Sub API ou un serveur MCP.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.