Subclass MKTileOverlay to load tiles from Authenticated WMTS Server

Platform Difficulty Skills Required
iOS Beginner Swift, UIKit
Description
In this tutorial we will present a way to handle third-party services with token based authorization for map tiles, in particular WMTS, on iOS MapKit. By subclassing MKTileOverlay we will handle requests and token acquisition. We will also cache tiles as a bonus!

Problem explained

At some point in my iOS application i had to customize MapKit, since it does not natively support WMTS (Web Map Tile Service), in order to load map tiles from a third-party provider. To keep things simple, since WMTS tiles is out of this scope of this tutorial, we will assume only three parameters need to be passed in url:

TILEMATRIX, TILEROW, TILECOL

The resulting implementation is similar to below, where path.z, path.y, path.x correspond to above parameters respectively.

class ThirdPartServiceMapOverlay : MKTileOverlay {

   override init(urlTemplate URLTemplate: String?) {

      super.init(urlTemplate: URLTemplate)
      // ......

   }

   override func url(forTilePath path: MKTileOverlayPath) -> URL {

       return URL(string: String(format: "[WMTS_SERVICE_URL]/tiles/%d/%d/%d.png", path.z, path.y, path.x))!

   }

}

Then adding the overlay in your map

let overlay = ThirdPartServiceMapOverlay()
mapView.addOverlay(overlay, level: .aboveLabels)

and finally implement the renderer in your MKMapViewDelegateMap


func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    if let tileOverlay = overlay as? MKTileOverlay {
        return MKTileOverlayRenderer(tileOverlay: tileOverlay)
    }
    return MKOverlayRenderer(overlay: overlay)
}

However, third party tile services use some kind of authentication mechanism, usually with the form of a token (Token Based Authentication).

If your provider just issues "static" tokens which give you access to the service while your subscription is active you only have to append it in url overload.

return URL(string: String(format: "[WMTS_SERVICE_URL]/tiles/%d/%d/%d.png?access_token=%s", path.z, path.y, path.x, token_provided))!

For non static token authentication services the provider issues tokens with a brief lifetime (usually 1h). In order to obtain them you need credentials like username / password (also from the provider).

The above makes sense for a web application for example, where you don't want to keep and send plaintext credentials in your client-side code (browser). Instead you implement the token request mechanism in your backend (which can safely encrypt, store and transmit credentials to provider endpoint) and call YOUR backend, to acquire the token.

This 1h duration token can be easily intercepted (since it is transmitted from your browser), but will only expire soon. In order to obtain the new one, access is needed to the credentials mentioned earlier which only reside in the backend and are invisible to the end user of your web application.

Above is a very common practice for service providers in general, not only tile services. Now, for our TileOverlay, the steps we are going to follow are:

  1. Start loading tiles (through overriding func loadTile) with a stored token if available. If none use a dummy one.
  2. If response to tile request is 401 (Unauthorized) - Request new token from back end and replace current one.

The problem is that the TileRenderer, obviously, requests many tiles simultaneously (asynchronous requests - different threads). We don't want to acquire a new token for each failed request. When token expires all requests will fail, thus we need to somehow synchronize and allow only one of them threads to handle the token renewal.

For this reason we will use the standard synchronization mechanism for these cases: a semaphore with a value of 1 (mutex). Each tile request thread that fails with unauthorized response will raise/wait the semaphore, and signal/release when done. Also a flag will be used (tokenUpdated) that indicates if the token is indeed refreshed, in order to notify other threads to exit.

override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) { 
    let url1 = self.url(forTilePath: path)
    if let cachedData = cache.object(forKey: url1 as AnyObject) as? Data {
        //print("Found in cache: [\(url1.absoluteString)]")
        result(cachedData, nil)
    } else {
        //print("Requesting: [\(url1.absoluteString)]")
        AF.request("\(url1.absoluteString)?access_token=\(access_token)").responseData(queue: DispatchQueue(label: "tile-request-queue")) {
        response in
            switch response.result {
                case .success(let data):
                    if(response.response?.statusCode == 200) {
                        //print("Adding to cache: [\(url1.absoluteString)]")
                        self.cache.setObject(data as AnyObject, forKey: url1 as AnyObject)
                        result(data, nil)
                    } else if (response.response?.statusCode == 401 || response.response?.statusCode == 403) {
                        self.semaphore.wait()
                        if(!self.tokenUpdated) {
                            print("Thread(\(Thread.current)): Token Expired!")
                                AF.request("\([YOUR_BACKEND_TOKEN_REQUEST_METHOD])", method: .get, parameters: nil, encoding: JSONEncoding.default)
                                .responseJSON(queue: DispatchQueue(label: "token-request-queue")) { response in
                                    switch response.result {
                                        case .success(let JSON):
                                            if(response.response?.statusCode == 200) {
                                                let response = JSON as! NSDictionary
                                                let token = response.object(forKey: "token")!
                                                print("\(Thread.current): Got \(token)")
                                                self.access_token = token as! String
                                                self.tokenUpdated = true
                                                // store in keychain
                                                do {
                                                    ... keychain.set(self.access_token, key: "token")
                                                } catch let error {
                                                    // log error
                                                }
                                                self.semaphore.signal()
                                                DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
                                                    self.tokenUpdated = false
                                                }
                                                self.loadTile(at: path, result: result)
                                        	} else if (response.response?.statusCode == 401 || response.response?.statusCode == 403) {
                                        	    let err: [String: Any] = [
                                        	        "Backend API": "Token Expired!"
                                        	    ]
                                        	    let errorTemp = NSError(domain: "", code: -1, userInfo: err)
                                        	    result(nil, errorTemp)
                                        	    self.semaphore.signal() // propably necessary for all stuck tile threads
                                        	}
                                        case .failure(let error):
                                            print(error)
                                            result(nil, error)
                                            self.semaphore.signal() // propably necessary for all stuck tile threads
                                   }
                                }
                        } else {
                            self.semaphore.signal()
                            self.loadTile(at: path, result: result)
                        }
                    } else {
                        print(response.response?.statusCode)
                    }
                case .failure(let error):
                    print(error)
                    result(nil, error)
            }
        }
    }
}


Top Buy me a coffee! ko-fi service logo for donation