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
- 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.
- 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. - Put the credentials in the environment
.env.local ADOBE_SIGN_CLIENT_ID=CBJCHBCAABAA... ADOBE_SIGN_CLIENT_SECRET=...
Configure the bundle
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
adobe_sign_oauth:
resource: '@AdobeSignBundle/config/routes/oauth.php'
| Name | Method and path | What it does |
|---|---|---|
adobe_sign_oauth_redirect | GET /adobe-sign/oauth/redirect | Generates 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_callback | GET /adobe-sign/oauth/callback | Takes 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:
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_storageis 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
- 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. - The bundle stores a state and redirects
OAuthClient::generateState()makes 32 random bytes (64 hex characters), stored in the session underadobe_sign_oauth_state. The redirect carriesresponse_type=code, your client id, the redirect URI, the scopes and the state. - 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,stateandapi_access_point, the API host of their account's data centre. - 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.
- The code is exchanged
The bundle posts the code, client id, secret and redirect URI to
/oauth/v2/tokenon the user's data centre. Codes are valid for five minutes. - Tokens are stored and announced
The token pair (with its expiry and access point) is encrypted and stored under
userKey($user),OAuthConnectedEventis dispatched, and the browser is redirected toredirect_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
| Status | Cause | What the user should do |
|---|---|---|
| 401 | Nobody 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. |
| 400 | STATE_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. |
| 403 | Adobe 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. |
| 502 | Adobe'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:
| Property | Type | Meaning |
|---|---|---|
$user | UserInterface | The signed-in user who connected. |
$key | string | The token-store key, from AdobeSignManager::userKey(). |
$apiAccessPoint | ?string | The account's API host, for example https://api.eu1.adobesign.com/. |
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:
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();
}
}
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:
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.
:selfcovers the user's own resources,:grouptheir group's,:accountthe whole account's. Wider modifiers need an administrator to consent. - Add what you use. Library templates need
library_read; web forms needwidget_read/widget_write. Ask for the minimum; consent screens with many scopes put users off. - A list replaces the default. Setting
oauth.scopessends 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 withhttp://); - 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.
Users get an error at Adobe when consenting. Why?
Usually a scope or modifier that isn't enabled on the API application, or an :account scope requested by a user who is not an account administrator. Adobe redirects back with an error and the callback answers 403 with Adobe's message.
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.