From 034449737c5bc5746cbaf8319a3d969c70ae9eef Mon Sep 17 00:00:00 2001 From: flashwave Date: Sun, 25 Aug 2024 01:41:47 +0000 Subject: [PATCH] Added API RPC routes and made backing code shareable. --- src/HanyuuContext.php | 24 +- src/OAuth2/OAuth2ApiRoutes.php | 225 +++++++++++ src/OAuth2/OAuth2Context.php | 289 +++++++++++++- src/OAuth2/OAuth2Routes.php | 675 -------------------------------- src/OAuth2/OAuth2RpcActions.php | 148 +++++++ src/OAuth2/OAuth2WebRoutes.php | 328 ++++++++++++++++ 6 files changed, 1009 insertions(+), 680 deletions(-) create mode 100644 src/OAuth2/OAuth2ApiRoutes.php delete mode 100644 src/OAuth2/OAuth2Routes.php create mode 100644 src/OAuth2/OAuth2RpcActions.php create mode 100644 src/OAuth2/OAuth2WebRoutes.php diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php index 9069c66..ec6e62b 100644 --- a/src/HanyuuContext.php +++ b/src/HanyuuContext.php @@ -1,6 +1,8 @@ misuzuRpc = new MisuzuRpcClient($config->scopeTo('misuzu')); $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( HAU_DIR_TEMPLATES, @@ -95,8 +100,11 @@ class HanyuuContext { return 503; }); - $routingCtx->register(new OAuth2\OAuth2Routes( - $this->config->scopeTo('oauth2'), + $routingCtx->register(new OAuth2\OAuth2ApiRoutes( + $this->oauth2Ctx, + $this->appsCtx + )); + $routingCtx->register(new OAuth2\OAuth2WebRoutes( $this->oauth2Ctx, $this->appsCtx, $this->templating, @@ -104,6 +112,16 @@ class HanyuuContext { $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; } } diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php new file mode 100644 index 0000000..11bcf86 --- /dev/null +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -0,0 +1,225 @@ + $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(), + ]; + } +} diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index 965719c..4482295 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -1,20 +1,29 @@ authorisations = new OAuth2AuthorisationData($dbConn); $this->tokens = new OAuth2TokensData($dbConn); $this->devices = new OAuth2DevicesData($dbConn); } + public function getConfig(): IConfig { + return $this->config; + } + public function getAuthorisationData(): OAuth2AuthorisationData { return $this->authorisations; } @@ -47,4 +56,280 @@ class OAuth2Context { 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); + } } diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php deleted file mode 100644 index 293b4ac..0000000 --- a/src/OAuth2/OAuth2Routes.php +++ /dev/null @@ -1,675 +0,0 @@ - $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(), - ]; - } -} diff --git a/src/OAuth2/OAuth2RpcActions.php b/src/OAuth2/OAuth2RpcActions.php new file mode 100644 index 0000000..57617dd --- /dev/null +++ b/src/OAuth2/OAuth2RpcActions.php @@ -0,0 +1,148 @@ +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); + } +} diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php new file mode 100644 index 0000000..8397b4a --- /dev/null +++ b/src/OAuth2/OAuth2WebRoutes.php @@ -0,0 +1,328 @@ +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; + } +}