diff --git a/README.md b/README.md index b53267421..c051ff2df 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/src/AuthorizationServer.php b/src/AuthorizationServer.php index f1e96146b..2a786d38c 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -10,6 +10,7 @@ namespace League\OAuth2\Server; use Defuse\Crypto\Key; +use Lcobucci\JWT\Parser; use League\Event\EmitterAwareInterface; use League\Event\EmitterAwareTrait; use League\OAuth2\Server\Exception\OAuthServerException; @@ -197,6 +198,22 @@ public function respondToAccessTokenRequest(ServerRequestInterface $request, Res throw OAuthServerException::unsupportedGrantType(); } + /** + * Return an introspection response. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * + * @return ResponseInterface + */ + public function respondToIntrospectionRequest(ServerRequestInterface $request, ResponseInterface $response) + { + $introspector = new Introspector($this->accessTokenRepository, $this->privateKey, new Parser); + $introspectionResponse = $introspector->respondToIntrospectionRequest($request); + + return $introspectionResponse->generateHttpResponse($response); + } + /** * Get the token type that grants will return in the HTTP response. * diff --git a/src/Grant/AuthCodeGrant.php b/src/Grant/AuthCodeGrant.php index a3ab8a32d..77c8d7ca6 100644 --- a/src/Grant/AuthCodeGrant.php +++ b/src/Grant/AuthCodeGrant.php @@ -204,6 +204,7 @@ public function getIdentifier() * Fetch the client_id parameter from the query string. * * @return string|null + * * @throws OAuthServerException */ protected function getClientIdFromRequest($request) diff --git a/src/Introspector.php b/src/Introspector.php new file mode 100644 index 000000000..506bc472d --- /dev/null +++ b/src/Introspector.php @@ -0,0 +1,165 @@ +accessTokenRepository = $accessTokenRepository; + $this->privateKey = $privateKey; + $this->parser = $parser; + } + + /** + * Return an introspection response. + * + * @param ServerRequestInterface $request + * + * @return IntrospectionResponse + */ + public function respondToIntrospectionRequest(ServerRequestInterface $request) + { + $jwt = $request->getParsedBody()['token'] ?? null; + + try { + $token = $this->parser->parse($jwt); + + $this->verifyToken($token); + $this->checkIfTokenIsExpired($token); + $this->checkIfTokenIsRevoked($token); + + return $this->createActiveResponse($token); + } catch (Exception $ex) { + return $this->createInactiveResponse(); + } + } + + /** + * Validate the JWT token. + * + * @param Token $token + * + * @throws OAuthServerException + */ + private function verifyToken(Token $token) + { + $keychain = new Keychain(); + $key = $keychain->getPrivateKey($this->privateKey->getKeyPath(), $this->privateKey->getPassPhrase()); + + if (!$token->verify(new Sha256, $key->getContent())) { + throw OAuthServerException::accessDenied('Access token could not be verified'); + } + } + + /** + * Ensure access token hasn't expired + * + * @param Token $token + * + * @throws OAuthServerException + */ + private function checkIfTokenIsExpired(Token $token) + { + $data = new ValidationData(time()); + + if (!$token->validate($data)) { + throw OAuthServerException::accessDenied('Access token is invalid'); + } + } + + /** + * Check if the given access token is revoked. + * + * @param Token $token + * + * @throws OAuthServerException + */ + private function checkIfTokenIsRevoked(Token $token) + { + if ($this->accessTokenRepository->isAccessTokenRevoked($token->getClaim('jti'))) { + throw OAuthServerException::accessDenied('Access token has been revoked'); + } + } + + /** + * Create active introspection response. + * + * @param Token $token + * + * @return IntrospectionResponse + */ + private function createActiveResponse(Token $token) + { + $response = new IntrospectionResponse(); + + $response->setIntrospectionData( + [ + '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 $response; + } + + /** + * Create inactive introspection response + * + * @return IntrospectionResponse + */ + private function createInactiveResponse() + { + $response = new IntrospectionResponse(); + + $response->setIntrospectionData( + [ + 'active' => false, + ] + ); + + return $response; + } +} diff --git a/src/ResponseTypes/IntrospectionResponse.php b/src/ResponseTypes/IntrospectionResponse.php new file mode 100644 index 000000000..543a2d948 --- /dev/null +++ b/src/ResponseTypes/IntrospectionResponse.php @@ -0,0 +1,41 @@ +introspectionData = $introspectionData; + } + + /** + * @return array + */ + public function getIntrospectionData() + { + return $this->introspectionData; + } + + /** + * @param ResponseInterface $response + * + * @return ResponseInterface + */ + public function generateHttpResponse(ResponseInterface $response) + { + $response->getBody()->write(json_encode($this->introspectionData)); + + return $response; + } +} diff --git a/tests/IntrospectorTest.php b/tests/IntrospectorTest.php new file mode 100644 index 000000000..c6e74ff4b --- /dev/null +++ b/tests/IntrospectorTest.php @@ -0,0 +1,150 @@ +getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + new Parser() + ); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getParsedBody')->willReturn([]); + + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals(['active' => false], $introspectionResponse->getIntrospectionData()); + } + + public function testRespondToRequestWithInvalidToken() + { + $parserMock = $this->getMockBuilder(Parser::class)->getMock(); + $tokenMock = $this->getMockBuilder(Token::class)->getMock(); + + $introspector = new Introspector( + $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + $parserMock + ); + + $parserMock->method('parse')->willReturn($tokenMock); + $tokenMock->method('verify')->willReturn(false); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getParsedBody')->willReturn(['token' => 'token']); + + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals(['active' => false], $introspectionResponse->getIntrospectionData()); + } + + public function testRespondToRequestWithExpiredToken() + { + $parserMock = $this->getMockBuilder(Parser::class)->getMock(); + $tokenMock = $this->getMockBuilder(Token::class)->getMock(); + + $introspector = new Introspector( + $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + $parserMock + ); + + $parserMock->method('parse')->willReturn($tokenMock); + $tokenMock->method('verify')->willReturn(true); + $tokenMock->method('validate')->willReturn(false); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getParsedBody')->willReturn(['token' => 'token']); + + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals(['active' => false], $introspectionResponse->getIntrospectionData()); + } + + public function testRespondToRequestWithRevokedToken() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $parserMock = $this->getMockBuilder(Parser::class)->getMock(); + $tokenMock = $this->getMockBuilder(Token::class)->getMock(); + + $introspector = new Introspector( + $accessTokenRepositoryMock, + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + $parserMock + ); + + $parserMock->method('parse')->willReturn($tokenMock); + $tokenMock->method('verify')->willReturn(true); + $tokenMock->method('validate')->willReturn(true); + $accessTokenRepositoryMock->method('isAccessTokenRevoked')->willReturn(true); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getParsedBody')->willReturn(['token' => 'token']); + + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals(['active' => false], $introspectionResponse->getIntrospectionData()); + } + + public function testRespondToRequestWithValidToken() + { + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $parserMock = $this->getMockBuilder(Parser::class)->getMock(); + $tokenMock = $this->getMockBuilder(Token::class)->getMock(); + + $introspector = new Introspector( + $accessTokenRepositoryMock, + new CryptKey('file://' . __DIR__ . '/Stubs/private.key'), + $parserMock + ); + + $parserMock->method('parse')->willReturn($tokenMock); + $tokenMock->method('verify')->willReturn(true); + $tokenMock->method('validate')->willReturn(true); + $tokenMock->method('getClaim')->willReturn('value'); + $accessTokenRepositoryMock->method('isAccessTokenRevoked')->willReturn(false); + + $requestMock = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $requestMock->method('getParsedBody')->willReturn(['token' => 'token']); + + $introspectionResponse = $introspector->respondToIntrospectionRequest($requestMock); + + $this->assertInstanceOf(IntrospectionResponse::class, $introspectionResponse); + $this->assertEquals( + [ + 'active' => true, + 'token_type' => 'access_token', + 'scope' => 'value', + 'client_id' => 'value', + 'exp' => 'value', + 'iat' => 'value', + 'sub' => 'value', + 'jti' => 'value', + ], + $introspectionResponse->getIntrospectionData() + ); + } +}