-
-
Save jdthorpe/aaa0d31a598f299a57e5c76535bf0690 to your computer and use it in GitHub Desktop.
/* An example app that uses expo-auth-session to connect to Azure AD (or hopefully most providers) | |
Features: | |
- secure cache with refresh on load | |
- securely stored refresh token using expo-secure-store | |
- uses zustand for global access to the token / logout | |
Based on [this gist](https://gist.github.com/thedewpoint/181281f8cbec10378ecd4bb65c0ae131) | |
*/ | |
import { useEffect, useState } from 'react'; | |
import { View, Text, Button, StyleSheet } from 'react-native'; | |
import * as WebBrowser from 'expo-web-browser'; | |
import { setItemAsync, getItemAsync, deleteItemAsync } from 'expo-secure-store'; | |
import { | |
makeRedirectUri, | |
useAuthRequest, | |
DiscoveryDocument, | |
AccessTokenRequest, | |
exchangeCodeAsync, | |
fetchDiscoveryAsync, | |
TokenResponseConfig, | |
TokenResponse, | |
// IF YOUR PROVIDER SUPPORTS A `revocationEndpoint`: | |
// revokeAsync, RefreshTokenRequestConfig, TokenTypeHint, | |
refreshAsync | |
} from 'expo-auth-session'; | |
import jwtDecode from 'jwt-decode'; | |
import { create } from 'zustand'; | |
// -------------------------------------------------- | |
// CONFIGURATION CONSTANTS | |
// -------------------------------------------------- | |
const endpoint = "https://login.microsoftonline.com/common/v2.0" | |
// or: | |
// const TENANT_ID = "{{ tenant id }}" | |
// const endpoint = "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0" | |
const clientId = "{{ clientId GUID }}" | |
const scheme = "my.app" | |
const scopes = ['openid', 'offline_access', 'profile', 'email'] | |
// -------------------------------------------------- | |
// -------------------------------------------------- | |
const AUTH_STORAGE_KEY = "refreshToken" | |
const storeRefreshToken = async (token: string) => setItemAsync(AUTH_STORAGE_KEY, token) | |
const deleteRefreshToken = async () => deleteItemAsync(AUTH_STORAGE_KEY) | |
const fetchRefreshToken = async () => getItemAsync(AUTH_STORAGE_KEY) | |
// -------------------------------------------------- | |
// Global Store | |
// -------------------------------------------------- | |
interface User { | |
idToken: string; | |
decoded: any; | |
} | |
interface StoreConfig { | |
user: null | User; | |
discovery: DiscoveryDocument | null; | |
authError: null | string; | |
logout: () => void; | |
setAuthError: (authError: string | null) => void; | |
setTokenResponse: (responseToken: TokenResponse) => void; | |
maybeRefreshToken: () => Promise<void>; | |
} | |
const useUserStore = create<StoreConfig>((set, get) => ({ | |
user: null, | |
discovery: null, | |
authError: null, | |
setAuthError: (authError: string | null) => set({ authError }), | |
logout: async () => { | |
try { | |
set({ user: null, authError: null }) | |
deleteRefreshToken() | |
// // IF YOUR PROVIDER SUPPORTS A `revocationEndpoint` (which Azure AD does not): | |
// const token = await fetchRefreshToken() | |
// const discovery = get().discovery || await fetchDiscoveryAsync(endpoint) | |
// await token ? revokeAsync({ token, clientId }, discovery) : undefined | |
} catch (err: any) { | |
set({ authError: "LOGOUT: " + (err.message || "something went wrong") }) | |
} | |
}, | |
setTokenResponse: (responseToken: TokenResponse) => { | |
// cache the token for next time | |
const tokenConfig: TokenResponseConfig = responseToken.getRequestConfig() | |
const { idToken, refreshToken } = tokenConfig; | |
refreshToken && storeRefreshToken(refreshToken); | |
// extract the user info | |
if (!idToken) return | |
const decoded = jwtDecode(idToken); | |
set({ user: { idToken, decoded } }) | |
}, | |
maybeRefreshToken: async () => { | |
const refreshToken = await fetchRefreshToken(); | |
if (!refreshToken) return // nothing to do | |
const discovery = get().discovery || await fetchDiscoveryAsync(endpoint) | |
get().setTokenResponse(await refreshAsync({ clientId, refreshToken }, discovery!)) | |
}, | |
})); | |
fetchDiscoveryAsync(endpoint).then(discovery => useUserStore.setState({ discovery })) | |
// -------------------------------------------------- | |
// -------------------------------------------------- | |
WebBrowser.maybeCompleteAuthSession(); | |
export default function Login() { | |
const { user, discovery, authError, | |
setAuthError, setTokenResponse, maybeRefreshToken, logout } = useUserStore() | |
const [cacheTried, setCacheTried] = useState(false) | |
const [codeUsed, setCodeUsed] = useState(false) | |
const redirectUri = makeRedirectUri({ scheme }); | |
const [request, response, promptAsync] = useAuthRequest({ clientId, scopes, redirectUri, }, discovery); | |
useEffect(() => { | |
WebBrowser.warmUpAsync(); | |
setAuthError(null); | |
return () => { WebBrowser.coolDownAsync(); }; | |
}, []); | |
useEffect(() => { | |
// try to fetch stored creds on load if not already logged (but don't try it | |
// more than once) | |
if (user || cacheTried) return | |
setCacheTried(true) // | |
maybeRefreshToken(); | |
}, [cacheTried, maybeRefreshToken, user]) | |
useEffect(() => { | |
if (!discovery || // not ready... | |
codeUsed // Access tokens are only good for a single use | |
) return | |
if (response?.type === "error") { | |
setAuthError("promptAsync: " + (response.params.error || "something went wrong")) | |
return | |
} | |
if (!discovery || (response?.type !== "success")) return; | |
const code = response.params.code; | |
if (!code) return; | |
const getToken = async () => { | |
let stage = "ACCESS TOKEN" | |
try { | |
setCodeUsed(true) | |
const accessToken = new AccessTokenRequest({ | |
code, clientId, redirectUri, | |
scopes: ['openid', 'offline_access', 'profile', 'email'], | |
extraParams: { | |
code_verifier: request?.codeVerifier ? request.codeVerifier : "", | |
}, | |
}); | |
stage = "EXCHANGE TOKEN" | |
setTokenResponse(await exchangeCodeAsync(accessToken, discovery)) | |
} catch (e: any) { | |
setAuthError(stage + ": " + (e.message || "something went wrong")) | |
} | |
} | |
getToken() | |
}, [response, discovery, codeUsed]) | |
return ( | |
<View style={styles.container}> | |
<View style={styles.row}> | |
<View> | |
<Button | |
disabled={(!request) || !!user} | |
title="Log in" | |
onPress={() => { | |
setCodeUsed(false) | |
promptAsync(); | |
}} | |
/> | |
</View> | |
<Button | |
disabled={!user} | |
title="Log out" | |
onPress={logout} | |
/> | |
<Button | |
disabled={!authError} | |
title="Clear" | |
onPress={() => setAuthError(null)} | |
/> | |
</View> | |
{/* <Text style={[styles.text]}>Cache tried: {cacheTried ? "yes" : "no"}</Text> */} | |
{/* <Text style={[styles.text]}>Code exists: {(!!response?.params?.code) ? "yes" : "no"}</Text> */} | |
{/* <Text style={[styles.text]}>Code Used: {codeUsed ? "yes" : "no"}</Text> */} | |
{/* <Text style={styles.text}>{JSON.stringify(response)}</Text> */} | |
{authError ? | |
<> | |
<Text style={[styles.heading]}>Auth Error:</Text> | |
<Text style={[styles.text, styles.error]}>{authError}</Text> | |
</> | |
: null} | |
{/* <Text style={[styles.heading]}>Redirect Uri:</Text> | |
<Text style={[styles.text]}>{redirectUri}</Text> */} | |
<Text style={[styles.heading]}>Token Data:</Text> | |
{user ? <Text style={[styles.text]}>{JSON.stringify(user.decoded)}</Text> : null} | |
</View> | |
) | |
} | |
const styles = StyleSheet.create({ | |
container: { | |
flex: 1, | |
backgroundColor: '#fff', | |
alignItems: 'stretch', | |
justifyContent: "flex-start", | |
outerWidth: "100%", | |
padding: 5 | |
}, | |
row: { | |
flexDirection: "row", | |
alignItems: "center", | |
justifyContent: "space-evenly", | |
}, | |
heading: { | |
padding: 5, | |
fontSize: 24, | |
}, | |
text: { | |
padding: 5, | |
fontSize: 14, | |
}, | |
error: { | |
color: 'red' | |
} | |
}); |
Thank you so much for this great example!
Does anyone have tested with Google? I'm trying to fetch from the url below but I get the following error:
Possible unhandled promise rejection: SyntaxError: JSON Parse error: Unexpected character: <
const endpoint = "https://accounts.google.com/o/oauth2/v2/auth";
const clientId: any = GOOGLE_WEB_CLIENT_ID;
const redirectUri: any = REDIRECT_URI;
const [discovery, setDiscovery] = useState({});
useEffect(() => {
async function loadDiscovery() {
// here is the issue causing promise rejection
const getDiscovery = await fetchDiscoveryAsync(endpoint).then((discovery) => setDiscovery({ discovery }));
// nothing is displayed in console
console.log("get getDiscovery >>>>>> " + JSON.stringify(getDiscovery));
}
loadDiscovery();
}, []);
const [request, response, promptAsync] = useAuthRequest({ clientId, scopes: ['email', 'profile'], redirectUri }, discovery);
useEffect(() => {
console.log(discovery);
if (!discovery) {
console.log("no discovery");
return;
}
if (response?.type === "error") {
console.log("promptAsync: " + (response.params.error || "something went wrong"))
return
}
if (!discovery || (response?.type !== "success")) {
console.log("no discovery and no response type");
return;
}
const code = response.params.code;
if (!code) {
console.log("no code");
return;
}
}, [response, discovery]);
After I finally found Google's discovery document link and using Uber authentication example, I can open the web browser authentication to enter Google's credentials:
const discovery = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
revocationEndpoint: 'https://oauth2.googleapis.com/revoke'
};
const AuthProvider = ({ children }: any) => {
const [request, response, promptAsync] = useAuthRequest({
clientId,
scopes: ['email', 'profile'],
redirectUri,
responseType: 'code',
},
discovery
);
useEffect(() => {
console.log("request >>>>>>>>>>>>>>>>>> " + JSON.stringify(request));
console.log("response >>>>>>>>>>>>>>>>>> " + JSON.stringify(response));
console.log("discovery >>>>>>>>>>>>>>>>>> " + JSON.stringify(discovery));
}, [response]);
...
/* Google */
const signInWithGoogle = async (navigation: any) => {
try {
promptAsync();
} catch (error) {
console.log("Error retrieving data from Google ==>> ", error);
}
}
...
}
I'm still facing blank page and an error after entering Google account login even if I use WebBrowser.maybeCompleteAuthSession();
:
Something went wrong trying to finish signing in. Please close this screen to go back to the app.
Do you know why? I remember Expo Go asked me for permission to access external link before open Google's authentication screen on my old login method using AuthSession.startAsync({ authUrl })
(SDK 48) and now it doesn't ask me anymore. It just opens it directly.
For anyone trying to use B2C, add redirect_uri to refreshAsync:
const refresh = await refreshAsync(
{
clientId: clientId,
refreshToken: refreshToken,
extraParams: {
redirect_uri: authRequest.redirectUri,
},
},
_discovery
);
Thank you for sharing this! Works great with Auth0, within a few modifications (
endpoint
,clientId
, and setting an appropriatepath
inmakeRedirectUri
).