Skip to content

Commit a03bee9

Browse files
sampan-s-nayaksampan
authored andcommitted
[Core] Migrate to HttpOnly cookie-based authentication for enhanced security (ray-project#58591)
Migrates Ray dashboard authentication from JavaScript-managed cookies to server-side HttpOnly cookies to enhance security against XSS attacks. This addresses code review feedback to improve the authentication implementation (ray-project#58368) main changes: - authentication middleware first looks for `Authorization` header, if not found it then looks at cookies to look for the auth token - new `api/authenticate` endpoint for verifying token and setting the auth token cookie (with `HttpOnly=true`, `SameSite=Strict` and `secure=true` (when using https)) - removed javascript based cookie manipulation utils and axios interceptors (were previously responsible for setting cookies) - cookies are deleted when connecting to a cluster with `AUTH_MODE=disabled`. connecting to a different ray cluster (with different auth token) using the same endpoint (eg due to port-forwarding or local testing) will reshow the popup and ask users to input the right token. --------- Signed-off-by: sampan <sampan@anyscale.com> Co-authored-by: sampan <sampan@anyscale.com>
1 parent f040088 commit a03bee9

File tree

9 files changed

+172
-257
lines changed

9 files changed

+172
-257
lines changed

python/ray/_private/authentication/authentication_constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@
2323
)
2424

2525
AUTHORIZATION_HEADER_NAME = "authorization"
26+
AUTHORIZATION_BEARER_PREFIX = "Bearer "
27+
28+
AUTHENTICATION_TOKEN_COOKIE_NAME = "ray-authentication-token"
29+
AUTHENTICATION_TOKEN_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days

python/ray/_private/authentication/http_token_authentication.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,22 @@ async def token_auth_middleware(request, handler):
4343
):
4444
return await handler(request)
4545

