Validate user_code expiry during the Device Code grant.

Add mock responses related to the Device Code grant.
This commit is contained in:
ErickSkrauch 2024-12-14 18:55:31 +01:00
parent 2cc27d34ad
commit 119a0f8078
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
8 changed files with 143 additions and 16 deletions

View File

@ -9,6 +9,7 @@ use api\modules\accounts\actions\ChangeEmailAction;
use api\modules\accounts\actions\EmailVerificationAction; use api\modules\accounts\actions\EmailVerificationAction;
use api\modules\accounts\actions\NewEmailVerificationAction; use api\modules\accounts\actions\NewEmailVerificationAction;
use api\modules\accounts\controllers\DefaultController; use api\modules\accounts\controllers\DefaultController;
use api\modules\oauth\controllers\AuthorizationController as OauthAuthorizationController;
use Closure; use Closure;
use yii\base\ActionEvent; use yii\base\ActionEvent;
use yii\base\BootstrapInterface; use yii\base\BootstrapInterface;
@ -37,11 +38,15 @@ final class MockDataResponse implements BootstrapInterface {
$event->isValid = false; $event->isValid = false;
} }
/**
* @return array<mixed>|null
*/
private function getResponse(ActionEvent $event): ?array { private function getResponse(ActionEvent $event): ?array {
$action = $event->action; $action = $event->action;
/** @var \yii\web\Controller $controller */ /** @var \yii\web\Controller $controller */
$controller = $action->controller; $controller = $action->controller;
$request = $controller->request; $request = $controller->request;
$response = $controller->response;
if ($controller instanceof SignupController && $action->id === 'index') { if ($controller instanceof SignupController && $action->id === 'index') {
$email = $request->post('email'); $email = $request->post('email');
if ($email === 'let-me-register@ely.by') { if ($email === 'let-me-register@ely.by') {
@ -91,6 +96,63 @@ final class MockDataResponse implements BootstrapInterface {
} }
} }
if ($controller instanceof OauthAuthorizationController) {
if ($action->id === 'validate') {
$userCode = $request->get('user_code');
if ($userCode === 'E2E-APPROVED' || $userCode === 'E2E-UNAPPROVED') {
return [
'success' => true,
'client' => [
'id' => 'test',
'name' => 'Ely.by Test',
'description' => "Some client's description",
],
'session' => [
'scopes' => 'account_info minecraft_server_session',
],
];
}
if ($userCode === 'E2E-EXPIRED') {
$response->setStatusCode(400);
return [
'success' => false,
'error' => 'expired_token',
'parameter' => 'user_code',
'statusCode' => 400,
];
}
if ($userCode === 'E2E-COMPLETED') {
$response->setStatusCode(400);
return [
'success' => false,
'error' => 'used_user_code',
'parameter' => 'user_code',
'statusCode' => 400,
];
}
}
if ($action->id === 'complete') {
$userCode = $request->get('user_code');
$accept = $request->post('accept');
if ($userCode === 'E2E-APPROVED' || ($userCode === 'E2E-UNAPPROVED' && $accept !== null)) {
return ['success' => true];
}
if ($userCode === 'E2E-UNAPPROVED' && $accept === null) {
$response->setStatusCode(401);
return [
'success' => false,
'error' => 'accept_required',
'parameter' => null,
'statusCode' => 401,
];
}
}
}
if ($controller instanceof DefaultController && $action->id === 'get') { if ($controller instanceof DefaultController && $action->id === 'get') {
$httpAuth = $request->getHeaders()->get('authorization'); $httpAuth = $request->getHeaders()->get('authorization');
if ($httpAuth === 'Bearer dummy_token') { if ($httpAuth === 'Bearer dummy_token') {

View File

@ -314,8 +314,8 @@ final readonly class OauthProcess {
$parameter = $matches[1]; $parameter = $matches[1];
} }
if ($parameter === null && str_starts_with($e->getErrorType(), 'invalid_')) { if ($parameter === null && $hint === 'user_code') {
$parameter = substr($e->getErrorType(), 8); // 8 is the length of the "invalid_" $parameter = $hint;
} }
$response = [ $response = [

View File

@ -156,15 +156,22 @@ final class CompleteFlowCest {
]); ]);
} }
public function tryToCompleteExpiredDeviceCodeFlow(FunctionalTester $I): void {
$I->amAuthenticated();
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'user_code' => 'EXPIRED',
]), ['accept' => true]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'expired_token',
]);
}
public function tryToCompleteAlreadyCompletedDeviceCodeFlow(FunctionalTester $I): void { public function tryToCompleteAlreadyCompletedDeviceCodeFlow(FunctionalTester $I): void {
$I->amAuthenticated(); $I->amAuthenticated();
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([ $I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'user_code' => 'AAAABBBB', 'user_code' => 'COMPLETED',
]), ['accept' => true]);
$I->canSeeResponseCodeIs(200);
$I->sendPOST('/api/oauth2/v1/complete?' . http_build_query([
'user_code' => 'AAAABBBB',
]), ['accept' => true]); ]), ['accept' => true]);
$I->canSeeResponseCodeIs(400); $I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([ $I->canSeeResponseContainsJson([

View File

@ -36,6 +36,19 @@ final class DeviceCodeCest {
]); ]);
} }
public function pollExpiredDeviceCode(FunctionalTester $I): void {
$I->sendPOST('/api/oauth2/v1/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code',
'client_id' => 'ely',
'device_code' => 'ZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YT',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'error' => 'expired_token',
'message' => 'The `device_code` has expired and the device authorization session has concluded.',
]);
}
/** /**
* @param Example<array{boolean}> $case * @param Example<array{boolean}> $case
*/ */

