misuzu/src/OAuth2/OAuth2Context.php
2025-02-25 02:30:24 +00:00

404 lines
13 KiB
PHP

<?php
namespace Misuzu\OAuth2;
use RuntimeException;
use Index\Config\Config;
use Index\Db\DbConnection;
use Misuzu\Apps\{AppsContext,AppInfo};
use Misuzu\OpenID\OpenIDContext;
use Misuzu\Users\UserInfo;
class OAuth2Context {
public private(set) OAuth2AuthorisationData $authorisations;
public private(set) OAuth2TokensData $tokens;
public private(set) OAuth2DevicesData $devices;
public function __construct(
private Config $config,
DbConnection $dbConn,
public private(set) AppsContext $appsCtx,
private OpenIDContext $openIdCtx,
) {
$this->authorisations = new OAuth2AuthorisationData($dbConn);
$this->tokens = new OAuth2TokensData($dbConn);
$this->devices = new OAuth2DevicesData($dbConn);
}
/**
* @param ?callable(string $line, scalar ...$args): void $logAction
*/
public function pruneExpired($logAction = null): void {
$logAction ??= function() {};
$logAction('Pruning expired refresh tokens...');
$pruned = $this->tokens->pruneExpiredRefresh();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired access tokens...');
$pruned = $this->tokens->pruneExpiredAccess();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired device authorisation requests...');
$pruned = $this->devices->pruneExpiredDevices();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired authorisation codes...');
$pruned = $this->authorisations->pruneExpiredAuthorisations();
$logAction(' Removed %d!', $pruned);
}
public function createAccess(
AppInfo $appInfo,
string $scope = '',
UserInfo|string|null $userInfo = null
): OAuth2AccessInfo {
return $this->tokens->createAccess(
$appInfo,
userInfo: $userInfo,
scope: $scope,
lifetime: $appInfo->accessTokenLifetime,
);
}
public function createRefresh(
AppInfo $appInfo,
OAuth2AccessInfo $accessInfo
): OAuth2RefreshInfo {
return $this->tokens->createRefresh(
$appInfo,
$accessInfo,
userInfo: $accessInfo->userId,
scope: $accessInfo->scope,
lifetime: $appInfo->refreshTokenLifetime,
);
}
/** @return string|array{ error: string, error_description: string } */
public function checkAndBuildScopeString(
AppInfo $appInfo,
?string $scope = null,
bool $skipFail = false
): string|array {
if($scope === null)
return '';
$scopeInfos = $this->appsCtx->handleScopeString($appInfo, $scope, breakOnFail: !$skipFail);
$scope = [];
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
if($skipFail)
continue;
return [
'error' => 'invalid_scope',
'error_description' => sprintf('Requested scope "%s" is %s.', $scopeName, $scopeInfo),
];
}
$scope[] = $scopeInfo->string;
}
return implode(' ', $scope);
}
/**
* @return array{
* device_code: string,
* user_code: string,
* verification_uri: string,
* verification_uri_complete: string,
* expires_in?: int,
* interval?: int
* }|array{ error: string, error_description: string }
*/
public function createDeviceAuthorisationRequest(AppInfo $appInfo, ?string $scope = null) {
$scope = $this->checkAndBuildScopeString($appInfo, $scope);
if(is_array($scope))
return $scope;
$deviceInfo = $this->devices->createDevice($appInfo, $scope);
$userCode = $deviceInfo->getUserCodeDashed();
$result = [
'device_code' => $deviceInfo->code,
'user_code' => $userCode,
'verification_uri' => $this->config->getString('device.verification_uri'),
'verification_uri_complete' => sprintf($this->config->getString('device.verification_uri_complete'), $userCode),
];
$expiresIn = $deviceInfo->remainingLifetime;
if($expiresIn < OAuth2DeviceInfo::DEFAULT_LIFETIME)
$result['expires_in'] = $expiresIn;
$interval = $deviceInfo->interval;
if($interval > OAuth2DeviceInfo::DEFAULT_POLL_INTERVAL)
$result['interval'] = $interval;
return $result;
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }
*/
public function packBearerTokenResult(
AppInfo $appInfo,
OAuth2AccessInfo $accessInfo,
?OAuth2RefreshInfo $refreshInfo = null,
?string $scope = null
): array {
$result = [
'access_token' => $accessInfo->token,
'token_type' => 'Bearer',
];
$expiresIn = $accessInfo->remainingLifetime;
if($expiresIn < OAuth2AccessInfo::DEFAULT_LIFETIME)
$result['expires_in'] = $expiresIn;
if($scope !== null)
$result['scope'] = $scope;
if($refreshInfo !== null)
$result['refresh_token'] = $refreshInfo->token;
if(in_array('openid', $accessInfo->scopes))
$result['id_token'] = $this->openIdCtx->createIdToken($appInfo, $accessInfo);
return $result;
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemAuthorisationCode(AppInfo $appInfo, bool $isAuthed, string $code, string $codeVerifier): array {
try {
$authsInfo = $this->authorisations->getAuthorisationInfo(
appInfo: $appInfo,
code: $code,
);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_grant',
'error_description' => 'No authorisation request with this code exists.',
];
}
if($authsInfo->expired)
return [
'error' => 'invalid_grant',
'error_description' => 'Authorisation request has expired.',
];
if(!$authsInfo->verifyCodeChallenge($codeVerifier))
return [
'error' => 'invalid_request',
'error_description' => 'Code challenge verification failed.',
];
$this->authorisations->deleteAuthorisation($authsInfo);
$scope = $this->checkAndBuildScopeString($appInfo, $authsInfo->scope, true);
$accessInfo = $this->createAccess(
$appInfo,
$scope,
$authsInfo->userId,
);
if($authsInfo->scope === $scope)
$scope = null;
$refreshInfo = $appInfo->issueRefreshToken
? $this->createRefresh($appInfo, $accessInfo)
: null;
return $this->packBearerTokenResult($appInfo, $accessInfo, $refreshInfo, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemRefreshToken(AppInfo $appInfo, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
try {
$refreshInfo = $this->tokens->getRefreshInfo($refreshToken, OAuth2RefreshInfoGetField::Token);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_grant',
'error_description' => 'No such refresh token exists.',
];
}
if($refreshInfo->appId !== $appInfo->id)
return [
'error' => 'invalid_grant',
'error_description' => 'This refresh token is not associated with this application.',
];
if($refreshInfo->expired)
return [
'error' => 'invalid_grant',
'error_description' => 'This refresh token has expired.',
];
$this->tokens->deleteRefresh($refreshInfo);
if(!empty($refreshInfo->accessId))
$this->tokens->deleteAccess(accessInfo: $refreshInfo->accessId);
if($scope === null)
$scope = $refreshInfo->scope;
elseif(!empty(array_diff(explode(' ', $scope), $refreshInfo->scopes)))
return [
'error' => 'invalid_scope',
'error_description' => 'You cannot request a greater scope than during initial authorisation, please restart authorisation.',
];
$scope = $this->checkAndBuildScopeString($appInfo, $scope, true);
$accessInfo = $this->createAccess(
$appInfo,
$scope,
$refreshInfo->userId,
);
if($refreshInfo->scope === $scope)
$scope = null;
$refreshInfo = null;
if($appInfo->issueRefreshToken)
$refreshInfo = $this->createRefresh($appInfo, $accessInfo);
return $this->packBearerTokenResult($appInfo, $accessInfo, $refreshInfo, $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemClientCredentials(AppInfo $appInfo, bool $isAuthed, ?string $scope = null): array {
if(!$appInfo->confidential)
return [
'error' => 'unauthorized_client',
'error_description' => 'This application is not allowed to use this grant type.',
];
if(!$isAuthed)
return [
'error' => 'invalid_client',
'error_description' => 'Application must authenticate with client secret in order to use this grant type.',
];
$requestedScope = $scope;
$scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true);
$accessInfo = $this->createAccess($appInfo, $scope);
if($requestedScope !== null && $scope === '')
return [
'error' => 'invalid_scope',
'error_description' => 'Requested scope is not valid.',
];
if($requestedScope === null || $scope === $requestedScope)
$scope = null;
return $this->packBearerTokenResult($appInfo, $accessInfo, scope: $scope);
}
/**
* @return array{
* access_token: string,
* token_type: 'Bearer',
* expires_in?: int,
* scope?: string,
* refresh_token?: string,
* }|array{ error: string, error_description: string }
*/
public function redeemDeviceCode(AppInfo $appInfo, bool $isAuthed, string $deviceCode): array {
try {
$deviceInfo = $this->devices->getDeviceInfo(
appInfo: $appInfo,
code: $deviceCode,
);
} catch(RuntimeException) {
return [
'error' => 'invalid_grant',
'error_description' => 'No such device code exists.',
];
}
if($deviceInfo->expired)
return [
'error' => 'expired_token',
'error_description' => 'This device code has expired.',
];
if($deviceInfo->speedy) {
$this->devices->incrementDevicePollInterval($deviceInfo);
return [
'error' => 'slow_down',
'error_description' => 'You are polling too fast, please increase your interval by 5 seconds.',
];
}
if($deviceInfo->pending) {
$this->devices->bumpDevicePollTime($deviceInfo);
return [
'error' => 'authorization_pending',
'error_description' => 'User has not yet completed authorisation, check again in a bit.',
];
}
$this->devices->deleteDevice($deviceInfo);
if(!$deviceInfo->approved)
return [
'error' => 'access_denied',
'error_description' => 'User has rejected authorisation attempt.',
];
if(empty($deviceInfo->userId))
return [
'error' => 'invalid_request',
'error_description' => 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.',
];
$scope = $this->checkAndBuildScopeString($appInfo, $deviceInfo->scope, true);
$accessInfo = $this->createAccess(
$appInfo,
$scope,
$deviceInfo->userId,
);
// 'scope' only has to be in the response if it differs from what was requested
if($deviceInfo->scope === $scope)
$scope = null;
$refreshInfo = $appInfo->issueRefreshToken
? $this->createRefresh($appInfo, $accessInfo)
: null;
return $this->packBearerTokenResult($appInfo, $accessInfo, $refreshInfo, $scope);
}
}