diff --git a/README.md b/README.md index 4d5fd2157..525fa8049 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ The following RFCs are implemented: * [RFC6750 " The OAuth 2.0 Authorization Framework: Bearer Token Usage"](https://tools.ietf.org/html/rfc6750) * [RFC7519 "JSON Web Token (JWT)"](https://tools.ietf.org/html/rfc7519) * [RFC7636 "Proof Key for Code Exchange by OAuth Public Clients"](https://tools.ietf.org/html/rfc7636) +* [RFC7662 "OAuth 2.0 Token Introspection"](https://tools.ietf.org/html/rfc7662) This library was created by Alex Bilbie. Find him on Twitter at [@alexbilbie](https://twitter.com/alexbilbie). diff --git a/examples/public/introspect.php b/examples/public/introspect.php new file mode 100644 index 000000000..36a3d9553 --- /dev/null +++ b/examples/public/introspect.php @@ -0,0 +1,59 @@ + function () { + + // Setup the authorization server + $server = new AuthorizationServer( + new ClientRepository(), // instance of ClientRepositoryInterface + new AccessTokenRepository(), // instance of AccessTokenRepositoryInterface + new ScopeRepository(), // instance of ScopeRepositoryInterface + 'file://' . __DIR__ . '/../private.key', // path to private key + 'lxZFUEsBCJ2Yb14IF2ygAHI5N4+ZAUXXaSeeJm6+twsUmIen' // encryption key + ); + + return $server; + }, +]); + +$app->post( + '/introspect', + function (ServerRequestInterface $request, ResponseInterface $response) use ($app) { + + /* @var \League\OAuth2\Server\AuthorizationServer $server */ + $server = $app->getContainer()->get(AuthorizationServer::class); + + try { + // Validate the given introspect request + $server->validateIntrospectionRequest($request); + + // Try to respond to the introspection request + return $server->respondToIntrospectionRequest($request, $response); + } catch (OAuthServerException $exception) { + + // All instances of OAuthServerException can be converted to a PSR-7 response + return $exception->generateHttpResponse($response); + } catch (\Exception $exception) { + + // Catch unexpected exceptions + $body = $response->getBody(); + $body->write($exception->getMessage()); + + return $response->withStatus(500)->withBody($body); + } + } +); + +$app->run(); diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index f1e96146b..a147d5989 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -14,12 +14,16 @@ use League\Event\EmitterAwareTrait; use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Grant\GrantTypeInterface; +use League\OAuth2\Server\IntrospectionValidators\BearerTokenValidator; +use League\OAuth2\Server\IntrospectionValidators\IntrospectionValidatorInterface; use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; use League\OAuth2\Server\Repositories\ClientRepositoryInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use League\OAuth2\Server\ResponseTypes\AbstractResponseType; +use League\OAuth2\Server\ResponseTypes\BearerTokenIntrospectionResponse; use League\OAuth2\Server\ResponseTypes\BearerTokenResponse; +use League\OAuth2\Server\ResponseTypes\IntrospectionResponse; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -53,6 +57,21 @@ class AuthorizationServer implements EmitterAwareInterface */ protected $responseType; + /** + * @var null|IntrospectionResponse + */ + protected $introspectionResponseType; + + /** + * @var null|IntrospectionValidatorInterface + */ + protected $introspectionValidator; + + /** + * @var null|Introspector + */ + protected $introspector; + /** * @var ClientRepositoryInterface */ @@ -197,6 +216,103 @@ public function respondToAccessTokenRequest(ServerRequestInterface $request, Res throw OAuthServerException::unsupportedGrantType(); } + /** + * Set the introspection response type. + * + * @param IntrospectionResponse $reponseType + */ + public function setIntrospectionReponseType(IntrospectionResponse $reponseType) + { + $this->introspectionResponseType = $reponseType; + } + + /** + * Set the validator used for introspection requests. + * + * @param IntrospectionValidatorInterface $introspectionValidator + */ + public function setIntrospectionValidator(IntrospectionValidatorInterface $introspectionValidator) + { + $this->introspectionValidator = $introspectionValidator; + } + + /** + * Get the introspection response. + * + * @return IntrospectionResponse + */ + protected function getIntrospectionResponseType() + { + if ($this->introspectionResponseType instanceof IntrospectionResponse === false) { + $this->introspectionResponseType = new BearerTokenIntrospectionResponse(); + } + + return $this->introspectionResponseType; + } + + /** + * Get the introspection response + * + * @return IntrospectionValidatorInterface + */ + protected function getIntrospectionValidator() + { + if ($this->introspectionValidator instanceof IntrospectionValidatorInterface === false) { + $this->introspectionValidator = new BearerTokenValidator($this->accessTokenRepository); + } + + return $this->introspectionValidator; + } + + /** + * Return an introspection response. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * + * @return ResponseInterface + */ + public function respondToIntrospectionRequest(ServerRequestInterface $request, ResponseInterface $response) + { + $introspector = $this->getIntrospector(); + + $introspectionResponse = $introspector->respondToIntrospectionRequest( + $request, + $this->getIntrospectionResponseType() + ); + + return $introspectionResponse->generateHttpResponse($response); + } + + /** + * Validate an introspection request. + * + * @param ServerRequestInterface $request + */ + public function validateIntrospectionRequest(ServerRequestInterface $request) + { + $introspector = $this->getIntrospector(); + $introspector->validateIntrospectionRequest($request); + } + + /** + * Returns the introspector. + * + * @return Introspector + */ + private function getIntrospector() + { + if (!isset($this->introspector)) { + $this->introspector = new Introspector( + $this->accessTokenRepository, + $this->privateKey, + $this->getIntrospectionValidator() + ); + } + + return $this->introspector; + } + /** * Get the token type that grants will return in the HTTP response. * diff --git a/src/IntrospectionValidators/BearerTokenValidator.php b/src/IntrospectionValidators/BearerTokenValidator.php new file mode 100644 index 000000000..7b9aa5f3d --- /dev/null +++ b/src/IntrospectionValidators/BearerTokenValidator.php @@ -0,0 +1,126 @@ +accessTokenRepository = $accessTokenRepository; + } + + /** + * Set the private key. + * + * @param \League\OAuth2\Server\CryptKey $key + */ + public function setPrivateKey(CryptKey $key) + { + $this->privateKey = $key; + } + + /** + * {@inheritdoc} + */ + public function validateIntrospection(ServerRequestInterface $request) + { + try { + $token = $this->getTokenFromRequest($request); + } catch (InvalidArgumentException $e) { + return false; + } + + if ( + $this->isTokenRevoked($token) || + $this->isTokenExpired($token) || + $this->isTokenUnverified($token) + ) { + return false; + } + + return true; + } + + /** + * Gets the token from the request body. + * + * @param ServerRequestInterface $request + * + * @return Token + */ + public function getTokenFromRequest(ServerRequestInterface $request) + { + $jwt = $request->getParsedBody()['token'] ?? null; + + return (new Parser()) + ->parse($jwt); + } + + /** + * Checks whether the token is unverified. + * + * @param Token $token + * + * @return bool + */ + private function isTokenUnverified(Token $token) + { + $keychain = new Keychain(); + + $key = $keychain->getPrivateKey( + $this->privateKey->getKeyPath(), + $this->privateKey->getPassPhrase() + ); + + return $token->verify(new Sha256(), $key->getContent()) === false; + } + + /** + * Ensure access token hasn't expired. + * + * @param Token $token + * + * @return bool + */ + private function isTokenExpired(Token $token) + { + $data = new ValidationData(time()); + + return !$token->validate($data); + } + + /** + * Check if the given token is revoked. + * + * @param Token $token + * + * @return bool + */ + private function isTokenRevoked(Token $token) + { + return $this->accessTokenRepository->isAccessTokenRevoked($token->getClaim('jti')); + } +} diff --git a/src/IntrospectionValidators/IntrospectionValidatorInterface.php b/src/IntrospectionValidators/IntrospectionValidatorInterface.php new file mode 100644 index 000000000..4c004e242 --- /dev/null +++ b/src/IntrospectionValidators/IntrospectionValidatorInterface.php @@ -0,0 +1,17 @@ +accessTokenRepository = $accessTokenRepository; + $this->privateKey = $privateKey; + $this->introspectionValidator = $introspectionValidator; + } + + /** + * Validate the introspection request. + * + * @param ServerRequestInterface $request + * + * @throws OAuthServerException + */ + public function validateIntrospectionRequest(ServerRequestInterface $request) + { + if ($request->getMethod() !== 'POST') { + throw OAuthServerException::accessDenied('Invalid request method'); + } + } + + /** + * Return an introspection response. + * + * @param ServerRequestInterface $request + * @param IntrospectionResponse $responseType + * + * @return IntrospectionResponse + */ + public function respondToIntrospectionRequest( + ServerRequestInterface $request, + IntrospectionResponse $responseType + ) { + $validator = $this->getIntrospectionValidator(); + + if ($validator->validateIntrospection($request)) { + $responseType->setRequest($request); + $responseType->setValidity(true); + } + + return $responseType; + } + + /** + * Get the introspection validator, falling back to the bearer token validator if not set. + * + * @return IntrospectionValidatorInterface + */ + protected function getIntrospectionValidator() + { + if ($this->introspectionValidator instanceof IntrospectionValidatorInterface === false) { + $this->introspectionValidator = new BearerTokenValidator($this->accessTokenRepository); + $this->introspectionValidator->setPrivateKey($this->privateKey); + } + + return $this->introspectionValidator; + } +} diff --git a/src/ResponseTypes/BearerTokenIntrospectionResponse.php b/src/ResponseTypes/BearerTokenIntrospectionResponse.php new file mode 100644 index 000000000..a784c893f --- /dev/null +++ b/src/ResponseTypes/BearerTokenIntrospectionResponse.php @@ -0,0 +1,45 @@ +getTokenFromRequest(); + + $responseParams = [ + 'active' => true, + 'token_type' => 'access_token', + 'scope' => $token->getClaim('scopes', ''), + 'client_id' => $token->getClaim('aud'), + 'exp' => $token->getClaim('exp'), + 'iat' => $token->getClaim('iat'), + 'sub' => $token->getClaim('sub'), + 'jti' => $token->getClaim('jti'), + ]; + + return array_merge($this->getExtraParams(), $responseParams); + } + + /** + * Gets the token from the request body. + * + * @return Token + */ + protected function getTokenFromRequest() + { + $jwt = $this->request->getParsedBody()['token'] ?? null; + + return (new Parser()) + ->parse($jwt); + } +} diff --git a/src/ResponseTypes/IntrospectionResponse.php b/src/ResponseTypes/IntrospectionResponse.php new file mode 100644 index 000000000..479834ca8 --- /dev/null +++ b/src/ResponseTypes/IntrospectionResponse.php @@ -0,0 +1,121 @@ +valid = $bool; + } + + /** + * Set the request. + * + * @param ServerRequestInterface $request + */ + public function setRequest(ServerRequestInterface $request) + { + $this->request = $request; + } + + /** + * Return the valid introspection parameters. + * + * @return array + */ + protected function validIntrospectionResponse() + { + $responseParams = [ + 'active' => true, + ]; + + return array_merge($this->getExtraParams(), $responseParams); + } + + /** + * Return the invalid introspection parameters. + * + * @return array + */ + protected function invalidIntrospectionResponse() + { + return [ + 'active' => false, + ]; + } + + /** + * Extract the introspection response. + * + * @return array + */ + public function getIntrospectionResponseParams() + { + return $this->isValid() ? + $this->validIntrospectionResponse() : + $this->invalidIntrospectionResponse(); + } + + /** + * Check if the response is valid. + * + * @return bool + */ + protected function isValid() + { + return $this->valid === true; + } + + /** + * Generate a HTTP response. + * + * @param ResponseInterface $response + * + * @return ResponseInterface + */ + public function generateHttpResponse(ResponseInterface $response) + { + $responseParams = $this->getIntrospectionResponseParams(); + + $response = $response + ->withStatus(200) + ->withHeader('pragma', 'no-cache') + ->withHeader('cache-control', 'no-store') + ->withHeader('content-type', 'application/json; charset=UTF-8'); + + $response->getBody()->write(json_encode($responseParams)); + + return $response; + } + + /** + * Add custom fields to your Introspection response here, then set your introspection + * reponse in AuthorizationServer::setIntrospectionResponseType() to pull in your version of + * this class rather than the default. + * + * @return array + */ + protected function getExtraParams() + { + return []; + } +} diff --git a/tests/IntrospectionValidators/BearerTokenValidatorTest.php b/tests/IntrospectionValidators/BearerTokenValidatorTest.php new file mode 100644 index 000000000..a1dd25bf7 --- /dev/null +++ b/tests/IntrospectionValidators/BearerTokenValidatorTest.php @@ -0,0 +1,143 @@ +getMockBuilder(BearerTokenValidator::class) + ->disableOriginalConstructor() + ->setMethods(['getTokenFromRequest']) + ->getMock(); + + $validator->method('getTokenFromRequest')->will( + $this->throwException(new InvalidArgumentException()) + ); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class) + ->getMock(); + + $this->assertFalse($validator->validateIntrospection($requestMock)); + } + + public function testReturnsFalseWhenTokenIsRevoked() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class) + ->getMock(); + + $accessTokenRepositoryMock->method('isAccessTokenRevoked') + ->willReturn(true); + + $validator = $this->getMockBuilder(BearerTokenValidator::class) + ->setConstructorArgs([$accessTokenRepositoryMock]) + ->setMethods(['getTokenFromRequest']) + ->getMock(); + + $tokenMock = $this->getMockBuilder(Token::class) + ->getMock(); + + $validator->method('getTokenFromRequest') + ->willReturn($tokenMock); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class) + ->getMock(); + + $this->assertFalse($validator->validateIntrospection($requestMock)); + } + + public function testReturnsFalseWhenTokenIsExpired() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class) + ->getMock(); + + $accessTokenRepositoryMock->method('isAccessTokenRevoked') + ->willReturn(false); + + $validator = $this->getMockBuilder(BearerTokenValidator::class) + ->setConstructorArgs([$accessTokenRepositoryMock]) + ->setMethods(['getTokenFromRequest']) + ->getMock(); + + $tokenMock = $this->getMockBuilder(Token::class) + ->getMock(); + + $tokenMock->method('validate')->willReturn(false); + + $validator->method('getTokenFromRequest') + ->willReturn($tokenMock); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class) + ->getMock(); + + $this->assertFalse($validator->validateIntrospection($requestMock)); + } + + public function testReturnsFalseWhenTokenIsUnverified() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class) + ->getMock(); + + $accessTokenRepositoryMock->method('isAccessTokenRevoked') + ->willReturn(false); + + $validator = $this->getMockBuilder(BearerTokenValidator::class) + ->setConstructorArgs([$accessTokenRepositoryMock]) + ->setMethods(['getTokenFromRequest']) + ->getMock(); + + $validator->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + + $tokenMock = $this->getMockBuilder(Token::class) + ->getMock(); + + $tokenMock->method('validate')->willReturn(true); + $tokenMock->method('verify')->willReturn(false); + + $validator->method('getTokenFromRequest') + ->willReturn($tokenMock); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class) + ->getMock(); + + $this->assertFalse($validator->validateIntrospection($requestMock)); + } + + public function testReturnsTrueWhenTokenIsValid() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class) + ->getMock(); + + $accessTokenRepositoryMock->method('isAccessTokenRevoked') + ->willReturn(false); + + $validator = $this->getMockBuilder(BearerTokenValidator::class) + ->setConstructorArgs([$accessTokenRepositoryMock]) + ->setMethods(['getTokenFromRequest']) + ->getMock(); + + $validator->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + + $tokenMock = $this->getMockBuilder(Token::class) + ->getMock(); + + $tokenMock->method('validate')->willReturn(true); + $tokenMock->method('verify')->willReturn(true); + + $validator->method('getTokenFromRequest') + ->willReturn($tokenMock); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class) + ->getMock(); + + $this->assertTrue($validator->validateIntrospection($requestMock)); + } +} diff --git a/tests/IntrospectorTest.php b/tests/IntrospectorTest.php new file mode 100644 index 000000000..111ff0ff9 --- /dev/null +++ b/tests/IntrospectorTest.php @@ -0,0 +1,100 @@ +getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key') + ); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getMethod')->willReturn('GET'); + $this->expectException(OAuthServerException::class); + + try { + $introspectionResponse = $introspector->validateIntrospectionRequest($requestMock); + } catch (OAuthServerException $e) { + $this->assertEquals('access_denied', $e->getErrorType()); + $this->assertEquals(401, $e->getHttpStatusCode()); + + throw $e; + } + } + + public function testPostRequest() + { + $introspector = new Introspector( + $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key') + ); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getMethod')->willReturn('POST'); + $this->assertNull($introspector->validateIntrospectionRequest($requestMock)); + } + + public function testRespondToInvalidRequest() + { + $validator = $this->getMockBuilder(IntrospectionValidatorInterface::class)->getMock(); + $validator->method('validateIntrospection')->willReturn(false); + + $introspector = new Introspector( + $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + $validator + ); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock, new IntrospectionResponse); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals( + [ + 'active' => false, + ], + $introspectionResponse->getIntrospectionResponseParams() + ); + } + + public function testRespondToValidRequest() + { + $validator = $this->getMockBuilder(IntrospectionValidatorInterface::class)->getMock(); + $validator->method('validateIntrospection')->willReturn(true); + + $introspector = new Introspector( + $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + $validator + ); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock, new IntrospectionResponse); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals( + [ + 'active' => true, + ], + $introspectionResponse->getIntrospectionResponseParams() + ); + } +} diff --git a/tests/ResponseTypes/BearerTokenIntrospectionResponseTest.php b/tests/ResponseTypes/BearerTokenIntrospectionResponseTest.php new file mode 100644 index 000000000..ac755ed56 --- /dev/null +++ b/tests/ResponseTypes/BearerTokenIntrospectionResponseTest.php @@ -0,0 +1,101 @@ +generateHttpResponse(new Response()); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertCorrectIntrospectionHeaders($response); + + $response->getBody()->rewind(); + $json = json_decode($response->getBody()->getContents()); + $this->assertAttributeEquals(false, 'active', $json); + } + + public function testValidIntrospectionResponse() + { + $responseType = $this->getMockBuilder(BearerTokenIntrospectionResponse::class) + ->setMethods(['getTokenFromRequest']) + ->getMock(); + + $tokenMock = $this->getMockBuilder(Token::class) + ->getMock(); + + $tokenMock->method('getClaim')->willReturn('value'); + + $responseType->method('getTokenFromRequest') + ->willReturn($tokenMock); + + $responseType->setValidity(true); + $response = $responseType->generateHttpResponse(new Response()); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertCorrectIntrospectionHeaders($response); + + $response->getBody()->rewind(); + $json = json_decode($response->getBody()->getContents()); + $this->assertAttributeEquals(true, 'active', $json); + $this->assertAttributeEquals('access_token', 'token_type', $json); + $this->assertAttributeEquals('value', 'scope', $json); + $this->assertAttributeEquals('value', 'client_id', $json); + $this->assertAttributeEquals('value', 'exp', $json); + $this->assertAttributeEquals('value', 'iat', $json); + $this->assertAttributeEquals('value', 'sub', $json); + $this->assertAttributeEquals('value', 'jti', $json); + } + + public function testValidIntrospectionResponseWithExtraParams() + { + $responseType = $this->getMockBuilder(BearerTokenIntrospectionResponse::class) + ->setMethods(['getTokenFromRequest', 'getExtraParams']) + ->getMock(); + + $tokenMock = $this->getMockBuilder(Token::class) + ->getMock(); + + $tokenMock->method('getClaim')->willReturn('value'); + + $responseType->method('getTokenFromRequest') + ->willReturn($tokenMock); + + $responseType->method('getExtraParams') + ->willReturn(['extra' => 'param']); + + $responseType->setValidity(true); + $response = $responseType->generateHttpResponse(new Response()); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertCorrectIntrospectionHeaders($response); + + $response->getBody()->rewind(); + $json = json_decode($response->getBody()->getContents()); + $this->assertAttributeEquals(true, 'active', $json); + $this->assertAttributeEquals('access_token', 'token_type', $json); + $this->assertAttributeEquals('value', 'scope', $json); + $this->assertAttributeEquals('value', 'client_id', $json); + $this->assertAttributeEquals('value', 'exp', $json); + $this->assertAttributeEquals('value', 'iat', $json); + $this->assertAttributeEquals('value', 'sub', $json); + $this->assertAttributeEquals('value', 'jti', $json); + $this->assertAttributeEquals('param', 'extra', $json); + } + + private function assertCorrectIntrospectionHeaders(ResponseInterface $response) + { + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('no-cache', $response->getHeader('pragma')[0]); + $this->assertEquals('no-store', $response->getHeader('cache-control')[0]); + $this->assertEquals('application/json; charset=UTF-8', $response->getHeader('content-type')[0]); + } +}