Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Guides

OAuth: connect user accounts

When your users have their own Acrobat Sign accounts, OAuth lets them connect those accounts to your app so agreements go out in their name. The bundle provides the two routes, keeps the state in the session, stores the tokens encrypted and refreshes them; you protect the routes, add a "Connect" button and call forUser().

When to use OAuth

Use OAuth when the agreements belong to your users rather than to you: a CRM where each salesperson sends from their own Acrobat Sign account, a SaaS whose customers already pay for Acrobat Sign, an agency tool acting for several client companies. The agreements then appear in each user's own Acrobat Sign dashboard, under their name, with their branding and their account's settings.

If everything is sent by your company from one account, an Integration Key is simpler: one secret, no redirects, no token storage. Both can live in one app; see Authentication.

Set up the Adobe application

  1. Create an API application

    In Acrobat Sign, go to Account → Acrobat Sign API → API Applications and create one. Note the client id and client secret.

  2. Configure OAuth for it

    Under Configure OAuth for Application, enter the redirect URI, for example https://app.example.com/adobe-sign/oauth/callback, and enable every scope you will request, with the modifier (self, group, account) you need. A requested scope that isn't enabled here makes Adobe refuse the consent.

  3. Put the credentials in the environment
    .env.local
    ADOBE_SIGN_CLIENT_ID=CBJCHBCAABAA...
    ADOBE_SIGN_CLIENT_SECRET=...

Configure the bundle

config/packages/adobe_sign.yaml
adobe_sign:
    auth: oauth                          # optional: makes the AdobeSign service the signed-in user's client
    oauth:
        client_id: '%env(ADOBE_SIGN_CLIENT_ID)%'
        client_secret: '%env(ADOBE_SIGN_CLIENT_SECRET)%'
        redirect_after: /settings/integrations
        token_store: doctrine            # see Token storage & Doctrine

auth: oauth is optional. Without it, the default client stays the Integration Key and you call forUser() wherever a user's own account should be used.

The routes

config/routes/adobe_sign.yaml
adobe_sign_oauth:
    resource: '@AdobeSignBundle/config/routes/oauth.php'
NameMethod and pathWhat it does
adobe_sign_oauth_redirectGET /adobe-sign/oauth/redirectGenerates a 64-character random state, stores it in the session and redirects to Adobe's consent page with your client id, redirect URI and scopes.
adobe_sign_oauth_callbackGET /adobe-sign/oauth/callbackTakes the state out of the session (single-use), requires a signed-in user, exchanges the code for tokens on the account's data centre, stores them under the user's key, dispatches OAuthConnectedEvent and redirects to redirect_after.

Protecting the routes

The routes don't check roles themselves; the callback only insists that someone is signed in (401 otherwise). Protect both with access_control so a signed-out visitor is sent to your login page before they ever reach Adobe:

config/packages/security.yaml
security:
    firewalls:
        main:
            lazy: true
            # ... form_login, login_link, OIDC, whatever you use
    access_control:
        - { path: ^/adobe-sign/oauth, roles: ROLE_USER }
  • The paths must be under a firewall that authenticates users, usually main. Outside any firewall, security.token_storage is always empty and the callback answers 401.
  • Match ^/adobe-sign/oauth, not ^/adobe-sign. A broader rule would also catch the webhook, and Adobe can't log in. See Webhooks.
  • Sessions must be enabled (framework.session, on by default in the Symfony skeleton). The state lives there.
  • Use a stricter role (ROLE_ADMIN, or a voter) when connecting has consequences beyond the user, for example with a per-team key.

The connect flow, step by step

