Log your users in with Google OAuth, API Platform and Vue.js

19 Apr 2024 • Vincent BATHELIER

I recently started a new project using Symfony 7, API Platform, and Vue.js 3. I wanted to allow users to log in or register with their Google account. Here is how I did it.

First, like you probably did, I looked online for a bundle that would help me with that. I found knpuniversity/oauth2-client-bundle which was promising. Reading in their README.md, I saw the following:

Not sure which to use? If you need OAuth (social) authentication & registration, try hwi/oauth-bundle. If you don’t like it, come back!

So what they say is that HWIOAuthBundle is exactly what I need? Yeah, but no. It’s not that simple. I’ll explain why and give you a solution.

The idea

The idea is to use HWIOAuthBundle to authenticate users with Google. Once authenticated, we will generate a JWT token with lexik/jwt-authentication-bundle and send it back to the user. The user will then use this token to authenticate with our API.

It works great if the Symfony application is handling the frontend. But in our case, we are using Vue.js. The problem is that OAuth applications are not allowed to have multiple redirect URIs (it’s the URI where the OAuth server responds with its access token). So we can’t have one for the Symfony application and one for the Vue.js application.

Because the OAuth process is handled by the Symfony application, when the user triggers the login with Google from the Vue.js frontend, we need to open a new window to the backend. The backend will redirect to the Google SSO page, allowing the user to enter its credentials. Once the user is authenticated, Google redirects to the backend with an access token within query parameters, then the backend will send the JWT token back to the frontend.

Setting up HWIOAuthBundle

First, let’s install HWIOAuthBundle in our Symfony project:

composer require hwi/oauth-bundle

Thanks to the Symfony Flex recipe, the bundle is now installed and configured to work with Symfony. We need to configure our OAuth provider, Google in our case. Here is the configuration I used:

# config/packages/hwi_oauth.yamlhwi_oauth:  resource_owners:    google:      type:                google      client_id:           '%env(GOOGLE_ID)%'      client_secret:       '%env(GOOGLE_SECRET)%'      scope:               "email profile"

Because I don’t want to share my Google credentials in version control, I used environment variables. You can set them in your .env or .env.local file:

# .env.localGOOGLE_ID='your_google_client_id'GOOGLE_SECRET='your_google_client_secret'

Now, to make our user able to log in with Google, we need to set up a firewall in our security.yaml:

# config/packages/security.yamlsecurity:  firewalls:    main:      pattern: ^/      lazy: true      provider: app_user_provider      oauth:         resource_owners:          google: /login/check-google # This route must be defined in your routes.yaml        login_path: /auth/login # This one too        use_forward: false        failure_path: hwi_oauth_connect_registration        oauth_user_provider:          service: hwi_oauth.user.provider.entity

Say the user successfully logs in with Google, HWIOAuthBundle needs a service that is able to load users based on the user response of the OAuth endpoint. As I am using Doctrine ORM to store my users, I chose the hwi_oauth.user.provider.entity service.

# config/services.yamlservices:  # ... other services  hwi_oauth.user.provider.entity:    class: HWI\Bundle\OAuthBundle\Security\Core\User\EntityUserProvider    arguments:      $class: App\Entity\User      $properties:        'google': 'googleId'

It will only work if our User entity implements the HWI\Bundle\OAuthBundle\Security\Core\User\OAuthUserProviderInterface. Here is how I did it:

// src/Entity/User.php#[ORM\Entity(repositoryClass: UserRepository::class)]class User implements UserInterface, PasswordAuthenticatedUserInterfaceclass User implements UserInterface, PasswordAuthenticatedUserInterface, OAuthAwareUserProviderInterface{    // ...    #[ORM\Column(length: 255, nullable: true)]     private ?string $googleId = null;    #[Groups(['user:read'])]    #[ORM\Column(type: Types::TEXT, nullable: true)]    private ?string $avatar = null;    public function loadUserByOAuthUserResponse(UserResponseInterface $response): UserInterface {         $this->setEmail($response->getEmail());        $this->setFirstname($response->getFirstName());        $this->setLastname($response->getLastName());        $this->setAvatar($response->getProfilePicture());        $this->setGoogleId($response->getUserIdentifier());        return $this;    }}

