diff --git a/changes/issue-14401-update-auth-token-storage b/changes/issue-14401-update-auth-token-storage new file mode 100644 index 0000000000..0e3ecda2e3 --- /dev/null +++ b/changes/issue-14401-update-auth-token-storage @@ -0,0 +1 @@ +- update storage of the auth token used in the UI; move if from local storage to a cookie. diff --git a/frontend/__mocks__/userMock.ts b/frontend/__mocks__/userMock.ts index a6436284d9..152400a514 100644 --- a/frontend/__mocks__/userMock.ts +++ b/frontend/__mocks__/userMock.ts @@ -13,6 +13,7 @@ const DEFAULT_USER_MOCK: IUser = { global_role: "admin", api_only: false, teams: [], + fleets: [], }; const createMockUser = (overrides?: Partial): IUser => { diff --git a/frontend/components/App/App.tsx b/frontend/components/App/App.tsx index ca805206fb..d3705b21af 100644 --- a/frontend/components/App/App.tsx +++ b/frontend/components/App/App.tsx @@ -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]); diff --git a/frontend/interfaces/user.ts b/frontend/interfaces/user.ts index 24ecd8191f..fcb80461b4 100644 --- a/frontend/interfaces/user.ts +++ b/frontend/interfaces/user.ts @@ -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. } /** diff --git a/frontend/pages/AccountPage/AccountPage.tsx b/frontend/pages/AccountPage/AccountPage.tsx index 2a28928307..72793518aa 100644 --- a/frontend/pages/AccountPage/AccountPage.tsx +++ b/frontend/pages/AccountPage/AccountPage.tsx @@ -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 => {

This token is intended for SSO users to authenticate in the diff --git a/frontend/pages/LoginPage/LoginPage.tsx b/frontend/pages/LoginPage/LoginPage.tsx index faaf285840..581d02c1a5 100644 --- a/frontend/pages/LoginPage/LoginPage.tsx +++ b/frontend/pages/LoginPage/LoginPage.tsx @@ -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); diff --git a/frontend/pages/LoginPage/LoginPreviewPage.tsx b/frontend/pages/LoginPage/LoginPreviewPage.tsx index 8005c110dd..dae263f201 100644 --- a/frontend/pages/LoginPage/LoginPreviewPage.tsx +++ b/frontend/pages/LoginPage/LoginPreviewPage.tsx @@ -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); diff --git a/frontend/pages/LogoutPage/LogoutPage.tsx b/frontend/pages/LogoutPage/LogoutPage.tsx index d3ac154622..d43cc31caa 100644 --- a/frontend/pages/LogoutPage/LogoutPage.tsx +++ b/frontend/pages/LogoutPage/LogoutPage.tsx @@ -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" diff --git a/frontend/pages/MfaPage/MfaPage.tsx b/frontend/pages/MfaPage/MfaPage.tsx index cb45fc2bf6..5c7b00a0c6 100644 --- a/frontend/pages/MfaPage/MfaPage.tsx +++ b/frontend/pages/MfaPage/MfaPage.tsx @@ -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); diff --git a/frontend/pages/RegistrationPage/RegistrationPage.tsx b/frontend/pages/RegistrationPage/RegistrationPage.tsx index 6ded1dcc53..ba55f0eefb 100644 --- a/frontend/pages/RegistrationPage/RegistrationPage.tsx +++ b/frontend/pages/RegistrationPage/RegistrationPage.tsx @@ -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); diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/components/AutocompleteDropdown/AutocompleteDropdown.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/components/AutocompleteDropdown/AutocompleteDropdown.tsx index f8f029ee4c..62d5e17fee 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/components/AutocompleteDropdown/AutocompleteDropdown.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/components/AutocompleteDropdown/AutocompleteDropdown.tsx @@ -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) => { diff --git a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx index 576251dcf4..5c79b08577 100644 --- a/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx +++ b/frontend/pages/admin/UserManagementPage/components/UsersTable/UsersTable.tsx @@ -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); diff --git a/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx b/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx index 715e4c04ec..725320e491 100644 --- a/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx +++ b/frontend/pages/policies/PolicyPage/screens/RunQuery.tsx @@ -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( diff --git a/frontend/pages/queries/live/screens/RunQuery.tsx b/frontend/pages/queries/live/screens/RunQuery.tsx index 8c514d5a45..088fe64518 100644 --- a/frontend/pages/queries/live/screens/RunQuery.tsx +++ b/frontend/pages/queries/live/screens/RunQuery.tsx @@ -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( diff --git a/frontend/router/components/AuthenticatedRoutes/AuthenticatedRoutes.tsx b/frontend/router/components/AuthenticatedRoutes/AuthenticatedRoutes.tsx index f400171b3b..5837bc91a0 100644 --- a/frontend/router/components/AuthenticatedRoutes/AuthenticatedRoutes.tsx +++ b/frontend/router/components/AuthenticatedRoutes/AuthenticatedRoutes.tsx @@ -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]); diff --git a/frontend/services/entities/mdm_android.ts b/frontend/services/entities/mdm_android.ts index 4e66fa2074..49d1506244 100644 --- a/frontend/services/entities/mdm_android.ts +++ b/frontend/services/entities/mdm_android.ts @@ -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, }); diff --git a/frontend/services/entities/sessions.ts b/frontend/services/entities/sessions.ts index 1f9f765d28..bc1787d8e5 100644 --- a/frontend/services/entities/sessions.ts +++ b/frontend/services/entities/sessions.ts @@ -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 => { 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, }; }); }, diff --git a/frontend/services/index.ts b/frontend/services/index.ts index 2226799e5c..caad3a1d27 100644 --- a/frontend/services/index.ts +++ b/frontend/services/index.ts @@ -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, diff --git a/frontend/test/stubs.ts b/frontend/test/stubs.ts index 6901ce23cf..05439cc986 100644 --- a/frontend/test/stubs.ts +++ b/frontend/test/stubs.ts @@ -25,6 +25,7 @@ export const userStub: IUser = { gravatar_url: "https://image.com", sso_enabled: false, teams: [{ ...userTeamStub }], + fleets: [{ ...userTeamStub }], }; export default { diff --git a/frontend/utilities/auth_token/index.ts b/frontend/utilities/auth_token/index.ts new file mode 100644 index 0000000000..7158fb07d9 --- /dev/null +++ b/frontend/utilities/auth_token/index.ts @@ -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, +}; diff --git a/frontend/utilities/local.ts b/frontend/utilities/local.ts index 63c985f146..8c4967a2b6 100644 --- a/frontend/utilities/local.ts +++ b/frontend/utilities/local.ts @@ -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; diff --git a/package.json b/package.json index 208bbac480..fc5bd8c41a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index e55fa33157..c0234a4cbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"