next-auth icon indicating copy to clipboard operation
next-auth copied to clipboard

feat: `next-auth/expo`

Open intagaming opened this issue 2 years ago β€’ 18 comments

β˜•οΈ Reasoning

I attempted to create the next-auth/expo module that supports using NextAuth in Expo, with an external Next.js server acting as the NextAuth Authorization Server.

The hope is that developers who want to have a Next.js + Expo monorepo could use NextAuth as its common authentication method.

In the general scheme of things, maybe NextAuth could become a "self-hosted Authorization Server". Currently there's no way for NextAuth to be used on Expo, so it's one step closer towards the common goal.

🧒 Checklist

  • [ ] Documentation
  • [ ] Tests
  • [ ] Ready to be merged

🎫 Affected issues

There might be issues that's related to this, I just didn't go scavenge to get them here.

πŸ’‘ Explanation

Here's how things work currently. The login flow looks like this:

  1. The Expo app calls the signIn() function from next-auth/expo. It takes a function that initiate the Expo Authentication flow. Inside this signIn() function, it invokes the argument function with the hope of obtaining the authentication result so that it can send them to the /api/auth/callback to get the sessionToken.
  2. Inside the function that initiates the Expo Authentication Flow, it calls the getSignInInfo() function in next-auth/expo to get the OAuth information required to initiate the Expo Auth Flow. The getSignInInfo() will make a POST call to /api/auth/proxy with the action of signin in the body, indicating a proxy request to /api/auth/signin. The proxy makes a NextAuthHandler() call simulating a POST to /api/auth/signin. It then gets whatever it needs in the response and return the result to the Expo app.
  3. With the sign in info obtained, the Expo Authentication Flow is initiated, prompting the user for their credential.
  4. After the flow is done, control is given back to the signIn() function in next-auth/expo. Assuming everything went ok, it will now make a proxy request to the /api/auth/callback with the auth information obtained and hope that a sessionToken comes back. (By "making a proxy request" I mean making a POST request to /api/auth/proxy, so keep that in mind.)
  5. If a sessionToken comes back, the signIn() function will store the token in Expo's SecureStore, then do await __NEXTAUTH._getSession({ event: "storage" }) so that the SessionProvider knows to go and fetch the session.
  6. The getSession() function in next-auth/expo will go and fetch the session. It does so by making a proxy request to /api/auth/session (via /api/auth/proxy, remember). Every request to /api/auth/proxy will include the sessionToken in the body. In the proxy handler, it will convert this body parameter into a cookie before simulating the request to the destination endpoint, like so:
cookies[options.cookies.sessionToken.name] = req.body.sessionToken

The login flow is now complete.

βœ”οΈ Todo

If by any chance this caught the interest of somebody, I would like to ask for some help with these following problems:

  • [ ] Currently there are some Expo & React Native dependencies/devDependencies added into the next-auth package. I have little experience with this so I have no idea what's proper to put in. It seems like the React peer dependency is unhappy right now (it is asking for React 18.0.0 and 18.2.0 something something which I don't understand.)
  • [ ] The next-auth/expo is straight up a derivative of next-auth/react, with some modifications. There are still residues, missing cases, and anything new is solely made up by me. So I need some help in there to polish up. (Side note: fetchData is a mod of the fetchData from next-auth/client/_utils.ts.)
  • [ ] See the comment in the provider setup below regarding the token request modification.
  • [ ] Is there better way so that on the Expo app, we don't have to write the Expo Authentication Flow initiate function ourselves?
  • [ ] If we setup a normal GitHub provider and a special Expo GitHub provider, it's supposed to be the same account but right now we have to link them together manually. Any solution?
  • [ ] Email and Credentials login method. Don't know if it works or not.

There might be a few more. If I realize something I'll post in the comment & update it here.

πŸ“Œ Resources

{
  ...GithubProvider({
    name: "GitHub Expo",
    clientId: process.env.EXPO_GITHUB_ID,
    clientSecret: process.env.EXPO_GITHUB_SECRET,
    checks: ["state", "pkce"], // This is because Expo Authentication uses PKCE. It can be disabled though.
    token: {
      async request(context) {
        // When requesting tokens, if the callbackUrl does not match, it will not work, the Authorization
        // Server won't give out tokens. Apparently this works with GitHub, though it should be an Expo
        // Auth proxy callbackUrl, like https://auth.expo.io/@xuanan2001/expo-app.
        const tokens = await context.client.oauthCallback(
          undefined,
          context.params,
          context.checks
        );
        return { tokens };
      },
    },
  }),
  id: nativeProviders.github,
}
import * as AuthSession from "expo-auth-session";
import { getSignInInfo, SigninResult } from "next-auth/expo";
import { Alert } from "react-native";

