Log your users in with Google OAuth, API Platform and Vue.js
19 Apr 2024 • Vincent BATHELIERI 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).