Implemented device code grant

This commit is contained in:
ErickSkrauch 2024-12-08 16:54:45 +01:00
parent c7d192d14e
commit 2cc27d34ad
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
28 changed files with 665 additions and 171 deletions

View File

@ -50,6 +50,7 @@ final class AuthorizationController extends Controller {
return [ return [
'validate' => ['GET'], 'validate' => ['GET'],
'complete' => ['POST'], 'complete' => ['POST'],
'device' => ['POST'],
'token' => ['POST'], 'token' => ['POST'],
]; ];
} }
@ -62,6 +63,10 @@ final class AuthorizationController extends Controller {
return $this->oauthProcess->complete($this->getServerRequest()); return $this->oauthProcess->complete($this->getServerRequest());
} }
public function actionDevice(): array {
return $this->oauthProcess->deviceCode($this->getServerRequest());
}
public function actionToken(): array { public function actionToken(): array {
return $this->oauthProcess->getToken($this->getServerRequest()); return $this->oauthProcess->getToken($this->getServerRequest());
} }

View File

@ -109,8 +109,10 @@ final readonly class OauthProcess {
$result = [ $result = [
'success' => true, 'success' => true,
'redirectUri' => $response->getHeaderLine('Location'),
]; ];
if ($response->hasHeader('Location')) {
$result['redirectUri'] = $response->getHeaderLine('Location');
}
Yii::$app->statsd->inc('oauth.complete.success'); Yii::$app->statsd->inc('oauth.complete.success');
} catch (OAuthServerException $e) { } catch (OAuthServerException $e) {
@ -125,6 +127,31 @@ final readonly class OauthProcess {
return $result; 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. * 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', 'response_type',
'scope', 'scope',
'state', 'state',
'user_code',
])), ])),
'client' => [ 'client' => [
'id' => $client->id, 'id' => $client->id,
@ -281,14 +309,19 @@ final readonly class OauthProcess {
*/ */
private function buildCompleteErrorResponse(OAuthServerException $e): array { private function buildCompleteErrorResponse(OAuthServerException $e): array {
$hint = $e->getPayload()['hint'] ?? ''; $hint = $e->getPayload()['hint'] ?? '';
$parameter = null;
if (preg_match('/the `(\w+)` scope/', $hint, $matches)) { if (preg_match('/the `(\w+)` scope/', $hint, $matches)) {
$parameter = $matches[1]; $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 = [ $response = [
'success' => false, 'success' => false,
'error' => $e->getErrorType(), 'error' => $e->getErrorType(),
'parameter' => $parameter ?? null, 'parameter' => $parameter,
'statusCode' => $e->getHttpStatusCode(), 'statusCode' => $e->getHttpStatusCode(),
]; ];

View File

@ -8,6 +8,9 @@ use common\components\OAuth2\Repositories\PublicScopeRepository;
class OauthSteps extends FunctionalTester { class OauthSteps extends FunctionalTester {
/**
* @param string[] $permissions
*/
public function obtainAuthCode(array $permissions = []): string { public function obtainAuthCode(array $permissions = []): string {
$this->amAuthenticated(); $this->amAuthenticated();
$this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ $this->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
@ -23,6 +26,9 @@ class OauthSteps extends FunctionalTester {
return $matches[1]; return $matches[1];
} }
/**
* @param string[] $permissions
*/
public function getAccessToken(array $permissions = []): string { public function getAccessToken(array $permissions = []): string {
$authCode = $this->obtainAuthCode($permissions); $authCode = $this->obtainAuthCode($permissions);
$response = $this->issueToken($authCode); $response = $this->issueToken($authCode);
@ -30,6 +36,9 @@ class OauthSteps extends FunctionalTester {
return $response['access_token']; return $response['access_token'];
} }
/**
* @param string[] $permissions
*/
public function getRefreshToken(array $permissions = []): string { public function getRefreshToken(array $permissions = []): string {
$authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions)); $authCode = $this->obtainAuthCode(array_merge([PublicScopeRepository::OFFLINE_ACCESS], $permissions));
$response = $this->issueToken($authCode); $response = $this->issueToken($authCode);
@ -37,6 +46,9 @@ class OauthSteps extends FunctionalTester {
return $response['refresh_token']; return $response['refresh_token'];
} }
/**
* @return array<string, mixed>
*/
public function issueToken(string $authCode): array { public function issueToken(string $authCode): array {
$this->sendPOST('/api/oauth2/v1/token', [ $this->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
@ -49,6 +61,9 @@ class OauthSteps extends FunctionalTester {
return json_decode($this->grabResponse(), true); return json_decode($this->grabResponse(), true);
} }
/**
* @param string[] $permissions
*/
public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string { public function getAccessTokenByClientCredentialsGrant(array $permissions = [], bool $useTrusted = true): string {
$this->sendPOST('/api/oauth2/v1/token', [ $this->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'client_credentials', 'grant_type' => 'client_credentials',

View File

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace api\tests\functional\oauth; namespace api\tests\functional\oauth;
use api\tests\FunctionalTester; 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->amAuthenticated();
$I->wantTo('get auth code if I require some scope and pass accept field');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely', 'client_id' => 'ely',
'redirect_uri' => 'http://ely.by', 'redirect_uri' => 'http://ely.by',
@ -23,10 +23,20 @@ class AuthCodeCest {
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
} }
/** public function successfullyCompleteDeviceCodeFlow(FunctionalTester $I): void {
* @before completeSuccess $I->amAuthenticated();
*/ $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
public function completeSuccessWithLessScopes(FunctionalTester $I): void { '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->amAuthenticated();
$I->wantTo('get auth code with less scopes as passed in the previous request without accept param'); $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([ $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
@ -41,10 +51,8 @@ class AuthCodeCest {
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
} }
/** #[Before('successfullyCompleteAuthCodeFlow')]
* @before completeSuccess public function successfullyCompleteAuthCodeFlowWithSameScopes(FunctionalTester $I): void {
*/
public function completeSuccessWithSameScopes(FunctionalTester $I): void {
$I->amAuthenticated(); $I->amAuthenticated();
$I->wantTo('get auth code with the same scopes as passed in the previous request without accept param'); $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([ $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->amAuthenticated();
$I->wantTo('get access_denied error if I pass accept in false state');
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'client_id' => 'ely', 'client_id' => 'ely',
'redirect_uri' => 'http://ely.by', '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 { public function invalidClientId(FunctionalTester $I): void {
$I->amAuthenticated(); $I->amAuthenticated();
$I->wantTo('check behavior on invalid client id'); $I->wantTo('check behavior on invalid client id');

View 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',
]);
}
}

View File

@ -5,10 +5,9 @@ namespace api\tests\functional\oauth;
use api\tests\FunctionalTester; use api\tests\FunctionalTester;
class ValidateCest { final class ValidateCest {
public function completelyValidateValidRequest(FunctionalTester $I): void { public function successfullyValidateRequestForAuthFlow(FunctionalTester $I): void {
$I->wantTo('validate and obtain information about new oauth request');
$I->sendGET('/api/oauth2/v1/validate', [ $I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely', 'client_id' => 'ely',
'redirect_uri' => 'http://ely.by', '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->wantTo('validate and get information with description replacement');
$I->sendGET('/api/oauth2/v1/validate', [ $I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely', '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->wantTo('check behavior on invalid client id');
$I->sendGET('/api/oauth2/v1/validate', [ $I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'non-exists-client', '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->wantTo('check behavior on some invalid scopes');
$I->sendGET('/api/oauth2/v1/validate', [ $I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely', 'client_id' => 'ely',
@ -91,7 +127,7 @@ class ValidateCest {
$I->canSeeResponseJsonMatchesJsonPath('$.redirectUri'); $I->canSeeResponseJsonMatchesJsonPath('$.redirectUri');
} }
public function requestInternalScope(FunctionalTester $I): void { public function requestInternalScopeAuthFlow(FunctionalTester $I): void {
$I->wantTo('check behavior on request internal scope'); $I->wantTo('check behavior on request internal scope');
$I->sendGET('/api/oauth2/v1/validate', [ $I->sendGET('/api/oauth2/v1/validate', [
'client_id' => 'ely', 'client_id' => 'ely',

View File

@ -6,9 +6,9 @@ namespace common\components\OAuth2;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use DateInterval; use DateInterval;
use League\OAuth2\Server\AuthorizationServer; 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 { public static function build(): AuthorizationServer {
$clientsRepo = new Repositories\ClientRepository(); $clientsRepo = new Repositories\ClientRepository();
@ -17,6 +17,7 @@ final class AuthorizationServerFactory extends BaseComponent {
$internalScopesRepo = new Repositories\InternalScopeRepository(); $internalScopesRepo = new Repositories\InternalScopeRepository();
$authCodesRepo = new Repositories\AuthCodeRepository(); $authCodesRepo = new Repositories\AuthCodeRepository();
$refreshTokensRepo = new Repositories\RefreshTokenRepository(); $refreshTokensRepo = new Repositories\RefreshTokenRepository();
$deviceCodesRepo = new Repositories\DeviceCodeRepository();
$accessTokenTTL = CarbonInterval::create(-1); // Set negative value to make tokens non expiring $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); $authServer->enableGrantType($clientCredentialsGrant, $accessTokenTTL);
$clientCredentialsGrant->setScopeRepository($internalScopesRepo); // Change repository after enabling $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; return $authServer;
} }

View File

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace common\components\OAuth2\Entities; namespace common\components\OAuth2\Entities;
use common\models\OauthClient;
use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\Traits\ClientTrait; use League\OAuth2\Server\Entities\Traits\ClientTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait;
@ -26,6 +27,15 @@ final class ClientEntity implements ClientEntityInterface {
$this->redirectUri = $redirectUri; $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 { public function isConfidential(): bool {
return true; return true;
} }

View 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;
}
}

View 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();
}
}

View File

@ -16,8 +16,7 @@ final class ClientRepository implements ClientRepositoryInterface {
return null; return null;
} }
// @phpstan-ignore argument.type return ClientEntity::fromModel($client);
return new ClientEntity($client->id, $client->name, $client->redirect_uri ?: '', (bool)$client->is_trusted);
} }
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool { public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool {
@ -30,7 +29,7 @@ final class ClientRepository implements ClientRepositoryInterface {
return false; return false;
} }
if ($clientSecret !== null && $clientSecret !== $client->secret) { if (!empty($clientSecret) && $clientSecret !== $client->secret) {
return false; return false;
} }

View File

@ -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]);
}
}

View File

@ -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;
}

View 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;
}
}

View 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']);
}
}

View File

@ -1,14 +0,0 @@
<?php
namespace common\models\amqp;
use yii\base\BaseObject;
class AccountBanned extends BaseObject {
public $accountId;
public $duration = -1;
public $message = '';
}

View File

@ -1,10 +0,0 @@
<?php
namespace common\models\amqp;
use yii\base\BaseObject;
class AccountPardoned extends BaseObject {
public $accountId;
}

View File

@ -1,14 +0,0 @@
<?php
namespace common\models\amqp;
use yii\base\BaseObject;
class EmailChanged extends BaseObject {
public $accountId;
public $oldEmail;
public $newEmail;
}

View File

@ -1,14 +0,0 @@
<?php
namespace common\models\amqp;
use yii\base\BaseObject;
class UsernameChanged extends BaseObject {
public $accountId;
public $oldUsername;
public $newUsername;
}

View File

@ -51,6 +51,7 @@ class FixtureHelper extends Module {
'usernamesHistory' => fixtures\UsernameHistoryFixture::class, 'usernamesHistory' => fixtures\UsernameHistoryFixture::class,
'oauthClients' => fixtures\OauthClientFixture::class, 'oauthClients' => fixtures\OauthClientFixture::class,
'oauthSessions' => fixtures\OauthSessionFixture::class, 'oauthSessions' => fixtures\OauthSessionFixture::class,
'oauthDeviceCodes' => fixtures\OauthDeviceCodeFixture::class,
'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class, 'legacyOauthSessionsScopes' => fixtures\LegacyOauthSessionScopeFixtures::class,
'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class, 'legacyOauthAccessTokens' => fixtures\LegacyOauthAccessTokenFixture::class,
'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class, 'legacyOauthAccessTokensScopes' => fixtures\LegacyOauthAccessTokenScopeFixture::class,

View 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,
];
}

View 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,
],
];

View File

@ -37,7 +37,7 @@
"erickskrauch/phpstan-yii2": "dev-master", "erickskrauch/phpstan-yii2": "dev-master",
"guzzlehttp/guzzle": "^6|^7", "guzzlehttp/guzzle": "^6|^7",
"lcobucci/jwt": "^5.4", "lcobucci/jwt": "^5.4",
"league/oauth2-server": "^9.1.0", "league/oauth2-server": "dev-master#03dcdd7 as 9.2.0",
"nesbot/carbon": "^3", "nesbot/carbon": "^3",
"nohnaimer/yii2-sentry": "^2.0", "nohnaimer/yii2-sentry": "^2.0",
"paragonie/constant_time_encoding": "^3", "paragonie/constant_time_encoding": "^3",

25
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "1b49f881a8b10f52645cc0b04cf58bf3", "content-hash": "b50434a13836bd5adf5d0083e8be7d73",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1450,16 +1450,16 @@
}, },
{ {
"name": "league/oauth2-server", "name": "league/oauth2-server",
"version": "9.1.0", "version": "dev-master",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/oauth2-server.git", "url": "https://github.com/thephpleague/oauth2-server.git",
"reference": "d511107cb018ead0bd84f86402b086306738c686" "reference": "03dcdd7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d511107cb018ead0bd84f86402b086306738c686", "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/03dcdd7",
"reference": "d511107cb018ead0bd84f86402b086306738c686", "reference": "03dcdd7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1491,6 +1491,7 @@
"slevomat/coding-standard": "^8.14.1", "slevomat/coding-standard": "^8.14.1",
"squizlabs/php_codesniffer": "^3.8" "squizlabs/php_codesniffer": "^3.8"
}, },
"default-branch": true,
"type": "library", "type": "library",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -1534,7 +1535,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/thephpleague/oauth2-server/issues", "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": [ "funding": [
{ {
@ -1542,7 +1543,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-11-21T22:47:09+00:00" "time": "2024-11-25T19:29:16+00:00"
}, },
{ {
"name": "league/uri", "name": "league/uri",
@ -10862,11 +10863,19 @@
"time": "2024-03-03T12:36:25+00:00" "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", "minimum-stability": "stable",
"stability-flags": { "stability-flags": {
"ely/yii2-tempmail-validator": 20, "ely/yii2-tempmail-validator": 20,
"erickskrauch/phpstan-yii2": 20, "erickskrauch/phpstan-yii2": 20,
"league/oauth2-server": 20,
"roave/security-advisories": 20 "roave/security-advisories": 20
}, },
"prefer-stable": false, "prefer-stable": false,

View File

@ -1,6 +1,9 @@
<?php <?php
declare(strict_types=1);
namespace console\db; namespace console\db;
use yii\db\Exception;
use yii\db\Migration as YiiMigration; use yii\db\Migration as YiiMigration;
/** /**
@ -18,6 +21,9 @@ class Migration extends YiiMigration {
return $tableOptions; return $tableOptions;
} }
/**
* @param array<string|\yii\db\ColumnSchemaBuilder>|null $columns
*/
public function createTable($table, $columns, $options = null): void { public function createTable($table, $columns, $options = null): void {
if ($options === null) { if ($options === null) {
$options = $this->getTableOptions(); $options = $this->getTableOptions();
@ -34,4 +40,21 @@ class Migration extends YiiMigration {
return ' PRIMARY KEY (' . implode(', ', $columns) . ') '; 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');
}
} }

View 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');
}
}

View File

@ -5,16 +5,17 @@
echo "<?php\n"; echo "<?php\n";
?> ?>
declare(strict_types=1);
use console\db\Migration; 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 {
} }

View File

@ -810,31 +810,6 @@ parameters:
count: 1 count: 1
path: api/tests/functional/_steps/AuthserverSteps.php 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\\}\\.$#" message: "#^Offset 1 does not exist on array\\{0\\?\\: string, 1\\?\\: non\\-empty\\-string\\}\\.$#"
count: 1 count: 1
@ -1430,56 +1405,6 @@ parameters:
count: 1 count: 1
path: common/models/UsernameHistory.php 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\\.$#" message: "#^Method common\\\\notifications\\\\AccountDeletedNotification\\:\\:getPayloads\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
@ -1880,11 +1805,6 @@ parameters:
count: 1 count: 1
path: common/validators/UsernameValidator.php 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\\(\\)$#" 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 count: 1