feat(ui): refreshToken implementation (#8864)

This commit is contained in:
Alejandro Bailo
2025-10-10 11:02:10 +02:00
committed by GitHub
parent ef60ea99c3
commit 046baa8eb9
10 changed files with 618 additions and 117 deletions

View File

@@ -12,6 +12,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775)
- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864)
### 🔄 Changed

View File

@@ -1,28 +1,132 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import NextAuth, { type NextAuthConfig, User } from "next-auth";
import { jwtDecode, type JwtPayload } from "jwt-decode";
import NextAuth, {
type DefaultSession,
type NextAuthConfig,
type Session,
User,
} from "next-auth";
import type { JWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { getToken, getUserByMe } from "./actions/auth";
import { apiBaseUrl } from "./lib";
import type { RolePermissionAttributes } from "./types/users";
interface CustomJwtPayload extends JwtPayload {
user_id: string;
tenant_id: string;
}
const refreshAccessToken = async (token: JwtPayload) => {
type DefaultSessionUser = NonNullable<DefaultSession["user"]>;
type TokenUser = DefaultSessionUser & {
companyName?: string;
dateJoined?: string;
permissions: RolePermissionAttributes;
};
type AuthToken = JWT & {
accessToken?: string;
refreshToken?: string;
accessTokenExpires?: number;
user_id?: string;
tenant_id?: string;
user?: TokenUser;
error?: string;
};
type ExtendedSession = Session & {
user?: TokenUser;
userId?: string;
tenantId?: string;
accessToken?: string;
refreshToken?: string;
error?: string;
};
const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_billing: false,
unlimited_visibility: false,
};
type TokenUserInput = Partial<TokenUser> & { company?: string };
const toTokenUser = (user?: TokenUserInput): TokenUser =>
({
name: user?.name ?? undefined,
email: user?.email ?? undefined,
companyName: user?.companyName ?? user?.company,
dateJoined: user?.dateJoined,
permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS },
}) as TokenUser;
type UserMeResponse = Awaited<ReturnType<typeof getUserByMe>>;
const tokenUserFromApi = (user: UserMeResponse) =>
toTokenUser({
name: user.name,
email: user.email,
companyName: user.company,
dateJoined: user.dateJoined,
permissions: user.permissions,
});
const applyDecodedClaims = (
target: AuthToken,
accessToken?: string,
logContext = "access token",
) => {
if (!accessToken) return;
try {
const decodedToken = jwtDecode<CustomJwtPayload>(accessToken);
target.accessTokenExpires = decodedToken.exp
? decodedToken.exp * 1000
: target.accessTokenExpires;
target.user_id = decodedToken.user_id ?? target.user_id;
target.tenant_id = decodedToken.tenant_id ?? target.tenant_id;
} catch (decodeError) {
// eslint-disable-next-line no-console
console.warn(`Unable to decode ${logContext}`, decodeError);
}
};
const refreshTokenPromises = new Map<string, Promise<AuthToken>>();
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
const refreshToken = token.refreshToken;
if (!refreshToken) {
return {
...token,
error: "MissingRefreshToken",
};
}
const existingPromise = refreshTokenPromises.get(refreshToken);
if (existingPromise) {
return existingPromise;
}
const url = new URL(`${apiBaseUrl}/tokens/refresh`);
const bodyData = {
data: {
type: "tokens-refresh",
attributes: {
refresh: (token as any).refreshToken,
refresh: refreshToken,
},
},
};
const refreshPromise = (async () => {
try {
const response = await fetch(url, {
method: "POST",
@@ -33,24 +137,64 @@ const refreshAccessToken = async (token: JwtPayload) => {
body: JSON.stringify(bodyData),
});
const payload = await response.json().catch(() => undefined);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const newTokens = await response.json();
const detail = payload?.errors?.[0]?.detail;
// eslint-disable-next-line no-console
console.warn(
"Failed to refresh access token:",
detail || `HTTP error ${response.status}`,
);
return {
...token,
accessToken: newTokens.data.attributes.access,
refreshToken: newTokens.data.attributes.refresh,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error refreshing access token:", error);
return {
error: "RefreshAccessTokenError",
};
}
const newAccessToken = payload?.data?.attributes?.access as
| string
| undefined;
const nextRefreshToken =
(payload?.data?.attributes?.refresh as string | undefined) ??
refreshToken;
if (!newAccessToken) {
// eslint-disable-next-line no-console
console.warn("Missing access token in refresh response");
return {
...token,
error: "RefreshAccessTokenError",
};
}
const nextToken: AuthToken = {
...token,
accessToken: newAccessToken,
refreshToken: nextRefreshToken,
error: undefined,
};
applyDecodedClaims(nextToken, newAccessToken, "refreshed access token");
return nextToken;
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Error refreshing access token:", error);
return {
...token,
error: "RefreshAccessTokenError",
};
}
})();
refreshTokenPromises.set(refreshToken, refreshPromise);
try {
return await refreshPromise;
} finally {
refreshTokenPromises.delete(refreshToken);
}
};
export const authConfig = {
@@ -90,13 +234,7 @@ export const authConfig = {
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
const user = {
name: userMeResponse.name,
email: userMeResponse.email,
company: userMeResponse?.company,
dateJoined: userMeResponse.dateJoined,
permissions: userMeResponse.permissions,
};
const user = tokenUserFromApi(userMeResponse);
return {
...user,
@@ -122,14 +260,7 @@ export const authConfig = {
try {
const userMeResponse = await getUserByMe(accessToken as string);
const user = {
name: userMeResponse.name,
email: userMeResponse.email,
company: userMeResponse?.company,
dateJoined: userMeResponse.dateJoined,
permissions: userMeResponse.permissions,
};
const user = tokenUserFromApi(userMeResponse);
return {
...user,
@@ -137,7 +268,6 @@ export const authConfig = {
refreshToken: credentials.refreshToken,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error in authorize:", error);
return null;
}
@@ -162,74 +292,68 @@ export const authConfig = {
},
jwt: async ({ token, account, user }) => {
if (token?.accessToken) {
const decodedToken = jwtDecode(
token.accessToken as string,
) as CustomJwtPayload;
// eslint-disable-next-line no-console
// console.log("decodedToken", decodedToken);
token.accessTokenExpires = (decodedToken.exp as number) * 1000;
token.user_id = decodedToken.user_id;
token.tenant_id = decodedToken.tenant_id;
}
const authToken = token as AuthToken;
const userInfo = {
name: user?.name,
companyName: user?.company,
email: user?.email,
dateJoined: user?.dateJoined,
permissions: user?.permissions || {
manage_users: false,
manage_account: false,
manage_providers: false,
manage_scans: false,
manage_integrations: false,
manage_billing: false,
unlimited_visibility: false,
},
};
applyDecodedClaims(authToken, authToken.accessToken);
if (account && user) {
return {
...token,
userId: token.user_id,
tenantId: token.tenant_id,
accessToken: (user as User & { accessToken: JwtPayload }).accessToken,
refreshToken: (user as User & { refreshToken: JwtPayload })
.refreshToken,
user: userInfo,
const signedInUser = user as User &
TokenUserInput & {
accessToken: string;
refreshToken: string;
};
const nextAuthToken: AuthToken = {
...authToken,
accessToken: signedInUser.accessToken,
refreshToken: signedInUser.refreshToken,
user: toTokenUser(signedInUser),
error: undefined,
};
applyDecodedClaims(
nextAuthToken,
signedInUser.accessToken,
"access token on sign-in",
);
return nextAuthToken;
}
// eslint-disable-next-line no-console
// console.log(
// "Access token expires",
// token.accessTokenExpires,
// new Date(Number(token.accessTokenExpires)),
// );
// If the access token is not expired, return the token
if (
typeof token.accessTokenExpires === "number" &&
Date.now() < token.accessTokenExpires
)
return token;
typeof authToken.accessTokenExpires === "number" &&
Date.now() < authToken.accessTokenExpires
) {
return authToken;
}
// If the access token is expired, try to refresh it
return refreshAccessToken(token as JwtPayload);
return refreshAccessToken(authToken);
},
session: async ({ session, token }) => {
if (token) {
session.userId = token?.user_id as string;
session.tenantId = token?.tenant_id as string;
session.accessToken = token?.accessToken as string;
session.refreshToken = token?.refreshToken as string;
session.user = token.user as any;
const authToken = token as AuthToken;
const nextSession = { ...session } as ExtendedSession;
if (authToken?.error) {
nextSession.error = authToken.error;
nextSession.user = undefined;
nextSession.userId = undefined;
nextSession.tenantId = undefined;
nextSession.accessToken = undefined;
nextSession.refreshToken = undefined;
return nextSession;
}
// console.log("session", session);
return session;
nextSession.error = undefined;
nextSession.userId = authToken.user_id ?? nextSession.userId;
nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId;
nextSession.accessToken =
authToken.accessToken ?? nextSession.accessToken;
nextSession.refreshToken =
authToken.refreshToken ?? nextSession.refreshToken;
nextSession.user = authToken.user ?? nextSession.user;
return nextSession;
},
},
} satisfies NextAuthConfig;

View File

@@ -35,6 +35,7 @@ export const SignInForm = ({
useEffect(() => {
const samlError = searchParams.get("sso_saml_failed");
const sessionError = searchParams.get("error");
if (samlError) {
setTimeout(() => {
@@ -46,6 +47,37 @@ export const SignInForm = ({
});
}, 100);
}
if (sessionError) {
setTimeout(() => {
const errorMessages: Record<
string,
{ title: string; description: string }
> = {
RefreshAccessTokenError: {
title: "Session Expired",
description:
"Your session has expired. Please sign in again to continue.",
},
MissingRefreshToken: {
title: "Session Error",
description:
"There was a problem with your session. Please sign in again.",
},
};
const errorConfig = errorMessages[sessionError] || {
title: "Authentication Error",
description: "Please sign in again to continue.",
};
toast({
variant: "destructive",
title: errorConfig.title,
description: errorConfig.description,
});
}, 100);
}
}, [searchParams, toast]);
const form = useForm<SignInFormData>({

View File

@@ -18,9 +18,20 @@ const isPublicRoute = (pathname: string): boolean => {
export default auth((req: NextRequest & { auth: any }) => {
const { pathname } = req.nextUrl;
const user = req.auth?.user;
const sessionError = req.auth?.error;
// If there's a session error (e.g., RefreshAccessTokenError), redirect to login with error info
if (sessionError && !isPublicRoute(pathname)) {
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("error", sessionError);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
if (!user && !isPublicRoute(pathname)) {
return NextResponse.redirect(new URL("/sign-in", req.url));
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
if (user?.permissions) {

23
ui/nextauth.d.ts vendored
View File

@@ -1,4 +1,4 @@
import { DefaultSession } from "next-auth";
import type { DefaultSession, User as NextAuthUser } from "next-auth";
import { RolePermissionAttributes } from "./types/users";
@@ -11,17 +11,18 @@ declare module "next-auth" {
permissions?: RolePermissionAttributes;
}
interface Session extends DefaultSession {
user: {
name: string;
email: string;
type SessionUser = NonNullable<DefaultSession["user"]> & {
companyName?: string;
dateJoined: string;
dateJoined?: string;
permissions: RolePermissionAttributes;
} & DefaultSession["user"];
userId: string;
tenantId: string;
accessToken: string;
refreshToken: string;
};
interface Session extends DefaultSession {
user?: SessionUser;
userId?: string;
tenantId?: string;
accessToken?: string;
refreshToken?: string;
error?: string;
}
}

View File

@@ -137,8 +137,8 @@ test.describe("Session Persistence", () => {
}) => {
// Try to access protected route without login
await page.goto(URLS.DASHBOARD);
// Should be redirected to login page
await expect(page).toHaveURL(URLS.LOGIN);
// Should be redirected to login page (may include callbackUrl)
await expect(page).toHaveURL(/\/sign-in/);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
});
@@ -154,7 +154,7 @@ test.describe("Session Persistence", () => {
// Verify cannot access protected route after logout
await page.goto(URLS.DASHBOARD);
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page).toHaveURL(/\/sign-in/);
});
test("should handle session timeout gracefully", async ({ browser }) => {
@@ -184,8 +184,8 @@ test.describe("Session Persistence", () => {
waitUntil: "networkidle",
});
// Should be redirected to login since this context has no auth
await expect(unauthPage).toHaveURL(URLS.LOGIN);
// Should be redirected to login since this context has no auth (may include callbackUrl)
await expect(unauthPage).toHaveURL(/\/sign-in/);
// Verify session is null in unauthenticated context
const unauthResponse = await unauthPage.request.get("/api/auth/session");

View File

@@ -0,0 +1,92 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
login,
verifySuccessfulLogin,
verifySessionValid,
TEST_CREDENTIALS,
URLS,
} from "./helpers";
test.describe("Middleware Error Handling", () => {
test("should allow access to public routes without session", async ({
page,
context,
}) => {
// Ensure no session exists
await context.clearCookies();
// Try to access login page (public route)
await page.goto(URLS.LOGIN);
await expect(page).toHaveURL(URLS.LOGIN);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
// Try to access sign-up page (public route)
await page.goto(URLS.SIGNUP);
await expect(page).toHaveURL(URLS.SIGNUP);
});
test("should maintain protection after session error", async ({
page,
context,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Navigate to a protected page
await page.goto("/providers");
await expect(page).toHaveURL("/providers");
// Simulate session error by corrupting cookie
const cookies = await context.cookies();
const sessionCookie = cookies.find((c) =>
c.name.includes("authjs.session-token"),
);
if (sessionCookie) {
await context.clearCookies();
await context.addCookies([
{
...sessionCookie,
value: "invalid-session-token",
},
]);
// Try to navigate to another protected page
await page.goto("/scans", { waitUntil: "networkidle" });
// Should be redirected to login (may include callbackUrl)
await expect(page).toHaveURL(/\/sign-in/);
}
});
test("should handle permission-based redirects", async ({ page }) => {
// Login with valid credentials
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Get user permissions using helper
const session = await verifySessionValid(page);
const permissions = session.user.permissions;
// Test billing route if user doesn't have permission
if (!permissions.manage_billing) {
await page.goto("/billing", { waitUntil: "networkidle" });
// Should be redirected to profile (as per middleware logic)
await expect(page).toHaveURL("/profile");
}
// Test integrations route if user doesn't have permission
if (!permissions.manage_integrations) {
await page.goto("/integrations", { waitUntil: "networkidle" });
// Should be redirected to profile (as per middleware logic)
await expect(page).toHaveURL("/profile");
}
});
});

View File

@@ -0,0 +1,124 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
login,
verifySuccessfulLogin,
getSession,
verifySessionValid,
TEST_CREDENTIALS,
URLS,
} from "./helpers";
test.describe("Token Refresh Flow", () => {
test("should refresh access token when expired", async ({ page }) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Get initial session using helper
const initialSession = await verifySessionValid(page);
const initialAccessToken = initialSession.accessToken;
// Wait for some time to allow token to potentially expire
// In a real scenario, you might want to manipulate the token expiry
await page.waitForTimeout(2000);
// Make a request that requires authentication
// This should trigger token refresh if needed
await page.reload();
await page.waitForLoadState("networkidle");
// Verify we're still authenticated
await expect(page).toHaveURL(URLS.DASHBOARD);
// Get session after potential refresh using helper
const refreshedSession = await verifySessionValid(page);
// User data should be maintained
expect(refreshedSession.user.email).toBe(initialSession.user.email);
expect(refreshedSession.userId).toBe(initialSession.userId);
expect(refreshedSession.tenantId).toBe(initialSession.tenantId);
});
test("should handle concurrent requests with token refresh", async ({
page,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Make multiple concurrent requests to the API
const requests = Array(5)
.fill(null)
.map(() => page.request.get("/api/auth/session"));
const responses = await Promise.all(requests);
// All requests should succeed - verify using helper
for (const response of responses) {
expect(response.ok()).toBeTruthy();
const session = await response.json();
// Validate session structure
expect(session).toBeTruthy();
expect(session.user).toBeTruthy();
expect(session.accessToken).toBeTruthy();
expect(session.refreshToken).toBeTruthy();
expect(session.error).toBeUndefined();
}
});
test("should preserve user permissions after token refresh", async ({
page,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Get initial session with permissions using helper
const initialSession = await verifySessionValid(page);
const initialPermissions = initialSession.user.permissions;
// Reload page to potentially trigger token refresh
await page.reload();
await page.waitForLoadState("networkidle");
// Get session after reload using helper
const refreshedSession = await verifySessionValid(page);
// Permissions should be preserved
expect(refreshedSession.user.permissions).toEqual(initialPermissions);
// All user data should be preserved
expect(refreshedSession.user.email).toBe(initialSession.user.email);
expect(refreshedSession.user.name).toBe(initialSession.user.name);
expect(refreshedSession.user.companyName).toBe(
initialSession.user.companyName,
);
});
test("should clear session when cookies are removed", async ({
page,
context,
}) => {
// Login
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Verify session is valid using helper
await verifySessionValid(page);
// Clear all cookies to simulate complete session expiry
await context.clearCookies();
// Verify session is null after clearing cookies
const expiredSession = await getSession(page);
expect(expiredSession).toBeNull();
// Note: Middleware redirect behavior is tested in auth-middleware-error.spec.ts
});
});

View File

@@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
import {
goToLogin,
login,
verifySuccessfulLogin,
TEST_CREDENTIALS,
URLS,
} from "./helpers";
test.describe("Session Error Messages", () => {
test("should show RefreshAccessTokenError message", async ({ page }) => {
// Navigate to sign-in with RefreshAccessTokenError query param
await page.goto("/sign-in?error=RefreshAccessTokenError");
// Wait for toast notification
await page.waitForTimeout(200);
// Verify error toast appears
const toast = page.locator('[role="status"], [role="alert"]').first();
const isVisible = await toast.isVisible().catch(() => false);
if (isVisible) {
const text = await toast.textContent();
expect(text).toContain("Session Expired");
expect(text).toContain("Please sign in again");
}
// Verify sign-in form is displayed
await expect(page.getByLabel("Email")).toBeVisible();
await expect(page.getByLabel("Password")).toBeVisible();
});
test("should show MissingRefreshToken error message", async ({ page }) => {
// Navigate to sign-in with MissingRefreshToken query param
await page.goto("/sign-in?error=MissingRefreshToken");
// Wait for toast notification
await page.waitForTimeout(200);
// Verify error toast appears
const toast = page.locator('[role="status"], [role="alert"]').first();
const isVisible = await toast.isVisible().catch(() => false);
if (isVisible) {
const text = await toast.textContent();
expect(text).toContain("Session Error");
}
// Verify sign-in form is displayed
await expect(page.getByLabel("Email")).toBeVisible();
});
test("should show generic error for unknown error types", async ({ page }) => {
// Navigate to sign-in with unknown error type
await page.goto("/sign-in?error=UnknownError");
// Wait for toast notification
await page.waitForTimeout(200);
// Verify generic error toast appears
const toast = page.locator('[role="status"], [role="alert"]').first();
const isVisible = await toast.isVisible().catch(() => false);
if (isVisible) {
const text = await toast.textContent();
expect(text).toContain("Authentication Error");
expect(text).toContain("Please sign in again");
}
});
test("should include callbackUrl in redirect", async ({
page,
context,
}) => {
// Login first
await goToLogin(page);
await login(page, TEST_CREDENTIALS.VALID);
await verifySuccessfulLogin(page);
// Navigate to a specific page
await page.goto("/scans");
await page.waitForLoadState("networkidle");
// Clear cookies to simulate session expiry
await context.clearCookies();
// Try to navigate to a different protected route
await page.goto("/providers");
// Should be redirected to login with callbackUrl
await expect(page).toHaveURL(/\/sign-in\?.*callbackUrl=/);
// Verify callbackUrl contains the attempted route
const url = new URL(page.url());
const callbackUrl = url.searchParams.get("callbackUrl");
expect(callbackUrl).toBe("/providers");
});
});

View File

@@ -100,7 +100,7 @@ export async function logout(page: Page) {
}
export async function verifyLogoutSuccess(page: Page) {
await expect(page).toHaveURL("/sign-in");
await expect(page).toHaveURL(/\/sign-in/);
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
}
@@ -137,3 +137,17 @@ export async function waitForPageLoad(page: Page) {
export async function verifyDashboardRoute(page: Page) {
await expect(page).toHaveURL("/");
}
export async function getSession(page: Page) {
const response = await page.request.get("/api/auth/session");
return response.json();
}
export async function verifySessionValid(page: Page) {
const session = await getSession(page);
expect(session).toBeTruthy();
expect(session.user).toBeTruthy();
expect(session.accessToken).toBeTruthy();
expect(session.refreshToken).toBeTruthy();
return session;
}