From 046baa8eb9f334e4c19c444818e2fa3aae70642c Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:02:10 +0200 Subject: [PATCH] feat(ui): refreshToken implementation (#8864) --- ui/CHANGELOG.md | 1 + ui/auth.config.ts | 320 ++++++++++++++------ ui/components/auth/oss/sign-in-form.tsx | 32 ++ ui/middleware.ts | 13 +- ui/nextauth.d.ts | 25 +- ui/tests/auth-login.spec.ts | 10 +- ui/tests/auth-middleware-error.spec.ts | 92 ++++++ ui/tests/auth-refresh-token.spec.ts | 124 ++++++++ ui/tests/auth-session-error-message.spec.ts | 102 +++++++ ui/tests/helpers.ts | 16 +- 10 files changed, 618 insertions(+), 117 deletions(-) create mode 100644 ui/tests/auth-middleware-error.spec.ts create mode 100644 ui/tests/auth-refresh-token.spec.ts create mode 100644 ui/tests/auth-session-error-message.spec.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 357a5ee90a..e03ba49540 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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 diff --git a/ui/auth.config.ts b/ui/auth.config.ts index f2b9db8811..ff3875b8ef 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -1,55 +1,199 @@ -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; + +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 & { 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>; + +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(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>(); + +const refreshAccessToken = async (token: AuthToken): Promise => { + 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, }, }, }; - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - }, - body: JSON.stringify(bodyData), - }); + const refreshPromise = (async () => { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + body: JSON.stringify(bodyData), + }); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const payload = await response.json().catch(() => undefined); + + 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 { - ...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", - }; + try { + return await refreshPromise; + } finally { + refreshTokenPromises.delete(refreshToken); } }; @@ -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; diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 0f5a109040..4c2ce66a93 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -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({ diff --git a/ui/middleware.ts b/ui/middleware.ts index d332bc03e8..83e5bb9c92 100644 --- a/ui/middleware.ts +++ b/ui/middleware.ts @@ -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) { diff --git a/ui/nextauth.d.ts b/ui/nextauth.d.ts index ffb22bc89b..7a70b19558 100644 --- a/ui/nextauth.d.ts +++ b/ui/nextauth.d.ts @@ -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; } + type SessionUser = NonNullable & { + companyName?: string; + dateJoined?: string; + permissions: RolePermissionAttributes; + }; + interface Session extends DefaultSession { - user: { - name: string; - email: string; - companyName?: string; - dateJoined: string; - permissions: RolePermissionAttributes; - } & DefaultSession["user"]; - userId: string; - tenantId: string; - accessToken: string; - refreshToken: string; + user?: SessionUser; + userId?: string; + tenantId?: string; + accessToken?: string; + refreshToken?: string; + error?: string; } } diff --git a/ui/tests/auth-login.spec.ts b/ui/tests/auth-login.spec.ts index 84dbc9b68f..fbe3fedebb 100644 --- a/ui/tests/auth-login.spec.ts +++ b/ui/tests/auth-login.spec.ts @@ -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"); diff --git a/ui/tests/auth-middleware-error.spec.ts b/ui/tests/auth-middleware-error.spec.ts new file mode 100644 index 0000000000..a69fa2ccdc --- /dev/null +++ b/ui/tests/auth-middleware-error.spec.ts @@ -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"); + } + }); + +}); diff --git a/ui/tests/auth-refresh-token.spec.ts b/ui/tests/auth-refresh-token.spec.ts new file mode 100644 index 0000000000..0b5a66ab17 --- /dev/null +++ b/ui/tests/auth-refresh-token.spec.ts @@ -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 + }); +}); diff --git a/ui/tests/auth-session-error-message.spec.ts b/ui/tests/auth-session-error-message.spec.ts new file mode 100644 index 0000000000..07f96abc1d --- /dev/null +++ b/ui/tests/auth-session-error-message.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"); + }); + +}); diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 51a4297427..9f5efb0fdf 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -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; +}