Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
61d11ad
:construction: WIP OAuth 2 redirect test
gturpin-dev Jul 4, 2025
314aa42
:sparkles: Configures Github SSO authentication flow for redirect part
gturpin-dev Jul 9, 2025
5b74663
:recycle: Refactors SSO to OAuth for better abstraction
gturpin-dev Jul 9, 2025
99db3a4
:sparkles: Implements OAuth2 Access token and user data fetching
gturpin-dev Jul 10, 2025
4faf7eb
:art: Coding styles
gturpin-dev Aug 6, 2025
db3ae0b
:fire: Remove useless test
gturpin-dev Aug 6, 2025
0ef78c8
:art: Coding styles
gturpin-dev Aug 6, 2025
1b8241d
:sparkles: Enhances OAuth functionality and adds Google provider
gturpin-dev Aug 13, 2025
46ad739
:sparkles: Adds OAuth state handling
gturpin-dev Aug 14, 2025
19c8811
:fire: Remove useless code
gturpin-dev Aug 14, 2025
445b4cb
:wrench: Refactors OAuth provider interface
gturpin-dev Aug 14, 2025
09c1cf9
:recycle: Use proper DI
gturpin-dev Aug 14, 2025
2bf9856
:bug: Fixes session flash key name
gturpin-dev Aug 14, 2025
134c9d5
:lock: Exposes state session slug
gturpin-dev Aug 14, 2025
cc3fe3b
✨ Adds Initializers for built-in providers and manager
gturpin-dev Aug 14, 2025
a11acaf
:construction: WIP wrap League GenericProvider
gturpin-dev Aug 27, 2025
d88080c
:construction: WIP old google provider
gturpin-dev Sep 17, 2025
84539e8
:construction: WIP League providers
gturpin-dev Sep 17, 2025
51bce70
:sparkles: Abstract things into a trait and add a specific GoogleProv…
gturpin-dev Sep 17, 2025
93396f9
:recycle: Properly handles userData fetching
gturpin-dev Sep 17, 2025
40f5ead
:sparkles: Create GithubProvider
gturpin-dev Sep 17, 2025
c9361cd
:recycle: Rework BuiltInInitializer and Cleanup
gturpin-dev Sep 17, 2025
4ef546e
:art: Coding styles
gturpin-dev Sep 17, 2025
7904749
:art: Reorder params
gturpin-dev Sep 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions packages/auth/src/OAuth/BuiltInOAuthProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth;

use Tempest\Auth\OAuth\Providers\GithubProvider;
use Tempest\Auth\OAuth\Providers\GoogleProvider;
use Tempest\Support\IsEnumHelper;

enum BuiltInOAuthProvider: string
{
use IsEnumHelper;

case GITHUB = 'github';
case GOOGLE = 'google';

/**
* @return class-string<OAuthProvider>
*/
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,
};
}
}
62 changes: 62 additions & 0 deletions packages/auth/src/OAuth/DataObjects/AccessToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth\DataObjects;

use League\OAuth2\Client\Token\AccessToken as LeagueAccessToken;
use League\OAuth2\Client\Token\AccessTokenInterface;
use Tempest\Mapper\MapFrom;

use function Tempest\map;

final readonly class AccessToken
{
public function __construct(
#[MapFrom('access_token')]
public string $accessToken,

#[MapFrom('token_type')]
public string $tokenType,

public string $scope,

#[MapFrom('expires_in')]
public ?int $expiresIn = null,

#[MapFrom('refresh_token')]
public ?string $refreshToken = null,

#[MapFrom('additional_informations')]
public ?array $additionalInformations = null,
) {}

public static function from(array $data): self
{
return map($data)->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,
]);
}
}
38 changes: 38 additions & 0 deletions packages/auth/src/OAuth/DataObjects/OAuthUserData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth\DataObjects;

use Tempest\Mapper\MapFrom;

use function Tempest\map;

final readonly class OAuthUserData
{
public function __construct(
#[MapFrom('ID', 'Id', 'sub', 'user_id')]
public int|string|null $id = null,

#[MapFrom('nickname', 'nick_name', 'login', 'username', 'user_name', 'preferred_username', 'given_name')]
public ?string $nickname = null,

#[MapFrom('name', 'full_name', 'fullName', 'display_name', 'displayName', 'family_name')]
public ?string $name = null,

public ?string $email = null,

#[MapFrom('avatar', 'avatar_url', 'picture', 'profile_image_url')]
public ?string $avatar = null,
public array $rawData = [],
) {}

public static function from(array $rawData): self
{
return map([
'rawData' => $rawData,
...$rawData,
])
->to(self::class);
}
}
9 changes: 9 additions & 0 deletions packages/auth/src/OAuth/Exceptions/InvalidCodeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth\Exceptions;

final class InvalidCodeException extends OAuthException
{
}
9 changes: 9 additions & 0 deletions packages/auth/src/OAuth/Exceptions/InvalidStateException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth\Exceptions;

final class InvalidStateException extends OAuthException
{
}
11 changes: 11 additions & 0 deletions packages/auth/src/OAuth/Exceptions/OAuthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth\Exceptions;

use RuntimeException;

class OAuthException extends RuntimeException
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth\Initializers;

use Tempest\Auth\OAuth\BuiltInOAuthProvider;
use Tempest\Auth\OAuth\Exceptions\OAuthException;
use Tempest\Auth\OAuth\OAuthProvider;
use Tempest\Auth\OAuth\Providers\GithubProvider;
use Tempest\Auth\OAuth\Providers\GoogleProvider;
use Tempest\Container\Container;
use Tempest\Container\DynamicInitializer;
use Tempest\Http\Session\Session;
use Tempest\Reflection\ClassReflector;
use TypeError;
use UnitEnum;

use function \Tempest\env;

final class BuiltInOAuthProviderInitializer implements DynamicInitializer
{
public function canInitialize(ClassReflector $class, UnitEnum|string|null $tag): bool
{
return ($class->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,
);
}
}
}
138 changes: 138 additions & 0 deletions packages/auth/src/OAuth/IsOauthProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

declare(strict_types=1);

namespace Tempest\Auth\OAuth;

use GuzzleHttp\Exception\GuzzleException;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Provider\GenericProvider;
use Tempest\Auth\OAuth\DataObjects\AccessToken;
use Tempest\Auth\OAuth\DataObjects\OAuthUserData;
use Tempest\Auth\OAuth\Exceptions\OAuthException;
use Tempest\Http\Session\Session;

use function Tempest\Support\str;

trait IsOauthProvider
{
// TODO : Should be #[Inject] property, but can't resolve chain in Initializers yet
public function __construct(
private readonly Session $session,
) {}

private GenericProvider $internalProvider;

public protected(set) string $clientId;
public protected(set) string $clientSecret;
public protected(set) string $redirectUri;
public protected(set) array $defaultScopes;
public protected(set) string $authorizeEndpoint;
public protected(set) string $accessTokenEndpoint;
public protected(set) string $userDataEndpoint;
public protected(set) string $stateSessionSlug;

/**
* @param array<string> $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<string, mixed>|null $additionalParameters Additional parameters to include in the authorization URL.
* @param array<string>|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<string, mixed>|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<string, mixed> $userData The raw user data array returned by the OAuth provider.
*/
protected function createUserDataFromResponse(array $userData): OAuthUserData
{
return OAuthUserData::from($userData);
}
}
Loading
Loading