Added API RPC routes and made backing code shareable.
This commit is contained in:
parent
b43c1ad2fd
commit
034449737c
6 changed files with 1009 additions and 680 deletions
|
@ -1,6 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
namespace Hanyuu;
|
namespace Hanyuu;
|
||||||
|
|
||||||
|
use Aiwass\HmacVerificationProvider;
|
||||||
|
use Aiwass\Server\RpcServer;
|
||||||
use Index\Environment;
|
use Index\Environment;
|
||||||
use Index\Colour\Colour;
|
use Index\Colour\Colour;
|
||||||
use Index\Data\IDbConnection;
|
use Index\Data\IDbConnection;
|
||||||
|
@ -27,7 +29,10 @@ class HanyuuContext {
|
||||||
$this->misuzuRpc = new MisuzuRpcClient($config->scopeTo('misuzu'));
|
$this->misuzuRpc = new MisuzuRpcClient($config->scopeTo('misuzu'));
|
||||||
|
|
||||||
$this->appsCtx = new Apps\AppsContext($dbConn);
|
$this->appsCtx = new Apps\AppsContext($dbConn);
|
||||||
$this->oauth2Ctx = new OAuth2\OAuth2Context($dbConn);
|
$this->oauth2Ctx = new OAuth2\OAuth2Context(
|
||||||
|
$config->scopeTo('oauth2'),
|
||||||
|
$dbConn
|
||||||
|
);
|
||||||
|
|
||||||
$this->templating = new SasaeEnvironment(
|
$this->templating = new SasaeEnvironment(
|
||||||
HAU_DIR_TEMPLATES,
|
HAU_DIR_TEMPLATES,
|
||||||
|
@ -95,8 +100,11 @@ class HanyuuContext {
|
||||||
return 503;
|
return 503;
|
||||||
});
|
});
|
||||||
|
|
||||||
$routingCtx->register(new OAuth2\OAuth2Routes(
|
$routingCtx->register(new OAuth2\OAuth2ApiRoutes(
|
||||||
$this->config->scopeTo('oauth2'),
|
$this->oauth2Ctx,
|
||||||
|
$this->appsCtx
|
||||||
|
));
|
||||||
|
$routingCtx->register(new OAuth2\OAuth2WebRoutes(
|
||||||
$this->oauth2Ctx,
|
$this->oauth2Ctx,
|
||||||
$this->appsCtx,
|
$this->appsCtx,
|
||||||
$this->templating,
|
$this->templating,
|
||||||
|
@ -104,6 +112,16 @@ class HanyuuContext {
|
||||||
$this->getCSRFPSecret(...)
|
$this->getCSRFPSecret(...)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$rpcServer = new RpcServer;
|
||||||
|
$routingCtx->register($rpcServer->createRouteHandler(
|
||||||
|
new HmacVerificationProvider(fn() => $this->config->getString('aleister:secret'))
|
||||||
|
));
|
||||||
|
|
||||||
|
$rpcServer->register(new OAuth2\OAuth2RpcActions(
|
||||||
|
$this->oauth2Ctx,
|
||||||
|
$this->appsCtx
|
||||||
|
));
|
||||||
|
|
||||||
return $routingCtx;
|
return $routingCtx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
225
src/OAuth2/OAuth2ApiRoutes.php
Normal file
225
src/OAuth2/OAuth2ApiRoutes.php
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
<?php
|
||||||
|
namespace Hanyuu\OAuth2;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler};
|
||||||
|
use Syokuhou\IConfig;
|
||||||
|
use Hanyuu\Apps\AppsContext;
|
||||||
|
|
||||||
|
final class OAuth2ApiRoutes extends RouteHandler {
|
||||||
|
public function __construct(
|
||||||
|
private OAuth2Context $oauth2Ctx,
|
||||||
|
private AppsContext $appsCtx
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private static function filter($response, array $result, bool $authzHeader = false): array {
|
||||||
|
if(array_key_exists('error', $result)) {
|
||||||
|
if($authzHeader) {
|
||||||
|
$wwwAuth = sprintf('Basic realm="%s"', $_SERVER['HTTP_HOST']);
|
||||||
|
foreach($result as $name => $value)
|
||||||
|
$wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value));
|
||||||
|
|
||||||
|
$response->setStatusCode(401);
|
||||||
|
$response->setHeader('WWW-Authenticate', $wwwAuth);
|
||||||
|
} else
|
||||||
|
$response->setStatusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpPost('/oauth2/request-authorise')]
|
||||||
|
public function postRequestAuthorise($response, $request) {
|
||||||
|
$response->setHeader('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
if(!$request->isFormContent())
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$content = $request->getContent();
|
||||||
|
|
||||||
|
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
||||||
|
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
|
||||||
|
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
|
||||||
|
$clientId = $authzHeader[0];
|
||||||
|
$clientSecret = $authzHeader[1] ?? '';
|
||||||
|
} elseif($authzHeader[0] !== '') {
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'You must use the Basic method for Authorization parameters.',
|
||||||
|
], authzHeader: true);
|
||||||
|
} else {
|
||||||
|
$clientId = (string)$content->getParam('client_id');
|
||||||
|
$clientSecret = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$appsData = $this->appsCtx->getData();
|
||||||
|
try {
|
||||||
|
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
], authzHeader: $authzHeader[0] !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if($clientSecret !== '') {
|
||||||
|
// TODO: rate limiting
|
||||||
|
if(!$appInfo->verifyClientSecret($clientSecret))
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'Provided client secret is not correct for this application.',
|
||||||
|
], authzHeader: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::filter($response, $this->oauth2Ctx->createDeviceAuthorisationRequest(
|
||||||
|
$appInfo,
|
||||||
|
$content->hasParam('scope') ? (string)$content->getParam('scope') : null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpOptions('/oauth2/token')]
|
||||||
|
#[HttpPost('/oauth2/token')]
|
||||||
|
public function postToken($response, $request) {
|
||||||
|
$response->setHeader('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
$originHeaders = ['Origin', 'X-Origin', 'Referer'];
|
||||||
|
$origins = [];
|
||||||
|
foreach($originHeaders as $originHeader) {
|
||||||
|
$originHeader = $request->getHeaderFirstLine($originHeader);
|
||||||
|
if($originHeader !== '' && !in_array($originHeader, $origins))
|
||||||
|
$origins[] = $originHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!empty($origins)) {
|
||||||
|
// TODO: check if none of the provided origins is on a blocklist or something
|
||||||
|
// different origins being specified for each header should probably also be considered suspect...
|
||||||
|
|
||||||
|
$response->setHeader('Access-Control-Allow-Origin', $origins[0]);
|
||||||
|
$response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST');
|
||||||
|
$response->setHeader('Access-Control-Allow-Headers', 'Authorization');
|
||||||
|
$response->setHeader('Access-Control-Expose-Headers', 'Vary');
|
||||||
|
foreach($originHeaders as $originHeader)
|
||||||
|
$response->setHeader('Vary', $originHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
if($request->getMethod() === 'OPTIONS')
|
||||||
|
return 204;
|
||||||
|
|
||||||
|
if(!$request->isFormContent())
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$content = $request->getContent();
|
||||||
|
|
||||||
|
// authz header should be the preferred method
|
||||||
|
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
||||||
|
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
|
||||||
|
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
|
||||||
|
$clientId = $authzHeader[0];
|
||||||
|
$clientSecret = $authzHeader[1] ?? '';
|
||||||
|
} elseif($authzHeader[0] !== '') {
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
|
||||||
|
], authzHeader: true);
|
||||||
|
} else {
|
||||||
|
$clientId = (string)$content->getParam('client_id');
|
||||||
|
$clientSecret = (string)$content->getParam('client_secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
$appsData = $this->appsCtx->getData();
|
||||||
|
try {
|
||||||
|
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
], authzHeader: $authzHeader[0] !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAuthed = false;
|
||||||
|
if($clientSecret !== '') {
|
||||||
|
// TODO: rate limiting
|
||||||
|
$isAuthed = $appInfo->verifyClientSecret($clientSecret);
|
||||||
|
if(!$isAuthed)
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'Provided client secret is not correct for this application.',
|
||||||
|
], authzHeader: $authzHeader[0] !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = (string)$content->getParam('grant_type');
|
||||||
|
|
||||||
|
if($type === 'authorization_code')
|
||||||
|
return self::filter($response, $this->oauth2Ctx->redeemAuthorisationCode(
|
||||||
|
$appInfo,
|
||||||
|
$isAuthed,
|
||||||
|
(string)$content->getParam('code'),
|
||||||
|
(string)$content->getParam('code_verifier')
|
||||||
|
));
|
||||||
|
|
||||||
|
if($type === 'refresh_token')
|
||||||
|
return self::filter($response, $this->oauth2Ctx->redeemRefreshToken(
|
||||||
|
$appInfo,
|
||||||
|
$isAuthed,
|
||||||
|
(string)$content->getParam('refresh_token'),
|
||||||
|
$content->hasParam('scope') ? (string)$content->getParam('scope') : null
|
||||||
|
));
|
||||||
|
|
||||||
|
if($type === 'client_credentials')
|
||||||
|
return self::filter($response, $this->oauth2Ctx->redeemClientCredentials(
|
||||||
|
$appInfo,
|
||||||
|
$isAuthed,
|
||||||
|
$content->hasParam('scope') ? (string)$content->getParam('scope') : null
|
||||||
|
));
|
||||||
|
|
||||||
|
if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
|
||||||
|
return self::filter($response, $this->oauth2Ctx->redeemDeviceCode(
|
||||||
|
$appInfo,
|
||||||
|
$isAuthed,
|
||||||
|
(string)$content->getParam('device_code')
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::filter($response, [
|
||||||
|
'error' => 'unsupported_grant_type',
|
||||||
|
'error_description' => 'Requested grant type is not supported by this server.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is a temporary endpoint so i can actually use access tokens for something already
|
||||||
|
#[HttpGet('/oauth2/check_token_do_not_rely_on_this_existing_in_a_year')]
|
||||||
|
public function getCheckTokenDoNotRelyOnThisExistingInAYear($response, $request) {
|
||||||
|
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
||||||
|
if(strcasecmp($authzHeader[0], 'Bearer') !== 0 || count($authzHeader) < 2) {
|
||||||
|
$response->setStatusCode(401);
|
||||||
|
$response->setHeader('WWW-Authenticate', 'Bearer');
|
||||||
|
return ['success' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tokenInfo = $this->oauth2Ctx->getTokensData()->getAccessInfo($authzHeader[1], OAuth2TokensData::ACCESS_BY_TOKEN);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
$response->setStatusCode(401);
|
||||||
|
$response->setHeader('WWW-Authenticate', 'Bearer');
|
||||||
|
return ['success' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($tokenInfo->hasExpired()) {
|
||||||
|
$response->setStatusCode(401);
|
||||||
|
$response->setHeader('WWW-Authenticate', 'Bearer');
|
||||||
|
return ['success' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'user_id' => $tokenInfo->getUserId(),
|
||||||
|
'scope' => $tokenInfo->getScopes(),
|
||||||
|
'expires_in' => $tokenInfo->getRemainingLifetime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +1,29 @@
|
||||||
<?php
|
<?php
|
||||||
namespace Hanyuu\OAuth2;
|
namespace Hanyuu\OAuth2;
|
||||||
|
|
||||||
use Index\Data\IDbConnection;
|
use RuntimeException;
|
||||||
use Hanyuu\Apps\AppInfo;
|
use Hanyuu\Apps\AppInfo;
|
||||||
|
use Index\Data\IDbConnection;
|
||||||
|
use Syokuhou\IConfig;
|
||||||
|
|
||||||
class OAuth2Context {
|
class OAuth2Context {
|
||||||
private OAuth2AuthorisationData $authorisations;
|
private OAuth2AuthorisationData $authorisations;
|
||||||
private OAuth2TokensData $tokens;
|
private OAuth2TokensData $tokens;
|
||||||
private OAuth2DevicesData $devices;
|
private OAuth2DevicesData $devices;
|
||||||
|
|
||||||
public function __construct(IDbConnection $dbConn) {
|
public function __construct(
|
||||||
|
private IConfig $config,
|
||||||
|
IDbConnection $dbConn
|
||||||
|
) {
|
||||||
$this->authorisations = new OAuth2AuthorisationData($dbConn);
|
$this->authorisations = new OAuth2AuthorisationData($dbConn);
|
||||||
$this->tokens = new OAuth2TokensData($dbConn);
|
$this->tokens = new OAuth2TokensData($dbConn);
|
||||||
$this->devices = new OAuth2DevicesData($dbConn);
|
$this->devices = new OAuth2DevicesData($dbConn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getConfig(): IConfig {
|
||||||
|
return $this->config;
|
||||||
|
}
|
||||||
|
|
||||||
public function getAuthorisationData(): OAuth2AuthorisationData {
|
public function getAuthorisationData(): OAuth2AuthorisationData {
|
||||||
return $this->authorisations;
|
return $this->authorisations;
|
||||||
}
|
}
|
||||||
|
@ -47,4 +56,280 @@ class OAuth2Context {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function filterScopes(string $source): array {
|
||||||
|
$scopes = [];
|
||||||
|
|
||||||
|
$source = explode(' ', $source);
|
||||||
|
foreach($source as $scope) {
|
||||||
|
$scope = trim($scope);
|
||||||
|
if($scope !== '' && !in_array($scope, $scopes))
|
||||||
|
$scopes[] = $scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($scopes);
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDeviceAuthorisationRequest(AppInfo $appInfo, ?string $scope = null) {
|
||||||
|
$scope ??= ''; // for now
|
||||||
|
$scopes = self::filterScopes($scope);
|
||||||
|
if(!$this->validateScopes($appInfo, $scopes))
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_scope',
|
||||||
|
'error_description' => 'An invalid scope was requested.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$scope = implode(' ', $scopes);
|
||||||
|
|
||||||
|
$deviceInfo = $this->getDevicesData()->createDevice($appInfo, $scope);
|
||||||
|
|
||||||
|
$userCode = $deviceInfo->getUserCodeDashed();
|
||||||
|
$result = [
|
||||||
|
'device_code' => $deviceInfo->getCode(),
|
||||||
|
'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->getRemainingLifetime();
|
||||||
|
if($expiresIn < OAuth2DeviceInfo::DEFAULT_LIFETIME)
|
||||||
|
$result['expires_in'] = $expiresIn;
|
||||||
|
|
||||||
|
$interval = $deviceInfo->getPollInterval();
|
||||||
|
if($interval > OAuth2DeviceInfo::DEFAULT_POLL_INTERVAL)
|
||||||
|
$result['interval'] = $interval;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packBearerTokenResult(OAuth2AccessInfo $accessInfo, ?OAuth2RefreshInfo $refreshInfo = null, ?string $scope = null): array {
|
||||||
|
$result = [
|
||||||
|
'access_token' => $accessInfo->getToken(),
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
];
|
||||||
|
|
||||||
|
$expiresIn = $accessInfo->getRemainingLifetime();
|
||||||
|
if($expiresIn < OAuth2AccessInfo::DEFAULT_LIFETIME)
|
||||||
|
$result['expires_in'] = $expiresIn;
|
||||||
|
|
||||||
|
if($scope !== null)
|
||||||
|
$result['scope'] = $scope;
|
||||||
|
|
||||||
|
if($refreshInfo !== null)
|
||||||
|
$result['refresh_token'] = $refreshInfo->getToken();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redeemAuthorisationCode(AppInfo $appInfo, bool $isAuthed, string $code, string $codeVerifier) {
|
||||||
|
$authsData = $this->getAuthorisationData();
|
||||||
|
try {
|
||||||
|
$authsInfo = $authsData->getAuthorisationInfo(
|
||||||
|
appInfo: $appInfo,
|
||||||
|
code: $code,
|
||||||
|
);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_grant',
|
||||||
|
'error_description' => 'No authorisation request with this code exists.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($authsInfo->hasExpired())
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_grant',
|
||||||
|
'error_description' => 'Authorisation request has expired.',
|
||||||
|
];
|
||||||
|
if(!$authsInfo->verifyCodeChallenge($codeVerifier))
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'error_description' => 'Code challenge verification failed.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$authsData->deleteAuthorisation($authsInfo);
|
||||||
|
|
||||||
|
$scopes = $authsInfo->getScopes();
|
||||||
|
if(!$this->validateScopes($appInfo, $scopes))
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_scope',
|
||||||
|
'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$scope = implode(' ', $scopes);
|
||||||
|
|
||||||
|
$tokensData = $this->getTokensData();
|
||||||
|
$accessInfo = $tokensData->createAccess(
|
||||||
|
$appInfo,
|
||||||
|
$authsInfo->getUserId(),
|
||||||
|
scope: $scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 'scope' only has to be in the response if it differs from what was requested
|
||||||
|
if($scope === $authsInfo->getScope())
|
||||||
|
$scope = null;
|
||||||
|
|
||||||
|
$refreshInfo = $appInfo->shouldIssueRefreshToken()
|
||||||
|
? $this->createRefresh($appInfo, $accessInfo)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redeemRefreshToken(AppInfo $appInfo, bool $isAuthed, string $refreshToken, ?string $scope = null) {
|
||||||
|
$tokensData = $this->getTokensData();
|
||||||
|
try {
|
||||||
|
$refreshInfo = $tokensData->getRefreshInfo($refreshToken, OAuth2TokensData::REFRESH_BY_TOKEN);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_grant',
|
||||||
|
'error_description' => 'No such refresh token exists.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($refreshInfo->getAppId() !== $appInfo->getId())
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_grant',
|
||||||
|
'error_description' => 'This refresh token is not associated with this application.',
|
||||||
|
];
|
||||||
|
if($refreshInfo->hasExpired())
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_grant',
|
||||||
|
'error_description' => 'This refresh token has expired.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$tokensData->deleteRefresh($refreshInfo);
|
||||||
|
|
||||||
|
if($refreshInfo->hasAccessId())
|
||||||
|
$tokensData->deleteAccess(accessInfo: $refreshInfo->getAccessId());
|
||||||
|
|
||||||
|
$newScopes = self::filterScopes($scope ?? $refreshInfo->getScope());
|
||||||
|
if(!$this->validateScopes($appInfo, $newScopes))
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_scope',
|
||||||
|
'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$scope = implode(' ', $newScopes);
|
||||||
|
$accessInfo = $tokensData->createAccess(
|
||||||
|
$appInfo,
|
||||||
|
$refreshInfo->getUserId(),
|
||||||
|
scope: $scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
if($refreshInfo->getScope() === $scope)
|
||||||
|
$scope = null;
|
||||||
|
|
||||||
|
$refreshInfo = null;
|
||||||
|
if($appInfo->shouldIssueRefreshToken())
|
||||||
|
$refreshInfo = $this->createRefresh($appInfo, $accessInfo);
|
||||||
|
|
||||||
|
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redeemClientCredentials(AppInfo $appInfo, bool $isAuthed, ?string $scope = null) {
|
||||||
|
if(!$appInfo->isConfidential())
|
||||||
|
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.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$scope ??= ''; // for now
|
||||||
|
$scopes = self::filterScopes($scope);
|
||||||
|
if(!$this->validateScopes($appInfo, $scopes))
|
||||||
|
return self::error($response, 'invalid_scope', 'Requested scope is no longer valid for this application, please restart authorisation.');
|
||||||
|
|
||||||
|
$origScope = $scope;
|
||||||
|
$scope = implode(' ', $scopes);
|
||||||
|
|
||||||
|
$accessInfo = $this->getTokensData()->createAccess($appInfo, scope: $scope);
|
||||||
|
|
||||||
|
if($scope === $origScope)
|
||||||
|
$scope = null;
|
||||||
|
|
||||||
|
return $this->packBearerTokenResult($accessInfo, scope: $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redeemDeviceCode(AppInfo $appInfo, bool $isAuthed, string $deviceCode) {
|
||||||
|
$devicesData = $this->getDevicesData();
|
||||||
|
try {
|
||||||
|
$deviceInfo = $devicesData->getDeviceInfo(
|
||||||
|
appInfo: $appInfo,
|
||||||
|
code: $deviceCode
|
||||||
|
);
|
||||||
|
} catch(RuntimeException) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_grant',
|
||||||
|
'error_description' => 'No such device code exists.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($deviceInfo->hasExpired())
|
||||||
|
return [
|
||||||
|
'error' => 'expired_token',
|
||||||
|
'error_description' => 'This device code has expired.',
|
||||||
|
];
|
||||||
|
|
||||||
|
if($deviceInfo->isSpeedy()) {
|
||||||
|
$devicesData->incrementDevicePollInterval($deviceInfo);
|
||||||
|
return [
|
||||||
|
'error' => 'slow_down',
|
||||||
|
'error_description' => 'You are polling too fast, please increase your interval by 5 seconds.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($deviceInfo->isPending()) {
|
||||||
|
$devicesData->bumpDevicePollTime($deviceInfo);
|
||||||
|
return [
|
||||||
|
'error' => 'authorization_pending',
|
||||||
|
'error_description' => 'User has not yet completed authorisation, check again in a bit.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$devicesData->deleteDevice($deviceInfo);
|
||||||
|
|
||||||
|
if(!$deviceInfo->isApproved())
|
||||||
|
return [
|
||||||
|
'error' => 'access_denied',
|
||||||
|
'error_description' => 'User has rejected authorisation attempt.',
|
||||||
|
];
|
||||||
|
|
||||||
|
if(!$deviceInfo->hasUserId())
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_request',
|
||||||
|
'error_description' => 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$scopes = $deviceInfo->getScopes();
|
||||||
|
if(!$this->validateScopes($appInfo, $scopes))
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_scope',
|
||||||
|
'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.',
|
||||||
|
];
|
||||||
|
|
||||||
|
$scope = implode(' ', $scopes);
|
||||||
|
|
||||||
|
$tokensData = $this->getTokensData();
|
||||||
|
$accessInfo = $tokensData->createAccess(
|
||||||
|
$appInfo,
|
||||||
|
$deviceInfo->getUserId(),
|
||||||
|
scope: $scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 'scope' only has to be in the response if it differs from what was requested
|
||||||
|
if($scope === $deviceInfo->getScope())
|
||||||
|
$scope = null;
|
||||||
|
|
||||||
|
$refreshInfo = $appInfo->shouldIssueRefreshToken()
|
||||||
|
? $this->createRefresh($appInfo, $accessInfo)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,675 +0,0 @@
|
||||||
<?php
|
|
||||||
namespace Hanyuu\OAuth2;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use RuntimeException;
|
|
||||||
use Index\{CSRFP,XString};
|
|
||||||
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler};
|
|
||||||
use Sasae\SasaeEnvironment;
|
|
||||||
use Syokuhou\IConfig;
|
|
||||||
use Hanyuu\Apps\AppsContext;
|
|
||||||
|
|
||||||
final class OAuth2Routes extends RouteHandler {
|
|
||||||
public function __construct(
|
|
||||||
private IConfig $config,
|
|
||||||
private OAuth2Context $oauth2Ctx,
|
|
||||||
private AppsContext $appsCtx,
|
|
||||||
private SasaeEnvironment $templating,
|
|
||||||
private $getAuthInfo,
|
|
||||||
private $getCSRFPSecret
|
|
||||||
) {
|
|
||||||
if(!is_callable($getAuthInfo))
|
|
||||||
throw new InvalidArgumentException('$getAuthInfo must be callable');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function error(
|
|
||||||
$response,
|
|
||||||
string $code,
|
|
||||||
string $message = '',
|
|
||||||
string $uri = '',
|
|
||||||
bool $authzHeader = false
|
|
||||||
) {
|
|
||||||
$info = ['error' => $code];
|
|
||||||
if($message !== '')
|
|
||||||
$info['error_description'] = $message;
|
|
||||||
if($uri !== '')
|
|
||||||
$info['error_uri'] = $uri;
|
|
||||||
|
|
||||||
if($authzHeader) {
|
|
||||||
$wwwAuth = sprintf('Basic realm="%s"', $_SERVER['HTTP_HOST']);
|
|
||||||
foreach($info as $name => $value)
|
|
||||||
$wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value));
|
|
||||||
|
|
||||||
$response->setStatusCode(401);
|
|
||||||
$response->setHeader('WWW-Authenticate', $wwwAuth);
|
|
||||||
} else
|
|
||||||
$response->setStatusCode(400);
|
|
||||||
|
|
||||||
return $info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function filterScopes(string $source): array {
|
|
||||||
$scopes = [];
|
|
||||||
|
|
||||||
$source = explode(' ', $source);
|
|
||||||
foreach($source as $scope) {
|
|
||||||
$scope = trim($scope);
|
|
||||||
if($scope !== '' && !in_array($scope, $scopes))
|
|
||||||
$scopes[] = $scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($scopes);
|
|
||||||
|
|
||||||
return $scopes;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpGet('/oauth2/authorise')]
|
|
||||||
public function getAuthorise($response, $request) {
|
|
||||||
$authInfo = ($this->getAuthInfo)();
|
|
||||||
if($authInfo->isFailure())
|
|
||||||
return $this->templating->render('oauth2/login', [
|
|
||||||
'login_url' => $authInfo->getLoginUrl(),
|
|
||||||
'register_url' => $authInfo->getRegisterUrl(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
|
||||||
|
|
||||||
return $this->templating->render('oauth2/authorise', [
|
|
||||||
'csrfp_token' => $csrfp->createToken(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpPost('/oauth2/authorise')]
|
|
||||||
public function postAuthorise($response, $request) {
|
|
||||||
if(!$request->isFormContent())
|
|
||||||
return 400;
|
|
||||||
|
|
||||||
// TODO: RATE LIMITING
|
|
||||||
|
|
||||||
$authInfo = ($this->getAuthInfo)();
|
|
||||||
if($authInfo->isFailure())
|
|
||||||
return ['error' => 'auth'];
|
|
||||||
|
|
||||||
$content = $request->getContent();
|
|
||||||
|
|
||||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
|
||||||
if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
|
|
||||||
return ['error' => 'csrf'];
|
|
||||||
|
|
||||||
$codeChallengeMethod = 'plain';
|
|
||||||
if($content->hasParam('ccm')) {
|
|
||||||
$codeChallengeMethod = $content->getParam('ccm');
|
|
||||||
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
|
|
||||||
return ['error' => 'method'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$codeChallenge = $content->getParam('cc');
|
|
||||||
$codeChallengeLength = strlen($codeChallenge);
|
|
||||||
if($codeChallengeMethod === 'S256') {
|
|
||||||
if($codeChallengeLength !== 43)
|
|
||||||
return ['error' => 'length'];
|
|
||||||
} else {
|
|
||||||
if($codeChallengeLength < 43 || $codeChallengeLength > 128)
|
|
||||||
return ['error' => 'length'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$appsData = $this->appsCtx->getData();
|
|
||||||
try {
|
|
||||||
$appInfo = $appsData->getAppInfo(clientId: (string)$content->getParam('client'), deleted: false);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return ['error' => 'client'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope = '';
|
|
||||||
if($content->hasParam('scope')) {
|
|
||||||
$scopes = self::filterScopes((string)$content->getParam('scope'));
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
|
||||||
return ['error' => 'scope'];
|
|
||||||
|
|
||||||
$scope = implode(' ', $scopes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if($content->hasParam('redirect')) {
|
|
||||||
$redirectUri = (string)$content->getParam('redirect');
|
|
||||||
$redirectUriId = $appsData->getAppUriId($appInfo, $redirectUri);
|
|
||||||
if($redirectUriId === null)
|
|
||||||
return ['error' => 'format'];
|
|
||||||
} else {
|
|
||||||
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
|
||||||
if(count($uriInfos) !== 1)
|
|
||||||
return ['error' => 'required'];
|
|
||||||
|
|
||||||
$uriInfo = array_pop($uriInfos);
|
|
||||||
$redirectUri = $uriInfo->getString();
|
|
||||||
$redirectUriId = $uriInfo->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
$authsData = $this->oauth2Ctx->getAuthorisationData();
|
|
||||||
try {
|
|
||||||
$authsInfo = $authsData->createAuthorisation(
|
|
||||||
$appInfo,
|
|
||||||
$authInfo->getUserInfo()->getId(),
|
|
||||||
$redirectUriId,
|
|
||||||
$codeChallenge,
|
|
||||||
$codeChallengeMethod,
|
|
||||||
$scope
|
|
||||||
);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => $authsInfo->getCode(),
|
|
||||||
'redirect' => $redirectUri,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpGet('/oauth2/resolve-authorise-app')]
|
|
||||||
public function getResolveAuthorise($response, $request) {
|
|
||||||
// TODO: RATE LIMITING
|
|
||||||
|
|
||||||
$authInfo = ($this->getAuthInfo)();
|
|
||||||
if($authInfo->isFailure())
|
|
||||||
return ['error' => 'auth'];
|
|
||||||
|
|
||||||
$sessionInfo = $authInfo->getSessionInfo();
|
|
||||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $sessionInfo->getToken());
|
|
||||||
if(!$csrfp->verifyToken((string)$request->getParam('csrfp')))
|
|
||||||
return ['error' => 'csrf'];
|
|
||||||
|
|
||||||
$appsData = $this->appsCtx->getData();
|
|
||||||
try {
|
|
||||||
$appInfo = $appsData->getAppInfo(clientId: (string)$request->getParam('client'), deleted: false);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return ['error' => 'client'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if($request->hasParam('redirect')) {
|
|
||||||
$redirectUri = (string)$request->getParam('redirect');
|
|
||||||
if($appsData->getAppUriId($appInfo, $redirectUri) === null)
|
|
||||||
return ['error' => 'format'];
|
|
||||||
} else {
|
|
||||||
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
|
||||||
if(count($uriInfos) !== 1)
|
|
||||||
return ['error' => 'required'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if($request->hasParam('scope')) {
|
|
||||||
$scopes = self::filterScopes((string)$request->getParam('scope'));
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
|
||||||
return ['error' => 'scope'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$userInfo = $authInfo->getUserInfo();
|
|
||||||
$result = [
|
|
||||||
'app' => [
|
|
||||||
'name' => $appInfo->getName(),
|
|
||||||
'summary' => $appInfo->getSummary(),
|
|
||||||
'trusted' => $appInfo->isTrusted(),
|
|
||||||
'links' => [
|
|
||||||
['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'user' => [
|
|
||||||
'name' => $userInfo->getName(),
|
|
||||||
'colour' => $userInfo->getColour(),
|
|
||||||
'profile_uri' => $userInfo->getProfileUrl(),
|
|
||||||
'avatar_uri' => $userInfo->getAvatar('x120'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if($authInfo->isGuise()) {
|
|
||||||
$guiseInfo = $authInfo->getGuiseInfo();
|
|
||||||
$result['user']['guise'] = [
|
|
||||||
'name' => $guiseInfo->getName(),
|
|
||||||
'colour' => $guiseInfo->getColour(),
|
|
||||||
'profile_uri' => $guiseInfo->getProfileUrl(),
|
|
||||||
'revert_uri' => $guiseInfo->getRevertUrl(),
|
|
||||||
'avatar_uri' => $guiseInfo->getAvatar('x60'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpGet('/oauth2/verify')]
|
|
||||||
public function getVerify($response, $request) {
|
|
||||||
$authInfo = ($this->getAuthInfo)();
|
|
||||||
if($authInfo->isFailure())
|
|
||||||
return $this->templating->render('oauth2/login', [
|
|
||||||
'login_url' => $authInfo->getLoginUrl(),
|
|
||||||
'register_url' => $authInfo->getRegisterUrl(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
|
||||||
|
|
||||||
return $this->templating->render('oauth2/verify', [
|
|
||||||
'csrfp_token' => $csrfp->createToken(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpPost('/oauth2/verify')]
|
|
||||||
public function postVerify($response, $request) {
|
|
||||||
if(!$request->isFormContent())
|
|
||||||
return 400;
|
|
||||||
|
|
||||||
// TODO: RATE LIMITING
|
|
||||||
|
|
||||||
$authInfo = ($this->getAuthInfo)();
|
|
||||||
if($authInfo->isFailure())
|
|
||||||
return ['error' => 'auth'];
|
|
||||||
|
|
||||||
$content = $request->getContent();
|
|
||||||
|
|
||||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
|
||||||
if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
|
|
||||||
return ['error' => 'csrf'];
|
|
||||||
|
|
||||||
$devicesData = $this->oauth2Ctx->getDevicesData();
|
|
||||||
try {
|
|
||||||
$deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$content->getParam('code'));
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return ['error' => 'code'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!$deviceInfo->isPending())
|
|
||||||
return ['error' => 'approval'];
|
|
||||||
|
|
||||||
$approve = (string)$content->getParam('approve');
|
|
||||||
if(!in_array($approve, ['yes', 'no']))
|
|
||||||
return ['error' => 'invalid'];
|
|
||||||
|
|
||||||
$approved = $approve === 'yes';
|
|
||||||
$devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->getUserInfo()->getId());
|
|
||||||
|
|
||||||
return [
|
|
||||||
'approval' => $approved ? 'approved' : 'denied',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpGet('/oauth2/resolve-verify')]
|
|
||||||
public function getResolveVerify($response, $request) {
|
|
||||||
// TODO: RATE LIMITING
|
|
||||||
|
|
||||||
$authInfo = ($this->getAuthInfo)();
|
|
||||||
if($authInfo->isFailure())
|
|
||||||
return ['error' => 'auth'];
|
|
||||||
|
|
||||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
|
||||||
if(!$csrfp->verifyToken((string)$request->getParam('csrfp')))
|
|
||||||
return ['error' => 'csrf'];
|
|
||||||
|
|
||||||
$devicesData = $this->oauth2Ctx->getDevicesData();
|
|
||||||
try {
|
|
||||||
$deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$request->getParam('code'));
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return ['error' => 'code'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!$deviceInfo->isPending())
|
|
||||||
return ['error' => 'approval'];
|
|
||||||
|
|
||||||
$appsData = $this->appsCtx->getData();
|
|
||||||
try {
|
|
||||||
$appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return ['error' => 'code'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$userInfo = $authInfo->getUserInfo();
|
|
||||||
$result = [
|
|
||||||
'req' => [
|
|
||||||
'code' => $deviceInfo->getUserCode(),
|
|
||||||
],
|
|
||||||
'app' => [
|
|
||||||
'name' => $appInfo->getName(),
|
|
||||||
'summary' => $appInfo->getSummary(),
|
|
||||||
'trusted' => $appInfo->isTrusted(),
|
|
||||||
'links' => [
|
|
||||||
['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'user' => [
|
|
||||||
'name' => $userInfo->getName(),
|
|
||||||
'colour' => $userInfo->getColour(),
|
|
||||||
'profile_uri' => $userInfo->getProfileUrl(),
|
|
||||||
'avatar_uri' => $userInfo->getAvatar('x120'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if($authInfo->isGuise()) {
|
|
||||||
$guiseInfo = $authInfo->getGuiseInfo();
|
|
||||||
$result['user']['guise'] = [
|
|
||||||
'name' => $guiseInfo->getName(),
|
|
||||||
'colour' => $guiseInfo->getColour(),
|
|
||||||
'profile_uri' => $guiseInfo->getProfileUrl(),
|
|
||||||
'revert_uri' => $guiseInfo->getRevertUrl(),
|
|
||||||
'avatar_uri' => $guiseInfo->getAvatar('x60'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpPost('/oauth2/request-authorise')]
|
|
||||||
public function postRequestAuthorise($response, $request) {
|
|
||||||
$response->setHeader('Cache-Control', 'no-store');
|
|
||||||
|
|
||||||
if(!$request->isFormContent())
|
|
||||||
return self::error($response, 'invalid_request', 'Your request must use content type application/x-www-form-urlencoded.');
|
|
||||||
|
|
||||||
$content = $request->getContent();
|
|
||||||
|
|
||||||
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
|
||||||
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
|
|
||||||
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
|
|
||||||
$clientId = $authzHeader[0];
|
|
||||||
$clientSecret = $authzHeader[1] ?? '';
|
|
||||||
} elseif($authzHeader[0] !== '') {
|
|
||||||
return self::error($response, 'invalid_client', 'You must use the Basic method for Authorization parameters.', authzHeader: true);
|
|
||||||
} else {
|
|
||||||
$clientId = (string)$content->getParam('client_id');
|
|
||||||
$clientSecret = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$appsData = $this->appsCtx->getData();
|
|
||||||
try {
|
|
||||||
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return self::error(
|
|
||||||
$response, 'invalid_client', 'No application has been registered with this client ID.',
|
|
||||||
authzHeader: $authzHeader[0] !== ''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$appAuthenticated = false;
|
|
||||||
if($clientSecret !== '') {
|
|
||||||
// TODO: rate limiting
|
|
||||||
if(!$appInfo->verifyClientSecret($clientSecret))
|
|
||||||
return self::error($response, 'invalid_client', 'Provided client secret is not correct for this application.', authzHeader: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$scopes = self::filterScopes((string)$content->getParam('scope'));
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
|
||||||
return self::error($response, 'invalid_scope', 'An invalid scope was requested.');
|
|
||||||
|
|
||||||
$scope = implode(' ', $scopes);
|
|
||||||
|
|
||||||
$deviceInfo = $this->oauth2Ctx->getDevicesData()->createDevice($appInfo, $scope);
|
|
||||||
|
|
||||||
$userCode = $deviceInfo->getUserCodeDashed();
|
|
||||||
$result = [
|
|
||||||
'device_code' => $deviceInfo->getCode(),
|
|
||||||
'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->getRemainingLifetime();
|
|
||||||
if($expiresIn < OAuth2DeviceInfo::DEFAULT_LIFETIME)
|
|
||||||
$result['expires_in'] = $expiresIn;
|
|
||||||
|
|
||||||
$interval = $deviceInfo->getPollInterval();
|
|
||||||
if($interval > OAuth2DeviceInfo::DEFAULT_POLL_INTERVAL)
|
|
||||||
$result['interval'] = $interval;
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[HttpOptions('/oauth2/token')]
|
|
||||||
#[HttpPost('/oauth2/token')]
|
|
||||||
public function postToken($response, $request) {
|
|
||||||
$response->setHeader('Cache-Control', 'no-store');
|
|
||||||
|
|
||||||
$originHeaders = ['Origin', 'X-Origin', 'Referer'];
|
|
||||||
$origins = [];
|
|
||||||
foreach($originHeaders as $originHeader) {
|
|
||||||
$originHeader = $request->getHeaderFirstLine($originHeader);
|
|
||||||
if($originHeader !== '' && !in_array($originHeader, $origins))
|
|
||||||
$origins[] = $originHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!empty($origins)) {
|
|
||||||
// TODO: check if none of the provided origins is on a blocklist or something
|
|
||||||
// different origins being specified for each header should probably also be considered suspect...
|
|
||||||
|
|
||||||
$response->setHeader('Access-Control-Allow-Origin', $origins[0]);
|
|
||||||
$response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST');
|
|
||||||
$response->setHeader('Access-Control-Allow-Headers', 'Authorization');
|
|
||||||
$response->setHeader('Access-Control-Expose-Headers', 'Vary');
|
|
||||||
foreach($originHeaders as $originHeader)
|
|
||||||
$response->setHeader('Vary', $originHeader);
|
|
||||||
}
|
|
||||||
|
|
||||||
if($request->getMethod() === 'OPTIONS')
|
|
||||||
return 204;
|
|
||||||
|
|
||||||
if(!$request->isFormContent())
|
|
||||||
return self::error($response, 'invalid_request', 'Your request must use content type application/x-www-form-urlencoded.');
|
|
||||||
|
|
||||||
$content = $request->getContent();
|
|
||||||
|
|
||||||
// authz header should be the preferred method
|
|
||||||
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
|
||||||
if(strcasecmp($authzHeader[0], 'Basic') === 0) {
|
|
||||||
$authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
|
|
||||||
$clientId = $authzHeader[0];
|
|
||||||
$clientSecret = $authzHeader[1] ?? '';
|
|
||||||
} elseif($authzHeader[0] !== '') {
|
|
||||||
return self::error($response, 'invalid_client', 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.', authzHeader: true);
|
|
||||||
} else {
|
|
||||||
$clientId = (string)$content->getParam('client_id');
|
|
||||||
$clientSecret = (string)$content->getParam('client_secret');
|
|
||||||
}
|
|
||||||
|
|
||||||
$appsData = $this->appsCtx->getData();
|
|
||||||
try {
|
|
||||||
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return self::error($response, 'invalid_client', 'No application has been registered with this client id.', authzHeader: $authzHeader[0] !== '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$appAuthenticated = false;
|
|
||||||
if($clientSecret !== '') {
|
|
||||||
// TODO: rate limiting
|
|
||||||
$appAuthenticated = $appInfo->verifyClientSecret($clientSecret);
|
|
||||||
if(!$appAuthenticated)
|
|
||||||
return self::error($response, 'invalid_client', 'Provided client secret is not correct for this application.', authzHeader: $authzHeader[0] !== '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$type = (string)$content->getParam('grant_type');
|
|
||||||
if($type === 'authorization_code') {
|
|
||||||
$authsData = $this->oauth2Ctx->getAuthorisationData();
|
|
||||||
try {
|
|
||||||
$authsInfo = $authsData->getAuthorisationInfo(
|
|
||||||
appInfo: $appInfo,
|
|
||||||
code: (string)$content->getParam('code'),
|
|
||||||
);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return self::error($response, 'invalid_grant', 'No authorisation request with this code exists.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if($authsInfo->hasExpired())
|
|
||||||
return self::error($response, 'invalid_grant', 'Authorisation request has expired.');
|
|
||||||
if(!$authsInfo->verifyCodeChallenge((string)$content->getParam('code_verifier')))
|
|
||||||
return self::error($response, 'invalid_request', 'Code challenge verification failed.');
|
|
||||||
|
|
||||||
$authsData->deleteAuthorisation($authsInfo);
|
|
||||||
|
|
||||||
$scopes = $authsInfo->getScopes();
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
|
||||||
return self::error($response, 'invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
|
|
||||||
|
|
||||||
$scope = implode(' ', $scopes);
|
|
||||||
|
|
||||||
$tokensData = $this->oauth2Ctx->getTokensData();
|
|
||||||
$accessInfo = $tokensData->createAccess(
|
|
||||||
$appInfo,
|
|
||||||
$authsInfo->getUserId(),
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 'scope' only has to be in the response if it differs from what was requested
|
|
||||||
if($scope === $authsInfo->getScope())
|
|
||||||
unset($scope);
|
|
||||||
|
|
||||||
if($appInfo->shouldIssueRefreshToken())
|
|
||||||
$refreshInfo = $this->oauth2Ctx->createRefresh($appInfo, $accessInfo);
|
|
||||||
} elseif($type === 'refresh_token') {
|
|
||||||
$tokensData = $this->oauth2Ctx->getTokensData();
|
|
||||||
try {
|
|
||||||
$refreshInfo = $tokensData->getRefreshInfo((string)$content->getParam('refresh_token'), OAuth2TokensData::REFRESH_BY_TOKEN);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
return self::error($response, 'invalid_grant', 'No such refresh token exists.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if($refreshInfo->getAppId() !== $appInfo->getId())
|
|
||||||
return self::error($response, 'invalid_grant', 'This refresh token is not associated with this application.');
|
|
||||||
if($refreshInfo->hasExpired())
|
|
||||||
return self::error($response, 'invalid_grant', 'This refresh token has expired.');
|
|
||||||
|
|
||||||
$tokensData->deleteRefresh($refreshInfo);
|
|
||||||
|
|
||||||
if($refreshInfo->hasAccessId())
|
|
||||||
$tokensData->deleteAccess(accessInfo: $refreshInfo->getAccessId());
|
|
||||||
|
|
||||||
// should not contain more than the original access_token, refresh info contains the stuff we need!
|
|
||||||
$newScopes = self::filterScopes((string)$content->getParam('scope'));
|
|
||||||
$oldScopes = [];
|
|
||||||
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $newScopes))
|
|
||||||
return self::error($response, 'invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
|
|
||||||
|
|
||||||
$accessInfo = $tokensData->createAccess(
|
|
||||||
$appInfo,
|
|
||||||
$refreshInfo->getUserId(),
|
|
||||||
scope: $refreshInfo->getScope(), // just copy from refreshInfo for now, should reverify!!
|
|
||||||
);
|
|
||||||
|
|
||||||
unset($refreshInfo);
|
|
||||||
|
|
||||||
if($appInfo->shouldIssueRefreshToken())
|
|
||||||
$refreshInfo = $this->oauth2Ctx->createRefresh($appInfo, $accessInfo);
|
|
||||||
} elseif($type === 'client_credentials') {
|
|
||||||
if(!$appInfo->isConfidential())
|
|
||||||
return self::error($response, 'unauthorized_client', 'This application is not allowed to use this grant type.');
|
|
||||||
if(!$appAuthenticated)
|
|
||||||
return self::error($response, 'invalid_client', 'Application must authenticate with client secret in order to use this grant type.');
|
|
||||||
|
|
||||||
$scopes = self::filterScopes((string)$content->getParam('scope'));
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
|
||||||
return self::error($response, 'invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
|
|
||||||
|
|
||||||
$scope = implode(' ', $scopes);
|
|
||||||
|
|
||||||
$accessInfo = $this->oauth2Ctx->getTokensData()->createAccess($appInfo, scope: $scope);
|
|
||||||
|
|
||||||
// i'll just unset it, really need to dive deeper into the scope sitch
|
|
||||||
unset($scope);
|
|
||||||
} elseif($type === 'device_code' || $type === 'urn:ietf:params:oauth:grant-type:device_code') {
|
|
||||||
$devicesData = $this->oauth2Ctx->getDevicesData();
|
|
||||||
try {
|
|
||||||
$deviceInfo = $devicesData->getDeviceInfo(
|
|
||||||
appInfo: $appInfo,
|
|
||||||
code: (string)$content->getParam('device_code')
|
|
||||||
);
|
|
||||||
} catch(RuntimeException) {
|
|
||||||
return self::error($response, 'invalid_grant', 'No such device code exists.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if($deviceInfo->hasExpired())
|
|
||||||
return self::error($response, 'expired_token', 'This device code has expired.');
|
|
||||||
|
|
||||||
if($deviceInfo->isSpeedy()) {
|
|
||||||
$devicesData->incrementDevicePollInterval($deviceInfo);
|
|
||||||
return self::error($response, 'slow_down', 'You are polling too fast, please increase your interval by 5 seconds.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if($deviceInfo->isPending()) {
|
|
||||||
$devicesData->bumpDevicePollTime($deviceInfo);
|
|
||||||
return self::error($response, 'authorization_pending', 'User has not yet completed authorisation, check again in a bit.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$devicesData->deleteDevice($deviceInfo);
|
|
||||||
|
|
||||||
if(!$deviceInfo->isApproved())
|
|
||||||
return self::error($response, 'access_denied', 'User has rejected authorisation attempt.');
|
|
||||||
|
|
||||||
if(!$deviceInfo->hasUserId())
|
|
||||||
return self::error($response, 'invalid_request', 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.');
|
|
||||||
|
|
||||||
$scopes = $deviceInfo->getScopes();
|
|
||||||
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
|
||||||
return self::error($response, 'invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
|
|
||||||
|
|
||||||
$scope = implode(' ', $scopes);
|
|
||||||
|
|
||||||
$tokensData = $this->oauth2Ctx->getTokensData();
|
|
||||||
$accessInfo = $tokensData->createAccess(
|
|
||||||
$appInfo,
|
|
||||||
$deviceInfo->getUserId(),
|
|
||||||
scope: $scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 'scope' only has to be in the response if it differs from what was requested
|
|
||||||
if($scope === $deviceInfo->getScope())
|
|
||||||
unset($scope);
|
|
||||||
|
|
||||||
if($appInfo->shouldIssueRefreshToken())
|
|
||||||
$refreshInfo = $this->oauth2Ctx->createRefresh($appInfo, $accessInfo);
|
|
||||||
} else
|
|
||||||
return self::error($response, 'unsupported_grant_type', 'Requested grant type is not supported by this server.');
|
|
||||||
|
|
||||||
if(empty($accessInfo))
|
|
||||||
return self::error($response, 'invalid_grant', 'Failed to request access token.');
|
|
||||||
|
|
||||||
$result = [
|
|
||||||
'access_token' => $accessInfo->getToken(),
|
|
||||||
'token_type' => 'Bearer',
|
|
||||||
];
|
|
||||||
|
|
||||||
$expiresIn = $accessInfo->getRemainingLifetime();
|
|
||||||
if($expiresIn < OAuth2AccessInfo::DEFAULT_LIFETIME)
|
|
||||||
$result['expires_in'] = $expiresIn;
|
|
||||||
|
|
||||||
if(isset($scope))
|
|
||||||
$result['scope'] = $scope;
|
|
||||||
|
|
||||||
if(!empty($refreshInfo))
|
|
||||||
$result['refresh_token'] = $refreshInfo->getToken();
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is a temporary endpoint so i can actually use access tokens for something already
|
|
||||||
#[HttpGet('/oauth2/check_token_do_not_rely_on_this_existing_in_a_year')]
|
|
||||||
public function getCheckTokenDoNotRelyOnThisExistingInAYear($response, $request) {
|
|
||||||
$authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
|
|
||||||
if(strcasecmp($authzHeader[0], 'Bearer') !== 0 || count($authzHeader) < 2) {
|
|
||||||
$response->setStatusCode(401);
|
|
||||||
$response->setHeader('WWW-Authenticate', 'Bearer');
|
|
||||||
return ['success' => false];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$tokenInfo = $this->oauth2Ctx->getTokensData()->getAccessInfo($authzHeader[1], OAuth2TokensData::ACCESS_BY_TOKEN);
|
|
||||||
} catch(RuntimeException $ex) {
|
|
||||||
$response->setStatusCode(401);
|
|
||||||
$response->setHeader('WWW-Authenticate', 'Bearer');
|
|
||||||
return ['success' => false];
|
|
||||||
}
|
|
||||||
|
|
||||||
if($tokenInfo->hasExpired()) {
|
|
||||||
$response->setStatusCode(401);
|
|
||||||
$response->setHeader('WWW-Authenticate', 'Bearer');
|
|
||||||
return ['success' => false];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'user_id' => $tokenInfo->getUserId(),
|
|
||||||
'scope' => $tokenInfo->getScopes(),
|
|
||||||
'expires_in' => $tokenInfo->getRemainingLifetime(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
148
src/OAuth2/OAuth2RpcActions.php
Normal file
148
src/OAuth2/OAuth2RpcActions.php
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
<?php
|
||||||
|
namespace Hanyuu\OAuth2;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Hanyuu\Apps\AppsContext;
|
||||||
|
use Aiwass\Server\{RpcActionHandler,RpcProcedure};
|
||||||
|
use Syokuhou\IConfig;
|
||||||
|
|
||||||
|
final class OAuth2RpcActions extends RpcActionHandler {
|
||||||
|
public function __construct(
|
||||||
|
private OAuth2Context $oauth2Ctx,
|
||||||
|
private AppsContext $appsCtx
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private static function filterScopes(string $source): array {
|
||||||
|
$scopes = [];
|
||||||
|
|
||||||
|
$source = explode(' ', $source);
|
||||||
|
foreach($source as $scope) {
|
||||||
|
$scope = trim($scope);
|
||||||
|
if($scope !== '' && !in_array($scope, $scopes))
|
||||||
|
$scopes[] = $scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($scopes);
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:attemptAppAuth')]
|
||||||
|
public function procAttemptAppAuth(string $clientId, string $clientSecret = ''): array {
|
||||||
|
try {
|
||||||
|
$appInfo = $this->appsCtx->getData()->getAppInfo(clientId: $clientId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['method' => 'basic', 'error' => 'app'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$authed = false;
|
||||||
|
if($clientSecret !== '') {
|
||||||
|
// todo: rate limiting
|
||||||
|
|
||||||
|
if(!$appInfo->verifyClientSecret($clientSecret))
|
||||||
|
return ['method' => 'basic', 'error' => 'secret'];
|
||||||
|
|
||||||
|
$authed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'method' => 'basic',
|
||||||
|
'authed' => $authed,
|
||||||
|
'app_id' => $appInfo->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:getTokenInfo')]
|
||||||
|
public function procGetTokenInfo(string $type, string $token): array {
|
||||||
|
if(strcasecmp($type, 'Bearer') !== 0)
|
||||||
|
return ['method' => 'bearer', 'error' => 'type'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tokenInfo = $this->oauth2Ctx->getTokensData()->getAccessInfo($token, OAuth2TokensData::ACCESS_BY_TOKEN);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['method' => 'bearer', 'error' => 'token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($tokenInfo->hasExpired())
|
||||||
|
return ['method' => 'bearer', 'error' => 'expires'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'method' => 'bearer',
|
||||||
|
'authed' => true,
|
||||||
|
'app_id' => $tokenInfo->getAppId(),
|
||||||
|
'user_id' => $tokenInfo->getUserId() ?? '0',
|
||||||
|
'scope' => $tokenInfo->getScope(),
|
||||||
|
'expires_in' => $tokenInfo->getRemainingLifetime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:createAuthoriseRequest')]
|
||||||
|
public function procCreateAuthoriseRequest(string $appId, ?string $scope = null): array {
|
||||||
|
try {
|
||||||
|
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->oauth2Ctx->createDeviceAuthorisationRequest($appInfo, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:createBearerToken:authorisationCode')]
|
||||||
|
public function procCreateBearerTokenAuthzCode(string $appId, bool $isAuthed, string $code, string $codeVerifier): array {
|
||||||
|
try {
|
||||||
|
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->oauth2Ctx->redeemAuthorisationCode($appInfo, $isAuthed, $code, $codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:createBearerToken:refreshToken')]
|
||||||
|
public function procCreateBearerTokenRefreshToken(string $appId, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
|
||||||
|
try {
|
||||||
|
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->oauth2Ctx->redeemRefreshToken($appInfo, $isAuthed, $refreshToken, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:createBearerToken:clientCredentials')]
|
||||||
|
public function procCreateBearerTokenClientCreds(string $appId, bool $isAuthed, ?string $scope = null): array {
|
||||||
|
try {
|
||||||
|
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->oauth2Ctx->redeemClientCredentials($appInfo, $isAuthed, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RpcProcedure('hanyuu:oauth2:createBearerToken:deviceCode')]
|
||||||
|
public function procCreateBearerTokenDeviceCode(string $appId, bool $isAuthed, string $deviceCode): array {
|
||||||
|
try {
|
||||||
|
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return [
|
||||||
|
'error' => 'invalid_client',
|
||||||
|
'error_description' => 'No application has been registered with this client ID.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->oauth2Ctx->redeemDeviceCode($appInfo, $isAuthed, $deviceCode);
|
||||||
|
}
|
||||||
|
}
|
328
src/OAuth2/OAuth2WebRoutes.php
Normal file
328
src/OAuth2/OAuth2WebRoutes.php
Normal file
|
@ -0,0 +1,328 @@
|
||||||
|
<?php
|
||||||
|
namespace Hanyuu\OAuth2;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
use Index\CSRFP;
|
||||||
|
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
|
||||||
|
use Sasae\SasaeEnvironment;
|
||||||
|
use Syokuhou\IConfig;
|
||||||
|
use Hanyuu\Apps\AppsContext;
|
||||||
|
|
||||||
|
final class OAuth2WebRoutes extends RouteHandler {
|
||||||
|
public function __construct(
|
||||||
|
private OAuth2Context $oauth2Ctx,
|
||||||
|
private AppsContext $appsCtx,
|
||||||
|
private SasaeEnvironment $templating,
|
||||||
|
private $getAuthInfo,
|
||||||
|
private $getCSRFPSecret
|
||||||
|
) {
|
||||||
|
if(!is_callable($getAuthInfo))
|
||||||
|
throw new InvalidArgumentException('$getAuthInfo must be callable');
|
||||||
|
if(!is_callable($getCSRFPSecret))
|
||||||
|
throw new InvalidArgumentException('$getCSRFPSecret must be callable');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function filterScopes(string $source): array {
|
||||||
|
$scopes = [];
|
||||||
|
|
||||||
|
$source = explode(' ', $source);
|
||||||
|
foreach($source as $scope) {
|
||||||
|
$scope = trim($scope);
|
||||||
|
if($scope !== '' && !in_array($scope, $scopes))
|
||||||
|
$scopes[] = $scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($scopes);
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpGet('/oauth2/authorise')]
|
||||||
|
public function getAuthorise($response, $request) {
|
||||||
|
$authInfo = ($this->getAuthInfo)();
|
||||||
|
if($authInfo->isFailure())
|
||||||
|
return $this->templating->render('oauth2/login', [
|
||||||
|
'login_url' => $authInfo->getLoginUrl(),
|
||||||
|
'register_url' => $authInfo->getRegisterUrl(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
||||||
|
|
||||||
|
return $this->templating->render('oauth2/authorise', [
|
||||||
|
'csrfp_token' => $csrfp->createToken(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpPost('/oauth2/authorise')]
|
||||||
|
public function postAuthorise($response, $request) {
|
||||||
|
if(!$request->isFormContent())
|
||||||
|
return 400;
|
||||||
|
|
||||||
|
// TODO: RATE LIMITING
|
||||||
|
|
||||||
|
$authInfo = ($this->getAuthInfo)();
|
||||||
|
if($authInfo->isFailure())
|
||||||
|
return ['error' => 'auth'];
|
||||||
|
|
||||||
|
$content = $request->getContent();
|
||||||
|
|
||||||
|
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
||||||
|
if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
|
||||||
|
return ['error' => 'csrf'];
|
||||||
|
|
||||||
|
$codeChallengeMethod = 'plain';
|
||||||
|
if($content->hasParam('ccm')) {
|
||||||
|
$codeChallengeMethod = $content->getParam('ccm');
|
||||||
|
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
|
||||||
|
return ['error' => 'method'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$codeChallenge = $content->getParam('cc');
|
||||||
|
$codeChallengeLength = strlen($codeChallenge);
|
||||||
|
if($codeChallengeMethod === 'S256') {
|
||||||
|
if($codeChallengeLength !== 43)
|
||||||
|
return ['error' => 'length'];
|
||||||
|
} else {
|
||||||
|
if($codeChallengeLength < 43 || $codeChallengeLength > 128)
|
||||||
|
return ['error' => 'length'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$appsData = $this->appsCtx->getData();
|
||||||
|
try {
|
||||||
|
$appInfo = $appsData->getAppInfo(clientId: (string)$content->getParam('client'), deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['error' => 'client'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = '';
|
||||||
|
if($content->hasParam('scope')) {
|
||||||
|
$scopes = self::filterScopes((string)$content->getParam('scope'));
|
||||||
|
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
||||||
|
return ['error' => 'scope'];
|
||||||
|
|
||||||
|
$scope = implode(' ', $scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if($content->hasParam('redirect')) {
|
||||||
|
$redirectUri = (string)$content->getParam('redirect');
|
||||||
|
$redirectUriId = $appsData->getAppUriId($appInfo, $redirectUri);
|
||||||
|
if($redirectUriId === null)
|
||||||
|
return ['error' => 'format'];
|
||||||
|
} else {
|
||||||
|
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
||||||
|
if(count($uriInfos) !== 1)
|
||||||
|
return ['error' => 'required'];
|
||||||
|
|
||||||
|
$uriInfo = array_pop($uriInfos);
|
||||||
|
$redirectUri = $uriInfo->getString();
|
||||||
|
$redirectUriId = $uriInfo->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$authsData = $this->oauth2Ctx->getAuthorisationData();
|
||||||
|
try {
|
||||||
|
$authsInfo = $authsData->createAuthorisation(
|
||||||
|
$appInfo,
|
||||||
|
$authInfo->getUserInfo()->getId(),
|
||||||
|
$redirectUriId,
|
||||||
|
$codeChallenge,
|
||||||
|
$codeChallengeMethod,
|
||||||
|
$scope
|
||||||
|
);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $authsInfo->getCode(),
|
||||||
|
'redirect' => $redirectUri,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpGet('/oauth2/resolve-authorise-app')]
|
||||||
|
public function getResolveAuthorise($response, $request) {
|
||||||
|
// TODO: RATE LIMITING
|
||||||
|
|
||||||
|
$authInfo = ($this->getAuthInfo)();
|
||||||
|
if($authInfo->isFailure())
|
||||||
|
return ['error' => 'auth'];
|
||||||
|
|
||||||
|
$sessionInfo = $authInfo->getSessionInfo();
|
||||||
|
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $sessionInfo->getToken());
|
||||||
|
if(!$csrfp->verifyToken((string)$request->getParam('csrfp')))
|
||||||
|
return ['error' => 'csrf'];
|
||||||
|
|
||||||
|
$appsData = $this->appsCtx->getData();
|
||||||
|
try {
|
||||||
|
$appInfo = $appsData->getAppInfo(clientId: (string)$request->getParam('client'), deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['error' => 'client'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($request->hasParam('redirect')) {
|
||||||
|
$redirectUri = (string)$request->getParam('redirect');
|
||||||
|
if($appsData->getAppUriId($appInfo, $redirectUri) === null)
|
||||||
|
return ['error' => 'format'];
|
||||||
|
} else {
|
||||||
|
$uriInfos = $appsData->getAppUriInfos($appInfo);
|
||||||
|
if(count($uriInfos) !== 1)
|
||||||
|
return ['error' => 'required'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if($request->hasParam('scope')) {
|
||||||
|
$scopes = self::filterScopes((string)$request->getParam('scope'));
|
||||||
|
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
|
||||||
|
return ['error' => 'scope'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userInfo = $authInfo->getUserInfo();
|
||||||
|
$result = [
|
||||||
|
'app' => [
|
||||||
|
'name' => $appInfo->getName(),
|
||||||
|
'summary' => $appInfo->getSummary(),
|
||||||
|
'trusted' => $appInfo->isTrusted(),
|
||||||
|
'links' => [
|
||||||
|
['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'user' => [
|
||||||
|
'name' => $userInfo->getName(),
|
||||||
|
'colour' => $userInfo->getColour(),
|
||||||
|
'profile_uri' => $userInfo->getProfileUrl(),
|
||||||
|
'avatar_uri' => $userInfo->getAvatar('x120'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if($authInfo->isGuise()) {
|
||||||
|
$guiseInfo = $authInfo->getGuiseInfo();
|
||||||
|
$result['user']['guise'] = [
|
||||||
|
'name' => $guiseInfo->getName(),
|
||||||
|
'colour' => $guiseInfo->getColour(),
|
||||||
|
'profile_uri' => $guiseInfo->getProfileUrl(),
|
||||||
|
'revert_uri' => $guiseInfo->getRevertUrl(),
|
||||||
|
'avatar_uri' => $guiseInfo->getAvatar('x60'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpGet('/oauth2/verify')]
|
||||||
|
public function getVerify($response, $request) {
|
||||||
|
$authInfo = ($this->getAuthInfo)();
|
||||||
|
if($authInfo->isFailure())
|
||||||
|
return $this->templating->render('oauth2/login', [
|
||||||
|
'login_url' => $authInfo->getLoginUrl(),
|
||||||
|
'register_url' => $authInfo->getRegisterUrl(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
||||||
|
|
||||||
|
return $this->templating->render('oauth2/verify', [
|
||||||
|
'csrfp_token' => $csrfp->createToken(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpPost('/oauth2/verify')]
|
||||||
|
public function postVerify($response, $request) {
|
||||||
|
if(!$request->isFormContent())
|
||||||
|
return 400;
|
||||||
|
|
||||||
|
// TODO: RATE LIMITING
|
||||||
|
|
||||||
|
$authInfo = ($this->getAuthInfo)();
|
||||||
|
if($authInfo->isFailure())
|
||||||
|
return ['error' => 'auth'];
|
||||||
|
|
||||||
|
$content = $request->getContent();
|
||||||
|
|
||||||
|
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
||||||
|
if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
|
||||||
|
return ['error' => 'csrf'];
|
||||||
|
|
||||||
|
$devicesData = $this->oauth2Ctx->getDevicesData();
|
||||||
|
try {
|
||||||
|
$deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$content->getParam('code'));
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['error' => 'code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!$deviceInfo->isPending())
|
||||||
|
return ['error' => 'approval'];
|
||||||
|
|
||||||
|
$approve = (string)$content->getParam('approve');
|
||||||
|
if(!in_array($approve, ['yes', 'no']))
|
||||||
|
return ['error' => 'invalid'];
|
||||||
|
|
||||||
|
$approved = $approve === 'yes';
|
||||||
|
$devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->getUserInfo()->getId());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'approval' => $approved ? 'approved' : 'denied',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[HttpGet('/oauth2/resolve-verify')]
|
||||||
|
public function getResolveVerify($response, $request) {
|
||||||
|
// TODO: RATE LIMITING
|
||||||
|
|
||||||
|
$authInfo = ($this->getAuthInfo)();
|
||||||
|
if($authInfo->isFailure())
|
||||||
|
return ['error' => 'auth'];
|
||||||
|
|
||||||
|
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken());
|
||||||
|
if(!$csrfp->verifyToken((string)$request->getParam('csrfp')))
|
||||||
|
return ['error' => 'csrf'];
|
||||||
|
|
||||||
|
$devicesData = $this->oauth2Ctx->getDevicesData();
|
||||||
|
try {
|
||||||
|
$deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$request->getParam('code'));
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['error' => 'code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!$deviceInfo->isPending())
|
||||||
|
return ['error' => 'approval'];
|
||||||
|
|
||||||
|
$appsData = $this->appsCtx->getData();
|
||||||
|
try {
|
||||||
|
$appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false);
|
||||||
|
} catch(RuntimeException $ex) {
|
||||||
|
return ['error' => 'code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userInfo = $authInfo->getUserInfo();
|
||||||
|
$result = [
|
||||||
|
'req' => [
|
||||||
|
'code' => $deviceInfo->getUserCode(),
|
||||||
|
],
|
||||||
|
'app' => [
|
||||||
|
'name' => $appInfo->getName(),
|
||||||
|
'summary' => $appInfo->getSummary(),
|
||||||
|
'trusted' => $appInfo->isTrusted(),
|
||||||
|
'links' => [
|
||||||
|
['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'user' => [
|
||||||
|
'name' => $userInfo->getName(),
|
||||||
|
'colour' => $userInfo->getColour(),
|
||||||
|
'profile_uri' => $userInfo->getProfileUrl(),
|
||||||
|
'avatar_uri' => $userInfo->getAvatar('x120'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if($authInfo->isGuise()) {
|
||||||
|
$guiseInfo = $authInfo->getGuiseInfo();
|
||||||
|
$result['user']['guise'] = [
|
||||||
|
'name' => $guiseInfo->getName(),
|
||||||
|
'colour' => $guiseInfo->getColour(),
|
||||||
|
'profile_uri' => $guiseInfo->getProfileUrl(),
|
||||||
|
'revert_uri' => $guiseInfo->getRevertUrl(),
|
||||||
|
'avatar_uri' => $guiseInfo->getAvatar('x60'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
Reference in a new issue