using CorePush.Interfaces; using CorePush.Utils; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; namespace CorePush.Apple { /// /// HTTP2 Apple Push Notification sender /// public class ApnSender : IApnSender { private static readonly ConcurrentDictionary> tokens = new ConcurrentDictionary>(); private static readonly Dictionary servers = new Dictionary { {ApnServerType.Development, "https://api.development.push.apple.com:443" }, {ApnServerType.Production, "https://api.push.apple.com:443" } }; private const string apnidHeader = "apns-id"; private const int tokenExpiresMinutes = 50; private readonly ApnSettings settings; private readonly HttpClient http; /// /// Apple push notification sender constructor /// /// Apple Push Notification settings public ApnSender(ApnSettings settings, HttpClient http) { this.settings = settings ?? throw new ArgumentNullException(nameof(settings)); this.http = http ?? throw new ArgumentNullException(nameof(http)); } /// /// Serialize and send notification to APN. Please see how your message should be formatted here: /// https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW1 /// Payload will be serialized using Newtonsoft.Json package. /// !IMPORTANT: If you send many messages at once, make sure to retry those calls. Apple typically doesn't like /// to receive too many requests and may ocasionally respond with HTTP 429. Just try/catch this call and retry as needed. /// /// Throws exception when not successful /// public ApnsResponse Send(object notification, string deviceToken, string apnsId = null, int apnsExpiration = 0, int apnsPriority = 10, bool isBackground = false, CancellationToken cancellationToken = default) { var task= SendAsync(notification, deviceToken, apnsId, apnsExpiration, apnsPriority, isBackground, cancellationToken); task.Wait(); return task.Result; } public async Task SendAsync( object notification, string deviceToken, string apnsId = null, int apnsExpiration = 0, int apnsPriority = 10, bool isBackground = false, CancellationToken cancellationToken = default) { var path = $"/3/device/{deviceToken}"; var json = JsonHelper.Serialize(notification); var request = new HttpRequestMessage(HttpMethod.Post, new Uri(servers[settings.ServerType] + path)) { Version = new Version(2, 0), Content = new StringContent(json) }; request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("bearer", GetJwtToken()); request.Headers.TryAddWithoutValidation(":method", "POST"); request.Headers.TryAddWithoutValidation(":path", path); request.Headers.Add("apns-topic", settings.AppBundleIdentifier); request.Headers.Add("apns-expiration", apnsExpiration.ToString()); request.Headers.Add("apns-priority", apnsPriority.ToString()); request.Headers.Add("apns-push-type", isBackground ? "background" : "alert"); // for iOS 13 required if (!string.IsNullOrWhiteSpace(apnsId)) { request.Headers.Add(apnidHeader, apnsId); } using (var response = await http.SendAsync(request, cancellationToken)) { var succeed = response.IsSuccessStatusCode; var content = await response.Content.ReadAsStringAsync(); var error = JsonHelper.Deserialize(content); return new ApnsResponse { IsSuccess = succeed, Error = error }; } } private string GetJwtToken() { var (token, date) = tokens.GetOrAdd(settings.AppBundleIdentifier, _ => new Tuple(CreateJwtToken(), DateTime.UtcNow)); if (date < DateTime.UtcNow.AddMinutes(-tokenExpiresMinutes)) { tokens.TryRemove(settings.AppBundleIdentifier, out _); return GetJwtToken(); } return token; } private string CreateJwtToken() { var header = JsonHelper.Serialize(new { alg = "ES256", kid = CleanP8Key(settings.P8PrivateKeyId) }); var payload = JsonHelper.Serialize(new { iss = settings.TeamId, iat = ToEpoch(DateTime.UtcNow) }); var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header)); var payloadBasae64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)); var unsignedJwtData = $"{headerBase64}.{payloadBasae64}"; var unsignedJwtBytes = Encoding.UTF8.GetBytes(unsignedJwtData); using (var dsa = AppleCryptoHelper.GetEllipticCurveAlgorithm(CleanP8Key(settings.P8PrivateKey))) { var signature = dsa.SignData(unsignedJwtBytes, 0, unsignedJwtBytes.Length, HashAlgorithmName.SHA256); return $"{unsignedJwtData}.{Convert.ToBase64String(signature)}"; } } private static int ToEpoch(DateTime time) { var span = DateTime.UtcNow - new DateTime(1970, 1, 1); return Convert.ToInt32(span.TotalSeconds); } private static string CleanP8Key(string p8Key) { // If we have an empty p8Key, then don't bother doing any tasks. if (string.IsNullOrEmpty(p8Key)) { return p8Key; } var lines = p8Key.Split(new [] { '\n' }).ToList(); if (0 != lines.Count && lines[0].StartsWith("-----BEGIN PRIVATE KEY-----")) { lines.RemoveAt(0); } if (0 != lines.Count && lines[lines.Count - 1].StartsWith("-----END PRIVATE KEY-----")) { lines.RemoveAt(lines.Count - 1); } var result = string.Join(string.Empty, lines); return result; } } }