Created
March 16, 2023 22:48
-
-
Save jdthorpe/aaa0d31a598f299a57e5c76535bf0690 to your computer and use it in GitHub Desktop.
expo-auth-session example
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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' | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
For anyone trying to use B2C, add redirect_uri to refreshAsync: