ArcGIS Portal Authorization – Using Server-Side Authorization in ArcGIS Portal

arcgis-javascript-apiarcgis-portalauthenticationc

I have an ASP.NET MVC web application that is authenticating users server-side against ArcGIS Portal successfully using Owin.Security.Providers.ArcGISPortal.

I would like to use the granted authorization client-side in the browser without requiring the user to login a second time via ArcGIS's JavaScript API. Does anyone have any pointers regarding how to obtain a relevant token and pass this through client-side to access portal secured web maps, etc.?

There is a context.AccessToken that I obtain via ArcGISPortalAuthenticationHandler and make available as a claim in order to share the access token.

Any ideas? If you know of a working example for ArcGIS Online then this could also help me as ArcGIS Online and ArcGIS Portal seem to be identical in their mechanics.

Further investigation…

Following the linked example found through the documentation for esri.IdentityManager.registerToken(), I've tried the following method to share the access token:

var credentialsJSON = {
    serverInfos: [{
        server: "https://[HOST]",
        tokenServiceUrl: "https://[HOST]/arcgis/tokens/",
        adminTokenServiceUrl: "https://[HOST]/arcgis/admin/generateToken",
        shortLivedTokenValidity: 1800,
        currentVersion: 10.5,
        hasServer: true
    }],
    oAuthInfos: [],
    credentials: [{
        userId: user.userId,
        server: "https://[HOST]/arcgis",
        token: user.userAccessToken,
        expires: user.userAccessTokenExpiry,
        validity: 1800,
        isAdmin: false,
        ssl: false,
        creationTime: user.userAccessTokenIssued,
        scope: "server"
    }]
};
esriId.initialize(credentialsJSON);

But I'm still not getting access to the webmap. I see a html login modal over the empty map div with the title "Please sign in to access the item on https://[HOST]/arcgis (b11824af61df463586dad40d1df7abbd)".

In the console log I see the following message logged:

dojo.io.script error Error: You do not have permissions to access this resource or perform this operation.
    at Object.g.load (init.js:984)
    at init.js:87
    at c (init.js:103)
    at d (init.js:103)
    at a.Deferred.resolve.callback (init.js:105)
    at c (init.js:104)
    at d (init.js:103)
    at a.Deferred.resolve.callback (init.js:105)
    at init.js:999
    at n (init.js:107)

and for the network request to

https://[HOST]/arcgis/sharing/rest/content/items/b11824af61df463586dad40d1df7abbd?f=json&callback=dojo.io.script.jsonp_dojoIoScript1._jsonpCallback

I see the following response:

dojo.io.script.jsonp_dojoIoScript1._jsonpCallback({"error":{"code":403,"messageCode":"GWM_0003","message":"You do not have permissions to access this resource or perform this operation.","details":[]}});

Best Answer

A full solution detailing all the moving parts both server-side and client side...

Server Side OAuth

Download and include the Owin.Security.Providers.ArcGISPortal project in your solution.

Add supporting configuration settings to the web.config file:

<appSettings>
  <add key="OAuth-ArcGISPortal:Name" value="Map Portal" />
  <add key="OAuth-ArcGISPortal:Host" value="https://yourmapportal.org/" />
  <add key="OAuth-ArcGISPortal:ClientId" value="YOUR-APP-CLIENTID" />
  <add key="OAuth-ArcGISPortal:ClientSecret" value="YOUR-APP-CLIENTSECRET" />
</appSettings>

Hook up the OAuth provider in OWIN Startup:

using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Owin;
using Owin.Security.Providers.ArcGISPortal;
using System.Configuration;

[assembly: OwinStartupAttribute(typeof(MyApp.Startup))]
namespace MyApp
{
    public partial class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            app.SetDefaultSignInAsAuthenticationType(DefaultAuthenticationTypes.ExternalCookie);

            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = DefaultAuthenticationTypes.ExternalCookie,
                LoginPath = new PathString("/Account/Login")
            });

            app.UseArcGISPortalAuthentication(new ArcGISPortalAuthenticationOptions(
                ConfigurationManager.AppSettings["OAuth-ArcGISPortal:Name"],
                ConfigurationManager.AppSettings["OAuth-ArcGISPortal:Host"],
                ConfigurationManager.AppSettings["OAuth-ArcGISPortal:ClientId"],
                ConfigurationManager.AppSettings["OAuth-ArcGISPortal:ClientSecret"]));
        }
    }
}