export const signinGithub = async (): Promise<SigninResult> => {
  const proxyRedirectUri = AuthSession.makeRedirectUri({ useProxy: true }); // https://auth.expo.io
  const provider = "github-expo";
  const signinInfo = await getSignInInfo({ provider, proxyRedirectUri });
  if (!signinInfo) {
    Alert.alert("Error", "Couldn't get sign in info from server");
    return;
  }
  const { state, codeChallenge, stateEncrypted, codeVerifier, clientId } =
    signinInfo;

  // This corresponds to useLoadedAuthRequest
  const request = new AuthSession.AuthRequest({
    clientId,
    scopes: ["read:user", "user:email", "openid"],
    redirectUri: proxyRedirectUri,
    codeChallengeMethod: AuthSession.CodeChallengeMethod.S256,
  });
  const discovery = {
    authorizationEndpoint: "https://github.com/login/oauth/authorize",
    tokenEndpoint: "https://github.com/login/oauth/access_token",
    revocationEndpoint:
      "https://github.com/settings/connections/applications/XXXXXXXXXXX", // ignore this, it should be set to a clientId.
  };

  request.state = state;
  request.codeChallenge = codeChallenge;
  await request.makeAuthUrlAsync(discovery);

  // useAuthRequestResult
  const result = await request.promptAsync(discovery, { useProxy: true });
  return {
    result,
    state,
    stateEncrypted,
    codeVerifier,
    provider,
  };
};

intagaming avatar Aug 28 '22 10:08 intagaming

The latest updates on your projects. Learn more about Vercel for Git β†—οΈŽ

Name Status Preview Updated
next-auth βœ… Ready (Inspect) Visit Preview Sep 26, 2022 at 8:18AM (UTC)

vercel[bot] avatar Aug 28 '22 10:08 vercel[bot]

BalΓ‘zs seem to approve of this idea:

I haven't used React Native myself, but the linked PR seems interesting. I am all for supporting Expo built-in if we can make it work. πŸ‘

Excited to use this!

KATT avatar Sep 01 '22 12:09 KATT

Very excited to see this integration land @intagaming! πŸ™Œ

ThangHuuVu avatar Sep 01 '22 13:09 ThangHuuVu

A very important missing piece in the Expo ecosystem πŸ‘πŸ‘

juliusmarminge avatar Sep 02 '22 17:09 juliusmarminge

I'm working on an app, Next.js & Expo monorepo, which is currently using the next-auth built from the PR branch. Anything new and I'll post it here. Meanwhile if anyone's also solving issues please collaborate ;)

So today I digged into the Email Provider. This could probably be done on Expo as well, but I think it would not be a good UX. We are relying on the user clicking a link in the email. What if the email never came? How to link it to the Expo app for token submission? What happens if we click the same link on desktop? It seems like there's many problems to be solved if we go this route, and even then it might not be pleasent to use.

