Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -45,7 +45,8 @@
"drupal/local": "^1.0",
"drupal/provus_edu": "^1.0",
"drupal/recipe_installer_kit": "^1.0.4",
"drupal/webform": "@beta"
"drupal/webform": "@beta",
"league/oauth2-client": "^2"
},
"require-dev": {
"drupal/core-dev": "^11",
Expand Down
50 changes: 50 additions & 0 deletions docroot/modules/custom/acquia_id/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Acquia ID

Provides OAuth2 single sign-on via Acquia ID (`id.acquia.com`) using the PKCE authorization code flow.

## Configuration

Set the following service parameters, typically in `settings.php` or a `services.yml` override:

```yaml
parameters:
acquia_id.client_id: 'your-oauth2-client-id'
# These default to production values and only need overriding for non-production environments.
# acquia_id.idp_base_uri: 'https://id.acquia.com/oauth2/default'
# acquia_id.cloud_api_base_uri: 'https://cloud.acquia.com'
```

The SSO route is `/acquia-id/sso`.

## Implementing user resolution

This module dispatches `\Drupal\acquia_id\Events\OAuth2AuthorizationEvent` once the
OAuth2 token exchange succeeds. **You must provide an event subscriber** that calls
`$event->setUser($user)` with the resolved Drupal user entity. If no user is set
after the event is dispatched, the SSO flow redirects to `idp_logout_redirect_uri`.

Example subscriber:

```php
use Drupal\acquia_id\Events\OAuth2AuthorizationEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class MyAuthorizationSubscriber implements EventSubscriberInterface {

public static function getSubscribedEvents(): array {
return [OAuth2AuthorizationEvent::class => 'onAuthorization'];
}

public function onAuthorization(OAuth2AuthorizationEvent $event): void {
$resourceOwner = $event->provider->getResourceOwner($event->accessToken);
$user = user_load_by_mail($resourceOwner->getId());
if ($user) {
$event->setUser($user);
}
}

}
```

Throw `\Drupal\Core\Access\AccessException` from the subscriber to deny login and
redirect to `idp_logout_redirect_uri`.
7 changes: 7 additions & 0 deletions docroot/modules/custom/acquia_id/acquia_id.info.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: 'Acquia ID'
type: module
description: 'OAuth2 single sign-on via Acquia ID (id.acquia.com).'
package: Acquia
core_version_requirement: ^11
dependencies:
- drupal:user
8 changes: 8 additions & 0 deletions docroot/modules/custom/acquia_id/acquia_id.routing.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
acquia_id.sso:
path: '/acquia-id/sso'
defaults:
_controller: Drupal\acquia_id\Controller\OAuth2Controller
requirements:
_custom_access: 'Drupal\acquia_id\Controller\OAuth2Controller::access'
options:
no_cache: TRUE
26 changes: 26 additions & 0 deletions docroot/modules/custom/acquia_id/acquia_id.services.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
parameters:
acquia_id.client_id: ''
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO get from IDM team

acquia_id.idp_base_uri: 'https://id.acquia.com/oauth2/default'
acquia_id.cloud_api_base_uri: 'https://cloud.acquia.com'
acquia_id.idp_logout_redirect_uri: 'https://cloud.acquia.com'

services:
acquia_id.oauth2.provider_factory:
class: Drupal\acquia_id\OAuth2\ProviderFactory
arguments:
- '@http_client_factory'
- '%acquia_id.client_id%'
- '%acquia_id.idp_base_uri%'
- '%acquia_id.cloud_api_base_uri%'
public: false

acquia_id.oauth2.provider:
class: Drupal\acquia_id\OAuth2\Provider\AcquiaIdProvider
factory: ['@acquia_id.oauth2.provider_factory', 'get']

acquia_id.oauth2.access_token_repository:
class: Drupal\acquia_id\OAuth2\AccessTokenRepository
arguments:
- '@acquia_id.oauth2.provider_factory'
- '@user.data'
- '@datetime.time'
9 changes: 9 additions & 0 deletions docroot/modules/custom/acquia_id/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "drupal/acquia_id",
"type": "drupal-custom-module",
"description": "OAuth2 single sign-on via Acquia ID (id.acquia.com).",
"license": "GPL-2.0-or-later",
"require": {
"league/oauth2-client": "^2"
}
}
156 changes: 156 additions & 0 deletions docroot/modules/custom/acquia_id/src/Controller/OAuth2Controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

declare(strict_types=1);

namespace Drupal\acquia_id\Controller;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Logger\LoggerChannelTrait;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\acquia_id\Events\OAuth2AuthorizationEvent;
use Drupal\acquia_id\OAuth2\AccessTokenRepository;
use Drupal\acquia_id\OAuth2\Provider\AcquiaIdProvider;
use GuzzleHttp\Exception\RequestException;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

final class OAuth2Controller implements ContainerInjectionInterface {

use LoggerChannelTrait;
use StringTranslationTrait;

private const OAUTH2_STATE = 'oauth2_state';
private const OAUTH2_PKCE = 'oauth2_pkce';

public function __construct(
private readonly AcquiaIdProvider $provider,
private readonly SessionInterface $session,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly AccessTokenRepository $accessTokenRepository,
private readonly string $idpLogoutRedirectUri,
) {
}

/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): self {
return new self(
$container->get('acquia_id.oauth2.provider'),
$container->get('session'),
$container->get('event_dispatcher'),
$container->get('acquia_id.oauth2.access_token_repository'),
$container->getParameter('acquia_id.idp_logout_redirect_uri'),
);
}