View File

@ -108,6 +108,32 @@ final class ValidateCest {
]); ]);
} }
public function expiredCodeForDeviceCode(FunctionalTester $I): void {
$I->sendGET('/api/oauth2/v1/validate', [
'user_code' => 'EXPIRED',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'expired_token',
'parameter' => 'user_code',
'statusCode' => 400,
]);
}
public function completedCodeForDeviceCode(FunctionalTester $I): void {
$I->sendGET('/api/oauth2/v1/validate', [
'user_code' => 'COMPLETED',
]);
$I->canSeeResponseCodeIs(400);
$I->canSeeResponseContainsJson([
'success' => false,
'error' => 'used_user_code',
'parameter' => 'user_code',
'statusCode' => 400,
]);
}
public function invalidScopesAuthFlow(FunctionalTester $I): void { 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', [

View File

@ -50,11 +50,15 @@ final class DeviceCodeGrant extends BaseDeviceCodeGrant {
$deviceCode = $this->deviceCodeRepository->getDeviceCodeEntityByUserCode($userCode); $deviceCode = $this->deviceCodeRepository->getDeviceCodeEntityByUserCode($userCode);
if ($deviceCode === null) { if ($deviceCode === null) {
throw new OAuthServerException('Unknown user code', 4, 'invalid_user_code', 401); throw new OAuthServerException('Unknown user code', 4, 'invalid_user_code', 401, 'user_code');
}
if ($deviceCode->getExpiryDateTime()->getTimestamp() < time()) {
throw OAuthServerException::expiredToken('user_code');
} }
if ($deviceCode->getUserIdentifier() !== null) { if ($deviceCode->getUserIdentifier() !== null) {
throw new OAuthServerException('The user code has already been used', 6, 'used_user_code', 400); throw new OAuthServerException('The user code has already been used', 6, 'used_user_code', 400, 'user_code');
} }
$authorizationRequest = new AuthorizationRequest(); $authorizationRequest = new AuthorizationRequest();

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
[ 'pending-code' => [
'device_code' => 'nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7', 'device_code' => 'nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7',
'user_code' => 'AAAABBBB', 'user_code' => 'AAAABBBB',
'client_id' => 'ely', 'client_id' => 'ely',
@ -12,4 +12,24 @@ return [
'last_polled_at' => null, 'last_polled_at' => null,
'expires_at' => time() + 1800, 'expires_at' => time() + 1800,
], ],
'expired-code' => [
'device_code' => 'ZFPbWycSxdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YT',
'user_code' => 'EXPIRED',
'client_id' => 'ely',
'scopes' => ['minecraft_server_session', 'account_info'],
'account_id' => null,
'is_approved' => null,
'last_polled_at' => time() - 1200,
'expires_at' => time() - 1800,
],
'completed-code' => [
'device_code' => 'xdRpHiYP2wnv3S0KHBgYky8fRDqfhhCqzke7nKuYFfwckZywqU8iUKv3ek4VtiMiMCkiC0YTZFPbWycS',
'user_code' => 'COMPLETED',
'client_id' => 'ely',
'scopes' => ['minecraft_server_session', 'account_info'],
'account_id' => 1,
'is_approved' => true,
'last_polled_at' => time(),
'expires_at' => time() + 1800,
],
]; ];

View File

@ -130,11 +130,6 @@ parameters:
count: 1 count: 1
path: api/controllers/SignupController.php path: api/controllers/SignupController.php
-
message: "#^Method api\\\\eventListeners\\\\MockDataResponse\\:\\:getResponse\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: api/eventListeners/MockDataResponse.php
- -
message: "#^Cannot access offset string on array\\|\\(callable\\(\\)\\: mixed\\)\\.$#" message: "#^Cannot access offset string on array\\|\\(callable\\(\\)\\: mixed\\)\\.$#"
count: 1 count: 1