In the previous article we created a project in the Google Cloud Console, as well as configured the access keys through the API.
In this article we'll create a project in Laravel that will authorize through Google and save access token to database. You will learn the basic queries to Google via the OAuth 2.0 protocol.
Let's skip creating the basic skeleton of a Laravel framework and start immediately with creating code to work with the Google API.
Creating the application
First, we need to install the necessary libraries to work with the API. The library allows us to work with Google API Services without unnecessary actions. You can also use a simple Guzzle
or CURL
client.
composer require google/apiclient
The next step is to add the following variables to your .env
file and add configuration to services.php
:
.env
GOOGLE_CLIENT_ID= #from .json file
GOOGLE_CLIENT_SECRET= #from .json file
GOOGLE_REDIRECT_URI= #from .json file
GOOGLE_REDIRECT_CALLBACK=https://localhost/oauth2 #redirect URL after fetching userinfo
GOOGLE_APPROVAL_PROMPT=force
GOOGLE_ACCESS_TYPE=offline
services.php
return [
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect_uri' => env('GOOGLE_REDIRECT_URI'),
'redirect_callback' => env('GOOGLE_REDIRECT_CALLBACK'),
'scopes' => [
\Google_Service_Calendar::CALENDAR_EVENTS_READONLY,
\Google_Service_Calendar::CALENDAR_READONLY,
\Google_Service_Oauth2::OPENID,
\Google_Service_Oauth2::USERINFO_EMAIL,
\Google_Service_Oauth2::USERINFO_PROFILE,
],
'approval_prompt' => env('GOOGLE_APPROVAL_PROMPT', 'force'),
'access_type' => env('GOOGLE_ACCESS_TYPE', 'offline'),
'include_granted_scopes' => true,
],
];
OAuth process
After creating and setting up our service, we need to log in through the OAuth process. To do that, we need to generate auth URL and redirect the user to the Google OAuth 2.0 server to begin the authentication and authorization process.
You can use OAuth 2.0 Scopes for Google APIs: The first column indicates the name of the scope, a list of which we have defined in the settings. Google will require consent for each when you authorize.
In our services.php
, we requested permission to:
openid
https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/userinfo.profile
https://www.googleapis.com/auth/calendar.events.readonly
https://www.googleapis.com/auth/calendar.readonly
Google as a Driver
We'll create a service to work with the Google API that will complement and encapsulate the way the app works and add some polymorphism to it.
A little later, we will expand the work of calendars to other providers, such as Outlook.
Create a Marker Interface (Tag Interface). In the future we will supplement it with the necessary methods.
Marker Interfaces are empty interfaces, i.e, they do not have any variables or methods declared in them.
interface ProviderInterface {}
Polymorphism will help us create an adaptive application.
Let's create a basic service to work with all popular calendars.
abstract class AbstractProvider implements ProviderInterface
{
protected $providerName;
protected $request;
protected $httpClient;
protected $clientId;
protected $clientSecret;
protected $redirectUrl;
protected $scopes = [];
protected $scopeSeparator = ' ';
protected $user;
/**
* Create a new provider instance.
*/
public function __construct(Request $request, string $clientId, string $clientSecret, string $redirectUrl, array $scopes = [])
{
$this->request = $request;
$this->clientId = $clientId;
$this->redirectUrl = $redirectUrl;
$this->clientSecret = $clientSecret;
$this->scopes = $scopes;
}
/**
* @return RedirectResponse
* @throws \Exception
*/
public function redirect(): RedirectResponse
{
$this->request->query->add(['state' => $this->getState()]);
if ($user = $this->request->user()) {
$this->request->query->add(['user_id' => $user->getKey()]);
}
return new RedirectResponse($this->createAuthUrl());
}
/**
* @return User
*/
public function getUser(): User
{
if (isset($this->user)) {
return $this->user;
}
try {
$credentials = $this->fetchAccessTokenWithAuthCode(
$this->request->get('code', '')
);
$this->user = $this->toUser($this->getBasicProfile($credentials));
} catch (\Exception $exception) {
report($exception);
throw new \InvalidArgumentException($exception->getMessage());
}
$state = $this->request->get('state', '');
if (isset($state)) {
$state = Crypt::decrypt($state);
}
return $this->user
->setRedirectCallback($state['redirect_callback'])
->setToken($credentials['access_token'])
->setRefreshToken($credentials['refresh_token'])
->setExpiresAt(
Carbon::now()->addSeconds($credentials['expires_in'])
)
->setScopes(
explode($this->getScopeSeparator(), $credentials['scope'])
);
}
abstract protected function createAuthUrl();
abstract protected function fetchAccessTokenWithAuthCode(string $code);
abstract protected function getBasicProfile($credentials);
abstract protected function toUser($userProfile);
}
Our first implementation will be a service for the work of Google, let's create it.
class GoogleProvider extends AbstractProvider
{
protected $providerName = 'google';
public function createAuthUrl(): string
{
return $this->getHttpClient()->createAuthUrl();
}
public function redirect(): RedirectResponse
{
if ($redirectCallback = config('services.google.redirect_callback')) {
$this->request->query->add(['redirect_callback' => $redirectCallback]);
}
return parent::redirect();
}
protected function fetchAccessTokenWithAuthCode(string $code): array
{
return $this->getHttpClient()->fetchAccessTokenWithAuthCode($code);
}
/**
* @return array
*/
protected function getBasicProfile($credentials)
{
$jwt = explode('.', $credentials['id_token']);
// Extract the middle part, base64 decode it, then json_decode it
return json_decode(base64_decode($jwt[1]), true);
}
/**
* @param Userinfo $userProfile
* @return void
*/
protected function toUser($userProfile)
{
return tap(new User(), function ($user) use ($userProfile) {
$user->setId($userProfile['sub']);
$user->setName($userProfile['name']);
$user->setEmail($userProfile['email']);
$user->setPicture($userProfile['picture']);
});
}
/**
* @return Client
*/
protected function getHttpClient(): Client
{
if (is_null($this->httpClient)) {
$this->httpClient = new \Google\Client();
$this->httpClient->setApplicationName(config('app.name'));
$this->httpClient->setClientId($this->clientId);
$this->httpClient->setClientSecret($this->clientSecret);
$this->httpClient->setRedirectUri($this->redirectUrl);
$this->httpClient->setScopes($this->scopes);
$this->httpClient->setApprovalPrompt(config('services.google.approval_prompt'));
$this->httpClient->setAccessType(config('services.google.access_type'));
$this->httpClient->setIncludeGrantedScopes(config('services.google.include_granted_scopes'));
// Add request query to the state
$this->httpClient->setState(
Crypt::encrypt($this->request->all())
);
}
return $this->httpClient;
}
}
The only thing missing is a manager to work with our service. Now we can safely inherit from our base class AbstractProvider
and implement new drivers.
use Illuminate\Support\Manager;
class CalendarManager extends Manager
{
protected function createGoogleDriver(): ProviderInterface
{
$config = $this->config->get('services.google');
return $this->buildProvider(GoogleProvider::class, $config);
}
protected function buildProvider($provider, $config): ProviderInterface
{
return new $provider(
$this->container->make('request'),
$config['client_id'],
$config['client_secret'],
$config['redirect_uri'],
$config['scopes']
);
}
}
The business logic for getting the data about the Google user and access tokens is almost ready. All that remains is to test it in a live environment.
All that's left to do is make our services accessible by creating new routes and controllers.
Route::name('oauth2.auth')->get('/oauth2/{provider}', [AccountController::class, 'auth']);
Route::name('oauth2.callback')->get('/oauth2/{provider}/callback', [AccountController::class, 'callback']);
The provider
parameter will be dynamically inserted and checked against the CalendarManager
class.
Let's start by redirecting the user to the Google consent screen using AccountController@auth()
function. Click on the link https://localhost/oauth2/google/.
public function auth(string $driver): RedirectResponse
{
try {
return app(CalendarManager::class)->driver($driver)->redirect();
} catch (\InvalidArgumentException $exception) {
report($exception);
abort(400, $exception->getMessage());
}
}
The CalendarManager
will find the right provider
from the URL and create the necessary driver to work with Google and will send a request for an authorization grant. (Step 1,2)
As soon as the user logs in and agrees with the scopes of our app (reading profile, email, calendars, events), they are redirected back to our AccountController@callback
, which we specified in the .env
file, we get a code
in the body of our response from the Google Auth server.
The getUser()
method, based on the code
will request an access token
and a refresh token
with which we can retrieve the data.
public function getUser():
$credentials = $this->fetchAccessTokenWithAuthCode(
$this->request->get('code', '')
);
Next, the access token
allows you to request private info (step 5,6). At the first request we need to get information about the owner of the account, his name, email and ID
.
$this->user = $this->toUser($this->getBasicProfile($credentials));
We encode the access token
and refresh tokens
via jwt, and store them in the database along with the profile data. The JWT token will store all token validity information.
public function encode(array $payload): string
{
$config = config('app');
$tokenId = base64_encode(random_bytes(16));
$issuedAt = new \DateTimeImmutable();
$jwtPayload = [
'iat' => $issuedAt->getTimestamp(),
'jti' => $tokenId,
'iss' => $config['name'],
'nbf' => $issuedAt->getTimestamp(),
'exp' => $payload['expires_at']->getTimestamp(),
'data' => [
'access_token' => $payload['access_token'],
'refresh_token' => $payload['refresh_token'],
'provider' => $payload['provider'],
'scopes' => $payload['scopes'],
'email' => $payload['email'],
'account_id' => $payload['account_id'],
]
];
return JWT::encode($jwtPayload, $config['key'], $this->alg);
And save everything in the database for later use.
Schema::create('oauth2_accounts', function (Blueprint $table) {
$table->id();
$table->string('account_id', 100);
$table->string('name');
$table->string('email')->index();
$table->string('picture');
$table->string('provider')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->text('token')->nullable();
$table->dateTime('expires_at')->nullable();
$table->timestamps();
});
UserService.php
public function saveFromUser(User $user, string $provider)
{
$payload = [
'account_id' => $user->getId(),
'email' => $user->getEmail(),
'name' => $user->getName(),
'picture' => $user->getPicture(),
'provider' => $provider,
'access_token' => $user->getAccessToken(),
'refresh_token' => $user->getRefreshToken(),
'scopes' => implode(' ', $user->getScopes()),
'expires_at' => $user->getExpiresAt(),
'created_at' => now(),
'updated_at' => now()
];
$payload['token'] = $this->encrypter->encode($payload);
unset($payload['access_token'], $payload['refresh_token'], $payload['scopes']);
if (DB::table('oauth2_accounts')
->where('account_id', $payload['account_id'])
->where('provider', $provider)
->exists()
) {
unset($payload['created_at']);
DB::table('oauth2_accounts')
->where('account_id', $payload['account_id'])
->where('provider', $provider)
->update($payload);
} else {
DB::table('oauth2_accounts')->insert($payload);
}
}
Bottom Line
In this article we created the application and the driver to work with the Google API, as well as authorized the user through OAuth 2 from Google and got tokens to access the server. We will need tokens to get data at any time. This approach will allow us to use access to API in any application, be it WEB or mobile.
You can find the full source code in the Laravel package at the GitHub.