Adobe Sign for Symfonyconceptlz/adobe-sign-symfony GitHub

Guides

Token storage & Doctrine

OAuth gives your app an access token and a refresh token for each connected user. They are as sensitive as a password and must outlive deploys and cache clears. This page explains how the bundle encrypts them, the two stores it ships (a cache pool and a Doctrine table), how to rotate the encryption key, and how to plug in your own store.

Choosing a store

token_store: cache (default)token_store: doctrineYour own service id
ClassCacheTokenStoreDoctrineDbalTokenStoreAnything implementing TokenStore
Lives inA PSR-6 pool (oauth.token_cache_pool)A table (adobe_sign_tokens) on a DBAL connectionWherever you decide
NeedsA persistent, shared pooldoctrine/dbal; a migration or the create commandYour code
Survives cache:pool:clearNoYesUp to you
In backupsOnly if the pool is backed upWith your databaseUp to you
EncryptedYesYesOnly if you use TokenEncryptor
Good forPrototypes; apps with a durable RedisMost production appsExisting secret stores, per-tenant databases

If you already run Doctrine, choose doctrine: tokens then live next to the users they belong to, are backed up with them, and nobody clears them by accident while debugging a cache problem.

Encryption

Both shipped stores pass tokens through Conceptlz\AdobeSign\Symfony\TokenStore\TokenEncryptor before they are written. It uses libsodium's crypto_secretbox (XSalsa20-Poly1305), which encrypts and authenticates:

  • Key. A 32-byte key derived with sodium_crypto_generichash from a domain-separation label plus oauth.encryption_key, which defaults to %kernel.secret% (your APP_SECRET). The label means the derived key differs from anything else that uses the same secret.
  • Format. v1: followed by base64 of a fresh 24-byte random nonce and the ciphertext. The same token encrypts differently every time.
  • Contents. The access token, refresh token, expiry and the account's API access point, as JSON.
  • Failure mode. A value encrypted with another key, tampered with, truncated or not produced by the encryptor decrypts to null. The store then reports "no token", isConnected() returns false, and the user reconnects. Nothing throws, and nothing half-valid is used.
  • No leaks in dumps. The secret is a #[\SensitiveParameter] and __debugInfo() masks it, so dump(), print_r() and stack traces don't print it.

An empty key (for example APP_SECRET unset) throws MisconfiguredException ("adobe_sign.oauth.encryption_key is empty; set it or kernel.secret (APP_SECRET).") the first time a token is encrypted or decrypted.

Rotating APP_SECRET, and a dedicated key

Because the default key derives from APP_SECRET, rotating APP_SECRET makes every stored token unreadable. Nothing crashes: each user simply appears disconnected and must click "Connect" again. That may be acceptable after a security incident; it is annoying as routine hygiene. To rotate APP_SECRET independently, give the tokens their own key:

config/packages/adobe_sign.yaml
adobe_sign:
    oauth:
        encryption_key: '%env(ADOBE_SIGN_TOKEN_KEY)%'
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'                 # generate a value
php bin/console secrets:set ADOBE_SIGN_TOKEN_KEY --env=prod       # store it in the vault

Switching to a dedicated key is itself a rotation

Tokens encrypted under APP_SECRET can't be read with the new key. Either make the switch before users connect, or re-encrypt once: read every token with an encryptor built from the old secret and put() it back through the store configured with the new one. The bundle has no automatic dual-key mode.

A one-off re-encryption for the Doctrine store (run it once, then delete it)
use Conceptlz\AdobeSign\Symfony\TokenStore\TokenEncryptor;

$old = new TokenEncryptor($oldAppSecret);
$new = new TokenEncryptor($newTokenKey);

foreach ($connection->fetchAllAssociative('SELECT user_key, payload FROM adobe_sign_tokens') as $row) {
    $token = $old->decrypt($row['payload']);
    if ($token !== null) {
        $connection->update('adobe_sign_tokens', ['payload' => $new->encrypt($token)], ['user_key' => $row['user_key']]);
    }
}

The cache-pool store

