Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions python/ray/_private/authentication/http_token_authentication.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
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

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
"""
Expand All @@ -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, ""
)
Expand Down
102 changes: 102 additions & 0 deletions python/ray/dashboard/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | undefined>();
useEffect(() => {
getNodeList().then((res) => {
if (res?.data?.data?.summary) {
Expand Down Expand Up @@ -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 (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={lightTheme}>
<Suspense fallback={Loading}>
<GlobalContext.Provider value={{ ...context, currentTimeZone }}>
<CssBaseline />
<TokenAuthenticationDialog
open={authenticationDialogOpen}
hasExistingToken={hasAttemptedAuthentication}
onSubmit={handleTokenSubmit}
error={authenticationError}
/>
<HashRouter>
<Routes>
{/* Redirect people hitting the /new path to root. TODO(aguo): Delete this redirect in ray 2.5 */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={true}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
error={errorMessage}
/>,
);

expect(screen.getByText(errorMessage)).toBeInTheDocument();
});

it("calls onSubmit with entered token when submit is clicked", async () => {
const user = userEvent.setup();
mockOnSubmit.mockResolvedValue(undefined);

render(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={true}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

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(
<TokenAuthenticationDialog
open={false}
hasExistingToken={false}
onSubmit={mockOnSubmit}
/>,
);

// Dialog should not be visible
expect(
screen.queryByText("Token Authentication Required"),
).not.toBeInTheDocument();
});
});
Loading