update auth token storage (#40504)

**Related issue:** Resolves #14401

this updates the mechanism of storing the auth token for a user that is
used for making requests and validating a user session. We change the
storage from local storage to a cookie. This allow a bit more security
and prepares for a future change where we will allow the browser to
handle setting and passing the auth token in the request.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] QA'd all new/changed functionality manually
This commit is contained in:
Gabriel Hernandez 2026-02-26 17:05:13 +00:00 committed by George Karr
parent 3ef6f40a10
commit 084ebe6e16
23 changed files with 87 additions and 46 deletions

View file

@ -0,0 +1 @@
- update storage of the auth token used in the UI; move if from local storage to a cookie.

View file

@ -13,6 +13,7 @@ const DEFAULT_USER_MOCK: IUser = {
global_role: "admin",
api_only: false,
teams: [],
fleets: [],
};
const createMockUser = (overrides?: Partial<IUser>): IUser => {

View file

@ -10,7 +10,7 @@ import QueryProvider from "context/query";
import PolicyProvider from "context/policy";
import NotificationProvider from "context/notification";
import { AppContext } from "context/app";
import { authToken, clearToken } from "utilities/local";
import authToken from "utilities/auth_token";
import useDeepEffect from "hooks/useDeepEffect";
import { QueryParams } from "utilities/url";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
@ -206,7 +206,7 @@ const App = ({ children, location }: IAppProps): JSX.Element => {
) {
return true;
}
clearToken();
authToken.remove();
// if this is not the device user page,
// redirect to login
if (!location?.pathname.includes("/device/")) {
@ -217,7 +217,7 @@ const App = ({ children, location }: IAppProps): JSX.Element => {
};
useEffect(() => {
if (authToken() && !location?.pathname.includes("/device/")) {
if (authToken.get() && !location?.pathname.includes("/device/")) {
fetchCurrentUser();
}
}, [location?.pathname]);

View file

@ -46,7 +46,7 @@ export interface IUser {
id: number;
name: string;
email: string;
role: UserRole;
role?: UserRole;
force_password_reset: boolean;
gravatar_url?: string;
gravatar_url_dark?: string;
@ -55,6 +55,7 @@ export interface IUser {
global_role: UserRole | null;
api_only: boolean;
teams: ITeam[];
fleets: ITeam[]; // This will eventually replace `teams`, but for now we need both to avoid breaking changes.
}
/**

View file

@ -5,7 +5,7 @@ import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { IUser } from "interfaces/user";
import usersAPI from "services/entities/users";
import { authToken } from "utilities/local";
import authToken from "utilities/auth_token";
import deepDifference from "utilities/deep_difference";
import formatErrorResponse from "utilities/format_error_response";
@ -192,7 +192,7 @@ const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => {
</p>
</InfoBanner>
<InputFieldHiddenContent
value={authToken() || ""}
value={authToken.get() || ""}
helpText={
<>
This token is intended for SSO users to authenticate in the

View file

@ -10,6 +10,7 @@ import { RoutingContext } from "context/routing";
import { ISSOSettings } from "interfaces/ssoSettings";
import { ILoginUserData } from "interfaces/user";
import local from "utilities/local";
import authToken from "utilities/auth_token";
import configAPI from "services/entities/config";
import sessionsAPI, { ISSOSettingsResponse } from "services/entities/sessions";
import formatErrorResponse from "utilities/format_error_response";
@ -121,7 +122,7 @@ const LoginPage = ({ router, location }: ILoginPageProps) => {
const response = await sessionsAPI.login(formData);
const { user, available_teams, token } = response;
local.setItem("auth_token", token);
authToken.save(token);
setCurrentUser(user);
setAvailableTeams(user, available_teams);

View file

@ -7,6 +7,7 @@ import paths from "router/paths";
import { AppContext } from "context/app";
import sessionsAPI from "services/entities/sessions";
import local from "utilities/local";
import authToken from "utilities/auth_token";
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
// @ts-ignore
@ -35,7 +36,7 @@ const LoginPreviewPage = ({ router }: ILoginPreviewPageProps): JSX.Element => {
const { user, available_teams, token } = await sessionsAPI.login(
formData
);
local.setItem("auth_token", token);
authToken.save(token);
setCurrentUser(user);
setAvailableTeams(user, available_teams);

View file

@ -4,7 +4,7 @@ import { InjectedRouter } from "react-router";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import sessionsAPI from "services/entities/sessions";
import { clearToken } from "utilities/local";
import authToken from "utilities/auth_token";
interface ILogoutPageProps {
router: InjectedRouter;
@ -18,7 +18,7 @@ const LogoutPage = ({ router }: ILogoutPageProps) => {
const logoutUser = async () => {
try {
await sessionsAPI.destroy();
clearToken();
authToken.remove();
setTimeout(() => {
window.location.href = isSandboxMode
? "https://www.fleetdm.com/logout"

View file

@ -6,6 +6,7 @@ import { AppContext } from "context/app";
import { RoutingContext } from "context/routing";
import paths from "router/paths";
import local from "utilities/local";
import authToken from "utilities/auth_token";
import configAPI from "services/entities/config";
import sessionsAPI from "services/entities/sessions";
@ -44,7 +45,7 @@ const MfaPage = ({ router, params }: IMfaPage) => {
const response = await sessionsAPI.finishMFA({ token: mfaToken });
const { user, available_teams, token } = response;
local.setItem("auth_token", token);
authToken.save(token);
setCurrentUser(user);
setAvailableTeams(user, available_teams);

View file

@ -6,6 +6,7 @@ import paths from "router/paths";
import { AppContext } from "context/app";
import usersAPI from "services/entities/users";
import local from "utilities/local";
import authToken from "utilities/auth_token";
import FlashMessage from "components/FlashMessage";
import { INotification } from "interfaces/notification";
@ -61,7 +62,7 @@ const RegistrationPage = ({ router }: IRegistrationPageProps) => {
setIsLoading(true);
try {
const { token } = await usersAPI.setup(formData);
local.setItem("auth_token", token);
authToken.save(token);
const { user, available_teams, settings } = await usersAPI.me();
setCurrentUser(user);

View file

@ -7,7 +7,7 @@ import React, { useCallback } from "react";
import { Async, OnChangeHandler, Option } from "react-select";
import classnames from "classnames";
import { authToken } from "utilities/local";
import authToken from "utilities/auth_token";
import debounce from "utilities/debounce";
import permissionUtils from "utilities/permissions";
import { IDropdownOption } from "interfaces/dropdownOption";
@ -97,7 +97,7 @@ const AutocompleteDropdown = ({
fetch(createUrl(resourceUrl, input), {
headers: {
authorization: `Bearer ${authToken()}`,
authorization: `Bearer ${authToken.get()}`,
},
})
.then((res) => {

View file

@ -7,7 +7,7 @@ import { IApiError } from "interfaces/errors";
import { IInvite, IEditInviteFormData } from "interfaces/invite";
import { IUser, IUserFormErrors } from "interfaces/user";
import { ITeam } from "interfaces/team";
import { clearToken } from "utilities/local";
import authToken from "utilities/auth_token";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
@ -415,7 +415,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
.deleteSessions(userEditing.id)
.then(() => {
if (isResettingCurrentUser) {
clearToken();
authToken.remove();
setTimeout(() => {
window.location.href = "/";
}, 500);

View file

@ -9,7 +9,7 @@ import campaignHelpers from "utilities/campaign_helpers";
import queryAPI from "services/entities/queries";
import debounce from "utilities/debounce";
import { BASE_URL, DEFAULT_CAMPAIGN_STATE } from "utilities/constants";
import { authToken } from "utilities/local";
import authToken from "utilities/auth_token";
import { ICampaign, ICampaignState } from "interfaces/campaign";
import { IPolicy } from "interfaces/policy";
@ -102,7 +102,7 @@ const RunQuery = ({
websocket?.send(
JSON.stringify({
type: "auth",
data: { token: authToken() },
data: { token: authToken.get() },
})
);
websocket?.send(

View file

@ -10,7 +10,7 @@ import campaignHelpers from "utilities/campaign_helpers";
import debounce from "utilities/debounce";
import { BASE_URL, DEFAULT_CAMPAIGN_STATE } from "utilities/constants";
import { authToken } from "utilities/local";
import authToken from "utilities/auth_token";
import { ICampaign, ICampaignState } from "interfaces/campaign";
import { IQuery } from "interfaces/query";
@ -113,7 +113,7 @@ const RunQuery = ({
websocket?.send(
JSON.stringify({
type: "auth",
data: { token: authToken() },
data: { token: authToken.get() },
})
);
websocket?.send(

View file

@ -5,7 +5,7 @@ import paths from "router/paths";
import { AppContext } from "context/app";
import { RoutingContext } from "context/routing";
import useDeepEffect from "hooks/useDeepEffect";
import { authToken, clearToken } from "utilities/local";
import authToken from "utilities/auth_token";
import { useErrorHandler } from "react-error-boundary";
import permissions from "utilities/permissions";
@ -78,7 +78,7 @@ export const AuthenticatedRoutes = ({
useDeepEffect(() => {
// this works with App.tsx. if authToken does
// exist, user state is checked and fetched if null
if (!authToken()) {
if (!authToken.get()) {
if (window.location.hostname.includes(".sandbox.fleetdm.com")) {
window.location.href = "https://www.fleetdm.com/try-fleet/login";
}
@ -86,7 +86,7 @@ export const AuthenticatedRoutes = ({
return redirectToLogin();
}
if (currentUser?.force_password_reset && !authToken()) {
if (currentUser?.force_password_reset && !authToken.get()) {
return redirectToPasswordReset();
}
@ -95,7 +95,7 @@ export const AuthenticatedRoutes = ({
}
if (currentUser && permissions.isNoAccess(currentUser)) {
clearToken();
authToken.remove();
return handlePageError({ status: 403 });
}
}, [currentUser]);

View file

@ -1,6 +1,6 @@
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { authToken } from "utilities/local";
import authToken from "utilities/auth_token";
interface IGetAndroidSignupUrlResponse {
android_enterprise_signup_url: string;
@ -38,7 +38,7 @@ export default {
const response = await fetch(endpoints.MDM_ANDROID_SSE_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${authToken()}`,
Authorization: `Bearer ${authToken.get()}`,
},
signal: abortSignal,
});

View file

@ -1,4 +1,6 @@
import { ISSOSettings } from "interfaces/ssoSettings";
import { IUser } from "interfaces/user";
import { ITeamSummary } from "interfaces/team";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import helpers from "utilities/helpers";
@ -16,8 +18,15 @@ export interface ISSOSettingsResponse {
settings: ISSOSettings;
}
export interface ILoginResponse {
user: IUser;
available_teams: ITeamSummary[];
available_fleets: ITeamSummary[];
token: string;
}
export default {
login: ({ email, password }: ILoginProps) => {
login: ({ email, password }: ILoginProps): Promise<ILoginResponse> => {
const { LOGIN } = endpoints;
return sendRequest(
@ -38,13 +47,12 @@ export default {
throw rawResponse;
}
const response = rawResponse.data;
const { user, available_teams } = response;
const { user } = response;
const userWithGravatarUrl = helpers.addGravatarUrlToResource(user);
return {
...response,
user: userWithGravatarUrl,
available_teams,
};
});
},

View file

@ -3,8 +3,9 @@ import axios, {
ResponseType as AxiosResponseType,
AxiosProgressEvent,
} from "axios";
import URL_PREFIX from "router/url_prefix";
import { authToken } from "utilities/local";
import authToken from "utilities/auth_token";
export const sendRequestWithProgress = async ({
method,
@ -32,7 +33,6 @@ export const sendRequestWithProgress = async ({
const { origin } = global.window.location;
const url = `${origin}${URL_PREFIX}/api${path}`;
const token = authToken();
try {
const response = await axios({
@ -42,7 +42,7 @@ export const sendRequestWithProgress = async ({
responseType,
timeout,
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${authToken.get()}`,
},
onDownloadProgress,
onUploadProgress,
@ -79,7 +79,6 @@ export const sendRequest = async (
const { origin } = global.window.location;
const url = `${origin}${URL_PREFIX}/api${path}`;
const token = authToken();
try {
const response = await axios({
@ -89,7 +88,7 @@ export const sendRequest = async (
responseType,
timeout,
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${authToken.get()}`,
},
});
@ -128,7 +127,6 @@ export const sendRequestWithHeaders = async (
const { origin } = global.window.location;
const url = `${origin}${URL_PREFIX}/api${path}`;
const token = authToken();
try {
const response = await axios({
@ -138,7 +136,7 @@ export const sendRequestWithHeaders = async (
responseType,
timeout,
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${authToken.get()}`,
...customHeaders,
},
});
@ -193,7 +191,6 @@ export const sendRequestWithProgressAndHeaders = async ({
const { origin } = global.window.location;
const url = `${origin}${URL_PREFIX}/api${path}`;
const token = authToken();
try {
const response = await axios({
@ -203,7 +200,7 @@ export const sendRequestWithProgressAndHeaders = async ({
responseType,
timeout,
headers: {
Authorization: `Bearer ${token}`,
Authorization: `Bearer ${authToken.get()}`,
...customHeaders,
},
onDownloadProgress,

View file

@ -25,6 +25,7 @@ export const userStub: IUser = {
gravatar_url: "https://image.com",
sso_enabled: false,
teams: [{ ...userTeamStub }],
fleets: [{ ...userTeamStub }],
};
export default {

View file

@ -0,0 +1,25 @@
/**
* This contains a collection of utility functions for working with
* users auth token.
*/
import Cookie from "js-cookie";
const save = (token: string): void => {
Cookie.set("__Host-token", token, { secure: true, sameSite: "lax" });
};
const get = (): string | null => {
return Cookie.get("__Host-token") || null;
};
const remove = (): void => {
// NOTE: the entire cookie including the name and values must be provided
// to correctly remove. That is why we include the options here as well.
Cookie.remove("__Host-token", { secure: true, sameSite: "lax" });
};
export default {
save,
get,
remove,
};

View file

@ -17,12 +17,4 @@ const local = {
},
};
export const authToken = (): string | null => {
return local.getItem("auth_token");
};
export const clearToken = (): void => {
return local.removeItem("auth_token");
};
export default local;

View file

@ -30,6 +30,7 @@
"file-saver": "1.3.8",
"history": "2.1.0",
"isomorphic-fetch": "3.0.0",
"js-cookie": "3.0.5",
"js-md5": "0.7.3",
"js-yaml": "3.14.2",
"lodash": "4.17.23",
@ -104,6 +105,7 @@
"@types/expect": "1.20.3",
"@types/file-saver": "2.0.5",
"@types/jest": "29.5.12",
"@types/js-cookie": "3.0.6",
"@types/js-md5": "0.4.3",
"@types/js-yaml": "4.0.5",
"@types/lodash": "4.14.179",

View file

@ -3680,6 +3680,11 @@
expect "^29.0.0"
pretty-format "^29.0.0"
"@types/js-cookie@3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.6.tgz#a04ca19e877687bd449f5ad37d33b104b71fdf95"
integrity sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==
"@types/js-md5@0.4.3":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@types/js-md5/-/js-md5-0.4.3.tgz#c134cbb71c75018876181f886a12a2f9962a312a"
@ -9308,6 +9313,10 @@ joycon@^3.0.1:
version "3.1.1"
resolved "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz"
integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==
js-cookie@3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc"
integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==
js-md5@0.7.3:
version "0.7.3"