Skip to content

Commit 1f9d864

Browse files
authored
Validate requests in APNSTestServer and fix JWT claim encoding (#246)
1 parent 12878da commit 1f9d864

7 files changed

Lines changed: 546 additions & 64 deletions

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ let package = Package(
7474
.product(name: "NIOSSL", package: "swift-nio-ssl"),
7575
.product(name: "NIOHTTP1", package: "swift-nio"),
7676
.product(name: "NIOHTTP2", package: "swift-nio-http2"),
77+
.product(name: "NIOConcurrencyHelpers", package: "swift-nio"),
7778
]
7879
),
7980
.target(

Package@swift-5.10.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ let package = Package(
7474
.product(name: "NIOSSL", package: "swift-nio-ssl"),
7575
.product(name: "NIOHTTP1", package: "swift-nio"),
7676
.product(name: "NIOHTTP2", package: "swift-nio-http2"),
77+
.product(name: "NIOConcurrencyHelpers", package: "swift-nio"),
7778
]
7879
),
7980
.target(

Sources/APNSCore/APNSAuthenticationTokenManager.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,12 @@ public final actor APNSAuthenticationTokenManager<Clock: _Concurrency.Clock> whe
100100
"""
101101

102102
let issueAtTime = DispatchWallTime.now()
103+
// `iat` is a JWT NumericDate (RFC 7519) and MUST be emitted as a JSON number, not a string.
104+
// `kid` belongs only in the header (RFC 7515 / Apple's docs) and must not be duplicated here.
103105
let payload = """
104106
{
105107
"iss": "\(teamIdentifier)",
106-
"iat": "\(issueAtTime.asSecondsSince1970)",
107-
"kid": "\(keyIdentifier)"
108+
"iat": \(issueAtTime.asSecondsSince1970)
108109
}
109110
"""
110111

Sources/APNSTestServer/APNSTestServer.swift

Lines changed: 301 additions & 42 deletions
Large diffs are not rendered by default.

Tests/APNSTests/APNSAuthenticationTokenManagerTests.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,19 @@ final class APNSAuthenticationTokenManagerTests: XCTestCase {
7373
string: String(splitToken[1]),
7474
options: [.base64UrlAlphabet, .omitPaddingCharacter]
7575
)
76-
let payload = String(bytes: decodedPayload, encoding: .utf8)
77-
let issuedAtTime = DispatchWallTime.now()
78-
let expectedPayload = """
79-
{
80-
"iss": "foo",
81-
"iat": "\(issuedAtTime.asSecondsSince1970)",
82-
"kid": "bar"
76+
77+
// The expected `iat` is computed *after* token generation, so comparing raw strings
78+
// is flaky across second boundaries. Instead, decode the JSON and assert `iss` plus
79+
// an `iat` that's within a few seconds of "now".
80+
struct Payload: Decodable {
81+
let iss: String
82+
let iat: Int64
8383
}
84-
"""
85-
XCTAssertEqual(payload, expectedPayload)
84+
let payload = try JSONDecoder().decode(Payload.self, from: Data(decodedPayload))
85+
let now = Date().timeIntervalSince1970
86+
87+
XCTAssertEqual(payload.iss, "foo")
88+
XCTAssertEqual(Double(payload.iat), now, accuracy: 5)
8689
}
8790

8891
func testTokenIsReused() async throws {
@@ -140,7 +143,7 @@ final class APNSAuthenticationTokenManagerTests: XCTestCase {
140143

141144
let payload = try decodeSegment(segments[1])
142145
XCTAssertTrue(payload.contains("\"iss\": \"foo\""))
143-
XCTAssertTrue(payload.contains("\"kid\": \"bar\""))
146+
XCTAssertFalse(payload.contains("\"kid\""), "kid must not be duplicated into the payload")
144147
}
145148

146149
private func decodeSegment(_ segment: Substring) throws -> String {

Tests/APNSTests/APNSClientSendTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ final class APNSClientSendTests: XCTestCase {
5757
let response = try await client.sendAlertNotification(Self.makeAlert(), deviceToken: Self.validDeviceToken)
5858

5959
XCTAssertNotNil(response.apnsID)
60+
// The mock server simulates the development environment, which always returns apns-unique-id.
61+
XCTAssertNotNil(response.apnsUniqueID)
6062
XCTAssertEqual(server.getSentNotifications().count, 1)
6163
}
6264

Tests/APNSTests/APNSTestServerValidationTests.swift

Lines changed: 225 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ import NIOCore
2121
import NIOHTTP1
2222
import AsyncHTTPClient
2323

24+
/// Builds a fake (unsigned) provider authentication token in the shape the mock server expects:
25+
/// `base64url(header).base64url(payload).base64url(signature)`. The server never verifies the
26+
/// ES256 signature (it has no public key), so any bytes work there.
27+
private func makeTestJWT(iss: String = "team", kid: String = "key", iatOffset: TimeInterval = 0) -> String {
28+
func base64url(_ string: String) -> String {
29+
Data(string.utf8).base64EncodedString()
30+
.replacingOccurrences(of: "+", with: "-")
31+
.replacingOccurrences(of: "/", with: "_")
32+
.replacingOccurrences(of: "=", with: "")
33+
}
34+
35+
let header = "{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\":\"\(kid)\"}"
36+
let iat = Int(Date().timeIntervalSince1970 + iatOffset)
37+
let payload = "{\"iss\":\"\(iss)\",\"iat\":\(iat)}"
38+
39+
return "\(base64url(header)).\(base64url(payload)).\(base64url("sig"))"
40+
}
41+
2442
final class APNSTestServerValidationTests: XCTestCase {
2543
var server: APNSTestServer!
2644
var httpClient: HTTPClient!
@@ -270,6 +288,7 @@ final class APNSTestServerValidationTests: XCTestCase {
270288
request.headers.add(name: "apns-topic", value: "com.example.app")
271289
request.headers.add(name: "apns-push-type", value: "alert")
272290
request.headers.add(name: "content-type", value: "application/json")
291+
request.headers.add(name: "authorization", value: "bearer \(makeTestJWT())")
273292
request.body = .bytes(ByteBuffer(string: "{}"))
274293

275294
let response = try await httpClient.execute(request, timeout: .seconds(30))
@@ -285,6 +304,7 @@ final class APNSTestServerValidationTests: XCTestCase {
285304
func testMethodNotAllowed_GET() async throws {
286305
var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
287306
request.method = .GET
307+
request.headers.add(name: "authorization", value: "bearer \(makeTestJWT())")
288308

289309
let response = try await httpClient.execute(request, timeout: .seconds(30))
290310
let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024)
@@ -297,6 +317,7 @@ final class APNSTestServerValidationTests: XCTestCase {
297317
func testMethodNotAllowed_PUT() async throws {
298318
var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
299319
request.method = .PUT
320+
request.headers.add(name: "authorization", value: "bearer \(makeTestJWT())")
300321

301322
let response = try await httpClient.execute(request, timeout: .seconds(30))
302323
let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024)
@@ -309,6 +330,7 @@ final class APNSTestServerValidationTests: XCTestCase {
309330
func testMethodNotAllowed_DELETE() async throws {
310331
var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
311332
request.method = .DELETE
333+
request.headers.add(name: "authorization", value: "bearer \(makeTestJWT())")
312334

313335
let response = try await httpClient.execute(request, timeout: .seconds(30))
314336
let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024)
@@ -348,32 +370,44 @@ final class APNSTestServerValidationTests: XCTestCase {
348370
let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024)
349371
let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? ""
350372

351-
// Wrong version falls through to generic NotFound (not /3/...)
373+
// Wrong version falls through to the generic bad-path case (not /3/...)
352374
XCTAssertEqual(response.status, .notFound)
353-
XCTAssertTrue(bodyString.contains("NotFound"))
375+
XCTAssertTrue(bodyString.contains("BadPath"))
354376
}
355377

356378
// MARK: - Valid Push Types Test
357379

358380
func testValidPushTypes() async throws {
359-
let validTypes = ["alert", "background", "location", "voip", "complication",
360-
"fileprovider", "mdm", "liveactivity", "pushtotalk", "widgets"]
361-
362-
for pushType in validTypes {
381+
// Each push type that enforces a topic suffix must be sent with a matching topic.
382+
let topicByPushType: [String: String] = [
383+
"alert": "com.example.app",
384+
"background": "com.example.app",
385+
"location": "com.example.app.location-query",
386+
"voip": "com.example.app.voip",
387+
"complication": "com.example.app.complication",
388+
"fileprovider": "com.example.app.pushkit.fileprovider",
389+
"mdm": "com.example.app",
390+
"liveactivity": "com.example.app.push-type.liveactivity",
391+
"pushtotalk": "com.example.app.voip-ptt",
392+
"widgets": "com.example.app.push-type.widgets",
393+
"controls": "com.example.app.push-type.controls",
394+
]
395+
396+
for (pushType, topic) in topicByPushType {
363397
let response = try await sendRawNotification(
364398
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
365-
topic: "com.example.app",
399+
topic: topic,
366400
pushType: pushType
367401
)
368402

369-
XCTAssertEqual(response.status, .ok, "Push type '\(pushType)' should be valid")
403+
XCTAssertEqual(response.status, .ok, "Push type '\(pushType)' should be valid, got: \(response.body)")
370404
}
371405
}
372406

373407
// MARK: - Valid Priorities Test
374408

375409
func testValidPriorities() async throws {
376-
for priority in ["5", "10"] {
410+
for priority in ["1", "5", "10"] {
377411
let response = try await sendRawNotification(
378412
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
379413
topic: "com.example.app",
@@ -383,6 +417,183 @@ final class APNSTestServerValidationTests: XCTestCase {
383417

384418
XCTAssertEqual(response.status, .ok, "Priority '\(priority)' should be valid")
385419
}
420+
421+
// `background` pushes must pair with priority 5 — priority 10 is rejected (BadPriority).
422+
let backgroundResponse = try await sendRawNotification(
423+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
424+
topic: "com.example.app",
425+
pushType: "background",
426+
priority: "5"
427+
)
428+
XCTAssertEqual(backgroundResponse.status, .ok)
429+
}
430+
431+
// MARK: - Authorization Tests
432+
433+
func testMissingAuthorization_returnsMissingProviderToken() async throws {
434+
let response = try await sendRawNotification(
435+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
436+
topic: "com.example.app",
437+
pushType: "alert",
438+
authorization: nil
439+
)
440+
441+
XCTAssertEqual(response.status, .forbidden)
442+
XCTAssertTrue(response.body.contains("MissingProviderToken"))
443+
}
444+
445+
func testGarbageAuthorization_returnsInvalidProviderToken() async throws {
446+
let response = try await sendRawNotification(
447+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
448+
topic: "com.example.app",
449+
pushType: "alert",
450+
authorization: "bearer not-a-real-token"
451+
)
452+
453+
XCTAssertEqual(response.status, .forbidden)
454+
XCTAssertTrue(response.body.contains("InvalidProviderToken"))
455+
}
456+
457+
func testStringIatToken_returnsInvalidProviderToken() async throws {
458+
// Pins the library-side fix: `iat` must be a JSON number, not a string (RFC 7519 NumericDate).
459+
func base64url(_ string: String) -> String {
460+
Data(string.utf8).base64EncodedString()
461+
.replacingOccurrences(of: "+", with: "-")
462+
.replacingOccurrences(of: "/", with: "_")
463+
.replacingOccurrences(of: "=", with: "")
464+
}
465+
let header = base64url("{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\":\"key\"}")
466+
let payload = base64url("{\"iss\":\"team\",\"iat\":\"\(Int(Date().timeIntervalSince1970))\"}")
467+
let token = "\(header).\(payload).\(base64url("sig"))"
468+
469+
let response = try await sendRawNotification(
470+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
471+
topic: "com.example.app",
472+
pushType: "alert",
473+
authorization: "bearer \(token)"
474+
)
475+
476+
XCTAssertEqual(response.status, .forbidden)
477+
XCTAssertTrue(response.body.contains("InvalidProviderToken"))
478+
}
479+
480+
func testExpiredIat_returnsExpiredProviderToken() async throws {
481+
let response = try await sendRawNotification(
482+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
483+
topic: "com.example.app",
484+
pushType: "alert",
485+
authorization: "bearer \(makeTestJWT(iatOffset: -3700))"
486+
)
487+
488+
XCTAssertEqual(response.status, .forbidden)
489+
XCTAssertTrue(response.body.contains("ExpiredProviderToken"))
490+
}
491+
492+
// MARK: - Topic Suffix Enforcement
493+
494+
func testWrongTopicSuffix_voip_returnsBadTopic() async throws {
495+
let response = try await sendRawNotification(
496+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
497+
topic: "com.example.app", // missing the required `.voip` suffix
498+
pushType: "voip"
499+
)
500+
501+
XCTAssertEqual(response.status, .badRequest)
502+
XCTAssertTrue(response.body.contains("BadTopic"))
503+
}
504+
505+
// MARK: - Background Priority
506+
507+
func testBackgroundWithPriority10_returnsBadPriority() async throws {
508+
let response = try await sendRawNotification(
509+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
510+
topic: "com.example.app",
511+
pushType: "background",
512+
priority: "10"
513+
)
514+
515+
XCTAssertEqual(response.status, .badRequest)
516+
XCTAssertTrue(response.body.contains("BadPriority"))
517+
}
518+
519+
// MARK: - Per-Push-Type Payload Limits
520+
521+
func testVoIPPayload_5000Bytes_accepted() async throws {
522+
let overhead = "{\"data\":\"\"}".utf8.count
523+
let payload = "{\"data\":\"" + String(repeating: "x", count: 5000 - overhead) + "\"}"
524+
XCTAssertEqual(payload.utf8.count, 5000)
525+
526+
let response = try await sendRawNotification(
527+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
528+
topic: "com.example.app.voip",
529+
pushType: "voip",
530+
body: payload
531+
)
532+
533+
XCTAssertEqual(response.status, .ok, "got: \(response.body)")
534+
}
535+
536+
func testVoIPPayload_5200Bytes_rejected() async throws {
537+
let overhead = "{\"data\":\"\"}".utf8.count
538+
let payload = "{\"data\":\"" + String(repeating: "x", count: 5200 - overhead) + "\"}"
539+
XCTAssertEqual(payload.utf8.count, 5200)
540+
541+
let response = try await sendRawNotification(
542+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
543+
topic: "com.example.app.voip",
544+
pushType: "voip",
545+
body: payload
546+
)
547+
548+
XCTAssertEqual(response.status, .badRequest)
549+
XCTAssertTrue(response.body.contains("PayloadTooLarge"))
550+
}
551+
552+
// MARK: - Malformed apns-id
553+
554+
func testMalformedAPNSID_returnsBadMessageId() async throws {
555+
var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
556+
request.method = .POST
557+
request.headers.add(name: "apns-topic", value: "com.example.app")
558+
request.headers.add(name: "apns-push-type", value: "alert")
559+
request.headers.add(name: "apns-id", value: "not-a-uuid")
560+
request.headers.add(name: "authorization", value: "bearer \(makeTestJWT())")
561+
request.headers.add(name: "content-type", value: "application/json")
562+
request.body = .bytes(ByteBuffer(string: "{}"))
563+
564+
let response = try await httpClient.execute(request, timeout: .seconds(30))
565+
let bodyBuffer = try await response.body.collect(upTo: 1024 * 1024)
566+
let bodyString = bodyBuffer.getString(at: 0, length: bodyBuffer.readableBytes) ?? ""
567+
568+
XCTAssertEqual(response.status, .badRequest)
569+
XCTAssertTrue(bodyString.contains("BadMessageId"))
570+
}
571+
572+
// MARK: - Response Override
573+
574+
func testResponseOverride_forcesStatus() async throws {
575+
server.setResponseOverride(.init(status: 500, body: "{\"reason\":\"InternalServerError\"}"))
576+
577+
let response = try await sendRawNotification(
578+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
579+
topic: "com.example.app",
580+
pushType: "alert"
581+
)
582+
583+
XCTAssertEqual(response.status, .internalServerError)
584+
XCTAssertTrue(response.body.contains("InternalServerError"))
585+
XCTAssertEqual(
586+
server.getSentNotifications().count, 0,
587+
"An overridden response must not be recorded as a sent notification"
588+
)
589+
590+
// The override is consumed after a single use — the next request goes through normal handling.
591+
let secondResponse = try await sendRawNotification(
592+
deviceToken: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
593+
topic: "com.example.app",
594+
pushType: "alert"
595+
)
596+
XCTAssertEqual(secondResponse.status, .ok)
386597
}
387598

388599
// MARK: - Helper Methods
@@ -394,7 +605,8 @@ final class APNSTestServerValidationTests: XCTestCase {
394605
priority: String? = nil,
395606
expiration: String? = nil,
396607
collapseID: String? = nil,
397-
body: String? = "{}"
608+
body: String? = "{}",
609+
authorization: String? = "bearer \(makeTestJWT())"
398610
) async throws -> (status: HTTPResponseStatus, body: String) {
399611
var request = HTTPClientRequest(url: "http://127.0.0.1:\(server.port)/3/device/\(deviceToken)")
400612
request.method = .POST
@@ -414,6 +626,9 @@ final class APNSTestServerValidationTests: XCTestCase {
414626
if let collapseID = collapseID {
415627
request.headers.add(name: "apns-collapse-id", value: collapseID)
416628
}
629+
if let authorization = authorization {
630+
request.headers.add(name: "authorization", value: authorization)
631+
}
417632

418633
request.headers.add(name: "content-type", value: "application/json")
419634

0 commit comments

Comments
 (0)