Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: expo auth #720

Merged
merged 26 commits into from
Jun 9, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
18bb853
feat: expo auth
juliusmarminge Nov 2, 2023
dc38d95
rm
juliusmarminge Nov 8, 2023
5a21451
fix format
juliusmarminge Dec 14, 2023
d2778a0
fix lock
juliusmarminge Jan 5, 2024
83980ab
feat: use expo-linking to construct urls for expo auth (#832)
jmcmullen Jan 5, 2024
35452bc
expo install --fix
juliusmarminge Jan 5, 2024
0603aff
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge Mar 5, 2024
fa58c38
nit
juliusmarminge Mar 5, 2024
0db5bee
fix cookie
juliusmarminge Mar 5, 2024
fe487d7
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge Mar 5, 2024
6ab0a75
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge Mar 5, 2024
9e23aa6
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge Mar 5, 2024
8f58650
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge Mar 5, 2024
e01dc76
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge May 11, 2024
fc4a591
use sync session store api
juliusmarminge May 11, 2024
4b95224
add custom handler back
juliusmarminge May 11, 2024
cb1a111
fix exports
juliusmarminge May 11, 2024
21d0ea9
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge May 11, 2024
3442dd2
Use .find() instead of always first cookie (#1043)
dBianchii May 18, 2024
1c8655b
Merge branch 'main' into 11-02-feat_expo_auth
juliusmarminge Jun 9, 2024
a8deb69
feat: Expo Auth without setting AUTH_URL. (#1054)
Wundero Jun 9, 2024
5804c19
some light refactoring
juliusmarminge Jun 9, 2024
f5f5395
dont' mutate args
juliusmarminge Jun 9, 2024
8eee5ca
helper
juliusmarminge Jun 9, 2024
59d869a
cmt
juliusmarminge Jun 9, 2024
2c8fc0d
fmt
juliusmarminge Jun 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 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,10 @@ 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 for the CSRF protection to work when developing locally, you will need to set the AUTH_URL to the same IP address your expo dev server is listening on. This address is displayed in your Expo CLI when starting the dev server.

## FAQ

### Does the starter include Solito?
Expand All @@ -138,14 +142,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
47 changes: 47 additions & 0 deletions apps/expo/src/utils/auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as Linking from "expo-linking";
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)}`,
);

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();

return async () => {
await signIn();
await utils.invalidate();
};
};

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

return async () => {
const res = await signOut.mutateAsync();
if (!res.success) return;
await deleteToken();
await utils.invalidate();
};
};
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);
54 changes: 53 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,55 @@
export { GET, POST } from "@acme/auth";
import type { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

import { GET as DEFAULT_GET, POST } from "@acme/auth";

export const runtime = "edge";

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

export const GET = async (
req: NextRequest,
props: { params: { nextauth: string[] } },
) => {
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);

const authResponse = await DEFAULT_GET(req);
const setCookie = authResponse.headers
.getSetCookie()
.find((cookie) => cookie.startsWith("authjs.session-token"));
const match = setCookie?.match(AUTH_COOKIE_PATTERN)?.[1];
Copy link

@ochicf ochicf May 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work for me, as the cookie starts differently and does not find any, throwing the error just below. Here's an example of my value:

authis.pkce.code verifier=; Max-Age=0; Path=/: HttpOnly: SameSite=Lax, authis.session-token=270ca062-6d2e-4f78-82c9-7690aaff8a6f: Path=/: Expires=Fri, 21 Jun 2024 07:20:50 GMT; HttpOnly; SameSite=Lax

Since the cookie pattern is defined as a regex in this file, could we just use it here to match the cookie?

const setCookie = authResponse.headers
  .getSetCookie()
  .find((cookie) => AUTH_COOKIE_PATTERN.test(cookie));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern itself is hardcoded as well - if you change the authjs cookie name, this will have issues no matter what. Perhaps it might make more sense to customize the cookie to be acme.session-token instead, and that way users of the app can just find+replace acme and will cover the cookie as well?

An alternative would be to just ignore the prefix with something like this:

const AUTH_COOKIE_PATTERN = /(?:\w+)\.session-token=([^;]+)/;

And then apply your suggestion to test the cookie.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Wundero I just realised that I misscopied the contents of the cookie 🤦

The part of the authjs.session-token does not change (at least on my case), I just wanted to highlight that the cookie contains another starting part (the authjs.pkce.code_verifier=... which caused that the .startsWith(...) condition to not comply.

authjs.pkce.code_verifier=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax, authjs.session-token=e9bf1a44-44f1-453f-b492-fc26adec22ef; Path=/; Expires=Sat, 22 Jun 2024 07:33:02 GMT; HttpOnly; SameSite=Lax

With that being clarified, your point is still valid as NextAuth allows to configure cookies (including changing its names), so even the .session-token part could be changed. Is this something that needs to be supported? IMHO it's too of an edge case and not worth it, but still, adding a possible way below.

Details

I guess the regex could be created from exported authConfig's cookie configuration (if any), defaulting to the current one:

const AUTH_COOKIE_NAME =
  authConfig.cookies?.sessionToken?.name || "authjs.session-token";
const AUTH_COOKIE_PATTERN = new RegExp(
  `${AUTH_COOKIE_NAME.replace(/\./g, "\\.")}=([^;]+)`,
);

Notes:

  • because of the satisfies NextAuthConfig: the actual exported authConfig does not have the cookies and the nested properties, so TS coplains of the authConfig.cookies?.sessionToken?.name access, so this would need to be handled
  • the authConfig object needs to be exported from @acme/auth
  • there's some string manipulation things going on (replacing . with \\. so the regexp is properly created... there could be more edge cases so things could break

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startsWith should still work, since the getSetCookie will return a list of all named cookies - the authjs.pkce.code_verifier (or any other authjs cookie) is a separate entry in the list, and should fail that check. In your example, it would split the cookies at the , and return these:

['authjs.pkce.code_verifier=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax', 'authjs.session-token=e9bf1a44-44f1-453f-b492-fc26adec22ef; Path=/; Expires=Sat, 22 Jun 2024 07:33:02 GMT; HttpOnly; SameSite=Lax']

Copy link

@ochicf ochicf May 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why (maybe different system, code or package versions?), but In my case it does not return the same as you.

What you provided:

// outer array
[
  // item 1
  'authjs.pkce.code_verifier=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax',
  // item 2
  'authjs.session-token=e9bf1a44-44f1-453f-b492-fc26adec22ef; Path=/; Expires=Sat, 22 Jun 2024 07:33:02 GMT; HttpOnly; SameSite=Lax'
]

What I'm getting:

// outer array
[
  // item 1 - the comma is inside the string -------------------------- v
  'authjs.pkce.code_verifier=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax, authjs.session-token=e9bf1a44-44f1-453f-b492-fc26adec22ef; Path=/; Expires=Sat, 22 Jun 2024 07:33:02 GMT; HttpOnly; SameSite=Lax'
]

So, in my case there's a single item that does not start with authjs.session-token.

To add a bit more into the mix, I'm doing some tests with HTTPS and the name of the cookie changes, which would make the startsWith to not work even if your case (note the __Secure-authjs.session-token names). TBH I'm trying with nextjs --experimental-https, haven't tried this in a production site or anything, so not sure if that would affect:

[
  '__Secure-authjs.pkce.code_verifier=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax, __Secure-authjs.session-token=d99b92b9-acf2-4f01-b1bc-91cff0bff535; Path=/; Expires=Thu, 27 Jun 2024 07:33:38 GMT; HttpOnly; Secure; SameSite=Lax'
]

In any case, IMHO it feels more consistent and resilient doing the AUTH_COOKIE_PATTERN.test(...), as just below it's doing a setCookie?.match(AUTH_COOKIE_PATTERN) which also allows the cookie to be in any place of the string.


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 DEFAULT_GET(req);
};

export { POST };
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;
11 changes: 8 additions & 3 deletions packages/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import superjson from "superjson";
import { ZodError } from "zod";

import type { Session } from "@acme/auth";
import { auth, validateToken } from "@acme/auth";
import { db } from "@acme/db/client";

/**
Expand All @@ -25,18 +26,22 @@ import { db } from "@acme/db/client";
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = (opts: {
export const createTRPCContext = async (opts: {
headers: Headers;
session: Session | null;
}) => {
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
const authToken = opts.headers.get("Authorization") ?? null;
const session = authToken
? await validateToken(authToken)
: opts.session ?? (await auth());

const source = opts.headers.get("x-trpc-source") ?? "unknown";
console.log(">>> tRPC Request from", source, "by", session?.user);

return {
session,
db,
token: authToken,
};
};

Expand Down
Loading
Loading