Apple Push Notification Service implementation - pitch

push
apns

(Kyle Browning) #1

Pitch

A lightweight, non intrusive, low dependency library to communicate with APNS over HTTP/2 built with Swift NIO.

Motivations

APNS is used to push billions of pushes a day, (7 billion per day in 2012). Many of us using Swift on the backend are using it to power our iOS applications. Having a community supported APNS implementation would go a long way to making it the fastest, free-ist, and simplest solution that exists.

Also too many non standard approaches currently exist. Some use code that depends on Security (Doesn't work on linux) and I haven't found one that uses NIO with no other dependencies. Some just execute curl commands.

Existing solutions

Almost all require a ton of dependencies.

Proposed Solution

Develop a Swift NIO HTTP2 solution with minimal dependencies.

A proof of concept implementation exists here

What it does do

  • Provides an API for handling connection to Apples HTTP2 APNS server
  • Provides proper error messages that APNS might respond with.
  • Uses custom/non dependency implementations of JSON Web Token specific to APNS (using rfc7519
  • Imports OpenSSL for SHA256 and ES256
  • Provides an interface for signing your Push Notifications
  • Signs your token request
  • Sends push notifications to a specific device.
  • Adheres to guidelines Apple Provides.

What it doesn't do YET

  • Use an OpenSSL implementation that is not CNIOOpenSSL

What it won't do.

  • Store/register device tokens
  • Build an HTTP2 generic client
  • Google Cloud Message
  • Refresh your token no more than once every 20 minutes and no less than once every 60 minutes. (up to connection handler)
  • Provide multiple device tokens to send same push to.

What it could do

Please let me know what y'all think. I’ll edit the topic with feedback as it comes in!


February 7th, 2019
SwiftNIO 2
[Discussion] NIOApns: NIO-based Apple Push Notification Service
(Tanner) #2

This package would be a great, shared foundation for building APNS support into higher level frameworks. Its only dependencies are NIO, NIOHTTP2, and NIOOpenSSL which means any existing NIO-based projects should be able to integrate this easily without needing to drag in large dependencies.

One thing we should be thinking of is that NIO will likely be dropping OpenSSL soon. This will make NIOAPNS' dependency on OpenSSL less ideal. In that case, we have a few options:

  • Find a pure Swift solution
  • Embed a more lightweight C library that does ECDSA (like NIOPostgres does for MD5)
  • Continue linking OpenSSL and wait for broader solution to Crypto

Option three seems fine as frameworks like Vapor will likely continue to link OpenSSL until a broader Crypto solution is agreed upon. But this is something we should consider.

Overall, huge +1, really nice work on this!


(Simon Pilkington) #3

I'm not very familiar with how the APNS API works. Would it be possible to add a usage section to the README or a short code example to show how the library could be integrated into higher level frameworks/applications?


(Kyle Browning) #4

Sure @tachyonics. An example now exists in main.swift.

Thats the simplest implementation. The reason I didn't include larger usage directive is because this library API might change based on community input.

The short explanation is you would import NIOAPNS, create an alert, open a connection, and send the request with your push notification.

In an example like Vapor, this would most likely be a service.

From there, your Vapor app can infer when to open connections to APNS, and which devices to send to the push notifications too.

To elaborate a bit further(maybe this isn't needed but Ill continue) I see it happening like this.

  • iOS app registers device for remote notifications
  • iOS app gets a device token from Apple
  • iOS app sends POST request with said token to a Vapor API
  • Vapor stores device token.
  • Later at some slotted time/event, for example when a new post is created, you open a connection to APNS, and send a request for each registered device.

Vapor would handle storing the device tokens via Fluent into the database of choice, and create these connections as needed, and asynchronously send the requests as needed. It's really up to how someone is trying to use APNS.

A good example is a pub sub model that something like PubNub (and others) use. You subscribe to a channel, say cats, and when you subscribe its tied to your registered device token. Something happens with cats and you want to notify everyone on that channel cats that some cat has been added, or maybe some cat spent a life.

This library consolidates, and uses the newest tech for Service Side Swift, in a way that requires no other dependencies. It does it with the same approach that NIO takes when building HTTP clients so that it is event-driven for high performance and non-blocking.

The main reason this is complicated is the advent of http2, Swift NIO http2 and JWT/ES256 encryption, incorporating all that for cross platform completeness, and near zero dependency(OpenSSL).

If this isn't wha you're asking I can make another stab at it in the morning.

Id be happy to explain the nuances of the APNS, but most of it can be read here and here.


(Ian Partridge) #5

Hi, it's good to see this proposal. Personally I think this is in scope for SSWG.

As others have noted, the dependency on NIOOpenSSL for crypto is annoying though. I am also not a massive fan of the hand-rolled JWT implementation - it would be good to have a separate SSWG package for that.

For information, IBM is currently working on a Swift package for elliptic-curve cryptography. This will use OpenSSL on Linux and the Security framework on Darwin to do the heavy lifting. Hence it will work cross-platform.

We want to open-source this soon, and plan to adopt it in our JWT library so we can support ES256 on Darwin and Linux.

Hopefully it's useful to people (and possibly to this package). Would there be interest in taking something like this in SSWG, or would people prefer to wait for the fabled "broader solution to Crypto" :slight_smile:


(Tanner) #6

I think both of those packages (elliptic-curve and JWT) are definitely worth pitching. Maybe the broader solution to crypto will end up being an amalgamation of smaller pieces like these.


(Kyle Browning) #7

Definitely open to JWT and curve. I wouldn't want it to hold up this pitch, but if the community feels that's more important, thats fine and we can work towards that too.

Otherwise Id rather do the refactor later if it is going to take months of work.


(Kyle Browning) #8

@tachyonics, Here's a more fleshed out example of how to do it specifically with Vapor. The steps are probably interchangeable to any Swift Server platform.

Vapor Service

Depending on which platform you are using, you will need to expose the NIOAPNS API to your apps code. In Vapor this can be done with Service, a dependency injection framework.

import NIO
import NIOAPNS
import Service

// MARK: - Service
public protocol APNSService: Service {
    var apnsConfig: APNSConfig { get }
    func send(deviceToken: String, aps: Aps, group: EventLoop) throws -> EventLoopFuture<APNSResponse>
}
public struct APNS: APNSService {
    public var apnsConfig: APNSConfig

    public init(apnsConfig: APNSConfig) {
        self.apnsConfig = apnsConfig
    }
    public func send(deviceToken: String, aps: Aps, group: EventLoop) throws -> EventLoopFuture<APNSResponse> {
        return APNSConnection.connect(apnsConfig: apnsConfig, on: group.next()).then({ (connection) -> EventLoopFuture<APNSResponse> in
            return connection.send(deviceToken: deviceToken, APNSRequest(aps: aps, custom: nil))
        })
    }
}

Vapor Config

In vapor, you register services in configure.swift

 let apnsConfig = APNSConfig.init(keyId: "9UC9ZLQ8YW", teamId: "ABBM6U9RM5", privateKeyPath: "/Users/kylebrowning/Downloads/key.p8", topic: "com.grasscove.Fern", env: .sandbox)
    services.register(APNS(apnsConfig: apnsConfig))

Route

This provides the APNS Service to be made available on requests. The device token used here was registered via Apple UserNotification SDK. (Which ill show in the next steps)

   router.get("singlePush") { req -> String in
        let temp = try req.make(APNS.self)
        let alert = Alert(title: "Hey There", subtitle: "Subtitle", body: "Body")
        let aps = Aps(alert: alert, category: nil, badge: 1)
        let resp = try temp.send(deviceToken: "223a86bdd22598fb3a76ce12eafd590c86592484539f9b8526d0e683ad10cf4f", aps: aps, group: req.eventLoop)
        print(resp)
        return "It works!"
    }

Expanded examples

Ive gone a step further and just to show how this might be expanded upon but im going to use GIST's so that this page doesn't get crazy long.

Here is a quick APNS Device model for Vapor

Here is how you register on iOS to our Vapor backend

This fetches all registered device tokens in Vapor, and pushes a notification to them


#9

I would like to add, that whatever we do, that we are able to support Android devices as well. I know this is a swift forum and maybe it's just a 'given' or an 'of course' but for a strong server side swift notification service to exist and thrive, I would think there needs to be that kind of support. Maybe there aren't any technical blockers to that - in this proposal but just wanted to mention it. Right now I am using Urban Airship and implemented, what they expect, inside my Vapor app and it works well with both iOS and Android devices.

thoughts?


(Kyle Browning) #11

@geeksweep, Theres definitely been some discussion around GCM as well. I'm going to take a look at that separately from this. Think of this as the scaffolding for APNS. Another one will be GCM. And then Vapor/Kitura/Perfect can implement solutions on top.

One package separated for each gives flexibility to anyone implementing a server side swift solution the option to choose which pieces they need. Im sure someone in the community (Im going to try!) will develop a solution on an SSS platform that provides a fully out of the box Push Notification Provider API for APNS and GCM

All that said there will probably be a separate pitch to put forward specifically for GCM.

You're welcome to get started if you'd like!

(I removed my other post and edited this one a bit because it didn't properly reply to you)


(Tobias) #12

I think this is a great idea and a good fit for the SSWG. In one of our projects we also implemented an APNS client on top of SwiftNIO. It is in fact very similar to your implementation.

One thing that I noticed after scanning the code: I think the high-level API should also include connection management. If I am not mistaken, in your Vapor example above every call to send will create a new connection. I think the preferred way is to keep the HTTP/2 connection alive. When you try to send a notification, the client should reuse an existing connection or create a new one if none is available. As this is part of the "APNS contract" I think an APNS framework should include this.


(Kyle Browning) #13

That’s fun Tobias!

I thought maybe leaving the connection implementation up the platform might be more widely flexible.

Do you think there is room to do both?

If so got any pseudo code or thoughts?

Maybe send becomes static and requires connection parameter but it’s defaulted to the encapsulated one?


(Tobias) #14

Yes, maybe connection management is up to the platform.

If not, I would ship another high level object ApnsClient that encapsulates the connection management.

This ApnsClient would provide a send(request: ApnsRequest) method. In this method, the client would check if there is already a connection it can use.

This is how I am doing it: this method returns a connection future for the requested Environment (sandbox or production). Here self.state is just a structure that maintains the state (.disconnected, .connecting(Future<Connection>) and .connected(Connection) for sandbox and production env and self.factory is a structure that provides factory methods for creating a new connection to either production or sandbox env. This factory is the owner of the ApnsConfiguration structure which includes all the details (key, teamid, etc...)

Note that the ApnsClient here is bound to a single EventLoop to ensure thread safety of the state.

private func connection0(for env: Environment) -> EventLoopFuture<ApnsConnection> {
    assert(self.eventLoop.inEventLoop)
    
    switch self.state[env] {
    case .disconnected:
        // we need to connect
        let future = self.factory[env].connect()
        self.state[env] = .connecting(future)
        future.whenSuccess { (connection) in
            self.state[env] = .connected(connection)
            self.logger.info("Connected to \(env) APNS server.")
            connection.closeFuture.whenComplete {
                guard case .connected(_) = self.state[env] else { return }
                self.state[env] = .disconnected
                self.logger.info("Disconnected from \(env) APNS server.")
            }
        }
        
        future.whenFailure { (error) in
            // connection failed
            self.state[env] = .disconnected
            self.logger.error("Failed to connect to \(env) APNS server: \(error)")
        }
        return future
    case .connecting(let future):
        // we are already connecting, reuse the future
        return future
    case .connected(let connection):
        // we are already connected
        return connection.eventLoop.newSucceededFuture(result: connection)
    }
}

(Kyle Browning) #15

Thanks for this detailed reply!

Judging by the other two pitches/proposals for the SSWG, it appears that connection is left up to the implementor.

Both the postgresql and nioredis implementations are not encapsulating connection, but merely exposing it.

Id be inclined to follow in their footsteps since their pitches are already proposals, but Im happy to take community input for scope ideas. Thats the entire point of the incubation process, and the pitch step.


(Tanner) #16

I agree. I think it's harder to agree upon how to do connection management properly and could slow up the proposal process. That's always something we can add in the future.


(Tobias) #17

Yes I guess you make a good point there. :wink:


(Andrew Edwards) #18

For this I’ve done something similar for google cloud provider when automatically refreshing your access tokens.


#19
  • Reads your .p8 APNS Push key from local file.

It is not secure to store private keys on the application server (either in a file or in memory). The best practice is to use a key management service that uses hardware security modules (like AWS KMS or Azure Key Vault) to store and apply the private key.

I think the library should delegate the digital signature generation to the application code rather than forcing the application to provide a file path. The application can then delegate the digital signature generation to a key management service.


(Kyle Browning) #20

Okay, we will need to find a way to expose this part and convert it to reading from data


(Kyle Browning) #21

Looks like we can do it like this.

Accepting PR's otherwise Ill get to this later this week.