Skip to content

Commit

Permalink
Imported Tingle.Extensions.PushNotifications
Browse files Browse the repository at this point in the history
  • Loading branch information
mburumaxwell committed Sep 30, 2023
1 parent 9dccb62 commit 9664913
Show file tree
Hide file tree
Showing 64 changed files with 3,458 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This repository contains projects/libraries for adding useful functionality to d
|`Tingle.Extensions.JsonPatch`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.JsonPatch.svg)](https://www.nuget.org/packages/Tingle.Extensions.JsonPatch/)|JSON Patch (RFC 6902) support for .NET to easily generate JSON Patch documents using `System.Text.Json` for client applications. See [docs](./src/Tingle.Extensions.JsonPatch/README.md).|
|`Tingle.Extensions.PhoneValidators`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.PhoneValidators.svg)](https://www.nuget.org/packages/Tingle.Extensions.PhoneValidators/)|Convenience for validation of phone numbers either via attributes or resolvable services. See [docs](./src/Tingle.Extensions.PhoneValidators/README.md).|
|`Tingle.Extensions.Processing`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Processing.svg)](https://www.nuget.org/packages/Tingle.Extensions.Processing/)|Helpers for making processing of bulk in memory tasks. See [docs](./src/Tingle.Extensions.Processing/README.md).|
|`Tingle.Extensions.PushNotifications`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.PushNotifications.svg)](https://www.nuget.org/packages/Tingle.Extensions.PushNotifications/)|Helpers for making processing of bulk in memory tasks. See [docs](./src/Tingle.Extensions.PushNotifications/README.md).|
|`Tingle.Extensions.Serilog`|[![NuGet](https://img.shields.io/nuget/v/Tingle.Extensions.Serilog.svg)](https://www.nuget.org/packages/Tingle.Extensions.Serilog/)|Extensions for working with [Serilog](https://serilog.net/). Including easier registration when working with different host setups, and general basics. See [docs](./src/Tingle.Extensions.Serilog/README.md) and [sample](./samples/SerilogSample).|

### Issues & Comments
Expand Down
14 changes: 14 additions & 0 deletions Tingle.Extensions.sln
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneVali
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing", "src\Tingle.Extensions.Processing\Tingle.Extensions.Processing.csproj", "{A803DE4B-B050-48F2-82A1-8E947D8FB96C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PushNotifications", "src\Tingle.Extensions.PushNotifications\Tingle.Extensions.PushNotifications.csproj", "{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Serilog", "src\Tingle.Extensions.Serilog\Tingle.Extensions.Serilog.csproj", "{29035EF2-2391-4441-AAC5-85AA43586EEB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{815F0941-3B70-4705-A583-AF627559595C}"
Expand Down Expand Up @@ -63,6 +65,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PhoneVali
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Processing.Tests", "tests\Tingle.Extensions.Processing.Tests\Tingle.Extensions.Processing.Tests.csproj", "{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.PushNotifications.Tests", "tests\Tingle.Extensions.PushNotifications.Tests\Tingle.Extensions.PushNotifications.Tests.csproj", "{A8AB6597-DEBB-4828-AE1B-A11FD818E428}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tingle.Extensions.Serilog.Tests", "tests\Tingle.Extensions.Serilog.Tests\Tingle.Extensions.Serilog.Tests.csproj", "{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F}"
Expand Down Expand Up @@ -138,6 +142,10 @@ Global
{A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A803DE4B-B050-48F2-82A1-8E947D8FB96C}.Release|Any CPU.Build.0 = Release|Any CPU
{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100}.Release|Any CPU.Build.0 = Release|Any CPU
{29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{29035EF2-2391-4441-AAC5-85AA43586EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{29035EF2-2391-4441-AAC5-85AA43586EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -190,6 +198,10 @@ Global
{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{978023EA-2ED5-4A28-96AD-4BB914EF2BE5}.Release|Any CPU.Build.0 = Release|Any CPU
{A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A8AB6597-DEBB-4828-AE1B-A11FD818E428}.Release|Any CPU.Build.0 = Release|Any CPU
{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E611861-09A3-4AE4-8392-E7CB9BE02B3B}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -239,6 +251,7 @@ Global
{913C0212-58AC-42B7-B555-F96B8E287E7F} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72}
{F46ADD04-B716-4E9B-9799-7C47DDDB08FC} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72}
{A803DE4B-B050-48F2-82A1-8E947D8FB96C} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72}
{6A1901A5-D01F-47E2-9ED5-2BE4CCE95100} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72}
{29035EF2-2391-4441-AAC5-85AA43586EEB} = {9546186D-D4E1-4EDB-956E-1F81C7F4DB72}
{A324CC70-36DD-4B38-9EC8-9069F6130FAC} = {815F0941-3B70-4705-A583-AF627559595C}
{E67CB6B9-6F42-4E63-9603-810B5B9FBF57} = {815F0941-3B70-4705-A583-AF627559595C}
Expand All @@ -252,6 +265,7 @@ Global
{B82E2980-E145-4341-BAE0-8FAE1F110D0C} = {815F0941-3B70-4705-A583-AF627559595C}
{526B37AD-256A-445A-9A42-E5C53989B11E} = {815F0941-3B70-4705-A583-AF627559595C}
{978023EA-2ED5-4A28-96AD-4BB914EF2BE5} = {815F0941-3B70-4705-A583-AF627559595C}
{A8AB6597-DEBB-4828-AE1B-A11FD818E428} = {815F0941-3B70-4705-A583-AF627559595C}
{8E611861-09A3-4AE4-8392-E7CB9BE02B3B} = {815F0941-3B70-4705-A583-AF627559595C}
{CEF2A8D5-771F-42A1-B61D-9DEA4AB1921C} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F}
{E04EC969-2539-46E9-B918-8C8B7BEB8828} = {CE7124E9-01B3-4AD6-8B8F-FB302E60FB7F}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using Tingle.Extensions.Http.Authentication;

namespace Tingle.Extensions.PushNotifications.Apple;

/// <summary>
/// Implementation of <see cref=" AuthenticationHandler"/> for <see cref="ApnsNotifier"/>.
/// </summary>
internal class ApnsAuthenticationHandler : CachingAuthenticationHeaderHandler
{
private readonly ApnsNotifierOptions options;

public ApnsAuthenticationHandler(IMemoryCache cache, IOptionsSnapshot<ApnsNotifierOptions> optionsAccessor, ILogger<ApnsAuthenticationHandler> logger)
{
Scheme = "bearer";
Cache = new(cache);
Logger = logger ?? throw new ArgumentNullException(nameof(logger));

options = optionsAccessor?.Value ?? throw new ArgumentNullException(nameof(optionsAccessor));
}

/// <inheritdoc/>
public override string CacheKey => $"apns:tokens:{options.TeamId}:{options.KeyId}";

/// <inheritdoc/>
protected override async Task<string?> GetParameterAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// get the token from cache
var token = await GetTokenFromCacheAsync(cancellationToken).ConfigureAwait(false);

if (string.IsNullOrWhiteSpace(token))
{
var keyId = options.KeyId!;

// prepare header
var header = new ApnsAuthHeader("ES256", keyId);
var header_json = System.Text.Json.JsonSerializer.Serialize(header, PushNotificationsJsonSerializerContext.Default.ApnsAuthHeader);
var headerBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(header_json));

// prepare payload
var payload = new ApnsAuthPayload(options.TeamId, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
var payload_json = System.Text.Json.JsonSerializer.Serialize(payload, PushNotificationsJsonSerializerContext.Default.ApnsAuthPayload);
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload_json));

// import key, https://stackoverflow.com/a/44008229
var privateKey = await options.PrivateKeyBytes!(keyId).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportPkcs8PrivateKey(privateKey, out _);

// sign data
var unsignedJwtData = $"{headerBase64}.{payloadBase64}";
var signature = ecdsa.SignData(Encoding.UTF8.GetBytes(unsignedJwtData), HashAlgorithmName.SHA256);
token = $"{unsignedJwtData}.{Convert.ToBase64String(signature)}";

// according to apple docs, the token should be refreshed no more than once every 20 minutes and
// no less than once every 60 minutes.
// https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns
var expires = DateTimeOffset.UtcNow.AddMinutes(60);

// save the token to the cache and set expiry in-line with the token life time
// bring the expiry time 5 seconds earlier to allow time for renewal
expires -= TimeSpan.FromSeconds(5);
await SetTokenInCacheAsync(token, expires, cancellationToken).ConfigureAwait(false);
}

return token;
}

internal record ApnsAuthHeader([property: JsonPropertyName("alg")] string? Algorithm,
[property: JsonPropertyName("kid")] string? KeyId);
internal record ApnsAuthPayload([property: JsonPropertyName("iss")] string? TeamId,
[property: JsonPropertyName("iat")] long IssuedAtSeconds);
}
128 changes: 128 additions & 0 deletions src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Text.Json.Serialization.Metadata;
using Tingle.Extensions.Http;
using Tingle.Extensions.PushNotifications.Apple.Models;
using SC = Tingle.Extensions.PushNotifications.PushNotificationsJsonSerializerContext;

namespace Tingle.Extensions.PushNotifications.Apple;

/// <summary>
/// A push notifier for Apple's Push Notification Service
/// </summary>
public class ApnsNotifier : AbstractHttpApiClient<ApnsNotifierOptions>
{
internal const string ProductionBaseUrl = "https://api.push.apple.com:443";
internal const string DevelopmentBaseUrl = "https://api.development.push.apple.com:443";

/// <summary>
/// Creates an instance of <see cref="ApnsNotifier"/>
/// </summary>
/// <param name="httpClient">the client for making requests</param>
/// <param name="optionsAccessor">the accessor for the configuration options</param>
public ApnsNotifier(HttpClient httpClient, IOptionsSnapshot<ApnsNotifierOptions> optionsAccessor) : base(httpClient, optionsAccessor) { }

/// <summary>Send a push notification via Apple Push Notification Service (APNS).</summary>
/// <param name="header">The header for the notification</param>
/// <param name="data">The data</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public virtual Task<ResourceResponse<ApnsMessageResponse, ApnsResponseError>> SendAsync(ApnsMessageHeader header,
ApnsMessageData data,
CancellationToken cancellationToken = default)
=> SendAsync(header, data, SC.Default.ApnsMessageData, cancellationToken);

/// <summary>Send a push notification with custom data via Apple Push Notification Service (APNS).</summary>
/// <param name="header">The header for the notification</param>
/// <param name="data">The data</param>
/// <param name="jsonTypeInfo">Metadata about the <typeparamref name="TData"/> to convert.</param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public virtual async Task<ResourceResponse<ApnsMessageResponse, ApnsResponseError>> SendAsync<TData>(ApnsMessageHeader header,
TData data,
JsonTypeInfo<TData> jsonTypeInfo,
CancellationToken cancellationToken = default)
where TData : ApnsMessageData
{
// infer the endpoint from the provided environment
var endpoint = new Uri(header.Environment == ApnsEnvironment.Production ? ProductionBaseUrl : DevelopmentBaseUrl);

// ensure we have a valid device token
if (string.IsNullOrWhiteSpace(header.DeviceToken))
{
throw new ArgumentException($"{nameof(header.DeviceToken)} cannot be null", nameof(header));
}

// ensure we have the aps node
if (data == null) throw new ArgumentNullException(nameof(data));
if (data.Aps == null) throw new ArgumentException($"{nameof(data.Aps)} cannot be null", nameof(data));

var path = $"/3/device/{header.DeviceToken}";
var uri = new Uri(endpoint, path);
var request = new HttpRequestMessage(HttpMethod.Post, uri)
{
Version = new Version(2, 0), // APNs requires HTTP/2
Content = MakeJsonContent(data, jsonTypeInfo),
};

// specify the header for content we can accept
request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json"));

// add header specific to APNs
request.Headers.TryAddWithoutValidation(":method", "POST");
request.Headers.TryAddWithoutValidation(":path", path);

// add an Id if specified
if (header.Id != null)
{
request.Headers.Add("apns-id", header.Id.ToString()!.ToLower());
}

// add expiration in UNIX seconds since Epoch.
var expiration = header.Expiration?.ToUnixTimeSeconds() ?? 0;
request.Headers.Add("apns-expiration", expiration.ToString());

// add priority as an int: 10,5.
var priority = (int)header.Priority;
request.Headers.Add("apns-priority", priority.ToString());

// add the push type (string, all lowercase)
var type = header.PushType.ToString().ToLower();
request.Headers.Add("apns-push-type", type);

// add the topic
// The topic of the remote notification, which is typically the bundle ID for the app.
// The certificate created in the developer account must include the capability for this topic.
// If the certificate includes multiple topics, you value for this header must be specified.
// If this request header is omitted and the APNs certificate does not specify multiple topics,
// the APNs server uses the certificate’s Subject as the default topic.
// If a provider token ins used instead of a certificate, a value for this request header must be specified.
// The topic provided should be provisioned for the team named in your developer account.
// For certain types of push, the topic needs to be suffixed with a pre-defined string
var topic = Options.BundleId;
topic += GetTopicSuffixFromPushType(header.PushType);
request.Headers.Add("apns-topic", topic);

// add a collapseId if specified
if (!string.IsNullOrEmpty(header.CollapseId))
{
// ensure the value is not longer than 64 bytes
var value = header.CollapseId[..Math.Min(header.CollapseId.Length, 64)];
request.Headers.Add("apns-collapse-id", value);
}

return await SendAsync(request, SC.Default.ApnsMessageResponse, SC.Default.ApnsResponseError, cancellationToken).ConfigureAwait(false);
}

internal static string? GetTopicSuffixFromPushType(ApnsPushType type)
{
return type switch
{
ApnsPushType.Voip => ".voip",
ApnsPushType.Complication => ".complication",
ApnsPushType.FileProvider => ".pushkit.fileprovider",
_ => null,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.DependencyInjection;

internal class ApnsNotifierConfigureOptions : IValidateOptions<ApnsNotifierOptions>
{
/// <inheritdoc/>
public ValidateOptionsResult Validate(string? name, ApnsNotifierOptions options)
{
// ensure we have a BundleId
if (string.IsNullOrEmpty(options.BundleId))
{
return ValidateOptionsResult.Fail($"{nameof(options.BundleId)} must be provided");
}

// ensure we have a PrivateKeyBytes resolver
if (options.PrivateKeyBytes is null)
{
return ValidateOptionsResult.Fail($"{nameof(options.PrivateKeyBytes)} must be provided");
}

// ensure we have a KeyId
if (string.IsNullOrEmpty(options.KeyId))
{
return ValidateOptionsResult.Fail($"{nameof(options.KeyId)} must be provided");
}

// ensure we have a TeamId
if (string.IsNullOrEmpty(options.TeamId))
{
return ValidateOptionsResult.Fail($"{nameof(options.TeamId)} must be provided");
}

return ValidateOptionsResult.Success;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Tingle.Extensions.Http;
using Tingle.Extensions.PushNotifications.Apple;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Configuration options for <see cref="ApnsNotifier"/>.
/// </summary>
public class ApnsNotifierOptions : AbstractHttpApiClientOptions
{
/// <summary>
/// Gets or sets a delegate to get the raw bytes of the private
/// key which is passed in the value of <see cref="KeyId"/>.
/// </summary>
/// <remarks>The private key should be in PKCS #8 (.p8) format.</remarks>
public virtual Func<string, Task<byte[]>>? PrivateKeyBytes { get; set; }

/// <summary>
/// Gets or sets the ID for your Apple Push Notifications private key.
/// </summary>
public virtual string? KeyId { get; set; }

/// <summary>
/// Gets or sets the Team ID for your Apple Developer account.
/// </summary>
public virtual string? TeamId { get; set; }

/// <summary>
/// Gets or sets the bundle ID for your app (iOS, watchOS, tvOS iPadOS, etc).
/// </summary>
public virtual string? BundleId { get; set; }
}
Loading

0 comments on commit 9664913

Please sign in to comment.