Skip to content

Commit 33ba50e

Browse files
sampan-s-nayaksampan
andauthored
[core] Support token based auth in ray dashboard UI (#58368)
## Description: when token auth is enabled, the dashboard prompts the user to enter the valid auth token and caches it (as a browser cookie). when token based auth is disabled, existing behaviour is retained. all dashboard ui based rpc's to to the ray cluster set the authorization header in their requests ## Screenshots token popup <img width="3440" height="2146" alt="image" src="https://github.com/user-attachments/assets/004c23a3-991e-4a2c-a2ad-5a0ce2e60893" /> on entering an invalid token <img width="3440" height="2146" alt="image" src="https://github.com/user-attachments/assets/7183a798-ceb7-4657-8706-39ce5fe8e61e" /> --------- Signed-off-by: sampan <[email protected]> Co-authored-by: sampan <[email protected]>
1 parent f81e366 commit 33ba50e

File tree

13 files changed

+840
-13
lines changed

13 files changed

+840
-13
lines changed

python/ray/_private/authentication/http_token_authentication.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
import logging
22
from types import ModuleType
3-
from typing import Dict, Optional
3+
from typing import Dict, List, Optional
44

55
from ray._private.authentication import authentication_constants
66
from ray.dashboard import authentication_utils as auth_utils
77

88
logger = logging.getLogger(__name__)
99

1010

11-
def get_token_auth_middleware(aiohttp_module: ModuleType):
11+
def get_token_auth_middleware(
12+
aiohttp_module: ModuleType,
13+
whitelisted_exact_paths: Optional[List[str]] = None,
14+
whitelisted_path_prefixes: Optional[List[str]] = None,
15+
):
1216
"""Internal helper to create token auth middleware with provided modules.
1317
1418
Args:
1519
aiohttp_module: The aiohttp module to use
20+
whitelisted_exact_paths: List of exact paths that don't require authentication
21+
whitelisted_path_prefixes: List of path prefixes that don't require authentication
1622
Returns:
1723
An aiohttp middleware function
1824
"""
@@ -28,6 +34,13 @@ async def token_auth_middleware(request, handler):
2834
if not auth_utils.is_token_auth_enabled():
2935
return await handler(request)
3036

37+
# skip authentication for whitelisted paths
38+
if (whitelisted_exact_paths and request.path in whitelisted_exact_paths) or (
39+
whitelisted_path_prefixes
40+
and request.path.startswith(tuple(whitelisted_path_prefixes))
41+
):
42+
return await handler(request)
43+
3144
auth_header = request.headers.get(
3245
authentication_constants.AUTHORIZATION_HEADER_NAME, ""
3346
)

python/ray/dashboard/client/src/App.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import dayjs from "dayjs";
44
import duration from "dayjs/plugin/duration";
55
import React, { Suspense, useEffect, useState } from "react";
66
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
7+
import {
8+
getAuthenticationMode,
9+
testTokenValidity,
10+
} from "./authentication/authentication";
11+
import { AUTHENTICATION_ERROR_EVENT } from "./authentication/constants";
12+
import {
13+
getAuthenticationToken,
14+
setAuthenticationToken,
15+
} from "./authentication/cookies";
16+
import TokenAuthenticationDialog from "./authentication/TokenAuthenticationDialog";
717
import ActorDetailPage, { ActorDetailLayout } from "./pages/actor/ActorDetail";
818
import { ActorLayout } from "./pages/actor/ActorLayout";
919
import Loading from "./pages/exception/Loading";
@@ -147,6 +157,14 @@ const App = () => {
147157
dashboardDatasource: undefined,
148158
serverTimeZone: undefined,
149159
});
160+
161+
// Authentication state
162+
const [authenticationDialogOpen, setAuthenticationDialogOpen] =
163+
useState(false);
164+
const [hasAttemptedAuthentication, setHasAttemptedAuthentication] =
165+
useState(false);
166+
const [authenticationError, setAuthenticationError] =
167+
useState<string | undefined>();
150168
useEffect(() => {
151169
getNodeList().then((res) => {
152170
if (res?.data?.data?.summary) {
@@ -218,12 +236,96 @@ const App = () => {
218236
updateTimezone();
219237
}, []);
220238

239+
// Check authentication mode on mount
240+
useEffect(() => {
241+
const checkAuthentication = async () => {
242+
try {
243+
const { authentication_mode } = await getAuthenticationMode();
244+
245+
if (authentication_mode === "token") {
246+
// Token authentication is enabled
247+
const existingToken = getAuthenticationToken();
248+
249+
if (!existingToken) {
250+
// No token found - show dialog immediately
251+
setAuthenticationDialogOpen(true);
252+
}
253+
// If token exists, let it be used by interceptor
254+
// If invalid, interceptor will trigger dialog via 401/403
255+
}
256+
} catch (error) {
257+
console.error("Failed to check authentication mode:", error);
258+
}
259+
};
260+
261+
checkAuthentication();
262+
}, []);
263+
264+
// Listen for authentication errors from axios interceptor
265+
useEffect(() => {
266+
const handleAuthenticationError = (event: Event) => {
267+
const customEvent = event as CustomEvent<{ hadToken: boolean }>;
268+
const hadToken = customEvent.detail?.hadToken ?? false;
269+
270+
setHasAttemptedAuthentication(hadToken);
271+
setAuthenticationDialogOpen(true);
272+
};
273+
274+
window.addEventListener(
275+
AUTHENTICATION_ERROR_EVENT,
276+
handleAuthenticationError,
277+
);
278+
279+
return () => {
280+
window.removeEventListener(
281+
AUTHENTICATION_ERROR_EVENT,
282+
handleAuthenticationError,
283+
);
284+
};
285+
}, []);
286+
287+
// Handle token submission from dialog
288+
const handleTokenSubmit = async (token: string) => {
289+
try {
290+
// Test if token is valid
291+
const isValid = await testTokenValidity(token);
292+
293+
if (isValid) {
294+
// Save token to cookie
295+
setAuthenticationToken(token);
296+
setHasAttemptedAuthentication(true);
297+
setAuthenticationDialogOpen(false);
298+
setAuthenticationError(undefined);
299+
300+
// Reload the page to refetch all data with the new token
301+
window.location.reload();
302+
} else {
303+
// Token is invalid
304+
setHasAttemptedAuthentication(true);
305+
setAuthenticationError(
306+
"Invalid authentication token. Please check and try again.",
307+
);
308+
}
309+
} catch (error) {
310+
console.error("Failed to validate token:", error);
311+
setAuthenticationError(
312+
"Failed to validate token. Please check your connection and try again.",
313+
);
314+
}
315+
};
316+
221317
return (
222318
<StyledEngineProvider injectFirst>
223319
<ThemeProvider theme={lightTheme}>
224320
<Suspense fallback={Loading}>
225321
<GlobalContext.Provider value={{ ...context, currentTimeZone }}>
226322
<CssBaseline />
323+
<TokenAuthenticationDialog
324+
open={authenticationDialogOpen}
325+
hasExistingToken={hasAttemptedAuthentication}
326+
onSubmit={handleTokenSubmit}
327+
error={authenticationError}
328+
/>
227329
<HashRouter>
228330
<Routes>
229331
{/* Redirect people hitting the /new path to root. TODO(aguo): Delete this redirect in ray 2.5 */}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import React from "react";
4+
import "@testing-library/jest-dom";
5+
import TokenAuthenticationDialog from "./TokenAuthenticationDialog";
6+
7+
describe("TokenAuthenticationDialog", () => {
8+
const mockOnSubmit = jest.fn();
9+
10+
beforeEach(() => {
11+
mockOnSubmit.mockClear();
12+
});
13+
14+
it("renders with initial message when no existing token", () => {
15+
render(
16+
<TokenAuthenticationDialog
17+
open={true}
18+
hasExistingToken={false}
19+
onSubmit={mockOnSubmit}
20+
/>,
21+
);
22+
23+
expect(
24+
screen.getByText("Token Authentication Required"),
25+
).toBeInTheDocument();
26+
expect(
27+
screen.getByText(/token authentication is enabled for this cluster/i),
28+
).toBeInTheDocument();
29+
});
30+
31+
it("renders with re-authentication message when has existing token", () => {
32+
render(
33+
<TokenAuthenticationDialog
34+
open={true}
35+
hasExistingToken={true}
36+
onSubmit={mockOnSubmit}
37+
/>,
38+
);
39+
40+
expect(
41+
screen.getByText("Token Authentication Required"),
42+
).toBeInTheDocument();
43+
expect(
44+
screen.getByText(/authentication token is invalid or has expired/i),
45+
).toBeInTheDocument();
46+
});
47+
48+
it("displays error message when provided", () => {
49+
const errorMessage = "Invalid token provided";
50+
render(
51+
<TokenAuthenticationDialog
52+
open={true}
53+
hasExistingToken={false}
54+
onSubmit={mockOnSubmit}
55+
error={errorMessage}
56+
/>,
57+
);
58+
59+
expect(screen.getByText(errorMessage)).toBeInTheDocument();
60+
});
61+
62+
it("calls onSubmit with entered token when submit is clicked", async () => {
63+
const user = userEvent.setup();
64+
mockOnSubmit.mockResolvedValue(undefined);
65+
66+
render(
67+
<TokenAuthenticationDialog
68+
open={true}
69+
hasExistingToken={false}
70+
onSubmit={mockOnSubmit}
71+
/>,
72+
);
73+
74+
const input = screen.getByLabelText(/authentication token/i);
75+
await user.type(input, "test-token-123");
76+
77+
const submitButton = screen.getByRole("button", { name: /submit/i });
78+
await user.click(submitButton);
79+
80+
await waitFor(() => {
81+
expect(mockOnSubmit).toHaveBeenCalledWith("test-token-123");
82+
});
83+
});
84+
85+
it("calls onSubmit when Enter key is pressed", async () => {
86+
const user = userEvent.setup();
87+
mockOnSubmit.mockResolvedValue(undefined);
88+
89+
render(
90+
<TokenAuthenticationDialog
91+
open={true}
92+
hasExistingToken={false}
93+
onSubmit={mockOnSubmit}
94+
/>,
95+
);
96+
97+
const input = screen.getByLabelText(/authentication token/i);
98+
await user.type(input, "test-token-123{Enter}");
99+
100+
await waitFor(() => {
101+
expect(mockOnSubmit).toHaveBeenCalledWith("test-token-123");
102+
});
103+
});
104+
105+
it("disables submit button when token is empty", () => {
106+
render(
107+
<TokenAuthenticationDialog
108+
open={true}
109+
hasExistingToken={false}
110+
onSubmit={mockOnSubmit}
111+
/>,
112+
);
113+
114+
const submitButton = screen.getByRole("button", { name: /submit/i });
115+
expect(submitButton).toBeDisabled();
116+
});
117+
118+
it("enables submit button when token is entered", async () => {
119+
const user = userEvent.setup();
120+
render(
121+
<TokenAuthenticationDialog
122+
open={true}
123+
hasExistingToken={false}
124+
onSubmit={mockOnSubmit}
125+
/>,
126+
);
127+
128+
const submitButton = screen.getByRole("button", { name: /submit/i });
129+
expect(submitButton).toBeDisabled();
130+
131+
const input = screen.getByLabelText(/authentication token/i);
132+
await user.type(input, "test-token");
133+
134+
expect(submitButton).not.toBeDisabled();
135+
});
136+
137+
it("toggles token visibility when visibility icon is clicked", async () => {
138+
const user = userEvent.setup();
139+
render(
140+
<TokenAuthenticationDialog
141+
open={true}
142+
hasExistingToken={false}
143+
onSubmit={mockOnSubmit}
144+
/>,
145+
);
146+
147+
const input = screen.getByLabelText(/authentication token/i);
148+
await user.type(input, "secret-token");
149+
150+
// Initially should be password type (hidden)
151+
expect(input).toHaveAttribute("type", "password");
152+
153+
// Click visibility toggle
154+
const toggleButton = screen.getByLabelText(/toggle token visibility/i);
155+
await user.click(toggleButton);
156+
157+
// Should now be text type (visible)
158+
expect(input).toHaveAttribute("type", "text");
159+
160+
// Click again to hide
161+
await user.click(toggleButton);
162+
expect(input).toHaveAttribute("type", "password");
163+
});
164+
165+
it("shows loading state during submission", async () => {
166+
const user = userEvent.setup();
167+
// Mock a slow submission
168+
mockOnSubmit.mockImplementation(
169+
() => new Promise((resolve) => setTimeout(resolve, 100)),
170+
);
171+
172+
render(
173+
<TokenAuthenticationDialog
174+
open={true}
175+
hasExistingToken={false}
176+
onSubmit={mockOnSubmit}
177+
/>,
178+
);
179+
180+
const input = screen.getByLabelText(/authentication token/i);
181+
await user.type(input, "test-token");
182+
183+
const submitButton = screen.getByRole("button", { name: /submit/i });
184+
await user.click(submitButton);
185+
186+
// Should show validating state
187+
await waitFor(() => {
188+
expect(screen.getByText(/validating.../i)).toBeInTheDocument();
189+
});
190+
});
191+
192+
it("does not render when open is false", () => {
193+
render(
194+
<TokenAuthenticationDialog
195+
open={false}
196+
hasExistingToken={false}
197+
onSubmit={mockOnSubmit}
198+
/>,
199+
);
200+
201+
// Dialog should not be visible
202+
expect(
203+
screen.queryByText("Token Authentication Required"),
204+
).not.toBeInTheDocument();
205+
});
206+
});

0 commit comments

Comments
 (0)