-
-
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
); thanks a lot @EHF32 WORKS PERFECT IN AAD B2C.
Thank you for sharing this! Works great with Auth0, within a few modifications (
endpoint,clientId, and setting an appropriatepathinmakeRedirectUri).