knpuniversity / oauth2-client-bundle

Easily talk to an OAuth2 server for social functionality in Symfony
https://symfonycasts.com
MIT License
775 stars 145 forks source link

invalid_grant error on google login #163

Closed InternalServerError closed 5 years ago

InternalServerError commented 5 years ago

Hello !

Thanks again for your work.

I'm almost ready to deploy the google SSO through this bundle :)

But "almost" means I still have a trouble. Indeed when I choose my email acount to log with, I have systematically a "invalid_grant" error. After debugging the GuardAuthenticator, everything's fine until retrieving user (it retrieves !) but when I return the user, I fall in onAuthenticationFailure with "invalid _grant" error.

I'm working with Symfony 4.1 and Doctrine ODM :

Here my code :

knpu_oauth2_client.yaml :

knpu_oauth2_client:
  clients:
    google:
      # must be "google" - it activates that type!
      type: google
      # add and configure client_id and client_secret in parameters.yml
      client_id: "%env(GOOGLE_CLIENT_ID)%"
      client_secret: "%env(GOOGLE_CLIENT_SECRET)%"
      # a route name you'll create
      redirect_route: connect_google_check
      redirect_params: {}
      # Optional value for sending access_type parameter. More detail: https://developers.google.com/identity/protocols/OpenIDConnect#authenticationuriparameters
      # access_type: ''
      # Optional value for sending hd parameter. More detail: https://developers.google.com/identity/protocols/OpenIDConnect#hd-param
      # hosted_domain: ''
      # Optional value for additional fields to be requested from the user profile. If set, these values will be included with the defaults. More details: https://developers.google.com/+/web/api/rest/latest/people
      # user_fields: {}
      # Optional value if you don't want or need to enable Google+ API access.
      # use_oidc_mode: false
      # whether to check OAuth2 "state": defaults to true
      # use_state: true

security.yaml :

security:
    encoders:
        FOS\UserBundle\Model\UserInterface: bcrypt
    providers:
        fos_userbundle:
            id: fos_user.user_provider.username_email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            pattern: ^/(?!(api|connect))
            form_login:
                provider: fos_userbundle
                csrf_token_generator: security.csrf.token_manager
            anonymous: ~
            logout:
                path: /logout
                target: /login
            remember_me:
                secret: '%env(APP_SECRET)%'
            guard:
                authenticators:
                    - App\Security\GuardAuthenticator\GoogleAuthenticator
                    - App\Security\GuardAuthenticator\LoginFormAuthenticator
                    - lexik_jwt_authentication.jwt_token_authenticator
                entry_point: App\Security\GuardAuthenticator\GoogleAuthenticator
        social:
            pattern: ^/connect/
            stateless: true
            anonymous: ~
            guard:
                authenticators:
                    - App\Security\GuardAuthenticator\GoogleAuthenticator

    access_control:
         - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/doc, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/api/token/refresh, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/connect/google, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/, roles: ROLE_USER }
         - { path: ^/api, roles: ROLE_USER }

GoogleAuthenticator :

<?php
declare(strict_types=1);

namespace App\Security\GuardAuthenticator;

use App\Document\User;
use Doctrine\ODM\MongoDB\DocumentManager;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\SocialAuthenticator;
use League\OAuth2\Client\Provider\GoogleUser;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Router;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use KnpU\OAuth2ClientBundle\Client\Provider\GoogleClient;

class GoogleAuthenticator extends SocialAuthenticator
{
    /**
     * @var ClientRegistry
     */
    protected $client;
    /**
     * @var DocumentManager
     */
    protected $documentManager;
    /**
     * @var Router
     */
    protected $router;

    public function __construct(
        ClientRegistry $client,
        DocumentManager $documentManager,
        RouterInterface $router
    ) {
        $this->client = $client;
        $this->documentManager = $documentManager;
        $this->router = $router;
    }

    /**
     * @param Request $request The request that resulted in an AuthenticationException
     * @param AuthenticationException $authException The exception that started the authentication process
     *
     * @return Response
     */
    public function start(
        Request $request,
        AuthenticationException $authException = null
    ) {
        return new JsonResponse(['message' => $authException->getMessage()], JsonResponse::HTTP_UNAUTHORIZED);
    }

    /**
     * @param Request $request
     *
     * @return bool
     */
    public function supports(Request $request): bool
    {
        return $request->attributes->get('_route') === 'connect_google_check';
    }

    /**
     * @param Request $request
     *
     * @return mixed Any non-null value
     *
     * @throws \UnexpectedValueException If null is returned
     */
    public function getCredentials(Request $request)
    {
        return $this->fetchAccessToken($this->getGoogleClient());
    }

    /**
     * @param mixed $credentials
     * @param UserProviderInterface $userProvider
     *
     * @throws AuthenticationException
     *
     * @return UserInterface|null
     */
    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        /** @var GoogleUser $googleUser */
        $googleUser = $this->getGoogleClient()->fetchUserFromToken($credentials);
        $userEmail = $googleUser->getEmail();

