Using Google OAuth 2.0 with a custom backend in Next.js
Pawan Udassi
Posted on November 4, 2023
The days of username and password based logins are now over.
Using OpenID Connect, users can be onboarded in a breeze in just one click.
But there is so much confusion over the overall flow of the authentication and authorisation that for a newcomer it is very easy to get lost in the jargon.
If you want to understand how passwordless logins work or want to setup a secure Social Login for a Client/Server application, this guide is for you.
How does passwordless login work?
When you click on the Sign in with Google button you are redirected to Google's Sign in page where you consent to share your profile to the site. The Google OAuth Provider shares an id-token that verifies your identity.
The id-token only contains basic info needed to make sure that it is actually you that is logged in. It contains your name, email, and also a signature by Google that acts as the proof. Now that's authentication or establishing the identity in simple terms.
But that's just your React (or Angular or whatever your boss told you to use ) client in the browser. You also have a backend server written in express ( I know you wanted to use Spring ). Right now only the frontend knows your identity.
Now to authenticate against your backend server, the client has to send this id-token to the backend to start a session. The backend would verify this id-token using Google SDK and it gets your profile and google's signature.
This is what a decoded id-token looks like :
{
iss: 'https://accounts.google.com',
azp: '102537660956-857id6odqfnou4kl6o1cbtl6b4sqgalv.apps.googleusercontent.com',
aud: '102537660956-857id6odqfnou4kl6o1cbtl6b4sqgalv.apps.googleusercontent.com',
sub: '112968794366023984808',
email: 'pawankumarudassi@gmail.com',
email_verified: true,
at_hash: 'VWL3lvTkSKyK7hvDnKpAJg',
name: 'Pawan Udassi',
given_name: 'Pawan',
family_name: 'Udassi',
locale: 'en',
iat: 1699079674,
exp: 1699083274
}
So now the backend knows that it's you yourself with your email and name and your nasty profile picture. But wait, how does the backend figure out what resources you are permitted to access.
Authentication vs Authorisation
When sending the id-token to the backend, the server authenticates the user and optionally adds an entry for the same to the database. And in the same request it creates a new token that contains profile info of the authenticated user and other details specifying scopes and grants to resources. The server returns this new token (that we will call AuthToken from now on) to the client.
The client receives and stores the AuthToken , and sends it to the server in the authorization header of any subsequent requests.
But Why do we need this new token. Can we not attach the same id-token with all the requests and simply verify it at the server each time. Well technically we can but this is not a good idea.
The id-token is like your birth certificate, it proves who you are. That's Authentication.
But the AuthToken is like your driver's license, it proves that you are permitted to drive. That's Authorization.
This extra information specifying access control or permissions simply cannot be attached to the id-token since the id-token was generated by the Auth Provider (Google in this case). Therefore we need to create a new token that will handle the authorisation.
Also the id-token is generated solely for the authentication purposes. Handling the authorisation in-house with custom tokens ensures best practices and more control.
Let's Code it
We will see the implementation for this flow using NextAuth with Next.js as client and express as backend server.
Register with Google OAuth
First, We need to register credentials for signup at Google Cloud Console.
Log in to the Google Cloud Console and do the following:
1) Create a project.
2) Head to Credentials and Click Create Credentials => OAuth client ID
3) Select Web Application under Application Type
4) Add authorised javascript origin : http://localhost:3000
5) Add authorised redirect uri:http://localhost:3000/api/auth/callback/google
Once this is done, click save and then you will get your client ID and secret, as shown below. Save these for further steps.
Express Server Setup
The express server is simple and contains only two routes, one for authentication/login and the other for testing authorization/accessing resources.
import express from "express";
import jwt from "jsonwebtoken";
import cors from "cors";
const app = express();
app.use(cors());
const PORT = 3333;
import dotenv from "dotenv";
dotenv.config();
import { OAuth2Client } from "google-auth-library";
const client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET
);
app.listen(PORT, (error) => {
if (!error)
console.log(
"Server is Successfully Running,and App is listening on port " + PORT
);
else console.log("Error occurred, server can't start", error);
});
app.post("/auth/login", async (req, res) => {
console.log(req.headers.authorization);
const tokenId = req.headers.authorization;
const ticket = await client.verifyIdToken({
idToken: tokenId.slice(7),
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
console.log(payload);
if (payload.aud != process.env.GOOGLE_CLIENT_ID)
return res.send("Unauthorised");
const { email, name } = payload;
const authToken = jwt.sign({ email, name }, process.env.SECRET);
res.json({ authToken });
});
app.post("/access", async (req, res) => {
try {
const authToken = req.headers.authorization;
const decoded = jwt.verify(authToken.slice(7), process.env.SECRET);
} catch (e) {
return res.json({ data: "NOT Authorised" });
}
res.json({ data: "Authorised" });
});
1) The /auth/login route gets the id-token in the headers and verifies it using the Google SDK. It then creates a new token 'authToken' using name and email and sends it as response to the client. You can add more information to this token as per implementation and access control. The client is to attach this authToken with any subsequent requests.
2) The /access route accepts request to access a resource and expects the authToken in the headers for the authorization. If the authToken is verified and contains permission to the resource, the handler returns with "Authorised" else it returns "NOT Authorised".
Client Setup
1) Install the next-auth package using npm.
2) We need to add api route for Google OAuth handler at app/api/auth/[..nextauth]/route.ts
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
httpOptions: {
timeout: 40000,
},
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code",
},
},
}),
],
callbacks: {
async jwt({ token, account, user }) {
if (account) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/login`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${account?.id_token}`,
},
}
);
const resParsed = await res.json();
token = Object.assign({}, token, {
id_token: account.id_token,
});
token = Object.assign({}, token, {
myToken: resParsed.authToken,
});
}
return token;
},
async session({ session, token }) {
if (session) {
session = Object.assign({}, session, {
id_token: token.id_token,
});
session = Object.assign({}, session, {
authToken: token.myToken,
});
}
return session;
},
},
});
export { handler as GET, handler as POST, handler };
Set the following env variables:
NEXT_PUBLIC_BACKEND_URL=http://localhost:3333
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXT_PUBLIC_BACKEND_URL=http://localhost:3333
Also add your GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to the env.
The jwt and session callbacks are called when signing in with Google. The id-token is sent to the server and the authToken received is attached to the session object. This session object is available at the client side and the authToken can thus be used to attach to requests to the server.
Session Object
In order to access the session object we need to create a client wrapper and use that to wrap the client code in layout. This wrapper is needed since the session provider uses the context api which is unavaiable in the next layout page.
"use client";
import React, { ReactNode } from "react";
import { SessionProvider } from "next-auth/react";
interface Props {
children: ReactNode;
}
export function SessProvider(props: Props) {
return <SessionProvider>{props.children}</SessionProvider>;
}
Use the wrapper inside layout page.
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { SessProvider } from "./components/SessProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "\"Create Next App\","
description: "\"Generated by create next app\","
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<SessProvider>{children}</SessProvider>
</body>
</html>
);
}
Adding Homepage
We will be using only one page at the / route.
"use client";
import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react";
export default function Home() {
const [response, setResponse] = useState<string | null>(null);
const { data: session } = useSession();
console.log(session);
const sendHandler = async function () {
console.log("sent", session);
const result = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/access`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${session?.authToken}`,
},
}
);
const res = await result.json();
console.log(res);
setResponse(res.data);
};
return (
<div className="flex flex-col gap-20 justify-center items-center h-screen">
{session?.user && (
<div className="flex flex-col gap-8">
<p>You are signed in right now</p>
<button
onClick={async () => await signOut()}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors duration-300 ease-in-out"
>
Sign Out
</button>
</div>
)}
{!session?.user && (
<div className="flex flex-col gap-8">
<p>You are not signed in right now</p>
<button
onClick={async () => await signIn("google")}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-3 rounded-full flex items-center justify-center gap-2 transition-colors duration-300 ease-in-out"
>
<h1 className="text-xl">Sign In</h1>
</button>
</div>
)}
<p className="text-3xl text-center">
{response
? response
: "Send request now to check if you are authorised"}
</p>
<button
onClick={sendHandler}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg transition-colors duration-300 ease-in-out"
>
<p className="text-xl">Send Request</p>
</button>
</div>
);
}
Testing it all
Clicking the "Send Request" button sends a post request to the '/access' route with authToken in authorisation headers.
If the user is logged in the server responds with "Authorised" else the authToken is null and the server responds with "NOT Authorised".
You can find the repository for the full implementation here
Find me on Linkedin
Posted on November 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.