mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +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)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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
23
ui/nextauth.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
92
ui/tests/auth-middleware-error.spec.ts
Normal file
92
ui/tests/auth-middleware-error.spec.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
124
ui/tests/auth-refresh-token.spec.ts
Normal file
124
ui/tests/auth-refresh-token.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
102
ui/tests/auth-session-error-message.spec.ts
Normal file
102
ui/tests/auth-session-error-message.spec.ts
Normal 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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user