I also feel like the Credentials Provider is not the route anyone should invest in. Passwords are cumbersome. Though it might be convenient, it should be limited as much as possible, starting today. Since I don't want to use an app that requires password, I won't do that to my users. I prefer OAuth. (Though Yubikey seems interesting, but don't know how it ended up in Password land, it seems unrelated to each other.)

So there's that, even though I'm using Google or GitHub with a password myself, I think it's okay for the time being as long as I don't have to create another password on a lesser-known site/app. Anyone interested, please chime in, but I'll probably leave these parts up to the interested ones.

As for the Expo Authentication initiate functions, I'm thinking of a folder at next-auth/expo/providers that would provide these functions since it might be trivial to abstract those into the lib. The only input should be the providerId that was set up especially for the Expo Auth. Here's the Google signin for reference, it looks pretty similar to the GitHub one. However last time I checked the Discord one seems to be not working with PKCE.

Google

import { nativeProviders } from "@acme/constants";
import * as AuthSession from "expo-auth-session";
import { discovery as googleDiscovery } from "expo-auth-session/providers/google";
import { getSignInInfo, SigninResult } from "next-auth/expo";
import { Alert } from "react-native";

export const signinGoogle = async (): Promise<SigninResult | null> => {
  const redirectUri = AuthSession.makeRedirectUri({ useProxy: true });
  const provider = nativeProviders.google; // providerId
  const signinInfo = await getSignInInfo({
    provider,
    proxyRedirectUri: redirectUri,
  });
  if (!signinInfo) {
    Alert.alert("Error", "Couldn't get sign in info from server");
    return null;
  }
  const { state, codeChallenge, stateEncrypted, codeVerifier, clientId } =
    signinInfo;

  // This corresponds to useLoadedAuthRequest
  const request = new AuthSession.AuthRequest({
    clientId,
    redirectUri,
    scopes: [
      "openid",
      "https://www.googleapis.com/auth/userinfo.profile",
      "https://www.googleapis.com/auth/userinfo.email",
    ],
  });

  request.state = state;
  request.codeChallenge = codeChallenge;
  request.codeVerifier = codeVerifier;
  await request.makeAuthUrlAsync(googleDiscovery);

  // useAuthRequestResult
  const result = await request.promptAsync(googleDiscovery, { useProxy: true });
  return {
    result,
    state,
    stateEncrypted,
    codeVerifier,
    provider,
  };
};
Discord (old code, not using the library but similar concept)

export const signinDiscord = async () => {
  const proxyRedirectUri = AuthSession.makeRedirectUri({ useProxy: true }); // https://auth.expo.io

  // This corresponds to useLoadedAuthRequest
  const request = new AuthSession.AuthRequest({
    clientId: Constants.manifest?.extra?.discordId ?? "",
    scopes: ["identify", "email"],
    redirectUri: proxyRedirectUri,
    usePKCE: false,
  });
  const discovery = {
    authorizationEndpoint: "https://discord.com/api/oauth2/authorize",
    tokenEndpoint: "https://discord.com/api/oauth2/token",
    revocationEndpoint: "https://discord.com/api/oauth2/token/revoke",
  };

  const provider = nativeProviders.discord;
  const {
    state,
    // codeChallenge,
    csrfTokenCookie,
    stateEncrypted,
    // codeVerifier,
  } = await trpcClient.query("auth.signIn", {
    provider,
    proxyRedirectUri,
    usePKCE: false,
  });
  request.state = state;
  // request.codeChallenge = codeChallenge;
  await request.makeAuthUrlAsync(discovery);

  // useAuthRequestResult
  const result = await request.promptAsync(discovery, { useProxy: true });
  return {
    result,
    state,
    csrfTokenCookie,
    stateEncrypted,
    // codeVerifier,
    proxyRedirectUri,
    provider,
  };
};

intagaming avatar Sep 03 '22 05:09 intagaming

I will be using this branch in my actively developed app for the next few months. Next.js & Expo monorepo of course, generated from https://github.com/t3-oss/create-t3-turbo. I did deploy Next.js to Vercel and run NextAuth off of that, signin/session/signout seems fine (^ thanks to that req.host commit). Here is how I modify/build/run this branch, it is a little bit convoluted, I wonder if there's a better way.

  1. I tried Gitpkg, but next-auth's monorepo can't be built without the master package.json, so this doesn't work.
  2. With the repo on my machine, I could make changes to the code.
  3. Build the packages/next-auth package with pnpm build
  4. In the Next.js or Expo project, install official next-auth. In my case, because they are in a monorepo, next-auth was installed in the root folder's node_modules, so Next.js and Expo shares the same next-auth instance.
  5. Copy the expo and core folder that were just being built to the node_modules/next-auth folder. Where that is depends on your project.
  6. Install patch-package: npm i patch-package. Add patch-package to your postinstall script, you'll see why.
  7. Run npx patch-package next-auth. That will create a patch file, you should check this file into Git. Now, everytime you npm i your project, npm will go install all of your dependencies including the official next-auth, then patch-package from the postinstall script will be run, patching that official next-auth to produce the expo and core folder from this PR branch's build product. I specifically only choose these 2 folders because they're all it needs to be modified.
  8. That should be it, the Next.js app can run now. The Expo app however needs some dependencies since the official next-auth didn't include them. I don't remember exactly which but here are some probable candidiates: npm i expo-auth-session expo-random expo-secure-store react-native-url-polyfill expo-constants

With that, the nooks and crannies of this PR branch were installed into your apps. As for the next-auth development, each time I make change I'd perform step 1 -> 4 and test the result, then do step 6 to update the patch file.


Here's the Google provider setup. It is a little bit different than the GitHub one:

{
  ...GoogleProvider({
    name: "Google Expo Proxy",
    clientId: env.GOOGLE_EXPO_PROXY_CLIENT_ID,
    clientSecret: env.GOOGLE_EXPO_PROXY_CLIENT_SECRET,
    checks: ["state", "pkce"],
    token: {
      async request(context) {
        const tokens = await context.client.callback( // 1
          env.EXPO_AUTH_PROXY_URL, // 2
          context.params,
          context.checks
        );
        return { tokens };
      },
    },
  }),
  id: nativeProviders.google, // "google-expo" in my case
},

First is the (1). We are using callback, not oauthCallback, because apparently Google provides id_token which will make next-auth yells if we don't use callback. next-auth will explicitly tell us that we need to change oauthCallback -> callback, so that's easy to fix if you forgot.

(2) is also crucial. With (2) being undefined, GitHub will still accept the code exchange, but Google won't. That is supposed to be the redirect_uri included in the code exchange manuver, which must match the redirect_uri that was receiving the state & code after user login completion, which is currently https://auth.expo.io/@your-app-owner/appschema. So do that and we're ok.

In the future the desired behaviour must be that we go to the Authorization Server directly, not via Expo Auth Proxy, so the redirect uri must be that of the standalone app if the app isn't running through Expo Go. Somehow I tried and failed, but I felt like I was getting close. This proxy method works in the meantime, so if I have time I will investigate (or someone could try and see, I'm very interested to reduce a middleman.)

