next-auth
next-auth copied to clipboard
feat: `next-auth/expo`
βοΈ 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:
- The Expo app calls the
signIn()
function fromnext-auth/expo
. It takes a function that initiate the Expo Authentication flow. Inside thissignIn()
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 thesessionToken
. - Inside the function that initiates the Expo Authentication Flow, it calls the
getSignInInfo()
function innext-auth/expo
to get the OAuth information required to initiate the Expo Auth Flow. ThegetSignInInfo()
will make aPOST
call to/api/auth/proxy
with theaction
ofsignin
in the body, indicating a proxy request to/api/auth/signin
. The proxy makes aNextAuthHandler()
call simulating aPOST
to/api/auth/signin
. It then gets whatever it needs in the response and return the result to the Expo app. - With the sign in info obtained, the Expo Authentication Flow is initiated, prompting the user for their credential.
- After the flow is done, control is given back to the
signIn()
function innext-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 asessionToken
comes back. (By "making a proxy request" I mean making aPOST
request to/api/auth/proxy
, so keep that in mind.) - If a
sessionToken
comes back, thesignIn()
function will store the token in Expo'sSecureStore
, then doawait __NEXTAUTH._getSession({ event: "storage" })
so that theSessionProvider
knows to go and fetch the session. - The
getSession()
function innext-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 thesessionToken
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 ofnext-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 thefetchData
fromnext-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
-
Here's my older attempt at explaining how this works. Included is a proof-of-concept app, but it's a fully-custom
next-auth/expo
module. Just use this as a reference. - ~~The Next project I use to develop this (it's a create-t3-app)~~
- ~~The Expo project I use to develop this (fresh Expo 46 app)~~
- I currently develop next-auth/expo and my app with this method.
- The
github-expo
provider is set up like so:
{
...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,
}
-
Here's how I currently set up the Expo Auth flow on the Expo app:
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,
};
};
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) |
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!
Very excited to see this integration land @intagaming! π
A very important missing piece in the Expo ecosystem ππ
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.
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,
};
};
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.
- I tried
Gitpkg
, but next-auth's monorepo can't be built without the masterpackage.json
, so this doesn't work. - With the repo on my machine, I could make changes to the code.
- Build the
packages/next-auth
package withpnpm build
- 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'snode_modules
, so Next.js and Expo shares the samenext-auth
instance. - Copy the
expo
andcore
folder that were just being built to thenode_modules/next-auth
folder. Where that is depends on your project. - Install
patch-package
:npm i patch-package
. Addpatch-package
to yourpostinstall
script, you'll see why. - Run
npx patch-package next-auth
. That will create a patch file, you should check this file into Git. Now, everytime younpm i
your project,npm
will go install all of your dependencies including the officialnext-auth
, thenpatch-package
from the postinstall script will be run, patching that official next-auth to produce theexpo
andcore
folder from this PR branch's build product. I specifically only choose these 2 folders because they're all it needs to be modified. - 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.)
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.
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.
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
- Review Sign in with Apple sample code.
- For an overview of design and formatting recommendations for Sign in with Apple, see the Human Interface Guidelines .
- Learn about the benefits of Sign in with Apple.
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:

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
.
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.
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.)
any news on this PR?
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.
@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.
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.
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
Hey are there any updates on this or other next-auth expo integration efforts?
Need this so much!
What needs to be done in order for this to be merged eventually?
any doc for this ?