Skip to content

Instantly share code, notes, and snippets.

@jdthorpe
Created March 16, 2023 22:48
Show Gist options
  • Save jdthorpe/aaa0d31a598f299a57e5c76535bf0690 to your computer and use it in GitHub Desktop.
Save jdthorpe/aaa0d31a598f299a57e5c76535bf0690 to your computer and use it in GitHub Desktop.
expo-auth-session example
/* 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'
}
});
@theGOTOguy
Copy link

Thank you for sharing this! Works great with Auth0, within a few modifications (endpoint, clientId, and setting an appropriate path in makeRedirectUri).

@robozb
Copy link

robozb commented Sep 6, 2023

Thank you so much for this great example!

@madsongr
Copy link

madsongr commented Feb 6, 2024

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]);

@madsongr
Copy link

madsongr commented Feb 16, 2024

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.

@EHF32
Copy link

EHF32 commented Mar 3, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment