├── .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 | --------------------------------------------------------------------------------