CacheTokenStore is the default because it needs no schema. Each token is one cache item:

  • Hashed keys. The item key is adobe_sign.token. plus the SHA-256 of the user key. User keys are often emails, and PSR-6 forbids @ and a few other characters in keys; hashing also keeps emails out of cache key listings.
  • No expiry set by the store. Items are saved with expiresAfter(null), which means "the pool's default lifetime". A pool without default_lifetime keeps them forever; a pool with one would silently drop tokens after that time.
  • Encrypted values. Anyone who can read the pool sees only v1:… strings.

Use a persistent, shared pool

The default token_cache_pool is cache.app, a filesystem pool under var/cache on each machine. Tokens there disappear when that directory is wiped (a fresh container, some deploy strategies, cache:pool:clear cache.app) and aren't visible to your other servers or workers on other machines. For anything beyond a single-server prototype, give tokens their own durable pool:

config/packages/cache.yaml
framework:
    cache:
        pools:
            adobe_sign.tokens:
                adapter: cache.adapter.redis
                provider: '%env(REDIS_URL)%'
                # no default_lifetime: tokens must not expire from the cache
config/packages/adobe_sign.yaml
adobe_sign:
    oauth:
        token_store: cache
        token_cache_pool: adobe_sign.tokens

Never an array or APCu pool

An array pool lives for one request (and is reset by kernel.reset); APCu is per server and per PHP-FPM pool. Tokens stored there vanish, and users appear disconnected moments after connecting. For Redis, also check the server's maxmemory-policy: an allkeys-* eviction policy can evict tokens under memory pressure. Use noeviction or volatile-* for the Redis instance that holds tokens.

The Doctrine DBAL store

composer require doctrine/dbal       # already there with symfony/orm-pack
config/packages/adobe_sign.yaml
adobe_sign:
    oauth:
        token_store: doctrine
        doctrine:
            connection: default        # service doctrine.dbal.default_connection
            table: adobe_sign_tokens

DoctrineDbalTokenStore keeps one row per user key. put() updates the row and inserts it if there was none; if two requests insert at the same moment, the loser's unique-constraint violation is caught and turned into an update, so concurrent token refreshes never fail. The store is registered under the service id adobe_sign.token_store.doctrine and also aliased to its class, so you can autowire DoctrineDbalTokenStore directly.

The table

ColumnDBAL typeContent
user_keystring, length 191, primary keyThe key from userKey(): the user identifier by default. 191 characters keeps the index within MySQL's limit for utf8mb4.
payloadtextThe encrypted token (v1:…).
updated_atdatetime_immutableWhen the token was last stored (connect or refresh). Handy for spotting stale connections.

With Doctrine ORM: migrations pick it up

When doctrine/orm is installed and token_store is doctrine, the bundle registers DoctrineSchemaListener on Doctrine's postGenerateSchema event. Whenever the ORM computes the schema it wants, the listener adds the token table (for the configured connection only, and only if it isn't there already). So the usual workflow just works:

php bin/console doctrine:migrations:diff       # the migration includes CREATE TABLE adobe_sign_tokens
php bin/console doctrine:migrations:migrate

The same listener keeps doctrine:schema:validate and doctrine:schema:update from wanting to drop the table. If you use doctrine.dbal.schema_filter to hide tables from the ORM, make sure it still lets adobe_sign_tokens through.

Without the ORM: create-token-table

With DBAL alone there is no schema diff, so the bundle offers a command (registered only when token_store is doctrine):

$ php bin/console adobe-sign:create-token-table

 [OK] Created table "adobe_sign_tokens".

$ php bin/console adobe-sign:create-token-table

 [OK] Table "adobe_sign_tokens" already exists.

It is idempotent, so it can run on every deploy. It never alters an existing table. If you manage schema with migrations but without the ORM, write the migration by hand with the same columns:

migrations/Version20261104120000.php
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;

final class Version20261104120000 extends AbstractMigration
{
    public function up(Schema $schema): void
    {
        $table = $schema->createTable('adobe_sign_tokens');
        $table->addColumn('user_key', Types::STRING, ['length' => 191]);
        $table->addColumn('payload', Types::TEXT);
        $table->addColumn('updated_at', Types::DATETIME_IMMUTABLE);
        $table->setPrimaryKey(['user_key']);
    }