intagaming avatar Sep 04 '22 06:09 intagaming

I've tried proving out this approach with a small side project. It's working for both Apple and Google providers, and I must say, it's magical being able to share my backend between a nextjs and expo app like this.

However, I've tried submitting the app to the iOS app store, and it was rejected with feedback that leads me to believe that Apple may not like this implementation using expo.auth.io as a proxy during the oauth flow. In their feedback they provided screenshots of the "Sign In With Apple" experience that an app would have with a more traditional implementation, with the comment:

Please see the attached screenshot of a typical Sign in with Apple login page. The authentication framework should present this format to Sign in with Apple, whereas your authentication proxy page requests the AppleID separately.

I don't mean to dissuade anyone from trying this PR, especially if they don't plan to target the iOS app store. I understand app reviewers can be inconsistent and it's likely others may have more success than me, but thought this might be useful information. I'm also curious if anyone using this library has successfully passed iOS app store review.

tmlamb avatar Oct 19 '22 20:10 tmlamb

I've tried proving out this approach with a small side project. It's working for both Apple and Google providers, and I must say, it's magical being able to share my backend between a nextjs and expo app like this.

However, I've tried submitting the app to the iOS app store, and it was rejected with feedback that leads me to believe that Apple may not like this implementation using expo.auth.io as a proxy during the oauth flow. In their feedback they provided screenshots of the "Sign In With Apple" experience that an app would have with a more traditional implementation, with the comment:

Please see the attached screenshot of a typical Sign in with Apple login page. The authentication framework should present this format to Sign in with Apple, whereas your authentication proxy page requests the AppleID separately.

I don't mean to dissuade anyone from trying this PR, especially if they don't plan to target the iOS app store. I understand app reviewers can be inconsistent and it's likely others may have more success than me, but thought this might be useful information. I'm also curious if anyone using this library has successfully passed iOS app store review.

@tmlamb Thank you for testing it out. I also think bypassing Expo proxy is an important step in using this in production. For now it's just being there for simplicity's sake. I will keep this as a high priority task and work on it as soon as I have the time.

Do you mind sharing as much as possible the information about the app review? It would help to see what exactly they are talking about.

intagaming avatar Oct 19 '22 22:10 intagaming

Do you mind sharing as much as possible the information about the app review? It would help to see what exactly they are talking about.

@intagaming The app was initially rejected because I only provided "Sign In With Google" as an option, which apparently goes against the following policy, so anyone using nextauth should prioritize Sign In With Apple if targeting iOS:

Guideline 4.8 - Design - Sign in with Apple

Your app uses a third-party login service, but does not offer Sign in with Apple. Apps that use a third-party login service for account authentication need to offer Sign in with Apple to users as an equivalent option.

After adding "Sign in with Apple", we started a conversation around the implementation. Here are the full comments so far. The initial rejection was vague:

Guideline 2.1 - Performance - App Completeness

We discovered one or more bugs in your app. Specifically, Sign in with Apple is not implemented properly. Please review the details below and complete the next steps.

