Next, js, Amplify js.fetchAuthSession({forceRefresh: true}) does not work as expected and returns tokens as undefined. How to manage a refresh token in Amplify v6 in Next.js
Before opening, please confirm:
- [X] I have searched for duplicate or closed issues and discussions.
- [X] I have read the guide for submitting bug reports.
- [X] I have done my best to include a minimal, self-contained set of instructions for consistently reproducing the issue.
JavaScript Framework
Next.js
Amplify APIs
Authentication
Amplify Version
v6
Amplify Categories
auth
Backend
None
Environment information
# Put output below this line
Describe the bug
Hello and thanks for the great library. I have the following problem. I need a token to set in the headers of each request as Authorization: Bearer ${token}. I am currently taking the accessToken from the fetchAuthSession and setting it in the headers. For this purpose I use axios interceptor. The problem is that if some time passes, about 15 minutes, and the application is not used, the token expires.
Accordingly, you will see that if I do not have a token, the authorization header is not submitted, which is a problem for me. The app only fixes after a refresh, but I want to get the refresh token without forcing the user to refresh because they might lose data.
Expected behavior
Is there a way Amplify to handle the refresh token itself, or to force refresh it when It expires ? I always need a valid token for my Authorization headers.
Reproduction steps
...
Code Snippet
// Put your code below this line.
// _app.tsx
import { type AppType } from "next/dist/shared/lib/utils";
import {
QueryClient,
QueryClientProvider,
} from 'react-query'
import NextTopLoader from 'nextjs-toploader';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { Amplify } from "aws-amplify";
import '@aws-amplify/ui-react/styles.css';
import AuthWrapper from "~/components/auth/AuthWrapper";
import "~/styles/globals.css";
import { DataProvider } from '~/context/globalState';
import { PdfFocusProvider } from "~/context/pdf";
import { CadDataProvider } from "~/context/cadData";
Amplify.configure({
Auth: {
Cognito: {
userPoolClientId: process.env.NEXT_PUBLIC_USER_POOL_CLIENT_ID ?? "",
userPoolId: process.env.NEXT_PUBLIC_USER_POOL_ID ?? "",
loginWith: {
oauth: {
domain: process.env.OAUTH_DOMAIN ?? "",
scopes: ['email'],
redirectSignIn: [process.env.NEXT_PUBLIC_REDIRECT_SIGN_IN ?? ""],
redirectSignOut: [process.env.NEXT_PUBLIC_REDIRECT_SIGN_OUT ?? ""],
// I tried to change responseType to "code", but it is not working for me
responseType: 'token',
},
username: false,
email: true,
phone: false,
}
}
}
});
const MyApp: AppType = ({ Component, pageProps }) => {
const queryClient = new QueryClient();
return (
<AuthWrapper>
<QueryClientProvider client={queryClient}>
<DataProvider>
<PdfFocusProvider>
<CadDataProvider>
<ToastContainer autoClose={1000} />
<NextTopLoader color="#120264" showSpinner height={3} />
<Component {...pageProps} />
</CadDataProvider>
</PdfFocusProvider>
</DataProvider>
</QueryClientProvider>
</AuthWrapper>
);
};
export default MyApp;
// apiClient.ts
import axios from "axios";
import { fetchAuthSession } from "aws-amplify/auth";
import { backendUrl } from "~/config";
const apiClient = axios.create({
baseURL: backendUrl,
responseType: "json",
});
apiClient.interceptors.request.use(
async (config) => {
try {
// When we get to this block tokens is always different from undefined, even if I pass {forceRefresh: true}
const session = await fetchAuthSession();
const accessToken = session.tokens?.accessToken?.toString();
if (accessToken) {
// here token is always different from undefined.
config.headers.Authorization = `Bearer ${accessToken}`;
console.log("Access Token set in request:", accessToken);
} else {
console.warn("No access token available");
}
} catch (error) {
console.error("Error fetching auth token", error);
}
return config;
},
(error) => Promise.reject(error)
);
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error?.response?.status === 401 && !originalRequest._retry) {
console.log("401 Unauthorized error, trying to refresh token");
// I am getting into this block when token expires
try {
const session = await fetchAuthSession({ forceRefresh: true });
// but here the tokens object is always undefined
const refreshToken = session.tokens?.accessToken?.toString();
if (refreshToken) {
console.log("Refresh token obtained:", refreshToken);
originalRequest.headers.Authorization = `Bearer ${refreshToken}`;
originalRequest._retry = true;
return apiClient(originalRequest);
} else {
console.warn("No refresh token available");
}
} catch (authError) {
console.error("Error refreshing auth token", authError);
return Promise.reject(authError);
}
}
return Promise.reject(error);
}
);
export default apiClient;
Log output
// Put your logs below this line
aws-exports.js
/* eslint-disable */ // WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.
const awsmobile = { "aws_project_region": "us-east-2" };
export default awsmobile;
Manual configuration
Amplify.configure({ Auth: { Cognito: { userPoolClientId: process.env.NEXT_PUBLIC_USER_POOL_CLIENT_ID ?? "", userPoolId: process.env.NEXT_PUBLIC_USER_POOL_ID ?? "", loginWith: { oauth: { domain: process.env.OAUTH_DOMAIN ?? "", scopes: ['email'], redirectSignIn: [process.env.NEXT_PUBLIC_REDIRECT_SIGN_IN ?? ""], redirectSignOut: [process.env.NEXT_PUBLIC_REDIRECT_SIGN_OUT ?? ""], responseType: 'token', }, username: false, email: true, phone: false, } } } });
Additional configuration
No response
Mobile Device
No response
Mobile Operating System
No response
Mobile Browser
No response
Mobile Browser Version
No response
Additional information and screenshots
No response
Hi @dayanapanova when fetchAuthSession() is called, if the locally persisted accessToken and idToken are expired, it will try to automatically refresh the tokens.
Have you changed access token expiration in the Amazon Cognito console.
There is a possibility that when you called fetchAuthSession in the Axios interceptor for outgoing request, the access token was about to expire and then expired when it reached to the service. You may check the expiry field of the accessToken before send the request out. If it's going to expire soon, you should try to refresh the token.
Hi @HuiSF, thank you for your reply, I made the following change in apiClient helper:
import axios from "axios";
import { fetchAuthSession } from "aws-amplify/auth";
import { backendUrl } from "~/config";
const apiClient = axios.create({
baseURL: backendUrl,
responseType: "json",
});
// Utility to check if the token is expiring soon
function isTokenExpiringSoon(expiryUnix: number): boolean {
// Get current time in Unix time and check if the token expires within the next 5 minutes
const currentTime = Math.floor(Date.now() / 1000); // Convert milliseconds to seconds
return expiryUnix - currentTime < 5 * 60; // 5 minutes buffer
}
apiClient.interceptors.request.use(
async (config) => {
try {
const session = await fetchAuthSession();
const accessToken = session.tokens?.accessToken?.toString();
const expiryTime = session.tokens?.accessToken?.payload?.exp;
if (accessToken && expiryTime && !isTokenExpiringSoon(expiryTime)) {
config.headers.Authorization = `Bearer ${accessToken}`;
console.log("Access Token set in request:", accessToken);
} else {
console.warn(
"Access token is expiring soon or not available. Refreshing token..."
);
// Force refresh the token
const refreshedSession = await fetchAuthSession({ forceRefresh: true });
console.log("refreshedSession", refreshedSession);
const refreshedToken = refreshedSession.tokens?.accessToken?.toString();
if (refreshedToken) {
config.headers.Authorization = `Bearer ${refreshedToken}`;
console.log("Refreshed Access Token set in request:", refreshedToken);
} else {
console.warn("Failed to refresh token");
}
}
} catch (error) {
console.error("Error fetching or refreshing auth token", error);
}
return config;
},
(error) => Promise.reject(error)
);
export default apiClient;
This is the error I am getting:
You will notice that I have one log in the console where I log console.log("Access Token set in request:", accessToken);. This works. But if I leave the browser for about 15 minutes, it goes into this case:
console.warn( "Access token is expiring soon or not available. Refreshing token..." );
And then I put a log console.log("refreshedSession", refreshedSession);, but it doesn't log in the console, it doesn't seem to get there.
@dayanapanova, can you clarify what minor version of v6 you're on or possibly provide package.json?
@cwomack Sure, I am provididing package.json. I am using "@aws-amplify/ui-react": "^6.1.6", and "aws-amplify": "^6.0.20",
{
"name": "llama-app-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"start": "next start"
},
"dependencies": {
"@aws-amplify/ui-react": "^6.1.6",
"@headlessui/react": "1.7.15",
"@heroicons/react": "2.0.18",
"@hookform/resolvers": "^3.3.4",
"@sentry/nextjs": "^7.57.0",
"@t3-oss/env-nextjs": "^0.3.1",
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/line-clamp": "^0.4.4",
"@wojtekmaj/react-hooks": "1.17.2",
"aws-amplify": "^6.0.20",
"axios": "^1.6.8",
"classnames": "^2.3.2",
"downshift": "^7.6.0",
"event-source-polyfill": "^1.0.31",
"fuse.js": "^6.6.2",
"lodash": "^4.17.21",
"lodash.debounce": "^4.0.8",
"md5": "2.3.0",
"next": "^13.4.2",
"nextjs-toploader": "^1.6.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-github-btn": "^1.4.0",
"react-hook-form": "^7.49.3",
"react-icons": "^4.10.1",
"react-intersection-observer": "9.5.1",
"react-pdf": "6.2.2",
"react-query": "^3.39.3",
"react-select": "^5.7.3",
"react-toastify": "^10.0.4",
"react-window": "1.8.9",
"uuid": "^9.0.0",
"yup": "^1.3.3",
"zod": "^3.21.4"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@types/eslint": "^8.37.0",
"@types/event-source-polyfill": "^1.0.5",
"@types/lodash": "^4.14.195",
"@types/lodash.debounce": "^4.0.7",
"@types/md5": "^2.3.2",
"@types/node": "^18.16.0",
"@types/prettier": "^2.7.2",
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"@types/react-window": "^1.8.5",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"autoprefixer": "^10.4.14",
"eslint": "^8.43.0",
"eslint-config-next": "^13.4.2",
"eslint-config-prettier": "^8.8.0",
"postcss": "^8.4.21",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.2.8",
"tailwindcss": "^3.3.0",
"typescript": "^5.0.4"
},
"ct3aMetadata": {
"initVersion": "7.13.1"
}
}
@dayanapanova, just want to call out that "code grant" is the recommended approach (see here for more as to why). I know you mentioned in a comment within your config that you attempted to change your responseType to code but had issues with it. Can you give a little more context as to the errors your experienced or what happened when you did? We'd like to help you make the transition to code grant as best we can!
yes I understand, the problem is the same. We are reproducing the problem - the application is open without interaction in the browser for about 15 minutes. That is, no requests are executed within 15 minutes. Then I get a network error where the request returns a 401 because I don't have the authorization header set. I think I go in here refreshedToken is undefined and that's why I don't have any headers set:
// Force refresh the token
const refreshedSession = await fetchAuthSession({ forceRefresh: true });
console.log("refreshedSession", refreshedSession);
const refreshedToken = refreshedSession.tokens?.accessToken?.toString();
The interesting thing is that initial fetchAuthSession works and returns tokens, but in this case, which I am describing to you, it does not return them to me. Here are some screenshot of errors while I have responseType set to code.
this is my apiClient.ts
import axios from "axios";
import { fetchAuthSession } from "aws-amplify/auth";
import { backendUrl } from "~/config";
const apiClient = axios.create({
baseURL: backendUrl,
responseType: "json",
});
// Utility to check if the token is expiring soon
function isTokenExpiringSoon(expiryUnix: number): boolean {
// Get current time in Unix time and check if the token expires within the next 5 minutes
const currentTime = Math.floor(Date.now() / 1000); // Convert milliseconds to seconds
return expiryUnix - currentTime < 5 * 60; // 5 minutes buffer
}
apiClient.interceptors.request.use(
async (config) => {
try {
const session = await fetchAuthSession();
const accessToken = session.tokens?.accessToken?.toString();
const expiryTime = session.tokens?.accessToken?.payload?.exp;
if (accessToken && expiryTime && !isTokenExpiringSoon(expiryTime)) {
config.headers.Authorization = `Bearer ${accessToken}`;
console.log("Access Token set in request:", accessToken);
} else {
console.warn(
"Access token is expiring soon or not available. Refreshing token..."
);
// Force refresh the token
const refreshedSession = await fetchAuthSession({ forceRefresh: true });
console.log("refreshedSession", refreshedSession);
const refreshedToken = refreshedSession.tokens?.accessToken?.toString();
if (refreshedToken) {
config.headers.Authorization = `Bearer ${refreshedToken}`;
console.log("Refreshed Access Token set in request:", refreshedToken);
} else {
console.warn("Failed to refresh token");
}
}
} catch (error) {
console.error("Error fetching or refreshing auth token", error);
}
return config;
},
(error) => Promise.reject(error)
);
export default apiClient;
And this is the Amplify config object:
Amplify.configure({
Auth: {
Cognito: {
userPoolClientId: process.env.NEXT_PUBLIC_USER_POOL_CLIENT_ID ?? "",
userPoolId: process.env.NEXT_PUBLIC_USER_POOL_ID ?? "",
loginWith: {
oauth: {
domain: process.env.OAUTH_DOMAIN ?? "",
scopes: ['email'],
redirectSignIn: [process.env.NEXT_PUBLIC_REDIRECT_SIGN_IN ?? ""],
redirectSignOut: [process.env.NEXT_PUBLIC_REDIRECT_SIGN_OUT ?? ""],
// responseType: 'token',
responseType: "code"
},
username: false,
email: true,
phone: false,
}
}
}
});
hello @dayanapanova , we will try to reproduce this issue and get back to you with next steps
@dayanapanova can you log out the underlyingError you are getting from fetchAuthSession . e.g error.underylingError ?
Yes, this is the error.
import axios from "axios";
import { fetchAuthSession } from "aws-amplify/auth";
import { backendUrl } from "~/config";
const apiClient = axios.create({
baseURL: backendUrl,
responseType: "json",
});
// Utility to check if the token is expiring soon
function isTokenExpiringSoon(expiryUnix: number): boolean {
// Get current time in Unix time and check if the token expires within the next 5 minutes
const currentTime = Math.floor(Date.now() / 1000); // Convert milliseconds to seconds
return expiryUnix - currentTime < 5 * 60; // 5 minutes buffer
}
apiClient.interceptors.request.use(
async (config) => {
try {
const session = await fetchAuthSession();
const accessToken = session.tokens?.accessToken?.toString();
const expiryTime = session.tokens?.accessToken?.payload?.exp;
if (accessToken && expiryTime && !isTokenExpiringSoon(expiryTime)) {
config.headers.Authorization = `Bearer ${accessToken}`;
console.log("Access Token set in request:", accessToken);
} else {
console.warn(
"Access token is expiring soon or not available. Refreshing token..."
);
// Force refresh the token
const refreshedSession = await fetchAuthSession({ forceRefresh: true });
console.log("refreshedSession", refreshedSession);
const refreshedToken = refreshedSession.tokens?.accessToken?.toString();
if (refreshedToken) {
config.headers.Authorization = `Bearer ${refreshedToken}`;
console.log("Refreshed Access Token set in request:", refreshedToken);
} else {
console.warn("Failed to refresh token");
}
}
} catch (error) {
console.error("Error fetching or refreshing auth token", error);
if (error?.underlyingError) {
console.log("Underlying Error:", error?.underlyingError);
}
}
return config;
},
(error) => Promise.reject(error)
);
export default apiClient;
@israx @cwomack hi, any updates on this topic ?
hello @dayanapanova . Is it possible for you to provide a reproduction app to help us reproduce this issue ?
hello @dayanapanova . After digging into the issue I was able to see that the underlying error is raised by a TypeError: Failed to fetch error coming from the fetch API, and that is used to make the Amplify API requests. This error can be caused due to:
- Network errors
- Malformed request due to a incomplete URL , headers, etc
- CORS
Based on the ticket description, this issue is mainly happening when the user unattended the app for a given period of time. So it could be that when the app starts making fetch requests, the network is either lost or trying to connect.
If that is the case, then something that can help is adding some retry logic until the connection is back.
hey @israx even though I added logic to retry on network problem I still get this error. I am also attaching the changes I made. Is it possible the issue to becoming from the Cognito settings?
import axios from "axios";
import axiosRetry from "axios-retry";
import { fetchAuthSession } from "aws-amplify/auth";
import { backendUrl } from "~/config";
const apiClient = axios.create({
baseURL: backendUrl,
responseType: "json",
});
// Utility to check if the token is expiring soon
function isTokenExpiringSoon(expiryUnix: number): boolean {
const currentTime = Math.floor(Date.now() / 1000);
return expiryUnix - currentTime < 5 * 60; // 5 minutes buffer
}
async function getAuthorizationHeader() {
const session = await fetchAuthSession();
const accessToken = session.tokens?.accessToken?.toString();
const expiryTime = session.tokens?.accessToken?.payload?.exp;
if (accessToken && expiryTime && !isTokenExpiringSoon(expiryTime)) {
return `Bearer ${accessToken}`;
} else {
console.warn(
"Access token is expiring soon or not available. Refreshing token..."
);
const refreshedSession = await fetchAuthSession({ forceRefresh: true });
const refreshedToken = refreshedSession.tokens?.accessToken?.toString();
if (refreshedToken) {
return `Bearer ${refreshedToken}`;
} else {
console.warn("Failed to refresh token");
throw new Error("Failed to refresh token");
}
}
}
apiClient.interceptors.request.use(
async (config) => {
try {
const authHeader = await getAuthorizationHeader();
config.headers.Authorization = authHeader;
} catch (error) {
console.error("Error fetching or refreshing auth token", error);
throw error;
}
return config;
}
(error) => Promise.reject(error)
);
// Adding retry logic to handle network errors and 401 status
axiosRetry(apiClient, {
retries: 3, // Number of retries
retryDelay: (retryCount) => {
console.log(`Retry attempt: ${retryCount}`);
return retryCount * 1000; // Time between retries in milliseconds
},
retryCondition: async (error) => {
console.log("Retry condition check for error:", error);
if (error.response?.status === 401) {
try {
const authHeader = await getAuthorizationHeader();
error.config.headers.Authorization = authHeader;
console.log("Retrying with refreshed token:", authHeader);
return true;
} catch (refreshError) {
console.error("Token refresh failed during retry logic", refreshError);
return false;
}
}
return (
axiosRetry.isNetworkError(error) ||
axiosRetry.isRetryableError(error) ||
error.response?.status >= 500
);
},
});
export default apiClient;
@israx Now, meanwhile, I'm working on another branch on another functionality and I had left the developer tools open and noticed the following logs in the console.
This means that refresh auth session works, but maybe under certain circumstances.(I haven't left the browser idle for 15 or 20 minutes like in previous reproductions, but we got into the token exparation block). Here on this branch I have not added a retry method in the logic. This probably means that the problem does not come from Amplify or the Cognito...
import axios from "axios";
import { fetchAuthSession } from "aws-amplify/auth";
import { backendUrl } from "~/config";
const apiClient = axios.create({
baseURL: backendUrl,
responseType: "json",
});
// Utility to check if the token is expiring soon
function isTokenExpiringSoon(expiryUnix: number): boolean {
// Get current time in Unix time and check if the token expires within the next 5 minutes
const currentTime = Math.floor(Date.now() / 1000); // Convert milliseconds to seconds
return expiryUnix - currentTime < 5 * 60; // 5 minutes buffer
}
apiClient.interceptors.request.use(
async (config) => {
try {
const session = await fetchAuthSession();
const accessToken = session.tokens?.accessToken?.toString();
const expiryTime = session.tokens?.accessToken?.payload?.exp;
if (accessToken && expiryTime && !isTokenExpiringSoon(expiryTime)) {
config.headers.Authorization = `Bearer ${accessToken}`;
} else {
console.warn(
"Access token is expiring soon or not available. Refreshing token..."
);
// Force refresh the token
const refreshedSession = await fetchAuthSession({ forceRefresh: true });
console.log("refreshedSession", refreshedSession);
const refreshedToken = refreshedSession.tokens?.accessToken?.toString();
if (refreshedToken) {
config.headers.Authorization = `Bearer ${refreshedToken}`;
console.log("Refreshed Access Token set in request:", refreshedToken);
} else {
console.warn("Failed to refresh token");
}
}
} catch (error) {
console.error("Error fetching or refreshing auth token", error);
}
return config;
},
(error) => Promise.reject(error)
);
export default apiClient;
@israx @HuiSF @cwomack @robbevan I think that we can close this issue. The refresh token is working as expected. The problem was that I have a component in Next.js where I am overriding the global fetch (the component is using a JS library that is not supporting custom headers and one approach is override of global fetch). This is breaking the refresh token call. Removing the logic for overrding global fetch and refresh token is applied correctly.
Thank you for your time!
Glad to hear that you were able to resolve this issue @dayanapanova and thanks for the follow up!