diff --git a/api/modules/oauth/models/OauthProcess.php b/api/modules/oauth/models/OauthProcess.php index b249493..49df346 100644 --- a/api/modules/oauth/models/OauthProcess.php +++ b/api/modules/oauth/models/OauthProcess.php @@ -270,6 +270,7 @@ final readonly class OauthProcess { 'response_type', 'scope', 'state', + 'user_code', ])), 'client' => [ 'id' => $client->id, @@ -306,14 +307,19 @@ final readonly class OauthProcess { */ private function buildCompleteErrorResponse(OAuthServerException $e): array { $hint = $e->getPayload()['hint'] ?? ''; + $parameter = null; if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { $parameter = $matches[1]; } + if ($parameter === null && str_starts_with($e->getErrorType(), 'invalid_')) { + $parameter = substr($e->getErrorType(), 8); // 8 is the length of the "invalid_" + } + $response = [ 'success' => false, 'error' => $e->getErrorType(), - 'parameter' => $parameter ?? null, + 'parameter' => $parameter, 'statusCode' => $e->getHttpStatusCode(), ]; diff --git a/api/tests/functional/oauth/ValidateCest.php b/api/tests/functional/oauth/ValidateCest.php index 8cf24d0..85a68cc 100644 --- a/api/tests/functional/oauth/ValidateCest.php +++ b/api/tests/functional/oauth/ValidateCest.php @@ -5,10 +5,9 @@ namespace api\tests\functional\oauth; use api\tests\FunctionalTester; -class ValidateCest { +final class ValidateCest { - public function completelyValidateValidRequest(FunctionalTester $I): void { - $I->wantTo('validate and obtain information about new oauth request'); + public function successfullyValidateRequestForAuthFlow(FunctionalTester $I): void { $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', 'redirect_uri' => 'http://ely.by', @@ -41,7 +40,31 @@ class ValidateCest { ]); } - public function completelyValidateValidRequestWithOverriddenDescription(FunctionalTester $I): void { + public function successfullyValidateRequestForDeviceCode(FunctionalTester $I): void { + $I->sendGET('/api/oauth2/v1/validate', [ + 'user_code' => 'AAAABBBB', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'oAuth' => [ + 'user_code' => 'AAAABBBB', + ], + 'client' => [ + 'id' => 'ely', + 'name' => 'Ely.by', + 'description' => 'Всем знакомое елуби', + ], + 'session' => [ + 'scopes' => [ + 'minecraft_server_session', + 'account_info', + ], + ], + ]); + } + + public function successfullyValidateRequestWithOverriddenDescriptionForAuthFlow(FunctionalTester $I): void { $I->wantTo('validate and get information with description replacement'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', @@ -57,7 +80,7 @@ class ValidateCest { ]); } - public function unknownClientId(FunctionalTester $I): void { + public function unknownClientIdAuthFlow(FunctionalTester $I): void { $I->wantTo('check behavior on invalid client id'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'non-exists-client', @@ -72,7 +95,20 @@ class ValidateCest { ]); } - public function invalidScopes(FunctionalTester $I): void { + public function invalidCodeForDeviceCode(FunctionalTester $I): void { + $I->sendGET('/api/oauth2/v1/validate', [ + 'user_code' => 'XXXXXXXX', + ]); + $I->canSeeResponseCodeIs(401); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'error' => 'invalid_user_code', + 'parameter' => 'user_code', + 'statusCode' => 401, + ]); + } + + public function invalidScopesAuthFlow(FunctionalTester $I): void { $I->wantTo('check behavior on some invalid scopes'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', @@ -91,7 +127,7 @@ class ValidateCest { $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); } - public function requestInternalScope(FunctionalTester $I): void { + public function requestInternalScopeAuthFlow(FunctionalTester $I): void { $I->wantTo('check behavior on request internal scope'); $I->sendGET('/api/oauth2/v1/validate', [ 'client_id' => 'ely', diff --git a/common/components/OAuth2/AuthorizationServerFactory.php b/common/components/OAuth2/AuthorizationServerFactory.php index c427b8c..06750e9 100644 --- a/common/components/OAuth2/AuthorizationServerFactory.php +++ b/common/components/OAuth2/AuthorizationServerFactory.php @@ -6,7 +6,6 @@ namespace common\components\OAuth2; use Carbon\CarbonInterval; use DateInterval; use League\OAuth2\Server\AuthorizationServer; -use League\OAuth2\Server\Grant\DeviceCodeGrant; use Yii; final class AuthorizationServerFactory { @@ -45,7 +44,7 @@ final class AuthorizationServerFactory { $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling $verificationUri = Yii::$app->request->getHostInfo() . '/code'; - $deviceCodeGrant = new DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri); + $deviceCodeGrant = new Grants\DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri); $deviceCodeGrant->setIntervalVisibility(true); $authServer->enableGrantType($deviceCodeGrant, $accessTokenTTL); $deviceCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling diff --git a/common/components/OAuth2/Entities/DeviceCodeEntity.php b/common/components/OAuth2/Entities/DeviceCodeEntity.php index b01eb3b..a547927 100644 --- a/common/components/OAuth2/Entities/DeviceCodeEntity.php +++ b/common/components/OAuth2/Entities/DeviceCodeEntity.php @@ -3,6 +3,8 @@ declare(strict_types=1); namespace common\components\OAuth2\Entities; +use Carbon\CarbonImmutable; +use common\models\OauthDeviceCode; use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; use League\OAuth2\Server\Entities\Traits\DeviceCodeTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; @@ -13,4 +15,26 @@ final class DeviceCodeEntity implements DeviceCodeEntityInterface { use TokenEntityTrait; use DeviceCodeTrait; + public static function fromModel(OauthDeviceCode $model): self { + $entity = new self(); + $entity->setIdentifier($model->device_code); // @phpstan-ignore argument.type + $entity->setUserCode($model->user_code); + $entity->setClient(ClientEntity::fromModel($model->client)); + $entity->setExpiryDateTime(CarbonImmutable::createFromTimestampUTC($model->expires_at)); + foreach ($model->scopes as $scope) { + $entity->addScope(new ScopeEntity($scope)); + } + + if ($model->account_id !== null) { + $entity->setUserIdentifier((string)$model->account_id); + $entity->setUserApproved((bool)$model->is_approved === true); + } + + if ($model->last_polled_at !== null) { + $entity->setLastPolledAt(CarbonImmutable::createFromTimestampUTC($model->last_polled_at)); + } + + return $entity; + } + } diff --git a/common/components/OAuth2/Grants/DeviceCodeGrant.php b/common/components/OAuth2/Grants/DeviceCodeGrant.php new file mode 100644 index 0000000..3de9317 --- /dev/null +++ b/common/components/OAuth2/Grants/DeviceCodeGrant.php @@ -0,0 +1,62 @@ +getQueryParams()['user_code']); + } + + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + */ + public function validateAuthorizationRequest(ServerRequestInterface $request): AuthorizationRequestInterface { + $userCode = $this->getQueryStringParameter('user_code', $request); + if ($userCode === null) { + throw OAuthServerException::invalidRequest('user_code'); + } + + $deviceCode = $this->deviceCodeRepository->getDeviceCodeEntityByUserCode($userCode); + if ($deviceCode === null) { + throw new OAuthServerException('Client authentication failed', 4, 'invalid_user_code', 401); + } + + $authorizationRequest = new AuthorizationRequest(); + $authorizationRequest->setGrantTypeId($this->getIdentifier()); + $authorizationRequest->setClient($deviceCode->getClient()); + $authorizationRequest->setScopes($deviceCode->getScopes()); + + return $authorizationRequest; + } + +} diff --git a/common/components/OAuth2/Repositories/DeviceCodeRepository.php b/common/components/OAuth2/Repositories/DeviceCodeRepository.php index adbc362..37a0d26 100644 --- a/common/components/OAuth2/Repositories/DeviceCodeRepository.php +++ b/common/components/OAuth2/Repositories/DeviceCodeRepository.php @@ -3,18 +3,14 @@ declare(strict_types=1); namespace common\components\OAuth2\Repositories; -use Carbon\CarbonImmutable; -use common\components\OAuth2\Entities\ClientEntity; use common\components\OAuth2\Entities\DeviceCodeEntity; -use common\components\OAuth2\Entities\ScopeEntity; use common\models\OauthDeviceCode; use League\OAuth2\Server\Entities\DeviceCodeEntityInterface; use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; -use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface; use Webmozart\Assert\Assert; use yii\db\Exception; -final class DeviceCodeRepository implements DeviceCodeRepositoryInterface { +final class DeviceCodeRepository implements ExtendedDeviceCodeRepositoryInterface { public function getNewDeviceCode(): DeviceCodeEntityInterface { return new DeviceCodeEntity(); @@ -50,25 +46,16 @@ final class DeviceCodeRepository implements DeviceCodeRepositoryInterface { return null; } - $entity = $this->getNewDeviceCode(); - $entity->setIdentifier($model->device_code); // @phpstan-ignore argument.type - $entity->setUserCode($model->user_code); - $entity->setClient(ClientEntity::fromModel($model->client)); - $entity->setExpiryDateTime(CarbonImmutable::createFromTimestampUTC($model->expires_at)); - foreach ($model->scopes as $scope) { - $entity->addScope(new ScopeEntity($scope)); + return DeviceCodeEntity::fromModel($model); + } + + public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface { + $model = OauthDeviceCode::findOne(['user_code' => $userCode]); + if ($model === null) { + return null; } - if ($model->account_id !== null) { - $entity->setUserIdentifier((string)$model->account_id); - $entity->setUserApproved((bool)$model->is_approved === true); - } - - if ($model->last_polled_at !== null) { - $entity->setLastPolledAt(CarbonImmutable::createFromTimestampUTC($model->last_polled_at)); - } - - return $entity; + return DeviceCodeEntity::fromModel($model); } public function revokeDeviceCode(string $codeId): void { diff --git a/common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php b/common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php new file mode 100644 index 0000000..e468348 --- /dev/null +++ b/common/components/OAuth2/Repositories/ExtendedDeviceCodeRepositoryInterface.php @@ -0,0 +1,16 @@ + fixtures\UsernameHistoryFixture::class, 'oauthClients' => fixtures\OauthClientFixture::class, 'oauthSessions' => fixtures\OauthSessionFixture::class, + 'oauthDeviceCodes' => fixtures\OauthDeviceCodeFixture::class, 'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, 'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, 'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, diff --git a/common/tests/fixtures/OauthDeviceCodeFixture.php b/common/tests/fixtures/OauthDeviceCodeFixture.php new file mode 100644 index 0000000..38d2ec1 --- /dev/null +++ b/common/tests/fixtures/OauthDeviceCodeFixture.php @@ -0,0 +1,20 @@ + 'nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7', + 'user_code' => 'AAAABBBB', + 'client_id' => 'ely', + 'scopes' => ['minecraft_server_session', 'account_info'], + 'account_id' => null, + 'is_approved' => null, + 'last_polled_at' => null, + 'expires_at' => time() + 1800, + ], +];