From 33b4d443f7fa21164cb39313dfb170e9f0397ab1 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 16:29:47 -0500 Subject: [PATCH 1/7] 6.0.0: validate inputs, Sendable payloads, Linux support, bounded requests Transport: keep URLSession. On Linux it negotiates HTTP/2 (ALPN h2) through libcurl+nghttp2 on swift:6.2/6.4-noble, arm64 and x86_64, so import FoundationNetworking where it exists. Correctness and security: - Device tokens must be 64-200 hex digits of even length (typed APNSClientError.invalidDeviceToken), so they can't inject path, query or fragment. - apns-topic must be bundle-ID syntax; apns-collapse-id is 1-64 bytes with no control characters; priority is an APNSPriority (10, 5, 1) and background pushes must use 5; expirations reject negatives and overflow. - Key and team IDs must be 10 ASCII alphanumerics, and the JWT header and claims are built with JSONEncoder. - APNSNotificationItem is really Sendable: customPayload takes APNSJSONValue (with literals and init(jsonObject:)); a custom "aps" key throws. - Payload limits checked client-side: 4096 bytes, 5120 for VoIP. - Provider tokens: reused for 50 minutes, never re-minted within 20 minutes; ExpiredProviderToken retries once with a fresh token, InvalidProviderToken invalidates, TooManyProviderTokenUpdates keeps the token. - Whole-request deadline (timeout:, default 30 s) that returns even when corelibs blocks on cancel; response bodies capped at 16 KiB (streamed on Darwin, checked after buffering on Linux). - Redacted description/debugDescription/Mirror for APNSClient and APNSCredentials: no key or provider token in prints, dump or Mirror. - The multi-token send returns [APNSBatchResult] with per-token Results. - 410 timestamps are Int64 milliseconds (invalidatedAt: Date); known reasons in APNSErrorReason; apns-unique-id exposed. - Environment.custom(host:port:) for mock servers. swift-crypto: "4.0.0"..<"5.0.0" (from: "3.0.0" meant 3.x only, which can't resolve next to Perfect-SMTP 6.0.0). Tests: hermetic URLProtocol stub; request shape, JWT signature verified with swift-crypto, validation, retries, timeouts, cancellation, redaction. The live-sandbox test is removed. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 2 +- Sources/PerfectNotifications/APNSClient.swift | 447 +++++++++++-- .../APNSCredentials.swift | 114 ++-- .../PerfectNotifications/APNSJSONValue.swift | 133 ++++ .../APNSNotification.swift | 91 +-- .../PerfectNotifications/APNSPushType.swift | 54 +- .../PerfectNotifications/APNSResponse.swift | 84 ++- .../PerfectNotifications/APNSValidation.swift | 83 +++ .../PayloadAndTokenTests.swift | 274 ++++++++ .../PerfectNotificationsTests.swift | 593 ++++++++---------- .../TestSupport.swift | 170 +++++ 11 files changed, 1550 insertions(+), 495 deletions(-) create mode 100644 Sources/PerfectNotifications/APNSJSONValue.swift create mode 100644 Sources/PerfectNotifications/APNSValidation.swift create mode 100644 Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift create mode 100644 Tests/PerfectNotificationsTests/TestSupport.swift diff --git a/Package.swift b/Package.swift index b8d9245..a8f5c71 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .library(name: "PerfectNotifications", targets: ["PerfectNotifications"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0") + .package(url: "https://github.com/apple/swift-crypto.git", "4.0.0"..<"5.0.0") ], targets: [ .target( diff --git a/Sources/PerfectNotifications/APNSClient.swift b/Sources/PerfectNotifications/APNSClient.swift index 74b71ac..9ac7548 100644 --- a/Sources/PerfectNotifications/APNSClient.swift +++ b/Sources/PerfectNotifications/APNSClient.swift @@ -1,9 +1,69 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif -/// Error thrown when a request cannot reach the APNs gateway (not a gateway rejection). -public enum APNSClientError: Error, Sendable { +/// An error raised by the client itself, before or instead of an APNs response. +/// APNs rejections are not errors: they come back as an ``APNSResponse`` with +/// ``APNSResponse/error`` set. +public enum APNSClientError: Error, Sendable, Equatable, CustomStringConvertible { + /// The device token is not 64–200 hex digits of even length. + case invalidDeviceToken + /// The topic is not a bundle ID (dot-separated ASCII letters, digits and `-`). + case invalidTopic + /// The collapse ID is empty, longer than 64 bytes, or contains a control character. + case invalidCollapseID + /// The priority isn't allowed for the push type (background pushes must use `.background`). + case invalidPriority(APNSPriority, pushType: APNSPushType) + /// A negative expiration, or a relative one that overflows. + case invalidExpiration + /// The key ID is not 10 ASCII letters or digits. + case invalidKeyID + /// The team ID is not 10 ASCII letters or digits. + case invalidTeamID + /// A ``APNSClient/Environment/custom(host:port:)`` host or port is malformed. + case invalidHost + /// The request timeout is not a positive, finite number of seconds. + case invalidTimeout + /// The payload exceeds the APNs limit for the push type (4096 bytes, 5120 for VoIP). + case payloadTooLarge(size: Int, limit: Int) + /// A custom payload used the reserved `aps` key. + case reservedPayloadKey(String) + /// ``APNSJSONValue/init(jsonObject:)`` met a value it can't represent. + case invalidJSONValue(String) + /// Signing the provider token failed. + case tokenSigningFailed + /// The request did not complete within the client's timeout. + case timedOut + /// The response body exceeded the client's limit (16 KiB; APNs bodies are under 1 KiB). + case responseTooLarge(limit: Int) + /// The request failed below HTTP (DNS, TCP, TLS). The string is the underlying error's + /// description; it never contains the provider token. case network(String) + /// The response was not HTTP. case invalidResponse + + public var description: String { + switch self { + case .invalidDeviceToken: return "invalid device token (expected 64-200 hex digits)" + case .invalidTopic: return "invalid apns-topic" + case .invalidCollapseID: return "invalid apns-collapse-id (1-64 bytes, no control characters)" + case .invalidPriority(let p, let t): return "priority \(p.rawValue) is not allowed for push type \(t.rawValue)" + case .invalidExpiration: return "invalid apns-expiration" + case .invalidKeyID: return "invalid key ID (expected 10 letters or digits)" + case .invalidTeamID: return "invalid team ID (expected 10 letters or digits)" + case .invalidHost: return "invalid custom host or port" + case .invalidTimeout: return "invalid timeout" + case .payloadTooLarge(let size, let limit): return "payload is \(size) bytes; the limit is \(limit)" + case .reservedPayloadKey(let k): return "custom payload key \"\(k)\" is reserved" + case .invalidJSONValue(let t): return "not a JSON value: \(t)" + case .tokenSigningFailed: return "signing the provider token failed" + case .timedOut: return "request timed out" + case .responseTooLarge(let limit): return "response body exceeded \(limit) bytes" + case .network(let s): return "network error: \(s)" + case .invalidResponse: return "response was not HTTP" + } + } } /// A Swift 6 client for the APNs HTTP/2 provider API (token-based auth). @@ -31,26 +91,41 @@ public enum APNSClientError: Error, Sendable { /// print("rejected: \(err.reason)") /// } /// ``` +/// +/// The client is a value type that shares one credentials object, so copies share the +/// cached provider token. Create one client per signing key and reuse it: `URLSession` +/// keeps the HTTP/2 connection open and multiplexes requests over it, as Apple asks. public struct APNSClient: Sendable { // MARK: Environment - public enum Environment: Sendable { + public enum Environment: Sendable, Equatable { case sandbox case production + /// A local or staging APNs mock (`https://host:port`). The provider token is sent + /// to this host, so only point it at a server you control. + case custom(host: String, port: Int) - var host: String { + var authority: String { switch self { case .sandbox: return "api.sandbox.push.apple.com" case .production: return "api.push.apple.com" + case .custom(let host, let port): return port == 443 ? host : "\(host):\(port)" } } } + /// The default whole-request timeout, in seconds. + public static let defaultTimeout: TimeInterval = 30 + /// The largest response body the client reads. APNs error bodies are a few dozen bytes. + static let maxResponseBodySize = 16 * 1024 + // MARK: Properties private let credentials: APNSCredentials - private let environment: Environment + public let environment: Environment + /// The whole-request deadline, in seconds, covering connect, send and response. + public let timeout: TimeInterval private let urlSession: URLSession // MARK: Init @@ -59,112 +134,113 @@ public struct APNSClient: Sendable { /// /// - Parameters: /// - keyID: The 10-character key ID from the `.p8` filename (e.g. `ABCDE12345`). - /// - teamID: Your Apple Developer Team ID. + /// - teamID: Your 10-character Apple Developer Team ID. /// - privateKeyPath: Path to the `.p8` private key file. /// - environment: `.sandbox` (default) or `.production`. /// - urlSession: Custom session for testing or certificate pinning. + /// - timeout: Whole-request deadline in seconds (default 30). + /// - Throws: ``APNSClientError/invalidKeyID``, ``APNSClientError/invalidTeamID``, + /// ``APNSClientError/invalidHost``, ``APNSClientError/invalidTimeout``, a file error, + /// or a swift-crypto error for a key that isn't a P-256 PEM. public init(keyID: String, teamID: String, privateKeyPath: String, environment: Environment = .sandbox, - urlSession: URLSession = .shared) throws { + urlSession: URLSession = .shared, + timeout: TimeInterval = APNSClient.defaultTimeout) throws { + try Self.validate(environment: environment, timeout: timeout) self.credentials = try APNSCredentials(keyID: keyID, teamID: teamID, privateKeyPath: privateKeyPath) self.environment = environment self.urlSession = urlSession + self.timeout = timeout } /// Supply the `.p8` key as a PEM string (useful when loading from env vars or config). public init(keyID: String, teamID: String, pemString: String, environment: Environment = .sandbox, - urlSession: URLSession = .shared) throws { + urlSession: URLSession = .shared, + timeout: TimeInterval = APNSClient.defaultTimeout) throws { + try Self.validate(environment: environment, timeout: timeout) self.credentials = try APNSCredentials(keyID: keyID, teamID: teamID, pemString: pemString) self.environment = environment self.urlSession = urlSession + self.timeout = timeout + } + + init(credentials: APNSCredentials, environment: Environment, urlSession: URLSession, + timeout: TimeInterval = APNSClient.defaultTimeout) throws { + try Self.validate(environment: environment, timeout: timeout) + self.credentials = credentials + self.environment = environment + self.urlSession = urlSession + self.timeout = timeout + } + + private static func validate(environment: Environment, timeout: TimeInterval) throws(APNSClientError) { + if case .custom(let host, let port) = environment { try APNSValidation.host(host, port: port) } + guard timeout.isFinite, timeout > 0 else { throw .invalidTimeout } } // MARK: - Send /// Send a notification to a single device token. /// + /// An `ExpiredProviderToken` rejection is retried once with a fresh token, unless the + /// current token is less than 20 minutes old (Apple's limit on token updates). + /// /// - Parameters: /// - notification: The payload to send. - /// - deviceToken: The hex device token string registered by the app. + /// - deviceToken: The hex device token registered by the app. /// - topic: Bundle ID of the app (`com.example.App`), or `com.example.App.voip` for VoIP. /// - pushType: Defaults to `.alert`. Must be `.background` for silent pushes. /// - priority: `apns-priority`. Defaults to ``APNSPushType/defaultPriority``. /// - expiration: When to discard an undeliverable notification. - /// - collapseID: Coalesces pending undelivered notifications with the same ID. + /// - collapseID: Coalesces pending undelivered notifications with the same ID (≤ 64 bytes). + /// - Returns: The APNs response; a rejection is a response with ``APNSResponse/error`` set. + /// - Throws: ``APNSClientError`` for invalid input, network failures and timeouts; + /// `CancellationError` if the task is cancelled. public func send( _ notification: APNSNotification, to deviceToken: String, topic: String, pushType: APNSPushType = .alert, - priority: Int? = nil, + priority: APNSPriority? = nil, expiration: APNSExpiration? = nil, collapseID: String? = nil ) async throws -> APNSResponse { - let url = URL(string: "https://\(environment.host)/3/device/\(deviceToken)")! - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = notification.payload - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue(topic, forHTTPHeaderField: "apns-topic") - request.setValue(pushType.rawValue, forHTTPHeaderField: "apns-push-type") - request.setValue("\(priority ?? pushType.defaultPriority)", forHTTPHeaderField: "apns-priority") - - let jwt = try credentials.token() - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - - if let exp = expiration { - request.setValue("\(exp.headerValue)", forHTTPHeaderField: "apns-expiration") - } - if let cid = collapseID { - request.setValue(cid, forHTTPHeaderField: "apns-collapse-id") - } - - let data: Data - let response: URLResponse - do { - (data, response) = try await urlSession.data(for: request) - } catch { - throw APNSClientError.network("\(error)") - } - - guard let http = response as? HTTPURLResponse else { - throw APNSClientError.invalidResponse - } - - let apnsID = http.value(forHTTPHeaderField: "apns-id") - - guard http.statusCode != 200 else { - return APNSResponse(statusCode: 200, apnsID: apnsID, error: nil) - } - - if !data.isEmpty, let body = try? JSONDecoder().decode(APNSErrorBody.self, from: data) { - let err = APNSResponseError(reason: body.reason, timestamp: body.timestamp) - return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, error: err) - } - return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, error: nil) + let prepared = try prepare(notification, topic: topic, pushType: pushType, + priority: priority, expiration: expiration, collapseID: collapseID) + return try await deliver(prepared, to: deviceToken) } - /// Send the same notification to multiple device tokens (sequential). - /// Returns one ``APNSResponse`` per token, in the same order. + /// Send the same notification to several device tokens, one after another. + /// + /// Returns one ``APNSBatchResult`` per token, in order. A token that fails (invalid + /// token, network error, timeout) gets a `.failure` and the rest are still sent. + /// Problems shared by every token (topic, priority, payload size, …) throw before + /// anything is sent, and cancellation throws `CancellationError`. public func send( _ notification: APNSNotification, to deviceTokens: [String], topic: String, pushType: APNSPushType = .alert, - priority: Int? = nil, + priority: APNSPriority? = nil, expiration: APNSExpiration? = nil, collapseID: String? = nil - ) async throws -> [APNSResponse] { - var responses = [APNSResponse]() - responses.reserveCapacity(deviceTokens.count) + ) async throws -> [APNSBatchResult] { + let prepared = try prepare(notification, topic: topic, pushType: pushType, + priority: priority, expiration: expiration, collapseID: collapseID) + var results = [APNSBatchResult]() + results.reserveCapacity(deviceTokens.count) for token in deviceTokens { - let r = try await send(notification, to: token, topic: topic, - pushType: pushType, priority: priority, - expiration: expiration, collapseID: collapseID) - responses.append(r) + try Task.checkCancellation() + let result: Result + do { + result = .success(try await deliver(prepared, to: token)) + } catch let error as APNSClientError { + result = .failure(error) + } + results.append(APNSBatchResult(deviceToken: token, result: result)) } - return responses + return results } // MARK: - Convenience helpers @@ -210,6 +286,247 @@ public struct APNSClient: Sendable { items.append(.contentAvailable) let notification = try APNSNotification(items: items) return try await send(notification, to: deviceToken, topic: topic, - pushType: .background, priority: 5) + pushType: .background, priority: .background) + } + + // MARK: - Request pipeline + + struct PreparedRequest: Sendable { + let body: Data + let headers: [(String, String)] + } + + func prepare(_ notification: APNSNotification, topic: String, pushType: APNSPushType, + priority: APNSPriority?, expiration: APNSExpiration?, + collapseID: String?) throws(APNSClientError) -> PreparedRequest { + try APNSValidation.topic(topic) + let priority = priority ?? pushType.defaultPriority + if pushType == .background && priority != .background { + throw .invalidPriority(priority, pushType: pushType) + } + let limit = APNSValidation.payloadLimit(for: pushType) + guard notification.payload.count <= limit else { + throw .payloadTooLarge(size: notification.payload.count, limit: limit) + } + var headers: [(String, String)] = [ + ("content-type", "application/json"), + ("apns-topic", topic), + ("apns-push-type", pushType.rawValue), + ("apns-priority", String(priority.rawValue)), + ] + if let expiration { + headers.append(("apns-expiration", String(try expiration.headerValue(now: Date())))) + } + if let collapseID { + try APNSValidation.collapseID(collapseID) + headers.append(("apns-collapse-id", collapseID)) + } + return PreparedRequest(body: notification.payload, headers: headers) + } + + /// Validates the token, sends, and handles provider-token rejections. + /// Throws only ``APNSClientError`` or `CancellationError`. + func deliver(_ prepared: PreparedRequest, to deviceToken: String) async throws -> APNSResponse { + try APNSValidation.deviceToken(deviceToken) + // The token is validated hex, so it can't change the path. + guard let url = URL(string: "https://\(environment.authority)/3/device/\(deviceToken)") else { + throw APNSClientError.invalidDeviceToken + } + var jwt = try providerToken() + var response = try await perform(prepared, url: url, jwt: jwt) + + switch response.error?.knownReason { + case .expiredProviderToken?: + // Retry once, but only if a new token can be minted without exceeding + // Apple's one-update-per-20-minutes limit. + if credentials.reject(jwt) { + jwt = try providerToken() + response = try await perform(prepared, url: url, jwt: jwt) + if response.error?.knownReason == .expiredProviderToken + || response.error?.knownReason == .invalidProviderToken { + _ = credentials.reject(jwt) + } + } + case .invalidProviderToken?: + // A new token from the same key is unlikely to help; don't retry, but don't + // keep reusing a rejected token past the 20-minute floor either. + _ = credentials.reject(jwt) + default: + // TooManyProviderTokenUpdates: keep the current token. The credentials never + // mint more than once per 20 minutes on their own. + break + } + return response + } + + private func providerToken() throws(APNSClientError) -> String { + do { return try credentials.token() } catch { throw .tokenSigningFailed } + } + + private func perform(_ prepared: PreparedRequest, url: URL, jwt: String) async throws -> APNSResponse { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = prepared.body + request.timeoutInterval = timeout + for (name, value) in prepared.headers { request.setValue(value, forHTTPHeaderField: name) } + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + + let session = urlSession + let request_ = request + let (data, http) = try await withDeadline(seconds: timeout) { + try await Self.load(request_, session: session) + } + + let apnsID = http.value(forHTTPHeaderField: "apns-id") + let uniqueID = http.value(forHTTPHeaderField: "apns-unique-id") + guard http.statusCode != 200 else { + return APNSResponse(statusCode: 200, apnsID: apnsID, apnsUniqueID: uniqueID, error: nil) + } + var error: APNSResponseError? + if !data.isEmpty, let body = try? JSONDecoder().decode(APNSErrorBody.self, from: data) { + error = APNSResponseError(reason: body.reason, timestamp: body.timestamp) + } + return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, apnsUniqueID: uniqueID, error: error) + } + + /// Loads the response, reading at most ``maxResponseBodySize`` bytes of body. + private static func load(_ request: URLRequest, session: URLSession) async throws -> (Data, HTTPURLResponse) { + let limit = maxResponseBodySize + do { + #if canImport(FoundationNetworking) + // corelibs Foundation has no streaming `bytes(for:)`, and keeps reading a body + // after its task is cancelled, so the size is checked once the body is in. + // The whole-request deadline still bounds how long that can take. + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw APNSClientError.invalidResponse } + guard data.count <= limit else { throw APNSClientError.responseTooLarge(limit: limit) } + return (data, http) + #else + let (bytes, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + bytes.task.cancel() + throw APNSClientError.invalidResponse + } + if http.expectedContentLength > Int64(limit) { + bytes.task.cancel() + throw APNSClientError.responseTooLarge(limit: limit) + } + var data = Data() + for try await byte in bytes { + data.append(byte) + if data.count > limit { + bytes.task.cancel() + throw APNSClientError.responseTooLarge(limit: limit) + } + } + return (data, http) + #endif + } catch let error as APNSClientError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError { + if error.code == .timedOut { throw APNSClientError.timedOut } + if error.code == .cancelled && Task.isCancelled { throw CancellationError() } + throw APNSClientError.network("URLError \(error.code.rawValue): \(error.localizedDescription)") + } catch { + throw APNSClientError.network(error.localizedDescription) + } + } +} + +// MARK: - Redaction + +extension APNSClient: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + public var description: String { + "APNSClient(environment: \(environment), keyID: \(credentials.keyID), teamID: \(credentials.teamID), timeout: \(timeout))" + } + public var debugDescription: String { description } + /// Shows the environment, IDs and timeout only: never the key or the provider token. + public var customMirror: Mirror { + Mirror(self, children: [ + "environment": environment, + "keyID": credentials.keyID, + "teamID": credentials.teamID, + "timeout": timeout, + ], displayStyle: .struct) + } +} + +// MARK: - Deadline + +/// Runs `operation` with a deadline. Returns as soon as the operation finishes, the +/// deadline passes (``APNSClientError/timedOut``) or the calling task is cancelled +/// (`CancellationError`), even if the operation ignores cancellation; the operation is +/// cancelled in the latter two cases. +func withDeadline( + seconds: TimeInterval, + _ operation: @escaping @Sendable () async throws -> T +) async throws -> T { + let race = DeadlineRace() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let work = Task { + do { race.finish(.success(try await operation())) } + catch { race.finish(.failure(error)) } + } + let nanos = UInt64(min(seconds, 86_400) * 1_000_000_000) + let timer = Task { + try? await Task.sleep(nanoseconds: nanos) + if !Task.isCancelled { race.finish(.failure(APNSClientError.timedOut)) } + } + race.start(continuation, tasks: [work, timer]) + } + } onCancel: { + race.finish(.failure(CancellationError())) + } +} + +private final class DeadlineRace: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var tasks: [Task] = [] + private var pending: Result? + private var done = false + + func start(_ continuation: CheckedContinuation, tasks: [Task]) { + let early: Result? = lock.withLock { + if let pending = self.pending { self.done = true; return pending } + self.continuation = continuation + self.tasks = tasks + return nil + } + if let early { + continuation.resume(with: early) + Self.cancel(tasks) + } + } + + func finish(_ result: Result) { + let (continuation, tasks): (CheckedContinuation?, [Task]) = lock.withLock { + guard !done else { return (nil, []) } + guard let c = self.continuation else { + // Not started yet (cancelled before the continuation existed): remember the + // first result for start(). + if pending == nil { pending = result } + return (nil, []) + } + done = true + self.continuation = nil + defer { self.tasks = [] } + return (c, self.tasks) + } + continuation?.resume(with: result) + Self.cancel(tasks) + } + + /// Cancels off the cooperative pool, after the caller has been resumed. On Linux, + /// cancelling a `URLSession` data task from its async API sometimes blocks the + /// cancelling thread indefinitely (seen in about 1 run in 20 against a server that + /// never answers); the caller must not wait for that, and it must not pin a + /// Swift-concurrency thread. + private static func cancel(_ tasks: [Task]) { + guard !tasks.isEmpty else { return } + DispatchQueue.global().async { tasks.forEach { $0.cancel() } } } } diff --git a/Sources/PerfectNotifications/APNSCredentials.swift b/Sources/PerfectNotifications/APNSCredentials.swift index 95d90fb..0eb4c76 100644 --- a/Sources/PerfectNotifications/APNSCredentials.swift +++ b/Sources/PerfectNotifications/APNSCredentials.swift @@ -1,74 +1,118 @@ import Crypto import Foundation -/// Holds an APNs `.p8` private key, mints JWT provider tokens, and rotates them -/// automatically before the 60-minute APNs expiry. +/// Holds an APNs `.p8` private key and mints JWT provider tokens. +/// +/// Apple accepts a provider token for up to 60 minutes, and refuses updates more often +/// than once every 20 minutes (`TooManyProviderTokenUpdates`). So a token is reused for +/// 50 minutes, is replaced early only after APNs rejected it, and is never replaced +/// within 20 minutes of being minted. final class APNSCredentials: @unchecked Sendable { - private let keyID: String - private let teamID: String + /// Proactive refresh age, safely inside Apple's 60-minute limit. + static let refreshAge: TimeInterval = 50 * 60 + /// Apple's minimum interval between token updates. + static let minimumRefreshInterval: TimeInterval = 20 * 60 + + let keyID: String + let teamID: String private let privateKey: P256.Signing.PrivateKey + private let now: @Sendable () -> Date private let lock = NSLock() private var cachedToken: String? - private var tokenIssuedAt: Date = .distantPast + private var issuedAt: Date = .distantPast + private var rejected = false // MARK: Init - init(keyID: String, teamID: String, privateKeyPath: String) throws { - self.keyID = keyID - self.teamID = teamID + convenience init(keyID: String, teamID: String, privateKeyPath: String) throws { let pem = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - self.privateKey = try P256.Signing.PrivateKey(pemRepresentation: pem) + try self.init(keyID: keyID, teamID: teamID, pemString: pem) } - init(keyID: String, teamID: String, pemString: String) throws { - self.keyID = keyID - self.teamID = teamID - self.privateKey = try P256.Signing.PrivateKey(pemRepresentation: pemString) + convenience init(keyID: String, teamID: String, pemString: String) throws { + // Validate the IDs before parsing the key, so a bad ID is reported as such. + try Self.validate(keyID: keyID, teamID: teamID) + try self.init(keyID: keyID, teamID: teamID, + key: P256.Signing.PrivateKey(pemRepresentation: pemString)) } - init(keyID: String, teamID: String, key: P256.Signing.PrivateKey) { + init(keyID: String, teamID: String, key: P256.Signing.PrivateKey, + now: @escaping @Sendable () -> Date = { Date() }) throws { + try Self.validate(keyID: keyID, teamID: teamID) self.keyID = keyID self.teamID = teamID self.privateKey = key + self.now = now + } + + private static func validate(keyID: String, teamID: String) throws(APNSClientError) { + guard APNSValidation.isTenCharacterID(keyID) else { throw .invalidKeyID } + guard APNSValidation.isTenCharacterID(teamID) else { throw .invalidTeamID } } // MARK: Token vending - /// Returns a valid JWT, reusing the cached token if it is less than 55 minutes old. + /// Returns the cached JWT, or mints a new one when the cached one is 50 minutes old, + /// or was rejected by APNs and is at least 20 minutes old. func token() throws -> String { - lock.lock() - defer { lock.unlock() } - if Date().timeIntervalSince(tokenIssuedAt) < 55 * 60, let cached = cachedToken { - return cached + try lock.withLock { + let current = now() + if let cached = cachedToken { + let age = current.timeIntervalSince(issuedAt) + if age < Self.minimumRefreshInterval { return cached } + if age < Self.refreshAge && !rejected { return cached } + } + let token = try makeJWT(issuedAt: current) + cachedToken = token + issuedAt = current + rejected = false + return token + } + } + + /// Records that APNs rejected `token` (`ExpiredProviderToken` or `InvalidProviderToken`). + /// + /// - Returns: `true` if the next ``token()`` call will return a different token, either + /// because one has already replaced `token` or because a new one may be minted now. + func reject(_ token: String) -> Bool { + lock.withLock { + guard token == cachedToken else { return true } + rejected = true + return now().timeIntervalSince(issuedAt) >= Self.minimumRefreshInterval } - let token = try makeJWT() - cachedToken = token - tokenIssuedAt = Date() - return token } // MARK: JWT minting - private func makeJWT() throws -> String { - let iat = Int(Date().timeIntervalSince1970) - let headerJSON = "{\"alg\":\"ES256\",\"kid\":\"\(keyID)\"}" - let payloadJSON = "{\"iss\":\"\(teamID)\",\"iat\":\(iat)}" + private struct Header: Encodable { let alg: String; let kid: String } + private struct Claims: Encodable { let iss: String; let iat: Int } - let signingInput = b64url(headerJSON) + "." + b64url(payloadJSON) - let hash = SHA256.hash(data: Data(signingInput.utf8)) - let sig = try privateKey.signature(for: hash) - return signingInput + "." + b64urlData(sig.rawRepresentation) + private func makeJWT(issuedAt: Date) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let header = try encoder.encode(Header(alg: "ES256", kid: keyID)) + let claims = try encoder.encode(Claims(iss: teamID, iat: Int(issuedAt.timeIntervalSince1970))) + let signingInput = base64URL(header) + "." + base64URL(claims) + let signature = try privateKey.signature(for: Data(signingInput.utf8)) + return signingInput + "." + base64URL(signature.rawRepresentation) } } -// MARK: - Base64URL helpers (no padding, URL-safe alphabet) +// MARK: - Redaction -private func b64url(_ string: String) -> String { - b64urlData(Data(string.utf8)) +extension APNSCredentials: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + var description: String { "APNSCredentials(keyID: \(keyID), teamID: \(teamID), key: )" } + var debugDescription: String { description } + var customMirror: Mirror { + Mirror(self, children: ["keyID": keyID, "teamID": teamID, "key": ""], + displayStyle: .class) + } } -private func b64urlData(_ data: Data) -> String { +// MARK: - Base64URL (no padding, URL-safe alphabet) + +func base64URL(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") diff --git a/Sources/PerfectNotifications/APNSJSONValue.swift b/Sources/PerfectNotifications/APNSJSONValue.swift new file mode 100644 index 0000000..3c6fd80 --- /dev/null +++ b/Sources/PerfectNotifications/APNSJSONValue.swift @@ -0,0 +1,133 @@ +import Foundation + +/// A JSON value: the `Sendable` replacement for the `Any` that 5.x's +/// `customPayload(String, Any)` accepted. +/// +/// Literals convert directly, so `.customPayload("orderId", "ORD-123")`, +/// `.customPayload("amount", 49)`, `.customPayload("refresh", true)` and +/// `.customPayload("tags", ["a", "b"])` compile as before. Wrap variables explicitly +/// (`.string(orderID)`, `.int(count)`), or convert an existing `JSONSerialization`-style +/// value with ``init(jsonObject:)``. +public enum APNSJSONValue: Sendable, Hashable, Codable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + case array([APNSJSONValue]) + case object([String: APNSJSONValue]) + + // MARK: Codable + + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let b = try? c.decode(Bool.self) { self = .bool(b); return } + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let d = try? c.decode(Double.self) { self = .double(d); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + if let a = try? c.decode([APNSJSONValue].self) { self = .array(a); return } + if let o = try? c.decode([String: APNSJSONValue].self) { self = .object(o); return } + throw DecodingError.dataCorruptedError(in: c, debugDescription: "not a JSON value") + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let s): try c.encode(s) + case .int(let i): try c.encode(i) + case .double(let d): try c.encode(d) + case .bool(let b): try c.encode(b) + case .null: try c.encodeNil() + case .array(let a): try c.encode(a) + case .object(let o): try c.encode(o) + } + } + + // MARK: Conversion from untyped values + + /// Convert an untyped JSON-compatible value (the kind `JSONSerialization` produces + /// or 5.x's `customPayload` accepted): `String`, `Bool`, integers, `Double`/`Float`, + /// `NSNumber`, `NSNull`, `[Any]`, `[String: Any]`, `Optional.none`, or an + /// `APNSJSONValue`. + /// + /// The conversion happens immediately, so the result is genuinely `Sendable`. + /// + /// - Throws: ``APNSClientError/invalidJSONValue(_:)`` naming the first unsupported type + /// (or a non-finite number). + public init(jsonObject value: Any) throws { + self = try Self.convert(value) + } + + private static func convert(_ value: Any) throws -> APNSJSONValue { + // Unwrap Optional boxed in Any. + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == .optional { + guard let wrapped = mirror.children.first?.value else { return .null } + return try convert(wrapped) + } + // Exact native Swift types first: on Darwin `as? NSNumber` would also match + // these through bridging, and `as? Bool` would match NSNumber(1). + switch value { + case let v as APNSJSONValue: return v + case let v as String: return .string(v) + case let v as Bool where type(of: value) == Bool.self: return .bool(v) + case let v as Int where type(of: value) == Int.self: return .int(v) + case let v as Int64 where type(of: value) == Int64.self: + guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("Int64 out of range") } + return .int(i) + case let v as Int32 where type(of: value) == Int32.self: return .int(Int(v)) + case let v as UInt where type(of: value) == UInt.self: + guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("UInt out of range") } + return .int(i) + case let v as Double where type(of: value) == Double.self: return try finite(v) + case let v as Float where type(of: value) == Float.self: return try finite(Double(v)) + case is NSNull: return .null + case let n as NSNumber: return try convert(number: n) + case let a as [Any]: return .array(try a.map(convert)) + case let o as [String: Any]: return .object(try o.mapValues(convert)) + default: + throw APNSClientError.invalidJSONValue(String(describing: type(of: value))) + } + } + + private static func finite(_ d: Double) throws -> APNSJSONValue { + guard d.isFinite else { throw APNSClientError.invalidJSONValue("non-finite number") } + return .double(d) + } + + private static func convert(number n: NSNumber) throws -> APNSJSONValue { + #if canImport(Darwin) + if CFGetTypeID(n) == CFBooleanGetTypeID() { return .bool(n.boolValue) } + #endif + switch String(cString: n.objCType) { + case "c", "B": + #if canImport(Darwin) + return .int(n.intValue) // a real char-typed number, not a CFBoolean + #else + return .bool(n.boolValue) + #endif + case "f", "d": + return try finite(n.doubleValue) + default: + guard let i = Int(exactly: n.int64Value) else { throw APNSClientError.invalidJSONValue("number out of range") } + return .int(i) + } + } +} + +// MARK: - Literals + +extension APNSJSONValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, + ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, ExpressibleByNilLiteral, + ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral { + public init(stringLiteral value: String) { self = .string(value) } + public init(integerLiteral value: Int) { self = .int(value) } + public init(floatLiteral value: Double) { self = .double(value) } + public init(booleanLiteral value: Bool) { self = .bool(value) } + public init(nilLiteral: ()) { self = .null } + public init(arrayLiteral elements: APNSJSONValue...) { self = .array(elements) } + public init(dictionaryLiteral elements: (String, APNSJSONValue)...) { + self = .object(Dictionary(elements, uniquingKeysWith: { _, last in last })) + } +} diff --git a/Sources/PerfectNotifications/APNSNotification.swift b/Sources/PerfectNotifications/APNSNotification.swift index e085963..71fc8f0 100644 --- a/Sources/PerfectNotifications/APNSNotification.swift +++ b/Sources/PerfectNotifications/APNSNotification.swift @@ -1,11 +1,7 @@ import Foundation /// Items that compose the `aps` dictionary and top-level custom keys of a push payload. -/// -/// - Note: `@unchecked Sendable` because ``customPayload(_:_:)`` accepts `Any` for -/// JSON flexibility. In practice only JSON-compatible types (String, Int, Double, -/// Bool, [String: Any], [Any]) should be used. -public enum APNSNotificationItem: @unchecked Sendable { +public enum APNSNotificationItem: Sendable, Equatable { // MARK: Alert /// Main body text of the alert banner. case alertBody(String) @@ -43,8 +39,11 @@ public enum APNSNotificationItem: @unchecked Sendable { case relevanceScore(Double) // MARK: Custom - /// A top-level JSON key/value pair outside the `aps` dictionary. - case customPayload(String, Any) + /// A top-level JSON key/value pair outside the `aps` dictionary. The key `aps` is + /// reserved and makes ``APNSNotification/init(items:)`` throw. + /// + /// Literals work directly: `.customPayload("orderId", "ORD-123")`, `.customPayload("n", 49)`. + case customPayload(String, APNSJSONValue) public enum InterruptionLevel: String, Sendable { case passive @@ -55,15 +54,22 @@ public enum APNSNotificationItem: @unchecked Sendable { } /// A push notification payload ready to send. -public struct APNSNotification: Sendable { - let payload: Data +/// +/// The size limit (4096 bytes, or 5120 for VoIP) depends on the push type, so it is +/// checked when the notification is sent. +public struct APNSNotification: Sendable, Equatable { + /// The encoded JSON payload. + public let payload: Data /// Build from notification items (recommended for standard alerts). + /// + /// - Throws: ``APNSClientError/reservedPayloadKey(_:)`` for a custom `aps` key, or an + /// `EncodingError` for a non-finite number. public init(items: [APNSNotificationItem]) throws { self.payload = try APNSNotificationItem.encodePayload(items) } - /// Supply a raw pre-encoded JSON payload. + /// Supply a raw pre-encoded JSON payload. It is sent as is. public init(raw: Data) { self.payload = raw } @@ -78,50 +84,49 @@ public struct APNSNotification: Sendable { extension APNSNotificationItem { static func encodePayload(_ items: [APNSNotificationItem]) throws -> Data { - var top = [String: Any]() - var aps = [String: Any]() - var alert = [String: Any]() + var top = [String: APNSJSONValue]() + var aps = [String: APNSJSONValue]() + var alert = [String: APNSJSONValue]() var alertBody: String? - var hasAlertDict = false for item in items { switch item { - case .alertBody(let s): alertBody = s - case .alertTitle(let s): alert["title"] = s; hasAlertDict = true - case .alertSubtitle(let s): alert["subtitle"] = s; hasAlertDict = true + case .alertBody(let s): alertBody = s + case .alertTitle(let s): alert["title"] = .string(s) + case .alertSubtitle(let s): alert["subtitle"] = .string(s) case .alertTitleLoc(let s, let a): - alert["title-loc-key"] = s - if let args = a { alert["title-loc-args"] = args } - hasAlertDict = true - case .alertActionLoc(let s): - alert["action-loc-key"] = s; hasAlertDict = true + alert["title-loc-key"] = .string(s) + if let args = a { alert["title-loc-args"] = .array(args.map(APNSJSONValue.string)) } + case .alertActionLoc(let s): alert["action-loc-key"] = .string(s) case .alertLoc(let s, let a): - alert["loc-key"] = s - if let args = a { alert["loc-args"] = args } - hasAlertDict = true - case .alertLaunchImage(let s): - alert["launch-image"] = s; hasAlertDict = true - case .badge(let i): aps["badge"] = i - case .sound(let s): aps["sound"] = s - case .contentAvailable: aps["content-available"] = 1 - case .mutableContent: aps["mutable-content"] = 1 - case .category(let s): aps["category"] = s - case .threadId(let s): aps["thread-id"] = s - case .targetContentId(let s):aps["target-content-id"] = s - case .interruptionLevel(let l): aps["interruption-level"] = l.rawValue - case .relevanceScore(let d): aps["relevance-score"] = d - case .customPayload(let k, let v): top[k] = v + alert["loc-key"] = .string(s) + if let args = a { alert["loc-args"] = .array(args.map(APNSJSONValue.string)) } + case .alertLaunchImage(let s): alert["launch-image"] = .string(s) + case .badge(let i): aps["badge"] = .int(i) + case .sound(let s): aps["sound"] = .string(s) + case .contentAvailable: aps["content-available"] = 1 + case .mutableContent: aps["mutable-content"] = 1 + case .category(let s): aps["category"] = .string(s) + case .threadId(let s): aps["thread-id"] = .string(s) + case .targetContentId(let s): aps["target-content-id"] = .string(s) + case .interruptionLevel(let l): aps["interruption-level"] = .string(l.rawValue) + case .relevanceScore(let d): aps["relevance-score"] = .double(d) + case .customPayload(let k, let v): + guard k != "aps" else { throw APNSClientError.reservedPayloadKey(k) } + top[k] = v } } if let body = alertBody { - if hasAlertDict { alert["body"] = body; aps["alert"] = alert } - else { aps["alert"] = body } - } else if hasAlertDict { - aps["alert"] = alert + if alert.isEmpty { aps["alert"] = .string(body) } + else { alert["body"] = .string(body); aps["alert"] = .object(alert) } + } else if !alert.isEmpty { + aps["alert"] = .object(alert) } - top["aps"] = aps - return try JSONSerialization.data(withJSONObject: top) + top["aps"] = .object(aps) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(top) } } diff --git a/Sources/PerfectNotifications/APNSPushType.swift b/Sources/PerfectNotifications/APNSPushType.swift index 53b28b6..198402f 100644 --- a/Sources/PerfectNotifications/APNSPushType.swift +++ b/Sources/PerfectNotifications/APNSPushType.swift @@ -1,7 +1,13 @@ import Foundation -/// The type of push notification being sent. +/// The type of push notification being sent (`apns-push-type`). /// Required by APNs since iOS 13. Each value carries a default priority. +/// +/// APNs also expects a matching topic suffix for some types: `.voip` → `.voip`, +/// `.complication` → `.complication`, `.fileProvider` → `.pushkit.fileprovider`, +/// `.liveActivity` → `.push-type.liveactivity`, `.location` → `.location-query`, +/// `.pushToTalk` → `.voip-ptt`. The client checks the topic's syntax, not the suffix; +/// APNs answers a mismatch with `DeviceTokenNotForTopic` or `TopicDisallowed`. public enum APNSPushType: String, Sendable, CaseIterable { case alert case background @@ -13,34 +19,48 @@ public enum APNSPushType: String, Sendable, CaseIterable { case liveActivity = "liveactivity" case pushToTalk = "pushtotalk" - /// Default `apns-priority` for this type. Background must use 5; others use 10. - public var defaultPriority: Int { - self == .background ? 5 : 10 + /// Default `apns-priority` for this type: `.background` for background pushes + /// (APNs requires it), `.immediate` for everything else. + public var defaultPriority: APNSPriority { + self == .background ? .background : .immediate } } -/// Push notification priority. -public enum APNSPriority: Int, Sendable { - /// Immediate delivery (10). Use for user-visible alerts. +/// Push notification priority (`apns-priority`). +public enum APNSPriority: Int, Sendable, CaseIterable { + /// 10: deliver immediately. The default for everything but background pushes. case immediate = 10 - /// Power-friendly delivery (5). Required for background push. + /// 5: deliver based on the power considerations of the device. Required for + /// `APNSPushType.background`; also valid for alerts that can wait. case background = 5 + /// 1: prioritize the device's power considerations over all other factors, and + /// don't wake the device. + case low = 1 } -/// When to discard an undeliverable notification. -public enum APNSExpiration: Sendable { - /// Discard immediately if the device is not reachable. +/// When to discard an undeliverable notification (`apns-expiration`). +public enum APNSExpiration: Sendable, Equatable { + /// Discard immediately if the device is not reachable (`apns-expiration: 0`). case immediate - /// Keep until this many seconds from now. + /// Keep until this many seconds from now. Must not be negative. case relative(Int) - /// Keep until this absolute UTC epoch second. + /// Keep until this absolute UTC epoch second. Must not be negative. case absolute(Int) - var headerValue: Int { + /// The header value, or ``APNSClientError/invalidExpiration`` for a negative value + /// or a relative value that overflows. + func headerValue(now: Date) throws(APNSClientError) -> Int { switch self { - case .immediate: return 0 - case .relative(let s): return Int(Date().timeIntervalSince1970) + s - case .absolute(let t): return t + case .immediate: + return 0 + case .relative(let seconds): + guard seconds >= 0 else { throw .invalidExpiration } + let (sum, overflow) = Int(now.timeIntervalSince1970).addingReportingOverflow(seconds) + guard !overflow else { throw .invalidExpiration } + return sum + case .absolute(let epoch): + guard epoch >= 0 else { throw .invalidExpiration } + return epoch } } } diff --git a/Sources/PerfectNotifications/APNSResponse.swift b/Sources/PerfectNotifications/APNSResponse.swift index 4c7bcd0..d13d7d4 100644 --- a/Sources/PerfectNotifications/APNSResponse.swift +++ b/Sources/PerfectNotifications/APNSResponse.swift @@ -1,36 +1,98 @@ import Foundation /// The outcome of a single APNs send request. -public struct APNSResponse: Sendable { +public struct APNSResponse: Sendable, Equatable { /// HTTP status code returned by APNs (200 = success). public let statusCode: Int - /// The notification ID assigned by APNs (`apns-id` response header). + /// The notification ID (`apns-id` response header). public let apnsID: String? - /// Non-nil when APNs rejected the notification. + /// The `apns-unique-id` response header. APNs only sends it in the sandbox environment; + /// it identifies the notification in the Push Notifications Console delivery log. + public let apnsUniqueID: String? + /// Non-nil when APNs rejected the notification and said why. public let error: APNSResponseError? public var isSuccess: Bool { statusCode == 200 } } /// An error reason returned by the APNs gateway. -/// -/// Common reason strings: `BadDeviceToken`, `DeviceTokenNotForTopic`, `Unregistered`, -/// `ExpiredToken`, `PayloadTooLarge`, `InvalidProviderToken`, `ExpiredProviderToken`, -/// `TooManyRequests`, `InternalServerError`, `ServiceUnavailable`. public struct APNSResponseError: Sendable, Equatable, CustomStringConvertible { + /// The `reason` string, such as `BadDeviceToken`. See ``APNSErrorReason`` for the known values. public let reason: String - /// For `Unregistered`/`ExpiredToken` — epoch timestamp when the token became invalid. - public let timestamp: Int? + /// For HTTP 410 (`Unregistered`, `ExpiredToken`): the last time APNs confirmed that the + /// device token was no longer valid for the topic, in **milliseconds** since the epoch. + public let timestamp: Int64? + + /// ``reason`` as a known value, or `nil` for a reason this version doesn't know. + public var knownReason: APNSErrorReason? { APNSErrorReason(rawValue: reason) } + + /// ``timestamp`` as a `Date`. + public var invalidatedAt: Date? { + timestamp.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) } + } public var description: String { guard let ts = timestamp else { return reason } - return "\(reason) (invalidated at \(ts))" + return "\(reason) (invalidated at \(ts) ms)" } } +/// The `reason` values APNs documents (from Apple's "Handling notification responses from +/// APNs"), grouped by HTTP status. APNs may add reasons; ``APNSResponseError/reason`` +/// always carries the raw string. +public enum APNSErrorReason: String, Sendable, CaseIterable { + // 400 + case badCollapseID = "BadCollapseId" + case badDeviceToken = "BadDeviceToken" + case badExpirationDate = "BadExpirationDate" + case badMessageID = "BadMessageId" + case badPriority = "BadPriority" + case badTopic = "BadTopic" + case deviceTokenNotForTopic = "DeviceTokenNotForTopic" + case duplicateHeaders = "DuplicateHeaders" + case idleTimeout = "IdleTimeout" + case invalidPushType = "InvalidPushType" + case missingDeviceToken = "MissingDeviceToken" + case missingTopic = "MissingTopic" + case payloadEmpty = "PayloadEmpty" + case topicDisallowed = "TopicDisallowed" + // 403 + case badCertificate = "BadCertificate" + case badCertificateEnvironment = "BadCertificateEnvironment" + case expiredProviderToken = "ExpiredProviderToken" + case forbidden = "Forbidden" + case invalidProviderToken = "InvalidProviderToken" + case missingProviderToken = "MissingProviderToken" + case unrelatedKeyIDInToken = "UnrelatedKeyIdInToken" + case badEnvironmentKeyInToken = "BadEnvironmentKeyInToken" + // 404, 405 + case badPath = "BadPath" + case methodNotAllowed = "MethodNotAllowed" + // 410 + case expiredToken = "ExpiredToken" + case unregistered = "Unregistered" + // 413 + case payloadTooLarge = "PayloadTooLarge" + // 429 + case tooManyProviderTokenUpdates = "TooManyProviderTokenUpdates" + case tooManyRequests = "TooManyRequests" + // 500, 503 + case internalServerError = "InternalServerError" + case serviceUnavailable = "ServiceUnavailable" + case shutdown = "Shutdown" +} + +/// The per-token outcome of sending one notification to several device tokens. +public struct APNSBatchResult: Sendable { + public let deviceToken: String + /// The APNs response (which may itself be a rejection), or the client error that + /// stopped this token's request: an invalid token, a network failure or a timeout. + public let result: Result +} + // MARK: - Internal decoding struct APNSErrorBody: Decodable { let reason: String - let timestamp: Int? + let timestamp: Int64? } diff --git a/Sources/PerfectNotifications/APNSValidation.swift b/Sources/PerfectNotifications/APNSValidation.swift new file mode 100644 index 0000000..b105d5e --- /dev/null +++ b/Sources/PerfectNotifications/APNSValidation.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Client-side checks on everything that goes into the request line, the headers or +/// the JWT. APNs would reject most bad values itself, but an unchecked device token or +/// topic could change the request path or add headers, so they are refused before any +/// request is built. +enum APNSValidation { + + /// Device tokens are hex. They are 32 bytes (64 hex digits) today; Apple says not to + /// hard-code the length, so up to 100 bytes (200 hex digits) is accepted. + static let deviceTokenHexLength = 64...200 + + /// `apns-collapse-id` must not exceed 64 bytes. + static let maxCollapseIDBytes = 64 + + /// Payload limits: 4 KB for regular pushes, 5 KB for VoIP. + static func payloadLimit(for pushType: APNSPushType) -> Int { + pushType == .voip ? 5120 : 4096 + } + + static func deviceToken(_ token: String) throws(APNSClientError) { + let bytes = token.utf8 + guard deviceTokenHexLength.contains(bytes.count), bytes.count % 2 == 0, + bytes.allSatisfy(isHexDigit) else { + throw .invalidDeviceToken + } + } + + /// A bundle ID, optionally with an APNs suffix (`.voip`, `.complication`, + /// `.pushkit.fileprovider`, `.push-type.liveactivity`, `.voip-ptt`, `.location-query`), + /// or an MDM topic: dot-separated, non-empty segments of ASCII letters, digits and `-`. + static func topic(_ topic: String) throws(APNSClientError) { + let bytes = topic.utf8 + guard (1...255).contains(bytes.count), + bytes.allSatisfy({ isAlphanumeric($0) || $0 == UInt8(ascii: "-") || $0 == UInt8(ascii: ".") }), + !topic.split(separator: ".", omittingEmptySubsequences: false).contains(where: \.isEmpty) + else { + throw .invalidTopic + } + } + + /// At most 64 bytes of UTF-8, with no C0/C1 control characters, DEL, or U+2028/U+2029. + static func collapseID(_ id: String) throws(APNSClientError) { + guard !id.isEmpty, id.utf8.count <= maxCollapseIDBytes, !containsControlCharacters(id) else { + throw .invalidCollapseID + } + } + + /// Apple key IDs and team IDs are 10 ASCII letters or digits. + static func isTenCharacterID(_ s: String) -> Bool { + s.utf8.count == 10 && s.utf8.allSatisfy(isAlphanumeric) + } + + /// A host name or IPv4 address for ``APNSClient/Environment/custom(host:port:)``. + static func host(_ host: String, port: Int) throws(APNSClientError) { + let bytes = host.utf8 + guard (1...253).contains(bytes.count), (1...65535).contains(port), + bytes.allSatisfy({ isAlphanumeric($0) || $0 == UInt8(ascii: "-") || $0 == UInt8(ascii: ".") }), + !host.split(separator: ".", omittingEmptySubsequences: false).contains(where: \.isEmpty) + else { + throw .invalidHost + } + } + + static func containsControlCharacters(_ s: String) -> Bool { + s.unicodeScalars.contains { scalar in + let v = scalar.value + return v < 0x20 || (0x7F...0x9F).contains(v) || v == 0x2028 || v == 0x2029 + } + } + + private static func isHexDigit(_ b: UInt8) -> Bool { + (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(b) + || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(b) + || (UInt8(ascii: "A")...UInt8(ascii: "F")).contains(b) + } + + private static func isAlphanumeric(_ b: UInt8) -> Bool { + (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(b) + || (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(b) + || (UInt8(ascii: "A")...UInt8(ascii: "Z")).contains(b) + } +} diff --git a/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift new file mode 100644 index 0000000..f75eb82 --- /dev/null +++ b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift @@ -0,0 +1,274 @@ +import Testing +import Foundation +import Crypto +@testable import PerfectNotifications + +/// Payload encoding, JSON values, validation and JWT minting: no transport involved. +struct PayloadTests { + + static let invalidDeviceTokens: [String] = { + let a64 = String(repeating: "a", count: 64) + var list: [String] = ["", "abc123token"] + list.append(String(repeating: "a", count: 63)) + list.append(String(repeating: "a", count: 65)) + list.append(String(repeating: "a", count: 202)) + list.append("../../../3/device/" + a64) + list.append(a64 + "?x=1") + list.append(a64 + "#frag") + list.append(a64 + "/") + list.append("zz" + String(a64.dropFirst(2))) + list.append(a64 + " ") + list.append("\u{FF21}\u{FF22}" + String(a64.dropFirst(2))) + list.append(a64 + "\r\nx: y") + list.append(String(repeating: "\u{0661}", count: 64)) + return list + }() + + // MARK: Push type and priority + + @Test func pushTypeRawValues() { + #expect(APNSPushType.alert.rawValue == "alert") + #expect(APNSPushType.background.rawValue == "background") + #expect(APNSPushType.liveActivity.rawValue == "liveactivity") + #expect(APNSPushType.pushToTalk.rawValue == "pushtotalk") + #expect(APNSPushType.fileProvider.rawValue == "fileprovider") + } + + @Test func defaultPriorities() { + #expect(APNSPushType.background.defaultPriority == .background) + for type in APNSPushType.allCases where type != .background { + #expect(type.defaultPriority == .immediate) + } + #expect(APNSPriority.allCases.map(\.rawValue).sorted() == [1, 5, 10]) + } + + // MARK: Payload encoding + + @Test func simpleAlertEncodesToExpectedJSON() throws { + let data = try APNSNotificationItem.encodePayload([ + .alertTitle("Hello"), .alertBody("World"), .badge(3), .sound("default") + ]) + #expect(String(decoding: data, as: UTF8.self) + == #"{"aps":{"alert":{"body":"World","title":"Hello"},"badge":3,"sound":"default"}}"#) + } + + @Test func bodyOnlyAlertUsesStringNotDict() throws { + let data = try APNSNotificationItem.encodePayload([.alertBody("Just a body")]) + #expect(String(decoding: data, as: UTF8.self) == #"{"aps":{"alert":"Just a body"}}"#) + } + + @Test func contentAvailableAndMutableContentEncodeAsOne() throws { + let aps = try json(APNSNotificationItem.encodePayload([.contentAvailable, .mutableContent]))["aps"] as? [String: Any] + #expect(aps?["content-available"] as? Int == 1) + #expect(aps?["mutable-content"] as? Int == 1) + } + + @Test func subtitleLocAndInterruptionLevelEncodeCorrectly() throws { + let data = try APNSNotificationItem.encodePayload([ + .alertTitle("Title"), .alertSubtitle("Subtitle"), + .alertLoc("KEY", ["a", "b"]), .interruptionLevel(.timeSensitive), .relevanceScore(0.8) + ]) + let aps = try #require(try json(data)["aps"] as? [String: Any]) + let alert = try #require(aps["alert"] as? [String: Any]) + #expect(alert["subtitle"] as? String == "Subtitle") + #expect(alert["loc-args"] as? [String] == ["a", "b"]) + #expect(aps["interruption-level"] as? String == "time-sensitive") + #expect(aps["relevance-score"] as? Double == 0.8) + } + + @Test func customPayloadLiteralsAppearAtTopLevel() throws { + let data = try APNSNotificationItem.encodePayload([ + .alertBody("msg"), + .customPayload("orderId", "ORD-123"), + .customPayload("amount", 49), + .customPayload("ratio", 0.5), + .customPayload("refresh", true), + .customPayload("none", nil), + .customPayload("nested", ["ids": [1, 2], "ok": false]), + ]) + #expect(String(decoding: data, as: UTF8.self) == + #"{"amount":49,"aps":{"alert":"msg"},"nested":{"ids":[1,2],"ok":false},"none":null,"orderId":"ORD-123","ratio":0.5,"refresh":true}"#) + } + + @Test func customPayloadCannotReplaceAps() { + #expect(throws: APNSClientError.reservedPayloadKey("aps")) { + try APNSNotification(items: [.alertBody("x"), .customPayload("aps", ["alert": "spoof"])]) + } + } + + @Test func nonFiniteNumbersAreRefused() { + #expect(throws: (any Error).self) { try APNSNotification(items: [.relevanceScore(.nan)]) } + #expect(throws: (any Error).self) { try APNSNotification(items: [.customPayload("x", .double(.infinity))]) } + } + + @Test func notificationItemsAreSendableValues() { + // Compile-time check: the 5.x `@unchecked Sendable` is gone. + func requireSendable(_: T) {} + requireSendable(APNSNotificationItem.customPayload("k", ["a": [1, "two", nil]])) + #expect(APNSNotificationItem.customPayload("k", 1) == .customPayload("k", .int(1))) + } + + // MARK: APNSJSONValue + + @Test func jsonObjectConversionFromJSONSerialization() throws { + let parsed = try JSONSerialization.jsonObject(with: Data( + #"{"b":true,"f":false,"i":42,"d":1.5,"s":"x","n":null,"a":[1,"y"],"o":{"k":0}}"#.utf8)) + let value = try APNSJSONValue(jsonObject: parsed) + #expect(value == [ + "b": true, "f": false, "i": 42, "d": 1.5, "s": "x", "n": nil, "a": [1, "y"], "o": ["k": 0], + ]) + } + + @Test func jsonObjectConversionFromSwiftValues() throws { + let dict: [String: Any] = ["i": 1, "b": true, "d": 2.25, "s": "str", "arr": [1, 2] as [Int], + "opt": Optional.none as Any, "i64": Int64(7)] + #expect(try APNSJSONValue(jsonObject: dict) == [ + "i": 1, "b": true, "d": 2.25, "s": "str", "arr": [1, 2], "opt": nil, "i64": 7, + ]) + } + + @Test func jsonObjectConversionRefusesNonJSON() { + #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: Date()) } + #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: [1: "int key"]) } + #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: Double.nan) } + } + + @Test func jsonValueRoundTrips() throws { + let value: APNSJSONValue = ["a": [1, 2.5, "s", true, nil], "o": ["x": ["y": false]]] + let decoded = try JSONDecoder().decode(APNSJSONValue.self, from: JSONEncoder().encode(value)) + #expect(decoded == value) + } + + // MARK: Validation + + @Test(arguments: invalidDeviceTokens) + func invalidDeviceTokensAreRefused(_ token: String) { + #expect(throws: APNSClientError.invalidDeviceToken) { try APNSValidation.deviceToken(token) } + } + + @Test func validDeviceTokens() throws { + try APNSValidation.deviceToken(token64) + try APNSValidation.deviceToken(token64.uppercased()) + try APNSValidation.deviceToken(String(repeating: "0f", count: 100)) + } + + @Test(arguments: [ + "com.example.App", "com.example.App.voip", "com.example.App.complication", + "com.example.App.pushkit.fileprovider", "com.example.App.push-type.liveactivity", + "com.example.App.voip-ptt", "com.example.App.location-query", + "com.apple.mgmt.External.7F3B4D8E-1111-2222-3333-444455556666", "App", + ]) + func validTopics(_ topic: String) throws { + try APNSValidation.topic(topic) + } + + @Test(arguments: [ + "", "com.example.App\r\napns-priority: 10", "com.example.App\n", "com example", "com..example", + ".com.example", "com.example.", "com/example", "com.exämple", "com.example_app", "a256", + ] as [String]) + func invalidTopicsAreRefused(_ topic: String) { + let topic = topic == "a256" ? String(repeating: "a", count: 256) : topic + #expect(throws: APNSClientError.invalidTopic) { try APNSValidation.topic(topic) } + } + + @Test func collapseIDLimits() throws { + try APNSValidation.collapseID(String(repeating: "x", count: 64)) + try APNSValidation.collapseID(String(repeating: "é", count: 32)) // 64 bytes + #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(String(repeating: "x", count: 65)) } + #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(String(repeating: "é", count: 33)) } + for bad in ["", "a\r\nb: c", "a\nb", "a\u{0}b", "a\u{7F}", "a\u{85}", "a\u{2028}b", "tab\there"] { + #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(bad) } + } + } + + @Test func expirationHeaderValues() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + #expect(try APNSExpiration.immediate.headerValue(now: now) == 0) + #expect(try APNSExpiration.relative(3600).headerValue(now: now) == 1_800_003_600) + #expect(try APNSExpiration.absolute(1_900_000_000).headerValue(now: now) == 1_900_000_000) + #expect(throws: APNSClientError.invalidExpiration) { try APNSExpiration.relative(.max).headerValue(now: now) } + #expect(throws: APNSClientError.invalidExpiration) { try APNSExpiration.relative(-1).headerValue(now: now) } + #expect(throws: APNSClientError.invalidExpiration) { try APNSExpiration.absolute(-5).headerValue(now: now) } + } + + // MARK: JWT + + @Test func jwtHasExactHeaderAndClaimsAndAValidSignature() throws { + let key = P256.Signing.PrivateKey() + let clock = TestClock() + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: key, now: { clock.now }) + let jwt = try decodeJWT(creds.token(), publicKey: key.publicKey) + #expect(jwt.headerJSON == #"{"alg":"ES256","kid":"ABCDE12345"}"#) + #expect(jwt.claimsJSON == #"{"iat":1800000000,"iss":"TEAM123456"}"#) + #expect(jwt.signatureValid) + // And not with another key. + #expect(try !decodeJWT(creds.token(), publicKey: P256.Signing.PrivateKey().publicKey).signatureValid) + } + + @Test func jwtPartsAreUnpaddedBase64URL() throws { + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey()) + let parts = try creds.token().split(separator: ".", omittingEmptySubsequences: false) + #expect(parts.count == 3) + for part in parts { + #expect(!part.isEmpty) + #expect(!part.contains(where: { "+/=".contains($0) })) + } + } + + @Test(arguments: [ + "ABCDE1234", "ABCDE123456", #"ABCD","x":"1"#, "ABCDE1234\"", "ABCDE 1234", "ABCDÉ12345", "", + ]) + func malformedKeyAndTeamIDsAreRefused(_ id: String) { + let key = P256.Signing.PrivateKey() + #expect(throws: APNSClientError.invalidKeyID) { try APNSCredentials(keyID: id, teamID: teamID, key: key) } + #expect(throws: APNSClientError.invalidTeamID) { try APNSCredentials(keyID: keyID, teamID: id, key: key) } + #expect(throws: APNSClientError.invalidKeyID) { + try APNSClient(keyID: id, teamID: teamID, pemString: key.pemRepresentation) + } + } + + @Test func tokenIsCachedThenRefreshedAtFiftyMinutes() throws { + let clock = TestClock() + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey(), + now: { clock.now }) + let first = try creds.token() + clock.advance(minutes: 49.9) + #expect(try creds.token() == first) + clock.advance(minutes: 0.2) + let second = try creds.token() + #expect(second != first) + #expect(try creds.token() == second) + } + + @Test func rejectedTokenIsReplacedOnlyAfterTwentyMinutes() throws { + let clock = TestClock() + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey(), + now: { clock.now }) + let first = try creds.token() + clock.advance(minutes: 5) + #expect(creds.reject(first) == false) // too soon to mint again + #expect(try creds.token() == first) + clock.advance(minutes: 15) // 20 minutes old + #expect(creds.reject(first) == true) + let second = try creds.token() + #expect(second != first) + #expect(creds.reject(first) == true) // stale token: already replaced + #expect(try creds.token() == second) + } + + // MARK: Redaction + + @Test func credentialsNeverPrintTheKey() throws { + let key = P256.Signing.PrivateKey() + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: key) + let token = try creds.token() + var dumped = "" + dump(creds, to: &dumped) + for text in ["\(creds)", String(reflecting: creds), dumped] { + #expect(!text.contains(token)) + #expect(!text.contains(String(token.split(separator: ".")[2]))) + #expect(!text.contains(key.rawRepresentation.base64EncodedString())) + #expect(text.contains("")) + } + } +} diff --git a/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift b/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift index ae173db..207f853 100644 --- a/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift +++ b/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift @@ -1,384 +1,331 @@ import Testing import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Crypto @testable import PerfectNotifications -// MARK: - URLProtocol mock +/// Blocks the calling thread, ignoring cancellation. +private func blockThread(seconds: TimeInterval) { Thread.sleep(forTimeInterval: seconds) } -final class MockURLProtocol: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) -> (HTTPURLResponse, Data))? - nonisolated(unsafe) static var lastRequest: URLRequest? - nonisolated(unsafe) static var lastBody: Data? +/// Request shape, response parsing, retries, timeouts and redaction, through a +/// URLProtocol stub. Serialized: the stub's state is shared. +@Suite(.serialized) +struct ClientTests { - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + // MARK: Request shape - override func startLoading() { - Self.lastBody = Self.readBody(from: request) - Self.lastRequest = request - guard let handler = Self.requestHandler else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } - let (response, data) = handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) + @Test func requestShape() async throws { + let key = P256.Signing.PrivateKey() + let client = try makeClient(key: key) { _ in ok() } + let notification = try APNSNotification(items: [.alertBody("test")]) + _ = try await client.send(notification, to: token64, topic: "com.example.App", + expiration: .absolute(1_900_000_000), collapseID: "order-42") + let req = try #require(MockURLProtocol.lastRequest) + #expect(req.url?.absoluteString == "https://api.sandbox.push.apple.com/3/device/\(token64)") + #expect(req.method == "POST") + #expect(req.body == notification.payload) + #expect(req.header("content-type") == "application/json") + #expect(req.header("apns-topic") == "com.example.App") + #expect(req.header("apns-push-type") == "alert") + #expect(req.header("apns-priority") == "10") + #expect(req.header("apns-expiration") == "1900000000") + #expect(req.header("apns-collapse-id") == "order-42") + let jwt = try decodeJWT(try #require(bearer(req)), publicKey: key.publicKey) + #expect(jwt.signatureValid) + #expect(jwt.header["kid"] as? String == keyID) + #expect(jwt.claims["iss"] as? String == teamID) } - override func stopLoading() {} - - static func readBody(from request: URLRequest) -> Data? { - if let b = request.httpBody { return b } - guard let stream = request.httpBodyStream else { return nil } - stream.open(); defer { stream.close() } - var data = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - while stream.hasBytesAvailable { - let n = stream.read(&buffer, maxLength: buffer.count) - if n <= 0 { break } - data.append(buffer, count: n) - } - return data + @Test func optionalHeadersAreOmittedByDefault() async throws { + let client = try makeClient { _ in ok() } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + let req = try #require(MockURLProtocol.lastRequest) + #expect(req.header("apns-expiration") == nil) + #expect(req.header("apns-collapse-id") == nil) } -} - -// MARK: - Helpers - -private func makeClient( - handler: @escaping @Sendable (URLRequest) -> (HTTPURLResponse, Data) -) throws -> APNSClient { - MockURLProtocol.lastRequest = nil - MockURLProtocol.requestHandler = handler - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [MockURLProtocol.self] - let session = URLSession(configuration: config) - let key = P256.Signing.PrivateKey() - return try APNSClient(keyID: "TESTKEY1234", teamID: "TEAMID1234", - pemString: key.pemRepresentation, - environment: .sandbox, urlSession: session) -} -private func okResponse(_ body: String = "{}") -> @Sendable (URLRequest) -> (HTTPURLResponse, Data) { - { req in - let apnsID = "test-apns-id-\(UUID().uuidString)" - let http = HTTPURLResponse( - url: req.url!, statusCode: 200, httpVersion: nil, - headerFields: ["apns-id": apnsID] - )! - return (http, Data(body.utf8)) - } -} + @Test func productionAndCustomHosts() async throws { + var client = try makeClient(environment: .production) { _ in ok() } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(MockURLProtocol.lastRequest?.url?.absoluteString == "https://api.push.apple.com/3/device/\(token64)") -private func errorResponse(statusCode: Int, reason: String) -> @Sendable (URLRequest) -> (HTTPURLResponse, Data) { - { req in - let http = HTTPURLResponse(url: req.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! - let body = "{\"reason\":\"\(reason)\"}" - return (http, Data(body.utf8)) - } -} + client = try makeClient(environment: .custom(host: "localhost", port: 8443)) { _ in ok() } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(MockURLProtocol.lastRequest?.url?.absoluteString == "https://localhost:8443/3/device/\(token64)") -private func unregisteredResponse(at timestamp: Int) -> @Sendable (URLRequest) -> (HTTPURLResponse, Data) { - { req in - let http = HTTPURLResponse(url: req.url!, statusCode: 410, httpVersion: nil, headerFields: nil)! - let body = "{\"reason\":\"Unregistered\",\"timestamp\":\(timestamp)}" - return (http, Data(body.utf8)) + for bad: APNSClient.Environment in [.custom(host: "evil.test/x?", port: 443), .custom(host: "a@b", port: 443), + .custom(host: "localhost", port: 0), .custom(host: "", port: 443)] { + #expect(throws: APNSClientError.invalidHost) { try makeClient(environment: bad) { _ in ok() } } + } } -} - -// MARK: - Tests - -@Suite(.serialized) -struct PerfectNotificationsTests { - // MARK: Push type - - @Test func pushTypeRawValues() { - #expect(APNSPushType.alert.rawValue == "alert") - #expect(APNSPushType.background.rawValue == "background") - #expect(APNSPushType.liveActivity.rawValue == "liveactivity") - #expect(APNSPushType.pushToTalk.rawValue == "pushtotalk") - #expect(APNSPushType.fileProvider.rawValue == "fileprovider") + @Test func backgroundPushUsesPriority5AndRefusesOthers() async throws { + let client = try makeClient { _ in ok() } + _ = try await client.sendBackground(to: token64, topic: "com.example.App", + customData: [.customPayload("refresh", true)]) + let req = try #require(MockURLProtocol.lastRequest) + #expect(req.header("apns-push-type") == "background") + #expect(req.header("apns-priority") == "5") + let body = try json(req.body) + #expect((body["aps"] as? [String: Any])?["content-available"] as? Int == 1) + #expect(body["refresh"] as? Bool == true) + + for priority in [APNSPriority.immediate, .low] { + await #expect(throws: APNSClientError.invalidPriority(priority, pushType: .background)) { + try await client.send(APNSNotification(items: [.contentAvailable]), to: token64, + topic: "com.example.App", pushType: .background, priority: priority) + } + } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, + topic: "com.example.App", priority: .low) + #expect(MockURLProtocol.lastRequest?.header("apns-priority") == "1") } - @Test func backgroundPushTypeHasPriority5() { - #expect(APNSPushType.background.defaultPriority == 5) - #expect(APNSPushType.alert.defaultPriority == 10) - #expect(APNSPushType.voip.defaultPriority == 10) - #expect(APNSPushType.liveActivity.defaultPriority == 10) - } + // MARK: Input validation never reaches the transport - // MARK: Payload encoding - - @Test func simpleAlertEncodesToExpectedJSON() throws { - let data = try APNSNotificationItem.encodePayload([ - .alertTitle("Hello"), - .alertBody("World"), - .badge(3), - .sound("default") - ]) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - let aps = json["aps"] as! [String: Any] - let alert = aps["alert"] as! [String: Any] - #expect(alert["title"] as? String == "Hello") - #expect(alert["body"] as? String == "World") - #expect(aps["badge"] as? Int == 3) - #expect(aps["sound"] as? String == "default") + @Test func invalidInputThrowsBeforeAnyRequest() async throws { + let client = try makeClient { _ in ok() } + let n = try APNSNotification(items: [.alertBody("x")]) + await #expect(throws: APNSClientError.invalidDeviceToken) { + try await client.send(n, to: "../../../3/device/" + token64 + "?", topic: "com.example.App") + } + await #expect(throws: APNSClientError.invalidTopic) { + try await client.send(n, to: token64, topic: "com.example.App\r\nx-injected: 1") + } + await #expect(throws: APNSClientError.invalidCollapseID) { + try await client.send(n, to: token64, topic: "com.example.App", collapseID: "a\r\nb: c") + } + await #expect(throws: APNSClientError.invalidExpiration) { + try await client.send(n, to: token64, topic: "com.example.App", expiration: .relative(.max)) + } + #expect(MockURLProtocol.requests.isEmpty) } - @Test func bodyOnlyAlertUsesStringNotDict() throws { - let data = try APNSNotificationItem.encodePayload([.alertBody("Just a body")]) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - let aps = json["aps"] as! [String: Any] - #expect(aps["alert"] as? String == "Just a body") + @Test func payloadSizeLimits() async throws { + let client = try makeClient { _ in ok() } + func payload(_ size: Int) -> APNSNotification { + let filler = String(repeating: "x", count: size - #"{"aps":{"alert":""}}"#.utf8.count) + return APNSNotification(raw: Data(#"{"aps":{"alert":"\#(filler)"}}"#.utf8)) + } + #expect(payload(4096).payload.count == 4096) + _ = try await client.send(payload(4096), to: token64, topic: "com.example.App") + await #expect(throws: APNSClientError.payloadTooLarge(size: 4097, limit: 4096)) { + try await client.send(payload(4097), to: token64, topic: "com.example.App") + } + _ = try await client.send(payload(5120), to: token64, topic: "com.example.App.voip", pushType: .voip) + await #expect(throws: APNSClientError.payloadTooLarge(size: 5121, limit: 5120)) { + try await client.send(payload(5121), to: token64, topic: "com.example.App.voip", pushType: .voip) + } + #expect(MockURLProtocol.requests.count == 2) } - @Test func contentAvailableAndMutableContentEncodeAsOne() throws { - let data = try APNSNotificationItem.encodePayload([.contentAvailable, .mutableContent]) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - let aps = json["aps"] as! [String: Any] - #expect(aps["content-available"] as? Int == 1) - #expect(aps["mutable-content"] as? Int == 1) - } + // MARK: Response parsing - @Test func subtitleAndInterruptionLevelEncodeCorrectly() throws { - let data = try APNSNotificationItem.encodePayload([ - .alertTitle("Title"), - .alertSubtitle("Subtitle"), - .interruptionLevel(.timeSensitive), - .relevanceScore(0.8) - ]) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - let aps = json["aps"] as! [String: Any] - let alert = aps["alert"] as! [String: Any] - #expect(alert["subtitle"] as? String == "Subtitle") - #expect(aps["interruption-level"] as? String == "time-sensitive") - #expect(aps["relevance-score"] as? Double == 0.8) + @Test func successResponse() async throws { + let client = try makeClient { _ in ok(apnsID: "ID-1", uniqueID: "UNIQUE-1") } + let result = try await client.sendAlert(to: token64, topic: "com.example.App", title: "Hey", body: "World", badge: 2) + #expect(result == APNSResponse(statusCode: 200, apnsID: "ID-1", apnsUniqueID: "UNIQUE-1", error: nil)) + #expect(result.isSuccess) + let aps = try #require(try json(MockURLProtocol.lastRequest!.body)["aps"] as? [String: Any]) + #expect((aps["alert"] as? [String: Any])?["title"] as? String == "Hey") + #expect(aps["badge"] as? Int == 2) } - @Test func customPayloadAppearsAtTopLevel() throws { - let data = try APNSNotificationItem.encodePayload([ - .alertBody("msg"), - .customPayload("orderId", "ORD-123"), - .customPayload("amount", 49) - ]) - let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] - #expect(json["orderId"] as? String == "ORD-123") - #expect(json["amount"] as? Int == 49) + @Test func rejectionParsesReason() async throws { + let client = try makeClient { _ in rejected(400, "BadDeviceToken") } + let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(!result.isSuccess) + #expect(result.statusCode == 400) + #expect(result.error?.reason == "BadDeviceToken") + #expect(result.error?.knownReason == .badDeviceToken) + #expect(result.apnsID != nil) } - // MARK: JWT structure - - @Test func jwtHasThreeBase64UrlParts() throws { - let key = P256.Signing.PrivateKey() - let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key) - let token = try creds.token() - let parts = token.split(separator: ".", omittingEmptySubsequences: false) - #expect(parts.count == 3) - // Each part should be non-empty and URL-safe base64 (no padding, no +/) - for part in parts { - #expect(!part.isEmpty) - #expect(!part.contains("+")) - #expect(!part.contains("/")) - #expect(!part.contains("=")) - } + @Test func unregisteredTimestampIsMilliseconds() async throws { + let ms: Int64 = 1_700_000_000_123 + let client = try makeClient { _ in rejected(410, "Unregistered", timestamp: ms) } + let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(result.statusCode == 410) + #expect(result.error?.knownReason == .unregistered) + #expect(result.error?.timestamp == ms) + #expect(result.error?.invalidatedAt == Date(timeIntervalSince1970: 1_700_000_000.123)) } - @Test func jwtHeaderContainsKidAndAlg() throws { - let key = P256.Signing.PrivateKey() - let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key) - let token = try creds.token() - let headerB64 = String(token.split(separator: ".").first!) - // Pad for standard base64 decoding - let padded = headerB64 + String(repeating: "=", count: (4 - headerB64.count % 4) % 4) - let urlDecoded = padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - let data = Data(base64Encoded: urlDecoded)! - let header = try JSONSerialization.jsonObject(with: data) as! [String: Any] - #expect(header["alg"] as? String == "ES256") - #expect(header["kid"] as? String == "KEYID12345") - } + @Test func unknownReasonAndNonJSONBodies() async throws { + var client = try makeClient { _ in rejected(400, "SomethingNew") } + var result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(result.error?.reason == "SomethingNew") + #expect(result.error?.knownReason == nil) - @Test func jwtPayloadContainsIssAndIat() throws { - let key = P256.Signing.PrivateKey() - let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key) - let beforeMint = Int(Date().timeIntervalSince1970) - let token = try creds.token() - let afterMint = Int(Date().timeIntervalSince1970) - - let payloadB64 = String(token.split(separator: ".")[1]) - let padded = payloadB64 + String(repeating: "=", count: (4 - payloadB64.count % 4) % 4) - let urlDecoded = padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - let data = Data(base64Encoded: urlDecoded)! - let payload = try JSONSerialization.jsonObject(with: data) as! [String: Any] - #expect(payload["iss"] as? String == "TEAMID1234") - let iat = payload["iat"] as! Int - #expect(iat >= beforeMint) - #expect(iat <= afterMint) + client = try makeClient { _ in .respond(status: 502, headers: [:], body: Data("bad gateway".utf8)) } + result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(result.statusCode == 502) + #expect(result.error == nil) } - @Test func jwtIsCachedOnSecondCall() throws { - let key = P256.Signing.PrivateKey() - let creds = APNSCredentials(keyID: "KEYID12345", teamID: "TEAMID1234", key: key) - let first = try creds.token() - let second = try creds.token() - #expect(first == second) + @Test func oversizedResponseBodyIsRefused() async throws { + let big = Data(repeating: UInt8(ascii: "x"), count: 64 * 1024) + let client = try makeClient { _ in .respond(status: 400, headers: [:], body: big) } + await #expect(throws: APNSClientError.responseTooLarge(limit: 16 * 1024)) { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + } } - // MARK: Request construction - - @Test func sendBuildsCorrectURL() async throws { - let client = try makeClient(handler: okResponse()) - _ = try await client.send( - try APNSNotification(items: [.alertBody("test")]), - to: "abc123token", - topic: "com.example.App" - ) - let url = MockURLProtocol.lastRequest?.url?.absoluteString - #expect(url == "https://api.sandbox.push.apple.com/3/device/abc123token") + @Test func networkFailureIsAClientError() async throws { + let client = try makeClient { _ in .fail(.cannotConnectToHost) } + do { + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + Issue.record("expected a network error") + } catch let APNSClientError.network(message) { + #expect(message.contains("\(URLError.Code.cannotConnectToHost.rawValue)")) + #expect(!message.contains("bearer")) + } } - @Test func sendIncludesRequiredHeaders() async throws { - let client = try makeClient(handler: okResponse()) - _ = try await client.send( - try APNSNotification(items: [.alertBody("test")]), - to: "tokenXYZ", - topic: "com.example.App", - pushType: .alert - ) - let req = MockURLProtocol.lastRequest! - #expect(req.value(forHTTPHeaderField: "apns-topic") == "com.example.App") - #expect(req.value(forHTTPHeaderField: "apns-push-type") == "alert") - #expect(req.value(forHTTPHeaderField: "apns-priority") == "10") - #expect(req.value(forHTTPHeaderField: "content-type") == "application/json") - let auth = req.value(forHTTPHeaderField: "authorization") ?? "" - #expect(auth.hasPrefix("bearer ")) - #expect(auth.split(separator: ".").count == 3) // valid JWT structure - } + // MARK: Timeouts and cancellation - @Test func backgroundPushUsesPriority5() async throws { - let client = try makeClient(handler: okResponse()) - _ = try await client.send( - try APNSNotification(items: [.contentAvailable]), - to: "token", - topic: "com.example.App", - pushType: .background - ) - #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-push-type") == "background") - #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-priority") == "5") + @Test func hangingRequestTimesOut() async throws { + let client = try makeClient(timeout: 0.5) { _ in .hang } + let start = Date() + await #expect(throws: APNSClientError.timedOut) { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + } + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed >= 0.4 && elapsed < 10) } - @Test func collapseIDHeaderIncludedWhenSet() async throws { - let client = try makeClient(handler: okResponse()) - _ = try await client.send( - try APNSNotification(items: [.alertBody("test")]), - to: "token", topic: "com.example.App", collapseID: "order-42" - ) - #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-collapse-id") == "order-42") + @Test func cancellationIsPrompt() async throws { + let client = try makeClient(timeout: 60) { _ in .hang } + let task = Task { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + } + try await Task.sleep(nanoseconds: 200_000_000) + let start = Date() + task.cancel() + await #expect(throws: CancellationError.self) { try await task.value } + #expect(Date().timeIntervalSince(start) < 5) } - @Test func productionEnvironmentUsesCorrectHost() async throws { - MockURLProtocol.requestHandler = okResponse() - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [MockURLProtocol.self] - let key = P256.Signing.PrivateKey() - let client = try APNSClient(keyID: "KID", teamID: "TID", - pemString: key.pemRepresentation, - environment: .production, - urlSession: URLSession(configuration: config)) - _ = try await client.send( - try APNSNotification(items: [.alertBody("test")]), - to: "token", topic: "com.example.App" - ) - let host = MockURLProtocol.lastRequest?.url?.host - #expect(host == "api.push.apple.com") + @Test func deadlineReturnsEvenIfTheOperationIgnoresCancellation() async throws { + let start = Date() + await #expect(throws: APNSClientError.timedOut) { + try await withDeadline(seconds: 0.2) { () async -> Int in + // Uncooperative work: does not check cancellation. + blockThread(seconds: 1.0) + return 1 + } + } + #expect(Date().timeIntervalSince(start) < 0.9) + #expect(try await withDeadline(seconds: 5) { 42 } == 42) } - // MARK: Response parsing + // MARK: Provider-token rejections - @Test func successResponseIsSuccess() async throws { - let client = try makeClient(handler: okResponse()) - let result = try await client.send( - try APNSNotification(items: [.alertBody("hi")]), - to: "token", topic: "com.example.App" - ) + @Test func expiredProviderTokenIsRetriedOnceWithAFreshToken() async throws { + let clock = TestClock() + let client = try makeClient(clock: clock) { i in i == 1 ? rejected(403, "ExpiredProviderToken") : ok() } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + clock.advance(minutes: 25) + let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") #expect(result.isSuccess) - #expect(result.statusCode == 200) - #expect(result.error == nil) - #expect(result.apnsID != nil) + let tokens = MockURLProtocol.requests.map(bearer) + #expect(tokens.count == 3) + #expect(tokens[0] == tokens[1]) + #expect(tokens[2] != tokens[1]) } - @Test func badDeviceTokenResponseParsesReason() async throws { - let client = try makeClient(handler: errorResponse(statusCode: 400, reason: "BadDeviceToken")) - let result = try await client.send( - try APNSNotification(items: [.alertBody("hi")]), - to: "badtoken", topic: "com.example.App" - ) - #expect(!result.isSuccess) - #expect(result.statusCode == 400) - #expect(result.error?.reason == "BadDeviceToken") + @Test func expiredProviderTokenIsNotRetriedWithinTwentyMinutes() async throws { + let clock = TestClock() + let client = try makeClient(clock: clock) { _ in rejected(403, "ExpiredProviderToken") } + clock.advance(minutes: 0) + let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(result.error?.knownReason == .expiredProviderToken) + #expect(MockURLProtocol.requests.count == 1) + // Still within 20 minutes: same token, no new mint. + clock.advance(minutes: 10) + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(MockURLProtocol.requests.count == 2) + #expect(bearer(MockURLProtocol.requests[0]) == bearer(MockURLProtocol.requests[1])) + // Past 20 minutes the rejected token is replaced on the next send. That fresh + // token is rejected too, and it can't be replaced yet, so there is no retry. + clock.advance(minutes: 11) + let third = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(third.error?.knownReason == .expiredProviderToken) + let tokens = MockURLProtocol.requests.map(bearer) + try #require(tokens.count == 3) + #expect(tokens[2] != tokens[1]) } - @Test func unregisteredResponseIncludesTimestamp() async throws { - let ts = 1_700_000_000 - let client = try makeClient(handler: unregisteredResponse(at: ts)) - let result = try await client.send( - try APNSNotification(items: [.alertBody("hi")]), - to: "oldtoken", topic: "com.example.App" - ) - #expect(result.statusCode == 410) - #expect(result.error?.reason == "Unregistered") - #expect(result.error?.timestamp == ts) + @Test func invalidProviderTokenInvalidatesWithoutRetry() async throws { + let clock = TestClock() + let client = try makeClient(clock: clock) { i in i == 0 ? rejected(403, "InvalidProviderToken") : ok() } + clock.advance(minutes: 0) + let first = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(first.error?.knownReason == .invalidProviderToken) + #expect(MockURLProtocol.requests.count == 1) + clock.advance(minutes: 21) // below the 50-minute refresh, but the token was rejected + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + let tokens = MockURLProtocol.requests.map(bearer) + #expect(tokens[1] != tokens[0]) } - // MARK: Convenience helpers - - @Test func sendAlertBuildsCorrectPayload() async throws { - let client = try makeClient(handler: okResponse()) - _ = try await client.sendAlert(to: "token", topic: "com.example.App", - title: "Hey", body: "World", badge: 2) - let body = MockURLProtocol.lastBody ?? Data() - let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] - let aps = json["aps"] as! [String: Any] - let alert = aps["alert"] as! [String: Any] - #expect(alert["title"] as? String == "Hey") - #expect(alert["body"] as? String == "World") - #expect(aps["badge"] as? Int == 2) - #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-push-type") == "alert") + @Test func tooManyProviderTokenUpdatesKeepsTheToken() async throws { + let clock = TestClock() + let client = try makeClient(clock: clock) { i in i == 0 ? rejected(429, "TooManyProviderTokenUpdates") : ok() } + let first = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(first.error?.knownReason == .tooManyProviderTokenUpdates) + clock.advance(minutes: 30) + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + let tokens = MockURLProtocol.requests.map(bearer) + #expect(tokens.count == 2) + #expect(tokens[0] == tokens[1]) } - @Test func sendBackgroundSetsPushTypeAndContentAvailable() async throws { - let client = try makeClient(handler: okResponse()) - _ = try await client.sendBackground(to: "token", topic: "com.example.App", - customData: [.customPayload("refresh", true)]) - let body = MockURLProtocol.lastBody ?? Data() - let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] - let aps = json["aps"] as! [String: Any] - #expect(aps["content-available"] as? Int == 1) - #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-push-type") == "background") - #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "apns-priority") == "5") + // MARK: Batch send + + @Test func batchCarriesPerTokenResults() async throws { + let client = try makeClient { i in i == 1 ? rejected(410, "Unregistered", timestamp: 1) : (i == 2 ? .fail(.networkConnectionLost) : ok()) } + let other = String(repeating: "cd", count: 32) + let third = String(repeating: "ef", count: 32) + let fourth = String(repeating: "01", count: 32) + let results = try await client.send(APNSNotification(items: [.alertBody("x")]), + to: [token64, "not-a-token", other, third, fourth], + topic: "com.example.App") + #expect(results.map(\.deviceToken) == [token64, "not-a-token", other, third, fourth]) + #expect(try results[0].result.get().isSuccess) + #expect(throws: APNSClientError.invalidDeviceToken) { try results[1].result.get() } + #expect(try results[2].result.get().error?.knownReason == .unregistered) + guard case .failure(.network) = results[3].result else { Issue.record("expected network failure"); return } + #expect(try results[4].result.get().isSuccess) + #expect(MockURLProtocol.requests.count == 4) } - // MARK: Live sandbox (gated) - - private var liveEnabled: Bool { - ProcessInfo.processInfo.environment["APNS_TESTS"] == "1" + @Test func batchSharedProblemsThrowBeforeSending() async throws { + let client = try makeClient { _ in ok() } + await #expect(throws: APNSClientError.invalidTopic) { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: [token64, token64], topic: "bad topic") + } + #expect(MockURLProtocol.requests.isEmpty) } - @Test func liveSandboxSendAlert() async throws { - guard liveEnabled, - let keyID = ProcessInfo.processInfo.environment["APNS_KEY_ID"], - let teamID = ProcessInfo.processInfo.environment["APNS_TEAM_ID"], - let keyPath = ProcessInfo.processInfo.environment["APNS_KEY_PATH"], - let token = ProcessInfo.processInfo.environment["APNS_DEVICE_TOKEN"], - let topic = ProcessInfo.processInfo.environment["APNS_TOPIC"] else { return } - - let client = try APNSClient(keyID: keyID, teamID: teamID, - privateKeyPath: keyPath, environment: .sandbox) - let result = try await client.sendAlert(to: token, topic: topic, - title: "Test", body: "Live sandbox test") - if !result.isSuccess { - Issue.record("sandbox push failed: \(result.error?.description ?? "HTTP \(result.statusCode)")") + // MARK: Redaction + + @Test func clientNeverPrintsTheKeyOrToken() async throws { + let key = P256.Signing.PrivateKey() + let client = try makeClient(key: key) { _ in ok() } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + let token = try #require(bearer(MockURLProtocol.lastRequest)) + var dumped = "" + dump(client, to: &dumped) + let mirrored = Mirror(reflecting: client).children.map { "\($0.label ?? ""): \($0.value)" }.joined(separator: "\n") + for text in ["\(client)", String(reflecting: client), dumped, mirrored] { + #expect(!text.contains(token)) + #expect(!text.contains(String(token.split(separator: ".")[2]))) + #expect(!text.contains(key.rawRepresentation.base64EncodedString())) + #expect(!text.contains("PRIVATE KEY")) + #expect(text.contains(keyID)) } - #expect(result.apnsID != nil) } } diff --git a/Tests/PerfectNotificationsTests/TestSupport.swift b/Tests/PerfectNotificationsTests/TestSupport.swift new file mode 100644 index 0000000..0972dc9 --- /dev/null +++ b/Tests/PerfectNotificationsTests/TestSupport.swift @@ -0,0 +1,170 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Crypto +@testable import PerfectNotifications + +// MARK: - URLProtocol stub +// +// Every request made through a session from `MockURLProtocol.session()` is answered here; +// nothing reaches the network. The suites using it are `.serialized`. + +enum MockReply: Sendable { + case respond(status: Int, headers: [String: String], body: Data) + case fail(URLError.Code) + /// Never answers (for timeout and cancellation tests). + case hang +} + +struct RecordedRequest: Sendable { + let url: URL? + let method: String? + let headers: [String: String] + let body: Data + func header(_ name: String) -> String? { + headers.first { $0.key.lowercased() == name.lowercased() }?.value + } +} + +final class MockURLProtocol: URLProtocol { + private static let lock = NSLock() + nonisolated(unsafe) private static var _handler: (@Sendable (Int) -> MockReply)? + nonisolated(unsafe) private static var _requests: [RecordedRequest] = [] + + static func reset(_ handler: @escaping @Sendable (_ callIndex: Int) -> MockReply) { + lock.withLock { _handler = handler; _requests = [] } + } + static var requests: [RecordedRequest] { lock.withLock { _requests } } + static var lastRequest: RecordedRequest? { requests.last } + + static func session() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let recorded = RecordedRequest(url: request.url, method: request.httpMethod, + headers: request.allHTTPHeaderFields ?? [:], + body: Self.readBody(from: request)) + let (index, handler) = Self.lock.withLock { () -> (Int, (@Sendable (Int) -> MockReply)?) in + Self._requests.append(recorded) + return (Self._requests.count - 1, Self._handler) + } + switch handler?(index) ?? .fail(.badServerResponse) { + case .respond(let status, let headers, let body): + let http = HTTPURLResponse(url: request.url!, statusCode: status, + httpVersion: "HTTP/2", headerFields: headers)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + case .fail(let code): + client?.urlProtocol(self, didFailWithError: URLError(code)) + case .hang: + break + } + } + + override func stopLoading() {} + + static func readBody(from request: URLRequest) -> Data { + if let b = request.httpBody { return b } + guard let stream = request.httpBodyStream else { return Data() } + stream.open(); defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + if n <= 0 { break } + data.append(buffer, count: n) + } + return data + } +} + +// MARK: - Replies + +func ok(apnsID: String = "8F1D2A6C-5B9E-4C3A-9F00-000000000001", + uniqueID: String? = nil) -> MockReply { + var headers = ["apns-id": apnsID] + if let uniqueID { headers["apns-unique-id"] = uniqueID } + return .respond(status: 200, headers: headers, body: Data()) +} + +func rejected(_ status: Int, _ reason: String, timestamp: Int64? = nil) -> MockReply { + let body = timestamp.map { "{\"reason\":\"\(reason)\",\"timestamp\":\($0)}" } ?? "{\"reason\":\"\(reason)\"}" + return .respond(status: status, headers: ["apns-id": "8F1D2A6C-5B9E-4C3A-9F00-000000000002"], + body: Data(body.utf8)) +} + +// MARK: - Fixtures + +/// A syntactically valid 32-byte device token. +let token64 = String(repeating: "ab", count: 32) +let keyID = "ABCDE12345" +let teamID = "TEAM123456" + +/// A settable clock for token-age tests. +final class TestClock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + init(_ start: Date = Date(timeIntervalSince1970: 1_800_000_000)) { current = start } + var now: Date { lock.withLock { current } } + func advance(minutes: Double) { lock.withLock { current += minutes * 60 } } +} + +func makeClient(key: P256.Signing.PrivateKey = P256.Signing.PrivateKey(), + clock: TestClock? = nil, + environment: APNSClient.Environment = .sandbox, + timeout: TimeInterval = APNSClient.defaultTimeout, + _ handler: @escaping @Sendable (Int) -> MockReply) throws -> APNSClient { + MockURLProtocol.reset(handler) + let now: @Sendable () -> Date + if let clock { now = { clock.now } } else { now = { Date() } } + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: key, now: now) + return try APNSClient(credentials: creds, environment: environment, + urlSession: MockURLProtocol.session(), timeout: timeout) +} + +// MARK: - JWT helpers + +func base64URLDecode(_ s: Substring) -> Data? { + var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + b64 += String(repeating: "=", count: (4 - b64.count % 4) % 4) + return Data(base64Encoded: b64) +} + +struct DecodedJWT { + let header: [String: Any] + let claims: [String: Any] + let headerJSON: String + let claimsJSON: String + let signatureValid: Bool +} + +func decodeJWT(_ jwt: String, publicKey: P256.Signing.PublicKey) throws -> DecodedJWT { + let parts = jwt.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, let h = base64URLDecode(parts[0]), let c = base64URLDecode(parts[1]), + let s = base64URLDecode(parts[2]) else { throw URLError(.cannotDecodeContentData) } + let signingInput = Data("\(parts[0]).\(parts[1])".utf8) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: s) + return DecodedJWT( + header: try JSONSerialization.jsonObject(with: h) as? [String: Any] ?? [:], + claims: try JSONSerialization.jsonObject(with: c) as? [String: Any] ?? [:], + headerJSON: String(decoding: h, as: UTF8.self), + claimsJSON: String(decoding: c, as: UTF8.self), + signatureValid: publicKey.isValidSignature(signature, for: signingInput)) +} + +func bearer(_ request: RecordedRequest?) -> String? { + guard let auth = request?.header("authorization"), auth.hasPrefix("bearer ") else { return nil } + return String(auth.dropFirst("bearer ".count)) +} + +func json(_ data: Data) throws -> [String: Any] { + try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] +} From 9321a2dc0a3c7f57f944360fdd07db6304336fd4 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 16:29:53 -0500 Subject: [PATCH 2/7] CI: Linux Swift 6.2/6.4 and macOS Xcode 26.0.1/26.6, debug and release Adapted from Perfect-CRUD's workflow: warnings as errors, contents: read, 30-minute timeouts, SWIFT_BACKTRACE=enable=no on Linux. No iOS job: an APNs provider is server-side and must not ship its signing key in an app. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89d2161 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + linux: + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) + runs-on: ubuntu-24.04 + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + env: + # Swift's backtracer adds minutes to a crashing test run in a container. + SWIFT_BACKTRACE: enable=no + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + # The tests answer every request with a URLProtocol stub; nothing reaches APNs. + - name: Test + run: swift test $FLAGS + + macos: + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS From 1461159af93e62bc72e56283d0e1b0544260800b Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 16:29:53 -0500 Subject: [PATCH 3/7] README for 6.0.0; remove README.zh_CN.md The README covers installation from 6.0.0, platforms, a quick start (checked by compiling it in a scratch package), sending options, provider tokens, security notes, the Linux caveats, testing, and migration from 5.x and from the pre-release main. The Chinese README documented the 5.x NotificationPusher API, which no longer exists; it stays on the legacy branch. Co-Authored-By: Claude Opus 5.5 --- README.md | 422 ++++++++++++++++++++++++------------------------ README.zh_CN.md | 207 ------------------------ 2 files changed, 212 insertions(+), 417 deletions(-) delete mode 100644 README.zh_CN.md diff --git a/README.md b/README.md index 99d6bb5..beeb9b3 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,254 @@ # Perfect-Notifications

- Swift 6.2 - Platforms macOS 12+ + Swift 6.2+ + Platforms macOS 12+ | Linux License Apache

-APNs remote Notifications for Perfect. This package adds push notification support to your server. Send notifications to iOS/macOS devices. +An Apple Push Notification service (APNs) provider client for Swift servers. It sends +notifications over the APNs HTTP/2 provider API with token-based (`.p8` key, ES256 JWT) +authentication, using Foundation's `URLSession`. The only dependency is +[apple/swift-crypto](https://github.com/apple/swift-crypto), for the JWT signature. -**A from-scratch Swift 6 rewrite** of the original package. The public API (`APNSClient`) is a -completely different, modern async/await surface — it does not use the original -`NotificationPusher` class. No dependency on NIO, PerfectHTTP, or any other package here — -networking runs over Foundation's `URLSession` async API, with -[apple/swift-crypto](https://github.com/apple/swift-crypto) as the only external dependency, used -to sign ES256 APNs provider JWTs. +6.0.0 is a Swift 6 rewrite with a new async API. The 5.x code (`NotificationPusher`, on +Perfect-Net) is on the [`legacy`](../../tree/legacy) branch; see [Migrating from 5.x](#migrating-from-5x). -The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. +## Installation -Building --------- - -This is a Swift Package Manager based project targeting **Swift 6.2** (strict concurrency / -`.swiftLanguageMode(.v6)`), macOS 12+. No other platform is currently declared in `Package.swift` -(Linux support has not been verified). - -Add this repository as a dependency in your `Package.swift` file: - -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.package(url: "https://github.com/PerfectlySoft/Perfect-Notifications.git", branch: "main") -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Overview --------- - -This system runs on the server side. Typically at app launch, an Apple device will register with Apple's system for remote notifications. Doing so will return to the device an ID which can be used by external systems to address the device and send notifications through APNs. - -When the device obtains its ID it will need to transmit it to **your** server. Your server will store this id and use it when sending notifications to one or more devices through APNs. - -The client itself, `APNSClient`, is a `Sendable` struct with `async throws` send methods — there is no callback-based API and no separate "configuration registry" step; you construct one client per set of APNs credentials and call `send`/`sendAlert`/`sendBackground` directly. - -Obtain APNs Auth Key --------- - -To connect your server to Apple's push notification system you will first need to obtain an "APNs Auth Key". This key is used on your server to configure its APNs access. You can generate this key through your Apple developer account portal. Log in to your developer account and choose "Certificates, IDs & Profiles" from the menu. Then, under "Keys", choose "All". - -If you haven't already created and downloaded the auth key, click "+" to create a new one. Enter a name for the key and make sure you select **Apple Push Notifications service (APNs)**. This one key can be used for both development or production and can be used for any of your iOS/macOS apps. - -Click "Continue", then "Confirm", then you will be given a chance to download the **private key**. You must download this key now and **save the file**. Also copy the "Key ID" shown in the same view. This will be a 10 character string. +```swift +.package(url: "https://github.com/PerfectlySoft/Perfect-Notifications.git", from: "6.0.0"), +``` -Finally you will need to locate your developer team id. Click "Account" near the window's top. Select "Membership" in the menu. You will then be shown much of your personal information, including "Team ID". This is another 10 character string. Copy this value. +and add `.product(name: "PerfectNotifications", package: "Perfect-Notifications")` to your target. -Server Configuration ------- +## Requirements -To send notifications from your server your must have four pieces of information: +- Swift 6.2 or later (`swift-tools-version: 6.2`, Swift 6 language mode). +- macOS 12 or later, or Linux. CI covers Swift 6.2 and 6.4 on Ubuntu 24.04 and Xcode 26.0.1 + and 26.6 on macOS, debug and release, with warnings as errors. +- On Linux, `URLSession` comes from `FoundationNetworking`, which uses the system libcurl. + APNs needs HTTP/2, so libcurl must be built with nghttp2. Ubuntu's `libcurl4` is, and the + official `swift:*-noble` images have it. +- swift-crypto `4.0.0..<5.0.0`. -1. The private key (`.p8`) file which was downloaded, or its PEM contents -2. The 10 character key id -3. Your 10 character team id -4. An iOS/macOS app id ("topic") +iOS, tvOS and watchOS aren't supported targets: a provider runs on a server, and must not ship +its signing key inside an app. -Unlike the original Perfect-era API, there is no separate "add a named configuration, then look it up later" step — you construct an `APNSClient` directly with the key material, and it internally mints and caches (auto-rotating every 55 minutes, before Apple's 60-minute expiry) the ES256 JWT provider token via `APNSCredentials` and swift-crypto. +## Quick start ```swift import PerfectNotifications -let apnsKeyIdentifier = "AB90CD56XY" -let apnsTeamIdentifier = "YX65DC09BA" -let apnsPrivateKeyFilePath = "./APNsAuthKey_AB90CD56XY.p8" -let topic = "my.app.id" - let client = try APNSClient( - keyID: apnsKeyIdentifier, - teamID: apnsTeamIdentifier, - privateKeyPath: apnsPrivateKeyFilePath, - environment: .sandbox // .production for release builds + keyID: "ABCDE12345", // from the .p8 file name + teamID: "TEAM123456", // Apple Developer → Membership + privateKeyPath: "/etc/myapp/AuthKey_ABCDE12345.p8", + environment: .sandbox // .production for App Store / TestFlight builds ) let notification = try APNSNotification(items: [ - .alertTitle("Hello!"), - .sound("default") + .alertTitle("Order ready"), + .alertBody("Your order is ready for pickup."), + .sound("default"), + .badge(1), + .customPayload("orderID", "ORD-123"), ]) -let response = try await client.send(notification, to: deviceToken, topic: topic) +let response = try await client.send(notification, + to: deviceToken, // hex string from the device + topic: "com.example.App") // your bundle ID if response.isSuccess { - print("sent — apns-id: \(response.apnsID ?? "?")") -} else if let err = response.error { - print("rejected: \(err.reason)") + print("sent, apns-id \(response.apnsID ?? "?")") +} else if let error = response.error { + switch error.knownReason { + case .unregistered?, .badDeviceToken?: + // Stop sending to this token. + print("drop token (invalid since \(error.invalidatedAt.map { "\($0)" } ?? "?"))") + default: + print("rejected: \(error.reason)") + } } ``` -`APNSCredentials` can also be supplied a PEM string directly (`APNSClient(keyID:teamID:pemString:environment:)`) instead of a file path — useful when the key is loaded from an environment variable or secrets store. +Create one client per signing key and keep it: `URLSession` keeps the HTTP/2 connection to APNs +open and multiplexes concurrent sends over it, as Apple asks. `APNSClient` is a `Sendable` +struct, so it can be shared across tasks; copies share the cached provider token. -Public API ----- +`APNSClient(keyID:teamID:pemString:)` takes the key's PEM text instead of a path, for keys +loaded from a secrets store. -The full public API surface follows: +### Getting the key -```swift -public struct APNSClient: Sendable { - public enum Environment: Sendable { case sandbox, production } - - /// Load the signing key from a `.p8` file on disk. - public init(keyID: String, teamID: String, privateKeyPath: String, - environment: Environment = .sandbox, - urlSession: URLSession = .shared) throws - - /// Supply the `.p8` key as a PEM string. - public init(keyID: String, teamID: String, pemString: String, - environment: Environment = .sandbox, - urlSession: URLSession = .shared) throws - - /// Send a notification to a single device token. - public func send( - _ notification: APNSNotification, - to deviceToken: String, - topic: String, - pushType: APNSPushType = .alert, - priority: Int? = nil, - expiration: APNSExpiration? = nil, - collapseID: String? = nil - ) async throws -> APNSResponse - - /// Send the same notification to multiple device tokens (sequential). - public func send( - _ notification: APNSNotification, - to deviceTokens: [String], - topic: String, - pushType: APNSPushType = .alert, - priority: Int? = nil, - expiration: APNSExpiration? = nil, - collapseID: String? = nil - ) async throws -> [APNSResponse] - - /// Build and send a simple alert in one call. - @discardableResult - public func sendAlert( - to deviceToken: String, - topic: String, - title: String, - body: String? = nil, - subtitle: String? = nil, - badge: Int? = nil, - sound: String? = nil, - category: String? = nil, - threadId: String? = nil, - interruptionLevel: APNSNotificationItem.InterruptionLevel? = nil, - collapseID: String? = nil, - mutableContent: Bool = false - ) async throws -> APNSResponse - - /// Send a silent background push (`content-available: 1`, priority 5). - @discardableResult - public func sendBackground( - to deviceToken: String, - topic: String, - customData: [APNSNotificationItem] = [] - ) async throws -> APNSResponse -} -``` +In the Apple Developer portal, under Certificates, IDs & Profiles → Keys, create a key with +**Apple Push Notifications service (APNs)** enabled and download the `.p8` file (it can only be +downloaded once). The 10-character key ID is shown with the key, and the 10-character team ID is +under Membership. One key works for every app in the team, in both environments. -The remaining structures, including `APNSNotificationItem`, follow: +## Sending ```swift -/// Items that compose the `aps` dictionary and top-level custom keys of a push payload. -/// `@unchecked Sendable` because `.customPayload` accepts `Any` for JSON flexibility. -public enum APNSNotificationItem: @unchecked Sendable { - case alertBody(String) - case alertTitle(String) - case alertSubtitle(String) - case alertTitleLoc(String, [String]?) - case alertActionLoc(String) - case alertLoc(String, [String]?) - case alertLaunchImage(String) - case badge(Int) - case sound(String) - case contentAvailable - case mutableContent - case category(String) - case threadId(String) - case targetContentId(String) - case interruptionLevel(InterruptionLevel) - case relevanceScore(Double) - case customPayload(String, Any) - - public enum InterruptionLevel: String, Sendable { - case passive, active - case timeSensitive = "time-sensitive" - case critical - } -} - -/// A push notification payload ready to send. -public struct APNSNotification: Sendable { - public init(items: [APNSNotificationItem]) throws - /// Supply a raw pre-encoded JSON payload. - public init(raw: Data) - /// Encode an `Encodable` value as the entire payload (must include the `aps` key). - public init(encodable: T) throws -} - -/// The type of push notification being sent (required by APNs since iOS 13). -public enum APNSPushType: String, Sendable, CaseIterable { - case alert, background, voip, complication - case fileProvider = "fileprovider" - case mdm, location - case liveActivity = "liveactivity" - case pushToTalk = "pushtotalk" +func send(_ notification: APNSNotification, to deviceToken: String, topic: String, + pushType: APNSPushType = .alert, priority: APNSPriority? = nil, + expiration: APNSExpiration? = nil, collapseID: String? = nil) async throws -> APNSResponse +``` - /// Background must use priority 5; all others default to 10. - public var defaultPriority: Int -} +- **Push type** (`apns-push-type`): `.alert`, `.background`, `.voip`, `.complication`, + `.fileProvider`, `.mdm`, `.location`, `.liveActivity`, `.pushToTalk`. Several need a topic + suffix: `com.example.App.voip`, `.complication`, `.pushkit.fileprovider`, + `.push-type.liveactivity`, `.location-query`, `.voip-ptt`. +- **Priority** (`apns-priority`): `.immediate` (10, the default), `.background` (5: delivery + that respects the device's power state) and `.low` (1: never wakes the device). Background + pushes must use `.background`, which is their default. +- **Expiration** (`apns-expiration`): `.immediate` (deliver once or drop), `.relative(seconds)` + or `.absolute(epochSeconds)`. +- **Collapse ID** (`apns-collapse-id`): up to 64 bytes; newer notifications with the same ID + replace older ones on the device. +- `sendAlert(to:topic:title:body:...)` and `sendBackground(to:topic:customData:)` cover the + common cases. + +### Payloads + +`APNSNotificationItem` builds the `aps` dictionary (`.alertTitle`, `.alertBody`, +`.alertSubtitle`, the `-loc` keys, `.badge`, `.sound`, `.contentAvailable`, `.mutableContent`, +`.category`, `.threadId`, `.targetContentId`, `.interruptionLevel`, `.relevanceScore`) and +custom top-level keys with `.customPayload(key, value)`. + +Custom values are `APNSJSONValue`, a `Sendable` JSON enum. Literals convert directly: -public enum APNSPriority: Int, Sendable { - case immediate = 10 - case background = 5 -} +```swift +.customPayload("orderID", "ORD-123") +.customPayload("amount", 49) +.customPayload("flags", ["new": true, "ids": [1, 2, 3]]) +.customPayload("orderID", .string(order.id)) // a variable: name the case +.customPayload("legacy", try APNSJSONValue(jsonObject: someAnyDictionary)) +``` -/// When to discard an undeliverable notification. -public enum APNSExpiration: Sendable { - case immediate - case relative(Int) - case absolute(Int) -} +`APNSNotification(raw:)` sends JSON bytes as is, and `APNSNotification(encodable:)` encodes +your own `Encodable` payload (it must contain `aps`). The payload limit is checked before sending: +4096 bytes, or 5120 for VoIP. -/// The outcome of a single APNs send request. -public struct APNSResponse: Sendable { - public let statusCode: Int - public let apnsID: String? - public let error: APNSResponseError? - public var isSuccess: Bool { statusCode == 200 } -} +### Several devices -/// An error reason returned by the APNs gateway (e.g. BadDeviceToken, Unregistered, ExpiredProviderToken). -public struct APNSResponseError: Sendable, Equatable, CustomStringConvertible { - public let reason: String - public let timestamp: Int? +```swift +let results = try await client.send(notification, to: tokens, topic: "com.example.App") +for r in results { + switch r.result { + case .success(let response) where response.isSuccess: break + case .success(let response): print(r.deviceToken, "rejected:", response.error?.reason ?? "\(response.statusCode)") + case .failure(let error): print(r.deviceToken, "not sent:", error) + } } ``` -Additional Notes ----- - -APNs requests are made from your server to Apple's servers `api.sandbox.push.apple.com` (development) or `api.push.apple.com` (production) on port 443, via `URLSession`'s async `data(for:)`. `URLSession` handles HTTP/2 connection reuse internally, in accordance with Apple's recommended usage of APNs. - -The package targets **Swift 6.2 with strict concurrency** enabled (`.swiftLanguageMode(.v6)` on both the library and test targets). `APNSClient`, `APNSNotification`, `APNSPushType`, `APNSResponse`, and `APNSResponseError` are plain `Sendable`; `APNSCredentials` and `APNSNotificationItem` are `@unchecked Sendable` (an `NSLock`-guarded JWT cache and an `Any`-typed payload case, respectively). No actors are used — `APNSCredentials` synchronizes its cached-token state with `NSLock`. - -The `Perfect-NotificationsExample` project linked from the original PerfectlySoft repo targets the legacy callback-based `NotificationPusher` API and is **not** compatible with this fork's `APNSClient`; treat it as historical reference only, not a working example for this package. - -## Further Information -For more information on the Perfect project, please visit [perfect.org](http://perfect.org). +Tokens are sent one after another. Each gets its own result: a bad token or a network error +for one token doesn't stop the rest. Problems shared by every token (topic, priority, payload +size) throw before anything is sent. For concurrency, call `send(_:to:topic:)` from a task group; +the requests share one HTTP/2 connection. + +### Responses and errors + +An APNs rejection is not a Swift error: it's an `APNSResponse` with `statusCode` and +`error.reason` (`"BadDeviceToken"`, `"Unregistered"`, …; `error.knownReason` maps it onto +`APNSErrorReason`). For HTTP 410, `error.timestamp` is when APNs last confirmed the token was +invalid, in **milliseconds** since the epoch, and `error.invalidatedAt` is the same as a `Date`. +`apnsID` and, in the sandbox, `apnsUniqueID` come from the response headers. + +`APNSClientError` is thrown for everything the client catches itself: invalid input (device +token, topic, collapse ID, priority, expiration, key or team ID, payload size), `.timedOut`, +`.responseTooLarge`, `.network(...)`. Cancelling the calling task throws `CancellationError` +promptly. + +### Provider tokens + +The client signs a JWT with your key and reuses it for 50 minutes (Apple accepts one for up to +60). Apple refuses token updates more often than once every 20 minutes +(`TooManyProviderTokenUpdates`), so the client never mints a new token sooner than that. +After an `ExpiredProviderToken` rejection, it mints a new token and retries once if the old one is +at least 20 minutes old. After `InvalidProviderToken` it doesn't retry (a new token from the same +key won't help) but stops reusing the rejected token once 20 minutes have passed. Each +`APNSClient` created from a key has its own token, so share one client rather than creating many +for the same key. + +### Timeouts + +`APNSClient(..., timeout: 30)` sets a whole-request deadline in seconds (default 30), covering +connect, send and response. Past it, `send` throws `APNSClientError.timedOut`. + +## Security notes + +- **Inputs are validated before a request is built.** Device tokens must be 64–200 hex digits of + even length, so a token can't add path segments, a query or a fragment (`../`, `?`, `#`). + Topics must be bundle-ID syntax; collapse IDs are limited to 64 bytes with no control + characters; key and team IDs must be 10 ASCII letters or digits. No header value can contain + CR, LF or another control character. The JWT is built with `JSONEncoder`. +- **Secrets stay out of logs.** `"\(client)"`, `String(reflecting:)`, `dump(client)` and `Mirror` + show the environment, key ID, team ID and timeout, but never the private key or the provider + token. `APNSClientError.network` carries the transport error's description, which never + includes request headers. +- **Response bodies are capped at 16 KiB** (APNs sends a few dozen bytes). On macOS the body is + streamed and the request is cancelled as soon as it passes the cap. On Linux, see below. +- **Keep the `.p8` key out of source control**, and readable only by the server's user. +- `Environment.custom(host:port:)` points the client at a mock APNs server for staging or tests. + The provider token is sent there, so only use a host you control. + +## Linux + +Verified on swift:6.2-noble and swift:6.4-noble, arm64 and x86_64: `URLSession` negotiates HTTP/2 +with ALPN `h2` against an HTTP/2-only server, reuses one connection, and multiplexes 20 +concurrent sends over it. Two corelibs Foundation limitations shape the Linux behaviour: + +- **No streaming body read.** corelibs `URLSession` buffers a response body completely, and + keeps reading after the task is cancelled. The 16 KiB cap is checked once the body is in, + and the deadline returns control on time, but a malicious endpoint that streams an endless + body can grow the process's memory until the transfer ends. Reaching that needs a server + that passes TLS verification for `api.push.apple.com` (or your custom host). In a local test, + memory reached 6 GB within the 5-second deadline and was released afterwards. +- **Cancelling a corelibs data task can block.** About 1 run in 20 against a server that never + answered, cancelling the task blocked the cancelling thread indefinitely. The client resumes + the caller first, then cancels on a Dispatch thread, so `send` still returns at the deadline. + +If those matter for your deployment, run the provider on macOS, or put an HTTP/2 proxy you trust +in front of it. + +## Testing + +The test suite is hermetic: requests go to a `URLProtocol` stub that records them (path, +headers, body, JWT signature checked with swift-crypto) and returns scripted responses. Nothing +contacts APNs. To test your own code, pass a `URLSession` whose configuration's `protocolClasses` +includes such a stub, or point `Environment.custom(host:port:)` at a local mock server. + +## Migrating from 5.x + +5.x registered named configurations on `NotificationPusher` and delivered results to callbacks. +6.0 has one `APNSClient` per key and `async` sends. + +| 5.x | 6.0 | +|---|---| +| `NotificationPusher.addConfigurationAPNS(name:production:keyId:teamId:privateKeyPath:)` | `let client = try APNSClient(keyID:teamID:privateKeyPath:environment:)` with `.sandbox` or `.production` | +| `NotificationPusher(apnsTopic:expiration:priority:collapseId:)` | `topic:`, `expiration:`, `priority:`, `collapseID:` parameters of `send` | +| `pushAPNS(configurationName:deviceToken:notificationItems:callback:)` | `try await client.send(APNSNotification(items: ...), to: token, topic: ...)` | +| `pushAPNS(configurationName:deviceTokens:notificationItems:callback:)` | `try await client.send(..., to: tokens, topic: ...)` → `[APNSBatchResult]` | +| `NotificationResponse` (`status`, `body`) | `APNSResponse` (`statusCode`, `apnsID`, `apnsUniqueID`, `error`) | +| `.customPayload(String, Any)` | `.customPayload(String, APNSJSONValue)`: literals work as before; wrap variables (`.string(x)`) or use `APNSJSONValue(jsonObject:)` | +| Perfect-Net / Perfect-Thread | `URLSession` and swift-crypto | + +`APNSNotificationItem`'s other cases keep their names. New: `.alertSubtitle`, `.mutableContent`, +`.targetContentId`, `.interruptionLevel`, `.relevanceScore`. + +### From the pre-release `main` (Swift 6 rewrite before 6.0.0) + +- `priority:` is an `APNSPriority?` (`.immediate`, `.background`, `.low`) instead of an `Int?`, + and `APNSPushType.defaultPriority` returns an `APNSPriority`. A background push with any other + priority throws `.invalidPriority`. +- `.customPayload` takes an `APNSJSONValue`, and `APNSNotificationItem` is properly `Sendable` + (and `Equatable`). A custom `aps` key throws `.reservedPayloadKey`. +- The multi-token `send` returns `[APNSBatchResult]` (token plus `Result`) instead of + `[APNSResponse]`, and no longer throws away every result when one token fails. +- `APNSResponseError.timestamp` is an `Int64?` of milliseconds (it was documented as seconds). +- Key IDs and team IDs must be 10 letters or digits; device tokens, topics, collapse IDs and + expirations are validated; payloads over the limit throw `.payloadTooLarge`. +- `APNSClientError` has new cases (exhaustive `switch`es need updating), and the `init`s take a + `timeout:`. `APNSExpiration` is `Equatable`, `APNSNotification.payload` is public. +- swift-crypto is `4.0.0..<5.0.0` (was `from: "3.0.0"`, which meant 3.x only and couldn't + resolve next to Perfect-SMTP 6). diff --git a/README.zh_CN.md b/README.zh_CN.md deleted file mode 100644 index 56d2354..0000000 --- a/README.zh_CN.md +++ /dev/null @@ -1,207 +0,0 @@ -# Perfect-Notifications [English](README.md) - -

- - Get Involed with Perfect! - -

- -

- - Swift 5.2 - - - Platforms OS X | Linux - - - License Apache - -

- -Notifications for Perfect 之 iOS消息推送。此软件包为您的服务器添加了推送通知支持。向iOS / macOS设备发送通知。 - - -编译 --------- - -请在您的Perfect项目中的Package.swift文件增加以下依存关系: - -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.package(url:"https://github.com/PerfectlySoft/Perfect-Notifications.git", from: "5.0.0") -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -## 综述 - -该程序在服务器上运行,通常是苹果移动设备在启动后,会自动在苹果网站上注册,以获得一个设备编号。 - -苹果设备需要在获得该编号之后,主动到⚠️**您的**⚠️服务器上登记。这样您的服务器就可以记录这个设备编号然后通过APN苹果网络服务给设备发消息。 - - -## 获取 APN 授权码 - -要使用苹果设备消息推送服务APN,您需要首先获得一个APN授权代码。这个代码可以在苹果开发者网站上取得。登录到您的开发者账号并选择 "Certificates, IDs & Profiles" 菜单,在"Keys"下,选择"All"。 - -如果还没有创建或者下载授权码,请点击”➕“创建一个新代码。输入私有钥匙的名称,并选择 **Apple Push Notifications service (APNs)**,即 Apple推送通知服务(APNs)。该授权码可以同时在开发阶段和生产部署阶段使用,适用于所有您的iOS / macOS设备。 - -点击 "Continue" 后,再点击"Confirm",可以下载 **私有钥匙** 。此时必须下载并保存该文件,并拷贝屏幕上十位字长的“钥匙代码”。 - -最后需要确定您的开发团队账号。点击"Account"浏览器上方的,选择 "Membership" 会员视图能够找到"Team ID"团队编号,也是10位字符串。请记录该字符串。 - -## 服务器配置 - -如果需要从服务器向苹果设备推送消息,您需要以下三个信息: - -1. 刚才下载的私有钥匙文件 -2. 10位授权码 -3. 10位开发团队代码 -4. 苹果设备或操作系统在APN上的编号 - -上述内容用于推送消息,注意不要在您的服务器源代码上保存这些东西,最好区别与服务器程序分开保存。为了简单说明,本手册假设钥匙文件在当前工作目录下,并且各个编号代码都已经以Swift变量形式导入到程序。 - -在服务器代码开始段,请导入函数库 `import PerfectNotifications`。随后请配置好上述内容用于实际消息推送。 - -``` swift -import PerfectNotifications - -// 应用程序名称(即Bundle Identifier),我们用这个名称来配置,但是不一定非得是这个形式 -let notificationsAppId = "my.app.id" - -let apnsKeyIdentifier = "AB90CD56XY" -let apnsTeamIdentifier = "YX65DC09BA" -let apnsPrivateKeyFilePath = "./APNsAuthKey_AB90CD56XY.p8" - -NotificationPusher.addConfigurationAPNS( -name: notificationsTestId, -production: false, // 如果是调试程序,则将这个生产服务器标志设置为“伪” -keyId: apnsKeyIdentifier, -teamId: apnsTeamIdentifier, -privateKeyPath: apnsPrivateKeyFilePath) -``` - -配置完成后就可以随时推送消息了。具体做法是创建一个`NotificationPusher`对象,设置应用程序代码或者“消息主题”,然后调用 `pushAPNS`函数: - -``` swift -let deviceIds: [String] = [...] -let n = NotificationPusher(apnsTopic: notificationsTestId) -n.pushAPNS( -configurationName: notificationsTestId, -deviceTokens: deviceIds, -notificationItems: [.alertBody("Hello!"), .sound("default")]) { -responses in -print("\(responses)") -... -} -``` - -创建 NotificationPusher 的消息主题是必要的。此外,可选参数还包括消息有效期、优先级和消息层次折叠代码,详见苹果APN有关文档。 - -## 函数界面 - -详细函数调用参考如下: - -```swift -public class NotificationPusher { - -/// 追加APNS 配置 -public static func addConfigurationAPNS( -name: String, -production: Bool, -keyId: String, -teamId: String, -privateKeyPath: String) - -/// 初始化推送实例并设置消息主题 -public init( -apnsTopic: String, -expiration: APNSExpiration = .immediate, -priority: APNSPriority = .immediate, -collapseId: String? = nil) - -/// 推送消息 -/// 需要提供已经设定好了配置名称和设备编号 -/// 需要提供待通知对象列表 APNSNotificationItems -/// 需要提供推送结果回调函数 -public func pushAPNS( -configurationName: String, -deviceToken: String, -notificationItems: [APNSNotificationItem], -callback: @escaping (NotificationResponse) -> ()) - -/// 向多个设备推送消息 -/// 需要提供预制配置名称、一个或者多个设备代码。所有设备都会收到相同的消息 -/// 需要提供待通知对象列表 APNSNotificationItems -/// 需要提供推送结果回调函数 -public func pushAPNS( -configurationName: String, deviceTokens: [String], -notificationItems: [APNSNotificationItem], -callback: @escaping ([NotificationResponse]) -> ()) -} -``` - -其他数据结果包括 APNSNotificationItem : - -```swift -/// Items to configure an individual notification push. -public enum APNSNotificationItem { -/// alert body child property -case alertBody(String) -/// alert title child property -case alertTitle(String) -/// alert title-loc-key -case alertTitleLoc(String, [String]?) -/// alert action-loc-key -case alertActionLoc(String) -/// alert loc-key -case alertLoc(String, [String]?) -/// alert launch-image -case alertLaunchImage(String) -/// aps badge key -case badge(Int) -/// aps sound key -case sound(String) -/// aps content-available key -case contentAvailable -/// aps category key -case category(String) -/// aps thread-id key -case threadId(String) -/// custom payload data -case customPayload(String, Any) -/// apn mutable-content key -case mutableContent -} - -public enum APNSPriority: Int { -case immediate = 10 -case background = 5 -} - -/// 消息有效期,超过之后不会在发送 -public enum APNSExpiration { -/// 如果无法立刻到达则丢弃消息 -case immediate -/// 当前时间加秒数 -case relative(Int) -/// UTC绝对时间 -case absolute(Int) -} - -/// 推送后的响应对象 -public struct NotificationResponse: CustomStringConvertible { -/// The response code for the request. -public let status: HTTPResponseStatus -/// The response body data bytes. -public let body: [UInt8] -/// The body data bytes interpreted as JSON and decoded into a Dictionary. -public var jsonObjectBody: [String:Any] -/// The body data bytes converted to String. -public var stringBody: String -public var description: String -} -``` - -## 其他注意事项 - -APNs 请求需要从您的服务器向苹果服务器 "api.development.push.apple.com" 或者 "api.push.apple.com" 443 端口进行通信。一次请求可以向一个或者多个设备推送消息。整个连接过程会一直持续,并且可以用于发送后续消息。如果连接终止或者空闲太久,则会重新打开一个新的连接。这种方式是苹果建议的方式用于处理大量同步通知。 - -可以参考代码示范 [Perfect-NotificationsExample](https://github.com/PerfectExamples/Perfect-NotificationsExample) 用于配置您自己的服务器和应用程序消息推送。 From ae4d53bb70dba11e6ce7a86ebc7e5c439f0d1d39 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 16:35:20 -0500 Subject: [PATCH 4/7] Replace the provider token when the clock steps backwards With the wall clock stepped back past the token's issue time, its age was negative, so it counted as fresh for up to "step + 20 minutes" while APNs answered ExpiredProviderToken. Steps of more than 60 seconds now force a new token. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectNotifications/APNSCredentials.swift | 12 +++++++++--- .../PayloadAndTokenTests.swift | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectNotifications/APNSCredentials.swift b/Sources/PerfectNotifications/APNSCredentials.swift index 0eb4c76..0e60c64 100644 --- a/Sources/PerfectNotifications/APNSCredentials.swift +++ b/Sources/PerfectNotifications/APNSCredentials.swift @@ -12,6 +12,8 @@ final class APNSCredentials: @unchecked Sendable { static let refreshAge: TimeInterval = 50 * 60 /// Apple's minimum interval between token updates. static let minimumRefreshInterval: TimeInterval = 20 * 60 + /// Backwards clock steps smaller than this are ignored. + static let clockStepTolerance: TimeInterval = 60 let keyID: String let teamID: String @@ -58,10 +60,13 @@ final class APNSCredentials: @unchecked Sendable { func token() throws -> String { try lock.withLock { let current = now() + // A clearly negative age means the clock stepped backwards; the token's real + // age is unknown, so replace it rather than risk keeping an expired one. if let cached = cachedToken { let age = current.timeIntervalSince(issuedAt) - if age < Self.minimumRefreshInterval { return cached } - if age < Self.refreshAge && !rejected { return cached } + let sane = age > -Self.clockStepTolerance + if sane && age < Self.minimumRefreshInterval { return cached } + if sane && age < Self.refreshAge && !rejected { return cached } } let token = try makeJWT(issuedAt: current) cachedToken = token @@ -79,7 +84,8 @@ final class APNSCredentials: @unchecked Sendable { lock.withLock { guard token == cachedToken else { return true } rejected = true - return now().timeIntervalSince(issuedAt) >= Self.minimumRefreshInterval + let age = now().timeIntervalSince(issuedAt) + return age <= -Self.clockStepTolerance || age >= Self.minimumRefreshInterval } } diff --git a/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift index f75eb82..41d272d 100644 --- a/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift +++ b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift @@ -256,6 +256,17 @@ struct PayloadTests { #expect(try creds.token() == second) } + @Test func clockGoingBackwardsReplacesTheToken() throws { + let clock = TestClock() + let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey(), + now: { clock.now }) + let first = try creds.token() + clock.advance(minutes: -90) + let second = try creds.token() + #expect(second != first) + #expect(try creds.token() == second) + } + // MARK: Redaction @Test func credentialsNeverPrintTheKey() throws { From e6c46fef87a549ab0a881114c8ad8f5ab67b9ee0 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 18:46:56 -0500 Subject: [PATCH 5/7] Review fixes: own delegate-driven transport, no redirects, stricter input Transport (HIGH, MEDIUM): requests no longer run as a Swift Task around URLSession.data(for:) that the deadline cancels. On Linux that cancellation called URLSessionTask.cancel() from the task's cancellation handler, with its status lock held, while URLSession's work queue was resuming the same continuation: both blocked, then every concurrency thread (reviewer's FIRST=c1,c2 poison repro, 3/3 on 6.2 arm64). APNSTransport now drives a callback-based data task on its own session, built from the caller's session's configuration, with its own delegate. The deadline and caller cancellation resume our continuation first, then cancel the URLSessionTask on a Dispatch thread, with no lock held. URLRequest.timeoutInterval is set to (timeout + 5) rounded up, so URLSession's idle timeout never races the deadline, and sub-second deadlines (truncated to 0 by corelibs) work. The body cap (16 KiB) is enforced as data arrives on both platforms. Authentication challenges are forwarded to the caller's session delegate. Redirects (MEDIUM): the session delegate refuses every redirect (corelibs ignores per-task delegates), and a 3xx or a response for another origin throws APNSClientError.unexpectedRedirect, so the provider token never leaves the APNs host and a redirect is never reported as success. Input: - Collapse IDs: 1-64 printable ASCII characters, 0x21-0x7E (no spaces). - APNSJSONValue(jsonObject:) refuses UInt64 and NSNumber "Q" values above Int.max instead of wrapping them to negatives. - JSON nesting is capped at 64 levels (jsonNestingTooDeep) in conversion, custom payload values and raw payloads; depth 20,000 crashed before. - badge must be >= 0 and relevanceScore 0-1 (invalidPayload). - APNSNotification(raw:) throws unless the bytes are a JSON object with an aps dictionary, at most 5120 bytes. Batch: cancelling keeps the results already obtained; the token in flight and later ones get .failure(.cancelled). APNSBatchResult is Equatable. Tokens: age is measured on a monotonic clock that counts sleep (CLOCK_MONOTONIC / CLOCK_BOOTTIME), so wall-clock steps in either direction neither keep an old token nor force new mints (repeated 61 s steps used to re-mint on every call). This replaces the step tolerance from ae4d53b. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectNotifications/APNSClient.swift | 186 ++++---------- .../APNSCredentials.swift | 46 ++-- .../PerfectNotifications/APNSJSONValue.swift | 45 +++- .../APNSNotification.swift | 35 ++- .../PerfectNotifications/APNSResponse.swift | 5 +- .../PerfectNotifications/APNSTransport.swift | 242 ++++++++++++++++++ .../PerfectNotifications/APNSValidation.swift | 18 +- .../PayloadAndTokenTests.swift | 118 +++++++-- .../PerfectNotificationsTests.swift | 71 ++++- .../TestSupport.swift | 35 ++- 10 files changed, 591 insertions(+), 210 deletions(-) create mode 100644 Sources/PerfectNotifications/APNSTransport.swift diff --git a/Sources/PerfectNotifications/APNSClient.swift b/Sources/PerfectNotifications/APNSClient.swift index 9ac7548..218298a 100644 --- a/Sources/PerfectNotifications/APNSClient.swift +++ b/Sources/PerfectNotifications/APNSClient.swift @@ -11,7 +11,7 @@ public enum APNSClientError: Error, Sendable, Equatable, CustomStringConvertible case invalidDeviceToken /// The topic is not a bundle ID (dot-separated ASCII letters, digits and `-`). case invalidTopic - /// The collapse ID is empty, longer than 64 bytes, or contains a control character. + /// The collapse ID is not 1–64 printable ASCII characters (`!` through `~`, no spaces). case invalidCollapseID /// The priority isn't allowed for the push type (background pushes must use `.background`). case invalidPriority(APNSPriority, pushType: APNSPushType) @@ -31,6 +31,11 @@ public enum APNSClientError: Error, Sendable, Equatable, CustomStringConvertible case reservedPayloadKey(String) /// ``APNSJSONValue/init(jsonObject:)`` met a value it can't represent. case invalidJSONValue(String) + /// A payload value is out of range (`badge` < 0, `relevanceScore` outside 0–1), or raw + /// payload bytes aren't a JSON object with an `aps` dictionary. + case invalidPayload(String) + /// JSON nested deeper than the limit (64 levels). + case jsonNestingTooDeep(limit: Int) /// Signing the provider token failed. case tokenSigningFailed /// The request did not complete within the client's timeout. @@ -42,12 +47,18 @@ public enum APNSClientError: Error, Sendable, Equatable, CustomStringConvertible case network(String) /// The response was not HTTP. case invalidResponse + /// The server answered with a redirect (or for another origin). APNs never redirects, + /// and the client never follows one, so the provider token stays with the APNs host. + case unexpectedRedirect(statusCode: Int) + /// In a batch send: the batch was cancelled before or while this token's request ran. + /// A token whose request was in flight may or may not have been delivered. + case cancelled public var description: String { switch self { case .invalidDeviceToken: return "invalid device token (expected 64-200 hex digits)" case .invalidTopic: return "invalid apns-topic" - case .invalidCollapseID: return "invalid apns-collapse-id (1-64 bytes, no control characters)" + case .invalidCollapseID: return "invalid apns-collapse-id (1-64 printable ASCII characters, no spaces)" case .invalidPriority(let p, let t): return "priority \(p.rawValue) is not allowed for push type \(t.rawValue)" case .invalidExpiration: return "invalid apns-expiration" case .invalidKeyID: return "invalid key ID (expected 10 letters or digits)" @@ -57,11 +68,15 @@ public enum APNSClientError: Error, Sendable, Equatable, CustomStringConvertible case .payloadTooLarge(let size, let limit): return "payload is \(size) bytes; the limit is \(limit)" case .reservedPayloadKey(let k): return "custom payload key \"\(k)\" is reserved" case .invalidJSONValue(let t): return "not a JSON value: \(t)" + case .invalidPayload(let why): return "invalid payload: \(why)" + case .jsonNestingTooDeep(let limit): return "JSON nested deeper than \(limit) levels" case .tokenSigningFailed: return "signing the provider token failed" case .timedOut: return "request timed out" case .responseTooLarge(let limit): return "response body exceeded \(limit) bytes" case .network(let s): return "network error: \(s)" case .invalidResponse: return "response was not HTTP" + case .unexpectedRedirect(let status): return "unexpected redirect (HTTP \(status)); not followed" + case .cancelled: return "cancelled" } } } @@ -126,7 +141,7 @@ public struct APNSClient: Sendable { public let environment: Environment /// The whole-request deadline, in seconds, covering connect, send and response. public let timeout: TimeInterval - private let urlSession: URLSession + private let transport: APNSTransport // MARK: Init @@ -137,7 +152,10 @@ public struct APNSClient: Sendable { /// - teamID: Your 10-character Apple Developer Team ID. /// - privateKeyPath: Path to the `.p8` private key file. /// - environment: `.sandbox` (default) or `.production`. - /// - urlSession: Custom session for testing or certificate pinning. + /// - urlSession: Its configuration (protocol classes, proxies, TLS settings) is used, + /// and its delegate receives authentication challenges, so certificate pinning keeps + /// working. The client sends through its own session built from it, which never + /// follows redirects. /// - timeout: Whole-request deadline in seconds (default 30). /// - Throws: ``APNSClientError/invalidKeyID``, ``APNSClientError/invalidTeamID``, /// ``APNSClientError/invalidHost``, ``APNSClientError/invalidTimeout``, a file error, @@ -149,7 +167,7 @@ public struct APNSClient: Sendable { try Self.validate(environment: environment, timeout: timeout) self.credentials = try APNSCredentials(keyID: keyID, teamID: teamID, privateKeyPath: privateKeyPath) self.environment = environment - self.urlSession = urlSession + self.transport = APNSTransport(basedOn: urlSession, maxBodySize: Self.maxResponseBodySize) self.timeout = timeout } @@ -161,7 +179,7 @@ public struct APNSClient: Sendable { try Self.validate(environment: environment, timeout: timeout) self.credentials = try APNSCredentials(keyID: keyID, teamID: teamID, pemString: pemString) self.environment = environment - self.urlSession = urlSession + self.transport = APNSTransport(basedOn: urlSession, maxBodySize: Self.maxResponseBodySize) self.timeout = timeout } @@ -170,7 +188,7 @@ public struct APNSClient: Sendable { try Self.validate(environment: environment, timeout: timeout) self.credentials = credentials self.environment = environment - self.urlSession = urlSession + self.transport = APNSTransport(basedOn: urlSession, maxBodySize: Self.maxResponseBodySize) self.timeout = timeout } @@ -216,7 +234,11 @@ public struct APNSClient: Sendable { /// Returns one ``APNSBatchResult`` per token, in order. A token that fails (invalid /// token, network error, timeout) gets a `.failure` and the rest are still sent. /// Problems shared by every token (topic, priority, payload size, …) throw before - /// anything is sent, and cancellation throws `CancellationError`. + /// anything is sent. + /// + /// Cancelling the calling task doesn't throw away what was already sent: tokens that + /// were sent keep their results, the token in flight and every later one get + /// `.failure(.cancelled)`. (The one in flight may still have been delivered.) public func send( _ notification: APNSNotification, to deviceTokens: [String], @@ -231,12 +253,18 @@ public struct APNSClient: Sendable { var results = [APNSBatchResult]() results.reserveCapacity(deviceTokens.count) for token in deviceTokens { - try Task.checkCancellation() + if Task.isCancelled { + results.append(APNSBatchResult(deviceToken: token, result: .failure(.cancelled))) + continue + } let result: Result do { result = .success(try await deliver(prepared, to: token)) } catch let error as APNSClientError { result = .failure(error) + } catch { + // CancellationError: this token's request was in flight. + result = .failure(.cancelled) } results.append(APNSBatchResult(deviceToken: token, result: result)) } @@ -367,14 +395,19 @@ public struct APNSClient: Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = prepared.body - request.timeoutInterval = timeout + // The deadline is enforced by the transport. URLSession's own idle timeout is set + // past it so the two never race (and because corelibs truncates it to whole + // seconds: a value under 1 s fails at once). + request.timeoutInterval = (timeout + 5).rounded(.up) for (name, value) in prepared.headers { request.setValue(value, forHTTPHeaderField: name) } request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - let session = urlSession - let request_ = request - let (data, http) = try await withDeadline(seconds: timeout) { - try await Self.load(request_, session: session) + let (data, http) = try await transport.send(request, deadline: timeout) + + // APNs never redirects, and the transport refuses to follow one. A 3xx, or a + // response for another origin, is reported rather than read as a result. + if (300...399).contains(http.statusCode) || http.url.map({ !Self.sameOrigin($0, url) }) == true { + throw APNSClientError.unexpectedRedirect(statusCode: http.statusCode) } let apnsID = http.value(forHTTPHeaderField: "apns-id") @@ -389,49 +422,10 @@ public struct APNSClient: Sendable { return APNSResponse(statusCode: http.statusCode, apnsID: apnsID, apnsUniqueID: uniqueID, error: error) } - /// Loads the response, reading at most ``maxResponseBodySize`` bytes of body. - private static func load(_ request: URLRequest, session: URLSession) async throws -> (Data, HTTPURLResponse) { - let limit = maxResponseBodySize - do { - #if canImport(FoundationNetworking) - // corelibs Foundation has no streaming `bytes(for:)`, and keeps reading a body - // after its task is cancelled, so the size is checked once the body is in. - // The whole-request deadline still bounds how long that can take. - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw APNSClientError.invalidResponse } - guard data.count <= limit else { throw APNSClientError.responseTooLarge(limit: limit) } - return (data, http) - #else - let (bytes, response) = try await session.bytes(for: request) - guard let http = response as? HTTPURLResponse else { - bytes.task.cancel() - throw APNSClientError.invalidResponse - } - if http.expectedContentLength > Int64(limit) { - bytes.task.cancel() - throw APNSClientError.responseTooLarge(limit: limit) - } - var data = Data() - for try await byte in bytes { - data.append(byte) - if data.count > limit { - bytes.task.cancel() - throw APNSClientError.responseTooLarge(limit: limit) - } - } - return (data, http) - #endif - } catch let error as APNSClientError { - throw error - } catch is CancellationError { - throw CancellationError() - } catch let error as URLError { - if error.code == .timedOut { throw APNSClientError.timedOut } - if error.code == .cancelled && Task.isCancelled { throw CancellationError() } - throw APNSClientError.network("URLError \(error.code.rawValue): \(error.localizedDescription)") - } catch { - throw APNSClientError.network(error.localizedDescription) - } + private static func sameOrigin(_ a: URL, _ b: URL) -> Bool { + a.scheme?.lowercased() == b.scheme?.lowercased() + && a.host?.lowercased() == b.host?.lowercased() + && (a.port ?? 443) == (b.port ?? 443) } } @@ -452,81 +446,3 @@ extension APNSClient: CustomStringConvertible, CustomDebugStringConvertible, Cus ], displayStyle: .struct) } } - -// MARK: - Deadline - -/// Runs `operation` with a deadline. Returns as soon as the operation finishes, the -/// deadline passes (``APNSClientError/timedOut``) or the calling task is cancelled -/// (`CancellationError`), even if the operation ignores cancellation; the operation is -/// cancelled in the latter two cases. -func withDeadline( - seconds: TimeInterval, - _ operation: @escaping @Sendable () async throws -> T -) async throws -> T { - let race = DeadlineRace() - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let work = Task { - do { race.finish(.success(try await operation())) } - catch { race.finish(.failure(error)) } - } - let nanos = UInt64(min(seconds, 86_400) * 1_000_000_000) - let timer = Task { - try? await Task.sleep(nanoseconds: nanos) - if !Task.isCancelled { race.finish(.failure(APNSClientError.timedOut)) } - } - race.start(continuation, tasks: [work, timer]) - } - } onCancel: { - race.finish(.failure(CancellationError())) - } -} - -private final class DeadlineRace: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation? - private var tasks: [Task] = [] - private var pending: Result? - private var done = false - - func start(_ continuation: CheckedContinuation, tasks: [Task]) { - let early: Result? = lock.withLock { - if let pending = self.pending { self.done = true; return pending } - self.continuation = continuation - self.tasks = tasks - return nil - } - if let early { - continuation.resume(with: early) - Self.cancel(tasks) - } - } - - func finish(_ result: Result) { - let (continuation, tasks): (CheckedContinuation?, [Task]) = lock.withLock { - guard !done else { return (nil, []) } - guard let c = self.continuation else { - // Not started yet (cancelled before the continuation existed): remember the - // first result for start(). - if pending == nil { pending = result } - return (nil, []) - } - done = true - self.continuation = nil - defer { self.tasks = [] } - return (c, self.tasks) - } - continuation?.resume(with: result) - Self.cancel(tasks) - } - - /// Cancels off the cooperative pool, after the caller has been resumed. On Linux, - /// cancelling a `URLSession` data task from its async API sometimes blocks the - /// cancelling thread indefinitely (seen in about 1 run in 20 against a server that - /// never answers); the caller must not wait for that, and it must not pin a - /// Swift-concurrency thread. - private static func cancel(_ tasks: [Task]) { - guard !tasks.isEmpty else { return } - DispatchQueue.global().async { tasks.forEach { $0.cancel() } } - } -} diff --git a/Sources/PerfectNotifications/APNSCredentials.swift b/Sources/PerfectNotifications/APNSCredentials.swift index 0e60c64..8ca0378 100644 --- a/Sources/PerfectNotifications/APNSCredentials.swift +++ b/Sources/PerfectNotifications/APNSCredentials.swift @@ -7,22 +7,27 @@ import Foundation /// than once every 20 minutes (`TooManyProviderTokenUpdates`). So a token is reused for /// 50 minutes, is replaced early only after APNs rejected it, and is never replaced /// within 20 minutes of being minted. +/// +/// Ages are measured on a monotonic clock that keeps counting while the machine sleeps +/// (`CLOCK_MONOTONIC` on Darwin, `CLOCK_BOOTTIME` on Linux), so stepping the wall clock +/// neither keeps an old token alive nor forces new ones. The wall clock is only used for +/// the token's `iat` claim. final class APNSCredentials: @unchecked Sendable { /// Proactive refresh age, safely inside Apple's 60-minute limit. static let refreshAge: TimeInterval = 50 * 60 /// Apple's minimum interval between token updates. static let minimumRefreshInterval: TimeInterval = 20 * 60 - /// Backwards clock steps smaller than this are ignored. - static let clockStepTolerance: TimeInterval = 60 let keyID: String let teamID: String private let privateKey: P256.Signing.PrivateKey private let now: @Sendable () -> Date + private let uptime: @Sendable () -> TimeInterval private let lock = NSLock() private var cachedToken: String? - private var issuedAt: Date = .distantPast + /// Monotonic time of the last mint. + private var mintedAt: TimeInterval = 0 private var rejected = false // MARK: Init @@ -40,12 +45,14 @@ final class APNSCredentials: @unchecked Sendable { } init(keyID: String, teamID: String, key: P256.Signing.PrivateKey, - now: @escaping @Sendable () -> Date = { Date() }) throws { + now: @escaping @Sendable () -> Date = { Date() }, + uptime: @escaping @Sendable () -> TimeInterval = monotonicSeconds) throws { try Self.validate(keyID: keyID, teamID: teamID) self.keyID = keyID self.teamID = teamID self.privateKey = key self.now = now + self.uptime = uptime } private static func validate(keyID: String, teamID: String) throws(APNSClientError) { @@ -59,18 +66,15 @@ final class APNSCredentials: @unchecked Sendable { /// or was rejected by APNs and is at least 20 minutes old. func token() throws -> String { try lock.withLock { - let current = now() - // A clearly negative age means the clock stepped backwards; the token's real - // age is unknown, so replace it rather than risk keeping an expired one. + let current = uptime() if let cached = cachedToken { - let age = current.timeIntervalSince(issuedAt) - let sane = age > -Self.clockStepTolerance - if sane && age < Self.minimumRefreshInterval { return cached } - if sane && age < Self.refreshAge && !rejected { return cached } + let age = current - mintedAt + if age < Self.minimumRefreshInterval { return cached } + if age < Self.refreshAge && !rejected { return cached } } - let token = try makeJWT(issuedAt: current) + let token = try makeJWT(issuedAt: now()) cachedToken = token - issuedAt = current + mintedAt = current rejected = false return token } @@ -84,8 +88,7 @@ final class APNSCredentials: @unchecked Sendable { lock.withLock { guard token == cachedToken else { return true } rejected = true - let age = now().timeIntervalSince(issuedAt) - return age <= -Self.clockStepTolerance || age >= Self.minimumRefreshInterval + return uptime() - mintedAt >= Self.minimumRefreshInterval } } @@ -116,6 +119,19 @@ extension APNSCredentials: CustomStringConvertible, CustomDebugStringConvertible } } +// MARK: - Monotonic clock + +/// Seconds on a clock that never steps and keeps counting through sleep. +@Sendable func monotonicSeconds() -> TimeInterval { + var ts = timespec() + #if canImport(Darwin) + clock_gettime(CLOCK_MONOTONIC, &ts) + #else + clock_gettime(CLOCK_BOOTTIME, &ts) + #endif + return TimeInterval(ts.tv_sec) + TimeInterval(ts.tv_nsec) / 1_000_000_000 +} + // MARK: - Base64URL (no padding, URL-safe alphabet) func base64URL(_ data: Data) -> String { diff --git a/Sources/PerfectNotifications/APNSJSONValue.swift b/Sources/PerfectNotifications/APNSJSONValue.swift index 3c6fd80..8608ef1 100644 --- a/Sources/PerfectNotifications/APNSJSONValue.swift +++ b/Sources/PerfectNotifications/APNSJSONValue.swift @@ -54,17 +54,39 @@ public enum APNSJSONValue: Sendable, Hashable, Codable { /// The conversion happens immediately, so the result is genuinely `Sendable`. /// /// - Throws: ``APNSClientError/invalidJSONValue(_:)`` naming the first unsupported type - /// (or a non-finite number). + /// (or a non-finite or out-of-range number), or ``APNSClientError/jsonNestingTooDeep(limit:)`` + /// past 64 levels. public init(jsonObject value: Any) throws { - self = try Self.convert(value) + self = try Self.convert(value, depth: 0) } - private static func convert(_ value: Any) throws -> APNSJSONValue { + /// The nesting depth: 0 for a scalar, 1 for a flat array or object. Iterative, so a + /// hostile value can't overflow the stack while being measured. + var depth: Int { + var deepest = 0 + var stack: [(APNSJSONValue, Int)] = [(self, 0)] + while let (value, level) = stack.popLast() { + switch value { + case .array(let a): + deepest = max(deepest, level + 1) + stack.append(contentsOf: a.map { ($0, level + 1) }) + case .object(let o): + deepest = max(deepest, level + 1) + stack.append(contentsOf: o.values.map { ($0, level + 1) }) + default: + break + } + } + return deepest + } + + private static func convert(_ value: Any, depth: Int) throws -> APNSJSONValue { + // `depth` counts the containers around `value`. // Unwrap Optional boxed in Any. let mirror = Mirror(reflecting: value) if mirror.displayStyle == .optional { guard let wrapped = mirror.children.first?.value else { return .null } - return try convert(wrapped) + return try Self.convert(wrapped, depth: depth) } // Exact native Swift types first: on Darwin `as? NSNumber` would also match // these through bridging, and `as? Bool` would match NSNumber(1). @@ -80,12 +102,19 @@ public enum APNSJSONValue: Sendable, Hashable, Codable { case let v as UInt where type(of: value) == UInt.self: guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("UInt out of range") } return .int(i) + case let v as UInt64 where type(of: value) == UInt64.self: + guard let i = Int(exactly: v) else { throw APNSClientError.invalidJSONValue("UInt64 out of range") } + return .int(i) case let v as Double where type(of: value) == Double.self: return try finite(v) case let v as Float where type(of: value) == Float.self: return try finite(Double(v)) case is NSNull: return .null case let n as NSNumber: return try convert(number: n) - case let a as [Any]: return .array(try a.map(convert)) - case let o as [String: Any]: return .object(try o.mapValues(convert)) + case let a as [Any]: + guard depth < APNSValidation.maxJSONDepth else { throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth) } + return .array(try a.map { try Self.convert($0, depth: depth + 1) }) + case let o as [String: Any]: + guard depth < APNSValidation.maxJSONDepth else { throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth) } + return .object(try o.mapValues { try Self.convert($0, depth: depth + 1) }) default: throw APNSClientError.invalidJSONValue(String(describing: type(of: value))) } @@ -109,6 +138,10 @@ public enum APNSJSONValue: Sendable, Hashable, Codable { #endif case "f", "d": return try finite(n.doubleValue) + case "Q", "L": + // Unsigned 64-bit: `int64Value` would wrap values above Int64.max to negatives. + guard let i = Int(exactly: n.uint64Value) else { throw APNSClientError.invalidJSONValue("number out of range") } + return .int(i) default: guard let i = Int(exactly: n.int64Value) else { throw APNSClientError.invalidJSONValue("number out of range") } return .int(i) diff --git a/Sources/PerfectNotifications/APNSNotification.swift b/Sources/PerfectNotifications/APNSNotification.swift index 71fc8f0..b48c9bb 100644 --- a/Sources/PerfectNotifications/APNSNotification.swift +++ b/Sources/PerfectNotifications/APNSNotification.swift @@ -63,14 +63,29 @@ public struct APNSNotification: Sendable, Equatable { /// Build from notification items (recommended for standard alerts). /// - /// - Throws: ``APNSClientError/reservedPayloadKey(_:)`` for a custom `aps` key, or an - /// `EncodingError` for a non-finite number. + /// - Throws: ``APNSClientError/reservedPayloadKey(_:)`` for a custom `aps` key, + /// ``APNSClientError/invalidPayload(_:)`` for a negative badge or a relevance score + /// outside 0–1, ``APNSClientError/jsonNestingTooDeep(limit:)`` for custom values nested + /// past 64 levels, or an `EncodingError` for a non-finite number. public init(items: [APNSNotificationItem]) throws { self.payload = try APNSNotificationItem.encodePayload(items) } - /// Supply a raw pre-encoded JSON payload. It is sent as is. - public init(raw: Data) { + /// Supply a raw pre-encoded JSON payload, sent as is. + /// + /// - Throws: ``APNSClientError/invalidPayload(_:)`` unless the bytes are a JSON object + /// with an `aps` dictionary, ``APNSClientError/payloadTooLarge(size:limit:)`` past + /// 5120 bytes (the largest limit; the push type's limit is checked when sending), or + /// ``APNSClientError/jsonNestingTooDeep(limit:)``. + public init(raw: Data) throws { + guard raw.count <= 5120 else { throw APNSClientError.payloadTooLarge(size: raw.count, limit: 5120) } + guard let value = try? JSONDecoder().decode(APNSJSONValue.self, from: raw), + case .object(let top) = value, case .object? = top["aps"] else { + throw APNSClientError.invalidPayload("raw payload must be a JSON object with an \"aps\" dictionary") + } + guard value.depth <= APNSValidation.maxJSONDepth else { + throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth) + } self.payload = raw } @@ -102,7 +117,9 @@ extension APNSNotificationItem { alert["loc-key"] = .string(s) if let args = a { alert["loc-args"] = .array(args.map(APNSJSONValue.string)) } case .alertLaunchImage(let s): alert["launch-image"] = .string(s) - case .badge(let i): aps["badge"] = .int(i) + case .badge(let i): + guard i >= 0 else { throw APNSClientError.invalidPayload("badge must not be negative") } + aps["badge"] = .int(i) case .sound(let s): aps["sound"] = .string(s) case .contentAvailable: aps["content-available"] = 1 case .mutableContent: aps["mutable-content"] = 1 @@ -110,9 +127,15 @@ extension APNSNotificationItem { case .threadId(let s): aps["thread-id"] = .string(s) case .targetContentId(let s): aps["target-content-id"] = .string(s) case .interruptionLevel(let l): aps["interruption-level"] = .string(l.rawValue) - case .relevanceScore(let d): aps["relevance-score"] = .double(d) + case .relevanceScore(let d): + guard (0...1).contains(d) else { throw APNSClientError.invalidPayload("relevanceScore must be 0-1") } + aps["relevance-score"] = .double(d) case .customPayload(let k, let v): guard k != "aps" else { throw APNSClientError.reservedPayloadKey(k) } + // The value sits one level below the top-level object. + guard v.depth < APNSValidation.maxJSONDepth else { + throw APNSClientError.jsonNestingTooDeep(limit: APNSValidation.maxJSONDepth) + } top[k] = v } } diff --git a/Sources/PerfectNotifications/APNSResponse.swift b/Sources/PerfectNotifications/APNSResponse.swift index d13d7d4..8575afd 100644 --- a/Sources/PerfectNotifications/APNSResponse.swift +++ b/Sources/PerfectNotifications/APNSResponse.swift @@ -83,10 +83,11 @@ public enum APNSErrorReason: String, Sendable, CaseIterable { } /// The per-token outcome of sending one notification to several device tokens. -public struct APNSBatchResult: Sendable { +public struct APNSBatchResult: Sendable, Equatable { public let deviceToken: String /// The APNs response (which may itself be a rejection), or the client error that - /// stopped this token's request: an invalid token, a network failure or a timeout. + /// stopped this token's request: an invalid token, a network failure, a timeout, or + /// ``APNSClientError/cancelled`` for tokens reached after the batch was cancelled. public let result: Result } diff --git a/Sources/PerfectNotifications/APNSTransport.swift b/Sources/PerfectNotifications/APNSTransport.swift new file mode 100644 index 0000000..bf8c3d8 --- /dev/null +++ b/Sources/PerfectNotifications/APNSTransport.swift @@ -0,0 +1,242 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// The client's HTTP transport: a `URLSession` driven through delegate callbacks. +/// +/// It is built from the caller's session's configuration (so protocol classes, proxies and +/// TLS settings carry over), with its own delegate, because: +/// +/// - **Redirects must not be followed.** APNs never redirects, and following one would +/// send the provider token to another origin. corelibs Foundation ignores per-task +/// delegates, so only a session delegate can refuse a redirect on Linux. +/// - **Cancellation must not go through Swift task cancellation.** On Linux, cancelling a +/// `Task` that awaits `URLSession.data(for:)` calls `URLSessionTask.cancel()` from the +/// cancellation handler, with the task's status lock held, and `cancel()` waits for the +/// session's work queue. If that queue is at that moment resuming the same task's +/// continuation (which needs the status lock), both block, and every concurrency thread +/// that touches the session follows. Here the deadline and caller cancellation resume +/// our own continuation and then cancel the `URLSessionTask` on a Dispatch thread, with +/// no lock held. +/// - **The body cap is enforced as data arrives**, on both platforms. +/// +/// Authentication challenges are forwarded to the caller's session delegate, so a custom +/// trust evaluation (certificate pinning, a private CA in tests) keeps working. +final class APNSTransport: Sendable { + private let session: URLSession + private let delegate: SessionDelegate + + init(basedOn given: URLSession, maxBodySize: Int) { + delegate = SessionDelegate(forwardingTo: given, maxBodySize: maxBodySize) + session = URLSession(configuration: given.configuration, delegate: delegate, delegateQueue: nil) + } + + deinit { + // The session retains its delegate until it is invalidated. + session.finishTasksAndInvalidate() + } + + /// Sends `request`, returning the body (at most `maxBodySize` bytes) and the response. + /// + /// Returns, throws ``APNSClientError/timedOut`` at the deadline, or throws + /// `CancellationError` when the calling task is cancelled, without waiting for + /// `URLSession` to wind the transfer down. + func send(_ request: URLRequest, deadline seconds: TimeInterval) async throws -> (Data, HTTPURLResponse) { + let flight = InFlight(limit: delegate.maxBodySize) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, HTTPURLResponse), any Error>) in + guard flight.attach(continuation) else { return } // cancelled before we started + let task = session.dataTask(with: request) + delegate.register(flight, for: task) + let timer = DispatchWorkItem { flight.finish(.failure(APNSClientError.timedOut)) } + guard flight.start(task: task, timer: timer) else { delegate.unregister(task); return } + DispatchQueue.global().asyncAfter(deadline: .now() + seconds, execute: timer) + task.resume() + } + } onCancel: { + // Never resume or cancel from inside the cancellation handler: it runs with + // the task's status lock held. + DispatchQueue.global().async { flight.finish(.failure(CancellationError())) } + } + } +} + +// MARK: - One request + +private final class InFlight: @unchecked Sendable { + let limit: Int + private let lock = NSLock() + private var continuation: CheckedContinuation<(Data, HTTPURLResponse), any Error>? + private var pending: Result<(Data, HTTPURLResponse), any Error>? + private var task: URLSessionTask? + private var timer: DispatchWorkItem? + private var done = false + private var data = Data() + private var response: HTTPURLResponse? + + init(limit: Int) { self.limit = limit } + + /// Returns `false` if the request already finished (it was cancelled first); the + /// continuation has then been resumed. + func attach(_ c: CheckedContinuation<(Data, HTTPURLResponse), any Error>) -> Bool { + let early: Result<(Data, HTTPURLResponse), any Error>? = lock.withLock { + if let pending { return pending } + continuation = c + return nil + } + if let early { c.resume(with: early); return false } + return true + } + + /// Returns `false` if the request finished in the meantime. + func start(task: URLSessionTask, timer: DispatchWorkItem) -> Bool { + lock.withLock { + guard !done else { return false } + self.task = task + self.timer = timer + return true + } + } + + func received(_ response: URLResponse) -> Bool { + guard let http = response as? HTTPURLResponse else { + finish(.failure(APNSClientError.invalidResponse)) + return false + } + if http.expectedContentLength > Int64(limit) { + finish(.failure(APNSClientError.responseTooLarge(limit: limit))) + return false + } + lock.withLock { self.response = http } + return true + } + + func received(_ chunk: Data) { + let over: Bool = lock.withLock { + guard !done else { return false } // ignore anything after the end + data.append(chunk) + return data.count > limit + } + if over { finish(.failure(APNSClientError.responseTooLarge(limit: limit))) } + } + + func completed(error: (any Error)?) { + if let error { + if let urlError = error as? URLError { + if urlError.code == .timedOut { finish(.failure(APNSClientError.timedOut)); return } + if urlError.code == .cancelled { finish(.failure(APNSClientError.network("cancelled"))); return } + finish(.failure(APNSClientError.network("URLError \(urlError.code.rawValue): \(urlError.localizedDescription)"))) + } else { + finish(.failure(APNSClientError.network(error.localizedDescription))) + } + return + } + let result: Result<(Data, HTTPURLResponse), any Error> = lock.withLock { + guard let response else { return .failure(APNSClientError.invalidResponse) } + return .success((data, response)) + } + finish(result) + } + + /// Resolves the request once: resumes the caller, then cancels the transfer (off the + /// caller's thread) if it is still running. + func finish(_ result: Result<(Data, HTTPURLResponse), any Error>) { + let (c, task, timer): (CheckedContinuation<(Data, HTTPURLResponse), any Error>?, URLSessionTask?, DispatchWorkItem?) = lock.withLock { + guard !done else { return (nil, nil, nil) } + done = true + data = Data() + guard let c = continuation else { + pending = result // not attached yet + return (nil, nil, nil) + } + continuation = nil + defer { self.task = nil; self.timer = nil } + return (c, self.task, self.timer) + } + c?.resume(with: result) + timer?.cancel() + if let task, case .failure = result { + DispatchQueue.global().async { task.cancel() } + } + } +} + +// MARK: - Session delegate + +private final class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { + let maxBodySize: Int + /// The caller's session, passed to its delegate when forwarding challenges. + private let callerSession: URLSession + private let lock = NSLock() + private var flights: [Int: InFlight] = [:] + + init(forwardingTo callerSession: URLSession, maxBodySize: Int) { + self.callerSession = callerSession + self.maxBodySize = maxBodySize + } + + func register(_ flight: InFlight, for task: URLSessionTask) { + lock.withLock { flights[task.taskIdentifier] = flight } + } + + func unregister(_ task: URLSessionTask) { + _ = lock.withLock { flights.removeValue(forKey: task.taskIdentifier) } + } + + private func flight(_ task: URLSessionTask) -> InFlight? { + lock.withLock { flights[task.taskIdentifier] } + } + + // MARK: Data + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) { + let keep = flight(dataTask)?.received(response) ?? false + completionHandler(keep ? .allow : .cancel) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + flight(dataTask)?.received(data) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + let f = lock.withLock { flights.removeValue(forKey: task.taskIdentifier) } + f?.completed(error: error) + } + + // MARK: Redirects: never followed + + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { + completionHandler(nil) + } + + // MARK: Challenges: forwarded to the caller's delegate + + #if canImport(FoundationNetworking) + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if let forward = callerSession.delegate as? URLSessionTaskDelegate { + forward.urlSession(callerSession, task: task, didReceive: challenge, completionHandler: completionHandler) + } else if let forward = callerSession.delegate { + forward.urlSession(callerSession, didReceive: challenge, completionHandler: completionHandler) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + #else + func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + let forward = callerSession.delegate + if (forward as? URLSessionTaskDelegate)?.urlSession?(callerSession, task: task, didReceive: challenge, + completionHandler: completionHandler) != nil { + return + } + if forward?.urlSession?(callerSession, didReceive: challenge, completionHandler: completionHandler) != nil { + return + } + completionHandler(.performDefaultHandling, nil) + } + #endif +} diff --git a/Sources/PerfectNotifications/APNSValidation.swift b/Sources/PerfectNotifications/APNSValidation.swift index b105d5e..8621818 100644 --- a/Sources/PerfectNotifications/APNSValidation.swift +++ b/Sources/PerfectNotifications/APNSValidation.swift @@ -13,6 +13,10 @@ enum APNSValidation { /// `apns-collapse-id` must not exceed 64 bytes. static let maxCollapseIDBytes = 64 + /// The deepest JSON nesting accepted in payloads (APNs payloads are a few levels deep; + /// the cap keeps recursive encoding and conversion off the end of the stack). + static let maxJSONDepth = 64 + /// Payload limits: 4 KB for regular pushes, 5 KB for VoIP. static func payloadLimit(for pushType: APNSPushType) -> Int { pushType == .voip ? 5120 : 4096 @@ -39,9 +43,12 @@ enum APNSValidation { } } - /// At most 64 bytes of UTF-8, with no C0/C1 control characters, DEL, or U+2028/U+2029. + /// 1–64 printable ASCII characters, `!` (0x21) through `~` (0x7E). Apple only documents + /// the 64-byte limit; spaces and non-ASCII are refused to stay on the safe side. static func collapseID(_ id: String) throws(APNSClientError) { - guard !id.isEmpty, id.utf8.count <= maxCollapseIDBytes, !containsControlCharacters(id) else { + let bytes = id.utf8 + guard (1...maxCollapseIDBytes).contains(bytes.count), + bytes.allSatisfy({ (0x21...0x7E).contains($0) }) else { throw .invalidCollapseID } } @@ -62,13 +69,6 @@ enum APNSValidation { } } - static func containsControlCharacters(_ s: String) -> Bool { - s.unicodeScalars.contains { scalar in - let v = scalar.value - return v < 0x20 || (0x7F...0x9F).contains(v) || v == 0x2028 || v == 0x2029 - } - } - private static func isHexDigit(_ b: UInt8) -> Bool { (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(b) || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(b) diff --git a/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift index 41d272d..55d5492 100644 --- a/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift +++ b/Tests/PerfectNotificationsTests/PayloadAndTokenTests.swift @@ -108,8 +108,84 @@ struct PayloadTests { #expect(APNSNotificationItem.customPayload("k", 1) == .customPayload("k", .int(1))) } + @Test func badgeAndRelevanceScoreRanges() throws { + _ = try APNSNotification(items: [.badge(0), .relevanceScore(0), .relevanceScore(1)]) + #expect(throws: APNSClientError.invalidPayload("badge must not be negative")) { + try APNSNotification(items: [.badge(-1)]) + } + for score in [-0.01, 1.01, .nan, .infinity] { + #expect(throws: APNSClientError.invalidPayload("relevanceScore must be 0-1")) { + try APNSNotification(items: [.relevanceScore(score)]) + } + } + } + + @Test func rawPayloadMustBeAnObjectWithAps() throws { + let ok = try APNSNotification(raw: Data(#"{"aps":{"alert":"x"},"k":1}"#.utf8)) + #expect(ok.payload.count == 27) + for bad in ["", "[]", "\"aps\"", #"{"aps":"x"}"#, #"{"alert":"x"}"#, "{garbage", #"{"aps":{}"#] { + #expect(throws: APNSClientError.self) { try APNSNotification(raw: Data(bad.utf8)) } + } + } + + // MARK: Nesting depth + + static func nestedArrays(_ depth: Int) -> APNSJSONValue { + var v: APNSJSONValue = 1 + for _ in 0.. Void) async { + await withCheckedContinuation { (done: CheckedContinuation) in + let thread = Thread { body(); done.resume() } + thread.stackSize = 64 << 20 + thread.start() + } + } + + @Test func deepNestingIsRefusedWithoutCrashing() async throws { + // Depth 20,000 used to overflow the stack in encodePayload and convert. + await Self.onBigStack { + #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) { + try APNSNotification(items: [.customPayload("deep", Self.nestedArrays(20_000))]) + } + var any: Any = 1 + for _ in 0..<20_000 { any = [any] } + #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) { try APNSJSONValue(jsonObject: any) } + let raw = String(repeating: "[", count: 20_000) + String(repeating: "]", count: 20_000) + #expect(throws: (any Error).self) { try APNSNotification(raw: Data(#"{"aps":{},"x":\#(raw)}"#.utf8)) } + } + // The boundary: 63 levels under a top-level key (64 with the payload object) is fine. + _ = try APNSNotification(items: [.customPayload("ok", Self.nestedArrays(63))]) + #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) { + try APNSNotification(items: [.customPayload("deep", Self.nestedArrays(64))]) + } + var sixtyFour: Any = 1 + for _ in 0..<64 { sixtyFour = [sixtyFour] } + #expect(try APNSJSONValue(jsonObject: sixtyFour) == Self.nestedArrays(64)) + sixtyFour = [sixtyFour] + #expect(throws: APNSClientError.jsonNestingTooDeep(limit: 64)) { try APNSJSONValue(jsonObject: sixtyFour) } + } + // MARK: APNSJSONValue + @Test func unsignedValuesAboveIntMaxAreRefused() throws { + #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: UInt64.max) } + #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: NSNumber(value: UInt64.max)) } + #expect(throws: APNSClientError.self) { try APNSJSONValue(jsonObject: UInt(Int.max) + 1) } + #expect(try APNSJSONValue(jsonObject: UInt64(Int.max)) == .int(Int.max)) + #expect(try APNSJSONValue(jsonObject: NSNumber(value: UInt64(42))) == 42) + // Whatever JSONSerialization makes of 2^64 - 1, it must not come out as -1. + let parsed = try JSONSerialization.jsonObject(with: Data("[18446744073709551615]".utf8)) + if let value = try? APNSJSONValue(jsonObject: parsed) { + #expect(value != [-1]) + } + } + @Test func jsonObjectConversionFromJSONSerialization() throws { let parsed = try JSONSerialization.jsonObject(with: Data( #"{"b":true,"f":false,"i":42,"d":1.5,"s":"x","n":null,"a":[1,"y"],"o":{"k":0}}"#.utf8)) @@ -173,10 +249,12 @@ struct PayloadTests { @Test func collapseIDLimits() throws { try APNSValidation.collapseID(String(repeating: "x", count: 64)) - try APNSValidation.collapseID(String(repeating: "é", count: 32)) // 64 bytes + try APNSValidation.collapseID("order-42:v2_(a)~!") #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(String(repeating: "x", count: 65)) } - #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(String(repeating: "é", count: 33)) } - for bad in ["", "a\r\nb: c", "a\nb", "a\u{0}b", "a\u{7F}", "a\u{85}", "a\u{2028}b", "tab\there"] { + // The reviewer's cases: leading space, trailing space, bidi override + ZWJ. + for bad in [" lead", "trail ", "a\u{202E}b\u{200D}", + "", "inner space", "é-collapse", "a\r\nb: c", "a\nb", "a\u{0}b", "a\u{7F}", "a\u{85}", + "a\u{2028}b", "tab\there"] { #expect(throws: APNSClientError.invalidCollapseID) { try APNSValidation.collapseID(bad) } } } @@ -196,7 +274,7 @@ struct PayloadTests { @Test func jwtHasExactHeaderAndClaimsAndAValidSignature() throws { let key = P256.Signing.PrivateKey() let clock = TestClock() - let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: key, now: { clock.now }) + let creds = try credentials(key, clock: clock) let jwt = try decodeJWT(creds.token(), publicKey: key.publicKey) #expect(jwt.headerJSON == #"{"alg":"ES256","kid":"ABCDE12345"}"#) #expect(jwt.claimsJSON == #"{"iat":1800000000,"iss":"TEAM123456"}"#) @@ -229,8 +307,7 @@ struct PayloadTests { @Test func tokenIsCachedThenRefreshedAtFiftyMinutes() throws { let clock = TestClock() - let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey(), - now: { clock.now }) + let creds = try credentials(clock: clock) let first = try creds.token() clock.advance(minutes: 49.9) #expect(try creds.token() == first) @@ -242,8 +319,7 @@ struct PayloadTests { @Test func rejectedTokenIsReplacedOnlyAfterTwentyMinutes() throws { let clock = TestClock() - let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey(), - now: { clock.now }) + let creds = try credentials(clock: clock) let first = try creds.token() clock.advance(minutes: 5) #expect(creds.reject(first) == false) // too soon to mint again @@ -256,15 +332,27 @@ struct PayloadTests { #expect(try creds.token() == second) } - @Test func clockGoingBackwardsReplacesTheToken() throws { + @Test func wallClockStepsDontAffectTokenAge() throws { let clock = TestClock() - let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: P256.Signing.PrivateKey(), - now: { clock.now }) + let creds = try credentials(clock: clock) let first = try creds.token() - clock.advance(minutes: -90) - let second = try creds.token() - #expect(second != first) - #expect(try creds.token() == second) + // Backwards steps (repeated, as a misbehaving VM clock might): no new mints. + for _ in 0..<20 { clock.stepWall(minutes: -1.1) } + #expect(try creds.token() == first) + clock.stepWall(minutes: -90) + #expect(try creds.token() == first) + // A forward wall step doesn't age the token either. + clock.stepWall(minutes: 600) + #expect(try creds.token() == first) + // Real (monotonic) time does. + clock.advance(minutes: 50) + #expect(try creds.token() != first) + } + + @Test func monotonicClockIsMonotonic() { + let a = monotonicSeconds() + let b = monotonicSeconds() + #expect(a > 0 && b >= a) } // MARK: Redaction diff --git a/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift b/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift index 207f853..e86d17c 100644 --- a/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift +++ b/Tests/PerfectNotificationsTests/PerfectNotificationsTests.swift @@ -6,9 +6,6 @@ import FoundationNetworking import Crypto @testable import PerfectNotifications -/// Blocks the calling thread, ignoring cancellation. -private func blockThread(seconds: TimeInterval) { Thread.sleep(forTimeInterval: seconds) } - /// Request shape, response parsing, retries, timeouts and redaction, through a /// URLProtocol stub. Serialized: the stub's state is shared. @Suite(.serialized) @@ -107,7 +104,7 @@ struct ClientTests { let client = try makeClient { _ in ok() } func payload(_ size: Int) -> APNSNotification { let filler = String(repeating: "x", count: size - #"{"aps":{"alert":""}}"#.utf8.count) - return APNSNotification(raw: Data(#"{"aps":{"alert":"\#(filler)"}}"#.utf8)) + return try! APNSNotification(raw: Data(#"{"aps":{"alert":"\#(filler)"}}"#.utf8)) } #expect(payload(4096).payload.count == 4096) _ = try await client.send(payload(4096), to: token64, topic: "com.example.App") @@ -115,8 +112,8 @@ struct ClientTests { try await client.send(payload(4097), to: token64, topic: "com.example.App") } _ = try await client.send(payload(5120), to: token64, topic: "com.example.App.voip", pushType: .voip) - await #expect(throws: APNSClientError.payloadTooLarge(size: 5121, limit: 5120)) { - try await client.send(payload(5121), to: token64, topic: "com.example.App.voip", pushType: .voip) + #expect(throws: APNSClientError.payloadTooLarge(size: 5121, limit: 5120)) { + try APNSNotification(raw: Data(repeating: UInt8(ascii: " "), count: 5121)) } #expect(MockURLProtocol.requests.count == 2) } @@ -208,17 +205,47 @@ struct ClientTests { #expect(Date().timeIntervalSince(start) < 5) } - @Test func deadlineReturnsEvenIfTheOperationIgnoresCancellation() async throws { + @Test func subSecondDeadlineWithAPromptServerSucceeds() async throws { + // corelibs truncates URLRequest.timeoutInterval to whole seconds, so a 0.5 s value + // used to fail at once on Linux. The client's own deadline handles fractions. + let client = try makeClient(timeout: 0.5) { _ in ok() } + let result = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + #expect(result.isSuccess) let start = Date() + let hung = try makeClient(timeout: 0.5) { _ in .hang } await #expect(throws: APNSClientError.timedOut) { - try await withDeadline(seconds: 0.2) { () async -> Int in - // Uncooperative work: does not check cancellation. - blockThread(seconds: 1.0) - return 1 + try await hung.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + } + #expect(Date().timeIntervalSince(start) >= 0.4) + } + + @Test func urlSessionIdleTimeoutIsPastTheDeadline() async throws { + let client = try makeClient(timeout: 0.5) { _ in ok() } + _ = try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") + // Recorded by the stub from the request as sent; the deadline itself is 0.5 s. + #expect(MockURLProtocol.lastTimeout == 6) + } + + // MARK: Redirects + + @Test func redirectStatusIsAnErrorNotASuccess() async throws { + for status in [301, 302, 307, 308] { + let client = try makeClient { _ in + .respond(status: status, headers: ["location": "https://evil.test/3/device/\(token64)"], body: Data()) + } + await #expect(throws: APNSClientError.unexpectedRedirect(statusCode: status)) { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") } + #expect(MockURLProtocol.requests.count == 1) + #expect(MockURLProtocol.requests.allSatisfy { $0.url?.host == "api.sandbox.push.apple.com" }) + } + } + + @Test func responseFromAnotherOriginIsRefused() async throws { + let client = try makeClient { _ in .respondFrom(url: "https://evil.test/3/device/x", status: 200) } + await #expect(throws: APNSClientError.unexpectedRedirect(statusCode: 200)) { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: token64, topic: "com.example.App") } - #expect(Date().timeIntervalSince(start) < 0.9) - #expect(try await withDeadline(seconds: 5) { 42 } == 42) } // MARK: Provider-token rejections @@ -302,6 +329,24 @@ struct ClientTests { #expect(MockURLProtocol.requests.count == 4) } + @Test func cancellingABatchKeepsTheResultsSoFar() async throws { + let client = try makeClient(timeout: 60) { i in i < 2 ? ok() : .hang } + let tokens = ["aa", "bb", "cc", "dd"].map { String(repeating: $0, count: 32) } + let task = Task { + try await client.send(APNSNotification(items: [.alertBody("x")]), to: tokens, topic: "com.example.App") + } + try await Task.sleep(nanoseconds: 300_000_000) + task.cancel() + let results = try await task.value + #expect(results.map(\.deviceToken) == tokens) + #expect(try results[0].result.get().isSuccess) + #expect(try results[1].result.get().isSuccess) + #expect(results[2].result == .failure(.cancelled)) // in flight + #expect(results[3].result == .failure(.cancelled)) // never started + #expect(MockURLProtocol.requests.count == 3) + #expect(results[2] == APNSBatchResult(deviceToken: tokens[2], result: .failure(.cancelled))) + } + @Test func batchSharedProblemsThrowBeforeSending() async throws { let client = try makeClient { _ in ok() } await #expect(throws: APNSClientError.invalidTopic) { diff --git a/Tests/PerfectNotificationsTests/TestSupport.swift b/Tests/PerfectNotificationsTests/TestSupport.swift index 0972dc9..79c3577 100644 --- a/Tests/PerfectNotificationsTests/TestSupport.swift +++ b/Tests/PerfectNotificationsTests/TestSupport.swift @@ -12,6 +12,8 @@ import Crypto enum MockReply: Sendable { case respond(status: Int, headers: [String: String], body: Data) + /// A response that claims to come from another URL (another origin). + case respondFrom(url: String, status: Int) case fail(URLError.Code) /// Never answers (for timeout and cancellation tests). case hang @@ -22,6 +24,7 @@ struct RecordedRequest: Sendable { let method: String? let headers: [String: String] let body: Data + var timeout: TimeInterval = 0 func header(_ name: String) -> String? { headers.first { $0.key.lowercased() == name.lowercased() }?.value } @@ -37,6 +40,7 @@ final class MockURLProtocol: URLProtocol { } static var requests: [RecordedRequest] { lock.withLock { _requests } } static var lastRequest: RecordedRequest? { requests.last } + static var lastTimeout: TimeInterval? { lastRequest?.timeout } static func session() -> URLSession { let config = URLSessionConfiguration.ephemeral @@ -50,7 +54,8 @@ final class MockURLProtocol: URLProtocol { override func startLoading() { let recorded = RecordedRequest(url: request.url, method: request.httpMethod, headers: request.allHTTPHeaderFields ?? [:], - body: Self.readBody(from: request)) + body: Self.readBody(from: request), + timeout: request.timeoutInterval) let (index, handler) = Self.lock.withLock { () -> (Int, (@Sendable (Int) -> MockReply)?) in Self._requests.append(recorded) return (Self._requests.count - 1, Self._handler) @@ -62,6 +67,11 @@ final class MockURLProtocol: URLProtocol { client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: body) client?.urlProtocolDidFinishLoading(self) + case .respondFrom(let url, let status): + let http = HTTPURLResponse(url: URL(string: url)!, statusCode: status, + httpVersion: "HTTP/2", headerFields: ["apns-id": "X"])! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocolDidFinishLoading(self) case .fail(let code): client?.urlProtocol(self, didFailWithError: URLError(code)) case .hang: @@ -108,13 +118,21 @@ let token64 = String(repeating: "ab", count: 32) let keyID = "ABCDE12345" let teamID = "TEAM123456" -/// A settable clock for token-age tests. +/// A settable pair of clocks for token-age tests: wall time (for `iat`) and monotonic +/// time (for token age). `advance` moves both; `stepWall` moves only the wall clock. final class TestClock: @unchecked Sendable { private let lock = NSLock() - private var current: Date - init(_ start: Date = Date(timeIntervalSince1970: 1_800_000_000)) { current = start } - var now: Date { lock.withLock { current } } - func advance(minutes: Double) { lock.withLock { current += minutes * 60 } } + private var wall: Date + private var mono: TimeInterval = 1_000 + init(_ start: Date = Date(timeIntervalSince1970: 1_800_000_000)) { wall = start } + var now: Date { lock.withLock { wall } } + var uptime: TimeInterval { lock.withLock { mono } } + func advance(minutes: Double) { lock.withLock { wall += minutes * 60; mono += minutes * 60 } } + func stepWall(minutes: Double) { lock.withLock { wall += minutes * 60 } } +} + +func credentials(_ key: P256.Signing.PrivateKey = P256.Signing.PrivateKey(), clock: TestClock) throws -> APNSCredentials { + try APNSCredentials(keyID: keyID, teamID: teamID, key: key, now: { clock.now }, uptime: { clock.uptime }) } func makeClient(key: P256.Signing.PrivateKey = P256.Signing.PrivateKey(), @@ -123,9 +141,8 @@ func makeClient(key: P256.Signing.PrivateKey = P256.Signing.PrivateKey(), timeout: TimeInterval = APNSClient.defaultTimeout, _ handler: @escaping @Sendable (Int) -> MockReply) throws -> APNSClient { MockURLProtocol.reset(handler) - let now: @Sendable () -> Date - if let clock { now = { clock.now } } else { now = { Date() } } - let creds = try APNSCredentials(keyID: keyID, teamID: teamID, key: key, now: now) + let creds = try clock.map { try credentials(key, clock: $0) } + ?? APNSCredentials(keyID: keyID, teamID: teamID, key: key) return try APNSClient(credentials: creds, environment: environment, urlSession: MockURLProtocol.session(), timeout: timeout) } From 8cd78276e9b079ad0ac9b3df106b15169f24b663 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 20:01:56 -0500 Subject: [PATCH 6/7] README: transport, redirects, collapse IDs, Linux behaviour, probes Documents the client's own session (configuration reused, only challenges forwarded to the caller's delegate), refused redirects, printable-ASCII collapse IDs, raw-payload validation, batch cancellation, monotonic token age and fractional timeouts. The Linux section now gives the measured endless-body behaviour (send throws in <= 0.14 s and RSS stays under 110 MB, but corelibs keeps reading 0.2-2.7 GB; the old data(for:) transport was OOM-killed at about 6 GB), the connection reset per timed-out request, and the planned 6.1 SwiftNIO transport. The test notes describe the scripted Linux probes that cover what the URLProtocol stub can't. Co-Authored-By: Claude Opus 5.5 --- README.md | 95 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index beeb9b3..7559eff 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,8 @@ func send(_ notification: APNSNotification, to deviceToken: String, topic: Strin pushes must use `.background`, which is their default. - **Expiration** (`apns-expiration`): `.immediate` (deliver once or drop), `.relative(seconds)` or `.absolute(epochSeconds)`. -- **Collapse ID** (`apns-collapse-id`): up to 64 bytes; newer notifications with the same ID - replace older ones on the device. +- **Collapse ID** (`apns-collapse-id`): 1–64 printable ASCII characters (`!` to `~`, no + spaces); newer notifications with the same ID replace older ones on the device. - `sendAlert(to:topic:title:body:...)` and `sendBackground(to:topic:customData:)` cover the common cases. @@ -124,9 +124,11 @@ Custom values are `APNSJSONValue`, a `Sendable` JSON enum. Literals convert dire .customPayload("legacy", try APNSJSONValue(jsonObject: someAnyDictionary)) ``` -`APNSNotification(raw:)` sends JSON bytes as is, and `APNSNotification(encodable:)` encodes -your own `Encodable` payload (it must contain `aps`). The payload limit is checked before sending: -4096 bytes, or 5120 for VoIP. +`APNSNotification(raw:)` sends JSON bytes as is, once it has checked that they are a JSON object +with an `aps` dictionary, and `APNSNotification(encodable:)` encodes your own `Encodable` payload +(it must contain `aps`). The payload limit is checked before sending: 4096 bytes, or 5120 for +VoIP. `badge` must not be negative, `relevanceScore` must be 0–1, and JSON may nest at most 64 +levels. ### Several devices @@ -143,8 +145,10 @@ for r in results { Tokens are sent one after another. Each gets its own result: a bad token or a network error for one token doesn't stop the rest. Problems shared by every token (topic, priority, payload -size) throw before anything is sent. For concurrency, call `send(_:to:topic:)` from a task group; -the requests share one HTTP/2 connection. +size) throw before anything is sent. Cancelling the calling task stops the batch without losing +what was sent: earlier tokens keep their results, and the token in flight and the rest get +`.failure(.cancelled)` (the one in flight may still have been delivered). For concurrency, call +`send(_:to:topic:)` from a task group; the requests share one HTTP/2 connection. ### Responses and errors @@ -156,8 +160,8 @@ invalid, in **milliseconds** since the epoch, and `error.invalidatedAt` is the s `APNSClientError` is thrown for everything the client catches itself: invalid input (device token, topic, collapse ID, priority, expiration, key or team ID, payload size), `.timedOut`, -`.responseTooLarge`, `.network(...)`. Cancelling the calling task throws `CancellationError` -promptly. +`.responseTooLarge`, `.unexpectedRedirect`, `.network(...)`. Cancelling the calling task throws +`CancellationError` promptly. ### Provider tokens @@ -166,28 +170,42 @@ The client signs a JWT with your key and reuses it for 50 minutes (Apple accepts (`TooManyProviderTokenUpdates`), so the client never mints a new token sooner than that. After an `ExpiredProviderToken` rejection, it mints a new token and retries once if the old one is at least 20 minutes old. After `InvalidProviderToken` it doesn't retry (a new token from the same -key won't help) but stops reusing the rejected token once 20 minutes have passed. Each +key won't help) but stops reusing the rejected token once 20 minutes have passed. Token age is +measured on a monotonic clock that counts sleep, so stepping the wall clock doesn't change when +tokens are replaced. Each `APNSClient` created from a key has its own token, so share one client rather than creating many for the same key. ### Timeouts -`APNSClient(..., timeout: 30)` sets a whole-request deadline in seconds (default 30), covering -connect, send and response. Past it, `send` throws `APNSClientError.timedOut`. +`APNSClient(..., timeout: 30)` sets a whole-request deadline in seconds (default 30, fractions +allowed), covering connect, send and response. Past it, `send` throws `APNSClientError.timedOut`. + +### The `urlSession:` parameter + +The client sends through its own `URLSession`, built from the configuration of the session you +pass (protocol classes, proxies, TLS settings and timeouts carry over), with its own delegate. It +forwards authentication challenges, including server-trust evaluation for certificate pinning, +to your session's delegate. Other delegate callbacks aren't forwarded. It needs its own delegate +to refuse redirects and to cancel requests safely on Linux. ## Security notes - **Inputs are validated before a request is built.** Device tokens must be 64–200 hex digits of even length, so a token can't add path segments, a query or a fragment (`../`, `?`, `#`). - Topics must be bundle-ID syntax; collapse IDs are limited to 64 bytes with no control - characters; key and team IDs must be 10 ASCII letters or digits. No header value can contain - CR, LF or another control character. The JWT is built with `JSONEncoder`. + Topics must be bundle-ID syntax; collapse IDs are 1–64 printable ASCII characters; key and + team IDs must be 10 ASCII letters or digits. No header value can contain CR, LF, a space or + another control character. The JWT is built with `JSONEncoder`. +- **Redirects are never followed.** APNs doesn't redirect, and following one would send the + provider token to another origin. A 3xx response, or one for another host, throws + `APNSClientError.unexpectedRedirect` instead of counting as a result. - **Secrets stay out of logs.** `"\(client)"`, `String(reflecting:)`, `dump(client)` and `Mirror` show the environment, key ID, team ID and timeout, but never the private key or the provider token. `APNSClientError.network` carries the transport error's description, which never includes request headers. -- **Response bodies are capped at 16 KiB** (APNs sends a few dozen bytes). On macOS the body is - streamed and the request is cancelled as soon as it passes the cap. On Linux, see below. +- **Response bodies are capped at 16 KiB** (APNs sends a few dozen bytes). The cap is enforced as + data arrives, and the request is cancelled when it passes it. On macOS that stops the + transfer. On Linux it doesn't; see below. - **Keep the `.p8` key out of source control**, and readable only by the server's user. - `Environment.custom(host:port:)` points the client at a mock APNs server for staging or tests. The provider token is sent there, so only use a host you control. @@ -196,20 +214,24 @@ connect, send and response. Past it, `send` throws `APNSClientError.timedOut`. Verified on swift:6.2-noble and swift:6.4-noble, arm64 and x86_64: `URLSession` negotiates HTTP/2 with ALPN `h2` against an HTTP/2-only server, reuses one connection, and multiplexes 20 -concurrent sends over it. Two corelibs Foundation limitations shape the Linux behaviour: - -- **No streaming body read.** corelibs `URLSession` buffers a response body completely, and - keeps reading after the task is cancelled. The 16 KiB cap is checked once the body is in, - and the deadline returns control on time, but a malicious endpoint that streams an endless - body can grow the process's memory until the transfer ends. Reaching that needs a server - that passes TLS verification for `api.push.apple.com` (or your custom host). In a local test, - memory reached 6 GB within the 5-second deadline and was released afterwards. -- **Cancelling a corelibs data task can block.** About 1 run in 20 against a server that never - answered, cancelling the task blocked the cancelling thread indefinitely. The client resumes - the caller first, then cancels on a Dispatch thread, so `send` still returns at the deadline. - -If those matter for your deployment, run the provider on macOS, or put an HTTP/2 proxy you trust -in front of it. +concurrent sends over it. Three corelibs Foundation behaviours to know about: + +- **An endless response body keeps downloading after the client gives up.** The client counts + the body as it arrives, throws `responseTooLarge` past 16 KiB, and discards anything later. + But corelibs keeps the transfer running after its task is cancelled. Against a local server + streaming an endless body, `send` threw within 0.14 s and memory stayed under 110 MB in 8 of + 8 runs. corelibs went on reading 0.2–2.7 GB, though, before the connection closed, wasting + bandwidth and CPU. (Before 6.0.0's final transport, the same server got the process + OOM-killed at about 6 GB within roughly 10 seconds, because `URLSession.data(for:)` buffered + everything.) Reaching this needs a server that passes TLS verification for + `api.push.apple.com` (or your custom host). A SwiftNIO HTTP/2 transport that resets the + stream at the cap is planned for 6.1. +- **A timed-out request costs the connection.** Cancelling a corelibs task resets the HTTP/2 + connection it was on, so the next request reconnects. Requests multiplexed alongside it + still complete. +- **Cancellation is never done under a lock.** Cancelling a corelibs task while it completes + could deadlock the Swift concurrency pool (the client used to do exactly that). The client now + resumes your task first and cancels the transfer on a Dispatch thread. ## Testing @@ -218,6 +240,13 @@ headers, body, JWT signature checked with swift-crypto) and returns scripted res contacts APNs. To test your own code, pass a `URLSession` whose configuration's `protocolClasses` includes such a stub, or point `Environment.custom(host:port:)` at a local mock server. +The URLProtocol stub can't reproduce corelibs' real network timing, so the Linux deadlock, +timeout, redirect and endless-body regressions are covered by scripted probes outside the +package: a SwiftNIO HTTP/2-only server with adversarial behaviours (never answering, a 1 MiB +header, 5,000 headers, a cross-origin 307, a trickled or endless body) and a client that runs +the poison sequence, sub-second timeouts and hang bursts. Release verification runs them on +Swift 6.2 and 6.4, arm64 and x86_64, in containers with no network. + ## Migrating from 5.x 5.x registered named configurations on `NotificationPusher` and delivered results to callbacks. @@ -250,5 +279,9 @@ includes such a stub, or point `Environment.custom(host:port:)` at a local mock expirations are validated; payloads over the limit throw `.payloadTooLarge`. - `APNSClientError` has new cases (exhaustive `switch`es need updating), and the `init`s take a `timeout:`. `APNSExpiration` is `Equatable`, `APNSNotification.payload` is public. +- `APNSNotification(raw:)` throws: the bytes must be a JSON object with an `aps` dictionary, at + most 5120 bytes. Collapse IDs must be printable ASCII without spaces. +- The client sends through its own session built from the one you pass; only authentication + challenges reach your session's delegate. - swift-crypto is `4.0.0..<5.0.0` (was `from: "3.0.0"`, which meant 3.x only and couldn't resolve next to Perfect-SMTP 6). From fd19c9512afd0c76d56785d8e15d72945fac61b2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Sun, 25 Oct 2026 21:11:02 -0500 Subject: [PATCH 7/7] Never answer a redirect after cancelling; precise deadline timer The session delegate answered every redirect callback with nil, even when the deadline or the caller had already cancelled the task. corelibs then trapped ("Received callback for HTTP redirection, but we're not waiting for it"): the reviewer's race (MIX=c1 CANCEL_EVERY=1 OPS=1000) crashed 5/5 on 6.2 arm64 and on both amd64 toolchains; 3/3 here on the old commit. The redirect is now answered only while the request is live, and a cancel requested during the answer is held until the answer is in, so the answer always comes first. The answer itself runs outside the request's lock, so a synchronous call back into the delegate can't deadlock. Also: a request cancelled before it started cancels its never-resumed task, so the session can finish invalidating; the deadline is a DispatchSourceTimer with 10 ms leeway (asyncAfter fired 3-6% late on macOS). README: timeouts don't reset the connection, the race probe is listed, and the redirect/cancel ordering is described. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 +++-- .../PerfectNotifications/APNSTransport.swift | 64 ++++++++++++++++--- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7559eff..426fddc 100644 --- a/README.md +++ b/README.md @@ -226,12 +226,14 @@ concurrent sends over it. Three corelibs Foundation behaviours to know about: everything.) Reaching this needs a server that passes TLS verification for `api.push.apple.com` (or your custom host). A SwiftNIO HTTP/2 transport that resets the stream at the cap is planned for 6.1. -- **A timed-out request costs the connection.** Cancelling a corelibs task resets the HTTP/2 - connection it was on, so the next request reconnects. Requests multiplexed alongside it - still complete. -- **Cancellation is never done under a lock.** Cancelling a corelibs task while it completes - could deadlock the Swift concurrency pool (the client used to do exactly that). The client now - resumes your task first and cancels the transfer on a Dispatch thread. +- **Timeouts don't cost the connection.** A timed-out or cancelled request is reset on its own; + requests multiplexed alongside it complete, and later ones reuse the connection (150 + timeouts in a row used 2 connections). +- **Cancellation is never done under a lock, and never races a callback.** Cancelling a + corelibs task while it completes could deadlock the Swift concurrency pool (the client used + to do exactly that). Answering a redirect callback after the task was cancelled crashes the + process. The client resumes your task first, cancels the transfer on a Dispatch thread, and + holds a cancel back until a pending redirect has been refused. ## Testing @@ -244,7 +246,8 @@ The URLProtocol stub can't reproduce corelibs' real network timing, so the Linux timeout, redirect and endless-body regressions are covered by scripted probes outside the package: a SwiftNIO HTTP/2-only server with adversarial behaviours (never answering, a 1 MiB header, 5,000 headers, a cross-origin 307, a trickled or endless body) and a client that runs -the poison sequence, sub-second timeouts and hang bursts. Release verification runs them on +the poison sequence, sub-second timeouts, hang bursts, and a race that cancels every one of +1,000 sends while it is being redirected. Release verification runs them on Swift 6.2 and 6.4, arm64 and x86_64, in containers with no network. ## Migrating from 5.x diff --git a/Sources/PerfectNotifications/APNSTransport.swift b/Sources/PerfectNotifications/APNSTransport.swift index bf8c3d8..d1a503e 100644 --- a/Sources/PerfectNotifications/APNSTransport.swift +++ b/Sources/PerfectNotifications/APNSTransport.swift @@ -49,9 +49,20 @@ final class APNSTransport: Sendable { guard flight.attach(continuation) else { return } // cancelled before we started let task = session.dataTask(with: request) delegate.register(flight, for: task) - let timer = DispatchWorkItem { flight.finish(.failure(APNSClientError.timedOut)) } - guard flight.start(task: task, timer: timer) else { delegate.unregister(task); return } - DispatchQueue.global().asyncAfter(deadline: .now() + seconds, execute: timer) + // A timer source with a small leeway: `asyncAfter` on a global queue fired 3–6 % + // late on macOS. + let timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now() + seconds, leeway: .milliseconds(10)) + timer.setEventHandler { flight.finish(.failure(APNSClientError.timedOut)) } + timer.resume() + guard flight.start(task: task, timer: timer) else { + // Finished in the meantime (cancelled). Cancel the never-resumed task too, + // so the session can finish invalidating. + timer.cancel() + delegate.unregister(task) + DispatchQueue.global().async { task.cancel() } + return + } task.resume() } } onCancel: { @@ -70,7 +81,10 @@ private final class InFlight: @unchecked Sendable { private var continuation: CheckedContinuation<(Data, HTTPURLResponse), any Error>? private var pending: Result<(Data, HTTPURLResponse), any Error>? private var task: URLSessionTask? - private var timer: DispatchWorkItem? + private var timer: DispatchSourceTimer? + /// Set while the delegate is answering a redirect; a cancel requested meanwhile waits. + private var answeringRedirect = false + private var deferredCancel: URLSessionTask? private var done = false private var data = Data() private var response: HTTPURLResponse? @@ -90,7 +104,7 @@ private final class InFlight: @unchecked Sendable { } /// Returns `false` if the request finished in the meantime. - func start(task: URLSessionTask, timer: DispatchWorkItem) -> Bool { + func start(task: URLSessionTask, timer: DispatchSourceTimer) -> Bool { lock.withLock { guard !done else { return false } self.task = task @@ -142,7 +156,7 @@ private final class InFlight: @unchecked Sendable { /// Resolves the request once: resumes the caller, then cancels the transfer (off the /// caller's thread) if it is still running. func finish(_ result: Result<(Data, HTTPURLResponse), any Error>) { - let (c, task, timer): (CheckedContinuation<(Data, HTTPURLResponse), any Error>?, URLSessionTask?, DispatchWorkItem?) = lock.withLock { + let (c, task, timer): (CheckedContinuation<(Data, HTTPURLResponse), any Error>?, URLSessionTask?, DispatchSourceTimer?) = lock.withLock { guard !done else { return (nil, nil, nil) } done = true data = Data() @@ -152,7 +166,14 @@ private final class InFlight: @unchecked Sendable { } continuation = nil defer { self.task = nil; self.timer = nil } - return (c, self.task, self.timer) + var task = self.task + if answeringRedirect, case .failure = result { + // corelibs traps if its redirect callback is answered after the task was + // cancelled; cancel once the answer is in. + deferredCancel = task + task = nil + } + return (c, task, self.timer) } c?.resume(with: result) timer?.cancel() @@ -160,6 +181,28 @@ private final class InFlight: @unchecked Sendable { DispatchQueue.global().async { task.cancel() } } } + + /// Answers a redirect callback (with `respond`) only while the request is live, so the + /// answer always comes before any cancel. `respond` runs outside the lock, in case the + /// URL loading system calls back into the delegate synchronously; a cancel requested + /// meanwhile is held until it returns. + func refuseRedirect(_ respond: () -> Void) { + let live = lock.withLock { () -> Bool in + guard !done else { return false } + answeringRedirect = true + return true + } + // When the request is already done, its cancel has been issued, and that resolves the + // pending redirect on both platforms; answering it too would trap corelibs. + guard live else { return } + respond() + let cancel: URLSessionTask? = lock.withLock { + answeringRedirect = false + defer { deferredCancel = nil } + return deferredCancel + } + if let cancel { DispatchQueue.global().async { cancel.cancel() } } + } } // MARK: - Session delegate @@ -209,7 +252,12 @@ private final class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecke func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { - completionHandler(nil) + if let flight = flight(task) { + flight.refuseRedirect { completionHandler(nil) } + } else { + // Not ours (or already completed): refuse without touching any state. + completionHandler(nil) + } } // MARK: Challenges: forwarded to the caller's delegate