        $user = $this->documentManager
            ->getRepository(User::class)
            ->findOneBy(['email' => $userEmail]);

        if ($user) {
            return $user;
        }

        return null;
    }

    /**
     * @param Request $request
     * @param AuthenticationException $exception
     *
     * @return JsonResponse
     */
    public function onAuthenticationFailure(
        Request $request,
        AuthenticationException $exception
    ): JsonResponse {
        return new JsonResponse(
            [
                'message' => strtr(
                    $exception->getMessageKey(),
                    $exception->getMessageData())
            ],
            JsonResponse::HTTP_FORBIDDEN
        );
    }

    /**
     * @param Request $request
     * @param TokenInterface $token
     * @param string $providerKey The provider (i.e. firewall) key
     *
     * @return Response|null
     */
    public function onAuthenticationSuccess(
        Request $request,
        TokenInterface $token,
        $providerKey
    ) {
        $user = $token->getUser();
        $googleApiToken = $this->fetchAccessToken($this->getGoogleClient());

        $user->setApiToken($googleApiToken);
        $this->documentManager->persist($user);
        $this->documentManager->flush();

        return null;
    }

    /**
     * @return GoogleClient
     */
    private function getGoogleClient(): GoogleClient
    {
        return $this->client->getClient('google');
    }
}

GoogleController.php :

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Document\User;
use App\Logger\PlanningLogger;
use Doctrine\ODM\MongoDB\DocumentManager;
use FOS\RestBundle\Controller\Annotations as Rest;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;

class GoogleController extends AbstractController
{
    /**
     * @var ClientRegistry
     */
    protected $clientRegistry;

    public function __construct(
        DocumentManager $documentManager,
        LoggerInterface $logger,
        ClientRegistry $clientRegistry
    ) {
        parent::__construct($documentManager, $logger);
        $this->clientRegistry = $clientRegistry;
    }

    /**
     * @Rest\Get("/connect/google", name="connect_google")
     *
     * @return RedirectResponse
     */
    public function connect()
    {
        return $this->clientRegistry->getClient('google')->redirect();
    }

    /**
     * @Rest\Get("/connect/google_check", name="connect_google_check")
     *
     * @param Request $request
     * @throws \League\OAuth2\Client\Provider\Exception\IdentityProviderException
     */
    public function connectCheckAction(Request $request) {}
}

Someone could help me to solve this please ?

Thanks !

weaverryan commented 5 years ago

Hey @InternalServerError!

Hmm.

After debugging the GuardAuthenticator, everything's fine until retrieving user (it retrieves !) but when I return the user, I fall in onAuthenticationFailure with "invalid_grant" error.

That part sounds weird to me. If you successfully return a User object from getUser(), authentication is complete and successful - there is actually no way to fail authentication once you have successfully return a User object from this method. I believe the failure is happening earlier - likely when you are calling return $this->fetchAccessToken($this->getGoogleClient()); (even though you said this works)... but I'm honestly not 100% sure.

Also, here is a description of this error I found, that might help:

invalid_grant The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.

Sorry I can't help more!

InternalServerError commented 5 years ago

Hello @weaverryan ! Thank you for your answer. Indeed it's a bit wird ! And I do not have more informations about this issue. The behavior seems to be when the getAccessToken method is called.

weaverryan commented 5 years ago

That makes sense - when you call getAccessToken(), that is when the authorization code is exchanged for the access token. If anything is wrong - redirect url doesn’t match, or you’ve somehow already asked for the access token (so the auth code is now “used”) - it sounds like you’ll get this error.

Sorry I can’t help more - good luck!

Sicaa commented 5 years ago

For those who are stuck into the same issue, look at this StackOverflow post: https://stackoverflow.com/questions/10576386/invalid-grant-trying-to-get-oauth-token-from-google

This issue probably appears because you left the "access_type" parameter blank in your knpu_oauth2_client.yaml file. You need to set it to "offline" to be able to refresh tokens for your server-side application, as explained in Google documentation:

Requesting offline access is a requirement for any application that needs to access a Google API when the user is not present. For example, an app that performs backup services or executes actions at predetermined times needs to be able to refresh its access token when the user is not present. The default style of access is called online.

Server-side web applications, installed applications, and devices all obtain refresh tokens during the authorization process. Refresh tokens are not typically used in client-side (JavaScript) web applications.

cf. https://developers.google.com/identity/protocols/OAuth2WebServer#offline

juancamunoz commented 2 years ago

For me the solution was to not call the method getAccessToken twice. I was doing

$user = $client->fetchUser();
$accessToken = $client->getAccessToken();

fetchUser method is already calling getAccessToken, for some reason that I don't really understand the second call always fail with the "invalid grant" exception.

I solved the problem getting the user from the token.

$accessToken = $client->getAccessToken();
$user = $client->fetchUserFromToken($accessToken);