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.
Related
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.
The question sounds vague so allow me to explain. I am wondering, what is the correct/best way to pass get a token from local storage and pass it into my axios request.
This is what I am doing now, and I am sure this is not correct so I want to fix it but am unsure how.
I have a component called TicketsComponent that requires authorization. Therefore, in componentDidMount(), I validate the token, and if its invalid then send the user back to login, otherwise, load the tickets. It looks like this:
componentDidMount() {
this._isMounted = true;
// validate token
const token = localStorage.getItem("token");
AuthService.validateToken()
.then((res) => {
if (res == undefined || res == null || !token) {
this.props.history.push("/login");
}
})
.then(() => {
TicketService.getTickets().then((res) => {
if (this._isMounted) {
this.setState({ tickets: res.data });
}
});
});
}
Both AuthService.validateToken() and TicketService.getTickets() require the JWT in the header. Here are those two functions:
validateToken() {
return axios
.get(API_BASE_URL + "authenticate", {
headers: {
token: this.getTokenFromLocalStorage(),
},
})
.then("did it")
.catch((error) => {
console.log("failed to validate token" + error);
return null;
});
}
getTickets() {
console.log("getting tickets!");
console.log("Environment variable: " + process.env.NODE_ENV);
return axios
.get(API_BASE_URL + "tickets", {
headers: { Authorization: `Bearer ${this.getTokenFromLocalStorage()}` },
})
.then("yessssss")
.catch((error) => {
console.log("failed to get tickets" + error);
});
}
The problem is that both AuthService and TicketService share the same function called getTokenFromLocalStorage(). That looks like this:
getTokenFromLocalStorage() {
const token = localStorage.getItem("token");
console.log("the token is -> " + token);
if (token === null) {
return undefined;
}
return token;
}
catch(err) {
return undefined;
}
So obviously this is not ideal. I have the same function in two services just to get the token from the header. What is the recommended way of doing this?
EDIT: I hope this kind of question is allowed. Even though the code is not actually broken per se, I still think this is useful to beginners like me to implement best practice.
You can create a shared axios instance like so:
const API_BASE_URL = 'https://example.com/api/'
const instance = axios.create({
baseURL: API_BASE_URL,
headers: { Authorization: `Bearer ${this.getTokenFromLocalStorage()}` },
});
Then you'd just import "instance" into the components and call:
import {instance} from '../wherever' // decide if you want to import default or not
// make sure to either include or exclude the / in the first parameter passed into the request method (e.g. '/authenticate' or 'authenticate') below based on whether you provided a / in the API_BASE_URL
instance.post('authenticate', {
// any additional config relevant to the request, e.g:
data: {
username: 'my user!',
password: 'super_secret_password'
}
})
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.
I'm creating a global function that checks whether the jwt token is expired or not.
I call this function if I'm fetching data from the api to confirm the user but I'm getting the error that I cannot update during an existing state transition and I don't have a clue what it means.
I also notice the the if(Date.now() >= expiredTime) was the one whose causing the problem
const AuthConfig = () => {
const history = useHistory();
let token = JSON.parse(localStorage.getItem("user"))["token"];
if (token) {
let { exp } = jwt_decode(token);
let expiredTime = exp * 1000 - 60000;
if (Date.now() >= expiredTime) {
localStorage.removeItem("user");
history.push("/login");
} else {
return {
headers: {
Authorization: `Bearer ${token}`,
},
};
}
}
};
I'm not sure if its correct but I call the function like this, since if jwt token is expired it redirect to the login page.
const config = AuthConfig()
const productData = async () => {
const { data } = await axios.get("http://127.0.0.1:5000/product", config);
setProduct(data);
};
I updated this peace of code and I could login to the application but when the jwt expires and it redirect to login using history.push I till get the same error. I tried using Redirect but its a little slow and I could still navigate in privateroutes before redirecting me to login
// old
let expiredTime = exp * 1000 - 60000;
if (Date.now() >= expiredTime)
// change
if (exp < Date.now() / 1000)
i would start from the beginning telling you that if this is a project that is going to production you always must put the auth token check in the backend especially if we talk about jwt authentication.
Otherwise if you have the strict necessity to put it in the React component i would suggest you to handle this with Promises doing something like this:
const config = Promise.all(AuthConfig()).then(()=> productData());
I would even consider to change the productData function to check if the data variable is not null before saving the state that is the reason why the compiler is giving you that error.
const productData = async () => {
const { data } = await axios.get("http://127.0.0.1:5000/product", config);
data && setProduct(data);
};
Finally consider putting this in the backend. Open another question if you need help on the backend too, i'll be glad to help you.
Have a nice day!
I'm still not sure how your code is used within a component context.
Currently your API and setProduct are called regardless whether AuthConfig() returns any value. During this time, you are also calling history.push(), which may be the reason why you encountered the error.
I can recommend you to check config for value before you try to call the API.
const config = AuthConfig()
if (config) {
const productData = async () => {
const { data } = await axios.get("http://127.0.0.1:5000/product", config);
setProduct(data);
};
}
I'm assuming that AuthConfig is a hook, since it contains a hook. And that it's consumed in a React component.
Raise the responsibility of redirecting to the consumer and try to express your logic as effects of their dependencies.
const useAuthConfig = ({ onExpire }) => {
let token = JSON.parse(localStorage.getItem("user"))["token"];
const [isExpired, setIsExpired] = useState(!token);
// Only callback once, when the expired flag turns on
useEffect(() => {
if (isExpired) onExpire();
}, [isExpired]);
// Check the token every render
// This doesn't really make sense cause the expired state
// will only update when the parent happens to update (which
// is arbitrary) but w/e
if (token) {
let { exp } = jwt_decode(token);
let expiredTime = exp * 1000 - 60000;
if (Date.now() >= expiredTime) {
setIsExpired(true);
return null;
}
}
// Don't make a new reference to this object every time
const header = useMemo(() => !isExpired
? ({
headers: {
Authorization: `Bearer ${token}`,
},
})
: null, [isExpired, token]);
return header;
};
const Parent = () => {
const history = useHistory();
// Let the caller decide what to do on expiry,
// and let useAuthConfig just worry about the auth config
const config = useAuthConfig({
onExpire: () => {
localStorage.removeItem("user");
history.push("/login");
}
});
const productData = async (config) => {
const { data } = await axios.get("http://127.0.0.1:5000/product", config);
setProduct(data);
};
useEffect(() => {
if (config) {
productData(config);
}
}, [config]);
};
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.