diff --git a/composer.json b/composer.json index 31a53496..2d9bc6a5 100644 --- a/composer.json +++ b/composer.json @@ -27,13 +27,19 @@ "illuminate/encryption": "^11.35|^12.0", "illuminate/http": "^11.35|^12.0", "illuminate/support": "^11.35|^12.0", - "league/oauth2-server": "^9.2", + "league/oauth2-server": "dev-master-token-revocation-introspection", "php-http/discovery": "^1.20", "phpseclib/phpseclib": "^3.0", "psr/http-factory-implementation": "*", "symfony/console": "^7.1", "symfony/psr-http-message-bridge": "^7.1" }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/hafezdivandari/oauth2-server" + } + ], "require-dev": { "mockery/mockery": "^1.6", "orchestra/testbench": "^9.9|^10.0", diff --git a/routes/web.php b/routes/web.php index c6f79b51..09003f98 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,6 +29,22 @@ ]); } +if (Passport::$tokenRevocationEnabled) { + Route::post('/revoke', [ + 'uses' => 'RevokeTokenController', + 'as' => 'revoke', + 'middleware' => 'throttle', + ]); +} + +if (Passport::$tokenIntrospectionEnabled) { + Route::post('/introspect', [ + 'uses' => 'IntrospectTokenController', + 'as' => 'introspect', + 'middleware' => 'throttle', + ]); +} + $guard = config('passport.guard', null); Route::middleware(['web', $guard ? 'auth:'.$guard : 'auth'])->group(function () { diff --git a/src/Http/Controllers/IntrospectTokenController.php b/src/Http/Controllers/IntrospectTokenController.php new file mode 100644 index 00000000..b3e3c4ec --- /dev/null +++ b/src/Http/Controllers/IntrospectTokenController.php @@ -0,0 +1,33 @@ +withErrorHandling( + fn () => $this->convertResponse( + $this->server->respondToTokenIntrospectionRequest($psrRequest, $psrResponse) + ) + ); + } +} diff --git a/src/Http/Controllers/RevokeTokenController.php b/src/Http/Controllers/RevokeTokenController.php new file mode 100644 index 00000000..e8a9057a --- /dev/null +++ b/src/Http/Controllers/RevokeTokenController.php @@ -0,0 +1,33 @@ +withErrorHandling( + fn () => $this->convertResponse( + $this->server->respondToTokenRevocationRequest($psrRequest, $psrResponse) + ) + ); + } +} diff --git a/src/Passport.php b/src/Passport.php index e261e78a..f4931d09 100644 --- a/src/Passport.php +++ b/src/Passport.php @@ -45,6 +45,16 @@ class Passport */ public static bool $passwordGrantEnabled = false; + /** + * Indicates if the token introspection is enabled. + */ + public static bool $tokenIntrospectionEnabled = false; + + /** + * Indicates if the token revocation is enabled. + */ + public static bool $tokenRevocationEnabled = false; + /** * The default scope. */ @@ -191,6 +201,22 @@ public static function enablePasswordGrant(): void static::$passwordGrantEnabled = true; } + /** + * Enable the token introspection. + */ + public static function enableTokenIntrospection(): void + { + static::$tokenIntrospectionEnabled = true; + } + + /** + * Enable the token revocation. + */ + public static function enableTokenRevocation(): void + { + static::$tokenRevocationEnabled = true; + } + /** * Set the default scope(s). Multiple scopes may be an array or specified delimited by spaces. * diff --git a/src/PassportServiceProvider.php b/src/PassportServiceProvider.php index 121f055f..d4d47e0e 100644 --- a/src/PassportServiceProvider.php +++ b/src/PassportServiceProvider.php @@ -32,6 +32,7 @@ use League\OAuth2\Server\Grant\RefreshTokenGrant; use League\OAuth2\Server\ResourceServer; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use League\OAuth2\Server\TokenServer; class PassportServiceProvider extends ServiceProvider { @@ -116,6 +117,7 @@ public function register(): void $this->registerResponseBindings(); $this->registerAuthorizationServer(); $this->registerResourceServer(); + $this->registerTokenServer(); $this->registerGuard(); } @@ -278,6 +280,20 @@ protected function registerResourceServer(): void )); } + /** + * Register the token server. + */ + protected function registerTokenServer(): void + { + $this->app->singleton(TokenServer::class, fn ($container) => new TokenServer( + $container->make(Bridge\ClientRepository::class), + $container->make(Bridge\AccessTokenRepository::class), + $container->make(Bridge\RefreshTokenRepository::class), + $this->makeCryptKey('public'), + Passport::tokenEncryptionKey($container->make('encrypter')) + )); + } + /** * Create a CryptKey instance. */ diff --git a/tests/Feature/TokenIntrospectionTest.php b/tests/Feature/TokenIntrospectionTest.php new file mode 100644 index 00000000..3050ab3b --- /dev/null +++ b/tests/Feature/TokenIntrospectionTest.php @@ -0,0 +1,129 @@ + 'Create', + 'read' => 'Read', + 'update' => 'Update', + 'delete' => 'Delete', + ]); + + Passport::authorizationView(fn ($params) => $params); + } + + public function testIntrospectToken() + { + $client = ClientFactory::new()->create(); + $user = UserFactory::new()->create(); + + $token = $this->requestToken($user, $client); + + $json = $this->post('/oauth/introspect', [ + 'token' => $token['access_token'], + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + ])->assertOk()->json(); + + $jwt = JWT::decode($token['access_token'], new Key(file_get_contents(self::PUBLIC_KEY), 'RS256')); + + $this->assertTrue($json['active']); + $this->assertSame('create read delete', $json['scope']); + $this->assertSame($client->getKey(), $json['client_id']); + $this->assertSame('Bearer', $json['token_type']); + $this->assertEquals($user->getAuthIdentifier(), $json['sub']); + $this->assertArrayHasKey('aud', $json); + $this->assertSame($jwt->jti, $json['jti']); + $this->assertSame((int) $jwt->exp, $json['exp']); + $this->assertSame((int) $jwt->iat, $json['iat']); + $this->assertSame((int) $jwt->nbf, $json['nbf']); + + $json = $this->post('/oauth/introspect', [ + 'token' => $token['refresh_token'], + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + ])->assertOk()->json(); + + $this->assertTrue($json['active']); + $this->assertSame('create read delete', $json['scope']); + $this->assertSame($client->getKey(), $json['client_id']); + $this->assertEquals($user->getAuthIdentifier(), $json['sub']); + $this->assertArrayHasKey('jti', $json); + $this->assertEqualsWithDelta(31536000, $json['exp'] - time(), 5); + } + + public function testInvalidClient(): void + { + $client1 = ClientFactory::new()->create(); + $client2 = ClientFactory::new()->create(); + $user = UserFactory::new()->create(); + + $token = $this->requestToken($user, $client1); + + $this->assertFalse($this->post('/oauth/introspect', [ + 'token' => $token['access_token'], + 'client_id' => $client2->getKey(), + 'client_secret' => $client2->plainSecret, + ])->assertOk()->json('active')); + + $this->assertTrue($this->post('/oauth/introspect', [ + 'token' => $token['access_token'], + 'client_id' => $client1->getKey(), + 'client_secret' => $client1->plainSecret, + ])->assertOk()->json('active')); + + $this->assertFalse($this->post('/oauth/introspect', [ + 'token' => $token['refresh_token'], + 'client_id' => $client2->getKey(), + 'client_secret' => $client2->plainSecret, + ])->assertOk()->json('active')); + + $this->assertTrue($this->post('/oauth/introspect', [ + 'token' => $token['refresh_token'], + 'client_id' => $client1->getKey(), + 'client_secret' => $client1->plainSecret, + ])->assertOk()->json('active')); + } + + private function requestToken(Authenticatable $user, Client $client) + { + $this->actingAs($user, 'web'); + + $authToken = $this->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => 'create read delete', + ]))->assertOk()->json('authToken'); + + $redirectUrl = $this->post('/oauth/authorize', ['auth_token' => $authToken])->headers->get('Location'); + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $params); + + return $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'redirect_uri' => $redirect, + 'code' => $params['code'], + ])->assertOK()->json(); + } +} diff --git a/tests/Feature/TokenRevocationTest.php b/tests/Feature/TokenRevocationTest.php new file mode 100644 index 00000000..c86344ce --- /dev/null +++ b/tests/Feature/TokenRevocationTest.php @@ -0,0 +1,132 @@ + 'Create', + 'read' => 'Read', + 'update' => 'Update', + 'delete' => 'Delete', + ]); + + Passport::authorizationView(fn ($params) => $params); + } + + public function testRevokeAccessToken() + { + $client = ClientFactory::new()->create(); + + $token = $this->requestToken($client); + + $requestData = [ + 'token' => $token['access_token'], + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + ]; + + $this->assertTrue($this->post('/oauth/introspect', $requestData)->assertOk()->json('active')); + + $this->post('/oauth/revoke', $requestData)->assertOk(); + + $this->assertFalse($this->post('/oauth/introspect', $requestData)->assertOk()->json('active')); + + $requestData['token'] = $token['refresh_token']; + + $this->assertTrue($this->post('/oauth/introspect', $requestData)->assertOk()->json('active')); + } + + public function testRevokeRefreshToken() + { + $client = ClientFactory::new()->create(); + + $token = $this->requestToken($client); + + $requestData = [ + 'token' => $token['refresh_token'], + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + ]; + + $this->assertTrue($this->post('/oauth/introspect', $requestData)->assertOk()->json('active')); + + $this->post('/oauth/revoke', $requestData)->assertOk(); + + $this->assertFalse($this->post('/oauth/introspect', $requestData)->assertOk()->json('active')); + + $requestData['token'] = $token['access_token']; + + $this->assertFalse($this->post('/oauth/introspect', $requestData)->assertOk()->json('active')); + } + + public function testInvalidClient(): void + { + $client1 = ClientFactory::new()->create(); + $client2 = ClientFactory::new()->create(); + + $token = $this->requestToken($client1); + + $this->post('/oauth/revoke', [ + 'token' => $token['access_token'], + 'client_id' => $client2->getKey(), + 'client_secret' => $client2->plainSecret, + ])->assertOk(); + + $this->post('/oauth/revoke', [ + 'token' => $token['refresh_token'], + 'client_id' => $client2->getKey(), + 'client_secret' => $client2->plainSecret, + ])->assertOk(); + + $this->assertTrue($this->post('/oauth/introspect', [ + 'token' => $token['access_token'], + 'client_id' => $client1->getKey(), + 'client_secret' => $client1->plainSecret, + ])->assertOk()->json('active')); + + $this->assertTrue($this->post('/oauth/introspect', [ + 'token' => $token['refresh_token'], + 'client_id' => $client1->getKey(), + 'client_secret' => $client1->plainSecret, + ])->assertOk()->json('active')); + } + + private function requestToken(Client $client) + { + $this->actingAs(UserFactory::new()->create(), 'web'); + + $authToken = $this->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => 'create read delete', + ]))->assertOk()->json('authToken'); + + $redirectUrl = $this->post('/oauth/authorize', ['auth_token' => $authToken])->headers->get('Location'); + parse_str(parse_url($redirectUrl, PHP_URL_QUERY), $params); + + return $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'redirect_uri' => $redirect, + 'code' => $params['code'], + ])->assertOK()->json(); + } +}