[Discussion] NIOAPNS: NIO-based Apple Push Notification Service

NIO-based Apple Push Notification Service

Package Description

Apple push notification service implementation built on Swift NIO.

Package name nio-apns
Proposed Maturity Level Sandbox
License Apache 2
Dependencies SwiftNIO 2.x, SwiftNIOSSL 2.x, SwiftNIOHTTP2 1.x

Introduction

NIOAPNS is a module thats gives server side swift applications the ability to use the Apple push notification service.

Motivation

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.

All of the currently maintained libraries either have framework specific dependencies, are not built with NIO, or do not provide enough extensibility while providing "out of the box" capabilities.

Existing Solutions

Proposed Solution

NIOApns provides the essential types for interacting with APNS Server (both production and sandbox).

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 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)

Future considerations and dependencies

  • BoringSSL
  • Common connection handler options
  • Swift-Log
  • Swift-Metric
  • Swift-JWT?

APNSConfiguration

APNSConfiguration is a structure that provides the system with common configuration.

public struct APNSConfiguration {
    public let keyIdentifier: String
    public let teamIdentifier: String
    public let signingMode: SigningMode
    public let topic: String
    public let environment: APNSEnvironment
    public let tlsConfiguration: TLSConfiguration

    public var url: URL {
        switch environment {
        case .production:
            return URL(string: "https://api.push.apple.com")!
        case .sandbox:
            return URL(string: "https://api.development.push.apple.com")!
        }
    }
}

Example APNSConfiguration

let apnsConfig = try APNSConfiguration(keyIdentifier: "9UC9ZLQ8YW",
                                   teamIdentifier: "ABBM6U9RM5",
                                   signingMode: .file(path: "/Users/kylebrowning/Downloads/AuthKey_9UC9ZLQ8YW.p8"),
                                   topic: "com.grasscove.Fern",
                                   environment: .sandbox)

SigningMode

SigningMode provides a method by which engineers can choose how their certificates are signed. Since security is important keeping we extracted this logic into three options. file, data, or custom.

public struct SigningMode {
    public let signer: APNSSigner
    init(signer: APNSSigner) {
        self.signer = signer
    }
}

extension SigningMode {
    public static func file(path: String) throws -> SigningMode {
        return .init(signer: try FileSigner(url: URL(fileURLWithPath: path)))
    }
    public static func data(data: Data) throws -> SigningMode {
        return .init(signer: try DataSigner(data: data))
    }
    public static func custom(signer: APNSSigner) -> SigningMode {
        return .init(signer: signer)
    }
}

Example Custom SigningMode that uses AWS for private keystorage

public class CustomSigner: APNSSigner {
   public func sign(digest: Data) throws -> Data {
     return try AWSKeyStore.sign(digest: digest)
   }
   public func verify(digest: Data, signature: Data) -> Bool {
      // verification
   }
}
let customSigner = CustomSigner()
let apnsConfig = APNSConfig(keyId: "9UC9ZLQ8YW",
                      teamId: "ABBM6U9RM5",
                      signingMode: .custom(signer: customSigner),
                      topic: "com.grasscove.Fern",
                      environment: .sandbox)

APNSConnection

APNSConnection is a class with methods thats provides a wrapper to NIO's ClientBootstrap. The swift-nio-http2 dependency is utilized here. It also provides a function to send a notification to a specific device token string.

Example APNSConnection

let apnsConfig = ...
let apns = try APNSConnection.connect(configuration: apnsConfig, on: group.next()).wait()

Alert

Alert is the actual meta data of the push notification alert someone wishes to send. More details on the specifcs of each property are provided here. They follow a 1-1 naming scheme listed in Apple's documentation

Example Alert

let alert = Alert(title: "Hey There", subtitle: "Full moon sighting", body: "There was a full moon last night did you see it")

APSPayload

