Верификация токена авторизации Facebook Limited Login на PHP

Ограниченный вход (Limited Login) от фейсбук

Недавно фейбук предложил вариант реализации ограниченного логина (Limited Login) для разработчиков приложений. Особенность нового способа авторизации в том, что данные конечного пользователя, использующего вход в Facebook через приложение, не используются для персонализации или измерения эффективности рекламы Facebook.

Для пользователей выгода очевидна — конфиденциальность. Фейсбук при этом может выполнять последние требования мобильных операционных систем. А вот у бекенд разработчиков веб приложений с iOS фронтендом появилась новая проблема — необходимость валидации фейсбук токена авторизации (AuthenticationToken) завернутого в OpenID Connect JWT токен.

На момент написания статьи необходимость обязательного использования ограниченного вход в Facebook относится только к iOS приложениям. Это связано с новыми условиями использования iOS 14.5 и принудительным использованием инфраструктуры прозрачности отслеживания приложений.

Что касается iOS, разработчикам разрешено использовать обычный вход в FB на iOS с версией 14.4 или ниже. Но с версии 14.5 разработчики должны показывать всплывающее окно ATT и выбирать способ входа в систему в зависимости от ответа пользователя.

Как работает данная технология в фейсбук можно прочитать в описании Limited Login технологии для разработчиков от фейсбук.

Валидация Limited Login OIDC фейсбук токена

Процесс валидации токена также подробно описан в описании валидации токена для разработчиков от фейсбук. Но примеров реализации валидации нового токена ни фейсбук не предоставляет, что вызывает некоторые проблемы с имплементацией, если вы ранее не сталкивались с такими понятиями как:  OpenID, JWK, PEM и JWT.

Успешный вход в фейсбук с использованием технологии ограниченного входа возвращает AuthenticationToken, который представляет собой JWT токен, всю необходимую информацию о пользователе и данные, которые требуются для ее валидации.

Чтобы убедиться в валидности данных необходимо проверить срок действия токена и  достоверность подписи данных. Также важно проверить, что токен выдан именно фейсбуком и именно для вашего фейсбук приложения.

Только убедившись в подлинности токена можно доверять предоставленным данным.

Из документации фейсбука и логики работы используемых технологий, можно сделать вывод, что реализация процесса получения данных пользователя из AuthenticationToken для дальнейшего использования выглядит примерно так:

  • Получаем связку паблик ключей для Facebook OIDC в JWK формате;
  • Конвертируем ключ в в PEM формат требуемый для валидации JWT токена;
  • Проверяем валидность и подлинность JWT токена;
  • Используем достоверные данные пользователя.

Реализация валидации OIDC фейсбук токена на PHP

Итак, приступим к реализации процесса получения подлинных данных пользователя из  Facebook OIDC по порядку.

Точка входа oauth/openid/jwks возвращает связку паблик ключей для Facebook OIDC имплементации в JWK формате. Для ее получения реализуем  FacebookJWKProvider предоставляющий паблик ключ фейсбук по идентификатору ключа (kid).

class FacebookJWKProvider
{
    private const FACEBOOK_JWKS_SOURCE_URL = ‘https://www.facebook.com/.well-known/oauth/openid/jwks/’;

    /** @var Client */
    private $client;

    /**
    * @return string
    */
    private function getFacebookJWKByKeyId(string $keyId): string
    {
        $response = $this->getGuzzleHttpClient()->request(
            'GET',
            self::FACEBOOK_JWKS_SOURCE_URL
        );

        $jsonResponse = $response->getBody()->getContents();
        $decodedResponse = json_decode($jsonResponse, 512, JSON_THROW_ON_ERROR);
        $jwkSet = $decodedResponse[‘keys’];

        $mappedJwkSet = [];
        foreach ($jwkSet as $jwk) {
            $mappedJwkSet[$jwk['kid']] = $jwk;
        }

        return $mappedJwkSet[$keyId] ?? null;
    }

    /**
    * @return Client
    */
    private function getGuzzleHttpClient(): Client
    {
        if (!$this->client) {
            $this->client = new Client([
                'timeout' => 10,
            ]);
        }
        return $this->client;
    }
}

Далее нам необходимо реализовать конвертер полученного ключа из JWK в PEM формат — JWKToPEMConvertor.

Для реализации конвертации публичного ключа фейсбука из JWK в PEM формат понадобится библиотека phpseclib. В примере используется ее вторая версия, которую можно легко установить с помощью композера:

composer require phpseclib/phpseclib:2.0

Если вам потребуется использовать phpseclib третьей версии, то загрузку ключа придется немного переписать.

Также для реализации конвертора потребуется Base64UrlDecoder. К сожалению язык программирования PHP не поддерживает Base64URL стандарт, но не составит труда реализовать Base64URL декодер стандартными функциями языка.

