Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement UserCredentials and ApplicationCredentials #10

Closed
patrickarlt opened this issue Aug 5, 2017 · 4 comments
Closed

Implement UserCredentials and ApplicationCredentials #10

patrickarlt opened this issue Aug 5, 2017 · 4 comments
Assignees

Comments

@patrickarlt
Copy link
Contributor

Based on discussion in https://github.com/ArcGIS/arcgis-rest-js/issues/5 we are going to implement authentication as follows:

import { request, UserSession, ErrorTypes } from 'arcgis-rest-core';

const session = new UserSession(/* ... */);

session.on('error', (error) => {
  // I'm still not sure if we should actually allow retries here or not, see comments...
});

// we would have to teach request how to use `AppAuthentication` to recover from
// auth failures. Hopefully via an interface so it doesn't bloat the core repo.
request(url, params, {
  authentication: session
}).then(/* .. */).catch((error) => {
  switch (error.name) {
    case ErrorTypes.ArcGISRequestError:
      // general error from the server
      break;
    case ErrorTypes.ArcGISAuthError:
      // authentication error from the server
      break;
    default:
      // some other kind of error, probably from the network or fetch
      break;
  }
});

// we would have to pass the `authentication` object down through all calls.
geocode(params, {
  url: '...', // override default geocode URL?
  authentication: session
}).then(/* ... */).catch(/* ... */);

Since the request method and the UserSession/AuthSession objects will be closely coupled now I think this should all go into arcgis-rest-core.

At a glance this means adding:

  • A new error type ArcGISAuthError.
  • A new enum ErrorTypes which will expose ArcGISAuthError and ArcGISRequestError
  • checkForErrors will have to throw ArcGISAuthError or ArcGISRequestError depending on what went wrong.

Now for some of the more challenging things:

Retrying authentication requests

The more I think about it the more I feel that we shouldn't try to implement automatic request retrying for a few reasons:

  1. Implementation of retrying is hard and highly app specific.
  2. Retrying is often async since you have to prompt for user input or wait for an async call. This is hard when resolving a promise chain.
  3. Retrying (as should above) means that Session have to be event emitters which means more dependencies.

I wouldn't be opposed to "simple retrying" like so:

const session = new UserSession(/* ... */);

function retryRequest ([url, params, options]) {
  return new Promise((resolve, reject) => {
    // prompt for auth would prompt the user for sign in then return a new UserSession
    promptForAuth(url).then((newSession) => {
      resolve(session);
    })
  })
}

request(url, params, {
  authentication: session
})

// handle an auth error
.catch((error) => {
  if (error.name === ErrorTypes.ArcGISAuthError) {
    return error.retry(retryRequest(error.originalRequestParams));
  }

  throw error; // if this isn't an auth error keep dying...
})
.catch(error => {
  // this could still be an auth error from your retry or a different error...
})

// handle a successful request from `error.retry` or elsewhere.
.then(response => {
  console.log(response);
});

This makes retrying requests when auth fails relatively simple and it makes sense in terms of the promise-based implementation we have today. If we want to implement retries this is the way I would handle it.

"Federation"

In the JS API the identity manager does "Federation" between servers and portals. It works like this:

  1. esri.request is called.
  2. IdentityManager looks up to see if the server being called is on the list of CORS enabled servers it knows about.
    a. If the server is on the list of CORS enabled servers the request is sent. IdentityManager saves this in its internal cache of "server infos".
    b. If the request is not on the list of CORS enabled servers a request is first sent to a request is sent to ${SERVER}/arcgis/rest/info?f=json if the request responds we know the server supports CORS then the request is sent. IdentityManager saves this in its internal cache of "server infos".
  3. If the request sent in step 2 fails with an auth error the JS API try the following steps in order:
    1. Ask IdentityManager for the token for the server the service sits on.
    2. If there is no token lookup if we know the server info this the server this service sits on:
      a. If we know the server info (see 2-b or 3-2-b) then proceed to step 4
      b. If we do not know the servers server info look it up (step 2-b). IdentityManager saves this in its internal cache of "server infos" and we can proceed to step 4
  4. we now know the server info of the service we are trying to make a request to. We need to see if we have a token for the servers owningSystemUrl. if the system has no owningSystemUrl then the user authenticates directly with the server but I'm not entirely sure how that works.
    a. we have a token for the owningSystemUrl cached in IdentityManager so make a request to ${owningSystemUrl}/sharing/generateToken and ask it to generate a token for the server our service is sitting on. This token is cached in IdentityManager. Goto step 5
    b. We do not have a token for the owningSystemUrl, popup the identity manager and ask the user to authenticate into the owningSystemUrl. Once the user signs their token is cached in IdentityManager. repeat step 4-a.
  5. Go to step 1 but with the token in the params.

In practice here is how this works for the following:

require(["esri/request"], function(request, id) {
  request('https://traffic.arcgis.com/arcgis/rest/services/World/Traffic/MapServer?f=json');
});
  1. since traffic.arcgis.com is not in the list of CORS enabled servers we need to make a request to https://traffic.arcgis.com/arcgis/rest/info?f=json.
  2. Now that we know the service supports CORS (because that request succeeded) we make a request to https://traffic.arcgis.com/arcgis/rest/services/World/Traffic/MapServer?f=json which fails with "Token Required".
  3. Another request to https://traffic.arcgis.com/arcgis/rest/info?f=json is made. We know the owning system URL is https://www.arcgis.com.
  4. Internally we have no token for https://traffic.arcgis.com.
  5. Internally we have no token for https://www.arcgis.com.
  6. Popup the auth dialog.

