From f30ff46803835f5ebd678172544e18e517567994 Mon Sep 17 00:00:00 2001 From: Luke Arms Date: Tue, 13 May 2025 01:14:16 +1000 Subject: [PATCH 1/3] Http: Rename/duplicate credential class files before refactoring --- .../AccessToken.php => GenericToken.php} | 0 src/Toolkit/Http/OAuth2/OAuth2AccessToken.php | 82 +++++++++++++++++++ ...okenTest.php => OAuth2AccessTokenTest.php} | 0 3 files changed, 82 insertions(+) rename src/Toolkit/Http/{OAuth2/AccessToken.php => GenericToken.php} (100%) create mode 100644 src/Toolkit/Http/OAuth2/OAuth2AccessToken.php rename tests/unit/Toolkit/Http/OAuth2/{AccessTokenTest.php => OAuth2AccessTokenTest.php} (100%) diff --git a/src/Toolkit/Http/OAuth2/AccessToken.php b/src/Toolkit/Http/GenericToken.php similarity index 100% rename from src/Toolkit/Http/OAuth2/AccessToken.php rename to src/Toolkit/Http/GenericToken.php diff --git a/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php b/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php new file mode 100644 index 00000000..67e533b5 --- /dev/null +++ b/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php @@ -0,0 +1,82 @@ + $Claims + */ +final class AccessToken implements CredentialInterface, Immutable, Readable +{ + use ReadableProtectedPropertiesTrait; + + protected string $Token; + protected string $Type; + protected ?DateTimeImmutable $Expires; + /** @var string[] */ + protected array $Scopes; + /** @var array */ + protected array $Claims; + + /** + * @param DateTimeInterface|int|null $expires `null` if the access token's + * lifetime is unknown, otherwise a {@see DateTimeInterface} or Unix + * timestamp representing its expiration time. + * @param string[]|null $scopes + * @param array|null $claims + */ + public function __construct( + string $token, + string $type, + $expires, + ?array $scopes = null, + ?array $claims = null + ) { + if (is_int($expires) && $expires < 0) { + throw new InvalidArgumentException(sprintf( + 'Invalid $expires: %d', + $expires + )); + } + + $this->Token = $token; + $this->Type = $type; + $this->Expires = $expires instanceof DateTimeInterface + ? Date::immutable($expires) + : ($expires === null + ? null + : new DateTimeImmutable("@$expires")); + $this->Scopes = $scopes ?: []; + $this->Claims = $claims ?: []; + } + + /** + * @inheritDoc + */ + public function getAuthenticationScheme(): string + { + return $this->Type; + } + + /** + * @inheritDoc + */ + public function getCredential(): string + { + return $this->Token; + } +} diff --git a/tests/unit/Toolkit/Http/OAuth2/AccessTokenTest.php b/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php similarity index 100% rename from tests/unit/Toolkit/Http/OAuth2/AccessTokenTest.php rename to tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php From 661294822a46b62a4277609a57aa5f810fdef65a Mon Sep 17 00:00:00 2001 From: Luke Arms Date: Tue, 13 May 2025 01:09:05 +1000 Subject: [PATCH 2/3] Http: Review credential classes - Split `AccessToken` into `GenericCredential`, `GenericToken` and `OAuth2AccessToken`, replacing public properties with getters --- .../Contract/Http/CredentialInterface.php | 7 +- src/Toolkit/Http/GenericCredential.php | 42 ++++++++++ src/Toolkit/Http/GenericToken.php | 72 +++++------------ src/Toolkit/Http/OAuth2/OAuth2AccessToken.php | 78 ++++++------------- src/Toolkit/Http/OAuth2/OAuth2Client.php | 33 ++++---- .../OAuth2/OAuth2Client/OAuth2TestClient.php | 4 +- tests/unit/Toolkit/Http/HeadersTest.php | 4 +- .../Http/OAuth2/OAuth2AccessTokenTest.php | 55 ++++++------- 8 files changed, 138 insertions(+), 157 deletions(-) create mode 100644 src/Toolkit/Http/GenericCredential.php diff --git a/src/Toolkit/Contract/Http/CredentialInterface.php b/src/Toolkit/Contract/Http/CredentialInterface.php index dbfbfd8f..7b867da8 100644 --- a/src/Toolkit/Contract/Http/CredentialInterface.php +++ b/src/Toolkit/Contract/Http/CredentialInterface.php @@ -8,12 +8,15 @@ interface CredentialInterface { /** - * Get the authentication scheme of the credential, e.g. "Bearer" + * Get the authentication scheme of the credential, e.g. "Basic", "Digest" + * or "Bearer" */ public function getAuthenticationScheme(): string; /** - * Get the credential + * Get the credential, e.g. a Base64-encoded user ID/password pair, a + * comma-delimited list of authorization parameters or an OAuth 2.0 access + * token */ public function getCredential(): string; } diff --git a/src/Toolkit/Http/GenericCredential.php b/src/Toolkit/Http/GenericCredential.php new file mode 100644 index 00000000..e0613bb7 --- /dev/null +++ b/src/Toolkit/Http/GenericCredential.php @@ -0,0 +1,42 @@ +AuthenticationScheme = $authenticationScheme; + $this->Credential = $credential; + } + + /** + * @inheritDoc + */ + public function getAuthenticationScheme(): string + { + return $this->AuthenticationScheme; + } + + /** + * @inheritDoc + */ + public function getCredential(): string + { + return $this->Credential; + } +} diff --git a/src/Toolkit/Http/GenericToken.php b/src/Toolkit/Http/GenericToken.php index 67e533b5..9d3c76eb 100644 --- a/src/Toolkit/Http/GenericToken.php +++ b/src/Toolkit/Http/GenericToken.php @@ -1,82 +1,52 @@ $Claims + * @api */ -final class AccessToken implements CredentialInterface, Immutable, Readable +class GenericToken extends GenericCredential { - use ReadableProtectedPropertiesTrait; - - protected string $Token; - protected string $Type; - protected ?DateTimeImmutable $Expires; - /** @var string[] */ - protected array $Scopes; - /** @var array */ - protected array $Claims; + private ?DateTimeImmutable $Expires; /** - * @param DateTimeInterface|int|null $expires `null` if the access token's - * lifetime is unknown, otherwise a {@see DateTimeInterface} or Unix + * @api + * + * @param DateTimeInterface|int|null $expires `null` if the token's lifetime + * is unknown or unlimited, otherwise a {@see DateTimeInterface} or Unix * timestamp representing its expiration time. - * @param string[]|null $scopes - * @param array|null $claims */ public function __construct( string $token, - string $type, - $expires, - ?array $scopes = null, - ?array $claims = null + string $authenticationScheme, + $expires = null ) { if (is_int($expires) && $expires < 0) { - throw new InvalidArgumentException(sprintf( - 'Invalid $expires: %d', - $expires - )); + throw new InvalidArgumentException( + sprintf('Invalid timestamp: %d', $expires), + ); } - $this->Token = $token; - $this->Type = $type; $this->Expires = $expires instanceof DateTimeInterface ? Date::immutable($expires) - : ($expires === null - ? null - : new DateTimeImmutable("@$expires")); - $this->Scopes = $scopes ?: []; - $this->Claims = $claims ?: []; - } + : ($expires !== null + ? new DateTimeImmutable('@' . $expires) + : null); - /** - * @inheritDoc - */ - public function getAuthenticationScheme(): string - { - return $this->Type; + parent::__construct($token, $authenticationScheme); } /** - * @inheritDoc + * Get the expiration time of the token, or null if its lifetime is unknown + * or unlimited */ - public function getCredential(): string + public function getExpires(): ?DateTimeImmutable { - return $this->Token; + return $this->Expires; } } diff --git a/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php b/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php index 67e533b5..9b53c77d 100644 --- a/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php +++ b/src/Toolkit/Http/OAuth2/OAuth2AccessToken.php @@ -2,81 +2,53 @@ namespace Salient\Http\OAuth2; -use Salient\Contract\Core\Entity\Readable; -use Salient\Contract\Core\Immutable; -use Salient\Contract\Http\CredentialInterface; -use Salient\Core\Concern\ReadableProtectedPropertiesTrait; -use Salient\Utility\Date; -use DateTimeImmutable; -use DateTimeInterface; -use InvalidArgumentException; +use Salient\Http\GenericToken; /** - * A token issued by an authorization provider for access to protected resources - * - * @property-read string $Token - * @property-read string $Type - * @property-read DateTimeImmutable|null $Expires - * @property-read string[] $Scopes - * @property-read array $Claims + * @api */ -final class AccessToken implements CredentialInterface, Immutable, Readable +class OAuth2AccessToken extends GenericToken { - use ReadableProtectedPropertiesTrait; - - protected string $Token; - protected string $Type; - protected ?DateTimeImmutable $Expires; /** @var string[] */ - protected array $Scopes; + private array $Scopes; /** @var array */ - protected array $Claims; + private array $Claims; /** - * @param DateTimeInterface|int|null $expires `null` if the access token's - * lifetime is unknown, otherwise a {@see DateTimeInterface} or Unix - * timestamp representing its expiration time. - * @param string[]|null $scopes - * @param array|null $claims + * @api + * + * @param string[] $scopes + * @param array $claims */ public function __construct( string $token, - string $type, - $expires, - ?array $scopes = null, - ?array $claims = null + $expires = null, + array $scopes = [], + array $claims = [] ) { - if (is_int($expires) && $expires < 0) { - throw new InvalidArgumentException(sprintf( - 'Invalid $expires: %d', - $expires - )); - } + $this->Scopes = $scopes; + $this->Claims = $claims; - $this->Token = $token; - $this->Type = $type; - $this->Expires = $expires instanceof DateTimeInterface - ? Date::immutable($expires) - : ($expires === null - ? null - : new DateTimeImmutable("@$expires")); - $this->Scopes = $scopes ?: []; - $this->Claims = $claims ?: []; + parent::__construct($token, 'Bearer', $expires); } /** - * @inheritDoc + * Get the token's scopes + * + * @return string[] */ - public function getAuthenticationScheme(): string + public function getScopes(): array { - return $this->Type; + return $this->Scopes; } /** - * @inheritDoc + * Get the token's claims + * + * @return array */ - public function getCredential(): string + public function getClaims(): array { - return $this->Token; + return $this->Claims; } } diff --git a/src/Toolkit/Http/OAuth2/OAuth2Client.php b/src/Toolkit/Http/OAuth2/OAuth2Client.php index b57abbbe..be3c6331 100644 --- a/src/Toolkit/Http/OAuth2/OAuth2Client.php +++ b/src/Toolkit/Http/OAuth2/OAuth2Client.php @@ -126,7 +126,7 @@ abstract protected function getJsonWebKeySetUrl(): ?string; * @param array|null $idToken * @param OAuth2GrantType::* $grantType */ - abstract protected function receiveToken(AccessToken $token, ?array $idToken, string $grantType): void; + abstract protected function receiveToken(OAuth2AccessToken $token, ?array $idToken, string $grantType): void; public function __construct() { @@ -162,9 +162,9 @@ final protected function getRedirectUri(): ?string * * @param string[]|null $scopes */ - final public function getAccessToken(?array $scopes = null): AccessToken + final public function getAccessToken(?array $scopes = null): OAuth2AccessToken { - $token = Cache::getInstance()->getInstanceOf($this->TokenKey, AccessToken::class); + $token = Cache::getInstance()->getInstanceOf($this->TokenKey, OAuth2AccessToken::class); if ($token) { if ($this->accessTokenHasScopes($token, $scopes)) { return $token; @@ -197,9 +197,9 @@ final public function getAccessToken(?array $scopes = null): AccessToken * * @param string[]|null $scopes */ - private function accessTokenHasScopes(AccessToken $token, ?array $scopes): bool + private function accessTokenHasScopes(OAuth2AccessToken $token, ?array $scopes): bool { - if ($scopes && array_diff($scopes, $token->Scopes)) { + if ($scopes && array_diff($scopes, $token->getScopes())) { return false; } return true; @@ -209,7 +209,7 @@ private function accessTokenHasScopes(AccessToken $token, ?array $scopes): bool * If an unexpired refresh token is available, use it to get a new access * token from the provider if possible */ - final protected function refreshAccessToken(): ?AccessToken + final protected function refreshAccessToken(): ?OAuth2AccessToken { $refreshToken = Cache::getString("{$this->TokenKey}:refresh"); return $refreshToken === null @@ -225,7 +225,7 @@ final protected function refreshAccessToken(): ?AccessToken * * @param array $options */ - final protected function authorize(array $options = []): AccessToken + final protected function authorize(array $options = []): OAuth2AccessToken { if (isset($options['scope'])) { $scopes = $this->filterScope($options['scope']); @@ -237,9 +237,9 @@ final protected function authorize(array $options = []): AccessToken $cache->has($this->TokenKey) || $cache->has("{$this->TokenKey}:refresh") ) { - $lastToken = $cache->getInstanceOf($this->TokenKey, AccessToken::class); + $lastToken = $cache->getInstanceOf($this->TokenKey, OAuth2AccessToken::class); if ($lastToken) { - $scopes = Arr::extend($lastToken->Scopes, ...$scopes); + $scopes = Arr::extend($lastToken->getScopes(), ...$scopes); } } $cache->close(); @@ -265,7 +265,7 @@ final protected function authorize(array $options = []): AccessToken /** * @param array $options */ - private function authorizeWithClientCredentials(array $options = []): AccessToken + private function authorizeWithClientCredentials(array $options = []): OAuth2AccessToken { // league/oauth2-client doesn't add scopes to client_credentials // requests @@ -290,7 +290,7 @@ private function authorizeWithClientCredentials(array $options = []): AccessToke /** * @param array $options */ - private function authorizeWithAuthorizationCode(array $options = []): AccessToken + private function authorizeWithAuthorizationCode(array $options = []): OAuth2AccessToken { if (!$this->Listener) { throw new LogicException('Cannot use the Authorization Code flow without a Listener'); @@ -380,7 +380,7 @@ private function requestAccessToken( string $grantType, array $options = [], $scope = null - ): AccessToken { + ): OAuth2AccessToken { Console::debug('Requesting access token with ' . $grantType); $_token = $this->Provider->getAccessToken($grantType, $options); @@ -419,21 +419,20 @@ private function requestAccessToken( ?? $scope); if (!$scopes && $grantType === OAuth2GrantType::REFRESH_TOKEN) { - $lastToken = Cache::getInstance()->getInstanceOf($this->TokenKey, AccessToken::class); + $lastToken = Cache::getInstance()->getInstanceOf($this->TokenKey, OAuth2AccessToken::class); if ($lastToken) { - $scopes = $lastToken->Scopes; + $scopes = $lastToken->getScopes(); } } - $token = new AccessToken( + $token = new OAuth2AccessToken( $accessToken, - $tokenType, $expires, $scopes ?: $this->getDefaultScopes(), $claims ); - Cache::set($this->TokenKey, $token, $token->Expires); + Cache::set($this->TokenKey, $token, $token->getExpires()); if ($idToken !== null) { $idToken = $this->getValidJsonWebToken($idToken, true); diff --git a/tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php b/tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php index 07e2ee79..89778a45 100644 --- a/tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php +++ b/tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php @@ -4,7 +4,7 @@ use League\OAuth2\Client\Provider\GenericProvider; use Salient\Core\Facade\Console; -use Salient\Http\OAuth2\AccessToken; +use Salient\Http\OAuth2\OAuth2AccessToken; use Salient\Http\OAuth2\OAuth2Client; use Salient\Http\OAuth2\OAuth2Flow; use Salient\Http\Server\Server; @@ -88,7 +88,7 @@ protected function getJsonWebKeySetUrl(): ?string /** * @inheritDoc */ - protected function receiveToken(AccessToken $token, ?array $idToken, string $grantType): void + protected function receiveToken(OAuth2AccessToken $token, ?array $idToken, string $grantType): void { Console::debug('OAuth 2.0 access token received'); } diff --git a/tests/unit/Toolkit/Http/HeadersTest.php b/tests/unit/Toolkit/Http/HeadersTest.php index 10f54727..db820399 100644 --- a/tests/unit/Toolkit/Http/HeadersTest.php +++ b/tests/unit/Toolkit/Http/HeadersTest.php @@ -10,7 +10,7 @@ use Salient\Contract\Http\HasHttpHeader; use Salient\Contract\Http\HasHttpHeaders; use Salient\Contract\Http\HasMediaType; -use Salient\Http\OAuth2\AccessToken; +use Salient\Http\OAuth2\OAuth2AccessToken; use Salient\Http\Headers; use Salient\Tests\TestCase; use Salient\Utility\Arr; @@ -664,7 +664,7 @@ public function testMap(): void public function testFilter(): void { $index = Arr::toIndex(Arr::lower(self::HEADERS_SENSITIVE)); - $token = new AccessToken('foo.bar.baz', 'Bearer', time() + 3600); + $token = new OAuth2AccessToken('foo.bar.baz', time() + 3600); $headers = (new Headers()) ->authorize($token) ->set(self::HEADER_ACCEPT, '*/*') diff --git a/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php b/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php index d1267d89..7708029c 100644 --- a/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php +++ b/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php @@ -2,64 +2,59 @@ namespace Salient\Tests\Http\OAuth2; -use Salient\Http\OAuth2\AccessToken; +use Salient\Http\OAuth2\OAuth2AccessToken; use Salient\Tests\TestCase; use DateTimeImmutable; use InvalidArgumentException; /** - * @covers \Salient\Http\OAuth2\AccessToken + * @covers \Salient\Http\OAuth2\OAuth2AccessToken + * @covers \Salient\Http\GenericToken + * @covers \Salient\Http\GenericCredential */ -final class AccessTokenTest extends TestCase +final class OAuth2AccessTokenTest extends TestCase { private const TOKEN = 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; - public function testInterfaceMethods(): void + public function testGetCredential(): void { - $token = new AccessToken(self::TOKEN, 'Bearer', null); - $this->assertSame(self::TOKEN, $token->getCredential()); + $token = new OAuth2AccessToken(self::TOKEN); $this->assertSame('Bearer', $token->getAuthenticationScheme()); + $this->assertSame(self::TOKEN, $token->getCredential()); } - public function testToken(): void - { - $token = new AccessToken(self::TOKEN, 'Bearer', null); - $this->assertSame(self::TOKEN, $token->Token); - $this->assertSame('Bearer', $token->Type); - } - - public function testExpires(): void + public function testGetExpires(): void { - $token = new AccessToken(self::TOKEN, 'Bearer', null); - $this->assertNull($token->Expires); + $token = new OAuth2AccessToken(self::TOKEN); + $this->assertNull($token->getExpires()); $expires = new DateTimeImmutable('+1 hour'); - $token = new AccessToken(self::TOKEN, 'Bearer', $expires); - $this->assertSame($expires, $token->Expires); + $token = new OAuth2AccessToken(self::TOKEN, $expires); + $this->assertSame($expires, $token->getExpires()); $expires = time() + 3600; - $token = new AccessToken(self::TOKEN, 'Bearer', $expires); - $this->assertNotNull($token->Expires); - $this->assertSame($expires, $token->Expires->getTimestamp()); + $token = new OAuth2AccessToken(self::TOKEN, $expires); + $this->assertNotNull($token->getExpires()); + $this->assertSame($expires, $token->getExpires()->getTimestamp()); } - public function testInvalidExpires(): void + public function testInvalidExpiration(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid $expires: -1'); - $token = new AccessToken(self::TOKEN, 'Bearer', -1); + $this->expectExceptionMessage('Invalid timestamp: -1'); + new OAuth2AccessToken(self::TOKEN, -1); } public function testScopesAndClaims(): void { $scopes = ['openid', 'profile']; $claims = ['aud' => __CLASS__]; - $token = new AccessToken(self::TOKEN, 'Bearer', null, $scopes, $claims); - $this->assertSame($scopes, $token->Scopes); - $this->assertSame($claims, $token->Claims); + $token = new OAuth2AccessToken(self::TOKEN, null, $scopes, $claims); + $this->assertSame($scopes, $token->getScopes()); + $this->assertSame($claims, $token->getClaims()); - $token = new AccessToken(self::TOKEN, 'Bearer', null, null, null); - $this->assertSame([], $token->Scopes); - $this->assertSame([], $token->Claims); + $token = new OAuth2AccessToken(self::TOKEN); + $this->assertSame([], $token->getScopes()); + $this->assertSame([], $token->getClaims()); } } From b7f6a33144987756d23077efc42bfb083d99de0c Mon Sep 17 00:00:00 2001 From: Luke Arms Date: Wed, 21 May 2025 00:07:28 +1000 Subject: [PATCH 3/3] Http: Review OAuth 2.0 classes - Rename `OAuth2GrantType` to `HasGrantType`; access its constants (now with `GRANT_` prefixes) via its implementations; add missing grants, incl. `device_code` for planned device flow support - Remove redundant `OAuth2Flow` (grants and flows are equivalent) - Add `HasResponseType` for values passed to the authorization endpoint, incl. for OpenID Connect flows --- phpstan-baseline-7.4.neon | 18 --- phpstan-baseline-8.3.neon | 18 --- phpstan.neon.dist | 1 + src/Toolkit/Http/OAuth2/HasGrantType.php | 47 ++++++++ src/Toolkit/Http/OAuth2/HasResponseType.php | 60 ++++++++++ src/Toolkit/Http/OAuth2/OAuth2Client.php | 109 ++++-------------- src/Toolkit/Http/OAuth2/OAuth2Flow.php | 19 --- src/Toolkit/Http/OAuth2/OAuth2GrantType.php | 13 --- .../OAuth2/OAuth2Client/OAuth2TestClient.php | 7 +- 9 files changed, 136 insertions(+), 156 deletions(-) create mode 100644 src/Toolkit/Http/OAuth2/HasGrantType.php create mode 100644 src/Toolkit/Http/OAuth2/HasResponseType.php delete mode 100644 src/Toolkit/Http/OAuth2/OAuth2Flow.php delete mode 100644 src/Toolkit/Http/OAuth2/OAuth2GrantType.php diff --git a/phpstan-baseline-7.4.neon b/phpstan-baseline-7.4.neon index c0bcc59b..820da76d 100644 --- a/phpstan-baseline-7.4.neon +++ b/phpstan-baseline-7.4.neon @@ -18,24 +18,6 @@ parameters: count: 1 path: tests/fixtures/Toolkit/Core/Process/cat.php - - - message: '#^Method Salient\\Tests\\Http\\OAuth2\\OAuth2Client\\OAuth2TestClient\:\:getFlow\(\) never returns 1 so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - - - - message: '#^Method Salient\\Tests\\Http\\OAuth2\\OAuth2Client\\OAuth2TestClient\:\:getJsonWebKeySetUrl\(\) never returns null so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - - - - message: '#^Method Salient\\Tests\\Http\\OAuth2\\OAuth2Client\\OAuth2TestClient\:\:getListener\(\) never returns null so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - - message: '#^PHPDoc tag @property has invalid value \(\$MyMagicProperty Description of MyBaseClass\:\:\$MyMagicProperty\)\: Unexpected token "\$MyMagicProperty", expected type at offset 46 on line 4$#' identifier: phpDoc.parseError diff --git a/phpstan-baseline-8.3.neon b/phpstan-baseline-8.3.neon index bef2b816..811aa250 100644 --- a/phpstan-baseline-8.3.neon +++ b/phpstan-baseline-8.3.neon @@ -18,24 +18,6 @@ parameters: count: 1 path: tests/fixtures/Toolkit/Core/Process/cat.php - - - message: '#^Method Salient\\Tests\\Http\\OAuth2\\OAuth2Client\\OAuth2TestClient\:\:getFlow\(\) never returns 1 so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - - - - message: '#^Method Salient\\Tests\\Http\\OAuth2\\OAuth2Client\\OAuth2TestClient\:\:getJsonWebKeySetUrl\(\) never returns null so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - - - - message: '#^Method Salient\\Tests\\Http\\OAuth2\\OAuth2Client\\OAuth2TestClient\:\:getListener\(\) never returns null so it can be removed from the return type\.$#' - identifier: return.unusedType - count: 1 - path: tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - - message: '#^PHPDoc tag @property has invalid value \(\$MyMagicProperty Description of MyBaseClass\:\:\$MyMagicProperty\)\: Unexpected token "\$MyMagicProperty", expected type at offset 46 on line 4$#' identifier: phpDoc.parseError diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 418daced..82bb44f4 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -40,6 +40,7 @@ parameters: paths: - src/Toolkit/Core/Facade/* - tests/unit/Toolkit/Core/Facade/* + - tests/fixtures/Toolkit/Http/OAuth2/OAuth2Client/OAuth2TestClient.php - identifier: salient.needless.coalesce paths: diff --git a/src/Toolkit/Http/OAuth2/HasGrantType.php b/src/Toolkit/Http/OAuth2/HasGrantType.php new file mode 100644 index 00000000..cb808458 --- /dev/null +++ b/src/Toolkit/Http/OAuth2/HasGrantType.php @@ -0,0 +1,47 @@ +withProxy( - * $proxyHost, - * $proxyPort, - * Env::getNullableBool('app_proxy_tls', null), - * Env::get('app_proxy_base_path', ''), - * ); - * } - * - * return $listener; - * } - * } - * ``` + * Get an in-process HTTP server to receive OAuth 2.0 redirects from the + * provider to the client, or null if flows that require it are disabled */ abstract protected function getListener(): ?Server; /** - * Return an OAuth 2.0 provider to request and validate tokens that - * authorize access to the resource server - * - * Example: - * - * The following provider could be used to authorize access to the Microsoft - * Graph API on behalf of a user or application. `redirectUri` can be - * omitted if support for the Authorization Code flow is not required. - * - * > The only scope required for access to the Microsoft Graph API is - * > `https://graph.microsoft.com/.default` - * - * ```php - * $this->AppId, - * 'clientSecret' => $this->Secret, - * 'redirectUri' => $this->getRedirectUri(), - * 'urlAuthorize' => sprintf('https://login.microsoftonline.com/%s/oauth2/authorize', $this->TenantId), - * 'urlAccessToken' => sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $this->TenantId), - * 'urlResourceOwnerDetails' => sprintf('https://login.microsoftonline.com/%s/openid/userinfo', $this->TenantId), - * 'scopes' => ['openid', 'profile', 'email', 'offline_access', 'https://graph.microsoft.com/.default'], - * 'scopeSeparator' => ' ', - * ]); - * } - * } - * ``` + * Get an OAuth 2.0 provider for the client */ abstract protected function getProvider(): AbstractProvider; /** - * Return the OAuth 2.0 flow to use + * Get the client's OAuth 2.0 flow * - * @return OAuth2Flow::* + * @return OAuth2Client::GRANT_* */ - abstract protected function getFlow(): int; + abstract protected function getFlow(): string; /** - * Return the URL of the OAuth 2.0 provider's JSON Web Key Set, or null to + * Get the URL of the OAuth 2.0 provider's JSON Web Key Set, or null to * disable JWT signature validation and decoding * * Required for token signature validation. Check the provider's @@ -124,9 +59,13 @@ abstract protected function getJsonWebKeySetUrl(): ?string; * Called when an access token is received from the OAuth 2.0 provider * * @param array|null $idToken - * @param OAuth2GrantType::* $grantType + * @param OAuth2Client::GRANT_* $grantType */ - abstract protected function receiveToken(OAuth2AccessToken $token, ?array $idToken, string $grantType): void; + abstract protected function receiveToken( + OAuth2AccessToken $token, + ?array $idToken, + string $grantType + ): void; public function __construct() { @@ -215,7 +154,7 @@ final protected function refreshAccessToken(): ?OAuth2AccessToken return $refreshToken === null ? null : $this->requestAccessToken( - OAuth2GrantType::REFRESH_TOKEN, + self::GRANT_REFRESH_TOKEN, ['refresh_token' => $refreshToken] ); } @@ -251,14 +190,14 @@ final protected function authorize(array $options = []): OAuth2AccessToken $this->flushTokens(); switch ($this->Flow) { - case OAuth2Flow::CLIENT_CREDENTIALS: + case self::GRANT_CLIENT_CREDENTIALS: return $this->authorizeWithClientCredentials($options); - case OAuth2Flow::AUTHORIZATION_CODE: + case self::GRANT_AUTHORIZATION_CODE: return $this->authorizeWithAuthorizationCode($options); default: - throw new LogicException(sprintf('Invalid OAuth2Flow: %d', $this->Flow)); + throw new LogicException(sprintf('Invalid flow: %s', $this->Flow)); } } @@ -282,7 +221,7 @@ private function authorizeWithClientCredentials(array $options = []): OAuth2Acce } return $this->requestAccessToken( - OAuth2GrantType::CLIENT_CREDENTIALS, + self::GRANT_CLIENT_CREDENTIALS, $options ); } @@ -327,7 +266,7 @@ private function authorizeWithAuthorizationCode(array $options = []): OAuth2Acce } return $this->requestAccessToken( - OAuth2GrantType::AUTHORIZATION_CODE, + self::GRANT_AUTHORIZATION_CODE, ['code' => $code], $options['scope'] ?? null ); @@ -372,7 +311,7 @@ private function receiveAuthorizationCode(ServerRequestInterface $request): Serv * Request an access token from the OAuth 2.0 provider, then validate, cache * and return it * - * @param string&OAuth2GrantType::* $grantType + * @param self::GRANT_* $grantType * @param array $options * @param mixed $scope */ @@ -418,7 +357,7 @@ private function requestAccessToken( ?? $options['scope'] ?? $scope); - if (!$scopes && $grantType === OAuth2GrantType::REFRESH_TOKEN) { + if (!$scopes && $grantType === self::GRANT_REFRESH_TOKEN) { $lastToken = Cache::getInstance()->getInstanceOf($this->TokenKey, OAuth2AccessToken::class); if ($lastToken) { $scopes = $lastToken->getScopes(); diff --git a/src/Toolkit/Http/OAuth2/OAuth2Flow.php b/src/Toolkit/Http/OAuth2/OAuth2Flow.php deleted file mode 100644 index 4e11fa61..00000000 --- a/src/Toolkit/Http/OAuth2/OAuth2Flow.php +++ /dev/null @@ -1,19 +0,0 @@ - $this->AppId, 'clientSecret' => $this->Secret, + // `redirectUri` can be omitted if support for the Authorization + // Code flow is not required 'redirectUri' => $this->getRedirectUri(), 'urlAuthorize' => sprintf('https://login.microsoftonline.com/%s/oauth2/authorize', $this->TenantId), 'urlAccessToken' => sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $this->TenantId), @@ -72,9 +73,9 @@ protected function getProvider(): GenericProvider /** * @inheritDoc */ - protected function getFlow(): int + protected function getFlow(): string { - return OAuth2Flow::CLIENT_CREDENTIALS; + return self::GRANT_CLIENT_CREDENTIALS; } /**