- {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(),