├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ ├── test.yml
│ └── lint.yml
├── src
├── Exceptions
│ ├── BadRequestException.php
│ ├── Exception.php
│ ├── MethodDoesNotSupportException.php
│ ├── InvalidArgumentException.php
│ ├── FeiShu
│ │ └── InvalidTicketException.php
│ ├── InvalidTokenException.php
│ └── AuthorizeFailedException.php
├── Providers
│ ├── Lark.php
│ ├── XiGua.php
│ ├── TouTiao.php
│ ├── Outlook.php
│ ├── Azure.php
│ ├── Gitee.php
│ ├── Douban.php
│ ├── Line.php
│ ├── Figma.php
│ ├── GitHub.php
│ ├── Google.php
│ ├── Baidu.php
│ ├── Facebook.php
│ ├── Coding.php
│ ├── Weibo.php
│ ├── QQ.php
│ ├── Linkedin.php
│ ├── DingTalk.php
│ ├── DouYin.php
│ ├── Taobao.php
│ ├── PayPal.php
│ ├── Tapd.php
│ ├── WeWork.php
│ ├── Alipay.php
│ ├── QCloud.php
│ ├── OpenWeWork.php
│ ├── WeChat.php
│ ├── FeiShu.php
│ └── Base.php
├── Contracts
│ ├── FactoryInterface.php
│ ├── UserInterface.php
│ └── ProviderInterface.php
├── Traits
│ └── HasAttributes.php
├── Config.php
├── User.php
└── SocialiteManager.php
├── phpstan.neon.dist
├── .gitignore
├── phpunit.xml
├── LICENSE
├── composer.json
└── tests
├── UserTest.php
├── SocialiteManagerTest.php
├── OAuthTest.php
└── Providers
├── WechatTest.php
├── FeiShuTest.php
├── DingTalkTest.php
├── QQTest.php
└── DouYinTest.php
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [overtrue]
2 |
--------------------------------------------------------------------------------
/src/Exceptions/BadRequestException.php:
--------------------------------------------------------------------------------
1 | token = $token;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Exceptions/AuthorizeFailedException.php:
--------------------------------------------------------------------------------
1 | body = (array) $body;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "composer" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
--------------------------------------------------------------------------------
/src/Contracts/FactoryInterface.php:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 | ./tests/
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | phpunit:
6 | name: PHP-${{ matrix.php_version }}-${{ matrix.perfer }}
7 | runs-on: ubuntu-latest
8 | strategy:
9 | fail-fast: false
10 | matrix:
11 | php_version:
12 | - 8.0
13 | - 8.1
14 | - 8.2
15 | - 8.3
16 | - 8.4
17 | - 8.5
18 | perfer:
19 | - stable
20 | - lowest
21 | steps:
22 | - uses: actions/checkout@v3
23 | - run: composer validate --strict
24 | - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
25 | id: composer-cache
26 | - uses: actions/cache@v3
27 | with:
28 | path: ${{ steps.composer-cache.outputs.dir }}
29 | key: dependencies-caches-php-${{ hashFiles('**/composer.lock') }}
30 | restore-keys: dependencies-caches-php-
31 | - run: composer update --prefer-dist --no-interaction --no-suggest --prefer-${{ matrix.perfer }}
32 | - run: ./vendor/bin/phpunit
33 |
--------------------------------------------------------------------------------
/src/Providers/XiGua.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->baseUrl.'/oauth/connect');
21 | }
22 |
23 | #[Pure]
24 | protected function mapUserToObject(array $user): Contracts\UserInterface
25 | {
26 | return new User([
27 | Contracts\ABNF_ID => $user[Contracts\ABNF_OPEN_ID] ?? null,
28 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NICKNAME] ?? null,
29 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NICKNAME] ?? null,
30 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
31 | ]);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Providers/TouTiao.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->baseUrl.'/oauth/authorize/');
21 | }
22 |
23 | #[Pure]
24 | protected function mapUserToObject(array $user): Contracts\UserInterface
25 | {
26 | return new User([
27 | Contracts\ABNF_ID => $user[Contracts\ABNF_OPEN_ID] ?? null,
28 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NICKNAME] ?? null,
29 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NICKNAME] ?? null,
30 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
31 | ]);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 overtrue
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | phpstan:
6 | name: PHPStan
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 | - run: composer validate --strict
11 | - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
12 | id: composer-cache
13 | - uses: actions/cache@v3
14 | with:
15 | path: ${{ steps.composer-cache.outputs.dir }}
16 | key: dependencies-caches-php-${{ hashFiles('**/composer.lock') }}
17 | restore-keys: dependencies-caches-php-
18 | - run: composer install --no-progress
19 | - run: composer phpstan
20 |
21 | php_cs_fixer:
22 | name: PHP-CS-Fxier
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v3
26 | - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
27 | id: composer-cache
28 | - uses: actions/cache@v3
29 | with:
30 | path: ${{ steps.composer-cache.outputs.dir }}
31 | key: dependencies-caches-php-${{ hashFiles('**/composer.lock') }}
32 | restore-keys: dependencies-caches-php-
33 | - run: composer install --no-progress
34 | - run: composer check-style
35 |
--------------------------------------------------------------------------------
/src/Contracts/UserInterface.php:
--------------------------------------------------------------------------------
1 | =8.0.2",
26 | "ext-json": "*",
27 | "ext-openssl": "*",
28 | "guzzlehttp/guzzle": "^7.0"
29 | },
30 | "require-dev": {
31 | "mockery/mockery": "^1.3",
32 | "phpunit/phpunit": "^11.3",
33 | "jetbrains/phpstorm-attributes": "^1.0",
34 | "phpstan/phpstan": "^2.1",
35 | "laravel/pint": "^1.2"
36 | },
37 | "license": "MIT",
38 | "authors": [
39 | {
40 | "name": "overtrue",
41 | "email": "anzhengchao@gmail.com"
42 | }
43 | ],
44 | "scripts": {
45 | "check-style": "@php vendor/bin/pint --test",
46 | "fix-style": "@php vendor/bin/pint",
47 | "fix": "@php vendor/bin/pint",
48 | "test": "@php vendor/bin/phpunit --colors=always",
49 | "phpstan": "@php vendor/bin/phpstan analyse src"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/UserTest.php:
--------------------------------------------------------------------------------
1 | assertSame('[]', json_encode(new User([])));
11 | $this->assertSame('{"access_token":"mock-token"}', json_encode(new User(['access_token' => 'mock-token'])));
12 | }
13 |
14 | public function test_it_can_get_refresh_token()
15 | {
16 | $user = new User([
17 | 'name' => 'fake_name',
18 | 'access_token' => 'mock-token',
19 | 'refresh_token' => 'fake_refresh',
20 | ]);
21 |
22 | // 能通过用 User 对象获取 refresh token
23 | $this->assertSame('fake_refresh', $user->getRefreshToken());
24 | $this->assertSame('{"name":"fake_name","access_token":"mock-token","refresh_token":"fake_refresh"}', json_encode($user));
25 |
26 | // 无 refresh_token 属性返回 null
27 | $user = new User([]);
28 | $this->assertSame(null, $user->getRefreshToken());
29 | // 能通过 setRefreshToken() 设置
30 | $user->setRefreshToken('fake_refreshToken');
31 | $this->assertSame('fake_refreshToken', $user->getRefreshToken());
32 | $this->assertSame('{"refresh_token":"fake_refreshToken"}', json_encode($user));
33 | }
34 |
35 | public function test_it_can_set_raw_data()
36 | {
37 | $user = new User([]);
38 | $data = ['data' => 'raw'];
39 |
40 | $user->setRaw($data);
41 | $this->assertSame($data, $user->getRaw());
42 | $this->assertSame(json_encode(['raw' => ['data' => 'raw']]), json_encode($user));
43 | }
44 |
45 | public function test_ie_can_get_attribute_by_magic_method()
46 | {
47 | $user = new User(['xx' => 'data']);
48 |
49 | $this->assertSame('data', $user->xx);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Traits/HasAttributes.php:
--------------------------------------------------------------------------------
1 | attributes;
15 | }
16 |
17 | public function getAttribute(string $name, mixed $default = null): mixed
18 | {
19 | return $this->attributes[$name] ?? $default;
20 | }
21 |
22 | public function setAttribute(string $name, mixed $value): self
23 | {
24 | $this->attributes[$name] = $value;
25 |
26 | return $this;
27 | }
28 |
29 | public function merge(array $attributes): self
30 | {
31 | $this->attributes = \array_merge($this->attributes, $attributes);
32 |
33 | return $this;
34 | }
35 |
36 | public function offsetExists(mixed $offset): bool
37 | {
38 | return \array_key_exists($offset, $this->attributes);
39 | }
40 |
41 | public function offsetGet(mixed $offset): mixed
42 | {
43 | return $this->getAttribute($offset);
44 | }
45 |
46 | public function offsetSet(mixed $offset, mixed $value): void
47 | {
48 | $this->setAttribute($offset, $value);
49 | }
50 |
51 | public function offsetUnset(mixed $offset): void
52 | {
53 | unset($this->attributes[$offset]);
54 | }
55 |
56 | public function __get(string $property): mixed
57 | {
58 | return $this->getAttribute($property);
59 | }
60 |
61 | #[Pure]
62 | public function toArray(): array
63 | {
64 | return $this->getAttributes();
65 | }
66 |
67 | public function toJSON(): string
68 | {
69 | $result = \json_encode($this->getAttributes(), JSON_UNESCAPED_UNICODE);
70 |
71 | $result === false && throw new Exceptions\Exception('Cannot Processing this instance as JSON stringify.');
72 |
73 | return $result;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Providers/Outlook.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://login.microsoftonline.com/common/oauth2/v2.0/authorize');
21 | }
22 |
23 | protected function getTokenUrl(): string
24 | {
25 | return 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
26 | }
27 |
28 | protected function getUserByToken(string $token, ?array $query = []): array
29 | {
30 | $response = $this->getHttpClient()->get(
31 | 'https://graph.microsoft.com/v1.0/me',
32 | ['headers' => [
33 | 'Accept' => 'application/json',
34 | 'Authorization' => 'Bearer '.$token,
35 | ],
36 | ]
37 | );
38 |
39 | return $this->fromJsonBody($response);
40 | }
41 |
42 | #[Pure]
43 | protected function mapUserToObject(array $user): Contracts\UserInterface
44 | {
45 | return new User([
46 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
47 | Contracts\ABNF_NICKNAME => null,
48 | Contracts\ABNF_NAME => $user['displayName'] ?? null,
49 | Contracts\ABNF_EMAIL => $user['userPrincipalName'] ?? null,
50 | Contracts\ABNF_AVATAR => null,
51 | ]);
52 | }
53 |
54 | #[ArrayShape([
55 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
56 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
57 | Contracts\RFC6749_ABNF_CODE => 'string',
58 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
59 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
60 | ])]
61 | protected function getTokenFields(string $code): array
62 | {
63 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/Providers/Azure.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->getBaseUrl().'/oauth2/v2.0/authorize');
21 | }
22 |
23 | protected function getBaseUrl(): string
24 | {
25 | return 'https://login.microsoftonline.com/'.$this->config['tenant'];
26 | }
27 |
28 | protected function getTokenUrl(): string
29 | {
30 | return $this->getBaseUrl().'/oauth2/v2.0/token';
31 | }
32 |
33 | protected function getUserByToken(string $token, ?array $query = []): array
34 | {
35 | $response = $this->getHttpClient()->get(
36 | 'https://graph.microsoft.com/v1.0/me',
37 | ['headers' => [
38 | 'Accept' => 'application/json',
39 | 'Authorization' => 'Bearer '.$token,
40 | ],
41 | ]
42 | );
43 |
44 | return $this->fromJsonBody($response);
45 | }
46 |
47 | #[Pure]
48 | protected function mapUserToObject(array $user): Contracts\UserInterface
49 | {
50 | return new User([
51 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
52 | Contracts\ABNF_NICKNAME => null,
53 | Contracts\ABNF_NAME => $user['displayName'] ?? null,
54 | Contracts\ABNF_EMAIL => $user['userPrincipalName'] ?? null,
55 | Contracts\ABNF_AVATAR => null,
56 | ]);
57 | }
58 |
59 | #[ArrayShape([
60 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
61 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
62 | Contracts\RFC6749_ABNF_CODE => 'string',
63 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
64 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
65 | ])]
66 | protected function getTokenFields(string $code): array
67 | {
68 | return parent::getTokenFields($code) + [
69 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
70 | ];
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/Providers/Gitee.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://gitee.com/oauth/authorize');
19 | }
20 |
21 | protected function getTokenUrl(): string
22 | {
23 | return 'https://gitee.com/oauth/token';
24 | }
25 |
26 | protected function getUserByToken(string $token): array
27 | {
28 | $userUrl = 'https://gitee.com/api/v5/user';
29 | $response = $this->getHttpClient()->get($userUrl, [
30 | 'query' => [
31 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
32 | ],
33 | ]);
34 |
35 | return $this->fromJsonBody($response);
36 | }
37 |
38 | #[Pure]
39 | protected function mapUserToObject(array $user): Contracts\UserInterface
40 | {
41 | return new User([
42 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
43 | Contracts\ABNF_NICKNAME => $user['login'] ?? null,
44 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
45 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
46 | Contracts\ABNF_AVATAR => $user['avatar_url'] ?? null,
47 | ]);
48 | }
49 |
50 | #[ArrayShape([
51 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
52 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
53 | Contracts\RFC6749_ABNF_CODE => 'string',
54 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
55 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
56 | ])]
57 | protected function getTokenFields(string $code): array
58 | {
59 | return [
60 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
61 | Contracts\RFC6749_ABNF_CLIENT_SECRET => $this->getClientSecret(),
62 | Contracts\RFC6749_ABNF_CODE => $code,
63 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
64 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
65 | ];
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Providers/Douban.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://www.douban.com/service/auth2/auth');
20 | }
21 |
22 | protected function getTokenUrl(): string
23 | {
24 | return 'https://www.douban.com/service/auth2/token';
25 | }
26 |
27 | protected function getUserByToken(string $token, ?array $query = []): array
28 | {
29 | $response = $this->getHttpClient()->get('https://api.douban.com/v2/user/~me', [
30 | 'headers' => [
31 | 'Authorization' => 'Bearer '.$token,
32 | ],
33 | ]);
34 |
35 | return $this->fromJsonBody($response);
36 | }
37 |
38 | #[Pure]
39 | protected function mapUserToObject(array $user): Contracts\UserInterface
40 | {
41 | return new User([
42 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
43 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NAME] ?? null,
44 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
45 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
46 | Contracts\ABNF_EMAIL => null,
47 | ]);
48 | }
49 |
50 | #[ArrayShape([
51 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
52 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
53 | Contracts\RFC6749_ABNF_CODE => 'string',
54 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
55 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
56 | ])]
57 | protected function getTokenFields(string $code): array
58 | {
59 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
60 | }
61 |
62 | public function tokenFromCode(string $code): array
63 | {
64 | $response = $this->getHttpClient()->post($this->getTokenUrl(), [
65 | 'form_params' => $this->getTokenFields($code),
66 | ]);
67 |
68 | return $this->normalizeAccessTokenResponse($response->getBody());
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Providers/Line.php:
--------------------------------------------------------------------------------
1 | state = $this->state ?: \md5(\uniqid(Contracts\RFC6749_ABNF_STATE, true));
26 |
27 | return $this->buildAuthUrlFromBase('https://access.line.me/oauth2/'.$this->version.'/authorize');
28 | }
29 |
30 | protected function getTokenUrl(): string
31 | {
32 | return $this->baseUrl.$this->version.'/token';
33 | }
34 |
35 | #[ArrayShape([
36 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
37 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
38 | Contracts\RFC6749_ABNF_CODE => 'string',
39 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
40 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
41 | ])]
42 | protected function getTokenFields(string $code): array
43 | {
44 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
45 | }
46 |
47 | protected function getUserByToken(string $token): array
48 | {
49 | $response = $this->getHttpClient()->get(
50 | 'https://api.line.me/v2/profile',
51 | [
52 | 'headers' => [
53 | 'Accept' => 'application/json',
54 | 'Authorization' => 'Bearer '.$token,
55 | ],
56 | ]
57 | );
58 |
59 | return $this->fromJsonBody($response);
60 | }
61 |
62 | #[Pure]
63 | protected function mapUserToObject(array $user): Contracts\UserInterface
64 | {
65 | return new User([
66 | Contracts\ABNF_ID => $user['userId'] ?? null,
67 | Contracts\ABNF_NAME => $user['displayName'] ?? null,
68 | Contracts\ABNF_NICKNAME => $user['displayName'] ?? null,
69 | Contracts\ABNF_AVATAR => $user['pictureUrl'] ?? null,
70 | Contracts\ABNF_EMAIL => null,
71 | ]);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Config.php:
--------------------------------------------------------------------------------
1 | config = $config;
15 | }
16 |
17 | public function get(string $key, mixed $default = null): mixed
18 | {
19 | $config = $this->config;
20 |
21 | if (isset($config[$key])) {
22 | return $config[$key];
23 | }
24 |
25 | foreach (\explode('.', $key) as $segment) {
26 | if (! \is_array($config) || ! \array_key_exists($segment, $config)) {
27 | return $default;
28 | }
29 | $config = $config[$segment];
30 | }
31 |
32 | return $config;
33 | }
34 |
35 | public function set(string $key, mixed $value): array
36 | {
37 | $keys = \explode('.', $key);
38 | $config = &$this->config;
39 |
40 | while (\count($keys) > 1) {
41 | $key = \array_shift($keys);
42 | if (! isset($config[$key]) || ! \is_array($config[$key])) {
43 | $config[$key] = [];
44 | }
45 | $config = &$config[$key];
46 | }
47 |
48 | $config[\array_shift($keys)] = $value;
49 |
50 | return $config;
51 | }
52 |
53 | public function has(string $key): bool
54 | {
55 | return (bool) $this->get($key);
56 | }
57 |
58 | public function offsetExists(mixed $offset): bool
59 | {
60 | \is_string($offset) || throw new Exceptions\InvalidArgumentException('The $offset must be type of string here.');
61 |
62 | return \array_key_exists($offset, $this->config);
63 | }
64 |
65 | public function offsetGet(mixed $offset): mixed
66 | {
67 | \is_string($offset) || throw new Exceptions\InvalidArgumentException('The $offset must be type of string here.');
68 |
69 | return $this->get($offset);
70 | }
71 |
72 | public function offsetSet(mixed $offset, mixed $value): void
73 | {
74 | \is_string($offset) || throw new Exceptions\InvalidArgumentException('The $offset must be type of string here.');
75 |
76 | $this->set($offset, $value);
77 | }
78 |
79 | public function offsetUnset(mixed $offset): void
80 | {
81 | \is_string($offset) || throw new Exceptions\InvalidArgumentException('The $offset must be type of string here.');
82 |
83 | $this->set($offset, null);
84 | }
85 |
86 | public function jsonSerialize(): array
87 | {
88 | return $this->config;
89 | }
90 |
91 | public function __toString(): string
92 | {
93 | return \json_encode($this, \JSON_UNESCAPED_UNICODE) ?: '';
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Providers/Figma.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://www.figma.com/oauth');
24 | }
25 |
26 | protected function getTokenUrl(): string
27 | {
28 | return 'https://www.figma.com/api/oauth/token';
29 | }
30 |
31 | public function tokenFromCode(string $code): array
32 | {
33 | $response = $this->getHttpClient()->post($this->getTokenUrl(), [
34 | 'form_params' => $this->getTokenFields($code),
35 | ]);
36 |
37 | return $this->normalizeAccessTokenResponse($response->getBody());
38 | }
39 |
40 | #[ArrayShape([
41 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
42 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
43 | Contracts\RFC6749_ABNF_CODE => 'string',
44 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
45 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
46 | ])]
47 | protected function getTokenFields(string $code): array
48 | {
49 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
50 | }
51 |
52 | protected function getCodeFields(): array
53 | {
54 | return parent::getCodeFields() + [Contracts\RFC6749_ABNF_STATE => \md5(\uniqid('state_', true))];
55 | }
56 |
57 | protected function getUserByToken(string $token, ?array $query = []): array
58 | {
59 | $response = $this->getHttpClient()->get('https://api.figma.com/v1/me', [
60 | 'headers' => [
61 | 'Accept' => 'application/json',
62 | 'Authorization' => 'Bearer '.$token,
63 | ],
64 | ]);
65 |
66 | return $this->fromJsonBody($response);
67 | }
68 |
69 | #[Pure]
70 | protected function mapUserToObject(array $user): Contracts\UserInterface
71 | {
72 | return new User([
73 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
74 | 'username' => $user[Contracts\ABNF_EMAIL] ?? null,
75 | Contracts\ABNF_NICKNAME => $user['handle'] ?? null,
76 | Contracts\ABNF_NAME => $user['handle'] ?? null,
77 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
78 | Contracts\ABNF_AVATAR => $user['img_url'] ?? null,
79 | ]);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/Providers/GitHub.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://github.com/login/oauth/authorize');
21 | }
22 |
23 | protected function getTokenUrl(): string
24 | {
25 | return 'https://github.com/login/oauth/access_token';
26 | }
27 |
28 | protected function getUserByToken(string $token): array
29 | {
30 | $userUrl = 'https://api.github.com/user';
31 |
32 | $response = $this->getHttpClient()->get(
33 | $userUrl,
34 | $this->createAuthorizationHeaders($token)
35 | );
36 |
37 | $user = $this->fromJsonBody($response);
38 |
39 | if (\in_array('user:email', $this->scopes)) {
40 | $user[Contracts\ABNF_EMAIL] = $this->getEmailByToken($token);
41 | }
42 |
43 | return $user;
44 | }
45 |
46 | protected function getEmailByToken(string $token): string
47 | {
48 | $emailsUrl = 'https://api.github.com/user/emails';
49 |
50 | try {
51 | $response = $this->getHttpClient()->get(
52 | $emailsUrl,
53 | $this->createAuthorizationHeaders($token)
54 | );
55 | } catch (\Throwable $e) {
56 | return '';
57 | }
58 |
59 | foreach ($this->fromJsonBody($response) as $email) {
60 | if ($email['primary'] && $email['verified']) {
61 | return $email[Contracts\ABNF_EMAIL];
62 | }
63 | }
64 |
65 | return '';
66 | }
67 |
68 | #[Pure]
69 | protected function mapUserToObject(array $user): Contracts\UserInterface
70 | {
71 | return new User([
72 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
73 | Contracts\ABNF_NICKNAME => $user['login'] ?? null,
74 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
75 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
76 | Contracts\ABNF_AVATAR => $user['avatar_url'] ?? null,
77 | ]);
78 | }
79 |
80 | #[ArrayShape(['headers' => 'array'])]
81 | protected function createAuthorizationHeaders(string $token): array
82 | {
83 | return [
84 | 'headers' => [
85 | 'Accept' => 'application/vnd.github.v3+json',
86 | 'Authorization' => \sprintf('token %s', $token),
87 | ],
88 | ];
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/Providers/Google.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://accounts.google.com/o/oauth2/v2/auth');
27 | }
28 |
29 | protected function getTokenUrl(): string
30 | {
31 | return 'https://www.googleapis.com/oauth2/v4/token';
32 | }
33 |
34 | public function tokenFromCode(string $code): array
35 | {
36 | $response = $this->getHttpClient()->post($this->getTokenUrl(), [
37 | 'form_params' => $this->getTokenFields($code),
38 | ]);
39 |
40 | return $this->normalizeAccessTokenResponse($response->getBody());
41 | }
42 |
43 | #[ArrayShape([
44 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
45 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
46 | Contracts\RFC6749_ABNF_CODE => 'string',
47 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
48 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
49 | ])]
50 | protected function getTokenFields(string $code): array
51 | {
52 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
53 | }
54 |
55 | protected function getUserByToken(string $token, ?array $query = []): array
56 | {
57 | $response = $this->getHttpClient()->get('https://www.googleapis.com/userinfo/v2/me', [
58 | 'headers' => [
59 | 'Accept' => 'application/json',
60 | 'Authorization' => 'Bearer '.$token,
61 | ],
62 | ]);
63 |
64 | return $this->fromJsonBody($response);
65 | }
66 |
67 | #[Pure]
68 | protected function mapUserToObject(array $user): Contracts\UserInterface
69 | {
70 | return new User([
71 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
72 | 'username' => $user[Contracts\ABNF_EMAIL] ?? null,
73 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NAME] ?? null,
74 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
75 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
76 | Contracts\ABNF_AVATAR => $user['picture'] ?? null,
77 | ]);
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Contracts/ProviderInterface.php:
--------------------------------------------------------------------------------
1 | display = $display;
28 |
29 | return $this;
30 | }
31 |
32 | public function withScopes(array $scopes): self
33 | {
34 | $this->scopes = $scopes;
35 |
36 | return $this;
37 | }
38 |
39 | protected function getAuthUrl(): string
40 | {
41 | return $this->buildAuthUrlFromBase($this->baseUrl.'/oauth/'.$this->version.'/authorize');
42 | }
43 |
44 | protected function getCodeFields(): array
45 | {
46 | return [
47 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
48 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
49 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
50 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
51 | 'display' => $this->display,
52 | ] + $this->parameters;
53 | }
54 |
55 | protected function getTokenUrl(): string
56 | {
57 | return $this->baseUrl.'/oauth/'.$this->version.'/token';
58 | }
59 |
60 | #[ArrayShape([
61 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
62 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
63 | Contracts\RFC6749_ABNF_CODE => 'string',
64 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
65 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
66 | ])]
67 | protected function getTokenFields(string $code): array
68 | {
69 | return parent::getTokenFields($code) + [
70 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
71 | ];
72 | }
73 |
74 | protected function getUserByToken(string $token): array
75 | {
76 | $response = $this->getHttpClient()->get(
77 | $this->baseUrl.'/rest/'.$this->version.'/passport/users/getInfo',
78 | [
79 | 'query' => [
80 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
81 | ],
82 | 'headers' => [
83 | 'Accept' => 'application/json',
84 | ],
85 | ]
86 | );
87 |
88 | return $this->fromJsonBody($response);
89 | }
90 |
91 | #[Pure]
92 | protected function mapUserToObject(array $user): Contracts\UserInterface
93 | {
94 | return new User([
95 | Contracts\ABNF_ID => $user['userid'] ?? null,
96 | Contracts\ABNF_NICKNAME => $user['realname'] ?? null,
97 | Contracts\ABNF_NAME => $user['username'] ?? null,
98 | Contracts\ABNF_EMAIL => '',
99 | Contracts\ABNF_AVATAR => $user['portrait'] ? 'http://tb.himg.baidu.com/sys/portraitn/item/'.$user['portrait'] : null,
100 | ]);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/Providers/Facebook.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://www.facebook.com/'.$this->version.'/dialog/oauth');
29 | }
30 |
31 | protected function getTokenUrl(): string
32 | {
33 | return $this->graphUrl.'/oauth/access_token';
34 | }
35 |
36 | public function tokenFromCode(string $code): array
37 | {
38 | $response = $this->getHttpClient()->get($this->getTokenUrl(), [
39 | 'query' => $this->getTokenFields($code),
40 | ]);
41 |
42 | return $this->normalizeAccessTokenResponse($response->getBody());
43 | }
44 |
45 | protected function getUserByToken(string $token, ?array $query = []): array
46 | {
47 | $appSecretProof = \hash_hmac('sha256', $token, $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_SECRET));
48 |
49 | $response = $this->getHttpClient()->get($this->graphUrl.'/'.$this->version.'/me', [
50 | 'query' => [
51 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
52 | 'appsecret_proof' => $appSecretProof,
53 | 'fields' => $this->formatScopes($this->fields, $this->scopeSeparator),
54 | ],
55 | 'headers' => [
56 | 'Accept' => 'application/json',
57 | ],
58 | ]);
59 |
60 | return $this->fromJsonBody($response);
61 | }
62 |
63 | #[Pure]
64 | protected function mapUserToObject(array $user): Contracts\UserInterface
65 | {
66 | $userId = $user[Contracts\ABNF_ID] ?? null;
67 | $avatarUrl = $this->graphUrl.'/'.$this->version.'/'.$userId.'/picture';
68 |
69 | $firstName = $user['first_name'] ?? null;
70 | $lastName = $user['last_name'] ?? null;
71 |
72 | return new User([
73 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
74 | Contracts\ABNF_NICKNAME => null,
75 | Contracts\ABNF_NAME => $firstName.' '.$lastName,
76 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
77 | Contracts\ABNF_AVATAR => $userId ? $avatarUrl.'?type=normal' : null,
78 | 'avatar_original' => $userId ? $avatarUrl.'?width=1920' : null,
79 | ]);
80 | }
81 |
82 | protected function getCodeFields(): array
83 | {
84 | $fields = parent::getCodeFields();
85 |
86 | if ($this->popup) {
87 | $fields['display'] = 'popup';
88 | }
89 |
90 | return $fields;
91 | }
92 |
93 | public function fields(array $fields): self
94 | {
95 | $this->fields = $fields;
96 |
97 | return $this;
98 | }
99 |
100 | public function asPopup(): self
101 | {
102 | $this->popup = true;
103 |
104 | return $this;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/User.php:
--------------------------------------------------------------------------------
1 | attributes = $attributes;
15 | }
16 |
17 | public function getId(): mixed
18 | {
19 | return $this->getAttribute(Contracts\ABNF_ID) ?? $this->getEmail();
20 | }
21 |
22 | public function getNickname(): ?string
23 | {
24 | return $this->getAttribute(Contracts\ABNF_NICKNAME) ?? $this->getName();
25 | }
26 |
27 | public function getName(): ?string
28 | {
29 | return $this->getAttribute(Contracts\ABNF_NAME);
30 | }
31 |
32 | public function getEmail(): ?string
33 | {
34 | return $this->getAttribute(Contracts\ABNF_EMAIL);
35 | }
36 |
37 | public function getAvatar(): ?string
38 | {
39 | return $this->getAttribute(Contracts\ABNF_AVATAR);
40 | }
41 |
42 | public function setAccessToken(string $value): self
43 | {
44 | $this->setAttribute(Contracts\RFC6749_ABNF_ACCESS_TOKEN, $value);
45 |
46 | return $this;
47 | }
48 |
49 | public function getAccessToken(): ?string
50 | {
51 | return $this->getAttribute(Contracts\RFC6749_ABNF_ACCESS_TOKEN);
52 | }
53 |
54 | public function setRefreshToken(?string $value): self
55 | {
56 | $this->setAttribute(Contracts\RFC6749_ABNF_REFRESH_TOKEN, $value);
57 |
58 | return $this;
59 | }
60 |
61 | public function getRefreshToken(): ?string
62 | {
63 | return $this->getAttribute(Contracts\RFC6749_ABNF_REFRESH_TOKEN);
64 | }
65 |
66 | public function setExpiresIn(int $value): self
67 | {
68 | $this->setAttribute(Contracts\RFC6749_ABNF_EXPIRES_IN, $value);
69 |
70 | return $this;
71 | }
72 |
73 | public function getExpiresIn(): ?int
74 | {
75 | return $this->getAttribute(Contracts\RFC6749_ABNF_EXPIRES_IN);
76 | }
77 |
78 | public function setRaw(array $user): self
79 | {
80 | $this->setAttribute('raw', $user);
81 |
82 | return $this;
83 | }
84 |
85 | public function getRaw(): array
86 | {
87 | return $this->getAttribute('raw', []);
88 | }
89 |
90 | public function setTokenResponse(array $response): self
91 | {
92 | $this->setAttribute('token_response', $response);
93 |
94 | return $this;
95 | }
96 |
97 | public function getTokenResponse(): mixed
98 | {
99 | return $this->getAttribute('token_response');
100 | }
101 |
102 | public function jsonSerialize(): array
103 | {
104 | return $this->attributes;
105 | }
106 |
107 | public function __serialize(): array
108 | {
109 | return $this->attributes;
110 | }
111 |
112 | public function __unserialize(array $serialized): void
113 | {
114 | $this->attributes = $serialized ?: [];
115 | }
116 |
117 | public function getProvider(): Contracts\ProviderInterface
118 | {
119 | return $this->provider ?? throw new Exceptions\Exception('The provider instance doesn\'t initialized correctly.');
120 | }
121 |
122 | public function setProvider(Contracts\ProviderInterface $provider): self
123 | {
124 | $this->provider = $provider;
125 |
126 | return $this;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/Providers/Coding.php:
--------------------------------------------------------------------------------
1 | config->get('team_url');
29 |
30 | if (! $teamUrl) {
31 | throw new InvalidArgumentException('Missing required config [team_url]');
32 | }
33 |
34 | // validate team_url
35 | if (filter_var($teamUrl, FILTER_VALIDATE_URL) === false) {
36 | throw new InvalidArgumentException('Invalid team_url');
37 | }
38 |
39 | $this->teamUrl = rtrim($teamUrl, '/');
40 | }
41 |
42 | protected function getAuthUrl(): string
43 | {
44 | return $this->buildAuthUrlFromBase("$this->teamUrl/oauth_authorize.html");
45 | }
46 |
47 | protected function getTokenUrl(): string
48 | {
49 | return "$this->teamUrl/api/oauth/access_token";
50 | }
51 |
52 | #[ArrayShape([
53 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
54 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
55 | Contracts\RFC6749_ABNF_CODE => 'string',
56 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'null|string',
57 | ])]
58 | protected function getTokenFields(string $code): array
59 | {
60 | return [
61 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
62 | Contracts\RFC6749_ABNF_CLIENT_SECRET => $this->getClientSecret(),
63 | Contracts\RFC6749_ABNF_CODE => $code,
64 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
65 | ];
66 | }
67 |
68 | /**
69 | * @throws \GuzzleHttp\Exception\GuzzleException
70 | * @throws \Overtrue\Socialite\Exceptions\BadRequestException
71 | */
72 | protected function getUserByToken(string $token): array
73 | {
74 | $responseInstance = $this->getHttpClient()->get(
75 | "$this->teamUrl/api/me",
76 | [
77 | 'query' => [
78 | 'access_token' => $token,
79 | ],
80 | ]
81 | );
82 |
83 | $response = $this->fromJsonBody($responseInstance);
84 |
85 | if (empty($response[Contracts\ABNF_ID])) {
86 | throw new Exceptions\BadRequestException((string) $responseInstance->getBody());
87 | }
88 |
89 | return $response;
90 | }
91 |
92 | #[Pure]
93 | protected function mapUserToObject(array $user): Contracts\UserInterface
94 | {
95 | return new User([
96 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
97 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NAME] ?? null,
98 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
99 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
100 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
101 | ]);
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/Providers/Weibo.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->baseUrl.'/oauth2/authorize');
25 | }
26 |
27 | protected function getTokenUrl(): string
28 | {
29 | return $this->baseUrl.'/2/oauth2/access_token';
30 | }
31 |
32 | #[ArrayShape([
33 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
34 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
35 | Contracts\RFC6749_ABNF_CODE => 'string',
36 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
37 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
38 | ])]
39 | protected function getTokenFields(string $code): array
40 | {
41 | return parent::getTokenFields($code) + [
42 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
43 | ];
44 | }
45 |
46 | /**
47 | * @throws Exceptions\InvalidTokenException
48 | */
49 | protected function getUserByToken(string $token): array
50 | {
51 | $uid = $this->getTokenPayload($token)['uid'] ?? null;
52 |
53 | if (empty($uid)) {
54 | throw new Exceptions\InvalidTokenException('Invalid token.', $token);
55 | }
56 |
57 | $response = $this->getHttpClient()->get($this->baseUrl.'/2/users/show.json', [
58 | 'query' => [
59 | 'uid' => $uid,
60 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
61 | ],
62 | 'headers' => [
63 | 'Accept' => 'application/json',
64 | ],
65 | ]);
66 |
67 | return $this->fromJsonBody($response);
68 | }
69 |
70 | /**
71 | * @throws Exceptions\InvalidTokenException
72 | */
73 | protected function getTokenPayload(string $token): array
74 | {
75 | $response = $this->getHttpClient()->post($this->baseUrl.'/oauth2/get_token_info', [
76 | 'query' => [
77 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
78 | ],
79 | 'headers' => [
80 | 'Accept' => 'application/json',
81 | ],
82 | ]);
83 |
84 | $response = $this->fromJsonBody($response);
85 |
86 | if (empty($response['uid'] ?? null)) {
87 | throw new Exceptions\InvalidTokenException(\sprintf('Invalid token %s', $token), $token);
88 | }
89 |
90 | return $response;
91 | }
92 |
93 | #[Pure]
94 | protected function mapUserToObject(array $user): Contracts\UserInterface
95 | {
96 | return new User([
97 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
98 | Contracts\ABNF_NICKNAME => $user['screen_name'] ?? null,
99 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
100 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
101 | Contracts\ABNF_AVATAR => $user['avatar_large'] ?? null,
102 | ]);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/tests/SocialiteManagerTest.php:
--------------------------------------------------------------------------------
1 | [
15 | 'provider' => 'github',
16 | 'client_id' => 'foo-app-id',
17 | 'client_secret' => 'your-app-secret',
18 | 'redirect' => 'http://localhost/socialite/callback.php',
19 | ],
20 | 'bar' => [
21 | 'provider' => 'github',
22 | 'client_id' => 'bar-app-id',
23 | 'client_secret' => 'your-app-secret',
24 | 'redirect' => 'http://localhost/socialite/callback.php',
25 | ],
26 | ];
27 |
28 | $manager = new SocialiteManager($config);
29 |
30 | $this->assertInstanceOf(GitHub::class, $manager->create('foo'));
31 | $this->assertSame('foo-app-id', $manager->create('foo')->getClientId());
32 |
33 | $this->assertInstanceOf(GitHub::class, $manager->create('bar'));
34 | $this->assertSame('bar-app-id', $manager->create('bar')->getClientId());
35 |
36 | // from name
37 | $config = [
38 | 'github' => [
39 | 'client_id' => 'your-app-id',
40 | 'client_secret' => 'your-app-secret',
41 | 'redirect' => 'http://localhost/socialite/callback.php',
42 | ],
43 | ];
44 |
45 | $manager = new SocialiteManager($config);
46 |
47 | $this->assertInstanceOf(GitHub::class, $manager->create('github'));
48 | $this->assertSame('your-app-id', $manager->create('github')->getClientId());
49 | }
50 |
51 | public function test_it_can_create_from_custom_creator()
52 | {
53 | $config = [
54 | 'foo' => [
55 | 'provider' => 'myprovider',
56 | 'client_id' => 'your-app-id',
57 | 'client_secret' => 'your-app-secret',
58 | 'redirect' => 'http://localhost/socialite/callback.php',
59 | ],
60 | ];
61 |
62 | $manager = new SocialiteManager($config);
63 |
64 | $manager->extend('myprovider', function ($config) {
65 | return new DummyProviderForCustomProviderTest($config);
66 | });
67 |
68 | $this->assertInstanceOf(DummyProviderForCustomProviderTest::class, $manager->create('foo'));
69 | }
70 |
71 | public function test_it_can_create_from_custom_provider_class()
72 | {
73 | $config = [
74 | 'foo' => [
75 | 'provider' => DummyProviderForCustomProviderTest::class,
76 | 'client_id' => 'your-app-id',
77 | 'client_secret' => 'your-app-secret',
78 | 'redirect' => 'http://localhost/socialite/callback.php',
79 | ],
80 | ];
81 |
82 | $manager = new SocialiteManager($config);
83 |
84 | $this->assertInstanceOf(DummyProviderForCustomProviderTest::class, $manager->create('foo'));
85 | }
86 | }
87 |
88 | class DummyProviderForCustomProviderTest extends Base
89 | {
90 | protected function getAuthUrl(): string
91 | {
92 | return '';
93 | }
94 |
95 | protected function getTokenUrl(): string
96 | {
97 | return '';
98 | }
99 |
100 | protected function getUserByToken(string $token): array
101 | {
102 | return [];
103 | }
104 |
105 | protected function mapUserToObject(array $user): User
106 | {
107 | return new User([]);
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/Providers/QQ.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->baseUrl.'/oauth2.0/authorize');
28 | }
29 |
30 | protected function getTokenUrl(): string
31 | {
32 | return $this->baseUrl.'/oauth2.0/token';
33 | }
34 |
35 | #[ArrayShape([
36 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
37 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
38 | Contracts\RFC6749_ABNF_CODE => 'string',
39 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
40 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
41 | ])]
42 | protected function getTokenFields(string $code): array
43 | {
44 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
45 | }
46 |
47 | public function tokenFromCode(string $code): array
48 | {
49 | $response = $this->getHttpClient()->get($this->getTokenUrl(), [
50 | 'query' => $this->getTokenFields($code),
51 | ]);
52 |
53 | \parse_str((string) $response->getBody(), $token);
54 |
55 | return $this->normalizeAccessTokenResponse($token);
56 | }
57 |
58 | public function withUnionId(): self
59 | {
60 | $this->withUnionId = true;
61 |
62 | return $this;
63 | }
64 |
65 | /**
66 | * @throws \GuzzleHttp\Exception\GuzzleException
67 | * @throws \Overtrue\Socialite\Exceptions\AuthorizeFailedException
68 | */
69 | protected function getUserByToken(string $token): array
70 | {
71 | $response = $this->getHttpClient()->get($this->baseUrl.'/oauth2.0/me', [
72 | 'query' => [
73 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
74 | 'fmt' => 'json',
75 | ] + ($this->withUnionId ? ['unionid' => 1] : []),
76 | ]);
77 |
78 | $me = $this->fromJsonBody($response);
79 |
80 | if (empty($me['openid'])) {
81 | throw new AuthorizeFailedException('Authorization failed: missing openid in token response', $me);
82 | }
83 |
84 | $response = $this->getHttpClient()->get($this->baseUrl.'/user/get_user_info', [
85 | 'query' => [
86 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
87 | 'fmt' => 'json',
88 | 'openid' => $me['openid'],
89 | 'oauth_consumer_key' => $this->getClientId(),
90 | ],
91 | ]);
92 |
93 | $user = $this->fromJsonBody($response);
94 |
95 | if (! array_key_exists('ret', $user) || $user['ret'] !== 0) {
96 | throw new AuthorizeFailedException('Authorize Failed: '.Utils::jsonEncode($user, \JSON_UNESCAPED_UNICODE), $user);
97 | }
98 |
99 | return $user + [
100 | 'unionid' => $me['unionid'] ?? null,
101 | 'openid' => $me['openid'],
102 | ];
103 | }
104 |
105 | #[Pure]
106 | protected function mapUserToObject(array $user): Contracts\UserInterface
107 | {
108 | return new User([
109 | Contracts\ABNF_ID => $user['openid'] ?? null,
110 | Contracts\ABNF_NAME => $user['nickname'] ?? null,
111 | Contracts\ABNF_NICKNAME => $user['nickname'] ?? null,
112 | Contracts\ABNF_EMAIL => $user['email'] ?? null,
113 | Contracts\ABNF_AVATAR => $user['figureurl_qq_2'] ?? null,
114 | ]);
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/Providers/Linkedin.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://www.linkedin.com/oauth/v2/authorization');
21 | }
22 |
23 | protected function getTokenUrl(): string
24 | {
25 | return 'https://www.linkedin.com/oauth/v2/accessToken';
26 | }
27 |
28 | #[ArrayShape([
29 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
30 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
31 | Contracts\RFC6749_ABNF_CODE => 'string',
32 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
33 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
34 | ])]
35 | protected function getTokenFields(string $code): array
36 | {
37 | return parent::getTokenFields($code) + [Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE];
38 | }
39 |
40 | protected function getUserByToken(string $token, ?array $query = []): array
41 | {
42 | $basicProfile = $this->getBasicProfile($token);
43 | $emailAddress = $this->getEmailAddress($token);
44 |
45 | return \array_merge($basicProfile, $emailAddress);
46 | }
47 |
48 | protected function getBasicProfile(string $token): array
49 | {
50 | $url = 'https://api.linkedin.com/v2/me?projection=(id,firstName,lastName,profilePicture(displayImage~:playableStreams))';
51 |
52 | $response = $this->getHttpClient()->get($url, [
53 | 'headers' => [
54 | 'Authorization' => 'Bearer '.$token,
55 | 'X-RestLi-Protocol-Version' => '2.0.0',
56 | ],
57 | ]);
58 |
59 | return $this->fromJsonBody($response);
60 | }
61 |
62 | protected function getEmailAddress(string $token): array
63 | {
64 | $url = 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))';
65 |
66 | $response = $this->getHttpClient()->get($url, [
67 | 'headers' => [
68 | 'Authorization' => 'Bearer '.$token,
69 | 'X-RestLi-Protocol-Version' => '2.0.0',
70 | ],
71 | ]);
72 |
73 | return $this->fromJsonBody($response)['elements'][0]['handle~'] ?? [];
74 | }
75 |
76 | protected function mapUserToObject(array $user): Contracts\UserInterface
77 | {
78 | $preferredLocale = ($user['firstName']['preferredLocale']['language'] ?? null).'_'.
79 | ($user['firstName']['preferredLocale']['country'] ?? null);
80 | $firstName = $user['firstName']['localized'][$preferredLocale] ?? null;
81 | $lastName = $user['lastName']['localized'][$preferredLocale] ?? null;
82 | $name = $firstName.' '.$lastName;
83 |
84 | $images = $user['profilePicture']['displayImage~']['elements'] ?? [];
85 | $avatars = \array_filter($images, static fn ($image) => ($image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width'] ?? 0) === 100);
86 | $avatar = \array_shift($avatars);
87 | $originalAvatars = \array_filter($images, static fn ($image) => ($image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width'] ?? 0) === 800);
88 | $originalAvatar = \array_shift($originalAvatars);
89 |
90 | return new User([
91 | Contracts\ABNF_ID => $user[Contracts\ABNF_ID] ?? null,
92 | Contracts\ABNF_NICKNAME => $name,
93 | Contracts\ABNF_NAME => $name,
94 | Contracts\ABNF_EMAIL => $user['emailAddress'] ?? null,
95 | Contracts\ABNF_AVATAR => $avatar['identifiers']['0']['identifier'] ?? null,
96 | 'avatar_original' => $originalAvatar['identifiers']['0']['identifier'] ?? null,
97 | ]);
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/SocialiteManager.php:
--------------------------------------------------------------------------------
1 | Providers\Alipay::class,
18 | Providers\Azure::NAME => Providers\Azure::class,
19 | Providers\Coding::NAME => Providers\Coding::class,
20 | Providers\DingTalk::NAME => Providers\DingTalk::class,
21 | Providers\DouYin::NAME => Providers\DouYin::class,
22 | Providers\Douban::NAME => Providers\Douban::class,
23 | Providers\Facebook::NAME => Providers\Facebook::class,
24 | Providers\FeiShu::NAME => Providers\FeiShu::class,
25 | Providers\Figma::NAME => Providers\Figma::class,
26 | Providers\GitHub::NAME => Providers\GitHub::class,
27 | Providers\Gitee::NAME => Providers\Gitee::class,
28 | Providers\Google::NAME => Providers\Google::class,
29 | Providers\Lark::NAME => Providers\Lark::class,
30 | Providers\Line::NAME => Providers\Line::class,
31 | Providers\Linkedin::NAME => Providers\Linkedin::class,
32 | Providers\OpenWeWork::NAME => Providers\OpenWeWork::class,
33 | Providers\Outlook::NAME => Providers\Outlook::class,
34 | Providers\QCloud::NAME => Providers\QCloud::class,
35 | Providers\QQ::NAME => Providers\QQ::class,
36 | Providers\Taobao::NAME => Providers\Taobao::class,
37 | Providers\Tapd::NAME => Providers\Tapd::class,
38 | Providers\TouTiao::NAME => Providers\TouTiao::class,
39 | Providers\WeChat::NAME => Providers\WeChat::class,
40 | Providers\WeWork::NAME => Providers\WeWork::class,
41 | Providers\Weibo::NAME => Providers\Weibo::class,
42 | Providers\XiGua::NAME => Providers\XiGua::class,
43 | Providers\PayPal::NAME => Providers\PayPal::class,
44 | ];
45 |
46 | #[Pure]
47 | public function __construct(array $config)
48 | {
49 | $this->config = new Config($config);
50 | }
51 |
52 | public function config(Config $config): self
53 | {
54 | $this->config = $config;
55 |
56 | return $this;
57 | }
58 |
59 | public function create(string $name): Contracts\ProviderInterface
60 | {
61 | $name = \strtolower($name);
62 |
63 | if (! isset($this->resolved[$name])) {
64 | $this->resolved[$name] = $this->createProvider($name);
65 | }
66 |
67 | return $this->resolved[$name];
68 | }
69 |
70 | public function extend(string $name, Closure $callback): self
71 | {
72 | self::$customCreators[\strtolower($name)] = $callback;
73 |
74 | return $this;
75 | }
76 |
77 | public function getResolvedProviders(): array
78 | {
79 | return $this->resolved;
80 | }
81 |
82 | public function buildProvider(string $provider, array $config): Contracts\ProviderInterface
83 | {
84 | $instance = new $provider($config);
85 |
86 | $instance instanceof Contracts\ProviderInterface || throw new Exceptions\InvalidArgumentException("The {$provider} must be instanceof ProviderInterface.");
87 |
88 | return $instance;
89 | }
90 |
91 | /**
92 | * @throws Exceptions\InvalidArgumentException
93 | */
94 | protected function createProvider(string $name): Contracts\ProviderInterface
95 | {
96 | $config = $this->config->get($name, []);
97 | $provider = $config['provider'] ?? $name;
98 |
99 | if (isset(self::$customCreators[$provider])) {
100 | return $this->callCustomCreator($provider, $config);
101 | }
102 |
103 | if (! $this->isValidProvider($provider)) {
104 | throw new Exceptions\InvalidArgumentException("Provider [{$name}] not supported.");
105 | }
106 |
107 | return $this->buildProvider(self::PROVIDERS[$provider] ?? $provider, $config);
108 | }
109 |
110 | protected function callCustomCreator(string $name, array $config): Contracts\ProviderInterface
111 | {
112 | return self::$customCreators[$name]($config);
113 | }
114 |
115 | protected function isValidProvider(string $provider): bool
116 | {
117 | return isset(self::PROVIDERS[$provider]) || \is_subclass_of($provider, Contracts\ProviderInterface::class);
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/Providers/DingTalk.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://oapi.dingtalk.com/connect/qrconnect');
31 | }
32 |
33 | /**
34 | * @throws Exceptions\InvalidArgumentException
35 | */
36 | protected function getTokenUrl(): string
37 | {
38 | throw new Exceptions\InvalidArgumentException('not supported to get access token.');
39 | }
40 |
41 | /**
42 | * @throws Exceptions\InvalidArgumentException
43 | */
44 | protected function getUserByToken(string $token): array
45 | {
46 | throw new Exceptions\InvalidArgumentException('Unable to use token get User.');
47 | }
48 |
49 | #[Pure]
50 | protected function mapUserToObject(array $user): Contracts\UserInterface
51 | {
52 | return new User([
53 | Contracts\ABNF_NAME => $user['nick'] ?? null,
54 | Contracts\ABNF_NICKNAME => $user['nick'] ?? null,
55 | Contracts\ABNF_ID => $user[Contracts\ABNF_OPEN_ID] ?? null,
56 | Contracts\ABNF_EMAIL => null,
57 | Contracts\ABNF_AVATAR => null,
58 | ]);
59 | }
60 |
61 | protected function getCodeFields(): array
62 | {
63 | return array_merge(
64 | [
65 | 'appid' => $this->getClientId(),
66 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
67 | Contracts\RFC6749_ABNF_CODE => $this->formatScopes($this->scopes, $this->scopeSeparator),
68 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
69 | ],
70 | $this->parameters
71 | );
72 | }
73 |
74 | public function getClientId(): ?string
75 | {
76 | return $this->getConfig()->get(Contracts\ABNF_APP_ID)
77 | ?? $this->getConfig()->get('appid')
78 | ?? $this->getConfig()->get('appId')
79 | ?? $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_ID);
80 | }
81 |
82 | public function getClientSecret(): ?string
83 | {
84 | return $this->getConfig()->get(Contracts\ABNF_APP_SECRET)
85 | ?? $this->getConfig()->get('appSecret')
86 | ?? $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_SECRET);
87 | }
88 |
89 | protected function createSignature(int $time): string
90 | {
91 | return \base64_encode(\hash_hmac('sha256', (string) $time, (string) $this->getClientSecret(), true));
92 | }
93 |
94 | /**
95 | * @see https://ding-doc.dingtalk.com/doc#/personnal/tmudue
96 | *
97 | * @throws Exceptions\BadRequestException
98 | */
99 | public function userFromCode(string $code): Contracts\UserInterface
100 | {
101 | $time = (int) \microtime(true) * 1000;
102 |
103 | $responseInstance = $this->getHttpClient()->post($this->getUserByCode, [
104 | 'query' => [
105 | 'accessKey' => $this->getClientId(),
106 | 'timestamp' => $time,
107 | 'signature' => $this->createSignature($time),
108 | ],
109 | 'json' => ['tmp_auth_code' => $code],
110 | ]);
111 | $response = $this->fromJsonBody($responseInstance);
112 |
113 | if (0 != ($response['errcode'] ?? 1)) {
114 | throw new Exceptions\BadRequestException((string) $responseInstance->getBody());
115 | }
116 |
117 | if (empty($response['user_info'])) {
118 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing user_info in response', $response);
119 | }
120 |
121 | if (empty($response['user_info'][Contracts\ABNF_OPEN_ID])) {
122 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing open_id in user_info response', $response);
123 | }
124 |
125 | return new User([
126 | Contracts\ABNF_NAME => $response['user_info']['nick'] ?? null,
127 | Contracts\ABNF_NICKNAME => $response['user_info']['nick'] ?? null,
128 | Contracts\ABNF_ID => $response['user_info'][Contracts\ABNF_OPEN_ID],
129 | ]);
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/Providers/DouYin.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->baseUrl.'/platform/oauth/connect/');
28 | }
29 |
30 | #[ArrayShape([
31 | 'client_key' => 'null|string',
32 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
33 | Contracts\RFC6749_ABNF_SCOPE => 'string',
34 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => 'string',
35 | ])]
36 | public function getCodeFields(): array
37 | {
38 | return [
39 | 'client_key' => $this->getClientId(),
40 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
41 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
42 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
43 | ];
44 | }
45 |
46 | protected function getTokenUrl(): string
47 | {
48 | return $this->baseUrl.'/oauth/access_token/';
49 | }
50 |
51 | /**
52 | * @throws Exceptions\AuthorizeFailedException
53 | */
54 | public function tokenFromCode(string $code): array
55 | {
56 | $response = $this->getHttpClient()->get(
57 | $this->getTokenUrl(),
58 | [
59 | 'query' => $this->getTokenFields($code),
60 | ]
61 | );
62 |
63 | $body = $this->fromJsonBody($response);
64 |
65 | if (empty($body['data'] ?? null) || ($body['data']['error_code'] ?? -1) != 0) {
66 | throw new Exceptions\AuthorizeFailedException('Invalid token response', $body);
67 | }
68 |
69 | if (empty($body['data'][Contracts\ABNF_OPEN_ID] ?? null)) {
70 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing open_id in token response', $body);
71 | }
72 |
73 | $this->withOpenId($body['data'][Contracts\ABNF_OPEN_ID]);
74 |
75 | return $this->normalizeAccessTokenResponse($body['data']);
76 | }
77 |
78 | #[ArrayShape([
79 | 'client_key' => 'null|string',
80 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
81 | Contracts\RFC6749_ABNF_CODE => 'string',
82 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
83 | ])]
84 | protected function getTokenFields(string $code): array
85 | {
86 | return [
87 | 'client_key' => $this->getClientId(),
88 | Contracts\RFC6749_ABNF_CLIENT_SECRET => $this->getClientSecret(),
89 | Contracts\RFC6749_ABNF_CODE => $code,
90 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
91 | ];
92 | }
93 |
94 | /**
95 | * @throws Exceptions\InvalidArgumentException
96 | */
97 | protected function getUserByToken(string $token): array
98 | {
99 | $userUrl = $this->baseUrl.'/oauth/userinfo/';
100 |
101 | if (empty($this->openId)) {
102 | throw new Exceptions\InvalidArgumentException('please set the `open_id` before issue the API request.');
103 | }
104 |
105 | $response = $this->getHttpClient()->get(
106 | $userUrl,
107 | [
108 | 'query' => [
109 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
110 | Contracts\ABNF_OPEN_ID => $this->openId,
111 | ],
112 | ]
113 | );
114 |
115 | $body = $this->fromJsonBody($response);
116 |
117 | return $body['data'] ?? [];
118 | }
119 |
120 | #[Pure]
121 | protected function mapUserToObject(array $user): Contracts\UserInterface
122 | {
123 | return new User([
124 | Contracts\ABNF_ID => $user[Contracts\ABNF_OPEN_ID] ?? null,
125 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NICKNAME] ?? null,
126 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NICKNAME] ?? null,
127 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
128 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
129 | ]);
130 | }
131 |
132 | public function withOpenId(string $openId): self
133 | {
134 | $this->openId = $openId;
135 |
136 | return $this;
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/Providers/Taobao.php:
--------------------------------------------------------------------------------
1 | view = $view;
28 |
29 | return $this;
30 | }
31 |
32 | protected function getAuthUrl(): string
33 | {
34 | return $this->buildAuthUrlFromBase($this->baseUrl.'/authorize');
35 | }
36 |
37 | #[ArrayShape([
38 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
39 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
40 | 'view' => 'string',
41 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => 'string',
42 | ])]
43 | public function getCodeFields(): array
44 | {
45 | return [
46 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
47 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
48 | 'view' => $this->view,
49 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
50 | ];
51 | }
52 |
53 | protected function getTokenUrl(): string
54 | {
55 | return $this->baseUrl.'/token';
56 | }
57 |
58 | #[ArrayShape([
59 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
60 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
61 | Contracts\RFC6749_ABNF_CODE => 'string',
62 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
63 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
64 | 'view' => 'string',
65 | ])]
66 | protected function getTokenFields(string $code): array
67 | {
68 | return parent::getTokenFields($code) + [
69 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
70 | 'view' => $this->view,
71 | ];
72 | }
73 |
74 | public function tokenFromCode(string $code): array
75 | {
76 | $response = $this->getHttpClient()->post($this->getTokenUrl(), [
77 | 'query' => $this->getTokenFields($code),
78 | ]);
79 |
80 | return $this->normalizeAccessTokenResponse($response->getBody());
81 | }
82 |
83 | protected function getUserByToken(string $token, ?array $query = []): array
84 | {
85 | $response = $this->getHttpClient()->post($this->getUserInfoUrl($this->gatewayUrl, $token));
86 |
87 | return $this->fromJsonBody($response);
88 | }
89 |
90 | #[Pure]
91 | protected function mapUserToObject(array $user): Contracts\UserInterface
92 | {
93 | return new User([
94 | Contracts\ABNF_ID => $user[Contracts\ABNF_OPEN_ID] ?? null,
95 | Contracts\ABNF_NICKNAME => $user['nick'] ?? null,
96 | Contracts\ABNF_NAME => $user['nick'] ?? null,
97 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
98 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
99 | ]);
100 | }
101 |
102 | protected function generateSign(array $params): string
103 | {
104 | \ksort($params);
105 |
106 | $stringToBeSigned = $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_SECRET);
107 |
108 | foreach ($params as $k => $v) {
109 | if (! \is_array($v) && ! \str_starts_with($v, '@')) {
110 | $stringToBeSigned .= "$k$v";
111 | }
112 | }
113 |
114 | $stringToBeSigned .= $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_SECRET);
115 |
116 | return \strtoupper(\md5($stringToBeSigned));
117 | }
118 |
119 | protected function getPublicFields(string $token, array $apiFields = []): array
120 | {
121 | $fields = [
122 | 'app_key' => $this->getClientId(),
123 | 'sign_method' => 'md5',
124 | 'session' => $token,
125 | 'timestamp' => (new \DateTime('now', new \DateTimeZone('Asia/Shanghai')))->format('Y-m-d H:i:s'),
126 | 'v' => '2.0',
127 | 'format' => 'json',
128 | ];
129 |
130 | $fields = \array_merge($apiFields, $fields);
131 | $fields['sign'] = $this->generateSign($fields);
132 |
133 | return $fields;
134 | }
135 |
136 | protected function getUserInfoUrl(string $url, string $token): string
137 | {
138 | $apiFields = ['method' => 'taobao.miniapp.userInfo.get'];
139 |
140 | $query = \http_build_query($this->getPublicFields($token, $apiFields), '', '&', $this->encodingType);
141 |
142 | return $url.'?'.$query;
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/Providers/PayPal.php:
--------------------------------------------------------------------------------
1 | sandbox = (bool) $this->config->get('sandbox', false);
46 | if ($this->sandbox) {
47 | $this->authUrl = 'https://www.sandbox.paypal.com/signin/authorize';
48 | $this->tokenURL = 'https://api-m.sandbox.paypal.com/v1/oauth2/token';
49 | $this->userinfoURL = 'https://api-m.sandbox.paypal.com/v1/identity/openidconnect/userinfo?schema=openid';
50 | }
51 | }
52 |
53 | /**
54 | * @return $this
55 | *
56 | * @see https://developer.paypal.com/docs/log-in-with-paypal/integrate/generate-button/
57 | */
58 | public function withResponseType(?string $responseType)
59 | {
60 | $this->responseType = $responseType;
61 |
62 | return $this;
63 | }
64 |
65 | protected function getAuthUrl(): string
66 | {
67 | return $this->buildAuthUrlFromBase($this->authUrl);
68 | }
69 |
70 | protected function getCodeFields(): array
71 | {
72 | $fields = \array_merge(
73 | [
74 | 'flowEntry' => $this->flowEntry,
75 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
76 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => $this->responseType,
77 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
78 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
79 | ],
80 | $this->parameters
81 | );
82 |
83 | if ($this->state) {
84 | $fields[Contracts\RFC6749_ABNF_STATE] = $this->state;
85 | }
86 |
87 | return $fields;
88 | }
89 |
90 | protected function getTokenUrl(): string
91 | {
92 | return $this->tokenURL;
93 | }
94 |
95 | /**
96 | * @throws \GuzzleHttp\Exception\GuzzleException
97 | * @throws \Overtrue\Socialite\Exceptions\AuthorizeFailedException
98 | *
99 | * @see https://developer.paypal.com/docs/log-in-with-paypal/integrate/#link-getaccesstoken
100 | */
101 | public function tokenFromCode(string $code): array
102 | {
103 | $response = $this->getHttpClient()->post(
104 | $this->getTokenUrl(),
105 | [
106 | 'form_params' => [
107 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_CLIENT_CREDENTIALS,
108 | Contracts\RFC6749_ABNF_CODE => $code,
109 | ],
110 | 'headers' => [
111 | 'Accept' => 'application/json',
112 | 'Authorization' => 'Basic '.\base64_encode(\sprintf('%s:%s', $this->getClientId(), $this->getClientSecret())),
113 | ],
114 | ]
115 | );
116 |
117 | return $this->normalizeAccessTokenResponse((string) $response->getBody());
118 | }
119 |
120 | /**
121 | * @throws \GuzzleHttp\Exception\GuzzleException
122 | *
123 | * @see https://developer.paypal.com/docs/api/identity/v1/#userinfo_get
124 | */
125 | protected function getUserByToken(string $token): array
126 | {
127 | $response = $this->getHttpClient()->get(
128 | $this->userinfoURL,
129 | [
130 | 'headers' => [
131 | 'Content-Type' => 'application/x-www-form-urlencoded',
132 | 'Authorization' => 'Bearer '.$token,
133 | ],
134 | ]
135 | );
136 |
137 | return $this->fromJsonBody($response);
138 | }
139 |
140 | #[Pure]
141 | protected function mapUserToObject(array $user): Contracts\UserInterface
142 | {
143 | $user[Contracts\ABNF_ID] = $user['user_id'] ?? null;
144 | $user[Contracts\ABNF_NICKNAME] = $user['given_name'] ?? $user['family_name'] ?? $user['middle_name'] ?? null;
145 | $user[Contracts\ABNF_NAME] = $user['name'] ?? '';
146 | $user[Contracts\ABNF_EMAIL] = $user[Contracts\ABNF_EMAIL] ?? null;
147 | $user[Contracts\ABNF_AVATAR] = $user['picture'] ?? null;
148 |
149 | return new User($user);
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/Providers/Tapd.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase($this->baseUrl.'/quickstart/testauth');
23 | }
24 |
25 | protected function getTokenUrl(): string
26 | {
27 | return $this->baseUrl.'/tokens/request_token';
28 | }
29 |
30 | protected function getRefreshTokenUrl(): string
31 | {
32 | return $this->baseUrl.'/tokens/refresh_token';
33 | }
34 |
35 | public function tokenFromCode(string $code): array
36 | {
37 | $response = $this->getHttpClient()->post($this->getTokenUrl(), [
38 | 'headers' => [
39 | 'Accept' => 'application/json',
40 | 'Authorization' => 'Basic '.\base64_encode(\sprintf('%s:%s', $this->getClientId(), $this->getClientSecret())),
41 | ],
42 | 'form_params' => $this->getTokenFields($code),
43 | ]);
44 |
45 | return $this->normalizeAccessTokenResponse($response->getBody());
46 | }
47 |
48 | #[ArrayShape([
49 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
50 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
51 | Contracts\RFC6749_ABNF_CODE => 'string',
52 | ])]
53 | protected function getTokenFields(string $code): array
54 | {
55 | return [
56 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
57 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
58 | Contracts\RFC6749_ABNF_CODE => $code,
59 | ];
60 | }
61 |
62 | #[ArrayShape([
63 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
64 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
65 | Contracts\RFC6749_ABNF_REFRESH_TOKEN => 'string',
66 | ])]
67 | protected function getRefreshTokenFields(string $refreshToken): array
68 | {
69 | return [
70 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_REFRESH_TOKEN,
71 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
72 | Contracts\RFC6749_ABNF_REFRESH_TOKEN => $refreshToken,
73 | ];
74 | }
75 |
76 | public function tokenFromRefreshToken(string $refreshToken): array
77 | {
78 | $response = $this->getHttpClient()->post($this->getRefreshTokenUrl(), [
79 | 'headers' => [
80 | 'Accept' => 'application/json',
81 | 'Authorization' => 'Basic '.\base64_encode(\sprintf('%s:%s', $this->getClientId(), $this->getClientSecret())),
82 | ],
83 | 'form_params' => $this->getRefreshTokenFields($refreshToken),
84 | ]);
85 |
86 | return $this->normalizeAccessTokenResponse((string) $response->getBody());
87 | }
88 |
89 | protected function getUserByToken(string $token): array
90 | {
91 | $response = $this->getHttpClient()->get($this->baseUrl.'/users/info', [
92 | 'headers' => [
93 | 'Accept' => 'application/json',
94 | 'Authorization' => 'Bearer '.$token,
95 | ],
96 | ]);
97 |
98 | return $this->fromJsonBody($response);
99 | }
100 |
101 | /**
102 | * @throws Exceptions\BadRequestException
103 | */
104 | protected function mapUserToObject(array $user): Contracts\UserInterface
105 | {
106 | if (! isset($user['status']) && $user['status'] != 1) {
107 | throw new Exceptions\BadRequestException('用户信息获取失败');
108 | }
109 |
110 | return new User([
111 | Contracts\ABNF_ID => $user['data'][Contracts\ABNF_ID] ?? null,
112 | Contracts\ABNF_NICKNAME => $user['data']['nick'] ?? null,
113 | Contracts\ABNF_NAME => $user['data'][Contracts\ABNF_NAME] ?? null,
114 | Contracts\ABNF_EMAIL => $user['data'][Contracts\ABNF_EMAIL] ?? null,
115 | Contracts\ABNF_AVATAR => $user['data'][Contracts\ABNF_AVATAR] ?? null,
116 | ]);
117 | }
118 |
119 | /**
120 | * @throws Exceptions\AuthorizeFailedException
121 | */
122 | protected function normalizeAccessTokenResponse(mixed $response): array
123 | {
124 | if ($response instanceof StreamInterface) {
125 | $response->rewind();
126 | $response = (string) $response;
127 | }
128 |
129 | if (\is_string($response)) {
130 | $response = \json_decode($response, true) ?? [];
131 | }
132 |
133 | if (! \is_array($response)) {
134 | throw new Exceptions\AuthorizeFailedException('Invalid token response', [$response]);
135 | }
136 |
137 | if (empty($response['data'][$this->accessTokenKey] ?? null)) {
138 | throw new Exceptions\AuthorizeFailedException('Authorize Failed: '.\json_encode($response, JSON_UNESCAPED_UNICODE), $response);
139 | }
140 |
141 | return $response + [
142 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $response['data'][$this->accessTokenKey],
143 | Contracts\RFC6749_ABNF_REFRESH_TOKEN => $response['data'][$this->refreshTokenKey] ?? null,
144 | Contracts\RFC6749_ABNF_EXPIRES_IN => \intval($response['data'][$this->expiresInKey] ?? 0),
145 | ];
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/tests/OAuthTest.php:
--------------------------------------------------------------------------------
1 | 'fake_client_id',
19 | 'client_secret' => 'fake_client_secret',
20 | ];
21 | $provider = new OAuthTestProviderStub($config);
22 |
23 | $this->assertSame('http://auth.url?client_id=fake_client_id&scope=info&response_type=code', $provider->redirect());
24 | }
25 |
26 | public function test_it_can_get_auth_url_with_redirect()
27 | {
28 | // 手动配置
29 | $config = [
30 | 'client_id' => 'fake_client_id',
31 | 'client_secret' => 'fake_client_secret',
32 | ];
33 | $provider = new OAuthTestProviderStub($config);
34 |
35 | $this->assertSame('http://auth.url?client_id=fake_client_id&redirect_uri=fake_redirect&scope=info&response_type=code', $provider->redirect('fake_redirect'));
36 |
37 | // 用配置属性配置
38 | $config += ['redirect_url' => 'fake_redirect'];
39 | $provider = new OAuthTestProviderStub($config);
40 |
41 | $this->assertSame('http://auth.url?client_id=fake_client_id&redirect_uri=fake_redirect&scope=info&response_type=code', $provider->redirect('fake_redirect'));
42 | }
43 |
44 | public function test_it_can_get_auth_url_with_scopes()
45 | {
46 | $config = [
47 | 'client_id' => 'fake_client_id',
48 | 'client_secret' => 'fake_client_secret',
49 | ];
50 | $provider = new OAuthTestProviderStub($config);
51 | $url = $provider->scopes(['test_info', 'test_email'])->redirect();
52 |
53 | $this->assertSame('http://auth.url?client_id=fake_client_id&scope=test_info%2Ctest_email&response_type=code', $url);
54 |
55 | // 切换scope分割符
56 | $url = $provider->scopes(['test_info', 'test_email'])->withScopeSeparator(' ')->redirect();
57 | $this->assertSame('http://auth.url?client_id=fake_client_id&scope=test_info%20test_email&response_type=code', $url);
58 | }
59 |
60 | public function test_it_can_get_auth_url_with_state()
61 | {
62 | $config = [
63 | 'client_id' => 'fake_client_id',
64 | 'client_secret' => 'fake_client_secret',
65 | ];
66 | $provider = new OAuthTestProviderStub($config);
67 | $url = $provider->withState(123456)->redirect();
68 |
69 | $this->assertSame('http://auth.url?client_id=fake_client_id&scope=info&response_type=code&state=123456', $url);
70 | }
71 |
72 | public function test_it_can_get_token()
73 | {
74 | $config = [
75 | 'client_id' => 'fake_client_id',
76 | 'client_secret' => 'fake_client_secret',
77 | ];
78 | $provider = new OAuthTestProviderStub($config);
79 | $response = m::mock(\Psr\Http\Message\ResponseInterface::class);
80 | $stream = m::mock(\Psr\Http\Message\StreamInterface::class);
81 |
82 | $stream->shouldReceive('__toString')->andReturn(\json_encode([
83 | 'access_token' => 'fake_access_token',
84 | 'refresh_token' => 'fake_refresh_token',
85 | 'expires_in' => 123456,
86 | ]));
87 | $response->shouldReceive('getBody')->andReturn($stream);
88 |
89 | $provider->getHttpClient()->shouldReceive('post')->with('http://token.url', [
90 | 'form_params' => [
91 | 'client_id' => 'fake_client_id',
92 | 'client_secret' => 'fake_client_secret',
93 | 'code' => 'fake_code',
94 | 'redirect_uri' => null,
95 | ],
96 | 'headers' => [
97 | 'Accept' => 'application/json',
98 | ],
99 | ])->andReturn($response);
100 |
101 | $this->assertSame([
102 | 'access_token' => 'fake_access_token',
103 | 'refresh_token' => 'fake_refresh_token',
104 | 'expires_in' => 123456,
105 | ], $provider->tokenFromCode('fake_code'));
106 | }
107 |
108 | public function test_it_can_get_user_by_token()
109 | {
110 | $config = [
111 | 'client_id' => 'fake_client_id',
112 | 'client_secret' => 'fake_client_secret',
113 | ];
114 | $provider = new OAuthTestProviderStub($config);
115 |
116 | $user = $provider->userFromToken('fake_access_token');
117 |
118 | $this->assertSame('foo', $user->getId());
119 | $this->assertSame(['id' => 'foo'], $user->getRaw());
120 | $this->assertSame('fake_access_token', $user->getAccessToken());
121 | }
122 |
123 | public function test_it_can_get_user_by_code()
124 | {
125 | $config = [
126 | 'client_id' => 'fake_client_id',
127 | 'client_secret' => 'fake_client_secret',
128 | ];
129 | $provider = new OAuthTestProviderStub($config);
130 |
131 | $response = m::mock(\Psr\Http\Message\ResponseInterface::class);
132 | $stream = m::mock(\Psr\Http\Message\StreamInterface::class);
133 |
134 | $stream->shouldReceive('__toString')->andReturn(\json_encode([
135 | 'access_token' => 'fake_access_token',
136 | 'refresh_token' => 'fake_refresh_token',
137 | 'expires_in' => 123456,
138 | ]));
139 | $response->shouldReceive('getBody')->andReturn($stream);
140 |
141 | $provider->getHttpClient()->shouldReceive('post')->with('http://token.url', [
142 | 'form_params' => [
143 | 'client_id' => 'fake_client_id',
144 | 'client_secret' => 'fake_client_secret',
145 | 'code' => 'fake_code',
146 | 'redirect_uri' => null,
147 | ],
148 | 'headers' => [
149 | 'Accept' => 'application/json',
150 | ],
151 | ])->andReturn($response);
152 |
153 | $this->assertSame([
154 | 'access_token' => 'fake_access_token',
155 | 'refresh_token' => 'fake_refresh_token',
156 | 'expires_in' => 123456,
157 | ], $provider->tokenFromCode('fake_code'));
158 |
159 | $user = $provider->userFromCode('fake_code');
160 | $tokenResponse = [
161 | 'access_token' => 'fake_access_token',
162 | 'refresh_token' => 'fake_refresh_token',
163 | 'expires_in' => 123456,
164 | ];
165 |
166 | $this->assertSame('foo', $user->getId());
167 | $this->assertSame($tokenResponse, $user->getTokenResponse());
168 | $this->assertSame('fake_access_token', $user->getAccessToken());
169 | $this->assertSame('fake_refresh_token', $user->getRefreshToken());
170 | }
171 | }
172 |
173 | class OAuthTestProviderStub extends Base
174 | {
175 | public $http;
176 |
177 | protected array $scopes = ['info'];
178 |
179 | protected int $encodingType = PHP_QUERY_RFC3986;
180 |
181 | protected function getAuthUrl(): string
182 | {
183 | $url = 'http://auth.url';
184 |
185 | return $this->buildAuthUrlFromBase($url);
186 | }
187 |
188 | protected function getTokenUrl(): string
189 | {
190 | return 'http://token.url';
191 | }
192 |
193 | protected function getUserByToken(string $token): array
194 | {
195 | return ['id' => 'foo'];
196 | }
197 |
198 | protected function mapUserToObject(array $user): User
199 | {
200 | return new User(['id' => $user['id']]);
201 | }
202 |
203 | /**
204 | * Get a fresh instance of the Guzzle HTTP client.
205 | */
206 | public function getHttpClient(): GuzzleHttp\Client
207 | {
208 | if ($this->http) {
209 | return $this->http;
210 | }
211 |
212 | return $this->http = m::mock(\GuzzleHttp\Client::class);
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/Providers/WeWork.php:
--------------------------------------------------------------------------------
1 | getConfig()->has('base_url')) {
32 | $this->baseUrl = $this->getConfig()->get('base_url');
33 | }
34 |
35 | if ($this->getConfig()->has('agent_id')) {
36 | $this->agentId = $this->getConfig()->get('agent_id');
37 | }
38 | }
39 |
40 | public function getBaseUrl(): ?string
41 | {
42 | return $this->baseUrl;
43 | }
44 |
45 | public function userFromCode(string $code): Contracts\UserInterface
46 | {
47 | $token = $this->getApiAccessToken();
48 | $user = $this->getUser($token, $code);
49 |
50 | if ($this->detailed) {
51 | if (empty($user['UserId'])) {
52 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing UserId in user response', $user);
53 | }
54 | $user = $this->getUserById($user['UserId']);
55 | }
56 |
57 | return $this->mapUserToObject($user)->setProvider($this)->setRaw($user);
58 | }
59 |
60 | public function withAgentId(int $agentId): self
61 | {
62 | $this->agentId = $agentId;
63 |
64 | return $this;
65 | }
66 |
67 | public function detailed(): self
68 | {
69 | $this->detailed = true;
70 |
71 | return $this;
72 | }
73 |
74 | public function asQrcode(): self
75 | {
76 | $this->asQrcode = true;
77 |
78 | return $this;
79 | }
80 |
81 | public function withApiAccessToken(string $apiAccessToken): self
82 | {
83 | $this->apiAccessToken = $apiAccessToken;
84 |
85 | return $this;
86 | }
87 |
88 | public function getAuthUrl(): string
89 | {
90 | $scopes = $this->formatScopes($this->scopes, $this->scopeSeparator);
91 | $queries = array_filter([
92 | 'appid' => $this->getClientId(),
93 | 'agentid' => $this->agentId,
94 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
95 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
96 | Contracts\RFC6749_ABNF_SCOPE => $scopes,
97 | Contracts\RFC6749_ABNF_STATE => $this->state,
98 | ]);
99 |
100 | if (! $this->agentId && (str_contains($scopes, 'snsapi_privateinfo') || $this->asQrcode)) {
101 | throw new Exceptions\InvalidArgumentException("agent_id is require when qrcode mode or scopes is 'snsapi_privateinfo'");
102 | }
103 |
104 | if ($this->asQrcode) {
105 | unset($queries[Contracts\RFC6749_ABNF_SCOPE]);
106 | unset($queries[Contracts\RFC6749_ABNF_RESPONSE_TYPE]);
107 |
108 | $queries['login_type'] = 'CorpApp';
109 |
110 | return \sprintf('https://login.work.weixin.qq.com/wwlogin/sso/login?%s', http_build_query($queries));
111 | }
112 |
113 | return \sprintf('https://open.weixin.qq.com/connect/oauth2/authorize?%s#wechat_redirect', \http_build_query($queries));
114 | }
115 |
116 | /**
117 | * @throws Exceptions\MethodDoesNotSupportException
118 | */
119 | protected function getUserByToken(string $token): array
120 | {
121 | throw new Exceptions\MethodDoesNotSupportException('WeWork doesn\'t support access_token mode');
122 | }
123 |
124 | protected function getApiAccessToken(): string
125 | {
126 | return $this->apiAccessToken ?? $this->apiAccessToken = $this->requestApiAccessToken();
127 | }
128 |
129 | protected function getUser(string $token, string $code): array
130 | {
131 | $responseInstance = $this->getHttpClient()->get(
132 | $this->baseUrl.'/cgi-bin/user/getuserinfo',
133 | [
134 | 'query' => \array_filter(
135 | [
136 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
137 | Contracts\RFC6749_ABNF_CODE => $code,
138 | ]
139 | ),
140 | ]
141 | );
142 |
143 | $response = $this->fromJsonBody($responseInstance);
144 |
145 | if (($response['errcode'] ?? 1) > 0 || (empty($response['UserId']) && empty($response['OpenId']))) {
146 | throw new Exceptions\AuthorizeFailedException((string) $responseInstance->getBody(), $response);
147 | } elseif (empty($response['UserId'])) {
148 | $this->detailed = false;
149 | }
150 |
151 | return $response;
152 | }
153 |
154 | /**
155 | * @throws Exceptions\AuthorizeFailedException
156 | */
157 | protected function getUserById(string $userId): array
158 | {
159 | $responseInstance = $this->getHttpClient()->post($this->baseUrl.'/cgi-bin/user/get', [
160 | 'query' => [
161 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $this->getApiAccessToken(),
162 | 'userid' => $userId,
163 | ],
164 | ]);
165 |
166 | $response = $this->fromJsonBody($responseInstance);
167 |
168 | if (($response['errcode'] ?? 1) > 0 || empty($response['userid'])) {
169 | throw new Exceptions\AuthorizeFailedException((string) $responseInstance->getBody(), $response);
170 | }
171 |
172 | return $response;
173 | }
174 |
175 | #[Pure]
176 | protected function mapUserToObject(array $user): Contracts\UserInterface
177 | {
178 | return new User($this->detailed ? [
179 | Contracts\ABNF_ID => $user['userid'] ?? null,
180 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
181 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
182 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
183 | ] : [
184 | Contracts\ABNF_ID => $user['UserId'] ?? null ?: $user['OpenId'] ?? null,
185 | ]);
186 | }
187 |
188 | /**
189 | * @throws Exceptions\AuthorizeFailedException
190 | */
191 | protected function requestApiAccessToken(): string
192 | {
193 | $responseInstance = $this->getHttpClient()->get($this->baseUrl.'/cgi-bin/gettoken', [
194 | 'query' => \array_filter(
195 | [
196 | 'corpid' => $this->config->get('corp_id')
197 | ?? $this->config->get('corpid')
198 | ?? $this->config->get(Contracts\RFC6749_ABNF_CLIENT_ID),
199 | 'corpsecret' => $this->config->get('corp_secret')
200 | ?? $this->config->get('corpsecret')
201 | ?? $this->config->get(Contracts\RFC6749_ABNF_CLIENT_SECRET),
202 | ]
203 | ),
204 | ]);
205 |
206 | $response = $this->fromJsonBody($responseInstance);
207 |
208 | if (($response['errcode'] ?? 1) > 0) {
209 | throw new Exceptions\AuthorizeFailedException((string) $responseInstance->getBody(), $response);
210 | }
211 |
212 | if (empty($response[Contracts\RFC6749_ABNF_ACCESS_TOKEN])) {
213 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing access_token in response', $response);
214 | }
215 |
216 | return $response[Contracts\RFC6749_ABNF_ACCESS_TOKEN];
217 | }
218 |
219 | protected function getTokenUrl(): string
220 | {
221 | return '';
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/tests/Providers/WechatTest.php:
--------------------------------------------------------------------------------
1 | 'client_id',
16 | 'client_secret' => 'client_secret',
17 | 'redirect_url' => 'http://localhost/socialite/callback.php',
18 | ]))->redirect();
19 |
20 | $this->assertStringStartsWith('https://open.weixin.qq.com/connect/qrconnect', $response);
21 | $this->assertMatchesRegularExpression('/redirect_uri=http%3A%2F%2Flocalhost%2Fsocialite%2Fcallback.php/', $response);
22 | }
23 |
24 | public function test_we_chat_provider_token_url_and_request_fields()
25 | {
26 | $provider = new WeChat([
27 | 'client_id' => 'client_id',
28 | 'client_secret' => 'client_secret',
29 | 'redirect_url' => 'http://localhost/socialite/callback.php',
30 | ]);
31 |
32 | $getTokenUrl = new ReflectionMethod(WeChat::class, 'getTokenUrl');
33 | $getTokenUrl->setAccessible(true);
34 |
35 | $getTokenFields = new ReflectionMethod(WeChat::class, 'getTokenFields');
36 | $getTokenFields->setAccessible(true);
37 |
38 | $getCodeFields = new ReflectionMethod(WeChat::class, 'getCodeFields');
39 | $getCodeFields->setAccessible(true);
40 |
41 | $this->assertSame('https://api.weixin.qq.com/sns/oauth2/access_token', $getTokenUrl->invoke($provider));
42 | $this->assertSame([
43 | 'appid' => 'client_id',
44 | 'secret' => 'client_secret',
45 | 'code' => 'iloveyou',
46 | 'grant_type' => 'authorization_code',
47 | ], $getTokenFields->invoke($provider, 'iloveyou'));
48 |
49 | $this->assertSame([
50 | 'appid' => 'client_id',
51 | 'redirect_uri' => 'http://localhost/socialite/callback.php',
52 | 'response_type' => 'code',
53 | 'scope' => 'snsapi_login',
54 | 'state' => 'wechat-state',
55 | 'connect_redirect' => 1,
56 | ], $getCodeFields->invoke($provider->withState('wechat-state')));
57 | }
58 |
59 | public function test_open_platform_component()
60 | {
61 | $provider = new WeChat([
62 | 'client_id' => 'client_id',
63 | 'client_secret' => null,
64 | 'redirect' => 'redirect-url',
65 | 'component' => [
66 | 'id' => 'component-app-id',
67 | 'token' => 'token',
68 | ],
69 | ]);
70 | $getTokenUrl = new ReflectionMethod(WeChat::class, 'getTokenUrl');
71 | $getTokenUrl->setAccessible(true);
72 |
73 | $getTokenFields = new ReflectionMethod(WeChat::class, 'getTokenFields');
74 | $getTokenFields->setAccessible(true);
75 |
76 | $getCodeFields = new ReflectionMethod(WeChat::class, 'getCodeFields');
77 | $getCodeFields->setAccessible(true);
78 |
79 | $this->assertSame([
80 | 'appid' => 'client_id',
81 | 'redirect_uri' => 'redirect-url',
82 | 'response_type' => 'code',
83 | 'scope' => 'snsapi_base',
84 | 'state' => 'state',
85 | 'connect_redirect' => 1,
86 | 'component_appid' => 'component-app-id',
87 | ], $getCodeFields->invoke($provider->withState('state')));
88 |
89 | $this->assertSame([
90 | 'appid' => 'client_id',
91 | 'component_appid' => 'component-app-id',
92 | 'component_access_token' => 'token',
93 | 'code' => 'simcode',
94 | 'grant_type' => 'authorization_code',
95 | ], $getTokenFields->invoke($provider, 'simcode'));
96 |
97 | $this->assertSame('https://api.weixin.qq.com/sns/oauth2/component/access_token', $getTokenUrl->invoke($provider));
98 | }
99 |
100 | public function test_open_platform_component_with_custom_parameters()
101 | {
102 | $provider = new WeChat([
103 | 'client_id' => 'client_id',
104 | 'client_secret' => null,
105 | 'redirect' => 'redirect-url',
106 | 'component' => [
107 | 'id' => 'component-app-id',
108 | 'token' => 'token',
109 | ],
110 | ]);
111 |
112 | $getCodeFields = new ReflectionMethod(WeChat::class, 'getCodeFields');
113 | $getCodeFields->setAccessible(true);
114 |
115 | $provider->with(['foo' => 'bar']);
116 |
117 | $fields = $getCodeFields->invoke($provider->withState('wechat-state'));
118 | $this->assertArrayHasKey('foo', $fields);
119 | $this->assertSame('bar', $fields['foo']);
120 | }
121 |
122 | public function test_throws_exception_when_openid_missing()
123 | {
124 | $provider = new WeChat([
125 | 'client_id' => 'client_id',
126 | 'client_secret' => 'client_secret',
127 | 'redirect_url' => 'http://localhost/socialite/callback.php',
128 | ]);
129 |
130 | // Test that getSnsapiBaseUserFromCode throws exception when openid is missing
131 | $getSnsapiBaseUserFromCode = new ReflectionMethod(WeChat::class, 'getSnsapiBaseUserFromCode');
132 | $getSnsapiBaseUserFromCode->setAccessible(true);
133 |
134 | // Mock the getTokenFromCode method to return response without openid
135 | $mockProvider = $this->getMockBuilder(WeChat::class)
136 | ->setConstructorArgs([[
137 | 'client_id' => 'client_id',
138 | 'client_secret' => 'client_secret',
139 | 'redirect_url' => 'http://localhost/socialite/callback.php',
140 | ]])
141 | ->onlyMethods(['getTokenFromCode', 'fromJsonBody'])
142 | ->getMock();
143 |
144 | $mockResponse = $this->createMock(\Psr\Http\Message\ResponseInterface::class);
145 | $mockProvider->method('getTokenFromCode')->willReturn($mockResponse);
146 |
147 | // Test missing openid
148 | $mockProvider->method('fromJsonBody')->willReturn(['access_token' => 'token123']);
149 |
150 | $this->expectException(AuthorizeFailedException::class);
151 | $this->expectExceptionMessage('Authorization failed: missing openid in token response');
152 |
153 | $getSnsapiBaseUserFromCode->invoke($mockProvider, 'test_code');
154 | }
155 |
156 | public function test_throws_exception_when_access_token_missing()
157 | {
158 | $provider = new WeChat([
159 | 'client_id' => 'client_id',
160 | 'client_secret' => 'client_secret',
161 | 'redirect_url' => 'http://localhost/socialite/callback.php',
162 | ]);
163 |
164 | // Test that getSnsapiBaseUserFromCode throws exception when access_token is missing
165 | $getSnsapiBaseUserFromCode = new ReflectionMethod(WeChat::class, 'getSnsapiBaseUserFromCode');
166 | $getSnsapiBaseUserFromCode->setAccessible(true);
167 |
168 | // Mock the getTokenFromCode method to return response without access_token
169 | $mockProvider = $this->getMockBuilder(WeChat::class)
170 | ->setConstructorArgs([[
171 | 'client_id' => 'client_id',
172 | 'client_secret' => 'client_secret',
173 | 'redirect_url' => 'http://localhost/socialite/callback.php',
174 | ]])
175 | ->onlyMethods(['getTokenFromCode', 'fromJsonBody'])
176 | ->getMock();
177 |
178 | $mockResponse = $this->createMock(\Psr\Http\Message\ResponseInterface::class);
179 | $mockProvider->method('getTokenFromCode')->willReturn($mockResponse);
180 |
181 | // Test missing access_token
182 | $mockProvider->method('fromJsonBody')->willReturn(['openid' => 'openid123']);
183 |
184 | $this->expectException(AuthorizeFailedException::class);
185 | $this->expectExceptionMessage('Authorization failed: missing access_token in token response');
186 |
187 | $getSnsapiBaseUserFromCode->invoke($mockProvider, 'test_code');
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/src/Providers/Alipay.php:
--------------------------------------------------------------------------------
1 | sandbox = (bool) $this->config->get('sandbox', false);
38 | if ($this->sandbox) {
39 | $this->baseUrl = 'https://openapi.alipaydev.com/gateway.do';
40 | $this->authUrl = 'https://openauth.alipaydev.com/oauth2/publicAppAuthorize.htm';
41 | }
42 | }
43 |
44 | protected function getAuthUrl(): string
45 | {
46 | return $this->buildAuthUrlFromBase($this->authUrl);
47 | }
48 |
49 | protected function getTokenUrl(): string
50 | {
51 | return $this->baseUrl;
52 | }
53 |
54 | /**
55 | * @throws Exceptions\BadRequestException
56 | */
57 | protected function getUserByToken(string $token): array
58 | {
59 | $params = $this->getPublicFields('alipay.user.info.share');
60 | $params += ['auth_token' => $token];
61 | $params['sign'] = $this->generateSign($params);
62 |
63 | $responseInstance = $this->getHttpClient()->post(
64 | $this->baseUrl,
65 | [
66 | 'form_params' => $params,
67 | 'headers' => [
68 | 'Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8',
69 | ],
70 | ]
71 | );
72 |
73 | $response = $this->fromJsonBody($responseInstance);
74 |
75 | if (! empty($response['error_response'] ?? null) || empty($response['alipay_user_info_share_response'] ?? [])) {
76 | throw new Exceptions\BadRequestException((string) $responseInstance->getBody());
77 | }
78 |
79 | return $response['alipay_user_info_share_response'];
80 | }
81 |
82 | #[Pure]
83 | protected function mapUserToObject(array $user): Contracts\UserInterface
84 | {
85 | return new User([
86 | Contracts\ABNF_ID => $user['user_id'] ?? null,
87 | Contracts\ABNF_NAME => $user['nick_name'] ?? null,
88 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
89 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
90 | ]);
91 | }
92 |
93 | /**
94 | * @throws Exceptions\BadRequestException
95 | */
96 | public function tokenFromCode(string $code): array
97 | {
98 | $responseInstance = $this->getHttpClient()->post(
99 | $this->getTokenUrl(),
100 | [
101 | 'form_params' => $this->getTokenFields($code),
102 | 'headers' => [
103 | 'Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8',
104 | ],
105 | ]
106 | );
107 | $response = $this->fromJsonBody($responseInstance);
108 |
109 | if (! empty($response['error_response'])) {
110 | throw new Exceptions\BadRequestException((string) $responseInstance->getBody());
111 | }
112 |
113 | if (empty($response['alipay_system_oauth_token_response'])) {
114 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing alipay_system_oauth_token_response in response', $response);
115 | }
116 |
117 | return $this->normalizeAccessTokenResponse($response['alipay_system_oauth_token_response']);
118 | }
119 |
120 | /**
121 | * @throws Exceptions\InvalidArgumentException
122 | */
123 | protected function getCodeFields(): array
124 | {
125 | if (empty($this->redirectUrl)) {
126 | throw new Exceptions\InvalidArgumentException('Please set the correct redirect URL refer which was on the Alipay Official Admin pannel.');
127 | }
128 |
129 | $fields = \array_merge(
130 | [
131 | Contracts\ABNF_APP_ID => $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_ID) ?? $this->getConfig()->get(Contracts\ABNF_APP_ID),
132 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
133 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
134 | ],
135 | $this->parameters
136 | );
137 |
138 | return $fields;
139 | }
140 |
141 | #[ArrayShape([
142 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
143 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
144 | Contracts\RFC6749_ABNF_CODE => 'string',
145 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
146 | Contracts\RFC6749_ABNF_GRANT_TYPE => 'string',
147 | ])]
148 | protected function getTokenFields(string $code): array
149 | {
150 | $params = $this->getPublicFields('alipay.system.oauth.token');
151 | $params += [
152 | Contracts\RFC6749_ABNF_CODE => $code,
153 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
154 | ];
155 | $params['sign'] = $this->generateSign($params);
156 |
157 | return $params;
158 | }
159 |
160 | #[ArrayShape([
161 | Contracts\ABNF_APP_ID => 'string',
162 | 'format' => 'string',
163 | 'charset' => 'string',
164 | 'sign_type' => 'string',
165 | 'method' => 'string',
166 | 'timestamp' => 'string',
167 | 'version' => 'string',
168 | ])]
169 | public function getPublicFields(string $method): array
170 | {
171 | return [
172 | Contracts\ABNF_APP_ID => $this->getConfig()->get(Contracts\RFC6749_ABNF_CLIENT_ID) ?? $this->getConfig()->get(Contracts\ABNF_APP_ID),
173 | 'format' => $this->format,
174 | 'charset' => $this->postCharset,
175 | 'sign_type' => $this->signType,
176 | 'method' => $method,
177 | 'timestamp' => (new \DateTime('now', new \DateTimeZone('Asia/Shanghai')))->format('Y-m-d H:i:s'),
178 | 'version' => $this->apiVersion,
179 | ];
180 | }
181 |
182 | /**
183 | * @see https://opendocs.alipay.com/open/289/105656
184 | */
185 | protected function generateSign(array $params): string
186 | {
187 | \ksort($params);
188 |
189 | return $this->signWithSHA256RSA($this->buildParams($params), $this->getConfig()->get('rsa_private_key'));
190 | }
191 |
192 | /**
193 | * @throws Exceptions\InvalidArgumentException
194 | */
195 | protected function signWithSHA256RSA(string $signContent, string $key): string
196 | {
197 | if (empty($key)) {
198 | throw new Exceptions\InvalidArgumentException('no RSA private key set.');
199 | }
200 |
201 | $key = "-----BEGIN RSA PRIVATE KEY-----\n".
202 | \chunk_split($key, 64, "\n").
203 | '-----END RSA PRIVATE KEY-----';
204 |
205 | \openssl_sign($signContent, $signValue, $key, \OPENSSL_ALGO_SHA256);
206 |
207 | return \base64_encode($signValue);
208 | }
209 |
210 | public static function buildParams(array $params, bool $urlencode = false, array $except = ['sign']): string
211 | {
212 | $param_str = '';
213 | foreach ($params as $k => $v) {
214 | if (\in_array($k, $except)) {
215 | continue;
216 | }
217 | $param_str .= $k.'=';
218 | $param_str .= $urlencode ? \rawurlencode($v) : $v;
219 | $param_str .= '&';
220 | }
221 |
222 | return \rtrim($param_str, '&');
223 | }
224 | }
225 |
--------------------------------------------------------------------------------
/src/Providers/QCloud.php:
--------------------------------------------------------------------------------
1 | buildAuthUrlFromBase('https://cloud.tencent.com/open/authorize');
29 | }
30 |
31 | protected function getTokenUrl(): string
32 | {
33 | return '';
34 | }
35 |
36 | protected function getAppId(): string
37 | {
38 | return $this->config->get(Contracts\ABNF_APP_ID) ?? $this->getClientId();
39 | }
40 |
41 | protected function getSecretId(): string
42 | {
43 | return $this->config->get('secret_id');
44 | }
45 |
46 | protected function getSecretKey(): string
47 | {
48 | return $this->config->get('secret_key');
49 | }
50 |
51 | public function TokenFromCode(string $code): array
52 | {
53 | $response = $this->performRequest(
54 | 'GET',
55 | 'open.tencentcloudapi.com',
56 | 'GetUserAccessToken',
57 | '2018-12-25',
58 | [
59 | 'query' => [
60 | 'UserAuthCode' => $code,
61 | ],
62 | ]
63 | );
64 |
65 | return $this->parseAccessToken($response);
66 | }
67 |
68 | /**
69 | * @throws Exceptions\AuthorizeFailedException
70 | */
71 | protected function getUserByToken(string $token): array
72 | {
73 | $secret = $this->getFederationToken($token);
74 |
75 | return $this->performRequest(
76 | 'GET',
77 | 'open.tencentcloudapi.com',
78 | 'GetUserBaseInfo',
79 | '2018-12-25',
80 | [
81 | 'headers' => [
82 | 'X-TC-Token' => $secret['Token'],
83 | ],
84 | ],
85 | $secret['TmpSecretId'],
86 | $secret['TmpSecretKey'],
87 | );
88 | }
89 |
90 | #[Pure]
91 | protected function mapUserToObject(array $user): Contracts\UserInterface
92 | {
93 | return new User([
94 | Contracts\ABNF_ID => $this->openId ?? null,
95 | Contracts\ABNF_NAME => $user['Nickname'] ?? null,
96 | Contracts\ABNF_NICKNAME => $user['Nickname'] ?? null,
97 | ]);
98 | }
99 |
100 | /**
101 | * @throws Exceptions\AuthorizeFailedException
102 | */
103 | public function performRequest(string $method, string $host, string $action, string $version, array $options = [], ?string $secretId = null, ?string $secretKey = null): array
104 | {
105 | $method = \strtoupper($method);
106 | $timestamp = \time();
107 | $credential = \sprintf('%s/%s/tc3_request', \gmdate('Y-m-d', $timestamp), $this->getServiceFromHost($host));
108 | $options['headers'] = \array_merge(
109 | $options['headers'] ?? [],
110 | [
111 | 'X-TC-Action' => $action,
112 | 'X-TC-Timestamp' => $timestamp,
113 | 'X-TC-Version' => $version,
114 | 'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8',
115 | ]
116 | );
117 |
118 | $signature = $this->sign($method, $host, $options['query'] ?? [], '', $options['headers'], $credential, $secretKey);
119 | $options['headers']['Authorization'] =
120 | \sprintf(
121 | 'TC3-HMAC-SHA256 Credential=%s/%s, SignedHeaders=content-type;host, Signature=%s',
122 | $secretId ?? $this->getSecretId(),
123 | $credential,
124 | $signature
125 | );
126 | $response = $this->getHttpClient()->get("https://{$host}/", $options);
127 |
128 | $response = $this->fromJsonBody($response);
129 |
130 | if (! empty($response['Response']['Error'])) {
131 | throw new Exceptions\AuthorizeFailedException(
132 | \sprintf('%s: %s', $response['Response']['Error']['Code'], $response['Response']['Error']['Message']),
133 | $response
134 | );
135 | }
136 |
137 | return $response['Response'] ?? [];
138 | }
139 |
140 | protected function sign(string $requestMethod, string $host, array $query, string $payload, array $headers, string $credential, ?string $secretKey = null): bool|string
141 | {
142 | $canonicalRequestString = \implode(
143 | "\n",
144 | [
145 | $requestMethod,
146 | '/',
147 | \http_build_query($query),
148 | "content-type:{$headers['Content-Type']}\nhost:{$host}\n",
149 | 'content-type;host',
150 | \hash('SHA256', $payload),
151 | ]
152 | );
153 |
154 | $signString = \implode(
155 | "\n",
156 | [
157 | 'TC3-HMAC-SHA256',
158 | $headers['X-TC-Timestamp'],
159 | $credential,
160 | \hash('SHA256', $canonicalRequestString),
161 | ]
162 | );
163 |
164 | $secretKey = $secretKey ?? $this->getSecretKey();
165 | $secretDate = \hash_hmac('SHA256', \gmdate('Y-m-d', $headers['X-TC-Timestamp']), "TC3{$secretKey}", true);
166 | $secretService = \hash_hmac('SHA256', $this->getServiceFromHost($host), $secretDate, true);
167 | $secretSigning = \hash_hmac('SHA256', 'tc3_request', $secretService, true);
168 |
169 | return \hash_hmac('SHA256', $signString, $secretSigning);
170 | }
171 |
172 | /**
173 | * @throws Exceptions\AuthorizeFailedException
174 | */
175 | protected function parseAccessToken(array|string $body): array
176 | {
177 | if (! \is_array($body)) {
178 | $body = \json_decode($body, true);
179 | }
180 |
181 | if (empty($body['UserOpenId'] ?? null)) {
182 | throw new Exceptions\AuthorizeFailedException('Authorize Failed: '.\json_encode($body, JSON_UNESCAPED_UNICODE), $body);
183 | }
184 |
185 | $this->openId = $body['UserOpenId'] ?? null;
186 | $this->unionId = $body['UserUnionId'] ?? null;
187 |
188 | return $body;
189 | }
190 |
191 | /**
192 | * @throws Exceptions\AuthorizeFailedException
193 | */
194 | protected function getFederationToken(string $accessToken): array
195 | {
196 | $response = $this->performRequest(
197 | 'GET',
198 | 'sts.tencentcloudapi.com',
199 | 'GetThirdPartyFederationToken',
200 | '2018-08-13',
201 | [
202 | 'query' => [
203 | 'UserAccessToken' => $accessToken,
204 | 'Duration' => 7200,
205 | 'ApiAppId' => 0,
206 | ],
207 | 'headers' => [
208 | 'X-TC-Region' => 'ap-guangzhou', // 官方人员说写死
209 | ],
210 | ]
211 | );
212 |
213 | if (empty($response['Credentials'] ?? null)) {
214 | throw new Exceptions\AuthorizeFailedException('Get Federation Token failed.', $response);
215 | }
216 |
217 | return $response['Credentials'];
218 | }
219 |
220 | protected function getCodeFields(): array
221 | {
222 | $fields = \array_merge(
223 | [
224 | Contracts\ABNF_APP_ID => $this->getAppId(),
225 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
226 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
227 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
228 | ],
229 | $this->parameters
230 | );
231 |
232 | if ($this->state) {
233 | $fields[Contracts\RFC6749_ABNF_STATE] = $this->state;
234 | }
235 |
236 | return $fields;
237 | }
238 |
239 | protected function getServiceFromHost(string $host): string
240 | {
241 | return \explode('.', $host)[0] ?? '';
242 | }
243 | }
244 |
--------------------------------------------------------------------------------
/tests/Providers/FeiShuTest.php:
--------------------------------------------------------------------------------
1 | 'xxxxx',
21 | 'app_secret' => 'yyyyy',
22 | 'app_mode' => 'internal',
23 | ];
24 | $f = new FeiShu($config);
25 | $rf = new \ReflectionObject($f);
26 |
27 | $this->assertEquals('xxxxx', $f->getClientId());
28 | $this->assertEquals('yyyyy', $f->getClientSecret());
29 |
30 | $rfProperty = $rf->getProperty('isInternalApp');
31 | $rfProperty->setAccessible(true);
32 | $this->assertEquals(true, $rfProperty->getValue($f));
33 |
34 | // diff filed way
35 | $config = [
36 | 'client_id' => 'xxxxx',
37 | 'client_secret' => 'yyyyy',
38 | 'mode' => 'internal',
39 | ];
40 |
41 | $f = new FeiShu($config);
42 | $rf = new \ReflectionObject($f);
43 |
44 | $this->assertEquals('xxxxx', $f->getClientId());
45 | $this->assertEquals('yyyyy', $f->getClientSecret());
46 | $rfProperty = $rf->getProperty('isInternalApp');
47 | $rfProperty->setAccessible(true);
48 | $this->assertEquals(true, $rfProperty->getValue($f));
49 |
50 | // no mode config way
51 | $config = [
52 | 'client_id' => 'xxxxx',
53 | 'client_secret' => 'yyyyy',
54 | ];
55 |
56 | $f = new FeiShu($config);
57 | $rf = new \ReflectionObject($f);
58 |
59 | $this->assertEquals('xxxxx', $f->getClientId());
60 | $this->assertEquals('yyyyy', $f->getClientSecret());
61 | $rfProperty = $rf->getProperty('isInternalApp');
62 | $rfProperty->setAccessible(true);
63 | $this->assertEquals(false, $rfProperty->getValue($f));
64 | }
65 |
66 | public function test_provider_with_internal_app_mode_work()
67 | {
68 | $config = [
69 | 'client_id' => 'xxxxx',
70 | 'client_secret' => 'yyyyy',
71 | ];
72 |
73 | $f = new FeiShu($config);
74 | $rf = new \ReflectionObject($f);
75 |
76 | $rfProperty = $rf->getProperty('isInternalApp');
77 | $rfProperty->setAccessible(true);
78 |
79 | $f->withInternalAppMode();
80 | $this->assertEquals(true, $rfProperty->getValue($f));
81 |
82 | $f->withDefaultMode();
83 | $this->assertEquals(false, $rfProperty->getValue($f));
84 | }
85 |
86 | public function test_provider_with_app_ticket_work()
87 | {
88 | $config = [
89 | 'client_id' => 'xxxxx',
90 | 'client_secret' => 'yyyyy',
91 | ];
92 |
93 | $f = new FeiShu($config);
94 | $f->withAppTicket('app_ticket');
95 | $this->assertEquals('app_ticket', $f->getConfig()->get('app_ticket'));
96 | }
97 |
98 | public function test_config_app_access_token_with_default_mode_no_app_ticket_work()
99 | {
100 | $config = [
101 | 'client_id' => 'xxxxx',
102 | 'client_secret' => 'yyyyy',
103 | ];
104 |
105 | $f = new FeiShu($config);
106 | $fr = new \ReflectionObject($f);
107 | $frClient = $fr->getProperty('httpClient');
108 | $frClient->setAccessible(true);
109 | $ff = new \ReflectionMethod(FeiShu::class, 'configAppAccessToken');
110 |
111 | $mock = new MockHandler([
112 | new Response(403, []),
113 | new Response(200, [], \json_encode([
114 | 'app_access_token' => 'app_access_token',
115 | ])),
116 | ]);
117 |
118 | $handler = HandlerStack::create($mock);
119 | $client = new Client(['handler' => $handler]);
120 | $frClient->setValue($f, $client);
121 | $ff->setAccessible(true);
122 |
123 | // 默认模式下没有 app_ticket
124 | $this->expectException(InvalidTicketException::class);
125 | $ff->invoke($f);
126 |
127 | $ff->invoke($f);
128 | $f->withAppTicket('app_ticket');
129 | $this->assertEquals('app_access_token', $f->getConfig()->get('app_access_token'));
130 |
131 | $this->expectException(InvalidTokenException::class);
132 | $ff->invoke($f);
133 | }
134 |
135 | public function test_config_app_access_token_with_default_mode_and_app_ticket_work_in_bad_response()
136 | {
137 | $config = [
138 | 'client_id' => 'xxxxx',
139 | 'client_secret' => 'yyyyy',
140 | ];
141 |
142 | $f = new FeiShu($config);
143 | $fr = new \ReflectionObject($f);
144 | $frClient = $fr->getProperty('httpClient');
145 | $frClient->setAccessible(true);
146 | $ff = new \ReflectionMethod(FeiShu::class, 'configAppAccessToken');
147 |
148 | $mock = new MockHandler([
149 | new Response(200, [], '{}'),
150 | ]);
151 |
152 | $handler = HandlerStack::create($mock);
153 | $client = new Client(['handler' => $handler]);
154 | $frClient->setValue($f, $client);
155 | $ff->setAccessible(true);
156 |
157 | $this->expectException(InvalidTokenException::class);
158 | $ff->invoke($f->withAppTicket('app_ticket'));
159 | }
160 |
161 | public function test_config_app_access_token_with_default_mode_and_app_ticket_work_in_good_response()
162 | {
163 | $config = [
164 | 'client_id' => 'xxxxx',
165 | 'client_secret' => 'yyyyy',
166 | ];
167 |
168 | $f = new FeiShu($config);
169 | $fr = new \ReflectionObject($f);
170 | $frClient = $fr->getProperty('httpClient');
171 | $frClient->setAccessible(true);
172 | $ff = new \ReflectionMethod(FeiShu::class, 'configAppAccessToken');
173 |
174 | $mock = new MockHandler([
175 | new Response(200, [], \json_encode([
176 | 'app_access_token' => 'app_access_token',
177 | ])),
178 | ]);
179 |
180 | $handler = HandlerStack::create($mock);
181 | $client = new Client(['handler' => $handler]);
182 | $frClient->setValue($f, $client);
183 | $ff->setAccessible(true);
184 |
185 | $this->assertEquals(null, $f->getConfig()->get('app_access_token'));
186 | $ff->invoke($f->withAppTicket('app_ticket'));
187 | $this->assertEquals('app_access_token', $f->getConfig()->get('app_access_token'));
188 | }
189 |
190 | public function test_config_app_access_token_with_internal_in_bad_response()
191 | {
192 | $config = [
193 | 'client_id' => 'xxxxx',
194 | 'client_secret' => 'yyyyy',
195 | 'mode' => 'internal',
196 | ];
197 |
198 | $f = new FeiShu($config);
199 | $fr = new \ReflectionObject($f);
200 | $frClient = $fr->getProperty('httpClient');
201 | $frClient->setAccessible(true);
202 | $ff = new \ReflectionMethod(FeiShu::class, 'configAppAccessToken');
203 |
204 | $mock = new MockHandler([
205 | new Response(200, [], '{}'),
206 | ]);
207 |
208 | $handler = HandlerStack::create($mock);
209 | $client = new Client(['handler' => $handler]);
210 | $frClient->setValue($f, $client);
211 | $ff->setAccessible(true);
212 |
213 | $this->expectException(InvalidTokenException::class);
214 | $ff->invoke($f);
215 | }
216 |
217 | public function test_config_app_access_token_with_internal_in_good_response()
218 | {
219 | $config = [
220 | 'client_id' => 'xxxxx',
221 | 'client_secret' => 'yyyyy',
222 | 'mode' => 'internal',
223 | ];
224 |
225 | $f = new FeiShu($config);
226 | $fr = new \ReflectionObject($f);
227 | $frClient = $fr->getProperty('httpClient');
228 | $frClient->setAccessible(true);
229 | $ff = new \ReflectionMethod(FeiShu::class, 'configAppAccessToken');
230 |
231 | $mock = new MockHandler([
232 | new Response(200, [], \json_encode([
233 | 'app_access_token' => 'app_access_token',
234 | ])),
235 | ]);
236 |
237 | $handler = HandlerStack::create($mock);
238 | $client = new Client(['handler' => $handler]);
239 | $frClient->setValue($f, $client);
240 | $ff->setAccessible(true);
241 |
242 | $this->assertEquals(null, $f->getConfig()->get('app_access_token'));
243 | $ff->invoke($f);
244 | $this->assertEquals('app_access_token', $f->getConfig()->get('app_access_token'));
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/src/Providers/OpenWeWork.php:
--------------------------------------------------------------------------------
1 | getConfig()->has('base_url')) {
40 | $this->baseUrl = $this->getConfig()->get('base_url');
41 | }
42 | }
43 |
44 | public function withAgentId(int $agentId): self
45 | {
46 | $this->agentId = $agentId;
47 |
48 | return $this;
49 | }
50 |
51 | public function detailed(): self
52 | {
53 | $this->detailed = true;
54 |
55 | return $this;
56 | }
57 |
58 | public function asQrcode(): self
59 | {
60 | $this->asQrcode = true;
61 |
62 | return $this;
63 | }
64 |
65 | public function withUserType(string $userType): self
66 | {
67 | $this->userType = $userType;
68 |
69 | return $this;
70 | }
71 |
72 | public function withLang(string $lang): self
73 | {
74 | $this->lang = $lang;
75 |
76 | return $this;
77 | }
78 |
79 | /**
80 | * @throws GuzzleException
81 | * @throws AuthorizeFailedException
82 | */
83 | public function userFromCode(string $code): Contracts\UserInterface
84 | {
85 | $user = $this->getUser($this->getSuiteAccessToken(), $code);
86 |
87 | if ($this->detailed) {
88 | if (empty($user['user_ticket'])) {
89 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing user_ticket in response', $user);
90 | }
91 | $user = \array_merge($user, $this->getUserByTicket($user['user_ticket']));
92 | }
93 |
94 | return $this->mapUserToObject($user)->setProvider($this)->setRaw($user);
95 | }
96 |
97 | public function withSuiteTicket(string $suiteTicket): self
98 | {
99 | $this->suiteTicket = $suiteTicket;
100 |
101 | return $this;
102 | }
103 |
104 | public function withSuiteAccessToken(string $suiteAccessToken): self
105 | {
106 | $this->suiteAccessToken = $suiteAccessToken;
107 |
108 | return $this;
109 | }
110 |
111 | /**
112 | * @throws Exceptions\InvalidArgumentException
113 | */
114 | public function getAuthUrl(): string
115 | {
116 | $queries = \array_filter([
117 | 'appid' => $this->getClientId(),
118 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
119 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
120 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
121 | Contracts\RFC6749_ABNF_STATE => $this->state,
122 | 'agentid' => $this->agentId,
123 | ]);
124 |
125 | if ($this->asQrcode) {
126 | $queries = array_filter([
127 | 'appid' => $queries['appid'] ?? $this->getClientId(),
128 | 'redirect_uri' => $queries['redirect_uri'] ?? $this->redirectUrl,
129 | 'usertype' => $this->userType,
130 | 'lang' => $this->lang,
131 | 'state' => $this->state,
132 | ]);
133 |
134 | return \sprintf('https://open.work.weixin.qq.com/wwopen/sso/3rd_qrConnect?%s', http_build_query($queries));
135 | }
136 |
137 | return \sprintf('https://open.weixin.qq.com/connect/oauth2/authorize?%s#wechat_redirect', \http_build_query($queries));
138 | }
139 |
140 | /**
141 | * @throws Exceptions\MethodDoesNotSupportException
142 | */
143 | protected function getUserByToken(string $token): array
144 | {
145 | throw new Exceptions\MethodDoesNotSupportException('Open WeWork doesn\'t support access_token mode');
146 | }
147 |
148 | protected function getSuiteAccessToken(): string
149 | {
150 | return $this->suiteAccessToken ?? $this->suiteAccessToken = $this->requestSuiteAccessToken();
151 | }
152 |
153 | /**
154 | * @throws Exceptions\AuthorizeFailedException|GuzzleException
155 | */
156 | protected function getUser(string $token, string $code): array
157 | {
158 | $responseInstance = $this->getHttpClient()->get(
159 | $this->baseUrl.'/cgi-bin/service/getuserinfo3rd',
160 | [
161 | 'query' => \array_filter(
162 | [
163 | 'suite_access_token' => $token,
164 | Contracts\RFC6749_ABNF_CODE => $code,
165 | ]
166 | ),
167 | ]
168 | );
169 |
170 | $response = $this->fromJsonBody($responseInstance);
171 |
172 | if (($response['errcode'] ?? 1) > 0 || (empty($response['UserId']) && empty($response['openid']))) {
173 | throw new Exceptions\AuthorizeFailedException((string) $responseInstance->getBody(), $response);
174 | } elseif (empty($response['user_ticket'])) {
175 | $this->detailed = false;
176 | }
177 |
178 | return $response;
179 | }
180 |
181 | /**
182 | * @throws Exceptions\AuthorizeFailedException
183 | * @throws GuzzleException
184 | */
185 | protected function getUserByTicket(string $userTicket): array
186 | {
187 | $responseInstance = $this->getHttpClient()->post(
188 | $this->baseUrl.'/cgi-bin/service/auth/getuserdetail3rd',
189 | [
190 | 'query' => [
191 | 'suite_access_token' => $this->getSuiteAccessToken(),
192 | ],
193 | 'json' => [
194 | 'user_ticket' => $userTicket,
195 | ],
196 | ],
197 | );
198 |
199 | $response = $this->fromJsonBody($responseInstance);
200 |
201 | if (($response['errcode'] ?? 1) > 0 || empty($response['userid'])) {
202 | throw new Exceptions\AuthorizeFailedException((string) $responseInstance->getBody(), $response);
203 | }
204 |
205 | return $response;
206 | }
207 |
208 | #[Pure]
209 | protected function mapUserToObject(array $user): Contracts\UserInterface
210 | {
211 | return new User($this->detailed ? [
212 | Contracts\ABNF_ID => $user['userid'] ?? $user['UserId'] ?? null,
213 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
214 | Contracts\ABNF_AVATAR => $user[Contracts\ABNF_AVATAR] ?? null,
215 | 'gender' => $user['gender'] ?? null,
216 | 'corpid' => $user['corpid'] ?? $user['CorpId'] ?? null,
217 | 'open_userid' => $user['open_userid'] ?? null,
218 | 'qr_code' => $user['qr_code'] ?? null,
219 | ] : [
220 | Contracts\ABNF_ID => $user['userid'] ?? $user['UserId'] ?? $user['OpenId'] ?? $user['openid'] ?? null,
221 | 'corpid' => $user['CorpId'] ?? null,
222 | 'open_userid' => $user['open_userid'] ?? null,
223 | ]);
224 | }
225 |
226 | /**
227 | * @throws Exceptions\AuthorizeFailedException
228 | * @throws GuzzleException
229 | */
230 | protected function requestSuiteAccessToken(): string
231 | {
232 | $responseInstance = $this->getHttpClient()->post(
233 | $this->baseUrl.'/cgi-bin/service/get_suite_token',
234 | [
235 | 'json' => [
236 | 'suite_id' => $this->config->get('suite_id') ?? $this->config->get('client_id'),
237 | 'suite_secret' => $this->config->get('suite_secret') ?? $this->config->get('client_secret'),
238 | 'suite_ticket' => $this->suiteTicket,
239 | ],
240 | ]
241 | );
242 |
243 | $response = $this->fromJsonBody($responseInstance);
244 |
245 | if (isset($response['errcode']) && $response['errcode'] > 0) {
246 | throw new Exceptions\AuthorizeFailedException((string) $responseInstance->getBody(), $response);
247 | }
248 |
249 | return $response['suite_access_token'];
250 | }
251 |
252 | protected function getTokenUrl(): string
253 | {
254 | return '';
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/src/Providers/WeChat.php:
--------------------------------------------------------------------------------
1 | getConfig()->has('component')) {
35 | $this->prepareForComponent((array) $this->getConfig()->get('component'));
36 | }
37 | }
38 |
39 | public function withOpenid(string $openid): self
40 | {
41 | $this->openid = $openid;
42 |
43 | return $this;
44 | }
45 |
46 | public function withCountryCode(): self
47 | {
48 | $this->withCountryCode = true;
49 |
50 | return $this;
51 | }
52 |
53 | public function tokenFromCode(string $code): array
54 | {
55 | $response = $this->getTokenFromCode($code);
56 |
57 | return $this->normalizeAccessTokenResponse($response->getBody());
58 | }
59 |
60 | /**
61 | * @param array $componentConfig [Contracts\ABNF_ID => xxx, Contracts\ABNF_TOKEN => xxx]
62 | */
63 | public function withComponent(array $componentConfig): self
64 | {
65 | $this->prepareForComponent($componentConfig);
66 |
67 | return $this;
68 | }
69 |
70 | public function getComponent(): ?array
71 | {
72 | return $this->component;
73 | }
74 |
75 | protected function getAuthUrl(): string
76 | {
77 | $path = 'oauth2/authorize';
78 |
79 | if (\in_array('snsapi_login', $this->scopes)) {
80 | $path = 'qrconnect';
81 | }
82 |
83 | return $this->buildAuthUrlFromBase("https://open.weixin.qq.com/connect/{$path}");
84 | }
85 |
86 | protected function buildAuthUrlFromBase(string $url): string
87 | {
88 | $query = \http_build_query($this->getCodeFields(), '', '&', $this->encodingType);
89 |
90 | return $url.'?'.$query.'#wechat_redirect';
91 | }
92 |
93 | protected function getCodeFields(): array
94 | {
95 | if (! empty($this->component)) {
96 | $this->with(\array_merge($this->parameters, ['component_appid' => $this->component[Contracts\ABNF_ID]]));
97 | }
98 |
99 | return \array_merge([
100 | 'appid' => $this->getClientId(),
101 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
102 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
103 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
104 | Contracts\RFC6749_ABNF_STATE => $this->state ?: \md5(\uniqid(Contracts\RFC6749_ABNF_STATE, true)),
105 | 'connect_redirect' => 1,
106 | ], $this->parameters);
107 | }
108 |
109 | protected function getTokenUrl(): string
110 | {
111 | return \sprintf($this->baseUrl.'/oauth2%s/access_token', empty($this->component) ? '' : '/component');
112 | }
113 |
114 | public function userFromCode(string $code): Contracts\UserInterface
115 | {
116 | if (\in_array('snsapi_base', $this->scopes)) {
117 | return $this->getSnsapiBaseUserFromCode($code);
118 | }
119 |
120 | $token = $this->tokenFromCode($code);
121 |
122 | if (empty($token['openid'])) {
123 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing openid in token response', $token);
124 | }
125 |
126 | $this->withOpenid($token['openid']);
127 |
128 | $user = $this->userFromToken($token[$this->accessTokenKey]);
129 |
130 | return $user->setRefreshToken($token[Contracts\RFC6749_ABNF_REFRESH_TOKEN])
131 | ->setExpiresIn($token[Contracts\RFC6749_ABNF_EXPIRES_IN])
132 | ->setTokenResponse($token);
133 | }
134 |
135 | protected function getSnsapiBaseUserFromCode(string $code): Contracts\UserInterface
136 | {
137 | $token = $this->fromJsonBody($this->getTokenFromCode($code));
138 |
139 | if (empty($token['openid'])) {
140 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing openid in token response', $token);
141 | }
142 |
143 | if (empty($token[$this->accessTokenKey])) {
144 | throw new Exceptions\AuthorizeFailedException('Authorization failed: missing access_token in token response', $token);
145 | }
146 |
147 | $user = [
148 | 'openid' => $token['openid'],
149 | ];
150 | if (isset($token['unionid'])) {
151 | $user['unionid'] = $token['unionid'];
152 | }
153 |
154 | return $this->mapUserToObject($token)->setProvider($this)->setRaw($user)->setAccessToken($token[$this->accessTokenKey]);
155 | }
156 |
157 | protected function getUserByToken(string $token): array
158 | {
159 | $language = $this->withCountryCode ? null : (isset($this->parameters['lang']) ? $this->parameters['lang'] : 'zh_CN');
160 |
161 | $response = $this->getHttpClient()->get($this->baseUrl.'/userinfo', [
162 | 'query' => \array_filter([
163 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $token,
164 | 'openid' => $this->openid,
165 | 'lang' => $language,
166 | ]),
167 | ]);
168 |
169 | return $this->fromJsonBody($response);
170 | }
171 |
172 | #[Pure]
173 | protected function mapUserToObject(array $user): Contracts\UserInterface
174 | {
175 | return new User([
176 | Contracts\ABNF_ID => $user['openid'] ?? null,
177 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NICKNAME] ?? null,
178 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NICKNAME] ?? null,
179 | Contracts\ABNF_AVATAR => $user['headimgurl'] ?? null,
180 | Contracts\ABNF_EMAIL => null,
181 | ]);
182 | }
183 |
184 | protected function getTokenFields(string $code): array
185 | {
186 | return empty($this->component) ? [
187 | 'appid' => $this->getClientId(),
188 | 'secret' => $this->getClientSecret(),
189 | Contracts\RFC6749_ABNF_CODE => $code,
190 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
191 | ] : [
192 | 'appid' => $this->getClientId(),
193 | 'component_appid' => $this->component[Contracts\ABNF_ID],
194 | 'component_access_token' => $this->component[Contracts\ABNF_TOKEN],
195 | Contracts\RFC6749_ABNF_CODE => $code,
196 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
197 | ];
198 | }
199 |
200 | protected function getTokenFromCode(string $code): ResponseInterface
201 | {
202 | return $this->getHttpClient()->get($this->getTokenUrl(), [
203 | 'headers' => ['Accept' => 'application/json'],
204 | 'query' => $this->getTokenFields($code),
205 | ]);
206 | }
207 |
208 | /**
209 | * @throws Exceptions\InvalidArgumentException
210 | */
211 | protected function prepareForComponent(array $component): void
212 | {
213 | $config = [];
214 | foreach ($component as $key => $value) {
215 | if (\is_callable($value)) {
216 | $value = \call_user_func($value, $this);
217 | }
218 |
219 | switch ($key) {
220 | case Contracts\ABNF_ID:
221 | case Contracts\ABNF_APP_ID:
222 | case 'component_app_id':
223 | $config[Contracts\ABNF_ID] = $value;
224 | break;
225 | case Contracts\ABNF_TOKEN:
226 | case Contracts\RFC6749_ABNF_ACCESS_TOKEN:
227 | case 'app_token':
228 | case 'component_access_token':
229 | $config[Contracts\ABNF_TOKEN] = $value;
230 | break;
231 | }
232 | }
233 |
234 | if (\count($config) !== 2) {
235 | throw new Exceptions\InvalidArgumentException('Please check your config arguments were available.');
236 | }
237 |
238 | if (\count($this->scopes) === 1 && \in_array('snsapi_login', $this->scopes)) {
239 | $this->scopes = ['snsapi_base'];
240 | }
241 |
242 | $this->component = $config;
243 | }
244 | }
245 |
--------------------------------------------------------------------------------
/tests/Providers/DingTalkTest.php:
--------------------------------------------------------------------------------
1 | 'client_id',
21 | 'client_secret' => 'client_secret',
22 | 'redirect_url' => 'http://localhost/callback',
23 | ]);
24 |
25 | $response = $provider->redirect();
26 |
27 | $this->assertStringStartsWith('https://oapi.dingtalk.com/connect/qrconnect', $response);
28 | $this->assertStringContainsString('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback', $response);
29 | $this->assertStringContainsString('appid=client_id', $response);
30 | }
31 |
32 | public function test_ding_talk_provider_configuration()
33 | {
34 | // Test with app_id configuration
35 | $provider = new DingTalk([
36 | 'app_id' => 'test_app_id',
37 | 'app_secret' => 'test_app_secret',
38 | 'redirect_url' => 'http://localhost/callback',
39 | ]);
40 |
41 | $this->assertSame('test_app_id', $provider->getClientId());
42 | $this->assertSame('test_app_secret', $provider->getClientSecret());
43 |
44 | // Test with appid configuration
45 | $provider = new DingTalk([
46 | 'appid' => 'test_appid',
47 | 'appSecret' => 'test_appsecret',
48 | 'redirect_url' => 'http://localhost/callback',
49 | ]);
50 |
51 | $this->assertSame('test_appid', $provider->getClientId());
52 | $this->assertSame('test_appsecret', $provider->getClientSecret());
53 |
54 | // Test with appId configuration
55 | $provider = new DingTalk([
56 | 'appId' => 'test_appId',
57 | 'client_secret' => 'test_client_secret',
58 | 'redirect_url' => 'http://localhost/callback',
59 | ]);
60 |
61 | $this->assertSame('test_appId', $provider->getClientId());
62 | $this->assertSame('test_client_secret', $provider->getClientSecret());
63 | }
64 |
65 | public function test_get_code_fields()
66 | {
67 | $provider = new DingTalk([
68 | 'client_id' => 'client_id',
69 | 'client_secret' => 'client_secret',
70 | 'redirect_url' => 'http://localhost/callback',
71 | ]);
72 |
73 | $getCodeFields = new ReflectionMethod(DingTalk::class, 'getCodeFields');
74 | $getCodeFields->setAccessible(true);
75 |
76 | $fields = $getCodeFields->invoke($provider->withState('test-state'));
77 |
78 | $this->assertSame([
79 | 'appid' => 'client_id',
80 | 'grant_type' => 'authorization_code',
81 | 'code' => 'snsapi_login',
82 | 'redirect_uri' => 'http://localhost/callback',
83 | ], $fields);
84 | }
85 |
86 | public function test_create_signature()
87 | {
88 | $provider = new DingTalk([
89 | 'client_id' => 'client_id',
90 | 'client_secret' => 'test_secret',
91 | 'redirect_url' => 'http://localhost/callback',
92 | ]);
93 |
94 | $createSignature = new ReflectionMethod(DingTalk::class, 'createSignature');
95 | $createSignature->setAccessible(true);
96 |
97 | $time = 1234567890000;
98 | $signature = $createSignature->invoke($provider, $time);
99 |
100 | $this->assertIsString($signature);
101 | $this->assertNotEmpty($signature);
102 | }
103 |
104 | public function test_user_from_code_success()
105 | {
106 | $provider = new DingTalk([
107 | 'client_id' => 'client_id',
108 | 'client_secret' => 'client_secret',
109 | 'redirect_url' => 'http://localhost/callback',
110 | ]);
111 |
112 | $mock = new MockHandler([
113 | new Response(200, [], '{"errcode": 0, "user_info": {"nick": "Test User", "open_id": "test_openid"}}'),
114 | ]);
115 |
116 | $handler = HandlerStack::create($mock);
117 | $client = new Client(['handler' => $handler]);
118 |
119 | $reflection = new \ReflectionObject($provider);
120 | $httpClientProperty = $reflection->getProperty('httpClient');
121 | $httpClientProperty->setAccessible(true);
122 | $httpClientProperty->setValue($provider, $client);
123 |
124 | $user = $provider->userFromCode('test_code');
125 |
126 | $this->assertSame('Test User', $user->getName());
127 | $this->assertSame('Test User', $user->getNickname());
128 | $this->assertSame('test_openid', $user->getId());
129 | }
130 |
131 | public function test_throws_exception_when_user_info_missing()
132 | {
133 | $provider = new DingTalk([
134 | 'client_id' => 'client_id',
135 | 'client_secret' => 'client_secret',
136 | 'redirect_url' => 'http://localhost/callback',
137 | ]);
138 |
139 | $mock = new MockHandler([
140 | new Response(200, [], '{"errcode": 0}'), // Missing user_info
141 | ]);
142 |
143 | $handler = HandlerStack::create($mock);
144 | $client = new Client(['handler' => $handler]);
145 |
146 | $reflection = new \ReflectionObject($provider);
147 | $httpClientProperty = $reflection->getProperty('httpClient');
148 | $httpClientProperty->setAccessible(true);
149 | $httpClientProperty->setValue($provider, $client);
150 |
151 | $this->expectException(AuthorizeFailedException::class);
152 | $this->expectExceptionMessage('Authorization failed: missing user_info in response');
153 |
154 | $provider->userFromCode('test_code');
155 | }
156 |
157 | public function test_throws_exception_when_error_code_non_zero()
158 | {
159 | $provider = new DingTalk([
160 | 'client_id' => 'client_id',
161 | 'client_secret' => 'client_secret',
162 | 'redirect_url' => 'http://localhost/callback',
163 | ]);
164 |
165 | $mock = new MockHandler([
166 | new Response(200, [], '{"errcode": 1, "errmsg": "Error message"}'),
167 | ]);
168 |
169 | $handler = HandlerStack::create($mock);
170 | $client = new Client(['handler' => $handler]);
171 |
172 | $reflection = new \ReflectionObject($provider);
173 | $httpClientProperty = $reflection->getProperty('httpClient');
174 | $httpClientProperty->setAccessible(true);
175 | $httpClientProperty->setValue($provider, $client);
176 |
177 | $this->expectException(BadRequestException::class);
178 |
179 | $provider->userFromCode('test_code');
180 | }
181 |
182 | public function test_get_token_url_throws_exception()
183 | {
184 | $provider = new DingTalk([
185 | 'client_id' => 'client_id',
186 | 'client_secret' => 'client_secret',
187 | 'redirect_url' => 'http://localhost/callback',
188 | ]);
189 |
190 | $this->expectException(\Overtrue\Socialite\Exceptions\InvalidArgumentException::class);
191 | $this->expectExceptionMessage('not supported to get access token.');
192 |
193 | $getTokenUrl = new ReflectionMethod(DingTalk::class, 'getTokenUrl');
194 | $getTokenUrl->setAccessible(true);
195 | $getTokenUrl->invoke($provider);
196 | }
197 |
198 | public function test_get_user_by_token_throws_exception()
199 | {
200 | $provider = new DingTalk([
201 | 'client_id' => 'client_id',
202 | 'client_secret' => 'client_secret',
203 | 'redirect_url' => 'http://localhost/callback',
204 | ]);
205 |
206 | $this->expectException(\Overtrue\Socialite\Exceptions\InvalidArgumentException::class);
207 | $this->expectExceptionMessage('Unable to use token get User.');
208 |
209 | $getUserByToken = new ReflectionMethod(DingTalk::class, 'getUserByToken');
210 | $getUserByToken->setAccessible(true);
211 | $getUserByToken->invoke($provider, 'test_token');
212 | }
213 |
214 | public function test_map_user_to_object()
215 | {
216 | $provider = new DingTalk([
217 | 'client_id' => 'client_id',
218 | 'client_secret' => 'client_secret',
219 | 'redirect_url' => 'http://localhost/callback',
220 | ]);
221 |
222 | $mapUserToObject = new ReflectionMethod(DingTalk::class, 'mapUserToObject');
223 | $mapUserToObject->setAccessible(true);
224 |
225 | $user = [
226 | 'nick' => 'Test User',
227 | 'open_id' => 'test_openid',
228 | ];
229 |
230 | $result = $mapUserToObject->invoke($provider, $user);
231 |
232 | $this->assertSame('test_openid', $result->getId());
233 | $this->assertSame('Test User', $result->getName());
234 | $this->assertSame('Test User', $result->getNickname());
235 | $this->assertNull($result->getEmail());
236 | $this->assertNull($result->getAvatar());
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/tests/Providers/QQTest.php:
--------------------------------------------------------------------------------
1 | 'client_id',
20 | 'client_secret' => 'client_secret',
21 | 'redirect_url' => 'http://localhost/callback',
22 | ]);
23 |
24 | $response = $provider->redirect();
25 |
26 | $this->assertStringStartsWith('https://graph.qq.com/oauth2.0/authorize', $response);
27 | $this->assertStringContainsString('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback', $response);
28 | $this->assertStringContainsString('client_id=client_id', $response);
29 | $this->assertStringContainsString('response_type=code', $response);
30 | $this->assertStringContainsString('scope=get_user_info', $response);
31 | }
32 |
33 | public function test_qq_provider_token_url_and_request_fields()
34 | {
35 | $provider = new QQ([
36 | 'client_id' => 'client_id',
37 | 'client_secret' => 'client_secret',
38 | 'redirect_url' => 'http://localhost/callback',
39 | ]);
40 |
41 | $getTokenUrl = new \ReflectionMethod(QQ::class, 'getTokenUrl');
42 | $getTokenUrl->setAccessible(true);
43 |
44 | $getTokenFields = new \ReflectionMethod(QQ::class, 'getTokenFields');
45 | $getTokenFields->setAccessible(true);
46 |
47 | $getCodeFields = new \ReflectionMethod(QQ::class, 'getCodeFields');
48 | $getCodeFields->setAccessible(true);
49 |
50 | $this->assertSame('https://graph.qq.com/oauth2.0/token', $getTokenUrl->invoke($provider));
51 |
52 | $this->assertSame([
53 | 'client_id' => 'client_id',
54 | 'client_secret' => 'client_secret',
55 | 'code' => 'test_code',
56 | 'redirect_uri' => 'http://localhost/callback',
57 | 'grant_type' => 'authorization_code',
58 | ], $getTokenFields->invoke($provider, 'test_code'));
59 |
60 | $this->assertSame([
61 | 'client_id' => 'client_id',
62 | 'redirect_uri' => 'http://localhost/callback',
63 | 'scope' => 'get_user_info',
64 | 'response_type' => 'code',
65 | 'state' => 'qq-state',
66 | ], $getCodeFields->invoke($provider->withState('qq-state')));
67 | }
68 |
69 | public function test_with_union_id_method()
70 | {
71 | $provider = new QQ([
72 | 'client_id' => 'client_id',
73 | 'client_secret' => 'client_secret',
74 | 'redirect_url' => 'http://localhost/callback',
75 | ]);
76 |
77 | $result = $provider->withUnionId();
78 | $this->assertInstanceOf(QQ::class, $result);
79 | $this->assertSame($provider, $result);
80 | }
81 |
82 | public function test_token_from_code_method()
83 | {
84 | $provider = new QQ([
85 | 'client_id' => 'client_id',
86 | 'client_secret' => 'client_secret',
87 | 'redirect_url' => 'http://localhost/callback',
88 | ]);
89 |
90 | $mock = new MockHandler([
91 | new Response(200, [], 'access_token=test_token&refresh_token=refresh_token&expires_in=7200'),
92 | ]);
93 |
94 | $handler = HandlerStack::create($mock);
95 | $client = new Client(['handler' => $handler]);
96 |
97 | $reflection = new \ReflectionObject($provider);
98 | $httpClientProperty = $reflection->getProperty('httpClient');
99 | $httpClientProperty->setAccessible(true);
100 | $httpClientProperty->setValue($provider, $client);
101 |
102 | $token = $provider->tokenFromCode('test_code');
103 |
104 | $this->assertArrayHasKey('access_token', $token);
105 | $this->assertSame('test_token', $token['access_token']);
106 | }
107 |
108 | public function test_get_user_by_token_success()
109 | {
110 | $provider = new QQ([
111 | 'client_id' => 'client_id',
112 | 'client_secret' => 'client_secret',
113 | 'redirect_url' => 'http://localhost/callback',
114 | ]);
115 |
116 | $mock = new MockHandler([
117 | // First call to get openid
118 | new Response(200, [], '{"openid": "test_openid", "unionid": "test_unionid"}'),
119 | // Second call to get user info
120 | new Response(200, [], '{"ret": 0, "nickname": "Test User", "figureurl_qq_2": "http://avatar.url"}'),
121 | ]);
122 |
123 | $handler = HandlerStack::create($mock);
124 | $client = new Client(['handler' => $handler]);
125 |
126 | $reflection = new \ReflectionObject($provider);
127 | $httpClientProperty = $reflection->getProperty('httpClient');
128 | $httpClientProperty->setAccessible(true);
129 | $httpClientProperty->setValue($provider, $client);
130 |
131 | // Use reflection to test protected method
132 | $getUserByToken = new ReflectionMethod(QQ::class, 'getUserByToken');
133 | $getUserByToken->setAccessible(true);
134 | $result = $getUserByToken->invoke($provider, 'test_token');
135 |
136 | $this->assertArrayHasKey('openid', $result);
137 | $this->assertSame('test_openid', $result['openid']);
138 | $this->assertArrayHasKey('unionid', $result);
139 | $this->assertSame('test_unionid', $result['unionid']);
140 | }
141 |
142 | public function test_throws_exception_when_openid_missing()
143 | {
144 | $provider = new QQ([
145 | 'client_id' => 'client_id',
146 | 'client_secret' => 'client_secret',
147 | 'redirect_url' => 'http://localhost/callback',
148 | ]);
149 |
150 | $mock = new MockHandler([
151 | // First call to get openid - return response missing openid
152 | new Response(200, [], '{"error": "invalid_request"}'),
153 | ]);
154 |
155 | $handler = HandlerStack::create($mock);
156 | $client = new Client(['handler' => $handler]);
157 |
158 | $reflection = new \ReflectionObject($provider);
159 | $httpClientProperty = $reflection->getProperty('httpClient');
160 | $httpClientProperty->setAccessible(true);
161 | $httpClientProperty->setValue($provider, $client);
162 |
163 | $this->expectException(AuthorizeFailedException::class);
164 | $this->expectExceptionMessage('Authorization failed: missing openid in token response');
165 |
166 | // Use reflection to test protected method
167 | $getUserByToken = new ReflectionMethod(QQ::class, 'getUserByToken');
168 | $getUserByToken->setAccessible(true);
169 | $getUserByToken->invoke($provider, 'test_token');
170 | }
171 |
172 | public function test_throws_exception_when_user_info_returns_fails()
173 | {
174 | $provider = new QQ([
175 | 'client_id' => 'client_id',
176 | 'client_secret' => 'client_secret',
177 | 'redirect_url' => 'http://localhost/callback',
178 | ]);
179 |
180 | $mock = new MockHandler([
181 | // First call to get openid - success
182 | new Response(200, [], '{"openid": "test_openid"}'),
183 | // Second call to get user info - failure
184 | new Response(200, [], '{"ret": 1, "msg": "parameter error"}'),
185 | ]);
186 |
187 | $handler = HandlerStack::create($mock);
188 | $client = new Client(['handler' => $handler]);
189 |
190 | $reflection = new \ReflectionObject($provider);
191 | $httpClientProperty = $reflection->getProperty('httpClient');
192 | $httpClientProperty->setAccessible(true);
193 | $httpClientProperty->setValue($provider, $client);
194 |
195 | $this->expectException(AuthorizeFailedException::class);
196 |
197 | // Use reflection to test protected method
198 | $getUserByToken = new ReflectionMethod(QQ::class, 'getUserByToken');
199 | $getUserByToken->setAccessible(true);
200 | $getUserByToken->invoke($provider, 'test_token');
201 | }
202 |
203 | public function test_map_user_to_object()
204 | {
205 | $provider = new QQ([
206 | 'client_id' => 'client_id',
207 | 'client_secret' => 'client_secret',
208 | 'redirect_url' => 'http://localhost/callback',
209 | ]);
210 |
211 | $mapUserToObject = new ReflectionMethod(QQ::class, 'mapUserToObject');
212 | $mapUserToObject->setAccessible(true);
213 |
214 | $user = [
215 | 'openid' => 'test_openid',
216 | 'nickname' => 'Test User',
217 | 'email' => 'test@example.com',
218 | 'figureurl_qq_2' => 'http://avatar.url',
219 | ];
220 |
221 | $result = $mapUserToObject->invoke($provider, $user);
222 |
223 | $this->assertSame('test_openid', $result->getId());
224 | $this->assertSame('Test User', $result->getName());
225 | $this->assertSame('Test User', $result->getNickname());
226 | $this->assertSame('test@example.com', $result->getEmail());
227 | $this->assertSame('http://avatar.url', $result->getAvatar());
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/src/Providers/FeiShu.php:
--------------------------------------------------------------------------------
1 | isInternalApp = ($this->config->get('app_mode') ?? $this->config->get('mode')) == 'internal';
30 | }
31 |
32 | protected function getAuthUrl(): string
33 | {
34 | return $this->buildAuthUrlFromBase($this->baseUrl.'/authen/v1/index');
35 | }
36 |
37 | #[ArrayShape([Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string', Contracts\ABNF_APP_ID => 'null|string'])]
38 | protected function getCodeFields(): array
39 | {
40 | return [
41 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
42 | Contracts\ABNF_APP_ID => $this->getClientId(),
43 | ];
44 | }
45 |
46 | protected function getTokenUrl(): string
47 | {
48 | return $this->baseUrl.'/authen/v1/access_token';
49 | }
50 |
51 | public function tokenFromCode(string $code): array
52 | {
53 | return $this->normalizeAccessTokenResponse($this->getTokenFromCode($code));
54 | }
55 |
56 | /**
57 | * @throws Exceptions\AuthorizeFailedException
58 | */
59 | protected function getTokenFromCode(string $code): array
60 | {
61 | $this->configAppAccessToken();
62 | $responseInstance = $this->getHttpClient()->post($this->getTokenUrl(), [
63 | 'json' => [
64 | 'app_access_token' => $this->config->get('app_access_token'),
65 | Contracts\RFC6749_ABNF_CODE => $code,
66 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_AUTHORATION_CODE,
67 | ],
68 | ]);
69 | $response = $this->fromJsonBody($responseInstance);
70 |
71 | if (empty($response['data'] ?? null)) {
72 | throw new Exceptions\AuthorizeFailedException('Invalid token response', $response);
73 | }
74 |
75 | return $this->normalizeAccessTokenResponse($response['data']);
76 | }
77 |
78 | protected function getRefreshTokenUrl(): string
79 | {
80 | return $this->baseUrl.'/authen/v1/refresh_access_token';
81 | }
82 |
83 | /**
84 | * @see https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/authen-v1/authen/refresh_access_token
85 | */
86 | public function refreshToken(string $refreshToken): array
87 | {
88 | $this->configAppAccessToken();
89 | $responseInstance = $this->getHttpClient()->post($this->getRefreshTokenUrl(), [
90 | 'json' => [
91 | 'app_access_token' => $this->config->get('app_access_token'),
92 | Contracts\RFC6749_ABNF_REFRESH_TOKEN => $refreshToken,
93 | Contracts\RFC6749_ABNF_GRANT_TYPE => Contracts\RFC6749_ABNF_REFRESH_TOKEN,
94 | ],
95 | ]);
96 | $response = $this->fromJsonBody($responseInstance);
97 |
98 | if (empty($response['data'] ?? null)) {
99 | throw new Exceptions\AuthorizeFailedException('Invalid token response', $response);
100 | }
101 |
102 | return $this->normalizeAccessTokenResponse($response['data']);
103 | }
104 |
105 | /**
106 | * @throws Exceptions\BadRequestException
107 | */
108 | protected function getUserByToken(string $token): array
109 | {
110 | $responseInstance = $this->getHttpClient()->get($this->baseUrl.'/authen/v1/user_info', [
111 | 'headers' => [
112 | 'Content-Type' => 'application/json',
113 | 'Authorization' => 'Bearer '.$token,
114 | ],
115 | 'query' => \array_filter(
116 | [
117 | 'user_access_token' => $token,
118 | ]
119 | ),
120 | ]);
121 |
122 | $response = $this->fromJsonBody($responseInstance);
123 |
124 | if (empty($response['data'] ?? null)) {
125 | throw new Exceptions\BadRequestException((string) $responseInstance->getBody());
126 | }
127 |
128 | return $response['data'];
129 | }
130 |
131 | #[Pure]
132 | protected function mapUserToObject(array $user): Contracts\UserInterface
133 | {
134 | return new User([
135 | Contracts\ABNF_ID => $user['user_id'] ?? null,
136 | Contracts\ABNF_NAME => $user[Contracts\ABNF_NAME] ?? null,
137 | Contracts\ABNF_NICKNAME => $user[Contracts\ABNF_NAME] ?? null,
138 | Contracts\ABNF_AVATAR => $user['avatar_url'] ?? null,
139 | Contracts\ABNF_EMAIL => $user[Contracts\ABNF_EMAIL] ?? null,
140 | ]);
141 | }
142 |
143 | public function withInternalAppMode(): self
144 | {
145 | $this->isInternalApp = true;
146 |
147 | return $this;
148 | }
149 |
150 | public function withDefaultMode(): self
151 | {
152 | $this->isInternalApp = false;
153 |
154 | return $this;
155 | }
156 |
157 | /**
158 | * set self::APP_TICKET in config attribute
159 | */
160 | public function withAppTicket(string $appTicket): self
161 | {
162 | $this->config->set(self::APP_TICKET, $appTicket);
163 |
164 | return $this;
165 | }
166 |
167 | /**
168 | * 设置 app_access_token 到 config 设置中
169 | * 应用维度授权凭证,开放平台可据此识别调用方的应用身份
170 | * 分内建和自建
171 | *
172 | * @throws Exceptions\FeiShu\InvalidTicketException
173 | * @throws Exceptions\InvalidTokenException
174 | */
175 | protected function configAppAccessToken(): self
176 | {
177 | $url = $this->baseUrl.'/auth/v3/app_access_token/';
178 | $params = [
179 | 'json' => [
180 | Contracts\ABNF_APP_ID => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_ID),
181 | Contracts\ABNF_APP_SECRET => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_SECRET),
182 | self::APP_TICKET => $this->config->get(self::APP_TICKET),
183 | ],
184 | ];
185 |
186 | if ($this->isInternalApp) {
187 | $url = $this->baseUrl.'/auth/v3/app_access_token/internal/';
188 | $params = [
189 | 'json' => [
190 | Contracts\ABNF_APP_ID => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_ID),
191 | Contracts\ABNF_APP_SECRET => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_SECRET),
192 | ],
193 | ];
194 | }
195 |
196 | if (! $this->isInternalApp && ! $this->config->has(self::APP_TICKET)) {
197 | throw new Exceptions\FeiShu\InvalidTicketException('You are using default mode, please config \'app_ticket\' first');
198 | }
199 |
200 | $responseInstance = $this->getHttpClient()->post($url, $params);
201 | $response = $this->fromJsonBody($responseInstance);
202 |
203 | if (empty($response['app_access_token'] ?? null)) {
204 | throw new Exceptions\InvalidTokenException('Invalid \'app_access_token\' response', (string) $responseInstance->getBody());
205 | }
206 |
207 | $this->config->set('app_access_token', $response['app_access_token']);
208 |
209 | return $this;
210 | }
211 |
212 | /**
213 | * 设置 tenant_access_token 到 config 属性中
214 | * 应用的企业授权凭证,开放平台据此识别调用方的应用身份和企业身份
215 | * 分内建和自建
216 | *
217 | * @throws Exceptions\BadRequestException
218 | * @throws Exceptions\AuthorizeFailedException
219 | */
220 | protected function configTenantAccessToken(): self
221 | {
222 | $url = $this->baseUrl.'/auth/v3/tenant_access_token/';
223 | $params = [
224 | 'json' => [
225 | Contracts\ABNF_APP_ID => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_ID),
226 | Contracts\ABNF_APP_SECRET => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_SECRET),
227 | self::APP_TICKET => $this->config->get(self::APP_TICKET),
228 | ],
229 | ];
230 |
231 | if ($this->isInternalApp) {
232 | $url = $this->baseUrl.'/auth/v3/tenant_access_token/internal/';
233 | $params = [
234 | 'json' => [
235 | Contracts\ABNF_APP_ID => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_ID),
236 | Contracts\ABNF_APP_SECRET => $this->config->get(Contracts\RFC6749_ABNF_CLIENT_SECRET),
237 | ],
238 | ];
239 | }
240 |
241 | if (! $this->isInternalApp && ! $this->config->has(self::APP_TICKET)) {
242 | throw new Exceptions\BadRequestException('You are using default mode, please config \'app_ticket\' first');
243 | }
244 |
245 | $response = $this->getHttpClient()->post($url, $params);
246 | $response = $this->fromJsonBody($response);
247 | if (empty($response['tenant_access_token'])) {
248 | throw new Exceptions\AuthorizeFailedException('Invalid tenant_access_token response', $response);
249 | }
250 |
251 | $this->config->set('tenant_access_token', $response['tenant_access_token']);
252 |
253 | return $this;
254 | }
255 | }
256 |
--------------------------------------------------------------------------------
/src/Providers/Base.php:
--------------------------------------------------------------------------------
1 | config = new Config($config);
45 |
46 | // set scopes
47 | if ($this->config->has('scopes') && is_array($this->config->get('scopes'))) {
48 | $this->scopes = $this->getConfig()->get('scopes');
49 | } elseif ($this->config->has(Contracts\RFC6749_ABNF_SCOPE) && is_string($this->getConfig()->get(Contracts\RFC6749_ABNF_SCOPE))) {
50 | $this->scopes = [$this->getConfig()->get(Contracts\RFC6749_ABNF_SCOPE)];
51 | }
52 |
53 | // normalize Contracts\RFC6749_ABNF_CLIENT_ID
54 | if (! $this->config->has(Contracts\RFC6749_ABNF_CLIENT_ID)) {
55 | $id = $this->config->get(Contracts\ABNF_APP_ID);
56 | if ($id != null) {
57 | $this->config->set(Contracts\RFC6749_ABNF_CLIENT_ID, $id);
58 | }
59 | }
60 |
61 | // normalize Contracts\RFC6749_ABNF_CLIENT_SECRET
62 | if (! $this->config->has(Contracts\RFC6749_ABNF_CLIENT_SECRET)) {
63 | $secret = $this->config->get(Contracts\ABNF_APP_SECRET);
64 | if ($secret != null) {
65 | $this->config->set(Contracts\RFC6749_ABNF_CLIENT_SECRET, $secret);
66 | }
67 | }
68 |
69 | // normalize 'redirect_url'
70 | if (! $this->config->has('redirect_url')) {
71 | $this->config->set('redirect_url', $this->config->get('redirect'));
72 | }
73 | $this->redirectUrl = $this->config->get('redirect_url');
74 | }
75 |
76 | abstract protected function getAuthUrl(): string;
77 |
78 | abstract protected function getTokenUrl(): string;
79 |
80 | abstract protected function getUserByToken(string $token): array;
81 |
82 | abstract protected function mapUserToObject(array $user): Contracts\UserInterface;
83 |
84 | public function redirect(?string $redirectUrl = null): string
85 | {
86 | if (! empty($redirectUrl)) {
87 | $this->withRedirectUrl($redirectUrl);
88 | }
89 |
90 | return $this->getAuthUrl();
91 | }
92 |
93 | public function userFromCode(string $code): Contracts\UserInterface
94 | {
95 | $tokenResponse = $this->tokenFromCode($code);
96 | $user = $this->userFromToken($tokenResponse[$this->accessTokenKey]);
97 |
98 | return $user->setRefreshToken($tokenResponse[$this->refreshTokenKey] ?? null)
99 | ->setExpiresIn($tokenResponse[$this->expiresInKey] ?? null)
100 | ->setTokenResponse($tokenResponse);
101 | }
102 |
103 | public function userFromToken(string $token): Contracts\UserInterface
104 | {
105 | $user = $this->getUserByToken($token);
106 |
107 | return $this->mapUserToObject($user)->setProvider($this)->setRaw($user)->setAccessToken($token);
108 | }
109 |
110 | public function tokenFromCode(string $code): array
111 | {
112 | $response = $this->getHttpClient()->post(
113 | $this->getTokenUrl(),
114 | [
115 | 'form_params' => $this->getTokenFields($code),
116 | 'headers' => [
117 | 'Accept' => 'application/json',
118 | ],
119 | ]
120 | );
121 |
122 | return $this->normalizeAccessTokenResponse((string) $response->getBody());
123 | }
124 |
125 | /**
126 | * @throws Exceptions\MethodDoesNotSupportException
127 | */
128 | public function refreshToken(string $refreshToken): mixed
129 | {
130 | throw new Exceptions\MethodDoesNotSupportException('refreshToken does not support.');
131 | }
132 |
133 | public function withRedirectUrl(string $redirectUrl): Contracts\ProviderInterface
134 | {
135 | $this->redirectUrl = $redirectUrl;
136 |
137 | return $this;
138 | }
139 |
140 | public function withState(string $state): Contracts\ProviderInterface
141 | {
142 | $this->state = $state;
143 |
144 | return $this;
145 | }
146 |
147 | public function scopes(array $scopes): Contracts\ProviderInterface
148 | {
149 | $this->scopes = $scopes;
150 |
151 | return $this;
152 | }
153 |
154 | public function with(array $parameters): Contracts\ProviderInterface
155 | {
156 | $this->parameters = $parameters;
157 |
158 | return $this;
159 | }
160 |
161 | public function getConfig(): Config
162 | {
163 | return $this->config;
164 | }
165 |
166 | public function withScopeSeparator(string $scopeSeparator): Contracts\ProviderInterface
167 | {
168 | $this->scopeSeparator = $scopeSeparator;
169 |
170 | return $this;
171 | }
172 |
173 | public function getClientId(): ?string
174 | {
175 | return $this->config->get(Contracts\RFC6749_ABNF_CLIENT_ID);
176 | }
177 |
178 | public function getClientSecret(): ?string
179 | {
180 | return $this->config->get(Contracts\RFC6749_ABNF_CLIENT_SECRET);
181 | }
182 |
183 | public function getHttpClient(): GuzzleClient
184 | {
185 | return $this->httpClient ?? new GuzzleClient($this->guzzleOptions);
186 | }
187 |
188 | public function setGuzzleOptions(array $config): Contracts\ProviderInterface
189 | {
190 | $this->guzzleOptions = $config;
191 |
192 | return $this;
193 | }
194 |
195 | public function getGuzzleOptions(): array
196 | {
197 | return $this->guzzleOptions;
198 | }
199 |
200 | protected function formatScopes(array $scopes, string $scopeSeparator): string
201 | {
202 | return \implode($scopeSeparator, $scopes);
203 | }
204 |
205 | #[ArrayShape([
206 | Contracts\RFC6749_ABNF_CLIENT_ID => 'null|string',
207 | Contracts\RFC6749_ABNF_CLIENT_SECRET => 'null|string',
208 | Contracts\RFC6749_ABNF_CODE => 'string',
209 | Contracts\RFC6749_ABNF_REDIRECT_URI => 'null|string',
210 | ])]
211 | protected function getTokenFields(string $code): array
212 | {
213 | return [
214 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
215 | Contracts\RFC6749_ABNF_CLIENT_SECRET => $this->getClientSecret(),
216 | Contracts\RFC6749_ABNF_CODE => $code,
217 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
218 | ];
219 | }
220 |
221 | protected function buildAuthUrlFromBase(string $url): string
222 | {
223 | $query = $this->getCodeFields() + ($this->state ? [Contracts\RFC6749_ABNF_STATE => $this->state] : []);
224 |
225 | return $url.'?'.\http_build_query($query, '', '&', $this->encodingType);
226 | }
227 |
228 | protected function getCodeFields(): array
229 | {
230 | $fields = \array_merge(
231 | [
232 | Contracts\RFC6749_ABNF_CLIENT_ID => $this->getClientId(),
233 | Contracts\RFC6749_ABNF_REDIRECT_URI => $this->redirectUrl,
234 | Contracts\RFC6749_ABNF_SCOPE => $this->formatScopes($this->scopes, $this->scopeSeparator),
235 | Contracts\RFC6749_ABNF_RESPONSE_TYPE => Contracts\RFC6749_ABNF_CODE,
236 | ],
237 | $this->parameters
238 | );
239 |
240 | if ($this->state) {
241 | $fields[Contracts\RFC6749_ABNF_STATE] = $this->state;
242 | }
243 |
244 | return $fields;
245 | }
246 |
247 | /**
248 | * @throws Exceptions\AuthorizeFailedException
249 | */
250 | protected function normalizeAccessTokenResponse(mixed $response): array
251 | {
252 | if ($response instanceof StreamInterface) {
253 | $response->tell() && $response->rewind();
254 | $response = (string) $response;
255 | }
256 |
257 | if (\is_string($response)) {
258 | $response = Utils::jsonDecode($response, true);
259 | }
260 |
261 | if (! \is_array($response)) {
262 | throw new Exceptions\AuthorizeFailedException('Invalid token response', [$response]);
263 | }
264 |
265 | if (empty($response[$this->accessTokenKey])) {
266 | throw new Exceptions\AuthorizeFailedException('Authorize Failed: '.Utils::jsonEncode($response, \JSON_UNESCAPED_UNICODE), $response);
267 | }
268 |
269 | return $response + [
270 | Contracts\RFC6749_ABNF_ACCESS_TOKEN => $response[$this->accessTokenKey],
271 | Contracts\RFC6749_ABNF_REFRESH_TOKEN => $response[$this->refreshTokenKey] ?? null,
272 | Contracts\RFC6749_ABNF_EXPIRES_IN => \intval($response[$this->expiresInKey] ?? 0),
273 | ];
274 | }
275 |
276 | protected function fromJsonBody(MessageInterface $response): array
277 | {
278 | $result = Utils::jsonDecode((string) $response->getBody(), true);
279 |
280 | \is_array($result) || throw new Exceptions\InvalidArgumentException('Decoded the given response payload failed.');
281 |
282 | return $result;
283 | }
284 | }
285 |
--------------------------------------------------------------------------------
/tests/Providers/DouYinTest.php:
--------------------------------------------------------------------------------
1 | 'client_id',
21 | 'client_secret' => 'client_secret',
22 | 'redirect_url' => 'http://localhost/callback',
23 | ]);
24 |
25 | $response = $provider->redirect();
26 |
27 | $this->assertStringStartsWith('https://open.douyin.com/platform/oauth/connect/', $response);
28 | $this->assertStringContainsString('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback', $response);
29 | $this->assertStringContainsString('client_key=client_id', $response);
30 | $this->assertStringContainsString('response_type=code', $response);
31 | $this->assertStringContainsString('scope=user_info', $response);
32 | }
33 |
34 | public function test_dou_yin_provider_urls_and_fields()
35 | {
36 | $provider = new DouYin([
37 | 'client_id' => 'client_id',
38 | 'client_secret' => 'client_secret',
39 | 'redirect_url' => 'http://localhost/callback',
40 | ]);
41 |
42 | $getTokenUrl = new ReflectionMethod(DouYin::class, 'getTokenUrl');
43 | $getTokenUrl->setAccessible(true);
44 |
45 | $getTokenFields = new ReflectionMethod(DouYin::class, 'getTokenFields');
46 | $getTokenFields->setAccessible(true);
47 |
48 | $this->assertSame('https://open.douyin.com/oauth/access_token/', $getTokenUrl->invoke($provider));
49 |
50 | $this->assertSame([
51 | 'client_key' => 'client_id',
52 | 'client_secret' => 'client_secret',
53 | 'code' => 'test_code',
54 | 'grant_type' => 'authorization_code',
55 | ], $getTokenFields->invoke($provider, 'test_code'));
56 |
57 | $this->assertSame([
58 | 'client_key' => 'client_id',
59 | 'redirect_uri' => 'http://localhost/callback',
60 | 'scope' => 'user_info',
61 | 'response_type' => 'code',
62 | ], $provider->getCodeFields());
63 | }
64 |
65 | public function test_with_open_id_method()
66 | {
67 | $provider = new DouYin([
68 | 'client_id' => 'client_id',
69 | 'client_secret' => 'client_secret',
70 | 'redirect_url' => 'http://localhost/callback',
71 | ]);
72 |
73 | $result = $provider->withOpenId('test_openid');
74 | $this->assertInstanceOf(DouYin::class, $result);
75 | $this->assertSame($provider, $result);
76 | }
77 |
78 | public function test_token_from_code_success()
79 | {
80 | $provider = new DouYin([
81 | 'client_id' => 'client_id',
82 | 'client_secret' => 'client_secret',
83 | 'redirect_url' => 'http://localhost/callback',
84 | ]);
85 |
86 | $mock = new MockHandler([
87 | new Response(200, [], '{"data": {"error_code": 0, "access_token": "token123", "open_id": "openid123"}}'),
88 | ]);
89 |
90 | $handler = HandlerStack::create($mock);
91 | $client = new Client(['handler' => $handler]);
92 |
93 | $reflection = new \ReflectionObject($provider);
94 | $httpClientProperty = $reflection->getProperty('httpClient');
95 | $httpClientProperty->setAccessible(true);
96 | $httpClientProperty->setValue($provider, $client);
97 |
98 | $token = $provider->tokenFromCode('test_code');
99 |
100 | $this->assertArrayHasKey('access_token', $token);
101 | $this->assertSame('token123', $token['access_token']);
102 | }
103 |
104 | public function test_throws_exception_when_open_id_missing()
105 | {
106 | $provider = new DouYin([
107 | 'client_id' => 'client_id',
108 | 'client_secret' => 'client_secret',
109 | 'redirect_url' => 'http://localhost/callback',
110 | ]);
111 |
112 | $mock = new MockHandler([
113 | new Response(200, [], '{"data": {"error_code": 0, "access_token": "token123"}}'), // Missing open_id
114 | ]);
115 |
116 | $handler = HandlerStack::create($mock);
117 | $client = new Client(['handler' => $handler]);
118 |
119 | $reflection = new \ReflectionObject($provider);
120 | $httpClientProperty = $reflection->getProperty('httpClient');
121 | $httpClientProperty->setAccessible(true);
122 | $httpClientProperty->setValue($provider, $client);
123 |
124 | $this->expectException(AuthorizeFailedException::class);
125 | $this->expectExceptionMessage('Authorization failed: missing open_id in token response');
126 |
127 | $provider->tokenFromCode('test_code');
128 | }
129 |
130 | public function test_throws_exception_when_data_missing()
131 | {
132 | $provider = new DouYin([
133 | 'client_id' => 'client_id',
134 | 'client_secret' => 'client_secret',
135 | 'redirect_url' => 'http://localhost/callback',
136 | ]);
137 |
138 | $mock = new MockHandler([
139 | new Response(200, [], '{"error": "missing data"}'), // Missing data
140 | ]);
141 |
142 | $handler = HandlerStack::create($mock);
143 | $client = new Client(['handler' => $handler]);
144 |
145 | $reflection = new \ReflectionObject($provider);
146 | $httpClientProperty = $reflection->getProperty('httpClient');
147 | $httpClientProperty->setAccessible(true);
148 | $httpClientProperty->setValue($provider, $client);
149 |
150 | $this->expectException(AuthorizeFailedException::class);
151 | $this->expectExceptionMessage('Invalid token response');
152 |
153 | $provider->tokenFromCode('test_code');
154 | }
155 |
156 | public function test_throws_exception_when_error_code_non_zero()
157 | {
158 | $provider = new DouYin([
159 | 'client_id' => 'client_id',
160 | 'client_secret' => 'client_secret',
161 | 'redirect_url' => 'http://localhost/callback',
162 | ]);
163 |
164 | $mock = new MockHandler([
165 | new Response(200, [], '{"data": {"error_code": 1, "description": "error occurred"}}'),
166 | ]);
167 |
168 | $handler = HandlerStack::create($mock);
169 | $client = new Client(['handler' => $handler]);
170 |
171 | $reflection = new \ReflectionObject($provider);
172 | $httpClientProperty = $reflection->getProperty('httpClient');
173 | $httpClientProperty->setAccessible(true);
174 | $httpClientProperty->setValue($provider, $client);
175 |
176 | $this->expectException(AuthorizeFailedException::class);
177 | $this->expectExceptionMessage('Invalid token response');
178 |
179 | $provider->tokenFromCode('test_code');
180 | }
181 |
182 | public function test_get_user_by_token_success()
183 | {
184 | $provider = new DouYin([
185 | 'client_id' => 'client_id',
186 | 'client_secret' => 'client_secret',
187 | 'redirect_url' => 'http://localhost/callback',
188 | ]);
189 |
190 | $mock = new MockHandler([
191 | new Response(200, [], '{"data": {"nickname": "Test User", "avatar": "http://avatar.url", "open_id": "test_openid"}}'),
192 | ]);
193 |
194 | $handler = HandlerStack::create($mock);
195 | $client = new Client(['handler' => $handler]);
196 |
197 | $reflection = new \ReflectionObject($provider);
198 | $httpClientProperty = $reflection->getProperty('httpClient');
199 | $httpClientProperty->setAccessible(true);
200 | $httpClientProperty->setValue($provider, $client);
201 |
202 | // Set openId first
203 | $provider->withOpenId('test_openid');
204 |
205 | $getUserByToken = new ReflectionMethod(DouYin::class, 'getUserByToken');
206 | $getUserByToken->setAccessible(true);
207 | $result = $getUserByToken->invoke($provider, 'test_token');
208 |
209 | $this->assertArrayHasKey('open_id', $result);
210 | $this->assertSame('test_openid', $result['open_id']);
211 | $this->assertArrayHasKey('nickname', $result);
212 | $this->assertSame('Test User', $result['nickname']);
213 | }
214 |
215 | public function test_get_user_by_token_throws_exception_when_open_id_empty()
216 | {
217 | $provider = new DouYin([
218 | 'client_id' => 'client_id',
219 | 'client_secret' => 'client_secret',
220 | 'redirect_url' => 'http://localhost/callback',
221 | ]);
222 |
223 | $this->expectException(InvalidArgumentException::class);
224 | $this->expectExceptionMessage('please set the `open_id` before issue the API request.');
225 |
226 | $getUserByToken = new ReflectionMethod(DouYin::class, 'getUserByToken');
227 | $getUserByToken->setAccessible(true);
228 | $getUserByToken->invoke($provider, 'test_token');
229 | }
230 |
231 | public function test_map_user_to_object()
232 | {
233 | $provider = new DouYin([
234 | 'client_id' => 'client_id',
235 | 'client_secret' => 'client_secret',
236 | 'redirect_url' => 'http://localhost/callback',
237 | ]);
238 |
239 | $mapUserToObject = new ReflectionMethod(DouYin::class, 'mapUserToObject');
240 | $mapUserToObject->setAccessible(true);
241 |
242 | $user = [
243 | 'open_id' => 'test_openid',
244 | 'nickname' => 'Test User',
245 | 'avatar' => 'http://avatar.url',
246 | 'email' => 'test@example.com',
247 | ];
248 |
249 | $result = $mapUserToObject->invoke($provider, $user);
250 |
251 | $this->assertSame('test_openid', $result->getId());
252 | $this->assertSame('Test User', $result->getName());
253 | $this->assertSame('Test User', $result->getNickname());
254 | $this->assertSame('http://avatar.url', $result->getAvatar());
255 | $this->assertSame('test@example.com', $result->getEmail());
256 | }
257 | }
258 |
--------------------------------------------------------------------------------