├── 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 | Redirecting back to the "= $appName ?? 'app' ?>"...
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
Yii External Authentication
12 |
13 |
14 |
15 | [](https://packagist.org/packages/yiisoft/yii-auth-client)
16 | [](https://packagist.org/packages/yiisoft/yii-auth-client)
17 | [](https://github.com/yiisoft/yii-auth-client/actions?query=workflow%3Abuild)
18 | [](https://codecov.io/gh/yiisoft/yii-auth-client)
19 | [](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/yii-auth-client/master)
20 | [](https://github.com/yiisoft/yii-auth-client/actions?query=workflow%3A%22static+analysis%22)
21 | [](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 | [](https://opencollective.com/yiisoft)
56 |
57 | ## Follow updates
58 |
59 | [](https://www.yiiframework.com/)
60 | [](https://twitter.com/yiiframework)
61 | [](https://t.me/yii3en)
62 | [](https://www.facebook.com/groups/yiitalk)
63 | [](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 | * - = $authChoice->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 |
--------------------------------------------------------------------------------