-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Imported Tingle.Extensions.PushNotifications
- Loading branch information
1 parent
9dccb62
commit 9664913
Showing
64 changed files
with
3,458 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
src/Tingle.Extensions.PushNotifications/Apple/ApnsAuthenticationHandler.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
128
src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifier.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
36 changes: 36 additions & 0 deletions
36
src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierConfigureOptions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
32 changes: 32 additions & 0 deletions
32
src/Tingle.Extensions.PushNotifications/Apple/ApnsNotifierOptions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | ||
} |
Oops, something went wrong.