diff --git a/composer.json b/composer.json index 05b561ca1..53c08b3d2 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "symfony/yaml": "^7.3", "tempest/highlight": "^2.11.4", "vlucas/phpdotenv": "^5.6.1", - "voku/portable-ascii": "^2.0.3" + "voku/portable-ascii": "^2.0.3", + "league/oauth2-client": "^2.8" }, "require-dev": { "aws/aws-sdk-php": "^3.338.0", diff --git a/packages/auth/src/OAuth/BuiltInOAuthProvider.php b/packages/auth/src/OAuth/BuiltInOAuthProvider.php new file mode 100644 index 000000000..4da838bb3 --- /dev/null +++ b/packages/auth/src/OAuth/BuiltInOAuthProvider.php @@ -0,0 +1,37 @@ + + */ + public function providerClass(): string + { + return match ($this) { + self::GITHUB => GithubProvider::class, + self::GOOGLE => GoogleProvider::class, + }; + } + + public static function fromProviderClass(string $providerClass): ?self + { + return match ($providerClass) { + GithubProvider::class => self::GITHUB, + GoogleProvider::class => self::GOOGLE, + default => null, + }; + } +} diff --git a/packages/auth/src/OAuth/DataObjects/AccessToken.php b/packages/auth/src/OAuth/DataObjects/AccessToken.php new file mode 100644 index 000000000..1794c4d3d --- /dev/null +++ b/packages/auth/src/OAuth/DataObjects/AccessToken.php @@ -0,0 +1,62 @@ +to(self::class); + } + + public static function fromLeagueAccessToken(AccessTokenInterface $accessToken): self + { + return new self( + accessToken: $accessToken->getToken(), + tokenType: $accessToken->getValues()['token_type'] ?? 'Bearer', + scope: $accessToken->getValues()['scope'] ?? '', + expiresIn: $accessToken->getExpires() + ? ($accessToken->getExpires() - time()) + : null, + refreshToken: $accessToken->getRefreshToken(), + additionalInformations: $accessToken->getValues() ?: null, + ); + } + + public function toLeagueAccessToken(): LeagueAccessToken + { + return new LeagueAccessToken([ + 'access_token' => $this->accessToken, + 'refresh_token' => $this->refreshToken, + 'expires_in' => $this->expiresIn, + ...$this->additionalInformations, + ]); + } +} diff --git a/packages/auth/src/OAuth/DataObjects/OAuthUserData.php b/packages/auth/src/OAuth/DataObjects/OAuthUserData.php new file mode 100644 index 000000000..c9c7437d6 --- /dev/null +++ b/packages/auth/src/OAuth/DataObjects/OAuthUserData.php @@ -0,0 +1,38 @@ + $rawData, + ...$rawData, + ]) + ->to(self::class); + } +} diff --git a/packages/auth/src/OAuth/Exceptions/InvalidCodeException.php b/packages/auth/src/OAuth/Exceptions/InvalidCodeException.php new file mode 100644 index 000000000..cbf0f6f11 --- /dev/null +++ b/packages/auth/src/OAuth/Exceptions/InvalidCodeException.php @@ -0,0 +1,9 @@ +implements(OAuthProvider::class) || $class->is(OAuthProvider::class)) && $tag === 'from_env_variables'; + } + + public function initialize(ClassReflector $class, UnitEnum|string|null $tag, Container $container): OAuthProvider + { + try { + $providerType = BuiltInOAuthProvider::fromProviderClass($class->getName()) ?? + throw new OAuthException(sprintf('No built-in OAuth2 provider found for class: "%s"', $class->getName())); + + return match ($providerType) { + BuiltInOAuthProvider::GOOGLE => new GoogleProvider( + session: $container->get(Session::class), + )->configure( + clientId: env('GOOGLE_CLIENT_ID'), + clientSecret: env('GOOGLE_CLIENT_SECRET'), + redirectUri: env('GOOGLE_REDIRECT_URI'), + ), + BuiltInOAuthProvider::GITHUB => new GithubProvider( + session: $container->get(Session::class), + )->configure( + clientId: env('GITHUB_CLIENT_ID'), + clientSecret: env('GITHUB_CLIENT_SECRET'), + redirectUri: env('GITHUB_REDIRECT_URI'), + ), + default => throw new OAuthException(sprintf('Cannot initialize "%s" built-in OAuth2 provider', $providerType->name)), + }; + } catch (TypeError $exception) { + throw new OAuthException( + sprintf('Failed to initialize OAuth2 provider for "%s". Ensure that the environment variables are set correctly.', $class->getName()), + previous: $exception, + ); + } + } +} diff --git a/packages/auth/src/OAuth/IsOauthProvider.php b/packages/auth/src/OAuth/IsOauthProvider.php new file mode 100644 index 000000000..6b56c9a07 --- /dev/null +++ b/packages/auth/src/OAuth/IsOauthProvider.php @@ -0,0 +1,138 @@ + $defaultScopes + */ + protected function configureInternalProvider( + string $clientId, + string $clientSecret, + string $redirectUri, + array $defaultScopes, + string $authorizeEndpoint, + string $accessTokenEndpoint, + string $userDataEndpoint, + string $stateSessionSlug = 'oauth-state', + ): static { + $this->clientId = $clientId; + $this->clientSecret = $clientSecret; + $this->redirectUri = $redirectUri; + $this->defaultScopes = $defaultScopes; + $this->authorizeEndpoint = $authorizeEndpoint; + $this->accessTokenEndpoint = $accessTokenEndpoint; + $this->userDataEndpoint = $userDataEndpoint; + $this->stateSessionSlug = $stateSessionSlug; + + $this->internalProvider = new GenericProvider([ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + 'redirectUri' => $this->redirectUri, + 'urlAuthorize' => $this->authorizeEndpoint, + 'urlAccessToken' => $this->accessTokenEndpoint, + 'urlResourceOwnerDetails' => $this->userDataEndpoint, + ]); + + return $this; + } + + /** + * @param array|null $additionalParameters Additional parameters to include in the authorization URL. + * @param array|null $scopes Scopes to request. If null, the default scopes will be used. + * @param string|null $state A state parameter to include in the authorization URL. If null, a random state will be generated. + */ + public function generateAuthorizationUrl( + array $additionalParameters = [], + ?array $scopes = null, + ?string $state = null, + ?string $scopeSeparator = ' ', + ): string { + $scopes ??= $this->defaultScopes; + $state ??= $this->generateState(); + + $this->session->flash($this->stateSessionSlug, $state); + + return $this->internalProvider->getAuthorizationUrl([ + 'scope' => implode($scopeSeparator, $scopes), + 'state' => $state, + ...$additionalParameters, + ]); + } + + /** + * @param array|null $additionalParameters Additional parameters to include in the request. + */ + public function generateAccessToken( + string $code, + array $additionalParameters = [], + ): AccessToken { + try { + $token = $this->internalProvider->getAccessToken( + grant: 'authorization_code', + options: [ + 'code' => $code, + ...$additionalParameters, + ], + ); + + return AccessToken::fromLeagueAccessToken($token); + } catch (GuzzleException|IdentityProviderException $e) { + throw new OAuthException('Failed to get access token: ' . $e->getMessage(), previous: $e); + } + } + + public function fetchUserDataFromToken( + AccessToken $accessToken, + ): OAuthUserData { + try { + return $this->createUserDataFromResponse( + $this->internalProvider->getResourceOwner($accessToken->toLeagueAccessToken())->toArray(), + ); + } catch (GuzzleException|IdentityProviderException $e) { + throw new OAuthException('Failed to get user data: ' . $e->getMessage(), previous: $e); + } + } + + protected function generateState(): string + { + return str()->random(40)->toString(); + } + + /** + * @param array $userData The raw user data array returned by the OAuth provider. + */ + protected function createUserDataFromResponse(array $userData): OAuthUserData + { + return OAuthUserData::from($userData); + } +} diff --git a/packages/auth/src/OAuth/OAuthProvider.php b/packages/auth/src/OAuth/OAuthProvider.php new file mode 100644 index 000000000..63692c537 --- /dev/null +++ b/packages/auth/src/OAuth/OAuthProvider.php @@ -0,0 +1,66 @@ + The default scopes for the OAuth2 provider. + */ + public array $defaultScopes { + get; + } + + /** + * @var string The URL to redirect the user for authorization. + */ + public string $authorizeEndpoint { + get; + } + + /** + * @var string The URL to exchange the authorization code for an access token. + */ + public string $accessTokenEndpoint { + get; + } + + /** + * @var string The URL to fetch user data after authorization. + */ + public string $userDataEndpoint { + get; + } + + /** + * @param array|null $additionalParameters Additional parameters to include in the authorization URL. + * @param array|null $scopes Scopes to request. If null, the default scopes will be used. + * @param string|null $state A state parameter to include in the authorization URL. If null, a random state will be generated. + */ + public function generateAuthorizationUrl( + array $additionalParameters = [], + ?array $scopes = null, + ?string $state = null, + ?string $scopeSeparator = ' ', + ): string; + + /** + * @param array|null $additionalParameters Additional parameters to include in the request. + */ + public function generateAccessToken( + string $code, + array $additionalParameters = [], + ): AccessToken; + + /* + * Fetches user data from the OAuth2 provider using the provided access token. + */ + public function fetchUserDataFromToken( + AccessToken $accessToken, + ): OAuthUserData; +} diff --git a/packages/auth/src/OAuth/Providers/GenericProvider.php b/packages/auth/src/OAuth/Providers/GenericProvider.php new file mode 100644 index 000000000..fe79f210f --- /dev/null +++ b/packages/auth/src/OAuth/Providers/GenericProvider.php @@ -0,0 +1,37 @@ + $defaultScopes + */ + public function configure( + string $clientId, + string $clientSecret, + string $redirectUri, + array $defaultScopes, + string $authorizeEndpoint, + string $accessTokenEndpoint, + string $userDataEndpoint, + string $stateSessionSlug = 'oauth-state', + ): self { + return $this->configureInternalProvider( + clientId: $clientId, + clientSecret: $clientSecret, + redirectUri: $redirectUri, + defaultScopes: $defaultScopes, + authorizeEndpoint: $authorizeEndpoint, + accessTokenEndpoint: $accessTokenEndpoint, + userDataEndpoint: $userDataEndpoint, + stateSessionSlug: $stateSessionSlug, + ); + } +} diff --git a/packages/auth/src/OAuth/Providers/GithubProvider.php b/packages/auth/src/OAuth/Providers/GithubProvider.php new file mode 100644 index 000000000..a2ddaaa62 --- /dev/null +++ b/packages/auth/src/OAuth/Providers/GithubProvider.php @@ -0,0 +1,53 @@ +defaultScopes = $defaultScopes ??= ['user:email']; + $this->authorizeEndpoint = 'https://github.com/login/oauth/authorize'; + $this->accessTokenEndpoint = 'https://github.com/login/oauth/access_token'; + $this->userDataEndpoint = 'https://api.github.com/user'; + + return $this->configureInternalProvider( + clientId: $clientId, + clientSecret: $clientSecret, + defaultScopes: $defaultScopes, + redirectUri: $redirectUri, + authorizeEndpoint: $this->authorizeEndpoint, + accessTokenEndpoint: $this->accessTokenEndpoint, + userDataEndpoint: $this->userDataEndpoint, + stateSessionSlug: $stateSessionSlug, + ); + } + + /** + * @inheritDoc + */ + protected function createUserDataFromResponse(array $userData): OAuthUserData + { + return new OAuthUserData( + id: $userData['id'] ?? null, + nickname: $userData['login'] ?? null, + name: $userData['name'] ?? null, + email: $userData['email'] ?? null, + avatar: $userData['avatar_url'] ?? null, + rawData: $userData, + ); + } +} diff --git a/packages/auth/src/OAuth/Providers/GoogleProvider.php b/packages/auth/src/OAuth/Providers/GoogleProvider.php new file mode 100644 index 000000000..4d5c8d11d --- /dev/null +++ b/packages/auth/src/OAuth/Providers/GoogleProvider.php @@ -0,0 +1,53 @@ +defaultScopes = $defaultScopes ??= ['openid', 'email', 'profile']; + $this->authorizeEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth'; + $this->accessTokenEndpoint = 'https://oauth2.googleapis.com/token'; + $this->userDataEndpoint = 'https://openidconnect.googleapis.com/v1/userinfo'; + + return $this->configureInternalProvider( + clientId: $clientId, + clientSecret: $clientSecret, + defaultScopes: $defaultScopes, + redirectUri: $redirectUri, + authorizeEndpoint: $this->authorizeEndpoint, + accessTokenEndpoint: $this->accessTokenEndpoint, + userDataEndpoint: $this->userDataEndpoint, + stateSessionSlug: $stateSessionSlug, + ); + } + + /** + * @inheritDoc + */ + protected function createUserDataFromResponse(array $userData): OAuthUserData + { + return new OAuthUserData( + id: $userData['sub'] ?? null, + nickname: $userData['given_name'] ?? null, + name: $userData['family_name'] ?? null, + email: $userData['email'] ?? null, + avatar: $userData['picture'] ?? null, + rawData: $userData, + ); + } +} diff --git a/tests/Fixtures/Modules/Form/form.view.php b/tests/Fixtures/Modules/Form/form.view.php index 682652896..36e2c986f 100644 --- a/tests/Fixtures/Modules/Form/form.view.php +++ b/tests/Fixtures/Modules/Form/form.view.php @@ -11,6 +11,7 @@ hasErrors()) { + ?>