404 lines
13 KiB
PHP
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);
|
|
}
|
|
}
|