At this point, you should be able to log your users in with Google. Great! But we do not have a JWT token yet, and it is necessary for the frontend to authenticate with our API.

Generating a JWT token

To generate a JWT token, we will use lexik/jwt-authentication-bundle. You can read the API Platform JWT documentation or follow this guide which is simplified. Let’s install it:

composer require lexik/jwt-authentication-bundle

JWT tokens are secure because they are signed with a secret key. We need to generate a key pair:

symfony console lexik:jwt:generate-keypair

Now, let’s configure a new firewall in our security.yaml for the JWT token:

# config/packages/security.yamlsecurity:  firewalls:    api:       pattern: ^/api/      stateless: true      provider: app_user_provider      jwt: ~    main:      # Same as before here

If you configured a json_login firewall, you can set the success and failure handlers to the following:

# config/packages/security.yamlsecurity:  firewalls:    api: # ...    main:      # ...      json_login:        # ...        success_handler: lexik_jwt_authentication.handler.authentication_success        failure_handler: lexik_jwt_authentication.handler.authentication_failure

Make HWIOAuthBundle return a JWT token

Based on the previous code example, my guess was that setting the success_handler in the oauth configuration would make HWIOAuthBundle return a JWT token. And it did! Here is the updated security.yaml:

# config/packages/security.yamlsecurity:  firewalls:    main:      # ...      oauth:        # ...        success_handler: lexik_jwt_authentication.handler.authentication_success        failure_handler: lexik_jwt_authentication.handler.authentication_failure

Now, when we visit the URL /connect/google and log in using Google, we receive a JSON Web Token (JWT) in the response. However, we cannot utilize it immediately because we need to send it back to the frontend.

{ "token": "..." }

Retrieve the JWT token from the backend

Our frontend is a Vue.js application hosted on a different domain from our Symfony application. Due to the SameSite cookie attribute, we can’t use cookies to store the JWT token. Instead, we’ll store the JWT token in localStorage.

The examples I’ll provide are adapted for Vue.js 3, but they’re easily adaptable to any JavaScript framework.

To handle authentication, I created an auth store based on Pinia:

// src/stores/auth.tsimport { defineStore } from 'pinia'import { ref } from 'vue'export const useAuthStore = defineStore('auth', () => {  const jwt = ref<string | null>(null)  const currentUser = ref<User | null>(null)  return { jwt, currentUser }})

Now, we need to call the /connect/google endpoint from our Vue.js application in a new window:

