Adding Sign In With Google To A Remix App From Scratch

tmrc

Tim

Posted on February 28, 2024

Adding Sign In With Google To A Remix App From Scratch

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='...'
Enter fullscreen mode Exit fullscreen mode

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
  });
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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('/')
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
tmrc
Tim

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