├── src ├── Providers │ ├── ProviderException.php │ ├── PayPalSandbox.php │ ├── YouTube.php │ ├── AzureActiveDirectory.php │ ├── MicrosoftGraph.php │ ├── GitLab.php │ ├── Mixcloud.php │ ├── Bitbucket.php │ ├── OpenStreetmap.php │ ├── WordPress.php │ ├── Amazon.php │ ├── Discogs.php │ ├── OpenCaching.php │ ├── Tumblr2.php │ ├── SoundCloud.php │ ├── TwitterCC.php │ ├── Twitter.php │ ├── PayPal.php │ ├── Imgur.php │ ├── Google.php │ ├── OpenStreetmap2.php │ ├── Stripe.php │ ├── NPROne.php │ ├── Pinterest.php │ ├── Foursquare.php │ ├── Tidal.php │ ├── Tumblr.php │ ├── BigCartel.php │ ├── Flickr.php │ ├── Patreon.php │ ├── Gitea.php │ ├── Codeberg.php │ ├── DeviantArt.php │ ├── Mastodon.php │ ├── MusicBrainz.php │ ├── Deezer.php │ ├── GuildWars2.php │ ├── Vimeo.php │ ├── Discord.php │ ├── GitHub.php │ ├── MailChimp.php │ ├── BattleNet.php │ ├── Spotify.php │ ├── TikTok.php │ ├── Reddit.php │ ├── Slack.php │ ├── Steam.php │ └── Twitch.php ├── OAuthException.php ├── Storage │ ├── ItemNotFoundException.php │ ├── OAuthStorageException.php │ ├── OAuthStorageAbstract.php │ ├── MemoryStorage.php │ ├── OAuthStorageInterface.php │ └── SessionStorage.php ├── OAuthOptions.php ├── Core │ ├── UnauthorizedAccessException.php │ ├── CSRFStateMismatchException.php │ ├── InvalidAccessTokenException.php │ ├── ClientCredentials.php │ ├── OAuth1Interface.php │ ├── UserInfo.php │ ├── TokenRefresh.php │ ├── PAR.php │ ├── TokenInvalidate.php │ ├── CSRFToken.php │ ├── Utilities.php │ ├── PKCE.php │ ├── OAuth2Interface.php │ ├── PARTrait.php │ ├── ClientCredentialsTrait.php │ ├── AuthenticatedUser.php │ ├── PKCETrait.php │ ├── TokenInvalidateTrait.php │ ├── AccessToken.php │ └── OAuthInterface.php ├── OAuthProviderFactory.php └── OAuthOptionsTrait.php ├── LICENSE └── composer.json /src/Providers/ProviderException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\OAuthException; 15 | 16 | class ProviderException extends OAuthException{ 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/OAuthException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth; 13 | 14 | use RuntimeException; 15 | 16 | /** 17 | * Generic exception container 18 | */ 19 | class OAuthException extends RuntimeException{ 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Storage/ItemNotFoundException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Storage; 13 | 14 | /** 15 | * Thrown when an item cannot be found in the storage 16 | */ 17 | final class ItemNotFoundException extends OAuthStorageException{ 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/Storage/OAuthStorageException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Storage; 13 | 14 | use chillerlan\OAuth\OAuthException; 15 | 16 | /** 17 | * Thrown on general storage errors 18 | */ 19 | class OAuthStorageException extends OAuthException{ 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/OAuthOptions.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth; 13 | 14 | use chillerlan\Settings\SettingsContainerAbstract; 15 | 16 | /** 17 | * This class holds all settings related to the OAuth provider 18 | */ 19 | class OAuthOptions extends SettingsContainerAbstract{ 20 | use OAuthOptionsTrait; 21 | } 22 | -------------------------------------------------------------------------------- /src/Core/UnauthorizedAccessException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | use chillerlan\OAuth\OAuthException; 15 | 16 | /** 17 | * Thrown on generic "Unauthorized" HTTP errors: 400, 401, 403 18 | */ 19 | class UnauthorizedAccessException extends OAuthException{ 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/Core/CSRFStateMismatchException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | use chillerlan\OAuth\OAuthException; 15 | 16 | /** 17 | * Thrown on mismatching CSRF ("state") token 18 | * 19 | * @see \chillerlan\OAuth\Core\CSRFToken 20 | */ 21 | class CSRFStateMismatchException extends OAuthException{ 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Core/InvalidAccessTokenException.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Thrown when an access token is expired and cannot be refreshed 16 | * 17 | * @see \chillerlan\OAuth\Core\TokenRefresh 18 | * @see \chillerlan\OAuth\Core\OAuth1Provider::getRequestAuthorization() 19 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getRequestAuthorization() 20 | */ 21 | class InvalidAccessTokenException extends UnauthorizedAccessException{ 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Providers/PayPalSandbox.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | /** 15 | * PayPal OAuth2 (sandbox) 16 | * 17 | * @link https://developer.paypal.com/api/rest/ 18 | */ 19 | class PayPalSandbox extends PayPal{ 20 | 21 | public const IDENTIFIER = 'PAYPALSANDBOX'; 22 | 23 | protected string $authorizationURL = 'https://www.sandbox.paypal.com/connect'; 24 | protected string $accessTokenURL = 'https://api.sandbox.paypal.com/v1/oauth2/token'; 25 | protected string $apiURL = 'https://api.sandbox.paypal.com'; 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/Core/ClientCredentials.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Indicates whether the provider is capable of the OAuth2 client credentials authentication flow. 16 | * 17 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 18 | */ 19 | interface ClientCredentials{ 20 | 21 | /** 22 | * Obtains an OAuth2 client credentials token and returns an AccessToken 23 | * 24 | * @param string[]|null $scopes 25 | */ 26 | public function getClientCredentialsToken(array|null $scopes = null):AccessToken; 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Providers/YouTube.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | /** 15 | * YouTube OAuth2 16 | * 17 | * @link https://developers.google.com/youtube 18 | */ 19 | class YouTube extends Google{ 20 | 21 | public const IDENTIFIER = 'YOUTUBE'; 22 | 23 | public const SCOPE_YOUTUBE = 'https://www.googleapis.com/auth/youtube'; 24 | public const SCOPE_YOUTUBE_GDATA = 'https://gdata.youtube.com'; 25 | 26 | public const DEFAULT_SCOPES = [ 27 | self::SCOPE_EMAIL, 28 | self::SCOPE_PROFILE, 29 | self::SCOPE_YOUTUBE, 30 | self::SCOPE_YOUTUBE_GDATA, 31 | ]; 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/Core/OAuth1Interface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Specifies the basic methods for an OAuth1 provider. 16 | */ 17 | interface OAuth1Interface extends OAuthInterface{ 18 | 19 | /** 20 | * Obtains an OAuth1 access token with the given $token and $verifier and returns an AccessToken object. 21 | * 22 | * The $token (request token) supplied via `$_GET['oauth_token']` should be in the storage at this point. 23 | * 24 | * @link https://datatracker.ietf.org/doc/html/rfc5849#section-2.3 25 | */ 26 | public function getAccessToken(string $requestToken, string $verifier):AccessToken; 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Core/UserInfo.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Indicates whether the service can provide information about the currently authenticated user, 16 | * usually via a "/me", "/user" or "/tokeninfo" endpoint. 17 | */ 18 | interface UserInfo{ 19 | 20 | /** 21 | * Returns information about the currently authenticated user (usually a /me or /user endpoint). 22 | * 23 | * @see \chillerlan\OAuth\Core\OAuthProvider::sendMeRequest() 24 | * @see \chillerlan\OAuth\Core\OAuthProvider::getMeResponseData() 25 | * @see \chillerlan\OAuth\Core\OAuthProvider::handleMeResponseError() 26 | */ 27 | public function me():AuthenticatedUser; 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/Core/TokenRefresh.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Indicates whether the provider is capable of the OAuth2 token refresh. 16 | * 17 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-1.5 18 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-6 19 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-10.4 20 | */ 21 | interface TokenRefresh{ 22 | 23 | /** 24 | * Attempts to refresh an existing AccessToken with an associated refresh token and returns a fresh AccessToken. 25 | * 26 | * @throws \chillerlan\OAuth\Providers\ProviderException 27 | */ 28 | public function refreshAccessToken(AccessToken|null $token = null):AccessToken; 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Core/PAR.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | use Psr\Http\Message\UriInterface; 15 | 16 | /** 17 | * Specifies the methods required for the OAuth2 Pushed Authorization Requests (PAR) 18 | * 19 | * @link https://datatracker.ietf.org/doc/html/rfc9126 20 | */ 21 | interface PAR{ 22 | 23 | /** 24 | * Sends the given authorization request parameters to the PAR endpoint and returns 25 | * the full authorization URL including the URN obtained from the PAR request 26 | * 27 | * @see \chillerlan\OAuth\Core\OAuth2Provider::$parAuthorizationURL 28 | * 29 | * @link https://datatracker.ietf.org/doc/html/rfc9126#section-1.1 30 | * 31 | * @param array $body 32 | */ 33 | public function getParRequestUri(array $body):UriInterface; 34 | 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 smiley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Core/TokenInvalidate.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2023 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Indicates whether the provider is capable of invalidating access tokens (RFC-7009 or proprietary) 16 | * 17 | * @link https://datatracker.ietf.org/doc/html/rfc7009 18 | */ 19 | interface TokenInvalidate{ 20 | 21 | /** 22 | * Allows to invalidate an access token 23 | * 24 | * Clients shall set the optional OAuthProvider::$revokeURL for use in this method. 25 | * If a token is given via $token, that token should be invalidated, 26 | * otherwise the current user token from the internal storage should be used. 27 | * Returns true if the operation was successful, false otherwise. 28 | * May throw a ProviderException if an error occurred. 29 | * 30 | * @see \chillerlan\OAuth\Core\OAuthProvider::$revokeURL 31 | * 32 | * @throws \chillerlan\OAuth\Providers\ProviderException 33 | */ 34 | public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool; 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Core/CSRFToken.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Specifies the methods required for the OAuth2 CSRF token validation ("state parameter") 16 | * 17 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 18 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 19 | */ 20 | interface CSRFToken{ 21 | 22 | /** 23 | * Checks whether the CSRF state was set and verifies against the last known state. 24 | * Throws a ProviderException if the given state is empty, unknown or doesn't match the known state. 25 | * 26 | * @throws \chillerlan\OAuth\Providers\ProviderException 27 | */ 28 | public function checkState(string|null $state = null):void; 29 | 30 | /** 31 | * Sets the CSRF state parameter in a given array of query parameters and stores that value 32 | * in the local storage for later verification. Returns the updated array of parameters. 33 | * 34 | * @param array $params 35 | * @return array 36 | * @throws \chillerlan\OAuth\Providers\ProviderException 37 | */ 38 | public function setState(array $params):array; 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Providers/AzureActiveDirectory.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{CSRFToken, OAuth2Provider}; 15 | 16 | /** 17 | * Microsoft identity platform (OAuth2) 18 | * 19 | * @link https://learn.microsoft.com/en-us/entra/identity-platform/v2-app-types 20 | * @link https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow 21 | * @link https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow 22 | */ 23 | abstract class AzureActiveDirectory extends OAuth2Provider implements CSRFToken{ 24 | 25 | public const SCOPE_OPENID = 'openid'; 26 | public const SCOPE_OPENID_EMAIL = 'email'; 27 | public const SCOPE_OPENID_PROFILE = 'profile'; 28 | public const SCOPE_OFFLINE_ACCESS = 'offline_access'; 29 | 30 | protected string $authorizationURL = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'; 31 | protected string $accessTokenURL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; 32 | protected string|null $userRevokeURL = 'https://account.live.com/consent/Manage'; 33 | // phpcs:ignore 34 | protected string|null $applicationURL = 'https://aad.portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps'; 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Core/Utilities.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @filesource 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\Utilities\File; 17 | use DirectoryIterator; 18 | use ReflectionClass; 19 | use function hash; 20 | use function substr; 21 | use function trim; 22 | 23 | /** 24 | * Common utilities for use with the OAuth providers 25 | */ 26 | class Utilities{ 27 | 28 | /** 29 | * Fetches a list of provider classes in the given directory 30 | * 31 | * @return array> 32 | */ 33 | public static function getProviders(string|null $providerDir = null, string|null $namespace = null):array{ 34 | $providerDir = File::realpath(($providerDir ?? __DIR__.'/../Providers')); 35 | $namespace = trim(($namespace ?? 'chillerlan\\OAuth\\Providers'), '\\'); 36 | $providers = []; 37 | 38 | foreach(new DirectoryIterator($providerDir) as $e){ 39 | 40 | if($e->getExtension() !== 'php'){ 41 | continue; 42 | } 43 | 44 | $r = new ReflectionClass($namespace.'\\'.substr($e->getFilename(), 0, -4)); 45 | 46 | if(!$r->implementsInterface(OAuthInterface::class) || $r->isAbstract()){ 47 | continue; 48 | } 49 | 50 | $providers[hash('crc32b', $r->getShortName())] = [ 51 | 'name' => $r->getShortName(), 52 | 'fqcn' => $r->getName(), 53 | 'path' => $e->getRealPath(), 54 | ]; 55 | 56 | } 57 | 58 | return $providers; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Providers/MicrosoftGraph.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, UserInfo}; 15 | 16 | /** 17 | * Microsoft Graph OAuth2 18 | * 19 | * @link https://learn.microsoft.com/en-us/graph/permissions-reference 20 | */ 21 | class MicrosoftGraph extends AzureActiveDirectory implements UserInfo{ 22 | 23 | public const IDENTIFIER = 'MICROSOFTGRAPH'; 24 | 25 | public const SCOPE_USER_READ = 'User.Read'; 26 | public const SCOPE_USER_READBASIC_ALL = 'User.ReadBasic.All'; 27 | 28 | public const DEFAULT_SCOPES = [ 29 | self::SCOPE_OPENID, 30 | self::SCOPE_OPENID_EMAIL, 31 | self::SCOPE_OPENID_PROFILE, 32 | self::SCOPE_OFFLINE_ACCESS, 33 | self::SCOPE_USER_READ, 34 | self::SCOPE_USER_READBASIC_ALL, 35 | ]; 36 | 37 | protected string $apiURL = 'https://graph.microsoft.com'; 38 | protected string|null $apiDocs = 'https://learn.microsoft.com/graph/overview'; 39 | 40 | /** @codeCoverageIgnore */ 41 | public function me():AuthenticatedUser{ 42 | $json = $this->getMeResponseData('/v1.0/me'); 43 | 44 | $userdata = [ 45 | 'data' => $json, 46 | 'handle' => $json['userPrincipalName'], 47 | 'displayName' => $json['displayName'], 48 | 'email' => $json['mail'], 49 | 'id' => $json['id'], 50 | ]; 51 | 52 | return new AuthenticatedUser($userdata); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/Providers/GitLab.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{ 15 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo, 16 | }; 17 | 18 | /** 19 | * GitLab OAuth2 20 | * 21 | * @link https://docs.gitlab.com/ee/api/oauth2.html 22 | */ 23 | class GitLab extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenRefresh, UserInfo{ 24 | use ClientCredentialsTrait; 25 | 26 | public const IDENTIFIER = 'GITLAB'; 27 | 28 | protected string $authorizationURL = 'https://gitlab.com/oauth/authorize'; 29 | protected string $accessTokenURL = 'https://gitlab.com/oauth/token'; 30 | protected string $apiURL = 'https://gitlab.com/api'; 31 | protected string|null $applicationURL = 'https://gitlab.com/profile/applications'; 32 | protected string|null $apiDocs = 'https://docs.gitlab.com/ee/api/rest/'; 33 | 34 | /** @codeCoverageIgnore */ 35 | public function me():AuthenticatedUser{ 36 | $json = $this->getMeResponseData('/v4/user'); 37 | 38 | $userdata = [ 39 | 'data' => (array)$json, 40 | 'avatar' => $json['avatar_url'], 41 | 'displayName' => $json['name'], 42 | 'email' => $json['email'], 43 | 'handle' => $json['username'], 44 | 'id' => $json['id'], 45 | 'url' => $json['web_url'], 46 | ]; 47 | 48 | return new AuthenticatedUser($userdata); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Providers/Mixcloud.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, OAuth2Provider, UserInfo}; 15 | 16 | /** 17 | * Mixcloud OAuth2 18 | * 19 | * note: a missing slash at the end of the path will end up in a HTTP/301 20 | * 21 | * @link https://www.mixcloud.com/developers/ 22 | */ 23 | class Mixcloud extends OAuth2Provider implements UserInfo{ 24 | 25 | public const IDENTIFIER = 'MIXCLOUD'; 26 | 27 | public const AUTH_METHOD = self::AUTH_METHOD_QUERY; 28 | 29 | protected string $authorizationURL = 'https://www.mixcloud.com/oauth/authorize'; 30 | protected string $accessTokenURL = 'https://www.mixcloud.com/oauth/access_token'; 31 | protected string $apiURL = 'https://api.mixcloud.com'; 32 | protected string|null $userRevokeURL = 'https://www.mixcloud.com/settings/applications/'; 33 | protected string|null $apiDocs = 'https://www.mixcloud.com/developers/'; 34 | protected string|null $applicationURL = 'https://www.mixcloud.com/developers/create/'; 35 | 36 | /** @codeCoverageIgnore */ 37 | public function me():AuthenticatedUser{ 38 | // mixcloud sends "Content-Type: text/javascript" for JSON content (????) 39 | $json = $this->getMeResponseData('/me/'); 40 | 41 | $userdata = [ 42 | 'data' => $json, 43 | 'avatar' => $json['pictures']['extra_large'], 44 | 'handle' => $json['username'], 45 | 'url' => $json['url'], 46 | ]; 47 | 48 | return new AuthenticatedUser($userdata); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Providers/Bitbucket.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{ 15 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo, 16 | }; 17 | 18 | /** 19 | * Bitbucket OAuth2 (Atlassian) 20 | * 21 | * @link https://developer.atlassian.com/cloud/bitbucket/oauth-2/ 22 | */ 23 | class Bitbucket extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenRefresh, UserInfo{ 24 | use ClientCredentialsTrait; 25 | 26 | public const IDENTIFIER = 'BITBUCKET'; 27 | 28 | protected string $authorizationURL = 'https://bitbucket.org/site/oauth2/authorize'; 29 | protected string $accessTokenURL = 'https://bitbucket.org/site/oauth2/access_token'; 30 | protected string $apiURL = 'https://api.bitbucket.org/2.0'; 31 | protected string|null $apiDocs = 'https://developer.atlassian.com/bitbucket/api/2/reference/'; 32 | protected string|null $applicationURL = 'https://developer.atlassian.com/apps/'; 33 | 34 | /** @codeCoverageIgnore */ 35 | public function me():AuthenticatedUser{ 36 | $json = $this->getMeResponseData('/user'); 37 | 38 | $userdata = [ 39 | 'data' => $json, 40 | 'avatar' => $json['links']['avatar']['href'], 41 | 'displayName' => $json['display_name'], 42 | 'handle' => $json['username'], 43 | 'id' => $json['account_id'], 44 | 'url' => $json['links']['self']['href'], 45 | ]; 46 | 47 | return new AuthenticatedUser($userdata); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/Providers/OpenStreetmap.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, OAuth1Provider, UserInfo}; 15 | 16 | /** 17 | * OpenStreetmap OAuth1 (deprecated) 18 | * 19 | * @link https://wiki.openstreetmap.org/wiki/API 20 | * @link https://wiki.openstreetmap.org/wiki/OAuth 21 | * 22 | * @deprecated https://github.com/openstreetmap/operations/issues/867 23 | */ 24 | class OpenStreetmap extends OAuth1Provider implements UserInfo{ 25 | 26 | public const IDENTIFIER = 'OPENSTREETMAP'; 27 | 28 | protected string $requestTokenURL = 'https://www.openstreetmap.org/oauth/request_token'; 29 | protected string $authorizationURL = 'https://www.openstreetmap.org/oauth/authorize'; 30 | protected string $accessTokenURL = 'https://www.openstreetmap.org/oauth/access_token'; 31 | protected string $apiURL = 'https://api.openstreetmap.org'; 32 | protected string|null $apiDocs = 'https://wiki.openstreetmap.org/wiki/API'; 33 | protected string|null $applicationURL = 'https://www.openstreetmap.org/user/{USERNAME}/oauth_clients'; 34 | 35 | /** @codeCoverageIgnore */ 36 | public function me():AuthenticatedUser{ 37 | $json = $this->getMeResponseData('/api/0.6/user/details.json'); 38 | 39 | $userdata = [ 40 | 'data' => $json, 41 | 'avatar' => $json['user']['img']['href'], 42 | 'displayName' => $json['user']['display_name'], 43 | 'id' => $json['user']['id'], 44 | ]; 45 | 46 | return new AuthenticatedUser($userdata); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/Providers/WordPress.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, UserInfo}; 17 | 18 | /** 19 | * WordPress OAuth2 20 | * 21 | * @link https://developer.wordpress.com/docs/oauth2/ 22 | */ 23 | class WordPress extends OAuth2Provider implements CSRFToken, UserInfo{ 24 | 25 | public const IDENTIFIER = 'WORDPRESS'; 26 | 27 | public const SCOPE_AUTH = 'auth'; 28 | public const SCOPE_GLOBAL = 'global'; 29 | 30 | public const DEFAULT_SCOPES = [ 31 | self::SCOPE_GLOBAL, 32 | ]; 33 | 34 | protected string $authorizationURL = 'https://public-api.wordpress.com/oauth2/authorize'; 35 | protected string $accessTokenURL = 'https://public-api.wordpress.com/oauth2/token'; 36 | protected string $apiURL = 'https://public-api.wordpress.com/rest'; 37 | protected string|null $userRevokeURL = 'https://wordpress.com/me/security/connected-applications'; 38 | protected string|null $apiDocs = 'https://developer.wordpress.com/docs/api/'; 39 | protected string|null $applicationURL = 'https://developer.wordpress.com/apps/'; 40 | 41 | /** @codeCoverageIgnore */ 42 | public function me():AuthenticatedUser{ 43 | $json = $this->getMeResponseData('/v1/me'); 44 | 45 | $userdata = [ 46 | 'data' => $json, 47 | 'avatar' => $json['avatar_URL'], 48 | 'handle' => $json['username'], 49 | 'displayName' => $json['display_name'], 50 | 'email' => $json['email'], 51 | 'id' => $json['ID'], 52 | 'url' => $json['profile_URL'], 53 | ]; 54 | 55 | return new AuthenticatedUser($userdata); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/Providers/Amazon.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo}; 17 | 18 | /** 19 | * Login with Amazon for Websites (OAuth2) 20 | * 21 | * @link https://developer.amazon.com/docs/login-with-amazon/web-docs.html 22 | * @link https://developer.amazon.com/docs/login-with-amazon/conceptual-overview.html 23 | */ 24 | class Amazon extends OAuth2Provider implements CSRFToken, TokenRefresh, UserInfo{ 25 | 26 | public const IDENTIFIER = 'AMAZON'; 27 | 28 | public const SCOPE_PROFILE = 'profile'; 29 | public const SCOPE_PROFILE_USER_ID = 'profile:user_id'; 30 | public const SCOPE_POSTAL_CODE = 'postal_code'; 31 | 32 | public const DEFAULT_SCOPES = [ 33 | self::SCOPE_PROFILE, 34 | self::SCOPE_PROFILE_USER_ID, 35 | ]; 36 | 37 | protected string $authorizationURL = 'https://www.amazon.com/ap/oa'; 38 | protected string $accessTokenURL = 'https://www.amazon.com/ap/oatoken'; 39 | protected string $apiURL = 'https://api.amazon.com'; 40 | protected string|null $applicationURL = 'https://developer.amazon.com/loginwithamazon/console/site/lwa/overview.html'; 41 | protected string|null $apiDocs = 'https://developer.amazon.com/docs/login-with-amazon/web-docs.html'; 42 | 43 | /** @codeCoverageIgnore */ 44 | public function me():AuthenticatedUser{ 45 | $json = $this->getMeResponseData('/user/profile'); 46 | 47 | $userdata = [ 48 | 'data' => $json, 49 | 'displayName' => $json['name'], 50 | 'email' => $json['email'], 51 | 'id' => $json['user_id'], 52 | ]; 53 | 54 | return new AuthenticatedUser($userdata); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Providers/Discogs.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, OAuth1Provider, UserInfo}; 15 | use function sprintf; 16 | 17 | /** 18 | * Discogs OAuth1 19 | * 20 | * @link https://www.discogs.com/developers/ 21 | * @link https://www.discogs.com/developers/#page:authentication,header:authentication-oauth-flow 22 | */ 23 | class Discogs extends OAuth1Provider implements UserInfo{ 24 | 25 | public const IDENTIFIER = 'DISCOGS'; 26 | 27 | public const HEADERS_API = [ 28 | 'Accept' => 'application/vnd.discogs.v2.discogs+json', 29 | ]; 30 | 31 | protected string $requestTokenURL = 'https://api.discogs.com/oauth/request_token'; 32 | protected string $authorizationURL = 'https://www.discogs.com/oauth/authorize'; 33 | protected string $accessTokenURL = 'https://api.discogs.com/oauth/access_token'; 34 | protected string $apiURL = 'https://api.discogs.com'; 35 | protected string|null $userRevokeURL = 'https://www.discogs.com/settings/applications'; 36 | protected string|null $apiDocs = 'https://www.discogs.com/developers/'; 37 | protected string|null $applicationURL = 'https://www.discogs.com/settings/developers'; 38 | 39 | /** @codeCoverageIgnore */ 40 | public function me():AuthenticatedUser{ 41 | $json = $this->getMeResponseData('/oauth/identity'); 42 | 43 | // we could do a second request to [resource_url] for the avatar and more info, but that's not really worth it. 44 | $userdata = [ 45 | 'data' => $json, 46 | 'handle' => $json['username'], 47 | 'id' => $json['id'], 48 | 'url' => sprintf('https://www.discogs.com/user/%s', $json['username']), 49 | ]; 50 | 51 | return new AuthenticatedUser($userdata); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/Providers/OpenCaching.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, OAuth1Provider, UserInfo}; 15 | use function implode; 16 | 17 | /** 18 | * Opencaching OAuth1 19 | * 20 | * @link https://www.opencaching.de/okapi/ 21 | */ 22 | class OpenCaching extends OAuth1Provider implements UserInfo{ 23 | 24 | public const IDENTIFIER = 'OPENCACHING'; 25 | 26 | protected const USER_FIELDS = [ 27 | 'uuid', 'username', 'profile_url', 'internal_id', 'date_registered', 28 | 'caches_found', 'caches_notfound', 'caches_hidden', 'rcmds_given', 29 | 'rcmds_left', 'rcmd_founds_needed', 'home_location', 30 | ]; 31 | 32 | protected string $requestTokenURL = 'https://www.opencaching.de/okapi/services/oauth/request_token'; 33 | protected string $authorizationURL = 'https://www.opencaching.de/okapi/services/oauth/authorize'; 34 | protected string $accessTokenURL = 'https://www.opencaching.de/okapi/services/oauth/access_token'; 35 | protected string $apiURL = 'https://www.opencaching.de/okapi/services'; 36 | protected string|null $userRevokeURL = 'https://www.opencaching.de/okapi/apps/'; 37 | protected string|null $apiDocs = 'https://www.opencaching.de/okapi/'; 38 | protected string|null $applicationURL = 'https://www.opencaching.de/okapi/signup.html'; 39 | 40 | /** @codeCoverageIgnore */ 41 | public function me():AuthenticatedUser{ 42 | $json = $this->getMeResponseData('/users/user', ['fields' => implode('|', $this::USER_FIELDS)]); 43 | 44 | $userdata = [ 45 | 'data' => $json, 46 | 'handle' => $json['username'], 47 | 'id' => $json['uuid'], 48 | 'url' => $json['profile_url'], 49 | ]; 50 | 51 | return new AuthenticatedUser($userdata); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/Providers/Tumblr2.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2023 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{ 15 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo, 16 | }; 17 | use function sprintf; 18 | 19 | /** 20 | * Tumblr OAuth2 21 | * 22 | * @link https://www.tumblr.com/docs/en/api/v2#oauth2-authorization 23 | */ 24 | class Tumblr2 extends OAuth2Provider implements CSRFToken, TokenRefresh, ClientCredentials, UserInfo{ 25 | use ClientCredentialsTrait; 26 | 27 | public const IDENTIFIER = 'TUMBLR2'; 28 | 29 | public const SCOPE_BASIC = 'basic'; 30 | public const SCOPE_WRITE = 'write'; 31 | public const SCOPE_OFFLINE_ACCESS = 'offline_access'; 32 | 33 | public const DEFAULT_SCOPES = [ 34 | self::SCOPE_BASIC, 35 | self::SCOPE_WRITE, 36 | self::SCOPE_OFFLINE_ACCESS, 37 | ]; 38 | 39 | protected string $authorizationURL = 'https://www.tumblr.com/oauth2/authorize'; 40 | protected string $accessTokenURL = 'https://api.tumblr.com/v2/oauth2/token'; 41 | protected string $apiURL = 'https://api.tumblr.com'; 42 | protected string|null $userRevokeURL = 'https://www.tumblr.com/settings/apps'; 43 | protected string|null $apiDocs = 'https://www.tumblr.com/docs/en/api/v2'; 44 | protected string|null $applicationURL = 'https://www.tumblr.com/oauth/apps'; 45 | 46 | /** @codeCoverageIgnore */ 47 | public function me():AuthenticatedUser{ 48 | $json = $this->getMeResponseData('/v2/user/info'); 49 | 50 | $userdata = [ 51 | 'data' => $json, 52 | 'handle' => $json['response']['user']['name'], 53 | 'url' => sprintf('https://www.tumblr.com/%s', $json['response']['user']['name']), 54 | ]; 55 | 56 | return new AuthenticatedUser($userdata); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/Providers/SoundCloud.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, OAuth2Provider, TokenRefresh, UserInfo}; 15 | 16 | /** 17 | * SoundCloud OAuth2 18 | * 19 | * @link https://developers.soundcloud.com/ 20 | * @link https://developers.soundcloud.com/docs/api/guide#authentication 21 | * @link https://developers.soundcloud.com/blog/security-updates-api 22 | */ 23 | class SoundCloud extends OAuth2Provider implements ClientCredentials, TokenRefresh, UserInfo{ 24 | use ClientCredentialsTrait; 25 | 26 | public const IDENTIFIER = 'SOUNDCLOUD'; 27 | 28 | public const SCOPE_NONEXPIRING = 'non-expiring'; 29 | # public const SCOPE_EMAIL = 'email'; // ??? 30 | 31 | public const DEFAULT_SCOPES = [ 32 | self::SCOPE_NONEXPIRING, 33 | ]; 34 | 35 | public const AUTH_PREFIX_HEADER = 'OAuth'; 36 | 37 | protected string $authorizationURL = 'https://api.soundcloud.com/connect'; 38 | protected string $accessTokenURL = 'https://api.soundcloud.com/oauth2/token'; 39 | protected string $apiURL = 'https://api.soundcloud.com'; 40 | protected string|null $userRevokeURL = 'https://soundcloud.com/settings/connections'; 41 | protected string|null $apiDocs = 'https://developers.soundcloud.com/'; 42 | protected string|null $applicationURL = 'https://soundcloud.com/you/apps'; 43 | 44 | /** @codeCoverageIgnore */ 45 | public function me():AuthenticatedUser{ 46 | $json = $this->getMeResponseData('/me'); 47 | 48 | $userdata = [ 49 | 'data' => $json, 50 | 'avatar' => $json['avatar_url'], 51 | 'handle' => $json['username'], 52 | 'displayName' => $json['full_name'], 53 | 'email' => $json['email'], 54 | 'id' => $json['id'], 55 | 'url' => $json['permalink_url'], 56 | ]; 57 | 58 | return new AuthenticatedUser($userdata); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Providers/TwitterCC.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AccessToken, ClientCredentials, ClientCredentialsTrait, OAuth2Provider}; 15 | use Psr\Http\Message\UriInterface; 16 | 17 | /** 18 | * Twitter OAuth2 (client credentials) 19 | * 20 | * @todo: twitter is dead. fuck elon musk. 21 | * 22 | * @link https://dev.twitter.com/overview/api 23 | * @link https://developer.twitter.com/en/docs/basics/authentication/overview/application-only 24 | * 25 | * @todo: https://developer.twitter.com/en/docs/basics/authentication/api-reference/invalidate_token 26 | */ 27 | class TwitterCC extends OAuth2Provider implements ClientCredentials{ 28 | use ClientCredentialsTrait; 29 | 30 | public const IDENTIFIER = 'TWITTERCC'; 31 | 32 | protected const AUTH_ERRMSG = 'TwitterCC only supports Client Credentials Grant,'. 33 | 'use the Twitter OAuth1 class for authentication instead.'; 34 | 35 | protected string $apiURL = 'https://api.twitter.com'; 36 | protected string|null $clientCredentialsTokenURL = 'https://api.twitter.com/oauth2/token'; 37 | protected string|null $userRevokeURL = 'https://twitter.com/settings/applications'; 38 | // phpcs:ignore 39 | protected string|null $apiDocs = 'https://developer.twitter.com/en/docs/basics/authentication/overview/application-only'; 40 | protected string|null $applicationURL = 'https://developer.twitter.com/apps'; 41 | 42 | /** 43 | * @throws \chillerlan\OAuth\Providers\ProviderException 44 | */ 45 | public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{ 46 | throw new ProviderException($this::AUTH_ERRMSG); 47 | } 48 | 49 | /** 50 | * @throws \chillerlan\OAuth\Providers\ProviderException 51 | */ 52 | public function getAccessToken(string $code, string|null $state = null):AccessToken{ 53 | throw new ProviderException($this::AUTH_ERRMSG); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Providers/Twitter.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, OAuth1Provider, UserInfo}; 15 | use function sprintf, str_replace; 16 | 17 | /** 18 | * Twitter OAuth1 19 | * 20 | * @todo: twitter is dead. fuck elon musk. 21 | * 22 | * @link https://developer.twitter.com/en/docs/basics/authentication/overview/oauth 23 | */ 24 | class Twitter extends OAuth1Provider implements UserInfo{ 25 | 26 | public const IDENTIFIER = 'TWITTER'; 27 | 28 | // choose your fighter 29 | /** @link https://developer.twitter.com/en/docs/basics/authentication/api-reference/authorize */ 30 | protected string $authorizationURL = 'https://api.twitter.com/oauth/authorize'; 31 | /** @link https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate */ 32 | # protected string $authorizationURL = 'https://api.twitter.com/oauth/authenticate'; 33 | 34 | protected string $requestTokenURL = 'https://api.twitter.com/oauth/request_token'; 35 | protected string $accessTokenURL = 'https://api.twitter.com/oauth/access_token'; 36 | protected string $apiURL = 'https://api.twitter.com'; 37 | protected string|null $userRevokeURL = 'https://twitter.com/settings/applications'; 38 | protected string|null $apiDocs = 'https://developer.twitter.com/docs'; 39 | protected string|null $applicationURL = 'https://developer.twitter.com/apps'; 40 | 41 | /** @codeCoverageIgnore */ 42 | public function me():AuthenticatedUser{ 43 | $json = $this->getMeResponseData('/1.1/account/verify_credentials.json'); 44 | 45 | $userdata = [ 46 | 'data' => $json, 47 | 'avatar' => str_replace('_normal', '_400x400', $json['profile_image_url_https']), 48 | 'handle' => $json['screen_name'], 49 | 'displayName' => $json['name'], 50 | 'id' => $json['id'], 51 | 'url' => sprintf('https://twitter.com/%s', $json['screen_name']), 52 | ]; 53 | 54 | return new AuthenticatedUser($userdata); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/Core/PKCE.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Specifies the methods required for the OAuth2 Proof Key for Code Exchange (PKCE) 16 | * 17 | * @link https://datatracker.ietf.org/doc/html/rfc7636 18 | * @link https://github.com/AdrienGras/pkce-php 19 | */ 20 | interface PKCE{ 21 | 22 | /** @var string */ 23 | public const CHALLENGE_METHOD_PLAIN = 'plain'; 24 | /** @var string */ 25 | public const CHALLENGE_METHOD_S256 = 'S256'; 26 | 27 | /** @var string */ 28 | public const VERIFIER_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; 29 | 30 | /** 31 | * generates a secure random "code_verifier" 32 | * 33 | * @link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 34 | */ 35 | public function generateVerifier(int $length):string; 36 | 37 | /** 38 | * generates a "code_challenge" for the given $codeVerifier 39 | * 40 | * @link https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 41 | */ 42 | public function generateChallenge(string $verifier, string $challengeMethod):string; 43 | 44 | /** 45 | * Sets the PKCE code challenge parameters in a given array of query parameters and stores 46 | * the verifier in the storage for later verification. Returns the updated array of parameters. 47 | * 48 | * @link https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 49 | * 50 | * @param array $params 51 | * @return array 52 | * @throws \chillerlan\OAuth\Providers\ProviderException 53 | */ 54 | public function setCodeChallenge(array $params, string $challengeMethod):array; 55 | 56 | /** 57 | * Sets the PKCE verifier parameter in a given array of query parameters 58 | * and deletes it from the storage afterwards. Returns the updated array of parameters. 59 | * 60 | * @link https://datatracker.ietf.org/doc/html/rfc7636#section-4.5 61 | * 62 | * @param array $params 63 | * @return array 64 | * @throws \chillerlan\OAuth\Providers\ProviderException 65 | */ 66 | public function setCodeVerifier(array $params):array; 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Providers/PayPal.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo, 18 | }; 19 | 20 | /** 21 | * PayPal OAuth2 22 | * 23 | * @link https://developer.paypal.com/api/rest/ 24 | */ 25 | class PayPal extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenRefresh, UserInfo{ 26 | use ClientCredentialsTrait; 27 | 28 | public const IDENTIFIER = 'PAYPAL'; 29 | 30 | public const SCOPE_BASIC_AUTH = 'openid'; 31 | public const SCOPE_FULL_NAME = 'profile'; 32 | public const SCOPE_EMAIL = 'email'; 33 | public const SCOPE_ADDRESS = 'address'; 34 | public const SCOPE_ACCOUNT = 'https://uri.paypal.com/services/paypalattributes'; 35 | 36 | public const DEFAULT_SCOPES = [ 37 | self::SCOPE_BASIC_AUTH, 38 | self::SCOPE_EMAIL, 39 | ]; 40 | 41 | public const USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST = true; 42 | 43 | protected string $accessTokenURL = 'https://api.paypal.com/v1/oauth2/token'; 44 | protected string $authorizationURL = 'https://www.paypal.com/connect'; 45 | protected string $apiURL = 'https://api.paypal.com'; 46 | protected string|null $applicationURL = 'https://developer.paypal.com/developer/applications/'; 47 | protected string|null $apiDocs = 'https://developer.paypal.com/docs/connect-with-paypal/reference/'; 48 | 49 | /** @codeCoverageIgnore */ 50 | public function me():AuthenticatedUser{ 51 | $json = $this->getMeResponseData('/v1/identity/oauth2/userinfo', ['schema' => 'paypalv1.1']); 52 | 53 | $userdata = [ 54 | 'data' => $json, 55 | 'displayName' => $json['name'], 56 | 'id' => $json['user_id'], 57 | ]; 58 | 59 | if(!empty($json['emails'])){ 60 | foreach($json['emails'] as $email){ 61 | if($email['primary']){ 62 | $userdata['email'] = $email['value']; 63 | break; 64 | } 65 | } 66 | } 67 | 68 | return new AuthenticatedUser($userdata); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/Providers/Imgur.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo}; 15 | use function sprintf, time; 16 | 17 | /** 18 | * Imgur OAuth2 19 | * 20 | * Note: imgur sends an "expires_in" of 315360000 (10 years!) for access tokens, 21 | * but states in the docs that tokens expire after one month. 22 | * 23 | * @link https://apidocs.imgur.com/ 24 | */ 25 | class Imgur extends OAuth2Provider implements CSRFToken, TokenRefresh, UserInfo{ 26 | 27 | public const IDENTIFIER = 'IMGUR'; 28 | 29 | protected string $authorizationURL = 'https://api.imgur.com/oauth2/authorize'; 30 | protected string $accessTokenURL = 'https://api.imgur.com/oauth2/token'; 31 | protected string $apiURL = 'https://api.imgur.com'; 32 | protected string|null $userRevokeURL = 'https://imgur.com/account/settings/apps'; 33 | protected string|null $apiDocs = 'https://apidocs.imgur.com'; 34 | protected string|null $applicationURL = 'https://api.imgur.com/oauth2/addclient'; 35 | 36 | public function getAccessToken(string $code, string|null $state = null):AccessToken{ 37 | $this->checkState($state); 38 | 39 | $body = $this->getAccessTokenRequestBodyParams($code); 40 | $response = $this->sendAccessTokenRequest($this->accessTokenURL, $body); 41 | $token = $this->parseTokenResponse($response); 42 | 43 | // set the expiry to a sane period to allow auto-refreshing 44 | $token->expires = (time() + 2592000); // 30 days 45 | 46 | $this->storage->storeAccessToken($token, $this->name); 47 | 48 | return $token; 49 | } 50 | 51 | /** @codeCoverageIgnore */ 52 | public function me():AuthenticatedUser{ 53 | $json = $this->getMeResponseData('/3/account/me'); 54 | 55 | $userdata = [ 56 | 'data' => $json, 57 | 'avatar' => $json['data']['avatar'], 58 | 'handle' => $json['data']['url'], 59 | 'id' => $json['data']['id'], 60 | 'url' => sprintf('https://imgur.com/user/%s', $json['data']['url']), 61 | ]; 62 | 63 | return new AuthenticatedUser($userdata); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/Providers/Google.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AuthenticatedUser, CSRFToken, OAuth2Provider, PKCE, PKCETrait, TokenInvalidate, TokenInvalidateTrait, UserInfo, 18 | }; 19 | 20 | /** 21 | * Google OAuth2 22 | * 23 | * @link https://developers.google.com/identity/protocols/oauth2/web-server 24 | * @link https://developers.google.com/identity/protocols/oauth2/service-account 25 | * @link https://developers.google.com/oauthplayground/ 26 | */ 27 | class Google extends OAuth2Provider implements CSRFToken, PKCE, TokenInvalidate, UserInfo{ 28 | use PKCETrait, TokenInvalidateTrait; 29 | 30 | public const IDENTIFIER = 'GOOGLE'; 31 | 32 | public const SCOPE_EMAIL = 'email'; 33 | public const SCOPE_PROFILE = 'profile'; 34 | public const SCOPE_USERINFO_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'; 35 | public const SCOPE_USERINFO_PROFILE = 'https://www.googleapis.com/auth/userinfo.profile'; 36 | 37 | public const DEFAULT_SCOPES = [ 38 | self::SCOPE_EMAIL, 39 | self::SCOPE_PROFILE, 40 | ]; 41 | 42 | protected string $authorizationURL = 'https://accounts.google.com/o/oauth2/auth'; 43 | protected string $accessTokenURL = 'https://oauth2.googleapis.com/token'; 44 | protected string $revokeURL = 'https://oauth2.googleapis.com/revoke'; 45 | protected string $apiURL = 'https://www.googleapis.com'; 46 | protected string|null $userRevokeURL = 'https://myaccount.google.com/connections'; 47 | protected string|null $apiDocs = 'https://developers.google.com/oauthplayground/'; 48 | protected string|null $applicationURL = 'https://console.developers.google.com/apis/credentials'; 49 | 50 | /** @codeCoverageIgnore */ 51 | public function me():AuthenticatedUser{ 52 | $json = $this->getMeResponseData('/userinfo/v2/me'); 53 | 54 | $userdata = [ 55 | 'data' => $json, 56 | 'avatar' => $json['picture'], 57 | 'displayName' => $json['name'], 58 | 'email' => $json['email'], 59 | 'id' => $json['id'], 60 | ]; 61 | 62 | return new AuthenticatedUser($userdata); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Core/OAuth2Interface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | /** 15 | * Specifies the basic methods for an OAuth2 provider. 16 | */ 17 | interface OAuth2Interface extends OAuthInterface{ 18 | 19 | /** @var int */ 20 | final public const AUTH_METHOD_HEADER = 1; 21 | /** @var int */ 22 | final public const AUTH_METHOD_QUERY = 2; 23 | 24 | /** 25 | * Specifies the authentication method: 26 | * 27 | * - OAuth2Interface::AUTH_METHOD_HEADER (Bearer, OAuth, ...) 28 | * - OAuth2Interface::AUTH_METHOD_QUERY (access_token, ...) 29 | * 30 | * @var int 31 | */ 32 | public const AUTH_METHOD = self::AUTH_METHOD_HEADER; 33 | 34 | /** 35 | * The name of the authentication header in case of OAuth2Interface::AUTH_METHOD_HEADER 36 | * 37 | * @var string 38 | */ 39 | public const AUTH_PREFIX_HEADER = 'Bearer'; 40 | 41 | /** 42 | * The name of the authentication query parameter in case of OAuth2Interface::AUTH_METHOD_QUERY 43 | * 44 | * @var string 45 | */ 46 | public const AUTH_PREFIX_QUERY = 'access_token'; 47 | 48 | /** 49 | * This indicates that the current provider requires an `Authorization: Basic ` header 50 | * in the access token request, rather than the key and secret in the request body. 51 | * 52 | * It saves provider inplementations from the hassle to override the respective methods: 53 | * 54 | * - `OAuth2Provider::getAccessTokenRequestBodyParams()` 55 | * - `OAuth2Provider::sendAccessTokenRequest()` 56 | * 57 | * I'm not sure where to put this: here or a feature interface (it's not exactly a feature). 58 | * I'll leave it here for now, subject to change. 59 | * 60 | * @var bool 61 | */ 62 | public const USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST = false; 63 | 64 | /** 65 | * Obtains an OAuth2 access token with the given $code, verifies the $state 66 | * if the provider implements the CSRFToken interface, and returns an AccessToken object 67 | * 68 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 69 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 70 | */ 71 | public function getAccessToken(string $code, string|null $state = null):AccessToken; 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/Providers/OpenStreetmap2.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, UserInfo}; 17 | 18 | /** 19 | * OpenStreetmap OAuth2 20 | * 21 | * @link https://wiki.openstreetmap.org/wiki/API 22 | * @link https://wiki.openstreetmap.org/wiki/OAuth 23 | * @link https://www.openstreetmap.org/.well-known/oauth-authorization-server 24 | */ 25 | class OpenStreetmap2 extends OAuth2Provider implements CSRFToken, UserInfo{ 26 | 27 | public const IDENTIFIER = 'OPENSTREETMAP2'; 28 | 29 | public const SCOPE_READ_PREFS = 'read_prefs'; 30 | public const SCOPE_WRITE_PREFS = 'write_prefs'; 31 | public const SCOPE_WRITE_DIARY = 'write_diary'; 32 | public const SCOPE_WRITE_API = 'write_api'; 33 | public const SCOPE_READ_GPX = 'read_gpx'; 34 | public const SCOPE_WRITE_GPX = 'write_gpx'; 35 | public const SCOPE_WRITE_NOTES = 'write_notes'; 36 | # public const SCOPE_READ_EMAIL = 'read_email'; 37 | # public const SCOPE_SKIP_AUTH = 'skip_authorization'; 38 | public const SCOPE_WRITE_REDACTIONS = 'write_redactions'; 39 | public const SCOPE_OPENID = 'openid'; 40 | 41 | public const DEFAULT_SCOPES = [ 42 | self::SCOPE_READ_GPX, 43 | self::SCOPE_READ_PREFS, 44 | ]; 45 | 46 | protected string $authorizationURL = 'https://www.openstreetmap.org/oauth2/authorize'; 47 | protected string $accessTokenURL = 'https://www.openstreetmap.org/oauth2/token'; 48 | # protected string $revokeURL = 'https://www.openstreetmap.org/oauth2/revoke'; // not implemented yet? 49 | protected string $apiURL = 'https://api.openstreetmap.org'; 50 | protected string|null $apiDocs = 'https://wiki.openstreetmap.org/wiki/API'; 51 | protected string|null $applicationURL = 'https://www.openstreetmap.org/oauth2/applications'; 52 | 53 | /** @codeCoverageIgnore */ 54 | public function me():AuthenticatedUser{ 55 | $json = $this->getMeResponseData('/api/0.6/user/details.json'); 56 | 57 | $userdata = [ 58 | 'data' => $json, 59 | 'avatar' => $json['user']['img']['href'], 60 | 'displayName' => $json['user']['display_name'], 61 | 'id' => $json['user']['id'], 62 | ]; 63 | 64 | return new AuthenticatedUser($userdata); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/Providers/Stripe.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, TokenRefresh, UserInfo, 18 | }; 19 | 20 | /** 21 | * Stripe OAuth2 22 | * 23 | * @link https://stripe.com/docs/api 24 | * @link https://stripe.com/docs/connect/authentication 25 | * @link https://stripe.com/docs/connect/oauth-reference 26 | * @link https://stripe.com/docs/connect/standard-accounts 27 | * @link https://gist.github.com/amfeng/3507366 28 | */ 29 | class Stripe extends OAuth2Provider implements CSRFToken, TokenRefresh, TokenInvalidate, UserInfo{ 30 | use TokenInvalidateTrait; 31 | 32 | public const IDENTIFIER = 'STRIPE'; 33 | 34 | public const SCOPE_READ_WRITE = 'read_write'; 35 | public const SCOPE_READ_ONLY = 'read_only'; 36 | 37 | public const DEFAULT_SCOPES = [ 38 | self::SCOPE_READ_ONLY, 39 | ]; 40 | 41 | protected string $authorizationURL = 'https://connect.stripe.com/oauth/authorize'; 42 | protected string $accessTokenURL = 'https://connect.stripe.com/oauth/token'; 43 | protected string $revokeURL = 'https://connect.stripe.com/oauth/deauthorize'; 44 | protected string $apiURL = 'https://api.stripe.com/v1'; 45 | protected string|null $userRevokeURL = 'https://dashboard.stripe.com/account/applications'; 46 | protected string|null $apiDocs = 'https://stripe.com/docs/api'; 47 | protected string|null $applicationURL = 'https://dashboard.stripe.com/apikeys'; 48 | 49 | /** @codeCoverageIgnore */ 50 | public function me():AuthenticatedUser{ 51 | $json = $this->getMeResponseData('/accounts'); 52 | 53 | $userdata = [ 54 | 'data' => $json, 55 | 'id' => $json['data'][0]['id'], 56 | ]; 57 | 58 | return new AuthenticatedUser($userdata); 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{ 65 | $params = $token->extraParams; 66 | 67 | if(!isset($params['stripe_user_id'])){ 68 | throw new ProviderException('"stripe_user_id" not found in token'); 69 | } 70 | 71 | return [ 72 | 'client_id' => $this->options->key, 73 | 'stripe_user_id' => $params['stripe_user_id'], 74 | ]; 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/Providers/NPROne.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AuthenticatedUser, CSRFToken, OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, TokenRefresh, UserInfo, 18 | }; 19 | use function in_array, sprintf, strtolower; 20 | 21 | /** 22 | * NPR API services (OAuth2) 23 | * 24 | * @link https://dev.npr.org 25 | * @link https://github.com/npr/npr-one-backend-proxy-php 26 | */ 27 | class NPROne extends OAuth2Provider implements CSRFToken, TokenRefresh, TokenInvalidate, UserInfo{ 28 | use TokenInvalidateTrait; 29 | 30 | public const IDENTIFIER = 'NPRONE'; 31 | 32 | public const SCOPE_IDENTITY_READONLY = 'identity.readonly'; 33 | public const SCOPE_IDENTITY_WRITE = 'identity.write'; 34 | public const SCOPE_LISTENING_READONLY = 'listening.readonly'; 35 | public const SCOPE_LISTENING_WRITE = 'listening.write'; 36 | public const SCOPE_LOCALACTIVATION = 'localactivation'; 37 | 38 | public const DEFAULT_SCOPES = [ 39 | self::SCOPE_IDENTITY_READONLY, 40 | self::SCOPE_LISTENING_READONLY, 41 | ]; 42 | 43 | protected string $apiURL = 'https://listening.api.npr.org'; 44 | protected string $authorizationURL = 'https://authorization.api.npr.org/v2/authorize'; 45 | protected string $accessTokenURL = 'https://authorization.api.npr.org/v2/token'; 46 | protected string $revokeURL = 'https://authorization.api.npr.org/v2/token/revoke'; 47 | protected string|null $apiDocs = 'https://dev.npr.org/api/'; 48 | protected string|null $applicationURL = 'https://dev.npr.org/console'; 49 | 50 | /** 51 | * Sets the API to work with ("listening" is set as default) 52 | * 53 | * @throws \chillerlan\OAuth\Providers\ProviderException 54 | */ 55 | public function setAPI(string $api):static{ 56 | $api = strtolower($api); 57 | 58 | if(!in_array($api, ['identity', 'listening', 'station'], true)){ 59 | throw new ProviderException(sprintf('invalid API: "%s"', $api)); 60 | } 61 | 62 | $this->apiURL = sprintf('https://%s.api.npr.org', $api); 63 | 64 | return $this; 65 | } 66 | 67 | /** @codeCoverageIgnore */ 68 | public function me():AuthenticatedUser{ 69 | $json = $this->getMeResponseData('https://identity.api.npr.org/v2/user'); 70 | 71 | $userdata = [ 72 | 'data' => $json, 73 | 'email' => $json['attributes']['email'], 74 | ]; 75 | 76 | return new AuthenticatedUser($userdata); 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://getcomposer.org/schema.json", 3 | "name": "chillerlan/php-oauth", 4 | "description": "A fully transparent, framework agnostic PSR-18 OAuth client.", 5 | "homepage": "https://github.com/chillerlan/php-oauth", 6 | "license": "MIT", 7 | "type": "library", 8 | "keywords": [ 9 | "oauth", "oauth1", "oauth2", "pkce", "authorization", "authentication", 10 | "client", "psr-7", "psr-17", "psr-18", "rfc5849", "rfc6749", "rfc7636" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "smiley", 15 | "email": "smiley@chillerlan.net", 16 | "homepage": "https://github.com/codemasher" 17 | }, 18 | { 19 | "name": "Contributors", 20 | "homepage":"https://github.com/chillerlan/php-oauth/graphs/contributors" 21 | } 22 | ], 23 | "funding": [ 24 | { 25 | "type": "Ko-Fi", 26 | "url": "https://ko-fi.com/codemasher" 27 | } 28 | ], 29 | "support": { 30 | "issues": "https://github.com/chillerlan/php-oauth/issues", 31 | "source": "https://github.com/chillerlan/php-oauth" 32 | }, 33 | "provide": { 34 | "psr/http-client-implementation": "1.0" 35 | }, 36 | "minimum-stability": "stable", 37 | "prefer-stable": true, 38 | "require": { 39 | "php": "^8.1", 40 | "ext-json": "*", 41 | "ext-sodium": "*", 42 | "chillerlan/php-http-message-utils": "^2.2.2", 43 | "chillerlan/php-settings-container": "^3.2.1", 44 | "chillerlan/php-standard-utilities": "^1.0.1", 45 | "psr/http-client": "^1.0", 46 | "psr/http-message": "^1.1 || ^2.0", 47 | "psr/log": "^1.1 || ^2.0 || ^3.0" 48 | }, 49 | "require-dev": { 50 | "chillerlan/php-dotenv": "^3.0", 51 | "chillerlan/phpunit-http": "^1.0", 52 | "guzzlehttp/guzzle": "^7.10", 53 | "monolog/monolog": "^3.7", 54 | "phan/phan": "^5.5.2", 55 | "phpmd/phpmd": "^2.15", 56 | "phpstan/phpstan": "^2.1.32", 57 | "phpstan/phpstan-deprecation-rules": "^2.0.3", 58 | "phpunit/phpunit": "^10.5", 59 | "slevomat/coding-standard": "^8.22", 60 | "squizlabs/php_codesniffer": "^4.0" 61 | }, 62 | "suggest": { 63 | "chillerlan/php-httpinterface": "^6.0 - an alternative PSR-18 HTTP Client" 64 | }, 65 | "autoload": { 66 | "psr-4": { 67 | "chillerlan\\OAuth\\": "src" 68 | } 69 | }, 70 | "autoload-dev": { 71 | "psr-4": { 72 | "chillerlan\\OAuthTest\\": "tests" 73 | } 74 | }, 75 | "scripts": { 76 | "phan": "@php vendor/bin/phan", 77 | "phpcs": "@php vendor/bin/phpcs", 78 | "phpstan": "@php vendor/bin/phpstan", 79 | "phpstan-baseline": "@php vendor/bin/phpstan --generate-baseline", 80 | "phpunit": "@php vendor/bin/phpunit" 81 | }, 82 | "config": { 83 | "lock": false, 84 | "sort-packages": true, 85 | "platform-check": true, 86 | "allow-plugins": { 87 | "dealerdirect/phpcodesniffer-composer-installer": true 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Providers/Pinterest.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo}; 17 | use function sprintf; 18 | 19 | /** 20 | * Pinterest OAuth2 21 | * 22 | * @link https://developers.pinterest.com/docs/getting-started/authentication/ 23 | */ 24 | class Pinterest extends OAuth2Provider implements CSRFToken, TokenRefresh, UserInfo{ 25 | 26 | public const IDENTIFIER = 'PINTEREST'; 27 | 28 | public const SCOPE_ADS_READ = 'ads:read'; 29 | public const SCOPE_ADS_WRITE = 'ads:write'; 30 | public const SCOPE_BOARDS_READ = 'boards:read'; 31 | public const SCOPE_BOARDS_READ_SECRET = 'boards:read_secret'; 32 | public const SCOPE_BOARDS_WRITE = 'boards:write'; 33 | public const SCOPE_BOARDS_WRITE_SECRET = 'boards:write_secret'; 34 | public const SCOPE_CATALOGS_READ = 'catalogs:read'; 35 | public const SCOPE_CATALOGS_WRITE = 'catalogs:write'; 36 | public const SCOPE_PINS_READ = 'pins:read'; 37 | public const SCOPE_PINS_READ_SECRET = 'pins:read_secret'; 38 | public const SCOPE_PINS_WRITE = 'pins:write'; 39 | public const SCOPE_PINS_WRITE_SECRET = 'pins:write_secret'; 40 | public const SCOPE_USER_ACCOUNTS_READ = 'user_accounts:read'; 41 | 42 | public const DEFAULT_SCOPES = [ 43 | self::SCOPE_BOARDS_READ, 44 | self::SCOPE_PINS_READ, 45 | self::SCOPE_USER_ACCOUNTS_READ, 46 | ]; 47 | 48 | public const USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST = true; 49 | 50 | protected string $authorizationURL = 'https://www.pinterest.com/oauth/'; 51 | protected string $accessTokenURL = 'https://api.pinterest.com/v5/oauth/token'; 52 | protected string $apiURL = 'https://api.pinterest.com'; 53 | protected string|null $apiDocs = 'https://developers.pinterest.com/docs/'; 54 | protected string|null $applicationURL = 'https://developers.pinterest.com/apps/'; 55 | protected string|null $userRevokeURL = 'https://www.pinterest.com/settings/security'; 56 | 57 | /** @codeCoverageIgnore */ 58 | public function me():AuthenticatedUser{ 59 | $json = $this->getMeResponseData('/v5/user_account'); 60 | 61 | $userdata = [ 62 | 'data' => $json, 63 | 'avatar' => $json['profile_image'], 64 | 'handle' => $json['username'], 65 | 'id' => $json['id'], 66 | 'url' => sprintf('https://www.pinterest.com/%s/', $json['username']), 67 | ]; 68 | 69 | return new AuthenticatedUser($userdata); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/Core/PARTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @phan-file-suppress PhanUndeclaredProperty, PhanUndeclaredMethod 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\HTTP\Utils\MessageUtil; 17 | use chillerlan\HTTP\Utils\QueryUtil; 18 | use chillerlan\OAuth\Providers\ProviderException; 19 | use Psr\Http\Message\UriInterface; 20 | use function sprintf; 21 | 22 | /** 23 | * Implements PAR (Pushed Authorization Requests) functionality 24 | * 25 | * @see \chillerlan\OAuth\Core\PAR 26 | */ 27 | trait PARTrait{ 28 | 29 | /** 30 | * implements PAR::getParRequestUri() 31 | * 32 | * @see \chillerlan\OAuth\Core\PAR::getParRequestUri() 33 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getAuthorizationURL() 34 | * 35 | * @param array $body 36 | */ 37 | public function getParRequestUri(array $body):UriInterface{ 38 | // send the request with the same method and parameters as the token requests 39 | // @link https://datatracker.ietf.org/doc/html/rfc9126#name-request 40 | $response = $this->sendAccessTokenRequest($this->parAuthorizationURL, $body); 41 | $status = $response->getStatusCode(); 42 | $json = MessageUtil::decodeJSON($response, true); 43 | 44 | // the response should be a 201, see: https://github.com/chillerlan/php-oauth/issues/6 45 | // something went horribly wrong 46 | if($status !== 201){ 47 | 48 | // @link https://datatracker.ietf.org/doc/html/rfc9126#section-2.3 49 | if(isset($json['error'], $json['error_description'])){ 50 | throw new ProviderException(sprintf('PAR error: "%s" (%s)', $json['error'], $json['error_description'])); 51 | } 52 | 53 | throw new ProviderException(sprintf('PAR request error: (HTTP/%s)', $status)); // @codeCoverageIgnore 54 | } 55 | 56 | $url = QueryUtil::merge($this->authorizationURL, $this->getParAuthorizationURLRequestParams($json)); 57 | 58 | return $this->uriFactory->createUri($url); 59 | } 60 | 61 | /** 62 | * Parses the response from the PAR request and returns the query parameters for the authorization URL 63 | * 64 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getParRequestUri() 65 | * 66 | * @param array $response 67 | * @return array 68 | * 69 | * @codeCoverageIgnore 70 | */ 71 | protected function getParAuthorizationURLRequestParams(array $response):array{ 72 | 73 | if(!isset($response['request_uri'])){ 74 | throw new ProviderException('PAR response error: "request_uri" missing'); 75 | } 76 | 77 | return [ 78 | 'client_id' => $this->options->key, 79 | 'request_uri' => $response['request_uri'], 80 | ]; 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/Providers/Foursquare.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, OAuth2Provider, UserInfo}; 15 | use Psr\Http\Message\{ResponseInterface, StreamInterface}; 16 | use function array_merge, sprintf; 17 | 18 | /** 19 | * Foursquare OAuth2 20 | * 21 | * @link https://location.foursquare.com/developer/reference/personalization-apis-authentication 22 | */ 23 | class Foursquare extends OAuth2Provider implements UserInfo{ 24 | 25 | public const IDENTIFIER = 'FOURSQUARE'; 26 | 27 | public const AUTH_METHOD = self::AUTH_METHOD_QUERY; 28 | public const AUTH_PREFIX_QUERY = 'oauth_token'; 29 | 30 | protected const API_VERSIONDATE = '20190225'; 31 | protected const QUERY_PARAMS = ['m' => 'foursquare', 'v' => self::API_VERSIONDATE]; 32 | 33 | protected string $authorizationURL = 'https://foursquare.com/oauth2/authenticate'; 34 | protected string $accessTokenURL = 'https://foursquare.com/oauth2/access_token'; 35 | protected string $apiURL = 'https://api.foursquare.com'; 36 | protected string|null $userRevokeURL = 'https://foursquare.com/settings/connections'; 37 | protected string|null $apiDocs = 'https://location.foursquare.com/developer/reference/foursquare-apis-overview'; 38 | protected string|null $applicationURL = 'https://foursquare.com/developers/apps'; 39 | 40 | /** @codeCoverageIgnore */ 41 | public function request( 42 | string $path, 43 | array|null $params = null, 44 | string|null $method = null, 45 | StreamInterface|array|string|null $body = null, 46 | array|null $headers = null, 47 | string|null $protocolVersion = null, 48 | ):ResponseInterface{ 49 | $params = array_merge(($params ?? []), $this::QUERY_PARAMS); 50 | 51 | return parent::request($path, $params, $method, $body, $headers, $protocolVersion); 52 | } 53 | 54 | /** @codeCoverageIgnore */ 55 | public function me():AuthenticatedUser{ 56 | $json = $this->getMeResponseData('/v2/users/self', $this::QUERY_PARAMS); 57 | $user = $json['response']['user']; 58 | 59 | $userdata = [ 60 | 'data' => $json, 61 | 'avatar' => sprintf('%s%s%s', $user['photo']['prefix'], $user['id'], $user['photo']['suffix']), 62 | 'displayName' => $user['firstName'], 63 | 'email' => $user['contact']['email'], 64 | 'id' => $user['id'], 65 | 'handle' => $user['handle'], 66 | 'url' => $user['canonicalUrl'], 67 | ]; 68 | 69 | return new AuthenticatedUser($userdata); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/Providers/Tidal.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2025 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, 18 | OAuth2Provider, PKCE, PKCETrait, TokenRefresh, UserInfo 19 | }; 20 | 21 | /** 22 | * Tidal OAuth2 (OAuth 2.1) 23 | * 24 | * @link https://developer.tidal.com/documentation/api-sdk/api-sdk-authorization 25 | */ 26 | class Tidal extends OAuth2Provider implements ClientCredentials, PKCE, TokenRefresh, UserInfo{ 27 | use ClientCredentialsTrait, PKCETrait; 28 | 29 | public const IDENTIFIER = 'TIDAL'; 30 | 31 | public const SCOPE_COLLECTION_READ = 'collection.read'; 32 | public const SCOPE_COLLECTION_WRITE = 'collection.write'; 33 | public const SCOPE_ENTITLEMENTS_READ = 'entitlements.read'; 34 | public const SCOPE_PLAYBACK = 'playback'; 35 | public const SCOPE_PLAYLISTS_READ = 'playlists.read'; 36 | public const SCOPE_PLAYLISTS_WRITE = 'playlists.write'; 37 | public const SCOPE_RECOMMENDATIONS_READ = 'recommendations.read'; 38 | public const SCOPE_SEARCH_READ = 'search.read'; 39 | public const SCOPE_SEARCH_WRITE = 'search.write'; 40 | public const SCOPE_USER_READ = 'user.read'; 41 | 42 | public const DEFAULT_SCOPES = [ 43 | self::SCOPE_COLLECTION_READ, 44 | self::SCOPE_COLLECTION_WRITE, 45 | self::SCOPE_PLAYLISTS_READ, 46 | self::SCOPE_PLAYLISTS_WRITE, 47 | self::SCOPE_RECOMMENDATIONS_READ, 48 | self::SCOPE_USER_READ, 49 | ]; 50 | 51 | public const HEADERS_API = [ 52 | 'Accept' => 'application/vnd.api+json', 53 | ]; 54 | 55 | public const USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST = true; 56 | 57 | protected string $authorizationURL = 'https://login.tidal.com/authorize'; 58 | protected string $accessTokenURL = 'https://auth.tidal.com/v1/oauth2/token'; 59 | protected string $apiURL = 'https://openapi.tidal.com'; 60 | protected string|null $userRevokeURL = 'https://account.tidal.com/third-party-apps'; 61 | protected string|null $apiDocs = 'https://developer.tidal.com/documentation'; 62 | protected string|null $applicationURL = 'https://developer.tidal.com/dashboard'; 63 | 64 | 65 | public function me():AuthenticatedUser{ 66 | $json = $this->getMeResponseData('/v2/users/me'); 67 | 68 | $userdata = [ 69 | 'data' => $json, 70 | 'handle' => $json['data']['attributes']['username'], 71 | 'email' => $json['data']['attributes']['email'], 72 | 'id' => $json['data']['id'], 73 | ]; 74 | 75 | return new AuthenticatedUser($userdata); 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Providers/Tumblr.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\HTTP\Utils\MessageUtil; 17 | use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, OAuth1Provider, UserInfo}; 18 | use function sprintf; 19 | 20 | /** 21 | * Tumblr OAuth1 22 | * 23 | * @link https://www.tumblr.com/docs/en/api/v2#oauth1-authorization 24 | */ 25 | class Tumblr extends OAuth1Provider implements UserInfo{ 26 | 27 | public const IDENTIFIER = 'TUMBLR'; 28 | 29 | protected string $requestTokenURL = 'https://www.tumblr.com/oauth/request_token'; 30 | protected string $authorizationURL = 'https://www.tumblr.com/oauth/authorize'; 31 | protected string $accessTokenURL = 'https://www.tumblr.com/oauth/access_token'; 32 | protected string $apiURL = 'https://api.tumblr.com'; 33 | protected string|null $userRevokeURL = 'https://www.tumblr.com/settings/apps'; 34 | protected string|null $apiDocs = 'https://www.tumblr.com/docs/en/api/v2'; 35 | protected string|null $applicationURL = 'https://www.tumblr.com/oauth/apps'; 36 | 37 | /** @codeCoverageIgnore */ 38 | public function me():AuthenticatedUser{ 39 | $json = $this->getMeResponseData('/v2/user/info'); 40 | 41 | $userdata = [ 42 | 'data' => $json, 43 | 'handle' => $json['response']['user']['name'], 44 | 'url' => sprintf('https://www.tumblr.com/%s', $json['response']['user']['name']), 45 | ]; 46 | 47 | return new AuthenticatedUser($userdata); 48 | } 49 | 50 | /** 51 | * Exchange the current token for an OAuth2 token - this will invalidate the OAuth1 token. 52 | * 53 | * @link https://www.tumblr.com/docs/en/api/v2#v2oauth2exchange---oauth1-to-oauth2-token-exchange 54 | * 55 | * @throws \chillerlan\OAuth\Providers\ProviderException 56 | */ 57 | public function exchangeForOAuth2Token():AccessToken{ 58 | $response = $this->request(path: '/v2/oauth2/exchange', method: 'POST'); 59 | $status = $response->getStatusCode(); 60 | $json = MessageUtil::decodeJSON($response); 61 | 62 | if($status === 200){ 63 | $token = $this->createAccessToken(); 64 | 65 | $token->accessToken = $json->access_token; 66 | $token->refreshToken = $json->refresh_token; 67 | $token->expires = $json->expires_in; 68 | $token->extraParams = ['scope' => $json->scope, 'token_type' => $json->token_type]; 69 | 70 | $this->storage->storeAccessToken($token, $this->name); 71 | 72 | return $token; 73 | } 74 | 75 | if(isset($json->meta, $json->meta->msg)){ 76 | throw new ProviderException($json->meta->msg); 77 | } 78 | 79 | throw new ProviderException(sprintf('token exchange error HTTP/%s', $status)); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Core/ClientCredentialsTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @phan-file-suppress PhanUndeclaredConstantOfClass, PhanUndeclaredProperty, PhanUndeclaredMethod 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\HTTP\Utils\QueryUtil; 17 | use Psr\Http\Message\ResponseInterface; 18 | use function implode; 19 | use const PHP_QUERY_RFC1738; 20 | 21 | /** 22 | * Implements Client Credentials functionality 23 | * 24 | * @see \chillerlan\OAuth\Core\ClientCredentials 25 | */ 26 | trait ClientCredentialsTrait{ 27 | 28 | /** 29 | * implements ClientCredentials::getClientCredentialsToken() 30 | * 31 | * @see \chillerlan\OAuth\Core\ClientCredentials::getClientCredentialsToken() 32 | * 33 | * @param string[]|null $scopes 34 | * @throws \chillerlan\OAuth\Providers\ProviderException 35 | */ 36 | public function getClientCredentialsToken(array|null $scopes = null):AccessToken{ 37 | $body = $this->getClientCredentialsTokenRequestBodyParams($scopes); 38 | $response = $this->sendClientCredentialsTokenRequest(($this->clientCredentialsTokenURL ?? $this->accessTokenURL), $body); 39 | $token = $this->parseTokenResponse($response); 40 | 41 | // provider didn't send a set of scopes with the token response, so add the given ones manually 42 | if(empty($token->scopes)){ 43 | $token->scopes = ($scopes ?? []); 44 | } 45 | 46 | $this->storage->storeAccessToken($token, $this->name); 47 | 48 | return $token; 49 | } 50 | 51 | /** 52 | * prepares the request body parameters for the client credentials token request 53 | * 54 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getClientCredentialsToken() 55 | * 56 | * @param string[]|null $scopes 57 | * @return array 58 | */ 59 | protected function getClientCredentialsTokenRequestBodyParams(array|null $scopes):array{ 60 | $body = ['grant_type' => 'client_credentials']; 61 | 62 | if(!empty($scopes)){ 63 | $body['scope'] = implode($this::SCOPES_DELIMITER, $scopes); 64 | } 65 | 66 | return $body; 67 | } 68 | 69 | /** 70 | * sends a request to the client credentials endpoint, using basic authentication 71 | * 72 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getClientCredentialsToken() 73 | * 74 | * @param array $body 75 | */ 76 | protected function sendClientCredentialsTokenRequest(string $url, array $body):ResponseInterface{ 77 | 78 | $request = $this->requestFactory 79 | ->createRequest('POST', $url) 80 | ->withHeader('Accept', 'application/json') 81 | ->withHeader('Accept-Encoding', 'identity') 82 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 83 | ->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738))) 84 | ; 85 | 86 | foreach($this::HEADERS_AUTH as $header => $value){ 87 | $request = $request->withHeader($header, $value); 88 | } 89 | 90 | $request = $this->addBasicAuthHeader($request); 91 | 92 | return $this->http->sendRequest($request); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/OAuthProviderFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth; 13 | 14 | use chillerlan\OAuth\Core\{OAuth1Interface, OAuth2Interface, OAuthInterface}; 15 | use chillerlan\OAuth\Providers\ProviderException; 16 | use chillerlan\OAuth\Storage\{MemoryStorage, OAuthStorageInterface}; 17 | use chillerlan\Settings\SettingsContainerInterface; 18 | use Psr\Http\Client\ClientInterface; 19 | use Psr\Http\Message\{RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface}; 20 | use Psr\Log\{LoggerInterface, NullLogger}; 21 | use function class_exists; 22 | 23 | /** 24 | * A simple OAuth provider factory (not sure if that clears the mess...) 25 | */ 26 | class OAuthProviderFactory{ 27 | 28 | protected ClientInterface $http; 29 | protected RequestFactoryInterface $requestFactory; 30 | protected StreamFactoryInterface $streamFactory; 31 | protected UriFactoryInterface $uriFactory; 32 | protected LoggerInterface $logger; 33 | 34 | /** 35 | * thank you PHP-FIG for absolutely nothing 36 | */ 37 | public function __construct( 38 | ClientInterface $http, 39 | RequestFactoryInterface $requestFactory, 40 | StreamFactoryInterface $streamFactory, 41 | UriFactoryInterface $uriFactory, 42 | LoggerInterface $logger = new NullLogger, 43 | ){ 44 | $this->http = $http; 45 | $this->requestFactory = $requestFactory; 46 | $this->streamFactory = $streamFactory; 47 | $this->uriFactory = $uriFactory; 48 | $this->logger = $logger; 49 | } 50 | 51 | /** 52 | * invokes a provider instance with the given $options and $storage interfaces 53 | */ 54 | public function getProvider( 55 | string $providerFQN, 56 | SettingsContainerInterface|OAuthOptions $options = new OAuthOptions, 57 | OAuthStorageInterface $storage = new MemoryStorage, 58 | ):OAuthInterface|OAuth1Interface|OAuth2Interface{ 59 | 60 | if(!class_exists($providerFQN)){ 61 | throw new ProviderException('invalid provider class given'); 62 | } 63 | 64 | return new $providerFQN( 65 | $options, 66 | $this->http, 67 | $this->requestFactory, 68 | $this->streamFactory, 69 | $this->uriFactory, 70 | $storage, 71 | $this->logger, 72 | ); 73 | 74 | } 75 | 76 | /** @codeCoverageIgnore */ 77 | public function setLogger(LoggerInterface $logger):static{ 78 | $this->logger = $logger; 79 | 80 | return $this; 81 | } 82 | 83 | /* 84 | * factory getters (convenience) 85 | */ 86 | 87 | /** @codeCoverageIgnore */ 88 | public function getRequestFactory():RequestFactoryInterface{ 89 | return $this->requestFactory; 90 | } 91 | 92 | /** @codeCoverageIgnore */ 93 | public function getStreamFactory():StreamFactoryInterface{ 94 | return $this->streamFactory; 95 | } 96 | 97 | /** @codeCoverageIgnore */ 98 | public function getUriFactory():UriFactoryInterface{ 99 | return $this->uriFactory; 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /src/Providers/BigCartel.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{ 15 | AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, UserInfo, 16 | }; 17 | use function sprintf; 18 | 19 | /** 20 | * BigCartel OAuth2 21 | * 22 | * @link https://developers.bigcartel.com/api/v1 23 | * @link https://bigcartel.wufoo.com/confirm/big-cartel-api-application/ 24 | */ 25 | class BigCartel extends OAuth2Provider implements CSRFToken, TokenInvalidate, UserInfo{ 26 | use TokenInvalidateTrait; 27 | 28 | public const IDENTIFIER = 'BIGCARTEL'; 29 | 30 | public const HEADERS_API = [ 31 | 'Accept' => 'application/vnd.api+json', 32 | ]; 33 | 34 | protected string $authorizationURL = 'https://my.bigcartel.com/oauth/authorize'; 35 | protected string $accessTokenURL = 'https://api.bigcartel.com/oauth/token'; 36 | protected string $revokeURL = 'https://api.bigcartel.com/oauth/deauthorize'; 37 | protected string $apiURL = 'https://api.bigcartel.com/v1'; 38 | protected string|null $userRevokeURL = 'https://my.bigcartel.com/account'; 39 | protected string|null $apiDocs = 'https://developers.bigcartel.com/api/v1'; 40 | protected string|null $applicationURL = 'https://bigcartel.wufoo.com/forms/big-cartel-api-application/'; 41 | 42 | /** @codeCoverageIgnore */ 43 | public function me():AuthenticatedUser{ 44 | $json = $this->getMeResponseData('/accounts'); 45 | 46 | $userdata = [ 47 | 'data' => $json, 48 | 'email' => $json['data'][0]['attributes']['contact_email'], 49 | 'handle' => $json['data'][0]['attributes']['subdomain'], 50 | 'id' => $json['data'][0]['id'], 51 | ]; 52 | 53 | return new AuthenticatedUser($userdata); 54 | } 55 | 56 | public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{ 57 | $tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name)); 58 | 59 | $request = $this->requestFactory 60 | ->createRequest('POST', sprintf('%s/%s', $this->revokeURL, $this->getAccountID($tokenToInvalidate))) 61 | ; 62 | 63 | $request = $this->addBasicAuthHeader($request); 64 | $response = $this->http->sendRequest($request); 65 | 66 | if($response->getStatusCode() === 204){ 67 | 68 | if($token === null){ 69 | $this->storage->clearAccessToken($this->name); 70 | } 71 | 72 | return true; 73 | } 74 | 75 | return false; // @codeCoverageIgnore 76 | } 77 | 78 | /** 79 | * Try to get the user ID from either the token or the `me()` endpoint 80 | * 81 | * @throws \chillerlan\OAuth\Providers\ProviderException 82 | */ 83 | protected function getAccountID(AccessToken $token):string{ 84 | 85 | if(isset($token->extraParams['account_id'])){ 86 | return (string)$token->extraParams['account_id']; 87 | } 88 | 89 | $me = $this->me(); 90 | 91 | if($me->id !== null){ 92 | return (string)$me->id; 93 | } 94 | 95 | throw new ProviderException('cannot determine account id'); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/Storage/OAuthStorageAbstract.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Storage; 13 | 14 | use chillerlan\OAuth\OAuthOptions; 15 | use chillerlan\OAuth\Core\AccessToken; 16 | use chillerlan\Settings\SettingsContainerInterface; 17 | use chillerlan\Utilities\Crypto; 18 | use Psr\Log\{LoggerInterface, NullLogger}; 19 | use function trim; 20 | 21 | /** 22 | * Implements an abstract OAuth storage adapter 23 | */ 24 | abstract class OAuthStorageAbstract implements OAuthStorageInterface{ 25 | 26 | final protected const KEY_TOKEN = 'TOKEN'; 27 | final protected const KEY_STATE = 'STATE'; 28 | final protected const KEY_VERIFIER = 'VERIFIER'; 29 | 30 | /** 31 | * Output format for encrypted data 32 | * 33 | * @var int 34 | */ 35 | protected const ENCRYPT_FORMAT = Crypto::ENCRYPT_FORMAT_HEX; 36 | 37 | /** 38 | * The options instance 39 | */ 40 | protected OAuthOptions|SettingsContainerInterface $options; 41 | 42 | /** 43 | * A PSR-3 logger 44 | */ 45 | protected LoggerInterface $logger; 46 | 47 | /** 48 | * OAuthStorageAbstract constructor. 49 | */ 50 | public function __construct( 51 | OAuthOptions|SettingsContainerInterface $options = new OAuthOptions, 52 | LoggerInterface $logger = new NullLogger, 53 | ){ 54 | $this->options = $options; 55 | $this->logger = $logger; 56 | 57 | if($this->options->useStorageEncryption === true && empty($this->options->storageEncryptionKey)){ 58 | throw new OAuthStorageException('no encryption key given'); 59 | } 60 | 61 | } 62 | 63 | /** @codeCoverageIgnore */ 64 | public function setLogger(LoggerInterface $logger):static{ 65 | $this->logger = $logger; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Gets the current provider name 72 | * 73 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 74 | */ 75 | protected function getProviderName(string $provider):string{ 76 | $name = trim($provider); 77 | 78 | if($name === ''){ 79 | throw new OAuthStorageException('provider name must not be empty'); 80 | } 81 | 82 | return $name; 83 | } 84 | 85 | public function toStorage(AccessToken $token):mixed{ 86 | $tokenJSON = $token->toJSON(); 87 | 88 | if($this->options->useStorageEncryption === true){ 89 | return $this->encrypt($tokenJSON); 90 | } 91 | 92 | return $tokenJSON; 93 | } 94 | 95 | public function fromStorage(mixed $data):AccessToken{ 96 | 97 | if($this->options->useStorageEncryption === true){ 98 | $data = $this->decrypt($data); 99 | } 100 | 101 | return (new AccessToken)->fromJSON($data); 102 | } 103 | 104 | /** 105 | * encrypts the given $data 106 | */ 107 | protected function encrypt(string $data):string{ 108 | return Crypto::encrypt($data, $this->options->storageEncryptionKey, $this::ENCRYPT_FORMAT); 109 | } 110 | 111 | /** 112 | * decrypts the given $encrypted data 113 | */ 114 | protected function decrypt(string $encrypted):string{ 115 | return Crypto::decrypt($encrypted, $this->options->storageEncryptionKey, $this::ENCRYPT_FORMAT); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/Providers/Flickr.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\HTTP\Utils\QueryUtil; 17 | use chillerlan\OAuth\Core\{AuthenticatedUser, InvalidAccessTokenException, OAuth1Provider, UserInfo}; 18 | use Psr\Http\Message\{ResponseInterface, StreamInterface}; 19 | use function array_merge, sprintf; 20 | 21 | /** 22 | * Flickr OAuth1 23 | * 24 | * @link https://www.flickr.com/services/api/auth.oauth.html 25 | * @link https://www.flickr.com/services/api/ 26 | */ 27 | class Flickr extends OAuth1Provider implements UserInfo{ 28 | 29 | public const IDENTIFIER = 'FLICKR'; 30 | 31 | public const PERM_READ = 'read'; 32 | public const PERM_WRITE = 'write'; 33 | public const PERM_DELETE = 'delete'; 34 | 35 | protected string $requestTokenURL = 'https://www.flickr.com/services/oauth/request_token'; 36 | protected string $authorizationURL = 'https://www.flickr.com/services/oauth/authorize'; 37 | protected string $accessTokenURL = 'https://www.flickr.com/services/oauth/access_token'; 38 | protected string $apiURL = 'https://api.flickr.com/services/rest'; 39 | protected string|null $userRevokeURL = 'https://www.flickr.com/services/auth/list.gne'; 40 | protected string|null $apiDocs = 'https://www.flickr.com/services/api/'; 41 | protected string|null $applicationURL = 'https://www.flickr.com/services/apps/create/'; 42 | 43 | public function request( 44 | string $path, 45 | array|null $params = null, 46 | string|null $method = null, 47 | StreamInterface|array|string|null $body = null, 48 | array|null $headers = null, 49 | string|null $protocolVersion = null, 50 | ):ResponseInterface{ 51 | 52 | $params = array_merge(($params ?? []), [ 53 | 'method' => $path, 54 | 'format' => 'json', 55 | 'nojsoncallback' => true, 56 | ]); 57 | 58 | $request = $this->getRequestAuthorization( 59 | $this->requestFactory->createRequest(($method ?? 'POST'), QueryUtil::merge($this->apiURL, $params)), 60 | ); 61 | 62 | return $this->http->sendRequest($request); 63 | } 64 | 65 | /** 66 | * hi flickr, can i have a 401 on invalid token??? 67 | * 68 | * @inheritDoc 69 | * @codeCoverageIgnore 70 | */ 71 | public function me():AuthenticatedUser{ 72 | 73 | $json = $this->getMeResponseData($this->apiURL, [ 74 | 'method' => 'flickr.test.login', 75 | 'format' => 'json', 76 | 'nojsoncallback' => true, 77 | ]); 78 | 79 | if(isset($json['stat'], $json['message']) && $json['stat'] === 'fail'){ 80 | 81 | if($json['message'] === 'Invalid auth token'){ 82 | throw new InvalidAccessTokenException($json['message']); 83 | } 84 | 85 | throw new ProviderException($json['message']); 86 | } 87 | 88 | $userdata = [ 89 | 'data' => $json['user'], 90 | 'handle' => $json['user']['username']['_content'], 91 | 'id' => $json['user']['id'], 92 | 'url' => sprintf('https://www.flickr.com/people/%s/', $json['user']['path_alias']), 93 | ]; 94 | 95 | return new AuthenticatedUser($userdata); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/Providers/Patreon.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo}; 17 | use function in_array; 18 | 19 | /** 20 | * Patreon v2 OAuth2 21 | * 22 | * @link https://docs.patreon.com/ 23 | * @link https://docs.patreon.com/#oauth 24 | * @link https://docs.patreon.com/#apiv2-oauth 25 | */ 26 | class Patreon extends OAuth2Provider implements CSRFToken, TokenRefresh, UserInfo{ 27 | 28 | public const IDENTIFIER = 'PATREON'; 29 | 30 | public const SCOPE_V1_USERS = 'users'; 31 | public const SCOPE_V1_PLEDGES_TO_ME = 'pledges-to-me'; 32 | public const SCOPE_V1_MY_CAMPAIGN = 'my-campaign'; 33 | 34 | // wow, consistency... 35 | public const SCOPE_V2_IDENTITY = 'identity'; 36 | public const SCOPE_V2_IDENTITY_EMAIL = 'identity[email]'; 37 | public const SCOPE_V2_IDENTITY_MEMBERSHIPS = 'identity.memberships'; 38 | public const SCOPE_V2_CAMPAIGNS = 'campaigns'; 39 | public const SCOPE_V2_CAMPAIGNS_WEBHOOK = 'w:campaigns.webhook'; 40 | public const SCOPE_V2_CAMPAIGNS_MEMBERS = 'campaigns.members'; 41 | public const SCOPE_V2_CAMPAIGNS_MEMBERS_EMAIL = 'campaigns.members[email]'; 42 | public const SCOPE_V2_CAMPAIGNS_MEMBERS_ADDRESS = 'campaigns.members.address'; 43 | 44 | public const DEFAULT_SCOPES = [ 45 | self::SCOPE_V2_IDENTITY, 46 | self::SCOPE_V2_IDENTITY_EMAIL, 47 | self::SCOPE_V2_IDENTITY_MEMBERSHIPS, 48 | self::SCOPE_V2_CAMPAIGNS, 49 | self::SCOPE_V2_CAMPAIGNS_MEMBERS, 50 | ]; 51 | 52 | protected string $authorizationURL = 'https://www.patreon.com/oauth2/authorize'; 53 | protected string $accessTokenURL = 'https://www.patreon.com/api/oauth2/token'; 54 | protected string $apiURL = 'https://www.patreon.com/api/oauth2'; 55 | protected string|null $apiDocs = 'https://docs.patreon.com/'; 56 | protected string|null $applicationURL = 'https://www.patreon.com/portal/registration/register-clients'; 57 | 58 | public function me():AuthenticatedUser{ 59 | $token = $this->storage->getAccessToken($this->name); 60 | 61 | if(in_array($this::SCOPE_V2_IDENTITY, $token->scopes, true)){ 62 | $endpoint = '/v2/identity'; 63 | $params = [ 64 | 'fields[user]' => 'about,created,email,first_name,full_name,image_url,'. 65 | 'last_name,social_connections,thumb_url,url,vanity', 66 | ]; 67 | } 68 | elseif(in_array($this::SCOPE_V1_USERS, $token->scopes, true)){ 69 | $endpoint = '/api/current_user'; 70 | $params = []; 71 | } 72 | else{ 73 | throw new ProviderException('invalid scopes for the identity endpoint'); 74 | } 75 | 76 | $json = $this->getMeResponseData($endpoint, $params); 77 | 78 | $userdata = [ 79 | 'data' => $json, 80 | 'handle' => $json['data']['attributes']['vanity'], 81 | 'avatar' => $json['data']['attributes']['image_url'], 82 | 'displayName' => $json['data']['attributes']['full_name'], 83 | 'email' => $json['data']['attributes']['email'], 84 | 'id' => $json['data']['id'], 85 | 'url' => $json['data']['attributes']['url'], 86 | ]; 87 | 88 | return new AuthenticatedUser($userdata); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Providers/Gitea.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, PKCE, PKCETrait, TokenRefresh, UserInfo}; 17 | use function sprintf; 18 | 19 | /** 20 | * Gitea OAuth2 21 | * 22 | * @link https://docs.gitea.com/development/oauth2-provider 23 | */ 24 | class Gitea extends OAuth2Provider implements CSRFToken, PKCE, TokenRefresh, UserInfo{ 25 | use PKCETrait; 26 | 27 | public const IDENTIFIER = 'GITEA'; 28 | 29 | public const SCOPE_ACTIVITYPUB = 'activitypub'; 30 | public const SCOPE_ACTIVITYPUB_READ = 'read:activitypub'; 31 | public const SCOPE_ACTIVITYPUB_WRITE = 'write:activitypub'; 32 | public const SCOPE_ADMIN = 'admin'; 33 | public const SCOPE_ADMIN_READ = 'read:admin'; 34 | public const SCOPE_ADMIN_WRITE = 'write:admin'; 35 | public const SCOPE_ISSUE = 'issue'; 36 | public const SCOPE_ISSUE_READ = 'read:issue'; 37 | public const SCOPE_ISSUE_WRITE = 'write:issue'; 38 | # public const SCOPE_MISC = 'misc'; 39 | # public const SCOPE_MISC_READ = 'read:misc'; 40 | # public const SCOPE_MISC_WRITE = 'write:misc'; 41 | public const SCOPE_NOTIFICATION = 'notification'; 42 | public const SCOPE_NOTIFICATION_READ = 'read:notification'; 43 | public const SCOPE_NOTIFICATION_WRITE = 'write:notification'; 44 | public const SCOPE_ORGANIZATION = 'organization'; 45 | public const SCOPE_ORGANIZATION_READ = 'read:organization'; 46 | public const SCOPE_ORGANIZATION_WRITE = 'write:organization'; 47 | public const SCOPE_PACKAGE = 'package'; 48 | public const SCOPE_PACKAGE_READ = 'read:package'; 49 | public const SCOPE_PACKAGE_WRITE = 'write:package'; 50 | public const SCOPE_REPOSITORY = 'repository'; 51 | public const SCOPE_REPOSITORY_READ = 'read:repository'; 52 | public const SCOPE_REPOSITORY_WRITE = 'write:repository'; 53 | public const SCOPE_USER = 'user'; 54 | public const SCOPE_USER_READ = 'read:user'; 55 | public const SCOPE_USER_WRITE = 'write:user'; 56 | 57 | public const DEFAULT_SCOPES = [ 58 | self::SCOPE_REPOSITORY_READ, 59 | self::SCOPE_USER_READ, 60 | ]; 61 | 62 | protected string $authorizationURL = 'https://gitea.com/login/oauth/authorize'; 63 | protected string $accessTokenURL = 'https://gitea.com/login/oauth/access_token'; 64 | protected string $apiURL = 'https://gitea.com/api'; 65 | protected string|null $apiDocs = 'https://docs.gitea.com/api/1.20/'; 66 | protected string|null $applicationURL = 'https://gitea.com/user/settings/applications'; 67 | protected string|null $userRevokeURL = 'https://gitea.com/user/settings/applications'; 68 | 69 | /** @codeCoverageIgnore */ 70 | public function me():AuthenticatedUser{ 71 | $json = $this->getMeResponseData('/v1/user'); 72 | 73 | $userdata = [ 74 | 'data' => $json, 75 | 'avatar' => $json['avatar_url'], 76 | 'handle' => $json['login'], 77 | 'displayName' => $json['full_name'], 78 | 'email' => $json['email'], 79 | 'id' => $json['id'], 80 | 'url' => sprintf('https://gitea.com/%s', $json['login']), 81 | ]; 82 | 83 | return new AuthenticatedUser($userdata); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/Core/AuthenticatedUser.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @filesource 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\Settings\SettingsContainerAbstract; 17 | use function intval, is_int, is_numeric, trim; 18 | 19 | /** 20 | * A simple read-only container for user data responses 21 | * 22 | * @see \chillerlan\OAuth\Core\UserInfo::me() 23 | * 24 | * @property string|null $handle 25 | * @property string|null $displayName 26 | * @property string|null $email 27 | * @property string|int|null $id 28 | * @property string|null $avatar 29 | * @property string|null $url 30 | * @property array $data 31 | */ 32 | final class AuthenticatedUser extends SettingsContainerAbstract{ 33 | 34 | /** 35 | * (magic) The user handle, account or tag name 36 | */ 37 | protected string|null $handle = null; 38 | 39 | /** 40 | * (magic) The user's display name 41 | */ 42 | protected string|null $displayName = null; 43 | 44 | /** 45 | * (magic) The (main) email address 46 | */ 47 | protected string|null $email = null; 48 | 49 | /** 50 | * (magic) A user ID, may be string or integer 51 | */ 52 | protected string|int|null $id = null; 53 | 54 | /** 55 | * (magic) An avatar URL 56 | */ 57 | protected string|null $avatar = null; 58 | 59 | /** 60 | * (magic) URL to the user profile 61 | */ 62 | protected string|null $url = null; 63 | 64 | /** 65 | * (magic) The full user endpoint response 66 | * 67 | * @var array 68 | */ 69 | protected array $data = []; 70 | 71 | /** 72 | * @noinspection PhpMissingParentConstructorInspection 73 | */ 74 | public function __construct(iterable|null $properties = null){ 75 | 76 | if(!empty($properties)){ 77 | // call the parent's setter here 78 | foreach($properties as $property => $value){ 79 | parent::__set($property, $value); 80 | } 81 | 82 | } 83 | 84 | } 85 | 86 | /* 87 | * make this class readonly 88 | */ 89 | 90 | /** @codeCoverageIgnore */ 91 | public function __set(string $property, mixed $value):void{ 92 | // noop 93 | } 94 | 95 | /** @codeCoverageIgnore */ 96 | public function fromIterable(iterable $properties):static{ // phpcs:ignore 97 | // noop 98 | return $this; 99 | } 100 | 101 | /** @codeCoverageIgnore */ 102 | public function fromJSON(string $json):static{ 103 | // noop 104 | return $this; 105 | } 106 | 107 | /* 108 | * setters 109 | */ 110 | 111 | /** 112 | * set the user id, convert to int if possible 113 | */ 114 | protected function set_id(string|int|null $id):void{ 115 | 116 | if($id === null){ 117 | return; 118 | } 119 | 120 | $this->id = $id; 121 | 122 | if(!is_int($id) && is_numeric($id)){ 123 | $intID = intval($id); 124 | 125 | if((string)$intID === $id){ 126 | $this->id = $intID; 127 | } 128 | } 129 | 130 | } 131 | 132 | /** 133 | * trim and set the display name 134 | */ 135 | protected function set_displayName(string|null $displayName):void{ 136 | 137 | if($displayName === null){ 138 | return; 139 | } 140 | 141 | $displayName = trim($displayName); 142 | 143 | if($displayName !== ''){ 144 | $this->displayName = $displayName; 145 | } 146 | 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/Core/PKCETrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @phan-file-suppress PhanUndeclaredProperty, PhanUndeclaredMethod 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\OAuth\Providers\ProviderException; 17 | use chillerlan\Utilities\{Crypto, Str}; 18 | use const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING; 19 | 20 | /** 21 | * Implements PKCE (Proof Key for Code Exchange) functionality 22 | * 23 | * @see \chillerlan\OAuth\Core\PKCE 24 | */ 25 | trait PKCETrait{ 26 | 27 | /** 28 | * implements PKCE::setCodeChallenge() 29 | * 30 | * @see \chillerlan\OAuth\Core\PKCE::setCodeChallenge() 31 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getAuthorizationURLRequestParams() 32 | * 33 | * @param array $params 34 | * @return array 35 | */ 36 | final public function setCodeChallenge(array $params, string $challengeMethod):array{ 37 | 38 | if(!isset($params['response_type']) || $params['response_type'] !== 'code'){ 39 | throw new ProviderException('invalid authorization request params'); 40 | } 41 | 42 | $verifier = $this->generateVerifier($this->options->pkceVerifierLength); 43 | 44 | $params['code_challenge'] = $this->generateChallenge($verifier, $challengeMethod); 45 | $params['code_challenge_method'] = $challengeMethod; 46 | 47 | $this->storage->storeCodeVerifier($verifier, $this->name); 48 | 49 | return $params; 50 | } 51 | 52 | /** 53 | * implements PKCE::setCodeVerifier() 54 | * 55 | * @see \chillerlan\OAuth\Core\PKCE::setCodeVerifier() 56 | * @see \chillerlan\OAuth\Core\OAuth2Provider::getAccessTokenRequestBodyParams() 57 | * 58 | * @param array $params 59 | * @return array 60 | */ 61 | final public function setCodeVerifier(array $params):array{ 62 | 63 | if(!isset($params['grant_type'], $params['code']) || $params['grant_type'] !== 'authorization_code'){ 64 | throw new ProviderException('invalid authorization request body'); 65 | } 66 | 67 | $params['code_verifier'] = $this->storage->getCodeVerifier($this->name); 68 | 69 | // delete verifier after use 70 | $this->storage->clearCodeVerifier($this->name); 71 | 72 | return $params; 73 | } 74 | 75 | /** 76 | * implements PKCE::generateVerifier() 77 | * 78 | * @see \chillerlan\OAuth\Core\PKCE::generateVerifier() 79 | * @see \chillerlan\OAuth\Core\OAuth2Provider::setCodeChallenge() 80 | */ 81 | final public function generateVerifier(int $length):string{ 82 | return Crypto::randomString($length, PKCE::VERIFIER_CHARSET); 83 | } 84 | 85 | /** 86 | * implements PKCE::generateChallenge() 87 | * 88 | * @see \chillerlan\OAuth\Core\PKCE::generateChallenge() 89 | * @see \chillerlan\OAuth\Core\OAuth2Provider::setCodeChallenge() 90 | */ 91 | final public function generateChallenge(string $verifier, string $challengeMethod):string{ 92 | 93 | if($challengeMethod === PKCE::CHALLENGE_METHOD_PLAIN){ 94 | return $verifier; 95 | } 96 | 97 | $verifier = match($challengeMethod){ 98 | PKCE::CHALLENGE_METHOD_S256 => Crypto::sha256($verifier, true), 99 | // no other hash methods yet 100 | default => throw new ProviderException('invalid PKCE challenge method'), // @codeCoverageIgnore 101 | }; 102 | 103 | return Str::base64encode($verifier, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/Providers/Codeberg.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, PKCE, PKCETrait, TokenRefresh, UserInfo}; 17 | use function sprintf; 18 | 19 | /** 20 | * Codeberg OAuth2 21 | * 22 | * @link https://forgejo.org/docs/latest/user/oauth2-provider/ 23 | * @link https://forgejo.org/docs/latest/user/token-scope/ 24 | * @link https://codeberg.org/api/swagger 25 | */ 26 | class Codeberg extends OAuth2Provider implements CSRFToken, PKCE, TokenRefresh, UserInfo{ 27 | use PKCETrait; 28 | 29 | public const IDENTIFIER = 'CODEBERG'; 30 | 31 | public const SCOPE_ACTIVITYPUB = 'activitypub'; 32 | public const SCOPE_ACTIVITYPUB_READ = 'read:activitypub'; 33 | public const SCOPE_ACTIVITYPUB_WRITE = 'write:activitypub'; 34 | public const SCOPE_ADMIN = 'admin'; 35 | public const SCOPE_ADMIN_READ = 'read:admin'; 36 | public const SCOPE_ADMIN_WRITE = 'write:admin'; 37 | public const SCOPE_ISSUE = 'issue'; 38 | public const SCOPE_ISSUE_READ = 'read:issue'; 39 | public const SCOPE_ISSUE_WRITE = 'write:issue'; 40 | public const SCOPE_MISC = 'misc'; 41 | public const SCOPE_MISC_READ = 'read:misc'; 42 | public const SCOPE_MISC_WRITE = 'write:misc'; 43 | public const SCOPE_NOTIFICATION = 'notification'; 44 | public const SCOPE_NOTIFICATION_READ = 'read:notification'; 45 | public const SCOPE_NOTIFICATION_WRITE = 'write:notification'; 46 | public const SCOPE_ORGANIZATION = 'organization'; 47 | public const SCOPE_ORGANIZATION_READ = 'read:organization'; 48 | public const SCOPE_ORGANIZATION_WRITE = 'write:organization'; 49 | public const SCOPE_PACKAGE = 'package'; 50 | public const SCOPE_PACKAGE_READ = 'read:package'; 51 | public const SCOPE_PACKAGE_WRITE = 'write:package'; 52 | public const SCOPE_REPOSITORY = 'repository'; 53 | public const SCOPE_REPOSITORY_READ = 'read:repository'; 54 | public const SCOPE_REPOSITORY_WRITE = 'write:repository'; 55 | public const SCOPE_USER = 'user'; 56 | public const SCOPE_USER_READ = 'read:user'; 57 | public const SCOPE_USER_WRITE = 'write:user'; 58 | 59 | public const DEFAULT_SCOPES = [ 60 | self::SCOPE_REPOSITORY_READ, 61 | self::SCOPE_USER_READ, 62 | ]; 63 | 64 | protected string $authorizationURL = 'https://codeberg.org/login/oauth/authorize'; 65 | protected string $accessTokenURL = 'https://codeberg.org/login/oauth/access_token'; 66 | protected string $apiURL = 'https://codeberg.org/api'; 67 | protected string|null $apiDocs = 'https://codeberg.org/api/swagger'; 68 | protected string|null $applicationURL = 'https://codeberg.org/user/settings/applications'; 69 | protected string|null $userRevokeURL = 'https://codeberg.org/user/settings/applications'; 70 | 71 | /** @codeCoverageIgnore */ 72 | public function me():AuthenticatedUser{ 73 | $json = $this->getMeResponseData('/v1/user'); 74 | 75 | $userdata = [ 76 | 'data' => $json, 77 | 'avatar' => $json['avatar_url'], 78 | 'handle' => $json['login'], 79 | 'displayName' => $json['full_name'], 80 | 'email' => $json['email'], 81 | 'id' => $json['id'], 82 | 'url' => sprintf('https://codeberg.org/%s', $json['login']), 83 | ]; 84 | 85 | return new AuthenticatedUser($userdata); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/Core/TokenInvalidateTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @phan-file-suppress PhanUndeclaredProperty, PhanUndeclaredMethod 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\HTTP\Utils\MessageUtil; 17 | use chillerlan\OAuth\Providers\ProviderException; 18 | use Psr\Http\Message\ResponseInterface; 19 | use function in_array; 20 | use function sprintf; 21 | use function str_contains; 22 | use function strtolower; 23 | use function trim; 24 | 25 | /** 26 | * Implements token invalidation functionality 27 | * 28 | * @see \chillerlan\OAuth\Core\TokenInvalidate 29 | */ 30 | trait TokenInvalidateTrait{ 31 | 32 | /** 33 | * implements TokenInvalidate::invalidateAccessToken() 34 | * 35 | * @see \chillerlan\OAuth\Core\TokenInvalidate::invalidateAccessToken() 36 | * @throws \chillerlan\OAuth\Providers\ProviderException 37 | */ 38 | public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{ 39 | $type = strtolower(trim(($type ?? 'access_token'))); 40 | 41 | // @link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 42 | if(!in_array($type, ['access_token', 'refresh_token'], true)){ 43 | throw new ProviderException(sprintf('invalid token type "%s"', $type)); // @codeCoverageIgnore 44 | } 45 | 46 | $tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name)); 47 | /** @phan-suppress-next-line PhanTypeMismatchArgumentNullable */ 48 | $body = $this->getInvalidateAccessTokenBodyParams($tokenToInvalidate, $type); 49 | $response = $this->sendTokenInvalidateRequest($this->revokeURL, $body); 50 | 51 | // some endpoints may return 204, others 200 with empty body 52 | if(in_array($response->getStatusCode(), [200, 204], true)){ 53 | 54 | // if the token was given via parameter it cannot be deleted from storage 55 | if($token === null){ 56 | $this->storage->clearAccessToken($this->name); 57 | } 58 | 59 | return true; 60 | } 61 | 62 | // ok, let's see if we got a response body 63 | // @link https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1 64 | if(str_contains($response->getHeaderLine('content-type'), 'json')){ 65 | $json = MessageUtil::decodeJSON($response, true); 66 | 67 | if(isset($json['error'])){ 68 | throw new ProviderException($json['error']); 69 | } 70 | } 71 | 72 | return false; 73 | } 74 | 75 | /** 76 | * Prepares the body for a token revocation request 77 | * 78 | * @see \chillerlan\OAuth\Core\OAuth2Provider::invalidateAccessToken() 79 | * 80 | * @return array 81 | */ 82 | protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{ 83 | return [ 84 | 'token' => $token->accessToken, 85 | 'token_type_hint' => $type, 86 | ]; 87 | } 88 | 89 | /** 90 | * Prepares and sends a request to the token invalidation endpoint 91 | * 92 | * @see \chillerlan\OAuth\Core\OAuth2Provider::invalidateAccessToken() 93 | * 94 | * @param array $body 95 | */ 96 | protected function sendTokenInvalidateRequest(string $url, array $body):ResponseInterface{ 97 | 98 | $request = $this->requestFactory 99 | ->createRequest('POST', $url) 100 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 101 | ; 102 | 103 | // some enpoints may require a basic auth header here 104 | $request = $this->setRequestBody($body, $request); 105 | 106 | return $this->http->sendRequest($request); 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Providers/DeviantArt.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\HTTP\Utils\MessageUtil; 17 | use chillerlan\OAuth\Core\{ 18 | AccessToken, AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, 19 | CSRFToken, OAuth2Provider, TokenInvalidate, TokenRefresh, UserInfo, 20 | }; 21 | use chillerlan\OAuth\Storage\MemoryStorage; 22 | use Throwable; 23 | use function sprintf; 24 | 25 | /** 26 | * DeviantArt OAuth2 27 | * 28 | * @link https://www.deviantart.com/developers/ 29 | */ 30 | class DeviantArt extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenInvalidate, TokenRefresh, UserInfo{ 31 | use ClientCredentialsTrait; 32 | 33 | public const IDENTIFIER = 'DEVIANTART'; 34 | 35 | public const SCOPE_BASIC = 'basic'; 36 | public const SCOPE_BROWSE = 'browse'; 37 | public const SCOPE_COLLECTION = 'collection'; 38 | public const SCOPE_COMMENT_POST = 'comment.post'; 39 | public const SCOPE_FEED = 'feed'; 40 | public const SCOPE_GALLERY = 'gallery'; 41 | public const SCOPE_MESSAGE = 'message'; 42 | public const SCOPE_NOTE = 'note'; 43 | public const SCOPE_STASH = 'stash'; 44 | public const SCOPE_USER = 'user'; 45 | public const SCOPE_USER_MANAGE = 'user.manage'; 46 | 47 | public const DEFAULT_SCOPES = [ 48 | self::SCOPE_BASIC, 49 | self::SCOPE_BROWSE, 50 | ]; 51 | 52 | public const HEADERS_API = [ 53 | 'dA-minor-version' => '20210526', 54 | ]; 55 | 56 | protected string $authorizationURL = 'https://www.deviantart.com/oauth2/authorize'; 57 | protected string $accessTokenURL = 'https://www.deviantart.com/oauth2/token'; 58 | protected string $revokeURL = 'https://www.deviantart.com/oauth2/revoke'; 59 | protected string $apiURL = 'https://www.deviantart.com/api/v1/oauth2'; 60 | protected string|null $userRevokeURL = 'https://www.deviantart.com/settings/applications'; 61 | protected string|null $apiDocs = 'https://www.deviantart.com/developers/'; 62 | protected string|null $applicationURL = 'https://www.deviantart.com/developers/apps'; 63 | 64 | /** @codeCoverageIgnore */ 65 | public function me():AuthenticatedUser{ 66 | $json = $this->getMeResponseData('/user/whoami'); 67 | 68 | $userdata = [ 69 | 'data' => $json, 70 | 'avatar' => $json['usericon'], 71 | 'handle' => $json['username'], 72 | 'id' => $json['userid'], 73 | 'url' => sprintf('https://www.deviantart.com/%s', $json['username']), 74 | ]; 75 | 76 | return new AuthenticatedUser($userdata); 77 | } 78 | 79 | public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{ 80 | 81 | if($token !== null){ 82 | // to revoke a token different from the one of the currently authenticated user, 83 | // we're going to clone the provider and feed the other token for the invalidate request 84 | return (clone $this) 85 | ->setStorage(new MemoryStorage) 86 | ->storeAccessToken($token) 87 | ->invalidateAccessToken() 88 | ; 89 | } 90 | 91 | $request = $this->requestFactory->createRequest('POST', $this->revokeURL); 92 | $response = $this->http->sendRequest($this->getRequestAuthorization($request)); 93 | 94 | try{ 95 | $json = MessageUtil::decodeJSON($response); 96 | } 97 | catch(Throwable){ 98 | return false; 99 | } 100 | 101 | if($response->getStatusCode() === 200 && !empty($json->success)){ 102 | // delete the token from storage 103 | $this->storage->clearAccessToken($this->name); 104 | 105 | return true; 106 | } 107 | 108 | return false; // @codeCoverageIgnore 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/Storage/MemoryStorage.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Storage; 13 | 14 | use chillerlan\OAuth\Core\AccessToken; 15 | 16 | /** 17 | * Implements a memory storage adapter. 18 | * 19 | * Note: the memory storage is not persistent, as tokens are only stored during script runtime. 20 | */ 21 | class MemoryStorage extends OAuthStorageAbstract{ 22 | 23 | /** 24 | * the storage array 25 | * 26 | * @var array> (the int keys are to keep phpstan silent) 27 | */ 28 | protected array $storage = [ 29 | self::KEY_TOKEN => [], 30 | self::KEY_STATE => [], 31 | self::KEY_VERIFIER => [], 32 | ]; 33 | 34 | 35 | /* 36 | * Access token 37 | */ 38 | 39 | public function storeAccessToken(AccessToken $token, string $provider):static{ 40 | $this->storage[$this::KEY_TOKEN][$this->getProviderName($provider)] = $token; 41 | 42 | return $this; 43 | } 44 | 45 | public function getAccessToken(string $provider):AccessToken{ 46 | 47 | if($this->hasAccessToken($provider)){ 48 | return $this->storage[$this::KEY_TOKEN][$this->getProviderName($provider)]; 49 | } 50 | 51 | throw new ItemNotFoundException($this::KEY_TOKEN); 52 | } 53 | 54 | public function hasAccessToken(string $provider):bool{ 55 | return !empty($this->storage[$this::KEY_TOKEN][$this->getProviderName($provider)]); 56 | } 57 | 58 | public function clearAccessToken(string $provider):static{ 59 | unset($this->storage[$this::KEY_TOKEN][$this->getProviderName($provider)]); 60 | 61 | return $this; 62 | } 63 | 64 | public function clearAllAccessTokens():static{ 65 | $this->storage[$this::KEY_TOKEN] = []; 66 | 67 | return $this; 68 | } 69 | 70 | 71 | /* 72 | * CSRF state 73 | */ 74 | 75 | public function storeCSRFState(string $state, string $provider):static{ 76 | $this->storage[$this::KEY_STATE][$this->getProviderName($provider)] = $state; 77 | 78 | return $this; 79 | } 80 | 81 | public function getCSRFState(string $provider):string{ 82 | 83 | if($this->hasCSRFState($provider)){ 84 | return $this->storage[$this::KEY_STATE][$this->getProviderName($provider)]; 85 | } 86 | 87 | throw new ItemNotFoundException($this::KEY_STATE); 88 | } 89 | 90 | public function hasCSRFState(string $provider):bool{ 91 | return !empty($this->storage[$this::KEY_STATE][$this->getProviderName($provider)]); 92 | } 93 | 94 | public function clearCSRFState(string $provider):static{ 95 | unset($this->storage[$this::KEY_STATE][$this->getProviderName($provider)]); 96 | 97 | return $this; 98 | } 99 | 100 | public function clearAllCSRFStates():static{ 101 | $this->storage[$this::KEY_STATE] = []; 102 | 103 | return $this; 104 | } 105 | 106 | 107 | /* 108 | * PKCE verifier 109 | */ 110 | 111 | public function storeCodeVerifier(string $verifier, string $provider):static{ 112 | $this->storage[$this::KEY_VERIFIER][$this->getProviderName($provider)] = $verifier; 113 | 114 | return $this; 115 | } 116 | 117 | public function getCodeVerifier(string $provider):string{ 118 | 119 | if($this->hasCodeVerifier($provider)){ 120 | return $this->storage[$this::KEY_VERIFIER][$this->getProviderName($provider)]; 121 | } 122 | 123 | throw new ItemNotFoundException($this::KEY_VERIFIER); 124 | } 125 | 126 | public function hasCodeVerifier(string $provider):bool{ 127 | return !empty($this->storage[$this::KEY_VERIFIER][$this->getProviderName($provider)]); 128 | } 129 | 130 | public function clearCodeVerifier(string $provider):static{ 131 | unset($this->storage[$this::KEY_VERIFIER][$this->getProviderName($provider)]); 132 | 133 | return $this; 134 | } 135 | 136 | public function clearAllCodeVerifiers():static{ 137 | $this->storage[$this::KEY_VERIFIER] = []; 138 | 139 | return $this; 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /src/Providers/Mastodon.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo}; 17 | use chillerlan\OAuth\OAuthException; 18 | use Psr\Http\Message\UriInterface; 19 | use function array_merge; 20 | 21 | /** 22 | * Mastodon OAuth2 (v4.x instances) 23 | * 24 | * @link https://docs.joinmastodon.org/client/intro/ 25 | * @link https://docs.joinmastodon.org/methods/apps/oauth/ 26 | */ 27 | class Mastodon extends OAuth2Provider implements CSRFToken, TokenRefresh, UserInfo{ 28 | 29 | public const IDENTIFIER = 'MASTODON'; 30 | 31 | public const SCOPE_READ = 'read'; 32 | public const SCOPE_WRITE = 'write'; 33 | public const SCOPE_FOLLOW = 'follow'; 34 | public const SCOPE_PUSH = 'push'; 35 | 36 | public const DEFAULT_SCOPES = [ 37 | self::SCOPE_READ, 38 | self::SCOPE_FOLLOW, 39 | ]; 40 | 41 | protected string $authorizationURL = 'https://mastodon.social/oauth/authorize'; 42 | protected string $accessTokenURL = 'https://mastodon.social/oauth/token'; 43 | protected string $apiURL = 'https://mastodon.social/api'; 44 | protected string|null $userRevokeURL = 'https://mastodon.social/oauth/authorized_applications'; 45 | protected string|null $apiDocs = 'https://docs.joinmastodon.org/api/'; 46 | protected string|null $applicationURL = 'https://mastodon.social/settings/applications'; 47 | protected string $instance = 'https://mastodon.social'; 48 | 49 | /** 50 | * set the internal URLs for the given Mastodon instance 51 | * 52 | * @throws \chillerlan\OAuth\OAuthException 53 | */ 54 | public function setInstance(UriInterface|string $instance):static{ 55 | 56 | if(!$instance instanceof UriInterface){ 57 | $instance = $this->uriFactory->createUri($instance); 58 | } 59 | 60 | if($instance->getHost() === ''){ 61 | throw new OAuthException('invalid instance URL'); 62 | } 63 | 64 | // enforce https and remove unnecessary parts 65 | $instance = $instance->withScheme('https')->withQuery('')->withFragment(''); 66 | 67 | // @todo: check if host exists/responds? 68 | $this->instance = (string)$instance->withPath(''); 69 | $this->apiURL = (string)$instance->withPath('/api'); 70 | $this->authorizationURL = (string)$instance->withPath('/oauth/authorize'); 71 | $this->accessTokenURL = (string)$instance->withPath('/oauth/token'); 72 | $this->userRevokeURL = (string)$instance->withPath('/oauth/authorized_applications'); 73 | $this->applicationURL = (string)$instance->withPath('/settings/applications'); 74 | 75 | return $this; 76 | } 77 | 78 | public function getAccessToken(string $code, string|null $state = null):AccessToken{ 79 | $this->checkState($state); // we're an instance of CSRFToken 80 | 81 | $body = $this->getAccessTokenRequestBodyParams($code); 82 | $response = $this->sendAccessTokenRequest($this->accessTokenURL, $body); 83 | $token = $this->parseTokenResponse($response); 84 | 85 | // store the instance the token belongs to 86 | $token->extraParams = array_merge($token->extraParams, ['instance' => $this->instance]); 87 | 88 | $this->storage->storeAccessToken($token, $this->name); 89 | 90 | return $token; 91 | } 92 | 93 | /** @codeCoverageIgnore */ 94 | public function me():AuthenticatedUser{ 95 | $json = $this->getMeResponseData('/v1/accounts/verify_credentials'); 96 | 97 | $userdata = [ 98 | 'data' => $json, 99 | 'avatar' => $json['avatar'], 100 | 'handle' => $json['acct'], 101 | 'displayName' => $json['display_name'], 102 | 'id' => $json['id'], 103 | 'url' => $json['url'], 104 | ]; 105 | 106 | return new AuthenticatedUser($userdata); 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Providers/MusicBrainz.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, TokenRefresh, UserInfo, 18 | }; 19 | use Psr\Http\Message\{ResponseInterface, StreamInterface}; 20 | use function in_array, strtoupper; 21 | 22 | /** 23 | * MusicBrainz OAuth2 24 | * 25 | * @link https://musicbrainz.org/doc/Development 26 | * @link https://musicbrainz.org/doc/Development/OAuth2 27 | */ 28 | class MusicBrainz extends OAuth2Provider implements CSRFToken, TokenInvalidate, TokenRefresh, UserInfo{ 29 | use TokenInvalidateTrait; 30 | 31 | public const IDENTIFIER = 'MUSICBRAINZ'; 32 | 33 | public const SCOPE_PROFILE = 'profile'; 34 | public const SCOPE_EMAIL = 'email'; 35 | public const SCOPE_TAG = 'tag'; 36 | public const SCOPE_RATING = 'rating'; 37 | public const SCOPE_COLLECTION = 'collection'; 38 | public const SCOPE_SUBMIT_ISRC = 'submit_isrc'; 39 | public const SCOPE_SUBMIT_BARCODE = 'submit_barcode'; 40 | 41 | public const DEFAULT_SCOPES = [ 42 | self::SCOPE_PROFILE, 43 | self::SCOPE_EMAIL, 44 | self::SCOPE_TAG, 45 | self::SCOPE_RATING, 46 | self::SCOPE_COLLECTION, 47 | ]; 48 | 49 | protected string $authorizationURL = 'https://musicbrainz.org/oauth2/authorize'; 50 | protected string $accessTokenURL = 'https://musicbrainz.org/oauth2/token'; 51 | protected string $revokeURL = 'https://musicbrainz.org/oauth2/revoke '; 52 | protected string $apiURL = 'https://musicbrainz.org/ws/2'; 53 | protected string|null $userRevokeURL = 'https://musicbrainz.org/account/applications'; 54 | protected string|null $apiDocs = 'https://musicbrainz.org/doc/Development'; 55 | protected string|null $applicationURL = 'https://musicbrainz.org/account/applications'; 56 | 57 | protected function getRefreshAccessTokenRequestBodyParams(string $refreshToken):array{ 58 | return [ 59 | 'client_id' => $this->options->key, 60 | 'client_secret' => $this->options->secret, 61 | 'grant_type' => 'refresh_token', 62 | 'refresh_token' => $refreshToken, 63 | ]; 64 | } 65 | 66 | /** 67 | * @return array 68 | */ 69 | protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{ 70 | return [ 71 | 'client_id' => $this->options->key, 72 | 'client_secret' => $this->options->secret, 73 | 'token' => $token->accessToken, 74 | 'token_type_hint' => $type, 75 | ]; 76 | } 77 | 78 | public function request( 79 | string $path, 80 | array|null $params = null, 81 | string|null $method = null, 82 | StreamInterface|array|string|null $body = null, 83 | array|null $headers = null, 84 | string|null $protocolVersion = null, 85 | ):ResponseInterface{ 86 | $params = ($params ?? []); 87 | $method = strtoupper(($method ?? 'GET')); 88 | 89 | if(!isset($params['fmt'])){ 90 | $params['fmt'] = 'json'; 91 | } 92 | 93 | if(in_array($method, ['POST', 'PUT', 'DELETE'], true) && !isset($params['client'])){ 94 | $params['client'] = $this::USER_AGENT; // @codeCoverageIgnore 95 | } 96 | 97 | return parent::request($path, $params, $method, $body, $headers, $protocolVersion); 98 | } 99 | 100 | /** @codeCoverageIgnore */ 101 | public function me():AuthenticatedUser{ 102 | $json = $this->getMeResponseData('https://musicbrainz.org/oauth2/userinfo', ['fmt' => 'json']); 103 | 104 | $userdata = [ 105 | 'data' => $json, 106 | 'handle' => $json['sub'], 107 | 'email' => $json['email'], 108 | 'id' => $json['metabrainz_user_id'], 109 | 'url' => $json['profile'], 110 | ]; 111 | 112 | return new AuthenticatedUser($userdata); 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /src/Providers/Deezer.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil}; 17 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, InvalidAccessTokenException, OAuth2Provider, UserInfo}; 18 | use Psr\Http\Message\ResponseInterface; 19 | use function array_merge, implode, trim; 20 | 21 | /** 22 | * Deezer OAuth2 23 | * 24 | * @link https://developers.deezer.com/api/oauth 25 | */ 26 | class Deezer extends OAuth2Provider implements CSRFToken, UserInfo{ 27 | 28 | public const IDENTIFIER = 'DEEZER'; 29 | 30 | public const SCOPE_BASIC = 'basic_access'; 31 | public const SCOPE_EMAIL = 'email'; 32 | public const SCOPE_OFFLINE_ACCESS = 'offline_access'; 33 | public const SCOPE_MANAGE_LIBRARY = 'manage_library'; 34 | public const SCOPE_MANAGE_COMMUNITY = 'manage_community'; 35 | public const SCOPE_DELETE_LIBRARY = 'delete_library'; 36 | public const SCOPE_LISTENING_HISTORY = 'listening_history'; 37 | 38 | public const DEFAULT_SCOPES = [ 39 | self::SCOPE_BASIC, 40 | self::SCOPE_EMAIL, 41 | self::SCOPE_OFFLINE_ACCESS, 42 | self::SCOPE_MANAGE_LIBRARY, 43 | self::SCOPE_LISTENING_HISTORY, 44 | ]; 45 | 46 | public const AUTH_METHOD = self::AUTH_METHOD_QUERY; 47 | 48 | protected string $authorizationURL = 'https://connect.deezer.com/oauth/auth.php'; 49 | protected string $accessTokenURL = 'https://connect.deezer.com/oauth/access_token.php'; 50 | protected string $apiURL = 'https://api.deezer.com'; 51 | protected string|null $userRevokeURL = 'https://www.deezer.com/account/apps'; 52 | protected string|null $apiDocs = 'https://developers.deezer.com/api'; 53 | protected string|null $applicationURL = 'https://developers.deezer.com/myapps'; 54 | 55 | /** 56 | * @inheritDoc 57 | * 58 | * sure, you *can* use different parameter names than the standard ones... https://xkcd.com/927/ 59 | */ 60 | protected function getAuthorizationURLRequestParams(array $params, array $scopes):array{ 61 | 62 | $params = array_merge($params, [ 63 | 'app_id' => $this->options->key, 64 | 'redirect_uri' => $this->options->callbackURL, 65 | 'perms' => implode($this::SCOPES_DELIMITER, $scopes), 66 | ]); 67 | 68 | return $this->setState($params); // we are instance of CSRFToken 69 | } 70 | 71 | /** 72 | * @inheritDoc 73 | */ 74 | protected function getAccessTokenRequestBodyParams(string $code):array{ 75 | return [ 76 | 'app_id' => $this->options->key, 77 | 'secret' => $this->options->secret, 78 | 'code' => $code, 79 | 'output' => 'json', // for some reason this has no effect 80 | ]; 81 | } 82 | 83 | /** 84 | * @inheritDoc 85 | * 86 | * hey deezer, I suggest re-reading the OAuth2 spec! 87 | * also the content-type of "text/html" here is... bad. 88 | */ 89 | protected function getTokenResponseData(ResponseInterface $response):array{ 90 | $data = trim(MessageUtil::getContents($response)); 91 | 92 | if($data === ''){ 93 | throw new ProviderException('invalid response'); 94 | } 95 | 96 | return QueryUtil::parse($data); 97 | } 98 | 99 | /** 100 | * deezer keeps testing my sanity - HTTP/200 on invalid token... sure 101 | * 102 | * @inheritDoc 103 | * @codeCoverageIgnore 104 | */ 105 | public function me():AuthenticatedUser{ 106 | $json = $this->getMeResponseData('/user/me'); 107 | 108 | if(isset($json['error']['code'], $json['error']['message'])){ 109 | 110 | if($json['error']['code'] === 300){ 111 | throw new InvalidAccessTokenException($json['error']['message']); 112 | } 113 | 114 | throw new ProviderException($json['error']['message']); 115 | } 116 | 117 | $userdata = [ 118 | 'data' => $json, 119 | 'avatar' => $json['picture'], 120 | 'handle' => $json['name'], 121 | 'id' => $json['id'], 122 | 'url' => $json['link'], 123 | ]; 124 | 125 | return new AuthenticatedUser($userdata); 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/Providers/GuildWars2.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil}; 17 | use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, OAuth2Provider, UserInfo}; 18 | use Psr\Http\Message\UriInterface; 19 | use function implode, preg_match, str_starts_with, substr; 20 | 21 | /** 22 | * Guild Wars 2 23 | * 24 | * Note: GW2 does not support authentication (anymore) but the API still works like a regular OAUth API, so... 25 | * 26 | * @link https://api.guildwars2.com/v2 27 | * @link https://wiki.guildwars2.com/wiki/API:Main 28 | */ 29 | class GuildWars2 extends OAuth2Provider implements UserInfo{ 30 | 31 | public const IDENTIFIER = 'GUILDWARS2'; 32 | 33 | public const SCOPE_ACCOUNT = 'account'; 34 | public const SCOPE_INVENTORIES = 'inventories'; 35 | public const SCOPE_CHARACTERS = 'characters'; 36 | public const SCOPE_TRADINGPOST = 'tradingpost'; 37 | public const SCOPE_WALLET = 'wallet'; 38 | public const SCOPE_UNLOCKS = 'unlocks'; 39 | public const SCOPE_PVP = 'pvp'; 40 | public const SCOPE_BUILDS = 'builds'; 41 | public const SCOPE_PROGRESSION = 'progression'; 42 | public const SCOPE_GUILDS = 'guilds'; 43 | 44 | protected string $authorizationURL = 'https://api.guildwars2.com/v2/tokeninfo'; 45 | protected string $apiURL = 'https://api.guildwars2.com'; 46 | protected string|null $userRevokeURL = 'https://account.arena.net/applications'; 47 | protected string|null $apiDocs = 'https://wiki.guildwars2.com/wiki/API:Main'; 48 | protected string|null $applicationURL = 'https://account.arena.net/applications'; 49 | 50 | /** 51 | * @throws \chillerlan\OAuth\Providers\ProviderException 52 | */ 53 | public function storeGW2Token(string $access_token):AccessToken{ 54 | 55 | if(!preg_match('/^[a-f\d\-]{72}$/i', $access_token)){ 56 | throw new ProviderException('invalid token'); 57 | } 58 | 59 | // to verify the token we need to send a request without authentication 60 | $request = $this->requestFactory 61 | ->createRequest('GET', QueryUtil::merge($this->authorizationURL, ['access_token' => $access_token])) 62 | ; 63 | 64 | $tokeninfo = MessageUtil::decodeJSON($this->http->sendRequest($request)); 65 | 66 | if(isset($tokeninfo->id) && str_starts_with($access_token, $tokeninfo->id)){ 67 | $token = $this->createAccessToken(); 68 | $token->accessToken = $access_token; 69 | $token->accessTokenSecret = substr($access_token, 36, 36); // the actual token 70 | $token->expires = AccessToken::NEVER_EXPIRES; 71 | $token->extraParams = [ 72 | 'token_type' => 'Bearer', 73 | 'id' => $tokeninfo->id, 74 | 'name' => $tokeninfo->name, 75 | 'scope' => implode($this::SCOPES_DELIMITER, $tokeninfo->permissions), 76 | ]; 77 | 78 | $this->storage->storeAccessToken($token, $this->name); 79 | 80 | return $token; 81 | } 82 | 83 | throw new ProviderException('unverified token'); // @codeCoverageIgnore 84 | } 85 | 86 | /** 87 | * @throws \chillerlan\OAuth\Providers\ProviderException 88 | */ 89 | public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{ 90 | throw new ProviderException('GuildWars2 does not support authentication anymore.'); 91 | } 92 | 93 | /** 94 | * @throws \chillerlan\OAuth\Providers\ProviderException 95 | */ 96 | public function getAccessToken(string $code, string|null $state = null):AccessToken{ 97 | throw new ProviderException('GuildWars2 does not support authentication anymore.'); 98 | } 99 | 100 | /** @codeCoverageIgnore */ 101 | public function me():AuthenticatedUser{ 102 | $json = $this->getMeResponseData('/v2/tokeninfo'); 103 | 104 | $userdata = [ 105 | 'data' => $json, 106 | 'handle' => $json['name'], 107 | 'id' => $json['id'], 108 | ]; 109 | 110 | return new AuthenticatedUser($userdata); 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/Providers/Vimeo.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AccessToken, AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, 18 | CSRFToken, OAuth2Provider, TokenInvalidate, UserInfo, 19 | }; 20 | use chillerlan\OAuth\Storage\MemoryStorage; 21 | use function str_replace; 22 | 23 | /** 24 | * Vimeo OAuth2 25 | * 26 | * @link https://developer.vimeo.com/ 27 | * @link https://developer.vimeo.com/api/authentication 28 | */ 29 | class Vimeo extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenInvalidate, UserInfo{ 30 | use ClientCredentialsTrait; 31 | 32 | public const IDENTIFIER = 'VIMEO'; 33 | 34 | /** 35 | * @link https://developer.vimeo.com/api/authentication#understanding-the-auth-process 36 | */ 37 | public const SCOPE_PUBLIC = 'public'; 38 | public const SCOPE_PRIVATE = 'private'; 39 | public const SCOPE_PURCHASED = 'purchased'; 40 | public const SCOPE_CREATE = 'create'; 41 | public const SCOPE_EDIT = 'edit'; 42 | public const SCOPE_DELETE = 'delete'; 43 | public const SCOPE_INTERACT = 'interact'; 44 | public const SCOPE_STATS = 'stats'; 45 | public const SCOPE_UPLOAD = 'upload'; 46 | public const SCOPE_PROMO_CODES = 'promo_codes'; 47 | public const SCOPE_VIDEO_FILES = 'video_files'; 48 | 49 | public const DEFAULT_SCOPES = [ 50 | self::SCOPE_PUBLIC, 51 | self::SCOPE_PRIVATE, 52 | self::SCOPE_INTERACT, 53 | self::SCOPE_STATS, 54 | ]; 55 | 56 | // @link https://developer.vimeo.com/api/changelog 57 | protected const API_VERSION = '3.4'; 58 | 59 | public const HEADERS_AUTH = [ 60 | 'Accept' => 'application/vnd.vimeo.*+json;version='.self::API_VERSION, 61 | ]; 62 | 63 | public const HEADERS_API = [ 64 | 'Accept' => 'application/vnd.vimeo.*+json;version='.self::API_VERSION, 65 | ]; 66 | 67 | protected string $authorizationURL = 'https://api.vimeo.com/oauth/authorize'; 68 | protected string $accessTokenURL = 'https://api.vimeo.com/oauth/access_token'; 69 | protected string $revokeURL = 'https://api.vimeo.com/tokens'; 70 | protected string $apiURL = 'https://api.vimeo.com'; 71 | protected string|null $userRevokeURL = 'https://vimeo.com/settings/apps'; 72 | protected string|null $clientCredentialsTokenURL = 'https://api.vimeo.com/oauth/authorize/client'; 73 | protected string|null $apiDocs = 'https://developer.vimeo.com'; 74 | protected string|null $applicationURL = 'https://developer.vimeo.com/apps'; 75 | 76 | /** @codeCoverageIgnore */ 77 | public function me():AuthenticatedUser{ 78 | $json = $this->getMeResponseData('/me'); 79 | 80 | $userdata = [ 81 | 'data' => $json, 82 | 'avatar' => $json['pictures']['base_link'], 83 | 'handle' => str_replace('https://vimeo.com/', '', $json['link']), 84 | 'displayName' => $json['name'], 85 | 'id' => str_replace('/users/', '', $json['uri']), 86 | 'url' => $json['link'], 87 | ]; 88 | 89 | return new AuthenticatedUser($userdata); 90 | } 91 | 92 | public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{ 93 | 94 | if($token !== null){ 95 | // to revoke a token different from the one of the currently authenticated user, 96 | // we're going to clone the provider and feed the other token for the invalidate request 97 | return (clone $this) 98 | ->setStorage(new MemoryStorage) 99 | ->storeAccessToken($token) 100 | ->invalidateAccessToken() 101 | ; 102 | } 103 | 104 | $request = $this->requestFactory->createRequest('DELETE', $this->revokeURL); 105 | $response = $this->http->sendRequest($this->getRequestAuthorization($request)); 106 | 107 | if($response->getStatusCode() === 204){ 108 | // delete the token from storage 109 | $this->storage->clearAccessToken($this->name); 110 | 111 | return true; 112 | } 113 | 114 | return false; 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/Core/AccessToken.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @filesource 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Core; 15 | 16 | use chillerlan\Settings\SettingsContainerAbstract; 17 | use DateInterval, DateTime; 18 | use function time; 19 | 20 | /** 21 | * Access token implementation for any OAuth version. 22 | * 23 | * @link https://datatracker.ietf.org/doc/html/rfc5849#section-2.3 24 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-1.4 25 | * 26 | * @property string|null $accessToken 27 | * @property string|null $accessTokenSecret 28 | * @property string|null $refreshToken 29 | * @property int $expires 30 | * @property string[] $scopes 31 | * @property array $extraParams 32 | * @property string|null $provider 33 | */ 34 | final class AccessToken extends SettingsContainerAbstract{ 35 | 36 | /** 37 | * Denotes an unknown end of lifetime, such a token should be considered as expired. 38 | * 39 | * @var int 40 | */ 41 | public const EXPIRY_UNKNOWN = -0xDEAD; 42 | 43 | /** 44 | * Denotes a token which never expires 45 | * 46 | * @var int 47 | */ 48 | public const NEVER_EXPIRES = -0xCAFE; 49 | 50 | /** 51 | * Defines a maximum expiry period (1 year) 52 | * 53 | * @var int 54 | */ 55 | public const EXPIRY_MAX = (86400 * 365); 56 | 57 | /** 58 | * (magic) The oauth access token 59 | */ 60 | protected string|null $accessToken = null; 61 | 62 | /** 63 | * (magic) The access token secret (OAuth1) 64 | */ 65 | protected string|null $accessTokenSecret = null; 66 | 67 | /** 68 | * (magic) An optional refresh token (OAuth2) 69 | */ 70 | protected string|null $refreshToken = null; 71 | 72 | /** 73 | * (magic) The token expiration time 74 | * 75 | * The getter accepts: `DateTime|DateInterval|int|null` 76 | */ 77 | protected int $expires = self::EXPIRY_UNKNOWN; 78 | 79 | /** 80 | * (magic) The scopes that are attached to this token 81 | * 82 | * @var string[] 83 | */ 84 | protected array $scopes = []; 85 | 86 | /** 87 | * (magic) Additional token parameters supplied by the provider 88 | * 89 | * @var array 90 | */ 91 | protected array $extraParams = []; 92 | 93 | /** 94 | * (magic) The provider that issued the token 95 | */ 96 | protected string|null $provider = null; 97 | 98 | /** 99 | * Sets the expiration for this token, clamps the expiry to EXPIRY_MAX 100 | * 101 | * - `0` sets the expiry to `NEVER_EXPIRES` 102 | * - `null`, negative integer values or timestamps from `DateTime` and `DateInterval` 103 | * that are in the past set the expiry to `EXPIRY_UNKNOWN` 104 | */ 105 | protected function set_expires(DateTime|DateInterval|int|null $expires = null):void{ 106 | $now = time(); 107 | $max = ($now + $this::EXPIRY_MAX); 108 | 109 | $this->expires = match(true){ 110 | $expires instanceof DateTime => $expires->getTimeStamp(), 111 | $expires instanceof DateInterval => (new DateTime)->add($expires)->getTimeStamp(), 112 | $expires === 0 || $expires === $this::NEVER_EXPIRES => $this::NEVER_EXPIRES, 113 | $expires > $now => $expires, 114 | $expires > 0 && $expires <= $this::EXPIRY_MAX => ($now + $expires), 115 | default => $this::EXPIRY_UNKNOWN, 116 | }; 117 | 118 | // clamp possibly expired values 119 | if(($expires instanceof DateTime || $expires instanceof DateInterval) && $this->expires < $now){ 120 | $this->expires = $this::EXPIRY_UNKNOWN; 121 | } 122 | 123 | // clamp max expiry 124 | if($this->expires > $max){ 125 | $this->expires = $max; // @codeCoverageIgnore 126 | } 127 | 128 | } 129 | 130 | /** 131 | * Checks whether this token is expired 132 | */ 133 | public function isExpired():bool{ 134 | 135 | if($this->expires === $this::NEVER_EXPIRES){ 136 | return false; 137 | } 138 | 139 | if($this->expires === $this::EXPIRY_UNKNOWN){ 140 | return true; 141 | } 142 | 143 | return time() > $this->expires; 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/OAuthOptionsTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth; 13 | 14 | use chillerlan\OAuth\Storage\OAuthStorageException; 15 | use chillerlan\Utilities\{Directory, File}; 16 | use function max, min, preg_match, sprintf, trim; 17 | 18 | /** 19 | * The settings for the OAuth provider 20 | * 21 | * @property string $key 22 | * @property string $secret 23 | * @property string $callbackURL 24 | * @property bool $useStorageEncryption 25 | * @property string $storageEncryptionKey 26 | * @property bool $tokenAutoRefresh 27 | * @property bool $sessionStart 28 | * @property bool $sessionStop 29 | * @property string $sessionStorageVar 30 | * @property string $fileStoragePath 31 | * @property int $pkceVerifierLength 32 | */ 33 | trait OAuthOptionsTrait{ 34 | 35 | /** 36 | * The application key (or client-id) given by your provider 37 | */ 38 | protected string $key = ''; 39 | 40 | /** 41 | * The application secret given by your provider 42 | */ 43 | protected string $secret = ''; 44 | 45 | /** 46 | * The (main) callback URL associated with your application 47 | */ 48 | protected string $callbackURL = ''; 49 | 50 | /** 51 | * Whether to use encryption for the file storage 52 | * 53 | * @see \chillerlan\OAuth\Storage\FileStorage 54 | */ 55 | protected bool $useStorageEncryption = false; 56 | 57 | /** 58 | * The encryption key (hexadecimal) to use 59 | * 60 | * @see \sodium_crypto_secretbox_keygen() 61 | * @see \chillerlan\OAuth\Storage\FileStorage 62 | */ 63 | protected string $storageEncryptionKey = ''; 64 | 65 | /** 66 | * Whether to automatically refresh access tokens (OAuth2) 67 | * 68 | * @see \chillerlan\OAuth\Core\TokenRefresh::refreshAccessToken() 69 | */ 70 | protected bool $tokenAutoRefresh = true; 71 | 72 | /** 73 | * Whether to start the session when session storage is used 74 | * 75 | * Note: this will only start a session if there is no active session present 76 | * 77 | * @see \session_status() 78 | * @see \chillerlan\OAuth\Storage\SessionStorage 79 | */ 80 | protected bool $sessionStart = true; 81 | 82 | /** 83 | * Whether to end the session when session storage is used 84 | * 85 | * Note: this is set to `false` by default to not interfere with other session managers 86 | * 87 | * @see \session_status() 88 | * @see \chillerlan\OAuth\Storage\SessionStorage 89 | */ 90 | protected bool $sessionStop = false; 91 | 92 | /** 93 | * The session key for the storage array 94 | * 95 | * @see \chillerlan\OAuth\Storage\SessionStorage 96 | */ 97 | protected string $sessionStorageVar = 'chillerlan-oauth-storage'; 98 | 99 | /** 100 | * The file storage root path (requires permissions 0777) 101 | * 102 | * @see \is_writable() 103 | * @see \chillerlan\OAuth\Storage\FileStorage 104 | */ 105 | protected string $fileStoragePath = ''; 106 | 107 | /** 108 | * The length of the PKCE challenge verifier (43-128 characters) 109 | * 110 | * @link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 111 | */ 112 | protected int $pkceVerifierLength = 128; 113 | 114 | /** 115 | * sets an encryption key 116 | */ 117 | protected function set_storageEncryptionKey(string $storageEncryptionKey):void{ 118 | 119 | if(!preg_match('/^[a-f\d]{64}$/i', $storageEncryptionKey)){ 120 | throw new OAuthStorageException('invalid encryption key'); 121 | } 122 | 123 | $this->storageEncryptionKey = $storageEncryptionKey; 124 | } 125 | 126 | /** 127 | * sets and verifies the file storage path 128 | */ 129 | protected function set_fileStoragePath(string $fileStoragePath):void{ 130 | $path = File::realpath(trim($fileStoragePath)); 131 | 132 | if(!Directory::isWritable($path) || !Directory::isReadable($path)){ 133 | throw new OAuthStorageException(sprintf('invalid storage path "%s"', $fileStoragePath)); 134 | } 135 | 136 | $this->fileStoragePath = $path; 137 | } 138 | 139 | /** 140 | * clamps the PKCE verifier length between 43 and 128 141 | */ 142 | protected function set_pkceVerifierLength(int $pkceVerifierLength):void{ 143 | $this->pkceVerifierLength = max(43, min(128, $pkceVerifierLength)); 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/Providers/Discord.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AccessToken, AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, 18 | OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, TokenRefresh, UserInfo, 19 | }; 20 | use function sprintf; 21 | 22 | /** 23 | * Discord OAuth2 24 | * 25 | * @link https://discord.com/developers/docs/topics/oauth2 26 | */ 27 | class Discord extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenInvalidate, TokenRefresh, UserInfo{ 28 | use ClientCredentialsTrait, TokenInvalidateTrait; 29 | 30 | public const IDENTIFIER = 'DISCORD'; 31 | 32 | public const SCOPE_APPLICATIONS_COMMANDS = 'applications.commands'; 33 | public const SCOPE_APPLICATIONS_COMMANDS_UPDATE = 'applications.commands.update'; 34 | public const SCOPE_APPLICATIONS_COMMANDS_PERMISSIONS_UPDATE = 'applications.commands.permissions.update'; 35 | public const SCOPE_APPLICATIONS_ENTITLEMENTS = 'applications.entitlements'; 36 | public const SCOPE_BOT = 'bot'; 37 | public const SCOPE_CONNECTIONS = 'connections'; 38 | public const SCOPE_EMAIL = 'email'; 39 | public const SCOPE_GDM_JOIN = 'gdm.join'; 40 | public const SCOPE_GUILDS = 'guilds'; 41 | public const SCOPE_GUILDS_JOIN = 'guilds.join'; 42 | public const SCOPE_GUILDS_MEMBERS_READ = 'guilds.members.read'; 43 | public const SCOPE_IDENTIFY = 'identify'; 44 | public const SCOPE_MESSAGES_READ = 'messages.read'; 45 | public const SCOPE_RELATIONSHIPS_READ = 'relationships.read'; 46 | public const SCOPE_ROLE_CONNECTIONS_WRITE = 'role_connections.write'; 47 | public const SCOPE_RPC = 'rpc'; 48 | public const SCOPE_RPC_ACTIVITIES_WRITE = 'rpc.activities.write'; 49 | public const SCOPE_RPC_NOTIFICATIONS_READ = 'rpc.notifications.read'; 50 | public const SCOPE_WEBHOOK_INCOMING = 'webhook.incoming'; 51 | 52 | public const DEFAULT_SCOPES = [ 53 | self::SCOPE_CONNECTIONS, 54 | self::SCOPE_EMAIL, 55 | self::SCOPE_IDENTIFY, 56 | self::SCOPE_GUILDS, 57 | self::SCOPE_GUILDS_JOIN, 58 | self::SCOPE_GDM_JOIN, 59 | self::SCOPE_MESSAGES_READ, 60 | ]; 61 | 62 | protected string $authorizationURL = 'https://discordapp.com/api/oauth2/authorize'; 63 | protected string $accessTokenURL = 'https://discordapp.com/api/oauth2/token'; 64 | protected string $revokeURL = 'https://discordapp.com/api/oauth2/token/revoke'; 65 | protected string $apiURL = 'https://discordapp.com/api'; 66 | protected string|null $apiDocs = 'https://discord.com/developers/'; 67 | protected string|null $applicationURL = 'https://discordapp.com/developers/applications/'; 68 | 69 | /** 70 | * @link https://github.com/discord/discord-api-docs/issues/2259#issuecomment-927180184 71 | * @return array 72 | */ 73 | protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{ 74 | return [ 75 | 'client_id' => $this->options->key, 76 | 'client_secret' => $this->options->secret, 77 | 'token' => $token->accessToken, 78 | 'token_type_hint' => $type, 79 | ]; 80 | } 81 | 82 | /** @codeCoverageIgnore */ 83 | public function me():AuthenticatedUser{ 84 | $json = $this->getMeResponseData('/users/@me'); 85 | 86 | $userdata = [ 87 | 'data' => $json, 88 | 'avatar' => sprintf('https://cdn.discordapp.com/avatars/%s/%s.png', $json['id'], $json['avatar']), 89 | 'displayName' => $json['global_name'], 90 | 'email' => $json['email'], 91 | 'handle' => $json['username'], 92 | 'id' => $json['id'], 93 | 'url' => sprintf('https://discordapp.com/users/%s', $json['id']), // @me 94 | ]; 95 | 96 | return new AuthenticatedUser($userdata); 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/Providers/GitHub.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, TokenRefresh, UserInfo}; 17 | 18 | /** 19 | * GitHub OAuth2 20 | * 21 | * @link https://docs.github.com/en/apps/oauth-apps/building-oauth-apps 22 | * @link https://docs.github.com/rest 23 | * @link https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens 24 | */ 25 | class GitHub extends OAuth2Provider implements CSRFToken, TokenRefresh, UserInfo{ 26 | 27 | public const IDENTIFIER = 'GITHUB'; 28 | 29 | // GitHub accepts both, comma and space, but the normalized scopes in the token response are only comma separated 30 | public const SCOPES_DELIMITER = ','; 31 | 32 | // @link https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes 33 | public const SCOPE_CODESPACE = 'codespace'; 34 | public const SCOPE_GIST = 'gist'; 35 | public const SCOPE_GPG_KEY_ADMIN = 'admin:gpg_key'; 36 | public const SCOPE_GPG_KEY_READ = 'read:gpg_key'; 37 | public const SCOPE_GPG_KEY_WRITE = 'write:gpg_key'; 38 | public const SCOPE_NOTIFICATIONS = 'notifications'; 39 | public const SCOPE_ORG_ADMIN = 'admin:org'; 40 | public const SCOPE_ORG_HOOK_ADMIN = 'admin:org_hook'; 41 | public const SCOPE_ORG_READ = 'read:org'; 42 | public const SCOPE_ORG_WRITE = 'write:org'; 43 | public const SCOPE_PACKAGES_DELETE = 'delete:packages'; 44 | public const SCOPE_PACKAGES_READ = 'read:packages'; 45 | public const SCOPE_PACKAGES_WRITE = 'write:packages'; 46 | public const SCOPE_PROJECT = 'project'; 47 | public const SCOPE_PROJECT_READ = 'read:project'; 48 | public const SCOPE_PUBLIC_KEY_ADMIN = 'admin:public_key'; 49 | public const SCOPE_PUBLIC_KEY_READ = 'read:public_key'; 50 | public const SCOPE_PUBLIC_KEY_WRITE = 'write:public_key'; 51 | public const SCOPE_PUBLIC_REPO = 'public_repo'; 52 | public const SCOPE_REPO = 'repo'; 53 | public const SCOPE_REPO_DELETE = 'delete_repo'; 54 | public const SCOPE_REPO_DEPLOYMENT = 'repo_deployment'; 55 | public const SCOPE_REPO_HOOK_ADMIN = 'admin:repo_hook'; 56 | public const SCOPE_REPO_HOOK_READ = 'read:repo_hook'; 57 | public const SCOPE_REPO_HOOK_WRITE = 'write:repo_hook'; 58 | public const SCOPE_REPO_INVITE = 'repo:invite'; 59 | public const SCOPE_REPO_STATUS = 'repo:status'; 60 | public const SCOPE_SECURITY_EVENTS = 'security_events'; 61 | public const SCOPE_USER = 'user'; 62 | public const SCOPE_USER_EMAIL = 'user:email'; 63 | public const SCOPE_USER_FOLLOW = 'user:follow'; 64 | public const SCOPE_USER_READ = 'read:user'; 65 | public const SCOPE_WORKFLOW = 'workflow'; 66 | 67 | public const DEFAULT_SCOPES = [ 68 | self::SCOPE_USER, 69 | self::SCOPE_PUBLIC_REPO, 70 | self::SCOPE_GIST, 71 | ]; 72 | 73 | public const HEADERS_AUTH = [ 74 | 'Accept' => 'application/json', 75 | ]; 76 | 77 | public const HEADERS_API = [ 78 | 'Accept' => 'application/vnd.github+json', 79 | 'X-GitHub-Api-Version' => '2022-11-28', 80 | ]; 81 | 82 | protected string $authorizationURL = 'https://github.com/login/oauth/authorize'; 83 | protected string $accessTokenURL = 'https://github.com/login/oauth/access_token'; 84 | protected string $apiURL = 'https://api.github.com'; 85 | protected string|null $userRevokeURL = 'https://github.com/settings/applications'; 86 | protected string|null $apiDocs = 'https://docs.github.com/rest'; 87 | protected string|null $applicationURL = 'https://github.com/settings/developers'; 88 | 89 | /** @codeCoverageIgnore */ 90 | public function me():AuthenticatedUser{ 91 | $json = $this->getMeResponseData('/user'); 92 | 93 | $userdata = [ 94 | 'data' => $json, 95 | 'avatar' => $json['avatar_url'], 96 | 'handle' => $json['login'], 97 | 'displayName' => $json['name'], 98 | 'email' => $json['email'], 99 | 'id' => $json['id'], 100 | 'url' => $json['html_url'], 101 | ]; 102 | 103 | return new AuthenticatedUser($userdata); 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/Providers/MailChimp.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\HTTP\Utils\MessageUtil; 15 | use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, UserInfo}; 16 | use chillerlan\OAuth\OAuthException; 17 | use Psr\Http\Message\{ResponseInterface, StreamInterface}; 18 | use function array_merge, sprintf; 19 | 20 | /** 21 | * MailChimp OAuth2 22 | * 23 | * @link https://mailchimp.com/developer/ 24 | * @link https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ 25 | */ 26 | class MailChimp extends OAuth2Provider implements CSRFToken, UserInfo{ 27 | 28 | public const IDENTIFIER = 'MAILCHIMP'; 29 | 30 | protected const API_BASE = 'https://%s.api.mailchimp.com'; 31 | protected const METADATA_ENDPOINT = 'https://login.mailchimp.com/oauth2/metadata'; 32 | 33 | protected string $authorizationURL = 'https://login.mailchimp.com/oauth2/authorize'; 34 | protected string $accessTokenURL = 'https://login.mailchimp.com/oauth2/token'; 35 | protected string|null $apiDocs = 'https://mailchimp.com/developer/'; 36 | protected string|null $applicationURL = 'https://admin.mailchimp.com/account/oauth2/'; 37 | // set to empty so that we don't run into "uninitialized" errors in mock tests, as the datacenter is in the token 38 | protected string $apiURL = ''; 39 | 40 | public function getAccessToken(string $code, string|null $state = null):AccessToken{ 41 | $this->checkState($state); 42 | 43 | $body = $this->getAccessTokenRequestBodyParams($code); 44 | $response = $this->sendAccessTokenRequest($this->accessTokenURL, $body); 45 | $token = $this->parseTokenResponse($response); 46 | 47 | // MailChimp needs another call to the auth metadata endpoint 48 | // to receive the datacenter prefix/API URL, which will then 49 | // be stored in AccessToken::$extraParams 50 | 51 | return $this->getTokenMetadata($token); 52 | } 53 | 54 | /** 55 | * @throws \chillerlan\OAuth\OAuthException 56 | */ 57 | public function getTokenMetadata(AccessToken|null $token = null):AccessToken{ 58 | $token ??= $this->storage->getAccessToken($this->name); 59 | 60 | $request = $this->requestFactory 61 | ->createRequest('GET', $this::METADATA_ENDPOINT) 62 | ->withHeader('Authorization', 'OAuth '.$token->accessToken) 63 | ; 64 | 65 | $response = $this->http->sendRequest($request); 66 | 67 | if($response->getStatusCode() !== 200){ 68 | throw new OAuthException('metadata response error'); // @codeCoverageIgnore 69 | } 70 | 71 | $token->extraParams = array_merge($token->extraParams, MessageUtil::decodeJSON($response, true)); 72 | 73 | $this->storage->storeAccessToken($token, $this->name); 74 | 75 | return $token; 76 | } 77 | 78 | public function request( 79 | string $path, 80 | array|null $params = null, 81 | string|null $method = null, 82 | StreamInterface|array|string|null $body = null, 83 | array|null $headers = null, 84 | string|null $protocolVersion = null, 85 | ):ResponseInterface{ 86 | $token = $this->storage->getAccessToken($this->name); 87 | // get the API URL from the token metadata 88 | $this->apiURL = sprintf($this::API_BASE, $token->extraParams['dc']); 89 | 90 | return parent::request($path, $params, $method, $body, $headers, $protocolVersion); 91 | } 92 | 93 | protected function sendMeRequest(string $endpoint, array|null $params = null):ResponseInterface{ 94 | return $this->request(path: $endpoint, params: $params); 95 | } 96 | 97 | /** 98 | * @link https://mailchimp.com/developer/marketing/api/root/list-api-root-resources/ 99 | * 100 | * @inheritDoc 101 | * @codeCoverageIgnore 102 | */ 103 | public function me():AuthenticatedUser{ 104 | $json = $this->getMeResponseData('/3.0/'); // trailing slash! 105 | 106 | $userdata = [ 107 | 'data' => $json, 108 | 'avatar' => $json['avatar_url'], 109 | 'displayName' => $json['username'], 110 | 'handle' => $json['account_name'], 111 | 'email' => $json['email'], 112 | 'id' => $json['account_id'], 113 | ]; 114 | 115 | return new AuthenticatedUser($userdata); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/Providers/BattleNet.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2019 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\OAuth\Core\{AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, OAuth2Provider, UserInfo}; 15 | use function in_array, ltrim, rtrim, sprintf, strtolower; 16 | 17 | /** 18 | * Battle.net OAuth2 19 | * 20 | * @link https://develop.battle.net/documentation/guides/using-oauth 21 | */ 22 | class BattleNet extends OAuth2Provider implements ClientCredentials, CSRFToken, UserInfo{ 23 | use ClientCredentialsTrait; 24 | 25 | public const IDENTIFIER = 'BATTLENET'; 26 | 27 | public const SCOPE_OPENID = 'openid'; 28 | public const SCOPE_PROFILE_D3 = 'd3.profile'; 29 | public const SCOPE_PROFILE_SC2 = 'sc2.profile'; 30 | public const SCOPE_PROFILE_WOW = 'wow.profile'; 31 | 32 | public const DEFAULT_SCOPES = [ 33 | self::SCOPE_OPENID, 34 | self::SCOPE_PROFILE_D3, 35 | self::SCOPE_PROFILE_SC2, 36 | self::SCOPE_PROFILE_WOW, 37 | ]; 38 | 39 | protected string|null $apiDocs = 'https://develop.battle.net/documentation'; 40 | protected string|null $applicationURL = 'https://develop.battle.net/access/clients'; 41 | protected string|null $userRevokeURL = 'https://account.blizzard.com/connections'; 42 | 43 | // the URL for the "OAuth" endpoints 44 | // @link https://develop.battle.net/documentation/battle-net/oauth-apis 45 | protected string $battleNetOauth = 'https://oauth.battle.net'; 46 | protected string $region = 'eu'; 47 | // these URLs will be set dynamically, depending on the chose datacenter 48 | protected string $apiURL = 'https://eu.api.blizzard.com'; 49 | protected string $authorizationURL = 'https://oauth.battle.net/authorize'; 50 | protected string $accessTokenURL = 'https://oauth.battle.net/token'; 51 | 52 | protected const KNOWN_DOMAINS = [ 53 | 'oauth.battle.net', 54 | 'eu.api.blizzard.com', 55 | 'kr.api.blizzard.com', 56 | 'tw.api.blizzard.com', 57 | 'us.api.blizzard.com', 58 | 'gateway.battlenet.com.cn', 59 | 'oauth.battlenet.com.cn', 60 | ]; 61 | 62 | protected function getRequestTarget(string $uri):string{ 63 | $parsedURL = $this->uriFactory->createUri($uri); 64 | $parsedHost = $parsedURL->getHost(); 65 | $api = $this->uriFactory->createUri($this->apiURL); 66 | 67 | if($parsedHost === ''){ 68 | $parsedPath = $parsedURL->getPath(); 69 | $apiURL = rtrim((string)$api, '/'); 70 | 71 | if($parsedPath === ''){ 72 | return $apiURL; 73 | } 74 | 75 | return sprintf('%s/%s', $apiURL, ltrim($parsedPath, '/')); 76 | } 77 | 78 | // for some reason we were given a host name 79 | 80 | // shortcut here for the known domains 81 | if(in_array($parsedHost, $this::KNOWN_DOMAINS, true)){ 82 | // we explicitly ignore any existing parameters here 83 | return (string)$parsedURL->withScheme('https')->withQuery('')->withFragment(''); 84 | } 85 | 86 | // back out if it doesn't match 87 | throw new ProviderException(sprintf('given host (%s) does not match provider (%s)', $parsedHost, $api->getHost())); 88 | } 89 | 90 | /** 91 | * Set the datacenter URLs for the given region 92 | * 93 | * @throws \chillerlan\OAuth\Providers\ProviderException 94 | */ 95 | public function setRegion(string $region):static{ 96 | $region = strtolower($region); 97 | 98 | if(!in_array($region, ['cn', 'eu', 'kr', 'tw', 'us'], true)){ 99 | throw new ProviderException('invalid region: '.$region); 100 | } 101 | 102 | $this->region = $region; 103 | $this->apiURL = sprintf('https://%s.api.blizzard.com', $this->region); 104 | $this->battleNetOauth = 'https://oauth.battle.net'; 105 | 106 | if($region === 'cn'){ 107 | $this->apiURL = 'https://gateway.battlenet.com.cn'; 108 | $this->battleNetOauth = 'https://oauth.battlenet.com.cn'; 109 | } 110 | 111 | $this->authorizationURL = $this->battleNetOauth.'/authorize'; 112 | $this->accessTokenURL = $this->battleNetOauth.'/token'; 113 | 114 | return $this; 115 | } 116 | 117 | /** @codeCoverageIgnore */ 118 | public function me():AuthenticatedUser{ 119 | $json = $this->getMeResponseData($this->battleNetOauth.'/oauth/userinfo'); 120 | 121 | $userdata = [ 122 | 'data' => $json, 123 | 'handle' => $json['battletag'], 124 | 'id' => $json['id'], 125 | ]; 126 | 127 | return new AuthenticatedUser($userdata); 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/Storage/OAuthStorageInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Storage; 13 | 14 | use chillerlan\OAuth\Core\AccessToken; 15 | use Psr\Log\LoggerInterface; 16 | 17 | /** 18 | * Specifies the methods required for an OAuth storage adapter 19 | * 20 | * The storage is intended to be invoked per-user, for whom it can 21 | * store tokens, state etc. for any of the implemented providers. 22 | * 23 | * The implementer must ensure that the same storage instance is not used for multiple users. 24 | */ 25 | interface OAuthStorageInterface{ 26 | 27 | /* 28 | * Common 29 | */ 30 | 31 | /** 32 | * Sets a logger. (LoggerAwareInterface is stupid) 33 | */ 34 | public function setLogger(LoggerInterface $logger):static; 35 | 36 | /** 37 | * Prepares an AccessToken for storage (serialize, encrypt etc.) 38 | * and returns a value that is suited for the underlying storage engine 39 | * 40 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 41 | */ 42 | public function toStorage(AccessToken $token):mixed; 43 | 44 | /** 45 | * Retrieves token JOSN from the underlying storage engine and returns an AccessToken 46 | * 47 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 48 | */ 49 | public function fromStorage(mixed $data):AccessToken; 50 | 51 | 52 | /* 53 | * Access token 54 | */ 55 | 56 | /** 57 | * Stores an AccessToken for the given $provider 58 | * 59 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 60 | */ 61 | public function storeAccessToken(AccessToken $token, string $provider):static; 62 | 63 | /** 64 | * Retrieves an AccessToken for the given $provider 65 | * 66 | * This method *must* throw a ItemNotFoundException if a token is not found 67 | * 68 | * @throws \chillerlan\OAuth\Storage\ItemNotFoundException 69 | */ 70 | public function getAccessToken(string $provider):AccessToken; 71 | 72 | /** 73 | * Checks if a token for $provider exists 74 | */ 75 | public function hasAccessToken(string $provider):bool; 76 | 77 | /** 78 | * Deletes the access token for a given $provider (and current user) 79 | * 80 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 81 | */ 82 | public function clearAccessToken(string $provider):static; 83 | 84 | /** 85 | * Deletes all access tokens (for the current user) 86 | * 87 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 88 | */ 89 | public function clearAllAccessTokens():static; 90 | 91 | 92 | /* 93 | * CSRF state 94 | */ 95 | 96 | /** 97 | * Stores a CSRF value for the given $provider 98 | * 99 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 100 | */ 101 | public function storeCSRFState(string $state, string $provider):static; 102 | 103 | /** 104 | * Retrieves a CSRF value for the given $provider 105 | * 106 | * This method *must* throw a ItemNotFoundException if a state is not found 107 | * 108 | * @throws \chillerlan\OAuth\Storage\ItemNotFoundException 109 | */ 110 | public function getCSRFState(string $provider):string; 111 | 112 | /** 113 | * Checks if a CSRF state for the given provider exists 114 | */ 115 | public function hasCSRFState(string $provider):bool; 116 | 117 | /** 118 | * Deletes a CSRF state for the given $provider (and current user) 119 | * 120 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 121 | */ 122 | public function clearCSRFState(string $provider):static; 123 | 124 | /** 125 | * Deletes all stored CSRF states (for the current user) 126 | * 127 | * @throws \chillerlan\OAuth\Storage\OAuthStorageException 128 | */ 129 | public function clearAllCSRFStates():static; 130 | 131 | 132 | /* 133 | * PKCE verifier 134 | */ 135 | 136 | /** 137 | * Stores a PKCE verifier 138 | */ 139 | public function storeCodeVerifier(string $verifier, string $provider):static; 140 | 141 | /** 142 | * Retrieves a PKCE verifier 143 | * 144 | * This method *must* throw a ItemNotFoundException if a verifier is not found 145 | * 146 | * @throws \chillerlan\OAuth\Storage\ItemNotFoundException 147 | */ 148 | public function getCodeVerifier(string $provider):string; 149 | 150 | /** 151 | * Checks whether a PKCE verifier exists 152 | */ 153 | public function hasCodeVerifier(string $provider):bool; 154 | 155 | /** 156 | * Deletes a PKCE verifier 157 | */ 158 | public function clearCodeVerifier(string $provider):static; 159 | 160 | /** 161 | * Deletes all PKCE verifiers for this user 162 | */ 163 | public function clearAllCodeVerifiers():static; 164 | 165 | } 166 | -------------------------------------------------------------------------------- /src/Providers/Spotify.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2018 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, 18 | OAuth2Provider, PKCE, PKCETrait, TokenRefresh, UserInfo, 19 | }; 20 | 21 | /** 22 | * Spotify OAuth2 23 | * 24 | * @link https://developer.spotify.com/documentation/web-api 25 | * @link https://developer.spotify.com/documentation/web-api/tutorials/code-flow 26 | * @link https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow 27 | * @link https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow 28 | */ 29 | class Spotify extends OAuth2Provider implements ClientCredentials, CSRFToken, PKCE, TokenRefresh, UserInfo{ 30 | use ClientCredentialsTrait, PKCETrait; 31 | 32 | public const IDENTIFIER = 'SPOTIFY'; 33 | 34 | /** 35 | * @link https://developer.spotify.com/documentation/web-api/concepts/scopes 36 | */ 37 | // images 38 | public const SCOPE_UGC_IMAGE_UPLOAD = 'ugc-image-upload'; 39 | // spotify connect 40 | public const SCOPE_USER_READ_PLAYBACK_STATE = 'user-read-playback-state'; 41 | public const SCOPE_USER_MODIFY_PLAYBACK_STATE = 'user-modify-playback-state'; 42 | public const SCOPE_USER_READ_CURRENTLY_PLAYING = 'user-read-currently-playing'; 43 | // playback 44 | # public const SCOPE_APP_REMOTE_CONTROL = 'app-remote-control'; // currently only on ios and android 45 | public const SCOPE_STREAMING = 'streaming'; // web playback SDK 46 | // playlists 47 | public const SCOPE_PLAYLIST_READ_PRIVATE = 'playlist-read-private'; 48 | public const SCOPE_PLAYLIST_READ_COLLABORATIVE = 'playlist-read-collaborative'; 49 | public const SCOPE_PLAYLIST_MODIFY_PRIVATE = 'playlist-modify-private'; 50 | public const SCOPE_PLAYLIST_MODIFY_PUBLIC = 'playlist-modify-public'; 51 | // follow 52 | public const SCOPE_USER_FOLLOW_MODIFY = 'user-follow-modify'; 53 | public const SCOPE_USER_FOLLOW_READ = 'user-follow-read'; 54 | // listening history 55 | public const SCOPE_USER_READ_PLAYBACK_POSITION = 'user-read-playback-position'; 56 | public const SCOPE_USER_TOP_READ = 'user-top-read'; 57 | public const SCOPE_USER_READ_RECENTLY_PLAYED = 'user-read-recently-played'; 58 | // library 59 | public const SCOPE_USER_LIBRARY_MODIFY = 'user-library-modify'; 60 | public const SCOPE_USER_LIBRARY_READ = 'user-library-read'; 61 | // users 62 | public const SCOPE_USER_READ_EMAIL = 'user-read-email'; 63 | public const SCOPE_USER_READ_PRIVATE = 'user-read-private'; 64 | // open access 65 | public const SCOPE_USER_SOA_LINK = 'user-soa-link'; 66 | public const SCOPE_USER_SOA_UNLINK = 'user-soa-unlink'; 67 | public const SCOPE_USER_MANAGE_ENTITLEMENTS = 'user-manage-entitlements'; 68 | public const SCOPE_USER_MANAGE_PARTNER = 'user-manage-partner'; 69 | public const SCOPE_USER_CREATE_PARTNER = 'user-create-partner'; 70 | 71 | public const DEFAULT_SCOPES = [ 72 | self::SCOPE_PLAYLIST_READ_COLLABORATIVE, 73 | self::SCOPE_PLAYLIST_MODIFY_PUBLIC, 74 | self::SCOPE_USER_FOLLOW_MODIFY, 75 | self::SCOPE_USER_FOLLOW_READ, 76 | self::SCOPE_USER_LIBRARY_READ, 77 | self::SCOPE_USER_LIBRARY_MODIFY, 78 | self::SCOPE_USER_TOP_READ, 79 | self::SCOPE_USER_READ_EMAIL, 80 | self::SCOPE_STREAMING, 81 | self::SCOPE_USER_READ_PLAYBACK_STATE, 82 | self::SCOPE_USER_MODIFY_PLAYBACK_STATE, 83 | self::SCOPE_USER_READ_CURRENTLY_PLAYING, 84 | self::SCOPE_USER_READ_RECENTLY_PLAYED, 85 | ]; 86 | 87 | protected string $authorizationURL = 'https://accounts.spotify.com/authorize'; 88 | protected string $accessTokenURL = 'https://accounts.spotify.com/api/token'; 89 | protected string $apiURL = 'https://api.spotify.com'; 90 | protected string|null $userRevokeURL = 'https://www.spotify.com/account/apps/'; 91 | protected string|null $apiDocs = 'https://developer.spotify.com/documentation/web-api/'; 92 | protected string|null $applicationURL = 'https://developer.spotify.com/dashboard'; 93 | 94 | /** @codeCoverageIgnore */ 95 | public function me():AuthenticatedUser{ 96 | $json = $this->getMeResponseData('/v1/me'); 97 | 98 | $userdata = [ 99 | 'data' => $json, 100 | 'avatar' => ($json['images'][1]['url'] ?? $json['images'][0]['url'] ?? null), 101 | 'handle' => $json['uri'], 102 | 'displayName' => $json['display_name'], 103 | 'email' => $json['email'], 104 | 'id' => $json['id'], 105 | 'url' => $json['external_urls']['spotify'], 106 | ]; 107 | 108 | return new AuthenticatedUser($userdata); 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/Providers/TikTok.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, PKCE, PKCETrait, TokenRefresh, UserInfo}; 17 | use function array_merge, implode; 18 | 19 | /** 20 | * @see https://developers.tiktok.com/doc/login-kit-web/ 21 | * @see https://developers.tiktok.com/doc/oauth-user-access-token-management/ 22 | */ 23 | class TikTok extends OAuth2Provider implements CSRFToken, PKCE, TokenRefresh, UserInfo{ 24 | use PKCETrait; 25 | 26 | public const IDENTIFIER = 'TIKTOK'; 27 | 28 | public const SCOPES_DELIMITER = ','; 29 | 30 | public const SCOPE_VIDEO_UPLOAD = 'video.upload'; 31 | public const SCOPE_VIDEO_LIST = 'video.list'; 32 | public const SCOPE_VIDEO_PUBLISH = 'video.publish'; 33 | public const SCOPE_USER_INFO_BASIC = 'user.info.basic'; 34 | public const SCOPE_USER_INFO_PROFILE = 'user.info.profile'; 35 | public const SCOPE_USER_INFO_STATS = 'user.info.stats'; 36 | public const SCOPE_PORTABILITY_PPOSTPROFILE_ONGOING = 'portability.postsandprofile.ongoing'; 37 | public const SCOPE_PORTABILITY_PPOSTPROFILE_SINGLE = 'portability.postsandprofile.single'; 38 | public const SCOPE_PORTABILITY_ALL_ONGOING = 'portability.all.ongoing'; 39 | public const SCOPE_PORTABILITY_ALL_SINGLE = 'portability.all.single'; 40 | public const SCOPE_PORTABILITY_DIRECTMESSAGES_ONGOING = 'portability.directmessages.ongoing'; 41 | public const SCOPE_PORTABILITY_DIRECTMESSAGES_SINGLE = 'portability.directmessages.single'; 42 | public const SCOPE_PORTABILITY_ACTIVITY_ONGOING = 'portability.activity.ongoing'; 43 | public const SCOPE_PORTABILITY_ACTIVITY_SINGLE = 'portability.activity.single'; 44 | 45 | public const DEFAULT_SCOPES = [ 46 | self::SCOPE_USER_INFO_BASIC, 47 | self::SCOPE_USER_INFO_PROFILE, 48 | self::SCOPE_USER_INFO_STATS, 49 | self::SCOPE_VIDEO_LIST, 50 | ]; 51 | 52 | protected string $authorizationURL = 'https://www.tiktok.com/v2/auth/authorize/'; 53 | protected string $accessTokenURL = 'https://open.tiktokapis.com/v2/oauth/token/'; 54 | protected string $revokeURL = 'https://open.tiktokapis.com/v2/oauth/revoke/'; 55 | protected string $apiURL = 'https://open.tiktokapis.com'; 56 | protected string|null $apiDocs = 'https://developers.tiktok.com/doc/overview/'; 57 | protected string|null $applicationURL = 'https://developers.tiktok.com/apps/'; 58 | protected string|null $userRevokeURL = 'https://example.com/user/settings/connections'; 59 | 60 | /** 61 | * @inheritDoc 62 | */ 63 | protected function getAuthorizationURLRequestParams(array $params, array $scopes):array{ 64 | 65 | unset($params['client_secret']); 66 | 67 | $params = array_merge($params, [ 68 | 'client_key' => $this->options->key, 69 | 'redirect_uri' => $this->options->callbackURL, 70 | 'response_type' => 'code', 71 | ]); 72 | 73 | if(!empty($scopes)){ 74 | $params['scope'] = implode($this::SCOPES_DELIMITER, $scopes); 75 | } 76 | 77 | $params = $this->setCodeChallenge($params, PKCE::CHALLENGE_METHOD_S256); 78 | 79 | return $this->setState($params); 80 | } 81 | 82 | /** 83 | * @inheritDoc 84 | */ 85 | protected function getAccessTokenRequestBodyParams(string $code):array{ 86 | 87 | $params = [ 88 | 'client_key' => $this->options->key, 89 | 'client_secret' => $this->options->secret, 90 | 'code' => $code, 91 | 'grant_type' => 'authorization_code', 92 | 'redirect_uri' => $this->options->callbackURL, 93 | ]; 94 | 95 | return $this->setCodeVerifier($params); 96 | } 97 | 98 | /** 99 | * @inheritDoc 100 | */ 101 | protected function getRefreshAccessTokenRequestBodyParams(string $refreshToken):array{ 102 | return [ 103 | 'client_key' => $this->options->key, 104 | 'client_secret' => $this->options->secret, 105 | 'grant_type' => 'refresh_token', 106 | 'refresh_token' => $refreshToken, 107 | ]; 108 | } 109 | 110 | public function me():AuthenticatedUser{ 111 | $params = ['fields' => 'open_id,avatar_url,display_name,profile_deep_link,username,is_verified']; 112 | $json = $this->getMeResponseData('/v2/user/info/', $params); 113 | 114 | $userdata = [ 115 | 'avatar' => $json['data']['user']['avatar_url'], 116 | 'data' => $json, 117 | 'displayName' => $json['data']['user']['display_name'], 118 | 'handle' => $json['data']['user']['username'], 119 | 'id' => $json['data']['user']['open_id'], 120 | 'url' => $json['data']['user']['profile_deep_link'], 121 | ]; 122 | 123 | return new AuthenticatedUser($userdata); 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/Providers/Reddit.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2024 smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{ 17 | AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, OAuth2Interface, 18 | OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, TokenRefresh, UserInfo, 19 | }; 20 | use Psr\Http\Message\ResponseInterface; 21 | use function sprintf; 22 | 23 | /** 24 | * Reddit OAuth2 25 | * 26 | * @link https://github.com/reddit-archive/reddit/wiki/OAuth2 27 | * @link https://github.com/reddit-archive/reddit/wiki/API 28 | * @link https://support.reddithelp.com/hc/en-us/articles/16160319875092-Reddit-Data-API-Wiki 29 | * @link https://github.com/reddit-archive/reddit/wiki/OAuth2#manually-revoking-a-token 30 | * @link https://www.reddit.com/dev/api 31 | */ 32 | class Reddit extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenRefresh, TokenInvalidate, UserInfo{ 33 | use ClientCredentialsTrait, TokenInvalidateTrait; 34 | 35 | public const IDENTIFIER = 'REDDIT'; 36 | 37 | public const SCOPE_ACCOUNT = 'account'; 38 | public const SCOPE_CREDDITS = 'creddits'; 39 | public const SCOPE_EDIT = 'edit'; 40 | public const SCOPE_FLAIR = 'flair'; 41 | public const SCOPE_HISTORY = 'history'; 42 | public const SCOPE_IDENTITY = 'identity'; 43 | public const SCOPE_LIVEMANAGE = 'livemanage'; 44 | public const SCOPE_MODCONFIG = 'modconfig'; 45 | public const SCOPE_MODCONTRIBUTORS = 'modcontributors'; 46 | public const SCOPE_MODFLAIR = 'modflair'; 47 | public const SCOPE_MODLOG = 'modlog'; 48 | public const SCOPE_MODMAIL = 'modmail'; 49 | public const SCOPE_MODNOTE = 'modnote'; 50 | public const SCOPE_MODOTHERS = 'modothers'; 51 | public const SCOPE_MODPOSTS = 'modposts'; 52 | public const SCOPE_MODSELF = 'modself'; 53 | public const SCOPE_MODTRAFFIC = 'modtraffic'; 54 | public const SCOPE_MODWIKI = 'modwiki'; 55 | public const SCOPE_MYSUBREDDITS = 'mysubreddits'; 56 | public const SCOPE_PRIVATEMESSAGES = 'privatemessages'; 57 | public const SCOPE_READ = 'read'; 58 | public const SCOPE_REPORT = 'report'; 59 | public const SCOPE_SAVE = 'save'; 60 | public const SCOPE_STRUCTUREDSTYLES = 'structuredstyles'; 61 | public const SCOPE_SUBMIT = 'submit'; 62 | public const SCOPE_SUBSCRIBE = 'subscribe'; 63 | public const SCOPE_VOTE = 'vote'; 64 | public const SCOPE_WIKIEDIT = 'wikiedit'; 65 | public const SCOPE_WIKIREAD = 'wikiread'; 66 | 67 | public const DEFAULT_SCOPES = [ 68 | self::SCOPE_ACCOUNT, 69 | self::SCOPE_IDENTITY, 70 | self::SCOPE_READ, 71 | ]; 72 | 73 | public const USER_AGENT = OAuth2Interface::USER_AGENT.' (by /u/chillerlan)'; 74 | 75 | public const HEADERS_AUTH = [ 76 | 'User-Agent' => self::USER_AGENT, 77 | ]; 78 | 79 | public const HEADERS_API = [ 80 | 'User-Agent' => self::USER_AGENT, 81 | ]; 82 | 83 | public const USES_BASIC_AUTH_IN_ACCESS_TOKEN_REQUEST = true; 84 | 85 | protected string $authorizationURL = 'https://www.reddit.com/api/v1/authorize'; 86 | protected string $accessTokenURL = 'https://www.reddit.com/api/v1/access_token'; 87 | protected string $apiURL = 'https://oauth.reddit.com/api'; 88 | protected string $revokeURL = 'https://www.reddit.com/api/v1/revoke_token'; 89 | protected string|null $apiDocs = 'https://www.reddit.com/dev/api'; 90 | protected string|null $applicationURL = 'https://www.reddit.com/prefs/apps/'; 91 | protected string|null $userRevokeURL = 'https://www.reddit.com/settings/privacy'; 92 | 93 | /** 94 | * @param array $body 95 | */ 96 | protected function sendTokenInvalidateRequest(string $url, array $body):ResponseInterface{ // phpcs:ignore 97 | 98 | $request = $this->requestFactory 99 | ->createRequest('POST', $url) 100 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 101 | ; 102 | 103 | $request = $this->addBasicAuthHeader($request); 104 | $request = $this->setRequestBody($body, $request); 105 | 106 | return $this->http->sendRequest($request); 107 | } 108 | 109 | /** @codeCoverageIgnore */ 110 | public function me():AuthenticatedUser{ 111 | $json = $this->getMeResponseData('/v1/me'); 112 | 113 | $userdata = [ 114 | 'data' => $json, 115 | 'avatar' => $json['subreddit']['icon_img'], 116 | 'handle' => $json['name'], 117 | 'displayName' => $json['subreddit']['title'], 118 | 'id' => $json['id'], 119 | 'url' => sprintf('https://www.reddit.com%s', $json['subreddit']['url']), 120 | ]; 121 | 122 | return new AuthenticatedUser($userdata); 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Providers/Slack.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, InvalidAccessTokenException, OAuth2Provider, UserInfo}; 17 | use function sprintf; 18 | 19 | /** 20 | * Slack v2 OAuth2 21 | * 22 | * @link https://api.slack.com/authentication/oauth-v2 23 | * @link https://api.slack.com/authentication/sign-in-with-slack 24 | * @link https://api.slack.com/authentication/token-types 25 | */ 26 | class Slack extends OAuth2Provider implements CSRFToken, UserInfo{ 27 | 28 | public const IDENTIFIER = 'SLACK'; 29 | 30 | // bot token 31 | public const SCOPE_BOT = 'bot'; 32 | 33 | // user token 34 | public const SCOPE_ADMIN = 'admin'; 35 | public const SCOPE_CHAT_WRITE_BOT = 'chat:write:bot'; 36 | public const SCOPE_CLIENT = 'client'; 37 | public const SCOPE_DND_READ = 'dnd:read'; 38 | public const SCOPE_DND_WRITE = 'dnd:write'; 39 | public const SCOPE_FILES_READ = 'files:read'; 40 | public const SCOPE_FILES_WRITE_USER = 'files:write:user'; 41 | public const SCOPE_IDENTIFY = 'identify'; 42 | public const SCOPE_IDENTITY_AVATAR = 'identity.avatar'; 43 | public const SCOPE_IDENTITY_BASIC = 'identity.basic'; 44 | public const SCOPE_IDENTITY_EMAIL = 'identity.email'; 45 | public const SCOPE_IDENTITY_TEAM = 'identity.team'; 46 | public const SCOPE_INCOMING_WEBHOOK = 'incoming-webhook'; 47 | public const SCOPE_POST = 'post'; 48 | public const SCOPE_READ = 'read'; 49 | public const SCOPE_REMINDERS_READ = 'reminders:read'; 50 | public const SCOPE_REMINDERS_WRITE = 'reminders:write'; 51 | public const SCOPE_SEARCH_READ = 'search:read'; 52 | public const SCOPE_STARS_READ = 'stars:read'; 53 | public const SCOPE_STARS_WRITE = 'stars:write'; 54 | 55 | // user & workspace tokens 56 | public const SCOPE_CHANNELS_HISTORY = 'channels:history'; 57 | public const SCOPE_CHANNELS_READ = 'channels:read'; 58 | public const SCOPE_CHANNELS_WRITE = 'channels:write'; 59 | public const SCOPE_CHAT_WRITE_USER = 'chat:write:user'; 60 | public const SCOPE_COMMANDS = 'commands'; 61 | public const SCOPE_EMOJI_READ = 'emoji:read'; 62 | public const SCOPE_GROUPS_HISTORY = 'groups:history'; 63 | public const SCOPE_GROUPS_READ = 'groups:read'; 64 | public const SCOPE_GROUPS_WRITE = 'groups:write'; 65 | public const SCOPE_IM_HISTORY = 'im:history'; 66 | public const SCOPE_IM_READ = 'im:read'; 67 | public const SCOPE_IM_WRITE = 'im:write'; 68 | public const SCOPE_LINKS_READ = 'links:read'; 69 | public const SCOPE_LINKS_WRITE = 'links:write'; 70 | public const SCOPE_MPIM_HISTORY = 'mpim:history'; 71 | public const SCOPE_MPIM_READ = 'mpim:read'; 72 | public const SCOPE_MPIM_WRITE = 'mpim:write'; 73 | public const SCOPE_PINS_READ = 'pins:read'; 74 | public const SCOPE_PINS_WRITE = 'pins:write'; 75 | public const SCOPE_REACTIONS_READ = 'reactions:read'; 76 | public const SCOPE_REACTIONS_WRITE = 'reactions:write'; 77 | public const SCOPE_TEAM_READ = 'team:read'; 78 | public const SCOPE_USERGROUPS_READ = 'usergroups:read'; 79 | public const SCOPE_USERGROUPS_WRITE = 'usergroups:write'; 80 | public const SCOPE_USERS_PROFILE_READ = 'users.profile:read'; 81 | public const SCOPE_USERS_PROFILE_WRITE = 'users.profile:write'; 82 | public const SCOPE_USERS_READ = 'users:read'; 83 | public const SCOPE_USERS_READ_EMAIL = 'users:read.email'; 84 | public const SCOPE_USERS_WRITE = 'users:write'; 85 | 86 | public const DEFAULT_SCOPES = [ 87 | self::SCOPE_IDENTITY_AVATAR, 88 | self::SCOPE_IDENTITY_BASIC, 89 | self::SCOPE_IDENTITY_EMAIL, 90 | self::SCOPE_IDENTITY_TEAM, 91 | ]; 92 | 93 | protected string $authorizationURL = 'https://slack.com/oauth/v2/authorize'; 94 | protected string $accessTokenURL = 'https://slack.com/api/oauth.v2.access'; 95 | protected string $apiURL = 'https://slack.com/api'; 96 | protected string|null $userRevokeURL = 'https://slack.com/apps/manage'; 97 | protected string|null $apiDocs = 'https://api.slack.com'; 98 | protected string|null $applicationURL = 'https://api.slack.com/apps'; 99 | 100 | /** 101 | * HTTP/200 OK on errors? you're fired. 102 | * 103 | * @inheritDoc 104 | * @codeCoverageIgnore 105 | */ 106 | public function me():AuthenticatedUser{ 107 | $json = $this->getMeResponseData('/users.identity'); 108 | 109 | if(!empty($json['ok'])){ 110 | 111 | $userdata = [ 112 | 'data' => $json, 113 | 'avatar' => $json['user']['image_512'], 114 | 'displayName' => $json['user']['name'], 115 | 'email' => $json['user']['email'], 116 | 'id' => $json['user']['id'], 117 | ]; 118 | 119 | return new AuthenticatedUser($userdata); 120 | } 121 | 122 | if(isset($json['error'])){ 123 | 124 | if($json['error'] === 'invalid_auth'){ 125 | throw new InvalidAccessTokenException; 126 | } 127 | 128 | throw new ProviderException($json['error']); 129 | } 130 | 131 | throw new ProviderException(sprintf('user info error')); 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /src/Storage/SessionStorage.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Storage; 13 | 14 | use chillerlan\OAuth\OAuthOptions; 15 | use chillerlan\OAuth\Core\AccessToken; 16 | use chillerlan\Settings\SettingsContainerInterface; 17 | use Psr\Log\{LoggerInterface, NullLogger}; 18 | use function session_start, session_status, session_write_close; 19 | use const PHP_SESSION_ACTIVE, PHP_SESSION_DISABLED; 20 | 21 | /** 22 | * Implements a session storage adapter. 23 | * 24 | * Note: the session storage is only half persistent, as tokens are stored for the duration of the session. 25 | */ 26 | class SessionStorage extends OAuthStorageAbstract{ 27 | 28 | /** 29 | * the key name for the storage array in $_SESSION 30 | */ 31 | protected string $storageVar; 32 | 33 | public function __construct( 34 | OAuthOptions|SettingsContainerInterface $options = new OAuthOptions, 35 | LoggerInterface $logger = new NullLogger, 36 | ){ 37 | parent::__construct($options, $logger); 38 | 39 | $this->storageVar = $this->options->sessionStorageVar; 40 | 41 | // Determine if the session has started. 42 | $status = session_status(); 43 | 44 | if($this->options->sessionStart && $status !== PHP_SESSION_DISABLED && $status !== PHP_SESSION_ACTIVE){ 45 | session_start(); 46 | } 47 | 48 | if(!isset($_SESSION[$this->storageVar])){ 49 | $_SESSION[$this->storageVar] = [ 50 | $this::KEY_TOKEN => [], 51 | $this::KEY_STATE => [], 52 | $this::KEY_VERIFIER => [], 53 | ]; 54 | } 55 | 56 | } 57 | 58 | /** 59 | * SessionStorage destructor. 60 | * 61 | * @codeCoverageIgnore 62 | */ 63 | public function __destruct(){ 64 | if($this->options->sessionStop && session_status() === PHP_SESSION_ACTIVE){ 65 | session_write_close(); 66 | } 67 | } 68 | 69 | 70 | /* 71 | * Access token 72 | */ 73 | 74 | public function storeAccessToken(AccessToken $token, string $provider):static{ 75 | $_SESSION[$this->storageVar][$this::KEY_TOKEN][$this->getProviderName($provider)] = $this->toStorage($token); 76 | 77 | return $this; 78 | } 79 | 80 | public function getAccessToken(string $provider):AccessToken{ 81 | 82 | if($this->hasAccessToken($provider)){ 83 | return $this->fromStorage($_SESSION[$this->storageVar][$this::KEY_TOKEN][$this->getProviderName($provider)]); 84 | } 85 | 86 | throw new ItemNotFoundException($this::KEY_TOKEN); 87 | } 88 | 89 | public function hasAccessToken(string $provider):bool{ 90 | return !empty($_SESSION[$this->storageVar][$this::KEY_TOKEN][$this->getProviderName($provider)]); 91 | } 92 | 93 | public function clearAccessToken(string $provider):static{ 94 | unset($_SESSION[$this->storageVar][$this::KEY_TOKEN][$this->getProviderName($provider)]); 95 | 96 | return $this; 97 | } 98 | 99 | public function clearAllAccessTokens():static{ 100 | $_SESSION[$this->storageVar][$this::KEY_TOKEN] = []; 101 | 102 | return $this; 103 | } 104 | 105 | 106 | /* 107 | * CSRF state 108 | */ 109 | 110 | public function storeCSRFState(string $state, string $provider):static{ 111 | 112 | if($this->options->useStorageEncryption === true){ 113 | $state = $this->encrypt($state); 114 | } 115 | 116 | $_SESSION[$this->storageVar][$this::KEY_STATE][$this->getProviderName($provider)] = $state; 117 | 118 | return $this; 119 | } 120 | 121 | public function getCSRFState(string $provider):string{ 122 | 123 | if(!$this->hasCSRFState($provider)){ 124 | throw new ItemNotFoundException($this::KEY_STATE); 125 | } 126 | 127 | $state = $_SESSION[$this->storageVar][$this::KEY_STATE][$this->getProviderName($provider)]; 128 | 129 | if($this->options->useStorageEncryption === true){ 130 | return $this->decrypt($state); 131 | } 132 | 133 | return $state; 134 | } 135 | 136 | public function hasCSRFState(string $provider):bool{ 137 | return !empty($_SESSION[$this->storageVar][$this::KEY_STATE][$this->getProviderName($provider)]); 138 | } 139 | 140 | public function clearCSRFState(string $provider):static{ 141 | unset($_SESSION[$this->storageVar][$this::KEY_STATE][$this->getProviderName($provider)]); 142 | 143 | return $this; 144 | } 145 | 146 | public function clearAllCSRFStates():static{ 147 | $_SESSION[$this->storageVar][$this::KEY_STATE] = []; 148 | 149 | return $this; 150 | } 151 | 152 | 153 | /* 154 | * PKCE verifier 155 | */ 156 | 157 | public function storeCodeVerifier(string $verifier, string $provider):static{ 158 | 159 | if($this->options->useStorageEncryption === true){ 160 | $verifier = $this->encrypt($verifier); 161 | } 162 | 163 | $_SESSION[$this->storageVar][$this::KEY_VERIFIER][$this->getProviderName($provider)] = $verifier; 164 | 165 | return $this; 166 | } 167 | 168 | public function getCodeVerifier(string $provider):string{ 169 | 170 | if(!$this->hasCodeVerifier($provider)){ 171 | throw new ItemNotFoundException($this::KEY_VERIFIER); 172 | } 173 | 174 | $verifier = $_SESSION[$this->storageVar][$this::KEY_VERIFIER][$this->getProviderName($provider)]; 175 | 176 | if($this->options->useStorageEncryption === true){ 177 | return $this->decrypt($verifier); 178 | } 179 | 180 | return $verifier; 181 | } 182 | 183 | public function hasCodeVerifier(string $provider):bool{ 184 | return !empty($_SESSION[$this->storageVar][$this::KEY_VERIFIER][$this->getProviderName($provider)]); 185 | } 186 | 187 | public function clearCodeVerifier(string $provider):static{ 188 | unset($_SESSION[$this->storageVar][$this::KEY_VERIFIER][$this->getProviderName($provider)]); 189 | 190 | return $this; 191 | } 192 | 193 | public function clearAllCodeVerifiers():static{ 194 | $_SESSION[$this->storageVar][$this::KEY_VERIFIER] = []; 195 | 196 | return $this; 197 | } 198 | 199 | } 200 | -------------------------------------------------------------------------------- /src/Core/OAuthInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Core; 13 | 14 | use chillerlan\OAuth\Storage\OAuthStorageInterface; 15 | use Psr\Http\Client\ClientInterface; 16 | use Psr\Log\LoggerInterface; 17 | use Psr\Http\Message\{ 18 | RequestFactoryInterface, RequestInterface, ResponseInterface, 19 | StreamFactoryInterface, StreamInterface, UriFactoryInterface, UriInterface 20 | }; 21 | 22 | /** 23 | * Specifies the basic methods for an OAuth provider. 24 | */ 25 | interface OAuthInterface extends ClientInterface{ 26 | 27 | /** 28 | * A common user agent string that can be used in requests 29 | * 30 | * @var string 31 | */ 32 | public const USER_AGENT = 'chillerlanPhpOAuth/1.0.0 +https://github.com/chillerlan/php-oauth'; 33 | 34 | /** 35 | * An identifier for the provider, usually the class name in ALLCAPS (required) 36 | * 37 | * @var string 38 | */ 39 | public const IDENTIFIER = ''; 40 | 41 | /** 42 | * additional headers to use during authentication 43 | * 44 | * Note: must not contain: Accept-Encoding, Authorization, Content-Length, Content-Type 45 | * 46 | * @var array 47 | */ 48 | public const HEADERS_AUTH = []; 49 | 50 | /** 51 | * additional headers to use during API access 52 | * 53 | * Note: must not contain: Authorization 54 | * 55 | * @var array 56 | */ 57 | public const HEADERS_API = []; 58 | 59 | /** 60 | * Default scopes to apply if none were provided via the $scopes parameter 61 | * 62 | * @var string[] 63 | */ 64 | public const DEFAULT_SCOPES = []; 65 | 66 | /** 67 | * The delimiter string for scopes 68 | * 69 | * @var string 70 | */ 71 | public const SCOPES_DELIMITER = ' '; 72 | 73 | /** 74 | * Returns the name of the provider/class 75 | */ 76 | public function getName():string; 77 | 78 | /** 79 | * Returns the link to the provider's API docs, or null if the value is not set 80 | */ 81 | public function getApiDocURL():string|null; 82 | 83 | /** 84 | * Returns the link to the provider's credential registration/application page, or null if the value is not set 85 | */ 86 | public function getApplicationURL():string|null; 87 | 88 | /** 89 | * Returns the link to the page where a user can revoke access tokens, or null if the value is not set 90 | */ 91 | public function getUserRevokeURL():string|null; 92 | 93 | /** 94 | * Prepares the URL with optional $params which redirects to the provider's authorization prompt 95 | * and returns a PSR-7 UriInterface with all necessary parameters set. 96 | * 97 | * If the provider supports RFC-9126 "Pushed Authorization Requests (PAR)", a request to the PAR endpoint 98 | * shall be made within this method in order to send authorization data and obtain a temporary request URI. 99 | * 100 | * @link https://datatracker.ietf.org/doc/html/rfc5849#section-2.2 101 | * @link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 102 | * @link https://datatracker.ietf.org/doc/html/rfc9126 103 | * @see \chillerlan\OAuth\Core\PAR 104 | * 105 | * @param array|null $params 106 | * @param string[]|null $scopes 107 | */ 108 | public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface; 109 | 110 | /** 111 | * Authorizes the $request with the credentials from the given $token 112 | * and returns a PSR-7 RequestInterface with all necessary headers and/or parameters set 113 | * 114 | * @see \chillerlan\OAuth\Core\OAuthProvider::sendRequest() 115 | */ 116 | public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface; 117 | 118 | /** 119 | * Prepares an API request to $path with the given parameters, gets authorization, fires the request 120 | * and returns a PSR-7 ResponseInterface with the corresponding API response 121 | * 122 | * @param array|null $params 123 | * @param StreamInterface|array|string|null $body 124 | * @param array|null $headers 125 | */ 126 | public function request( 127 | string $path, 128 | array|null $params = null, 129 | string|null $method = null, 130 | StreamInterface|array|string|null $body = null, 131 | array|null $headers = null, 132 | string|null $protocolVersion = null, 133 | ):ResponseInterface; 134 | 135 | /** 136 | * Sets an optional OAuthStorageInterface 137 | */ 138 | public function setStorage(OAuthStorageInterface $storage):static; 139 | 140 | /** 141 | * Returns the current OAuthStorageInterface 142 | */ 143 | public function getStorage():OAuthStorageInterface; 144 | 145 | /** 146 | * Sets an access token in the current OAuthStorageInterface (shorthand/convenience) 147 | */ 148 | public function storeAccessToken(AccessToken $token):static; 149 | 150 | /** 151 | * Gets an access token from the current OAuthStorageInterface (shorthand/convenience) 152 | */ 153 | public function getAccessTokenFromStorage():AccessToken; 154 | 155 | /** 156 | * Sets an optional PSR-3 LoggerInterface 157 | */ 158 | public function setLogger(LoggerInterface $logger):static; 159 | 160 | /** 161 | * Sets an optional PSR-17 RequestFactoryInterface 162 | */ 163 | public function setRequestFactory(RequestFactoryInterface $requestFactory):static; 164 | 165 | /** 166 | * Sets an optional PSR-17 StreamFactoryInterface 167 | */ 168 | public function setStreamFactory(StreamFactoryInterface $streamFactory):static; 169 | 170 | /** 171 | * Sets an optional PSR-17 UriFactoryInterface 172 | */ 173 | public function setUriFactory(UriFactoryInterface $uriFactory):static; 174 | 175 | } 176 | -------------------------------------------------------------------------------- /src/Providers/Steam.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2021 smiley 8 | * @license MIT 9 | */ 10 | declare(strict_types=1); 11 | 12 | namespace chillerlan\OAuth\Providers; 13 | 14 | use chillerlan\HTTP\Utils\{MessageUtil, QueryUtil, UriUtil}; 15 | use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, OAuthProvider, UserInfo}; 16 | use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface}; 17 | use function explode, intval, str_replace; 18 | 19 | /** 20 | * Steam OpenID 21 | * 22 | * @link https://steamcommunity.com/dev 23 | * @link https://partner.steamgames.com/doc/webapi_overview 24 | * @link https://partner.steamgames.com/doc/features/auth 25 | * @link https://steamwebapi.azurewebsites.net/ 26 | */ 27 | class Steam extends OAuthProvider implements UserInfo{ 28 | 29 | public const IDENTIFIER = 'STEAM'; 30 | 31 | protected string $authorizationURL = 'https://steamcommunity.com/openid/login'; 32 | protected string $accessTokenURL = 'https://steamcommunity.com/openid/login'; 33 | protected string $apiURL = 'https://api.steampowered.com'; 34 | protected string|null $applicationURL = 'https://steamcommunity.com/dev/apikey'; 35 | protected string|null $apiDocs = 'https://developer.valvesoftware.com/wiki/Steam_Web_API'; 36 | 37 | /** 38 | * we ignore user supplied params here 39 | * 40 | * @inheritDoc 41 | * 42 | * @param array|null $params 43 | * @param string[]|null $scopes 44 | */ 45 | public function getAuthorizationURL(array|null $params = null, array|null $scopes = null):UriInterface{ 46 | 47 | $params = [ 48 | 'openid.ns' => 'http://specs.openid.net/auth/2.0', 49 | 'openid.mode' => 'checkid_setup', 50 | 'openid.return_to' => $this->options->callbackURL, 51 | 'openid.realm' => $this->options->key, 52 | 'openid.identity' => 'http://specs.openid.net/auth/2.0/identifier_select', 53 | 'openid.claimed_id' => 'http://specs.openid.net/auth/2.0/identifier_select', 54 | ]; 55 | 56 | return $this->uriFactory->createUri(QueryUtil::merge($this->authorizationURL, $params)); 57 | } 58 | 59 | /** 60 | * Obtains an "authentication token" (the steamID64) 61 | * 62 | * @param array $urlQuery 63 | */ 64 | public function getAccessToken(array $urlQuery):AccessToken{ 65 | $body = $this->getAccessTokenRequestBodyParams($urlQuery); 66 | $response = $this->sendAccessTokenRequest($this->accessTokenURL, $body); 67 | $token = $this->parseTokenResponse($response, $urlQuery['openid_claimed_id']); 68 | 69 | $this->storage->storeAccessToken($token, $this->name); 70 | 71 | return $token; 72 | } 73 | 74 | /** 75 | * prepares the request body parameters for the access token request 76 | * 77 | * @param array $received 78 | * @return array 79 | */ 80 | protected function getAccessTokenRequestBodyParams(array $received):array{ 81 | 82 | $body = [ 83 | 'openid.mode' => 'check_authentication', 84 | 'openid.ns' => 'http://specs.openid.net/auth/2.0', 85 | 'openid.sig' => $received['openid_sig'], 86 | ]; 87 | 88 | foreach(explode(',', $received['openid_signed']) as $item){ 89 | $body['openid.'.$item] = $received['openid_'.$item]; 90 | } 91 | 92 | return $body; 93 | } 94 | 95 | /** 96 | * sends a request to the access token endpoint $url with the given $params as URL query 97 | * 98 | * @param array $body 99 | */ 100 | protected function sendAccessTokenRequest(string $url, array $body):ResponseInterface{ 101 | 102 | $request = $this->requestFactory 103 | ->createRequest('POST', $url) 104 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 105 | ->withBody($this->streamFactory->createStream(QueryUtil::build($body))); 106 | 107 | return $this->http->sendRequest($request); 108 | } 109 | 110 | /** 111 | * @throws \chillerlan\OAuth\Providers\ProviderException 112 | */ 113 | protected function parseTokenResponse(ResponseInterface $response, string $claimed_id):AccessToken{ 114 | $data = explode("\x0a", MessageUtil::getContents($response)); 115 | 116 | if(!isset($data[1]) || !str_starts_with($data[1], 'is_valid')){ 117 | throw new ProviderException('unable to parse token response'); 118 | } 119 | 120 | if($data[1] !== 'is_valid:true'){ 121 | throw new ProviderException('invalid id'); 122 | } 123 | 124 | $token = $this->createAccessToken(); 125 | $id = str_replace('https://steamcommunity.com/openid/id/', '', $claimed_id); 126 | 127 | // as this method is intended for one-time authentication only we'll not receive a token. 128 | // instead we're gonna save the verified steam user id as token as it is required 129 | // for several "authenticated" endpoints. 130 | $token->accessToken = $id; 131 | $token->expires = AccessToken::NEVER_EXPIRES; 132 | $token->extraParams = [ 133 | 'claimed_id' => $claimed_id, 134 | 'id_int' => intval($id), 135 | ]; 136 | 137 | return $token; 138 | } 139 | 140 | public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface{ 141 | $uri = UriUtil::withQueryValue($request->getUri(), 'key', $this->options->secret); 142 | 143 | return $request->withUri($uri); 144 | } 145 | 146 | /** @codeCoverageIgnore */ 147 | public function me():AuthenticatedUser{ 148 | $token = $this->storage->getAccessToken($this->name); 149 | $json = $this->getMeResponseData('/ISteamUser/GetPlayerSummaries/v0002/', ['steamids' => $token->accessToken]); 150 | 151 | if(!isset($json['response']['players'][0])){ 152 | throw new ProviderException('invalid response'); 153 | } 154 | 155 | $data = $json['response']['players'][0]; 156 | 157 | $userdata = [ 158 | 'data' => $data, 159 | 'avatar' => $data['avatarfull'], 160 | 'displayName' => $data['personaname'], 161 | 'id' => $data['steamid'], 162 | 'url' => $data['profileurl'], 163 | ]; 164 | 165 | return new AuthenticatedUser($userdata); 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/Providers/Twitch.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright 2017 Smiley 8 | * @license MIT 9 | * 10 | * @noinspection PhpUnused 11 | */ 12 | declare(strict_types=1); 13 | 14 | namespace chillerlan\OAuth\Providers; 15 | 16 | use chillerlan\HTTP\Utils\QueryUtil; 17 | use chillerlan\OAuth\Core\{ 18 | AccessToken, AuthenticatedUser, ClientCredentials, ClientCredentialsTrait, CSRFToken, InvalidAccessTokenException, 19 | OAuth2Provider, TokenInvalidate, TokenInvalidateTrait, TokenRefresh, UserInfo, 20 | }; 21 | use Psr\Http\Message\{RequestInterface, ResponseInterface}; 22 | use function implode, sprintf; 23 | use const PHP_QUERY_RFC1738; 24 | 25 | /** 26 | * Twitch OAuth2 27 | * 28 | * @link https://dev.twitch.tv/docs/api/reference/ 29 | * @link https://dev.twitch.tv/docs/authentication/ 30 | * @link https://dev.twitch.tv/docs/authentication#oauth-client-credentials-flow-app-access-tokens 31 | */ 32 | class Twitch extends OAuth2Provider implements ClientCredentials, CSRFToken, TokenInvalidate, TokenRefresh, UserInfo{ 33 | use ClientCredentialsTrait, TokenInvalidateTrait; 34 | 35 | public const IDENTIFIER = 'TWITCH'; 36 | 37 | public const SCOPE_ANALYTICS_READ_EXTENSIONS = 'analytics:read:extensions'; 38 | public const SCOPE_ANALYTICS_READ_GAMES = 'analytics:read:games'; 39 | public const SCOPE_BITS_READ = 'bits:read'; 40 | public const SCOPE_CHANNEL_EDIT_COMMERCIAL = 'channel:edit:commercial'; 41 | public const SCOPE_CHANNEL_MANAGE_BROADCAST = 'channel:manage:broadcast'; 42 | public const SCOPE_CHANNEL_MANAGE_EXTENSIONS = 'channel:manage:extensions'; 43 | public const SCOPE_CHANNEL_MANAGE_REDEMPTIONS = 'channel:manage:redemptions'; 44 | public const SCOPE_CHANNEL_MANAGE_VIDEOS = 'channel:manage:videos'; 45 | public const SCOPE_CHANNEL_READ_EDITORS = 'channel:read:editors'; 46 | public const SCOPE_CHANNEL_READ_HYPE_TRAIN = 'channel:read:hype_train'; 47 | public const SCOPE_CHANNEL_READ_REDEMPTIONS = 'channel:read:redemptions'; 48 | public const SCOPE_CHANNEL_READ_STREAM_KEY = 'channel:read:stream_key'; 49 | public const SCOPE_CHANNEL_READ_SUBSCRIPTIONS = 'channel:read:subscriptions'; 50 | public const SCOPE_CLIPS_EDIT = 'clips:edit'; 51 | public const SCOPE_MODERATION_READ = 'moderation:read'; 52 | public const SCOPE_USER_EDIT = 'user:edit'; 53 | public const SCOPE_USER_EDIT_FOLLOWS = 'user:edit:follows'; 54 | public const SCOPE_USER_READ_BLOCKED_USERS = 'user:read:blocked_users'; 55 | public const SCOPE_USER_MANAGE_BLOCKED_USERS = 'user:manage:blocked_users'; 56 | public const SCOPE_USER_READ_BROADCAST = 'user:read:broadcast'; 57 | public const SCOPE_USER_READ_EMAIL = 'user:read:email'; 58 | public const SCOPE_USER_READ_SUBSCRIPTIONS = 'user:read:subscriptions'; 59 | 60 | public const DEFAULT_SCOPES = [ 61 | self::SCOPE_USER_READ_EMAIL, 62 | ]; 63 | 64 | public const HEADERS_AUTH = [ 65 | 'Accept' => 'application/vnd.twitchtv.v5+json', 66 | ]; 67 | 68 | public const HEADERS_API = [ 69 | 'Accept' => 'application/vnd.twitchtv.v5+json', 70 | ]; 71 | 72 | protected string $authorizationURL = 'https://id.twitch.tv/oauth2/authorize'; 73 | protected string $accessTokenURL = 'https://id.twitch.tv/oauth2/token'; 74 | protected string $revokeURL = 'https://id.twitch.tv/oauth2/revoke'; 75 | protected string $apiURL = 'https://api.twitch.tv'; 76 | protected string|null $userRevokeURL = 'https://www.twitch.tv/settings/connections'; 77 | protected string|null $apiDocs = 'https://dev.twitch.tv/docs/api/reference/'; 78 | protected string|null $applicationURL = 'https://dev.twitch.tv/console/apps/create'; 79 | 80 | /** 81 | * @param string[]|null $scopes 82 | * @return array 83 | */ 84 | protected function getClientCredentialsTokenRequestBodyParams(array|null $scopes):array{ 85 | 86 | $params = [ 87 | 'client_id' => $this->options->key, 88 | 'client_secret' => $this->options->secret, 89 | 'grant_type' => 'client_credentials', 90 | ]; 91 | 92 | if($scopes !== null){ 93 | $params['scope'] = implode($this::SCOPES_DELIMITER, $scopes); 94 | } 95 | 96 | return $params; 97 | } 98 | 99 | /** 100 | * @param array $body 101 | */ 102 | protected function sendClientCredentialsTokenRequest(string $url, array $body):ResponseInterface{ 103 | 104 | $request = $this->requestFactory 105 | ->createRequest('POST', $url) 106 | ->withHeader('Accept-Encoding', 'identity') 107 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded') 108 | ->withBody($this->streamFactory->createStream(QueryUtil::build($body, PHP_QUERY_RFC1738))) 109 | ; 110 | 111 | foreach($this::HEADERS_AUTH as $header => $value){ 112 | $request = $request->withAddedHeader($header, $value); 113 | } 114 | 115 | return $this->http->sendRequest($request); 116 | } 117 | 118 | /** 119 | * @return array 120 | */ 121 | protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{ 122 | return [ 123 | 'client_id' => $this->options->key, 124 | 'token' => $token->accessToken, 125 | 'token_type_hint' => $type, 126 | ]; 127 | } 128 | 129 | public function getRequestAuthorization(RequestInterface $request, AccessToken|null $token = null):RequestInterface{ 130 | $token ??= $this->storage->getAccessToken($this->name); 131 | 132 | if($token->isExpired()){ 133 | 134 | if($this->options->tokenAutoRefresh !== true){ 135 | throw new InvalidAccessTokenException; 136 | } 137 | 138 | $token = $this->refreshAccessToken($token); 139 | } 140 | 141 | return $request 142 | ->withHeader('Authorization', $this::AUTH_PREFIX_HEADER.' '.$token->accessToken) 143 | ->withHeader('Client-ID', $this->options->key); 144 | } 145 | 146 | /** @codeCoverageIgnore */ 147 | public function me():AuthenticatedUser{ 148 | $json = $this->getMeResponseData('/helix/users'); 149 | $user = $json['data'][0]; 150 | 151 | $userdata = [ 152 | 'data' => $user, 153 | 'avatar' => $user['profile_image_url'], 154 | 'handle' => $user['login'], 155 | 'displayName' => $user['display_name'], 156 | 'email' => $user['email'], 157 | 'id' => $user['id'], 158 | 'url' => sprintf('https://www.twitch.tv/%s', $user['login']), 159 | ]; 160 | 161 | return new AuthenticatedUser($userdata); 162 | } 163 | 164 | } 165 | --------------------------------------------------------------------------------