diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04ee5f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +.phpunit.result.cache +.idea/ diff --git a/LICENSE b/LICENSE index 0969233..fb6eb5d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Galette Community +Copyright (©) 2024 The Galette Team Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d545a4 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Galette Provider for OAuth 2.0 Client + +This package provides Galette OAuth 2.0 support for the PHP League's [OAuth 2.0 Client](https://github.com/thephpleague/oauth2-client). + +The [Galette OAuth plugin](https://galette-community.github.io/plugin-oauth2/) must be installed on you [Galette](https://galette.eu) instance. + +## Installation + +```cmd +composer require galette-community/oauth2-galette +``` + +## Usage + +```php +$galetteProvider = new \Galette\OAuth2\Client\Provider\Galette([ + //information related to the app where you will use galette-oauth2 + 'clientId' => 'yourId', // The client ID assigned to you + 'clientSecret' => 'yourSecret', // The client password assigned to you + 'redirectUri' => 'yourRedirectUri', // The return URL you specified for your app + //information related to the galette instance you want to connect to + 'instance' => 'yourInstance', // The instance of Galette you want to connect to + 'pluginDir' => 'yourPluginDir', // The directory where the plugin is installed - defaults to 'plugin-oauth2' +]); + +// Get authorization code +if (!isset($_GET['code'])) { + // Options are optional, defaults to 'read_prefs' only + $options = ['instance' => 'https://my.galette']; + + // Get authorization URL + $authorizationUrl = $galetteProvider->getAuthorizationUrl($options); + + // Get state and store it to the session + $_SESSION['oauth2state'] = $galetteProvider->getState(); + + // Redirect user to authorization URL + header('Location: ' . $authorizationUrl); + exit; +// Check for errors +} elseif (empty($_GET['state']) || (isset($_SESSION['oauth2state']) && $_GET['state'] !== $_SESSION['oauth2state'])) { + if (isset($_SESSION['oauth2state'])) { + unset($_SESSION['oauth2state']); + } + exit('Invalid state'); +} else { + // Get access token + try { + $accessToken = $galetteProvider->getAccessToken( + 'authorization_code', + [ + 'code' => $_GET['code'] + ] + ); + } catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) { + exit($e->getMessage()); + } + + // Get resource owner + try { + $resourceOwner = $galetteProvider->getResourceOwner($accessToken); + } catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) { + exit($e->getMessage()); + } + + // Now you can store the results to session etc. + $_SESSION['accessToken'] = $accessToken; + $_SESSION['resourceOwner'] = $resourceOwner; + + var_dump( + $resourceOwner->getId(), + $resourceOwner->getEmail(), + $resourceOwner->getUsername(), + $resourceOwner->getLang(), + $resourceOwner->getStatus(), + $resourceOwner->toArray() + ); +} +``` + +For more information see the PHP League's general usage examples. + +## Testing + +``` bash +./vendor/bin/phpunit +``` + +## License + +The MIT License (MIT). Please see [License File](https://github.com/jbelien/oauth2-openstreetmap/blob/master/LICENSE) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0b0c0e8 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "galette/oauth2-galette", + "description": "Galette OAuth 2.0 support for the PHP League's OAuth 2.0 client", + "license": "MIT", + "authors": [ + { + "name": "Johan Cwiklinski", + "homepage": "https://github.com/trashy" + } + ], + "keywords": [ + "oauth", + "oauth2", + "client", + "authorization", + "authorisation", + "galette" + ], + "require": { + "php": "^8.1", + "league/oauth2-client": "^2.7" + }, + "autoload": { + "psr-4": { + "Galette\\OAuth2\\Client\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Galette\\OAuth2\\Client\\Test\\": "tests/src/" + } + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.8", + "mockery/mockery": "^1.6" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..092fa61 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + ./tests/ + + + diff --git a/src/Provider/Galette.php b/src/Provider/Galette.php new file mode 100644 index 0000000..fffcdd4 --- /dev/null +++ b/src/Provider/Galette.php @@ -0,0 +1,101 @@ +instance = $options['instance']; + + if (isset($options['scope'])) { + $this->scope = $options['scope']; + } + + if (isset($options['pluginDir'])) { + $this->pluginDirectory = $options['pluginDir']; + } + + parent::__construct($options, $collaborators); + } + + public function getBaseURL(): string + { + return sprintf( + '%s/plugins/%s', + trim($this->instance, '/'), + $this->pluginDirectory + ); + } + + public function getBaseAuthorizationUrl() + { + return $this->getBaseURL() . '/authorize'; + } + + public function getBaseAccessTokenUrl(array $params) + { + return $this->getBaseURL() . '/access_token'; + } + + public function getResourceOwnerDetailsUrl(AccessToken $token) + { + return $this->getBaseURL() . '/user'; + } + + protected function getDefaultScopes() + { + return []; + } + + protected function checkResponse(ResponseInterface $response, $data) + { + $statusCode = $response->getStatusCode(); + if ($statusCode > 400) { + throw new IdentityProviderException( + $data['message'] ?: $response->getReasonPhrase(), + $statusCode, + $response + ); + } + } + + protected function createResourceOwner(array $response, AccessToken $token) + { + return new GaletteResourceOwner($response); + } +} diff --git a/src/Provider/GaletteResourceOwner.php b/src/Provider/GaletteResourceOwner.php new file mode 100644 index 0000000..dcbef0c --- /dev/null +++ b/src/Provider/GaletteResourceOwner.php @@ -0,0 +1,82 @@ + + */ + protected array $response; + + public function __construct(array $response = []) + { + $this->response = $response; + } + + /** + * Get resource owner id + * + * @return int|null + */ + public function getId(): ?int + { + return $this->getValueByKey($this->response, 'id'); + } + + /** + * Get resource owner email + * + * @return string|null + */ + public function getEmail(): ?string + { + return $this->getValueByKey($this->response, 'email'); + } + + /** + * Get resource owner username + * + * @return string|null + */ + public function getUsername(): ?string + { + return $this->getValueByKey($this->response, 'username'); + } + + /** + * Get resource owner language + * + * @return string|null + */ + public function getLang(): ?string + { + return $this->getValueByKey($this->response, 'language'); + } + + /** + * Get resource owner membership status + * + * @return int|null + */ + public function getStatus(): ?int + { + return (int)$this->getValueByKey($this->response, 'status'); + } + + + /** + * Return all owner details available as an array. + * + * @return array + */ + public function toArray(): array + { + return $this->response; + } +} \ No newline at end of file diff --git a/tests/src/Provider/GaletteTest.php b/tests/src/Provider/GaletteTest.php new file mode 100644 index 0000000..39e5c15 --- /dev/null +++ b/tests/src/Provider/GaletteTest.php @@ -0,0 +1,202 @@ +provider = new \Galette\OAuth2\Client\Provider\Galette([ + 'clientId' => 'mock_client_id', + 'clientSecret' => 'mock_secret', + 'redirectUri' => 'mock_redirect_uri', + 'instance' => $this->instance, + 'pluginDir' => $this->pluginDir, + ]); + } + + public function testAuthorizationUrl() + { + $url = $this->provider->getAuthorizationUrl(); + $uri = parse_url($url); + parse_str($uri['query'], $query); + + $this->assertArrayHasKey('client_id', $query); + $this->assertArrayHasKey('response_type', $query); + $this->assertArrayHasKey('redirect_uri', $query); + $this->assertNotNull($this->provider->getState()); + } + + public function testGetBaseAccessTokenUrl() + { + $params = []; + + $url = $this->provider->getBaseAccessTokenUrl($params); + $uri = parse_url($url); + + $this->assertEquals('/galette/plugins/oauth2Plugin/access_token', $uri['path']); + } + + public function testGetAuthorizationUrl() + { + $url = $this->provider->getAuthorizationUrl(); + $uri = parse_url($url); + + $this->assertEquals('/galette/plugins/oauth2Plugin/authorize', $uri['path']); + } + + public function testGetAccessToken() + { + $response = m::mock('Psr\Http\Message\ResponseInterface'); + //$response->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "token_type": "bearer", "account_id": "12345", "uid": "deprecated_id"}'); + $response + ->shouldReceive('getBody') + ->andReturn( + new Stream( + fopen( + 'data://text/plain,{"access_token": "mock_access_token"}', + 'r' + ) + ) + ); + $response + ->shouldReceive('getStatusCode') + ->andReturn(200); + + $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send')->times(1)->andReturn($response); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $this->assertEquals('mock_access_token', $token->getToken()); + } + + public function testUserData() + { + $userId = 42; + $username = 'mock_username'; + $email = 'mock_mail@mock_domain.com'; + $lang = 'fr_FR'; + $status = 1; + + $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $postResponse + ->shouldReceive('getBody') + ->andReturn( + new Stream( + fopen( + 'data://text/plain,{"access_token": "mock_access_token"}', + 'r' + ) + ) + ); + + $postResponse + ->shouldReceive('getStatusCode') + ->andReturn(200); + + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + + $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $userResponse + ->shouldReceive('getBody') + ->andReturn( + new Stream( + fopen( + 'data://text/plain,{"id": '.$userId.', "username": "'.$username.'", "email": "'.$email.'", "language": "'.$lang.'", "status": '.$status.'}', + 'r' + ) + ) + ); + + $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn(200); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send') + ->times(2) + ->andReturn($postResponse, $userResponse); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $user = $this->provider->getResourceOwner($token); + + $this->assertEquals($userId, $user->getId()); + $this->assertEquals($email, $user->getEmail()); + $this->assertEquals($username, $user->getUsername()); + $this->assertEquals($lang, $user->getLang()); + $this->assertEquals($status, $user->getStatus()); + + $this->assertSame( + [ + 'id' => $userId, + 'username' => $username, + 'email' => $email, + 'language' => $lang, + 'status' => $status, + ], + $user->toArray() + ); + } + + public function testUserDataFails() + { + $this->expectException(\League\OAuth2\Client\Provider\Exception\IdentityProviderException::class); + $status = rand(400,600); + + $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $postResponse + ->shouldReceive('getBody') + ->andReturn( + new Stream( + fopen( + 'data://text/plain,{"access_token": "mock_access_token"}', + 'r' + ) + ) + ); + + $postResponse + ->shouldReceive('getStatusCode') + ->andReturn(200); + + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + + $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $userResponse + ->shouldReceive('getBody') + ->andReturn( + new Stream( + fopen( + 'data://text/plain,{"error": "invalid_request","message": "Unknown request"}', + 'r' + ) + ) + ); + $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn($status); + $userResponse->shouldReceive('getReasonPhrase')->andReturn('Unknown request'); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send') + ->times(2) + ->andReturn($postResponse, $userResponse); + $this->provider->setHttpClient($client); + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $user = $this->provider->getResourceOwner($token); + } +}