Skip to content

Commit 35220e9

Browse files
authored
Change AuthenticationManager to class with lock (#142)
1 parent a6c6410 commit 35220e9

7 files changed

+1206
-431
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the APNSwift open source project
4+
//
5+
// Copyright (c) 2022 the APNSwift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of APNSwift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Crypto
16+
import Dispatch
17+
import Logging
18+
import NIOConcurrencyHelpers
19+
20+
/// A class to manage the authentication tokens for a single APNS connection.
21+
final class APNSAuthenticationTokenManager {
22+
private struct Token {
23+
/// This is the actual JWT token prefixed with `bearer`.
24+
///
25+
/// This is stored as a ``String`` since we use it as an HTTP headers.
26+
var token: String
27+
var issuedAt: DispatchWallTime
28+
}
29+
30+
/// APNS rejects any token that is more than 1 hour old. We set the duration to be slightly less to refresh earlier.
31+
private static let expirationDurationInSeconds: Int = 60 * 55
32+
33+
/// The private key used for signing the tokens.
34+
private let privateKey: P256.Signing.PrivateKey
35+
/// The private key's team identifier.
36+
private let teamIdentifier: String
37+
/// The private key's identifier.
38+
private let keyIdentifier: String
39+
/// The logger.
40+
private let logger: Logger
41+
/// A closure to get the current time. This allows for properly testing the behaviour.
42+
/// Furthermore, we can expose this to clients at some point if they want to provide an NTP synced date.
43+
private let currentTimeFactory: () -> DispatchWallTime
44+
45+
/// The lock used to protect access to the ``lastGeneratedToken``.
46+
private let lock = Lock()
47+
48+
/// The last generated token.
49+
private var lastGeneratedToken: Token?
50+
51+
/// Initializes a new ``APNSAuthenticationTokenManager``.
52+
///
53+
/// - Parameters:
54+
/// - privateKey: The private key used for signing the tokens.
55+
/// - teamIdentifier: The private key's team identifier.
56+
/// - keyIdentifier: The private key's identifier.
57+
/// - logger: The logger.
58+
/// - currentTimeFactory: A closure to get the current time.
59+
init(
60+
privateKey: P256.Signing.PrivateKey,
61+
teamIdentifier: String,
62+
keyIdentifier: String,
63+
logger: Logger,
64+
currentTimeFactory: @escaping () -> DispatchWallTime = { .now() }
65+
) {
66+
self.privateKey = privateKey
67+
self.teamIdentifier = teamIdentifier
68+
self.keyIdentifier = keyIdentifier
69+
self.logger = logger
70+
self.currentTimeFactory = currentTimeFactory
71+
}
72+
73+
/// This returns the next valid token.
74+
///
75+
/// If there is a previously generated token that is still valid it will be returned, otherwise a fresh token will be generated.
76+
var nextValidToken: String {
77+
get throws {
78+
// First we check if there is a previously generated token
79+
// and if that token is still valid.
80+
if let lastGeneratedToken = lastGeneratedToken,
81+
self.currentTimeFactory().asSecondsSince1970 - lastGeneratedToken.issuedAt.asSecondsSince1970 < Self
82+
.expirationDurationInSeconds
83+
{
84+
// The last generated token is still valid
85+
86+
logger.debug(
87+
"APNSAuthenticationTokenManager reusing previously generated token",
88+
metadata: [
89+
LoggingKeys.authenticationTokenIssuedAt: "\(lastGeneratedToken.issuedAt)",
90+
LoggingKeys.authenticationTokenIssuer: "\(teamIdentifier)",
91+
LoggingKeys.authenticationTokenKeyID: "\(keyIdentifier)",
92+
]
93+
)
94+
return lastGeneratedToken.token
95+
} else {
96+
let token = try generateNewToken(
97+
privateKey: privateKey,
98+
teamIdentifier: teamIdentifier,
99+
keyIdentifier: keyIdentifier
100+
)
101+
lastGeneratedToken = token
102+
103+
return token.token
104+
}
105+
}
106+
}
107+
108+
private func generateNewToken(
109+
privateKey: P256.Signing.PrivateKey,
110+
teamIdentifier: String,
111+
keyIdentifier: String
112+
) throws -> Token {
113+
let header = """
114+
{
115+
"alg": "ES256",
116+
"typ": "JWT",
117+
"kid": "\(keyIdentifier)"
118+
}
119+
"""
120+
121+
let issueAtTime = currentTimeFactory()
122+
let payload = """
123+
{
124+
"iss": "\(teamIdentifier)",
125+
"iat": "\(issueAtTime.asSecondsSince1970)",
126+
"kid": "\(keyIdentifier)"
127+
}
128+
"""
129+
130+
// The header and the payload need to be base64 encoded
131+
// before we can sign them
132+
let encodedHeader = Base64.encodeBytes(bytes: header.utf8, options: [.base64UrlAlphabet, .omitPaddingCharacter])
133+
let encodedPayload = Base64.encodeBytes(
134+
bytes: payload.utf8,
135+
options: [.base64UrlAlphabet, .omitPaddingCharacter]
136+
)
137+
let period = UInt8(ascii: ".")
138+
139+
var encodedData = [UInt8]()
140+
/// This should fit the whole JWT token. I arrived at the number
141+
/// by generating a bunch of tokens and took the upper limit + some.
142+
encodedData.reserveCapacity(400)
143+
encodedData.append(contentsOf: encodedHeader)
144+
encodedData.append(period)
145+
encodedData.append(contentsOf: encodedPayload)
146+
147+
let signatureData = try privateKey.signature(for: encodedData)
148+
let base64Signature = Base64.encodeBytes(
149+
bytes: signatureData.rawRepresentation,
150+
options: [.base64UrlAlphabet, .omitPaddingCharacter]
151+
)
152+
153+
encodedData.append(period)
154+
encodedData.append(contentsOf: base64Signature)
155+
156+
logger.debug(
157+
"APNSAuthenticationTokenManager generated new token",
158+
metadata: [
159+
LoggingKeys.authenticationTokenIssuedAt: "\(issueAtTime)",
160+
LoggingKeys.authenticationTokenIssuer: "\(teamIdentifier)",
161+
LoggingKeys.authenticationTokenKeyID: "\(keyIdentifier)",
162+
]
163+
)
164+
165+
// We are prefixing the token here to avoid an additional
166+
// allocation for setting the header.
167+
return Token(
168+
token: "bearer " + String(decoding: encodedData, as: UTF8.self),
169+
issuedAt: issueAtTime
170+
)
171+
}
172+
}
173+
174+
private extension DispatchWallTime {
175+
var asSecondsSince1970: Int64 {
176+
-Int64(bitPattern: rawValue) / 1_000_000_000
177+
}
178+
}

Sources/APNSwift/APNSBearerTokenFactory.swift

-62
This file was deleted.

Sources/APNSwift/APNSClient.swift

+7-5
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public final class APNSClient {
2424
label: "APNS-do-not-log", factory: { _ in SwiftLogNoOpLogHandler() })
2525

2626
private let configuration: APNSConfiguration
27-
private let bearerTokenFactory: APNSBearerTokenFactory
27+
private let authenticationTokenManager: APNSAuthenticationTokenManager
2828
private let httpClient: HTTPClient
2929

3030
internal let jsonEncoder = JSONEncoder()
@@ -37,9 +37,11 @@ public final class APNSClient {
3737
configuration: APNSConfiguration
3838
) {
3939
self.configuration = configuration
40-
self.bearerTokenFactory = APNSBearerTokenFactory(
41-
authenticationConfig: configuration.authenticationConfig,
42-
logger: configuration.logger
40+
self.authenticationTokenManager = APNSAuthenticationTokenManager(
41+
privateKey: configuration.authenticationConfig.privateKey,
42+
teamIdentifier: configuration.authenticationConfig.teamIdentifier,
43+
keyIdentifier: configuration.authenticationConfig.keyIdentifier,
44+
logger: configuration.logger ?? Self.loggingDisabled
4345
)
4446

4547
let httpClientConfiguration = HTTPClient.Configuration(
@@ -112,7 +114,7 @@ public final class APNSClient {
112114
request.headers.add(name: "apns-collapse-id", value: collapseId)
113115
}
114116

115-
let bearerToken = try await bearerTokenFactory.getCurrentBearerToken()
117+
let bearerToken = try authenticationTokenManager.nextValidToken
116118

117119
request.headers.add(name: "authorization", value: "bearer \(bearerToken)")
118120

Sources/APNSwift/APNSSigner.swift

-46
This file was deleted.

0 commit comments

Comments
 (0)