Skip to content

Commit

Permalink
feat: expo auth (#720)
Browse files Browse the repository at this point in the history
* feat: expo auth

* rm

* fix format

* fix lock

* feat: use expo-linking to construct urls for expo auth (#832)

* expo install --fix

* nit

* fix cookie

* use sync session store api

* add custom handler back

* fix exports

* Use .find() instead of always first cookie (#1043)

* feat: Expo Auth without setting AUTH_URL. (#1054)

* feat: expo-auth without auth_url env var

* Fix session cookie matching

* feat: Restore old CSRF checks in non-dev environments

* chore: Documenting some decisions with comments

* Use node env instead of vercel-specific env var

* Update readme to describe oauth changes

* Fix redirectTo being missing and enforce home nav since it was showing a weird page

* Disallow backwards navigation upon auth change

* some light refactoring

* dont' mutate args

* helper

* cmt

* fmt

---------

Co-authored-by: Jay McMullen <[email protected]>
Co-authored-by: Gabriel Bianchi <[email protected]>
Co-authored-by: Sam Riddle <[email protected]>
  • Loading branch information
4 people authored Jun 9, 2024
1 parent 74c3c58 commit 390b1b1
Show file tree
Hide file tree
Showing 16 changed files with 316 additions and 69 deletions.
16 changes: 7 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ packages
├─ api
| └─ tRPC v11 router definition
├─ auth
| └─ Authentication using next-auth. **NOTE: Only for Next.js app, not Expo**
| └─ Authentication using next-auth.
├─ db
| └─ Typesafe db calls using Drizzle & Supabase
└─ ui
Expand Down Expand Up @@ -130,6 +130,12 @@ To add a new package, simply run `pnpm turbo gen init` in the monorepo root. Thi

The generator sets up the `package.json`, `tsconfig.json` and a `index.ts`, as well as configures all the necessary configurations for tooling around your package such as formatting, linting and typechecking. When the package is created, you're ready to go build out the package.

### 4. Configuring Next-Auth to work with Expo

In order to get Next-Auth to work with Expo, you must either:
- Add your local IP (e.g. 192.168.x.y) to your OAuth provider (by default Discord), and you may have to update this if it gets reassigned
- Deploy the Auth Proxy and point your OAuth provider to the proxy

## FAQ

### Does the starter include Solito?
Expand All @@ -138,14 +144,6 @@ No. Solito will not be included in this repo. It is a great tool if you want to

Integrating Solito into this repo isn't hard, and there are a few [official templates](https://github.com/nandorojo/solito/tree/master/example-monorepos) by the creators of Solito that you can use as a reference.

### What auth solution should I use instead of Next-Auth.js for Expo?

I've left this kind of open for you to decide. Some options are [Clerk](https://clerk.dev), [Supabase Auth](https://supabase.com/docs/guides/auth), [Firebase Auth](https://firebase.google.com/docs/auth/) or [Auth0](https://auth0.com/docs). Note that if you're dropping the Expo app for something more "browser-like", you can still use Next-Auth.js for those. [See an example in a Plasmo Chrome Extension here](https://github.com/t3-oss/create-t3-turbo/tree/chrome/apps/chrome).

The Clerk.dev team even made an [official template repository](https://github.com/clerkinc/t3-turbo-and-clerk) integrating Clerk.dev with this repo.

During Launch Week 7, Supabase [announced their fork](https://supabase.com/blog/launch-week-7-community-highlights#t3-turbo-x-supabase) of this repo integrating it with their newly announced auth improvements. You can check it out [here](https://github.com/supabase-community/create-t3-turbo).

### Does this pattern leak backend code to my client applications?

No, it does not. The `api` package should only be a production dependency in the Next.js application where it's served. The Expo app, and all other apps you may add in the future, should only add the `api` package as a dev dependency. This lets you have full typesafety in your client applications, while keeping your backend code safe.
Expand Down
2 changes: 2 additions & 0 deletions apps/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
"expo-dev-client": "~4.0.13",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.11",
"expo-secure-store": "^13.0.1",
"expo-splash-screen": "~0.27.4",
"expo-status-bar": "~1.12.1",
"expo-web-browser": "^13.0.3",
"nativewind": "~4.0.36",
"react": "18.3.1",
"react-dom": "18.3.1",
Expand Down
33 changes: 24 additions & 9 deletions apps/expo/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { Button, Pressable, Text, TextInput, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Link, Stack } from "expo-router";
import { FlashList } from "@shopify/flash-list";

import type { RouterOutputs } from "~/utils/api";
import { api } from "~/utils/api";
import { useSignIn, useSignOut, useUser } from "~/utils/auth";

function PostCard(props: {
post: RouterOutputs["post"]["all"][number];
Expand Down Expand Up @@ -94,30 +95,44 @@ function CreatePost() {
);
}

function MobileAuth() {
const user = useUser();
const signIn = useSignIn();
const signOut = useSignOut();

return (
<>
<Text className="pb-2 text-center text-xl font-semibold text-white">
{user?.name ?? "Not logged in"}
</Text>
<Button
onPress={() => (user ? signOut() : signIn())}
title={user ? "Sign Out" : "Sign In With Discord"}
color={"#5B65E9"}
/>
</>
);
}

export default function Index() {
const utils = api.useUtils();

const postQuery = api.post.all.useQuery();

const deletePostMutation = api.post.delete.useMutation({
onSettled: () => utils.post.all.invalidate().then(),
onSettled: () => utils.post.all.invalidate(),
});

return (
<SafeAreaView className=" bg-background">
<SafeAreaView className="bg-background">
{/* Changes page title visible on the header */}
<Stack.Screen options={{ title: "Home Page" }} />
<View className="h-full w-full bg-background p-4">
<Text className="pb-2 text-center text-5xl font-bold text-foreground">
Create <Text className="text-primary">T3</Text> Turbo
</Text>

<Pressable
onPress={() => void utils.post.all.invalidate()}
className="flex items-center rounded-lg bg-primary p-2"
>
<Text className="text-foreground"> Refresh posts</Text>
</Pressable>
<MobileAuth />

<View className="py-2">
<Text className="font-semibold italic text-primary">
Expand Down
33 changes: 7 additions & 26 deletions apps/expo/src/utils/api.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,20 @@
import { useState } from "react";
import Constants from "expo-constants";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import superjson from "superjson";

import type { AppRouter } from "@acme/api";

import { getBaseUrl } from "./base-url";
import { getToken } from "./session-store";

/**
* A set of typesafe hooks for consuming your API.
*/
export const api = createTRPCReact<AppRouter>();
export { type RouterInputs, type RouterOutputs } from "@acme/api";

/**
* Extend this function when going to production by
* setting the baseUrl to your production API URL.
*/
const getBaseUrl = () => {
/**
* Gets the IP address of your host-machine. If it cannot automatically find it,
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
* you don't have anything else running on it, or you'd have to change it.
*
* **NOTE**: This is only for development. In production, you'll want to set the
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(":")[0];

if (!localhost) {
// return "https://turbo.t3.gg";
throw new Error(
"Failed to get localhost. Please point to your production server.",
);
}
return `http://${localhost}:3000`;
};

/**
* A wrapper for your app that provides the TRPC context.
* Use only in _app.tsx
Expand All @@ -59,6 +36,10 @@ export function TRPCProvider(props: { children: React.ReactNode }) {
headers() {
const headers = new Map<string, string>();
headers.set("x-trpc-source", "expo-react");

const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);

return Object.fromEntries(headers);
},
}),
Expand Down
53 changes: 53 additions & 0 deletions apps/expo/src/utils/auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import * as Browser from "expo-web-browser";

import { api } from "./api";
import { getBaseUrl } from "./base-url";
import { deleteToken, setToken } from "./session-store";

export const signIn = async () => {
const signInUrl = `${getBaseUrl()}/api/auth/signin`;
const redirectTo = Linking.createURL("/login");
const result = await Browser.openAuthSessionAsync(
`${signInUrl}?expo-redirect=${encodeURIComponent(redirectTo)}`,
redirectTo,
);

if (result.type !== "success") return;
const url = Linking.parse(result.url);
const sessionToken = String(url.queryParams?.session_token);
if (!sessionToken) return;

setToken(sessionToken);
};

export const useUser = () => {
const { data: session } = api.auth.getSession.useQuery();
return session?.user ?? null;
};

export const useSignIn = () => {
const utils = api.useUtils();
const router = useRouter();

return async () => {
await signIn();
await utils.invalidate();
router.replace("/");
};
};

export const useSignOut = () => {
const utils = api.useUtils();
const signOut = api.auth.signOut.useMutation();
const router = useRouter();

return async () => {
const res = await signOut.mutateAsync();
if (!res.success) return;
await deleteToken();
await utils.invalidate();
router.replace("/");
};
};
26 changes: 26 additions & 0 deletions apps/expo/src/utils/base-url.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Constants from "expo-constants";

/**
* Extend this function when going to production by
* setting the baseUrl to your production API URL.
*/
export const getBaseUrl = () => {
/**
* Gets the IP address of your host-machine. If it cannot automatically find it,
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
* you don't have anything else running on it, or you'd have to change it.
*
* **NOTE**: This is only for development. In production, you'll want to set the
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(":")[0];

if (!localhost) {
// return "https://turbo.t3.gg";
throw new Error(
"Failed to get localhost. Please point to your production server.",
);
}
return `http://${localhost}:3000`;
};
6 changes: 6 additions & 0 deletions apps/expo/src/utils/session-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as SecureStore from "expo-secure-store";

const key = "session_token";
export const getToken = () => SecureStore.getItem(key);
export const deleteToken = () => SecureStore.deleteItemAsync(key);
export const setToken = (v: string) => SecureStore.setItem(key, v);
80 changes: 79 additions & 1 deletion apps/nextjs/src/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,81 @@
export { GET, POST } from "@acme/auth";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

import { handlers, isSecureContext } from "@acme/auth";

export const runtime = "edge";

const EXPO_COOKIE_NAME = "__acme-expo-redirect-state";
const AUTH_COOKIE_PATTERN = /authjs\.session-token=([^;]+)/;

/**
* Noop in production.
*
* In development, rewrite the request URL to use localhost instead of host IP address
* so that Expo Auth works without getting trapped by Next.js CSRF protection.
* @param req The request to modify
* @returns The modified request.
*/
function rewriteRequestUrlInDevelopment(req: NextRequest) {
if (isSecureContext) return req;

const host = req.headers.get("host");
const newURL = new URL(req.url);
newURL.host = host ?? req.nextUrl.host;
return new NextRequest(newURL, req);
}

export const POST = async (_req: NextRequest) => {
// First step must be to correct the request URL.
const req = rewriteRequestUrlInDevelopment(_req);
return handlers.POST(req);
};

export const GET = async (
_req: NextRequest,
props: { params: { nextauth: string[] } },
) => {
// First step must be to correct the request URL.
const req = rewriteRequestUrlInDevelopment(_req);

const nextauthAction = props.params.nextauth[0];
const isExpoSignIn = req.nextUrl.searchParams.get("expo-redirect");
const isExpoCallback = cookies().get(EXPO_COOKIE_NAME);

if (nextauthAction === "signin" && !!isExpoSignIn) {
// set a cookie we can read in the callback
// to know to send the user back to expo
cookies().set({
name: EXPO_COOKIE_NAME,
value: isExpoSignIn,
maxAge: 60 * 10, // 10 min
path: "/",
});
}

if (nextauthAction === "callback" && !!isExpoCallback) {
cookies().delete(EXPO_COOKIE_NAME);

// Run original handler, then extract the session token from the response
// Send it back via a query param in the Expo deep link. The Expo app
// will then get that and set it in the session storage.
const authResponse = await handlers.GET(req);
const setCookie = authResponse.headers
.getSetCookie()
.find((cookie) => AUTH_COOKIE_PATTERN.test(cookie));
const match = setCookie?.match(AUTH_COOKIE_PATTERN)?.[1];

if (!match)
throw new Error(
"Unable to find session cookie: " +
JSON.stringify(authResponse.headers.getSetCookie()),
);

const url = new URL(isExpoCallback.value);
url.searchParams.set("session_token", match);
return NextResponse.redirect(url);
}

// Every other request just calls the default handler
return handlers.GET(req);
};
9 changes: 9 additions & 0 deletions packages/api/src/router/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { TRPCRouterRecord } from "@trpc/server";

import { invalidateSessionToken } from "@acme/auth";

import { protectedProcedure, publicProcedure } from "../trpc";

export const authRouter = {
Expand All @@ -9,4 +11,11 @@ export const authRouter = {
getSecretMessage: protectedProcedure.query(() => {
return "you can see this secret message!";
}),
signOut: protectedProcedure.mutation(async (opts) => {
if (!opts.ctx.token) {
return { success: false };
}
await invalidateSessionToken(opts.ctx.token);
return { success: true };
}),
} satisfies TRPCRouterRecord;
Loading

0 comments on commit 390b1b1

Please sign in to comment.