/**
* Handles the OAuth2 authorization code PKCE flow.
*
* @link https://oauth.net/2/pkce/
*/
public function __invoke(Request $request): RedirectResponse {
// Check if this is a subrequest being made on access denied exception.
// @see \Drupal\Core\EventSubscriber\HttpExceptionSubscriberBase::onException().
if ($request->attributes->has('exception')) {
$request->query->replace();
}

if (!$request->query->has('code') && !$request->query->has('state')) {
if ($request->query->has('destination')) {
$this->session->set('oauth2_destination', $request->query->get('destination'));
$request->query->set('destination', '');
}
$response = new TrustedRedirectResponse(
$this->provider->getAuthorizationUrl(),
Response::HTTP_SEE_OTHER
);
$this->session->set(self::OAUTH2_STATE, $this->provider->getState());
$this->session->set(self::OAUTH2_PKCE, $this->provider->getPkceCode());
return $response;
}

if (!$request->query->has('state')) {
throw new AccessDeniedHttpException('Missing state');
}
if ($request->query->get('state') !== $this->session->get(self::OAUTH2_STATE)) {
throw new AccessDeniedHttpException('Invalid state');
}

if ($request->query->has('error')) {
return $this->accessDeniedRedirect($request->query->get('error_description', ''));
}

try {
$this->provider->setPkceCode($this->session->get(self::OAUTH2_PKCE));
$token = $this->provider->getAccessToken('authorization_code', [
'code' => $request->query->get('code', ''),
]);
assert($token instanceof AccessToken);
}
catch (\Exception $e) {
throw new AccessDeniedHttpException($e->getMessage(), $e);
}

$event = new OAuth2AuthorizationEvent($this->provider, $token);
try {
$this->eventDispatcher->dispatch($event);
}
catch (\Exception $e) {
return $this->accessDeniedRedirect($e->getMessage());
}

$user = $event->getUser();
if ($user === NULL) {
return $this->accessDeniedRedirect('User not determined from OAuth authorization.');
}

user_login_finalize($user);
$this->accessTokenRepository->store((int) $user->id(), $token);

if ($destination = $this->session->get('oauth2_destination')) {
$url = Url::fromUserInput($destination)->setAbsolute();
$this->session->remove('oauth2_destination');
}
else {
$url = Url::fromRoute('<front>');
}

return new RedirectResponse($url->toString(), Response::HTTP_SEE_OTHER);
}

/**
* Checks access for the SSO route.
*/
public function access(AccountInterface $account): AccessResultInterface {
$token = NULL;
try {
$token = $this->accessTokenRepository->get((int) $account->id());
}
catch (IdentityProviderException) {
return AccessResult::allowed();
}
catch (RequestException) {
}

return AccessResult::allowedIf($account->isAnonymous() || $token === NULL);
}

private function accessDeniedRedirect(string $logMessage = 'Access denied'): RedirectResponse {
$this->getLogger('acquia_id')->error($this->t('Error: @message', ['@message' => $logMessage]));
return new TrustedRedirectResponse($this->idpLogoutRedirectUri, Response::HTTP_SEE_OTHER);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Drupal\acquia_id\Events;

use Drupal\acquia_id\OAuth2\Provider\IdpProvider;
use Drupal\user\UserInterface;
use League\OAuth2\Client\Token\AccessToken;
use Symfony\Contracts\EventDispatcher\Event;

final class OAuth2AuthorizationEvent extends Event {

private UserInterface|null $user = NULL;

public function __construct(
public readonly IdpProvider $provider,
public readonly AccessToken $accessToken,
) {
}

public function setUser(UserInterface $user): void {
$this->user = $user;
}

public function getUser(): ?UserInterface {
return $this->user;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Drupal\acquia_id\OAuth2;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\user\UserDataInterface;
use League\OAuth2\Client\Token\AccessToken;

final class AccessTokenRepository {

/**
* The refresh token TTL from Acquia Cloud is 90 minutes.
*/
private const REFRESH_TOKEN_TTL = 90;

private const string STORAGE_KEY = 'acquia_id_access_token';

public function __construct(
private readonly ProviderFactory $providerFactory,
private readonly UserDataInterface $userData,
private readonly TimeInterface $time,
) {}

/**
* Gets the access token for the given user, refreshing it if expired.
*
* @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException
*/
public function get(int $id): ?AccessToken {
$tokenData = $this->userData->get('acquia_id', $id, self::STORAGE_KEY);

if (empty($tokenData) ||
($tokenData['timestamp'] < ($this->time->getCurrentTime() - (self::REFRESH_TOKEN_TTL * 60)))) {
return NULL;
}

$token = $tokenData['access_token'];
if ($token->hasExpired()) {
/** @var \League\OAuth2\Client\Token\AccessToken $token */
$token = $this->providerFactory->get()->getAccessToken('refresh_token', [
'refresh_token' => $token->getRefreshToken() ?? '',
]);
$this->store($id, $token);
}

return $token;
}

public function store(int $id, AccessToken $token): void {
$this->userData->set('acquia_id', $id, self::STORAGE_KEY, [
'access_token' => $token,
'timestamp' => $this->time->getCurrentTime(),
]);
}

public function delete(int $id): void {
$this->userData->delete('acquia_id', $id, self::STORAGE_KEY);
}

/**
* @return list<int|string>
*/
public function getUserIdsWithTokens(): array {
return array_keys($this->userData->get('acquia_id', name: self::STORAGE_KEY));
}

}
Loading
Loading