Browser Symfony app Adobe consent Adobe API GET …/oauth/redirect state → session (single-use) 302 → Adobe (state) user signs in, consents 302 → callback?code&state&api_access_point GET …/oauth/callback state removed + compared · user? POST /oauth/v2/token (code, secret) access + refresh token TokenStore::put() · OAuthConnectedEvent 302 → redirect_after
The state ties the callback to the session that started it; the code is exchanged server-to-server on the account's own data centre, so the client secret never reaches the browser.
  1. The user clicks "Connect"

    A plain link to the redirect route: <a href="{{ path('adobe_sign_oauth_redirect') }}">Connect Acrobat Sign</a>. It must be a top-level navigation, not an XHR, because the response is a redirect to Adobe's page.

  2. The bundle stores a state and redirects

    OAuthClient::generateState() makes 32 random bytes (64 hex characters), stored in the session under adobe_sign_oauth_state. The redirect carries response_type=code, your client id, the redirect URI, the scopes and the state.

  3. The user consents at Adobe

    They sign in to Acrobat Sign (if they aren't already) and approve the scopes. Adobe sends them back to the redirect URI with code, state and api_access_point, the API host of their account's data centre.

  4. The callback validates

    The state is removed from the session first, so a callback URL can be used once. Then: a user must be signed in; Adobe must not report an error; the state must match (constant-time compare); a code must be present; the access point must be on one of Adobe's domains.

  5. The code is exchanged

    The bundle posts the code, client id, secret and redirect URI to /oauth/v2/token on the user's data centre. Codes are valid for five minutes.

  6. Tokens are stored and announced

    The token pair (with its expiry and access point) is encrypted and stored under userKey($user), OAuthConnectedEvent is dispatched, and the browser is redirected to redirect_after.

Why the state lives in the session

The state is OAuth's CSRF protection. Without it, an attacker could make a victim's browser complete a flow the attacker started, connecting the attacker's Acrobat Sign account to the victim's user, so the victim's agreements would go out through the attacker's account. Storing a random value in the victim's own session and requiring it back proves the callback belongs to a flow this browser started. Because the session is the anchor, the user must come back to the same host (same session cookie) they started from.

What the callback answers when something goes wrong

StatusCauseWhat the user should do
401Nobody is signed in when the callback runs (route outside the firewall, session lost, signed out meanwhile). The state has already been consumed.Sign in and click "Connect" again.
400STATE_MISMATCH: no state in the session, or a different one (another tab, a replayed URL, a different host). MISSING_CODE: no code in the URL. Or an api_access_point outside Adobe's domains.Start again from your app, in one tab. See Troubleshooting.
403Adobe reported an error (the user clicked "Cancel", a scope isn't enabled for the application, the redirect URI isn't registered) or refused the code.Read the message; fix the application's settings at Adobe if needed.
502Adobe's token endpoint could not be reached.Try again.

These are ordinary Symfony HTTP exceptions, so your error pages render them. To show a friendlier page, handle kernel.exception for requests whose _route attribute is adobe_sign_oauth_callback.

The OAuthConnectedEvent

Dispatched once per successful connection, after the tokens are stored and before the redirect. Its three readonly properties:

PropertyTypeMeaning
$userUserInterfaceThe signed-in user who connected.
$keystringThe token-store key, from AdobeSignManager::userKey().
$apiAccessPoint?stringThe account's API host, for example https://api.eu1.adobesign.com/.
src/EventListener/RecordAdobeSignConnection.php
use App\Entity\User;
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Symfony\Event\OAuthConnectedEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class RecordAdobeSignConnection
{
    public function __construct(
        private readonly AdobeSignManager $adobeSign,
        private readonly EntityManagerInterface $em,
    ) {
    }

    public function __invoke(OAuthConnectedEvent $event): void
    {
        // Which Acrobat Sign user did they connect? Useful to show "Connected as ...".
        $me = $this->adobeSign->forUser($event->key)->users()->me();

        if ($event->user instanceof User) {
            $event->user->setAdobeSignEmail($me->getEmail());
            $this->em->flush();
        }
    }
}

The listener runs inside the callback request, so keep it short; an exception here turns the redirect into an error page, although the tokens are already stored.

Acting as a connected user

// In a controller: the signed-in user
if (!$adobeSign->isConnected($this->getUser())) {
    return $this->redirectToRoute('adobe_sign_oauth_redirect');
}
$client = $adobeSign->forUser($this->getUser());
$client->agreements()->create($agreementInfo);

// In a worker or command: by the stored key
$client = $adobeSign->forUser($contract->getSenderKey());     // e.g. "jane@example.com"

forUser() accepts a UserInterface (resolved through the key resolver) or the key itself. Keys survive serialisation, so store $adobeSign->userKey($user) on records that a worker will process later. The client talks straight to the user's data centre (the stored api_access_point), so OAuth clients don't need the GET /baseUris lookup.

Refreshing tokens

Access tokens last about an hour. The core's token provider refreshes them 60 seconds before they expire, or after the API rejects one, and writes the new pair back through the token store. Adobe doesn't rotate refresh tokens, so two processes refreshing at the same moment both end up with valid tokens; no lock is needed. If a refresh token stops working (the user revoked access, or it was unused for too long), the call throws OAuthException; send the user through "Connect" again.

The default client with auth: oauth

With auth: oauth, $manager->client() and the autowired AdobeSign service are forUser() for whoever is signed in. That is convenient in controllers. Anywhere without a signed-in user (console, workers, listeners for unauthenticated requests) they throw MisconfiguredException with a message pointing you to forUser(). Also note that adobe-sign:verify without --user fails in this mode, for the same reason.

One connection per team: UserKeyResolver

By default tokens are keyed by UserInterface::getUserIdentifier() (often the email). To share one Acrobat Sign connection across a team, implement the resolver and point the configuration at it:

src/AdobeSign/TeamKeyResolver.php
namespace App\AdobeSign;

use App\Entity\User;
use Conceptlz\AdobeSign\Symfony\UserKeyResolver;
use Symfony\Component\Security\Core\User\UserInterface;

final class TeamKeyResolver implements UserKeyResolver
{
    public function keyFor(UserInterface $user): string
    {
        if (!$user instanceof User) {
            throw new \LogicException('Only app users can connect Acrobat Sign.');
        }

        return 'team:'.$user->getTeam()->getId();
    }
}
config/packages/adobe_sign.yaml
adobe_sign:
    oauth:
        user_key_resolver: App\AdobeSign\TeamKeyResolver

The resolver is used everywhere a UserInterface is passed: the callback, OAuthConnectedEvent::$key, forUser(), isConnected() and userKey(). Strings and ints bypass it. Two consequences to plan for: the last team member who connects replaces the team's tokens, so restrict the routes to team admins; and changing the resolver changes every key, so existing connections are no longer found.

Disconnecting

The TokenStore interface only has get() and put(). Both shipped stores add forget(). A complete disconnect revokes the token at Adobe (so it stops working everywhere) and then forgets it locally:

src/Controller/IntegrationController.php
use Conceptlz\AdobeSign\Exception\AdobeSignException;
use Conceptlz\AdobeSign\Symfony\AdobeSignManager;
use Conceptlz\AdobeSign\Symfony\TokenStore\{CacheTokenStore, DoctrineDbalTokenStore};

#[Route('/settings/integrations/adobe-sign', methods: ['DELETE'])]
public function disconnect(AdobeSignManager $adobeSign): Response
{
    $key = $adobeSign->userKey($this->getUser());
    $store = $adobeSign->tokens();

    if (($token = $store->get($key)) !== null) {
        try {
            $adobeSign->oauth()->revoke($token);      // POST /oauth/v2/revoke: kills the pair
        } catch (AdobeSignException) {
            // Already revoked by the user, or Adobe unreachable: forget it locally anyway.
        }
    }
    if ($store instanceof CacheTokenStore || $store instanceof DoctrineDbalTokenStore) {
        $store->forget($key);
    }

    return $this->redirectToRoute('settings_integrations');
}

Users can also revoke access from Acrobat Sign's own settings. Your app finds out on the next call (OAuthException) while isConnected() still says true, because it only looks at the store. Catch the exception, forget the token and ask the user to reconnect.

Choosing scopes

The default, AdobeSignBundle::DEFAULT_SCOPES, covers sending and tracking your own agreements and webhooks:

user_login:self  agreement_read:self  agreement_write:self  agreement_send:self  webhook_read:self  webhook_write:self
  • Modifiers. :self covers the user's own resources, :group their group's, :account the whole account's. Wider modifiers need an administrator to consent.
  • Add what you use. Library templates need library_read; web forms need widget_read/widget_write. Ask for the minimum; consent screens with many scopes put users off.
  • A list replaces the default. Setting oauth.scopes sends exactly your list.
  • Enable them at Adobe first. Every scope must be enabled on the API application; otherwise the consent fails and the callback answers 403.
  • Changes need a reconnect. Existing tokens keep the scopes they were granted.

After connecting: redirect_after

oauth.redirect_after (default /) is a path or URL. It is fixed in configuration, not taken from the request, so it can't be abused as an open redirect. To return users to where they started, store that path in the session before linking to the connect route, and redirect from a small controller at redirect_after.

The redirect URI

When oauth.redirect_uri is null, the manager generates the absolute URL of adobe_sign_oauth_callback using the router's current context: the scheme and host of the request, plus any route prefix. Adobe compares it character for character with the URI registered on the API application, and the same value is sent again in the token exchange. Set it explicitly when:

  • the app runs behind a load balancer or proxy that terminates TLS and you haven't configured framework.trusted_proxies (the URL would start with http://);
  • users reach the app under several hosts (www. and the apex, customer subdomains); register one and set it;
  • you build OAuth URLs outside a request (console), where the router falls back to framework.router.default_uri.

Common questions

Can I use the OAuth routes without SecurityBundle?

No. The callback stores tokens under the signed-in user and answers 401 without one. If your app identifies users differently, build your own connect flow with $manager->oauth() (authorizationUrl(), handleCallback()) and $manager->tokens()->put($key, $token).

How do I show "Connected" or "Connect" in a template?

Pass $adobeSign->isConnected($this->getUser()) from the controller. It reads the token store and doesn't call Adobe. If you want the account email too, record it in an OAuthConnectedEvent listener.

Can one user connect several Acrobat Sign accounts?

Not through the bundle's routes, which store one token per key. Build a custom flow with $manager->oauth() and your own keys (for example "jane@example.com#2"), then call forUser() with those keys.

How are the tokens protected at rest?

Encrypted with libsodium secretbox using a key derived from oauth.encryption_key (kernel.secret by default), in whichever store you configured. A database dump or cache snapshot alone doesn't expose them. See Encryption.