mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(ui): refreshToken implementation (#8864)
This commit is contained in:
@@ -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)
|
- 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)
|
- 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)
|
- 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
|
### 🔄 Changed
|
||||||
|
|
||||||
|
|||||||
+222
-98
@@ -1,55 +1,199 @@
|
|||||||
import { jwtDecode, JwtPayload } from "jwt-decode";
|
import { jwtDecode, type JwtPayload } from "jwt-decode";
|
||||||
import NextAuth, { type NextAuthConfig, User } from "next-auth";
|
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 Credentials from "next-auth/providers/credentials";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getToken, getUserByMe } from "./actions/auth";
|
import { getToken, getUserByMe } from "./actions/auth";
|
||||||
import { apiBaseUrl } from "./lib";
|
import { apiBaseUrl } from "./lib";
|
||||||
|
import type { RolePermissionAttributes } from "./types/users";
|
||||||
|
|
||||||
interface CustomJwtPayload extends JwtPayload {
|
interface CustomJwtPayload extends JwtPayload {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
tenant_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 url = new URL(`${apiBaseUrl}/tokens/refresh`);
|
||||||
|
|
||||||
const bodyData = {
|
const bodyData = {
|
||||||
data: {
|
data: {
|
||||||
type: "tokens-refresh",
|
type: "tokens-refresh",
|
||||||
attributes: {
|
attributes: {
|
||||||
refresh: (token as any).refreshToken,
|
refresh: refreshToken,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const refreshPromise = (async () => {
|
||||||
const response = await fetch(url, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(url, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/vnd.api+json",
|
headers: {
|
||||||
Accept: "application/vnd.api+json",
|
"Content-Type": "application/vnd.api+json",
|
||||||
},
|
Accept: "application/vnd.api+json",
|
||||||
body: JSON.stringify(bodyData),
|
},
|
||||||
});
|
body: JSON.stringify(bodyData),
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const payload = await response.json().catch(() => undefined);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
|
if (!response.ok) {
|
||||||
|
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,
|
||||||
|
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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const newTokens = await response.json();
|
refreshTokenPromises.set(refreshToken, refreshPromise);
|
||||||
|
|
||||||
return {
|
try {
|
||||||
...token,
|
return await refreshPromise;
|
||||||
accessToken: newTokens.data.attributes.access,
|
} finally {
|
||||||
refreshToken: newTokens.data.attributes.refresh,
|
refreshTokenPromises.delete(refreshToken);
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Error refreshing access token:", error);
|
|
||||||
return {
|
|
||||||
error: "RefreshAccessTokenError",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,13 +234,7 @@ export const authConfig = {
|
|||||||
|
|
||||||
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
|
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
|
||||||
|
|
||||||
const user = {
|
const user = tokenUserFromApi(userMeResponse);
|
||||||
name: userMeResponse.name,
|
|
||||||
email: userMeResponse.email,
|
|
||||||
company: userMeResponse?.company,
|
|
||||||
dateJoined: userMeResponse.dateJoined,
|
|
||||||
permissions: userMeResponse.permissions,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
@@ -122,14 +260,7 @@ export const authConfig = {
|
|||||||
try {
|
try {
|
||||||
const userMeResponse = await getUserByMe(accessToken as string);
|
const userMeResponse = await getUserByMe(accessToken as string);
|
||||||
|
|
||||||
const user = {
|
const user = tokenUserFromApi(userMeResponse);
|
||||||
name: userMeResponse.name,
|
|
||||||
email: userMeResponse.email,
|
|
||||||
company: userMeResponse?.company,
|
|
||||||
dateJoined: userMeResponse.dateJoined,
|
|
||||||
|
|
||||||
permissions: userMeResponse.permissions,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
@@ -137,7 +268,6 @@ export const authConfig = {
|
|||||||
refreshToken: credentials.refreshToken,
|
refreshToken: credentials.refreshToken,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Error in authorize:", error);
|
console.error("Error in authorize:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -162,74 +292,68 @@ export const authConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
jwt: async ({ token, account, user }) => {
|
jwt: async ({ token, account, user }) => {
|
||||||
if (token?.accessToken) {
|
const authToken = token as AuthToken;
|
||||||
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 userInfo = {
|
applyDecodedClaims(authToken, authToken.accessToken);
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (account && user) {
|
if (account && user) {
|
||||||
return {
|
const signedInUser = user as User &
|
||||||
...token,
|
TokenUserInput & {
|
||||||
userId: token.user_id,
|
accessToken: string;
|
||||||
tenantId: token.tenant_id,
|
refreshToken: string;
|
||||||
accessToken: (user as User & { accessToken: JwtPayload }).accessToken,
|
};
|
||||||
refreshToken: (user as User & { refreshToken: JwtPayload })
|
|
||||||
.refreshToken,
|
const nextAuthToken: AuthToken = {
|
||||||
user: userInfo,
|
...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 (
|
if (
|
||||||
typeof token.accessTokenExpires === "number" &&
|
typeof authToken.accessTokenExpires === "number" &&
|
||||||
Date.now() < token.accessTokenExpires
|
Date.now() < authToken.accessTokenExpires
|
||||||
)
|
) {
|
||||||
return token;
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
// If the access token is expired, try to refresh it
|
return refreshAccessToken(authToken);
|
||||||
return refreshAccessToken(token as JwtPayload);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
session: async ({ session, token }) => {
|
session: async ({ session, token }) => {
|
||||||
if (token) {
|
const authToken = token as AuthToken;
|
||||||
session.userId = token?.user_id as string;
|
const nextSession = { ...session } as ExtendedSession;
|
||||||
session.tenantId = token?.tenant_id as string;
|
|
||||||
session.accessToken = token?.accessToken as string;
|
if (authToken?.error) {
|
||||||
session.refreshToken = token?.refreshToken as string;
|
nextSession.error = authToken.error;
|
||||||
session.user = token.user as any;
|
nextSession.user = undefined;
|
||||||
|
nextSession.userId = undefined;
|
||||||
|
nextSession.tenantId = undefined;
|
||||||
|
nextSession.accessToken = undefined;
|
||||||
|
nextSession.refreshToken = undefined;
|
||||||
|
return nextSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("session", session);
|
nextSession.error = undefined;
|
||||||
return session;
|
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;
|
} satisfies NextAuthConfig;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const SignInForm = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const samlError = searchParams.get("sso_saml_failed");
|
const samlError = searchParams.get("sso_saml_failed");
|
||||||
|
const sessionError = searchParams.get("error");
|
||||||
|
|
||||||
if (samlError) {
|
if (samlError) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -46,6 +47,37 @@ export const SignInForm = ({
|
|||||||
});
|
});
|
||||||
}, 100);
|
}, 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]);
|
}, [searchParams, toast]);
|
||||||
|
|
||||||
const form = useForm<SignInFormData>({
|
const form = useForm<SignInFormData>({
|
||||||
|
|||||||
+12
-1
@@ -18,9 +18,20 @@ const isPublicRoute = (pathname: string): boolean => {
|
|||||||
export default auth((req: NextRequest & { auth: any }) => {
|
export default auth((req: NextRequest & { auth: any }) => {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const user = req.auth?.user;
|
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)) {
|
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) {
|
if (user?.permissions) {
|
||||||
|
|||||||
Vendored
+13
-12
@@ -1,4 +1,4 @@
|
|||||||
import { DefaultSession } from "next-auth";
|
import type { DefaultSession, User as NextAuthUser } from "next-auth";
|
||||||
|
|
||||||
import { RolePermissionAttributes } from "./types/users";
|
import { RolePermissionAttributes } from "./types/users";
|
||||||
|
|
||||||
@@ -11,17 +11,18 @@ declare module "next-auth" {
|
|||||||
permissions?: RolePermissionAttributes;
|
permissions?: RolePermissionAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionUser = NonNullable<DefaultSession["user"]> & {
|
||||||
|
companyName?: string;
|
||||||
|
dateJoined?: string;
|
||||||
|
permissions: RolePermissionAttributes;
|
||||||
|
};
|
||||||
|
|
||||||
interface Session extends DefaultSession {
|
interface Session extends DefaultSession {
|
||||||
user: {
|
user?: SessionUser;
|
||||||
name: string;
|
userId?: string;
|
||||||
email: string;
|
tenantId?: string;
|
||||||
companyName?: string;
|
accessToken?: string;
|
||||||
dateJoined: string;
|
refreshToken?: string;
|
||||||
permissions: RolePermissionAttributes;
|
error?: string;
|
||||||
} & DefaultSession["user"];
|
|
||||||
userId: string;
|
|
||||||
tenantId: string;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ test.describe("Session Persistence", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Try to access protected route without login
|
// Try to access protected route without login
|
||||||
await page.goto(URLS.DASHBOARD);
|
await page.goto(URLS.DASHBOARD);
|
||||||
// Should be redirected to login page
|
// Should be redirected to login page (may include callbackUrl)
|
||||||
await expect(page).toHaveURL(URLS.LOGIN);
|
await expect(page).toHaveURL(/\/sign-in/);
|
||||||
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
|
await expect(page.getByText("Sign in", { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ test.describe("Session Persistence", () => {
|
|||||||
|
|
||||||
// Verify cannot access protected route after logout
|
// Verify cannot access protected route after logout
|
||||||
await page.goto(URLS.DASHBOARD);
|
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 }) => {
|
test("should handle session timeout gracefully", async ({ browser }) => {
|
||||||
@@ -184,8 +184,8 @@ test.describe("Session Persistence", () => {
|
|||||||
waitUntil: "networkidle",
|
waitUntil: "networkidle",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should be redirected to login since this context has no auth
|
// Should be redirected to login since this context has no auth (may include callbackUrl)
|
||||||
await expect(unauthPage).toHaveURL(URLS.LOGIN);
|
await expect(unauthPage).toHaveURL(/\/sign-in/);
|
||||||
|
|
||||||
// Verify session is null in unauthenticated context
|
// Verify session is null in unauthenticated context
|
||||||
const unauthResponse = await unauthPage.request.get("/api/auth/session");
|
const unauthResponse = await unauthPage.request.get("/api/auth/session");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
+15
-1
@@ -100,7 +100,7 @@ export async function logout(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyLogoutSuccess(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();
|
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) {
|
export async function verifyDashboardRoute(page: Page) {
|
||||||
await expect(page).toHaveURL("/");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user