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: next-auth/expo #5240

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft

feat: next-auth/expo #5240

wants to merge 22 commits into from

Conversation

intagaming
Copy link

@intagaming intagaming commented Aug 28, 2022

☕️ 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,
  };
};

@vercel
Copy link

vercel bot commented Aug 28, 2022

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)

@github-actions github-actions bot added core Refers to `@auth/core` TypeScript Issues relating to TypeScript labels Aug 28, 2022
@KATT
Copy link
Contributor

KATT commented Sep 1, 2022

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!

@ThangHuuVu
Copy link
Member

Very excited to see this integration land @intagaming! 🙌

@juliusmarminge
Copy link
Contributor

juliusmarminge commented Sep 2, 2022

A very important missing piece in the Expo ecosystem 👏👏

@intagaming
Copy link
Author

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
Copy link
Author

intagaming commented Oct 19, 2022

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.

@tmlamb
Copy link

tmlamb commented Oct 20, 2022

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
Copy link

tmlamb commented Oct 21, 2022

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.)

@valerius21
Copy link

valerius21 commented Nov 12, 2022

any news on this PR?

@intagaming
Copy link
Author

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.

@nickreese
Copy link

@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.

@tmlamb
Copy link

tmlamb commented Jan 9, 2023

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.

@redbar0n redbar0n mentioned this pull request Jan 10, 2023
@juliusmarminge
Copy link
Contributor

juliusmarminge commented Jan 28, 2023

Been taking a stab at this from time to time lately and got it working for t3-turbo for those interested: t3-oss/create-t3-turbo#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

@AbhinavPalacharla
Copy link

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

Copy link

@misbahkhalilaz misbahkhalilaz left a comment

Choose a reason for hiding this comment

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

Very excited to use this. Brilliant!
LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Refers to `@auth/core` TypeScript Issues relating to TypeScript
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants