diff --git a/phpstan-baseline-7.4.neon b/phpstan-baseline-7.4.neon index c0bcc59b2..820da76d6 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 bef2b816d..811aa2506 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 418daced1..82bb44f4e 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/Contract/Http/CredentialInterface.php b/src/Toolkit/Contract/Http/CredentialInterface.php index dbfbfd8f3..7b867da83 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 000000000..e0613bb7c --- /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 new file mode 100644 index 000000000..9d3c76ebb --- /dev/null +++ b/src/Toolkit/Http/GenericToken.php @@ -0,0 +1,52 @@ +Expires = $expires instanceof DateTimeInterface + ? Date::immutable($expires) + : ($expires !== null + ? new DateTimeImmutable('@' . $expires) + : null); + + parent::__construct($token, $authenticationScheme); + } + + /** + * Get the expiration time of the token, or null if its lifetime is unknown + * or unlimited + */ + public function getExpires(): ?DateTimeImmutable + { + return $this->Expires; + } +} diff --git a/src/Toolkit/Http/OAuth2/AccessToken.php b/src/Toolkit/Http/OAuth2/AccessToken.php deleted file mode 100644 index 67e533b58..000000000 --- a/src/Toolkit/Http/OAuth2/AccessToken.php +++ /dev/null @@ -1,82 +0,0 @@ - $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/src/Toolkit/Http/OAuth2/HasGrantType.php b/src/Toolkit/Http/OAuth2/HasGrantType.php new file mode 100644 index 000000000..cb8084588 --- /dev/null +++ b/src/Toolkit/Http/OAuth2/HasGrantType.php @@ -0,0 +1,47 @@ + */ + private array $Claims; + + /** + * @api + * + * @param string[] $scopes + * @param array $claims + */ + public function __construct( + string $token, + $expires = null, + array $scopes = [], + array $claims = [] + ) { + $this->Scopes = $scopes; + $this->Claims = $claims; + + parent::__construct($token, 'Bearer', $expires); + } + + /** + * Get the token's scopes + * + * @return string[] + */ + public function getScopes(): array + { + return $this->Scopes; + } + + /** + * Get the token's claims + * + * @return array + */ + public function getClaims(): array + { + return $this->Claims; + } +} diff --git a/src/Toolkit/Http/OAuth2/OAuth2Client.php b/src/Toolkit/Http/OAuth2/OAuth2Client.php index b57abbbeb..fd3620ba6 100644 --- a/src/Toolkit/Http/OAuth2/OAuth2Client.php +++ b/src/Toolkit/Http/OAuth2/OAuth2Client.php @@ -10,7 +10,6 @@ use Salient\Core\Facade\Cache; use Salient\Core\Facade\Console; use Salient\Curler\Curler; -use Salient\Http\Message\Response; use Salient\Http\Server\Server; use Salient\Http\Server\ServerResponse; use Salient\Utility\Arr; @@ -21,98 +20,34 @@ use Throwable; use UnexpectedValueException; -/** - * A headless OAuth 2.0 client that acquires and validates tokens required for - * access to protected resources - */ -abstract class OAuth2Client +abstract class OAuth2Client implements HasGrantType, HasResponseType { private ?Server $Listener; private AbstractProvider $Provider; - /** @var OAuth2Flow::* */ - private int $Flow; + /** @var self::GRANT_* */ + private string $Flow; private string $TokenKey; /** - * Return an HTTP listener to receive OAuth 2.0 redirects from the provider, - * or null to disable flows that require it - * - * Reference implementation: - * - * ```php - * 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(AccessToken $token, ?array $idToken, string $grantType): void; + abstract protected function receiveToken( + OAuth2AccessToken $token, + ?array $idToken, + string $grantType + ): void; public function __construct() { @@ -162,9 +101,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 +136,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,13 +148,13 @@ 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 ? null : $this->requestAccessToken( - OAuth2GrantType::REFRESH_TOKEN, + self::GRANT_REFRESH_TOKEN, ['refresh_token' => $refreshToken] ); } @@ -225,7 +164,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 +176,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(); @@ -251,21 +190,21 @@ final protected function authorize(array $options = []): AccessToken $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)); } } /** * @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 @@ -282,7 +221,7 @@ private function authorizeWithClientCredentials(array $options = []): AccessToke } return $this->requestAccessToken( - OAuth2GrantType::CLIENT_CREDENTIALS, + self::GRANT_CLIENT_CREDENTIALS, $options ); } @@ -290,7 +229,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'); @@ -327,7 +266,7 @@ private function authorizeWithAuthorizationCode(array $options = []): AccessToke } 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 */ @@ -380,7 +319,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); @@ -418,22 +357,21 @@ private function requestAccessToken( ?? $options['scope'] ?? $scope); - if (!$scopes && $grantType === OAuth2GrantType::REFRESH_TOKEN) { - $lastToken = Cache::getInstance()->getInstanceOf($this->TokenKey, AccessToken::class); + if (!$scopes && $grantType === self::GRANT_REFRESH_TOKEN) { + $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/src/Toolkit/Http/OAuth2/OAuth2Flow.php b/src/Toolkit/Http/OAuth2/OAuth2Flow.php deleted file mode 100644 index 4e11fa610..000000000 --- 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; } /** @@ -88,7 +89,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 10f547278..db8203997 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/AccessTokenTest.php b/tests/unit/Toolkit/Http/OAuth2/AccessTokenTest.php deleted file mode 100644 index d1267d893..000000000 --- a/tests/unit/Toolkit/Http/OAuth2/AccessTokenTest.php +++ /dev/null @@ -1,65 +0,0 @@ -assertSame(self::TOKEN, $token->getCredential()); - $this->assertSame('Bearer', $token->getAuthenticationScheme()); - } - - 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 - { - $token = new AccessToken(self::TOKEN, 'Bearer', null); - $this->assertNull($token->Expires); - - $expires = new DateTimeImmutable('+1 hour'); - $token = new AccessToken(self::TOKEN, 'Bearer', $expires); - $this->assertSame($expires, $token->Expires); - - $expires = time() + 3600; - $token = new AccessToken(self::TOKEN, 'Bearer', $expires); - $this->assertNotNull($token->Expires); - $this->assertSame($expires, $token->Expires->getTimestamp()); - } - - public function testInvalidExpires(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid $expires: -1'); - $token = new AccessToken(self::TOKEN, 'Bearer', -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 AccessToken(self::TOKEN, 'Bearer', null, null, null); - $this->assertSame([], $token->Scopes); - $this->assertSame([], $token->Claims); - } -} diff --git a/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php b/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php new file mode 100644 index 000000000..7708029c1 --- /dev/null +++ b/tests/unit/Toolkit/Http/OAuth2/OAuth2AccessTokenTest.php @@ -0,0 +1,60 @@ +assertSame('Bearer', $token->getAuthenticationScheme()); + $this->assertSame(self::TOKEN, $token->getCredential()); + } + + public function testGetExpires(): void + { + $token = new OAuth2AccessToken(self::TOKEN); + $this->assertNull($token->getExpires()); + + $expires = new DateTimeImmutable('+1 hour'); + $token = new OAuth2AccessToken(self::TOKEN, $expires); + $this->assertSame($expires, $token->getExpires()); + + $expires = time() + 3600; + $token = new OAuth2AccessToken(self::TOKEN, $expires); + $this->assertNotNull($token->getExpires()); + $this->assertSame($expires, $token->getExpires()->getTimestamp()); + } + + public function testInvalidExpiration(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid timestamp: -1'); + new OAuth2AccessToken(self::TOKEN, -1); + } + + public function testScopesAndClaims(): void + { + $scopes = ['openid', 'profile']; + $claims = ['aud' => __CLASS__]; + $token = new OAuth2AccessToken(self::TOKEN, null, $scopes, $claims); + $this->assertSame($scopes, $token->getScopes()); + $this->assertSame($claims, $token->getClaims()); + + $token = new OAuth2AccessToken(self::TOKEN); + $this->assertSame([], $token->getScopes()); + $this->assertSame([], $token->getClaims()); + } +}