33using Org . BouncyCastle . Crypto . Parameters ;
44using Org . BouncyCastle . Security ;
55using System ;
6+ using System . Collections . Concurrent ;
67using System . Collections . Generic ;
78using System . Net . Http ;
89using System . Security . Cryptography ;
@@ -16,39 +17,27 @@ namespace CorePush.Apple
1617 /// </summary>
1718 public class ApnSender : IApnSender
1819 {
20+ private static readonly ConcurrentDictionary < string , Tuple < string , DateTime > > tokens = new ConcurrentDictionary < string , Tuple < string , DateTime > > ( ) ;
1921 private static readonly Dictionary < ApnServerType , string > servers = new Dictionary < ApnServerType , string >
2022 {
2123 { ApnServerType . Development , "https://api.development.push.apple.com:443" } ,
2224 { ApnServerType . Production , "https://api.push.apple.com:443" }
2325 } ;
2426
2527 private const string apnidHeader = "apns-id" ;
28+ private const int tokenExpiresMinutes = 50 ;
2629
27- private readonly string p8privateKey ;
28- private readonly string p8privateKeyId ;
29- private readonly string teamId ;
30- private readonly string appBundleIdentifier ;
31- private readonly ApnServerType server ;
32- private readonly Lazy < string > jwtToken ;
33- private readonly Lazy < HttpClient > http ;
30+ private readonly ApnSettings settings ;
31+ private readonly HttpClient http ;
3432
3533 /// <summary>
36- /// Initialize sender
34+ /// Apple push notification sender constructor
3735 /// </summary>
38- /// <param name="p8privateKey">p8 certificate string</param>
39- /// <param name="privateKeyId">10 digit p8 certificate id. Usually a part of a downloadable certificate filename</param>
40- /// <param name="teamId">Apple 10 digit team id</param>
41- /// <param name="appBundleIdentifier">App slug / bundle name</param>
42- /// <param name="server">Development or Production server</param>
43- public ApnSender ( string p8privateKey , string p8privateKeyId , string teamId , string appBundleIdentifier , ApnServerType server )
36+ /// <param name="settings">Apple Push Notification settings</param>
37+ public ApnSender ( ApnSettings settings , HttpClient http )
4438 {
45- this . p8privateKey = p8privateKey ;
46- this . p8privateKeyId = p8privateKeyId ;
47- this . teamId = teamId ;
48- this . server = server ;
49- this . appBundleIdentifier = appBundleIdentifier ;
50- this . jwtToken = new Lazy < string > ( ( ) => CreateJwtToken ( ) ) ;
51- this . http = new Lazy < HttpClient > ( ( ) => new HttpClient ( ) ) ;
39+ this . settings = settings ?? throw new ArgumentNullException ( nameof ( settings ) ) ;
40+ this . http = http ?? throw new ArgumentNullException ( nameof ( http ) ) ;
5241 }
5342
5443 /// <summary>
@@ -70,15 +59,15 @@ public async Task<ApnsResponse> SendAsync(
7059 var path = $ "/3/device/{ deviceToken } ";
7160 var json = JsonHelper . Serialize ( notification ) ;
7261
73- var request = new HttpRequestMessage ( HttpMethod . Post , new Uri ( servers [ server ] + path ) )
62+ var request = new HttpRequestMessage ( HttpMethod . Post , new Uri ( servers [ settings . ServerType ] + path ) )
7463 {
7564 Version = new Version ( 2 , 0 ) ,
7665 Content = new StringContent ( json )
7766 } ;
78- request . Headers . Authorization = new System . Net . Http . Headers . AuthenticationHeaderValue ( "bearer" , jwtToken . Value ) ;
67+ request . Headers . Authorization = new System . Net . Http . Headers . AuthenticationHeaderValue ( "bearer" , GetJwtToken ( ) ) ;
7968 request . Headers . TryAddWithoutValidation ( ":method" , "POST" ) ;
8069 request . Headers . TryAddWithoutValidation ( ":path" , path ) ;
81- request . Headers . Add ( "apns-topic" , appBundleIdentifier ) ;
70+ request . Headers . Add ( "apns-topic" , settings . AppBundleIdentifier ) ;
8271 request . Headers . Add ( "apns-expiration" , apnsExpiration . ToString ( ) ) ;
8372 request . Headers . Add ( "apns-priority" , apnsPriority . ToString ( ) ) ;
8473 request . Headers . Add ( "apns-push-type" , isBackground ? "background" : "alert" ) ; // for iOS 13 required
@@ -87,7 +76,7 @@ public async Task<ApnsResponse> SendAsync(
8776 request . Headers . Add ( apnidHeader , apnsId ) ;
8877 }
8978
90- using var response = await http . Value . SendAsync ( request ) ;
79+ using var response = await http . SendAsync ( request ) ;
9180 var succeed = response . IsSuccessStatusCode ;
9281 var content = await response . Content . ReadAsStringAsync ( ) ;
9382 var error = JsonHelper . Deserialize < ApnsError > ( content ) ;
@@ -99,15 +88,27 @@ public async Task<ApnsResponse> SendAsync(
9988 } ;
10089 }
10190
91+ private string GetJwtToken ( )
92+ {
93+ var ( token , date ) = tokens . GetOrAdd ( settings . AppBundleIdentifier , _ => new Tuple < string , DateTime > ( CreateJwtToken ( ) , DateTime . UtcNow ) ) ;
94+ if ( date < DateTime . UtcNow . AddMinutes ( - tokenExpiresMinutes ) )
95+ {
96+ tokens . TryRemove ( settings . AppBundleIdentifier , out _ ) ;
97+ return GetJwtToken ( ) ;
98+ }
99+
100+ return token ;
101+ }
102+
102103 private string CreateJwtToken ( )
103104 {
104- var header = JsonHelper . Serialize ( new { alg = "ES256" , kid = p8privateKeyId } ) ;
105- var payload = JsonHelper . Serialize ( new { iss = teamId , iat = ToEpoch ( DateTime . UtcNow ) } ) ;
105+ var header = JsonHelper . Serialize ( new { alg = "ES256" , kid = settings . P8PrivateKeyId } ) ;
106+ var payload = JsonHelper . Serialize ( new { iss = settings . TeamId , iat = ToEpoch ( DateTime . UtcNow ) } ) ;
106107 var headerBase64 = Convert . ToBase64String ( Encoding . UTF8 . GetBytes ( header ) ) ;
107108 var payloadBasae64 = Convert . ToBase64String ( Encoding . UTF8 . GetBytes ( payload ) ) ;
108109 var unsignedJwtData = $ "{ headerBase64 } .{ payloadBasae64 } ";
109110 var unsignedJwtBytes = Encoding . UTF8 . GetBytes ( unsignedJwtData ) ;
110- using var dsa = GetEllipticCurveAlgorithm ( p8privateKey ) ;
111+ using var dsa = GetEllipticCurveAlgorithm ( settings . P8PrivateKey ) ;
111112 var signature = dsa . SignData ( unsignedJwtBytes , 0 , unsignedJwtBytes . Length , HashAlgorithmName . SHA256 ) ;
112113
113114 return $ "{ unsignedJwtData } .{ Convert . ToBase64String ( signature ) } ";
@@ -119,20 +120,11 @@ private static int ToEpoch(DateTime time)
119120 return Convert . ToInt32 ( span . TotalSeconds ) ;
120121 }
121122
122- public void Dispose ( )
123- {
124- if ( http . IsValueCreated )
125- {
126- http . Value . Dispose ( ) ;
127- }
128- }
129-
123+ // TODO: I'd like to get rid of BouncyCastle dependency...
130124 // Needed to run on docker linux: ECDsa.Create("ECDsaCng") would generate PlatformNotSupportedException: Windows Cryptography Next Generation (CNG) is not supported on this platform.
131125 private static ECDsa GetEllipticCurveAlgorithm ( string privateKey )
132126 {
133- var keyParams = ( ECPrivateKeyParameters ) PrivateKeyFactory
134- . CreateKey ( Convert . FromBase64String ( privateKey ) ) ;
135-
127+ var keyParams = ( ECPrivateKeyParameters ) PrivateKeyFactory . CreateKey ( Convert . FromBase64String ( privateKey ) ) ;
136128 var q = keyParams . Parameters . G . Multiply ( keyParams . D ) . Normalize ( ) ;
137129
138130 return ECDsa . Create ( new ECParameters
0 commit comments