Here is how the same request works with a token registered.

require([
  "esri/request",
  "esri/identity/IdentityManager"
], function(request, id) {
  id.registerToken({
    token: "TOKEN",
    server: "https://www.arcgis.com/sharing/rest"
  });
});

Replace token with a token from a user or app.

  1. since traffic.arcgis.com is not in the list of CORS enabled servers we need to make a request to https://traffic.arcgis.com/arcgis/rest/info?f=json.
  2. Now that we know the service supports CORS (because that request succeeded) we make a request to https://traffic.arcgis.com/arcgis/rest/services/World/Traffic/MapServer?f=json which fails with "Token Required".
  3. Another request to https://traffic.arcgis.com/arcgis/rest/info?f=json is made. We know the owning system URL is https://www.arcgis.com.
  4. Internally we have no token for https://traffic.arcgis.com.
  5. Internally we have no token for https://www.arcgis.com.
  6. We DO have a token for https://www.arcgis.com so we can call https://www.arcgis.com/sharing/generateToken and ask it for a token for the https://traffic.arcgis.com/arcgis/rest/services/World/Traffic/MapServer
  7. Retry the request with out shiny new token!
  8. Request successful.

In short this is really complicated but does accomplish a few goals.

  • Security - the users portal token is never sent to a server. This prevents malicious attacks like standing up a fake service and stealing portal tokens.
  • Handling CORS support - by testing servers for CORS support we can also support older browsers and servers where people have CORS turned off by recommending a proxy to them.

This entire infrastructure is setup most to support the security use case. Doing the full generate token dance, especially with known services like traffic.arcgis.com seems silly. If someone has man-in-the-middled you and broken HTTPS (somehow) they can see your portal key anyway when it gets sent to /sharing/generateToken. If you send your portal token to a malicious service you 1 open up something with a malicious resource in it, 2 make a request to that resource, 3 sign in. It would probably be easier to just spoof the JS API experience and steal portal tokens in a simple phishing attack.

I would propose the following:

  1. Each session maintains a list of trustedServers which defaults to any server under the arcgis.com. Developers can add additional servers.
  2. If the request is to a trustedServer in that session attach the token and make the request. This works because tokens from the owningSytemUrl are also valid on any federated servers.
  3. If the request is not to a trustedServer do the following:
    • Get the server URL of the request
    • Lookup the server info or call server/info to get the owningSystemUrl.
    • Lookup to see if we have a token for the owningSystemUrl
    • Call owningSystemUrl/generateToken to get a token for that server. Save this token and server info so we can look it up again later.
    • make the request with our new token.

This minimizes extra requests as much as possible. Standard AGOL users should see no additional requests. Users on Enterprise should see an extra server/info and owningSystemUrl/generateToken once per ArcGIS Server they call.


Final Design

import { request, UserSession, ErrorTypes } from 'arcgis-rest-core';

const session = new UserSession(/* ... */);

function getNewSession ({url, params, options}) {
  return new Promise((resolve, reject) => {
    // prompt for auth would prompt the user for sign in then return a new UserSession
    promptForAuth(url).then((newSession) => {
      resolve(session);
    })
  })
}

// we would have to teach request how to use `AppAuthentication` to recover from
// auth failures. Hopefully via an interface so it doesn't bloat the core repo.
request(url, params, {
  authentication: session
})

// handle errors first so we can retry...
.catch((error) => {
  switch (error.name) {
    case ErrorTypes.ArcGISRequestError:
      // general error from the server
      break;
    case ErrorTypes.ArcGISAuthError:
      return error.retryRequest(getNewSession(error.requestOptions))
      break;
    default:
      // some other kind of error, probably from the network or fetch
      break;
  }
})

// handle success either from the original request or retryRequest
.then(/* .. */);
@dbouwman
Copy link
Member

dbouwman commented Aug 6, 2017

@patrickarlt Heroic work decoding what the IdentityManager is doing! I totally agree that requesting separate tokens from "well-known-servers" is silly and wasteful. As you note, there are myriad other vectors, likely easier, to compromise a user's token.

One question - since I'd assume the implementation of promptForAuth(...) is application specific (i.e. React vs Angular vs node cli etc), how do you envision that working? Should getNewSession(...) take the token returned from oAuth and just hit /portal/self to pick up additional info to store in the session, rejecting if the token is invalid?

Also - I created #11 regarding building a set of demo apps to validate that this library will work for our various use-cases.

@patrickarlt
Copy link
Contributor Author

patrickarlt commented Aug 6, 2017

@dbouwman Rereading my comment it was unclear that BOTHpromptForAuth AND getNewSession would be application specific. error.retryRequest would simply accept a promise that resolves to a different session to try the request with. It would be up to the user to validate that this new session is good.

@dbouwman
Copy link
Member

dbouwman commented Aug 7, 2017

@patrickarlt Perfecto!

@patrickarlt
Copy link
Contributor Author

patrickarlt commented Aug 9, 2017

Basic implementation of UserSession and ApplicationSession are done in #16.

patrickarlt pushed a commit that referenced this issue Jan 25, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants