Skip to content

Commit c62626f

Browse files
committed
v3 - inject HttpClient, JWT expiration handling, removed IDisposable
1 parent e3507e6 commit c62626f

File tree

7 files changed

+98
-62
lines changed

7 files changed

+98
-62
lines changed

CorePush/Apple/ApnSender.cs

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Org.BouncyCastle.Crypto.Parameters;
44
using Org.BouncyCastle.Security;
55
using System;
6+
using System.Collections.Concurrent;
67
using System.Collections.Generic;
78
using System.Net.Http;
89
using 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

CorePush/Apple/ApnSettings.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace CorePush.Apple
2+
{
3+
public class ApnSettings
4+
{
5+
/// <summary>
6+
/// p8 certificate string
7+
/// </summary>
8+
public string P8PrivateKey { get; set; }
9+
10+
/// <summary>
11+
/// 10 digit p8 certificate id. Usually a part of a downloadable certificate filename
12+
/// </summary>
13+
public string P8PrivateKeyId { get; set; }
14+
15+
/// <summary>
16+
/// Apple 10 digit team id
17+
/// </summary>
18+
public string TeamId { get; set; }
19+
20+
/// <summary>
21+
/// App slug / bundle name
22+
/// </summary>
23+
public string AppBundleIdentifier { get; set; }
24+
25+
/// <summary>
26+
/// Development or Production server
27+
/// </summary>
28+
public ApnServerType ServerType { get; set; }
29+
}
30+
}

CorePush/CorePush.csproj

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@
66
<Authors>Andrei M</Authors>
77
<Company />
88
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
9-
<AssemblyVersion>2.1.2.0</AssemblyVersion>
10-
<FileVersion>2.1.2.0</FileVersion>
11-
<Version>2.1.2</Version>
9+
<AssemblyVersion>3.0.0.0</AssemblyVersion>
10+
<FileVersion>3.0.0.0</FileVersion>
11+
<Version>3.0.0</Version>
1212
<PackageProjectUrl>https://github.com/andrei-m-code/CorePush</PackageProjectUrl>
1313
<PackageLicenseUrl />
1414
<PackageReleaseNotes>
15+
v3.0.0 [Includes breaking changes]
16+
- Added ApnSettings and FcmSettings
17+
- HttpClient has to be injected for FcmSender and ApnSender
18+
- Both ApnSender and FcmSender are not IDisposable anymore as HttpClient is injected and managed outside
19+
- ApnSender JWT token expiration is managed according to Apple documentation
20+
21+
v2.1.2
1522
- Added IFcmSender and IApnSender interfaces
23+
24+
Previous Versions:
1625
- Requires .NET Core 3.1
1726
- Target framework changed to netstandard2.1
1827
- Docker/Linux support is added, thanks to @JeroenBer

CorePush/Google/FcmSender.cs

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@ namespace CorePush.Google
1414
public class FcmSender : IFcmSender
1515
{
1616
private readonly string fcmUrl = "https://fcm.googleapis.com/fcm/send";
17-
private readonly string serverKey;
18-
private readonly string senderId;
19-
private readonly Lazy<HttpClient> lazyHttp = new Lazy<HttpClient>();
17+
private readonly FcmSettings settings;
18+
private readonly HttpClient http;
2019

21-
public FcmSender(string serverKey, string senderId)
20+
public FcmSender(FcmSettings settings, HttpClient http)
2221
{
23-
this.serverKey = serverKey;
24-
this.senderId = senderId;
22+
this.settings = settings;
23+
this.http = http;
2524
}
2625

2726
/// <summary>
@@ -41,22 +40,14 @@ public async Task<FcmResponse> SendAsync(string deviceId, object payload)
4140
var json = jsonObject.ToString();
4241

4342
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, fcmUrl);
44-
httpRequest.Headers.Add("Authorization", $"key = {serverKey}");
45-
httpRequest.Headers.Add("Sender", $"id = {senderId}");
43+
httpRequest.Headers.Add("Authorization", $"key = {settings.ServerKey}");
44+
httpRequest.Headers.Add("Sender", $"id = {settings.SenderId}");
4645
httpRequest.Content = new StringContent(json, Encoding.UTF8, "application/json");
47-
using var response = await lazyHttp.Value.SendAsync(httpRequest);
46+
using var response = await http.SendAsync(httpRequest);
4847
response.EnsureSuccessStatusCode();
4948
var responseString = await response.Content.ReadAsStringAsync();
5049

5150
return JsonHelper.Deserialize<FcmResponse>(responseString);
5251
}
53-
54-
public void Dispose()
55-
{
56-
if (lazyHttp.IsValueCreated)
57-
{
58-
lazyHttp.Value.Dispose();
59-
}
60-
}
6152
}
6253
}

CorePush/Google/FcmSettings.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace CorePush.Google
2+
{
3+
public class FcmSettings
4+
{
5+
/// <summary>
6+
/// FCM Sender ID
7+
/// </summary>
8+
public string SenderId { get; set; }
9+
10+
/// <summary>
11+
/// FCM Server Key
12+
/// </summary>
13+
public string ServerKey { get; set; }
14+
}
15+
}

CorePush/Interfaces/IApnSender.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace CorePush.Interfaces
66
{
7-
public interface IApnSender : IDisposable
7+
public interface IApnSender
88
{
99
Task<ApnsResponse> SendAsync(
1010
object notification,

CorePush/Interfaces/IFcmSender.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
using System;
21
using System.Threading.Tasks;
32
using CorePush.Google;
43

54
namespace CorePush.Interfaces
65
{
7-
public interface IFcmSender : IDisposable
6+
public interface IFcmSender
87
{
98
Task<FcmResponse> SendAsync(string deviceId, object payload);
109
}

0 commit comments

Comments
 (0)