diff --git a/python/ray/_private/authentication/http_token_authentication.py b/python/ray/_private/authentication/http_token_authentication.py index 8f68e9893815..d6feef61bc33 100644 --- a/python/ray/_private/authentication/http_token_authentication.py +++ b/python/ray/_private/authentication/http_token_authentication.py @@ -1,6 +1,6 @@ import logging from types import ModuleType -from typing import Dict, Optional +from typing import Dict, List, Optional from ray._private.authentication import authentication_constants from ray.dashboard import authentication_utils as auth_utils @@ -8,11 +8,17 @@ logger = logging.getLogger(__name__) -def get_token_auth_middleware(aiohttp_module: ModuleType): +def get_token_auth_middleware( + aiohttp_module: ModuleType, + whitelisted_exact_paths: Optional[List[str]] = None, + whitelisted_path_prefixes: Optional[List[str]] = None, +): """Internal helper to create token auth middleware with provided modules. Args: aiohttp_module: The aiohttp module to use + whitelisted_exact_paths: List of exact paths that don't require authentication + whitelisted_path_prefixes: List of path prefixes that don't require authentication Returns: An aiohttp middleware function """ @@ -28,6 +34,13 @@ async def token_auth_middleware(request, handler): if not auth_utils.is_token_auth_enabled(): return await handler(request) + # skip authentication for whitelisted paths + if (whitelisted_exact_paths and request.path in whitelisted_exact_paths) or ( + whitelisted_path_prefixes + and request.path.startswith(tuple(whitelisted_path_prefixes)) + ): + return await handler(request) + auth_header = request.headers.get( authentication_constants.AUTHORIZATION_HEADER_NAME, "" ) diff --git a/python/ray/dashboard/client/src/App.tsx b/python/ray/dashboard/client/src/App.tsx index 7e37d6819ca6..ddb8164d3c9e 100644 --- a/python/ray/dashboard/client/src/App.tsx +++ b/python/ray/dashboard/client/src/App.tsx @@ -4,6 +4,16 @@ import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import React, { Suspense, useEffect, useState } from "react"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import { + getAuthenticationMode, + testTokenValidity, +} from "./authentication/authentication"; +import { AUTHENTICATION_ERROR_EVENT } from "./authentication/constants"; +import { + getAuthenticationToken, + setAuthenticationToken, +} from "./authentication/cookies"; +import TokenAuthenticationDialog from "./authentication/TokenAuthenticationDialog"; import ActorDetailPage, { ActorDetailLayout } from "./pages/actor/ActorDetail"; import { ActorLayout } from "./pages/actor/ActorLayout"; import Loading from "./pages/exception/Loading"; @@ -147,6 +157,14 @@ const App = () => { dashboardDatasource: undefined, serverTimeZone: undefined, }); + + // Authentication state + const [authenticationDialogOpen, setAuthenticationDialogOpen] = + useState(false); + const [hasAttemptedAuthentication, setHasAttemptedAuthentication] = + useState(false); + const [authenticationError, setAuthenticationError] = + useState(); useEffect(() => { getNodeList().then((res) => { if (res?.data?.data?.summary) { @@ -218,12 +236,96 @@ const App = () => { updateTimezone(); }, []); + // Check authentication mode on mount + useEffect(() => { + const checkAuthentication = async () => { + try { + const { authentication_mode } = await getAuthenticationMode(); + + if (authentication_mode === "token") { + // Token authentication is enabled + const existingToken = getAuthenticationToken(); + + if (!existingToken) { + // No token found - show dialog immediately + setAuthenticationDialogOpen(true); + } + // If token exists, let it be used by interceptor + // If invalid, interceptor will trigger dialog via 401/403 + } + } catch (error) { + console.error("Failed to check authentication mode:", error); + } + }; + + checkAuthentication(); + }, []); + + // Listen for authentication errors from axios interceptor + useEffect(() => { + const handleAuthenticationError = (event: Event) => { + const customEvent = event as CustomEvent<{ hadToken: boolean }>; + const hadToken = customEvent.detail?.hadToken ?? false; + + setHasAttemptedAuthentication(hadToken); + setAuthenticationDialogOpen(true); + }; + + window.addEventListener( + AUTHENTICATION_ERROR_EVENT, + handleAuthenticationError, + ); + + return () => { + window.removeEventListener( + AUTHENTICATION_ERROR_EVENT, + handleAuthenticationError, + ); + }; + }, []); + + // Handle token submission from dialog + const handleTokenSubmit = async (token: string) => { + try { + // Test if token is valid + const isValid = await testTokenValidity(token); + + if (isValid) { + // Save token to cookie + setAuthenticationToken(token); + setHasAttemptedAuthentication(true); + setAuthenticationDialogOpen(false); + setAuthenticationError(undefined); + + // Reload the page to refetch all data with the new token + window.location.reload(); + } else { + // Token is invalid + setHasAttemptedAuthentication(true); + setAuthenticationError( + "Invalid authentication token. Please check and try again.", + ); + } + } catch (error) { + console.error("Failed to validate token:", error); + setAuthenticationError( + "Failed to validate token. Please check your connection and try again.", + ); + } + }; + return ( + {/* Redirect people hitting the /new path to root. TODO(aguo): Delete this redirect in ray 2.5 */} diff --git a/python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.test.tsx b/python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.test.tsx new file mode 100644 index 000000000000..bf7a0c0419b3 --- /dev/null +++ b/python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.test.tsx @@ -0,0 +1,206 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import "@testing-library/jest-dom"; +import TokenAuthenticationDialog from "./TokenAuthenticationDialog"; + +describe("TokenAuthenticationDialog", () => { + const mockOnSubmit = jest.fn(); + + beforeEach(() => { + mockOnSubmit.mockClear(); + }); + + it("renders with initial message when no existing token", () => { + render( + , + ); + + expect( + screen.getByText("Token Authentication Required"), + ).toBeInTheDocument(); + expect( + screen.getByText(/token authentication is enabled for this cluster/i), + ).toBeInTheDocument(); + }); + + it("renders with re-authentication message when has existing token", () => { + render( + , + ); + + expect( + screen.getByText("Token Authentication Required"), + ).toBeInTheDocument(); + expect( + screen.getByText(/authentication token is invalid or has expired/i), + ).toBeInTheDocument(); + }); + + it("displays error message when provided", () => { + const errorMessage = "Invalid token provided"; + render( + , + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("calls onSubmit with entered token when submit is clicked", async () => { + const user = userEvent.setup(); + mockOnSubmit.mockResolvedValue(undefined); + + render( + , + ); + + const input = screen.getByLabelText(/authentication token/i); + await user.type(input, "test-token-123"); + + const submitButton = screen.getByRole("button", { name: /submit/i }); + await user.click(submitButton); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith("test-token-123"); + }); + }); + + it("calls onSubmit when Enter key is pressed", async () => { + const user = userEvent.setup(); + mockOnSubmit.mockResolvedValue(undefined); + + render( + , + ); + + const input = screen.getByLabelText(/authentication token/i); + await user.type(input, "test-token-123{Enter}"); + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith("test-token-123"); + }); + }); + + it("disables submit button when token is empty", () => { + render( + , + ); + + const submitButton = screen.getByRole("button", { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + + it("enables submit button when token is entered", async () => { + const user = userEvent.setup(); + render( + , + ); + + const submitButton = screen.getByRole("button", { name: /submit/i }); + expect(submitButton).toBeDisabled(); + + const input = screen.getByLabelText(/authentication token/i); + await user.type(input, "test-token"); + + expect(submitButton).not.toBeDisabled(); + }); + + it("toggles token visibility when visibility icon is clicked", async () => { + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByLabelText(/authentication token/i); + await user.type(input, "secret-token"); + + // Initially should be password type (hidden) + expect(input).toHaveAttribute("type", "password"); + + // Click visibility toggle + const toggleButton = screen.getByLabelText(/toggle token visibility/i); + await user.click(toggleButton); + + // Should now be text type (visible) + expect(input).toHaveAttribute("type", "text"); + + // Click again to hide + await user.click(toggleButton); + expect(input).toHaveAttribute("type", "password"); + }); + + it("shows loading state during submission", async () => { + const user = userEvent.setup(); + // Mock a slow submission + mockOnSubmit.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ); + + render( + , + ); + + const input = screen.getByLabelText(/authentication token/i); + await user.type(input, "test-token"); + + const submitButton = screen.getByRole("button", { name: /submit/i }); + await user.click(submitButton); + + // Should show validating state + await waitFor(() => { + expect(screen.getByText(/validating.../i)).toBeInTheDocument(); + }); + }); + + it("does not render when open is false", () => { + render( + , + ); + + // Dialog should not be visible + expect( + screen.queryByText("Token Authentication Required"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.tsx b/python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.tsx new file mode 100644 index 000000000000..e260b1d49bf3 --- /dev/null +++ b/python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.tsx @@ -0,0 +1,142 @@ +/** + * Dialog component for Ray dashboard token authentication. + * Prompts users to enter their authentication token when token auth is enabled. + */ + +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + InputAdornment, + TextField, +} from "@mui/material"; +import React, { useState } from "react"; + +export type TokenAuthenticationDialogProps = { + /** Whether the dialog is open */ + open: boolean; + /** Whether the user has previously entered a token (affects messaging) */ + hasExistingToken: boolean; + /** Callback when user submits a token */ + onSubmit: (token: string) => Promise; + /** Optional error message to display */ + error?: string; +}; + +/** + * Token Authentication Dialog Component. + * + * Shows different messages based on whether this is the first time + * (hasExistingToken=false) or if a previously stored token was rejected + * (hasExistingToken=true). + */ +export const TokenAuthenticationDialog: React.FC = + ({ open, hasExistingToken, onSubmit, error }) => { + const [token, setToken] = useState(""); + const [showToken, setShowToken] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!token.trim()) { + return; + } + + setIsSubmitting(true); + try { + await onSubmit(token.trim()); + // If successful, the parent component will close the dialog + // and likely reload the page + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !isSubmitting) { + handleSubmit(); + } + }; + + const toggleShowToken = () => { + setShowToken(!showToken); + }; + + // Different messages based on whether this is initial auth or re-auth + const title = "Token Authentication Required"; + const message = hasExistingToken + ? "The authentication token is invalid or has expired. Please provide a valid authentication token." + : "Token authentication is enabled for this cluster. Please provide a valid authentication token."; + + return ( + + {title} + + + {message} + + + {error && ( + + {error} + + )} + + setToken(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isSubmitting} + placeholder="Enter your authentication token" + InputProps={{ + endAdornment: ( + + + {showToken ? : } + + + ), + }} + /> + + + + + + ); + }; + +export default TokenAuthenticationDialog; diff --git a/python/ray/dashboard/client/src/authentication/authentication.ts b/python/ray/dashboard/client/src/authentication/authentication.ts new file mode 100644 index 000000000000..c7579a1995fa --- /dev/null +++ b/python/ray/dashboard/client/src/authentication/authentication.ts @@ -0,0 +1,56 @@ +/** + * Authentication service for Ray dashboard. + * Provides functions to check authentication mode and validate tokens when token auth is enabled. + */ + +import axios from "axios"; +import { formatUrl, get } from "../service/requestHandlers"; + +/** + * Response type for authentication mode endpoint. + */ +export type AuthenticationModeResponse = { + authentication_mode: "disabled" | "token"; +}; + +/** + * Get the current authentication mode from the server. + * This endpoint is public and does not require authentication. + * + * @returns Promise resolving to the authentication mode + */ +export const getAuthenticationMode = + async (): Promise => { + const response = await get( + "/api/authentication_mode", + ); + return response.data; + }; + +/** + * Test if a token is valid by making a request to the /api/version endpoint + * which is fast and reliable. + * + * Note: This uses plain axios (not axiosInstance) to avoid the request interceptor + * that would add the token from cookies, since we want to test the specific token + * passed as a parameter. It also avoids the response interceptor that would dispatch + * global authentication error events, since we handle 401/403 errors locally. + * + * @param token - The authentication token to test + * @returns Promise resolving to true if token is valid, false otherwise + */ +export const testTokenValidity = async (token: string): Promise => { + try { + await axios.get(formatUrl("/api/version"), { + headers: { Authorization: `Bearer ${token}` }, + }); + return true; + } catch (error: any) { + // 401 (Unauthorized) or 403 (Forbidden) means invalid token + if (error.response?.status === 401 || error.response?.status === 403) { + return false; + } + // For other errors (network, server errors, etc.), re-throw + throw error; + } +}; diff --git a/python/ray/dashboard/client/src/authentication/constants.ts b/python/ray/dashboard/client/src/authentication/constants.ts new file mode 100644 index 000000000000..fce013e5f30d --- /dev/null +++ b/python/ray/dashboard/client/src/authentication/constants.ts @@ -0,0 +1,9 @@ +/** + * Authentication-related constants for the Ray dashboard. + */ + +/** + * Event name dispatched when an authentication error occurs (401 or 403). + * Listened to by App.tsx to show the authentication dialog. + */ +export const AUTHENTICATION_ERROR_EVENT = "ray-authentication-error"; diff --git a/python/ray/dashboard/client/src/authentication/cookies.test.ts b/python/ray/dashboard/client/src/authentication/cookies.test.ts new file mode 100644 index 000000000000..d1cb22d67a24 --- /dev/null +++ b/python/ray/dashboard/client/src/authentication/cookies.test.ts @@ -0,0 +1,107 @@ +import "@testing-library/jest-dom"; +import { + clearAuthenticationToken, + deleteCookie, + getAuthenticationToken, + getCookie, + setAuthenticationToken, + setCookie, +} from "./cookies"; + +describe("Cookie utilities", () => { + beforeEach(() => { + // Clear all cookies before each test + document.cookie.split(";").forEach((cookie) => { + const name = cookie.split("=")[0].trim(); + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + }); + }); + + describe("setCookie and getCookie", () => { + it("sets and retrieves a cookie", () => { + setCookie("test-cookie", "test-value"); + const value = getCookie("test-cookie"); + expect(value).toBe("test-value"); + }); + + it("returns null for non-existent cookie", () => { + const value = getCookie("non-existent"); + expect(value).toBeNull(); + }); + + it("overwrites existing cookie with same name", () => { + setCookie("test-cookie", "value1"); + setCookie("test-cookie", "value2"); + const value = getCookie("test-cookie"); + expect(value).toBe("value2"); + }); + }); + + describe("deleteCookie", () => { + it("deletes an existing cookie", () => { + setCookie("test-cookie", "test-value"); + expect(getCookie("test-cookie")).toBe("test-value"); + + deleteCookie("test-cookie"); + expect(getCookie("test-cookie")).toBeNull(); + }); + + it("handles deletion of non-existent cookie", () => { + // Should not throw error + expect(() => deleteCookie("non-existent")).not.toThrow(); + }); + }); + + describe("Authentication token functions", () => { + it("sets and retrieves authentication token", () => { + const testToken = "test-auth-token-123"; + setAuthenticationToken(testToken); + + const retrievedToken = getAuthenticationToken(); + expect(retrievedToken).toBe(testToken); + }); + + it("returns null when no authentication token is set", () => { + const token = getAuthenticationToken(); + expect(token).toBeNull(); + }); + + it("clears authentication token", () => { + setAuthenticationToken("test-token"); + expect(getAuthenticationToken()).toBe("test-token"); + + clearAuthenticationToken(); + expect(getAuthenticationToken()).toBeNull(); + }); + + it("overwrites existing authentication token", () => { + setAuthenticationToken("token1"); + expect(getAuthenticationToken()).toBe("token1"); + + setAuthenticationToken("token2"); + expect(getAuthenticationToken()).toBe("token2"); + }); + }); + + describe("Multiple cookies", () => { + it("handles multiple cookies independently", () => { + setCookie("cookie1", "value1"); + setCookie("cookie2", "value2"); + setCookie("cookie3", "value3"); + + expect(getCookie("cookie1")).toBe("value1"); + expect(getCookie("cookie2")).toBe("value2"); + expect(getCookie("cookie3")).toBe("value3"); + }); + + it("deletes only specified cookie", () => { + setCookie("cookie1", "value1"); + setCookie("cookie2", "value2"); + + deleteCookie("cookie1"); + + expect(getCookie("cookie1")).toBeNull(); + expect(getCookie("cookie2")).toBe("value2"); + }); + }); +}); diff --git a/python/ray/dashboard/client/src/authentication/cookies.ts b/python/ray/dashboard/client/src/authentication/cookies.ts new file mode 100644 index 000000000000..12180de6b973 --- /dev/null +++ b/python/ray/dashboard/client/src/authentication/cookies.ts @@ -0,0 +1,78 @@ +/** + * Cookie utility functions for Ray dashboard authentication. + */ + +const AUTHENTICATION_TOKEN_COOKIE_NAME = "ray-authentication-token"; + +/** + * Get a cookie value by name. + * + * @param name - The name of the cookie to retrieve + * @returns The cookie value if found, null otherwise + */ +export const getCookie = (name: string): string | null => { + const nameEQ = name + "="; + const cookies = document.cookie.split(";"); + + for (let i = 0; i < cookies.length; i++) { + let cookie = cookies[i]; + while (cookie.charAt(0) === " ") { + cookie = cookie.substring(1, cookie.length); + } + if (cookie.indexOf(nameEQ) === 0) { + return cookie.substring(nameEQ.length, cookie.length); + } + } + return null; +}; + +/** + * Set a cookie with the given name, value, and expiration. + * + * @param name - The name of the cookie + * @param value - The value to store in the cookie + * @param days - Number of days until the cookie expires (default: 30) + */ +export const setCookie = (name: string, value: string, days = 30): void => { + let expires = ""; + if (days) { + const date = new Date(); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + (value || "") + expires + "; path=/"; +}; + +/** + * Delete a cookie by name. + * + * @param name - The name of the cookie to delete + */ +export const deleteCookie = (name: string): void => { + document.cookie = name + "=; Max-Age=-99999999; path=/"; +}; + +/** + * Get the authentication token from cookies. + * + * @returns The authentication token if found, null otherwise + */ +export const getAuthenticationToken = (): string | null => { + return getCookie(AUTHENTICATION_TOKEN_COOKIE_NAME); +}; + +/** + * Set the authentication token in cookies. + * + * @param token - The authentication token to store + */ +export const setAuthenticationToken = (token: string): void => { + setCookie(AUTHENTICATION_TOKEN_COOKIE_NAME, token); +}; + +/** + * Clear the authentication token from cookies. + */ +export const clearAuthenticationToken = (): void => { + deleteCookie(AUTHENTICATION_TOKEN_COOKIE_NAME); +}; diff --git a/python/ray/dashboard/client/src/service/event.ts b/python/ray/dashboard/client/src/service/event.ts index dcd153ed4542..25bba277885d 100644 --- a/python/ray/dashboard/client/src/service/event.ts +++ b/python/ray/dashboard/client/src/service/event.ts @@ -1,18 +1,18 @@ -import axios from "axios"; import { EventGlobalRsp, EventRsp } from "../type/event"; +import { axiosInstance } from "./requestHandlers"; export const getEvents = (jobId: string) => { if (jobId) { - return axios.get(`events?job_id=${jobId}`); + return axiosInstance.get(`events?job_id=${jobId}`); } }; export const getPipelineEvents = (jobId: string) => { if (jobId) { - return axios.get(`events?job_id=${jobId}&view=pipeline`); + return axiosInstance.get(`events?job_id=${jobId}&view=pipeline`); } }; export const getGlobalEvents = () => { - return axios.get("events"); + return axiosInstance.get("events"); }; diff --git a/python/ray/dashboard/client/src/service/requestHandlers.ts b/python/ray/dashboard/client/src/service/requestHandlers.ts index 9da2ff6fc8aa..5addbaf518a8 100644 --- a/python/ray/dashboard/client/src/service/requestHandlers.ts +++ b/python/ray/dashboard/client/src/service/requestHandlers.ts @@ -9,6 +9,8 @@ */ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; +import { AUTHENTICATION_ERROR_EVENT } from "../authentication/constants"; +import { getAuthenticationToken } from "../authentication/cookies"; /** * This function formats URLs such that the user's browser @@ -26,9 +28,54 @@ export const formatUrl = (url: string): string => { return url; }; +// Create axios instance with interceptors for authentication +const axiosInstance = axios.create(); + +// Export the configured axios instance for direct use when needed +export { axiosInstance }; + +// Request interceptor: Add authentication token if available +axiosInstance.interceptors.request.use( + (config) => { + const token = getAuthenticationToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +// Response interceptor: Handle 401/403 errors +axiosInstance.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + // If we get 401 (Unauthorized) or 403 (Forbidden), dispatch an event + // so the App component can show the authentication dialog + if (error.response?.status === 401 || error.response?.status === 403) { + // Check if there was a token in the request + const hadToken = !!getAuthenticationToken(); + + // Dispatch custom event for authentication error + window.dispatchEvent( + new CustomEvent(AUTHENTICATION_ERROR_EVENT, { + detail: { hadToken }, + }), + ); + } + + // Re-throw the error so the caller can handle it if needed + return Promise.reject(error); + }, +); + export const get = >( url: string, config?: AxiosRequestConfig, ): Promise => { - return axios.get(formatUrl(url), config); + return axiosInstance.get(formatUrl(url), config); }; diff --git a/python/ray/dashboard/client/src/service/util.ts b/python/ray/dashboard/client/src/service/util.ts index 966c82db2919..e666c6fbc8d2 100644 --- a/python/ray/dashboard/client/src/service/util.ts +++ b/python/ray/dashboard/client/src/service/util.ts @@ -1,4 +1,4 @@ -import axios from "axios"; +import { axiosInstance } from "./requestHandlers"; type CMDRsp = { result: boolean; @@ -9,7 +9,7 @@ type CMDRsp = { }; export const getJstack = (ip: string, pid: string) => { - return axios.get("utils/jstack", { + return axiosInstance.get("utils/jstack", { params: { ip, pid, @@ -18,7 +18,7 @@ export const getJstack = (ip: string, pid: string) => { }; export const getJmap = (ip: string, pid: string) => { - return axios.get("utils/jmap", { + return axiosInstance.get("utils/jmap", { params: { ip, pid, @@ -27,7 +27,7 @@ export const getJmap = (ip: string, pid: string) => { }; export const getJstat = (ip: string, pid: string, options: string) => { - return axios.get("utils/jstat", { + return axiosInstance.get("utils/jstat", { params: { ip, pid, @@ -48,5 +48,5 @@ type NamespacesRsp = { }; export const getNamespaces = () => { - return axios.get("namespaces"); + return axiosInstance.get("namespaces"); }; diff --git a/python/ray/dashboard/http_server_head.py b/python/ray/dashboard/http_server_head.py index f08c84a91dd5..593cfbbbb2b4 100644 --- a/python/ray/dashboard/http_server_head.py +++ b/python/ray/dashboard/http_server_head.py @@ -23,6 +23,7 @@ from ray._private.authentication.http_token_authentication import ( get_token_auth_middleware, ) +from ray._raylet import AuthenticationMode, get_authentication_mode from ray.dashboard.dashboard_metrics import DashboardPrometheusMetrics from ray.dashboard.head import DashboardHeadModule @@ -162,6 +163,22 @@ async def get_timezone(self, req) -> aiohttp.web.Response: status=500, text="Internal Server Error:" + str(e) ) + @routes.get("/api/authentication_mode") + async def get_authentication_mode(self, req) -> aiohttp.web.Response: + try: + mode = get_authentication_mode() + if mode == AuthenticationMode.TOKEN: + mode_str = "token" + else: + mode_str = "disabled" + + return aiohttp.web.json_response({"authentication_mode": mode_str}) + except Exception as e: + logger.error(f"Error getting authentication mode: {e}") + return aiohttp.web.Response( + status=500, text="Internal Server Error: " + str(e) + ) + def get_address(self): assert self.http_host and self.http_port return self.http_host, self.http_port @@ -249,6 +266,15 @@ async def run( for h in subprocess_module_handles: SubprocessRouteTable.bind(h) + # Public endpoints that don't require authentication. + # These are needed for the dashboard to load and request an auth token. + public_exact_paths = { + "/", # Root index.html + "/favicon.ico", + "/api/authentication_mode", + } + public_path_prefixes = ("/static/",) # Static assets (JS, CSS, images) + # Http server should be initialized after all modules loaded. # working_dir uploads for job submission can be up to 100MiB. @@ -256,7 +282,9 @@ async def run( client_max_size=ray_constants.DASHBOARD_CLIENT_MAX_SIZE, middlewares=[ self.metrics_middleware, - get_token_auth_middleware(aiohttp), + get_token_auth_middleware( + aiohttp, public_exact_paths, public_path_prefixes + ), self.path_clean_middleware, self.browsers_no_post_put_middleware, self.cache_control_static_middleware, diff --git a/python/ray/dashboard/tests/test_dashboard_auth.py b/python/ray/dashboard/tests/test_dashboard_auth.py index 7407fc199a1d..5f4f9b8ffc11 100644 --- a/python/ray/dashboard/tests/test_dashboard_auth.py +++ b/python/ray/dashboard/tests/test_dashboard_auth.py @@ -63,6 +63,45 @@ def test_dashboard_auth_disabled(setup_cluster_without_token_auth): assert response.status_code == 200 +def test_authentication_mode_endpoint_with_token_auth(setup_cluster_with_token_auth): + """Test authentication_mode endpoint returns 'token' when auth is enabled.""" + + cluster_info = setup_cluster_with_token_auth + + # This endpoint should be accessible WITHOUT authentication + response = requests.get(f"{cluster_info['dashboard_url']}/api/authentication_mode") + + assert response.status_code == 200 + assert response.json() == {"authentication_mode": "token"} + + +def test_authentication_mode_endpoint_without_auth(setup_cluster_without_token_auth): + """Test authentication_mode endpoint returns 'disabled' when auth is off.""" + + cluster_info = setup_cluster_without_token_auth + + response = requests.get(f"{cluster_info['dashboard_url']}/api/authentication_mode") + + assert response.status_code == 200 + assert response.json() == {"authentication_mode": "disabled"} + + +def test_authentication_mode_endpoint_is_public(setup_cluster_with_token_auth): + """Test authentication_mode endpoint works without Authorization header.""" + + cluster_info = setup_cluster_with_token_auth + + # Call WITHOUT any authorization header - should still succeed + response = requests.get( + f"{cluster_info['dashboard_url']}/api/authentication_mode", + headers={}, # Explicitly no auth + ) + + # Should succeed even with token auth enabled + assert response.status_code == 200 + assert response.json() == {"authentication_mode": "token"} + + if __name__ == "__main__": sys.exit(pytest.main(["-vv", __file__]))