Implemented device code grant

This commit is contained in:
ErickSkrauch
2024-12-08 16:54:45 +01:00
parent c7d192d14e
commit 2cc27d34ad
28 changed files with 665 additions and 171 deletions

View File

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

View File

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

View File

@@ -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',

View File

@@ -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');

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;
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',