export const useAuthStore = defineStore('auth', () => {  // ...  const loginWith = async (service: 'google') => {     window.open(`http://localhost/connect/${service}`)  }  return { jwt, currentUser }   return { jwt, currentUser, loginWith } })
<script lang="ts" setup>  import { useAuthStore } from '@/stores/auth'  const authStore = useAuthStore()</script><template>  <button @click="authStore.loginWith('google')">Login with Google</button></template>

Now, when the user clicks the « Login with Google » button, a new window opens, redirecting the user to the /connect/google endpoint. The user authenticates with Google, and the backend responds with a JWT token.

Currently, we can’t intercept the response in the frontend because the new window is on a different domain. To resolve this, we need to utilize the postMessage API to send the JWT token from the backend to the frontend.

// src/stores/auth.tsconst loginWith = async (service: 'google') => {  window.open(`http://localhost/connect/${service}`)  window.addEventListener('message', receiveMessage) }const receiveMessage = (event: MessageEvent) => {   if (event.data.type === 'authentication') {    jwt.value = event.data.token    window.removeEventListener('message', receiveMessage)    loadCurrentUser()  }}

The Lexik JWT bundle sends the JWT token in the response with the application/json content type. However, if we intend to use the postMessage API, we must send the response with the text/html content type and include the necessary script in the response.

To accomplish this, we need to create a custom response handler that utilizes the lexik_jwt_authentication.handler.authentication_success service. Here’s how I implemented it:

// src/Security/OAuthJwtSuccessHandler.phpnamespace App\Security;use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;class OAuthJwtSuccessHandler implements AuthenticationSuccessHandlerInterface{    public function __construct(        // This is auto injected by Symfony        private AuthenticationSuccessHandler $jwtSuccessHandler,     ) {    }    public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response {        $response = $this->jwtSuccessHandler->onAuthenticationSuccess($request, $token);         $body = json_decode($response->getContent(), true);        $jwt = $body['token'];        return new Response('<html><body><script>            window.opener.postMessage({                type: \'authentication\',                token: ' . json_encode($jwt) . ',            }, \'*\');            window.close();        </script></body></html>');    }}

Notice how I replaced the JWTAuthenticationBundle response with a custom one that includes only the essential elements to send the JWT token to the frontend. Additionally, the window will automatically close itself once the token is transmitted.

Next, we must configure this newly created handler in our security.yaml file.

# config/packages/security.yamlsecurity:  firewalls:    main:      # ...      oauth:        # ...        success_handler: lexik_jwt_authentication.handler.authentication_success        success_handler: App\Security\OAuthJwtSuccessHandler # This is our custom handler

Now, when a user logs in using Google, the backend sends the JWT token to the frontend using the postMessage API. The frontend can then store the JWT token in the localStorage and use it to authenticate with the API.

// src/stores/auth.tsconst jwt = ref<string | null>(null) const currentUser = ref<User | null>(null)const stored = JSON.parse(localStorage.getItem('auth') || 'null') const jwt = ref<string | null>(stored ? stored.jwt : null)const currentUser = ref<User | null>(stored ? stored.currentUser : null)// ...// Any time the JWT token or the current user changes, we store them in the localStoragewatch([jwt, currentUser], () => {    localStorage.setItem('auth', JSON.stringify({ jwt: jwt.value, currentUser: currentUser.value }))})

Congratulations! You now have a method to log in users with Google OAuth and authenticate them with your API using a JWT token.

However, there’s one additional step. Since your new users aren’t registered in your database yet.

Registering users after OAuth success

The HWIOAuthBundle offers a user registration mechanism called connect. While I’m confident it functions effectively, it relies on Symfony forms, which I’m refraining from using since I’m opting for Vue.js instead. My intention is to have users automatically registered upon their successful Google login.

Additionally, when attempting to buypass the form step, I encountered an error.

Account could not be linked correctly.

After hours of debugging and searching online, I abandoned using this connect feature. However, I discovered that the class EntityUserProvider.php from HWIOAuthBundle was responsible for retrieving the user from the database. Instead of throwing an exception, I decided to override it and create a new user if it didn’t exist.

I won’t provide the entire class here, but you can find it in the link above. Here’s the part I modified:

// src/Security/EntityUserProvider.phppublic function loadUserByOAuthUserResponse(UserResponseInterface $response): ?UserInterface{    $resourceOwnerName = $response->getResourceOwner()->getName();    if (!isset($this->properties[$resourceOwnerName])) {        throw new \RuntimeException(sprintf("No property defined for entity for resource owner '%s'.", $resourceOwnerName));    }    $username = method_exists($response, 'getUserIdentifier') ? $response->getUserIdentifier() : $response->getUserIdentifier();    if (null === $user = $this->findUser([$this->properties[$resourceOwnerName] => $username])) {        throw $this->createUserNotFoundException($username, sprintf("User '%s' not found.", $username));         $user = $this->registerUser($response);     }    return $user;}private function registerUser(UserResponseInterface $response): UserInterface{    /** @var User */    $user = new $this->class();    $user->loadUserByOAuthUserResponse($response);    $this->em->persist($user);    $this->em->flush();    return $user;}

Despite this, we must instruct the HWIOAuthBundle to utilize our custom EntityUserProvider service. Here’s how I accomplished it:

# config/services.yamlservices:  # ... other services  hwi_oauth.user.provider.entity:    class: HWI\Bundle\OAuthBundle\Security\Core\User\EntityUserProvider    class: App\Security\EntityUserProvider    arguments:      $class: App\Entity\User      $properties:        'google': 'googleId'

And finally, if our users log in using Google OAuth and haven’t been registered in our database yet, they’ll be automatically registered.

Conclusion

In this article, we explored how to log in users with Google OAuth and authenticate them with your API using a JWT token. I hope you found it helpful. If you have any questions or feedback, please don’t hesitate to reach out to me on X (@d9beuD).