From 9058a84a09411627be89badbe7e038152c6a7549 Mon Sep 17 00:00:00 2001 From: Grzegorz Grzybek Date: Fri, 23 Feb 2024 12:46:56 +0100 Subject: [PATCH] feat(auth): OIDC authentication works with server side support --- app/craco.config.js | 29 +- .../plugins/auth/keycloak/keycloak-service.ts | 2 +- .../src/plugins/auth/oidc/oidc-service.ts | 515 ++++++++++++------ yarn.lock | 67 --- 4 files changed, 371 insertions(+), 242 deletions(-) diff --git a/app/craco.config.js b/app/craco.config.js index da2c4a2fb..fcd1d8707 100644 --- a/app/craco.config.js +++ b/app/craco.config.js @@ -167,18 +167,25 @@ module.exports = { authenticated = false res.redirect('/hawtio/login') }) + + const oidcEnabled = true + const oidcConfig = { + "method": "oidc", + "provider": "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0", + "client_id": "66666666-7777-8888-9999-000000000000", + "response_mode": "fragment", + "scope": "openid email profile api://hawtio-server/Jolokia.Access", + "redirect_uri": "http://localhost:3000/hawtio/", + "code_challenge_method": "S256", + "prompt": "login" + } devServer.app.get('/hawtio/auth/config', (_, res) => { - res.type("application/json") - res.send(JSON.stringify({ - "method": "oidc", - "provider": "https://login.microsoftonline.com/8fd8ed3d-c739-410f-83ab-ac2228fa6bbf/v2.0", - "client_id": "3bb7fe5a-34bb-4afa-bf6a-292c050cb821", - "response_mode": "fragment", - "scope": "openid email profile api://hawtio-server/Jolokia.Access", - "redirect_uri": "http://localhost:3000/hawtio/", - "code_challenge_method": "S256", - "prompt": "login" - })) + if (oidcEnabled) { + res.type("application/json") + res.send(JSON.stringify(oidcConfig)) + } else { + res.sendStatus(404) + } }) devServer.app.get('/hawtio/proxy/enabled', (_, res) => res.send(String(proxyEnabled))) devServer.app.get('/hawtio/plugin', (_, res) => res.send(JSON.stringify(plugin))) diff --git a/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts b/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts index 7966ffbbc..b0049c85e 100644 --- a/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts +++ b/packages/hawtio/src/plugins/auth/keycloak/keycloak-service.ts @@ -135,7 +135,7 @@ class KeycloakService implements IKeycloakService { } if (userProfile.username && userProfile.token) { - resolve({ username: userProfile.username, isLogin: true }) + resolve({ username: userProfile.username, isLogin: true, isLoading: false }) userService.setToken(userProfile.token) } diff --git a/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts b/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts index 9a77816af..429627613 100644 --- a/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts +++ b/packages/hawtio/src/plugins/auth/oidc/oidc-service.ts @@ -1,10 +1,11 @@ import { ResolveUser, userService } from "@hawtiosrc/auth/user-service" import { Logger } from "@hawtiosrc/core" - -// import { jwtDecode } from "jwt-decode" -import { AuthorizationServer, Client, OAuth2Error } from "oauth4webapi" +import { jwtDecode } from "jwt-decode" import * as oidc from "oauth4webapi" +import { AuthorizationServer, Client, OAuth2Error } from "oauth4webapi" import { fetchPath } from "@hawtiosrc/util/fetch" +import $ from "jquery" +import { getCookie } from "@hawtiosrc/util/https" const pluginName = "hawtio-oidc" const log = Logger.get(pluginName) @@ -40,12 +41,19 @@ export interface IOidcService { registerUserHooks(): void } +class UserInfo { + user: string | null = null + access_token: string | null | undefined = null + refresh_token: string | null | undefined = null + at_exp: number = 0 +} + export class OidcService implements IOidcService { + // promises created during construction - should be already resolved in fetchUser private readonly config: Promise - private readonly enabled: Promise private readonly oidcMetadata: Promise - access_token: string | null = null + private readonly userInfo: Promise constructor() { this.config = fetchPath("auth/config", { @@ -57,11 +65,12 @@ export class OidcService implements IOidcService { this.enabled = this.isOidcEnabled() this.oidcMetadata = this.fetchOidcMetadata() + this.userInfo = this.initialize() } private async isOidcEnabled(): Promise { const cfg = await this.config - return cfg?.method === "oidc" + return cfg?.method === "oidc" && cfg?.provider != null } private async fetchOidcMetadata(): Promise { @@ -73,209 +82,389 @@ export class OidcService implements IOidcService { return null } - const cfgUrl = new URL(cfg!.provider) - res = await oidc.discoveryRequest(cfgUrl).catch(e => { - log.error("Failed OIDC discovery request", e) - }) - if (!res || !res.ok) { + if (cfg["openid-configuration"]) { + // no need to contact .well-known/openid-configuration here - we have what we need from auth/config + log.info("Using pre-fetched openid-configuration") + return cfg["openid-configuration"] + } else { + log.info("Fetching openid-configuration") + const cfgUrl = new URL(cfg!.provider) + res = await oidc.discoveryRequest(cfgUrl).catch(e => { + log.error("Failed OIDC discovery request", e) + }) + if (!res || !res.ok) { + return null + } + + return await oidc.processDiscoveryResponse(cfgUrl, res) + } + } + + private async initialize(): Promise { + const config = await this.config + const enabled = await this.enabled + const as = await this.oidcMetadata + if (!config || !enabled || !as) { return null } - return await oidc.processDiscoveryResponse(cfgUrl, res) - } + // we have all the metadata to try to log in to OpenID provider. - registerUserHooks() { - const fetchUser = async (resolveUser: ResolveUser, signal: AbortSignal | null, proceed: (() => boolean) | null) => { - if (proceed && !proceed()) { - return false + // have we just been redirected with OAuth2 authirization response in query/fragment? + let urlParams: URLSearchParams | null = null + + if (config!.response_mode === "fragment") { + if (window.location.hash && window.location.hash.length > 0) { + urlParams = new URLSearchParams(window.location.hash.substring(1)) } - const config = await this.config - const enabled = await this.enabled - const as = await this.oidcMetadata - if (!config || !enabled || !as || (proceed && !proceed())) { - return false + } else if (config!.response_mode === "query") { + if (window.location.search || window.location.search.length > 0) { + urlParams = new URLSearchParams(window.location.search.substring(1)) } + } + + const goodParamsRequired = [ "code", "state" ] + const errorParamsRequired = [ "error" ] - // we have all the metadata to try to log in to OpenID provider. + if (as["authorization_response_iss_parameter_supported"]) { + // https://datatracker.ietf.org/doc/html/rfc9207#section-2.3 + goodParamsRequired.push("iss") + } - // have we just been redirected with OAuth2 authirization response in query/fragment? - let urlParams: URLSearchParams | null = null + let oauthSuccess = urlParams != null + let oauthError = false + if (urlParams != null) { + goodParamsRequired.forEach(p => { + oauthSuccess &&= urlParams!.get(p) != null + }) + errorParamsRequired.forEach(p => { + oauthError ||= urlParams!.get(p) != null + }) + } - if (config!.response_mode === "fragment") { - if (window.location.hash && window.location.hash.length > 0) { - urlParams = new URLSearchParams(window.location.hash.substring(1)) - } - } else if (config!.response_mode === "query") { - if (window.location.search || window.location.search.length > 0) { - urlParams = new URLSearchParams(window.location.search.substring(1)) - } + if (oauthError) { + // we are already after redirect, but there was an OAuth2/OpenID problem + const error: OAuth2Error = { + error: urlParams?.get("error") as string, + error_description: urlParams?.get("error_description") as string, + error_uri: urlParams?.get("error_uri") as string } + log.error("OpenID Connect error", error) + return null + } - const goodParamsRequired = [ "code", "state" ] - const errorParamsRequired = [ "error" ] - - if (as["authorization_response_iss_parameter_supported"]) { - // https://datatracker.ietf.org/doc/html/rfc9207#section-2.3 - goodParamsRequired.push("iss") + if (!oauthSuccess) { + // there are no query/fragment params in the URL, so we're logging for the first time + const code_challenge_method = config!.code_challenge_method + const code_verifier = oidc.generateRandomCodeVerifier() + const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier) + + const state = oidc.generateRandomState() + const nonce = oidc.generateRandomNonce() + + // put some data to localStorage, so we can verify the OAuth2 response after redirect + localStorage.removeItem("hawtio-oidc-login") + localStorage.setItem("hawtio-oidc-login", JSON.stringify({ + "st": state, + "cv": code_verifier, + "n": nonce, + "h": window.location.href + })) + log.info("Added to local storage", localStorage.getItem("hawtio-oidc-login")) + + const authorizationUrl = new URL(as!.authorization_endpoint!) + authorizationUrl.searchParams.set('response_type', 'code') + authorizationUrl.searchParams.set('response_mode', config.response_mode) + authorizationUrl.searchParams.set('client_id', config.client_id) + authorizationUrl.searchParams.set('redirect_uri', config.redirect_uri) + authorizationUrl.searchParams.set('scope', config.scope) + if (code_challenge_method) { + authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method) + authorizationUrl.searchParams.set('code_challenge', code_challenge) + } + authorizationUrl.searchParams.set('state', state) + authorizationUrl.searchParams.set('nonce', nonce) + // authorizationUrl.searchParams.set('login_hint', 'hawtio-viewer@fuseqe.onmicrosoft.com') + // authorizationUrl.searchParams.set('hsu', '1') + if (config.prompt) { + authorizationUrl.searchParams.set('prompt', config.prompt) } - let oauthSuccess = urlParams != null - let oauthError = false - if (urlParams != null) { - goodParamsRequired.forEach(p => { - oauthSuccess &&= urlParams!.get(p) != null + log.info("Redirecting to ", authorizationUrl) + + // point of no return + window.location.assign(authorizationUrl) + // return unresolvable promise to wait for redirect + return new Promise((resolve, reject) => { + log.debug("Waiting for redirect") + }) + } + + const client: Client = { + client_id: config.client_id, + token_endpoint_auth_method: "none" + } + + // there are proper OAuth2/OpenID params, so we can exchange them for access_token, refresh_token and id_token + const state = urlParams!.get("state") + const authResponse = oidc.validateAuthResponse(as, client, urlParams!, state!) + + if (oidc.isOAuth2Error(authResponse)) { + log.error("OpenID Authorization error", authResponse) + return null + } + + log.info("Getting localStore data, because we have params", urlParams) + const loginDataString = localStorage.getItem("hawtio-oidc-login") + // localStorage.removeItem("hawtio-oidc-login") + if (!loginDataString) { + log.warn("No local data, can't proceed with OpenID authorization grant") + return null + } + const loginData = JSON.parse(loginDataString) + if (!loginData.cv || !loginData.st) { + log.warn("Missing local data, can't proceed with OpenID authorization grant") + return null + } + + const options: { signal?: AbortSignal } = {} + const res = await oidc.authorizationCodeGrantRequest(as, client, authResponse, config.redirect_uri, loginData.cv, options) + .catch(e => { + log.warn("Problem accessing OpenID token endpoint", e) + return null }) - errorParamsRequired.forEach(p => { - oauthError ||= urlParams!.get(p) != null + if (!res) { + return null + } + + const tokenResponse = await oidc.processAuthorizationCodeOpenIDResponse(as, client, res, loginData.n, oidc.skipAuthTimeCheck) + .catch(e => { + log.warn("Problem processing OpenID token response", e) + return null }) + if (!tokenResponse) { + return null + } + if (oidc.isOAuth2Error(tokenResponse)) { + log.error("OpenID Token error", tokenResponse) + return null + } + + const access_token = tokenResponse["access_token"] + const refresh_token = tokenResponse["refresh_token"] + let at_exp: number = 0 + // const id_token = tokenResponse["id_token"] + + // we have to parse (though we shouldn't according to MS) access_token to get it's validity + try { + const access_token_decoded = jwtDecode(access_token) + if (access_token_decoded["exp"]) { + at_exp = access_token_decoded["exp"] + } else { + at_exp = 0 + log.warn("Access token doesn't contain \"exp\" information") } + } catch (e) { + log.warn("Problem determining access token validity", e) + } - if (oauthError) { - // we are already after redirect, but there was an OAuth2/OpenID problem - const error: OAuth2Error = { - error: urlParams?.get("error") as string, - error_description: urlParams?.get("error_description") as string, - error_uri: urlParams?.get("error_uri") as string - } - log.error("OpenID Connect error", error) + const claims = oidc.getValidatedIdTokenClaims(tokenResponse) + const user = (claims.preferred_username ?? claims.sub) as string + + // clear the URL bar + window.history.replaceState(null, "", loginData.h) + + return { + user, + access_token, + refresh_token, + at_exp + } + + this.setupJQueryAjax() + this.setupFetch() + } + + private isTokenExpiring(at_exp: number): boolean { + const now = Math.floor(Date.now() / 1000) + if (at_exp - 5 < now) { + // is expired, or will expire within 5 seconds + return true + } else { + return false + } + } + + registerUserHooks() { + const fetchUser = async (resolveUser: ResolveUser, signal: AbortSignal | null, proceed: (() => boolean) | null) => { + if (proceed && !proceed()) { return false } - if (!oauthSuccess) { - // there are no query/fragment params in the URL, so we're logging for the first time - const code_challenge_method = config!.code_challenge_method - const code_verifier = oidc.generateRandomCodeVerifier() - const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier) - if (proceed && !proceed()) { - return false - } - - const state = oidc.generateRandomState() - const nonce = oidc.generateRandomNonce() - - // put some data to localStorage, so we can verify the OAuth2 response after redirect - localStorage.removeItem("hawtio-oidc-login") - localStorage.setItem("hawtio-oidc-login", JSON.stringify({ - "st": state, - "cv": code_verifier, - "n": nonce, - "h": window.location.href - })) - log.info("Added to local storage", localStorage.getItem("hawtio-oidc-login")) - - const authorizationUrl = new URL(as!.authorization_endpoint!) - authorizationUrl.searchParams.set('response_type', 'code') - authorizationUrl.searchParams.set('response_mode', config.response_mode) - authorizationUrl.searchParams.set('client_id', config.client_id) - authorizationUrl.searchParams.set('redirect_uri', config.redirect_uri) - authorizationUrl.searchParams.set('scope', config.scope) - if (code_challenge_method) { - authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method) - authorizationUrl.searchParams.set('code_challenge', code_challenge) - } - authorizationUrl.searchParams.set('state', state) - authorizationUrl.searchParams.set('nonce', nonce) - // authorizationUrl.searchParams.set('login_hint', 'hawtio-viewer@fuseqe.onmicrosoft.com') - // authorizationUrl.searchParams.set('hsu', '1') - if (config.prompt) { - authorizationUrl.searchParams.set('prompt', config.prompt) - } + const userInfo = await this.userInfo + if (!userInfo) { + return false + } + resolveUser({ username: userInfo.user!, isLogin: true, isLoading: false }) + userService.setToken(userInfo.access_token!) - log.info("Redirecting to ", authorizationUrl) + return true + } + userService.addFetchUserHook("oidc", fetchUser) - // point of no return - window.location.assign(authorizationUrl) - // resolve as "pending" user, to avoid flickering - resolveUser({ username: "", isLogin: true, isLoading: true }) + const logout = async () => { + const md = await this.oidcMetadata + if (md?.end_session_endpoint) { + window.location.replace(md?.end_session_endpoint) return true } + return false + } + userService.addLogoutHook("oidc", logout) + } + + private async updateToken(success: (token: string) => void, failure?: () => void) { + const userInfo = await this.userInfo + if (!userInfo) { + return + } + if (userInfo.refresh_token) { + const config = await this.config + const enabled = await this.enabled + const as = await this.oidcMetadata + if (!config || !enabled || !as) { + return + } const client: Client = { client_id: config.client_id, token_endpoint_auth_method: "none" } - // there are proper OAuth2/OpenID params, so we can exchange them for access_token, refresh_token and id_token - const state = urlParams!.get("state") - const authResponse = oidc.validateAuthResponse(as, client, urlParams!, state!) - - if (oidc.isOAuth2Error(authResponse)) { - log.error("OpenID Authorization error", authResponse) - return false + const res = await oidc.refreshTokenGrantRequest(as, client, userInfo.refresh_token).catch(e => { + log.error("Problem refreshing token", e) + if (failure) { + failure() + } + }) + if (!res) { + return } - - log.info("Getting localStore data, because we have params", urlParams) - const loginDataString = localStorage.getItem("hawtio-oidc-login") - // localStorage.removeItem("hawtio-oidc-login") - if (!loginDataString) { - log.warn("No local data, can't proceed with OpenID authorization grant") - return false + const refreshResponse = await oidc.processRefreshTokenResponse(as, client, res).catch(e => { + log.error("Problem processing refresh token response", e) + }) + if (!refreshResponse) { + return } - const loginData = JSON.parse(loginDataString) - if (!loginData.cv || !loginData.st) { - log.warn("Missing local data, can't proceed with OpenID authorization grant") - return false + userInfo.access_token = refreshResponse["access_token"] as string + userInfo.refresh_token = refreshResponse["refresh_token"] as string + const access_token_decoded = jwtDecode(userInfo.access_token) + if (access_token_decoded["exp"]) { + userInfo.at_exp = access_token_decoded["exp"] + } else { + userInfo.at_exp = 0 + log.warn("Access token doesn't contain \"exp\" information") } + success(userInfo.access_token) + } else { + log.error("No refresh token available") + } + } - const options: { signal?: AbortSignal } = {} - if (signal) { - options.signal = signal - } - const res = await oidc.authorizationCodeGrantRequest(as, client, authResponse, config.redirect_uri, loginData.cv, options) - .catch(e => { - log.warn("Problem accessing OpenID token endpoint", e) - return null - }) - if (!res) { - return false - } + private async setupJQueryAjax() { + const userInfo = await this.userInfo + if (!userInfo) { + return + } - const tokenResponse = await oidc.processAuthorizationCodeOpenIDResponse(as, client, res, loginData.n, oidc.skipAuthTimeCheck) - .catch(e => { - log.warn("Problem processing OpenID token response", e) - return null - }) - if (!tokenResponse) { + log.debug('Set authorization header to OIDC token for AJAX requests') + const beforeSend = (xhr: JQueryXHR, settings: JQueryAjaxSettings) => { + const logPrefix = 'jQuery -' + if (!userInfo.access_token || this.isTokenExpiring(userInfo.at_exp)) { + log.debug(logPrefix, 'Try to update token for request:', settings.url) + this.updateToken( + token => { + if (token) { + log.debug(logPrefix, 'OIDC token refreshed. Set new value to userService') + userService.setToken(token) + } + log.debug(logPrefix, 'Re-sending request after successfully updating OIDC token:', settings.url) + $.ajax(settings) + }, + () => { + log.debug(logPrefix, 'Logging out due to token update failed') + userService.logout() + }, + ) return false } - if (oidc.isOAuth2Error(tokenResponse)) { - log.error("OpenID Token error", tokenResponse) - return false + + xhr.setRequestHeader('Authorization', `Bearer ${userInfo.access_token}`) + + // For CSRF protection with Spring Security + const token = getCookie('XSRF-TOKEN') + if (token) { + log.debug(logPrefix, 'Set XSRF token header from cookies') + xhr.setRequestHeader('X-XSRF-TOKEN', token) } - this.access_token = tokenResponse["access_token"] - // const refresh_token = tokenResponse["refresh_token"] - // const id_token = tokenResponse["id_token"] + return // To suppress ts(7030) + } + $.ajaxSetup({ beforeSend }) + } - // we have to parse (though we shouldn't according to MS) access_token to get it's validity - // try { - // const access_token_decoded = jwtDecode(access_token) - // const at_exp = access_token_decoded["exp"] - // } catch (e) { - // log.debug("Problem determining access token validity", e) - // } + private async setupFetch() { + const userInfo = await this.userInfo + if (!userInfo) { + return + } - const claims = oidc.getValidatedIdTokenClaims(tokenResponse) - const user = (claims.preferred_username ?? claims.sub) as string + log.debug('Intercept Fetch API to attach OIDC token to authorization header') + const { fetch: originalFetch } = window + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const logPrefix = 'Fetch -' + log.debug(logPrefix, 'Fetch intercepted for OIDC authentication') + + if (!userInfo.access_token || this.isTokenExpiring(userInfo.at_exp)) { + log.debug(logPrefix, 'Try to update token for request:', input) + return new Promise((resolve, reject) => { + this.updateToken( + token => { + if (token) { + log.debug(logPrefix, 'OIDC token refreshed. Set new value to userService') + userService.setToken(token) + } + log.debug(logPrefix, 'Re-sending request after successfully updating OIDC token:', input) + resolve(fetch(input, init)) + }, + () => { + log.debug(logPrefix, 'Logging out due to token update failed') + userService.logout() + reject() + }, + ) + }) + } - // clear the URL bar - window.history.replaceState(null, "", loginData.h) + init = { ...init } - resolveUser({ username: user, isLogin: true, isLoading: false }) - userService.setToken(this.access_token) - return true - } - userService.addFetchUserHook("oidc", fetchUser) + init.headers = { + ...init.headers, + Authorization: `Bearer ${userInfo.access_token}`, + } - const logout = async () => { - const md = await this.oidcMetadata - if (md?.end_session_endpoint) { - window.location.replace(md?.end_session_endpoint) - return true + // For CSRF protection with Spring Security + const token = getCookie('XSRF-TOKEN') + if (token) { + log.debug(logPrefix, 'Set XSRF token header from cookies') + init.headers = { + ...init.headers, + 'X-XSRF-TOKEN': token, + } } - return false + + return originalFetch(input, init) } - userService.addLogoutHook("oidc", logout) } } diff --git a/yarn.lock b/yarn.lock index 623bacf39..cb9465414 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13022,24 +13022,6 @@ __metadata: languageName: node linkType: hard -"jsonwebtoken@npm:^8.0.0": - version: 8.5.1 - resolution: "jsonwebtoken@npm:8.5.1" - dependencies: - jws: "npm:^3.2.2" - lodash.includes: "npm:^4.3.0" - lodash.isboolean: "npm:^3.0.3" - lodash.isinteger: "npm:^4.0.4" - lodash.isnumber: "npm:^3.0.3" - lodash.isplainobject: "npm:^4.0.6" - lodash.isstring: "npm:^4.0.1" - lodash.once: "npm:^4.0.0" - ms: "npm:^2.1.1" - semver: "npm:^5.6.0" - checksum: 10/a7b52ea570f70bea183ceca970c003f223d9d3425d72498002e9775485c7584bfa3751d1c7291dbb59738074cba288effe73591b87bec5d467622ab3a156fdb6 - languageName: node - linkType: hard - "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.3": version: 3.3.3 resolution: "jsx-ast-utils@npm:3.3.3" @@ -13318,27 +13300,6 @@ __metadata: languageName: node linkType: hard -"lodash.includes@npm:^4.3.0": - version: 4.3.0 - resolution: "lodash.includes@npm:4.3.0" - checksum: 10/45e0a7c7838c931732cbfede6327da321b2b10482d5063ed21c020fa72b09ca3a4aa3bda4073906ab3f436cf36eb85a52ea3f08b7bab1e0baca8235b0e08fe51 - languageName: node - linkType: hard - -"lodash.isboolean@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isboolean@npm:3.0.3" - checksum: 10/b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250 - languageName: node - linkType: hard - -"lodash.isinteger@npm:^4.0.4": - version: 4.0.4 - resolution: "lodash.isinteger@npm:4.0.4" - checksum: 10/c971f5a2d67384f429892715550c67bac9f285604a0dd79275fd19fef7717aec7f2a6a33d60769686e436ceb9771fd95fe7fcb68ad030fc907d568d5a3b65f70 - languageName: node - linkType: hard - "lodash.ismatch@npm:^4.4.0": version: 4.4.0 resolution: "lodash.ismatch@npm:4.4.0" @@ -13367,27 +13328,6 @@ __metadata: languageName: node linkType: hard -"lodash.isnumber@npm:^3.0.3": - version: 3.0.3 - resolution: "lodash.isnumber@npm:3.0.3" - checksum: 10/913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2 - languageName: node - linkType: hard - -"lodash.isplainobject@npm:^4.0.6": - version: 4.0.6 - resolution: "lodash.isplainobject@npm:4.0.6" - checksum: 10/29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337 - languageName: node - linkType: hard - -"lodash.isstring@npm:^4.0.1": - version: 4.0.1 - resolution: "lodash.isstring@npm:4.0.1" - checksum: 10/eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0 - languageName: node - linkType: hard - "lodash.map@npm:^4.5.1": version: 4.6.0 resolution: "lodash.map@npm:4.6.0" @@ -13416,13 +13356,6 @@ __metadata: languageName: node linkType: hard -"lodash.once@npm:^4.0.0": - version: 4.1.1 - resolution: "lodash.once@npm:4.1.1" - checksum: 10/202f2c8c3d45e401b148a96de228e50ea6951ee5a9315ca5e15733d5a07a6b1a02d9da1e7fdf6950679e17e8ca8f7190ec33cae47beb249b0c50019d753f38f3 - languageName: node - linkType: hard - "lodash.sortby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.sortby@npm:4.7.0"