Query not re-executed after refreshing access token and calling foward(operation) - reactjs

I feel like there's probably just a fundamental gap in my knowledge, as I'm new to Apollo Client. But I've perused Stack Overflow, GitHub issues, and the Google for obvious solutions to an issue I'm running into and haven't found any.
Basically I have the following Apollo Client setup (simplified):
const auth = new Auth()
const authMiddleware = new ApolloLink((operation, forward) => {
const authToken = auth.getToken().access_token
console.log(authToken)
operation.setContext(({ headers = {} }) => ({
headers: {
...headers,
authorization: authToken ? `Bearer ${authToken}` : ''
}
}))
return forward(operation)
})
const cache = new InMemoryCache()
const errorLink = onError(({ forward, graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ extensions, locations, message, path }) => {
if (extensions.code === 'access-denied') {
auth.refresh()
.then(() => {
console.log(`new access token: ${auth.getToken().access_token}`)
return forward(operation)
}).catch((error) => {
handleLogout(error)
})
}
})
}
})
const handleLogout = (reason) => {
auth.logout()
}
const httpLink = new HttpLink({ uri: '' })
const client = new ApolloClient({
cache: cache,
link: ApolloLink.from([
errorLink,
authMiddleware,
httpLink
])
})
And I have a simple query:
client.query({
query: Queries.MyQuery
}).then((response) => {
console.log(response)
}, (error) => {
console.log(error)
})
The client successfully executes the query if there's a valid OAuth access token the first time it runs. If, however, I expire the access token on our OAuth server and then try to execute the query, it does not complete successfully.
When debugging, I can see what's going on:
authMiddleware adds the old access token properly to the request header.
The request fails because the token is no longer valid. This is handled property by errorLink.
errorLink also successfully retrieves a new access token and returns forward(operation).
authMiddleware gets called again, adds the new access token, and returns forward(operation).
This is where things break down. The query never re-executes. If I manually refresh the page to re-execute the query, it uses the new access token and completes successfully.
From reading the docs, it sounds like the way I've set it up should work, but obviously I'm doing something incorrectly.

I was able to piece together what was going on by digging into a variety of sources. It was confusing mostly because a lot of devs have struggled with this in the past (and still seem to be), so there's a plethora of outdated solutions and posts out there.
This GitHub issue was the most useful source of information, even though it's attached to a repository that's now deprecated. This Stack Overflow answer was also helpful.
I spent some time going down the path of using a utility method to turn the promise into an Observable, but this is no longer required if you use fromPromise.
Here's the solution I ended up with that works with Apollo Client 3.2.0:
const authLink = new ApolloLink((operation, forward) => {
const authToken = auth.getToken().access_token
console.info(`access token: ${authToken}`)
operation.setContext(({ headers }) => ({
headers: {
...headers,
authorization: authToken ? `Bearer ${authToken}` : ''
}
}))
return forward(operation)
})
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
const firstGraphQLError = graphQLErrors[0]
if (firstGraphQLError.extensions.code === 'access-denied') {
let innerForward
if (!isRefreshing) {
isRefreshing = true
innerForward = fromPromise(
auth.refresh()
.then(() => {
const authToken = auth.getToken().access_token
console.info(`access token refreshed: ${authToken}`)
resolvePendingRequests()
return authToken
})
.catch(() => {
pendingRequests = []
// Log the user out here.
return false
})
.finally(() => {
isRefreshing = false
})
).filter(value => Boolean(value))
} else {
innerForward = fromPromise(
new Promise(resolve => {
pendingRequests.push(() => resolve())
})
)
}
return innerForward.flatMap(() => {
return forward(operation)
})
} else {
console.log(`[GraphQL error]: Message: ${firstGraphQLError.message}, Location: ${firstGraphQLError.locations}, Path: ${firstGraphQLError.path}`)
}
}
if (networkError) {
console.log(`[Network error]: ${networkError}`)
}
})
const client = new ApolloClient({
cache: new InMemoryCache(),
link: from([
errorLink,
authLink,
new HttpLink({ uri: '' })
])
})
This solution also handles multiple concurrent requests, queuing them up and requesting them once the access token has been refreshed.

Related

Graphql: which is better to use variables or headers

