Adding Sign In With Google To A Remix App From Scratch
Tim
Posted on February 28, 2024
While several libraries provide Google authentication for Remix, such as remix-auth-google, they may not meet all use cases or be cumbersome to adapt to your existing authentication patterns. This was the case when I looked into implementing Google authentication into my project. So, let's look into how we can do it ourselves!
Prerequisites
You will need the client secret, client ID, and redirect URL values from Google. Below is a link about how to get them. Save them in a dotenv file in the root of your directory.
# .env
GOOGLE_CLIENT_ID='...'
GOOGLE_CLIENT_SECRET='...'
GOOGLE_REDIRECT_URI='...'
https://developers.google.com/identity/protocols/oauth2/web-server#creatingcred
Make sure the redirect URL maps to a route in your remix app. In this example, we create a route at
/google-callback
.
Setting Up The Google SDK
We will use google-auth-library
to handle the dirty details of the OAuth process for us. Let's start by setting that up.
Run npm i google-auth-library
.
// app/lib/oauth-providers/google.ts
import { OAuth2Client } from "google-auth-library";
// Make sure the environment variables are set
if (
!process.env.GOOGLE_CLIENT_ID ||
!process.env.GOOGLE_CLIENT_SECRET ||
!process.env.GOOGLE_REDIRECT_URI
) {
throw new Error(
"Missing GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, or GOOGLE_REDIRECT_URI"
);
}
const oauthClient = new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI
});
export const generateAuthUrl = (state: string) => {
return oauthClient.generateAuthUrl({
access_type: "online",
scope: ["https://www.googleapis.com/auth/userinfo.email"],
state
});
};
We create an OAuth client and a helper function to generate the Google sign in URL we will send our users to. The scope
represents what Google related access we want from the user. In this case, we want to see what their email is. You can see a full list of scopes here. The state
is any data we want to be carried through the OAuth process and returned to us once completed. That will make more sense when we use it later.
The
google-auth-library
SDK has great inline documentation. Hover over any function or argument you want to know more about for more information.
Creating The Register Page
We will start by creating the route the user will use to register. The route will have a loader function and link.
// app/routes/register.tsx
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { generateAuthUrl } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
// Put "register" in the state so we know where the user is
// coming from when they are sent back to us from Google.
return json({ googleAuthUrl: generateAuthUrl("register") });
}
export default function Register() {
const { googleAuthUrl } = useLoaderData<typeof loader>();
return (
<div>
<a href={googleAuthUrl}>Continue with Google</a>
</div>
);
}
We want to ensure we call
generateAuthUrl
in a server context so we don't expose our Google secret to the client.
When the user clicks on the link, they will be sent to a Google sign in page. After completing it, they will be sent to the redirect URL set when creating credentials in Google.
Handling the Response From Google
When the user is sent back to us from Google, there will be a few pieces of data in the query parameters of the URL. The two we are interested in are code
and state
. The code
can be exchanged for access and identity tokens from Google. The state
is the string we passed to generateAuthUrl
earlier. We can access this data and process it accordingly in the loader function of the route.
// app/routes/google-callback.tsx
import { type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const searchParams = new URL(request.url).searchParams
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!state || !code) {
// Throw an error or redirect back to the register page
}
}
// This component should never get rendered, so it can be anything
export default function GoogleCallback() {
return (
<div>
<h1>GoogleCallback</h1>
</div>
);
}
Back in the oauth-providers/google.ts
file, let's add another function.
// app/lib/oauth-providers/google.ts
...
export const getTokenFromCode = (code: string) => {
const { tokens } = await oauthClient.getToken(code);
if (!tokens.id_token) {
throw new Error("Something went wrong. Please try again.");
}
const payload = await oauthClient.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const idTokenBody = payload.getPayload();
if (!idTokenBody) {
throw new Error("Something went wrong. Please try again.");
}
return idTokenBody
}
This function exchanges the code for an identity token and then verifies the token. It is important to verify the token to prevent malicious users from abusing the system.
The identity token carries basic information about the user and looks something like this.
{
"iss": 'https://accounts.google.com',
"azp": '...',
"aud": '...',
"sub": '...',
"email": '...',
"email_verified": true,
"at_hash": '...',
"iat": 1709138090,
"exp": 1709140490
}
This is a good breakdown of what is in an identity token. What we are interested in is the sub
attribute, which represents the unique ID of the user from Google's perspective. We can use it to associate the user in our database with their Google account.
Registering A User
Returning to our loader, we can use our new function to register a user.
// app/routes/google-callback.tsx
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { getTokenFromCode } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
const searchParams = new URL(request.url).searchParams
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!state || !code) {
// Throw an error or redirect back to the register page
}
const idToken = await getTokenFromCode(code)
// Replace with your database code
const user = await db.users.find.where({ google_sub: idToken.sub })
if (state === "register" && user) {
// The Google account is already associated with a user
// Throw an error or redirect to the sign in page
return redirect("/sign-in")
}
if (state === "register") {
// Replace with your database code
const newUser = await db.insert.users({ google_sub: idToken.sub })
// Insert whatever you need here to create a session
await createSession(newUser)
return redirect("/dashboard")
}
}
User Sign In
After a user is registered, they will need to be able to sign in using their Google account later on. The process for this is not very different from the register flow. First, we create the route and get an auth URL from Google. This time, we will set our state to "sign-in."
// app/routes/sign-in.tsx
import { type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { generateAuthUrl } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
return json({ googleAuthUrl: generateAuthUrl("sign-in") });
}
export default function SignIn() {
const { googleAuthUrl } = useLoaderData<typeof loader>();
return (
<div>
<a href={googleAuthUrl}>Continue with Google</a>
</div>
);
}
The user will go through the same Google sign in flow and be sent to the /google-callback
route after. We can update the loader to handle the sign in scenario.
// app/routes/google-callback.tsx
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { getTokenFromCode } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
const searchParams = new URL(request.url).searchParams
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!state || !code) {
// Throw an error or redirect back to the register page
}
const idToken = await getTokenFromCode(code)
// Replace with your database code
const user = await db.users.find.where({ google_sub: idToken.sub })
...
if (state === "sign-in" && !user) {
// The Google account is not associated with a user
// Throw an error or redirect back to the register page
return redirect("/register")
}
if (state === "sign-in") {
await createSession(user)
return redirect('/dashboard')
}
// If somehow a user ends up here (they should not be able to)
// send them to the home page
return redirect('/')
}
And there you have it! A simple user sign in/register flow using Google OAuth. Use this as a starting point for your application. Be sure to add styles and error handling for a nice user experience.
// app/lib/oauth-providers/google.ts
import { OAuth2Client } from "google-auth-library";
if (
!process.env.GOOGLE_CLIENT_ID ||
!process.env.GOOGLE_CLIENT_SECRET ||
!process.env.GOOGLE_REDIRECT_URI
) {
throw new Error(
"Missing GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, or GOOGLE_REDIRECT_URI"
);
}
const oauthClient = new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI
});
export const generateAuthUrl = (state: string) => {
return oauthClient.generateAuthUrl({
access_type: "online",
scope: ["https://www.googleapis.com/auth/userinfo.email"],
state
});
};
export const getTokenFromCode = (code: string) => {
const { tokens } = await oauthClient.getToken(code);
if (!tokens.id_token) {
throw new Error("Something went wrong. Please try again.");
}
const payload = await oauthClient.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const idTokenBody = payload.getPayload();
if (!idTokenBody) {
throw new Error("Something went wrong. Please try again.");
}
return idTokenBody
}
// app/routes/register.tsx
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { generateAuthUrl } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
return json({ googleAuthUrl: generateAuthUrl("register") });
}
export default function Register() {
const { googleAuthUrl } = useLoaderData<typeof loader>();
return (
<div>
<a href={googleAuthUrl}>Continue with Google</a>
</div>
);
}
// app/routes/sign-in.tsx
import { type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { generateAuthUrl } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
return json({ googleAuthUrl: generateAuthUrl("sign-in") });
}
export default function SignIn() {
const { googleAuthUrl } = useLoaderData<typeof loader>();
return (
<div>
<a href={googleAuthUrl}>Continue with Google</a>
</div>
);
}
// app/routes/google-callback.tsx
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { getTokenFromCode } from "../lib/oauth-providers/google";
export async function loader({ request }: LoaderFunctionArgs) {
const searchParams = new URL(request.url).searchParams
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!state || !code) {
return redirect("/")
}
const idToken = await getTokenFromCode(code)
const user = await db.users.find.where({ google_sub: idToken.sub })
if (state === "register" && user) {
return redirect("/sign-in")
}
if (state === "register") {
const newUser = await db.insert.users({ google_sub: idToken.sub })
await createSession(newUser)
return redirect('/dashboard')
}
if (state === "sign-in" && !user) {
return redirect("/register")
}
if (state === "sign-in") {
await createSession(user)
return redirect('/dashboard')
}
return redirect('/')
}
export default function GoogleCallback() {
return (
<div>
<h1>GoogleCallback</h1>
</div>
);
}
Posted on February 28, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 26, 2024