NextFeathers uses JSON web token (JWT) for authentication when calling the Restful API implemented by FeathersJS. The JWT token was simply saved in the browser's localStorage and removed when the user is logged out. Many people said this is very bad because the hacker could run Javascript via what so-called XSS on your website, and read the data from localStorage. Personally, I kinda against this because it's unlikely happened, and as I know that is how AWS-amplify works by default. But there is indeed a risk, so I would like to fix it.
Saving JWT to HttpOnly Cookie sounds like a solution, but there is an issue that I have “Next' and ”Feathers" on different domains (i.e. different ports,) and the cookie can only be sent with requests made to the same domain inside a Cookie
HTTP header. That means, we set the httpOnly cookie on deniapps.com, which cannot be sent to api.deniapps.com, so we are not able to pass the JWT token for authentication. To fix this, we have to use a middleware called Proxy. So the API request will be sent to the same domain, for example, https://deniapps.com/api/proxy/post, and then the Proxy will pass the request to the real API at https://api.deniapps.com/post.
The overall workflow is like below:
The main part is pages/proxy/[…path].js, which I modified from the one in Max's post. (Thank you! Max)
import httpProxy from "http-proxy";
import Cookies from "cookies";
import url from "url";
const API_URL = process.env.API_HOST;
const proxy = httpProxy.createProxyServer();
export const config = {
api: {
bodyParser: false,
},
};
export default (req, res) => {
return new Promise((resolve, reject) => {
const pathname = url.parse(req.url).pathname;
const isLogin = pathname === "/api/proxy/authentication";
const cookies = new Cookies(req, res);
const authToken = cookies.get("auth-token");
// Rewrite URL, strip out leading '/api'
// '/api/proxy/*' becomes '${API_URL}/*'
req.url = req.url.replace(/^\/api\/proxy/, "");
// Don't forward cookies to API
req.headers.cookie = "";
// Set auth-token header from cookie
if (authToken) {
req.headers["Authorization"] = authToken;
}
proxy
.once("proxyRes", (proxyRes, req, res) => {
if (isLogin) {
let responseBody = "";
proxyRes.on("data", (chunk) => {
responseBody += chunk;
});
proxyRes.on("end", () => {
try {
const { accessToken, user } = JSON.parse(responseBody);
const cookies = new Cookies(req, res);
cookies.set("auth-token", accessToken, {
httpOnly: true,
sameSite: "lax", // CSRF protection
});
res.status(200).json({ user });
resolve();
} catch (err) {
reject(err);
}
});
} else {
resolve();
}
})
.once("error", reject)
.web(req, res, {
target: API_URL,
autoRewrite: false,
selfHandleResponse: isLogin,
});
});
};
I also created a local API to check if the user is logged in named “check-login.js” as below:
/**
* pages/api/check-login.js
*
* A API endpoint for checking if the user is logged in.
*/
import { isExpired } from "helpers/common";
export default (req, res) => {
const Cookies = require("cookies");
const cookies = new Cookies(req, res);
const authToken = cookies.get("auth-token") || "";
const loggedIn = !isExpired(authToken);
res.status(200).json({
loggedIn,
});
};
I checked user is logged in in the _app.js when the componentDidMount, so basically every time a page is hard loaded, it will check if the user is logged in, if so, user info is shown on the header, otherwise, the “Login” button is shown instead.
/**
* pages/_app.js
*/
componentDidMount = async () => {
const deniUser = localStorage.getItem(NEXT_PUBLIC_USER_LC_KEY);
if (deniUser) {
const userStatus = await axios
.get("/api/check-login")
.then((response) => response.data);
if (userStatus.loggedIn) {
const deniUserObj = JSON.parse(deniUser);
this.setState({
user: deniUserObj.firstName,
});
}
}
this.setState({
isReady: true,
});
};
Initially, I used https://api.deniapps.com in the production, but go the following error:
SyntaxError: Unexpected token in JSON at position 0
I think it's related to HTTP. To use HTTPS as a proxy target, we need to use “SSL” options with key/cert defined. But since, I have an API server running on the same server with a different port, so I could just change it to http://localhost:5050.
Also, here I don't set cookie expiration, so it expires at the end of the session (the could mean a different thing for different browsers.)
The last thing, you may take a look at how I implement the log out at NextFeathers. And check out how to renew the access token if needed.