mirror of
				https://github.com/elyby/accounts.git
				synced 2025-05-31 14:11:46 +05:30 
			
		
		
		
	Implemented device code grant
This commit is contained in:
		@@ -50,6 +50,7 @@ final class AuthorizationController extends Controller {
 | 
			
		||||
        return [
 | 
			
		||||
            'validate' => ['GET'],
 | 
			
		||||
            'complete' => ['POST'],
 | 
			
		||||
            'device' => ['POST'],
 | 
			
		||||
            'token' => ['POST'],
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
@@ -62,6 +63,10 @@ final class AuthorizationController extends Controller {
 | 
			
		||||
        return $this->oauthProcess->complete($this->getServerRequest());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function actionDevice(): array {
 | 
			
		||||
        return $this->oauthProcess->deviceCode($this->getServerRequest());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function actionToken(): array {
 | 
			
		||||
        return $this->oauthProcess->getToken($this->getServerRequest());
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -109,8 +109,10 @@ final readonly class OauthProcess {
 | 
			
		||||
 | 
			
		||||
            $result = [
 | 
			
		||||
                'success' => true,
 | 
			
		||||
                'redirectUri' => $response->getHeaderLine('Location'),
 | 
			
		||||
            ];
 | 
			
		||||
            if ($response->hasHeader('Location')) {
 | 
			
		||||
                $result['redirectUri'] = $response->getHeaderLine('Location');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            Yii::$app->statsd->inc('oauth.complete.success');
 | 
			
		||||
        } catch (OAuthServerException $e) {
 | 
			
		||||
@@ -125,6 +127,31 @@ final readonly class OauthProcess {
 | 
			
		||||
        return $result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @return array{
 | 
			
		||||
     *     device_code: string,
 | 
			
		||||
     *     user_code: string,
 | 
			
		||||
     *     verification_uri: string,
 | 
			
		||||
     *     interval: int,
 | 
			
		||||
     *     expires_in: int,
 | 
			
		||||
     * }|array{
 | 
			
		||||
     *     error: string,
 | 
			
		||||
     *     message: string,
 | 
			
		||||
     * }
 | 
			
		||||
     */
 | 
			
		||||
    public function deviceCode(ServerRequestInterface $request): array {
 | 
			
		||||
        try {
 | 
			
		||||
            $response = $this->server->respondToDeviceAuthorizationRequest($request, new Response());
 | 
			
		||||
        } catch (OAuthServerException $e) {
 | 
			
		||||
            Yii::$app->response->statusCode = $e->getHttpStatusCode();
 | 
			
		||||
            return $this->buildIssueErrorResponse($e);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Yii::$app->statsd->inc('oauth.deviceCode.initialize');
 | 
			
		||||
 | 
			
		||||
        return json_decode((string)$response->getBody(), true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The method is executed by the application server to which auth_token or refresh_token was given.
 | 
			
		||||
     *
 | 
			
		||||
@@ -245,6 +272,7 @@ final readonly class OauthProcess {
 | 
			
		||||
                'response_type',
 | 
			
		||||
                'scope',
 | 
			
		||||
                'state',
 | 
			
		||||
                'user_code',
 | 
			
		||||
            ])),
 | 
			
		||||
            'client' => [
 | 
			
		||||
                'id' => $client->id,
 | 
			
		||||
@@ -281,14 +309,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(),
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,9 @@ use common\components\OAuth2\Repositories\PublicScopeRepository;
 | 
			
		||||
 | 
			
		||||
class OauthSteps extends FunctionalTester {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string[] $permissions
 | 
			
		||||
     */
 | 
			
		||||
    public function obtainAuthCode(array $permissions = []): string {
 | 
			
		||||
        $this->amAuthenticated();
 | 
			
		||||
        $this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
@@ -23,6 +26,9 @@ class OauthSteps extends FunctionalTester {
 | 
			
		||||
        return $matches[1];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string[] $permissions
 | 
			
		||||
     */
 | 
			
		||||
    public function getAccessToken(array $permissions = []): string {
 | 
			
		||||
        $authCode = $this->obtainAuthCode($permissions);
 | 
			
		||||
        $response = $this->issueToken($authCode);
 | 
			
		||||
@@ -30,6 +36,9 @@ class OauthSteps extends FunctionalTester {
 | 
			
		||||
        return $response['access_token'];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string[] $permissions
 | 
			
		||||
     */
 | 
			
		||||
    public function getRefreshToken(array $permissions = []): string {
 | 
			
		||||
        $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions));
 | 
			
		||||
        $response = $this->issueToken($authCode);
 | 
			
		||||
@@ -37,6 +46,9 @@ class OauthSteps extends FunctionalTester {
 | 
			
		||||
        return $response['refresh_token'];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @return array<string, mixed>
 | 
			
		||||
     */
 | 
			
		||||
    public function issueToken(string $authCode): array {
 | 
			
		||||
        $this->sendPOST('/api/oauth2/v1/token', [
 | 
			
		||||
            'grant_type' => 'authorization_code',
 | 
			
		||||
@@ -49,6 +61,9 @@ class OauthSteps extends FunctionalTester {
 | 
			
		||||
        return json_decode($this->grabResponse(), true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param string[] $permissions
 | 
			
		||||
     */
 | 
			
		||||
    public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string {
 | 
			
		||||
        $this->sendPOST('/api/oauth2/v1/token', [
 | 
			
		||||
            'grant_type' => 'client_credentials',
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,12 @@ declare(strict_types=1);
 | 
			
		||||
namespace api\tests\functional\oauth;
 | 
			
		||||
 | 
			
		||||
use api\tests\FunctionalTester;
 | 
			
		||||
use Codeception\Attribute\Before;
 | 
			
		||||
 | 
			
		||||
class AuthCodeCest {
 | 
			
		||||
final class CompleteFlowCest {
 | 
			
		||||
 | 
			
		||||
    public function completeSuccess(FunctionalTester $I): void {
 | 
			
		||||
    public function successfullyCompleteAuthCodeFlow(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->wantTo('get auth code if I require some scope and pass accept field');
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'redirect_uri' => 'http://ely.by',
 | 
			
		||||
@@ -23,10 +23,20 @@ class AuthCodeCest {
 | 
			
		||||
        $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @before completeSuccess
 | 
			
		||||
     */
 | 
			
		||||
    public function completeSuccessWithLessScopes(FunctionalTester $I): void {
 | 
			
		||||
    public function successfullyCompleteDeviceCodeFlow(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'user_code' => 'AAAABBBB',
 | 
			
		||||
        ]), ['accept' => true]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(200);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'success' => true,
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->cantSeeResponseJsonMatchesJsonPath('$.redirectUri');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #[Before('successfullyCompleteAuthCodeFlow')]
 | 
			
		||||
    public function successfullyCompleteAuthCodeFlowWithLessScopes(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->wantTo('get auth code with less scopes as passed in the previous request without accept param');
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
@@ -41,10 +51,8 @@ class AuthCodeCest {
 | 
			
		||||
        $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @before completeSuccess
 | 
			
		||||
     */
 | 
			
		||||
    public function completeSuccessWithSameScopes(FunctionalTester $I): void {
 | 
			
		||||
    #[Before('successfullyCompleteAuthCodeFlow')]
 | 
			
		||||
    public function successfullyCompleteAuthCodeFlowWithSameScopes(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->wantTo('get auth code with the same scopes as passed in the previous request without accept param');
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
@@ -119,9 +127,8 @@ class AuthCodeCest {
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function testCompleteActionWithDismissState(FunctionalTester $I): void {
 | 
			
		||||
    public function completeAuthCodeFlowWithDecline(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->wantTo('get access_denied error if I pass accept in false state');
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'redirect_uri' => 'http://ely.by',
 | 
			
		||||
@@ -138,6 +145,34 @@ class AuthCodeCest {
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function completeDeviceCodeFlowWithDecline(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'user_code' => 'AAAABBBB',
 | 
			
		||||
        ]), ['accept' => false]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(200);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'success' => true,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function tryToCompleteAlreadyCompletedDeviceCodeFlow(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'user_code' => 'AAAABBBB',
 | 
			
		||||
        ]), ['accept' => true]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(200);
 | 
			
		||||
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'user_code' => 'AAAABBBB',
 | 
			
		||||
        ]), ['accept' => true]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(400);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'success' => false,
 | 
			
		||||
            'error' => 'used_user_code',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function invalidClientId(FunctionalTester $I): void {
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->wantTo('check behavior on invalid client id');
 | 
			
		||||
							
								
								
									
										106
									
								
								api/tests/functional/oauth/DeviceCodeCest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								api/tests/functional/oauth/DeviceCodeCest.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,106 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace api\tests\functional\oauth;
 | 
			
		||||
 | 
			
		||||
use api\tests\FunctionalTester;
 | 
			
		||||
use Codeception\Attribute\Examples;
 | 
			
		||||
use Codeception\Example;
 | 
			
		||||
 | 
			
		||||
final class DeviceCodeCest {
 | 
			
		||||
 | 
			
		||||
    public function initiateFlow(FunctionalTester $I): void {
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/device', [
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'scope' => 'account_info minecraft_server_session',
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(200);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'verification_uri' => 'http://localhost/code',
 | 
			
		||||
            'interval' => 5,
 | 
			
		||||
            'expires_in' => 600,
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->canSeeResponseJsonMatchesJsonPath('$.device_code');
 | 
			
		||||
        $I->canSeeResponseJsonMatchesJsonPath('$.user_code');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function pollPendingDeviceCode(FunctionalTester $I): void {
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/token', [
 | 
			
		||||
            'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'device_code' => 'nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7',
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(400);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'error' => 'authorization_pending',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param Example<array{boolean}> $case
 | 
			
		||||
     */
 | 
			
		||||
    #[Examples(true)]
 | 
			
		||||
    #[Examples(false)]
 | 
			
		||||
    public function finishFlowWithApprovedCode(FunctionalTester $I, Example $case): void {
 | 
			
		||||
        // Initialize flow
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/device', [
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'scope' => 'account_info minecraft_server_session',
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(200);
 | 
			
		||||
 | 
			
		||||
        ['user_code' => $userCode, 'device_code' => $deviceCode] = json_decode($I->grabResponse(), true);
 | 
			
		||||
 | 
			
		||||
        // Approve device code by the user
 | 
			
		||||
        $I->amAuthenticated();
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
 | 
			
		||||
            'user_code' => $userCode,
 | 
			
		||||
        ]), ['accept' => $case[0]]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(200);
 | 
			
		||||
 | 
			
		||||
        // Finish flow by obtaining the access token
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/token', [
 | 
			
		||||
            'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'device_code' => $deviceCode,
 | 
			
		||||
        ]);
 | 
			
		||||
        if ($case[0]) {
 | 
			
		||||
            $I->canSeeResponseCodeIs(200);
 | 
			
		||||
            $I->canSeeResponseContainsJson([
 | 
			
		||||
                'token_type' => 'Bearer',
 | 
			
		||||
            ]);
 | 
			
		||||
            $I->canSeeResponseJsonMatchesJsonPath('$.access_token');
 | 
			
		||||
            $I->cantSeeResponseJsonMatchesJsonPath('$.expires_in');
 | 
			
		||||
            $I->cantSeeResponseJsonMatchesJsonPath('$.refresh_token');
 | 
			
		||||
        } else {
 | 
			
		||||
            $I->canSeeResponseCodeIs(401);
 | 
			
		||||
            $I->canSeeResponseContainsJson([
 | 
			
		||||
                'error' => 'access_denied',
 | 
			
		||||
                'message' => 'The resource owner or authorization server denied the request.',
 | 
			
		||||
            ]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getAnErrorForUnknownClient(FunctionalTester $I): void {
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/device', [
 | 
			
		||||
            'client_id' => 'invalid-client',
 | 
			
		||||
            'scope' => 'account_info minecraft_server_session',
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(401);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'error' => 'invalid_client',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getAnErrorForInvalidScopes(FunctionalTester $I): void {
 | 
			
		||||
        $I->sendPOST('/api/oauth2/v1/device', [
 | 
			
		||||
            'client_id' => 'ely',
 | 
			
		||||
            'scope' => 'unknown-scope',
 | 
			
		||||
        ]);
 | 
			
		||||
        $I->canSeeResponseCodeIs(400);
 | 
			
		||||
        $I->canSeeResponseContainsJson([
 | 
			
		||||
            'error' => 'invalid_scope',
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,9 @@ namespace common\components\OAuth2;
 | 
			
		||||
use Carbon\CarbonInterval;
 | 
			
		||||
use DateInterval;
 | 
			
		||||
use League\OAuth2\Server\AuthorizationServer;
 | 
			
		||||
use yii\base\Component as BaseComponent;
 | 
			
		||||
use Yii;
 | 
			
		||||
 | 
			
		||||
final class AuthorizationServerFactory extends BaseComponent {
 | 
			
		||||
final class AuthorizationServerFactory {
 | 
			
		||||
 | 
			
		||||
    public static function build(): AuthorizationServer {
 | 
			
		||||
        $clientsRepo = new Repositories\ClientRepository();
 | 
			
		||||
@@ -17,6 +17,7 @@ final class AuthorizationServerFactory extends BaseComponent {
 | 
			
		||||
        $internalScopesRepo = new Repositories\InternalScopeRepository();
 | 
			
		||||
        $authCodesRepo = new Repositories\AuthCodeRepository();
 | 
			
		||||
        $refreshTokensRepo = new Repositories\RefreshTokenRepository();
 | 
			
		||||
        $deviceCodesRepo = new Repositories\DeviceCodeRepository();
 | 
			
		||||
 | 
			
		||||
        $accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring
 | 
			
		||||
 | 
			
		||||
@@ -42,6 +43,12 @@ final class AuthorizationServerFactory extends BaseComponent {
 | 
			
		||||
        $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
 | 
			
		||||
        $clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling
 | 
			
		||||
 | 
			
		||||
        $verificationUri = Yii::$app->request->getHostInfo() . '/code';
 | 
			
		||||
        $deviceCodeGrant = new Grants\DeviceCodeGrant($deviceCodesRepo, $refreshTokensRepo, new DateInterval('PT10M'), $verificationUri);
 | 
			
		||||
        $deviceCodeGrant->setIntervalVisibility(true);
 | 
			
		||||
        $authServer->enableGrantType($deviceCodeGrant, $accessTokenTTL);
 | 
			
		||||
        $deviceCodeGrant->setScopeRepository($publicScopesRepo); // Change repository after enabling
 | 
			
		||||
 | 
			
		||||
        return $authServer;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\components\OAuth2\Entities;
 | 
			
		||||
 | 
			
		||||
use common\models\OauthClient;
 | 
			
		||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
 | 
			
		||||
use League\OAuth2\Server\Entities\Traits\ClientTrait;
 | 
			
		||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
 | 
			
		||||
@@ -26,6 +27,15 @@ final class ClientEntity implements ClientEntityInterface {
 | 
			
		||||
        $this->redirectUri = $redirectUri;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static function fromModel(OauthClient $model): self {
 | 
			
		||||
        return new self(
 | 
			
		||||
            $model->id, // @phpstan-ignore argument.type
 | 
			
		||||
            $model->name,
 | 
			
		||||
            $model->redirect_uri ?: '',
 | 
			
		||||
            (bool)$model->is_trusted,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function isConfidential(): bool {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										40
									
								
								common/components/OAuth2/Entities/DeviceCodeEntity.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								common/components/OAuth2/Entities/DeviceCodeEntity.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
<?php
 | 
			
		||||
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;
 | 
			
		||||
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
 | 
			
		||||
 | 
			
		||||
final class DeviceCodeEntity implements DeviceCodeEntityInterface {
 | 
			
		||||
    use EntityTrait;
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										84
									
								
								common/components/OAuth2/Grants/DeviceCodeGrant.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								common/components/OAuth2/Grants/DeviceCodeGrant.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\components\OAuth2\Grants;
 | 
			
		||||
 | 
			
		||||
use common\components\OAuth2\Repositories\ExtendedDeviceCodeRepositoryInterface;
 | 
			
		||||
use common\components\OAuth2\ResponseTypes\EmptyResponse;
 | 
			
		||||
use DateInterval;
 | 
			
		||||
use League\OAuth2\Server\Exception\OAuthServerException;
 | 
			
		||||
use League\OAuth2\Server\Grant\DeviceCodeGrant as BaseDeviceCodeGrant;
 | 
			
		||||
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
 | 
			
		||||
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
 | 
			
		||||
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
 | 
			
		||||
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
 | 
			
		||||
use Psr\Http\Message\ServerRequestInterface;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @property ExtendedDeviceCodeRepositoryInterface $deviceCodeRepository
 | 
			
		||||
 */
 | 
			
		||||
final class DeviceCodeGrant extends BaseDeviceCodeGrant {
 | 
			
		||||
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        ExtendedDeviceCodeRepositoryInterface $deviceCodeRepository,
 | 
			
		||||
        RefreshTokenRepositoryInterface $refreshTokenRepository,
 | 
			
		||||
        DateInterval $deviceCodeTTL,
 | 
			
		||||
        string $verificationUri,
 | 
			
		||||
        int $retryInterval = 5,
 | 
			
		||||
    ) {
 | 
			
		||||
        parent::__construct(
 | 
			
		||||
            $deviceCodeRepository,
 | 
			
		||||
            $refreshTokenRepository,
 | 
			
		||||
            $deviceCodeTTL,
 | 
			
		||||
            $verificationUri,
 | 
			
		||||
            $retryInterval,
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function canRespondToAuthorizationRequest(ServerRequestInterface $request): bool {
 | 
			
		||||
        return isset($request->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('Unknown user code', 4, 'invalid_user_code', 401);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($deviceCode->getUserIdentifier() !== null) {
 | 
			
		||||
            throw new OAuthServerException('The user code has already been used', 6, 'used_user_code', 400);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $authorizationRequest = new AuthorizationRequest();
 | 
			
		||||
        $authorizationRequest->setGrantTypeId($this->getIdentifier());
 | 
			
		||||
        $authorizationRequest->setClient($deviceCode->getClient());
 | 
			
		||||
        $authorizationRequest->setScopes($deviceCode->getScopes());
 | 
			
		||||
        // We need the device code during the "completeAuthorizationRequest" implementation, so store it inside some unused field.
 | 
			
		||||
        // Perfectly the implementation must rely on the "user code" but library's implementation built on top of the "device code".
 | 
			
		||||
        $authorizationRequest->setCodeChallenge($deviceCode->getIdentifier());
 | 
			
		||||
 | 
			
		||||
        return $authorizationRequest;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @throws \League\OAuth2\Server\Exception\OAuthServerException
 | 
			
		||||
     */
 | 
			
		||||
    public function completeAuthorizationRequest(AuthorizationRequestInterface $authorizationRequest): ResponseTypeInterface {
 | 
			
		||||
        $this->completeDeviceAuthorizationRequest(
 | 
			
		||||
            $authorizationRequest->getCodeChallenge(),
 | 
			
		||||
            $authorizationRequest->getUser()->getIdentifier(),
 | 
			
		||||
            $authorizationRequest->isAuthorizationApproved(),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return new EmptyResponse();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -16,8 +16,7 @@ final class ClientRepository implements ClientRepositoryInterface {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // @phpstan-ignore argument.type
 | 
			
		||||
        return new ClientEntity($client->id, $client->name, $client->redirect_uri ?: '', (bool)$client->is_trusted);
 | 
			
		||||
        return ClientEntity::fromModel($client);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool {
 | 
			
		||||
@@ -30,7 +29,7 @@ final class ClientRepository implements ClientRepositoryInterface {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if ($clientSecret !== null && $clientSecret !== $client->secret) {
 | 
			
		||||
        if (!empty($clientSecret) && $clientSecret !== $client->secret) {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,73 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\components\OAuth2\Repositories;
 | 
			
		||||
 | 
			
		||||
use common\components\OAuth2\Entities\DeviceCodeEntity;
 | 
			
		||||
use common\models\OauthDeviceCode;
 | 
			
		||||
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
 | 
			
		||||
use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
 | 
			
		||||
use Webmozart\Assert\Assert;
 | 
			
		||||
use yii\db\Exception;
 | 
			
		||||
 | 
			
		||||
final class DeviceCodeRepository implements ExtendedDeviceCodeRepositoryInterface {
 | 
			
		||||
 | 
			
		||||
    public function getNewDeviceCode(): DeviceCodeEntityInterface {
 | 
			
		||||
        return new DeviceCodeEntity();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function persistDeviceCode(DeviceCodeEntityInterface $deviceCodeEntity): void {
 | 
			
		||||
        $model = $this->findModelByDeviceCode($deviceCodeEntity->getIdentifier()) ?? new OauthDeviceCode();
 | 
			
		||||
        $model->device_code = $deviceCodeEntity->getIdentifier();
 | 
			
		||||
        $model->user_code = $deviceCodeEntity->getUserCode();
 | 
			
		||||
        $model->client_id = $deviceCodeEntity->getClient()->getIdentifier();
 | 
			
		||||
        $model->scopes = array_map(fn($scope) => $scope->getIdentifier(), $deviceCodeEntity->getScopes());
 | 
			
		||||
        $model->last_polled_at = $deviceCodeEntity->getLastPolledAt()?->getTimestamp();
 | 
			
		||||
        $model->expires_at = $deviceCodeEntity->getExpiryDateTime()->getTimestamp();
 | 
			
		||||
        if ($deviceCodeEntity->getUserIdentifier() !== null) {
 | 
			
		||||
            $model->account_id = (int)$deviceCodeEntity->getUserIdentifier();
 | 
			
		||||
            $model->is_approved = $deviceCodeEntity->getUserApproved();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            Assert::true($model->save());
 | 
			
		||||
        } catch (Exception $e) {
 | 
			
		||||
            if (str_contains($e->getMessage(), 'duplicate')) {
 | 
			
		||||
                throw UniqueTokenIdentifierConstraintViolationException::create();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            throw $e;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getDeviceCodeEntityByDeviceCode(string $deviceCodeEntity): ?DeviceCodeEntityInterface {
 | 
			
		||||
        $model = $this->findModelByDeviceCode($deviceCodeEntity);
 | 
			
		||||
        if ($model === null) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return DeviceCodeEntity::fromModel($model);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface {
 | 
			
		||||
        $model = OauthDeviceCode::findOne(['user_code' => $userCode]);
 | 
			
		||||
        if ($model === null) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return DeviceCodeEntity::fromModel($model);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function revokeDeviceCode(string $codeId): void {
 | 
			
		||||
        $this->findModelByDeviceCode($codeId)?->delete();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function isDeviceCodeRevoked(string $codeId): bool {
 | 
			
		||||
        return $this->findModelByDeviceCode($codeId) === null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private function findModelByDeviceCode(string $deviceCode): ?OauthDeviceCode {
 | 
			
		||||
        return OauthDeviceCode::findOne(['device_code' => $deviceCode]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\components\OAuth2\Repositories;
 | 
			
		||||
 | 
			
		||||
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
 | 
			
		||||
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
 | 
			
		||||
 | 
			
		||||
interface ExtendedDeviceCodeRepositoryInterface extends DeviceCodeRepositoryInterface {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @phpstan-param non-empty-string $userCode
 | 
			
		||||
     */
 | 
			
		||||
    public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								common/components/OAuth2/ResponseTypes/EmptyResponse.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								common/components/OAuth2/ResponseTypes/EmptyResponse.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\components\OAuth2\ResponseTypes;
 | 
			
		||||
 | 
			
		||||
use League\OAuth2\Server\ResponseTypes\AbstractResponseType;
 | 
			
		||||
use Psr\Http\Message\ResponseInterface;
 | 
			
		||||
 | 
			
		||||
final class EmptyResponse extends AbstractResponseType {
 | 
			
		||||
 | 
			
		||||
    public function generateHttpResponse(ResponseInterface $response): ResponseInterface {
 | 
			
		||||
        return $response;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										47
									
								
								common/models/OauthDeviceCode.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								common/models/OauthDeviceCode.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\models;
 | 
			
		||||
 | 
			
		||||
use yii\behaviors\AttributeTypecastBehavior;
 | 
			
		||||
use yii\db\ActiveRecord;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Fields:
 | 
			
		||||
 * @property string $device_code
 | 
			
		||||
 * @property string $user_code
 | 
			
		||||
 * @property string $client_id
 | 
			
		||||
 * @property array $scopes
 | 
			
		||||
 * @property int|null $account_id
 | 
			
		||||
 * @property bool|null $is_approved
 | 
			
		||||
 * @property int|null $last_polled_at
 | 
			
		||||
 * @property int $expires_at
 | 
			
		||||
 *
 | 
			
		||||
 * Relations:
 | 
			
		||||
 * @property-read OauthClient $client
 | 
			
		||||
 */
 | 
			
		||||
final class OauthDeviceCode extends ActiveRecord {
 | 
			
		||||
 | 
			
		||||
    public static function tableName(): string {
 | 
			
		||||
        return 'oauth_device_codes';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function behaviors(): array {
 | 
			
		||||
        return [
 | 
			
		||||
            [
 | 
			
		||||
                'class' => AttributeTypecastBehavior::class,
 | 
			
		||||
                'attributeTypes' => [
 | 
			
		||||
                    'is_approved' => AttributeTypecastBehavior::TYPE_BOOLEAN,
 | 
			
		||||
                ],
 | 
			
		||||
                'typecastAfterSave' => true,
 | 
			
		||||
                'typecastAfterFind' => true,
 | 
			
		||||
            ],
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getClient(): OauthClientQuery {
 | 
			
		||||
        /** @noinspection PhpIncompatibleReturnTypeInspection */
 | 
			
		||||
        return $this->hasOne(OauthClient::class, ['id' => 'client_id']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
<?php
 | 
			
		||||
namespace common\models\amqp;
 | 
			
		||||
 | 
			
		||||
use yii\base\BaseObject;
 | 
			
		||||
 | 
			
		||||
class AccountBanned extends BaseObject {
 | 
			
		||||
 | 
			
		||||
    public $accountId;
 | 
			
		||||
 | 
			
		||||
    public $duration = -1;
 | 
			
		||||
 | 
			
		||||
    public $message = '';
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,10 +0,0 @@
 | 
			
		||||
<?php
 | 
			
		||||
namespace common\models\amqp;
 | 
			
		||||
 | 
			
		||||
use yii\base\BaseObject;
 | 
			
		||||
 | 
			
		||||
class AccountPardoned extends BaseObject {
 | 
			
		||||
 | 
			
		||||
    public $accountId;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
<?php
 | 
			
		||||
namespace common\models\amqp;
 | 
			
		||||
 | 
			
		||||
use yii\base\BaseObject;
 | 
			
		||||
 | 
			
		||||
class EmailChanged extends BaseObject {
 | 
			
		||||
 | 
			
		||||
    public $accountId;
 | 
			
		||||
 | 
			
		||||
    public $oldEmail;
 | 
			
		||||
 | 
			
		||||
    public $newEmail;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -1,14 +0,0 @@
 | 
			
		||||
<?php
 | 
			
		||||
namespace common\models\amqp;
 | 
			
		||||
 | 
			
		||||
use yii\base\BaseObject;
 | 
			
		||||
 | 
			
		||||
class UsernameChanged extends BaseObject {
 | 
			
		||||
 | 
			
		||||
    public $accountId;
 | 
			
		||||
 | 
			
		||||
    public $oldUsername;
 | 
			
		||||
 | 
			
		||||
    public $newUsername;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -51,6 +51,7 @@ class FixtureHelper extends Module {
 | 
			
		||||
            'usernamesHistory' => 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,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								common/tests/fixtures/OauthDeviceCodeFixture.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								common/tests/fixtures/OauthDeviceCodeFixture.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace common\tests\fixtures;
 | 
			
		||||
 | 
			
		||||
use common\models\OauthDeviceCode;
 | 
			
		||||
use yii\test\ActiveFixture;
 | 
			
		||||
 | 
			
		||||
final class OauthDeviceCodeFixture extends ActiveFixture {
 | 
			
		||||
 | 
			
		||||
    public $modelClass = OauthDeviceCode::class;
 | 
			
		||||
 | 
			
		||||
    public $dataFile = '@root/common/tests/fixtures/data/oauth-device-codes.php';
 | 
			
		||||
 | 
			
		||||
    public $depends = [
 | 
			
		||||
        OauthClientFixture::class,
 | 
			
		||||
        AccountFixture::class,
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										15
									
								
								common/tests/fixtures/data/oauth-device-codes.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								common/tests/fixtures/data/oauth-device-codes.php
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
return [
 | 
			
		||||
    [
 | 
			
		||||
        'device_code' => '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,
 | 
			
		||||
    ],
 | 
			
		||||
];
 | 
			
		||||
@@ -37,7 +37,7 @@
 | 
			
		||||
        "erickskrauch/phpstan-yii2": "dev-master",
 | 
			
		||||
        "guzzlehttp/guzzle": "^6|^7",
 | 
			
		||||
        "lcobucci/jwt": "^5.4",
 | 
			
		||||
        "league/oauth2-server": "^9.1.0",
 | 
			
		||||
        "league/oauth2-server": "dev-master#03dcdd7 as 9.2.0",
 | 
			
		||||
        "nesbot/carbon": "^3",
 | 
			
		||||
        "nohnaimer/yii2-sentry": "^2.0",
 | 
			
		||||
        "paragonie/constant_time_encoding": "^3",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										25
									
								
								composer.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										25
									
								
								composer.lock
									
									
									
										generated
									
									
									
								
							@@ -4,7 +4,7 @@
 | 
			
		||||
        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
 | 
			
		||||
        "This file is @generated automatically"
 | 
			
		||||
    ],
 | 
			
		||||
    "content-hash": "1b49f881a8b10f52645cc0b04cf58bf3",
 | 
			
		||||
    "content-hash": "b50434a13836bd5adf5d0083e8be7d73",
 | 
			
		||||
    "packages": [
 | 
			
		||||
        {
 | 
			
		||||
            "name": "bacon/bacon-qr-code",
 | 
			
		||||
@@ -1450,16 +1450,16 @@
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "name": "league/oauth2-server",
 | 
			
		||||
            "version": "9.1.0",
 | 
			
		||||
            "version": "dev-master",
 | 
			
		||||
            "source": {
 | 
			
		||||
                "type": "git",
 | 
			
		||||
                "url": "https://github.com/thephpleague/oauth2-server.git",
 | 
			
		||||
                "reference": "d511107cb018ead0bd84f86402b086306738c686"
 | 
			
		||||
                "reference": "03dcdd7"
 | 
			
		||||
            },
 | 
			
		||||
            "dist": {
 | 
			
		||||
                "type": "zip",
 | 
			
		||||
                "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d511107cb018ead0bd84f86402b086306738c686",
 | 
			
		||||
                "reference": "d511107cb018ead0bd84f86402b086306738c686",
 | 
			
		||||
                "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/03dcdd7",
 | 
			
		||||
                "reference": "03dcdd7",
 | 
			
		||||
                "shasum": ""
 | 
			
		||||
            },
 | 
			
		||||
            "require": {
 | 
			
		||||
@@ -1491,6 +1491,7 @@
 | 
			
		||||
                "slevomat/coding-standard": "^8.14.1",
 | 
			
		||||
                "squizlabs/php_codesniffer": "^3.8"
 | 
			
		||||
            },
 | 
			
		||||
            "default-branch": true,
 | 
			
		||||
            "type": "library",
 | 
			
		||||
            "autoload": {
 | 
			
		||||
                "psr-4": {
 | 
			
		||||
@@ -1534,7 +1535,7 @@
 | 
			
		||||
            ],
 | 
			
		||||
            "support": {
 | 
			
		||||
                "issues": "https://github.com/thephpleague/oauth2-server/issues",
 | 
			
		||||
                "source": "https://github.com/thephpleague/oauth2-server/tree/9.1.0"
 | 
			
		||||
                "source": "https://github.com/thephpleague/oauth2-server/tree/master"
 | 
			
		||||
            },
 | 
			
		||||
            "funding": [
 | 
			
		||||
                {
 | 
			
		||||
@@ -1542,7 +1543,7 @@
 | 
			
		||||
                    "type": "github"
 | 
			
		||||
                }
 | 
			
		||||
            ],
 | 
			
		||||
            "time": "2024-11-21T22:47:09+00:00"
 | 
			
		||||
            "time": "2024-11-25T19:29:16+00:00"
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "name": "league/uri",
 | 
			
		||||
@@ -10862,11 +10863,19 @@
 | 
			
		||||
            "time": "2024-03-03T12:36:25+00:00"
 | 
			
		||||
        }
 | 
			
		||||
    ],
 | 
			
		||||
    "aliases": [],
 | 
			
		||||
    "aliases": [
 | 
			
		||||
        {
 | 
			
		||||
            "package": "league/oauth2-server",
 | 
			
		||||
            "version": "9999999-dev",
 | 
			
		||||
            "alias": "9.2.0",
 | 
			
		||||
            "alias_normalized": "9.2.0.0"
 | 
			
		||||
        }
 | 
			
		||||
    ],
 | 
			
		||||
    "minimum-stability": "stable",
 | 
			
		||||
    "stability-flags": {
 | 
			
		||||
        "ely/yii2-tempmail-validator": 20,
 | 
			
		||||
        "erickskrauch/phpstan-yii2": 20,
 | 
			
		||||
        "league/oauth2-server": 20,
 | 
			
		||||
        "roave/security-advisories": 20
 | 
			
		||||
    },
 | 
			
		||||
    "prefer-stable": false,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,9 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
namespace console\db;
 | 
			
		||||
 | 
			
		||||
use yii\db\Exception;
 | 
			
		||||
use yii\db\Migration as YiiMigration;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -18,6 +21,9 @@ class Migration extends YiiMigration {
 | 
			
		||||
        return $tableOptions;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @param array<string|\yii\db\ColumnSchemaBuilder>|null $columns
 | 
			
		||||
     */
 | 
			
		||||
    public function createTable($table, $columns, $options = null): void {
 | 
			
		||||
        if ($options === null) {
 | 
			
		||||
            $options = $this->getTableOptions();
 | 
			
		||||
@@ -34,4 +40,21 @@ class Migration extends YiiMigration {
 | 
			
		||||
        return ' PRIMARY KEY (' . implode(', ', $columns) . ') ';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function getPrimaryKeyType(string $table, bool $nullable = false): string {
 | 
			
		||||
        $primaryKeys = $this->db->getTableSchema($table)->primaryKey;
 | 
			
		||||
        if (count($primaryKeys) === 0) {
 | 
			
		||||
            throw new Exception("The table \"{$table}\" have no primary keys.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (count($primaryKeys) > 1) {
 | 
			
		||||
            throw new Exception("The table \"{$table}\" have more than one primary key.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $this->getColumnType($table, $primaryKeys[0], $nullable);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function getColumnType(string $table, string $column, bool $nullable = false): string {
 | 
			
		||||
        return $this->db->getTableSchema($table)->getColumn($column)->dbType . ($nullable ? '' : ' NOT NULL');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										36
									
								
								console/migrations/m241206_172929_oauth_device_codes.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								console/migrations/m241206_172929_oauth_device_codes.php
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
<?php
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
use console\db\Migration;
 | 
			
		||||
 | 
			
		||||
class m241206_172929_oauth_device_codes extends Migration {
 | 
			
		||||
 | 
			
		||||
    public function safeUp(): void {
 | 
			
		||||
        $this->createTable('oauth_device_codes', [
 | 
			
		||||
            'device_code' => $this->string(96)->notNull(),
 | 
			
		||||
            'user_code' => $this->string(16)->notNull(),
 | 
			
		||||
            'client_id' => $this->getPrimaryKeyType('oauth_clients'),
 | 
			
		||||
            'scopes' => $this->json()->notNull()->toString('scopes'),
 | 
			
		||||
            'account_id' => $this->getPrimaryKeyType('accounts', true),
 | 
			
		||||
            'is_approved' => $this->boolean()->unsigned(),
 | 
			
		||||
            'last_polled_at' => $this->integer(11)->unsigned(),
 | 
			
		||||
            'expires_at' => $this->integer(11)->unsigned()->notNull(),
 | 
			
		||||
            $this->primary('device_code'),
 | 
			
		||||
        ]);
 | 
			
		||||
        $this->createIndex('user_code', 'oauth_device_codes', 'user_code', true);
 | 
			
		||||
        $this->createIndex('expires_in', 'oauth_device_codes', 'expires_at');
 | 
			
		||||
        $this->addForeignKey('FK_oauth_device_code_to_oauth_client', 'oauth_device_codes', 'client_id', 'oauth_clients', 'id', 'CASCADE', 'CASCADE');
 | 
			
		||||
        $this->addForeignKey('FK_oauth_device_code_to_account', 'oauth_device_codes', 'account_id', 'accounts', 'id', 'CASCADE', 'CASCADE');
 | 
			
		||||
        $this->execute('
 | 
			
		||||
            CREATE EVENT oauth_device_codes_cleanup
 | 
			
		||||
                      ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 HOUR
 | 
			
		||||
                      DO DELETE FROM oauth_device_codes WHERE expires_at < UNIX_TIMESTAMP()
 | 
			
		||||
        ');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function safeDown(): void {
 | 
			
		||||
        $this->execute('DROP EVENT oauth_device_codes_cleanup');
 | 
			
		||||
        $this->dropTable('oauth_device_codes');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -5,16 +5,17 @@
 | 
			
		||||
 | 
			
		||||
echo "<?php\n";
 | 
			
		||||
?>
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
use console\db\Migration;
 | 
			
		||||
 | 
			
		||||
class <?= $className; ?> extends Migration {
 | 
			
		||||
final class <?= $className; ?> extends Migration {
 | 
			
		||||
 | 
			
		||||
    public function safeUp() {
 | 
			
		||||
    public function safeUp(): void {
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function safeDown() {
 | 
			
		||||
    public function safeDown(): void {
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -810,31 +810,6 @@ parameters:
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: api/tests/functional/_steps/AuthserverSteps.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:getAccessToken\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: api/tests/functional/_steps/OauthSteps.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:getAccessTokenByClientCredentialsGrant\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: api/tests/functional/_steps/OauthSteps.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:getRefreshToken\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: api/tests/functional/_steps/OauthSteps.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:issueToken\\(\\) return type has no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: api/tests/functional/_steps/OauthSteps.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method api\\\\tests\\\\functional\\\\_steps\\\\OauthSteps\\:\\:obtainAuthCode\\(\\) has parameter \\$permissions with no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: api/tests/functional/_steps/OauthSteps.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Offset 1 does not exist on array\\{0\\?\\: string, 1\\?\\: non\\-empty\\-string\\}\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
@@ -1430,56 +1405,6 @@ parameters:
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/UsernameHistory.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\AccountBanned\\:\\:\\$accountId has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/AccountBanned.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\AccountBanned\\:\\:\\$duration has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/AccountBanned.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\AccountBanned\\:\\:\\$message has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/AccountBanned.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\AccountPardoned\\:\\:\\$accountId has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/AccountPardoned.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\EmailChanged\\:\\:\\$accountId has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/EmailChanged.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\EmailChanged\\:\\:\\$newEmail has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/EmailChanged.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\EmailChanged\\:\\:\\$oldEmail has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/EmailChanged.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\UsernameChanged\\:\\:\\$accountId has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/UsernameChanged.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\UsernameChanged\\:\\:\\$newUsername has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/UsernameChanged.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Property common\\\\models\\\\amqp\\\\UsernameChanged\\:\\:\\$oldUsername has no type specified\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/models/amqp/UsernameChanged.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method common\\\\notifications\\\\AccountDeletedNotification\\:\\:getPayloads\\(\\) return type has no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
@@ -1880,11 +1805,6 @@ parameters:
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: common/validators/UsernameValidator.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Method console\\\\db\\\\Migration\\:\\:createTable\\(\\) has parameter \\$columns with no value type specified in iterable type array\\.$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
			path: console/db/Migration.php
 | 
			
		||||
 | 
			
		||||
		-
 | 
			
		||||
			message: "#^Return type \\(void\\) of method m130524_201442_init\\:\\:down\\(\\) should be compatible with return type \\(bool\\) of method yii\\\\db\\\\MigrationInterface\\:\\:down\\(\\)$#"
 | 
			
		||||
			count: 1
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user