I'm writing back on nestjs/mongodb and front on reactjs. And use graphql between them. I had some needed info in headers, or I passed it through variables in query.
Is it cheaper to pass it through variables or through context?
When user logs in, I'm setting headers: filialIds
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem(${localStorageAppPrefix}.token`);
return {
headers: {
...headers,
filialIds: `${localStorage.getItem(`${localStorageAppPrefix}.filialIds`) ?? ''}`,
authorization: token ? `Bearer ${token}` : '',
},
};
});
export const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});`
When user query smth, I'm checking his filialIds and role in Guard
`
#Injectable() export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const requiredRoles = this.reflector.getAllAndOverride<UserRoles[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles) {
return true;
}
const queryFilialIds =
safeJSONParse(ctx.getContext()?.req?.headers?.filialids ?? '') ?? [];
const { roles, filialIds } = ctx.getContext()?.req?.user ?? {};
const hasRequiredFilials = filialIds?.every(
(filialId) => queryFilialIds.indexOf(filialId) !== -1,
);
const hasRequiredRoles = requiredRoles.some(
(role) => roles?.indexOf(role) !== -1,
);
return hasRequiredRoles || hasRequiredFilials;
}
}`
But I also need the access to filialIds and role in service, like here:
async getCount(context): Promise<number> {
const filialIds =
JSON.parse(context?.req?.headers?.filialids ?? '') ?? [];
return this.userModel.countDocuments({ filialIds: { $in: filialIds } });
}
So the question is: Should I use context or pass it from graphql query like here:
const { data } = useQuery(GET_USER, {
variables: { filialIds: filialIds ?? [] },
skip: !filialIds?.length,
});
There are many ways to solve this problem. I presume that filialIds is some sort of affiliation id?
If the affiliation id is an attribute of the user then you could include it in your JWT token or just append it to the user object whenever you check the JWT. Furthermore if it's a user attribute then there's no need to send it from the client to server at all.
If the affiliation id is an attribute of the site in a multi-tenant situation then a header makes perfect sense - not however that one site could potentially spoof another
If most or many of your resolvers need this variable then having it as a query variable seems tedious.

sessionStorage not available immediately after navigate

I am trying to implement an React solution with Strapi as backend where authorization is done using JWT-keys. My login form is implemented using the function below:
const handleLogin = async (e) => {
let responsekey = null
e.preventDefault();
const data = {
identifier: LoginState.username,
password: LoginState.password
}
await http.post(`auth/local`, data).then((response) => {
setAuth({
userid: response.data.user.id,
loggedin: true
})
responsekey = response.data.jwt
setLoginState({...LoginState, success: true});
sessionStorage.setItem('product-authkey', responsekey);
navigate('/profile');
}).catch(function(error) {
let result = ErrorHandlerAPI(error);
setLoginState({...LoginState, errormessage: result, erroroccurred: true});
});
}
The API-handler should return an Axios item which can be used to query the API. That function is also shown below. If no API-key is present it should return an Axios object without one as for some functionality in the site no JWT-key is necessary.
const GetAPI = () => {
let result = null
console.log(sessionStorage.getItem("product-authkey"))
if (sessionStorage.getItem("product-authkey") === null) {
result = axios.create(
{
baseURL: localurl,
headers: {
'Content-type': 'application/json'
}
}
)
} else {
result = axios.create({
baseURL: localurl,
headers: {
'Content-type': 'application/json',
'Authorization': `Bearer ${sessionStorage.getItem("product-authkey")}`
}
})
}
return result
}
export default GetAPI()
However, once the user is redirected to the profile page (on which an API-call is made which needs an JWT-key), the request fails as there is no key present in the sessionStorage. The console.log also shows 'null'. If I look at the DevTools I do see that the key is there... And if I refresh the profile page the request goes through with the key, so the key and backend are working as they should.
I tried making the GetAPI function to be synchronous and to move the navigate command out of the await part in the handleLogin function, but that didn't help.
Does someone have an idea?
Thanks!
Sincerely,
Jelle
UPDATE:
Seems to work now, but I need to introduce the getAPI in the useEffect hook, I am not sure if that is a good pattern. This is the code of the profile page:
useEffect(() => {
let testapi = GetAPI()
const getMatches = async () => {
const response = await testapi.get(`/profile/${auth.userid}`)
const rawdata = response.data.data
... etc
}, [setMatchState]
export default GetAPI() this is the problematic line. You are running the GetApi function when the module loads. Basically you only get the token when you visit the site and the js files are loaded. Then you keep working with null. When you reload the page it can load the token from the session storage.
The solution is to export the function and call it when you need to make an api call.

Embed code onto another page and use their variables

I have created a web app in React with several pages. To make it a bit secure for our department, at least so everyone can't enter the site and see what's going on, there are some code that does some checks, and also creates some variables I can use for each of my pages.
The issue is that this code is the same on every single page.
So basically it looks something like:
const MyApp= () => {
const { instance, accounts, inProgress } = useMsal();
const [accessToken, setAccessToken] = useState<any>(null);
const name = accounts[0] && accounts[0].name;
const email = accounts[0] && accounts[0].username;
const isAuthenticated = useIsAuthenticated();
useEffect(() => {
const request = {
...loginRequest,
account: accounts[0],
};
instance
.acquireTokenSilent(request)
.then((response: any) => {
setAccessToken(response.accessToken);
})
.catch(() => {
instance.acquireTokenPopup(request).then((response: any) => {
setAccessToken(response.accessToken);
});
});
}, [isAuthenticated]);
function POST(path: string, data: any) {
return fetch(`${fetchUrl}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: accessToken,
},
body: JSON.stringify(data),
});
}
<something else>
return (
<>
<div>{name}</div>
<div>{email}</div>
</>
);
};
export default MyApp;
And it's just annoying to write this on every page. So is there some way to create some kind of component, and then embed it into all pages, where I still have the option to use the const variables created ?

I am getting error while updateQueryData in rtk query, how do I fix this?

I am new to react query and tried many tutorials still I am not logging anything and nothing happens after the following code, cannot even log new data, It doesnt feel like there is any error. POST is working perfectly.
addCategory: builder.mutation({
query: (body) => ({
method: "POST",
url: apiLinks.categoryPUT,
body: body,
}),
async onQueryStarted(body, { dispatch, queryFulfilled }) {
try {
const res = await queryFulfilled;
const id = _.get(res, "data.data.InsertedID", Date.now());
dispatch(
categoryApi.util.updateQueryData(
"getAllCategory",
undefined,
(data) => {
const newBody = { id, ...body };
console.log({ data, newBody }); //nothing shows here
data.push(newBody);
}
)
);
} catch (error) {
console.log({ error }); // nothing logs here
}
},
}),
problem was I used this: useGetAllCategoryQuery("getAllCat"); to fetch data.
useGetAllCategoryQuery("getAllCat");
so it should be:
categoryApi.util.updateQueryData(
"getAllCategory",
"getAllCat", //change here
seems its a unique identifier for cache data

How to persist data using Apollo?

I have a Apollo client and server with a React app in which users can log in. This is the Apollo server mutation for the login:
loginUser: async (root, args) => {
const theUser = await prisma.user.findUnique({
where: {email: String(args.email)},
});
if (!theUser) throw new Error('Unable to Login');
const isMatch = bcrypt.compareSync(args.password, theUser.password);
if (!isMatch) throw new Error('Unable to Login');
return {token: jwt.sign(theUser, 'supersecret'), currentUser: theUser};
},
This returns a JWT and the user that's logging in.
In my React app I have a login component:
// Login.tsx
const [loginUserRes] = useMutation(resolvers.mutations.LoginUser);
const handleSubmit = async (e) => {
e.preventDefault();
const {data} = await loginUserRes({variables: {
email: formData.email,
password: formData.password,
}});
if (data) {
currentUserVar({
email: data.loginUser.currentUser.email,
id: data.loginUser.currentUser.id,
loggedIn: true,
});
window.localStorage.setItem('token', data.loginUser.token);
}
};
This function passes the form data to the LoginUser mutation which returns data if authentication is successful. Then I have a reactive variable called currentUserVar I store the email and id of the user in there so I can use it throughout the application. Finally I store the JWT in a LocalStorage so I can send it for authorization:
// index.tsx
const authLink = setContext((_, {headers}) => {
const token = localStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
Everything is working, except for the fact that if a user refreshes the user data is gone and they have to log in again, which is of course quite annoying.
So I was hoping to get some advice on how to persist the data, perhaps using Apollo? I suppose I could add a checkbox with a remember me function that stores the email and id in the LocalStorage and when the app initiates check if there's user data in the LocalStorage and than use that, but I was wondering if there's a better/other way to do this.
When it comes to the login problem , you have set the headers on your every single request , but did you pass a fuction to the ApolloServer constructor that checks the headers from every single request ? Something like this:
const server=new ApolloServer({
typeDefs,
resolvers,
context:async({req})=>{
const me=getMe(req)
return {
models,
me,
process.env.SECRET
}
}
})
const getMe = async req => {
const token = req.headers['x-token'];
if (token) {
try {
return await jwt.verify(token, process.env.SECRET);
} catch (e) {
throw new AuthenticationError(
'Your session expired. Sign in again.',
);
}
}
};
As for the data persistence part of the question , you have to use setItem to persist the token in the locatStorage.

Resources