diff --git a/assets/oauth2.css/scope.css b/assets/oauth2.css/scope.css index b35d6dd..84a7b22 100644 --- a/assets/oauth2.css/scope.css +++ b/assets/oauth2.css/scope.css @@ -28,6 +28,10 @@ background-color: #0a0; margin: 2px; } +.oauth2-scope-perm-icon-warn { + mask: url('/images/circle-regular.svg') no-repeat center, url('/images/exclamation-solid.svg') no-repeat center center / 10px 10px; + background-color: #c80; +} .oauth2-scope-perm-text { font-size: .8em; line-height: 1.4em; diff --git a/assets/oauth2.js/app/scope.jsx b/assets/oauth2.js/app/scope.jsx index 328cf0b..f28ebd2 100644 --- a/assets/oauth2.js/app/scope.jsx +++ b/assets/oauth2.js/app/scope.jsx @@ -1,6 +1,10 @@ -const HanyuuOAuth2AppScopeEntry = function(text) { +const HanyuuOAuth2AppScopeEntry = function(text, warn) { + const icon =
; + if(warn) + icon.classList.add('oauth2-scope-perm-icon-warn'); + const element =
-
+ {icon}
{text}
; @@ -9,14 +13,14 @@ const HanyuuOAuth2AppScopeEntry = function(text) { }; }; -const HanyuuOAuth2AppScopeList = function() { - // TODO: actual scope listing - const permsElem =
- {new HanyuuOAuth2AppScopeEntry('Do anything because I have not made up scopes yet.')} - {new HanyuuOAuth2AppScopeEntry('Eat soup.')} - {new HanyuuOAuth2AppScopeEntry('These are placeholders.')} - {new HanyuuOAuth2AppScopeEntry('This one is really long because I want to test wrapping and how the chevron icon thing will handle it so there will be a lot of text here, the app will not be gaining anything from it but yeah sometimes you just need to explode seventy times.')} -
; +const HanyuuOAuth2AppScopeList = function(scopes) { + const permsElem =
; + if(Array.isArray(scopes) && scopes.length > 0) { + for(const scope of scopes) + if(typeof scope === 'string') + permsElem.appendChild(new HanyuuOAuth2AppScopeEntry(scope).element); + } else + permsElem.appendChild(new HanyuuOAuth2AppScopeEntry('A limited amount of things. No scope was specified by the developer.', true).element); const element =
This application will be able to:
diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js index 3374981..ee47be0 100644 --- a/assets/oauth2.js/authorise.js +++ b/assets/oauth2.js/authorise.js @@ -75,7 +75,7 @@ const HanyuuOAuth2Authorise = async () => { window.location.assign(errorUri); }; - const translateError = serverError => { + const translateError = (serverError, detail) => { if(serverError === 'auth') return displayError('access_denied'); if(serverError === 'csrf') @@ -91,7 +91,7 @@ const HanyuuOAuth2Authorise = async () => { if(serverError === 'required') return displayError('invalid_request', 'A registered redirect URI must be specified.'); if(serverError === 'scope') - return displayError('invalid_scope'); + return displayError('invalid_scope', detail === undefined ? undefined : `Requested scope "${detail.scope}" is ${detail.reason}.`); if(serverError === 'authorise') return displayError('server_error', 'Server was unable to complete authorisation.'); @@ -162,7 +162,7 @@ const HanyuuOAuth2Authorise = async () => { if(!resolved) throw 'authorisation resolve failed'; if(typeof resolved.error === 'string') - return translateError(resolved.error); + return translateError(resolved.error, resolved); const userHeader = new HanyuuOAuth2UserHeader(resolved.user); header.setElement(userHeader); @@ -184,7 +184,7 @@ const HanyuuOAuth2Authorise = async () => { if(!response) throw 'authorisation failed'; if(typeof response.error === 'string') - return translateError(response.error); + return translateError(response.error, resolved); const authoriseUri = new URL(response.redirect); authoriseUri.searchParams.set('code', response.code); @@ -206,11 +206,8 @@ const HanyuuOAuth2Authorise = async () => { return; } - const appElem = new HanyuuOAuth2AppInfo(resolved.app); - eAuthsInfo.replaceWith(appElem.element); - - const scopeElem = new HanyuuOAuth2AppScopeList(); - eAuthsScope.replaceWith(scopeElem.element); + eAuthsInfo.replaceWith(new HanyuuOAuth2AppInfo(resolved.app).element); + eAuthsScope.replaceWith(new HanyuuOAuth2AppScopeList(resolved.scope).element); fAuths.onsubmit = ev => { ev.preventDefault(); diff --git a/assets/oauth2.js/verify.js b/assets/oauth2.js/verify.js index 9504474..c69dfa9 100644 --- a/assets/oauth2.js/verify.js +++ b/assets/oauth2.js/verify.js @@ -40,9 +40,16 @@ const HanyuuOAuth2Verify = () => { alert('This code is not associated with any authorisation request.'); else if(response.error === 'approval') alert('The authorisation request associated with this code is not pending approval.'); + else if(response.error === 'expired') + alert('The authorisation request has expired, please restart the process from the application or device.'); else if(response.error === 'invalid') alert('Invalid approval state specified.'); - else + else if(response.error === 'scope') { + alert(`Requested scope "${response.scope}" is ${response.reason}.`); + loading.visible = false; + rDenied.classList.remove('hidden'); + return; + } else alert(`An unknown error occurred: ${response.error}`); loading.visible = false; @@ -83,7 +90,8 @@ const HanyuuOAuth2Verify = () => { loading.visible = true; fCode.classList.add('hidden'); - $x.get(`/oauth2/resolve-verify?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${encodeURIComponent(eUserCode.value)}`, { type: 'json' }) + userCode= encodeURIComponent(eUserCode.value); + $x.get(`/oauth2/resolve-verify?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${userCode}`, { type: 'json' }) .then(result => { const response = result.body(); @@ -102,9 +110,16 @@ const HanyuuOAuth2Verify = () => { alert('Request verification failed, please refresh and try again.'); else if(response.error === 'code') alert('This code is not associated with any authorisation request.'); + else if(response.error === 'expired') + alert('The authorisation request has expired, please restart the process from the application or device.'); else if(response.error === 'approval') alert('The authorisation request associated with this code is not pending approval.'); - else + else if(response.error === 'scope') { + verifyAuthsRequest(false).finally(() => { + alert(`Requested scope "${response.scope}" is ${response.reason}.`); + }); + return; + } else alert(`An unknown error occurred: ${response.error}`); loading.visible = false; @@ -125,11 +140,8 @@ const HanyuuOAuth2Verify = () => { return; } - const appElem = new HanyuuOAuth2AppInfo(response.app); - eAuthsInfo.replaceWith(appElem.element); - - const scopeElem = new HanyuuOAuth2AppScopeList(); - eAuthsScope.replaceWith(scopeElem.element); + eAuthsInfo.replaceWith(new HanyuuOAuth2AppInfo(response.app).element); + eAuthsScope.replaceWith(new HanyuuOAuth2AppScopeList(response.scope).element); loading.visible = false; fAuths.classList.remove('hidden'); diff --git a/public/images/circle-regular.svg b/public/images/circle-regular.svg new file mode 100644 index 0000000..bd6be27 --- /dev/null +++ b/public/images/circle-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/exclamation-solid.svg b/public/images/exclamation-solid.svg new file mode 100644 index 0000000..53d7d5b --- /dev/null +++ b/public/images/exclamation-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Apps/AppInfo.php b/src/Apps/AppInfo.php index 305d2ec..3623532 100644 --- a/src/Apps/AppInfo.php +++ b/src/Apps/AppInfo.php @@ -90,7 +90,7 @@ class AppInfo { public function getClientSecret(): string { return $this->clientSecret; } - public function verifyClientSecret(string $input): bool { + public function verifyClientSecret(#[\SensitiveParameter] string $input): bool { return $this->isConfidential() && password_verify($input, $this->clientSecret); } diff --git a/src/Apps/AppScopesInfo.php b/src/Apps/AppScopesInfo.php new file mode 100644 index 0000000..76b7c42 --- /dev/null +++ b/src/Apps/AppScopesInfo.php @@ -0,0 +1,27 @@ +allowed; + } + + public function getDenied(): array { + return $this->denied; + } + + public function isAllowed(string $scope, bool $requiresAllow): bool { + if(in_array($scope, $this->denied)) + return false; + + if($requiresAllow && !in_array($scope, $this->allowed)) + return false; + + return true; + } +} diff --git a/src/Apps/AppsContext.php b/src/Apps/AppsContext.php index 5e0803a..bd581cb 100644 --- a/src/Apps/AppsContext.php +++ b/src/Apps/AppsContext.php @@ -1,16 +1,60 @@ data = new AppsData($dbConn); + $this->apps = new AppsData($dbConn); + $this->scopes = new ScopesData($dbConn); } - public function getData(): AppsData { - return $this->data; + public function getAppsData(): AppsData { + return $this->apps; + } + + public function getScopesData(): ScopesData { + return $this->scopes; + } + + public function handleScopeString(AppInfo|string $appInfo, string $scope, bool $allowDeprecated = false, bool $sort = true, bool $breakOnFail = true): array { + return $this->handleScopes($appInfo, explode(' ', $scope), $allowDeprecated, $sort, $breakOnFail); + } + + public function handleScopes(AppInfo|string $appInfo, array $strings, bool $allowDeprecated = false, bool $sort = true, bool $breakOnFail = true): array { + if(is_string($appInfo)) + $appInfo = $this->apps->getAppInfo(appId: $appInfo, deleted: false); + + $infos = []; + + foreach($strings as $string) { + try { + $scopeInfo = $this->scopes->getScopeInfo($string, ScopesData::BY_STRING); + + if(!$allowDeprecated && $scopeInfo->isDeprecated()) { + $infos[$string] = 'deprecated'; + if($breakOnFail) break; else continue; + } + + if(!$this->apps->isAppScopeAllowed($appInfo, $scopeInfo)) { + $infos[$string] = 'restricted'; + if($breakOnFail) break; else continue; + } + + $infos[$string] = $scopeInfo; + } catch(RuntimeException $ex) { + $infos[$string] = 'unknown'; + if($breakOnFail) break; else continue; + } + } + + if($sort) + ksort($infos, SORT_STRING); + + return $infos; } } diff --git a/src/Apps/AppsData.php b/src/Apps/AppsData.php index 761d53f..c9dca98 100644 --- a/src/Apps/AppsData.php +++ b/src/Apps/AppsData.php @@ -1,17 +1,18 @@ dbConn = $dbConn; + public function __construct( + private IDbConnection $dbConn + ) { $this->cache = new DbStatementCache($dbConn); } @@ -44,6 +45,54 @@ class AppsData { return AppInfo::fromResult($result); } + public const CLIENT_ID_LENGTH = 20; + public const CLIENT_SECRET_ALGO = PASSWORD_ARGON2ID; + + public const TYPE_PUBLIC = 'public'; + public const TYPE_CONFIDENTIAL = 'confidential'; + public const TYPES = [self::TYPE_PUBLIC, self::TYPE_CONFIDENTIAL]; + + public function createApp( + string $name, + string $type, + bool $trusted = false, + string $summary = '', + string $website = '', + ?string $clientId = null, + #[\SensitiveParameter] ?string $clientSecret = null, + ?int $refreshLifetime = null + ): AppInfo { + if(trim($name) === '') + throw new InvalidArgumentException('$name may not be empty'); + if(!in_array($type, self::TYPES)) + throw new InvalidArgumentException('$type is not a valid type'); + + if($clientId === null) + $clientId = XString::random(self::CLIENT_ID_LENGTH); + elseif(trim($clientId) === '') + throw new InvalidArgumentException('$clientId may not be empty'); + + $summary = trim($summary); + $website = trim($website); + $clientSecret = $clientSecret === null ? '' : password_hash($clientSecret, self::CLIENT_SECRET_ALGO); + + if($type === self::TYPE_CONFIDENTIAL && $clientSecret === '') + throw new InvalidArgumentException('$clientSecret must be specified for confidential clients'); + + $stmt = $this->cache->get('INSERT INTO hau_apps (app_name, app_summary, app_website, app_trusted, app_type, app_refresh_lifetime, app_client_id, app_client_secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + $stmt->addParameter(1, $name); + $stmt->addParameter(2, $summary); + $stmt->addParameter(3, $website); + $stmt->addParameter(4, $trusted ? 1 : 0); + $stmt->addParameter(5, $type); + $stmt->addParameter(6, $refreshLifetime); + $stmt->addParameter(7, $clientId); + $stmt->addParameter(8, $clientSecret); + $stmt->execute(); + + return $this->getScopeInfo(appId: (string)$this->dbConn->getLastInsertId()); + } + public function countAppUris(AppInfo|string $appInfo): int { $stmt = $this->cache->get('SELECT COUNT(*) FROM hau_apps_uris WHERE app_id = ?'); $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); @@ -87,4 +136,53 @@ class AppsData { $result = $stmt->getResult(); return $result->next() ? $result->getStringOrNull(0) : null; } + + public function getAppScopes(AppInfo|string $appInfo): array { + $allowed = []; + $denied = []; + + $stmt = $this->cache->get('SELECT (SELECT scope_string FROM hau_scopes AS _s WHERE _s.scope_id = _as.scope_id), scope_allowed FROM hau_apps_scopes AS _as WHERE app_id = ?'); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + while($result->next()) { + $scopeStr = $result->getString(0); + if($result->getBoolean(1)) + $allowed[] = $scopeStr; + else + $denied[] = $scopeStr; + } + + return new AppScopesInfo($allowed, $denied); + } + + public function setAppScopeAllow(AppInfo|string $appInfo, ScopeInfo $scopeInfo, ?bool $allowed): void { + if($allowed !== $scopeInfo->isRestricted()) { + $stmt = $this->cache->get('DELETE FROM hau_apps_scopes WHERE app_id = ? AND scope_id = ?'); + } else { + $stmt = $this->cache->get('REPLACE INTO hau_apps_scopes (app_id, scope_id, scope_allowed) VALUES (?, ?, ?)'); + $stmt->addParameter(3, $allowed ? 1 : 0); + } + + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->addParameter(2, $scopeInfo->getId()); + $stmt->execute(); + } + + public function isAppScopeAllowed(AppInfo|string $appInfo, ScopeInfo|string $scopeInfo): bool { + $stmt = $this->cache->get('SELECT ? AS _scope_id, COALESCE( + (SELECT scope_allowed FROM hau_apps_scopes WHERE app_id = ? AND scope_id = _scope_id), + (SELECT NOT scope_restricted FROM hau_scopes WHERE scope_id = _scope_id) + )'); + $stmt->addParameter(1, $scopeInfo instanceof ScopeInfo ? $scopeInfo->getId() : $scopeInfo); + $stmt->addParameter(2, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('failed to check scope ACL'); + + return $result->getBoolean(1); + } } diff --git a/src/Apps/ScopeInfo.php b/src/Apps/ScopeInfo.php new file mode 100644 index 0000000..91befb8 --- /dev/null +++ b/src/Apps/ScopeInfo.php @@ -0,0 +1,58 @@ +getString(0), + $result->getString(1), + $result->getBoolean(2), + $result->getString(3), + $result->getInteger(4), + $result->getIntegerOrNull(5) + ); + } + + public function getId(): string { + return $this->id; + } + + public function getString(): string { + return $this->string; + } + + public function isRestricted(): bool { + return $this->restricted; + } + + public function hasSummary(): bool { + return $this->summary !== ''; + } + + public function getSummary(): string { + return $this->summary; + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function isDeprecated(): bool { + return $this->deprecated !== null; + } + + public function getDeprecatedTime(): ?int { + return $this->deprecated; + } +} diff --git a/src/Apps/ScopesData.php b/src/Apps/ScopesData.php new file mode 100644 index 0000000..714e499 --- /dev/null +++ b/src/Apps/ScopesData.php @@ -0,0 +1,56 @@ +cache = new DbStatementCache($dbConn); + } + + public const BY_ID = 'id'; + public const BY_STRING = 'string'; + + public function getScopeInfo(string $value, string $mode): ScopeInfo { + $stmt = $this->cache->get(sprintf( + 'SELECT scope_id, scope_string, scope_restricted, scope_summary, UNIX_TIMESTAMP(scope_created), UNIX_TIMESTAMP(scope_deprecated) FROM hau_scopes WHERE %s = ?', + match($mode) { + self::BY_ID => 'scope_id', + self::BY_STRING => 'scope_string', + default => throw new InvalidArgumentException('$mode is not a valid mode') + } + )); + + $stmt->addParameter(1, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Scope not found.'); + + return ScopeInfo::fromResult($result); + } + + public function createScope(string $string, bool $restricted, string $summary = ''): ScopeInfo { + if(trim($string) === '') + throw new InvalidArgumentException('$string may not be empty'); + if(preg_match('#[^A-Za-z0-9:-]#', $string)) + throw new InvalidArgumentException('$string contains invalid characters'); + + $summary = trim($summary); + + $stmt = $this->cache->get('INSERT INTO hau_scopes (scope_string, scope_restricted, scope_summary) VALUES (?, ?, ?)'); + $stmt->addParameter(1, $string); + $stmt->addParameter(2, $restricted ? 1 : 0); + $stmt->addParameter(3, $summary); + $stmt->execute(); + + return $this->getScopeInfo((string)$this->dbConn->getLastInsertId(), self::BY_ID); + } +} diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php index ec6e62b..7a2298a 100644 --- a/src/HanyuuContext.php +++ b/src/HanyuuContext.php @@ -31,7 +31,8 @@ class HanyuuContext { $this->appsCtx = new Apps\AppsContext($dbConn); $this->oauth2Ctx = new OAuth2\OAuth2Context( $config->scopeTo('oauth2'), - $dbConn + $dbConn, + $this->appsCtx ); $this->templating = new SasaeEnvironment( @@ -100,13 +101,9 @@ class HanyuuContext { return 503; }); - $routingCtx->register(new OAuth2\OAuth2ApiRoutes( - $this->oauth2Ctx, - $this->appsCtx - )); + $routingCtx->register(new OAuth2\OAuth2ApiRoutes($this->oauth2Ctx)); $routingCtx->register(new OAuth2\OAuth2WebRoutes( $this->oauth2Ctx, - $this->appsCtx, $this->templating, $this->getAuthInfo(...), $this->getCSRFPSecret(...) @@ -117,10 +114,7 @@ class HanyuuContext { new HmacVerificationProvider(fn() => $this->config->getString('aleister:secret')) )); - $rpcServer->register(new OAuth2\OAuth2RpcActions( - $this->oauth2Ctx, - $this->appsCtx - )); + $rpcServer->register(new OAuth2\OAuth2RpcActions($this->oauth2Ctx)); return $routingCtx; } diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php index 11bcf86..b2e1519 100644 --- a/src/OAuth2/OAuth2ApiRoutes.php +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -4,12 +4,10 @@ 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 OAuth2Context $oauth2Ctx ) {} private static function filter($response, array $result, bool $authzHeader = false): array { @@ -55,7 +53,8 @@ final class OAuth2ApiRoutes extends RouteHandler { $clientSecret = ''; } - $appsData = $this->appsCtx->getData(); + $appsCtx = $this->oauth2Ctx->getApps(); + $appsData = $appsCtx->getAppsData(); try { $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { @@ -132,7 +131,8 @@ final class OAuth2ApiRoutes extends RouteHandler { $clientSecret = (string)$content->getParam('client_secret'); } - $appsData = $this->appsCtx->getData(); + $appsCtx = $this->oauth2Ctx->getApps(); + $appsData = $appsCtx->getAppsData(); try { $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index 4482295..6f9c91e 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -2,7 +2,7 @@ namespace Hanyuu\OAuth2; use RuntimeException; -use Hanyuu\Apps\AppInfo; +use Hanyuu\Apps\{AppsContext,AppInfo}; use Index\Data\IDbConnection; use Syokuhou\IConfig; @@ -13,7 +13,8 @@ class OAuth2Context { public function __construct( private IConfig $config, - IDbConnection $dbConn + IDbConnection $dbConn, + private AppsContext $appsCtx ) { $this->authorisations = new OAuth2AuthorisationData($dbConn); $this->tokens = new OAuth2TokensData($dbConn); @@ -24,6 +25,10 @@ class OAuth2Context { return $this->config; } + public function getApps(): AppsContext { + return $this->appsCtx; + } + public function getAuthorisationData(): OAuth2AuthorisationData { return $this->authorisations; } @@ -49,39 +54,34 @@ class OAuth2Context { ); } - public function validateScopes(AppInfo $appInfo, array $scopes): bool { - foreach($scopes as $scope) - if(strlen($scope) > 128) // rather than this, actually check if they are defined/supported or smth - return false; + public function checkAndBuildScopeString(AppInfo $appInfo, ?string $scope = null, bool $skipFail = false): string|array { + if($scope === null) + return ''; - return true; - } + $scopeInfos = $this->appsCtx->handleScopeString($appInfo, $scope, breakOnFail: !$skipFail); + $scope = []; - public static function filterScopes(string $source): array { - $scopes = []; + foreach($scopeInfos as $scopeName => $scopeInfo) { + if(is_string($scopeInfo)) { + if($skipFail) + continue; - $source = explode(' ', $source); - foreach($source as $scope) { - $scope = trim($scope); - if($scope !== '' && !in_array($scope, $scopes)) - $scopes[] = $scope; + return [ + 'error' => 'invalid_scope', + 'error_description' => sprintf('Requested scope "%s" is %s.', $scopeName, $scopeInfo), + ]; + } + + $scope[] = $scopeInfo->getString(); } - sort($scopes); - - return $scopes; + return implode(' ', $scope); } 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); + $scope = $this->checkAndBuildScopeString($appInfo, $scope); + if(is_array($scope)) + return $scope; $deviceInfo = $this->getDevicesData()->createDevice($appInfo, $scope); @@ -150,14 +150,7 @@ class OAuth2Context { $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); + $scope = $this->checkAndBuildScopeString($appInfo, $authsInfo->getScope(), true); $tokensData = $this->getTokensData(); $accessInfo = $tokensData->createAccess( @@ -166,8 +159,7 @@ class OAuth2Context { scope: $scope, ); - // 'scope' only has to be in the response if it differs from what was requested - if($scope === $authsInfo->getScope()) + if($authsInfo->getScope() === $scope) $scope = null; $refreshInfo = $appInfo->shouldIssueRefreshToken() @@ -204,14 +196,16 @@ class OAuth2Context { if($refreshInfo->hasAccessId()) $tokensData->deleteAccess(accessInfo: $refreshInfo->getAccessId()); - $newScopes = self::filterScopes($scope ?? $refreshInfo->getScope()); - if(!$this->validateScopes($appInfo, $newScopes)) + if($scope === null) + $scope = $refreshInfo->getScope(); + elseif(!empty(array_diff(explode(' ', $scope), $refreshInfo->getScopes()))) return [ 'error' => 'invalid_scope', - 'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.', + 'error_description' => 'You cannot request a greater scope than during initial authorisation, please restart authorisation.', ]; - $scope = implode(' ', $newScopes); + $scope = $this->checkAndBuildScopeString($appInfo, $scope, true); + $accessInfo = $tokensData->createAccess( $appInfo, $refreshInfo->getUserId(), @@ -240,17 +234,17 @@ class OAuth2Context { '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); - + $requestedScope = $scope; + $scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true); $accessInfo = $this->getTokensData()->createAccess($appInfo, scope: $scope); - if($scope === $origScope) + if($requestedScope !== null && $scope === '') + return [ + 'error' => 'invalid_scope', + 'error_description' => 'Requested scope is not valid.', + ]; + + if($requestedScope === null || $scope === $requestedScope) $scope = null; return $this->packBearerTokenResult($accessInfo, scope: $scope); @@ -306,14 +300,7 @@ class OAuth2Context { '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); + $scope = $this->checkAndBuildScopeString($appInfo, $deviceInfo->getScope(), true); $tokensData = $this->getTokensData(); $accessInfo = $tokensData->createAccess( @@ -323,7 +310,7 @@ class OAuth2Context { ); // 'scope' only has to be in the response if it differs from what was requested - if($scope === $deviceInfo->getScope()) + if($deviceInfo->getScope() === $scope) $scope = null; $refreshInfo = $appInfo->shouldIssueRefreshToken() diff --git a/src/OAuth2/OAuth2RpcActions.php b/src/OAuth2/OAuth2RpcActions.php index 8f57749..3028927 100644 --- a/src/OAuth2/OAuth2RpcActions.php +++ b/src/OAuth2/OAuth2RpcActions.php @@ -8,8 +8,7 @@ use Syokuhou\IConfig; final class OAuth2RpcActions extends RpcActionHandler { public function __construct( - private OAuth2Context $oauth2Ctx, - private AppsContext $appsCtx + private OAuth2Context $oauth2Ctx ) {} private static function filterScopes(string $source): array { @@ -30,14 +29,14 @@ final class OAuth2RpcActions extends RpcActionHandler { #[RpcProcedure('hanyuu:oauth2:attemptAppAuth')] public function procAttemptAppAuth(string $remoteAddr, string $clientId, string $clientSecret = ''): array { try { - $appInfo = $this->appsCtx->getData()->getAppInfo(clientId: $clientId, deleted: false); + $appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { return ['method' => 'basic', 'error' => 'app']; } $authed = false; if($clientSecret !== '') { - // todo: rate limiting + // TODO: rate limiting if(!$appInfo->verifyClientSecret($clientSecret)) return ['method' => 'basic', 'error' => 'secret']; @@ -77,7 +76,7 @@ final class OAuth2RpcActions extends RpcActionHandler { #[RpcProcedure('hanyuu:oauth2:createAuthoriseRequest')] public function procCreateAuthoriseRequest(string $appId, ?string $scope = null): array { try { - $appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false); + $appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false); } catch(RuntimeException $ex) { return [ 'error' => 'invalid_client', @@ -91,7 +90,7 @@ final class OAuth2RpcActions extends RpcActionHandler { #[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); + $appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false); } catch(RuntimeException $ex) { return [ 'error' => 'invalid_client', @@ -105,7 +104,7 @@ final class OAuth2RpcActions extends RpcActionHandler { #[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); + $appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false); } catch(RuntimeException $ex) { return [ 'error' => 'invalid_client', @@ -119,7 +118,7 @@ final class OAuth2RpcActions extends RpcActionHandler { #[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); + $appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false); } catch(RuntimeException $ex) { return [ 'error' => 'invalid_client', @@ -133,7 +132,7 @@ final class OAuth2RpcActions extends RpcActionHandler { #[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); + $appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false); } catch(RuntimeException $ex) { return [ 'error' => 'invalid_client', diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php index 8397b4a..a955e36 100644 --- a/src/OAuth2/OAuth2WebRoutes.php +++ b/src/OAuth2/OAuth2WebRoutes.php @@ -7,12 +7,10 @@ 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 @@ -23,21 +21,6 @@ final class OAuth2WebRoutes extends RouteHandler { 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)(); @@ -88,21 +71,27 @@ final class OAuth2WebRoutes extends RouteHandler { return ['error' => 'length']; } - $appsData = $this->appsCtx->getData(); + $appsCtx = $this->oauth2Ctx->getApps(); + $appsData = $appsCtx->getAppsData(); 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 = []; + $scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, (string)$content->getParam('scope')); - $scope = implode(' ', $scopes); - } + foreach($scopeInfos as $scopeName => $scopeInfo) { + if(is_string($scopeInfo)) + return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo]; + + $scope[] = $scopeInfo->getString(); + } + + $scope = implode(' ', $scope); + } else $scope = ''; if($content->hasParam('redirect')) { $redirectUri = (string)$content->getParam('redirect'); @@ -152,7 +141,7 @@ final class OAuth2WebRoutes extends RouteHandler { if(!$csrfp->verifyToken((string)$request->getParam('csrfp'))) return ['error' => 'csrf']; - $appsData = $this->appsCtx->getData(); + $appsData = $this->oauth2Ctx->getApps()->getAppsData(); try { $appInfo = $appsData->getAppInfo(clientId: (string)$request->getParam('client'), deleted: false); } catch(RuntimeException $ex) { @@ -169,10 +158,16 @@ final class OAuth2WebRoutes extends RouteHandler { return ['error' => 'required']; } + $scope = []; if($request->hasParam('scope')) { - $scopes = self::filterScopes((string)$request->getParam('scope')); - if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) - return ['error' => 'scope']; + $scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, (string)$request->getParam('scope')); + + foreach($scopeInfos as $scopeName => $scopeInfo) { + if(is_string($scopeInfo)) + return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo]; + + $scope[] = $scopeInfo->getSummary(); + } } $userInfo = $authInfo->getUserInfo(); @@ -191,6 +186,7 @@ final class OAuth2WebRoutes extends RouteHandler { 'profile_uri' => $userInfo->getProfileUrl(), 'avatar_uri' => $userInfo->getAvatar('x120'), ], + 'scope' => $scope, ]; if($authInfo->isGuise()) { @@ -240,6 +236,10 @@ final class OAuth2WebRoutes extends RouteHandler { if(!$csrfp->verifyToken((string)$content->getParam('_csrfp'))) return ['error' => 'csrf']; + $approve = (string)$content->getParam('approve'); + if(!in_array($approve, ['yes', 'no'])) + return ['error' => 'invalid']; + $devicesData = $this->oauth2Ctx->getDevicesData(); try { $deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$content->getParam('code')); @@ -247,16 +247,40 @@ final class OAuth2WebRoutes extends RouteHandler { return ['error' => 'code']; } + if($deviceInfo->hasExpired()) + return ['error' => 'expired']; + if(!$deviceInfo->isPending()) return ['error' => 'approval']; - $approve = (string)$content->getParam('approve'); - if(!in_array($approve, ['yes', 'no'])) - return ['error' => 'invalid']; - $approved = $approve === 'yes'; + + $error = null; + if($approved) { + $appsData = $this->oauth2Ctx->getApps()->getAppsData(); + try { + $appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false); + } catch(RuntimeException $ex) { + return ['error' => 'code']; + } + + $scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, $deviceInfo->getScope()); + foreach($scopeInfos as $scopeName => $scopeInfo) { + if(is_string($scopeInfo)) { + $approved = false; + $error = ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo]; + break; + } + + $scope[] = $scopeInfo->getSummary(); + } + } + $devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->getUserInfo()->getId()); + if($error !== null) + return $error; + return [ 'approval' => $approved ? 'approved' : 'denied', ]; @@ -281,16 +305,28 @@ final class OAuth2WebRoutes extends RouteHandler { return ['error' => 'code']; } + if($deviceInfo->hasExpired()) + return ['error' => 'expired']; + if(!$deviceInfo->isPending()) return ['error' => 'approval']; - $appsData = $this->appsCtx->getData(); + $appsData = $this->oauth2Ctx->getApps()->getAppsData(); try { $appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false); } catch(RuntimeException $ex) { return ['error' => 'code']; } + $scope = []; + $scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, $deviceInfo->getScope()); + foreach($scopeInfos as $scopeName => $scopeInfo) { + if(is_string($scopeInfo)) + return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo]; + + $scope[] = $scopeInfo->getSummary(); + } + $userInfo = $authInfo->getUserInfo(); $result = [ 'req' => [ @@ -304,6 +340,7 @@ final class OAuth2WebRoutes extends RouteHandler { ['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()], ], ], + 'scope' => $scope, 'user' => [ 'name' => $userInfo->getName(), 'colour' => $userInfo->getColour(),