Adding Sign In With Google To A Remix App From Scratch
Tags: javascript, webdev, tutorial, typescript
Published: 2024-02-28 19:40:20

While several libraries provide Google authentication for Remix, such as [remix-auth-google](https://github.com/pbteja1998/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`](https://www.npmjs.com/package/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`.
```ts
// 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](https://developers.google.com/identity/protocols/oauth2/scopes). 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.
```tsx
// 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.
```tsx
// 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.
```tsx
// 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.
```json
{
  "iss": 'https://accounts.google.com',
  "azp": '...',
  "aud": '...',
  "sub": '...',
  "email": '...',
  "email_verified": true,
  "at_hash": '...',
  "iat": 1709138090,
  "exp": 1709140490
}
```
[This](https://connect2id.com/learn/openid-connect) 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.
```tsx
// 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."
```tsx
// 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.
```tsx
// 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.

```ts
// 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
}
```

```tsx
// 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>
  );
}
```

```tsx
// 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>
  );
}
```

```tsx
// 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>
  );
}
```