46+
# Check Authorization header first (for API clients)
4647
auth_header = request.headers.get(
4748
authentication_constants.AUTHORIZATION_HEADER_NAME, ""
4849
)
50+
51+
# If no Authorization header, check cookie (for web dashboard)
52+
if not auth_header:
53+
token = request.cookies.get(
54+
authentication_constants.AUTHENTICATION_TOKEN_COOKIE_NAME
55+
)
56+
if token:
57+
# Format as Bearer token for validation
58+
auth_header = (
59+
authentication_constants.AUTHORIZATION_BEARER_PREFIX + token
60+
)
61+
4962
if not auth_header:
5063
return aiohttp_module.web.Response(
5164
status=401, text="Unauthorized: Missing authentication token"

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

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,10 @@ import duration from "dayjs/plugin/duration";
55
import React, { Suspense, useEffect, useState } from "react";
66
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
77
import {
8+
authenticateWithToken,
89
getAuthenticationMode,
9-
testTokenValidity,
1010
} from "./authentication/authentication";
1111
import { AUTHENTICATION_ERROR_EVENT } from "./authentication/constants";
12-
import {
13-
clearAuthenticationToken,
14-
getAuthenticationToken,
15-
setAuthenticationToken,
16-
} from "./authentication/cookies";
1712
import TokenAuthenticationDialog from "./authentication/TokenAuthenticationDialog";
1813
import ActorDetailPage, { ActorDetailLayout } from "./pages/actor/ActorDetail";
1914
import { ActorLayout } from "./pages/actor/ActorLayout";
@@ -245,20 +240,11 @@ const App = () => {
245240

246241
if (authentication_mode === "token") {
247242
// Token authentication is enabled
248-
const existingToken = getAuthenticationToken();
249-
250-
if (!existingToken) {
251-
// No token found - show dialog immediately
252-
setAuthenticationDialogOpen(true);
253-
}
254-
// If token exists, let it be used by interceptor
255-
// If invalid, interceptor will trigger dialog via 401/403
243+
// The HttpOnly cookie will be sent automatically with requests
244+
// If no valid cookie exists, the first request will trigger 401/403
245+
// and the response interceptor will show the authentication dialog
256246
} else {
257-
// Auth mode is disabled - clear any existing token from cookie
258-
const existingToken = getAuthenticationToken();
259-
if (existingToken) {
260-
clearAuthenticationToken();
261-
}
247+
// Auth mode is disabled
262248
}
263249
} catch (error) {
264250
console.error("Failed to check authentication mode:", error);
@@ -294,12 +280,12 @@ const App = () => {
294280
// Handle token submission from dialog
295281
const handleTokenSubmit = async (token: string) => {
296282
try {
297-
// Test if token is valid
298-
const isValid = await testTokenValidity(token);
283+
// Authenticate with the server - this will validate the token
284+
// and set an HttpOnly cookie if valid
285+
const isValid = await authenticateWithToken(token);
299286

300287
if (isValid) {
301-
// Save token to cookie
302-
setAuthenticationToken(token);
288+
// Token is valid and server has set HttpOnly cookie
303289
setHasAttemptedAuthentication(true);
304290
setAuthenticationDialogOpen(false);
305291
setAuthenticationError(undefined);
@@ -314,9 +300,9 @@ const App = () => {
314300
);
315301
}
316302
} catch (error) {
317-
console.error("Failed to validate token:", error);
303+
console.error("Failed to authenticate:", error);
318304
setAuthenticationError(
319-
"Failed to validate token. Please check your connection and try again.",
305+
"Failed to authenticate. Please check your connection and try again.",
320306
);
321307
}
322308
};

python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.test.tsx renamed to python/ray/dashboard/client/src/authentication/TokenAuthenticationDialog.component.test.tsx

File renamed without changes.

python/ray/dashboard/client/src/authentication/authentication.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Authentication service for Ray dashboard.
3-
* Provides functions to check authentication mode and validate tokens when token auth is enabled.
3+
* Provides functions to check authentication mode and authenticate with tokens.
44
*/
55

66
import axios from "axios";
@@ -28,22 +28,24 @@ export const getAuthenticationMode =
2828
};
2929

3030
/**
31-
* Test if a token is valid by making a request to the /api/version endpoint
32-
* which is fast and reliable.
31+
* Authenticate with the server using a token.
32+
* If the token is valid, the server will set an HttpOnly cookie that will be
33+
* automatically included in all subsequent requests.
3334
*
34-
* Note: This uses plain axios (not axiosInstance) to avoid the request interceptor
35-
* that would add the token from cookies, since we want to test the specific token
36-
* passed as a parameter. It also avoids the response interceptor that would dispatch
37-
* global authentication error events, since we handle 401/403 errors locally.
38-
*
39-
* @param token - The authentication token to test
40-
* @returns Promise resolving to true if token is valid, false otherwise
35+
* @param token - The authentication token to validate and use
36+
* @returns Promise resolving to true if authentication succeeded, false otherwise
4137
*/
42-
export const testTokenValidity = async (token: string): Promise<boolean> => {
38+
export const authenticateWithToken = async (
39+
token: string,
40+
): Promise<boolean> => {
4341
try {
44-
await axios.get(formatUrl("/api/version"), {
45-
headers: { Authorization: `Bearer ${token}` },
46-
});
42+
await axios.post(
43+
formatUrl("/api/authenticate"),
44+
{},
45+
{
46+
headers: { Authorization: `Bearer ${token}` },
47+
},
48+
);
4749
return true;
4850
} catch (error: any) {
4951
// 401 (Unauthorized) or 403 (Forbidden) means invalid token

python/ray/dashboard/client/src/authentication/cookies.test.ts

Lines changed: 0 additions & 107 deletions
This file was deleted.

python/ray/dashboard/client/src/authentication/cookies.ts

Lines changed: 0 additions & 78 deletions
This file was deleted.

python/ray/dashboard/client/src/service/requestHandlers.ts

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
1212
import { AUTHENTICATION_ERROR_EVENT } from "../authentication/constants";
13-
import { getAuthenticationToken } from "../authentication/cookies";
1413

1514
/**
1615
* This function formats URLs such that the user's browser
@@ -34,20 +33,6 @@ const axiosInstance = axios.create();
3433
// Export the configured axios instance for direct use when needed
3534
export { axiosInstance };
3635

37-
// Request interceptor: Add authentication token if available
38-
axiosInstance.interceptors.request.use(
39-
(config) => {
40-
const token = getAuthenticationToken();
41-
if (token) {
42-
config.headers.Authorization = `Bearer ${token}`;
43-
}
44-
return config;
45-
},
46-
(error) => {
47-
return Promise.reject(error);
48-
},
49-
);
50-
5136
// Response interceptor: Handle 401/403 errors
5237
axiosInstance.interceptors.response.use(
5338
(response) => {
@@ -57,10 +42,10 @@ axiosInstance.interceptors.response.use(
5742
// If we get 401 (Unauthorized) or 403 (Forbidden), dispatch an event
5843
// so the App component can show the authentication dialog
5944
if (error.response?.status === 401 || error.response?.status === 403) {
60-
// Check if there was a token in the request
61-
const hadToken = !!getAuthenticationToken();
62-
6345
// Dispatch custom event for authentication error
46+
// 401 means no token was provided, 403 means the token was invalid.
47+
// This distinction is used to show a more specific message in the dialog.
48+
const hadToken = error.response.status === 403;
6449
window.dispatchEvent(
6550
new CustomEvent(AUTHENTICATION_ERROR_EVENT, {
6651
detail: { hadToken },

0 commit comments

Comments
 (0)