class Base64UrlDecoder
{
    /**
    * @param string $base64UrlEncodedString
    * @return string
    */
    private base64UrlDecode(string base64UrlEncodedString): string
    {
        return base64_decode(strtr($base64UrlEncodedString, '-_', '+/');
    }
}

Теперь реализуем сам JWKToPEMConvertor.

class JWKToPEMConvertor
{
    /** @var Base64UrlDecoder */
    private $base64UrlDecoder;

    /**
    * @param Base64UrlDecoder $base64UrlDecoder
    */
    public function __construct(
    Base64UrlDecoder $base64UrlDecoder
    ) {
        $this->base64UrlDecoder = $base64UrlDecoder;
    }

    /**
    * @param array $jwk
    * @return string
    */
    public function convert(array $jwk): string
    {
        if (
            !array_key_exists('e', $jwk)
            || !array_key_exists('n', $jwk)
            || !array_key_exists('kty', $jwk)
        ) {
            throw new \InvalidArgumentException('Invalid JWK');
        }

        if ($jwk['kty'] !== 'RSA') {
            throw new \InvalidArgumentException('RSA key type is currently only supported.');
        }

        $keySrc = [
            'e' => new BigInteger(
                base64_decode($jwk['e']),
                256
            ),
            'n' => new BigInteger(
                $this->base64UrlDecoder->base64UrlDecode($jwk['n']),
                256
            ),
        ];

        $rsa = new RSA();
        $rsa->loadKey($keySrc);

        return $rsa->getPublicKey();
        //return PublicKeyLoader::load($keySrc); // phpseclib 3.0+ variant
    }
}

Итак, у нас есть JWT токен, мы можем теперь получить паблик ключ фейсбука и конвертировать его в PEM формат. Осталось декодировать токен, выполнить валидацию и проверить его подлинность.

Декодировать токен проще всего при помощи firebase библиотеки firebase/php-jwt, которую можно легко установить через композер:

composer require firebase/php-jwt:5.0.0

Реализуем для декодирования и валидации токена FacebookAuthenticationTokenVerifier.

class FacebookAuthenticationTokenVerifier
{
    private const FACEBOOK_ISSUER = 'https://facebook.com';

    /** @var FacebookJWKProvider */
    public $facebookJWKProvider;

    /** @var JWKToPEMConvertor */
    public $jWKToPEMConvertor;

    /** @var Base64UrlDecoder */
    private $base64UrlDecoder;

    /** @var string */
    private $facebookApplicationId;

    /**
    * @param FacebookJWKProvider $facebookJWKProvider
    * @param JWKToPEMConvertor $jWKToPEMConvertor
    * @param Base64UrlDecoder $base64UrlDecoder
    * @param string $facebookApplicationId
    */
    public function __construct(
        FacebookJWKProvider $facebookJWKProvider,
        JWKToPEMConvertor $jWKToPEMConvertor,
        Base64UrlDecoder $base64UrlDecoder,
        string $facebookApplicationId
    ) {
        $this->facebookJWKProvider = $facebookJWKProvider;
        $this->jWKToPEMConvertor = $jWKToPEMConvertor;
        $this->base64UrlDecoder = $base64UrlDecoder;
        $this->facebookApplicationId = $facebookApplicationId;
    }

    /**
    * @param string $authenticationToken
    * @return string|null
    */
    public function getVerifiedAuthenticationToken(string $authenticationToken): ?string
    {
        list($encodedHeader) = explode('.', $authenticationToken);
        $header = json_decode(
            $this->base64UrlDecoder->base64UrlDecode($encodedHeader)),
            true,
            512,
            JSON_THROW_ON_ERROR
        );

        if (!($publicKeyId = $header['kid'] ?? null)) {
            throw new \InvalidArgumentException('Key id in facebook authentication token not found');
        }

        $jwk = $this->facebookJWKProvider->getFacebookJWKByKeyId($publicKeyId);
        $publicKeyAsPem = $this->jWKToPEMConvertor->convert($jwk);
        $decodedAuthenticationToken = JWT::decode(
            $authenticationToken,
            $publicKeyAsPem,
            ['RS256']
        );

        if (decodedAuthenticationToken->iss !== self::FACEBOOK_ISSUER) {
            throw new \InvalidArgumentException('Token Issuer is invalid');
        }

        if ($decodedAuthenticationToken->aud !== $this->facebookApplicationId) {
            throw new \InvalidArgumentException('Token Application ID is invalid');
        }

        return $decodedAuthenticationToken;
    }
}

Верифицированный токен представляет собой объект с набором данных пользователя, которые можно смело использовать как доверенные:

{
    "iss": "https://facebook.com",
    "aud": "Facebook Application ID",
    "sub": "Facebook User ID",
    "iat": 1640871169,
    "exp": 1640874769,
    "jti": "Token UID"
    "nonce": "Your NONCE",
    "given_name": "Facebook User First Name",
    "family_name": "Facebook User Second Name",
    "name": "Facebook User Full Name",
    "picture": "https://platform-lookaside.fbsbx.com/platform/profilepic/picture-url"
}

Вот такая вот оказалась простая не простая задача по верификации токена фейсбук авторизации.