    public function down(Schema $schema): void
    {
        $schema->dropTable('adobe_sign_tokens');
    }
}

A separate connection

To keep secrets in a different database, define a second DBAL connection and name it: oauth.doctrine.connection: secrets makes the store use doctrine.dbal.secrets_connection. The schema listener only adds the table to the schema of that connection, so a diff for the default entity manager won't include it; run the create command or a migration against the secrets connection.

Forgetting a token

Both shipped stores have forget(string $key): void, which deletes the stored token. It is not part of the core TokenStore interface and does not revoke the token at Adobe; see Disconnecting for the full pattern. When a user account is deleted in your app, forget their token in the same place you delete their other data.

Writing your own TokenStore

Implement Conceptlz\AdobeSign\Auth\TokenStore, which has two methods, and register the class as a service. Reuse the bundle's encryptor (autowirable by its class name) unless your storage encrypts on its own:

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

use App\Repository\UserRepository;
use Conceptlz\AdobeSign\Auth\AccessToken;
use Conceptlz\AdobeSign\Auth\TokenStore;
use Conceptlz\AdobeSign\Symfony\TokenStore\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;

/** Keeps each user's token in a column on the users table. */
final class UserColumnTokenStore implements TokenStore
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly EntityManagerInterface $em,
        private readonly TokenEncryptor $encryptor,
    ) {
    }

    public function get(string $key): ?AccessToken
    {
        $sealed = $this->users->findOneBy(['email' => $key])?->getAdobeSignToken();

        return $sealed !== null ? $this->encryptor->decrypt($sealed) : null;
    }

    public function put(string $key, AccessToken $token): void
    {
        $user = $this->users->findOneBy(['email' => $key]) ?? throw new \RuntimeException("No user $key.");
        $user->setAdobeSignToken($this->encryptor->encrypt($token));
        $this->em->flush();
    }
}
config/packages/adobe_sign.yaml
adobe_sign:
    oauth:
        token_store: App\AdobeSign\UserColumnTokenStore

Rules a store must follow, because the core relies on them:

  • get() returns null for "nothing usable". Missing, undecryptable or corrupt all mean null; the core then reports NO_TOKEN and the user reconnects.
  • put() overwrites. It is called on connect and after every refresh, possibly from two processes at once. Last write wins is correct; both values are valid.
  • It is called from workers. No request, no session, no signed-in user. Key off the string you are given.
  • It survives kernel.reset. Don't cache tokens in a property for longer than a request or message, or implement ResetInterface to clear it.

The adobe-sign:create-token-table command and the schema listener only exist for token_store: doctrine; your own store manages its own schema.

Common questions

Can I switch from the cache store to Doctrine later?

Yes, but stored tokens don't move by themselves. Either let users reconnect, or copy them once: for each known user key, get() from a CacheTokenStore on the old pool and put() into the DoctrineDbalTokenStore. Both use the same encryptor, so the key stays the same.

How do I list every connected user?

With the Doctrine store, SELECT user_key, updated_at FROM adobe_sign_tokens. The cache store can't list (keys are hashed), which is one more reason to prefer Doctrine in production.

Is kernel.secret a good enough encryption key?

Cryptographically yes, if it is long and random (at least 32 random characters; recent Symfony skeletons leave APP_SECRET empty in .env, so set it per environment). The reason to use a dedicated encryption_key is operational: rotating APP_SECRET then doesn't disconnect your users.

Our staging database is a copy of production. Are tokens exposed there?

Not if staging has a different APP_SECRET or encryption_key: the copied payloads won't decrypt and every user appears disconnected. If staging shares the production key, it can act as your users at Adobe; give it its own key, or empty the table when you copy.

What personal data does the token table hold?

The user key (by default the user identifier, often an email) in plain text, and the encrypted token. Treat the row as personal data: delete it with forget() when you delete the user, and use a key resolver that returns an opaque id if you'd rather not store emails.