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',
|
||||
|
Reference in New Issue
Block a user