Steps to reproduce: The login workflow after selecting Sign in with Apple is not using the Authentication Framework properly. Please review the resources below.

Resources

I responded with a request for more specifics:

Regarding, "Guideline 2.1 - Performance - App Completeness", can you clarify what you mean by "The login workflow after selecting Sign in with Apple is not using the Authentication Framework properly."? Is the login flow with apple not working, or is there an issue with how it's implemented using the auth.expo.io as a proxy page?

They responded with the statement I provided in my initial comment on this PR:

Please see the attached screenshot of a typical Sign in with Apple login page. The authentication framework should present this format to Sign in with Apple, whereas your authentication proxy page requests the AppleID separately. If authentication is provided by a separate contractor, you will need to contact them to take corrective action.

For screenshots, they provided a few from my own app's flow, showing it go through the in-app browser and expo's proxy:

MySignInWithApple

Along with this screenshot from another app which I assume is the more integrated non-browser-based experience you would get with a native iOS app or expo-apple-authentication.

SignInWithApple

tmlamb avatar Oct 20 '22 14:10 tmlamb

There seems to be a possibility of using a combination of next-auth/expo and expo-apple-authentication to achieve the native iOS "Sign In With Apple" widget flow. I've tested successful signin with most of the setup similar to what you've described in this PR @intagaming, except for replacing the calls to next-auth/expo's makeAuthUrlAsync and promptAsync methods with a call to expo-apple-authentication's signInAsync method. signInAsync produces an auth code that I'm able to pass to next-auth/expo's signIn method and successfully validate on the server. I need to do more testing to prove this out but this seems like a potential path forward if Apple does push back on the browser based flow.

My working POC

One downside I see is that this process doesn't seem like it can work when testing with Expo Go. The auth code produced by Apple's sign in widget is tied to bundle identifier of the app that launches the sign in widget, and the Client ID/Client Secret used in the next-auth backend is tied to the App ID setup configured for your app in Apple's Certificates, Identifiers, and Profiles portal; if they don't match then Apple's token auth endpoint will return an error. Running your app in Expo Go produces an auth code from Apple's signin widget using Expo Go app's bundle identifier (host.exp.Exponent), so when the next-auth backend calls apple's auth endpoint to verify it using the Client ID/Secret generated from your App ID, it fails:

error: OPError: invalid_grant (client_id mismatch. The code was not issued to com.example.app.)

tmlamb avatar Oct 21 '22 19:10 tmlamb

any news on this PR?

valerius21 avatar Nov 12 '22 18:11 valerius21

any news on this PR?

Actually yes. I aborted the intent of doing a native app at the moment, so this might take another very long time unless someone steps in and continue the work. I could provide assistant to anyone willing to. It's unfortunate that I don't get the time to work on this more.

I'm still looking for a self-hosted Authorization Server. If someone actually has the demand to get this working, please take matter into your hand - it's very easy to manoeuvre the code. Even I can do it with limited knowledge about everything. I'm now just like the people that landed here - I'm watching the progress being made by the community. If it actually has demand, it should receive the work it deserves.

intagaming avatar Nov 12 '22 18:11 intagaming

@tmlamb I've been studying your implementation and playing with it on iOS. Really smooth integration with Apple, for Google Auth it redirects to expo.io which is probably a bit alarming it some.

Honestly I'm just starting to grok what is happening with this implementation. Great job. Going to try and implement something similar, it seems you've hit the holy grail of code sharing.

Thank you for making this project public.

nickreese avatar Jan 08 '23 15:01 nickreese

I'm glad you found it helpful @nickreese. I agree that getting rid of the expo proxy in the google flow is important. The ease at which it's working with the Apple flow without a proxy makes me hopeful, and I do plan on giving it a try when I have time.

tmlamb avatar Jan 09 '23 21:01 tmlamb

Been taking a stab at this from time to time lately and got it working for t3-turbo for those interested: https://github.com/t3-oss/create-t3-turbo/pull/133

Will take a look at implementing into the new authjs monorepo structure if i have the time - probably best to wait for the @auth/nextjs pacakge though

juliusmarminge avatar Jan 28 '23 12:01 juliusmarminge

Hey are there any updates on this or other next-auth expo integration efforts?

AbhinavPalacharla avatar Feb 06 '23 00:02 AbhinavPalacharla

Need this so much!

What needs to be done in order for this to be merged eventually?

dBianchii avatar May 21 '24 01:05 dBianchii

any doc for this ?

LeulAria avatar Jun 28 '24 07:06 LeulAria