APSPayload is the meta data of the push notification. Things like the alert, badge count. More details on the specifcs of each property are provided here. They follow a 1-1 naming scheme listed in Apple's documentation

Example APSPayload

let alert = ...
let aps = APSPayload(alert: alert, badge: 1, sound: "cow.wav")

Putting it all together

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let apnsConfig = try APNSConfiguration(keyIdentifier: "9UC9ZLQ8YW",
                                   teamIdentifier: "ABBM6U9RM5",
                                   signingMode: .file(path: "/Users/kylebrowning/Downloads/AuthKey_9UC9ZLQ8YW.p8"),
                                   topic: "com.grasscove.Fern",
                                   environment: .sandbox)

let apns = try APNSConnection.connect(configuration: apnsConfig, on: group.next()).wait()
let alert = Alert(title: "Hey There", subtitle: "Full moon sighting", body: "There was a full moon last night did you see it")
let aps = APSPayload(alert: alert, badge: 1, sound: "cow.wav")
let notification = BasicNotification(aps: aps)
let res = try apns.send(notification, to: "de1d666223de85db0186f654852cc960551125ee841ca044fdf5ef6a4756a77e").wait()
try apns.close().wait()
try group.syncShutdownGracefully()

Custom Notification Data

Apple provides engineers with the ability to add custom payload data to each notification. In order to faciliate this we have the APNSNotification.

Example

struct AcmeNotification: APNSNotification {
    let acme2: [String]
    let aps: APSPayload

    init(acme2: [String], aps: APSPayload) {
        self.acme2 = acme2
        self.aps = aps
    }
}

let apns: APNSConnection: = ...
let aps: APSPayload = ...
let notification = AcmeNotification(acme2: ["bang", "whiz"], aps: aps)
let res = try apns.send(notification, to: "de1d666223de85db0186f654852cc960551125ee841ca044fdf5ef6a4756a77e").wait()

Maturity

This package meets the following criteria according to the SSWG Incubation Process:

Vapor, and I, are in the processes of providing a (framework agnostic) higher-level library: APNSkit. This is the initial work on connection pooling and things we thought would be hard to get right in the initial incubation periods before submitting..

Alternatives Considered

N/A

Special Thanks

Tanner Everything really
fumoboy007 APNSSigner idea
David Hart Custom Notifications, best practices and Apples naming conventions
IanPartridge JWT, crypto usages, overall feedback
Nathan Harris General questions on Incubation process and templates
Everyone who participated in the pitch The feedback thread was so lively, thank you!
15 Likes

This looks great! Thanks for the very detailed write-up.

One question:
Wouldn't it make sense to think of a generic interface for push notifications and not limit ourselves to Apple's service in the original design?