Add supporting Account Controller methods:

using System.Web;
using System.Web.Mvc;
using Microsoft.Owin.Security;
using System.Threading.Tasks;

namespace MyApp.Controllers
{
    public class AccountController : Controller
    {

        //
        // GET: /Account/
        [AllowAnonymous]
        public ActionResult Login(string returnUrl)
        {
            if (Request.IsAuthenticated) return RedirectToLocal(returnUrl); // No need to login if already authenticated

            ViewBag.ReturnUrl = returnUrl;
            return View();
        }

        //
        // POST: /Account/ExternalLogin
        [HttpPost]
        [AllowAnonymous]
        [ValidateAntiForgeryToken]
        public ActionResult ExternalLogin(string provider, string returnUrl, string shopName = "")
        {
            if (Request.IsAuthenticated) return RedirectToLocal(returnUrl); // No need to login if already authenticated

            // Request a redirect to the external login provider
            return new ChallengeResult(provider, Url.Action("ExternalLoginCallback", "Account", new { ReturnUrl = returnUrl }));
        }

        //
        // GET: /Account/ExternalLoginCallback
        [AllowAnonymous]
        public async Task<ActionResult> ExternalLoginCallback(string returnUrl)
        {
            var loginInfo = await AuthenticationManager.GetExternalLoginInfoAsync();
            if (loginInfo == null)
            {
                if (!string.IsNullOrEmpty(returnUrl))
                    return RedirectToLocal(returnUrl);
                else
                    return RedirectToAction("Login");
            }

            AuthenticationManager.SignIn(loginInfo.ExternalIdentity);

            return RedirectToLocal(returnUrl);
        }

        //
        // POST: /Account/LogOff
        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult LogOff()
        {
            AuthenticationManager.SignOut();
            return RedirectToAction("Index", "Home");
        }

        private ActionResult RedirectToLocal(string returnUrl)
        {
            if (Url.IsLocalUrl(returnUrl))
            {
                return Redirect(returnUrl);
            }
            else
            {
                return RedirectToAction("Index", "Home");
            }
        }

        private IAuthenticationManager AuthenticationManager
        {
            get
            {
                return HttpContext.GetOwinContext().Authentication;
            }
        }

        private class ChallengeResult : HttpUnauthorizedResult
        {
            public ChallengeResult(string provider, string redirectUri)
            {
                LoginProvider = provider;
                RedirectUri = redirectUri;
            }

            public string LoginProvider { get; set; }
            public string RedirectUri { get; set; }

            public override void ExecuteResult(ControllerContext context)
            {
                var properties = new AuthenticationProperties() { RedirectUri = RedirectUri };
                context.HttpContext.GetOwinContext().Authentication.Challenge(properties, LoginProvider);
            }
        }

    }
}

Add helper methods to expose OAuth tokens and provide a method to refresh the access token:

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Security.Principal;
using System.Text;
using System.Web;

namespace MyApp
{
    public static class OAuthHelpers
    {
        /// <summary>
        /// Based on access_token response e.g.
        /// {"access_token":"MuVp3AEmD4ApDHy70IBjXPOlWQFGpUKeh9A6j2TpkaO0097A8KIHqjYoPhlgCdMcFjf4gdREvaLxobZtL4X8DoEa4bDZ50mCPkNRTpMqwv-dak-jq5bj92FGZJy4j_B63wWUevodHAcT8szLLtfyGC_y71lwzNLQDYB4_MaZN_0fnA-0l7o7HJA_5ApeuToMJYMOnCGOKtz3tX4uHoQBOQ..","expires_in":1800,"username":"User@ORG","ssl":true}
        /// </summary>
        public class AccessToken
        {
            public AccessTokenError Error;
            public string Token;
            public int ExpiresIn;
        }

        /// <summary>
        /// Based on error response e.g.
        /// {"error":{"code":498,"error":"invalid_request","error_description":"Invalid refresh_token","message":"Invalid refresh_token","details":[]}}
        /// </summary>
        public class AccessTokenError
        {
            public int Code;
            public string Error;
            public string Description;
            public string Message;
        }

        public static string GetAccessToken(IIdentity identity)
        {
            return GetIdentityClaim(identity, "urn:ArcGISPortal:access_token");
        }

        public static void SetAccessToken(IIdentity identity, string value)
        {
            SetIdentityClaim(identity, "urn:ArcGISPortal:access_token", value);
        }

