├── resources ├── assets │ ├── authchoice.png │ ├── authchoice-h.png │ ├── authchoice.css │ ├── authchoice-h.css │ └── authchoice.js └── views │ └── redirect.php ├── CHANGELOG.md ├── rector.php ├── src ├── Exception │ ├── NotSupportedException.php │ ├── InvalidConfigException.php │ ├── ClientException.php │ └── InvalidResponseException.php ├── Widget │ ├── AuthChoiceItem.php │ └── AuthChoice.php ├── Signature │ ├── PlainText.php │ ├── HmacSha.php │ ├── Signature.php │ └── RsaSha.php ├── StateStorage │ ├── DummyStateStorage.php │ ├── StateStorageInterface.php │ └── SessionStateStorage.php ├── Asset │ ├── AuthChoiceStyleAsset.php │ └── AuthChoiceAsset.php ├── Factory │ └── CollectionFactory.php ├── AuthClientInterface.php ├── OAuth2Interface.php ├── Client │ ├── TikTok.php │ ├── GitHub.php │ ├── Yandex.php │ ├── LinkedIn.php │ ├── Google.php │ ├── X.php │ ├── MicrosoftOnline.php │ ├── VKontakte.php │ ├── Facebook.php │ └── OpenIdConnect.php ├── OAuthInterface.php ├── Collection.php ├── RequestUtil.php ├── OAuthToken.php ├── AuthClient.php ├── OAuth.php ├── AuthAction.php └── OAuth2.php ├── .phpunit-watcher.yml ├── config ├── params.php └── di.php ├── infection.json.dist ├── psalm.xml ├── LICENSE.md ├── .styleci.yml ├── Makefile ├── README.md └── composer.json /resources/assets/authchoice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiisoft/yii-auth-client/HEAD/resources/assets/authchoice.png -------------------------------------------------------------------------------- /resources/assets/authchoice-h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiisoft/yii-auth-client/HEAD/resources/assets/authchoice-h.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Framework External Authentication Extension Change Log 2 | 3 | ## 1.1.0 under development 4 | 5 | - Initial release. 6 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__ . '/src', 10 | ]) 11 | ->withPhpSets(php83: true); 12 | -------------------------------------------------------------------------------- /src/Exception/NotSupportedException.php: -------------------------------------------------------------------------------- 1 | [ 7 | '@auth-client' => dirname(__DIR__), 8 | ], 9 | 'yiisoft/yii-auth-client' => [ 10 | 'enabled' => true, 11 | 'clients' => [], 12 | ], 13 | ]; 14 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php:\/\/stderr", 9 | "stryker": { 10 | "report": "master" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Widget/AuthChoiceItem.php: -------------------------------------------------------------------------------- 1 | new CollectionFactory($params['yiisoft/yii-auth-client']['clients']), 14 | StateStorageInterface::class => SessionStateStorage::class, 15 | ]; 16 | -------------------------------------------------------------------------------- /src/Signature/PlainText.php: -------------------------------------------------------------------------------- 1 | clients as $name => $client) { 29 | if (!is_string($name)) { 30 | throw new \InvalidArgumentException('Client name must be set.'); 31 | } 32 | $clients[$name] = $container->get($client); 33 | } 34 | return new Collection($clients); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/AuthClientInterface.php: -------------------------------------------------------------------------------- 1 | optionValue 26 | */ 27 | public function getViewOptions(): array; 28 | 29 | public function getButtonClass(): string; 30 | 31 | /** 32 | * The Client id is publically visible in button urls 33 | * The Client secret must not be made available publically => exclude from interface 34 | * 35 | * @return string 36 | */ 37 | public function getClientId(): string; 38 | 39 | public function buildAuthUrl(ServerRequestInterface $incomingRequest, array $params): string; 40 | } 41 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /resources/assets/authchoice.css: -------------------------------------------------------------------------------- 1 | .auth-icon { 2 | display: block; 3 | width: 32px; 4 | height: 32px; 5 | background: url(authchoice.png) no-repeat; 6 | border-radius: 3px; 7 | margin: 0 auto; 8 | } 9 | 10 | .auth-icon.google { 11 | background-position: 0 -34px; 12 | } 13 | .auth-icon.twitter { 14 | background-position: 0 -68px; 15 | } 16 | .auth-icon.yandex { 17 | background-position: 0 -102px; 18 | } 19 | .auth-icon.vkontakte { 20 | background-position: 0 -136px; 21 | } 22 | .auth-icon.facebook { 23 | background-position: 0 -170px; 24 | } 25 | .auth-icon.linkedin { 26 | background-position: 0 -204px; 27 | } 28 | .auth-icon.github { 29 | background-position: 0 -238px; 30 | } 31 | .auth-icon.live { 32 | background-position: 0 -272px; 33 | } 34 | 35 | .auth-clients { 36 | display: block; 37 | margin: 0 0 1em; 38 | list-style: none; 39 | overflow: auto; 40 | } 41 | 42 | .auth-clients li { 43 | float: left; 44 | display: block; 45 | margin: 0 1em 0 0; 46 | text-align: center; 47 | } 48 | 49 | .auth-title { 50 | display: block; 51 | margin-top: 0.4em; 52 | text-align: center; 53 | width: 58px; 54 | } -------------------------------------------------------------------------------- /resources/assets/authchoice-h.css: -------------------------------------------------------------------------------- 1 | .auth-icon-h { 2 | display: block; 3 | width: 32px; 4 | height: 32px; 5 | background: url(authchoice-h.png) no-repeat; 6 | border-radius: 3px; 7 | margin: 0 auto; 8 | } 9 | 10 | .auth-icon-h.google { 11 | background-position: -32px 0; 12 | } 13 | .auth-icon-h.twitter { 14 | background-position: -64px 0; 15 | } 16 | .auth-icon-h.yandex { 17 | background-position: -96px 0; 18 | } 19 | .auth-icon-h.vkontakte { 20 | background-position: -128px 0; 21 | } 22 | .auth-icon-h.facebook { 23 | background-position: -160px 0; 24 | } 25 | .auth-icon-h.linkedin { 26 | background-position: -192px 0; 27 | } 28 | .auth-icon-h.github { 29 | background-position: -224px 0; 30 | } 31 | .auth-icon-h.live { 32 | background-position: -256px 0; 33 | } 34 | 35 | .auth-clients { 36 | display: block; 37 | margin: 0 0 1em; 38 | list-style: none; 39 | overflow: auto; 40 | } 41 | 42 | .auth-clients li { 43 | float: left; 44 | display: block; 45 | margin: 0 1em 0 0; 46 | text-align: center; 47 | } 48 | 49 | .auth-title { 50 | display: block; 51 | margin-top: 0.4em; 52 | text-align: center; 53 | width: 58px; 54 | } -------------------------------------------------------------------------------- /src/Signature/HmacSha.php: -------------------------------------------------------------------------------- 1 | **Note:** This class requires PHP "Hash" extension(). 15 | */ 16 | final class HmacSha extends Signature 17 | { 18 | public function __construct(/** 19 | * @var string hash algorithm, e.g. `sha1`, `sha256` and so on. 20 | * 21 | * @link https://php.net/manual/ru/function.hash-algos.php 22 | */ 23 | private readonly string $algorithm 24 | ) { 25 | if (!function_exists('hash_hmac')) { 26 | throw new NotSupportedException('PHP "Hash" extension is required.'); 27 | } 28 | } 29 | 30 | #[\Override] 31 | public function getName(): string 32 | { 33 | return 'HMAC-' . strtoupper($this->algorithm); 34 | } 35 | 36 | #[\Override] 37 | public function generateSignature(string $baseString, string $key): string 38 | { 39 | return base64_encode(hash_hmac($this->algorithm, $baseString, $key, true)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/views/redirect.php: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 33 | 34 | 35 | 36 | 37 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/Signature/Signature.php: -------------------------------------------------------------------------------- 1 | generateSignature($baseString, $key); 31 | if (empty($signature) || empty($expectedSignature)) { 32 | return false; 33 | } 34 | 35 | return strcmp($expectedSignature, $signature) === 0; 36 | } 37 | 38 | /** 39 | * Generates OAuth request signature. 40 | * 41 | * @param string $baseString signature base string. 42 | * @param string $key signature key. 43 | * 44 | * @return string signature string. 45 | */ 46 | abstract public function generateSignature(string $baseString, string $key): string; 47 | } 48 | -------------------------------------------------------------------------------- /src/StateStorage/StateStorageInterface.php: -------------------------------------------------------------------------------- 1 | session->set($key, $value); 35 | } 36 | 37 | #[\Override] 38 | public function get(string $key): mixed 39 | { 40 | return $this->session->get($key); 41 | } 42 | 43 | #[\Override] 44 | public function remove(string $key): void 45 | { 46 | $this->session->remove($key); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/OAuth2Interface.php: -------------------------------------------------------------------------------- 1 | dirname(__DIR__, 2), 24 | * '@assets' => '@root/public/assets', 25 | * '@assetsUrl' => '@baseUrl/assets', 26 | * '@vendor' => '@root/vendor', 27 | * 28 | * 2. Check that '@assetsUrl' is also listed 29 | * 3. Check that the '@vendor' alias is also included as above. 30 | * 4. The alias should point to public/assets as seen above 31 | * 5. The public/assets folder's 8 digit subfolder e.g. ab615tyu will only 32 | * be included ... if the sourcePath files do not already exist in a pre-existing folder 33 | * e.g. tr674trs 34 | * 6. Register the Asset in your application's layout file e.g. 35 | * resources/views/layout/main.php 36 | * 37 | * @var string|null 38 | */ 39 | public ?string $basePath = '@assets'; 40 | 41 | public ?string $baseUrl = '@assetsUrl'; 42 | 43 | public ?string $sourcePath = '@vendor/yiisoft/yii-auth-client/resources/assets'; 44 | 45 | /** 46 | * @psalm-suppress NonInvariantDocblockPropertyType $js 47 | */ 48 | public array $js = [ 49 | 'authchoice.js', 50 | ]; 51 | 52 | public array $depends = [ 53 | AuthChoiceStyleAsset::class, 54 | ]; 55 | } 56 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr12 2 | risky: true 3 | 4 | version: 8.3 5 | 6 | finder: 7 | exclude: 8 | - "resources" 9 | - "vendor" 10 | 11 | enabled: 12 | - alpha_ordered_traits 13 | - array_indentation 14 | - array_push 15 | - combine_consecutive_issets 16 | - combine_consecutive_unsets 17 | - combine_nested_dirname 18 | - declare_strict_types 19 | - dir_constant 20 | - fully_qualified_strict_types 21 | - function_to_constant 22 | - hash_to_slash_comment 23 | - is_null 24 | - logical_operators 25 | - magic_constant_casing 26 | - magic_method_casing 27 | - method_separation 28 | - modernize_types_casting 29 | - native_function_casing 30 | - native_function_type_declaration_casing 31 | - no_alias_functions 32 | - no_empty_comment 33 | - no_empty_phpdoc 34 | - no_empty_statement 35 | - no_extra_block_blank_lines 36 | - no_short_bool_cast 37 | - no_superfluous_elseif 38 | - no_unneeded_control_parentheses 39 | - no_unneeded_curly_braces 40 | - no_unneeded_final_method 41 | - no_unset_cast 42 | - no_unused_imports 43 | - no_unused_lambda_imports 44 | - no_useless_else 45 | - no_useless_return 46 | - normalize_index_brace 47 | - phpdoc_no_empty_return 48 | - phpdoc_no_useless_inheritdoc 49 | - phpdoc_order 50 | - phpdoc_property 51 | - phpdoc_scalar 52 | - phpdoc_singular_inheritdoc 53 | - phpdoc_trim 54 | - phpdoc_trim_consecutive_blank_line_separation 55 | - phpdoc_type_to_var 56 | - phpdoc_types 57 | - phpdoc_types_order 58 | - print_to_echo 59 | - regular_callable_call 60 | - return_assignment 61 | - self_accessor 62 | - self_static_accessor 63 | - set_type_to_cast 64 | - short_array_syntax 65 | - short_list_syntax 66 | - simplified_if_return 67 | - single_quote 68 | - standardize_not_equals 69 | - ternary_to_null_coalescing 70 | - trailing_comma_in_multiline_array 71 | - unalign_double_arrow 72 | - unalign_equals 73 | - empty_loop_body_braces 74 | - integer_literal_case 75 | - union_type_without_spaces 76 | 77 | disabled: 78 | - function_declaration -------------------------------------------------------------------------------- /src/Client/TikTok.php: -------------------------------------------------------------------------------- 1 | getParam('access_token'); 31 | 32 | if (strlen($tokenString) === 0) { 33 | return []; 34 | } 35 | 36 | $request = $this->requestFactory 37 | ->createRequest('GET', $url) 38 | ->withHeader('Authorization', 'Bearer ' . $tokenString); 39 | 40 | try { 41 | $response = $this->httpClient->sendRequest($request); 42 | $body = $response->getBody()->getContents(); 43 | if (strlen($body) > 0) { 44 | return (array) json_decode($body, true); 45 | } 46 | } catch (\Throwable) { 47 | // Optionally log error: $e->getMessage() 48 | return []; 49 | } 50 | 51 | return []; 52 | } 53 | 54 | protected function initUserAttributes(): array 55 | { 56 | $token = $this->getAccessToken(); 57 | if ($token instanceof OAuthToken) { 58 | return $this->getCurrentUserJsonArray($token); 59 | } 60 | return []; 61 | } 62 | 63 | #[\Override] 64 | public function getButtonClass(): string 65 | { 66 | return ''; 67 | } 68 | 69 | /** 70 | * @return string 71 | * 72 | * @psalm-return 'user.info.profile' 73 | */ 74 | #[\Override] 75 | protected function getDefaultScope(): string 76 | { 77 | return 'user.info.profile'; 78 | } 79 | 80 | #[\Override] 81 | public function getName(): string 82 | { 83 | return 'tiktok'; 84 | } 85 | 86 | #[\Override] 87 | public function getTitle(): string 88 | { 89 | return 'TikTok'; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/OAuthInterface.php: -------------------------------------------------------------------------------- 1 | [ 17 | * 'google' => [ 18 | * 'class' => Yiisoft\Yii\AuthClient\Clients\Google::class, 19 | * 'setClientId()' => ['google_client_id'], 20 | * 'setClientSecret()' => ['google_client_secret'], 21 | * ], 22 | * 'facebook' => [ 23 | * 'class' => Yiisoft\Yii\AuthClient\Clients\Facebook::class, 24 | * 'setClientId()' => ['facebook_client_id'], 25 | * 'setClientSecret()' => ['facebook_client_secret'], 26 | * ] 27 | * ... 28 | * ] 29 | * ``` 30 | */ 31 | final class Collection 32 | { 33 | public function __construct( 34 | /** 35 | * @var array|OAuth2Interface[] list of OAuth2 clients with their configuration in format: 'clientName' => [...] 36 | */ 37 | private array $clients 38 | ) { 39 | } 40 | 41 | public function getClient(string $name): OAuth2 42 | { 43 | if (!$this->hasClient($name)) { 44 | throw new InvalidArgumentException("Unknown auth client '{$name}'."); 45 | } 46 | 47 | $client = $this->clients[$name]; 48 | if (!($client instanceof OAuth2)) { 49 | throw new RuntimeException( 50 | 'The Client should be an OAuth2 Interface.' 51 | ); 52 | } 53 | return $client; 54 | } 55 | 56 | /** 57 | * @psalm-return array 58 | */ 59 | public function getClients(): array 60 | { 61 | $clients = []; 62 | 63 | /** 64 | * @var OAuth2 $client 65 | * @var string $name 66 | * @var array $this->clients 67 | */ 68 | foreach ($this->clients as $name => $client) { 69 | $clients[$name] = $this->getClient($name); 70 | } 71 | 72 | return $clients; 73 | } 74 | 75 | /** 76 | * @param array $clients list of auth clients indexed by their names 77 | */ 78 | public function setClients(array $clients): void 79 | { 80 | $this->clients = $clients; 81 | } 82 | 83 | /** 84 | * Checks if client exists in the hub. 85 | * 86 | * @param string $name client id. 87 | * 88 | * @return bool whether client exist. 89 | */ 90 | public function hasClient(string $name): bool 91 | { 92 | return array_key_exists($name, $this->clients); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /resources/assets/authchoice.js: -------------------------------------------------------------------------------- 1 | // Main function to initialize the authchoice widget 2 | function authchoice(container, options) { 3 | var defaults = { 4 | triggerSelector: 'a.auth-link', 5 | popup: { 6 | resizable: 'yes', 7 | scrollbars: 'no', 8 | toolbar: 'no', 9 | menubar: 'no', 10 | location: 'no', 11 | directories: 'no', 12 | status: 'yes', 13 | width: 450, 14 | height: 380 15 | } 16 | }; 17 | 18 | options = extend(defaults, options || {}); 19 | options.popup = extend(defaults.popup, options.popup || {}); 20 | 21 | var triggers = container.querySelectorAll(options.triggerSelector); 22 | 23 | triggers.forEach(function(trigger) { 24 | trigger.addEventListener('click', function(e) { 25 | e.preventDefault(); 26 | 27 | var authChoicePopup = container._authChoicePopup; 28 | 29 | if (authChoicePopup) { 30 | authChoicePopup.close(); 31 | } 32 | 33 | var url = trigger.href; 34 | var popupOptions = extend({}, options.popup); 35 | 36 | var localPopupWidth = trigger.getAttribute('data-popup-width'); 37 | if (localPopupWidth) { 38 | popupOptions.width = localPopupWidth; 39 | } 40 | var localPopupHeight = trigger.getAttribute('data-popup-height'); 41 | if (localPopupHeight) { 42 | popupOptions.height = localPopupHeight; 43 | } 44 | 45 | popupOptions.left = (window.screen.width - popupOptions.width) / 2; 46 | popupOptions.top = (window.screen.height - popupOptions.height) / 2; 47 | 48 | var popupFeatureParts = []; 49 | for (var propName in popupOptions) { 50 | if (Object.prototype.hasOwnProperty.call(popupOptions, propName)) { 51 | popupFeatureParts.push(propName + '=' + popupOptions[propName]); 52 | } 53 | } 54 | var popupFeature = popupFeatureParts.join(','); 55 | 56 | authChoicePopup = window.open(url, 'yii_auth_choice', popupFeature); 57 | if (authChoicePopup) { 58 | authChoicePopup.focus(); 59 | container._authChoicePopup = authChoicePopup; 60 | } 61 | }); 62 | }); 63 | } 64 | 65 | // Attach to window for usage 66 | window.authchoice = authchoice; 67 | 68 | // Auto-init for DOM elements with [data-authchoice] attribute 69 | document.addEventListener('DOMContentLoaded', function() { 70 | var containers = document.querySelectorAll('[data-authchoice]'); 71 | containers.forEach(function(container) { 72 | window.authchoice(container); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/RequestUtil.php: -------------------------------------------------------------------------------- 1 | getUri()->withQuery(http_build_query($newParams, '', '&', PHP_QUERY_RFC3986)); 40 | return $request->withUri($uri); 41 | } 42 | 43 | /** 44 | * @return (string|(string|null)[]|null)[] 45 | * 46 | * @psalm-return array 47 | */ 48 | public static function getParams(RequestInterface $request): array 49 | { 50 | $queryString = $request->getUri()->getQuery(); 51 | if ($queryString === '') { 52 | return []; 53 | } 54 | 55 | $result = []; 56 | 57 | foreach (explode('&', $queryString) as $pair) { 58 | $parts = explode('=', $pair, 2); 59 | $key = rawurldecode($parts[0]); 60 | $value = isset($parts[1]) ? rawurldecode($parts[1]) : null; 61 | if (!isset($result[$key])) { 62 | $result[$key] = $value; 63 | } else { 64 | if (!is_array($result[$key])) { 65 | $result[$key] = [$result[$key]]; 66 | } 67 | $result[$key][] = $value; 68 | } 69 | } 70 | return $result; 71 | } 72 | 73 | public static function addHeaders(RequestInterface $request, array $headers): RequestInterface 74 | { 75 | /** 76 | * @see Psr\Http\Message\withHeader 77 | * @var string $header Case-insensitive header field name. 78 | * @var string|string[] $value Header value(s). 79 | */ 80 | foreach ($headers as $header => $value) { 81 | $request = $request->withHeader($header, $value); 82 | } 83 | return $request; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 | 6 | Oauth 7 | 8 | 9 | OpenId 10 | 11 |

