UNPKG

@esri/arcgis-rest-request

Version:

Common methods and utilities for @esri/arcgis-rest-js packages.

1,041 lines (1,040 loc) 56.1 kB
"use strict"; /* Copyright (c) 2017-2019 Environmental Systems Research Institute, Inc. * Apache-2.0 */ Object.defineProperty(exports, "__esModule", { value: true }); exports.UserSession = exports.ArcGISIdentityManager = void 0; const request_js_1 = require("./request.js"); const decode_query_string_js_1 = require("./utils/decode-query-string.js"); const encode_query_string_js_1 = require("./utils/encode-query-string.js"); const fetch_token_js_1 = require("./fetch-token.js"); const federation_utils_js_1 = require("./federation-utils.js"); const validate_app_access_js_1 = require("./validate-app-access.js"); const clean_url_js_1 = require("./utils/clean-url.js"); const revoke_token_js_1 = require("./revoke-token.js"); const generate_code_challenge_js_1 = require("./utils/generate-code-challenge.js"); const generate_random_string_js_1 = require("./utils/generate-random-string.js"); const ArcGISAccessDeniedError_js_1 = require("./utils/ArcGISAccessDeniedError.js"); const ArcGISTokenRequestError_js_1 = require("./utils/ArcGISTokenRequestError.js"); const index_js_1 = require("./index.js"); const AuthenticationManagerBase_js_1 = require("./AuthenticationManagerBase.js"); /** * distinguish between an ICredential and IArcGISIdentityManagerOptions */ function isCredential(credential) { return (typeof credential.userId === "string" || typeof credential.expires === "number"); } /** * Used to authenticate both ArcGIS Online and ArcGIS Enterprise users. `ArcGISIdentityManager` includes helper methods for [OAuth 2.0](https://developers.arcgis.com/documentation/mapping-apis-and-services/security/oauth-2.0/) in both browser and server applications. * * **It is not recommended to construct `ArcGISIdentityManager` directly**. Instead there are several static methods used for specific workflows. The 2 primary workflows relate to oAuth 2.0: * * * {@linkcode ArcGISIdentityManager.beginOAuth2} and {@linkcode ArcGISIdentityManager.completeOAuth2} for oAuth 2.0 in browser-only environment. * * {@linkcode ArcGISIdentityManager.authorize} and {@linkcode ArcGISIdentityManager.exchangeAuthorizationCode} for oAuth 2.0 for server-enabled application. * * Other more specialized helpers for less common workflows also exist: * * * {@linkcode ArcGISIdentityManager.fromToken} for when you have an existing token from another source and would like create an `ArcGISIdentityManager` instance. * * {@linkcode ArcGISIdentityManager.fromCredential} for creating an `ArcGISIdentityManager` instance from a `Credentials` object in the ArcGIS JS API `IdentityManager` * * {@linkcode ArcGISIdentityManager.signIn} for authenticating directly with a user's username and password for environments with a user interface for oAuth 2.0. * * Once a manager is created there are additional utilities: * * * {@linkcode ArcGISIdentityManager.serialize} can be used to create a JSON object representing an instance of `ArcGISIdentityManager` * * {@linkcode ArcGISIdentityManager.deserialize} will create a new `ArcGISIdentityManager` from a JSON object created with {@linkcode ArcGISIdentityManager.serialize} * * {@linkcode ArcGISIdentityManager.destroy} or {@linkcode ArcGISIdentityManager.signOut} will invalidate any tokens in use by the `ArcGISIdentityManager`. */ class ArcGISIdentityManager extends AuthenticationManagerBase_js_1.AuthenticationManagerBase { constructor(options) { super(options); this.clientId = options.clientId; this._refreshToken = options.refreshToken; this._refreshTokenExpires = options.refreshTokenExpires; this.password = options.password; this._token = options.token; this._tokenExpires = options.tokenExpires; this.portal = options.portal ? (0, clean_url_js_1.cleanUrl)(options.portal) : "https://www.arcgis.com/sharing/rest"; this.ssl = options.ssl; this.provider = options.provider || "arcgis"; this.tokenDuration = options.tokenDuration || 20160; this.redirectUri = options.redirectUri; this.server = options.server; this.referer = options.referer; this.federatedServers = {}; this.trustedDomains = []; // if a non-federated server was passed explicitly, it should be trusted. if (options.server) { // if the url includes more than '/arcgis/', trim the rest const root = this.getServerRootUrl(options.server); this.federatedServers[root] = { token: options.token, expires: options.tokenExpires }; } this._pendingTokenRequests = {}; } /** * The current ArcGIS Online or ArcGIS Enterprise `token`. */ get token() { return this._token; } /** * The expiration time of the current `token`. */ get tokenExpires() { return this._tokenExpires; } /** * The current token to ArcGIS Online or ArcGIS Enterprise. */ get refreshToken() { return this._refreshToken; } /** * The expiration time of the current `refreshToken`. */ get refreshTokenExpires() { return this._refreshTokenExpires; } /** * Returns `true` if these credentials can be refreshed and `false` if it cannot. */ get canRefresh() { if (this.username && this.password) { return true; } if (this.clientId && this.refreshToken && this.redirectUri) { return true; } return false; } /** * Begins a new browser-based OAuth 2.0 sign in. If `options.popup` is `true` the authentication window will open in a new tab/window. Otherwise, the user will be redirected to the authorization page in their current tab/window and the function will return `undefined`. * * If `popup` is `true` (the default) this method will return a `Promise` that resolves to an `ArcGISIdentityManager` instance and you must call {@linkcode ArcGISIdentityManager.completeOAuth2()} on the page defined in the `redirectUri`. Otherwise it will return undefined and the {@linkcode ArcGISIdentityManager.completeOAuth2()} method will return a `Promise` that resolves to an `ArcGISIdentityManager` instance. * * A {@linkcode ArcGISAccessDeniedError} error will be thrown if the user denies the request on the authorization screen. * * @browserOnly */ static beginOAuth2(options, win) { /* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */ if (!win && window) { win = window; } const { portal, provider, clientId, expiration, redirectUri, popup, popupWindowFeatures, locale, params, style, pkce, state } = Object.assign({ portal: "https://www.arcgis.com/sharing/rest", provider: "arcgis", expiration: 20160, popup: true, popupWindowFeatures: "height=400,width=600,menubar=no,location=yes,resizable=yes,scrollbars=yes,status=yes", locale: "", style: "", pkce: true }, options); /** * Generate a random string for the `state` param and store it in local storage. This is used * to validate that all parts of the oAuth process were performed on the same client. */ const stateId = state || (0, generate_random_string_js_1.generateRandomString)(win); const stateStorageKey = `ARCGIS_REST_JS_AUTH_STATE_${clientId}`; win.localStorage.setItem(stateStorageKey, stateId); // Start setting up the URL to the authorization screen. let authorizeUrl = `${(0, clean_url_js_1.cleanUrl)(portal)}/oauth2/authorize`; const authorizeUrlParams = { client_id: clientId, response_type: pkce ? "code" : "token", expiration: expiration, redirect_uri: redirectUri, state: JSON.stringify({ id: stateId, originalUrl: win.location.href // this is used to reset the URL back the original URL upon return }), locale: locale, style: style }; // If we are authorizing through a specific social provider update the params and base URL. if (provider !== "arcgis") { authorizeUrl = `${(0, clean_url_js_1.cleanUrl)(portal)}/oauth2/social/authorize`; authorizeUrlParams.socialLoginProviderName = provider; authorizeUrlParams.autoAccountCreateForSocial = true; } /** * set a value that will be set to a promise which will later resolve when we are ready * to send users to the authorization page. */ let setupAuth; if (pkce) { /** * If we are authenticating with PKCE we need to generate the code challenge which is * async so we generate the code challenge and assign the resulting Promise to `setupAuth` */ const codeVerifier = (0, generate_random_string_js_1.generateRandomString)(win); const codeVerifierStorageKey = `ARCGIS_REST_JS_CODE_VERIFIER_${clientId}`; win.localStorage.setItem(codeVerifierStorageKey, codeVerifier); setupAuth = (0, generate_code_challenge_js_1.generateCodeChallenge)(codeVerifier, win).then(function (codeChallenge) { authorizeUrlParams.code_challenge_method = codeChallenge ? "S256" : "plain"; authorizeUrlParams.code_challenge = codeChallenge ? codeChallenge : codeVerifier; }); } else { /** * If we aren't authenticating with PKCE we can just assign a resolved promise to `setupAuth` */ setupAuth = Promise.resolve(); } /** * Once we are done setting up with (for PKCE) we can start the auth process. */ return setupAuth.then(() => { // combine the authorize URL and params authorizeUrl = `${authorizeUrl}?${(0, encode_query_string_js_1.encodeQueryString)(authorizeUrlParams)}`; // append additional params passed by the user if (params) { authorizeUrl = `${authorizeUrl}&${(0, encode_query_string_js_1.encodeQueryString)(params)}`; } if (popup) { // If we are authenticating a popup we need to return a Promise that will resolve to an ArcGISIdentityManager later. return new Promise((resolve, reject) => { // Add an event listener to listen for when a user calls `ArcGISIdentityManager.completeOAuth2()` in the popup. win.addEventListener(`arcgis-rest-js-popup-auth-${clientId}`, (e) => { if (e.detail.error === "access_denied") { const error = new ArcGISAccessDeniedError_js_1.ArcGISAccessDeniedError(); reject(error); return error; } if (e.detail.errorMessage) { const error = new request_js_1.ArcGISAuthError(e.detail.errorMessage, e.detail.error); reject(error); return error; } resolve(new ArcGISIdentityManager({ clientId, portal, ssl: e.detail.ssl, token: e.detail.token, tokenExpires: e.detail.expires, username: e.detail.username, refreshToken: e.detail.refreshToken, refreshTokenExpires: e.detail.refreshTokenExpires, redirectUri })); }, { once: true }); // open the popup win.open(authorizeUrl, "oauth-window", popupWindowFeatures); win.dispatchEvent(new CustomEvent("arcgis-rest-js-popup-auth-start")); }); } else { // If we aren't authenticating with a popup just send the user to the authorization page. win.location.href = authorizeUrl; return undefined; } }); } /** * Completes a browser-based OAuth 2.0 sign in. If `options.popup` is `true` the user * will be returned to the previous window and the popup will close. Otherwise a new `ArcGISIdentityManager` will be returned. You must pass the same values for `clientId`, `popup`, `portal`, and `pkce` as you used in `beginOAuth2()`. * * A {@linkcode ArcGISAccessDeniedError} error will be thrown if the user denies the request on the authorization screen. * @browserOnly */ static completeOAuth2(options, win) { /* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */ if (!win && window) { win = window; } // pull out necessary options const { portal, clientId, popup, pkce, redirectUri } = Object.assign({ portal: "https://www.arcgis.com/sharing/rest", popup: true, pkce: true }, options); // pull the saved state id out of local storage const stateStorageKey = `ARCGIS_REST_JS_AUTH_STATE_${clientId}`; const stateId = win.localStorage.getItem(stateStorageKey); // get the params provided by the server and compare the server state with the client saved state const params = (0, decode_query_string_js_1.decodeQueryString)(pkce ? win.location.search.replace(/^\?/, "") : win.location.hash.replace(/^#/, "")); const state = params && params.state ? JSON.parse(params.state) : undefined; function reportError(errorMessage, error, originalUrl) { win.localStorage.removeItem(stateStorageKey); if (popup && win.opener) { win.opener.dispatchEvent(new CustomEvent(`arcgis-rest-js-popup-auth-${clientId}`, { detail: { error, errorMessage } })); win.close(); return; } if (originalUrl) { win.history.replaceState(win.history.state, "", originalUrl); } if (error === "access_denied") { return Promise.reject(new ArcGISAccessDeniedError_js_1.ArcGISAccessDeniedError()); } return Promise.reject(new request_js_1.ArcGISAuthError(errorMessage, error)); } // create a function to create the final ArcGISIdentityManager from the token info. function createManager(oauthInfo, originalUrl) { win.localStorage.removeItem(stateStorageKey); if (popup && win.opener) { win.opener.dispatchEvent(new CustomEvent(`arcgis-rest-js-popup-auth-${clientId}`, { detail: Object.assign({}, oauthInfo) })); win.close(); return; } win.history.replaceState(win.history.state, "", originalUrl); return new ArcGISIdentityManager({ clientId, portal, ssl: oauthInfo.ssl, token: oauthInfo.token, tokenExpires: oauthInfo.expires, username: oauthInfo.username, refreshToken: oauthInfo.refreshToken, refreshTokenExpires: oauthInfo.refreshTokenExpires, // At 4.0.0 it was possible (in JS code) to not pass redirectUri and fallback to win.location.href, however this broke support for redirect URIs with query params. // Now similar to 3.x.x you must pass the redirectUri parameter explicitly. See https://github.com/Esri/arcgis-rest-js/issues/995 redirectUri: redirectUri || /* istanbul ignore next: TypeScript wont compile if we omit redirectUri */ location.href.replace(location.search, "") }); } if (!stateId || !state) { return reportError("No authentication state was found, call `ArcGISIdentityManager.beginOAuth2(...)` to start the authentication process.", "no-auth-state"); } if (state.id !== stateId) { return reportError("Saved client state did not match server sent state.", "mismatched-auth-state"); } if (params.error) { const error = params.error; const errorMessage = params.error_description || "Unknown error"; return reportError(errorMessage, error, state.originalUrl); } /** * If we are using PKCE the authorization code will be in the query params. * For implicit grants the token will be in the hash. */ if (pkce && params.code) { const tokenEndpoint = (0, clean_url_js_1.cleanUrl)(`${portal}/oauth2/token/`); const codeVerifierStorageKey = `ARCGIS_REST_JS_CODE_VERIFIER_${clientId}`; const codeVerifier = win.localStorage.getItem(codeVerifierStorageKey); win.localStorage.removeItem(codeVerifierStorageKey); // exchange our auth code for a token + refresh token return (0, fetch_token_js_1.fetchToken)(tokenEndpoint, { httpMethod: "POST", params: { client_id: clientId, code_verifier: codeVerifier, grant_type: "authorization_code", // using location.href here does not support query params but shipped with 4.0.0. See https://github.com/Esri/arcgis-rest-js/issues/995 redirect_uri: redirectUri || location.href.replace(location.search, ""), code: params.code } }) .then((tokenResponse) => { return createManager(Object.assign(Object.assign({}, tokenResponse), state), state.originalUrl); }) .catch((e) => { return reportError(e.originalMessage, e.code, state.originalUrl); }); } if (!pkce && params.access_token) { return Promise.resolve(createManager(Object.assign({ token: params.access_token, expires: new Date(Date.now() + parseInt(params.expires_in, 10) * 1000), ssl: params.ssl === "true", username: params.username }, state), state.originalUrl)); } return reportError("Unknown error", "oauth-error", state.originalUrl); } /** * Request credentials information from the parent application * * When an application is embedded into another application via an IFrame, the embedded app can * use `window.postMessage` to request credentials from the host application. This function wraps * that behavior. * * The ArcGIS API for Javascript has this built into the Identity Manager as of the 4.19 release. * * Note: The parent application will not respond if the embedded app's origin is not: * - the same origin as the parent or *.arcgis.com (JSAPI) * - in the list of valid child origins (REST-JS) * * * @param parentOrigin origin of the parent frame. Passed into the embedded application as `parentOrigin` query param * @browserOnly */ static fromParent(parentOrigin, win) { /* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */ if (!win && window) { win = window; } // Declare handler outside of promise scope so we can detach it let handler; // return a promise that will resolve when the handler receives // session information from the correct origin return new Promise((resolve, reject) => { // create an event handler that just wraps the parentMessageHandler handler = (event) => { // ensure we only listen to events from the parent if (event.source === win.parent && event.data) { try { return resolve(ArcGISIdentityManager.parentMessageHandler(event)); } catch (err) { return reject(err); } } }; // add listener win.addEventListener("message", handler, false); win.parent.postMessage({ type: "arcgis:auth:requestCredential" }, parentOrigin); }).then((manager) => { win.removeEventListener("message", handler, false); return manager; }); } /** * Begins a new server-based OAuth 2.0 sign in. This will redirect the user to * the ArcGIS Online or ArcGIS Enterprise authorization page. * * @nodeOnly */ static authorize(options, response) { const { portal, clientId, expiration, redirectUri, state } = Object.assign({ portal: "https://arcgis.com/sharing/rest", expiration: 20160 }, options); const queryParams = { client_id: clientId, expiration, response_type: "code", redirect_uri: redirectUri }; if (state) { queryParams.state = state; } const url = `${portal}/oauth2/authorize?${(0, encode_query_string_js_1.encodeQueryString)(queryParams)}`; response.writeHead(301, { Location: url }); response.end(); } /** * Completes the server-based OAuth 2.0 sign in process by exchanging the `authorizationCode` * for a `access_token`. * * @nodeOnly */ static exchangeAuthorizationCode(options, authorizationCode) { const { portal, clientId, redirectUri } = Object.assign({ portal: "https://www.arcgis.com/sharing/rest" }, options); return (0, fetch_token_js_1.fetchToken)(`${portal}/oauth2/token`, { params: { grant_type: "authorization_code", client_id: clientId, redirect_uri: redirectUri, code: authorizationCode } }) .then((response) => { return new ArcGISIdentityManager({ clientId, portal, ssl: response.ssl, redirectUri, refreshToken: response.refreshToken, refreshTokenExpires: response.refreshTokenExpires, token: response.token, tokenExpires: response.expires, username: response.username }); }) .catch((e) => { throw new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError(e.message, ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.REFRESH_TOKEN_EXCHANGE_FAILED, e.response, e.url, e.options); }); } /** * Deserializes a JSON string previously created with {@linkcode ArcGISIdentityManager.serialize} to an {@linkcode ArcGISIdentityManager} instance. * * ```js * // create an ArcGISIdentityManager instance * const serializedString = manager.serialize(); * localStorage.setItem("arcgis-identity-manager", serializedString); * * // later, you can retrieve the manager from localStorage * const serializedString = localStorage.getItem("arcgis-identity-manager"); * const manager = ArcGISIdentityManager.deserialize(serializedString); * ``` * * @param str A JSON string representing an instance of `ArcGISIdentityManager`. This can be created with {@linkcode ArcGISIdentityManager.serialize}. */ static deserialize(str) { const options = JSON.parse(str); return new ArcGISIdentityManager({ clientId: options.clientId, refreshToken: options.refreshToken, refreshTokenExpires: options.refreshTokenExpires ? new Date(options.refreshTokenExpires) : undefined, username: options.username, password: options.password, token: options.token, tokenExpires: options.tokenExpires ? new Date(options.tokenExpires) : undefined, portal: options.portal, ssl: options.ssl, tokenDuration: options.tokenDuration, redirectUri: options.redirectUri, server: options.server }); } /** * Translates authentication from the format used in the [`IdentityManager` class in the ArcGIS API for JavaScript](https://developers.arcgis.com/javascript/latest/api-reference/esri-identity-Credential.html). * * You will need to call both [`IdentityManger.findCredential`](https://developers.arcgis.com/javascript/latest/api-reference/esri-identity-IdentityManager.html#findCredential) and [`IdentityManger.findServerInfo`](https://developers.arcgis.com/javascript/latest/api-reference/esri-identity-IdentityManager.html#findServerInfo) to obtain both parameters for this method. * * This method can be used with {@linkcode ArcGISIdentityManager.toCredential} to interop with the ArcGIS API for JavaScript. * * ```js * require(["esri/id"], (esriId) => { * const credential = esriId.findCredential("https://www.arcgis.com/sharing/rest"); * const serverInfo = esriId.findServerInfo("https://www.arcgis.com/sharing/rest"); * * const manager = ArcGISIdentityManager.fromCredential(credential, serverInfo); * }); * ``` * * @returns ArcGISIdentityManager */ static fromCredential(credential, serverInfo) { // At ArcGIS Online 9.1, credentials no longer include the ssl and expires properties // Here, we provide default values for them to cover this condition const ssl = typeof credential.ssl !== "undefined" ? credential.ssl : true; const expires = credential.expires || Date.now() + 7200000; /* 2 hours */ if (serverInfo.hasServer) { return new ArcGISIdentityManager({ server: credential.server, ssl, token: credential.token, username: credential.userId, tokenExpires: new Date(expires) }); } return new ArcGISIdentityManager({ portal: (0, clean_url_js_1.cleanUrl)(credential.server.includes("sharing/rest") ? credential.server : credential.server + `/sharing/rest`), ssl, token: credential.token, username: credential.userId, tokenExpires: new Date(expires) }); } /** * Handle the response from the parent * @param event DOM Event */ static parentMessageHandler(event) { if (event.data.type === "arcgis:auth:credential") { const credential = event.data.credential; // at 4.x - 4.5 we were passing .toJSON() instead of .toCredential() // so we attempt to handle either payload for backwards compatibility // but at the next breaking change we should only support an ICredential return isCredential(credential) ? ArcGISIdentityManager.fromCredential(credential, { hasPortal: true, hasServer: false, server: credential.server }) : new ArcGISIdentityManager(credential); } if (event.data.type === "arcgis:auth:error") { const err = new Error(event.data.error.message); err.name = event.data.error.name; throw err; } else { throw new Error("Unknown message type."); } } /** * Revokes all active tokens for a provided {@linkcode ArcGISIdentityManager}. The can be considered the equivalent to signing the user out of your application. */ static destroy(manager) { return (0, revoke_token_js_1.revokeToken)({ clientId: manager.clientId, portal: manager.portal, token: manager.refreshToken || manager.token }); } /** * Create a {@linkcode ArcGISIdentityManager} from an existing token. Useful for when you have a users token from a different authentication system and want to get a {@linkcode ArcGISIdentityManager}. */ static fromToken(options) { const manager = new ArcGISIdentityManager(options); return manager.getUser().then(() => { return manager; }); } /** * Initialize a {@linkcode ArcGISIdentityManager} with a user's `username` and `password`. **This method is intended ONLY for applications without a user interface such as CLI tools.**. * * If possible you should use {@linkcode ArcGISIdentityManager.beginOAuth2} to authenticate users in a browser or {@linkcode ArcGISIdentityManager.authorize} for authenticating users with a web server. */ static signIn(options) { const manager = new ArcGISIdentityManager(options); return manager.getUser().then(() => { return manager; }); } /** * Returns authentication in a format useable in the [`IdentityManager.registerToken()` method in the ArcGIS API for JavaScript](https://developers.arcgis.com/javascript/latest/api-reference/esri-identity-IdentityManager.html#registerToken). * * This method can be used with {@linkcode ArcGISIdentityManager.fromCredential} to interop with the ArcGIS API for JavaScript. * * ```js * require(["esri/id"], (esriId) => { * esriId.registerToken(manager.toCredential()); * }) * ``` * * @returns ICredential */ toCredential() { return { expires: this.tokenExpires.getTime(), server: this.server || this.portal, ssl: this.ssl, token: this.token, userId: this.username }; } /** * Returns information about the currently logged in user's [portal](https://developers.arcgis.com/rest/users-groups-and-items/portal-self.htm). Subsequent calls will *not* result in additional web traffic. * * ```js * manager.getPortal() * .then(response => { * console.log(portal.name); // "City of ..." * }) * ``` * * @param requestOptions - Options for the request. NOTE: `rawResponse` is not supported by this operation. * @returns A Promise that will resolve with the data from the response. */ getPortal(requestOptions) { if (this._pendingPortalRequest) { return this._pendingPortalRequest; } else if (this._portalInfo) { return Promise.resolve(this._portalInfo); } else { const url = `${this.portal}/portals/self`; const options = Object.assign(Object.assign({ httpMethod: "GET", authentication: this }, requestOptions), { rawResponse: false }); this._pendingPortalRequest = (0, request_js_1.request)(url, options).then((response) => { this._portalInfo = response; this._pendingPortalRequest = null; return response; }); return this._pendingPortalRequest; } } /** * Gets an appropriate token for the given URL. If `portal` is ArcGIS Online and * the request is to an ArcGIS Online domain `token` will be used. If the request * is to the current `portal` the current `token` will also be used. However if * the request is to an unknown server we will validate the server with a request * to our current `portal`. */ getToken(url, requestOptions) { if ((0, federation_utils_js_1.canUseOnlineToken)(this.portal, url)) { return this.getFreshToken(requestOptions); } else if (new RegExp(this.portal, "i").test(url)) { return this.getFreshToken(requestOptions); } else { return this.getTokenForServer(url, requestOptions); } } /** * Get application access information for the current user * see `validateAppAccess` function for details * * @param clientId application client id */ validateAppAccess(clientId) { return this.getToken(this.portal).then((token) => { return (0, validate_app_access_js_1.validateAppAccess)(token, clientId); }); } /** * Converts the `ArcGISIdentityManager` instance to a JSON object. This is called when the instance is serialized to JSON with [`JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). * * ```js * import { ArcGISIdentityManager } from '@esri/arcgis-rest-request'; * * const session = ArcGISIdentityManager.fromCredentials({ * clientId: "abc123", * clientSecret: "••••••" * }) * * const json = JSON.stringify(session); * ``` * * @returns A plain object representation of the instance. */ toJSON() { return { type: "ArcGISIdentityManager", clientId: this.clientId, refreshToken: this.refreshToken, refreshTokenExpires: this.refreshTokenExpires || undefined, username: this.username, password: this.password, token: this.token, tokenExpires: this.tokenExpires || undefined, portal: this.portal, ssl: this.ssl, tokenDuration: this.tokenDuration, redirectUri: this.redirectUri, server: this.server }; } /** * Serializes the `ArcGISIdentityManager` instance to a JSON string. * * ```js * // create an ArcGISIdentityManager instance * const serializedString = manager.serialize(); * localStorage.setItem("arcgis-identity-manager", serializedString); * * // later, you can retrieve the manager from localStorage * const serializedString = localStorage.getItem("arcgis-identity-manager"); * const manager = ArcGISIdentityManager.deserialize(serializedString); * ``` * * @returns The serialized JSON string. */ serialize() { return JSON.stringify(this); } /** * For a "Host" app that embeds other platform apps via iframes, after authenticating the user * and creating a ArcGISIdentityManager, the app can then enable "post message" style authentication by calling * this method. * * Internally this adds an event listener on window for the `message` event * * @param validChildOrigins Array of origins that are allowed to request authentication from the host app */ enablePostMessageAuth(validChildOrigins, win) { /* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */ if (!win && window) { win = window; } this._hostHandler = this.createPostMessageHandler(validChildOrigins); win.addEventListener("message", this._hostHandler, false); } /** * For a "Host" app that has embedded other platform apps via iframes, when the host needs * to transition routes, it should call `ArcGISIdentityManager.disablePostMessageAuth()` to remove * the event listener and prevent memory leaks */ disablePostMessageAuth(win) { /* istanbul ignore next: must pass in a mockwindow for tests so we can't cover the other branch */ if (!win && window) { win = window; } win.removeEventListener("message", this._hostHandler, false); } /** * Manually refreshes the current `token` and `tokenExpires`. */ refreshCredentials(requestOptions) { // make sure subsequent calls to getUser() don't returned cached metadata this.clearCachedUserInfo(); if (this.username && this.password) { return this.refreshWithUsernameAndPassword(requestOptions); } if (this.clientId && this.refreshToken) { return this.refreshWithRefreshToken(); } return Promise.reject(new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError("Unable to refresh token. No refresh token or password present.", ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.TOKEN_REFRESH_FAILED)); } /** * Determines the root of the ArcGIS Server or Portal for a given URL. * * @param url the URl to determine the root url for. */ getServerRootUrl(url) { const [root] = (0, clean_url_js_1.cleanUrl)(url).split(/\/rest(\/admin)?\/services(?:\/|#|\?|$)/); const [match, protocol, domainAndPath] = root.match(/(https?:\/\/)(.+)/); const [domain, ...path] = domainAndPath.split("/"); // only the domain is lowercased because in some cases an org id might be // in the path which cannot be lowercased. return `${protocol}${domain.toLowerCase()}/${path.join("/")}`; } /** * Returns the proper [`credentials`] option for `fetch` for a given domain. * See [trusted server](https://enterprise.arcgis.com/en/portal/latest/administer/windows/configure-security.htm#ESRI_SECTION1_70CC159B3540440AB325BE5D89DBE94A). * Used internally by underlying request methods to add support for specific security considerations. * * @param url The url of the request * @returns "include" or "same-origin" */ getDomainCredentials(url) { if (!this.trustedDomains || !this.trustedDomains.length) { return "same-origin"; } url = url.toLowerCase(); return this.trustedDomains.some((domainWithProtocol) => { return url.startsWith(domainWithProtocol.toLowerCase()); }) ? "include" : "same-origin"; } /** * Convenience method for {@linkcode ArcGISIdentityManager.destroy} for this instance of `ArcGISIdentityManager` */ signOut() { return ArcGISIdentityManager.destroy(this); } /** * Return a function that closes over the validOrigins array and * can be used as an event handler for the `message` event * * @param validOrigins Array of valid origins */ createPostMessageHandler(validOrigins) { // return a function that closes over the validOrigins and // has access to the credential return (event) => { // Verify that the origin is valid // Note: do not use regex's here. validOrigins is an array so we're checking that the event's origin // is in the array via exact match. More info about avoiding postMessage xss issues here // https://jlajara.gitlab.io/web/2020/07/17/Dom_XSS_PostMessage_2.html#tipsbypasses-in-postmessage-vulnerabilities const isValidOrigin = validOrigins.indexOf(event.origin) > -1; // JSAPI handles this slightly differently - instead of checking a list, it will respond if // event.origin === window.location.origin || event.origin.endsWith('.arcgis.com') // For Hub, and to enable cross domain debugging with port's in urls, we are opting to // use a list of valid origins // Ensure the message type is something we want to handle const isValidType = event.data.type === "arcgis:auth:requestCredential"; // Ensure we don't pass an expired session forward const isTokenValid = this.tokenExpires.getTime() > Date.now(); if (isValidOrigin && isValidType) { let msg = {}; if (isTokenValid) { const credential = this.toCredential(); // the following line allows us to conform to our spec without changing other depended-on functionality // https://github.com/Esri/arcgis-rest-js/blob/master/packages/arcgis-rest-auth/post-message-auth-spec.md#arcgisauthcredential credential.server = credential.server.replace("/sharing/rest", ""); msg = { type: "arcgis:auth:credential", credential }; } else { msg = { type: "arcgis:auth:error", error: { name: "tokenExpiredError", message: "Token was expired, and not returned to the child application" } }; } event.source.postMessage(msg, event.origin); } }; } /** * Validates that a given URL is properly federated with our current `portal`. * Attempts to use the internal `federatedServers` cache first. */ getTokenForServer(url, requestOptions) { // requests to /rest/services/ and /rest/admin/services/ are both valid // Federated servers may have inconsistent casing, so lowerCase it const root = this.getServerRootUrl(url); const existingToken = this.federatedServers[root]; if (existingToken && existingToken.expires && existingToken.expires.getTime() > Date.now()) { return Promise.resolve(existingToken.token); } if (this._pendingTokenRequests[root]) { return this._pendingTokenRequests[root]; } this._pendingTokenRequests[root] = this.fetchAuthorizedDomains().then(() => { return (0, request_js_1.request)(`${root}/rest/info`, { credentials: this.getDomainCredentials(url) }) .then((serverInfo) => { if (serverInfo.owningSystemUrl) { /** * if this server is not owned by this portal * bail out with an error since we know we wont * be able to generate a token */ if (!(0, federation_utils_js_1.isFederated)(serverInfo.owningSystemUrl, this.portal)) { throw new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError(`${url} is not federated with ${this.portal}.`, ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.NOT_FEDERATED); } else { /** * if the server is federated, use the relevant token endpoint. */ return (0, request_js_1.request)(`${serverInfo.owningSystemUrl}/sharing/rest/info`, requestOptions); } } else if (serverInfo.authInfo && this.federatedServers[root] !== undefined) { /** * if its a stand-alone instance of ArcGIS Server that doesn't advertise * federation, but the root server url is recognized, use its built in token endpoint. */ return Promise.resolve({ authInfo: serverInfo.authInfo }); } else { throw new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError(`${url} is not federated with any portal and is not explicitly trusted.`, ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.NOT_FEDERATED); } }) .then((serverInfo) => { // an expired token cant be used to generate a new token so refresh our credentials before trying to generate a server token if (this.token && this.tokenExpires.getTime() < Date.now()) { // If we are authenticated to a single server just refresh with username and password and use the new credentials as the credentials for this server. if (this.server) { return this.refreshCredentials().then(() => { return { token: this.token, expires: this.tokenExpires }; }); } // Otherwise refresh the credentials for the portal and generate a URL for the specific server. return this.refreshCredentials().then(() => { return this.generateTokenForServer(serverInfo.authInfo.tokenServicesUrl, root); }); } else { return this.generateTokenForServer(serverInfo.authInfo.tokenServicesUrl, root); } }) .then((response) => { this.federatedServers[root] = response; delete this._pendingTokenRequests[root]; return response.token; }); }); return this._pendingTokenRequests[root]; } /** * Generates a token for a given `serverUrl` using a given `tokenServicesUrl`. */ generateTokenForServer(tokenServicesUrl, serverUrl) { return (0, request_js_1.request)(tokenServicesUrl, { params: { token: this.token, serverUrl, expiration: this.tokenDuration } }) .then((response) => { return { token: response.token, expires: new Date(response.expires - 1000 * 60 * 5) }; }) .catch((e) => { throw new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError(e.message, ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.GENERATE_TOKEN_FOR_SERVER_FAILED, e.response, e.url, e.options); }); } /** * Returns an unexpired token for the current `portal`. */ getFreshToken(requestOptions) { if (this.token && !this.tokenExpires) { return Promise.resolve(this.token); } if (this.token && this.tokenExpires && this.tokenExpires.getTime() > Date.now()) { return Promise.resolve(this.token); } if (!this._pendingTokenRequests[this.portal]) { this._pendingTokenRequests[this.portal] = this.refreshCredentials(requestOptions).then(() => { this._pendingTokenRequests[this.portal] = null; return this.token; }); } return this._pendingTokenRequests[this.portal]; } /** * Refreshes the current `token` and `tokenExpires` with `username` and * `password`. */ refreshWithUsernameAndPassword(requestOptions) { const params = { username: this.username, password: this.password, expiration: this.tokenDuration, client: "referer", referer: this.referer ? this.referer : typeof window !== "undefined" && typeof window.document !== "undefined" && window.location && window.location.origin ? window.location.origin : /* istanbul ignore next */ index_js_1.NODEJS_DEFAULT_REFERER_HEADER }; return (this.server ? (0, request_js_1.request)(`${this.getServerRootUrl(this.server)}/rest/info`).then((response) => { return (0, request_js_1.request)(response.authInfo.tokenServicesUrl, Object.assign({ params }, requestOptions)); }) : (0, request_js_1.request)(`${this.portal}/generateToken`, Object.assign({ params }, requestOptions))) .then((response) => { this.updateToken(response.token, new Date(response.expires)); return this; }) .catch((e) => { throw new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError(e.message, ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.TOKEN_REFRESH_FAILED, e.response, e.url, e.options); }); } /** * Refreshes the current `token` and `tokenExpires` with `refreshToken`. */ refreshWithRefreshToken(requestOptions) { // If our refresh token expires sometime in the next 24 hours then refresh the refresh token const ONE_DAY_IN_MILLISECONDS = 1000 * 60 * 60 * 24; if (this.refreshToken && this.refreshTokenExpires && this.refreshTokenExpires.getTime() - ONE_DAY_IN_MILLISECONDS < Date.now()) { return this.exchangeRefreshToken(requestOptions); } const options = Object.assign({ params: { client_id: this.clientId, refresh_token: this.refreshToken, grant_type: "refresh_token" } }, requestOptions); return (0, fetch_token_js_1.fetchToken)(`${this.portal}/oauth2/token`, options) .then((response) => { return this.updateToken(response.token, response.expires); }) .catch((e) => { throw new ArcGISTokenRequestError_js_1.ArcGISTokenRequestError(e.message, ArcGISTokenRequestError_js_1.ArcGISTokenRequestErrorCodes.TOKEN_REFRESH_FAILED, e.response, e.url, e.options); }); } /** * Update the stored {@linkcode ArcGISIdentityManager.token} and {@linkcode ArcGISIdentityManager.tokenExpires} properties. This method is used internally when refreshing tokens. * You may need