        public static int GetAccessTokenExpiresIn(IIdentity identity)
        {
            return Convert.ToInt32(GetIdentityClaim(identity, "urn:ArcGISPortal:expires_in"));
        }

        public static void SetAccessTokenExpiresIn(IIdentity identity, string value)
        {
            SetIdentityClaim(identity, "urn:ArcGISPortal:expires_in", value);
        }

        public static string GetRefreshToken(IIdentity identity)
        {
            return GetIdentityClaim(identity, "urn:ArcGISPortal:refresh_token");
        }

        /// <returns>
        /// New access_token if no error, else null if an error is encountered.
        /// </returns>
        /// <remarks>
        /// Code based on https://stackoverflow.com/a/24972426/250254
        /// </remarks>
        public static AccessToken RefreshAccessToken(IIdentity identity)
        {
            string url = Properties.Settings.Default.ArcGisPortalUrl + "/sharing/oauth2/token";

            var parameters = new List<KeyValuePair<string, string>>
            {
                new KeyValuePair<string, string>("client_id", ConfigurationManager.AppSettings["OAuth-ArcGISPortal:ClientId"]),
                //new KeyValuePair<string, string>("client_secret", ConfigurationManager.AppSettings["OAuth-ArcGISPortal:ClientSecret"]), // Not required - https://developers.arcgis.com/documentation/core-concepts/security-and-authentication/server-based-user-logins/#request-parameters-2
                new KeyValuePair<string, string>("refresh_token", GetRefreshToken(identity)),
                new KeyValuePair<string, string>("grant_type", "refresh_token")
            };

            var content = GetContentAsync(url, "POST", parameters);

            // Deserializes the token response
            // Example of good response:
            // {"access_token":"MuVp3AEmD4ApDHy70IBjXPOlWQFGpUKeh9A6j2TpkaO0097A8KIHqjYoPhlgCdMcFjf4gdREvaLxobZtL4X8DoEa4bDZ50mCPkNRTpMqwv-dak-jq5bj92FGZJy4j_B63wWUevodHAcT8szLLtfyGC_y71lwzNLQDYB4_MaZN_0fnA-0l7o7HJA_5ApeuToMJYMOnCGOKtz3tX4uHoQBOQ..","expires_in":1800,"username":"GavinH@CH","ssl":true}
            // Example of error response:
            // {"error":{"code":498,"error":"invalid_request","error_description":"Invalid refresh_token","message":"Invalid refresh_token","details":[]}}
            dynamic response = JsonConvert.DeserializeObject<dynamic>(content);

            var token = new AccessToken();
            if (response.error != null)
            {
                token.Error = new AccessTokenError
                {
                    Code = response.error.code,
                    Error = response.error.error,
                    Description = response.error.error_description,
                    Message = response.error.message
                };
            }
            else
            {
                token.Token = response.access_token;
                token.ExpiresIn = response.expires_in;
            }

            // Update claims with new token
            SetAccessToken(identity, (string)response.access_token);
            SetAccessTokenExpiresIn(identity, (string)response.expires_in);

            return token;
        }

        private static string GetIdentityClaim(IIdentity identity, string claim)
        {
            return (identity as ClaimsIdentity).Claims.First(c => c.Type == claim).Value;
        }

        // Based on https://stackoverflow.com/a/32112002/250254
        private static void SetIdentityClaim(IIdentity identity, string claim, string value)
        {
            var claimsIdentity = (identity as ClaimsIdentity);

            // Check for existing claim and remove it
            var existingClaim = claimsIdentity.FindFirst(claim);
            if (existingClaim != null)
                (identity as ClaimsIdentity).RemoveClaim(existingClaim);

            // Add new claim
            claimsIdentity.AddClaim(new Claim(claim, value));
            var authenticationManager = HttpContext.Current.GetOwinContext().Authentication;
            authenticationManager.AuthenticationResponseGrant = new AuthenticationResponseGrant(new ClaimsPrincipal(identity), new AuthenticationProperties() { IsPersistent = true });
        }

        private static string GetContentAsync(string url, string method = "POST", IEnumerable<KeyValuePair<string, string>> parameters = null)
        {
            return method == "POST" ? PostAsync(url, parameters) : GetAsync(url, parameters);
        }

