nextcloud_webdav_client-1.0.x-dev/src/Service/NextCloudOAuth2Manager.php
src/Service/NextCloudOAuth2Manager.php
<?php
namespace Drupal\nextcloud_webdav_client\Service;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Component\Datetime\TimeInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;
/**
* OAuth2 token manager for NextCloud WebDAV authentication.
*/
class NextCloudOAuth2Manager {
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $httpClient;
/**
* The logger channel.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* Constructs a NextCloudOAuth2Manager object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
* The logger factory.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
ClientInterface $http_client,
LoggerChannelFactoryInterface $logger_factory,
TimeInterface $time
) {
$this->configFactory = $config_factory;
$this->httpClient = $http_client;
$this->logger = $logger_factory->get('nextcloud_webdav_client');
$this->time = $time;
}
/**
* Gets the current access token.
*
* Automatically refreshes the token if it's expired or about to expire.
*
* @return string|null
* The access token, or NULL if unavailable.
*/
public function getAccessToken(): ?string {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$access_token = $config->get('oauth2_access_token');
$token_expiry = $config->get('oauth2_token_expiry');
// If no token exists, return null.
if (empty($access_token)) {
return NULL;
}
// Check if token is expired or about to expire (within 5 minutes).
$current_time = $this->time->getRequestTime();
$buffer_time = 300; // 5 minutes buffer.
if (!empty($token_expiry) && ($current_time + $buffer_time) >= $token_expiry) {
$this->logger->info('Access token expired or about to expire, attempting refresh.');
if ($this->refreshAccessToken()) {
// Get the newly refreshed token.
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$access_token = $config->get('oauth2_access_token');
}
else {
$this->logger->warning('Failed to refresh access token.');
return NULL;
}
}
return $access_token;
}
/**
* Refreshes the access token using the refresh token.
*
* @return bool
* TRUE if the token was refreshed successfully, FALSE otherwise.
*/
public function refreshAccessToken(): bool {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$refresh_token = $config->get('oauth2_refresh_token');
$token_endpoint = $config->get('oauth2_token_endpoint');
$client_id = $config->get('oauth2_client_id');
$client_secret = $config->get('oauth2_client_secret');
$use_index_php = $config->get('oauth2_use_index_php');
if (empty($refresh_token) || empty($token_endpoint)) {
$this->logger->error('Cannot refresh token: missing refresh token or token endpoint.');
return FALSE;
}
// Add /index.php/ prefix if configured.
if ($use_index_php) {
$token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
}
try {
$response = $this->httpClient->request('POST', $token_endpoint, [
RequestOptions::FORM_PARAMS => [
'grant_type' => 'refresh_token',
'refresh_token' => $refresh_token,
'client_id' => $client_id,
'client_secret' => $client_secret,
],
RequestOptions::HEADERS => [
'Accept' => 'application/json',
],
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
if (empty($data['access_token'])) {
$this->logger->error('Token refresh response missing access_token.');
return FALSE;
}
// Calculate token expiry time.
$expires_in = $data['expires_in'] ?? 3600;
$token_expiry = $this->time->getRequestTime() + $expires_in;
// Update configuration with new tokens.
$editable_config = $this->configFactory->getEditable('nextcloud_webdav_client.settings');
$editable_config
->set('oauth2_access_token', $data['access_token'])
->set('oauth2_token_expiry', $token_expiry);
// Update refresh token if a new one was provided.
if (!empty($data['refresh_token'])) {
$editable_config->set('oauth2_refresh_token', $data['refresh_token']);
}
$editable_config->save();
$this->logger->info('Access token refreshed successfully.');
return TRUE;
}
catch (RequestException $e) {
$this->logger->error('Error refreshing access token: @message', [
'@message' => $e->getMessage(),
]);
return FALSE;
}
catch (\Exception $e) {
$this->logger->error('Unexpected error refreshing access token: @message', [
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Checks if the current token is valid.
*
* @return bool
* TRUE if a valid token exists, FALSE otherwise.
*/
public function isTokenValid(): bool {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$access_token = $config->get('oauth2_access_token');
$token_expiry = $config->get('oauth2_token_expiry');
if (empty($access_token)) {
return FALSE;
}
// Check if token is expired.
$current_time = $this->time->getRequestTime();
if (!empty($token_expiry) && $current_time >= $token_expiry) {
return FALSE;
}
return TRUE;
}
/**
* Initiates the OAuth2 authorization flow.
*
* @param string $redirect_uri
* The callback URI to redirect to after authorization.
* @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
* The session service for CSRF state storage.
*
* @return string
* The authorization URL to redirect the user to.
*/
public function initiateOAuth2Flow(string $redirect_uri, $session): string {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$authorize_endpoint = $config->get('oauth2_authorize_endpoint');
$client_id = $config->get('oauth2_client_id');
$scopes = $config->get('oauth2_scopes') ?: 'openid profile email';
$use_index_php = $config->get('oauth2_use_index_php');
// Add /index.php/ prefix if configured.
if ($use_index_php) {
$authorize_endpoint = $this->addIndexPhpPrefix($authorize_endpoint);
}
// Generate cryptographically secure state for CSRF protection.
$state = bin2hex(random_bytes(32));
// Store state in session for validation in callback.
$session->set('nextcloud_oauth2_state', $state);
$session->set('nextcloud_oauth2_state_time', time());
$params = [
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => $redirect_uri,
'scope' => $scopes,
'state' => $state,
];
return $authorize_endpoint . '?' . http_build_query($params);
}
/**
* Exchanges an authorization code for access and refresh tokens.
*
* @param string $code
* The authorization code from the OAuth2 provider.
* @param string $redirect_uri
* The redirect URI used in the authorization request.
*
* @return bool
* TRUE if tokens were obtained successfully, FALSE otherwise.
*/
public function exchangeAuthorizationCode(string $code, string $redirect_uri): bool {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$token_endpoint = $config->get('oauth2_token_endpoint');
$client_id = $config->get('oauth2_client_id');
$client_secret = $config->get('oauth2_client_secret');
$use_index_php = $config->get('oauth2_use_index_php');
if (empty($token_endpoint) || empty($client_id)) {
$this->logger->error('Cannot exchange code: missing token endpoint or client ID.');
return FALSE;
}
// Add /index.php/ prefix if configured.
if ($use_index_php) {
$token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
}
try {
$response = $this->httpClient->request('POST', $token_endpoint, [
RequestOptions::FORM_PARAMS => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirect_uri,
'client_id' => $client_id,
'client_secret' => $client_secret,
],
RequestOptions::HEADERS => [
'Accept' => 'application/json',
],
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
if (empty($data['access_token'])) {
$this->logger->error('Token exchange response missing access_token.');
return FALSE;
}
// Calculate token expiry time.
$expires_in = $data['expires_in'] ?? 3600;
$token_expiry = $this->time->getRequestTime() + $expires_in;
// Store tokens in configuration.
$editable_config = $this->configFactory->getEditable('nextcloud_webdav_client.settings');
$editable_config
->set('oauth2_access_token', $data['access_token'])
->set('oauth2_token_expiry', $token_expiry);
if (!empty($data['refresh_token'])) {
$editable_config->set('oauth2_refresh_token', $data['refresh_token']);
}
$editable_config->save();
$this->logger->info('OAuth2 authorization successful, tokens stored.');
return TRUE;
}
catch (RequestException $e) {
$this->logger->error('Error exchanging authorization code: @message', [
'@message' => $e->getMessage(),
]);
return FALSE;
}
catch (\Exception $e) {
$this->logger->error('Unexpected error exchanging authorization code: @message', [
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Clears all stored OAuth2 tokens.
*
* This effectively revokes the authorization.
*/
public function clearTokens(): void {
$config = $this->configFactory->getEditable('nextcloud_webdav_client.settings');
$config
->clear('oauth2_access_token')
->clear('oauth2_refresh_token')
->clear('oauth2_token_expiry')
->save();
$this->logger->info('OAuth2 tokens cleared.');
}
/**
* Gets the token expiry timestamp.
*
* @return int|null
* The token expiry timestamp, or NULL if not set.
*/
public function getTokenExpiry(): ?int {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
return $config->get('oauth2_token_expiry');
}
/**
* Checks if OAuth2 is properly configured.
*
* @return bool
* TRUE if OAuth2 configuration is complete, FALSE otherwise.
*/
public function isConfigured(): bool {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$client_id = $config->get('oauth2_client_id');
$client_secret = $config->get('oauth2_client_secret');
$token_endpoint = $config->get('oauth2_token_endpoint');
$authorize_endpoint = $config->get('oauth2_authorize_endpoint');
return !empty($client_id)
&& !empty($client_secret)
&& !empty($token_endpoint)
&& !empty($authorize_endpoint);
}
/**
* Adds /index.php/ prefix to a URL if not already present.
*
* @param string $url
* The URL to process.
*
* @return string
* The URL with /index.php/ prefix.
*/
protected function addIndexPhpPrefix(string $url): string {
// Parse the URL.
$parts = parse_url($url);
if (!isset($parts['path'])) {
return $url;
}
// Check if /index.php/ is already in the path.
if (strpos($parts['path'], '/index.php/') !== FALSE) {
return $url;
}
// Add /index.php/ at the beginning of the path.
$parts['path'] = '/index.php' . $parts['path'];
// Rebuild the URL.
$result = $parts['scheme'] . '://' . $parts['host'];
if (isset($parts['port'])) {
$result .= ':' . $parts['port'];
}
$result .= $parts['path'];
if (isset($parts['query'])) {
$result .= '?' . $parts['query'];
}
if (isset($parts['fragment'])) {
$result .= '#' . $parts['fragment'];
}
return $result;
}
/**
* Gets the access token for a specific user.
*
* Automatically refreshes the token if it's expired or about to expire.
*
* @param \Drupal\user\UserInterface $user
* The user.
*
* @return string|null
* The access token, or NULL if unavailable.
*/
public function getUserAccessToken(\Drupal\user\UserInterface $user): ?string {
/** @var \Drupal\nextcloud_webdav_client\NextCloudUserTokenStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
$token_entity = $storage->loadByUser($user);
if (!$token_entity) {
return NULL;
}
// Check if expired, refresh if needed.
if ($token_entity->isExpired()) {
$this->logger->info('User @uid access token expired or about to expire, attempting refresh.', [
'@uid' => $user->id(),
]);
if ($this->refreshUserAccessToken($user)) {
$token_entity = $storage->loadByUser($user);
}
else {
$this->logger->warning('Failed to refresh access token for user @uid.', [
'@uid' => $user->id(),
]);
return NULL;
}
}
return $token_entity->getAccessToken();
}
/**
* Refreshes the access token for a specific user.
*
* @param \Drupal\user\UserInterface $user
* The user.
*
* @return bool
* TRUE if the token was refreshed successfully, FALSE otherwise.
*/
public function refreshUserAccessToken(\Drupal\user\UserInterface $user): bool {
/** @var \Drupal\nextcloud_webdav_client\NextCloudUserTokenStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
$token_entity = $storage->loadByUser($user);
if (!$token_entity || !$token_entity->getRefreshToken()) {
$this->logger->error('Cannot refresh token for user @uid: missing token entity or refresh token.', [
'@uid' => $user->id(),
]);
return FALSE;
}
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$token_endpoint = $config->get('oauth2_token_endpoint');
$client_id = $config->get('oauth2_client_id');
$client_secret = $config->get('oauth2_client_secret');
$use_index_php = $config->get('oauth2_use_index_php');
if (empty($token_endpoint)) {
$this->logger->error('Cannot refresh token for user @uid: missing token endpoint.', [
'@uid' => $user->id(),
]);
return FALSE;
}
// Add /index.php/ prefix if configured.
if ($use_index_php) {
$token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
}
try {
$response = $this->httpClient->request('POST', $token_endpoint, [
RequestOptions::FORM_PARAMS => [
'grant_type' => 'refresh_token',
'refresh_token' => $token_entity->getRefreshToken(),
'client_id' => $client_id,
'client_secret' => $client_secret,
],
RequestOptions::HEADERS => [
'Accept' => 'application/json',
],
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
if (empty($data['access_token'])) {
$this->logger->error('Token refresh response missing access_token for user @uid.', [
'@uid' => $user->id(),
]);
return FALSE;
}
// Calculate token expiry time.
$expires_in = $data['expires_in'] ?? 3600;
$token_expiry = $this->time->getRequestTime() + $expires_in;
// Update token entity.
$token_data = [
'access_token' => $data['access_token'],
'token_expiry' => $token_expiry,
];
if (!empty($data['refresh_token'])) {
$token_data['refresh_token'] = $data['refresh_token'];
}
$storage->updateToken($token_entity, $token_data);
$this->logger->info('Access token refreshed successfully for user @uid.', [
'@uid' => $user->id(),
]);
return TRUE;
}
catch (RequestException $e) {
$this->logger->error('Error refreshing access token for user @uid: @message', [
'@uid' => $user->id(),
'@message' => $e->getMessage(),
]);
return FALSE;
}
catch (\Exception $e) {
$this->logger->error('Unexpected error refreshing access token for user @uid: @message', [
'@uid' => $user->id(),
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Initiates the OAuth2 authorization flow for a specific user.
*
* @param \Drupal\user\UserInterface $user
* The user.
* @param string $redirect_uri
* The callback URI to redirect to after authorization.
* @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
* The session service for CSRF state storage.
*
* @return string
* The authorization URL to redirect the user to.
*/
public function initiateUserOAuth2Flow(\Drupal\user\UserInterface $user, string $redirect_uri, $session): string {
// For per-user mode, the flow is the same, just different redirect handling.
return $this->initiateOAuth2Flow($redirect_uri, $session);
}
/**
* Exchanges an authorization code for tokens for a specific user.
*
* @param \Drupal\user\UserInterface $user
* The user.
* @param string $code
* The authorization code from the OAuth2 provider.
* @param string $redirect_uri
* The redirect URI used in the authorization request.
*
* @return bool
* TRUE if tokens were obtained successfully, FALSE otherwise.
*/
public function exchangeAuthorizationCodeForUser(\Drupal\user\UserInterface $user, string $code, string $redirect_uri): bool {
$config = $this->configFactory->get('nextcloud_webdav_client.settings');
$token_endpoint = $config->get('oauth2_token_endpoint');
$client_id = $config->get('oauth2_client_id');
$client_secret = $config->get('oauth2_client_secret');
$use_index_php = $config->get('oauth2_use_index_php');
if (empty($token_endpoint) || empty($client_id)) {
$this->logger->error('Cannot exchange code for user @uid: missing token endpoint or client ID.', [
'@uid' => $user->id(),
]);
return FALSE;
}
// Add /index.php/ prefix if configured.
if ($use_index_php) {
$token_endpoint = $this->addIndexPhpPrefix($token_endpoint);
}
try {
$response = $this->httpClient->request('POST', $token_endpoint, [
RequestOptions::FORM_PARAMS => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirect_uri,
'client_id' => $client_id,
'client_secret' => $client_secret,
],
RequestOptions::HEADERS => [
'Accept' => 'application/json',
],
]);
$data = json_decode($response->getBody()->getContents(), TRUE);
if (empty($data['access_token'])) {
$this->logger->error('Token exchange response missing access_token for user @uid.', [
'@uid' => $user->id(),
]);
return FALSE;
}
// Calculate token expiry time.
$expires_in = $data['expires_in'] ?? 3600;
$token_expiry = $this->time->getRequestTime() + $expires_in;
// Get NextCloud username from config (can be enhanced later to fetch from user info endpoint).
$nextcloud_username = $user->getAccountName();
// Store tokens for this user.
/** @var \Drupal\nextcloud_webdav_client\NextCloudUserTokenStorageInterface $storage */
$storage = \Drupal::entityTypeManager()->getStorage('nextcloud_user_token');
$token_data = [
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? NULL,
'token_expiry' => $token_expiry,
'nextcloud_username' => $nextcloud_username,
];
$storage->createForUser($user, $token_data);
$this->logger->info('OAuth2 authorization successful for user @uid, tokens stored.', [
'@uid' => $user->id(),
]);
return TRUE;
}
catch (RequestException $e) {
$this->logger->error('Error exchanging authorization code for user @uid: @message', [
'@uid' => $user->id(),
'@message' => $e->getMessage(),
]);
return FALSE;
}
catch (\Exception $e) {
$this->logger->error('Unexpected error exchanging authorization code for user @uid: @message', [
'@uid' => $user->id(),
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
}
