Skip to content

Commit

Permalink
Add options to handle other oAuth2 servers, e.g. Google (#13)
Browse files Browse the repository at this point in the history
* Change cookie domain to accept empty value

* Allow to have null in cookie domain

* Configure scope delimiter

* Allow to authenticate user by SSO email

* Rename method `findOneByEmail` to `findOneBySsoEmail`

* Accept opaque oAuth2 access tokens

* Store access token to cache on first request
  • Loading branch information
pulzarraider authored Sep 12, 2023
1 parent 4094053 commit 50bea95
Show file tree
Hide file tree
Showing 11 changed files with 410 additions and 30 deletions.
4 changes: 2 additions & 2 deletions src/Configuration/CookieConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
final class CookieConfiguration
{
public function __construct(
private readonly string $domain,
private readonly ?string $domain,
private readonly bool $secure,
private readonly string $jwtPayloadCookieName,
private readonly string $jwtSignatureCookieName,
Expand All @@ -18,7 +18,7 @@ public function __construct(
) {
}

public function getDomain(): string
public function getDomain(): ?string
{
return $this->domain;
}
Expand Down
15 changes: 13 additions & 2 deletions src/Configuration/OAuth2Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public function __construct(
private readonly string $ssoClientSecret,
private readonly string $ssoPublicCert,
private readonly array $ssoScopes,
private readonly string $ssoScopeDelimiter,
private readonly bool $considerAccessTokenAsJwt,
private readonly CacheItemPoolInterface $accessTokenCachePool,
) {
}
Expand All @@ -36,8 +38,12 @@ public function getSsoAuthorizeUrl(): string
return $this->ssoAuthorizeUrl;
}

public function getSsoUserInfoUrl(string $userId): string
public function getSsoUserInfoUrl(?string $userId): string
{
if (!$userId) {
return $this->ssoUserInfoUrl;
}

return str_replace(self::SSO_USER_ID_PLACEHOLDER_URL, $userId, $this->ssoUserInfoUrl);
}

Expand All @@ -57,7 +63,7 @@ public function getResolvedSsoAuthorizeUrl(string $state): string
'response_type' => 'code',
'state' => $state,
'redirect_uri' => $this->getSsoRedirectUrl(),
'scope' => implode(',', $this->getSsoScopes()),
'scope' => implode($this->ssoScopeDelimiter, $this->getSsoScopes()),
])
);
}
Expand Down Expand Up @@ -94,4 +100,9 @@ public function getAccessTokenCachePool(): CacheItemPoolInterface
{
return $this->accessTokenCachePool;
}

public function isAccessTokenConsideredJwt(): bool
{
return $this->considerAccessTokenAsJwt;
}
}
1 change: 1 addition & 0 deletions src/Contracts/OAuth2AuthUserRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
interface OAuth2AuthUserRepositoryInterface
{
public function findOneBySsoUserId(string $ssoUserId): ?AnzuAuthUserInterface;
public function findOneBySsoEmail(string $email): ?AnzuAuthUserInterface;
}
3 changes: 3 additions & 0 deletions src/DependencyInjection/AnzuSystemsAuthExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ public function load(array $configs, ContainerBuilder $container): void
->setArgument('$ssoClientSecret', $oauth2Section['client_secret'])
->setArgument('$ssoPublicCert', $oauth2Section['public_cert'])
->setArgument('$ssoScopes', $oauth2Section['scopes'])
->setArgument('$ssoScopeDelimiter', $oauth2Section['scope_delimiter'])
->setArgument('$considerAccessTokenAsJwt', $oauth2Section['consider_access_token_as_jwt'])
->setArgument('$accessTokenCachePool', new Reference($oauth2Section['access_token_cache']))
;

Expand All @@ -116,6 +118,7 @@ public function load(array $configs, ContainerBuilder $container): void
->register(GrantAccessByOAuth2TokenProcess::class)
->setAutowired(true)
->setAutoconfigured(true)
->setArgument('$authMethod', $oauth2Section['auth_method'])
;

$container
Expand Down
10 changes: 9 additions & 1 deletion src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace AnzuSystems\AuthBundle\DependencyInjection;

