diff --git a/docs/src/guides/browser-authentication.md b/docs/src/guides/browser-authentication.md index 2a82f80f00..e63230bc37 100644 --- a/docs/src/guides/browser-authentication.md +++ b/docs/src/guides/browser-authentication.md @@ -12,30 +12,28 @@ In the [Node.js](/arcgis-rest-js/guides/node/) guide we explained how to instant ![browser based login](https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/images/authorization-screen.png) - ### Resources -* [Implementing Named User Login](https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/signing-in-arcgis-online-users/) -* [Browser-based Named User Login](https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/browser-based-user-logins/) +- [Implementing Named User Login](https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/signing-in-arcgis-online-users/) +- [Browser-based Named User Login](https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/browser-based-user-logins/) ```js // register your own app to create a unique clientId -const clientId = "abc123" +const clientId = "abc123"; UserSession.beginOAuth2({ clientId, - redirectUri: 'https://yourapp.com/authenticate.html' -}) - .then(session) + redirectUri: "https://yourapp.com/authenticate.html", +}).then(session); ``` -After the user has logged in, the `session` will keep track of individual `trustedServers` that are known to be federated and pass a token through when making requests. +After the user has logged in, the `session` will keep track of individual `federatedServers` that are known to be federated and pass a token through when making requests. ```js -request(url, { authentication: session }) +request(url, { authentication: session }); ``` ### Demos -* [OAuth 2.0 Browser](https://github.com/Esri/arcgis-rest-js/tree/master/demos/oauth2-browser) -* [Retrying Requests](https://github.com/Esri/arcgis-rest-js/tree/master/demos/oauth2-browser-retry) +- [OAuth 2.0 Browser](https://github.com/Esri/arcgis-rest-js/tree/master/demos/oauth2-browser) +- [Retrying Requests](https://github.com/Esri/arcgis-rest-js/tree/master/demos/oauth2-browser-retry) diff --git a/packages/arcgis-rest-auth/src/UserSession.ts b/packages/arcgis-rest-auth/src/UserSession.ts index 4b233e9f1f..7e45ccafdb 100644 --- a/packages/arcgis-rest-auth/src/UserSession.ts +++ b/packages/arcgis-rest-auth/src/UserSession.ts @@ -74,21 +74,6 @@ function defer(): IDeferred { return deferred as IDeferred; } -/** - * Used to test if a URL is an ArcGIS Online URL - */ -const arcgisOnlineUrlRegex = /^https?:\/\/\S+\.arcgis\.com.+/; - -/** - * Used to test if a URL is production ArcGIS Online Portal - */ -const arcgisOnlinePortalRegex = /^https?:\/\/www\.arcgis\.com\/sharing\/rest+/; - -/** - * Used to test if a URL is an ArcGIS Online Organization Portal - */ -const arcgisOnlineOrgPortalRegex = /^https?:\/\/(?:[a-z0-9-]+\.maps)?.\arcgis\.com\/sharing\/rest/; - /** * Options for static OAuth 2.0 helper methods on `UserSession`. */ @@ -292,6 +277,17 @@ export class UserSession implements IAuthenticationManager { get refreshTokenExpires() { return this._refreshTokenExpires; } + + /** + * Deprecated, use `federatedServers` instead. + * + * @deprecated + */ + get trustedServers() { + console.log("DEPRECATED: use federatedServers instead"); + return this.federatedServers; + } + /** * 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 @@ -622,8 +618,8 @@ export class UserSession implements IAuthenticationManager { public static fromCredential(credential: ICredential) { // 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 */); + const ssl = typeof credential.ssl !== "undefined" ? credential.ssl : true; + const expires = credential.expires || Date.now() + 7200000 /* 2 hours */; return new UserSession({ portal: credential.server.includes("sharing/rest") @@ -632,7 +628,7 @@ export class UserSession implements IAuthenticationManager { ssl, token: credential.token, username: credential.userId, - tokenExpires: new Date(expires) + tokenExpires: new Date(expires), }); } @@ -715,11 +711,17 @@ export class UserSession implements IAuthenticationManager { */ private _user: IUser; + /** + * Hydrated by a call to [getPortal()](#getPortal-summary). + */ + private _portalInfo: any; + private _token: string; private _tokenExpires: Date; private _refreshToken: string; private _refreshTokenExpires: Date; private _pendingUserRequest: Promise; + private _pendingPortalRequest: Promise; /** * Internal object to keep track of pending token requests. Used to prevent @@ -730,16 +732,22 @@ export class UserSession implements IAuthenticationManager { }; /** - * Internal list of trusted 3rd party servers (federated servers) that have - * been validated with `generateToken`. + * Internal list of tokens to 3rd party servers (federated servers) that have + * been created via `generateToken`. The object key is the root URL of the server. */ - private trustedServers: { + private federatedServers: { [key: string]: { token: string; expires: Date; }; }; + /** + * Internal list of 3rd party domains that should receive all cookies (credentials: "include"). + * Used to for PKI and IWA workflows in high security environments. + */ + private trustedDomains: string[]; + private _hostHandler: any; constructor(options: IUserSessionOptions) { @@ -758,14 +766,17 @@ export class UserSession implements IAuthenticationManager { this.tokenDuration = options.tokenDuration || 20160; this.redirectUri = options.redirectUri; this.refreshTokenTTL = options.refreshTokenTTL || 1440; + this.server = options.server; + + this.federatedServers = {}; + this.trustedDomains = []; - this.trustedServers = {}; // 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.trustedServers[root] = { + this.federatedServers[root] = { token: options.token, expires: options.tokenExpires, }; @@ -830,6 +841,44 @@ export class UserSession implements IAuthenticationManager { } } + /** + * 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 + * session.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. + */ + public getPortal(requestOptions?: IRequestOptions): Promise { + if (this._pendingPortalRequest) { + return this._pendingPortalRequest; + } else if (this._portalInfo) { + return Promise.resolve(this._portalInfo); + } else { + const url = `${this.portal}/portals/self`; + + const options = { + httpMethod: "GET", + authentication: this, + ...requestOptions, + rawResponse: false, + } as IRequestOptions; + + this._pendingPortalRequest = request(url, options).then((response) => { + this._portalInfo = response; + this._pendingPortalRequest = null; + return response; + }); + + return this._pendingPortalRequest; + } + } + /** * Returns the username for the currently logged in [user](https://developers.arcgis.com/rest/users-groups-and-items/user.htm). Subsequent calls will *not* result in additional web traffic. This is also used internally when a username is required for some requests but is not present in the options. * @@ -968,6 +1017,27 @@ export class UserSession implements IAuthenticationManager { // 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" + */ + public getDomainCredentials(url: string): RequestCredentials { + if (!this.trustedDomains || !this.trustedDomains.length) { + return "same-origin"; + } + + return this.trustedDomains.some((domainWithProtocol) => { + return url.startsWith(domainWithProtocol); + }) + ? "include" + : "same-origin"; + } + /** * Return a function that closes over the validOrigins array and * can be used as an event handler for the `message` event @@ -997,7 +1067,7 @@ export class UserSession implements IAuthenticationManager { 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', ''); + credential.server = credential.server.replace("/sharing/rest", ""); event.source.postMessage( { type: "arcgis:auth:credential", @@ -1011,7 +1081,7 @@ export class UserSession implements IAuthenticationManager { /** * Validates that a given URL is properly federated with our current `portal`. - * Attempts to use the internal `trustedServers` cache first. + * Attempts to use the internal `federatedServers` cache first. */ private getTokenForServer( url: string, @@ -1020,7 +1090,7 @@ export class UserSession implements IAuthenticationManager { // 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.trustedServers[root]; + const existingToken = this.federatedServers[root]; if ( existingToken && @@ -1034,82 +1104,90 @@ export class UserSession implements IAuthenticationManager { return this._pendingTokenRequests[root]; } - this._pendingTokenRequests[root] = request(`${root}/rest/info`) - .then((response) => { - if (response.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 (!isFederated(response.owningSystemUrl, this.portal)) { - throw new ArcGISAuthError( - `${url} is not federated with ${this.portal}.`, - "NOT_FEDERATED" - ); - } else { - /** - * if the server is federated, use the relevant token endpoint. - */ - return request( - `${response.owningSystemUrl}/sharing/rest/info`, - requestOptions - ); - } - } else if ( - response.authInfo && - this.trustedServers[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: response.authInfo }); - } else { - throw new ArcGISAuthError( - `${url} is not federated with any portal and is not explicitly trusted.`, - "NOT_FEDERATED" - ); - } - }) - .then((response: any) => { - return response.authInfo.tokenServicesUrl; - }) - .then((tokenServicesUrl: string) => { - // an expired token cant be used to generate a new token - if (this.token && this.tokenExpires.getTime() > Date.now()) { - return generateToken(tokenServicesUrl, { - params: { - token: this.token, - serverUrl: url, - expiration: this.tokenDuration, - client: "referer", - }, - }); - // generate an entirely fresh token if necessary - } else { - return generateToken(tokenServicesUrl, { - params: { - username: this.username, - password: this.password, - expiration: this.tokenDuration, - client: "referer", - }, - }).then((response: any) => { - this._token = response.token; - this._tokenExpires = new Date(response.expires); - return response; + this._pendingTokenRequests[root] = this.fetchAuthorizedDomains().then( + () => { + return request(`${root}/rest/info`, { + credentials: this.getDomainCredentials(url), + }) + .then((response) => { + if (response.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 (!isFederated(response.owningSystemUrl, this.portal)) { + throw new ArcGISAuthError( + `${url} is not federated with ${this.portal}.`, + "NOT_FEDERATED" + ); + } else { + /** + * if the server is federated, use the relevant token endpoint. + */ + return request( + `${response.owningSystemUrl}/sharing/rest/info`, + requestOptions + ); + } + } else if ( + response.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: response.authInfo, + }); + } else { + throw new ArcGISAuthError( + `${url} is not federated with any portal and is not explicitly trusted.`, + "NOT_FEDERATED" + ); + } + }) + .then((response: any) => { + return response.authInfo.tokenServicesUrl; + }) + .then((tokenServicesUrl: string) => { + // an expired token cant be used to generate a new token + if (this.token && this.tokenExpires.getTime() > Date.now()) { + return generateToken(tokenServicesUrl, { + params: { + token: this.token, + serverUrl: url, + expiration: this.tokenDuration, + client: "referer", + }, + }); + // generate an entirely fresh token if necessary + } else { + return generateToken(tokenServicesUrl, { + params: { + username: this.username, + password: this.password, + expiration: this.tokenDuration, + client: "referer", + }, + }).then((response: any) => { + this._token = response.token; + this._tokenExpires = new Date(response.expires); + return response; + }); + } + }) + .then((response) => { + this.federatedServers[root] = { + expires: new Date(response.expires), + token: response.token, + }; + delete this._pendingTokenRequests[root]; + return response.token; }); - } - }) - .then((response) => { - this.trustedServers[root] = { - expires: new Date(response.expires), - token: response.token, - }; - delete this._pendingTokenRequests[root]; - return response.token; - }); + } + ); return this._pendingTokenRequests[root]; } @@ -1222,4 +1300,40 @@ export class UserSession implements IAuthenticationManager { } ); } + + /** + * ensures that the authorizedCrossOriginDomains are obtained from the portal and cached + * so we can check them later. + * + * @returns this + */ + private fetchAuthorizedDomains() { + // if this token is for a specific server or we don't have a portal + // don't get the portal info because we cant get the authorizedCrossOriginDomains + if (this.server || !this.portal) { + return Promise.resolve(this); + } + + return this.getPortal().then((portalInfo) => { + /** + * Specific domains can be configured as secure.esri.com or https://secure.esri.com this + * normalizes to https://secure.esri.com so we can use startsWith later. + */ + if ( + portalInfo.authorizedCrossOriginDomains && + portalInfo.authorizedCrossOriginDomains.length + ) { + this.trustedDomains = portalInfo.authorizedCrossOriginDomains + .filter((d: string) => !d.startsWith("http://")) + .map((d: string) => { + if (d.startsWith("https://")) { + return d; + } else { + return `https://${d}`; + } + }); + } + return this; + }); + } } diff --git a/packages/arcgis-rest-auth/src/generate-token.ts b/packages/arcgis-rest-auth/src/generate-token.ts index 44f99bb59a..15fea0566b 100644 --- a/packages/arcgis-rest-auth/src/generate-token.ts +++ b/packages/arcgis-rest-auth/src/generate-token.ts @@ -5,7 +5,7 @@ import { request, IRequestOptions, ITokenRequestOptions, - NODEJS_DEFAULT_REFERER_HEADER + NODEJS_DEFAULT_REFERER_HEADER, } from "@esri/arcgis-rest-request"; export interface IGenerateTokenResponse { diff --git a/packages/arcgis-rest-auth/test/UserSession.test.ts b/packages/arcgis-rest-auth/test/UserSession.test.ts index 13f54168f6..9348708caf 100644 --- a/packages/arcgis-rest-auth/test/UserSession.test.ts +++ b/packages/arcgis-rest-auth/test/UserSession.test.ts @@ -199,6 +199,13 @@ describe("UserSession", () => { }, }); + fetchMock.getOnce( + "https://pnp00035.esri.com/portal/sharing/rest/portals/self?f=json&token=existing-session-token", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.postOnce("https://pnp00035.esri.com/portal/sharing/rest/info", { owningSystemUrl: "https://pnp00035.esri.com/portal", authInfo: { @@ -306,6 +313,13 @@ describe("UserSession", () => { }, }); + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.postOnce("https://gis.city.gov/sharing/rest/info", { owningSystemUrl: "http://gis.city.gov", authInfo: { @@ -357,6 +371,13 @@ describe("UserSession", () => { }, }); + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.postOnce("https://gis.city.gov/sharing/rest/info", { owningSystemUrl: "http://gis.city.gov", authInfo: { @@ -406,6 +427,17 @@ describe("UserSession", () => { }, }); + fetchMock.postOnce("https://gis.city.gov/sharing/rest/generateToken", { + token: "portalToken", + }); + + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=portalToken", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.postOnce("https://gis.city.gov/sharing/rest/info", { owningSystemUrl: "http://gis.city.gov", authInfo: { @@ -455,6 +487,13 @@ describe("UserSession", () => { { repeat: 1, method: "POST" } ); + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.mock( "https://gis.city.gov/sharing/rest/info", { @@ -505,6 +544,20 @@ describe("UserSession", () => { tokenExpires: YESTERDAY, }); + // similates refreshing the token with the refresh token + fetchMock.postOnce("https://www.arcgis.com/sharing/rest/oauth2/token", { + access_token: "newToken", + expires_in: 60, + username: " c@sey", + }); + + fetchMock.getOnce( + "https://www.arcgis.com/sharing/rest/portals/self?f=json&token=newToken", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.post("https://gisservices.city.gov/public/rest/info", { currentVersion: 10.51, fullVersion: "10.5.1.120", @@ -537,6 +590,19 @@ describe("UserSession", () => { tokenExpires: YESTERDAY, }); + fetchMock.postOnce("https://www.arcgis.com/sharing/rest/oauth2/token", { + access_token: "newToken", + expires_in: 60, + username: " c@sey", + }); + + fetchMock.getOnce( + "https://www.arcgis.com/sharing/rest/portals/self?f=json&token=newToken", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.post("https://gisservices.city.gov/public/rest/info", { currentVersion: 10.51, fullVersion: "10.5.1.120", @@ -588,6 +654,19 @@ describe("UserSession", () => { fullVersion: "10.5.1.120", }); + fetchMock.postOnce("https://www.arcgis.com/sharing/rest/oauth2/token", { + access_token: "newToken", + expires_in: 60, + username: " c@sey", + }); + + fetchMock.getOnce( + "https://www.arcgis.com/sharing/rest/portals/self?f=json&token=newToken", + { + authorizedCrossOriginDomains: [], + } + ); + fetchMock.post( "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", { @@ -1654,7 +1733,9 @@ describe("UserSession", () => { expect(session.portal).toEqual("https://www.arcgis.com/sharing/rest"); expect(session.ssl).toBeTruthy(); expect(session.token).toEqual("token"); - expect(session.tokenExpires).toEqual(new Date(Date.now() + 7200000 /* 2 hours */)); + expect(session.tokenExpires).toEqual( + new Date(Date.now() + 7200000 /* 2 hours */) + ); jasmine.clock().uninstall(); }); @@ -1839,4 +1920,340 @@ describe("UserSession", () => { }); }); }); + + describe(".getPortal()", () => { + afterEach(fetchMock.restore); + + it("should cache metadata about the portal", (done) => { + // we intentionally only mock one response + fetchMock.once( + "https://www.arcgis.com/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: ["gis.city.com"], + } + ); + + const session = new UserSession({ + clientId: "clientId", + redirectUri: "https://example-app.com/redirect-uri", + token: "token", + tokenExpires: TOMORROW, + refreshToken: "refreshToken", + refreshTokenExpires: TOMORROW, + refreshTokenTTL: 1440, + username: "jsmith", + password: "123456", + }); + + session + .getPortal() + .then((response) => { + expect(response.authorizedCrossOriginDomains).toEqual([ + "gis.city.com", + ]); + session + .getPortal() + .then((cachedResponse) => { + expect(cachedResponse.authorizedCrossOriginDomains).toEqual([ + "gis.city.com", + ]); + done(); + }) + .catch((e) => { + fail(e); + }); + }) + .catch((e) => { + fail(e); + }); + }); + + it("should never make more then 1 request", (done) => { + // we intentionally only mock one response + fetchMock.once( + "https://www.arcgis.com/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: ["gis.city.com"], + } + ); + + const session = new UserSession({ + clientId: "clientId", + redirectUri: "https://example-app.com/redirect-uri", + token: "token", + tokenExpires: TOMORROW, + refreshToken: "refreshToken", + refreshTokenExpires: TOMORROW, + refreshTokenTTL: 1440, + username: "jsmith", + password: "123456", + }); + + Promise.all([session.getPortal(), session.getPortal()]) + .then(() => { + done(); + }) + .catch((e) => { + fail(e); + }); + }); + }); + + describe("fetchAuthorizedDomains/getDomainCredentials", () => { + it("should default to same-origin credentials when no domains are listed in authorizedCrossOriginDomains", (done) => { + const session = new UserSession({ + clientId: "id", + token: "token", + refreshToken: "refresh", + tokenExpires: TOMORROW, + portal: "https://gis.city.gov/sharing/rest", + }); + + fetchMock.postOnce("https://gisservices.city.gov/public/rest/info", { + currentVersion: 10.51, + fullVersion: "10.5.1.120", + owningSystemUrl: "https://gis.city.gov", + authInfo: { + isTokenBasedSecurity: true, + tokenServicesUrl: "https://gis.city.gov/sharing/generateToken", + }, + }); + + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: [], + } + ); + + fetchMock.postOnce("https://gis.city.gov/sharing/rest/info", { + owningSystemUrl: "http://gis.city.gov", + authInfo: { + tokenServicesUrl: "https://gis.city.gov/sharing/generateToken", + isTokenBasedSecurity: true, + }, + }); + + fetchMock.postOnce("https://gis.city.gov/sharing/generateToken", { + token: "serverToken", + expires: TOMORROW, + }); + + fetchMock.post( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", + { + count: 123, + } + ); + + request( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", + { + authentication: session, + } + ) + .then((response) => { + const { credentials } = fetchMock.lastOptions( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query" + ); + expect(credentials).toEqual("same-origin"); + + done(); + }) + .catch((e) => { + fail(e); + }); + }); + + it("should set the credentials option to include when a server is listed in authorizedCrossOriginDomains", (done) => { + const session = new UserSession({ + clientId: "id", + token: "token", + refreshToken: "refresh", + tokenExpires: TOMORROW, + portal: "https://gis.city.gov/sharing/rest", + }); + + fetchMock.postOnce("https://gisservices.city.gov/public/rest/info", { + currentVersion: 10.51, + fullVersion: "10.5.1.120", + owningSystemUrl: "https://gis.city.gov", + authInfo: { + isTokenBasedSecurity: true, + tokenServicesUrl: "https://gis.city.gov/sharing/generateToken", + }, + }); + + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: ["https://gisservices.city.gov"], + } + ); + + fetchMock.postOnce("https://gis.city.gov/sharing/rest/info", { + owningSystemUrl: "http://gis.city.gov", + authInfo: { + tokenServicesUrl: "https://gis.city.gov/sharing/generateToken", + isTokenBasedSecurity: true, + }, + }); + + fetchMock.postOnce("https://gis.city.gov/sharing/generateToken", { + token: "serverToken", + expires: TOMORROW, + }); + + fetchMock.post( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", + { + count: 123, + } + ); + + request( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", + { + authentication: session, + } + ) + .then((response) => { + const { credentials } = fetchMock.lastOptions( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query" + ); + expect(credentials).toEqual("include"); + + done(); + }) + .catch((e) => { + fail(e); + }); + }); + }); + + it("should still send same-origin credentials even if another domain is listed in authorizedCrossOriginDomains", (done) => { + const session = new UserSession({ + clientId: "id", + token: "token", + refreshToken: "refresh", + tokenExpires: TOMORROW, + portal: "https://gis.city.gov/sharing/rest", + }); + + fetchMock.postOnce("https://gisservices.city.gov/public/rest/info", { + currentVersion: 10.51, + fullVersion: "10.5.1.120", + owningSystemUrl: "https://gis.city.gov", + authInfo: { + isTokenBasedSecurity: true, + tokenServicesUrl: "https://gis.city.gov/sharing/generateToken", + }, + }); + + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: ["https://other.city.gov"], + } + ); + + fetchMock.postOnce("https://gis.city.gov/sharing/rest/info", { + owningSystemUrl: "http://gis.city.gov", + authInfo: { + tokenServicesUrl: "https://gis.city.gov/sharing/generateToken", + isTokenBasedSecurity: true, + }, + }); + + fetchMock.postOnce("https://gis.city.gov/sharing/generateToken", { + token: "serverToken", + expires: TOMORROW, + }); + + fetchMock.post( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", + { + count: 123, + } + ); + + request( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query", + { + authentication: session, + } + ) + .then((response) => { + const { credentials } = fetchMock.lastOptions( + "https://gisservices.city.gov/public/rest/services/trees/FeatureServer/0/query" + ); + expect(credentials).toEqual("same-origin"); + + done(); + }) + .catch((e) => { + fail(e); + }); + }); + + it("should normalize optional protocols in authorizedCrossOriginDomains", (done) => { + const session = new UserSession({ + clientId: "id", + token: "token", + refreshToken: "refresh", + tokenExpires: TOMORROW, + portal: "https://gis.city.gov/sharing/rest", + }); + + fetchMock.getOnce( + "https://gis.city.gov/sharing/rest/portals/self?f=json&token=token", + { + authorizedCrossOriginDomains: ["one.city.gov", "https://two.city.gov"], + } + ); + + (session as any) + .fetchAuthorizedDomains() + .then(() => { + expect((session as any).trustedDomains).toEqual([ + "https://one.city.gov", + "https://two.city.gov", + ]); + done(); + }) + .catch((e: Error) => { + fail(e); + }); + }); + + it("should not use domain credentials if portal is null", (done) => { + const session = new UserSession({ + clientId: "id", + token: "token", + refreshToken: "refresh", + tokenExpires: TOMORROW, + portal: null, + server: "https://fakeserver.com/arcgis", + }); + + (session as any) + .fetchAuthorizedDomains() + .then(() => { + done(); + }) + .catch((e: Error) => { + fail(e); + }); + }); + + it("should deprecate trustedServers", () => { + const session = new UserSession({ + clientId: "id", + token: "token", + }); + + expect((session as any).trustedServers).toBe( + (session as any).federatedServers + ); + }); }); diff --git a/packages/arcgis-rest-request/src/request.ts b/packages/arcgis-rest-request/src/request.ts index da94dbc7bd..7f1dc7393d 100644 --- a/packages/arcgis-rest-request/src/request.ts +++ b/packages/arcgis-rest-request/src/request.ts @@ -254,7 +254,7 @@ export function request( method: httpMethod, /* ensures behavior mimics XMLHttpRequest. needed to support sending IWA cookies */ - credentials: "same-origin", + credentials: options.credentials || "same-origin", }; // the /oauth2/platformSelf route will add X-Esri-Auth-Client-Id header @@ -292,6 +292,10 @@ export function request( params.token = token; } + if (authentication && authentication.getDomainCredentials) { + fetchOptions.credentials = authentication.getDomainCredentials(url); + } + // Custom headers to add to request. IRequestOptions.headers with merge over requestHeaders. const requestHeaders: { [key: string]: any; @@ -404,6 +408,7 @@ export function request( options, originalAuthError ); + if (originalAuthError) { /* if the request was made to an unfederated service that didnt require authentication, add the base url and a dummy token @@ -412,7 +417,8 @@ export function request( const truncatedUrl: string = url .toLowerCase() .split(/\/rest(\/admin)?\/services\//)[0]; - (options.authentication as any).trustedServers[truncatedUrl] = { + + (options.authentication as any).federatedServers[truncatedUrl] = { token: [], // default to 24 hours expires: new Date(Date.now() + 86400 * 1000), diff --git a/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts b/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts index 9c190840fb..de62c91353 100644 --- a/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts +++ b/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts @@ -18,4 +18,5 @@ export interface IAuthenticationManager { */ portal: string; getToken(url: string, requestOptions?: ITokenRequestOptions): Promise; + getDomainCredentials?(url: string): RequestCredentials; } diff --git a/packages/arcgis-rest-request/src/utils/IRequestOptions.ts b/packages/arcgis-rest-request/src/utils/IRequestOptions.ts index b1d8e96d2f..1f29de5c1b 100644 --- a/packages/arcgis-rest-request/src/utils/IRequestOptions.ts +++ b/packages/arcgis-rest-request/src/utils/IRequestOptions.ts @@ -36,6 +36,10 @@ export interface IRequestOptions { * The implementation of `fetch` to use. Defaults to a global `fetch`. */ fetch?: (input: RequestInfo, init?: RequestInit) => Promise; + /** + * A string indicating whether credentials (cookies) will be sent with the request. Used internally for authentication workflows. + */ + credentials?: RequestCredentials; /** * If the length of a GET request's URL exceeds `maxUrlLength` the request will use POST instead. */ diff --git a/packages/arcgis-rest-service-admin/test/addTo.test.ts b/packages/arcgis-rest-service-admin/test/addTo.test.ts index 7547a49e9f..695ab47d40 100644 --- a/packages/arcgis-rest-service-admin/test/addTo.test.ts +++ b/packages/arcgis-rest-service-admin/test/addTo.test.ts @@ -12,7 +12,7 @@ import { AddToFeatureServiceSuccessResponseFredAndGinger, AddToFeatureServiceSuccessResponseFayardAndHarold, AddToFeatureServiceSuccessResponseCydAndGene, - AddToFeatureServiceError + AddToFeatureServiceError, } from "./mocks/service"; import { layerDefinitionSid } from "./mocks/layerDefinition"; @@ -31,69 +31,69 @@ describe("add to feature service", () => { refreshTokenTTL: 1440, username: "casey", password: "123456", - portal: "https://myorg.maps.arcgis.com/sharing/rest" + portal: "https://myorg.maps.arcgis.com/sharing/rest", }); const MOCK_USER_REQOPTS = { - authentication: MOCK_USER_SESSION + authentication: MOCK_USER_SESSION, }; const layerDescriptionFred: ILayer = { name: "Fred", id: "1899", - layerType: "Feature Layer" + layerType: "Feature Layer", }; const layerDescriptionGinger: ILayer = { name: "Ginger", id: "1911", - layerType: "Feature Layer" + layerType: "Feature Layer", }; const layerDescriptionCyd: ILayer = { name: "Cyd", id: "1922", - layerType: "Feature Layer" + layerType: "Feature Layer", }; const layerDescriptionFail: ILayer = { name: "", id: "", - layerType: "Feature Layer" + layerType: "Feature Layer", }; const tableDescriptionFayard: ITable = { name: "Fayard", - id: 1914 + id: 1914, }; const tableDescriptionHarold: ITable = { name: "Harold", - id: 1921 + id: 1921, }; const tableDescriptionGene: ITable = { name: "Gene", - id: 1912 + id: 1912, }; const tableDescriptionFail: ITable = { name: "", - id: 0 + id: 0, }; - it("should add a pair of layers", done => { + it("should add a pair of layers", (done) => { fetchMock.once("*", AddToFeatureServiceSuccessResponseFredAndGinger); addToServiceDefinition( "https://services1.arcgis.com/ORG/arcgis/rest/services/FEATURE_SERVICE/FeatureServer", { layers: [layerDescriptionFred, layerDescriptionGinger], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } ) .then( - response => { + (response) => { // Check service call expect(fetchMock.called()).toEqual(true); const [url, options]: [string, RequestInit] = fetchMock.lastCall( @@ -110,7 +110,7 @@ describe("add to feature service", () => { encodeParam( "addToDefinition", JSON.stringify({ - layers: [layerDescriptionFred, layerDescriptionGinger] + layers: [layerDescriptionFred, layerDescriptionGinger], }) ) ); @@ -126,23 +126,23 @@ describe("add to feature service", () => { fail(); // call is supposed to succeed } ) - .catch(e => { + .catch((e) => { fail(e); }); }); - it("should add a pair of tables", done => { + it("should add a pair of tables", (done) => { fetchMock.once("*", AddToFeatureServiceSuccessResponseFayardAndHarold); addToServiceDefinition( "https://services1.arcgis.com/ORG/arcgis/rest/services/FEATURE_SERVICE/FeatureServer", { tables: [tableDescriptionFayard, tableDescriptionHarold], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } ) .then( - response => { + (response) => { // Check service call expect(fetchMock.called()).toEqual(true); const [url, options]: [string, RequestInit] = fetchMock.lastCall( @@ -159,7 +159,7 @@ describe("add to feature service", () => { encodeParam( "addToDefinition", JSON.stringify({ - tables: [tableDescriptionFayard, tableDescriptionHarold] + tables: [tableDescriptionFayard, tableDescriptionHarold], }) ) ); @@ -175,12 +175,12 @@ describe("add to feature service", () => { fail(); // call is supposed to succeed } ) - .catch(e => { + .catch((e) => { fail(e); }); }); - it("should add a layer and a table", done => { + it("should add a layer and a table", (done) => { fetchMock.once("*", AddToFeatureServiceSuccessResponseCydAndGene); addToServiceDefinition( @@ -188,10 +188,10 @@ describe("add to feature service", () => { { layers: [layerDescriptionCyd], tables: [tableDescriptionGene], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } ) - .then(response => { + .then((response) => { // Check service call expect(fetchMock.called()).toEqual(true); const [url, options]: [string, RequestInit] = fetchMock.lastCall("*"); @@ -207,7 +207,7 @@ describe("add to feature service", () => { "addToDefinition", JSON.stringify({ layers: [layerDescriptionCyd], - tables: [tableDescriptionGene] + tables: [tableDescriptionGene], }) ) ); @@ -218,22 +218,22 @@ describe("add to feature service", () => { ); done(); }) - .catch(e => { + .catch((e) => { fail(e); }); }); - it("should add a layer definition", done => { + it("should add a layer definition", (done) => { fetchMock.once("*", AddToFeatureServiceSuccessResponseCydAndGene); addToServiceDefinition( "https://services1.arcgis.com/ORG/arcgis/rest/services/FEATURE_SERVICE/FeatureServer", { layers: [layerDefinitionSid], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } ) - .then(response => { + .then((response) => { // Check service call expect(fetchMock.called()).toEqual(true); const [url, options]: [string, RequestInit] = fetchMock.lastCall("*"); @@ -248,7 +248,7 @@ describe("add to feature service", () => { encodeParam( "addToDefinition", JSON.stringify({ - layers: [layerDefinitionSid] + layers: [layerDefinitionSid], }) ) ); @@ -259,21 +259,21 @@ describe("add to feature service", () => { ); done(); }) - .catch(e => { + .catch((e) => { fail(e); }); }); - it("should fail to add a bad layer", done => { + it("should fail to add a bad layer", (done) => { fetchMock.once("*", AddToFeatureServiceError); addToServiceDefinition( "https://services1.arcgis.com/ORG/arcgis/rest/services/FEATURE_SERVICE/FeatureServer", { layers: [layerDescriptionFail], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } - ).catch(error => { + ).catch((error) => { expect(error.name).toBe(ErrorTypes.ArcGISRequestError); expect(error.message).toBe( "400: Unable to add feature service definition." @@ -284,23 +284,23 @@ describe("add to feature service", () => { ); // params added internally aren't surfaced in the error expect(error.options.params.addToDefinition).toEqual({ - layers: [layerDescriptionFail] + layers: [layerDescriptionFail], }); expect(error.options.httpMethod).toEqual("POST"); done(); }); }); - it("should fail to add a bad table", done => { + it("should fail to add a bad table", (done) => { fetchMock.once("*", AddToFeatureServiceError); addToServiceDefinition( "https://services1.arcgis.com/ORG/arcgis/rest/services/FEATURE_SERVICE/FeatureServer", { tables: [tableDescriptionFail], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } - ).catch(error => { + ).catch((error) => { expect(error.name).toBe(ErrorTypes.ArcGISRequestError); expect(error.message).toBe( "400: Unable to add feature service definition." @@ -311,14 +311,14 @@ describe("add to feature service", () => { ); // params added internally aren't surfaced in the error expect(error.options.params.addToDefinition).toEqual({ - tables: [tableDescriptionFail] + tables: [tableDescriptionFail], }); expect(error.options.httpMethod).toEqual("POST"); done(); }); }); - it("should fail to add a bad layer and a bad table", done => { + it("should fail to add a bad layer and a bad table", (done) => { fetchMock.once("*", AddToFeatureServiceError); addToServiceDefinition( @@ -326,9 +326,9 @@ describe("add to feature service", () => { { layers: [layerDescriptionFail], tables: [tableDescriptionFail], - ...MOCK_USER_REQOPTS + ...MOCK_USER_REQOPTS, } - ).catch(error => { + ).catch((error) => { expect(error.name).toBe(ErrorTypes.ArcGISRequestError); expect(error.message).toBe( "400: Unable to add feature service definition." @@ -340,7 +340,7 @@ describe("add to feature service", () => { // params added internally aren't surfaced in the error expect(error.options.params.addToDefinition).toEqual({ tables: [tableDescriptionFail], - layers: [layerDescriptionFail] + layers: [layerDescriptionFail], }); expect(error.options.httpMethod).toEqual("POST"); done();