In the server-side world we'll most likely always need to also send Google's push notifications, and I think it would make sense to have a common interface (well it would need to be researched if that's possible).

NIOApns should ideally then sit on top of this abstraction just like LogHandlers use the fantastic swift-log API package.

Yeah this was brought up in the pitch thread.

One package separated for each gives flexibility to anyone implementing a server side swift solution the option to choose which pieces they need. Some people are only targeting iOS, why would we include Google code in that sense?

And vice versa, someone may only want GCM, why would we include APNS.

The idea is that this is low level and small enough that a larger package can be built on top of this that provides both use cases. This would most likely be handled by Vapor, or Kitura, or Perfect etc.

I misunderstood the original ask, edit response below:

If it is possible to provide a generic push interface (which I don't believe it is considering the differences in HTTP and API design), we can abstract the connection logic and push logic from this package to use that in the future. And at that point it seems like we're just wrapping NIOHttp logic for a connectionProtocol and a push protocol.

3 Likes

Makes sense. Thanks!

Overall, this is a great start and I see ways of easily jumping in and using this service - nice job!

Naming

One minor question - is the module / package named NIOAPNS (in all caps) or NIOApns (lowercase after the A)?

Namespacing

Regardless, if NIOAPNS is the module name, I wonder how much we could get away with namespacing under APNS?

public enum APNS {
    public struct Configuration { }
    public enum Environment { }
    // etc ...
}

APNSNotification

I think we could rename APNSNotificationProtocol to drop the Protocol suffix and rename APNSNotification to something like BasicNotification (if namespaced under APNS)

Implementation

One of the things up to this point that we've seen, is a goal to be as down to the wire as possible with these libraries - but that's because up to this point it's been API libraries & database drivers. So perhaps this feedback is not as worthwhile if the scope is willing to expand.

What would be the overall impact of changing protocol requirements and implementations from using Foundation.Data to NIO.ByteBuffer?

Like I said, perhaps this library will be the first to be 1 level higher in abstraction. As I see the current SSWG ecosystem:

1        2              3
NIO      DB Drivers     APNS (Service Modules?)
2 Likes

Compared to say, logging and metrics, I think it's a bit more difficult to design abstractions upfront for possibly two entirely distinct solutions of Push Notifications.

It would be totally reasonable that once we have both an APNS and GCM package, to then revisit and find the common API that is then (perhaps) proposed and used as the common currency and both libraries revisit their implementations to conform to the new API requirements.

However IMO, I think in this specific library's use case, is that a common abstraction is now in the realm of a framework, rather than an ecosystem-wide common API.

Thanks! That looks like a good start.

I don't have lots of time right now so please excuse my telegraph style comments below :slight_smile:

  • I think there are too many top-level types. I think the API could benefit from nesting related types
  • why is it swift-nio-http2-apns? I think you should drop the http2, I'm sure that if APNS switched to say HTTP/1.1 (unlikely :stuck_out_tongue:) you'd then implement that too in this package
  • APNSResponse's API looks more complicated than necessary to me: You set response.data and if that fails, response.error gets set behind the scenes. I think there should be an initialiser init<Bytes: Sequence>(bytes: Bytes) throws where Bytes.Element == UInt8 and if the initialisation fails, it would just throw
  • the APNS payload type is called APSPayload, is there an N missing?
  • API docs would be great. For example there's a threadID, I believe that's for conversation threads or something?
  • why do we need the APNSNotificationProtocol protocol? Why don't we just pass the contained aps: APSPayload?
  • APNSRequestEncoder closes the Channel if something goes wrong. Usually a reusable ChannelHandler that is a NIO-citizen does not close the Channel itself. It rather uses context.fireErrorCaught(error) through the ChannelPipeline so that the user can choose what to do. In most cases, there's no recovery and the 'application handler' (usually the last handler in a pipeline) will close the connection. But in certain cases the user might be able to handle the error and then we shouldn't just throw away the channel.
  • APNSStreamHandler shouldn't be necessary, you can just use RequestResponseHandler which should be way more efficient and correct.
  • please do not @_exported import (in Exported.swift), this will break SemVer majorly
  • do we need the APNSSigner protocol? There is only one implementation (DataSigner). I think we could rename DataSigner to APNSSigner. The parameters don't actually need to be Data, you literally only get their pointer. If you just make the init(data:) and sign(digest:) arguments UnsafeRawBufferPointer then you can add Data, ByteBuffer, and Array<UInt8> convenience functions
  • this looks very dangerous:
#if (OPENSSL_VERSION_NUMBER < 0x10100000L) || !defined(LIBRESSL_VERSION_NUMBER)
typedef struct ECDSA_SIG_st {
    BIGNUM *r;
    BIGNUM *s;
} ECDSA_SIG;
#endif

because if defines a type that must match your system's OpenSSL version's definition of that struct. If it's not a perfect match then really bad things will happen.

Thanks for the detailed reply!

  1. Will fix this with something I think makes sense. Ill link a PR here.
  2. Will change, no reason. SEO purposes I guess.
  3. Can you flesh out this example a bit more, Im not sure I fully understand.
  4. Sorry, yes this is an Apple term they use on the documentation called the APSDictionary. I didn't want to call it APSDictionary to be confused with a regular Dictionary.
  5. Yes, this is for grouping notifications on the iOS device screen. I linked to Apple's docs here, but ill add more in the files.
  6. APNSNotificationProtocol is needed so custom payloads can be generated. If you look in the original post here, I talked about its necessity. Should I elaborate more?
  7. Would failing the promise here work as well, or should I fireErrorCaught as well.
  8. Using RequestResponseHandler added another dependency but if we are okay with that, then I am too
  9. okay.
  10. The Signer protocol is more for convenience functions while developing. But I suppose we could wrap them all up.
  11. Honestly, this is the biggest crux of the library. I have no way of guaranteeing which open ssl version is installed, but ECDSA_SIG_st is required to sign with the private key. If we went with (like in this PR BoringSSL) this could be removed. But then we have to keep up with BOringSSL constant state of changes.

Would love to continue to hear your thoughts. Ill get to work on a PR with this feedback that's actionable right now.

The idea being that the initialization should either fail, or throw depending on the semantics, rather than setting an optional property. It could make the object error prone to initialization and means that the type has a memory layout larger than necessary for 80% of the time it's created.

I thought APSPayload was a named type in the APNS documentation?

Perhaps, but I think it can be cleaned up by my suggestion of repurposing APNSNotification as the protocol name and renaming the struct as APNSBasicNotification or conforming APSPayload to the protocol

More experience NIO developers should speak up - but I think the semantics are based on the purpose of the ChannelHandler that is receiving the event and making the decision.

If the handler is just doing some data mangling that will pass further up/down the pipeline - it should call fireErrorCaught.

But if it's actually coordinating between a read/write cycle (because it's at the "top" of the pipeline), it probably holds a reference to a user's promise that should be failed.

Is relying on NIO's bundled SSL library not recommended? I'm assuming it wouldn't because IIRC it's not part of the API / SemVer contract - right?

1 Like

You're correct. it is. Edited my comment. No coffee yet!

1 Like

So relying on swift-nio-ssl to implement TLS for you is recommended as that's what swift-nio-ssl is for.

Relying on CNIOBoringSSL is very much not recommended, the problem is we can't recommend you using it because BoringSSL doesn't actually have a stable API at all (it doesn't even have version numbers). swift-nio-ssl's API is just the ChannelHandlers and the supporting types, basically what's covered in the API docs.

The fact that NIO's TLS implementation is implemented using a vendored version of BoringSSL is an implementation detail that might in theory change (unlikely). But we can't recommend using CNIOBoringSSL directly as it's API might arbitrarily change. We also document this.

Absolutely right, in this case we should two two things: 1) fail the write promise (if present), 2) fire an error through the pipeline.

Yeah, I think it would be great to motivate it with an example where we actually need that protocol.

Im somewhat confused here, there is an example directly under it.

@johannesweiss, @Mordil

Heres the commit that cleans some of that up. initial discussion feedback. · swift-server-community/APNSwift@bcff9d9 · GitHub

1 Like

I don't believe APSPayload can conform to the APNSNotification because it needs to be top-level.

{
    "aps" : {
        "alert" : {
            "title" : "Game Request",
            "body" : "Bob wants to play poker",
            "action-loc-key" : "PLAY"
        },
        "badge" : 5
    },
    "acme1" : "bar",
    "acme2" : [ "bang",  "whiz" ]
}

Heres a PR with initial namespace changes. LMK!

https://github.com/kylebrowning/swift-nio-apns/pull/13

FWIW Ill update the documentation on everything after a few more days of feedback.
Trying to limit constantly editing things :slight_smile:

1 Like

The OpaquePointer issue has been resolved for Open SSL 1.0 and 1.1 here

Im now working on documentation for the whole project.

Tests have been added in as well by Laurent!!

3 Likes

The feedback thread is now here.

1 Like