        private static string PostAsync(string url, IEnumerable<KeyValuePair<string, string>> parameters = null)
        {
            var uri = new Uri(url);

            var request = WebRequest.Create(uri) as HttpWebRequest;
            request.Method = "POST";
            request.KeepAlive = true;
            request.ContentType = "application/x-www-form-urlencoded";

            var postParameters = GetPostParameters(parameters);

            var bs = Encoding.UTF8.GetBytes(postParameters);
            using (var reqStream = request.GetRequestStream())
            {
                reqStream.Write(bs, 0, bs.Length);
            }

            using (var response = request.GetResponse())
            {
                var sr = new StreamReader(response.GetResponseStream());
                var jsonResponse = sr.ReadToEnd();
                sr.Close();

                return jsonResponse;
            }
        }

        private static string GetPostParameters(IEnumerable<KeyValuePair<string, string>> parameters = null)
        {
            var postParameters = string.Empty;
            foreach (var parameter in parameters)
            {
                postParameters += string.Format("&{0}={1}", parameter.Key,
                    HttpUtility.HtmlEncode(parameter.Value));
            }
            postParameters = postParameters.Substring(1);

            return postParameters;
        }

        private static string GetAsync(string url, IEnumerable<KeyValuePair<string, string>> parameters = null)
        {
            url += "?" + GetQueryStringParameters(parameters);

            var forIdsWebRequest = WebRequest.Create(url);
            using (var response = (HttpWebResponse)forIdsWebRequest.GetResponse())
            {
                using (var data = response.GetResponseStream())
                using (var reader = new StreamReader(data))
                {
                    var jsonResponse = reader.ReadToEnd();

                    return jsonResponse;
                }
            }
        }

        private static string GetQueryStringParameters(IEnumerable<KeyValuePair<string, string>> parameters = null)
        {
            var queryStringParameters = string.Empty;
            foreach (var parameter in parameters)
            {
                queryStringParameters += string.Format("&{0}={1}", parameter.Key,
                    HttpUtility.HtmlEncode(parameter.Value));
            }
            queryStringParameters = queryStringParameters.Substring(1);

            return queryStringParameters;
        }
    }
}

Add an API Controller to provide a method the client-side application can call to obtain a refreshed OAuth access token:

using System.Web.Http;
using static MyApp.OAuthHelpers;

namespace MyApp.Controllers.Api
{
    [Authorize]
    public class AccessTokenController : ApiController
    {
        // GET api/accesstoken
        public AccessToken Get()
        {
            return OAuthHelpers.RefreshAccessToken(User.Identity);
        }
    }
}

Client Side OAuth

The ArcGIS API for JavaScript has very good mechanisms supporting it's OAuth 2.0 provider for map based single-page-applications, but if there's a server-side component that needs to share the authentication then some extra effort is required.

Provide a log-off mechanism for use by both the user and that can be called through JavaScript:

<ul class="nav navbar-nav navbar-right">
    @if (Request.IsAuthenticated)
    {
        <li><a href="javascript:document.getElementById('logoutForm').submit()">Log off <strong>@User.Identity.Name.Split('@')[0]</strong></a></li>
        using (Html.BeginForm("LogOff", "Account", FormMethod.Post, new { id = "logoutForm" }))
        {
            @Html.AntiForgeryToken()
        }
    }
</ul>

Make server-side information available to the client-side application:

<script>
    var myapp = myapp || {};

    myapp.user = '@User.Identity.Name.Split('@')[0]';
    myapp.userId = '@User.Identity.Name';
    myapp.initialAccessToken = '@MyApp.OAuthHelpers.GetAccessToken(User.Identity)';
    myapp.initialAccessTokenExpiresIn = @(MyApp.OAuthHelpers.GetAccessTokenExpiresIn(User.Identity));

    myapp.config = myapp.config || {};
    myapp.config.reloginUrl = '@Href("~/Account/Login?ReturnUrl=" + HttpUtility.UrlEncode(Request.RawUrl))';
</script>

