config_preview_deploy-1.0.0-alpha3/src/Controller/OAuthController.php
src/Controller/OAuthController.php
<?php
declare(strict_types=1);
namespace Drupal\config_preview_deploy\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Drupal\key\KeyRepositoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Drupal\Core\Routing\TrustedRedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use GuzzleHttp\ClientInterface;
/**
* Handles OAuth 2.0 authorization flow for config deployment.
*/
class OAuthController extends ControllerBase {
/**
* The HTTP client.
*/
protected ClientInterface $httpClient;
/**
* The private tempstore factory.
*/
protected PrivateTempStoreFactory $privateTempStoreFactory;
/**
* The key repository service.
*/
protected KeyRepositoryInterface $keyRepository;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
$instance = parent::create($container);
$instance->httpClient = $container->get('http_client');
$instance->privateTempStoreFactory = $container->get('tempstore.private');
$instance->keyRepository = $container->get('key.repository');
return $instance;
}
/**
* Handles OAuth callback from production environment.
*
* This method receives the authorization code from production OAuth server
* and exchanges it for an access token.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object containing OAuth callback parameters.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* Redirect to deployment form with token or error.
*/
public function callback(Request $request): RedirectResponse {
$code = $request->query->get('code');
$state = $request->query->get('state');
$error = $request->query->get('error');
// Check for OAuth errors.
if ($error) {
$error_description = $request->query->get('error_description', 'Unknown OAuth error');
$this->messenger()->addError($this->t('OAuth authorization failed: @error - @description', [
'@error' => $error,
'@description' => $error_description,
]));
return new RedirectResponse(Url::fromRoute('config_preview_deploy.dashboard')->toString());
}
// Validate required parameters.
if (!$code || !$state) {
$this->messenger()->addError($this->t('Invalid OAuth callback - missing authorization code or state parameter.'));
return new RedirectResponse(Url::fromRoute('config_preview_deploy.dashboard')->toString());
}
// Validate state parameter to prevent CSRF attacks.
$tempstore = $this->privateTempStoreFactory->get('config_preview_deploy');
$expected_state = $tempstore->get('oauth_state');
if (!$expected_state || !hash_equals($expected_state, $state)) {
$this->messenger()->addError($this->t('Invalid OAuth state parameter - possible CSRF attack.'));
return new RedirectResponse(Url::fromRoute('config_preview_deploy.dashboard')->toString());
}
try {
// Exchange authorization code for access token.
$token_data = $this->exchangeCodeForToken($code);
if ($token_data) {
// Store access token in session for deployment.
$tempstore = $this->privateTempStoreFactory->get('config_preview_deploy');
$tempstore->set('access_token', $token_data['access_token']);
$tempstore->set('token_type', $token_data['token_type'] ?? 'Bearer');
$tempstore->set('expires_in', $token_data['expires_in'] ?? 3600);
$tempstore->set('token_received_at', time());
// Check target and redirect accordingly.
$target = $tempstore->get('oauth_target');
if ($target === 'rebase') {
$tempstore->delete('oauth_target');
return new RedirectResponse(Url::fromRoute('config_preview_deploy.rebase_form')->toString());
}
// Default redirect to deploy form.
return new RedirectResponse(Url::fromRoute('config_preview_deploy.deploy_form')->toString());
}
else {
$this->messenger()->addError($this->t('Failed to exchange authorization code for access token.'));
}
}
catch (\Exception $e) {
$this->getLogger('config_preview_deploy')->error('OAuth token exchange failed: @message', [
'@message' => $e->getMessage(),
]);
$this->messenger()->addError($this->t('OAuth authorization failed due to technical error.'));
}
return new RedirectResponse(Url::fromRoute('config_preview_deploy.dashboard')->toString());
}
/**
* Initiates OAuth authorization flow with production environment.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* Redirect to production OAuth authorize endpoint.
*/
public function authorize(Request $request): RedirectResponse {
$config = $this->config('config_preview_deploy.settings');
$production_url = $config->get('production_url');
if (!$production_url) {
$this->messenger()->addError($this->t('Production URL not configured. Please configure it in module settings.'));
return new RedirectResponse(Url::fromRoute('config_preview_deploy.dashboard')->toString());
}
// Load OAuth consumer configuration.
$consumers = $this->entityTypeManager()
->getStorage('consumer')
->loadByProperties(['client_id' => 'config_preview_deploy']);
if (empty($consumers)) {
$this->messenger()->addError($this->t('OAuth consumer not found. Please reinstall the module.'));
return new RedirectResponse(Url::fromRoute('config_preview_deploy.dashboard')->toString());
}
$consumer = reset($consumers);
// Generate and store state parameter for CSRF protection.
$state = bin2hex(random_bytes(16));
$tempstore = $this->privateTempStoreFactory->get('config_preview_deploy');
$tempstore->set('oauth_state', $state);
// Store target if provided.
$target = $request->query->get('target');
if ($target) {
$tempstore->set('oauth_target', $target);
}
// Build authorization URL.
$callback_url = Url::fromRoute('config_preview_deploy.oauth_callback', [], ['absolute' => TRUE])->toString();
$authorize_params = [
'response_type' => 'code',
'client_id' => $consumer->getClientId(),
'redirect_uri' => $callback_url,
'scope' => 'config_preview_deploy',
'state' => $state,
];
$authorize_url = rtrim($production_url, '/') . '/oauth/authorize?' . http_build_query($authorize_params);
return new TrustedRedirectResponse($authorize_url);
}
/**
* Exchanges authorization code for access token.
*
* @param string $code
* The authorization code from OAuth callback.
*
* @return array|null
* Token data array or NULL on failure.
*/
protected function exchangeCodeForToken(string $code): ?array {
$config = $this->config('config_preview_deploy.settings');
$production_url = $config->get('production_url');
// Load OAuth consumer.
$consumers = $this->entityTypeManager()
->getStorage('consumer')
->loadByProperties(['client_id' => 'config_preview_deploy']);
if (empty($consumers)) {
throw new \RuntimeException($this->t('OAuth consumer not found')->render());
}
$consumer = reset($consumers);
$callback_url = Url::fromRoute('config_preview_deploy.oauth_callback', [], ['absolute' => TRUE])->toString();
// Prepare token exchange request.
$token_params = [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $callback_url,
'client_id' => $consumer->getClientId(),
// Get client secret from Key module.
'client_secret' => $this->keyRepository->getKey('config_deploy_secret')?->getKeyValue(),
];
$token_url = rtrim($production_url, '/') . '/oauth/token';
try {
$response = $this->httpClient->post($token_url, [
'form_params' => $token_params,
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'timeout' => 30,
]);
$token_data = json_decode($response->getBody()->getContents(), TRUE);
if (isset($token_data['access_token'])) {
return $token_data;
}
else {
$this->getLogger('config_preview_deploy')->error('Token exchange response missing access_token');
return NULL;
}
}
catch (\Exception $e) {
$this->getLogger('config_preview_deploy')->error('Token exchange HTTP error: @message', [
'@message' => $e->getMessage(),
]);
throw $e;
}
}
}