Yii External Authentication

12 |
13 |

14 | 15 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/yii-auth-client/v/stable.png)](https://packagist.org/packages/yiisoft/yii-auth-client) 16 | [![Total Downloads](https://poser.pugx.org/yiisoft/yii-auth-client/downloads.png)](https://packagist.org/packages/yiisoft/yii-auth-client) 17 | [![Build status](https://github.com/yiisoft/yii-auth-client/workflows/build/badge.svg)](https://github.com/yiisoft/yii-auth-client/actions?query=workflow%3Abuild) 18 | [![Code Coverage](https://codecov.io/gh/yiisoft/yii-auth-client/branch/master/graph/badge.svg)](https://codecov.io/gh/yiisoft/yii-auth-client) 19 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fyii-auth-client%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-auth-client/master) 20 | [![static analysis](https://github.com/yiisoft/yii-auth-client/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/yii-auth-client/actions?query=workflow%3A%22static+analysis%22) 21 | [![type-coverage](https://shepherd.dev/github/yiisoft/yii-auth-client/coverage.svg)](https://shepherd.dev/github/yiisoft/yii-auth-client) 22 | 23 | This extension adds [OAuth](https://oauth.net/), [OAuth2](https://oauth.net/2/) and [OpenId Connect](https://openid.net/connect/) 24 | consumers for the [Yii framework](https://www.yiiframework.com). 25 | 26 | ## Requirements 27 | 28 | - PHP 8.1 or higher. 29 | 30 | ## Installation 31 | 32 | The package could be installed with [Composer](https://getcomposer.org): 33 | 34 | ```shell 35 | composer require yiisoft/yii-auth-client 36 | ``` 37 | 38 | ## Documentation 39 | 40 | - Guide: [English](docs/guide/en/README.md), [Русский](docs/guide/ru/README.md) 41 | - [Internals](docs/internals.md) 42 | 43 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 44 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 45 | 46 | ## License 47 | 48 | The Yii External Authentication is free software. It is released under the terms of the BSD License. 49 | Please see [`LICENSE`](./LICENSE.md) for more information. 50 | 51 | Maintained by [Yii Software](https://www.yiiframework.com/). 52 | 53 | ## Support the project 54 | 55 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 56 | 57 | ## Follow updates 58 | 59 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 60 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 61 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 62 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 63 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/yii-auth-client", 3 | "description": "Yii Framework external authentication via OAuth", 4 | "keywords": [ 5 | "yii", 6 | "OAuth", 7 | "auth", 8 | "api" 9 | ], 10 | "type": "library", 11 | "license": "BSD-3-Clause", 12 | "support": { 13 | "issues": "https://github.com/yiisoft/yii-auth-client/issues?state=open", 14 | "source": "https://github.com/yiisoft/yii-auth-client", 15 | "forum": "https://www.yiiframework.com/forum/", 16 | "wiki": "https://www.yiiframework.com/wiki/", 17 | "irc": "ircs://irc.libera.chat:6697/yii", 18 | "chat": "https://t.me/yii3en" 19 | }, 20 | "funding": [ 21 | { 22 | "type": "opencollective", 23 | "url": "https://opencollective.com/yiisoft" 24 | }, 25 | { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/yiisoft" 28 | } 29 | ], 30 | "require": { 31 | "php": "8.1 - 8.4", 32 | "phpseclib/phpseclib": ">=3.0.46", 33 | "psr/container": "^2.0.2", 34 | "psr/http-client": "^1.0.3", 35 | "psr/http-factory": "^1.1", 36 | "psr/http-factory-implementation": "1.0", 37 | "psr/http-message": "^2.0", 38 | "psr/http-message-implementation": "1.0", 39 | "psr/http-server-handler": "^1.0.2", 40 | "psr/http-server-middleware": "^1.0.2", 41 | "psr/log-implementation": "^2.0", 42 | "psr/simple-cache-implementation": "^1.0", 43 | "web-token/jwt-framework": ">=4.0.6", 44 | "yiisoft/aliases": "^3.1", 45 | "yiisoft/assets": "^5.1.1", 46 | "yiisoft/factory": "^1.3", 47 | "yiisoft/html": "^3.11", 48 | "yiisoft/http": "^1.2", 49 | "yiisoft/json": "^1.0", 50 | "yiisoft/router": "^4.0", 51 | "yiisoft/session": "^3.0", 52 | "yiisoft/view": "^12.2.1", 53 | "yiisoft/widget": "^2.2" 54 | }, 55 | "require-dev": { 56 | "ext-curl": "*", 57 | "maglnet/composer-require-checker": "^4.16.1", 58 | "kriswallsmith/buzz": "^1.3", 59 | "nyholm/psr7": "^1.8.2", 60 | "phpunit/phpunit": ">=11.5.9", 61 | "rector/rector": "^2.1.6", 62 | "roave/infection-static-analysis-plugin": ">=1.39", 63 | "spatie/phpunit-watcher": "^1.24", 64 | "styleci/cli": "^1.6.0", 65 | "vimeo/psalm": "^6.13.1", 66 | "yiisoft/cache": "^3.1", 67 | "yiisoft/di": "^1.4", 68 | "yiisoft/log": "^2.1.1", 69 | "yiisoft/router-fastroute": "^4.0.1", 70 | "yiisoft/psr-dummy-provider": "^1.0.2" 71 | }, 72 | "repositories": [ 73 | { 74 | "type": "composer", 75 | "url": "https://asset-packagist.org" 76 | } 77 | ], 78 | "autoload": { 79 | "psr-4": { 80 | "Yiisoft\\Yii\\AuthClient\\": "src" 81 | } 82 | }, 83 | "autoload-dev": { 84 | "psr-4": { 85 | "Yiisoft\\Yii\\AuthClient\\Tests\\": "tests" 86 | } 87 | }, 88 | "extra": { 89 | "branch-alias": { 90 | "dev-master": "3.0.x-dev" 91 | }, 92 | "config-plugin-options": { 93 | "source-directory": "config" 94 | }, 95 | "config-plugin": { 96 | "di": "di.php", 97 | "params": "params.php" 98 | } 99 | }, 100 | "config": { 101 | "sort-packages": true, 102 | "bump-after-update": true, 103 | "allow-plugins": { 104 | "infection/extension-installer": true, 105 | "composer/package-versions-deprecated": true 106 | } 107 | }, 108 | "scripts": { 109 | "test": "phpunit --stop-on-notice --display-phpunit-deprecations --testdox", 110 | "test-watch": "phpunit-watcher watch" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Client/GitHub.php: -------------------------------------------------------------------------------- 1 | . 15 | * 16 | * Example application configuration: 17 | * 18 | * config/common/params.php 19 | * 20 | * 'yiisoft/yii-auth-client' => [ 21 | * 'enabled' => true, 22 | * 'clients' => [ 23 | * 'github' => [ 24 | * 'class' => 'Yiisoft\Yii\AuthClient\Client\Github::class', 25 | * 'clientId' => $_ENV['GITHUB_API_CLIENT_ID'] ?? '', 26 | * 'clientSecret' => $_ENV['GITHUB_API_CLIENT_SECRET'] ?? '', 27 | * 'returnUrl' => $_ENV['GITHUB_API_CLIENT_RETURN_URL'] ?? '', 28 | * ], 29 | * ], 30 | * ], 31 | * 32 | * @link https://developer.github.com/v3/oauth/ 33 | * @link https://github.com/settings/applications/new 34 | */ 35 | final class GitHub extends OAuth2 36 | { 37 | /** 38 | * @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#1-request-a-users-github-identity 39 | */ 40 | protected string $authUrl = 'https://github.com/login/oauth/authorize'; 41 | 42 | /** 43 | * @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github 44 | */ 45 | protected string $tokenUrl = 'https://github.com/login/oauth/access_token'; 46 | 47 | protected string $endpoint = 'https://api.github.com'; 48 | 49 | public function getCurrentUserJsonArray(OAuthToken $token): array 50 | { 51 | // Here is the actual 'access-token' which the user has allowed us to access their basic info. 52 | $tokenString = (string)$token->getParam('access_token'); 53 | 54 | if ($tokenString !== '') { 55 | $request = $this->createRequest('GET', 'https://api.github.com/user'); 56 | 57 | $request = RequestUtil::addHeaders( 58 | $request, 59 | [ 60 | 'Authorization' => 'Bearer ' . $tokenString, 61 | ] 62 | ); 63 | 64 | $response = $this->sendRequest($request); 65 | 66 | // the array returns basic info of the user including login i.e. username, and github id 67 | // which will be used later to concatenate or build-up a username for our purposes. 68 | return (array)json_decode($response->getBody()->getContents(), true); 69 | } 70 | 71 | return []; 72 | } 73 | 74 | protected function initUserAttributes(): array 75 | { 76 | $token = $this->getAccessToken(); 77 | if ($token instanceof OAuthToken) { 78 | return $this->getCurrentUserJsonArray($token); 79 | } 80 | return []; 81 | } 82 | 83 | #[\Override] 84 | public function getName(): string 85 | { 86 | return 'github'; 87 | } 88 | 89 | #[\Override] 90 | public function getTitle(): string 91 | { 92 | return 'GitHub'; 93 | } 94 | 95 | #[\Override] 96 | public function getButtonClass(): string 97 | { 98 | return 'btn btn-primary bi bi-github'; 99 | } 100 | 101 | /** 102 | * @return int[] 103 | * 104 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 105 | */ 106 | #[\Override] 107 | protected function defaultViewOptions(): array 108 | { 109 | return [ 110 | 'popupWidth' => 860, 111 | 'popupHeight' => 480, 112 | ]; 113 | } 114 | 115 | /** 116 | * @return string 117 | * 118 | * @psalm-return 'user' 119 | */ 120 | #[\Override] 121 | protected function getDefaultScope(): string 122 | { 123 | return 'user'; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Client/Yandex.php: -------------------------------------------------------------------------------- 1 | . 18 | * 19 | * @link https://oauth.yandex.ru/client/new 20 | * @link https://api.yandex.ru/login/doc/dg/reference/response.xml 21 | * @link https://yandex.com/dev/id/doc/en/codes/code-url 22 | */ 23 | final class Yandex extends OAuth2 24 | { 25 | protected string $authUrl = 'https://oauth.yandex.com/authorize'; 26 | 27 | protected string $tokenUrl = 'https://oauth.yandex.com/token'; 28 | 29 | protected string $endpoint = 'https://login.yandex.ru'; 30 | 31 | #[\Override] 32 | public function applyAccessTokenToRequest(RequestInterface $request, OAuthToken $accessToken): RequestInterface 33 | { 34 | $params = RequestUtil::getParams($request); 35 | 36 | $paramsToAdd = []; 37 | 38 | if (!isset($params['format'])) { 39 | $paramsToAdd['format'] = 'json'; 40 | } 41 | 42 | $paramsToAdd['oauth_token'] = $accessToken->getToken(); 43 | 44 | return RequestUtil::addParams($request, $paramsToAdd); 45 | } 46 | 47 | public function getCurrentUserJsonArray( 48 | OAuthToken $oAuthToken, 49 | ClientInterface $clientInterface, 50 | RequestFactoryInterface $requestFactoryInterface 51 | ): array { 52 | $tokenString = (string)$oAuthToken->getParam('access_token'); 53 | 54 | if ($tokenString !== '') { 55 | $request = $requestFactoryInterface 56 | ->createRequest('GET', $this->endpoint) 57 | ->withHeader('Authorization', "OAuth $tokenString"); 58 | 59 | try { 60 | $response = $clientInterface->sendRequest($request); 61 | $body = (string)$response->getBody(); 62 | if (!empty($body)) { 63 | return (array) json_decode($body, true); 64 | } 65 | return []; 66 | } catch (\Psr\Http\Client\ClientExceptionInterface) { 67 | return []; 68 | } 69 | } 70 | 71 | return []; 72 | } 73 | 74 | protected function initUserAttributes(): array 75 | { 76 | $token = $this->getAccessToken(); 77 | if ($token instanceof OAuthToken) { 78 | // Use $this->httpClient and $this->requestFactory from the parent OAuth2 class 79 | return $this->getCurrentUserJsonArray($token, $this->httpClient, $this->requestFactory); 80 | } 81 | return []; 82 | } 83 | 84 | #[\Override] 85 | public function getButtonClass(): string 86 | { 87 | return 'btn btn-dark bi'; 88 | } 89 | 90 | /** 91 | * @return int[] 92 | * 93 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 94 | */ 95 | #[\Override] 96 | protected function defaultViewOptions(): array 97 | { 98 | return [ 99 | 'popupWidth' => 860, 100 | 'popupHeight' => 480, 101 | ]; 102 | } 103 | 104 | /** 105 | * @see https://oauth.yandex.com/client//info 106 | * @see https://yandex.com/dev/id/doc/en/user-information#common 107 | * @return string 108 | * 109 | * @psalm-return 'login:info' 110 | */ 111 | #[\Override] 112 | protected function getDefaultScope(): string 113 | { 114 | return 'login:info'; 115 | } 116 | 117 | #[\Override] 118 | public function getName(): string 119 | { 120 | return 'yandex'; 121 | } 122 | 123 | #[\Override] 124 | public function getTitle(): string 125 | { 126 | return 'Yandex'; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Client/LinkedIn.php: -------------------------------------------------------------------------------- 1 | . 17 | * @link https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?source=recommendations&tabs=HTTPS1 18 | * @link https://developer.linkedin.com/docs/oauth2 19 | * @link https://www.linkedin.com/secure/developer 20 | * @link https://developer.linkedin.com/docs/rest-api 21 | */ 22 | final class LinkedIn extends OAuth2 23 | { 24 | protected string $version = 'v2'; 25 | protected string $authUrl = 'https://www.linkedin.com/oauth/v2/authorization'; 26 | protected string $tokenUrl = 'https://www.linkedin.com/oauth/v2/accessToken'; 27 | protected string $endpoint = 'https://api.linkedin.com/v2'; 28 | 29 | /** 30 | * Fetch current user information using PSR-18 HTTP Client and PSR-17 Request Factory. 31 | * 32 | * @param OAuthToken $token 33 | * @param ClientInterface $httpClient PSR-18 HTTP Client 34 | * @param RequestFactoryInterface $requestFactory PSR-17 Request Factory 35 | * @return array 36 | */ 37 | public function getCurrentUserJsonArray( 38 | OAuthToken $token, 39 | ClientInterface $httpClient, 40 | RequestFactoryInterface $requestFactory 41 | ): array { 42 | $tokenString = (string)$token->getParam('access_token'); 43 | if ($tokenString !== '') { 44 | return []; 45 | } 46 | 47 | $url = sprintf( 48 | 'https://api.linkedin.com/%s/userinfo', 49 | $this->version 50 | ); 51 | 52 | $request = $requestFactory->createRequest('GET', $url) 53 | ->withHeader('Authorization', 'Bearer ' . $tokenString); 54 | 55 | try { 56 | /** @var ResponseInterface $response */ 57 | $response = $httpClient->sendRequest($request); 58 | $body = $response->getBody()->getContents(); 59 | if (strlen($body) > 0) { 60 | return (array)json_decode($body, true); 61 | } 62 | } catch (\Throwable) { 63 | return []; 64 | } 65 | 66 | return []; 67 | } 68 | 69 | protected function initUserAttributes(): array 70 | { 71 | $token = $this->getAccessToken(); 72 | if ($token instanceof OAuthToken) { 73 | // Use $this->httpClient and $this->requestFactory from the parent OAuth2 class 74 | return $this->getCurrentUserJsonArray($token, $this->httpClient, $this->requestFactory); 75 | } 76 | return []; 77 | } 78 | 79 | #[\Override] 80 | public function getName(): string 81 | { 82 | return 'linkedin'; 83 | } 84 | 85 | #[\Override] 86 | public function getTitle(): string 87 | { 88 | return 'LinkedIn'; 89 | } 90 | 91 | #[\Override] 92 | public function getButtonClass(): string 93 | { 94 | return 'btn btn-info bi bi-linkedin'; 95 | } 96 | 97 | /** 98 | * @return int[] 99 | * 100 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 101 | */ 102 | #[\Override] 103 | protected function defaultViewOptions(): array 104 | { 105 | return [ 106 | 'popupWidth' => 860, 107 | 'popupHeight' => 480, 108 | ]; 109 | } 110 | 111 | /** 112 | * openid - Use your name and photo 113 | * profile - Use your name and photo 114 | * email - Use the primary email address associated with your LinkedIn account 115 | * w_member_social - Create, modify, and delete posts, comments, and reactions on your behalf 116 | * 117 | * @return string 118 | * 119 | * @psalm-return 'openid profile email w_member_social' 120 | */ 121 | #[\Override] 122 | protected function getDefaultScope(): string 123 | { 124 | return 'openid profile email w_member_social'; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Client/Google.php: -------------------------------------------------------------------------------- 1 | 15 | * and setup its credentials at . 16 | * Create an Oauth2 Web Application and record the resultant Client Id and Client Secret in e.g a .env file and insert your website's returnUrl e.g. https:\\example.com\callbackGoogle 17 | * @see Google+ Api is being shutdown https://developers.google.com/+/api-shutdown 18 | * @see https://developers.google.com/oauthplayground 19 | * @see 20 | */ 21 | class Google extends OAuth2 22 | { 23 | protected string $version = 'v2'; 24 | protected string $authUrl = 'https://accounts.google.com/o/oauth2/v2/auth'; 25 | protected string $tokenUrl = 'https://oauth2.googleapis.com/token'; 26 | protected string $endPoint = 'https://www.googleapis.com/oauth2/v2/userinfo'; 27 | 28 | public function getCurrentUserJsonArray(OAuthToken $token): array 29 | { 30 | /** 31 | * e.g. '{all the params}' => '' 32 | * @var array $params 33 | */ 34 | $tokenParams = $token->getParams(); 35 | 36 | /** 37 | * e.g. convert the above key, namely '{all the params}', into an array 38 | * @var array $tokenArray 39 | */ 40 | $tokenArray = array_keys($tokenParams); 41 | 42 | /** 43 | * @var string $jsonString 44 | */ 45 | $jsonString = $tokenArray[0]; 46 | 47 | /** 48 | * @var array $finalArray 49 | */ 50 | $finalArray = json_decode($jsonString, true); 51 | 52 | /** 53 | * @var string $tokenString 54 | */ 55 | $tokenString = $finalArray['access_token'] ?? ''; 56 | 57 | if ($tokenString !== '') { 58 | $url = sprintf( 59 | 'https://www.googleapis.com/oauth2/%s/userinfo', 60 | $this->version 61 | ); 62 | 63 | $request = $this->createRequest('GET', $url); 64 | 65 | $request = RequestUtil::addHeaders( 66 | $request, 67 | [ 68 | 'Authorization' => 'Bearer ' . $tokenString, 69 | 'Host' => 'www.googleapis.com', 70 | 'Content-length' => 0, 71 | ] 72 | ); 73 | 74 | $response = $this->sendRequest($request); 75 | 76 | return (array)json_decode($response->getBody()->getContents(), true); 77 | } 78 | 79 | return []; 80 | } 81 | 82 | protected function initUserAttributes(): array 83 | { 84 | $token = $this->getAccessToken(); 85 | if ($token instanceof OAuthToken) { 86 | return $this->getCurrentUserJsonArray($token); 87 | } 88 | return []; 89 | } 90 | 91 | #[\Override] 92 | public function getName(): string 93 | { 94 | return 'google'; 95 | } 96 | 97 | #[\Override] 98 | public function getTitle(): string 99 | { 100 | return 'Google'; 101 | } 102 | 103 | #[\Override] 104 | public function getButtonClass(): string 105 | { 106 | return 'btn btn-primary bi bi-google'; 107 | } 108 | 109 | /** 110 | * @return int[] 111 | * 112 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 113 | */ 114 | #[\Override] 115 | protected function defaultViewOptions(): array 116 | { 117 | return [ 118 | 'popupWidth' => 860, 119 | 'popupHeight' => 480, 120 | ]; 121 | } 122 | 123 | /** 124 | * @see https://www.googleapis.com/auth/userinfo.profile will output userinfo.profile 125 | * @see https://www.googleapis.com/auth/userinfo.email will output userinfo.email 126 | * @psalm-return 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email' 127 | */ 128 | #[\Override] 129 | protected function getDefaultScope(): string 130 | { 131 | return 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email'; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Client/X.php: -------------------------------------------------------------------------------- 1 | App Permissions: Read 19 | * -> Type of App: Native App: Public Client (Not Confidential Client) 20 | * 21 | * PKCE: An extension to the authorization code flow to prevent several attacks and to be able 22 | * to perform the OAuth exchange from public clients securely using two parameters code_challenge and 23 | * code_challenge_method. 24 | * 25 | * OAuth 2.0 is an industry-standard authorization protocol that allows for greater 26 | * control over an application’s scope, and authorization flows across multiple devices. 27 | * OAuth 2.0 allows you to pick specific fine-grained scopes which give you specific permissions 28 | * on behalf of a user. 29 | * 30 | * By default, the access token you create through the Authorization Code Flow with PKCE will 31 | * only stay valid for two hours unless you have used the offline.access scope. 32 | * 33 | * Refresh tokens allow an application to obtain a new access token without prompting the user 34 | * via the refresh token flow. 35 | 36 | * If the scope offline.access is applied, an OAuth 2.0 refresh token will be issued. With this refresh token, 37 | * you obtain an access token. If this scope is not passed, we will not generate a refresh token. 38 | * 39 | * Grant Types Available: Authorization code (used here), client credentials, device code, and refresh token. 40 | * 41 | * @see https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code 42 | */ 43 | 44 | final class X extends OAuth2 45 | { 46 | protected string $authUrl = 'https://x.com/i/oauth2/authorize'; 47 | 48 | protected string $tokenUrl = 'https://api.x.com/2/oauth2/token'; 49 | 50 | protected string $endpoint = 'https://api.x.com/2/users/me'; 51 | 52 | /** 53 | * Fetch current user information using PSR-18 HTTP Client and PSR-17 Request Factory, 54 | * instead of curl. 55 | * 56 | * @param OAuthToken $token 57 | * @param ClientInterface $httpClient 58 | * @param RequestFactoryInterface $requestFactory 59 | * @return array 60 | */ 61 | public function getCurrentUserJsonArray( 62 | OAuthToken $token, 63 | ClientInterface $httpClient, 64 | RequestFactoryInterface $requestFactory 65 | ): array { 66 | $tokenString = (string)$token->getParam('access_token'); 67 | if (strlen($tokenString) === 0) { 68 | return []; 69 | } 70 | 71 | $request = $requestFactory->createRequest('GET', $this->endpoint) 72 | ->withHeader('Authorization', 'Bearer ' . $tokenString) 73 | ->withHeader('Content-Type', 'application/json'); 74 | 75 | try { 76 | $response = $httpClient->sendRequest($request); 77 | $body = $response->getBody()->getContents(); 78 | if (strlen($body) > 0) { 79 | return (array)json_decode($body, true); 80 | } 81 | } catch (\Throwable) { 82 | // Optionally log error: $e->getMessage() 83 | return []; 84 | } 85 | 86 | return []; 87 | } 88 | 89 | protected function initUserAttributes(): array 90 | { 91 | $token = $this->getAccessToken(); 92 | if ($token instanceof OAuthToken) { 93 | return $this->getCurrentUserJsonArray($token, $this->httpClient, $this->requestFactory); 94 | } 95 | return []; 96 | } 97 | 98 | #[\Override] 99 | public function getName(): string 100 | { 101 | return 'x'; 102 | } 103 | 104 | #[\Override] 105 | public function getTitle(): string 106 | { 107 | return 'X'; 108 | } 109 | 110 | #[\Override] 111 | public function getButtonClass(): string 112 | { 113 | return 'btn btn-dark bi bi-twitter'; 114 | } 115 | 116 | /** 117 | * @return int[] 118 | * 119 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 120 | */ 121 | #[\Override] 122 | protected function defaultViewOptions(): array 123 | { 124 | return [ 125 | 'popupWidth' => 860, 126 | 'popupHeight' => 480, 127 | ]; 128 | } 129 | 130 | /** 131 | * @return string 132 | * 133 | * @psalm-return 'users.read tweet.read offline.access' 134 | */ 135 | #[\Override] 136 | protected function getDefaultScope(): string 137 | { 138 | return 'users.read tweet.read offline.access'; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/OAuthToken.php: -------------------------------------------------------------------------------- 1 | createTimestamp = time(); 38 | } 39 | 40 | /** 41 | * Returns the token secret value. 42 | * @psalm-suppress MixedReturnStatement 43 | * @psalm-suppress MixedInferredReturnType 44 | * @return string token secret value. 45 | */ 46 | public function getTokenSecret(): string 47 | { 48 | return $this->getParam($this->tokenSecretParamKey ?: 'oauth_token_secret'); 49 | } 50 | 51 | /** 52 | * Sets token value. 53 | * 54 | * @param string $token token value. 55 | */ 56 | public function setToken(string $token): void 57 | { 58 | $this->setParam($this->tokenParamKey ?: 'oauth_token', $token); 59 | } 60 | 61 | /** 62 | * Returns param by name. 63 | * 64 | * @param string $name param name. 65 | * 66 | * @return mixed param value. 67 | */ 68 | public function getParam(string $name): mixed 69 | { 70 | return $this->params[$name] ?? null; 71 | } 72 | 73 | /** 74 | * Sets token expire duration. 75 | * 76 | * @param int $expireDuration token expiration duration. 77 | */ 78 | public function setExpireDuration(int $expireDuration): void 79 | { 80 | $this->setParam($this->getExpireDurationParamKey(), $expireDuration); 81 | } 82 | 83 | /** 84 | * @return string expire duration param key. 85 | */ 86 | public function getExpireDurationParamKey(): string 87 | { 88 | if ($this->expireDurationParamKey === null) { 89 | $this->expireDurationParamKey = $this->defaultExpireDurationParamKey(); 90 | } 91 | 92 | return $this->expireDurationParamKey; 93 | } 94 | 95 | /** 96 | * Fetches default expire duration param key. 97 | * 98 | * @return string expire duration param key. 99 | */ 100 | protected function defaultExpireDurationParamKey(): string 101 | { 102 | $expireDurationParamKey = 'expires_in'; 103 | /** 104 | * @var mixed $value 105 | */ 106 | foreach ($this->getParams() as $name => $value) { 107 | if (!str_contains((string)$name, 'expir')) { 108 | } else { 109 | $expireDurationParamKey = (string)$name; 110 | break; 111 | } 112 | } 113 | return $expireDurationParamKey; 114 | } 115 | 116 | /** 117 | * @return array 118 | */ 119 | public function getParams(): array 120 | { 121 | return $this->params; 122 | } 123 | 124 | /** 125 | * Sets param by name. 126 | * 127 | * @param string $name param name. 128 | * @param mixed $value param value, 129 | */ 130 | public function setParam(string $name, mixed $value): void 131 | { 132 | $this->params[$name] = $value; 133 | } 134 | 135 | /** 136 | * Sets the token secret value. 137 | * 138 | * @param string $tokenSecret token secret. 139 | */ 140 | public function setTokenSecret(string $tokenSecret): void 141 | { 142 | $this->setParam($this->tokenSecretParamKey ?: 'oauth_token_secret', $tokenSecret); 143 | } 144 | 145 | /** 146 | * @param array $params 147 | */ 148 | public function setParams(array $params): void 149 | { 150 | $this->params = $params; 151 | } 152 | 153 | /** 154 | * Checks if token is valid. 155 | * 156 | * @return bool is token valid. 157 | */ 158 | public function getIsValid(): bool 159 | { 160 | $token = $this->getToken(); 161 | 162 | return strlen($token ?? '') > 0 && !$this->getIsExpired(); 163 | } 164 | 165 | /** 166 | * Returns token value. 167 | * @psalm-suppress MixedReturnStatement 168 | * @psalm-suppress MixedInferredReturnType 169 | */ 170 | public function getToken(): ?string 171 | { 172 | return $this->getParam($this->tokenParamKey); 173 | } 174 | 175 | /** 176 | * Checks if token has expired. 177 | * 178 | * @return bool is token expired. 179 | */ 180 | public function getIsExpired(): bool 181 | { 182 | $expirationDuration = (int)$this->getExpireDuration(); 183 | 184 | return time() >= ($this->createTimestamp + $expirationDuration); 185 | } 186 | 187 | /** 188 | * Returns the token expiration duration. 189 | * 190 | * return mixed token expiration duration. 191 | */ 192 | public function getExpireDuration(): mixed 193 | { 194 | return $this->getParam($this->getExpireDurationParamKey()); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/AuthClient.php: -------------------------------------------------------------------------------- 1 | sourceSpecification 24 | * 'sourceSpecification' can be: 25 | * - string, raw attribute name 26 | * - array, pass to raw attribute value 27 | * - callable, PHP callback, which should accept array of raw attributes and return normalized value. 28 | * 29 | * For example: 30 | * 31 | * ```php 32 | * 'normalizeUserAttributeMap' => [ 33 | * 'about' => 'bio', 34 | * 'language' => ['languages', 0, 'name'], 35 | * 'fullName' => function ($attributes) { 36 | * return $attributes['firstName'] . ' ' . $attributes['lastName']; 37 | * }, 38 | * ], 39 | * ``` 40 | */ 41 | protected array $normalizeUserAttributeMap = []; 42 | 43 | /** 44 | * @var array $viewOptions view options in format: optionName => optionValue 45 | */ 46 | protected array $viewOptions; 47 | 48 | public function __construct( 49 | protected PsrClientInterface $httpClient, 50 | protected RequestFactoryInterface $requestFactory, 51 | /** 52 | * @var StateStorageInterface state storage to be used. 53 | */ 54 | private readonly StateStorageInterface $stateStorage 55 | ) { 56 | } 57 | 58 | public function setRequestFactory(RequestFactoryInterface $requestFactory): void 59 | { 60 | $this->requestFactory = $requestFactory; 61 | } 62 | 63 | public function getRequestFactory(): RequestFactoryInterface 64 | { 65 | return $this->requestFactory; 66 | } 67 | 68 | /** 69 | * @return array normalize user attribute map. 70 | */ 71 | public function getNormalizeUserAttributeMap(): array 72 | { 73 | if (empty($this->normalizeUserAttributeMap)) { 74 | $this->normalizeUserAttributeMap = $this->defaultNormalizeUserAttributeMap(); 75 | } 76 | 77 | return $this->normalizeUserAttributeMap; 78 | } 79 | 80 | /** 81 | * Returns the default {@see normalizeUserAttributeMap} value. 82 | * Particular client may override this method in order to provide specific default map. 83 | * 84 | * @return array normalize attribute map. 85 | * 86 | * @psalm-return array 87 | */ 88 | protected function defaultNormalizeUserAttributeMap(): array 89 | { 90 | return []; 91 | } 92 | 93 | /** 94 | * @return array view options in format: optionName => optionValue 95 | */ 96 | #[\Override] 97 | public function getViewOptions(): array 98 | { 99 | if (empty($this->viewOptions)) { 100 | $this->viewOptions = $this->defaultViewOptions(); 101 | } 102 | 103 | return $this->viewOptions; 104 | } 105 | 106 | /** 107 | * Returns the default {@see viewOptions} value. 108 | * Particular client may override this method in order to provide specific default view options. 109 | * 110 | * @return array list of default {@see viewOptions} 111 | * 112 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 113 | */ 114 | protected function defaultViewOptions(): array 115 | { 116 | return [ 117 | 'popupWidth' => 860, 118 | 'popupHeight' => 480, 119 | ]; 120 | } 121 | 122 | #[\Override] 123 | abstract public function buildAuthUrl(ServerRequestInterface $incomingRequest, array $params): string; 124 | 125 | public function createRequest(string $method, string $uri): RequestInterface 126 | { 127 | return $this->requestFactory->createRequest($method, $uri); 128 | } 129 | 130 | /** 131 | * Sets persistent state. 132 | * 133 | * @param string $key state key. 134 | * @param mixed $value state value 135 | * 136 | * @return $this the object itself 137 | */ 138 | protected function setState(string $key, $value): self 139 | { 140 | $this->stateStorage->set($this->getStateKeyPrefix() . $key, $value); 141 | return $this; 142 | } 143 | 144 | /** 145 | * Returns session key prefix, which is used to store internal states. 146 | * 147 | * @return string session key prefix. 148 | */ 149 | protected function getStateKeyPrefix(): string 150 | { 151 | return static::class . '_' . $this->getName() . '_'; 152 | } 153 | 154 | /** 155 | * Returns persistent state value. 156 | * 157 | * @param string $key state key. 158 | * 159 | * @return mixed state value. 160 | */ 161 | protected function getState(string $key): mixed 162 | { 163 | return $this->stateStorage->get($this->getStateKeyPrefix() . $key); 164 | } 165 | 166 | /** 167 | * Removes persistent state value. 168 | * 169 | * @param string $key state key. 170 | */ 171 | protected function removeState(string $key): void 172 | { 173 | $this->stateStorage->remove($this->getStateKeyPrefix() . $key); 174 | } 175 | 176 | protected function sendRequest(RequestInterface $request): ResponseInterface 177 | { 178 | return $this->httpClient->sendRequest($request); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Client/MicrosoftOnline.php: -------------------------------------------------------------------------------- 1 | 23 | * 24 | * https://learn.microsoft.com/en-us/azure/active-directory-b2c/tutorial-register-applications 25 | * 26 | * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow 27 | */ 28 | final class MicrosoftOnline extends OAuth2 29 | { 30 | /** 31 | * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#protocol-details 32 | */ 33 | protected string $authUrl = 'https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/authorize'; 34 | 35 | protected string $tokenUrl = 'https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token'; 36 | 37 | protected string $endpoint = 'https://graph.microsoft.com/v1.0/me'; 38 | 39 | /** 40 | * tenant can be one of 'common', 'organisation', 'consumers', or the actual TenantID. 41 | * @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code 42 | */ 43 | protected string $tenant = 'common'; 44 | 45 | public function setTenant(string $tenant): void 46 | { 47 | $this->tenant = $tenant; 48 | } 49 | 50 | public function getTenant(): string 51 | { 52 | return $this->tenant; 53 | } 54 | 55 | #[\Override] 56 | public function setAuthUrl(string $authUrl): void 57 | { 58 | $this->authUrl = $authUrl; 59 | } 60 | 61 | public function getAuthUrlWithTenantInserted(string $tenant): string 62 | { 63 | return 'https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/authorize'; 64 | } 65 | 66 | #[\Override] 67 | public function setTokenUrl(string $tokenUrl): void 68 | { 69 | $this->tokenUrl = $tokenUrl; 70 | } 71 | 72 | public function getTokenUrlWithTenantInserted(string $tenant): string 73 | { 74 | return 'https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/token'; 75 | } 76 | 77 | /** 78 | * Fetch current user information using PSR-18 HTTP Client and PSR-17 Request Factory. 79 | * 80 | * @param OAuthToken $token 81 | * @param ClientInterface $httpClient 82 | * @param RequestFactoryInterface $requestFactory 83 | * @return array 84 | */ 85 | public function getCurrentUserJsonArray( 86 | OAuthToken $token, 87 | ClientInterface $httpClient, 88 | RequestFactoryInterface $requestFactory 89 | ): array { 90 | $tokenString = (string)$token->getParam('access_token'); 91 | if (strlen($tokenString) === 0) { 92 | return []; 93 | } 94 | 95 | $request = $requestFactory->createRequest('GET', 'https://graph.microsoft.com/v1.0/me') 96 | ->withHeader('Authorization', 'Bearer ' . $tokenString) 97 | ->withHeader('Content-Type', 'application/json'); 98 | 99 | try { 100 | /** @var ResponseInterface $response */ 101 | $response = $httpClient->sendRequest($request); 102 | $body = $response->getBody()->getContents(); 103 | if (strlen($body) > 0) { 104 | return (array)json_decode($body, true); 105 | } 106 | } catch (\Throwable) { 107 | return []; 108 | } 109 | 110 | return []; 111 | } 112 | 113 | protected function initUserAttributes(): array 114 | { 115 | $token = $this->getAccessToken(); 116 | if ($token instanceof OAuthToken) { 117 | // Use $this->httpClient and $this->requestFactory from the parent OAuth2 class 118 | return $this->getCurrentUserJsonArray($token, $this->httpClient, $this->requestFactory); 119 | } 120 | return []; 121 | } 122 | 123 | #[\Override] 124 | public function getName(): string 125 | { 126 | return 'microsoftonline'; 127 | } 128 | 129 | #[\Override] 130 | public function getTitle(): string 131 | { 132 | return 'MicrosoftOnline'; 133 | } 134 | 135 | #[\Override] 136 | public function getButtonClass(): string 137 | { 138 | return 'btn btn-warning bi bi-microsoft'; 139 | } 140 | 141 | /** 142 | * @return int[] 143 | * 144 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 145 | */ 146 | #[\Override] 147 | protected function defaultViewOptions(): array 148 | { 149 | return [ 150 | 'popupWidth' => 860, 151 | 'popupHeight' => 480, 152 | ]; 153 | } 154 | 155 | /** 156 | * Purpose: Use this scope to be able to get the User's id and to build a suitable login using a sub string of the user id 157 | * @return string 158 | * 159 | * @psalm-return 'offline_access User.Read' 160 | */ 161 | #[\Override] 162 | protected function getDefaultScope(): string 163 | { 164 | return 'offline_access User.Read'; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Signature/RsaSha.php: -------------------------------------------------------------------------------- 1 | **Note:** This class requires PHP "OpenSSL" extension({@link https://php.net/manual/en/book.openssl.php}). 17 | */ 18 | final class RsaSha extends Signature 19 | { 20 | /** 21 | * @var string path to the file, which holds private key certificate. 22 | */ 23 | private string $privateCertificateFile; 24 | /** 25 | * @var string path to the file, which holds public key certificate. 26 | */ 27 | private string $publicCertificateFile; 28 | /** 29 | * @var int|string signature hash algorithm, e.g. `OPENSSL_ALGO_SHA1`, `OPENSSL_ALGO_SHA256` and so on. 30 | * 31 | * @link https://php.net/manual/en/openssl.signature-algos.php 32 | */ 33 | private $algorithm; 34 | 35 | /** 36 | * @var string|null OpenSSL private key certificate content. 37 | * This value can be fetched from file specified by {@see privateCertificateFile}. 38 | */ 39 | private ?string $privateCertificate = null; 40 | /** 41 | * @var string|null OpenSSL public key certificate content. 42 | * This value can be fetched from file specified by {@see publicCertificateFile}. 43 | */ 44 | private ?string $publicCertificate = null; 45 | 46 | public function __construct(string $algorithm = '') 47 | { 48 | if (!function_exists('openssl_sign')) { 49 | throw new NotSupportedException('PHP "OpenSSL" extension is required.'); 50 | } 51 | } 52 | 53 | /** 54 | * @param string $publicCertificateFile public key certificate file. 55 | */ 56 | public function setPublicCertificateFile(string $publicCertificateFile): void 57 | { 58 | $this->publicCertificateFile = $publicCertificateFile; 59 | } 60 | 61 | /** 62 | * @param string $privateCertificateFile private key certificate file. 63 | */ 64 | public function setPrivateCertificateFile(string $privateCertificateFile): void 65 | { 66 | $this->privateCertificateFile = $privateCertificateFile; 67 | } 68 | 69 | #[\Override] 70 | public function getName(): string 71 | { 72 | if (is_int($this->algorithm)) { 73 | $constants = get_defined_constants(true); 74 | if (isset($constants['openssl'])) { 75 | foreach ($constants['openssl'] as $name => $value) { 76 | if (!str_starts_with($name, 'OPENSSL_ALGO_')) { 77 | continue; 78 | } 79 | if ($value === $this->algorithm) { 80 | $algorithmName = substr($name, strlen('OPENSSL_ALGO_')); 81 | break; 82 | } 83 | } 84 | } 85 | 86 | if (!isset($algorithmName)) { 87 | throw new InvalidConfigException("Unable to determine name of algorithm '{$this->algorithm}'"); 88 | } 89 | } else { 90 | $algorithmName = strtoupper($this->algorithm); 91 | } 92 | return 'RSA-' . (string) $algorithmName; 93 | } 94 | 95 | #[\Override] 96 | public function generateSignature(string $baseString, string $key): string 97 | { 98 | $privateCertificateContent = $this->getPrivateCertificate(); 99 | 100 | // For PHP 8+, you can pass the PEM string directly to openssl_sign() 101 | openssl_sign($baseString, $signature, $privateCertificateContent, $this->algorithm); 102 | 103 | return base64_encode($signature); 104 | } 105 | 106 | /** 107 | * @return string private key certificate content. 108 | */ 109 | public function getPrivateCertificate(): string 110 | { 111 | if ($this->privateCertificate === null) { 112 | $this->privateCertificate = $this->initPrivateCertificate(); 113 | } 114 | 115 | return $this->privateCertificate; 116 | } 117 | 118 | /** 119 | * Creates initial value for {@see privateCertificate}. 120 | * This method will attempt to fetch the certificate value from {@see privateCertificateFile} file. 121 | * 122 | * @throws InvalidConfigException on failure. 123 | * 124 | * @return string private certificate content. 125 | */ 126 | protected function initPrivateCertificate(): string 127 | { 128 | if (!empty($this->privateCertificateFile)) { 129 | if (!file_exists($this->privateCertificateFile)) { 130 | throw new InvalidConfigException( 131 | "Private certificate file '{$this->privateCertificateFile}' does not exist!" 132 | ); 133 | } 134 | $privateCertificateFile = file_get_contents($this->privateCertificateFile); 135 | if ($privateCertificateFile === false) { 136 | throw new InvalidConfigException('Failed to fetch private certificate file'); 137 | } 138 | return $privateCertificateFile; 139 | } 140 | return ''; 141 | } 142 | 143 | #[\Override] 144 | public function verify(string $signature, string $baseString, string $key): bool 145 | { 146 | $decodedSignature = base64_decode($signature); 147 | // Fetch the public key cert based on the request 148 | $publicCertificate = $this->getPublicCertificate(); 149 | // Pull the public key ID from the certificate 150 | $publicKeyId = openssl_pkey_get_public($publicCertificate); 151 | // Check the computed signature against the one passed in the query 152 | $verificationResult = openssl_verify($baseString, $decodedSignature, $publicKeyId, $this->algorithm); 153 | // Release the key resource 154 | if (PHP_MAJOR_VERSION < 8) { 155 | openssl_pkey_free($publicKeyId); 156 | } 157 | 158 | return $verificationResult === 1; 159 | } 160 | 161 | /** 162 | * @return string public key certificate content. 163 | */ 164 | public function getPublicCertificate(): string 165 | { 166 | if ($this->publicCertificate === null) { 167 | $this->publicCertificate = $this->initPublicCertificate(); 168 | } 169 | 170 | return $this->publicCertificate; 171 | } 172 | 173 | /** 174 | * Creates initial value for {@see publicCertificate}. 175 | * This method will attempt to fetch the certificate value from {@see publicCertificateFile} file. 176 | * 177 | * @throws InvalidConfigException on failure. 178 | * 179 | * @return string public certificate content. 180 | */ 181 | protected function initPublicCertificate(): string 182 | { 183 | $content = ''; 184 | if (!empty($this->publicCertificateFile)) { 185 | if (!file_exists($this->publicCertificateFile)) { 186 | throw new InvalidConfigException( 187 | "Public certificate file '{$this->publicCertificateFile}' does not exist!" 188 | ); 189 | } 190 | $fp = fopen($this->publicCertificateFile, 'rb'); 191 | 192 | $fgetsFp = fgets($fp); 193 | while (!feof($fp) && is_string($fgetsFp)) { 194 | $content .= $fgetsFp; 195 | } 196 | fclose($fp); 197 | } 198 | return $content; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Client/VKontakte.php: -------------------------------------------------------------------------------- 1 | . 17 | * @see https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/start-integration/auth-without-sdk/auth-without-sdk-web 18 | * @see https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/start-integration/how-auth-works/auth-flow-web 19 | * @see https://id.vk.ru/about/business/go/accounts/{USER}/apps/{APPLICATION_ID}/edit 20 | * 21 | * Authorization Code Workflow Client Id => VKontakte Application Id 22 | * Authorization Code Workflow Secret Id => Access Keys: Protected Key ... to perform requests to the VKontakte API on behalf of the application (used here) 23 | * Access Keys: Service Key ... to perform requests to the VKontakte API on behalf of the application (not used here) 24 | * when user authorization is not required 25 | */ 26 | final class VKontakte extends OAuth2 27 | { 28 | protected string $authUrl = 'https://id.vk.ru/authorize'; 29 | 30 | protected string $tokenUrl = 'https://id.vk.ru/oauth2/auth'; 31 | 32 | protected string $endpoint = 'https://id.vk.ru/oauth2/user_info'; 33 | 34 | /** 35 | * Example answer: [ 36 | * 'access_token' => 'XXXXX', 37 | * 'refresh_token' => 'XXXXX', 38 | * 'expires_in' => 0, 39 | * 'user_id' => 1234567890, 40 | * 'state' => 'XXX', 41 | * 'scope' => 'email phone' 42 | * ] 43 | * 44 | * @see https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/start-integration/auth-without-sdk/auth-without-sdk-web 45 | * Step 6: Getting New Access Token After Previous Token Expires 46 | * 47 | * @param string $refreshToken 48 | * @param string $clientId 49 | * @param string $deviceId 50 | * @param string $state 51 | * @param ClientInterface $httpClient 52 | * @param RequestFactoryInterface $requestFactory 53 | * @return mixed 54 | */ 55 | public function step6GettingNewAccessTokenAfterPreviousExpires( 56 | string $refreshToken, 57 | string $clientId, 58 | string $deviceId, 59 | string $state, 60 | ClientInterface $httpClient, 61 | RequestFactoryInterface $requestFactory 62 | ): mixed { 63 | $url = $this->tokenUrl; 64 | $data = [ 65 | 'grant_type' => 'refresh_token', 66 | 'refresh_token' => $refreshToken, 67 | 'client_id' => $clientId, 68 | 'device_id' => $deviceId, 69 | 'state' => $state, 70 | ]; 71 | 72 | $request = $requestFactory->createRequest('POST', $url) 73 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded'); 74 | 75 | // Add form body 76 | $request->getBody()->write(http_build_query($data)); 77 | 78 | try { 79 | $response = $httpClient->sendRequest($request); 80 | $body = $response->getBody()->getContents(); 81 | if ($response->getStatusCode() >= 400) { 82 | return [ 83 | 'error' => 'Error:' . $response->getReasonPhrase(), 84 | ]; 85 | } 86 | if (strlen($body) > 0) { 87 | return json_decode($body, true); 88 | } 89 | } catch (\Throwable $e) { 90 | return [ 91 | 'error' => 'Exception: ' . $e->getMessage(), 92 | ]; 93 | } 94 | 95 | return []; 96 | } 97 | 98 | /** 99 | * Example answer: ["response" => 1] 100 | * 101 | * @see https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/start-integration/auth-without-sdk/auth-without-sdk-web 102 | * #Step 7. Token invalidation (logout) 103 | * 104 | * Converted to use PSR-18 ClientInterface and PSR-17 RequestFactoryInterface instead of curl. 105 | */ 106 | public function step7TokenInvalidationWithClientId( 107 | OAuthToken $token, 108 | string $clientId, 109 | ClientInterface $httpClient, 110 | RequestFactoryInterface $requestFactory 111 | ): array { 112 | $url = 'https://id.vk.ru/oauth2/user_info'; 113 | $tokenString = (string)$token->getParam('access_token'); 114 | 115 | if (strlen($tokenString) === 0) { 116 | return []; 117 | } 118 | 119 | $fullUrl = $url . '?client_id=' . urlencode($clientId) . '&access_token=' . urlencode($tokenString); 120 | 121 | $request = $requestFactory->createRequest('GET', $fullUrl); 122 | 123 | try { 124 | /** @var ResponseInterface $response */ 125 | $response = $httpClient->sendRequest($request); 126 | $body = $response->getBody()->getContents(); 127 | if (!empty($body)) { 128 | return (array) json_decode($body, true); 129 | } 130 | } catch (\Throwable) { 131 | // Optionally log error: $e->getMessage() 132 | return []; 133 | } 134 | 135 | return []; 136 | } 137 | 138 | /** 139 | * Example Answer: 140 | * [ 141 | * "user" => [ 142 | * "user_id" => "1234567890", 143 | * "first_name" => "Ivan", 144 | * "last_name" => "Ivanov", 145 | * "phone" => "79991234567", 146 | * "avatar" => "https://pp.userapi.com/60tZWMo4SmwcploUVl9XEt8ufnTTvDUmQ6Bj1g/mmv1pcj63C4.png", 147 | * "email" => "ivan_i123@vk.ru", 148 | * "sex" => 2, 149 | * "verified" => false, 150 | * "birthday" => "01.01.2000" 151 | * ] 152 | * ] 153 | * 154 | * @see https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/start-integration/auth-without-sdk/auth-without-sdk-web 155 | * #Step 8. (Optional) Obtaining user data 156 | */ 157 | public function step8ObtainingUserDataArrayWithClientId( 158 | OAuthToken $token, 159 | string $clientId, 160 | ClientInterface $httpClient, 161 | RequestFactoryInterface $requestFactory 162 | ): array { 163 | $url = 'https://id.vk.ru/oauth2/user_info'; 164 | $tokenString = (string)$token->getParam('access_token'); 165 | 166 | if (strlen($tokenString) === 0) { 167 | return []; 168 | } 169 | 170 | $fullUrl = $url . '?client_id=' . urlencode($clientId) . '&access_token=' . urlencode($tokenString); 171 | 172 | $request = $requestFactory->createRequest('GET', $fullUrl); 173 | 174 | try { 175 | /** @var ResponseInterface $response */ 176 | $response = $httpClient->sendRequest($request); 177 | $body = $response->getBody()->getContents(); 178 | if (strlen($body) > 0) { 179 | return (array)json_decode($body, true); 180 | } 181 | } catch (\Throwable) { 182 | // Optionally log error: $e->getMessage() 183 | return []; 184 | } 185 | 186 | return []; 187 | } 188 | 189 | /** 190 | * Example answer: [ 191 | * "user" => [ 192 | * "user_id" => "1234567890", 193 | * "first_name" => "Ivan", 194 | * "last_name" => "Ivanov", 195 | * "avatar" => "https://pp.userapi.com/60tZWMo4SmwcploUVl9XEt8ufnTTvDUmQ6Bj1g/mmv1pcj63C4.png", 196 | * "sex" => 2, 197 | * "verified" => false 198 | * ] 199 | * ] 200 | * 201 | * @see https://id.vk.ru/about/business/go/docs/ru/vkid/latest/vk-id/connection/start-integration/auth-without-sdk/auth-without-sdk-web 202 | * #Step 9. (Optional) Getting public user data 203 | */ 204 | public function step9GetPublicUserDataArrayWithClientId( 205 | string $clientId, 206 | string $userId, 207 | ClientInterface $httpClient, 208 | RequestFactoryInterface $requestFactory 209 | ): array { 210 | $fullUrl = $this->endpoint . '?client_id=' . urlencode($clientId) . '&user_id=' . urlencode($userId); 211 | 212 | $request = $requestFactory->createRequest('GET', $fullUrl); 213 | 214 | try { 215 | /** @var ResponseInterface $response */ 216 | $response = $httpClient->sendRequest($request); 217 | $body = $response->getBody()->getContents(); 218 | if (strlen($body) > 0) { 219 | return (array) json_decode($body, true); 220 | } 221 | } catch (\Throwable) { 222 | // Optionally log error: $e->getMessage() 223 | return []; 224 | } 225 | 226 | return []; 227 | } 228 | 229 | #[\Override] 230 | public function getName(): string 231 | { 232 | return 'vkontakte'; 233 | } 234 | 235 | #[\Override] 236 | public function getTitle(): string 237 | { 238 | return 'VKontakte'; 239 | } 240 | 241 | #[\Override] 242 | public function getButtonClass(): string 243 | { 244 | return 'btn btn-dark'; 245 | } 246 | 247 | /** 248 | * @return int[] 249 | * 250 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 251 | */ 252 | #[\Override] 253 | protected function defaultViewOptions(): array 254 | { 255 | return [ 256 | 'popupWidth' => 860, 257 | 'popupHeight' => 480, 258 | ]; 259 | } 260 | 261 | /** 262 | * @return string 263 | * 264 | * @psalm-return 'email phone' 265 | */ 266 | #[\Override] 267 | protected function getDefaultScope(): string 268 | { 269 | return 'email phone'; 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/Client/Facebook.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * Example application configuration: 19 | * 20 | * config/common/params.php 21 | * 22 | * 'yiisoft/yii-auth-client' => [ 23 | * 'enabled' => true, 24 | * 'clients' => [ 25 | * 'facebook' => [ 26 | * 'class' => 'Yiisoft\Yii\AuthClient\Client\Facebook::class', 27 | * 'clientId' => $_ENV['FACEBOOK_API_CLIENT_ID'] ?? '', 28 | * 'clientSecret' => $_ENV['FACEBOOK_API_CLIENT_SECRET'] ?? '', 29 | * 'returnUrl' => $_ENV['FACEBOOK_API_CLIENT_RETURN_URL'] ?? '', 30 | * ], 31 | * ], 32 | * ], 33 | * 34 | * @link https://developers.facebook.com/apps 35 | * @link https://developers.facebook.com/docs/graph-api 36 | */ 37 | final class Facebook extends OAuth2 38 | { 39 | protected string $graphApiVersion = 'v23.0'; 40 | protected string $authUrl = 'https://www.facebook.com/dialog/oauth'; 41 | protected string $tokenUrl = 'https://graph.facebook.com/oauth/access_token'; 42 | protected string $endpoint = 'https://graph.facebook.com'; 43 | /** @var string[] */ 44 | protected array $endpointFields = ['id', 'name', 'first_name', 'last_name']; 45 | protected bool $autoRefreshAccessToken = false; // Facebook does not provide access token refreshment 46 | 47 | /** 48 | * @var bool whether to automatically upgrade short-live (2 hours) access token to long-live (60 days) one, after fetching it. 49 | * 50 | * @see exchangeToken() 51 | */ 52 | private bool $autoExchangeAccessToken = false; 53 | 54 | /** 55 | * @var string URL endpoint for the client auth code generation. 56 | * 57 | * @link https://developers.facebook.com/docs/facebook-login/access-tokens/expiration-and-extension 58 | * @see fetchClientAuthCode() 59 | * @see fetchClientAccessToken() 60 | */ 61 | private string $clientAuthCodeUrl = 'https://graph.facebook.com/oauth/client_code'; 62 | 63 | public function getCurrentUserJsonArray(OAuthToken $token): array 64 | { 65 | $params = $token->getParams(); 66 | $finalValue = ''; 67 | $finalValue = array_key_last($params); 68 | 69 | /** 70 | * @var string $finalValue 71 | * @var array $array 72 | */ 73 | $array = json_decode($finalValue, true); 74 | $tokenString = (string)($array['access_token'] ?? ''); 75 | 76 | if ($tokenString !== '') { 77 | $queryParams = [ 78 | 'fields' => implode(',', $this->endpointFields), 79 | ]; 80 | $url = sprintf( 81 | $this->endpoint . '/%s/me?%s', 82 | urlencode($this->graphApiVersion), 83 | http_build_query($queryParams) 84 | ); 85 | $request = $this->createRequest('GET', $url); 86 | $request = RequestUtil::addHeaders( 87 | $request, 88 | [ 89 | 'Authorization' => 'Bearer ' . $tokenString, 90 | ] 91 | ); 92 | $response = $this->sendRequest($request); 93 | return (array) json_decode($response->getBody()->getContents(), true); 94 | } 95 | return []; 96 | } 97 | 98 | protected function initUserAttributes(): array 99 | { 100 | $token = $this->getAccessToken(); 101 | if ($token instanceof OAuthToken) { 102 | return $this->getCurrentUserJsonArray($token); 103 | } 104 | return []; 105 | } 106 | 107 | #[\Override] 108 | public function applyAccessTokenToRequest(RequestInterface $request, OAuthToken $accessToken): RequestInterface 109 | { 110 | $request = parent::applyAccessTokenToRequest($request, $accessToken); 111 | $params = []; 112 | if (!empty($machineId = (string)$accessToken->getParam('machine_id'))) { 113 | $params['machine_id'] = $machineId; 114 | } 115 | $token = $accessToken->getToken(); 116 | if (null !== $token) { 117 | $params['appsecret_proof'] = hash_hmac('sha256', $token, $this->clientSecret); 118 | } 119 | return RequestUtil::addParams($request, $params); 120 | } 121 | 122 | #[\Override] 123 | public function fetchAccessToken(ServerRequestInterface $incomingRequest, string $authCode, array $params = []): OAuthToken 124 | { 125 | $token = parent::fetchAccessToken($incomingRequest, $authCode, $params); 126 | if ($this->autoExchangeAccessToken) { 127 | $token = $this->exchangeAccessToken($token); 128 | } 129 | return $token; 130 | } 131 | 132 | /** 133 | * Exchanges short-live (2 hours) access token to long-live (60 days) one. 134 | * Note that this method will success for already long-live token, but will not actually prolong it any further. 135 | * Pay attention, that this method will fail on already expired access token. 136 | * 137 | * @link https://developers.facebook.com/docs/facebook-login/access-tokens/expiration-and-extension 138 | * 139 | * @param OAuthToken $token short-live access token. 140 | * 141 | * @return OAuthToken long-live access token. 142 | */ 143 | public function exchangeAccessToken(OAuthToken $token): OAuthToken 144 | { 145 | [ 146 | 'grant_type' => 'fb_exchange_token', 147 | 'fb_exchange_token' => $token->getToken(), 148 | ]; 149 | 150 | $request = $this->createRequest('POST', $this->getTokenUrl()); 151 | //->setParams($params); 152 | $this->applyClientCredentialsToRequest($request); 153 | $response = $this->sendRequest($request); 154 | 155 | $token = $this->createToken(['params' => $response]); 156 | $this->setAccessToken($token); 157 | 158 | return $token; 159 | } 160 | 161 | /** 162 | * Requests the authorization code for the client-specific access token. 163 | * This make sense for the distributed applications, which provides several Auth clients (web and mobile) 164 | * to avoid triggering Facebook's automated spam systems. 165 | * 166 | * @link https://developers.facebook.com/docs/facebook-login/access-tokens/expiration-and-extension 167 | * 168 | * @see fetchClientAccessToken() 169 | * 170 | * @param ServerRequestInterface $incomingRequest 171 | * @param OAuthToken|null $token access token, if not set {@see accessToken} will be used. 172 | * @param array $params additional request params. 173 | * 174 | * @return numeric-string client auth code. 175 | */ 176 | public function fetchClientAuthCode( 177 | ServerRequestInterface $incomingRequest, 178 | OAuthToken $token = null, 179 | array $params = [] 180 | ): string { 181 | if ($token === null) { 182 | $token = $this->getAccessToken(); 183 | } 184 | if (null !== $token) { 185 | $params = array_merge( 186 | [ 187 | 'access_token' => $token->getToken(), 188 | 'redirect_uri' => $this->getReturnUrl($incomingRequest), 189 | ], 190 | $params 191 | ); 192 | } 193 | $request = $this->createRequest('POST', $this->clientAuthCodeUrl); 194 | $request = RequestUtil::addParams($request, $params); 195 | 196 | $request = $this->applyClientCredentialsToRequest($request); 197 | 198 | $response = $this->sendRequest($request); 199 | 200 | return (string)$response->getStatusCode(); 201 | } 202 | 203 | /** 204 | * Fetches access token from client-specific authorization code. 205 | * This make sense for the distributed applications, which provides several Auth clients (web and mobile) 206 | * to avoid triggering Facebook's automated spam systems. 207 | * 208 | * @link https://developers.facebook.com/docs/facebook-login/access-tokens/expiration-and-extension 209 | * @see fetchClientAuthCode() 210 | * 211 | * @param ServerRequestInterface $incomingRequest 212 | * @param string $authCode client auth code. 213 | * @param array $params 214 | * 215 | * @return OAuthToken long-live client-specific access token. 216 | */ 217 | public function fetchClientAccessToken( 218 | ServerRequestInterface $incomingRequest, 219 | string $authCode, 220 | array $params = [] 221 | ): OAuthToken { 222 | $params = array_merge( 223 | [ 224 | 'code' => $authCode, 225 | 'redirect_uri' => $this->getReturnUrl($incomingRequest), 226 | 'client_id' => $this->clientId, 227 | ], 228 | $params 229 | ); 230 | 231 | $request = $this->createRequest('POST', $this->getTokenUrl()); 232 | $request = RequestUtil::addParams($request, $params); 233 | 234 | $response = $this->sendRequest($request); 235 | 236 | $token = $this->createToken(['params' => $response]); 237 | $this->setAccessToken($token); 238 | 239 | return $token; 240 | } 241 | 242 | #[\Override] 243 | public function getName(): string 244 | { 245 | return 'facebook'; 246 | } 247 | 248 | #[\Override] 249 | public function getTitle(): string 250 | { 251 | return 'Facebook'; 252 | } 253 | 254 | #[\Override] 255 | public function getButtonClass(): string 256 | { 257 | return 'btn btn-primary bi bi-facebook'; 258 | } 259 | 260 | /** 261 | * @return int[] 262 | * 263 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 264 | */ 265 | #[\Override] 266 | protected function defaultViewOptions(): array 267 | { 268 | return [ 269 | 'popupWidth' => 860, 270 | 'popupHeight' => 480, 271 | ]; 272 | } 273 | 274 | /** 275 | * @return string 276 | * 277 | * @psalm-return 'public_profile' 278 | */ 279 | #[\Override] 280 | protected function getDefaultScope(): string 281 | { 282 | return 'public_profile'; 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /src/OAuth.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 77 | } 78 | 79 | public function getYiisoftFactory(): YiisoftFactory 80 | { 81 | return $this->factory; 82 | } 83 | 84 | public function setAuthUrl(string $authUrl): void 85 | { 86 | $this->authUrl = $authUrl; 87 | } 88 | 89 | /** 90 | * @param ServerRequestInterface $request 91 | * 92 | * @return string return URL. 93 | */ 94 | public function getReturnUrl(ServerRequestInterface $request): string 95 | { 96 | if ($this->returnUrl === '') { 97 | $this->returnUrl = $this->defaultReturnUrl($request); 98 | } 99 | return $this->returnUrl; 100 | } 101 | 102 | /** 103 | * @param string $returnUrl return URL 104 | */ 105 | public function setReturnUrl(string $returnUrl): void 106 | { 107 | $this->returnUrl = $returnUrl; 108 | } 109 | 110 | /** 111 | * Composes default {@see returnUrl} value. 112 | * 113 | * @param ServerRequestInterface $request 114 | * 115 | * @return string return URL. 116 | */ 117 | protected function defaultReturnUrl(ServerRequestInterface $request): string 118 | { 119 | return (string)$request->getUri(); 120 | } 121 | 122 | /** 123 | * Performs request to the OAuth API returning response data. 124 | * You may use {@see createApiRequest()} method instead, gaining more control over request execution. 125 | * 126 | * @param string $apiSubUrl API sub URL, which will be append to {@see apiBaseUrl}, or absolute API URL. 127 | * @param string $method request method. 128 | * @param array|string $data request data or content. 129 | * @param array $headers additional request headers. 130 | * 131 | * @throws Exception 132 | * 133 | * @return array API response data. 134 | * 135 | * @see createApiRequest() 136 | */ 137 | public function api($apiSubUrl, $method = 'GET', $data = [], $headers = []): array 138 | { 139 | $request = $this->createApiRequest($method, $apiSubUrl); 140 | $request = RequestUtil::addHeaders($request, $headers); 141 | 142 | if (!empty($data)) { 143 | if (is_array($data)) { 144 | $request = RequestUtil::addParams($request, $data); 145 | } else { 146 | $request->getBody()->write($data); 147 | } 148 | } 149 | 150 | $request = $this->beforeApiRequestSend($request); 151 | $response = $this->sendRequest($request); 152 | 153 | if ($response->getStatusCode() !== 200) { 154 | throw new InvalidResponseException( 155 | $response, 156 | 'Request failed with code: ' . $response->getStatusCode() . ', message: ' . (string)$response->getBody() 157 | ); 158 | } 159 | 160 | return (array)Json::decode($response->getBody()->getContents()); 161 | } 162 | 163 | /** 164 | * Creates an HTTP request for the API call. 165 | * The created request will be automatically processed adding access token parameters and signature 166 | * before sending. You may use {@see createRequest()} to gain full control over request composition and execution. 167 | * 168 | * @param string $method 169 | * @param string $uri 170 | * 171 | * @return RequestInterface HTTP request instance. 172 | * 173 | * @see createRequest() 174 | */ 175 | public function createApiRequest(string $method, string $uri): RequestInterface 176 | { 177 | return $this->createRequest($method, $this->endpoint . $uri); 178 | } 179 | 180 | public function beforeApiRequestSend(RequestInterface $request): RequestInterface 181 | { 182 | $accessToken = $this->getAccessToken(); 183 | if (!is_object($accessToken) || !$accessToken->getIsValid()) { 184 | throw new Exception('Invalid access token.'); 185 | } 186 | 187 | return $this->applyAccessTokenToRequest($request, $accessToken); 188 | } 189 | 190 | /** 191 | * @return OAuthToken|null auth token instance. 192 | */ 193 | public function getAccessToken(): ?OAuthToken 194 | { 195 | if (!is_object($this->accessToken)) { 196 | $this->accessToken = $this->restoreAccessToken(); 197 | } 198 | 199 | return $this->accessToken; 200 | } 201 | 202 | /** 203 | * Sets access token to be used. 204 | * 205 | * @param array|OAuthToken $token access token or its configuration. 206 | */ 207 | public function setAccessToken(array|OAuthToken $token): void 208 | { 209 | if (is_array($token) && !empty($token)) { 210 | /** 211 | * @psalm-suppress MixedAssignment $newToken 212 | */ 213 | $newToken = $this->createToken($token); 214 | /** 215 | * @psalm-suppress MixedAssignment $this->accessToken 216 | */ 217 | $this->accessToken = $newToken; 218 | /** 219 | * @psalm-suppress MixedArgument $newToken 220 | */ 221 | $this->saveAccessToken($newToken); 222 | } 223 | if ($token instanceof OAuthToken) { 224 | $this->accessToken = $token; 225 | $this->saveAccessToken($token); 226 | } 227 | } 228 | 229 | /** 230 | * Restores access token. 231 | * 232 | * @return OAuthToken|null 233 | */ 234 | protected function restoreAccessToken(): ?OAuthToken 235 | { 236 | /** 237 | * @psalm-suppress MixedAssignment $token 238 | */ 239 | if (($token = $this->getState('token')) instanceof OAuthToken) { 240 | if ($token->getIsExpired() && $this->autoRefreshAccessToken) { 241 | return $this->refreshAccessToken($token); 242 | } 243 | return $token; 244 | } 245 | return null; 246 | } 247 | 248 | /** 249 | * Gets new auth token to replace expired one. 250 | * 251 | * @param OAuthToken $token expired auth token. 252 | * 253 | * @return OAuthToken new auth token. 254 | */ 255 | abstract public function refreshAccessToken(OAuthToken $token): OAuthToken; 256 | 257 | /** 258 | * Applies access token to the HTTP request instance. 259 | * 260 | * @param RequestInterface $request HTTP request instance. 261 | * @param OAuthToken $accessToken access token instance. 262 | */ 263 | abstract public function applyAccessTokenToRequest( 264 | RequestInterface $request, 265 | OAuthToken $accessToken 266 | ): RequestInterface; 267 | 268 | /** 269 | * Creates token from its configuration. 270 | * 271 | * @param array $tokenConfig token configuration. 272 | * 273 | * @throws \Yiisoft\Definitions\Exception\InvalidConfigException 274 | * @see Yiisoft\Factory\Factory 275 | * @psalm-suppress MixedReturnStatement 276 | * @psalm-suppress MixedInferredReturnType OAuthToken 277 | */ 278 | protected function createToken(array $tokenConfig): OAuthToken 279 | { 280 | if (!array_key_exists('class', $tokenConfig)) { 281 | $tokenConfig['class'] = OAuthToken::class; 282 | } 283 | return $this->factory->create($tokenConfig['class']); 284 | } 285 | 286 | /** 287 | * Saves token as persistent state. 288 | * 289 | * @param OAuthToken|null $token auth token to be saved. 290 | * 291 | * @return $this the object itself. 292 | */ 293 | protected function saveAccessToken(OAuthToken $token = null): self 294 | { 295 | return $this->setState('token', $token); 296 | } 297 | 298 | /** 299 | * @return string 300 | */ 301 | public function getScope(): string 302 | { 303 | if ($this->scope === null) { 304 | return $this->getDefaultScope(); 305 | } 306 | 307 | return $this->scope; 308 | } 309 | 310 | /** 311 | * @return string 312 | * 313 | * @psalm-return '' 314 | */ 315 | protected function getDefaultScope(): string 316 | { 317 | return ''; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Widget/AuthChoice.php: -------------------------------------------------------------------------------- 1 | authRoute('site/auth'); ?> 32 | * ``` 33 | * 34 | * You can customize the widget appearance by using {@see begin()} and {@see end()} syntax 35 | * along with using method {@see clientLink()} or {@see createClientUrl()}. 36 | * For example: 37 | * 38 | * ```php 39 | * 42 | * ['site/auth'] 44 | * ]); ?> 45 | *
    46 | * getClients() as $client): ?> 47 | *
  • clientLink($client) ?>
  • 48 | * 49 | *