Use the access token with ArcGIS API for JavaScript code. The following code example includes the following:

  • Routing all ArcGIS Portal API calls through the proxy.ashx handler.
  • Providing a mechanism for refreshing the access token.
  • Automatically logging off the user if the current access token is invalid (which will redirect them to the login form).
  • Redirecting the user to the login form if there's an error refreshing the access token (possibly because their login has expired).

    require(['dojo/ready', 'dojo/promise/all', 'esri/map', 'esri/IdentityManager', 'esri/request', 'esri/config', 'esri/arcgis/utils', 'esri/tasks/GeometryService', 'dojo/domReady!'], function (ready, all, Map, esriId, esriRequest, Config, Utils, GeometryService) { ready(function () {

            registerAccessToken(myapp.initialAccessToken, myapp.initialAccessTokenExpiresIn, false);
    
            Utils.arcgisUrl = myapp.config.arcGisPortalUrl + '/sharing/rest/content/items';
            Config.portalUrl = myapp.config.arcGisPortalUrl;
    
            Config.defaults.io.proxyUrl = myapp.config.proxyUrl;
            Config.defaults.io.alwaysUseProxy = false;
    
            Config.defaults.geometryService = new GeometryService(myapp.config.gisGeometryServiceUrl);
    
            Utils.createMap(myapp.config.arcgisWebMapId, 'map', {
                mapOptions: {
                    slider: true,
                    nav: false
                },
                geometryServiceURL: myapp.config.gisGeometryServiceUrl,
                ignorePopups: true
            }).then(function (response) {
                myapp.mapviewer.map = response.map;
    
                // ...
    
            });
        });
    
    function refreshAccessToken(retryOnCheckSignInStatusFailure) {
        var request = esriRequest({
            url: '/api/AccessToken',
            content: { f: 'json' },
            handleAs: 'json'
        });
        request.then(
            function (response) {
                if (response.Error != null) {
                    console.error(response.Error);
                    redirectToLogin();
                }
                else {
                    registerAccessToken(response.Token, response.ExpiresIn, retryOnCheckSignInStatusFailure);
                }
            }, function (error) {
                console.error(error);
                redirectToLogin();
            }
        );
    }
    
    function registerAccessToken(token, expiresIn, retryOnCheckSignInStatusFailure) {
        // Calculate expires
        var d = new Date();
        d.setSeconds(d.getSeconds() + expiresIn); 
    
        var server = ecan.config.arcGisPortalUrl + '/sharing/rest';
    
        var token = {
            'server': server,
            'userId': ecan.userId,
            'token': token,
            'ssl': true,
            'expires': d.getTime()
        };
        esriId.registerToken(token);
    
        // Bit hacky, but checkSignInStatus() seems to fail the first time token is refreshed and registerToken() called,
        // then passes the second time so we retry the refreshAccessToken() method again before forcing user to re-login.
        // Ideally it would be good to get a grip on what's really going on here as it's consistent behaviour!
        // Potentially registerToken() hasn't done its job by the first time we call checkSignInStatus()?
        esriId.checkSignInStatus(server).then(
            function (response) {
                console.log('esriId.checkSignInStatus == good');
    
                // Schedule token refresh
                setTimeout(function () {
                    refreshAccessToken(true);
                }, ((expiresIn - 15) * 1000)); // Refresh 15 seconds early
    
            }
        ).otherwise(
            function (error) {
                console.error(error);
    
                if (retryOnCheckSignInStatusFailure) {
                    console.log('Retry access token refresh');
                    refreshAccessToken(false);
                }
                else {
                    // Logout user to force them to login and refresh token
                    document.getElementById('logoutForm').submit();
                }
            }
        );
    };
    
    function redirectToLogin() {
        window.location.href = ecan.config.reloginUrl;
    };
    

    });

It's worth noting that the server-side obtained access token is likely to have a longer lifespan than the client-side authentication context, obtained by registering the access token through the ArcGIS API for JavaScript esriId.registerToken(token) method. For this reason we need extra logic to check for authentication failures and force the user to re-login when problems are detected.

It's possible that a web page may show content to a user whos authentication has timed-out. The user may not be aware of the timeout, continuing to try to use single-page-application type functionality that relies on calls to an authentication-protected API. The resulting experience for the user being that the application is broken as it's no longer refreshing data. In such cases it's desirable to intercept all API call responses, checking for a 401 unauthenticated response and redirecting the user to the login form.

Angular JS example of intercepting all API call responses to check for 401 unauthenticated responses to action:

var myapp = myapp || {};
myapp.ui = angular.module('myapp', ['ui.bootstrap'])
    .config(function ($httpProvider) {
        $httpProvider.interceptors.push(['$q', '$window', function ($q, $window) {
            return {
                responseError: function (response) {
                    if (response.status == 401) {
                        $window.location.href = myapp.config.reloginUrl;
                    }
                    return $q.reject(response);
                }
            };
        }]);
    });