|
@@ -1,504 +0,0 @@
|
|
|
-import axios, { AxiosError, AxiosRequestConfig } from "axios"
|
|
|
-import { loadAsync, makeRedirectUri, ResponseType } from "expo-auth-session"
|
|
|
-import { deleteItemAsync, getItemAsync, setItemAsync } from "expo-secure-store"
|
|
|
-import jwt_decode from 'jwt-decode'
|
|
|
-import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"
|
|
|
-import { Alert, AppState } from "react-native"
|
|
|
-import { useQueryClient } from "react-query"
|
|
|
-import { v4 } from 'uuid'
|
|
|
-import { navigate } from "../../components/navigation/root-navigator"
|
|
|
-import { LogCategory, LOGGING } from "../../utils/logging"
|
|
|
-import { API_URL } from "../../utils/RequestUtils"
|
|
|
-import { queryKeys } from "../../api/query-keys"
|
|
|
-
|
|
|
-export const SECURESTORE_ACCESSTOKEN = "access"
|
|
|
-export const SECURESTORE_REFRESHTOKEN = "refresh"
|
|
|
-export const SECURESTORE_NOTIFTOKEN = 'notif'
|
|
|
-export const SECURESTORE_ID = 'id'
|
|
|
-export const SECURESTORE_ANONID = 'anon'
|
|
|
-
|
|
|
-interface AuthState {
|
|
|
- accessToken: string,
|
|
|
- notificationToken: string,
|
|
|
- setNotificationTokenAsync: (token: string) => Promise<void>,
|
|
|
- setAccessTokenAsync: (token: string) => Promise<void>,
|
|
|
- setRefreshTokenAsync: (token: string) => Promise<void>,
|
|
|
- setUserIdAsync: (id: string) => Promise<void>,
|
|
|
- clearAuthStorage: () => Promise<void>,
|
|
|
- setAlert: (alert: GlobalAlert) => void,
|
|
|
- refreshToken: string,
|
|
|
- userId: string,
|
|
|
- anonUserId: string,
|
|
|
- authStateLoading: boolean,
|
|
|
- setAuthStateLoading: (state: boolean) => void,
|
|
|
- sendApiRequestAsync: (config: RequestConfig) => Promise<any>,
|
|
|
- login: () => Promise<AuthenticationResult>,
|
|
|
- logout: () => Promise<void>,
|
|
|
- landmarkOwnedByUser: (landmark: Landmark) => boolean,
|
|
|
-}
|
|
|
-
|
|
|
-interface RequestConfig {
|
|
|
- axiosConfig: AxiosRequestConfig,
|
|
|
- authorized: boolean
|
|
|
- errorMessage: string
|
|
|
- loggingCategory: LogCategory
|
|
|
-}
|
|
|
-
|
|
|
-export interface IdToken {
|
|
|
- sub: string
|
|
|
-}
|
|
|
-
|
|
|
-interface AuthenticationResult {
|
|
|
- success: boolean
|
|
|
- errorMessage?: string
|
|
|
-}
|
|
|
-
|
|
|
-interface GlobalAlert {
|
|
|
- title: string
|
|
|
- message: string
|
|
|
- type: 'success' | 'error' | 'warning'
|
|
|
- callback?: () => void,
|
|
|
- callbackButtonText?: string
|
|
|
-}
|
|
|
-
|
|
|
-const setStorageItem = async (key: string, value: string) => {
|
|
|
- if (value) {
|
|
|
- LOGGING.log('SYSTEM', 'info', "Setting storage item for: " + key)
|
|
|
- await setItemAsync(key, value)
|
|
|
- }
|
|
|
- else {
|
|
|
- LOGGING.log('SYSTEM', 'info', "Deleting storage item: " + key)
|
|
|
- await deleteItemAsync(key)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const AuthContext = createContext(null)
|
|
|
-
|
|
|
-/**
|
|
|
- * A base url for the api's authorization endpoints
|
|
|
- */
|
|
|
- const issuer = API_URL + "/o";
|
|
|
-
|
|
|
- /**
|
|
|
- * An object containing the discovery endpoints for the api, necessary for OIDC authentication {@link https://swagger.io/docs/specification/authentication/openid-connect-discovery/}
|
|
|
- */
|
|
|
- const discovery = {
|
|
|
- authorizationEndpoint: issuer + "/authorize/",
|
|
|
- tokenEndpoint: issuer + "/token/",
|
|
|
- revocationEndpoint: issuer + "/revoke/",
|
|
|
- };
|
|
|
-
|
|
|
- const redirectUri = makeRedirectUri({
|
|
|
- path: 'callback'
|
|
|
-});
|
|
|
-
|
|
|
-export const AuthContextProvider: React.FC = ({children}) => {
|
|
|
- const [accessToken, _setAccessToken] = useState<string>()
|
|
|
- const [refreshToken, setRefreshToken] = useState<string>()
|
|
|
- const [notificationToken, setNotificationToken] = useState<string>()
|
|
|
- const [userId, setUserId] = useState<string>()
|
|
|
- const [anonUserId, setAnonUserId] = useState<string>()
|
|
|
- const [authStateLoading, setAuthStateLoading] = useState<boolean>(false)
|
|
|
- const [alert, setAlert] = useState<GlobalAlert>()
|
|
|
-
|
|
|
- const refreshingToken = useRef<boolean>(false)
|
|
|
- const accessTokenRef = useRef<string>(accessToken)
|
|
|
-
|
|
|
- const setAccessToken = (token: string) => {
|
|
|
- accessTokenRef.current = token
|
|
|
- _setAccessToken(token)
|
|
|
- }
|
|
|
-
|
|
|
- const queryClient = useQueryClient()
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- const loadAuthStateFromStorageOnAppLoad = async () => {
|
|
|
- LOGGING.log("AUTH", 'info', "App started, loading existing auth state from storage...")
|
|
|
- const accessTokenFromStorage = await getItemAsync(SECURESTORE_ACCESSTOKEN)
|
|
|
-
|
|
|
- if (accessTokenFromStorage) {
|
|
|
- LOGGING.log("AUTH", 'info', "Access token found in storage, testing if it is valid...")
|
|
|
- try {
|
|
|
- const response = await sendApiRequestAsync({
|
|
|
- axiosConfig: {
|
|
|
- method: 'GET',
|
|
|
- url: '/api/me/',
|
|
|
- headers: {Authorization: 'Bearer ' + accessTokenFromStorage}
|
|
|
- },
|
|
|
- authorized: false,
|
|
|
- errorMessage: 'Failed to retrieve user data from server',
|
|
|
- loggingCategory: 'AUTH'
|
|
|
- })
|
|
|
-
|
|
|
- if (response.status == 200) {
|
|
|
- setAccessToken(accessTokenFromStorage)
|
|
|
- setRefreshToken(await getItemAsync(SECURESTORE_REFRESHTOKEN))
|
|
|
- setNotificationToken(await getItemAsync(SECURESTORE_NOTIFTOKEN))
|
|
|
- setUserId(await getItemAsync(SECURESTORE_ID))
|
|
|
- LOGGING.log("AUTH", 'info', "Access token is valid, auth state has been loaded...")
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- catch {
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- await setAccessTokenAsync("")
|
|
|
- await setRefreshTokenAsync("")
|
|
|
- await setNotificationTokenAsync('')
|
|
|
- await setUserIdAsync('')
|
|
|
- LOGGING.log("AUTH", 'info', "No auth state found in storage, starting with an empty auth state...")
|
|
|
-
|
|
|
- let anonUserId = await getItemAsync(SECURESTORE_ANONID)
|
|
|
- if (anonUserId) {
|
|
|
- setAnonUserId(anonUserId)
|
|
|
- }
|
|
|
- else {
|
|
|
- anonUserId = v4()
|
|
|
- await setItemAsync(SECURESTORE_ANONID, anonUserId)
|
|
|
- setAnonUserId(anonUserId)
|
|
|
- }
|
|
|
- LOGGING.log("AUTH", 'info', "Created anonymous id for non account user: " + anonUserId)
|
|
|
- }
|
|
|
- loadAuthStateFromStorageOnAppLoad()
|
|
|
- }, [])
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- const checkAuthStateOnAppForeground = async (state) => {
|
|
|
- let currentAccessToken = accessTokenRef.current
|
|
|
- if (currentAccessToken === undefined) {
|
|
|
- currentAccessToken = await getItemAsync(SECURESTORE_ACCESSTOKEN)
|
|
|
- }
|
|
|
-
|
|
|
- if (currentAccessToken && state == 'active') {
|
|
|
- LOGGING.log("AUTH", 'info', "App was foregrounded and access token found, checking if it is still valid...")
|
|
|
- try {
|
|
|
- const response = await sendApiRequestAsync({
|
|
|
- axiosConfig: {
|
|
|
- method: 'GET',
|
|
|
- url: '/api/me/',
|
|
|
- headers: {Authorization: 'Bearer ' + accessTokenRef.current}
|
|
|
- },
|
|
|
- authorized: false,
|
|
|
- errorMessage: 'Failed to retrieve user data from server while checking access token',
|
|
|
- loggingCategory: 'AUTH'
|
|
|
- })
|
|
|
- if (response.status == 200) {
|
|
|
- LOGGING.log("AUTH", 'info', "Access token is valid, no action required")
|
|
|
- return
|
|
|
- }
|
|
|
- } catch (error) {}
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- AppState.addEventListener('change', checkAuthStateOnAppForeground)
|
|
|
-
|
|
|
- return () => {
|
|
|
- AppState.removeEventListener('change', checkAuthStateOnAppForeground)
|
|
|
- }
|
|
|
- }, [])
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- if (alert) {
|
|
|
- LOGGING.log("SYSTEM", 'info', "Showing alert")
|
|
|
- let buttons = [{text: alert.callbackButtonText ? alert.callbackButtonText : 'OK', onPress: alert.callback}, {text: 'Cancel', onPress: () => console.log('canceled')}]
|
|
|
- const alertTitle = alert.title
|
|
|
- Alert.alert(alertTitle, alert.message, buttons)
|
|
|
- setAlert(undefined)
|
|
|
- }
|
|
|
- }, [alert])
|
|
|
-
|
|
|
- useEffect(() => {
|
|
|
- const convertExistingAnonymousLandmarksOnAccessTokenChange = async () => {
|
|
|
- if (accessToken && anonUserId) {
|
|
|
- await convertExistingAnonymousLandmarks()
|
|
|
- }
|
|
|
- }
|
|
|
- convertExistingAnonymousLandmarksOnAccessTokenChange()
|
|
|
- }, [accessToken])
|
|
|
-
|
|
|
- const setAccessTokenAsync = async (token: string) => {
|
|
|
- setAccessToken(token)
|
|
|
- await setStorageItem(SECURESTORE_ACCESSTOKEN, token)
|
|
|
- }
|
|
|
-
|
|
|
- const setRefreshTokenAsync = async (token: string) => {
|
|
|
- setRefreshToken(token)
|
|
|
- await setStorageItem(SECURESTORE_REFRESHTOKEN, token)
|
|
|
- }
|
|
|
-
|
|
|
- const setUserIdAsync = async (id: string) => {
|
|
|
- setUserId(id)
|
|
|
- await setStorageItem(SECURESTORE_ID, id)
|
|
|
- }
|
|
|
-
|
|
|
- const setNotificationTokenAsync = async (token: string) => {
|
|
|
- setNotificationToken(token)
|
|
|
- await setStorageItem(SECURESTORE_NOTIFTOKEN, token)
|
|
|
- }
|
|
|
-
|
|
|
- const clearAuthStorage = async () => {
|
|
|
- await Promise.all([
|
|
|
- setAccessTokenAsync(""),
|
|
|
- setRefreshTokenAsync(""),
|
|
|
- setNotificationTokenAsync(""),
|
|
|
- setUserIdAsync("")
|
|
|
- ])
|
|
|
- }
|
|
|
-
|
|
|
- const sendApiRequestAsync = async ({axiosConfig, authorized = false, errorMessage = 'An error occured', loggingCategory = "SYSTEM"}: RequestConfig) => {
|
|
|
- if (authorized && !axiosConfig?.headers?.Authorization) {
|
|
|
- axiosConfig.headers = {
|
|
|
- ...axiosConfig.headers,
|
|
|
- Authorization: `Bearer ${accessToken}`,
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- axiosConfig.baseURL = API_URL
|
|
|
-
|
|
|
- try {
|
|
|
- return await axios(axiosConfig)
|
|
|
- } catch (error) {
|
|
|
- const axiosError = error as AxiosError
|
|
|
- if (axiosError.response.status == 401 || axiosError.response.status == 403) {
|
|
|
- if (!refreshingToken.current) {
|
|
|
- await refreshAccessToken()
|
|
|
- }
|
|
|
- }
|
|
|
- console.log(error)
|
|
|
- LOGGING.log("AUTH", 'error', errorMessage)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const login = async (): Promise<AuthenticationResult> => {
|
|
|
- try {
|
|
|
- setAuthStateLoading(true)
|
|
|
- LOGGING.log('AUTH', 'info', "Starting login process...")
|
|
|
-
|
|
|
- // initiate authentication request to the server
|
|
|
- const request = await loadAsync({
|
|
|
- clientId: "atlas.mobile",
|
|
|
- responseType: ResponseType.Code,
|
|
|
- redirectUri,
|
|
|
- usePKCE: true,
|
|
|
- scopes: ['openid']
|
|
|
- }, discovery)
|
|
|
-
|
|
|
- // handle authentication response from the server
|
|
|
- LOGGING.log('AUTH', 'info', "Prompting user with web browser...")
|
|
|
- const response = await request.promptAsync(discovery, {createTask: false});
|
|
|
-
|
|
|
- // if succesful, prepare a request for an access/id token
|
|
|
- if (response.type == "success" && request.codeVerifier) {
|
|
|
- const tokenData = new URLSearchParams();
|
|
|
- tokenData.append('grant_type', 'authorization_code');
|
|
|
- tokenData.append('client_id', 'atlas.mobile');
|
|
|
- tokenData.append('code', response.params.code);
|
|
|
- tokenData.append('redirect_uri', request.redirectUri);
|
|
|
- tokenData.append('code_verifier', request.codeVerifier);
|
|
|
- LOGGING.log('AUTH', 'info', "User successfully authenticated, attempting to get access token...")
|
|
|
-
|
|
|
- // send the token request
|
|
|
- try {
|
|
|
- const response = await axios.post(API_URL + `/o/token/`, tokenData, {
|
|
|
- headers: {
|
|
|
- 'Content-Type': "application/x-www-form-urlencoded"
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- const tokenResponse = response.data;
|
|
|
-
|
|
|
- // if its a successful response, decode the jwt id token, and store the tokens in the corresponding stores
|
|
|
- const idToken = jwt_decode(tokenResponse.id_token) as IdToken;
|
|
|
-
|
|
|
- setAuthStateLoading(false)
|
|
|
-
|
|
|
- await setAccessTokenAsync(tokenResponse.access_token);
|
|
|
- await setRefreshTokenAsync(tokenResponse.refresh_token);
|
|
|
- await setUserIdAsync(idToken.sub)
|
|
|
-
|
|
|
- LOGGING.log('AUTH', 'info', "Successfully retrieved access token, login process completed")
|
|
|
-
|
|
|
- return {success: true}
|
|
|
- } catch (error) {
|
|
|
- LOGGING.log('AUTH', 'error', "An error occured when trying to retrieve access token: " + error)
|
|
|
- setAuthStateLoading(false)
|
|
|
- }
|
|
|
- }
|
|
|
- else if (response.type == "cancel") {
|
|
|
- LOGGING.log('AUTH', 'info', "User canceled login")
|
|
|
- setAuthStateLoading(false)
|
|
|
- }
|
|
|
- else if (response.type == "dismiss") {
|
|
|
- LOGGING.log('AUTH', 'info', "User canceled login")
|
|
|
- setAuthStateLoading(false)
|
|
|
- }
|
|
|
- else {
|
|
|
- LOGGING.log('AUTH', 'error', "An error occured when trying to authenticate user: " + response.type)
|
|
|
- setAlert({title: 'Login error', message: "Something went wrong while logging in. Please try again.", callback: () => setAuthStateLoading(false), type: 'error'})
|
|
|
- }
|
|
|
- }
|
|
|
- catch (error) {
|
|
|
- LOGGING.log('AUTH', 'error', "An error occured when trying to authenticate user: " + error)
|
|
|
- setAlert({title: 'Login error', message: "Something went wrong while logging in. Please try again.", callback: () => setAuthStateLoading(false), type: 'error'})
|
|
|
- }
|
|
|
-
|
|
|
- return {success: false}
|
|
|
- }
|
|
|
-
|
|
|
- const logout = async () => {
|
|
|
- LOGGING.log('AUTH', 'info', "Starting logout process...")
|
|
|
- setAuthStateLoading(true)
|
|
|
- try {
|
|
|
- const tokenParams = new URLSearchParams();
|
|
|
- tokenParams.append('client_id', 'atlas.mobile');
|
|
|
- tokenParams.append('token', accessToken as string);
|
|
|
-
|
|
|
- await axios.post(API_URL + `/o/revoke-token/`, tokenParams, {
|
|
|
- headers: {
|
|
|
- 'Content-Type': 'application/x-www-form-urlencoded'
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- queryClient.setQueryData(queryKeys.GET_OWN_PROFILE, null)
|
|
|
- await setAnonUserId(await getItemAsync(SECURESTORE_ANONID))
|
|
|
- await clearAuthStorage()
|
|
|
- LOGGING.log('AUTH', 'info', "Successfully logged out. Local data has been cleared, and the device's anonymous id has been reloaded.")
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
- LOGGING.log('AUTH', 'info', "Something when wrong while logging out: " + error)
|
|
|
- setAlert({title: 'Error', message: "Something went wrong while logging out. Please try again.", callback: () => {}, type: 'error'})
|
|
|
- }
|
|
|
- setAuthStateLoading(false)
|
|
|
- }
|
|
|
-
|
|
|
- const refreshAccessToken = async () => {
|
|
|
- let success = true;
|
|
|
- refreshingToken.current = true
|
|
|
- let currentRefreshToken = refreshToken
|
|
|
- if (!currentRefreshToken) {
|
|
|
- currentRefreshToken = await getItemAsync(SECURESTORE_REFRESHTOKEN);
|
|
|
- }
|
|
|
-
|
|
|
- if (currentRefreshToken) {
|
|
|
- try {
|
|
|
- const tokenData = new URLSearchParams();
|
|
|
- tokenData.append('grant_type', 'refresh_token');
|
|
|
- tokenData.append('refresh_token', currentRefreshToken);
|
|
|
- tokenData.append('client_id', 'atlas.mobile');
|
|
|
- console.log('[Authentication]: Attempting to refresh token...')
|
|
|
- const { data: refreshResponseData } = await axios.post(API_URL + "/o/token/", tokenData, {
|
|
|
- headers: { 'Content-Type': "application/x-www-form-urlencoded" }
|
|
|
- });
|
|
|
-
|
|
|
- await setRefreshTokenAsync(refreshResponseData.refresh_token);
|
|
|
- await setAccessTokenAsync(refreshResponseData.access_token);
|
|
|
-
|
|
|
- console.info('Successfully refreshed access token.')
|
|
|
- }
|
|
|
- catch (error) {
|
|
|
- setAlert({
|
|
|
- title: 'Login timeout',
|
|
|
- message: "It looks like you've been away for awhile! Unfortunately we weren't able to log you back in, you'll have to do that manually.",
|
|
|
- callback: () => {navigate('Account')},
|
|
|
- type: 'error',
|
|
|
- callbackButtonText: "Go to login"
|
|
|
- })
|
|
|
- success = false
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- refreshingToken.current = false
|
|
|
- return success
|
|
|
- }
|
|
|
-
|
|
|
- const landmarkOwnedByUser = (landmark: Landmark) => {
|
|
|
- const owned = landmark?.user && landmark?.user == userId || landmark?.anonymous && landmark?.anonymous == anonUserId
|
|
|
- return owned
|
|
|
- }
|
|
|
-
|
|
|
- const convertExistingAnonymousLandmarks = async () => {
|
|
|
- try {
|
|
|
- LOGGING.log("AUTH", 'info', "Checking server to see if new logged in user has any anonymous landmarks...")
|
|
|
- const response = await sendApiRequestAsync({
|
|
|
- axiosConfig: {
|
|
|
- method: 'GET',
|
|
|
- url: `/api/landmarks/anon/${anonUserId}/`
|
|
|
- },
|
|
|
- authorized: true,
|
|
|
- errorMessage: 'An error occured while checking for anonymous landmarks',
|
|
|
- loggingCategory: "AUTH",
|
|
|
- })
|
|
|
-
|
|
|
- if (response?.data?.has_landmark) {
|
|
|
- // send request to convert landarks
|
|
|
- LOGGING.log("AUTH", 'info', "Anonymous landmarks found belonging to user. Converting to owned landmarks...")
|
|
|
- await sendApiRequestAsync({
|
|
|
- axiosConfig: {
|
|
|
- method: 'POST',
|
|
|
- url: `/api/landmarks/convert/${anonUserId}/`
|
|
|
- },
|
|
|
- authorized: true,
|
|
|
- errorMessage: 'Something went wrong when converting anonymous landmarks',
|
|
|
- loggingCategory: "AUTH",
|
|
|
- })
|
|
|
-
|
|
|
- LOGGING.log("AUTH", 'info', "Successfully converted anonymous landmarks to owned landmarks.")
|
|
|
-
|
|
|
- setAlert({
|
|
|
- title: 'Heads up',
|
|
|
- message: "It looks like you added some landmarks before creating an account, so those landmarks are now owned by your newly created account.",
|
|
|
- callback: () => LOGGING.log('AUTH', 'info', 'Notifying user of converted landmarks'),
|
|
|
- type: 'warning'
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- setAnonUserId('')
|
|
|
- } catch (error) {
|
|
|
- LOGGING.log("AUTH", 'error', "An error occured while converting anonymous landmarks: " + error)
|
|
|
-
|
|
|
- // TODO: implement transfer anonymus landmarks in accounts
|
|
|
- // setAlert({
|
|
|
- // title: 'Error',
|
|
|
- // message: "Failed to convert your old anonymous landmarks to your account. Please try again manually by going to Account -> Information -> Transfer anonymous landmarks.",
|
|
|
- // callback: () => navigate("Account"),
|
|
|
- // type: 'error'
|
|
|
- // })
|
|
|
- return false
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const authState = useMemo<AuthState>(() => ({
|
|
|
- accessToken,
|
|
|
- notificationToken,
|
|
|
- setNotificationTokenAsync,
|
|
|
- setAccessTokenAsync,
|
|
|
- setRefreshTokenAsync,
|
|
|
- setUserIdAsync,
|
|
|
- clearAuthStorage,
|
|
|
- landmarkOwnedByUser,
|
|
|
- refreshToken,
|
|
|
- userId,
|
|
|
- authStateLoading,
|
|
|
- anonUserId,
|
|
|
- setAuthStateLoading,
|
|
|
- setAlert,
|
|
|
- sendApiRequestAsync,
|
|
|
- login,
|
|
|
- logout,
|
|
|
- }), [accessToken, refreshToken, userId, authStateLoading, anonUserId])
|
|
|
-
|
|
|
- return (
|
|
|
- <AuthContext.Provider value={authState}>
|
|
|
- {children}
|
|
|
- </AuthContext.Provider>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-export const useAuth = () => {
|
|
|
- const context = useContext<AuthState>(AuthContext)
|
|
|
- if (context === undefined) {
|
|
|
- throw new Error('useAuth must be used within a AuthProvider')
|
|
|
- }
|
|
|
- return context
|
|
|
-}
|