This commit is contained in:
2025-10-20 14:10:54 +02:00
parent 75ca8fd840
commit d2c1970ef8
732 changed files with 101915 additions and 2 deletions

View File

@@ -0,0 +1 @@
/vendor

View File

@@ -0,0 +1,17 @@
<?xml version="1.0"?>
<ruleset name="Smolblog League Project" namespace="Smolblog\League">
<description>Coding standards for Smolblog-maintained PHPLeague-compatible packages.</description>
<file>./src</file>
<arg name="extensions" value="php"/>
<arg name="tab-width" value="2"/>
<arg name="colors"/>
<ini name="memory_limit" value="64M"/>
<autoload>./vendor/autoload.php</autoload>
<!-- Use PSR-2 as our base -->
<rule ref="PSR2"/>
</ruleset>

View File

@@ -0,0 +1,19 @@
# Changelog #
This project uses [semantic versioning](https://semver.org).
## Version 1
### 1.0.0
- Initial release
### 1.0.1
- Correctly return error description ([PR #2](https://github.com/smolblog/oauth2-twitter/pull/2))
### 1.1.0
- Fix a license mismatch (#4)
- Add user profile image to the default user (#5)
- Update README to reflect state of Twitter API and this library

View File

@@ -0,0 +1,43 @@
# Contributing
Contributions are **welcome** and will be fully **credited**.
We accept contributions via Pull Requests on [Github](https://github.com/smolblog/oauth2-twitter).
## Pull Requests
- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer).
- **Add tests!** - Your patch won't be accepted if it doesn't have tests.
- **Document any change in behaviour** - Make sure the README and any other relevant documentation are kept up-to-date.
- **Consider our release cycle** - We try to follow SemVer. Randomly breaking public APIs is not an option.
- **Create topic branches** - Don't ask us to pull from your master branch.
- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting.
- **Ensure tests pass!** - Please run the tests (see below) before submitting your pull request, and make sure they pass. We won't accept a patch until all tests pass.
- **Ensure no coding standards violations** - Please run PHP Code Sniffer using the PSR-2 standard (see below) before submitting your pull request. A violation will cause the build to fail, so please make sure there are no violations. We can't accept a patch if the build fails.
## Running Tests
``` bash
$ composer test
```
## Running PHP Code Sniffer
``` bash
$ composer lintfix
$ composer lint
```
**Happy coding**!

View File

@@ -0,0 +1,28 @@
Modified BSD License
====================
_Copyright © 2022-2023, Smolblog and contributors_
_All rights reserved._
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the Smolblog project nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL EVAN HILDRETH D/B/A SMOLBLOG BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,128 @@
# Twitter Provider for OAuth 2.0 Client
This package provides Twitter OAuth 2.0 support for the PHP League's [OAuth 2.0 Client](https://github.com/thephpleague/oauth2-client).
## Installation
To install, use composer:
```
composer require smolblog/oauth2-twitter
```
## Usage
Usage is the same as The League's OAuth client, using `\Smolblog\OAuth2\Client\Provider\Twitter` as the provider.
### Authorization Code Flow
```php
<?php
session_start();
require_once 'vendor/autoload.php';
$provider = new Smolblog\OAuth2\Client\Provider\Twitter([
'clientId' => 'MjVXMnRGVUN5Ym5lcVllcTVKZkk6MTpjaQ',
'clientSecret' => 'YDPiM-JsC5xU44P2VijGJRB7zdKB1PckCGjOynXGx9HZM7N6As',
'redirectUri' => 'http://oddevan.test/twitter-test/',
]);
if (!isset($_GET['code'])) {
unset($_SESSION['oauth2state']);
unset($_SESSION['oauth2verifier']);
// Optional: The default scopes are tweet.read, users.read,
// and offline.access. You can change them like this:
$options = [
scope => [
tweet.read,
tweet.write,
tweet.moderate.write,
users.read,
follows.read,
follows.write,
offline.access,
space.read,
mute.read,
mute.write,
like.read,
like.write,
list.read,
list.write,
block.read,
block.write,
bookmark.read,
bookmark.write,
],
];
// If we don't have an authorization code then get one
$authUrl = $provider->getAuthorizationUrl($options);
$_SESSION['oauth2state'] = $provider->getState();
// We also need to store the PKCE Verification code so we can send it with
// the authorization code request.
$_SESSION['oauth2verifier'] = $provider->getPkceVerifier();
header('Location: '.$authUrl);
exit;
// Check given state against previously stored one to mitigate CSRF attack
} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
unset($_SESSION['oauth2state']);
exit('Invalid state');
} else {
try {
// Try to get an access token (using the authorization code grant)
$token = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code'],
'code_verifier' => $_SESSION['oauth2verifier'],
]);
// Optional: Now you have a token you can look up a users profile data
// We got an access token, let's now get the user's details
$user = $provider->getResourceOwner($token);
// Use these details to create a new profile
printf('Hello %s!', $user->getName());
} catch (Exception $e) {
echo '<pre>';
print_r($e);
echo '</pre>';
// Failed to get user details
exit('Oh dear...');
}
// Use this to interact with an API on the users behalf
echo $token->getToken();
}
```
## Changelog
See `CHANGELOG.md`
## Credits
- [Evan Hildreth](https://github.com/oddevan)
- [Niklas](https://github.com/niklaswa)
Maintained* as part of the [Smolblog](https://smolblog.org/) project.
_*With [Twitter's new paid API][twapi], the Smolblog project is no longer able to reliably maintain this plugin. We will
fix any issues we can, but we can no longer react to new features. If you want to take over active maintenance, get in
touch._
[twapi]: https://developer.twitter.com/en/docs/twitter-api/getting-started/about-twitter-api#item0
## License
The Modified 3-clause BSD License (BSD). Please see [License File](https://github.com/smolblog/oauth2-twitter/blob/main/LICENSE.md) for more information.

View File

@@ -0,0 +1,48 @@
{
"name": "smolblog\/oauth2-twitter",
"description": "Twitter OAuth 2.0 Client Provider for The PHP League OAuth2-Client",
"license": "BSD-3-Clause",
"authors": [
{
"name": "Smolblog",
"email": "dev@smolblog.org"
},
{
"name": "Evan Hildreth",
"email": "me@eph.me"
}
],
"keywords": [
"oauth",
"oauth2",
"client",
"authorization",
"authorisation",
"twitter"
],
"require": {
"php": "^7.3 || ^8.0",
"league\/oauth2-client": "^2.0",
"paragonie\/random-lib": "^2.0"
},
"require-dev": {
"eloquent\/phony-phpunit": "^6.0 || ^7.1",
"phpunit\/phpunit": ">=8.0",
"squizlabs\/php_codesniffer": "^3.0"
},
"autoload": {
"psr-4": {
"Pshowsso\\Scope68f5e85e9608b\\Smolblog\\OAuth2\\Client\\Provider\\": "src\/"
}
},
"autoload-dev": {
"psr-4": {
"Pshowsso\\Scope68f5e85e9608b\\Smolblog\\OAuth2\\Client\\Provider\\Test\\": "test\/src\/"
}
},
"scripts": {
"test": "phpunit --testdox test\/src\/",
"lint": ".\/vendor\/squizlabs\/php_codesniffer\/bin\/phpcs",
"lintfix": ".\/vendor\/squizlabs\/php_codesniffer\/bin\/phpcbf"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
<?php
/**
* This file is part of the smolblog/oauth2-twitter library
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @copyright Copyright (c) Evan Hildreth <me@eph.me> (on behalf of the Smolblog project)
* @license http://opensource.org/licenses/BSD BSD
* @link https://packagist.org/packages/smolblog/oauth2-twitter Packagist
* @link https://github.com/smolblog/oauth2-twitter GitHub
*/
namespace Pshowsso\Scope68f5e85e9608b\Smolblog\OAuth2\Client\Provider;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Provider\AbstractProvider;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Token\AccessToken;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use Pshowsso\Scope68f5e85e9608b\Psr\Http\Message\RequestInterface;
use Pshowsso\Scope68f5e85e9608b\Psr\Http\Message\ResponseInterface;
use RandomLib\Factory as RandomLibFactory;
/**
* Represents a Twitter OAuth2 service provider (authorization server).
*
* @link http://tools.ietf.org/html/rfc6749#section-1.1 Roles (RFC 6749, §1.1)
*/
class Twitter extends AbstractProvider
{
use BearerAuthorizationTrait;
/**
* In addition to state, store a PKCE verifier that will be used when
* getting the authorization token.
*
* @link https://www.oauth.com/oauth2-servers/pkce/authorization-code-exchange/
*
* @var string
*/
protected string $pkceVerifier;
/**
* Get the unhashed PKCE Verifier string for the request.
*
* @return string
*/
public function getPkceVerifier(): string
{
if (!isset($this->pkceVerifier)) {
$this->pkceVerifier = $this->generatePkceVerifier();
}
return $this->pkceVerifier;
}
/**
* Get the unhashed PKCE Verifier string for the request.
*
* Forward-compatability with upcoming League/OAuth2 release.
*
* @return string
*/
public function getPkceCode(): string
{
return $this->getPkceVerifier();
}
/**
* Set the unhashed PKCE verifier string.
*
* Forward-compatability with upcoming League/OAuth2 release.
*
* @param string $pkceCode
* @return void
*/
public function setPkceCode($pkceCode)
{
$this->pkceVerifier = $pkceCode;
}
/**
* Returns the base URL for authorizing a client.
*
* Eg. https://oauth.service.com/authorize
*
* @return string
*/
public function getBaseAuthorizationUrl(): string
{
return 'https://twitter.com/i/oauth2/authorize';
}
protected function getAuthorizationParameters(array $options): array
{
if (!isset($options['code_challenge'])) {
$options['code_challenge'] = $this->generatePkceChallenge();
$options['code_challenge_method'] = 'S256';
}
return parent::getAuthorizationParameters($options);
}
/**
* Returns a prepared request for requesting an access token. Overridden
* to add the required headers for Twitter
*
* @param array $params Query string parameters
* @return RequestInterface
*/
protected function getAccessTokenRequest(array $params): RequestInterface
{
$request = parent::getAccessTokenRequest($params);
$token_string = base64_encode($this->clientId . ':' . $this->clientSecret);
return $request->withHeader('Authorization', "Basic {$token_string}");
}
/**
* Returns the base URL for requesting an access token.
*
* Eg. https://oauth.service.com/token
*
* @param array $params
* @return string
*/
public function getBaseAccessTokenUrl(array $params): string
{
return 'https://api.twitter.com/2/oauth2/token';
}
/**
* Returns the URL for requesting the resource owner's details.
*
* @param AccessToken $token
* @return string
*/
public function getResourceOwnerDetailsUrl(AccessToken $token): string
{
return 'https://api.twitter.com/2/users/me?user.fields=id,name,username,profile_image_url';
}
/**
* Returns the default scopes used by this provider.
*
* This should only be the scopes that are required to request the details
* of the resource owner, rather than all the available scopes.
*
* @return array
*/
protected function getDefaultScopes(): array
{
return ['tweet.read', 'users.read', 'offline.access'];
}
/**
* Returns the string that should be used to separate scopes when building
* the URL for requesting an access token.
*
* @return string Contains one space (` `)
*/
protected function getScopeSeparator(): string
{
return ' ';
}
/**
* Checks a provider response for errors.
*
* @throws IdentityProviderException
* @param ResponseInterface $response
* @param array|string $data Parsed response data
* @return void
*/
protected function checkResponse(ResponseInterface $response, $data): void
{
if ($response->getStatusCode() == 200) {
return;
}
$error = $data['error_description'] ?? '';
$code = $data['code'] ?? $response->getStatusCode();
throw new IdentityProviderException($error, $code, $data);
}
/**
* Generates a resource owner object from a successful resource owner
* details request.
*
* @param array $response
* @param AccessToken $token
* @return TwitterUser
*/
protected function createResourceOwner(array $response, AccessToken $token): TwitterUser
{
return new TwitterUser($response);
}
/**
* Gives a URL-friendly Base64-encoded version of a string
*
* @link https://www.oauth.com/oauth2-servers/pkce/authorization-request/
*
* @param string $param String to encode
* @return string
*/
private function base64Urlencode(string $param): string
{
return rtrim(strtr(base64_encode($param), '+/', '-_'), '=');
}
/**
* Create a PKCE verifier string.
*
* @link https://www.oauth.com/oauth2-servers/pkce/authorization-request/
*
* @return string
*/
public function generatePkceVerifier(): string
{
$generator = (new RandomLibFactory())->getMediumStrengthGenerator();
return $generator->generateString(
$generator->generateInt(43, 128),
// Length between 43-128 characters
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~'
);
}
/**
* Get the hashed and encoded PKCE challenge string for the request.
*
* @param string $passed_verifier Verifier string to use. Defaults to $this->getPkceVerifier().
* @return string
*/
public function generatePkceChallenge(string $passed_verifier = null): string
{
$verifier = $passed_verifier ?? $this->getPkceVerifier();
return $this->base64Urlencode(hash('SHA256', $verifier, \true));
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Pshowsso\Scope68f5e85e9608b\Smolblog\OAuth2\Client\Provider;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Provider\ResourceOwnerInterface;
class TwitterUser implements ResourceOwnerInterface
{
/**
* @var array
*/
protected $response;
/**
* @param array $response
*/
public function __construct(array $response)
{
$this->response = $response['data'] ?? [];
}
public function getId()
{
return $this->response['id'];
}
public function getName()
{
return $this->response['name'];
}
public function getUsername()
{
return $this->response['username'];
}
public function getImageUrl()
{
return $this->response['profile_image_url'];
}
/**
* Get user data as an array.
*
* @return array
*/
public function toArray(): array
{
return $this->response;
}
private function getResponseValue($key)
{
return $this->response[$key] ?? null;
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Smolblog\OAuth2\Client\Test\Provider;
use Eloquent\Phony\Phpunit\Phony;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Provider\ResourceOwnerInterface;
use Pshowsso\Scope68f5e85e9608b\League\OAuth2\Client\Token\AccessToken;
use PHPUnit\Framework\TestCase;
use Pshowsso\Scope68f5e85e9608b\Smolblog\OAuth2\Client\Provider\Twitter as TwitterProvider;
class TwitterTest extends TestCase
{
/** @var TwitterProvider */
protected $provider;
protected function setUp(): void
{
$this->provider = new TwitterProvider(['clientId' => 'mock_client_id', 'clientSecret' => 'mock_secret', 'redirectUri' => 'none', 'pkceVerifier' => 'ENuF7brJJNM5v-dEROtJf.Uee3kTO-GqNQ33fyuY33oixZXo9Vxiomml8-~3ulU9xu4xr_rj1weIer9UYu1JEzK_ZuDUtXe-zHi_2b6Eu41c~HEhzIlV6_QOQWeuvlyh']);
}
/**
* @link https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code
*/
public function testSmipleAuthorizationUrl(): void
{
$url = $this->provider->getAuthorizationUrl();
$uri = parse_url($url);
parse_str($uri['query'], $query);
self::assertArrayHasKey('response_type', $query);
self::assertArrayHasKey('client_id', $query);
self::assertArrayHasKey('redirect_uri', $query);
self::assertArrayHasKey('state', $query);
self::assertArrayHasKey('code_challenge', $query);
self::assertArrayHasKey('code_challenge_method', $query);
self::assertEquals('code', $query['response_type']);
self::assertEquals('mock_client_id', $query['client_id']);
self::assertEquals('none', $query['redirect_uri']);
self::assertEquals('Q7tD_xw-1L6mtr1RgNQ6-ZHCqA2mRg8_5_OqERLrJtE', $query['code_challenge']);
self::assertEquals('S256', $query['code_challenge_method']);
self::assertStringContainsString('tweet.read', $query['scope']);
self::assertStringContainsString('users.read', $query['scope']);
self::assertStringContainsString('offline.access', $query['scope']);
self::assertNotEmpty($this->provider->getState());
}
public function testBaseAccessTokenUrl(): void
{
$url = $this->provider->getBaseAccessTokenUrl([]);
$uri = parse_url($url);
self::assertEquals('/2/oauth2/token', $uri['path']);
}
public function testResourceOwnerDetailsUrl(): void
{
$token = $this->mockAccessToken();
$url = $this->provider->getResourceOwnerDetailsUrl($token);
self::assertEquals('https://api.twitter.com/2/users/me', $url);
}
public function testUserData(): void
{
// Mock
$response = ["data" => ["id" => "1132750396936589312", "name" => "Smolblog", "username" => "_smolblog"]];
$token = $this->mockAccessToken();
$provider = Phony::partialMock(TwitterProvider::class);
$provider->fetchResourceOwnerDetails->returns($response);
$google = $provider->get();
// Execute
$user = $google->getResourceOwner($token);
// Verify
Phony::inOrder($provider->fetchResourceOwnerDetails->called());
self::assertInstanceOf(ResourceOwnerInterface::class, $user);
self::assertEquals(1132750396936589312, $user->getId());
self::assertEquals('Smolblog', $user->getName());
self::assertEquals('_smolblog', $user->getUsername());
$user = $user->toArray();
self::assertArrayHasKey('id', $user);
self::assertArrayHasKey('name', $user);
self::assertArrayHasKey('username', $user);
}
public function testErrorResponse(): void
{
// Mock
$error_json = '{
"title": "Unauthorized",
"type": "about:blank",
"status": 401,
"detail": "Unauthorized"
}';
$stream = Phony::mock('Pshowsso\Scope68f5e85e9608b\GuzzleHttp\Psr7\Stream');
$stream->__toString->returns($error_json);
$response = Phony::mock('Pshowsso\Scope68f5e85e9608b\GuzzleHttp\Psr7\Response');
$response->getHeader->returns(['application/json']);
$response->getBody->returns($stream);
$provider = Phony::partialMock(TwitterProvider::class);
$provider->getResponse->returns($response);
$google = $provider->get();
$token = $this->mockAccessToken();
// Expect
$this->expectException(IdentityProviderException::class);
// Execute
$user = $google->getResourceOwner($token);
// Verify
Phony::inOrder($provider->getResponse->calledWith($this->instanceOf('Pshowsso\Scope68f5e85e9608b\GuzzleHttp\Psr7\Request')), $response->getHeader->called(), $response->getBody->called());
}
public function testVerifierGeneration(): void
{
$verifier = $this->provider->generatePkceVerifier();
$match_result = preg_match('/^[A-Za-z0-9\-._~]{43,128}$/', $verifier);
self::assertEquals(1, $match_result);
}
public function testChallengeGeneration(): void
{
$tests = ['g0sseWY2Gp772L_Xu7T1tHkeqRGAOk_9JnU9gFYCmKkVbkFUHu5izyZEivpxDsZU-r40geolIbX64zEvQ7Y4SOYwKL9drG9OF2g1kTB.PJ7nHPbVLFJFL-ziSv6KclSK' => 'hzRLCtPmWN3w_EVqGW19ARrMaXZBwYrpnTMkelrYIv4', 'd_O4i_N0nDZdsjl6JGE.vYoIi-Yr8lXcEYWUKXbjwojf8VtMaTmOSwJJYQ5n5NYz2BrdKSQFkLei3sSzP0dygP8vUkH3rP-dEBl9l5rvFAUXtjsTXUusxwRTisOUPe~Y' => 'Lk5oLe4qImaZKgQbT4ICB9rfD5Hy4ozjydlCP_9nPlo', 'H5MmPYr8-j.GHXGzaN.Ck8LFh-kmeK_Q6xgUZfOSYkYJHKObUJgtP0xcLCkAySnMBQ~-L-RUUfdNr7r2kT1-9Mpabf5wmoBbPRft.T8HFUiyuVCd4KcX2wRGfc1evspn' => 'e5KT8_NuYwqcBGkdv3t1Wk-QnbozLkjSaFXKfvDp0nU', 'D4R-xl8r_6slynxksZhCSbwj5fDB2Hdk8ZzfdW8iWqqbOx7A0oP_XCffIatxBR~J0JYAddxcpIBshuNOTxwUTXhm~24OZWAzmnn-s5FOnOK~mnetlfvDeH6cjhHg~H0-' => 'NA7eMVS9lXYsvSWA1T2wFXfxNK8Yx-RttVo9iwmQ2FM', 'Fk0SY30MvDDXCfwO8TiHz0cFADb3sP8-DqCDysiH7iY4NI_sVHW8Bbyl1sypVY61m4fGv4VzEX.ASdir4BRfcD..I70mINH~_L-g0_Y9xLXD9Di0fYu0psevbxm0yh~w' => 'VPKX0gnLeTzjM-UJ5Mc5ZR5VGQzh8ukr_RbFzbfYJ30'];
foreach ($tests as $verifier => $expected) {
self::assertEquals($expected, $this->provider->generatePkceChallenge($verifier));
}
}
private function mockAccessToken(): AccessToken
{
return new AccessToken(['access_token' => 'mock_access_token']);
}
}