You can read the full official Google documentation here
After finishing my photo gallery project using NextJS, I felt that I was somewhat comfortable with it. I didn't want to start another tutorial or project with NextJS again because I felt that learning something new would be much better for me. As numerous companies would be maintaining a dedicated backend server (and repo), I felt the need to study NodeJS using the Express framework.
My history with NodeJS (Express): When I was in the military, I remember watching a tutorial by Traversy Media on the MERN stack. However, because the computers in my military base were very old (+ old monitors), the overall studying environment was very very bad. Because of this, I just stuck with NextJS - I didn't have to run multiple terminals or anything and using NextJS just felt more comfortable and convenient. Okay, back to the blog post
While poking around with it, I tried to implement authentication using Kinde. Now, using authentication services like Kinde or Clerk is extremely easy and convenient. However, I wanted to learn how authentication actually worked under the hood. So that's why I decided to try implementing OAuth 2.0 with Google.
Now, I knew nothing about OAuth 2.0 with Google. However, after watching some videos and reading some blog posts, I got the overall picture of how it works.
How does OAuth 2.0 work?
Unlike the traditional authentication method, where the user would have to enter their username and password, OAuth 2.0 works by providing the application with a resource key, which allows access by using that key.
For example, let's say that we have a chatting app. To log into this app, instead of having to type in the user's username and password, we would use an external service (such as Google). We will ask Google for a special key which will enable the user to access our service.
How did I implement it?
Here's how I did it:
import cookieParser from "cookie-parser";
import cors from "cors";
import dotenv from "dotenv";
import express from "express";
import authRoutes from "./routes/authRoutes";
const app = express();
app.use(express.json())
app.use(cors({
origin: "http://localhost:5173",
credentials: true
}))
app.use(cookieParser())
app.use("/api/auth", authRoutes)
app.listen(process.env.PORT, () => {
console.log(`server running on port - ${process.env.PORT}`)
})
And here's the code for requesting Google the user's tokens:
import express from "express"
import dotenv from "dotenv"
import { OAuth2Client } from "google-auth-library"
dotenv.config()
const CLIENT_URL = process.env.CLIENT_URL
const CLIENT_ID = process.env.CLIENT_ID
const CLIENT_SECRET = process.env.CLIENT_SECRET
const REDIRECT_URI = process.env.REDIRECT_URI
const oauth2Client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI)
I'm using a npm package called "google-auth-library". I didn't use any other packages because I really wanted to understand how it worked.
In the code above, I'm importing the necessary packages and bringing in the .env
variables.
...
const authRoutes = express.Router()
.get("/google", (req, res) => {
res.header("Access-Control-Allow-Origin", 'http://localhost:5173');
res.header("Access-Control-Allow-Credentials", 'true');
res.header("Referrer-Policy", "no-referrer-when-downgrade");
const authUrl = oauth2Client.generateAuthUrl({
access_type: "offline",
scope: 'https://www.googleapis.com/auth/userinfo.profile openid ',
prompt: "consent"
})
res.redirect(authUrl)
})
I set some CORS and access to credentials headers. After that there's the code which gets the authUrl
value. By redirecting to the authUrl
, we will be navigated to the familiar google log in page. I've also set the access_type
, scope
, and the prompt
values.
access_type
- when set to offline, the application will refresh the access tokens when the user is not present at the browser.scope
- A list of scopes that identify the resources that my application would access on the user's behalf.prompt
- when set to "consent", prompt the user for consent.
We then redirect the user to the authUrl
.
After the user logs in with his/her Google account, the user is navigated to the callback route.
...
.get("/google/callback", async (req, res) => {
const code = req.query.code
if (typeof code !== "string") {
return res.status(400).send("Invalid authorization code")
}
try {
const { tokens } = await oauth2Client.getToken(code)
oauth2Client.setCredentials(tokens)
const id_token = tokens.id_token
const ticket = await oauth2Client.verifyIdToken({
idToken: id_token!,
audience: CLIENT_ID
})
const user = ticket.getPayload()
res.cookie("user", JSON.stringify({
id: user?.sub,
email: user?.email,
name: user?.name,
picture: user?.picture,
}), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 60 * 1000,
sameSite: "none"
})
res.redirect(`${CLIENT_URL}/dashboard`)
} catch (err) {
console.error('Error during authentication', err);
res.status(500).send('Authentication failed');
}
})
This route handles the redirect from Google. We retrieve the authorization code from the query parameters. If it's not a string, we return an error. We then "trade" the authorization code for the access and ID tokens. After setting the credentials on the OAuth2 client, we extract the id_token
.
We then verify the ID Token. we pass in the id_token to check that it hasn't been altered. We also pass in the audience value which we set in the .env
file (You can get the credentials values from Google Dev Console).
After all this, we then extract the user by using the getPayload()
method. This extracts the information of the user such as the name and email.
For the auth method, I've chosen to set the user info as cookies. You can see that I've set the cookies as such. We can also add the user to our database at this point. We can check to see if this user already exists. If he/she is a new user, we register the user in our db. If this user already exists, we move on to setting cookies.
res.cookie("user", JSON.stringify({
id: user?.sub,
email: user?.email,
name: user?.name,
picture: user?.picture,
}), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 60 * 1000,
sameSite: "none"
})
Here, you can see that I'm storing quite a lot of data as the token. However, I don't know if storing cookies like this is recommended in terms of application security. In my future projects, I'll set the token with something more secure (maybe by generating a custom token or something like that)
After all this, we redirect the user to the /dashboard
page.
Now, after this, it's time to protect our routes. Coming from a background of using convenient services like Clerk and Kinde, implementing route protection wasn't smooth. However, I found out that a lot of people used React Context. (Again, I don't know if this is industry standard or not but I've found many videos and articles using React context for this - I want to learn how big companies protect their route. Please hire me 🙂)
To do this, I first created an api route which checks the cookies.
...
.get("/check-auth", (req, res) => {
const userCookie = req.cookies.user
if (userCookie) {
const user = JSON.parse(userCookie)
res.status(200).json({
id: user?.id,
username: user?.name
})
} else {
res.json(null)
}
})
After checking that the user exists in our cookie, we send it to the client.
Now, in the client, I'm using React (created with Vite) and react-router-dom for routing.
const router = createBrowserRouter([
{
path: "/",
element: (
<ProtectedRoute>
<Home />
</ProtectedRoute>
)
},
{
path: "/dashboard",
element: (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute >
)
}
])
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider >
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>,
)
And here's the AuthProvider.
const AuthContext = createContext<{ user: User | null; loading: boolean } | undefined>(undefined)
type AuthProviderProps = PropsWithChildren & {
isSignedIn?: boolean
}
export default function AuthProvider({
children,
}: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const checkAuth = async () => {
try {
const response = await fetch("/api/auth/check-auth", {
credentials: "include"
})
if (response.ok) {
const data = await response.json()
setUser(data)
} else {
setUser(null)
}
} catch (error) {
console.error("Error checking authentication status - try logging in or refresh the page", error)
} finally {
setLoading(false)
}
}
checkAuth()
}, [])
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}
...And here's the custom wrapper which we'll use to protect our routes.
import { PropsWithChildren, useEffect } from "react";
import { useNavigate } from "react-router-dom"
import { useAuth } from "./AuthProvider";
type ProtectedRouteProps = PropsWithChildren
export default function ProtectedRoute({
children
}: ProtectedRouteProps) {
const { user, loading } = useAuth()
const navigate = useNavigate();
useEffect(() => {
if (!loading) {
if (user === null) {
navigate("/", { replace: true })
}
if (user) {
navigate("/dashboard", { replace: true })
}
}
}, [navigate, user, loading])
if (loading) return null
return children
}
I was facing an issue where when I was testing this, I would get flickering effects. For example, when I wasn't logged in and tried to access the /dashboard
page, I would briefly see the /dashboard
page (for like 0.01) seconds. To fix this, you can see from above that I've added a loading state. When it's loading, I return null
. After this, the problem seemed to have been solved.
The convenient thing about this approach is that I can simply wrap any page I want that needs to be protected.
This is it for now. We can add more functionalities such as extending the cookie lifetime every time the user makes a request to an api route (whenever the user shows signs of activity). However, this post is about the very basics so I didn't include that.
Finishing off
Welp, despite the fact that it took me 10 hours to figure this out, I learned a lot about how OAuth 2.0 works with Google. I feel confident now knowing how I won't have to use auth services. However, during this project, I kinda felt why people opt for those services (because they're so convenient and easy to use). But still, knowing how to implement auth on my own would definitely help me out in the future