Expo + Lucia v3 for Oauth
5 min read

I’ve been working on an Expo iOS app lately.

I needed two auth options for this app, a primary Google OAuth option and an Apple OAuth option to comply with app store guidelines (got another post coming about this one). I thought Google OAuth was going to be a pain to implement with native deep linking for redirect URLs, an in-app browser cookies, and things like that but it turned out to be surprisingly straightforward for my first time implementing this kind of auth in a non-web environment.

Just a heads up, I'm using Hono, Primsa, and Postgres for the backend with Lucia as a auth wrapper here but the native code should be universal for most expo projects. The main headache on this project was the frontend/general server logic so implementing this in a different stack, if you’re using one, shouldn’t be too bad.

Backend

When I first started looking into this, I thought creating a Google OAuth app for native would be way harder than web but, to my surprise, the setup process on Google Cloud was absolutely identical. To create your credentials you can simply go through the setup process for the “Web application” option when prompted, adding authorized domains for your local and production api servers. We’ll setup callbacks in a second. Make sure to create environment variables for your client id and client secret.

On the server, I’m using a previous version of the auth library Lucia before it became a more abstract learning resource. They still have some great tutorials using the creator’s other libraries for setting up auth. In their new docs they have a solid list of steps to implement sessions with your database of choice here. Their Google OAuth tutorial is also what the rest of the code I’ll show is based off of so you can use that as a really good starting point. In that same tutorial they also detail the basic Google config as well.

Similar to the Lucia tutorial, I have two routes for Google OAuth with an additional route for users to logout, but that’s pretty generic so I’ll cover it briefly towards the end of this section.

Google Login Route

The Google login route takes the exact same shape as Lucia’s except, write before setting cookies, I set two important parameters:

url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");

These are to control how the Google OAuth browser flow works and the user data we get out of it. You can find a more detailed description of each of these options here (some of which are obviously required) but I’ll briefly explain the optional ones I’m using. These essentially allow us to ask a user for a refresh token every time they attempt to login. Although this might be the slightest hit to user experience it gets rid of a ton of edge cases for the server to handle, especially on native which doesn’t behave exactly like the web in this aspect.

Google Callback Route

return c.redirect(`${env.EXPO_REDIRECT_URI}/login?session_token=${session.id}&expiration=${encodeURIComponent(session.expiresAt.getTime())}`);

The callback route is identical to that of the Lucia tutorial, besides the crucial custom redirect URI. Obviously, redirecting to a native Expo iOS app works a bit differently than redirecting to the web. In my case, there are 3 key components to this.

  • The first is the app scheme of your app. This will follow a pattern like com.example.mobile . This allows iOS to handle which app it’s directing your link too, so this is the first part of the URI where you would normally find a web app’s domain.

  • Second is the path that we define below in the frontend section.

  • Finally, we have the session token and expiration query parameters that pass on the session information from the server to our app.

Logout Route

The logout route is super simple. Lucia details it at the bottom of this page, but it essentially checks for a session first (to make sure the person trying to logout is logged in to begin with), and then invalidates the requesting user’s session.

User Route

Also for my app, I needed a simple route to determine if a user is logged in at all and, if so, get that user’s data. This takes the shape of checking the session, throwing an error if one isn’t found, or instead if there is a session, return the user’s data back to the client.

Frontend

The backend was relatively simple for me but since I had never worked with expo OAuth like this, I ran into some trouble here.

For all of my auth related stuff in my app, I create a React context to hold all of my auth state and a hook with all of the auth related methods and a consumption of the context.

useAuth

Here are some of the functions within my hook and what they do.

// this is just a simple fetch wrapper that includes the session token as a bearer token within the request
import { reqWithAuth } from "../utils/reqWithAuth";
import * as Browser from "expo-web-browser";
import * as Linking from "expo-linking";
import * as SecureStore from "expo-secure-store";

async function getUser() {
  // get the user data from the endpoint defined above or return nothing (when no session is returned)
}

// check if the user is logged in
async function checkLoginState() {}

// call the logout endpoint and delete all local credentials
async function logout() {}

// delete all state and remove from storage
async function deleteCredentialsFromStorage() {}

async function loginWithGoogle() {
	// if there's a logged in user already don't continue with logging in
  if (context?.sessionToken || context?.user) {
    const user = await getUser();
    if (user) return;
  }

  // expo specific auth browser method, the second argument is the redirect uri, make sure it matches the redirect uri you use in your google callback endpoint
  const result = await Browser.openAuthSessionAsync(`${API_URL}/login/google`, `${APP_SCHEME}/login`);

  if (result.type !== "success") return;
  const url = Linking.parse(result.url);
  const sessionToken = url.queryParams?.session_token?.toString() ?? null;
  const expiration = url.queryParams?.expiration?.toString() ?? null;
  if (!sessionToken || !expiration) return;
  // store tokens in state and device storage and get user data from the endpoint
}

By using the hook and calling checkLoginState() at the root of my app on open in a useEffect, I can check on if a user is logged in and manage my state that why. By using the isLoggedIn variable that my context provides me, I can use that to conditionally render authenticated pages or not. Finally, to allow a user to login with Google, I bind the loginWithGoogle function to a button on the login screen to kickoff the flow.

Let me know if you want to see a full repo with all of the code. Hope this helps.