diff --git a/apps/admin-ui/src/components/alert/Alerts.tsx b/apps/admin-ui/src/components/alert/Alerts.tsx index df631940e6..2db400b698 100644 --- a/apps/admin-ui/src/components/alert/Alerts.tsx +++ b/apps/admin-ui/src/components/alert/Alerts.tsx @@ -1,6 +1,5 @@ +import { NetworkError } from "@keycloak/keycloak-admin-client"; import { AlertVariant } from "@patternfly/react-core"; -import type { AxiosError } from "axios"; -import axios from "axios"; import { FunctionComponent, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -85,8 +84,8 @@ function getErrorMessage(error: unknown) { return error; } - if (axios.isAxiosError(error)) { - return getErrorMessageAxios(error); + if (error instanceof NetworkError) { + return getNetworkErrorMessage(error); } if (error instanceof Error) { @@ -96,8 +95,8 @@ function getErrorMessage(error: unknown) { throw new Error("Unable to determine error message."); } -function getErrorMessageAxios(error: AxiosError) { - const data = (error.response?.data ?? {}) as Record; +function getNetworkErrorMessage({ responseData }: NetworkError) { + const data = responseData as Record; for (const key of ["error_description", "errorMessage", "error"]) { const value = data[key]; diff --git a/apps/admin-ui/src/context/RealmsContext.tsx b/apps/admin-ui/src/context/RealmsContext.tsx index 9f6cb271a3..cc58eed8ce 100644 --- a/apps/admin-ui/src/context/RealmsContext.tsx +++ b/apps/admin-ui/src/context/RealmsContext.tsx @@ -1,3 +1,4 @@ +import { NetworkError } from "@keycloak/keycloak-admin-client"; import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { sortBy } from "lodash-es"; import { @@ -7,7 +8,6 @@ import { useRef, useState, } from "react"; -import axios from "axios"; import { RecentUsed } from "../components/realm-selector/recent-used"; import { createNamedContext } from "../utils/createNamedContext"; @@ -46,11 +46,7 @@ export const RealmsProvider: FunctionComponent = ({ children }) => { try { return await adminClient.realms.find({ briefRepresentation: true }); } catch (error) { - if ( - axios.isAxiosError(error) && - error.response && - error.response.status < 500 - ) { + if (error instanceof NetworkError && error.response.status < 500) { return []; } diff --git a/apps/admin-ui/src/context/auth/AdminClient.tsx b/apps/admin-ui/src/context/auth/AdminClient.tsx index d5f873568e..1d3fc7a5e0 100644 --- a/apps/admin-ui/src/context/auth/AdminClient.tsx +++ b/apps/admin-ui/src/context/auth/AdminClient.tsx @@ -1,5 +1,4 @@ import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; -import axios from "axios"; import Keycloak from "keycloak-js"; import { DependencyList, useEffect } from "react"; import { useErrorHandler } from "react-error-boundary"; @@ -39,35 +38,24 @@ export function useFetch( callback: (param: T) => void, deps?: DependencyList ) { - const { adminClient } = useAdminClient(); const onError = useErrorHandler(); + const controller = new AbortController(); + const { signal } = controller; useEffect(() => { - const source = axios.CancelToken.source(); - - adminClient.setConfig({ - requestConfig: { cancelToken: source.token }, - }); - adminClientCall() .then((result) => { - if (!source.token.reason) { + if (!signal.aborted) { callback(result); } }) .catch((error) => { - if (!axios.isCancel(error)) { + if (!signal.aborted) { onError(error); } }); - adminClient.setConfig({ - requestConfig: { cancelToken: undefined }, - }); - - return () => { - source.cancel(); - }; + return () => controller.abort(); }, deps); } diff --git a/libs/keycloak-admin-client/README.md b/libs/keycloak-admin-client/README.md index 76ca6f2d0f..072daa1c36 100644 --- a/libs/keycloak-admin-client/README.md +++ b/libs/keycloak-admin-client/README.md @@ -22,8 +22,8 @@ import KcAdminClient from '@keycloak/keycloak-admin-client'; // { // baseUrl: 'http://127.0.0.1:8080', // realmName: 'master', -// requestConfig: { -// /* Axios request config options https://github.com/axios/axios#request-config */ +// requestOptions: { +// /* Fetch request options https://developer.mozilla.org/en-US/docs/Web/API/fetch#options */ // }, // } const kcAdminClient = new KcAdminClient(); diff --git a/libs/keycloak-admin-client/package.json b/libs/keycloak-admin-client/package.json index 564cd19db3..00fbee3c09 100644 --- a/libs/keycloak-admin-client/package.json +++ b/libs/keycloak-admin-client/package.json @@ -37,7 +37,6 @@ } }, "dependencies": { - "axios": "^0.27.2", "camelize-ts": "^2.1.1", "lodash-es": "^4.17.21", "url-join": "^5.0.0", diff --git a/libs/keycloak-admin-client/src/client.ts b/libs/keycloak-admin-client/src/client.ts index 37ef23b12d..655bee8e74 100644 --- a/libs/keycloak-admin-client/src/client.ts +++ b/libs/keycloak-admin-client/src/client.ts @@ -1,4 +1,3 @@ -import type { AxiosRequestConfig } from "axios"; import type { RequestArgs } from "./resources/agent.js"; import { AttackDetection } from "./resources/attackDetection.js"; import { AuthenticationManagement } from "./resources/authenticationManagement.js"; @@ -26,7 +25,7 @@ export interface TokenProvider { export interface ConnectionConfig { baseUrl?: string; realmName?: string; - requestConfig?: AxiosRequestConfig; + requestOptions?: RequestInit; requestArgOptions?: Pick; } @@ -55,14 +54,14 @@ export class KeycloakAdminClient { public accessToken?: string; public refreshToken?: string; - private requestConfig?: AxiosRequestConfig; + private requestOptions?: RequestInit; private globalRequestArgOptions?: Pick; private tokenProvider?: TokenProvider; constructor(connectionConfig?: ConnectionConfig) { this.baseUrl = connectionConfig?.baseUrl || defaultBaseUrl; this.realmName = connectionConfig?.realmName || defaultRealm; - this.requestConfig = connectionConfig?.requestConfig; + this.requestOptions = connectionConfig?.requestOptions; this.globalRequestArgOptions = connectionConfig?.requestArgOptions; // Initialize resources @@ -89,7 +88,7 @@ export class KeycloakAdminClient { baseUrl: this.baseUrl, realmName: this.realmName, credentials, - requestConfig: this.requestConfig, + requestOptions: this.requestOptions, }); this.accessToken = accessToken; this.refreshToken = refreshToken; @@ -115,8 +114,8 @@ export class KeycloakAdminClient { return this.accessToken; } - public getRequestConfig() { - return this.requestConfig; + public getRequestOptions() { + return this.requestOptions; } public getGlobalRequestArgOptions(): @@ -139,6 +138,6 @@ export class KeycloakAdminClient { ) { this.realmName = connectionConfig.realmName; } - this.requestConfig = connectionConfig.requestConfig; + this.requestOptions = connectionConfig.requestOptions; } } diff --git a/libs/keycloak-admin-client/src/index.ts b/libs/keycloak-admin-client/src/index.ts index 03b575c6a1..4829e2ded7 100644 --- a/libs/keycloak-admin-client/src/index.ts +++ b/libs/keycloak-admin-client/src/index.ts @@ -1,5 +1,7 @@ -import { RequiredActionAlias } from "./defs/requiredActionProviderRepresentation.js"; import { KeycloakAdminClient } from "./client.js"; +import { RequiredActionAlias } from "./defs/requiredActionProviderRepresentation.js"; export const requiredAction = RequiredActionAlias; export default KeycloakAdminClient; +export { NetworkError } from "./utils/fetchWithError.js"; +export type { NetworkErrorOptions } from "./utils/fetchWithError.js"; diff --git a/libs/keycloak-admin-client/src/resources/agent.ts b/libs/keycloak-admin-client/src/resources/agent.ts index d3550a4886..535f995a28 100644 --- a/libs/keycloak-admin-client/src/resources/agent.ts +++ b/libs/keycloak-admin-client/src/resources/agent.ts @@ -1,13 +1,19 @@ -import axios, { AxiosRequestConfig, AxiosRequestHeaders, Method } from "axios"; import { isUndefined, last, omit, pick } from "lodash-es"; import urlJoin from "url-join"; import { parseTemplate } from "url-template"; import type { KeycloakAdminClient } from "../client.js"; +import { + fetchWithError, + NetworkError, + parseResponse, +} from "../utils/fetchWithError.js"; import { stringifyQueryParams } from "../utils/stringifyQueryParams.js"; // constants const SLASH = "/"; +type Method = "GET" | "POST" | "PUT" | "DELETE"; + // interface export interface RequestArgs { method: Method; @@ -31,7 +37,7 @@ export interface RequestArgs { * Keys to be ignored, meaning that they will not be filtered out of the request payload even if they are a part of `urlParamKeys` or `queryParamKeys`, */ ignoredKeys?: string[]; - headers?: AxiosRequestHeaders; + headers?: HeadersInit; } export class Agent { @@ -76,7 +82,9 @@ export class Agent { const baseParams = this.getBaseParams?.() ?? {}; // Filter query parameters by queryParamKeys - const queryParams = queryParamKeys ? pick(payload, queryParamKeys) : null; + const queryParams = queryParamKeys + ? pick(payload, queryParamKeys) + : undefined; // Add filtered payload parameters to base parameters const allUrlParamKeys = [...Object.keys(baseParams), ...urlParamKeys]; @@ -128,7 +136,9 @@ export class Agent { const baseParams = this.getBaseParams?.() ?? {}; // Filter query parameters by queryParamKeys - const queryParams = queryParamKeys ? pick(query, queryParamKeys) : null; + const queryParams = queryParamKeys + ? pick(query, queryParamKeys) + : undefined; // Add filtered query parameters to base parameters const allUrlParamKeys = [...Object.keys(baseParams), ...urlParamKeys]; @@ -171,64 +181,72 @@ export class Agent { path: string; payload: any; urlParams: any; - queryParams?: Record | null; + queryParams?: Record; catchNotFound: boolean; payloadKey?: string; returnResourceIdInLocationHeader?: { field: string }; - headers?: AxiosRequestHeaders; + headers?: HeadersInit; }) { const newPath = urlJoin(this.basePath, path); // Parse template and replace with values from urlParams const pathTemplate = parseTemplate(newPath); const parsedPath = pathTemplate.expand(urlParams); - const url = `${this.getBaseUrl?.() ?? ""}${parsedPath}`; + const url = new URL(`${this.getBaseUrl?.() ?? ""}${parsedPath}`); + const requestOptions = { ...this.client.getRequestOptions() }; + const requestHeaders = new Headers([ + ...new Headers(requestOptions.headers).entries(), + ["authorization", `Bearer ${await this.client.getAccessToken()}`], + ["accept", "application/json, text/plain, */*"], + ...new Headers(headers).entries(), + ]); - // Prepare request config - const requestConfig: AxiosRequestConfig = { - paramsSerializer: (params) => stringifyQueryParams(params), - ...(this.client.getRequestConfig() || {}), - method, - url, - }; + const searchParams: Record = {}; - // Headers - requestConfig.headers = { - ...requestConfig.headers, - Authorization: `bearer ${await this.client.getAccessToken()}`, - ...headers, - }; - - // Put payload into querystring if method is GET + // Add payload parameters to search params if method is 'GET'. if (method === "GET") { - requestConfig.params = payload; + Object.assign(searchParams, payload); + } else if (requestHeaders.get("content-type") === "text/plain") { + // Pass the payload as a plain string if the content type is 'text/plain'. + requestOptions.body = payload as unknown as string; } else { - // Set the request data to the payload, or the value corresponding to the payloadKey, if it's defined - requestConfig.data = payloadKey ? payload[payloadKey] : payload; + // Otherwise assume it's JSON and stringify it. + requestOptions.body = JSON.stringify( + payloadKey ? payload[payloadKey] : payload + ); + } + + if (!requestHeaders.has("content-type")) { + requestHeaders.set("content-type", "application/json"); } - // Concat to existing queryParams if (queryParams) { - requestConfig.params = requestConfig.params - ? { - ...requestConfig.params, - ...queryParams, - } - : queryParams; + Object.assign(searchParams, queryParams); + } + + url.search = stringifyQueryParams(searchParams); + + if (!requestHeaders.has("content-type")) { + requestHeaders.set("content-type", "application/x-www-form-urlencoded"); } try { - const res = await axios.default(requestConfig); + const res = await fetchWithError(url, { + ...requestOptions, + headers: requestHeaders, + method, + }); + // now we get the response of the http request // if `resourceIdInLocationHeader` is true, we'll get the resourceId from the location header field // todo: find a better way to find the id in path, maybe some kind of pattern matching // for now, we simply split the last sub-path of the path returned in location header field if (returnResourceIdInLocationHeader) { - const locationHeader = res.headers.location; + const locationHeader = res.headers.get("location"); if (typeof locationHeader !== "string") { throw new Error( - `location header is not found in request: ${res.config.url}` + `location header is not found in request: ${res.url}` ); } @@ -236,7 +254,7 @@ export class Agent { if (!resourceId) { // throw an error to let users know the response is not expected throw new Error( - `resourceId is not found in Location header from request: ${res.config.url}` + `resourceId is not found in Location header from request: ${res.url}` ); } @@ -244,11 +262,12 @@ export class Agent { const { field } = returnResourceIdInLocationHeader; return { [field]: resourceId }; } - return res.data; + + return parseResponse(res); } catch (err) { if ( - axios.default.isAxiosError(err) && - err.response?.status === 404 && + err instanceof NetworkError && + err.response.status === 404 && catchNotFound ) { return null; diff --git a/libs/keycloak-admin-client/src/resources/users.ts b/libs/keycloak-admin-client/src/resources/users.ts index bab8dffb25..3c42f59f01 100644 --- a/libs/keycloak-admin-client/src/resources/users.ts +++ b/libs/keycloak-admin-client/src/resources/users.ts @@ -223,7 +223,6 @@ export class Users extends Resource<{ realm?: string }> { urlParamKeys: ["id"], payloadKey: "actions", queryParamKeys: ["lifespan", "redirectUri", "clientId"], - headers: { "content-type": "application/json" }, keyTransform: { clientId: "client_id", redirectUri: "redirect_uri", diff --git a/libs/keycloak-admin-client/src/utils/auth.ts b/libs/keycloak-admin-client/src/utils/auth.ts index 0ef7cb3d32..787bc24431 100644 --- a/libs/keycloak-admin-client/src/utils/auth.ts +++ b/libs/keycloak-admin-client/src/utils/auth.ts @@ -1,6 +1,6 @@ -import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import camelize from "camelize-ts"; import { defaultBaseUrl, defaultRealm } from "./constants.js"; +import { fetchWithError } from "./fetchWithError.js"; import { stringifyQueryParams } from "./stringifyQueryParams.js"; export type GrantTypes = "client_credentials" | "password" | "refresh_token"; @@ -20,7 +20,7 @@ export interface Settings { realmName?: string; baseUrl?: string; credentials: Credentials; - requestConfig?: AxiosRequestConfig; + requestOptions?: RequestInit; } export interface TokenResponseRaw { @@ -69,20 +69,25 @@ export const getToken = async (settings: Settings): Promise => { : {}), }); - const config: AxiosRequestConfig = { - ...settings.requestConfig, - }; + const options = settings.requestOptions ?? {}; + const headers = new Headers(options.headers); if (credentials.clientSecret) { - config.auth = { - username: credentials.clientId, - password: credentials.clientSecret, - }; + headers.set( + "Authorization", + atob(credentials.clientId + ":" + credentials.clientSecret) + ); } - const { data } = await axios.default.post< - any, - AxiosResponse - >(url, payload, config); + headers.set("content-type", "application/x-www-form-urlencoded"); + + const response = await fetchWithError(url, { + ...options, + method: "POST", + headers, + body: payload, + }); + + const data: TokenResponseRaw = await response.json(); return camelize(data); }; diff --git a/libs/keycloak-admin-client/src/utils/fetchWithError.ts b/libs/keycloak-admin-client/src/utils/fetchWithError.ts new file mode 100644 index 0000000000..aa84df2495 --- /dev/null +++ b/libs/keycloak-admin-client/src/utils/fetchWithError.ts @@ -0,0 +1,44 @@ +export type NetworkErrorOptions = { response: Response; responseData: unknown }; + +export class NetworkError extends Error { + response: Response; + responseData: unknown; + + constructor(message: string, options: NetworkErrorOptions) { + super(message); + this.response = options.response; + this.responseData = options.responseData; + } +} + +export async function fetchWithError( + input: RequestInfo | URL, + init?: RequestInit +) { + const response = await fetch(input, init); + + if (!response.ok) { + const responseData = await parseResponse(response); + throw new NetworkError("Network response was not OK.", { + response, + responseData, + }); + } + + return response; +} + +export async function parseResponse(response: Response): Promise { + if (!response.body) { + return ""; + } + + const data = await response.text(); + + try { + return JSON.parse(data); + // eslint-disable-next-line no-empty + } catch (error) {} + + return data; +} diff --git a/package-lock.json b/package-lock.json index b416a40107..81c16fa8b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -548,7 +548,6 @@ "version": "999.0.0-dev", "license": "Apache-2.0", "dependencies": { - "axios": "^0.27.2", "camelize-ts": "^2.1.1", "lodash-es": "^4.17.21", "url-join": "^5.0.0", @@ -5691,6 +5690,7 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5732,14 +5732,6 @@ "dev": true, "license": "MIT" }, - "node_modules/axios": { - "version": "0.27.2", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.14.9", - "form-data": "^4.0.0" - } - }, "node_modules/babel-loader": { "version": "8.2.5", "dev": true, @@ -6685,6 +6677,7 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7597,6 +7590,7 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -9065,24 +9059,6 @@ "tabbable": "^5.3.2" } }, - "node_modules/follow-redirects": { - "version": "1.15.1", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-in": { "version": "1.0.2", "dev": true, @@ -9101,6 +9077,7 @@ }, "node_modules/form-data": { "version": "4.0.0", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -11233,6 +11210,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11240,6 +11218,7 @@ }, "node_modules/mime-types": { "version": "2.1.35", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -17819,7 +17798,6 @@ "@types/lodash-es": "^4.17.5", "@types/mocha": "^10.0.1", "@types/node": "^18.0.3", - "axios": "^0.27.2", "camelize-ts": "^2.1.1", "chai": "^4.3.7", "lodash-es": "^4.17.21", @@ -19854,7 +19832,8 @@ "optional": true }, "asynckit": { - "version": "0.4.0" + "version": "0.4.0", + "dev": true }, "at-least-node": { "version": "1.0.0", @@ -19875,13 +19854,6 @@ "version": "1.11.0", "dev": true }, - "axios": { - "version": "0.27.2", - "requires": { - "follow-redirects": "^1.14.9", - "form-data": "^4.0.0" - } - }, "babel-loader": { "version": "8.2.5", "dev": true, @@ -20516,6 +20488,7 @@ }, "combined-stream": { "version": "1.0.8", + "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -21123,7 +21096,8 @@ } }, "delayed-stream": { - "version": "1.0.0" + "version": "1.0.0", + "dev": true }, "deprecation": { "version": "2.3.1" @@ -22140,9 +22114,6 @@ "tabbable": "^5.3.2" } }, - "follow-redirects": { - "version": "1.15.1" - }, "for-in": { "version": "1.0.2", "dev": true @@ -22153,6 +22124,7 @@ }, "form-data": { "version": "4.0.0", + "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -23570,10 +23542,12 @@ } }, "mime-db": { - "version": "1.52.0" + "version": "1.52.0", + "dev": true }, "mime-types": { "version": "2.1.35", + "dev": true, "requires": { "mime-db": "1.52.0" }