diff --git a/web/e2e/specs/auth.spec.ts b/web/e2e/specs/auth.spec.ts index 80aca00b99..9e0c803255 100644 --- a/web/e2e/specs/auth.spec.ts +++ b/web/e2e/specs/auth.spec.ts @@ -6,9 +6,9 @@ * viewer nav restrictions; all-routes smoke. */ -import { test, expect } from "../fixtures/frigate-test"; +import { test, expect, type FrigateApp } from "../fixtures/frigate-test"; import { systemTab } from "../helpers/system-tabs"; -import { viewerProfile } from "../fixtures/mock-data/profile"; +import { adminProfile, viewerProfile } from "../fixtures/mock-data/profile"; test.describe("Auth — admin access @high", () => { test("admin /system renders general tab", async ({ frigateApp }) => { @@ -109,3 +109,71 @@ test.describe("Auth — all routes smoke @high @mobile", () => { } }); }); + +test.describe("Auth: login redirect @high", () => { + // 401 on /profile is how the app learns that a login is required + test.use({ expectedErrors: [/401/] }); + + async function requireLogin(frigateApp: FrigateApp) { + let loggedIn = false; + await frigateApp.page.route("**/api/profile", (route) => + loggedIn + ? route.fulfill({ json: adminProfile() }) + : route.fulfill({ status: 401, body: "" }), + ); + await frigateApp.page.route("**/api/login", (route) => { + loggedIn = true; + return route.fulfill({ json: {} }); + }); + await frigateApp.page.route("**/api/auth/first_time_login", (route) => + route.fulfill({ json: { admin_first_time_login: false } }), + ); + } + + async function logIn(frigateApp: FrigateApp) { + await frigateApp.page.getByLabel("Username").fill("admin"); + await frigateApp.page.getByLabel("Password").fill("password"); + await frigateApp.page.getByRole("button", { name: "Login" }).click(); + } + + test("returns to the requested url after logging in", async ({ + frigateApp, + }) => { + await requireLogin(frigateApp); + await frigateApp.page.goto("/explore?labels=person"); + await expect(frigateApp.page).toHaveURL( + `/login?redirect=${encodeURIComponent("/explore?labels=person")}`, + ); + + await logIn(frigateApp); + await expect(frigateApp.page).toHaveURL("/explore?labels=person"); + await expect(frigateApp.page.locator("#pageRoot")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("goes home after logging in from the home page", async ({ + frigateApp, + }) => { + await requireLogin(frigateApp); + await frigateApp.page.goto("/"); + await expect(frigateApp.page).toHaveURL("/login"); + + await logIn(frigateApp); + await expect(frigateApp.page).toHaveURL("/"); + }); + + for (const target of ["https://example.com/", "//example.com/"]) { + test(`ignores a redirect to another site (${target})`, async ({ + frigateApp, + }) => { + await requireLogin(frigateApp); + await frigateApp.page.goto( + `/login?redirect=${encodeURIComponent(target)}`, + ); + + await logIn(frigateApp); + await expect(frigateApp.page).toHaveURL("/"); + }); + } +}); diff --git a/web/src/api/auth-redirect.ts b/web/src/api/auth-redirect.ts index f19e2b8a5a..31aef0a02f 100644 --- a/web/src/api/auth-redirect.ts +++ b/web/src/api/auth-redirect.ts @@ -1,3 +1,5 @@ +import { baseUrl } from "./baseUrl"; + // Module-level flag to prevent multiple simultaneous redirects // (eg, when multiple SWR queries fail with 401 at once, or when // both ApiProvider and ProtectedRoute try to redirect) @@ -10,3 +12,46 @@ export function isRedirectingToLogin(): boolean { export function setRedirectingToLogin(value: boolean): void { _isRedirectingToLogin = value; } + +const REDIRECT_PARAM = "redirect"; + +// Build the login page url, carrying the current location along +// so the user can be returned to it after logging in +export function getLoginUrl(loginPage: string = `${baseUrl}login`): string { + const url = new URL(loginPage, window.location.href); + + // an external login page (eg, an auth proxy) handles its own return url + if (url.origin !== window.location.origin) { + return loginPage; + } + + const { pathname, search, hash } = window.location; + const current = `${pathname}${search}${hash}`; + + // no need to carry the home page, it is the default after logging in + if (current !== new URL(baseUrl).pathname) { + url.searchParams.set(REDIRECT_PARAM, current); + } + + return url.href; +} + +// Resolve where to send the user after a successful login +export function getPostLoginUrl(): string { + const target = new URLSearchParams(window.location.search).get( + REDIRECT_PARAM, + ); + + if (!target) { + return baseUrl; + } + + // only return to urls on this origin so the param can't be used + // to send the user to another site + try { + const url = new URL(target, window.location.origin); + return url.origin === window.location.origin ? url.href : baseUrl; + } catch { + return baseUrl; + } +} diff --git a/web/src/api/index.tsx b/web/src/api/index.tsx index 41cb7d24c9..5b881fc55f 100644 --- a/web/src/api/index.tsx +++ b/web/src/api/index.tsx @@ -3,7 +3,11 @@ import { SWRConfig } from "swr"; import { WsProvider } from "./WsProvider"; import axios from "axios"; import { ReactNode } from "react"; -import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect"; +import { + getLoginUrl, + isRedirectingToLogin, + setRedirectingToLogin, +} from "./auth-redirect"; axios.defaults.baseURL = `${baseUrl}api/`; @@ -34,7 +38,7 @@ export function ApiProvider({ children, options }: ApiProviderType) { const loginPage = error.response.headers.get("location") ?? "login"; if (window.location.href !== loginPage && !isRedirectingToLogin()) { setRedirectingToLogin(true); - window.location.href = loginPage; + window.location.href = getLoginUrl(loginPage); } } }, diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx index 7bacbacba8..e0951bce5f 100644 --- a/web/src/components/auth/AuthForm.tsx +++ b/web/src/components/auth/AuthForm.tsx @@ -2,7 +2,7 @@ import * as React from "react"; -import { baseUrl } from "../../api/baseUrl"; +import { getPostLoginUrl } from "@/api/auth-redirect"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -69,7 +69,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { username: profileRes.data.username, role: profileRes.data.role || "viewer", }); - window.location.href = baseUrl; + window.location.href = getPostLoginUrl(); } catch (error) { if (axios.isAxiosError(error)) { const err = error as AxiosError; diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx index c10a94a020..f9067f95dd 100644 --- a/web/src/components/auth/ProtectedRoute.tsx +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -3,10 +3,10 @@ import { Navigate, Outlet } from "react-router-dom"; import { AuthContext } from "@/context/auth-context"; import ActivityIndicator from "../indicators/activity-indicator"; import { + getLoginUrl, isRedirectingToLogin, setRedirectingToLogin, } from "@/api/auth-redirect"; -import { baseUrl } from "@/api/baseUrl"; export default function ProtectedRoute({ requiredRoles, @@ -25,7 +25,7 @@ export default function ProtectedRoute({ !isRedirectingToLogin() ) { setRedirectingToLogin(true); - window.location.href = `${baseUrl}login`; + window.location.href = getLoginUrl(); } }, [auth.isLoading, auth.isAuthenticated, auth.user]);