Integrating Secure SPAs, APIs and ArcGIS Server

This post outlines a technique I have used recently for consuming secure ArcGIS Server services (using the built in ArcGIS token store) from a client side SPA with the Esri JS API v4.4 whilst having a separate identity store for the application. There are 4 main application components to consider:

  • the identity provider for our application / API
  • a server side API for data access
  • the client application
  • ArcGIS Server

For background purposes, the reason not to use ArcGIS Server security for the application itself is partly down to tooling / flexibility but also since this method allows you to use multiple ArcGIS Servers.

Identity Provider

For this I used Identity Server. There is a bunch of documentation and samples on their site so I encourage you to look through that if you want detailed information. The basic workflow in the application is that the user accesses the website, they then get a token from our identity provider and store it for use in the client application when it calls our API.

API

For data access / business logic there are REST API endpoints exposed. These are called from our application to perform the relevant operations / data lookups etc. Since we want to restrict access to these endpoints then the API is secured using our identity provider and will authenticate incoming calls using token validation middleware.

Client Application

This could be written using your framework of choice (there are plenty to choose from!) as it doesn’t really impact the design. I chose React and used the Create React App cli tool as a starting point. What is important is that this component is responsible for calling the other application components. These interactions are in the form of

  • API calls
  • ArcGIS Server REST calls
  • Authenticating the user via the identity provider

After obtaining the token from the identity provider this can be used to set the Authorization request header on each call to our API. It is a good idea to have a single place in your code to act as a gateway for these calls so you have less to change / maintain. Using axios for the requests and example would look like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*global REACT_APP_API*/
import axios from 'axios';
import axiosRetry from 'axios-retry';
import store from '../store';
axiosRetry(axios, { retries: 2 });
// a request helper which reads the access_token from the redux state and passes it in its HTTP request
export default function apiRequest(url, method = 'GET', data = {}) {
const token = store.getState().oidc.user.access_token;
const headers = {
'Accept': 'application/json',
'Authorization': `Bearer ${token}`
};
const options = {
url: url.indexOf('://') > -1 ? url : REACT_APP_API + url,
timeout: 5000, // 5 seconds
method,
headers,
data
};
return axios.request(options)
.then(
(response) => ({ data: response.data }),
(error) => ({ error: error }))
.catch(function (error) {
// we get here if there is an unrecoverable error i.e. axiosRetry didn't fix it
if (error.response) {
// do some error stuff
}
});
}

To make life easier I also used esri-loader as this makes integrating the Esri JS API with modern JS tooling much more painless. As an added convenience I use esri-loader-react in conjunction with that.

ArcGIS Server

Now all our calls to the API will have the access token sent so that we can securely access our endpoints. The next problem is that we also need to access services from ArcGIS Server which are secured using ArcGIS Servers built in accounts and tokens. We could do this by making the user log in again using an ArcGIS Server account but that would be a bad user experience, and if you wanted to consume services from multiple ArcGIS Servers then it would compound the problem. So to make the security transparent to the user we can use an application login for accessing our ArcGIS data. Again there are a couple of options for this, we could use the Esri Proxy and set the credentials in the proxy.config but this relies on the HTTP referer header for blocking requests and so could be spoofed. Version 4.4 of the Esri JS API allows you to register a token from ArcGIS Server with the Identity Manager so a better solution is to generate the token first and then register that so that subsequent requests to the ArcGIS Server have the token appended. To do this we need to create some logins using ArcGIS Server (and if we want more granularity then set roles for these login accounts). Now we will use these accounts to generate short lived ArcGIS tokens via our secure API. The API method needs to match the claims for the user who initiated the request to some configuration we set in the API project as this will map the incoming claim to an ArcGIS application login. Then this can be used to request the token from ArcGIS Server. To get the token I used another library I wrote call ArcGIS.PCL and used the TokenProvider CheckGenerateToken function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
namespace API.Modules
{
using API.Configuration;
using ArcGIS.ServiceModel;
using IdentityModel;
using Nancy;
using Nancy.Security;
using System;
using System.Collections.Generic;
using System.Linq;
public class TokenModule : NancyModule
{
static Dictionary<string, TokenProvider> _tokenProviders;
public TokenModule(List<TokenServerSettings> tokenServerSettings)
: base("/gis")
{
if (tokenServerSettings == null)
{
throw new ArgumentNullException(nameof(tokenServerSettings));
}
var tokenProviders = new Dictionary<string, TokenProvider>();
foreach (var tokenServerSetting in tokenServerSettings)
{
tokenProviders.Add(tokenServerSetting.ClaimRole, new TokenProvider(tokenServerSetting.Url, tokenServerSetting.Username, tokenServerSetting.Password));
}
_tokenProviders = tokenProviders;
if (!_tokenProviders.ContainsKey("user"))
{
throw new ArgumentException("No token configuration for the user claim role, did you forget to update the TokenServerSettings in appsettings.json?");
}
this.RequiresAuthentication();
Get("/token", async (parameters, ct) =>
{
var roles = this.Context.CurrentUser.Claims
.Where(claim => string.Equals(JwtClaimTypes.Role, claim.Type, StringComparison.OrdinalIgnoreCase))
.ToList();
TokenProvider tokenProvider = null;
if (roles != null && roles.Any())
{
foreach (var role in roles)
{
if (_tokenProviders.ContainsKey(role.Value))
{
tokenProvider = _tokenProviders[role.Value];
break;
}
}
}
if (tokenProvider == null)
{
tokenProvider = _tokenProviders["user"];
}
var token = await tokenProvider.CheckGenerateToken(ct);
return Negotiate
.WithStatusCode(HttpStatusCode.OK)
.WithModel(token);
});
}
}
}

Once we have the ArcGIS token in our client application we register it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// this calls the method shown in C# above
apiRequest(GET_ARCGIS_TOKEN_URL).then(result => {
// dojoRequire from esri-loader
dojoRequire(
['esri/identity/IdentityManager'],
(esriId) => {
esriId.registerToken({
server: layer.url,
ssl: result.data.alwaysUseSsl,
token: result.data.value,
expires: result.data.expiry
});
});
});

This works with any call in the Esri JS API such as map image requests, query / identify calls and also map printing (previously I would have to set the token explicitly on the layer so that printing would work with secure services). You should track the token expiration too so that you can request a new one when needed.

Summary

So to summarize:

  • use built in ArcGIS security
  • use your own application and API security
  • request ArcGIS tokens via your API
  • store that token with the registerToken function on the IdentityManager in you client application