use AnzuSystems\AuthBundle\Configuration\OAuth2Configuration;
use AnzuSystems\AuthBundle\Domain\Process\OAuth2\GrantAccessByOAuth2TokenProcess;
use AnzuSystems\AuthBundle\Model\Enum\AuthType;
use AnzuSystems\AuthBundle\Model\Enum\JwtAlgorithm;
use AnzuSystems\AuthBundle\Model\SsoUserDto;
Expand Down Expand Up @@ -37,8 +38,9 @@ private function addCookieSection(): NodeDefinition
{
return (new TreeBuilder('cookie'))->getRootNode()
->isRequired()
->addDefaultsIfNotSet()
->children()
->scalarNode('domain')->isRequired()->cannotBeEmpty()->end()
->scalarNode('domain')->defaultValue(null)->end()
->booleanNode('secure')->isRequired()->end()
->scalarNode('device_id_name')->defaultValue('anz_di')->end()
->arrayNode('jwt')
Expand Down Expand Up @@ -148,7 +150,13 @@ private function addOAuth2AuthorizationSection(): NodeDefinition
->scalarNode('client_id')->defaultValue('')->end()
->scalarNode('client_secret')->defaultValue('')->end()
->scalarNode('public_cert')->defaultValue('')->end()
->enumNode('scope_delimiter')->values([' ', ','])->defaultValue(',')->end()
->arrayNode('scopes')->scalarPrototype()->end()->end()
->booleanNode('consider_access_token_as_jwt')->defaultTrue()->end()
->enumNode('auth_method')
->values([GrantAccessByOAuth2TokenProcess::AUTH_METHOD_SSO_ID, GrantAccessByOAuth2TokenProcess::AUTH_METHOD_SSO_EMAIL])
->defaultValue(GrantAccessByOAuth2TokenProcess::AUTH_METHOD_SSO_ID)
->end()
->end()
;
}
Expand Down
67 changes: 60 additions & 7 deletions src/Domain/Process/OAuth2/GrantAccessByOAuth2TokenProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@

namespace AnzuSystems\AuthBundle\Domain\Process\OAuth2;

use AnzuSystems\AuthBundle\Contracts\AnzuAuthUserInterface;
use AnzuSystems\AuthBundle\Contracts\OAuth2AuthUserRepositoryInterface;
use AnzuSystems\AuthBundle\Domain\Process\GrantAccessOnResponseProcess;
use AnzuSystems\AuthBundle\Exception\InvalidJwtException;
use AnzuSystems\AuthBundle\Exception\UnsuccessfulAccessTokenRequestException;
use AnzuSystems\AuthBundle\Exception\UnsuccessfulUserInfoRequestException;
use AnzuSystems\AuthBundle\HttpClient\OAuth2HttpClient;
use AnzuSystems\AuthBundle\Model\AccessTokenDto;
use AnzuSystems\AuthBundle\Model\Enum\UserOAuthLoginState;
use AnzuSystems\AuthBundle\Util\HttpUtil;
use AnzuSystems\CommonBundle\Log\Factory\LogContextFactory;
use AnzuSystems\CommonBundle\Traits\SerializerAwareTrait;
use AnzuSystems\Contracts\Exception\AnzuException;
use AnzuSystems\SerializerBundle\Exception\SerializerException;
use Exception;
use Lcobucci\JWT\Token\RegisteredClaims;
Expand All @@ -26,41 +30,59 @@ final class GrantAccessByOAuth2TokenProcess
{
use SerializerAwareTrait;

public const AUTH_METHOD_SSO_ID = 'sso_id';
public const AUTH_METHOD_SSO_EMAIL = 'sso_email';

public function __construct(
private readonly OAuth2HttpClient $OAuth2HttpClient,
private readonly GrantAccessOnResponseProcess $grantAccessOnResponseProcess,
private readonly ValidateOAuth2AccessTokenProcess $validateOAuth2AccessTokenProcess,
private readonly OAuth2AuthUserRepositoryInterface $OAuth2AuthUserRepository,
private readonly OAuth2AuthUserRepositoryInterface $oAuth2AuthUserRepository,
private readonly HttpUtil $httpUtil,
private readonly LoggerInterface $appLogger,
private readonly LogContextFactory $contextFactory,
private readonly string $authMethod,
) {
}

/**
* @throws SerializerException
* @throws AnzuException
*/
public function execute(Request $request): Response
{
$code = (string) $request->query->get('code');

try {
$ssoJwt = $this->OAuth2HttpClient->requestAccessTokenByAuthCode($code);
$accessTokenDto = $this->OAuth2HttpClient->requestAccessTokenByAuthCode($code);
} catch (UnsuccessfulAccessTokenRequestException $exception) {
$this->logException($request, $exception);

return $this->createRedirectResponseForRequest($request, UserOAuthLoginState::FailureSsoCommunicationFailed);
}

if ($accessTokenDto->getJwt()) {
// validate jwt
try {
$this->validateOAuth2AccessTokenProcess->execute($accessTokenDto->getJwt());
} catch (InvalidJwtException $exception) {
$this->logException($request, $exception);

return $this->createRedirectResponseForRequest($request, UserOAuthLoginState::FailureUnauthorized);
}
}

try {
$this->validateOAuth2AccessTokenProcess->execute($ssoJwt->getAccessToken());
$ssoUserId = (string) $ssoJwt->getAccessToken()->claims()->get(RegisteredClaims::SUBJECT);
} catch (InvalidJwtException $exception) {
$authUser = $this->getAuthUser($accessTokenDto);
} catch (UnsuccessfulUserInfoRequestException | UnsuccessfulAccessTokenRequestException $exception) {
$this->logException($request, $exception);

return $this->createRedirectResponseForRequest($request, UserOAuthLoginState::FailureUnauthorized);
return $this->createRedirectResponseForRequest(
$request,
UserOAuthLoginState::FailureSsoCommunicationFailed
);
}
$authUser = $this->OAuth2AuthUserRepository->findOneBySsoUserId($ssoUserId);

if (null === $authUser || false === $authUser->isEnabled()) {
return $this->createRedirectResponseForRequest($request, UserOAuthLoginState::FailureUnauthorized);
}
Expand Down Expand Up @@ -92,4 +114,35 @@ private function createRedirectResponseForRequest(Request $request, UserOAuthLog

return new RedirectResponse($redirectUrl);
}

/**
* @throws AnzuException
* @throws UnsuccessfulAccessTokenRequestException
* @throws UnsuccessfulUserInfoRequestException
*/
private function getAuthUser(AccessTokenDto $accessTokenDto): ?AnzuAuthUserInterface
{
if (self::AUTH_METHOD_SSO_EMAIL === $this->authMethod) {
// fetch user info
$ssoUser = $this->OAuth2HttpClient->getSsoUserInfo();

return $this->oAuth2AuthUserRepository->findOneBySsoEmail($ssoUser->getEmail());
}

if (self::AUTH_METHOD_SSO_ID === $this->authMethod) {
// prefer to use the jwt
if ($accessTokenDto->getJwt()) {
$ssoUserId = (string) $accessTokenDto->getJwt()->claims()->get(RegisteredClaims::SUBJECT);

return $this->oAuth2AuthUserRepository->findOneBySsoUserId($ssoUserId);
}

// otherwise fetch user info
$ssoUser = $this->OAuth2HttpClient->getSsoUserInfo();

return $this->oAuth2AuthUserRepository->findOneBySsoUserId($ssoUser->getId());
}

throw new AnzuException(sprintf('Unknown auth method "%s".', $this->authMethod));
}
}
61 changes: 44 additions & 17 deletions src/HttpClient/OAuth2HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
use AnzuSystems\AuthBundle\Configuration\OAuth2Configuration;
use AnzuSystems\AuthBundle\Exception\UnsuccessfulAccessTokenRequestException;
use AnzuSystems\AuthBundle\Exception\UnsuccessfulUserInfoRequestException;
use AnzuSystems\AuthBundle\Model\AccessTokenDto;
use AnzuSystems\AuthBundle\Model\AccessTokenResponseDto;
use AnzuSystems\AuthBundle\Model\OpaqueAccessTokenResponseDto;
use AnzuSystems\AuthBundle\Model\SsoUserDto;
use AnzuSystems\CommonBundle\Log\Factory\LogContextFactory;
use AnzuSystems\SerializerBundle\Exception\SerializerException;
use AnzuSystems\SerializerBundle\Serializer;
use DateTimeInterface;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
Expand All @@ -31,29 +32,33 @@ public function __construct(
/**
* @throws UnsuccessfulAccessTokenRequestException
*/
public function requestAccessTokenByAuthCode(string $code): AccessTokenResponseDto
public function requestAccessTokenByAuthCode(string $code): AccessTokenDto
{
return $this->sendTokenRequest($this->configuration->getSsoAccessTokenUrl(), [
$accessToken = $this->sendTokenRequest($this->configuration->getSsoAccessTokenUrl(), [
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => $this->configuration->getSsoClientId(),
'client_secret' => $this->configuration->getSsoClientSecret(),
'redirect_uri' => $this->configuration->getSsoRedirectUrl(),
]);

$this->storeAccessTokenToCache($this->getAccessTokenCacheItem(), $accessToken);

return $accessToken;
}

/**
* @throws UnsuccessfulAccessTokenRequestException
* @throws UnsuccessfulUserInfoRequestException
*/
public function getSsoUserInfo(string $id): SsoUserDto
public function getSsoUserInfo(?string $id = null): SsoUserDto
{
try {
$response = $this->client->request(
method: Request::METHOD_GET,
url: $this->configuration->getSsoUserInfoUrl($id),
options: [
'auth_bearer' => $this->requestAccessTokenForClientService()->getAccessToken()->toString(),
'auth_bearer' => $this->requestAccessTokenForClientService()->getAccessToken(),
]
);

Expand All @@ -70,11 +75,9 @@ public function getSsoUserInfo(string $id): SsoUserDto
*
* @noinspection PhpDocMissingThrowsInspection
*/
private function requestAccessTokenForClientService(): AccessTokenResponseDto
private function requestAccessTokenForClientService(): AccessTokenDto
{
$cachePool = $this->configuration->getAccessTokenCachePool();
/** @noinspection PhpUnhandledExceptionInspection */
$accessTokenCacheItem = $cachePool->getItem(self::CLIENT_SERVICE_ACCESS_TOKEN_CACHE_KEY);
$accessTokenCacheItem = $this->getAccessTokenCacheItem();
if ($accessTokenCacheItem->isHit()) {
return $accessTokenCacheItem->get();
}
Expand All @@ -84,28 +87,52 @@ private function requestAccessTokenForClientService(): AccessTokenResponseDto
'client_id' => $this->configuration->getSsoClientId(),
'client_secret' => $this->configuration->getSsoClientSecret(),
]);
/** @var DateTimeInterface $expiresAfter */
$expiresAfter = $accessToken->getAccessToken()->claims()->get('exp');
$accessTokenCacheItem->set($accessToken);
$accessTokenCacheItem->expiresAt($expiresAfter);
$cachePool->save($accessTokenCacheItem);

$this->storeAccessTokenToCache($accessTokenCacheItem, $accessToken);

return $accessToken;
}

/**
* @throws UnsuccessfulAccessTokenRequestException
*/
private function sendTokenRequest(string $url, array $bodyParameters): AccessTokenResponseDto
private function sendTokenRequest(string $url, array $bodyParameters): AccessTokenDto
{
try {
$response = $this->client->request(Request::METHOD_POST, $url, ['body' => $bodyParameters]);

return $this->serializer->deserialize($response->getContent(), AccessTokenResponseDto::class);
if ($this->configuration->isAccessTokenConsideredJwt()) {
return AccessTokenDto::createFromJwtAccessTokenResponse(
$this->serializer->deserialize($response->getContent(), AccessTokenResponseDto::class)
);
}

return AccessTokenDto::createFromOpaqueAccessTokenResponse(
$this->serializer->deserialize($response->getContent(), OpaqueAccessTokenResponseDto::class)
);
} catch (ExceptionInterface $exception) {
throw UnsuccessfulAccessTokenRequestException::create('Token request failed!', $exception);
} catch (SerializerException $exception) {
throw UnsuccessfulAccessTokenRequestException::create('Invalid jwt token response!', $exception);
}
}

private function getAccessTokenCacheItem(): CacheItemInterface
{
/** @noinspection PhpUnhandledExceptionInspection */
return $this->configuration->getAccessTokenCachePool()->getItem(
self::CLIENT_SERVICE_ACCESS_TOKEN_CACHE_KEY
);
}

private function storeAccessTokenToCache(
CacheItemInterface $accessTokenCacheItem,
AccessTokenDto $accessToken
): void {
$cachePool = $this->configuration->getAccessTokenCachePool();

$accessTokenCacheItem->set($accessToken);
$accessTokenCacheItem->expiresAt($accessToken->getExpiresAt());
$cachePool->save($accessTokenCacheItem);
}
}
Loading

0 comments on commit 50bea95

Please sign in to comment.