50 | * 51 | * ``` 52 | * 53 | * This widget supports following keys for {@see AuthClientInterface::getViewOptions()} result: 54 | * 55 | * - popupWidth: int, width of the popup window in pixels. 56 | * - popupHeight: int, height of the popup window in pixels. 57 | * - widget: array, configuration for the widget, which should be used to render a client link; 58 | * such widget should be a subclass of {@see AuthChoiceItem}. 59 | * 60 | * @see \Yiisoft\Yii\AuthClient\AuthAction 61 | */ 62 | final class AuthChoice extends Widget 63 | { 64 | /** 65 | * @var string name of the GET param , which should be used to passed auth client id to URL 66 | * defined by {@see baseAuthUrl}. 67 | */ 68 | private string $clientIdGetParamName = 'authclient'; 69 | /** 70 | * @var array the HTML attributes that should be rendered in the div HTML tag representing the container element. 71 | * 72 | * @see Html::renderTagAttributes() for details on how attributes are being rendered. 73 | */ 74 | private array $options = []; 75 | /** 76 | * @var array additional options to be passed to the underlying JS plugin. 77 | */ 78 | private array $clientOptions = []; 79 | /** 80 | * @var bool indicates if popup window should be used instead of direct links. 81 | */ 82 | private bool $popupMode = true; 83 | /** 84 | * @var bool indicates if widget content, should be rendered automatically. 85 | * Note: this value automatically set to 'false' at the first call of {@see createClientUrl()} 86 | */ 87 | private bool $autoRender = true; 88 | 89 | /** 90 | * @var string route name for the external clients authentication URL. 91 | */ 92 | private string $authRoute = ''; 93 | 94 | private array $clients; 95 | 96 | public function __construct( 97 | Collection $clientCollection, 98 | private readonly UrlGeneratorInterface $urlGenerator, 99 | private readonly WebView $webView, 100 | private readonly AssetManager $assetManager, 101 | ) { 102 | $this->clients = $clientCollection->getClients(); 103 | $this->init(); 104 | } 105 | 106 | /** 107 | * Initializes the widget. 108 | */ 109 | public function init(): void 110 | { 111 | if ($this->popupMode) { 112 | $this->assetManager->register(AuthChoiceAsset::class); 113 | 114 | if (empty($this->clientOptions)) { 115 | $options = ''; 116 | } else { 117 | $options = Json::htmlEncode($this->clientOptions); 118 | } 119 | 120 | $this->webView->registerJs(" 121 | const el = document.getElementById('" . $this->getId() . "'); 122 | if (el && typeof authchoice === 'function') { 123 | authchoice(el, {$options}); 124 | } 125 | "); 126 | } else { 127 | $this->assetManager->register(AuthChoiceStyleAsset::class); 128 | } 129 | 130 | $this->options['id'] = $this->getId(); 131 | // This next line can cause header related issues 132 | echo Html::tag('div', '', $this->options)->open(); 133 | } 134 | 135 | public function getId(): string 136 | { 137 | return 'yii-auth-client'; 138 | } 139 | 140 | /** 141 | * Runs the widget. 142 | * 143 | * @throws \Yiisoft\Definitions\Exception\InvalidConfigException 144 | * 145 | * @return string rendered HTML. 146 | */ 147 | #[\Override] 148 | public function render(): string 149 | { 150 | $content = ''; 151 | if ($this->autoRender) { 152 | $content .= $this->renderMainContent(); 153 | } 154 | $content .= Html::tag('div')->close(); 155 | return $content; 156 | } 157 | 158 | /** 159 | * Renders the main content, which includes all external services links. 160 | * 161 | * @throws InvalidConfigException 162 | * @throws \Yiisoft\Definitions\Exception\InvalidConfigException 163 | * 164 | * @return string generated HTML. 165 | */ 166 | protected function renderMainContent(): string 167 | { 168 | $items = []; 169 | /** 170 | * @var OAuth2 $externalService 171 | */ 172 | foreach ($this->getClients() as $externalService) { 173 | $items[] = Html::tag('li', $this->clientLink($externalService)); 174 | } 175 | 176 | return Html::tag('ul', implode('', $items), ['class' => 'auth-clients'])->render(); 177 | } 178 | 179 | /** 180 | * @return array 181 | * @psalm-suppress MixedReturnTypeCoercion 182 | * @psalm-return array 183 | */ 184 | public function getClients(): array 185 | { 186 | return $this->clients; 187 | } 188 | 189 | /** 190 | * @param OAuth2[] $clients 191 | */ 192 | public function setClients(array $clients): void 193 | { 194 | $this->clients = $clients; 195 | } 196 | 197 | public function getClient(string $name): OAuth2 198 | { 199 | $clients = array_filter( 200 | $this->getClients(), 201 | fn($client) => $client->getName() === $name 202 | ); 203 | $client = end($clients); 204 | 205 | if ($client === false) { 206 | throw new InvalidConfigException("OAuth2 client with name '{$name}' not found."); 207 | } 208 | 209 | return $client; 210 | } 211 | 212 | /** 213 | * Outputs client auth link. 214 | * 215 | * @param OAuth2 $client extending from an auth client instance. 216 | * @param string $text link text, if not set - default value will be generated. 217 | * @param array $htmlOptions link HTML options. 218 | * 219 | * @throws InvalidConfigException on wrong configuration. 220 | * @throws \Yiisoft\Definitions\Exception\InvalidConfigException 221 | * 222 | * @return string generated HTML. 223 | */ 224 | public function clientLink(OAuth2 $client, string $text = null, array $htmlOptions = []): string 225 | { 226 | $viewOptions = $client->getViewOptions(); 227 | 228 | if (empty($viewOptions['widget'])) { 229 | if ($text === null) { 230 | $text = Html::tag('span', '', ['class' => 'auth-icon ' . $client->getName()])->render(); 231 | } 232 | if (!isset($htmlOptions['class'])) { 233 | $htmlOptions['class'] = $client->getName(); 234 | } 235 | if (!isset($htmlOptions['title'])) { 236 | $htmlOptions['title'] = $client->getTitle(); 237 | } 238 | Html::addCssClass($htmlOptions, ['widget' => 'auth-link']); 239 | 240 | if ($this->popupMode) { 241 | if (isset($viewOptions['popupWidth'])) { 242 | /** 243 | * @var int $viewOptions['popupWidth'] 244 | * @var int $htmlOptions['data-popup-width'] 245 | */ 246 | $htmlOptions['data-popup-width'] = $viewOptions['popupWidth']; 247 | } 248 | if (isset($viewOptions['popupHeight'])) { 249 | /** 250 | * @var int $viewOptions['popupHeight'] 251 | * @var int $htmlOptions['data-popup-height'] 252 | */ 253 | $htmlOptions['data-popup-height'] = $viewOptions['popupHeight']; 254 | } 255 | } 256 | 257 | return Html::a($text, $this->createClientUrl($client), $htmlOptions)->render(); 258 | } 259 | 260 | $widgetConfig = (array)$viewOptions['widget']; 261 | if (!isset($widgetConfig['class'])) { 262 | throw new InvalidConfigException('Widget config "class" parameter is missing'); 263 | } 264 | /* @var $widgetClass Widget */ 265 | $widgetClass = $widgetConfig['class']; 266 | /** 267 | * @psalm-suppress MixedArgument $widgetClass 268 | */ 269 | if (!is_subclass_of($widgetClass, AuthChoiceItem::class)) { 270 | throw new InvalidConfigException('Item widget class must be subclass of "' . AuthChoiceItem::class . '"'); 271 | } 272 | unset($widgetConfig['class']); 273 | $widgetConfig['client'] = $client; 274 | $widgetConfig['authChoice'] = $this; 275 | return $widgetClass::widget($widgetConfig)->render(); 276 | } 277 | 278 | /** 279 | * Composes client auth URL. 280 | * 281 | * @param AuthClientInterface $client external auth client instance. 282 | * 283 | * @return string auth URL. 284 | */ 285 | public function createClientUrl($client): string 286 | { 287 | $this->autoRender = false; 288 | $params = []; 289 | $params[$this->clientIdGetParamName] = $client->getName(); 290 | 291 | return $this->urlGenerator->generate($this->authRoute, $params); 292 | } 293 | 294 | /** 295 | * @param string $authRoute 296 | * 297 | * @return self 298 | */ 299 | public function authRoute(string $authRoute): self 300 | { 301 | $this->authRoute = $authRoute; 302 | return $this; 303 | } 304 | 305 | /** 306 | * Note: Popup window with {$authRoute} e.g. 'auth/authclient' 307 | * @param array $provider 308 | * @param string $name 309 | * @return string 310 | */ 311 | public function authRoutedButtons(string $authRoute, array $provider, string $name): string 312 | { 313 | foreach ($this->getClients() as $client) { 314 | if ($name === $client->getName()) { 315 | if (strlen($client->getClientId()) > 0) { 316 | $viewOptions = $client->getViewOptions(); 317 | $height = (string) $viewOptions['popupHeight']; 318 | $width = (string) $viewOptions['popupWidth']; 319 | $this->authRoute($authRoute); 320 | return $this->clientLink($client, ' ' . ucfirst((string) $provider['buttonName']), [ 321 | 'onclick' => "window.open(this.href, 'authPopup', 'width=" . $width . ',height=' . $height . "'); return false;", 322 | 'class' => $client->getButtonClass() , 323 | ]); 324 | } 325 | } 326 | } 327 | return ''; 328 | } 329 | 330 | /** 331 | * Note: No popup window and no route 332 | * @param ServerRequestInterface $request 333 | * @param array $provider 334 | * @param string $name 335 | * @return string 336 | */ 337 | public function absoluteButtons(ServerRequestInterface $request, array $provider, string $name): string 338 | { 339 | foreach ($this->getClients() as $client) { 340 | if ($name === $client->getName()) { 341 | if (strlen($client->getClientId()) > 0) { 342 | $clientAuthUrl = $client->buildAuthUrl($request, (array) $provider['params']); 343 | return A::tag() 344 | ->addClass($client->getButtonClass()) 345 | ->content(' ' . ucfirst((string) $provider['buttonName'])) 346 | ->href($clientAuthUrl) 347 | ->id('btn-' . $name) 348 | ->render(); 349 | } 350 | } 351 | } 352 | return ''; 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/AuthAction.php: -------------------------------------------------------------------------------- 1 | [ 34 | * 'class' => \Yiisoft\Yii\AuthClient\AuthAction::class, 35 | * 'successCallback' => [$this, 'successCallback'], 36 | * ], 37 | * ] 38 | * } 39 | * 40 | * public function successCallback($client) 41 | * { 42 | * $attributes = $client->getUserAttributes(); 43 | * // user login or signup comes here 44 | * } 45 | * } 46 | * ``` 47 | * 48 | * Usually authentication via external services is performed inside the popup window. 49 | * This action handles the redirection and closing of popup window correctly. 50 | * 51 | * @see Collection 52 | * @see \Yiisoft\Yii\AuthClient\Widget\AuthChoice 53 | */ 54 | final class AuthAction implements MiddlewareInterface 55 | { 56 | public const string AUTH_NAME = 'auth_displayname'; 57 | /** 58 | * @var string name of the GET param, which is used to passed auth client id to this action. 59 | * Note: watch for the naming, make sure you do not choose name used in some auth protocol. 60 | */ 61 | private string $clientIdGetParamName = 'authclient'; 62 | /** 63 | * @psalm-param TCallableString $successCallback PHP callback, which should be triggered in case of successful authentication. 64 | * 65 | * @see https://psalm.dev/docs/running_psalm/plugins/plugins_type_system/ 66 | * This callback should accept {@see AuthClientInterface} instance as an argument. 67 | * For example: 68 | 69 | * ```php 70 | * public function onAuthSuccess(ClientInterface $client) 71 | * { 72 | * $attributes = $client->getUserAttributes(); 73 | * // user login or signup comes here 74 | * } 75 | * ``` 76 | 77 | * If this callback returns {@see ResponseInterface} instance, it will be used as action response, 78 | * otherwise redirection to {@see successUrl} will be performed. 79 | * 80 | * @var callable 81 | */ 82 | private $successCallback; 83 | /** 84 | * @psalm-param TCallableString $cancelCallback PHP callback, which should be triggered in case of authentication cancellation. 85 | * 86 | * @see https://psalm.dev/docs/running_psalm/plugins/plugins_type_system/ 87 | * This callback should accept {@see AuthClientInterface} instance as an argument. 88 | * For example: 89 | 90 | * ```php 91 | * public function onAuthCancel(ClientInterface $client) 92 | * { 93 | * // set flash, logging, etc. 94 | * } 95 | * ``` 96 | 97 | * If this callback returns {@see ResponseInterface} instance, it will be used as action response, 98 | * otherwise redirection to {@see cancelUrl} will be performed. 99 | * 100 | * @var callable 101 | */ 102 | private $cancelCallback; 103 | /** 104 | * @var string name or alias of the view file, which should be rendered in order to perform redirection. 105 | * If not set - default one will be used. 106 | */ 107 | private ?string $redirectView = null; 108 | 109 | /** 110 | * @var string the redirect url after successful authorization. 111 | */ 112 | private readonly string $successUrl; 113 | /** 114 | * @var string the redirect url after unsuccessful authorization (e.g. user canceled). 115 | */ 116 | private readonly string $cancelUrl; 117 | 118 | public function __construct( 119 | /** 120 | * @var Collection 121 | * It should point to {@see Collection} instance. 122 | */ 123 | private readonly Collection $clientCollection, 124 | private readonly Aliases $aliases, 125 | private readonly WebView $view, 126 | private readonly ResponseFactoryInterface $responseFactory 127 | ) { 128 | } 129 | 130 | /** 131 | * @param string $url successful URL. 132 | * 133 | * @return AuthAction 134 | */ 135 | public function withSuccessUrl(string $url): self 136 | { 137 | $new = clone $this; 138 | $new->successUrl = $url; 139 | return $new; 140 | } 141 | 142 | /** 143 | * @param string $url cancel URL. 144 | * 145 | * @return AuthAction 146 | */ 147 | public function withCancelUrl(string $url): self 148 | { 149 | $new = clone $this; 150 | $new->cancelUrl = $url; 151 | return $new; 152 | } 153 | 154 | #[\Override] 155 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 156 | { 157 | $clientId = (string)$request->getAttribute($this->clientIdGetParamName); 158 | if (strlen($clientId) > 0) { 159 | if (!$this->clientCollection->hasClient($clientId)) { 160 | return $this->responseFactory->createResponse(Status::NOT_FOUND, "Unknown auth client '{$clientId}'"); 161 | } 162 | $client = $this->clientCollection->getClient($clientId); 163 | 164 | return $this->auth($client, $request); 165 | } 166 | 167 | return $this->responseFactory->createResponse(Status::NOT_FOUND); 168 | } 169 | 170 | /** 171 | * Perform authentication for the given client. 172 | * 173 | * @param mixed $client auth client instance. 174 | * @param ServerRequestInterface $request 175 | * 176 | * @throws InvalidConfigException 177 | * @throws NotSupportedException on invalid client. 178 | * @throws Throwable 179 | * @throws ViewNotFoundException 180 | * @throws \Yiisoft\Definitions\Exception\InvalidConfigException 181 | * 182 | * @return ResponseInterface response instance. 183 | */ 184 | private function auth(AuthClientInterface $client, ServerRequestInterface $request): ResponseInterface 185 | { 186 | if ($client instanceof OAuth2) { 187 | return $this->authOAuth2($client, $request); 188 | } 189 | /** 190 | * @psalm-suppress MixedArgument $client 191 | */ 192 | throw new NotSupportedException('Provider "' . $client::class . '" is not supported.'); 193 | } 194 | 195 | /** 196 | * Performs OAuth2 auth flow. 197 | * 198 | * @param OAuth2 $client auth client instance. 199 | * @param ServerRequestInterface $request 200 | * 201 | * @throws InvalidConfigException 202 | * @throws Throwable 203 | * @throws ViewNotFoundException 204 | * 205 | * @return ResponseInterface action response. 206 | */ 207 | private function authOAuth2(OAuth2 $client, ServerRequestInterface $request): ResponseInterface 208 | { 209 | $queryParams = $request->getQueryParams(); 210 | 211 | if (isset($queryParams['error']) && (strlen($error = (string)$queryParams['error']) > 0)) { 212 | if ($error === 'access_denied') { 213 | // user denied error 214 | return $this->authCancel($client); 215 | } 216 | /** 217 | * @var string|null $queryParams['error_description'] 218 | */ 219 | $errorMessage = $queryParams['error_description'] ?? ((string)$queryParams['error_message'] ?: null); 220 | if ($errorMessage === null) { 221 | $errorMessage = http_build_query($queryParams); 222 | } 223 | throw new Exception('Auth error: ' . $errorMessage); 224 | } 225 | 226 | // Get the access_token and save them to the session. 227 | if (isset($queryParams['code']) && (strlen($code = (string)$queryParams['code']) > 0)) { 228 | $token = $client->fetchAccessToken($request, $code); 229 | if (strlen((string) $token->getToken()) > 0) { 230 | return $this->authSuccess($client); 231 | } 232 | return $this->authCancel($client); 233 | } 234 | $url = $client->buildAuthUrl($request, []); 235 | return $this->responseFactory 236 | ->createResponse(Status::MOVED_PERMANENTLY) 237 | ->withHeader('Location', $url); 238 | } 239 | 240 | /** 241 | * This method is invoked in case of authentication cancellation. 242 | * 243 | * @param AuthClientInterface $client auth client instance. 244 | * 245 | * @throws Throwable 246 | * @throws ViewNotFoundException 247 | * 248 | * @return ResponseInterface response instance. 249 | */ 250 | private function authCancel(AuthClientInterface $client): ResponseInterface 251 | { 252 | if (!is_callable($this->cancelCallback)) { 253 | throw new InvalidConfigException( 254 | '"' . self::class . '::$successCallback" should be a valid callback.' 255 | ); 256 | } 257 | /** 258 | * @var ResponseInterface $response 259 | */ 260 | $response = ($this->cancelCallback)($client); 261 | if ($response instanceof ResponseInterface) { 262 | return $response; 263 | } 264 | 265 | return $this->redirectCancel(); 266 | } 267 | 268 | /** 269 | * Redirect to the {@see cancelUrl} or simply close the popup window. 270 | * 271 | * @param string $url URL to redirect. 272 | * 273 | * @throws Throwable 274 | * @throws ViewNotFoundException 275 | * 276 | * @return ResponseInterface response instance. 277 | */ 278 | private function redirectCancel(?string $url = null): ResponseInterface 279 | { 280 | if ($url === null) { 281 | $url = $this->cancelUrl; 282 | } 283 | return $this->redirect($url, false); 284 | } 285 | 286 | /** 287 | * Redirect to the given URL or simply close the popup window. 288 | * 289 | * @param string $url URL to redirect, could be a string or array config to generate a valid URL. 290 | * @param bool $enforceRedirect indicates if redirect should be performed even in case of popup window. 291 | * 292 | * @throws Throwable 293 | * @throws ViewNotFoundException 294 | * 295 | * @return ResponseInterface response instance. 296 | */ 297 | private function redirect(string $url, bool $enforceRedirect = true): ResponseInterface 298 | { 299 | $viewFile = $this->redirectView; 300 | if ($viewFile === null) { 301 | $viewFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'redirect.php'; 302 | } else { 303 | $viewFile = $this->aliases->get($viewFile); 304 | } 305 | 306 | $viewData = [ 307 | 'url' => $url, 308 | 'enforceRedirect' => $enforceRedirect, 309 | ]; 310 | 311 | $response = $this->responseFactory->createResponse(); 312 | 313 | $response->getBody()->write($this->view->render($viewFile, $viewData)); 314 | 315 | return $response; 316 | } 317 | 318 | /** 319 | * This method is invoked in case of successful authentication via auth client. 320 | * 321 | * @param AuthClientInterface $client auth client instance. 322 | * 323 | * @throws InvalidConfigException on invalid success callback. 324 | * @throws Throwable 325 | * @throws ViewNotFoundException 326 | * 327 | * @return ResponseInterface response instance. 328 | */ 329 | private function authSuccess(AuthClientInterface $client): ResponseInterface 330 | { 331 | if (!is_callable($this->successCallback)) { 332 | throw new InvalidConfigException( 333 | '"' . self::class . '::$successCallback" should be a valid callback.' 334 | ); 335 | } 336 | 337 | /** 338 | * @psalm-suppress MixedAssignment 339 | */ 340 | $response = ($this->successCallback)($client); 341 | if ($response instanceof ResponseInterface) { 342 | return $response; 343 | } 344 | 345 | return $this->redirectSuccess(); 346 | } 347 | 348 | /** 349 | * Redirect to the URL. If URL is null, {@see successUrl} will be used. 350 | * 351 | * @param string|null $url URL to redirect. 352 | * 353 | * @throws Throwable 354 | * @throws ViewNotFoundException 355 | * 356 | * @return ResponseInterface response instance. 357 | */ 358 | private function redirectSuccess(?string $url = null): ResponseInterface 359 | { 360 | if ($url === null) { 361 | $url = $this->successUrl; 362 | } 363 | return $this->redirect($url); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/OAuth2.php: -------------------------------------------------------------------------------- 1 | factory); 65 | } 66 | 67 | /** 68 | * Composes user authorization URL. 69 | * 70 | * @param ServerRequestInterface $incomingRequest 71 | * @param array $params additional auth GET params. 72 | * 73 | * @return string authorization URL. 74 | */ 75 | #[\Override] 76 | public function buildAuthUrl( 77 | ServerRequestInterface $incomingRequest, 78 | array $params = [] 79 | ): string { 80 | $defaultParams = [ 81 | 'client_id' => $this->clientId, 82 | 'response_type' => 'code', 83 | 'redirect_uri' => $this->getOauth2ReturnUrl(), 84 | 'xoauth_displayname' => $incomingRequest->getAttribute(AuthAction::AUTH_NAME), 85 | ]; 86 | if (!empty($this->getScope())) { 87 | $defaultParams['scope'] = $this->getScope(); 88 | } 89 | if ($this->validateAuthState) { 90 | $authState = $this->generateAuthState(); 91 | $this->setState('authState', $authState); 92 | $defaultParams['state'] = $authState; 93 | } 94 | 95 | return RequestUtil::composeUrl($this->authUrl, array_merge($defaultParams, $params)); 96 | } 97 | 98 | /** 99 | * Compare a callback query parameter 'state' with the saved Auth Client's 'authState' parameter 100 | * in order to prevent CSRF attacks 101 | * 102 | * Use: Typically used in a AuthController's callback function specifically for an Identity Provider e.g. Facebook 103 | * 104 | * @return mixed 105 | */ 106 | public function getSessionAuthState(): mixed 107 | { 108 | /** 109 | * @see src\AuthClient protected function getState('authState') 110 | */ 111 | return $this->getState('authState'); 112 | } 113 | 114 | /** 115 | * Generates the auth state value. 116 | * 117 | * @return string auth state value. 118 | */ 119 | protected function generateAuthState(): string 120 | { 121 | $baseString = static::class . '-' . time(); 122 | $sessionId = $this->session->getId(); 123 | if (null !== $sessionId) { 124 | if ($this->session->isActive()) { 125 | $baseString .= '-' . $sessionId; 126 | } 127 | } 128 | return hash('sha256', uniqid($baseString, true)); 129 | } 130 | 131 | /** 132 | * Fetches access token from authorization code. 133 | * 134 | * @param ServerRequestInterface $incomingRequest 135 | * @param string $authCode authorization code, usually comes at GET parameter 'code'. 136 | * @param array $params additional request params. 137 | * 138 | * @return OAuthToken access token. 139 | */ 140 | public function fetchAccessToken( 141 | ServerRequestInterface $incomingRequest, 142 | string $authCode, 143 | array $params = [] 144 | ): OAuthToken { 145 | if ($this->validateAuthState) { 146 | /** 147 | * @psalm-suppress MixedAssignment 148 | */ 149 | $authState = $this->getState('authState'); 150 | $queryParams = $incomingRequest->getQueryParams(); 151 | $bodyParams = $incomingRequest->getParsedBody(); 152 | /** 153 | * @psalm-suppress MixedAssignment 154 | */ 155 | $incomingState = $queryParams['state'] ?? ($bodyParams['state'] ?? null); 156 | if (is_string($incomingState)) { 157 | if (strcmp($incomingState, (string)$authState) !== 0) { 158 | throw new InvalidArgumentException('Invalid auth state parameter.'); 159 | } 160 | } 161 | if ($incomingState === null) { 162 | throw new InvalidArgumentException('Invalid auth state parameter.'); 163 | } 164 | if (empty($authState)) { 165 | throw new InvalidArgumentException('Invalid auth state parameter.'); 166 | } 167 | $this->removeState('authState'); 168 | } 169 | 170 | $defaultParams = [ 171 | 'code' => $authCode, 172 | 'redirect_uri' => $this->getOauth2ReturnUrl(), 173 | ]; 174 | 175 | $request = $this->createRequest('POST', $this->tokenUrl); 176 | $request = RequestUtil::addParams($request, array_merge($defaultParams, $params)); 177 | $request = $this->applyClientCredentialsToRequest($request); 178 | $response = $this->sendRequest($request); 179 | $contents = $response->getBody()->getContents(); 180 | $output = $this->parse_str_clean($contents); 181 | $token = new OAuthToken(); 182 | /** 183 | * @var string $key 184 | * @var string $value 185 | */ 186 | foreach ($output as $key => $value) { 187 | $token->setParam($key, $value); 188 | } 189 | return $token; 190 | } 191 | 192 | /** 193 | * Note: This function will be adapted later to accomodate the 'confidential client'. 194 | * @see https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/authorization-code 195 | * Used specifically for the X i.e. Twitter OAuth2.0 Authorization code with PKCE and public client i.e. 196 | * client id included in request body; 197 | * and NOT Confidential Client i.e. Client id not included in the request body 198 | * @param ServerRequestInterface $incomingRequest 199 | * @param string $authCode 200 | * @param array $params 201 | * @throws InvalidArgumentException 202 | * @return OAuthToken 203 | */ 204 | public function fetchAccessTokenWithCodeVerifier( 205 | ServerRequestInterface $incomingRequest, 206 | string $authCode, 207 | array $params = [], 208 | ): OAuthToken { 209 | if ($this->validateAuthState) { 210 | /** 211 | * @psalm-suppress MixedAssignment 212 | */ 213 | $authState = $this->getState('authState'); 214 | 215 | $queryParams = $incomingRequest->getQueryParams(); 216 | $bodyParams = $incomingRequest->getParsedBody(); 217 | 218 | /** 219 | * @psalm-suppress MixedAssignment 220 | */ 221 | $incomingState = $queryParams['state'] ?? ($bodyParams['state'] ?? null); 222 | 223 | if (is_string($incomingState)) { 224 | if (strcmp($incomingState, (string)$authState) !== 0) { 225 | throw new InvalidArgumentException('Invalid auth state parameter.'); 226 | } 227 | } 228 | if ($incomingState === null) { 229 | throw new InvalidArgumentException('Invalid auth state parameter.'); 230 | } 231 | if (empty($authState)) { 232 | throw new InvalidArgumentException('Invalid auth state parameter.'); 233 | } 234 | $this->removeState('authState'); 235 | } 236 | 237 | $requestBody = [ 238 | 'code' => $authCode, 239 | 'grant_type' => 'authorization_code', 240 | 'client_id' => $this->clientId, 241 | 'client_secret' => $this->clientSecret, 242 | 'redirect_uri' => $params['redirect_uri'] ?? '', 243 | 'code_verifier' => $params['code_verifier'] ?? '', 244 | ]; 245 | 246 | $request = $this->requestFactory 247 | ->createRequest('POST', $this->tokenUrl) 248 | ->withHeader('Content-Type', 'application/x-www-form-urlencoded'); 249 | 250 | $request->getBody()->write(http_build_query($requestBody)); 251 | 252 | try { 253 | $response = $this->httpClient->sendRequest($request); 254 | $body = $response->getBody()->getContents(); 255 | if (strlen($body) > 0) { 256 | $output = (array) json_decode($body, true); 257 | } else { 258 | $output = []; 259 | } 260 | } catch (\Throwable $e) { 261 | $output = []; 262 | } 263 | 264 | $token = new OAuthToken(); 265 | /** 266 | * @var string $key 267 | * @var string $value 268 | */ 269 | foreach ($output as $key => $value) { 270 | $token->setParam($key, $value); 271 | } 272 | return $token; 273 | } 274 | 275 | /** 276 | * Applies client credentials (e.g. {@see clientId} and {@see clientSecret}) to the HTTP request instance. 277 | * This method should be invoked before sending any HTTP request, which requires client credentials. 278 | * 279 | * @param RequestInterface $request HTTP request instance. 280 | * 281 | * @return RequestInterface 282 | */ 283 | protected function applyClientCredentialsToRequest(RequestInterface $request): RequestInterface 284 | { 285 | return RequestUtil::addParams( 286 | $request, 287 | [ 288 | 'client_id' => $this->clientId, 289 | 'client_secret' => $this->clientSecret, 290 | ] 291 | ); 292 | } 293 | 294 | /** 295 | * Creates token from its configuration. 296 | * 297 | * @param array $tokenConfig token configuration. 298 | * @return OAuthToken token instance. 299 | */ 300 | #[\Override] 301 | protected function createToken(array $tokenConfig = []): OAuthToken 302 | { 303 | $tokenConfig['tokenParamKey'] = 'access_token'; 304 | 305 | return parent::createToken($tokenConfig); 306 | } 307 | 308 | public function setClientId(string $clientId): void 309 | { 310 | $this->clientId = $clientId; 311 | } 312 | 313 | #[\Override] 314 | public function getClientId(): string 315 | { 316 | return $this->clientId; 317 | } 318 | 319 | public function setClientSecret(string $clientSecret): void 320 | { 321 | $this->clientSecret = $clientSecret; 322 | } 323 | 324 | public function getClientSecret(): string 325 | { 326 | return $this->clientSecret; 327 | } 328 | 329 | public function getOauth2ReturnUrl(): string 330 | { 331 | return $this->returnUrl; 332 | } 333 | 334 | public function setOauth2ReturnUrl(string $returnUrl): void 335 | { 336 | $this->returnUrl = $returnUrl; 337 | } 338 | 339 | #[\Override] 340 | public function applyAccessTokenToRequest(RequestInterface $request, OAuthToken $accessToken): RequestInterface 341 | { 342 | return RequestUtil::addParams( 343 | $request, 344 | [ 345 | 'access_token' => $accessToken->getToken(), 346 | ] 347 | ); 348 | } 349 | 350 | /** 351 | * Gets new auth token to replace expired one. 352 | * 353 | * @see https://developers.google.com/oauthplayground 354 | * 355 | * @param OAuthToken $token expired auth token. 356 | * 357 | * @return OAuthToken new auth token. 358 | */ 359 | #[\Override] 360 | public function refreshAccessToken(OAuthToken $token): OAuthToken 361 | { 362 | $params = [ 363 | 'grant_type' => 'refresh_token', 364 | ]; 365 | $params = array_merge($token->getParams(), $params); 366 | 367 | $request = $this->createRequest('POST', $this->tokenUrl); 368 | 369 | $request = RequestUtil::addParams($request, $params); 370 | 371 | $request = $this->applyClientCredentialsToRequest($request); 372 | 373 | $response = $this->sendRequest($request); 374 | 375 | $contents = $response->getBody()->getContents(); 376 | 377 | $output = $this->parse_str_clean($contents); 378 | 379 | $token = new OAuthToken(); 380 | /** 381 | * @var string $key 382 | * @var string $value 383 | */ 384 | foreach ($output as $key => $value) { 385 | $token->setParam($key, $value); 386 | } 387 | return $token; 388 | } 389 | 390 | public function getTokenUrl(): string 391 | { 392 | return $this->tokenUrl; 393 | } 394 | 395 | public function setTokenUrl(string $tokenUrl): void 396 | { 397 | $this->tokenUrl = $tokenUrl; 398 | } 399 | 400 | public function withValidateAuthState(): self 401 | { 402 | $new = clone $this; 403 | $new->validateAuthState = true; 404 | return $new; 405 | } 406 | 407 | public function withoutValidateAuthState(): self 408 | { 409 | $new = clone $this; 410 | $new->validateAuthState = false; 411 | return $new; 412 | } 413 | 414 | /** 415 | * Composes default {@see returnUrl} value. 416 | * 417 | * @param ServerRequestInterface $request 418 | * 419 | * @return string return URL. 420 | */ 421 | #[\Override] 422 | protected function defaultReturnUrl(ServerRequestInterface $request): string 423 | { 424 | $params = $request->getQueryParams(); 425 | unset($params['code'], $params['state']); 426 | 427 | return (string)$request->getUri()->withQuery(http_build_query($params, '', '&', PHP_QUERY_RFC3986)); 428 | } 429 | 430 | /** 431 | * Purpose: Prevent, inter alia, underscores in keys of an array 432 | * @see https://www.php.net/manual/en/function.parse-str.php#126789 433 | */ 434 | private function parse_str_clean(string $querystr): array 435 | { 436 | $qquerystr = str_ireplace(['.','%2E','+',' ','%20'], ['QQleQPunT', 'QQleQPunT', 'QQleQSpaTIE', 'QQleQSpaTIE', 'QQleQSpaTIE'], $querystr); 437 | $arr = null; 438 | parse_str($qquerystr, $arr); 439 | return $this->sanitizeKeys($arr, $querystr); 440 | } 441 | 442 | private function sanitizeKeys(array &$arr, string $querystr): array 443 | { 444 | /** 445 | * @var array|string $val 446 | */ 447 | foreach ($arr as $key => $val) { 448 | // restore values to original 449 | 450 | $newval = $val; 451 | 452 | if (is_string($val)) { 453 | $newval = str_replace(['QQleQPunT', 'QQleQSpaTIE'], ['.',' '], $val); 454 | } 455 | 456 | $newkey = str_replace(['QQleQPunT', 'QQleQSpaTIE'], ['.',' '], (string)$key); 457 | 458 | if (str_contains($newkey, '_')) { 459 | // periode of space or [ or ] converted to _. Restore with querystring 460 | $regex = '/&(' . str_replace('_', '[ \.\[\]]', preg_quote($newkey, '/')) . ')=/'; 461 | $matches = null ; 462 | if (preg_match_all($regex, '&' . urldecode($querystr), $matches) > 0) { 463 | if (count(array_unique($matches[1])) === 1 && (string)$key != $matches[1][0]) { 464 | $newkey = $matches[1][0] ; 465 | } 466 | } 467 | } 468 | 469 | if ($newkey !== $key) { 470 | unset($arr[$key]); 471 | $arr[$newkey] = $newval ; 472 | } elseif ($val !== $newval) { 473 | $arr[$key] = $newval; 474 | } 475 | 476 | if (is_array($val)) { 477 | /** 478 | * @psalm-suppress MixedArgument $arr[$newkey] 479 | */ 480 | $this->sanitizeKeys($arr[$newkey], $querystr); 481 | } 482 | } 483 | return $arr; 484 | } 485 | } 486 | -------------------------------------------------------------------------------- /src/Client/OpenIdConnect.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 142 | $this->name = $name; 143 | $this->title = $title; 144 | parent::__construct($httpClient, $requestFactory, $stateStorage, $factory, $session); 145 | } 146 | 147 | /** 148 | * @param ServerRequestInterface $incomingRequest 149 | * @param array $params 150 | * @return string 151 | */ 152 | #[\Override] 153 | public function buildAuthUrl( 154 | ServerRequestInterface $incomingRequest, 155 | array $params = [] 156 | ): string { 157 | if (strlen($this->authUrl) == 0) { 158 | $this->authUrl = (string) $this->getConfigParam('authorization_endpoint'); 159 | } 160 | return parent::buildAuthUrl($incomingRequest, $params); 161 | } 162 | 163 | /** 164 | * Returns particular configuration parameter value. 165 | * 166 | * @param string $name configuration parameter name. 167 | * 168 | * @throws InvalidConfigException 169 | * @throws InvalidArgumentException 170 | * 171 | * @return mixed configuration parameter value. 172 | */ 173 | public function getConfigParam(string $name): mixed 174 | { 175 | $params = $this->getConfigParams(); 176 | /** 177 | * @psalm-suppress PossiblyInvalidArrayOffset 178 | */ 179 | return $params[$name]; 180 | } 181 | 182 | /** 183 | * @throws InvalidConfigException 184 | * @throws InvalidArgumentException 185 | * 186 | * @return array|string OpenID provider configuration parameters. 187 | */ 188 | public function getConfigParams(): array|string 189 | { 190 | if (empty($this->configParams)) { 191 | $cacheKey = $this->configParamsCacheKeyPrefix . $this->getName(); 192 | if (empty($configParams = (array) $this->cache->get($cacheKey))) { 193 | $configParams = $this->discoverConfig(); 194 | } 195 | 196 | $this->configParams = $configParams; 197 | $this->cache->set($cacheKey, $configParams); 198 | } 199 | return $this->configParams; 200 | } 201 | 202 | /** 203 | * Discovers OpenID Provider configuration parameters. 204 | * 205 | * @throws InvalidConfigException 206 | * 207 | * @return array OpenID Provider configuration parameters. 208 | */ 209 | private function discoverConfig(): array 210 | { 211 | if (empty($this->issuerUrl)) { 212 | throw new InvalidConfigException('Cannot discover config because issuer URL is not set.'); 213 | } 214 | $configUrl = $this->issuerUrl . '/.well-known/openid-configuration'; 215 | $request = $this->createRequest('GET', $configUrl); 216 | $response = $this->sendRequest($request); 217 | 218 | return (array)json_decode($response->getBody()->getContents(), true); 219 | } 220 | 221 | /** 222 | * @param ServerRequestInterface $incomingRequest 223 | * @param string $authCode 224 | * @param array $params 225 | * @return OAuthToken 226 | */ 227 | #[\Override] 228 | public function fetchAccessToken(ServerRequestInterface $incomingRequest, string $authCode, array $params = []): OAuthToken 229 | { 230 | if (empty($this->tokenUrl)) { 231 | $this->tokenUrl = (string) $this->getConfigParam('token_endpoint'); 232 | } 233 | 234 | if (!isset($params['nonce']) && $this->getValidateAuthNonce()) { 235 | $nonce = $this->generateAuthNonce(); 236 | $this->setState('authNonce', $nonce); 237 | $params['nonce'] = $nonce; 238 | } 239 | 240 | return parent::fetchAccessToken($incomingRequest, $authCode, $params); 241 | } 242 | 243 | /** 244 | * @throws InvalidConfigException 245 | * @throws InvalidArgumentException 246 | * 247 | * @return bool whether to use and validate auth 'nonce' parameter in authentication flow. 248 | */ 249 | public function getValidateAuthNonce(): bool 250 | { 251 | if ($this->validateAuthNonce === null) { 252 | $this->validateAuthNonce = $this->validateJws && in_array( 253 | 'nonce', 254 | (array) $this->getConfigParam('claims_supported'), 255 | true 256 | ); 257 | } 258 | return $this->validateAuthNonce; 259 | } 260 | 261 | /** 262 | * @param bool $validateAuthNonce whether to use and validate auth 'nonce' parameter in authentication flow. 263 | */ 264 | public function setValidateAuthNonce($validateAuthNonce): void 265 | { 266 | $this->validateAuthNonce = $validateAuthNonce; 267 | } 268 | 269 | /** 270 | * Generates the auth nonce value. 271 | * 272 | * @throws Exception 273 | * 274 | * @return string auth nonce value. 275 | */ 276 | protected function generateAuthNonce(): string 277 | { 278 | return Random::string(); 279 | } 280 | 281 | /** 282 | * @param OAuthToken $token 283 | * @return OAuthToken 284 | */ 285 | #[\Override] 286 | public function refreshAccessToken(OAuthToken $token): OAuthToken 287 | { 288 | if (strlen($this->tokenUrl) == 0) { 289 | $this->tokenUrl = (string) $this->getConfigParam('token_endpoint'); 290 | } 291 | return parent::refreshAccessToken($token); 292 | } 293 | 294 | #[\Override] 295 | public function getName(): string 296 | { 297 | /** 298 | * Note 1: Change OpenIdConnect::class to OAuth, Google, 299 | * Note 2: Keep 'oidc' unchanged 300 | * Related logic: app's config/web/di/yii-auth-client 301 | * `@var array $paramsClients['oidc']` 302 | * `$openidconnectClient = $paramsClients['oidc'];` 303 | * 304 | * Related logic: app's config/common/params [yiisoft/yii-auth-client] => 305 | * [ 306 | * 'oidc' => [ 307 | * 'class' => 'Yiisoft\Yii\AuthClient\Client\OpenIdConnect::class', 308 | * 'issuerUrl' => 'dev-0yporhwwkgkdmu1g.uk.auth0.com', 309 | * 'clientId' => $_ENV['OIDC_API_CLIENT_ID'] ?? '', 310 | * 'clientSecret' => $_ENV['OIDC_API_CLIENT_SECRET'] ?? '', 311 | * 'returnUrl' => $_ENV['OIDC_API_CLIENT_RETURN_URL'] ?? '', 312 | * ], 313 | */ 314 | return 'oidc'; 315 | } 316 | 317 | #[\Override] 318 | public function getTitle(): string 319 | { 320 | return 'Open Id Connect'; 321 | } 322 | 323 | #[\Override] 324 | public function getButtonClass(): string 325 | { 326 | return ''; 327 | } 328 | 329 | /** 330 | * @return int[] 331 | * 332 | * @psalm-return array{popupWidth: 860, popupHeight: 480} 333 | */ 334 | #[\Override] 335 | protected function defaultViewOptions(): array 336 | { 337 | return [ 338 | 'popupWidth' => 860, 339 | 'popupHeight' => 480, 340 | ]; 341 | } 342 | 343 | public function setIssuerUrl(string $url): void 344 | { 345 | $this->issuerUrl = rtrim($url, '/'); 346 | } 347 | 348 | protected function initUserAttributes(): array 349 | { 350 | return $this->api((array) $this->getConfigParam('userinfo_endpoint'), 'GET'); 351 | } 352 | 353 | #[\Override] 354 | protected function applyClientCredentialsToRequest(RequestInterface $request): RequestInterface 355 | { 356 | $supportedAuthMethods = (array) $this->getConfigParam('token_endpoint_auth_methods_supported'); 357 | 358 | if (in_array('client_secret_basic', $supportedAuthMethods, true)) { 359 | $request = $request->withHeader( 360 | 'Authorization', 361 | 'Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret) 362 | ); 363 | } elseif (in_array('client_secret_post', $supportedAuthMethods, true)) { 364 | $request = RequestUtil::addParams( 365 | $request, 366 | [ 367 | 'client_id' => $this->clientId, 368 | 'client_secret' => $this->clientSecret, 369 | ] 370 | ); 371 | } elseif (in_array('client_secret_jwt', $supportedAuthMethods, true)) { 372 | $header = [ 373 | 'typ' => 'JWT', 374 | 'alg' => 'HS256', 375 | ]; 376 | $payload = [ 377 | 'iss' => $this->clientId, 378 | 'sub' => $this->clientId, 379 | 'aud' => $this->tokenUrl, 380 | 'jti' => $this->generateAuthNonce(), 381 | 'iat' => time(), 382 | 'exp' => time() + 3600, 383 | ]; 384 | 385 | $signatureBaseString = base64_encode(Json::encode($header)) . '.' . base64_encode(Json::encode($payload)); 386 | $signatureMethod = new HmacSha('sha256'); 387 | $signature = $signatureMethod->generateSignature($signatureBaseString, $this->clientSecret); 388 | 389 | $assertion = $signatureBaseString . '.' . $signature; 390 | 391 | $request = RequestUtil::addParams( 392 | $request, 393 | [ 394 | 'assertion' => $assertion, 395 | ] 396 | ); 397 | } else { 398 | throw new InvalidConfigException( 399 | 'Unable to authenticate request: No auth method supported' 400 | ); 401 | } 402 | return $request; 403 | } 404 | 405 | #[\Override] 406 | protected function defaultReturnUrl(ServerRequestInterface $request): string 407 | { 408 | $params = $request->getQueryParams(); 409 | // OAuth2 specifics : 410 | unset($params['code'], $params['state'], $params['nonce'], $params['authuser'], $params['session_state'], $params['prompt']); 411 | // OpenIdConnect specifics : 412 | 413 | 414 | return $request->getUri()->withQuery(http_build_query($params, '', '&', PHP_QUERY_RFC3986))->__toString(); 415 | } 416 | 417 | #[\Override] 418 | protected function createToken(array $tokenConfig = []): OAuthToken 419 | { 420 | $params = (array) $tokenConfig['params']; 421 | $idToken = (string) $params['id_token']; 422 | if ($this->validateJws) { 423 | $jwsData = $this->loadJws($idToken); 424 | $this->validateClaims($jwsData); 425 | $tokenConfig['params'] = array_merge($params, $jwsData); 426 | 427 | if ($this->getValidateAuthNonce()) { 428 | $nonce = isset($jwsData['nonce']) ? (string) $jwsData['nonce'] : ''; 429 | $authNonce = (string) $this->getState('authNonce'); 430 | if (!isset($jwsData['nonce']) || empty($authNonce) || strcmp($nonce, $authNonce) !== 0) { 431 | throw new ClientException('Invalid auth nonce', 400); 432 | } 433 | 434 | $this->removeState('authNonce'); 435 | } 436 | } 437 | 438 | return parent::createToken($tokenConfig); 439 | } 440 | 441 | /** 442 | * Decrypts/validates JWS, returning related data. 443 | * 444 | * @param string $jws raw JWS input. 445 | * 446 | * @throws ClientException on invalid JWS signature. 447 | * 448 | * @return array JWS underlying data. 449 | */ 450 | protected function loadJws(string $jws): array 451 | { 452 | try { 453 | $jwsLoader = $this->getJwsLoader(); 454 | $signature = null; 455 | $jwsVerified = $jwsLoader->loadAndVerifyWithKeySet($jws, $this->getJwkSet(), $signature); 456 | return (array) Json::decode($jwsVerified->getPayload(), true); 457 | } catch (Exception $e) { 458 | throw new ClientException('Loading JWS: Exception: ' . $e->getMessage(), $e->getCode()); 459 | } 460 | } 461 | 462 | /** 463 | * Return JWSLoader that validate the JWS token. 464 | * 465 | * @throws InvalidConfigException on invalid algorithm provide in configuration. 466 | * 467 | * @return JWSLoader to do token validation. 468 | */ 469 | protected function getJwsLoader(): JWSLoader 470 | { 471 | if (!($this->jwsLoader instanceof JWSLoader)) { 472 | $algorithms = []; 473 | /** @var string $algorithm */ 474 | foreach ($this->allowedJwsAlgorithms as $algorithm) { 475 | $class = '\Jose\Component\Signature\Algorithm\\' . $algorithm; 476 | if (!class_exists($class)) { 477 | throw new InvalidConfigException("Algorithm class $class doesn't exist"); 478 | } 479 | /** 480 | * @psalm-suppress MixedMethodCall new $class() 481 | */ 482 | $algorithms[] = new $class(); 483 | } 484 | /** 485 | * @psalm-suppress ArgumentTypeCoercion 486 | */ 487 | $algorithmManager = new AlgorithmManager($algorithms); 488 | $compactSerializer = new CompactSerializer(); 489 | /** @psalm-var string[] $this->allowedJwsAlgorithms */ 490 | $checker = new AlgorithmChecker($this->allowedJwsAlgorithms); 491 | $this->jwsLoader = new JWSLoader( 492 | new JWSSerializerManager([$compactSerializer]), 493 | new JWSVerifier($algorithmManager), 494 | new HeaderCheckerManager( 495 | [new AlgorithmChecker($checker)], 496 | [new JWSTokenSupport()] 497 | ) 498 | ); 499 | } 500 | return $this->jwsLoader; 501 | } 502 | 503 | protected function getJwkSet(): ?JWKSet 504 | { 505 | $jwkSet = $this->jwkSet; 506 | if (!($this->jwkSet instanceof JWKSet)) { 507 | $cacheKey = $this->configParamsCacheKeyPrefix . 'jwkSet'; 508 | 509 | /** @var mixed $jwkSetRaw */ 510 | $jwkSetRaw = $this->cache->get($cacheKey); 511 | 512 | /** @var JWKSet|null $jwkSet */ 513 | $jwkSet = $jwkSetRaw instanceof JWKSet ? $jwkSetRaw : null; 514 | 515 | if ($jwkSet === null) { 516 | /** @var mixed $jwksUriRaw */ 517 | $jwksUriRaw = $this->getConfigParam('jwks_uri'); 518 | $jwksUri = is_string($jwksUriRaw) ? $jwksUriRaw : ''; 519 | $request = $this->createRequest('GET', $jwksUri); 520 | $response = $this->sendRequest($request); 521 | /** @var mixed $jsonBody */ 522 | $jsonBody = Json::decode($response->getBody()->getContents()); 523 | $jsonBody = is_array($jsonBody) ? $jsonBody : []; 524 | $jwkSet = JWKFactory::createFromValues($jsonBody); 525 | } 526 | $this->cache->set($cacheKey, $jwkSet); 527 | } 528 | return $jwkSet instanceof JWKSet ? $jwkSet : null; 529 | } 530 | 531 | /** 532 | * Validates the claims data received from OpenID provider. 533 | * 534 | * @param array $claims claims data. 535 | * 536 | * @throws ClientException on invalid claims. 537 | */ 538 | protected function validateClaims(array $claims): void 539 | { 540 | $iss = isset($claims['iss']) ? (string) $claims['iss'] : ''; 541 | $issuerUrl = $this->issuerUrl; 542 | if (!isset($claims['iss']) || strcmp(rtrim($iss, '/'), rtrim($issuerUrl, '/')) !== 0) { 543 | throw new ClientException('Invalid "iss"', 400); 544 | } 545 | if (!isset($claims['aud']) || (strcmp((string) $claims['aud'], $this->clientId) !== 0)) { 546 | throw new ClientException('Invalid "aud"', 400); 547 | } 548 | } 549 | } 550 | --------------------------------------------------------------------------------