diff --git a/.travis.yml b/.travis.yml index 06088137..cf183702 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,4 +8,5 @@ matrix: script: - dotnet build FirebaseAdmin/FirebaseAdmin - dotnet build FirebaseAdmin/FirebaseAdmin.Snippets + - dotnet build FirebaseAdmin/FirebaseAdmin.IntegrationTests - dotnet test FirebaseAdmin/FirebaseAdmin.Tests diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj index c428e7b4..d146599a 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj @@ -2,8 +2,9 @@ netcoreapp2.0 - false + true + ../../stylecop_test.ruleset @@ -12,6 +13,9 @@ + + all + diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs index 1c56baf5..af4d0dce 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAuthTest.cs @@ -17,24 +17,24 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; -using Xunit; using FirebaseAdmin; using FirebaseAdmin.Auth; using Google.Apis.Auth.OAuth2; using Google.Apis.Util; +using Xunit; namespace FirebaseAdmin.IntegrationTests { public class FirebaseAuthTest { - private const string VerifyCustomTokenUrl = + private const string VerifyCustomTokenUrl = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken"; public FirebaseAuthTest() { IntegrationTestUtils.EnsureDefaultApp(); } - + [Fact] public async Task CreateCustomToken() { @@ -50,9 +50,9 @@ public async Task CreateCustomTokenWithClaims() { var developerClaims = new Dictionary() { - {"admin", true}, - {"package", "gold"}, - {"magicNumber", 42L}, + { "admin", true }, + { "package", "gold" }, + { "magicNumber", 42L }, }; var customToken = await FirebaseAuth.DefaultInstance.CreateCustomTokenAsync( "testuser", developerClaims); @@ -72,13 +72,14 @@ public async Task CreateCustomTokenWithClaims() public async Task CreateCustomTokenWithoutServiceAccount() { var googleCred = FirebaseApp.DefaultInstance.Options.Credential; - var serviceAcct = (ServiceAccountCredential) googleCred.UnderlyingCredential; - var token = await ((ITokenAccess) googleCred).GetAccessTokenForRequestAsync(); - var app = FirebaseApp.Create(new AppOptions() - { - Credential = GoogleCredential.FromAccessToken(token), - ServiceAccountId = serviceAcct.Id, - }, "IAMSignApp"); + var serviceAcct = (ServiceAccountCredential)googleCred.UnderlyingCredential; + var token = await ((ITokenAccess)googleCred).GetAccessTokenForRequestAsync(); + var app = FirebaseApp.Create( + new AppOptions() + { + Credential = GoogleCredential.FromAccessToken(token), + ServiceAccountId = serviceAcct.Id, + }, "IAMSignApp"); try { var customToken = await FirebaseAuth.GetAuth(app).CreateCustomTokenAsync( @@ -98,7 +99,7 @@ public async Task SetCustomUserClaims() { var customClaims = new Dictionary() { - {"admin", true} + { "admin", true }, }; await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync("testuser", customClaims); @@ -126,12 +127,13 @@ private static async Task SignInWithCustomTokenAsync(string customToken) var rb = new Google.Apis.Requests.RequestBuilder() { Method = Google.Apis.Http.HttpConsts.Post, - BaseUri = new Uri(VerifyCustomTokenUrl), + BaseUri = new Uri(VerifyCustomTokenUrl), }; rb.AddParameter(RequestParameterType.Query, "key", IntegrationTestUtils.GetApiKey()); var request = rb.CreateRequest(); var jsonSerializer = Google.Apis.Json.NewtonsoftJsonSerializer.Instance; - var payload = jsonSerializer.Serialize(new SignInRequest{ + var payload = jsonSerializer.Serialize(new SignInRequest + { CustomToken = customToken, ReturnSecureToken = true, }); @@ -159,6 +161,6 @@ internal class SignInRequest internal class SignInResponse { [Newtonsoft.Json.JsonProperty("idToken")] - public String IdToken { get; set; } + public string IdToken { get; set; } } } diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs new file mode 100644 index 00000000..790294d8 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs @@ -0,0 +1,53 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FirebaseAdmin.Messaging; +using Xunit; + +namespace FirebaseAdmin.IntegrationTests +{ + public class FirebaseMessagingTest + { + public FirebaseMessagingTest() + { + IntegrationTestUtils.EnsureDefaultApp(); + } + + [Fact] + public async Task Send() + { + var message = new Message() + { + Topic = "foo-bar", + Notification = new Notification() + { + Title = "Title", + Body = "Body", + }, + Android = new AndroidConfig() + { + Priority = Priority.Normal, + TimeToLive = TimeSpan.FromHours(1), + RestrictedPackageName = "com.google.firebase.testing", + }, + }; + var id = await FirebaseMessaging.DefaultInstance.SendAsync(message, dryRun: true); + Assert.True(!string.IsNullOrEmpty(id)); + Assert.Matches(new Regex("^projects/.*/messages/.*$"), id); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/IntegrationTestUtils.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/IntegrationTestUtils.cs index 5761de85..bee0adc0 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/IntegrationTestUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/IntegrationTestUtils.cs @@ -26,13 +26,15 @@ internal static class IntegrationTestUtils private const string ServiceAccountFile = "./resources/integration_cert.json"; private const string ApiKeyFile = "./resources/integration_apikey.txt"; - private static readonly Lazy DefaultFirebaseApp = new Lazy(() => { - var options = new AppOptions() + private static readonly Lazy DefaultFirebaseApp = new Lazy( + () => { - Credential = GoogleCredential.FromFile(ServiceAccountFile), - }; - return FirebaseApp.Create(options); - }, true); + var options = new AppOptions() + { + Credential = GoogleCredential.FromFile(ServiceAccountFile), + }; + return FirebaseApp.Create(options); + }, true); public static FirebaseApp EnsureDefaultApp() { diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj index 2a896f89..ee80a864 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj @@ -3,6 +3,8 @@ netcoreapp2.0 false + true + ../../stylecop_test.ruleset @@ -11,6 +13,9 @@ + + all + diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAppSnippets.cs b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAppSnippets.cs index 106daa1a..32ca9be5 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAppSnippets.cs +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAppSnippets.cs @@ -21,9 +21,9 @@ namespace FirebaseAdmin.Snippets { - class FirebaseAppSnippets + internal class FirebaseAppSnippets { - static void InitSdkWithServiceAccount() + internal static void InitSdkWithServiceAccount() { // [START initialize_sdk_with_service_account] FirebaseApp.Create(new AppOptions() @@ -33,7 +33,7 @@ static void InitSdkWithServiceAccount() // [END initialize_sdk_with_service_account] } - static void InitSdkWithApplicationDefault() + internal static void InitSdkWithApplicationDefault() { // [START initialize_sdk_with_application_default] FirebaseApp.Create(new AppOptions() @@ -43,7 +43,7 @@ static void InitSdkWithApplicationDefault() // [END initialize_sdk_with_application_default] } - static void InitSdkWithRefreshToken() + internal static void InitSdkWithRefreshToken() { // [START initialize_sdk_with_refresh_token] FirebaseApp.Create(new AppOptions() @@ -53,14 +53,14 @@ static void InitSdkWithRefreshToken() // [END initialize_sdk_with_refresh_token] } - static void InitSdkWithDefaultConfig() + internal static void InitSdkWithDefaultConfig() { // [START initialize_sdk_with_default_config] FirebaseApp.Create(); // [END initialize_sdk_with_default_config] } - static void InitDefaultApp() + internal static void InitDefaultApp() { // [START access_services_default] // Initialize the default app @@ -78,7 +78,7 @@ static void InitDefaultApp() // [END access_services_default] } - static void InitCustomApp() + internal static void InitCustomApp() { var defaultOptions = new AppOptions() { @@ -107,7 +107,7 @@ static void InitCustomApp() // [END access_services_nondefault] } - static void InitWithServiceAccountId() + internal static void InitWithServiceAccountId() { // [START initialize_sdk_with_service_account_id] FirebaseApp.Create(new AppOptions() diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs index bb7e094e..0afb71e2 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAuthSnippets.cs @@ -19,9 +19,9 @@ namespace FirebaseAdmin.Snippets { - class FirebaseAuthSnippets + internal class FirebaseAuthSnippets { - static async Task CreateCustomTokenAsync() + internal static async Task CreateCustomTokenAsync() { // [START custom_token] var uid = "some-uid"; @@ -32,7 +32,7 @@ static async Task CreateCustomTokenAsync() Console.WriteLine("Created custom token: {0}", customToken); } - static async Task CreateCustomTokenWithClaimsAsync() + internal static async Task CreateCustomTokenWithClaimsAsync() { // [START custom_token_with_claims] var uid = "some-uid"; @@ -48,7 +48,7 @@ static async Task CreateCustomTokenWithClaimsAsync() Console.WriteLine("Created custom token: {0}", customToken); } - static async Task VeridyIdTokenAsync(string idToken) + internal static async Task VeridyIdTokenAsync(string idToken) { // [START verify_id_token] FirebaseToken decodedToken = await FirebaseAuth.DefaultInstance diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseAuthTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseAuthTest.cs index bc8a90df..491aa9af 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseAuthTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseAuthTest.cs @@ -20,15 +20,17 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Xunit; +using FirebaseAdmin.Auth; +using Google.Apis.Auth; using Google.Apis.Auth.OAuth2; +using Xunit; [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace FirebaseAdmin.Auth.Tests { - public class FirebaseAuthTest: IDisposable + public class FirebaseAuthTest : IDisposable { - private static readonly GoogleCredential mockCredential = + private static readonly GoogleCredential MockCredential = GoogleCredential.FromAccessToken("test-token"); [Fact] @@ -40,7 +42,7 @@ public void GetAuthWithoutApp() [Fact] public void GetDefaultAuth() { - var app = FirebaseApp.Create(new AppOptions(){Credential = mockCredential}); + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); FirebaseAuth auth = FirebaseAuth.DefaultInstance; Assert.Same(auth, FirebaseAuth.DefaultInstance); app.Delete(); @@ -50,7 +52,7 @@ public void GetDefaultAuth() [Fact] public void GetAuth() { - var app = FirebaseApp.Create(new AppOptions(){Credential = mockCredential}, "MyApp"); + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }, "MyApp"); FirebaseAuth auth = FirebaseAuth.GetAuth(app); Assert.Same(auth, FirebaseAuth.GetAuth(app)); app.Delete(); @@ -60,7 +62,7 @@ public void GetAuth() [Fact] public async Task UseAfterDelete() { - var app = FirebaseApp.Create(new AppOptions(){Credential = mockCredential}); + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); FirebaseAuth auth = FirebaseAuth.DefaultInstance; app.Delete(); await Assert.ThrowsAsync( @@ -73,7 +75,7 @@ await Assert.ThrowsAsync( public async Task CreateCustomToken() { var cred = GoogleCredential.FromFile("./resources/service_account.json"); - FirebaseApp.Create(new AppOptions(){Credential = cred}); + FirebaseApp.Create(new AppOptions() { Credential = cred }); var token = await FirebaseAuth.DefaultInstance.CreateCustomTokenAsync("user1"); VerifyCustomToken(token, "user1", null); } @@ -82,12 +84,12 @@ public async Task CreateCustomToken() public async Task CreateCustomTokenWithClaims() { var cred = GoogleCredential.FromFile("./resources/service_account.json"); - FirebaseApp.Create(new AppOptions(){Credential = cred}); + FirebaseApp.Create(new AppOptions() { Credential = cred }); var developerClaims = new Dictionary() { - {"admin", true}, - {"package", "gold"}, - {"magicNumber", 42L}, + { "admin", true }, + { "package", "gold" }, + { "magicNumber", 42L }, }; var token = await FirebaseAuth.DefaultInstance.CreateCustomTokenAsync( "user2", developerClaims); @@ -98,7 +100,7 @@ public async Task CreateCustomTokenWithClaims() public async Task CreateCustomTokenCancel() { var cred = GoogleCredential.FromFile("./resources/service_account.json"); - FirebaseApp.Create(new AppOptions(){Credential = cred}); + FirebaseApp.Create(new AppOptions() { Credential = cred }); var canceller = new CancellationTokenSource(); canceller.Cancel(); await Assert.ThrowsAsync( @@ -109,7 +111,7 @@ await Assert.ThrowsAsync( [Fact] public async Task CreateCustomTokenInvalidCredential() { - FirebaseApp.Create(new AppOptions(){Credential = mockCredential}); + FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); await Assert.ThrowsAsync( async () => await FirebaseAuth.DefaultInstance.CreateCustomTokenAsync("user1")); } @@ -117,7 +119,7 @@ await Assert.ThrowsAsync( [Fact] public async Task VerifyIdTokenNoProjectId() { - FirebaseApp.Create(new AppOptions(){Credential = mockCredential}); + FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); var idToken = await FirebaseTokenVerifierTest.CreateTestTokenAsync(); await Assert.ThrowsAsync( async () => await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(idToken)); @@ -128,7 +130,7 @@ public async Task VerifyIdTokenCancel() { FirebaseApp.Create(new AppOptions() { - Credential = mockCredential, + Credential = MockCredential, ProjectId = "test-project", }); var canceller = new CancellationTokenSource(); @@ -139,12 +141,29 @@ await Assert.ThrowsAnyAsync( idToken, canceller.Token)); } + [Fact] + public async Task SetCustomUserClaimsNoProjectId() + { + FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); + var customClaims = new Dictionary() + { + { "admin", true }, + }; + await Assert.ThrowsAsync( + async () => await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync("user1", customClaims)); + } + + public void Dispose() + { + FirebaseApp.DeleteAll(); + } + private static void VerifyCustomToken(string token, string uid, Dictionary claims) { - String[] segments = token.Split("."); + string[] segments = token.Split("."); Assert.Equal(3, segments.Length); - var payload = JwtUtils.Decode(segments[1]); + var payload = JwtUtils.Decode(segments[1]); Assert.Equal("client@test-project.iam.gserviceaccount.com", payload.Issuer); Assert.Equal("client@test-project.iam.gserviceaccount.com", payload.Subject); Assert.Equal(uid, payload.Uid); @@ -164,28 +183,11 @@ private static void VerifyCustomToken(string token, string uid, Dictionary() - { - {"admin", true} - }; - await Assert.ThrowsAsync( - async () => await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync("user1", customClaims)); - } - - public void Dispose() - { - FirebaseApp.DeleteAll(); - } } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenFactoryTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenFactoryTest.cs index 6ad7d462..fa954b82 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenFactoryTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenFactoryTest.cs @@ -20,11 +20,11 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Xunit; -using FirebaseAdmin.Tests; using FirebaseAdmin.Auth; +using FirebaseAdmin.Tests; using Google.Apis.Auth; using Google.Apis.Util; +using Xunit; namespace FirebaseAdmin.Auth.Tests { @@ -56,9 +56,9 @@ public async Task CreateCustomTokenWithClaims() var factory = new FirebaseTokenFactory(new MockSigner(), clock); var developerClaims = new Dictionary() { - {"admin", true}, - {"package", "gold"}, - {"magicNumber", 42L}, + { "admin", true }, + { "package", "gold" }, + { "magicNumber", 42L }, }; var token = await factory.CreateCustomTokenAsync("user2", developerClaims); VerifyCustomToken(token, "user2", developerClaims); @@ -71,37 +71,39 @@ public async Task InvalidUid() await Assert.ThrowsAsync( async () => await factory.CreateCustomTokenAsync(null)); await Assert.ThrowsAsync( - async () => await factory.CreateCustomTokenAsync("")); + async () => await factory.CreateCustomTokenAsync(string.Empty)); await Assert.ThrowsAsync( - async () => await factory.CreateCustomTokenAsync(new String('a', 129))); + async () => await factory.CreateCustomTokenAsync(new string('a', 129))); } [Fact] public async Task ReservedClaims() { var factory = new FirebaseTokenFactory(new MockSigner(), new MockClock()); - foreach(var key in FirebaseTokenFactory.ReservedClaims) + foreach (var key in FirebaseTokenFactory.ReservedClaims) { - var developerClaims = new Dictionary(){ - {key, "value"}, + var developerClaims = new Dictionary() + { + { key, "value" }, }; await Assert.ThrowsAsync( - async () => await factory.CreateCustomTokenAsync("user", developerClaims)); - } + async () => await factory.CreateCustomTokenAsync("user", developerClaims)); + } } private static void VerifyCustomToken( string token, string uid, Dictionary claims) { - String[] segments = token.Split("."); + string[] segments = token.Split("."); Assert.Equal(3, segments.Length); + // verify header var header = JwtUtils.Decode(segments[0]); Assert.Equal("JWT", header.Type); Assert.Equal("RS256", header.Algorithm); // verify payload - var payload = JwtUtils.Decode(segments[1]); + var payload = JwtUtils.Decode(segments[1]); Assert.Equal(MockSigner.KeyIdString, payload.Issuer); Assert.Equal(MockSigner.KeyIdString, payload.Subject); Assert.Equal(uid, payload.Uid); @@ -141,6 +143,6 @@ public Task SignDataAsync(byte[] data, CancellationToken cancellationTok return Task.FromResult(Encoding.UTF8.GetBytes(Signature)); } - public void Dispose() {} + public void Dispose() { } } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenVerifierTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenVerifierTest.cs index f8bfea99..306d3b5b 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenVerifierTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenVerifierTest.cs @@ -20,20 +20,23 @@ using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; -using Xunit; -using Google.Apis.Auth.OAuth2; -using Google.Apis.Util; using FirebaseAdmin.Auth; using FirebaseAdmin.Tests; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Util; +using Xunit; namespace FirebaseAdmin.Auth.Tests { - public class FirebaseTokenVerifierTest: IDisposable + public class FirebaseTokenVerifierTest : IDisposable { private static readonly IPublicKeySource KeySource = new FileSystemPublicKeySource( "./resources/public_cert.pem"); + private static readonly IClock Clock = new MockClock(); + private static readonly ISigner Signer = CreateTestSigner(); + private static readonly FirebaseTokenVerifier TokenVerifier = new FirebaseTokenVerifier( new FirebaseTokenVerifierArgs() { @@ -46,7 +49,7 @@ public class FirebaseTokenVerifierTest: IDisposable PublicKeySource = KeySource, }); - private static readonly GoogleCredential mockCredential = + private static readonly GoogleCredential MockCredential = GoogleCredential.FromAccessToken("test-token"); [Fact] @@ -54,17 +57,18 @@ public async Task ValidToken() { var payload = new Dictionary() { - {"foo", "bar"}, + { "foo", "bar" }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); var decoded = await TokenVerifier.VerifyTokenAsync(idToken); Assert.Equal("testuser", decoded.Uid); Assert.Equal("test-project", decoded.Audience); Assert.Equal("testuser", decoded.Subject); + // The default test token created by CreateTestTokenAsync has an issue time 10 minutes // ago, and an expiry time 50 minutes in the future. - Assert.Equal(Clock.UnixTimestamp() - 60 * 10, decoded.IssuedAtTimeSeconds); - Assert.Equal(Clock.UnixTimestamp() + 60 * 50, decoded.ExpirationTimeSeconds); + Assert.Equal(Clock.UnixTimestamp() - (60 * 10), decoded.IssuedAtTimeSeconds); + Assert.Equal(Clock.UnixTimestamp() + (60 * 50), decoded.ExpirationTimeSeconds); Assert.Single(decoded.Claims); object value; Assert.True(decoded.Claims.TryGetValue("foo", out value)); @@ -77,7 +81,7 @@ public async Task InvalidArgument() await Assert.ThrowsAsync( async () => await TokenVerifier.VerifyTokenAsync(null)); await Assert.ThrowsAsync( - async () => await TokenVerifier.VerifyTokenAsync("")); + async () => await TokenVerifier.VerifyTokenAsync(string.Empty)); } [Fact] @@ -92,7 +96,7 @@ public async Task NoKid() { var header = new Dictionary() { - {"kid", ""}, + { "kid", string.Empty }, }; var idToken = await CreateTestTokenAsync(headerOverrides: header); await Assert.ThrowsAsync( @@ -104,7 +108,7 @@ public async Task IncorrectKid() { var header = new Dictionary() { - {"kid", "incorrect-key-id"}, + { "kid", "incorrect-key-id" }, }; var idToken = await CreateTestTokenAsync(headerOverrides: header); await Assert.ThrowsAsync( @@ -116,7 +120,7 @@ public async Task IncorrectAlgorithm() { var header = new Dictionary() { - {"alg", "HS256"}, + { "alg", "HS256" }, }; var idToken = await CreateTestTokenAsync(headerOverrides: header); await Assert.ThrowsAsync( @@ -128,7 +132,7 @@ public async Task Expired() { var payload = new Dictionary() { - {"exp", Clock.UnixTimestamp() - 60}, + { "exp", Clock.UnixTimestamp() - 60 }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); await Assert.ThrowsAsync( @@ -140,7 +144,7 @@ public async Task InvalidIssuedAt() { var payload = new Dictionary() { - {"iat", Clock.UnixTimestamp() + 60}, + { "iat", Clock.UnixTimestamp() + 60 }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); await Assert.ThrowsAsync( @@ -152,7 +156,7 @@ public async Task InvalidIssuer() { var payload = new Dictionary() { - {"iss", "wrong-issuer"}, + { "iss", "wrong-issuer" }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); await Assert.ThrowsAsync( @@ -164,7 +168,7 @@ public async Task InvalidAudience() { var payload = new Dictionary() { - {"aud", "wrong-audience"}, + { "aud", "wrong-audience" }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); await Assert.ThrowsAsync( @@ -176,7 +180,7 @@ public async Task EmptySubject() { var payload = new Dictionary() { - {"sub", ""}, + { "sub", string.Empty }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); await Assert.ThrowsAsync( @@ -188,7 +192,7 @@ public async Task LongSubject() { var payload = new Dictionary() { - {"sub", new String('a', 129)}, + { "sub", new string('a', 129) }, }; var idToken = await CreateTestTokenAsync(payloadOverrides: payload); await Assert.ThrowsAsync( @@ -200,7 +204,7 @@ public void ProjectIdFromOptions() { var app = FirebaseApp.Create(new AppOptions() { - Credential = mockCredential, + Credential = MockCredential, ProjectId = "explicit-project-id", }); var verifier = FirebaseTokenVerifier.CreateIDTokenVerifier(app); @@ -226,14 +230,14 @@ public void ProjectIdFromEnvironment() { var app = FirebaseApp.Create(new AppOptions() { - Credential = mockCredential, + Credential = MockCredential, }); var verifier = FirebaseTokenVerifier.CreateIDTokenVerifier(app); Assert.Equal("env-project-id", verifier.ProjectId); } finally { - Environment.SetEnvironmentVariable("GOOGLE_CLOUD_PROJECT", ""); + Environment.SetEnvironmentVariable("GOOGLE_CLOUD_PROJECT", string.Empty); } } @@ -253,9 +257,9 @@ internal static async Task CreateTestTokenAsync( { var header = new Dictionary() { - {"alg", "RS256"}, - {"typ", "jwt"}, - {"kid", "test-key-id"}, + { "alg", "RS256" }, + { "typ", "jwt" }, + { "kid", "test-key-id" }, }; if (headerOverrides != null) { @@ -267,11 +271,11 @@ internal static async Task CreateTestTokenAsync( var payload = new Dictionary() { - {"sub", "testuser"}, - {"iss", "https://securetoken.google.com/test-project"}, - {"aud", "test-project"}, - {"iat", Clock.UnixTimestamp() - 60 * 10}, - {"exp", Clock.UnixTimestamp() + 60 * 50}, + { "sub", "testuser" }, + { "iss", "https://securetoken.google.com/test-project" }, + { "aud", "test-project" }, + { "iat", Clock.UnixTimestamp() - (60 * 10) }, + { "exp", Clock.UnixTimestamp() + (60 * 50) }, }; if (payloadOverrides != null) { @@ -280,32 +284,33 @@ internal static async Task CreateTestTokenAsync( payload[entry.Key] = entry.Value; } } + return await JwtUtils.CreateSignedJwtAsync(header, payload, Signer); } private static ISigner CreateTestSigner() { var credential = GoogleCredential.FromFile("./resources/service_account.json"); - var serviceAccount = (ServiceAccountCredential) credential.UnderlyingCredential; + var serviceAccount = (ServiceAccountCredential)credential.UnderlyingCredential; return new ServiceAccountSigner(serviceAccount); } } internal class FileSystemPublicKeySource : IPublicKeySource { - private IReadOnlyList _rsa; + private IReadOnlyList rsa; public FileSystemPublicKeySource(string file) { var x509cert = new X509Certificate2(File.ReadAllBytes(file)); - var rsa = (RSA) x509cert.PublicKey.Key; - _rsa = ImmutableList.Create(new PublicKey("test-key-id", rsa)); + var rsa = (RSA)x509cert.PublicKey.Key; + this.rsa = ImmutableList.Create(new PublicKey("test-key-id", rsa)); } public Task> GetPublicKeysAsync( CancellationToken cancellationToken) { - return Task.FromResult(_rsa); + return Task.FromResult(this.rsa); } } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs index 3b666c49..c3114fe2 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseUserManagerTest.cs @@ -14,25 +14,26 @@ using System; using System.Collections.Generic; -using Xunit; -using Google.Apis.Auth.OAuth2; -using FirebaseAdmin.Tests; -using System.Threading.Tasks; using System.Net; +using System.Threading.Tasks; +using FirebaseAdmin.Tests; +using Google.Apis.Auth.OAuth2; +using Xunit; namespace FirebaseAdmin.Auth.Tests { public class FirebaseUserManagerTest { - private static readonly GoogleCredential mockCredential = + private const string MockProjectId = "project1"; + + private static readonly GoogleCredential MockCredential = GoogleCredential.FromAccessToken("test-token"); - private const string mockProjectId = "project1"; [Fact] public void InvalidUidForUserRecord() { Assert.Throws(() => new UserRecord(null)); - Assert.Throws(() => new UserRecord("")); + Assert.Throws(() => new UserRecord(string.Empty)); Assert.Throws(() => new UserRecord(new string('a', 129))); } @@ -41,18 +42,20 @@ public void ReservedClaims() { foreach (var key in FirebaseTokenFactory.ReservedClaims) { - var customClaims = new Dictionary(){ - {key, "value"}, + var customClaims = new Dictionary() + { + { key, "value" }, }; - Assert.Throws(() => new UserRecord("user1") { CustomClaims = customClaims}); + Assert.Throws(() => new UserRecord("user1") { CustomClaims = customClaims }); } } [Fact] public void EmptyClaims() { - var emptyClaims = new Dictionary(){ - {"", "value"}, + var emptyClaims = new Dictionary() + { + { string.Empty, "value" }, }; Assert.Throws(() => new UserRecord("user1") { CustomClaims = emptyClaims }); } @@ -73,18 +76,19 @@ public async Task UpdateUser() { var handler = new MockMessageHandler() { - Response = new UserRecord("user1") + Response = new UserRecord("user1"), }; var factory = new MockHttpClientFactory(handler); var userManager = new FirebaseUserManager( new FirebaseUserManagerArgs { - Credential = mockCredential, - ProjectId = mockProjectId, - ClientFactory = factory + Credential = MockCredential, + ProjectId = MockProjectId, + ClientFactory = factory, }); - var customClaims = new Dictionary(){ - {"admin", true}, + var customClaims = new Dictionary() + { + { "admin", true }, }; await userManager.UpdateUserAsync(new UserRecord("user1") { CustomClaims = customClaims }); @@ -95,18 +99,19 @@ public async Task UpdateUserIncorrectResponseObject() { var handler = new MockMessageHandler() { - Response = new object() + Response = new object(), }; var factory = new MockHttpClientFactory(handler); var userManager = new FirebaseUserManager( new FirebaseUserManagerArgs { - Credential = mockCredential, - ProjectId = mockProjectId, - ClientFactory = factory + Credential = MockCredential, + ProjectId = MockProjectId, + ClientFactory = factory, }); - var customClaims = new Dictionary(){ - {"admin", true}, + var customClaims = new Dictionary() + { + { "admin", true }, }; await Assert.ThrowsAsync( @@ -118,18 +123,19 @@ public async Task UpdateUserIncorrectResponseUid() { var handler = new MockMessageHandler() { - Response = new UserRecord("testuser") + Response = new UserRecord("testuser"), }; var factory = new MockHttpClientFactory(handler); var userManager = new FirebaseUserManager( new FirebaseUserManagerArgs { - Credential = mockCredential, - ProjectId = mockProjectId, - ClientFactory = factory + Credential = MockCredential, + ProjectId = MockProjectId, + ClientFactory = factory, }); - var customClaims = new Dictionary(){ - {"admin", true}, + var customClaims = new Dictionary() + { + { "admin", true }, }; await Assert.ThrowsAsync( @@ -141,18 +147,19 @@ public async Task UpdateUserHttpError() { var handler = new MockMessageHandler() { - StatusCode = HttpStatusCode.InternalServerError + StatusCode = HttpStatusCode.InternalServerError, }; var factory = new MockHttpClientFactory(handler); var userManager = new FirebaseUserManager( new FirebaseUserManagerArgs { - Credential = mockCredential, - ProjectId = mockProjectId, - ClientFactory = factory + Credential = MockCredential, + ProjectId = MockProjectId, + ClientFactory = factory, }); - var customClaims = new Dictionary(){ - {"admin", true}, + var customClaims = new Dictionary() + { + { "admin", true }, }; await Assert.ThrowsAsync( diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/HttpPublicKeySourceTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/HttpPublicKeySourceTest.cs index e7563010..cf4406b4 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/HttpPublicKeySourceTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/HttpPublicKeySourceTest.cs @@ -18,9 +18,9 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using Xunit; -using FirebaseAdmin.Tests; using FirebaseAdmin.Auth; +using FirebaseAdmin.Tests; +using Xunit; namespace FirebaseAdmin.Auth.Tests { @@ -92,7 +92,7 @@ public void InvalidArguments() Assert.Throws( () => new HttpPublicKeySource(null, clock, clientFactory)); Assert.Throws( - () => new HttpPublicKeySource("", clock, clientFactory)); + () => new HttpPublicKeySource(string.Empty, clock, clientFactory)); Assert.Throws( () => new HttpPublicKeySource("https://example.com/certs", null, clientFactory)); Assert.Throws( diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/IAMSignerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/IAMSignerTest.cs index 20815944..3bd82579 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/IAMSignerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/IAMSignerTest.cs @@ -19,14 +19,65 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using FirebaseAdmin.Tests; using Google.Apis.Auth.OAuth2; using Google.Apis.Http; using Google.Apis.Json; using Xunit; -using FirebaseAdmin.Tests; namespace FirebaseAdmin.Auth.Tests { + public class IAMSignerTest + { + [Fact] + public async Task Signer() + { + var bytes = Encoding.UTF8.GetBytes("signature"); + var handler = new MockMessageHandler() + { + Response = "discovered-service-account", + }; + var factory = new MockHttpClientFactory(handler); + var signer = new IAMSigner(factory, GoogleCredential.FromAccessToken("token")); + Assert.Equal("discovered-service-account", await signer.GetKeyIdAsync()); + Assert.Equal(1, handler.Calls); + + // should only fetch account once + Assert.Equal("discovered-service-account", await signer.GetKeyIdAsync()); + Assert.Equal(1, handler.Calls); + + handler.Response = new IAMSigner.SignBlobResponse() + { + Signature = Convert.ToBase64String(bytes), + }; + byte[] data = Encoding.UTF8.GetBytes("Hello world"); + byte[] signature = await signer.SignDataAsync(data); + Assert.Equal(bytes, signature); + var req = NewtonsoftJsonSerializer.Instance.Deserialize( + handler.Request); + Assert.Equal(Convert.ToBase64String(data), req.BytesToSign); + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task AccountDiscoveryError() + { + var bytes = Encoding.UTF8.GetBytes("signature"); + var handler = new MockMessageHandler() + { + StatusCode = HttpStatusCode.InternalServerError, + }; + var factory = new MockHttpClientFactory(handler); + var signer = new IAMSigner(factory, GoogleCredential.FromAccessToken("token")); + await Assert.ThrowsAsync( + async () => await signer.GetKeyIdAsync()); + Assert.Equal(1, handler.Calls); + await Assert.ThrowsAsync( + async () => await signer.GetKeyIdAsync()); + Assert.Equal(1, handler.Calls); + } + } + public class FixedAccountIAMSignerTest { [Fact] @@ -35,7 +86,7 @@ public async Task Signer() var bytes = Encoding.UTF8.GetBytes("signature"); var handler = new MockMessageHandler() { - Response = new SignBlobResponse() + Response = new IAMSigner.SignBlobResponse() { Signature = Convert.ToBase64String(bytes), }, @@ -47,7 +98,7 @@ public async Task Signer() byte[] data = Encoding.UTF8.GetBytes("Hello world"); byte[] signature = await signer.SignDataAsync(data); Assert.Equal(bytes, signature); - var req = NewtonsoftJsonSerializer.Instance.Deserialize( + var req = NewtonsoftJsonSerializer.Instance.Deserialize( handler.Request); Assert.Equal(Convert.ToBase64String(data), req.BytesToSign); Assert.Equal(1, handler.Calls); @@ -59,7 +110,7 @@ public async Task WelformedSignError() var handler = new MockMessageHandler() { StatusCode = HttpStatusCode.InternalServerError, - Response = @"{""error"": {""message"": ""test reason""}}" + Response = @"{""error"": {""message"": ""test reason""}}", }; var factory = new MockHttpClientFactory(handler); var signer = new FixedAccountIAMSigner( @@ -77,7 +128,7 @@ public async Task UnexpectedSignError() var handler = new MockMessageHandler() { StatusCode = HttpStatusCode.InternalServerError, - Response = "not json" + Response = "not json", }; var factory = new MockHttpClientFactory(handler); var signer = new FixedAccountIAMSigner( @@ -89,55 +140,4 @@ public async Task UnexpectedSignError() Assert.Contains("not json", ex.Message); } } - - public class IAMSignerTest - { - [Fact] - public async Task Signer() - { - var bytes = Encoding.UTF8.GetBytes("signature"); - var handler = new MockMessageHandler() - { - Response = "discovered-service-account", - }; - var factory = new MockHttpClientFactory(handler); - var signer = new IAMSigner(factory, GoogleCredential.FromAccessToken("token")); - Assert.Equal("discovered-service-account", await signer.GetKeyIdAsync()); - Assert.Equal(1, handler.Calls); - - // should only fetch account once - Assert.Equal("discovered-service-account", await signer.GetKeyIdAsync()); - Assert.Equal(1, handler.Calls); - - handler.Response = new SignBlobResponse() - { - Signature = Convert.ToBase64String(bytes), - }; - byte[] data = Encoding.UTF8.GetBytes("Hello world"); - byte[] signature = await signer.SignDataAsync(data); - Assert.Equal(bytes, signature); - var req = NewtonsoftJsonSerializer.Instance.Deserialize( - handler.Request); - Assert.Equal(Convert.ToBase64String(data), req.BytesToSign); - Assert.Equal(2, handler.Calls); - } - - [Fact] - public async Task AccountDiscoveryError() - { - var bytes = Encoding.UTF8.GetBytes("signature"); - var handler = new MockMessageHandler() - { - StatusCode = HttpStatusCode.InternalServerError, - }; - var factory = new MockHttpClientFactory(handler); - var signer = new IAMSigner(factory, GoogleCredential.FromAccessToken("token")); - await Assert.ThrowsAsync( - async () => await signer.GetKeyIdAsync()); - Assert.Equal(1, handler.Calls); - await Assert.ThrowsAsync( - async () => await signer.GetKeyIdAsync()); - Assert.Equal(1, handler.Calls); - } - } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/ServiceAccountSignerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/ServiceAccountSignerTest.cs index 2874a79f..cf76f125 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/ServiceAccountSignerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/ServiceAccountSignerTest.cs @@ -18,9 +18,9 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading.Tasks; -using Xunit; using FirebaseAdmin.Auth; using Google.Apis.Auth.OAuth2; +using Xunit; namespace FirebaseAdmin.Auth.Tests { @@ -30,13 +30,13 @@ public class ServiceAccountSignerTest public async Task Signer() { var credential = GoogleCredential.FromFile("./resources/service_account.json"); - var serviceAccount = (ServiceAccountCredential) credential.UnderlyingCredential; + var serviceAccount = (ServiceAccountCredential)credential.UnderlyingCredential; var signer = new ServiceAccountSigner(serviceAccount); - Assert.Equal("client@test-project.iam.gserviceaccount.com", - await signer.GetKeyIdAsync()); + Assert.Equal( + "client@test-project.iam.gserviceaccount.com", await signer.GetKeyIdAsync()); byte[] data = Encoding.UTF8.GetBytes("Hello world"); byte[] signature = signer.SignDataAsync(data).Result; - Assert.True(Verify(data, signature)); + Assert.True(this.Verify(data, signature)); } [Fact] @@ -48,7 +48,7 @@ public void NullCredential() private bool Verify(byte[] data, byte[] signature) { var x509cert = new X509Certificate2(File.ReadAllBytes("./resources/public_cert.pem")); - var rsa = (RSA) x509cert.PublicKey.Key; + var rsa = (RSA)x509cert.PublicKey.Key; return rsa.VerifyData( data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj index fd4c9036..8641fa7b 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj @@ -5,6 +5,8 @@ false ../../FirebaseAdmin.snk true + true + ../../stylecop_test.ruleset @@ -13,6 +15,9 @@ + + all + diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppTest.cs index cc525acd..5a453821 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppTest.cs @@ -14,13 +14,13 @@ using System; using System.Threading.Tasks; -using Xunit; using FirebaseAdmin; using Google.Apis.Auth.OAuth2; +using Xunit; namespace FirebaseAdmin.Tests { - public class FirebaseAppTest: IDisposable + public class FirebaseAppTest : IDisposable { private static readonly AppOptions TestOptions = new AppOptions() { @@ -96,7 +96,7 @@ public void CreateAppOptions() }; var app = FirebaseApp.Create(options); Assert.Equal("[DEFAULT]", app.Name); - + var copy = app.Options; Assert.NotSame(options, copy); Assert.Same(credential, copy.Credential); @@ -114,7 +114,7 @@ public void ServiceAccountCredentialScoping() }; var app = FirebaseApp.Create(options); Assert.Equal("[DEFAULT]", app.Name); - + var copy = app.Options; Assert.NotSame(options, copy); Assert.NotSame(credential, copy.Credential); @@ -139,7 +139,7 @@ public void ApplicationDefaultCredentials() } finally { - Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", ""); + Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", string.Empty); } } @@ -169,7 +169,7 @@ public void GetProjectIdFromServiceAccount() [Fact] public void GetProjectIdFromEnvironment() { - foreach (var name in new string[]{"GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"}) + foreach (var name in new string[] { "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT" }) { Environment.SetEnvironmentVariable(name, "env-project"); try @@ -180,7 +180,7 @@ public void GetProjectIdFromEnvironment() } finally { - Environment.SetEnvironmentVariable(name, ""); + Environment.SetEnvironmentVariable(name, string.Empty); } } } @@ -196,14 +196,15 @@ public void GetOrInitService() var service1 = app.GetOrInit("MockService", factory); var service2 = app.GetOrInit("MockService", factory); Assert.Same(service1, service2); - Assert.Throws(() => { + Assert.Throws(() => + { app.GetOrInit("MockService", () => { return new OtherMockService(); }); }); - + Assert.False(service1.Deleted); app.Delete(); Assert.True(service1.Deleted); - Assert.Throws(() => + Assert.Throws(() => { app.GetOrInit("MockService", factory); }); @@ -211,22 +212,22 @@ public void GetOrInitService() public void Dispose() { - FirebaseApp.DeleteAll(); + FirebaseApp.DeleteAll(); } } - internal class MockService: IFirebaseService + internal class MockService : IFirebaseService { public bool Deleted { get; private set; } public void Delete() { - Deleted = true; + this.Deleted = true; } } - internal class OtherMockService: IFirebaseService + internal class OtherMockService : IFirebaseService { - public void Delete() {} + public void Delete() { } } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs new file mode 100644 index 00000000..50d459cf --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs @@ -0,0 +1,116 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Tests; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Newtonsoft.Json; +using Xunit; + +namespace FirebaseAdmin.Messaging.Tests +{ + public class FirebaseMessagingClientTest + { + private static readonly GoogleCredential MockCredential = + GoogleCredential.FromAccessToken("test-token"); + + [Fact] + public void NoProjectId() + { + var clientFactory = new HttpClientFactory(); + Assert.Throws( + () => new FirebaseMessagingClient(clientFactory, MockCredential, null)); + Assert.Throws( + () => new FirebaseMessagingClient(clientFactory, MockCredential, string.Empty)); + } + + [Fact] + public void NoCredential() + { + var clientFactory = new HttpClientFactory(); + Assert.Throws( + () => new FirebaseMessagingClient(clientFactory, null, "test-project")); + } + + [Fact] + public void NoClientFactory() + { + var clientFactory = new HttpClientFactory(); + Assert.Throws( + () => new FirebaseMessagingClient(null, MockCredential, "test-project")); + } + + [Fact] + public async Task SendAsync() + { + var handler = new MockMessageHandler() + { + Response = new FirebaseMessagingClient.SendResponse() + { + Name = "test-response", + }, + }; + var factory = new MockHttpClientFactory(handler); + var client = new FirebaseMessagingClient(factory, MockCredential, "test-project"); + var message = new Message() + { + Topic = "test-topic", + }; + var response = await client.SendAsync(message); + Assert.Equal("test-response", response); + var req = JsonConvert.DeserializeObject( + handler.Request); + Assert.Equal("test-topic", req.Message.Topic); + Assert.False(req.ValidateOnly); + Assert.Equal(1, handler.Calls); + + // Send in dryRun mode. + response = await client.SendAsync(message, dryRun: true); + Assert.Equal("test-response", response); + req = JsonConvert.DeserializeObject( + handler.Request); + Assert.Equal("test-topic", req.Message.Topic); + Assert.True(req.ValidateOnly); + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task HttpErrorAsync() + { + var handler = new MockMessageHandler() + { + StatusCode = HttpStatusCode.InternalServerError, + Response = "not json", + }; + var factory = new MockHttpClientFactory(handler); + var client = new FirebaseMessagingClient(factory, MockCredential, "test-project"); + var message = new Message() + { + Topic = "test-topic", + }; + var ex = await Assert.ThrowsAsync( + async () => await client.SendAsync(message)); + Assert.Contains("not json", ex.Message); + var req = JsonConvert.DeserializeObject( + handler.Request); + Assert.Equal("test-topic", req.Message.Topic); + Assert.False(req.ValidateOnly); + Assert.Equal(1, handler.Calls); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs new file mode 100644 index 00000000..363ac3c7 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingTest.cs @@ -0,0 +1,84 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Tests; +using Google.Apis.Auth.OAuth2; +using Xunit; + +namespace FirebaseAdmin.Messaging.Tests +{ + public class FirebaseMessagingTest : IDisposable + { + private static readonly GoogleCredential MockCredential = + GoogleCredential.FromFile("./resources/service_account.json"); + + [Fact] + public void GetMessagingWithoutApp() + { + Assert.Null(FirebaseMessaging.DefaultInstance); + } + + [Fact] + public void GetDefaultMessaging() + { + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); + FirebaseMessaging messaging = FirebaseMessaging.DefaultInstance; + Assert.NotNull(messaging); + Assert.Same(messaging, FirebaseMessaging.DefaultInstance); + app.Delete(); + Assert.Null(FirebaseMessaging.DefaultInstance); + } + + [Fact] + public void GetMessaging() + { + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }, "MyApp"); + FirebaseMessaging messaging = FirebaseMessaging.GetMessaging(app); + Assert.NotNull(messaging); + Assert.Same(messaging, FirebaseMessaging.GetMessaging(app)); + app.Delete(); + Assert.Throws(() => FirebaseMessaging.GetMessaging(app)); + } + + [Fact] + public async Task UseAfterDelete() + { + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); + FirebaseMessaging messaging = FirebaseMessaging.DefaultInstance; + app.Delete(); + await Assert.ThrowsAsync( + async () => await messaging.SendAsync(new Message() { Topic = "test-topic" })); + } + + [Fact] + public async Task SendMessageCancel() + { + var cred = GoogleCredential.FromFile("./resources/service_account.json"); + FirebaseApp.Create(new AppOptions() { Credential = cred }); + var canceller = new CancellationTokenSource(); + canceller.Cancel(); + await Assert.ThrowsAsync( + async () => await FirebaseMessaging.DefaultInstance.SendAsync( + new Message() { Topic = "test-topic" }, canceller.Token)); + } + + public void Dispose() + { + FirebaseApp.DeleteAll(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs new file mode 100644 index 00000000..eaee0d05 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/MessageTest.cs @@ -0,0 +1,1570 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Google.Apis.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace FirebaseAdmin.Messaging.Tests +{ + public class MessageTest + { + [Fact] + public void EmptyMessage() + { + var message = new Message() { Token = "test-token" }; + this.AssertJsonEquals(new JObject() { { "token", "test-token" } }, message); + + message = new Message() { Topic = "test-topic" }; + this.AssertJsonEquals(new JObject() { { "topic", "test-topic" } }, message); + + message = new Message() { Condition = "test-condition" }; + this.AssertJsonEquals(new JObject() { { "condition", "test-condition" } }, message); + } + + [Fact] + public void PrefixedTopicName() + { + var message = new Message() { Topic = "/topics/test-topic" }; + this.AssertJsonEquals(new JObject() { { "topic", "test-topic" } }, message); + } + + [Fact] + public void DataMessage() + { + var message = new Message() + { + Topic = "test-topic", + Data = new Dictionary() + { + { "k1", "v1" }, + { "k2", "v2" }, + }, + }; + this.AssertJsonEquals( + new JObject() + { + { "topic", "test-topic" }, + { "data", new JObject() { { "k1", "v1" }, { "k2", "v2" } } }, + }, message); + } + + [Fact] + public void Notification() + { + var message = new Message() + { + Topic = "test-topic", + Notification = new Notification() + { + Title = "title", + Body = "body", + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "notification", new JObject() + { + { "title", "title" }, + { "body", "body" }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void MessageDeserialization() + { + var original = new Message() + { + Topic = "test-topic", + Data = new Dictionary() { { "key", "value" } }, + Notification = new Notification() + { + Title = "title", + Body = "body", + }, + Android = new AndroidConfig() + { + RestrictedPackageName = "test-pkg-name", + }, + Apns = new ApnsConfig() + { + Aps = new Aps() + { + AlertString = "test-alert", + }, + }, + Webpush = new WebpushConfig() + { + Data = new Dictionary() { { "key", "value" } }, + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Topic, copy.Topic); + Assert.Equal(original.Data, copy.Data); + Assert.Equal(original.Notification.Title, copy.Notification.Title); + Assert.Equal(original.Notification.Body, copy.Notification.Body); + Assert.Equal( + original.Android.RestrictedPackageName, copy.Android.RestrictedPackageName); + Assert.Equal(original.Apns.Aps.AlertString, copy.Apns.Aps.AlertString); + Assert.Equal(original.Webpush.Data, copy.Webpush.Data); + } + + [Fact] + public void MessageCopy() + { + var original = new Message() + { + Topic = "test-topic", + Data = new Dictionary(), + Notification = new Notification(), + Android = new AndroidConfig(), + Apns = new ApnsConfig(), + Webpush = new WebpushConfig(), + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Data, copy.Data); + Assert.NotSame(original.Notification, copy.Notification); + Assert.NotSame(original.Android, copy.Android); + Assert.NotSame(original.Apns, copy.Apns); + Assert.NotSame(original.Webpush, copy.Webpush); + } + + [Fact] + public void MessageWithoutTarget() + { + Assert.Throws(() => new Message().CopyAndValidate()); + } + + [Fact] + public void MultipleTargets() + { + var message = new Message() + { + Token = "test-token", + Topic = "test-topic", + }; + Assert.Throws(() => message.CopyAndValidate()); + + message = new Message() + { + Token = "test-token", + Condition = "test-condition", + }; + Assert.Throws(() => message.CopyAndValidate()); + + message = new Message() + { + Condition = "test-condition", + Topic = "test-topic", + }; + Assert.Throws(() => message.CopyAndValidate()); + + message = new Message() + { + Token = "test-token", + Topic = "test-topic", + Condition = "test-condition", + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void InvalidTopicNames() + { + var topics = new List() + { + "/topics/", "/foo/bar", "foo bar", + }; + foreach (var topic in topics) + { + var message = new Message() { Topic = topic }; + Assert.Throws(() => message.CopyAndValidate()); + } + } + + [Fact] + public void AndroidConfig() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig() + { + CollapseKey = "collapse-key", + Priority = Priority.High, + TimeToLive = TimeSpan.FromMilliseconds(10), + RestrictedPackageName = "test-pkg-name", + Data = new Dictionary() + { + { "k1", "v1" }, + { "k2", "v2" }, + }, + Notification = new AndroidNotification() + { + Title = "title", + Body = "body", + Icon = "icon", + Color = "#112233", + Sound = "sound", + Tag = "tag", + ClickAction = "click-action", + TitleLocKey = "title-loc-key", + TitleLocArgs = new List() { "arg1", "arg2" }, + BodyLocKey = "body-loc-key", + BodyLocArgs = new List() { "arg3", "arg4" }, + ChannelId = "channel-id", + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "android", new JObject() + { + { "collapse_key", "collapse-key" }, + { "priority", "high" }, + { "ttl", "0.010000000s" }, + { "restricted_package_name", "test-pkg-name" }, + { "data", new JObject() { { "k1", "v1" }, { "k2", "v2" } } }, + { + "notification", new JObject() + { + { "title", "title" }, + { "body", "body" }, + { "icon", "icon" }, + { "color", "#112233" }, + { "sound", "sound" }, + { "tag", "tag" }, + { "click_action", "click-action" }, + { "title_loc_key", "title-loc-key" }, + { "title_loc_args", new JArray() { "arg1", "arg2" } }, + { "body_loc_key", "body-loc-key" }, + { "body_loc_args", new JArray() { "arg3", "arg4" } }, + { "channel_id", "channel-id" }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void AndroidConfigMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig(), + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { "android", new JObject() }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void AndroidConfigFullSecondsTTL() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig() + { + TimeToLive = TimeSpan.FromHours(1), + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "android", new JObject() + { + { "ttl", "3600s" }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void AndroidConfigDeserialization() + { + var original = new AndroidConfig() + { + CollapseKey = "collapse-key", + RestrictedPackageName = "test-pkg-name", + TimeToLive = TimeSpan.FromSeconds(10.5), + Priority = Priority.High, + Data = new Dictionary() + { + { "key", "value" }, + }, + Notification = new AndroidNotification() + { + Title = "title", + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.CollapseKey, copy.CollapseKey); + Assert.Equal(original.RestrictedPackageName, copy.RestrictedPackageName); + Assert.Equal(original.Priority, copy.Priority); + Assert.Equal(original.TimeToLive, copy.TimeToLive); + Assert.Equal(original.Data, copy.Data); + Assert.Equal(original.Notification.Title, copy.Notification.Title); + } + + [Fact] + public void AndroidConfigCopy() + { + var original = new AndroidConfig() + { + Data = new Dictionary(), + Notification = new AndroidNotification(), + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Data, copy.Data); + Assert.NotSame(original.Notification, copy.Notification); + } + + [Fact] + public void AndroidNotificationDeserialization() + { + var original = new AndroidNotification() + { + Title = "title", + Body = "body", + Icon = "icon", + Color = "#112233", + Sound = "sound", + Tag = "tag", + ClickAction = "click-action", + TitleLocKey = "title-loc-key", + TitleLocArgs = new List() { "arg1", "arg2" }, + BodyLocKey = "body-loc-key", + BodyLocArgs = new List() { "arg3", "arg4" }, + ChannelId = "channel-id", + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Title, copy.Title); + Assert.Equal(original.Body, copy.Body); + Assert.Equal(original.Icon, copy.Icon); + Assert.Equal(original.Color, copy.Color); + Assert.Equal(original.Sound, copy.Sound); + Assert.Equal(original.Tag, copy.Tag); + Assert.Equal(original.ClickAction, copy.ClickAction); + Assert.Equal(original.TitleLocKey, copy.TitleLocKey); + Assert.Equal(original.TitleLocArgs, copy.TitleLocArgs); + Assert.Equal(original.BodyLocKey, copy.BodyLocKey); + Assert.Equal(original.BodyLocArgs, copy.BodyLocArgs); + Assert.Equal(original.ChannelId, copy.ChannelId); + } + + [Fact] + public void AndroidNotificationCopy() + { + var original = new AndroidNotification() + { + TitleLocKey = "title-loc-key", + TitleLocArgs = new List() { "arg1", "arg2" }, + BodyLocKey = "body-loc-key", + BodyLocArgs = new List() { "arg3", "arg4" }, + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.TitleLocArgs, copy.TitleLocArgs); + Assert.NotSame(original.BodyLocArgs, copy.BodyLocArgs); + } + + [Fact] + public void AndroidConfigInvalidTTL() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig() + { + TimeToLive = TimeSpan.FromHours(-1), + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationInvalidColor() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig() + { + Notification = new AndroidNotification() + { + Color = "not-a-color", + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationInvalidTitleLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig() + { + Notification = new AndroidNotification() + { + TitleLocArgs = new List() { "arg" }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void AndroidNotificationInvalidBodyLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Android = new AndroidConfig() + { + Notification = new AndroidNotification() + { + BodyLocArgs = new List() { "arg" }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void WebpushConfig() + { + var message = new Message() + { + Topic = "test-topic", + Webpush = new WebpushConfig() + { + Headers = new Dictionary() + { + { "header1", "header-value1" }, + { "header2", "header-value2" }, + }, + Data = new Dictionary() + { + { "key1", "value1" }, + { "key2", "value2" }, + }, + Notification = new WebpushNotification() + { + Title = "title", + Body = "body", + Icon = "icon", + Badge = "badge", + Data = new Dictionary() + { + { "some", "data" }, + }, + Direction = Direction.LeftToRight, + Image = "image", + Language = "language", + Tag = "tag", + Silent = true, + RequireInteraction = true, + Renotify = true, + TimestampMillis = 100, + Vibrate = new int[] { 10, 5, 10 }, + Actions = new List() + { + new Action() + { + ActionName = "Accept", + Title = "Ok", + Icon = "ok-button", + }, + new Action() + { + ActionName = "Reject", + Title = "Cancel", + Icon = "cancel-button", + }, + }, + CustomData = new Dictionary() + { + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "webpush", new JObject() + { + { + "headers", new JObject() + { + { "header1", "header-value1" }, + { "header2", "header-value2" }, + } + }, + { + "data", new JObject() + { + { "key1", "value1" }, + { "key2", "value2" }, + } + }, + { + "notification", new JObject() + { + { "title", "title" }, + { "body", "body" }, + { "icon", "icon" }, + { "badge", "badge" }, + { + "data", new JObject() + { + { "some", "data" }, + } + }, + { "dir", "ltr" }, + { "image", "image" }, + { "lang", "language" }, + { "renotify", true }, + { "requireInteraction", true }, + { "silent", true }, + { "tag", "tag" }, + { "timestamp", 100 }, + { "vibrate", new JArray() { 10, 5, 10 } }, + { + "actions", new JArray() + { + new JObject() + { + { "action", "Accept" }, + { "title", "Ok" }, + { "icon", "ok-button" }, + }, + new JObject() + { + { "action", "Reject" }, + { "title", "Cancel" }, + { "icon", "cancel-button" }, + }, + } + }, + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void WebpushConfigMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Webpush = new WebpushConfig(), + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { "webpush", new JObject() }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void WebpushConfigMinimalNotification() + { + var message = new Message() + { + Topic = "test-topic", + Webpush = new WebpushConfig() + { + Notification = new WebpushNotification() + { + Title = "title", + Body = "body", + Icon = "icon", + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "webpush", new JObject() + { + { + "notification", new JObject() + { + { "title", "title" }, + { "body", "body" }, + { "icon", "icon" }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void WebpushConfigDeserialization() + { + var original = new WebpushConfig() + { + Headers = new Dictionary() + { + { "header1", "header-value1" }, + { "header2", "header-value2" }, + }, + Data = new Dictionary() + { + { "key1", "value1" }, + { "key2", "value2" }, + }, + Notification = new WebpushNotification() + { + Title = "title", + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Headers, copy.Headers); + Assert.Equal(original.Data, copy.Data); + Assert.Equal(original.Notification.Title, copy.Notification.Title); + } + + [Fact] + public void WebpushConfigCopy() + { + var original = new WebpushConfig() + { + Headers = new Dictionary(), + Data = new Dictionary(), + Notification = new WebpushNotification(), + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Headers, copy.Headers); + Assert.NotSame(original.Data, copy.Data); + Assert.NotSame(original.Notification, copy.Notification); + } + + [Fact] + public void WebpushNotificationDeserialization() + { + var original = new WebpushNotification() + { + Title = "title", + Body = "body", + Icon = "icon", + Badge = "badge", + Data = new Dictionary() + { + { "some", "data" }, + }, + Direction = Direction.LeftToRight, + Image = "image", + Language = "language", + Tag = "tag", + Silent = true, + RequireInteraction = true, + Renotify = true, + TimestampMillis = 100, + Vibrate = new int[] { 10, 5, 10 }, + Actions = new List() + { + new Action() + { + ActionName = "Accept", + Title = "Ok", + Icon = "ok-button", + }, + new Action() + { + ActionName = "Reject", + Title = "Cancel", + Icon = "cancel-button", + }, + }, + CustomData = new Dictionary() + { + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Title, copy.Title); + Assert.Equal(original.Body, copy.Body); + Assert.Equal(original.Icon, copy.Icon); + Assert.Equal(original.Badge, copy.Badge); + Assert.Equal(new JObject() { { "some", "data" } }, copy.Data); + Assert.Equal(original.Direction, copy.Direction); + Assert.Equal(original.Image, copy.Image); + Assert.Equal(original.Language, copy.Language); + Assert.Equal(original.Tag, copy.Tag); + Assert.Equal(original.Silent, copy.Silent); + Assert.Equal(original.RequireInteraction, copy.RequireInteraction); + Assert.Equal(original.Renotify, copy.Renotify); + Assert.Equal(original.TimestampMillis, copy.TimestampMillis); + Assert.Equal(original.Vibrate, copy.Vibrate); + var originalActions = original.Actions.ToList(); + var copyActions = original.Actions.ToList(); + Assert.Equal(originalActions.Count, copyActions.Count); + for (int i = 0; i < originalActions.Count; i++) + { + Assert.Equal(originalActions[i].ActionName, copyActions[i].ActionName); + Assert.Equal(originalActions[i].Title, copyActions[i].Title); + Assert.Equal(originalActions[i].Icon, copyActions[i].Icon); + } + + Assert.Equal(original.CustomData, copy.CustomData); + } + + [Fact] + public void WebpushNotificationCopy() + { + var original = new WebpushNotification() + { + Actions = new List() + { + new Action() + { + ActionName = "Accept", + Title = "Ok", + Icon = "ok-button", + }, + }, + CustomData = new Dictionary() + { + { "custom-key1", "custom-data" }, + }, + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Actions, copy.Actions); + Assert.NotSame(original.Actions.First(), copy.Actions.First()); + Assert.NotSame(original.CustomData, copy.CustomData); + Assert.Equal(original.CustomData, copy.CustomData); + } + + [Fact] + public void WebpushNotificationDuplicateKeys() + { + var message = new Message() + { + Topic = "test-topic", + Webpush = new WebpushConfig() + { + Notification = new WebpushNotification() + { + Title = "title", + CustomData = new Dictionary() { { "title", "other" } }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsConfig() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Headers = new Dictionary() + { + { "k1", "v1" }, + { "k2", "v2" }, + }, + Aps = new Aps() + { + AlertString = "alert-text", + Badge = 0, + Category = "test-category", + ContentAvailable = true, + MutableContent = true, + Sound = "sound-file", + ThreadId = "test-thread", + CustomData = new Dictionary() + { + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + }, + }, + CustomData = new Dictionary() + { + { "custom-key3", "custom-data" }, + { "custom-key4", true }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "headers", new JObject() + { + { "k1", "v1" }, + { "k2", "v2" }, + } + }, + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { "alert", "alert-text" }, + { "badge", 0 }, + { "category", "test-category" }, + { "content-available", 1 }, + { "mutable-content", 1 }, + { "sound", "sound-file" }, + { "thread-id", "test-thread" }, + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + } + }, + { "custom-key3", "custom-data" }, + { "custom-key4", true }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsConfigMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps(), + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { "aps", new JObject() }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsConfigDeserialization() + { + var original = new ApnsConfig() + { + Headers = new Dictionary() + { + { "k1", "v1" }, + { "k2", "v2" }, + }, + Aps = new Aps() + { + AlertString = "alert-text", + }, + CustomData = new Dictionary() + { + { "custom-key3", "custom-data" }, + { "custom-key4", true }, + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Headers, copy.Headers); + Assert.Equal(original.CustomData, copy.CustomData); + Assert.Equal(original.Aps.AlertString, copy.Aps.AlertString); + } + + [Fact] + public void ApnsConfigCopy() + { + var original = new ApnsConfig() + { + Headers = new Dictionary(), + Aps = new Aps(), + CustomData = new Dictionary(), + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.Headers, copy.Headers); + Assert.NotSame(original.Aps, copy.Aps); + Assert.NotSame(original.CustomData, copy.CustomData); + } + + [Fact] + public void ApnsConfigCustomApsDeserialization() + { + var original = new ApnsConfig() + { + Headers = new Dictionary() + { + { "k1", "v1" }, + { "k2", "v2" }, + }, + CustomData = new Dictionary() + { + { + "aps", new Dictionary() + { + { "alert", "alert-text" }, + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + } + }, + { "custom-key3", "custom-data" }, + { "custom-key4", true }, + }, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Headers, copy.Headers); + original.CustomData.Remove("aps"); + Assert.Equal(original.CustomData, copy.CustomData); + Assert.Equal("alert-text", copy.Aps.AlertString); + var customApsData = new Dictionary() + { + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + }; + Assert.Equal(customApsData, copy.Aps.CustomData); + } + + [Fact] + public void ApnsCriticalSound() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() + { + Name = "default", + Critical = true, + Volume = 0.5, + }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "sound", new JObject() + { + { "name", "default" }, + { "critical", 1 }, + { "volume", 0.5 }, + } + }, + } + }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsCriticalSoundMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() { Name = "default" }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "sound", new JObject() + { + { "name", "default" }, + } + }, + } + }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsCriticalSoundDeserialization() + { + var original = new CriticalSound() + { + Name = "default", + Volume = 0.5, + Critical = true, + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.Name, copy.Name); + Assert.Equal(original.Volume.Value, copy.Volume.Value); + Assert.Equal(original.Critical, copy.Critical); + } + + [Fact] + public void ApnsApsAlert() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + ActionLocKey = "action-key", + Body = "test-body", + LaunchImage = "test-image", + LocArgs = new List() { "arg1", "arg2" }, + LocKey = "loc-key", + Subtitle = "test-subtitle", + SubtitleLocArgs = new List() { "arg3", "arg4" }, + SubtitleLocKey = "subtitle-key", + Title = "test-title", + TitleLocArgs = new List() { "arg5", "arg6" }, + TitleLocKey = "title-key", + }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "alert", new JObject() + { + { "action-loc-key", "action-key" }, + { "body", "test-body" }, + { "launch-image", "test-image" }, + { "loc-args", new JArray() { "arg1", "arg2" } }, + { "loc-key", "loc-key" }, + { "subtitle", "test-subtitle" }, + { "subtitle-loc-args", new JArray() { "arg3", "arg4" } }, + { "subtitle-loc-key", "subtitle-key" }, + { "title", "test-title" }, + { "title-loc-args", new JArray() { "arg5", "arg6" } }, + { "title-loc-key", "title-key" }, + } + }, + } + }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsApsAlertMinimal() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert(), + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { + "alert", new JObject() + }, + } + }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApsAlertDeserialization() + { + var original = new ApsAlert() + { + ActionLocKey = "action-key", + Body = "test-body", + LaunchImage = "test-image", + LocArgs = new List() { "arg1", "arg2" }, + LocKey = "loc-key", + Subtitle = "test-subtitle", + SubtitleLocArgs = new List() { "arg3", "arg4" }, + SubtitleLocKey = "subtitle-key", + Title = "test-title", + TitleLocArgs = new List() { "arg5", "arg6" }, + TitleLocKey = "title-key", + }; + var json = NewtonsoftJsonSerializer.Instance.Serialize(original); + var copy = NewtonsoftJsonSerializer.Instance.Deserialize(json); + Assert.Equal(original.ActionLocKey, copy.ActionLocKey); + Assert.Equal(original.Body, copy.Body); + Assert.Equal(original.LaunchImage, copy.LaunchImage); + Assert.Equal(original.LocArgs, copy.LocArgs); + Assert.Equal(original.LocKey, copy.LocKey); + Assert.Equal(original.Subtitle, copy.Subtitle); + Assert.Equal(original.SubtitleLocArgs, copy.SubtitleLocArgs); + Assert.Equal(original.SubtitleLocKey, copy.SubtitleLocKey); + Assert.Equal(original.Title, copy.Title); + Assert.Equal(original.TitleLocArgs, copy.TitleLocArgs); + Assert.Equal(original.TitleLocKey, copy.TitleLocKey); + } + + [Fact] + public void ApsAlertCopy() + { + var original = new ApsAlert() + { + LocArgs = new List() { "arg1", "arg2" }, + LocKey = "loc-key", + SubtitleLocArgs = new List() { "arg3", "arg4" }, + SubtitleLocKey = "subtitle-key", + TitleLocArgs = new List() { "arg5", "arg6" }, + TitleLocKey = "title-key", + }; + var copy = original.CopyAndValidate(); + Assert.NotSame(original, copy); + Assert.NotSame(original.LocArgs, copy.LocArgs); + Assert.NotSame(original.SubtitleLocArgs, copy.SubtitleLocArgs); + Assert.NotSame(original.TitleLocArgs, copy.TitleLocArgs); + } + + [Fact] + public void ApnsApsAlertInvalidTitleLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + TitleLocArgs = new List() { "arg1", "arg2" }, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsApsAlertInvalidSubtitleLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + SubtitleLocArgs = new List() { "arg1", "arg2" }, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsApsAlertInvalidLocArgs() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Alert = new ApsAlert() + { + LocArgs = new List() { "arg1", "arg2" }, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsCustomApsWithStandardProperties() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + CustomData = new Dictionary() + { + { + "aps", new Dictionary() + { + { "alert", "alert-text" }, + { "badge", 42 }, + } + }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { "alert", "alert-text" }, + { "badge", 42 }, + } + }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsCustomApsWithCustomProperties() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + CustomData = new Dictionary() + { + { + "aps", new Dictionary() + { + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + } + }, + }, + }, + }; + var expected = new JObject() + { + { "topic", "test-topic" }, + { + "apns", new JObject() + { + { + "payload", new JObject() + { + { + "aps", new JObject() + { + { "custom-key1", "custom-data" }, + { "custom-key2", true }, + } + }, + } + }, + } + }, + }; + this.AssertJsonEquals(expected, message); + } + + [Fact] + public void ApnsNoAps() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + CustomData = new Dictionary() + { + { "test", "custom-data" }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsDuplicateAps() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + AlertString = "alert-text", + }, + CustomData = new Dictionary() + { + { "aps", "custom-data" }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApsDuplicateKeys() + { + var aps = new Aps() + { + AlertString = "alert-text", + CustomData = new Dictionary() + { + { "alert", "other-alert-text" }, + }, + }; + Assert.Throws(() => aps.CopyAndValidate()); + } + + [Fact] + public void ApnsDuplicateApsAlerts() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + AlertString = "alert-text", + Alert = new ApsAlert() + { + Body = "other-alert-text", + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsDuplicateApsSounds() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + Sound = "default", + CriticalSound = new CriticalSound() + { + Name = "other=sound", + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsInvalidCriticalSoundNoName() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound(), + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsInvalidCriticalSoundVolumeTooLow() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() + { + Name = "default", + Volume = -0.1, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + [Fact] + public void ApnsInvalidCriticalSoundVolumeTooHigh() + { + var message = new Message() + { + Topic = "test-topic", + Apns = new ApnsConfig() + { + Aps = new Aps() + { + CriticalSound = new CriticalSound() + { + Name = "default", + Volume = 1.1, + }, + }, + }, + }; + Assert.Throws(() => message.CopyAndValidate()); + } + + private void AssertJsonEquals(JObject expected, Message actual) + { + var json = NewtonsoftJsonSerializer.Instance.Serialize(actual.CopyAndValidate()); + var parsed = JObject.Parse(json); + Assert.True( + JToken.DeepEquals(expected, parsed), + $"Expected: {expected.ToString()}\nActual: {parsed.ToString()}"); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/MockClock.cs b/FirebaseAdmin/FirebaseAdmin.Tests/MockClock.cs index d4fb09e6..f68f7785 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/MockClock.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/MockClock.cs @@ -19,36 +19,37 @@ namespace FirebaseAdmin.Tests { public class MockClock : IClock { - public DateTime Now + private object mutex = new object(); + private DateTime utcNow; + + public MockClock() { - get { return UtcNow.ToLocalTime(); } - set { UtcNow = value.ToUniversalTime(); } + this.Now = DateTime.Now; } - private object _lock = new object(); - private DateTime _utcNow; + public DateTime Now + { + get { return this.UtcNow.ToLocalTime(); } + set { this.UtcNow = value.ToUniversalTime(); } + } public DateTime UtcNow { get { - lock (_lock) + lock (this.mutex) { - return _utcNow; + return this.utcNow; } } + set { - lock (_lock) + lock (this.mutex) { - _utcNow = value; + this.utcNow = value; } } } - - public MockClock() - { - Now = DateTime.Now; - } } } diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs b/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs index 7bc4e209..b6456e79 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs @@ -26,99 +26,103 @@ namespace FirebaseAdmin.Tests { - internal class MockHttpClientFactory : HttpClientFactory - { - private HttpMessageHandler Handler { get; set; } - - public MockHttpClientFactory(HttpMessageHandler handler) - { - Handler = handler; - } - - protected override HttpMessageHandler CreateHandler(CreateHttpClientArgs args) - { - return Handler; - } - } - /// /// An implementation that counts the number of requests - /// processed. + /// and facilitates mocking HTTP interactions locally. /// - internal abstract class CountableMessageHandler : HttpMessageHandler + internal class MockMessageHandler : CountableMessageHandler { - private int _calls; - - public int Calls - { - get { return _calls; } - } - - sealed protected override Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) + public MockMessageHandler() { - Interlocked.Increment(ref _calls); - return SendAsyncCore(request, cancellationToken); + this.StatusCode = HttpStatusCode.OK; } - protected abstract Task SendAsyncCore( - HttpRequestMessage request, CancellationToken cancellationToken); - } + public delegate void SetHeaders(HttpResponseHeaders header); - /// - /// An implementation that counts the number of requests - /// and facilitates mocking HTTP interactions locally. - /// - internal class MockMessageHandler : CountableMessageHandler - { public string Request { get; private set; } - + public HttpStatusCode StatusCode { get; set; } - public Object Response { get; set; } - public delegate void SetHeaders(HttpResponseHeaders header); + public object Response { get; set; } public SetHeaders ApplyHeaders { get; set; } - public MockMessageHandler() - { - StatusCode = HttpStatusCode.OK; - } - protected override async Task SendAsyncCore( HttpRequestMessage request, CancellationToken cancellationToken) { if (request.Content != null) { - Request = await request.Content.ReadAsStringAsync(); + this.Request = await request.Content.ReadAsStringAsync(); } else { - Request = null; - } + this.Request = null; + } + var resp = new HttpResponseMessage(); string json; - if (Response is byte[]) + if (this.Response is byte[]) { - json = Encoding.UTF8.GetString(Response as byte[]); + json = Encoding.UTF8.GetString(this.Response as byte[]); } - else if (Response is string) + else if (this.Response is string) { - json = Response as string; + json = this.Response as string; } else { - json = NewtonsoftJsonSerializer.Instance.Serialize(Response); - } - resp.StatusCode = StatusCode; - if (ApplyHeaders != null) + json = NewtonsoftJsonSerializer.Instance.Serialize(this.Response); + } + + resp.StatusCode = this.StatusCode; + if (this.ApplyHeaders != null) { - ApplyHeaders(resp.Headers); + this.ApplyHeaders(resp.Headers); } + resp.Content = new StringContent(json, Encoding.UTF8, "application/json"); var tcs = new TaskCompletionSource(); tcs.SetResult(resp); return await tcs.Task; } } + + /// + /// An implementation that counts the number of requests + /// processed. + /// + internal abstract class CountableMessageHandler : HttpMessageHandler + { + private int calls; + + public int Calls + { + get { return this.calls; } + } + + protected sealed override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref this.calls); + return this.SendAsyncCore(request, cancellationToken); + } + + protected abstract Task SendAsyncCore( + HttpRequestMessage request, CancellationToken cancellationToken); + } + + internal class MockHttpClientFactory : HttpClientFactory + { + private readonly HttpMessageHandler handler; + + public MockHttpClientFactory(HttpMessageHandler handler) + { + this.handler = handler; + } + + protected override HttpMessageHandler CreateHandler(CreateHttpClientArgs args) + { + return this.handler; + } + } } diff --git a/FirebaseAdmin/FirebaseAdmin/AppOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppOptions.cs index e5ff1fbc..9d529ac5 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppOptions.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppOptions.cs @@ -26,35 +26,37 @@ namespace FirebaseAdmin public sealed class AppOptions { /// - /// used to authorize an app. All service calls made by - /// the app will be authorized using this. + /// Initializes a new instance of the class. + /// + public AppOptions() { } + + internal AppOptions(AppOptions options) + { + this.Credential = options.Credential; + this.ProjectId = options.ProjectId; + this.ServiceAccountId = options.ServiceAccountId; + } + + /// + /// Gets or sets the used to authorize an app. All service + /// calls made by the app will be authorized using this. /// public GoogleCredential Credential { get; set; } /// - /// The Google Cloud Platform project ID that should be associated with an app. + /// Gets or sets the Google Cloud Platform project ID that should be associated with an + /// app. /// public string ProjectId { get; set; } /// - /// The unique ID of the service account that should be associated with an app. + /// Gets or sets the unique ID of the service account that should be associated with an + /// app. /// This is used to /// create custom auth tokens when service account credentials are not available. The /// service account ID can be found in the client_email field of the service account /// JSON. /// public string ServiceAccountId { get; set; } - - /// - /// Creates a new instance. - /// - public AppOptions() {} - - internal AppOptions(AppOptions options) - { - Credential = options.Credential; - ProjectId = options.ProjectId; - ServiceAccountId = options.ServiceAccountId; - } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseAuth.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseAuth.cs index bdea0d95..65910fbb 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseAuth.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseAuth.cs @@ -23,24 +23,62 @@ namespace FirebaseAdmin.Auth /// This is the entry point to all server-side Firebase Authentication operations. You can /// get an instance of this class via FirebaseAuth.DefaultInstance. /// - public sealed class FirebaseAuth: IFirebaseService + public sealed class FirebaseAuth : IFirebaseService { - private readonly FirebaseApp _app; - private bool _deleted; - private readonly Lazy _tokenFactory; - private readonly Lazy _idTokenVerifier; - private readonly Lazy _userManager; - private readonly Object _lock = new Object(); + private readonly FirebaseApp app; + private readonly Lazy tokenFactory; + private readonly Lazy idTokenVerifier; + private readonly Lazy userManager; + private readonly object authLock = new object(); + private bool deleted; private FirebaseAuth(FirebaseApp app) { - _app = app; - _tokenFactory = new Lazy(() => - FirebaseTokenFactory.Create(_app), true); - _idTokenVerifier = new Lazy(() => - FirebaseTokenVerifier.CreateIDTokenVerifier(_app), true); - _userManager = new Lazy(() => - FirebaseUserManager.Create(_app)); + this.app = app; + this.tokenFactory = new Lazy( + () => FirebaseTokenFactory.Create(this.app), true); + this.idTokenVerifier = new Lazy( + () => FirebaseTokenVerifier.CreateIDTokenVerifier(this.app), true); + this.userManager = new Lazy(() => + FirebaseUserManager.Create(this.app)); + } + + /// + /// Gets the auth instance associated with the default Firebase app. This property is + /// null if the default app doesn't yet exist. + /// + public static FirebaseAuth DefaultInstance + { + get + { + var app = FirebaseApp.DefaultInstance; + if (app == null) + { + return null; + } + + return GetAuth(app); + } + } + + /// + /// Returns the auth instance for the specified app. + /// + /// The instance associated with the specified + /// app. + /// If the app argument is null. + /// An app instance. + public static FirebaseAuth GetAuth(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException("App argument must not be null."); + } + + return app.GetOrInit(typeof(FirebaseAuth).Name, () => + { + return new FirebaseAuth(app); + }); } /// @@ -76,7 +114,7 @@ private FirebaseAuth(FirebaseApp app) /// 128 characters. public async Task CreateCustomTokenAsync(string uid) { - return await CreateCustomTokenAsync(uid, default(CancellationToken)); + return await this.CreateCustomTokenAsync(uid, default(CancellationToken)); } /// @@ -115,7 +153,7 @@ public async Task CreateCustomTokenAsync(string uid) public async Task CreateCustomTokenAsync( string uid, CancellationToken cancellationToken) { - return await CreateCustomTokenAsync(uid, null, cancellationToken); + return await this.CreateCustomTokenAsync(uid, null, cancellationToken); } /// @@ -142,7 +180,7 @@ public async Task CreateCustomTokenAsync( public async Task CreateCustomTokenAsync( string uid, IDictionary developerClaims) { - return await CreateCustomTokenAsync(uid, developerClaims, default(CancellationToken)); + return await this.CreateCustomTokenAsync(uid, developerClaims, default(CancellationToken)); } /// @@ -174,14 +212,16 @@ public async Task CreateCustomTokenAsync( CancellationToken cancellationToken) { FirebaseTokenFactory tokenFactory; - lock (_lock) + lock (this.authLock) { - if (_deleted) + if (this.deleted) { throw new InvalidOperationException("Cannot invoke after deleting the app."); } - tokenFactory = _tokenFactory.Value; + + tokenFactory = this.tokenFactory.Value; } + return await tokenFactory.CreateCustomTokenAsync( uid, developerClaims, cancellationToken).ConfigureAwait(false); } @@ -204,7 +244,7 @@ public async Task CreateCustomTokenAsync( /// A Firebase ID token string to parse and verify. public async Task VerifyIdTokenAsync(string idToken) { - return await VerifyIdTokenAsync(idToken, default(CancellationToken)); + return await this.VerifyIdTokenAsync(idToken, default(CancellationToken)); } /// @@ -228,26 +268,28 @@ public async Task VerifyIdTokenAsync(string idToken) public async Task VerifyIdTokenAsync( string idToken, CancellationToken cancellationToken) { - lock (_lock) + lock (this.authLock) { - if (_deleted) + if (this.deleted) { throw new InvalidOperationException("Cannot invoke after deleting the app."); } } - return await _idTokenVerifier.Value.VerifyTokenAsync(idToken, cancellationToken) + + return await this.idTokenVerifier.Value.VerifyTokenAsync(idToken, cancellationToken) .ConfigureAwait(false); } /// - /// Sets the specified custom claims on an existing user account. A null claims value + /// Sets the specified custom claims on an existing user account. A null claims value /// removes any claims currently set on the user account. The claims should serialize into /// a valid JSON string. The serialized claims must not be larger than 1000 characters. /// + /// A task that completes when the claims have been set. /// If is null, empty or longer - /// than 128 characters. Or, if the serialized is larger than 1000 + /// than 128 characters. Or, if the serialized is larger than 1000 /// characters. - /// The user ID string for the custom claims will be set. Must not be null + /// The user ID string for the custom claims will be set. Must not be null /// or longer than 128 characters. /// /// The claims to be stored on the user account, and made @@ -255,9 +297,9 @@ public async Task VerifyIdTokenAsync( /// serialization it should not be larger than 1000 characters. public async Task SetCustomUserClaimsAsync(string uid, IReadOnlyDictionary claims) { - lock (_lock) + lock (this.authLock) { - if (_deleted) + if (this.deleted) { throw new InvalidOperationException("Cannot invoke after deleting the app."); } @@ -265,58 +307,30 @@ public async Task SetCustomUserClaimsAsync(string uid, IReadOnlyDictionary + /// Deletes this service instance. + /// void IFirebaseService.Delete() { - lock (_lock) + lock (this.authLock) { - _deleted = true; - if (_tokenFactory.IsValueCreated) + this.deleted = true; + if (this.tokenFactory.IsValueCreated) { - _tokenFactory.Value.Dispose(); + this.tokenFactory.Value.Dispose(); } - } - } - /// - /// The auth instance associated with the default Firebase app. This property is - /// null if the default app doesn't yet exist. - /// - public static FirebaseAuth DefaultInstance - { - get - { - var app = FirebaseApp.DefaultInstance; - if (app == null) + if (this.userManager.IsValueCreated) { - return null; + this.userManager.Value.Dispose(); } - return GetAuth(app); } } - - /// - /// Returns the auth instance for the specified app. - /// - /// The instance associated with the specified - /// app. - /// If the app argument is null. - /// An app instance. - public static FirebaseAuth GetAuth(FirebaseApp app) - { - if (app == null) - { - throw new ArgumentNullException("App argument must not be null."); - } - return app.GetOrInit(typeof(FirebaseAuth).Name, () => - { - return new FirebaseAuth(app); - }); - } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs index e9c0c7e3..9cd4cc23 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs @@ -23,73 +23,54 @@ namespace FirebaseAdmin.Auth /// public sealed class FirebaseToken { + internal FirebaseToken(FirebaseTokenArgs args) + { + this.Issuer = args.Issuer; + this.Subject = args.Subject; + this.Audience = args.Audience; + this.ExpirationTimeSeconds = args.ExpirationTimeSeconds; + this.IssuedAtTimeSeconds = args.IssuedAtTimeSeconds; + this.Uid = args.Subject; + this.Claims = args.Claims; + } + /// - /// The issuer claim that identifies the principal that issued the JWT. + /// Gets the issuer claim that identifies the principal that issued the JWT. /// public string Issuer { get; private set; } /// - /// The subject claim identifying the principal that is the subject of the JWT. + /// Gets the subject claim identifying the principal that is the subject of the JWT. /// public string Subject { get; private set; } /// - /// The audience claim that identifies the audience that the JWT is intended for. + /// Gets the audience claim that identifies the audience that the JWT is intended for. /// public string Audience { get; private set; } /// - /// The expiration time claim that identifies the expiration time (in seconds) + /// Gets the expiration time claim that identifies the expiration time (in seconds) /// on or after which the token MUST NOT be accepted for processing. /// public long ExpirationTimeSeconds { get; private set; } /// - /// The issued at claim that identifies the time (in seconds) at which the JWT was issued. + /// Gets the issued at claim that identifies the time (in seconds) at which the JWT was + /// issued. /// public long IssuedAtTimeSeconds { get; private set; } - + /// - /// User ID of the user to which this ID token belongs. This is same as Subject. + /// Gets the User ID of the user to which this ID token belongs. This is same as + /// . /// public string Uid { get; private set; } /// - /// A read-only dictionary of all other claims present in the JWT. This can be used to + /// Gets Aall other claims present in the JWT as a readonly dictionary. This can be used to /// access custom claims of the token. /// public IReadOnlyDictionary Claims { get; private set; } - - internal FirebaseToken(FirebaseTokenArgs args) - { - Issuer = args.Issuer; - Subject = args.Subject; - Audience = args.Audience; - ExpirationTimeSeconds = args.ExpirationTimeSeconds; - IssuedAtTimeSeconds = args.IssuedAtTimeSeconds; - Uid = args.Subject; - Claims = args.Claims; - } - } - - internal sealed class FirebaseTokenArgs - { - [JsonProperty("iss")] - public string Issuer { get; set; } - - [JsonProperty("sub")] - public string Subject { get; set; } - - [JsonProperty("aud")] - public string Audience { get; set; } - - [JsonProperty("exp")] - public long ExpirationTimeSeconds { get; set; } - - [JsonProperty("iat")] - public long IssuedAtTimeSeconds { get; set; } - - [JsonIgnore] - public IReadOnlyDictionary Claims { get; set; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenArgs.cs new file mode 100644 index 00000000..027feb6b --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenArgs.cs @@ -0,0 +1,40 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Auth +{ + internal sealed class FirebaseTokenArgs + { + [JsonProperty("iss")] + public string Issuer { get; set; } + + [JsonProperty("sub")] + public string Subject { get; set; } + + [JsonProperty("aud")] + public string Audience { get; set; } + + [JsonProperty("exp")] + public long ExpirationTimeSeconds { get; set; } + + [JsonProperty("iat")] + public long IssuedAtTimeSeconds { get; set; } + + [JsonIgnore] + public IReadOnlyDictionary Claims { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenFactory.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenFactory.cs index 79e3b4ab..411c97fa 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenFactory.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenFactory.cs @@ -15,46 +15,86 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; -using System.Threading.Tasks; -using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Google.Apis.Auth; -using Google.Apis.Http; using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; using Google.Apis.Util; -[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey="+ -"002400000480000094000000060200000024000052534131000400000100010081328559eaab41"+ -"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02"+ -"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597"+ -"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f"+ +[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey=" + +"002400000480000094000000060200000024000052534131000400000100010081328559eaab41" + +"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02" + +"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597" + +"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f" + "badddb9c")] namespace FirebaseAdmin.Auth { /// /// A helper class that creates Firebase custom tokens. /// - internal class FirebaseTokenFactory: IDisposable + internal class FirebaseTokenFactory : IDisposable { public const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" + "google.identity.identitytoolkit.v1.IdentityToolkit"; - + public const int TokenDurationSeconds = 3600; public static readonly DateTime UnixEpoch = new DateTime( 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + public static readonly ImmutableList ReservedClaims = ImmutableList.Create( - "acr", "amr", "at_hash", "aud", "auth_time", "azp", "cnf", "c_hash", - "exp", "firebase", "iat", "iss", "jti", "nbf", "nonce", "sub" - ); + "acr", + "amr", + "at_hash", + "aud", + "auth_time", + "azp", + "cnf", + "c_hash", + "exp", + "firebase", + "iat", + "iss", + "jti", + "nbf", + "nonce", + "sub"); - private readonly ISigner _signer; - private readonly IClock _clock; + private readonly ISigner signer; + private readonly IClock clock; public FirebaseTokenFactory(ISigner signer, IClock clock) { - _signer = signer.ThrowIfNull(nameof(signer)); - _clock = clock.ThrowIfNull(nameof(clock)); + this.signer = signer.ThrowIfNull(nameof(signer)); + this.clock = clock.ThrowIfNull(nameof(clock)); + } + + public static FirebaseTokenFactory Create(FirebaseApp app) + { + ISigner signer = null; + var serviceAccount = app.Options.Credential.ToServiceAccountCredential(); + if (serviceAccount != null) + { + // If the app was initialized with a service account, use it to sign + // tokens locally. + signer = new ServiceAccountSigner(serviceAccount); + } + else if (string.IsNullOrEmpty(app.Options.ServiceAccountId)) + { + // If no service account ID is specified, attempt to discover one and invoke the + // IAM service with it. + signer = new IAMSigner(new HttpClientFactory(), app.Options.Credential); + } + else + { + // If a service account ID is specified, invoke the IAM service with it. + signer = new FixedAccountIAMSigner( + new HttpClientFactory(), app.Options.Credential, app.Options.ServiceAccountId); + } + + return new FirebaseTokenFactory(signer, SystemClock.Default); } public async Task CreateCustomTokenAsync( @@ -70,6 +110,7 @@ public async Task CreateCustomTokenAsync( { throw new ArgumentException("uid must not be longer than 128 characters"); } + if (developerClaims != null) { foreach (var entry in developerClaims) @@ -85,11 +126,11 @@ public async Task CreateCustomTokenAsync( var header = new JsonWebSignature.Header() { Algorithm = "RS256", - Type = "JWT" + Type = "JWT", }; - - var issued = (int)(_clock.UtcNow - UnixEpoch).TotalSeconds; - var keyId = await _signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); + + var issued = (int)(this.clock.UtcNow - UnixEpoch).TotalSeconds; + var keyId = await this.signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); var payload = new CustomTokenPayload() { Uid = uid, @@ -97,53 +138,30 @@ public async Task CreateCustomTokenAsync( Subject = keyId, Audience = FirebaseAudience, IssuedAtTimeSeconds = issued, - ExpirationTimeSeconds = issued + TokenDurationSeconds, + ExpirationTimeSeconds = issued + TokenDurationSeconds, }; + if (developerClaims != null && developerClaims.Count > 0) { payload.Claims = developerClaims; } + return await JwtUtils.CreateSignedJwtAsync( - header, payload, _signer, cancellationToken).ConfigureAwait(false); + header, payload, this.signer, cancellationToken).ConfigureAwait(false); } public void Dispose() { - _signer.Dispose(); + this.signer.Dispose(); } - public static FirebaseTokenFactory Create(FirebaseApp app) + internal class CustomTokenPayload : JsonWebToken.Payload { - ISigner signer = null; - var serviceAccount = app.Options.Credential.ToServiceAccountCredential(); - if (serviceAccount != null) - { - // If the app was initialized with a service account, use it to sign - // tokens locally. - signer = new ServiceAccountSigner(serviceAccount); - } - else if (string.IsNullOrEmpty(app.Options.ServiceAccountId)) - { - // If no service account ID is specified, attempt to discover one and invoke the - // IAM service with it. - signer = new IAMSigner(new HttpClientFactory(), app.Options.Credential); - } - else - { - // If a service account ID is specified, invoke the IAM service with it. - signer = new FixedAccountIAMSigner( - new HttpClientFactory(), app.Options.Credential, app.Options.ServiceAccountId); - } - return new FirebaseTokenFactory(signer, SystemClock.Default); - } - } + [Newtonsoft.Json.JsonPropertyAttribute("uid")] + public string Uid { get; set; } - internal class CustomTokenPayload: JsonWebToken.Payload - { - [Newtonsoft.Json.JsonPropertyAttribute("uid")] - public string Uid { get; set; } - - [Newtonsoft.Json.JsonPropertyAttribute("claims")] - public IDictionary Claims { get; set; } + [Newtonsoft.Json.JsonPropertyAttribute("claims")] + public IDictionary Claims { get; set; } + } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifier.cs index e96b3349..172fa03e 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifier.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifier.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -34,7 +35,7 @@ internal sealed class FirebaseTokenVerifier private const string IdTokenCertUrl = "https://www.googleapis.com/robot/v1/metadata/x509/" + "securetoken@system.gserviceaccount.com"; - private const string FirebaseAudience ="https://identitytoolkit.googleapis.com/" + private const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" + "google.identity.identitytoolkit.v1.IdentityToolkit"; // See http://oid-info.com/get/2.16.840.1.101.3.4.2.1 @@ -43,117 +44,146 @@ internal sealed class FirebaseTokenVerifier private static readonly IReadOnlyList StandardClaims = ImmutableList.Create("iss", "aud", "exp", "iat", "sub", "uid"); - public string ProjectId { get; } - private readonly string _shortName; - private readonly string _articledShortName; - private readonly string _operation; - private readonly string _url; - private readonly string _issuer; - private readonly IClock _clock; - private readonly IPublicKeySource _keySource; - - public FirebaseTokenVerifier(FirebaseTokenVerifierArgs args) + private readonly string shortName; + private readonly string articledShortName; + private readonly string operation; + private readonly string url; + private readonly string issuer; + private readonly IClock clock; + private readonly IPublicKeySource keySource; + + internal FirebaseTokenVerifier(FirebaseTokenVerifierArgs args) { - ProjectId = args.ProjectId.ThrowIfNullOrEmpty(nameof(args.ProjectId)); - _shortName = args.ShortName.ThrowIfNullOrEmpty(nameof(args.ShortName)); - _operation = args.Operation.ThrowIfNullOrEmpty(nameof(args.Operation)); - _url = args.Url.ThrowIfNullOrEmpty(nameof(args.Url)); - _issuer = args.Issuer.ThrowIfNullOrEmpty(nameof(args.Issuer)); - _clock = args.Clock.ThrowIfNull(nameof(args.Clock)); - _keySource = args.PublicKeySource.ThrowIfNull(nameof(args.PublicKeySource)); - if ("aeiou".Contains(_shortName.ToLower().Substring(0, 1))) + this.ProjectId = args.ProjectId.ThrowIfNullOrEmpty(nameof(args.ProjectId)); + this.shortName = args.ShortName.ThrowIfNullOrEmpty(nameof(args.ShortName)); + this.operation = args.Operation.ThrowIfNullOrEmpty(nameof(args.Operation)); + this.url = args.Url.ThrowIfNullOrEmpty(nameof(args.Url)); + this.issuer = args.Issuer.ThrowIfNullOrEmpty(nameof(args.Issuer)); + this.clock = args.Clock.ThrowIfNull(nameof(args.Clock)); + this.keySource = args.PublicKeySource.ThrowIfNull(nameof(args.PublicKeySource)); + if ("aeiou".Contains(this.shortName.ToLower().Substring(0, 1))) { - _articledShortName = $"an {_shortName}"; + this.articledShortName = $"an {this.shortName}"; } else { - _articledShortName = $"a {_shortName}"; + this.articledShortName = $"a {this.shortName}"; } } - public async Task VerifyTokenAsync( + public string ProjectId { get; } + + internal static FirebaseTokenVerifier CreateIDTokenVerifier(FirebaseApp app) + { + var projectId = app.GetProjectId(); + if (string.IsNullOrEmpty(projectId)) + { + throw new ArgumentException( + "Must initialize FirebaseApp with a project ID to verify ID tokens."); + } + + var keySource = new HttpPublicKeySource( + IdTokenCertUrl, SystemClock.Default, new HttpClientFactory()); + var args = new FirebaseTokenVerifierArgs() + { + ProjectId = projectId, + ShortName = "ID token", + Operation = "VerifyIdTokenAsync()", + Url = "https://firebase.google.com/docs/auth/admin/verify-id-tokens", + Issuer = "https://securetoken.google.com/", + Clock = SystemClock.Default, + PublicKeySource = keySource, + }; + + return new FirebaseTokenVerifier(args); + } + + internal async Task VerifyTokenAsync( string token, CancellationToken cancellationToken = default(CancellationToken)) { if (string.IsNullOrEmpty(token)) { - throw new ArgumentException($"{_shortName} must not be null or empty."); + throw new ArgumentException($"{this.shortName} must not be null or empty."); } + string[] segments = token.Split('.'); if (segments.Length != 3) { - throw new FirebaseException($"Incorrect number of segments in ${_shortName}."); + throw new FirebaseException($"Incorrect number of segments in ${this.shortName}."); } var header = JwtUtils.Decode(segments[0]); var payload = JwtUtils.Decode(segments[1]); - var projectIdMessage = $"Make sure the {_shortName} comes from the same Firebase " + var projectIdMessage = $"Make sure the {this.shortName} comes from the same Firebase " + "project as the credential used to initialize this SDK."; - var verifyTokenMessage = $"See {_url} for details on how to retrieve a value " - + $"{_shortName}."; - var issuer = _issuer + ProjectId; + var verifyTokenMessage = $"See {this.url} for details on how to retrieve a value " + + $"{this.shortName}."; + var issuer = this.issuer + this.ProjectId; string error = null; if (string.IsNullOrEmpty(header.KeyId)) { - if (FirebaseAudience == payload.Audience) + if (payload.Audience == FirebaseAudience) { - error = $"{_operation} expects {_articledShortName}, but was given a custom " + error = $"{this.operation} expects {this.articledShortName}, but was given a custom " + "token."; } else if (header.Algorithm == "HS256") { - error = $"{_operation} expects {_articledShortName}, but was given a legacy " + error = $"{this.operation} expects {this.articledShortName}, but was given a legacy " + "custom token."; } else { - error = $"Firebase {_shortName} has no 'kid' claim."; + error = $"Firebase {this.shortName} has no 'kid' claim."; } } else if (header.Algorithm != "RS256") { - error = $"Firebase {_shortName} has incorrect algorithm. Expected RS256 but got " + error = $"Firebase {this.shortName} has incorrect algorithm. Expected RS256 but got " + $"{header.Algorithm}. {verifyTokenMessage}"; } - else if (ProjectId != payload.Audience) + else if (this.ProjectId != payload.Audience) { - error = $"{_shortName} has incorrect audience (aud) claim. Expected {ProjectId} " + error = $"{this.shortName} has incorrect audience (aud) claim. Expected {this.ProjectId} " + $"but got {payload.Audience}. {projectIdMessage} {verifyTokenMessage}"; } else if (payload.Issuer != issuer) { - error = $"{_shortName} has incorrect issuer (iss) claim. Expected {issuer} but " + error = $"{this.shortName} has incorrect issuer (iss) claim. Expected {issuer} but " + $"got {payload.Issuer}. {projectIdMessage} {verifyTokenMessage}"; } - else if (payload.IssuedAtTimeSeconds > _clock.UnixTimestamp()) + else if (payload.IssuedAtTimeSeconds > this.clock.UnixTimestamp()) { - error = $"Firebase {_shortName} issued at future timestamp"; + error = $"Firebase {this.shortName} issued at future timestamp"; } - else if (payload.ExpirationTimeSeconds < _clock.UnixTimestamp()) + else if (payload.ExpirationTimeSeconds < this.clock.UnixTimestamp()) { - error = $"Firebase {_shortName} expired at {payload.ExpirationTimeSeconds}"; + error = $"Firebase {this.shortName} expired at {payload.ExpirationTimeSeconds}"; } else if (string.IsNullOrEmpty(payload.Subject)) { - error = $"Firebase {_shortName} has no or empty subject (sub) claim."; + error = $"Firebase {this.shortName} has no or empty subject (sub) claim."; } else if (payload.Subject.Length > 128) { - error = $"Firebase {_shortName} has a subject claim longer than 128 characters."; + error = $"Firebase {this.shortName} has a subject claim longer than 128 characters."; } - + if (error != null) { throw new FirebaseException(error); } - await VerifySignatureAsync(segments, header.KeyId, cancellationToken) + await this.VerifySignatureAsync(segments, header.KeyId, cancellationToken) .ConfigureAwait(false); var allClaims = JwtUtils.Decode>(segments[1]); + // Remove standard claims, so that only custom claims would remain. foreach (var claim in StandardClaims) { allClaims.Remove(claim); } + payload.Claims = allClaims.ToImmutableDictionary(); return new FirebaseToken(payload); } @@ -162,6 +192,14 @@ await VerifySignatureAsync(segments, header.KeyId, cancellationToken) /// Verifies the integrity of a JWT by validating its signature. The JWT must be specified /// as an array of three segments (header, body and signature). /// + [SuppressMessage( + "StyleCop.Analyzers", + "SA1009:ClosingParenthesisMustBeSpacedCorrectly", + Justification = "Use of directives.")] + [SuppressMessage( + "StyleCop.Analyzers", + "SA1111:ClosingParenthesisMustBeOnLineOfLastParameter", + Justification = "Use of directives.")] private async Task VerifySignatureAsync( string[] segments, string keyId, CancellationToken cancellationToken) { @@ -171,15 +209,16 @@ private async Task VerifySignatureAsync( hash = hashAlg.ComputeHash( Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); } + var signature = JwtUtils.Base64DecodeToBytes(segments[2]); - var keys = await _keySource.GetPublicKeysAsync(cancellationToken) + var keys = await this.keySource.GetPublicKeysAsync(cancellationToken) .ConfigureAwait(false); var verified = keys.Any(key => #if NETSTANDARD1_5 || NETSTANDARD2_0 key.Id == keyId && key.RSA.VerifyHash( hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) #elif NET45 - key.Id == keyId && + key.Id == keyId && ((RSACryptoServiceProvider) key.RSA).VerifyHash(hash, Sha256Oid, signature) #else #error Unsupported target @@ -187,42 +226,8 @@ private async Task VerifySignatureAsync( ); if (!verified) { - throw new FirebaseException($"Failed to verify {_shortName} signature."); + throw new FirebaseException($"Failed to verify {this.shortName} signature."); } } - - internal static FirebaseTokenVerifier CreateIDTokenVerifier(FirebaseApp app) - { - var projectId = app.GetProjectId(); - if (string.IsNullOrEmpty(projectId)) - { - throw new ArgumentException( - "Must initialize FirebaseApp with a project ID to verify ID tokens."); - } - var keySource = new HttpPublicKeySource( - IdTokenCertUrl, SystemClock.Default, new HttpClientFactory()); - var args = new FirebaseTokenVerifierArgs() - { - ProjectId = projectId, - ShortName = "ID token", - Operation = "VerifyIdTokenAsync()", - Url = "https://firebase.google.com/docs/auth/admin/verify-id-tokens", - Issuer = "https://securetoken.google.com/", - Clock = SystemClock.Default, - PublicKeySource = keySource, - }; - return new FirebaseTokenVerifier(args); - } - } - - internal sealed class FirebaseTokenVerifierArgs - { - public string ProjectId { get; set; } - public string ShortName { get; set; } - public string Operation { get; set; } - public string Url { get; set; } - public string Issuer { get; set; } - public IClock Clock { get; set; } - public IPublicKeySource PublicKeySource { get; set; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifierArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifierArgs.cs new file mode 100644 index 00000000..d79524b7 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifierArgs.cs @@ -0,0 +1,35 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Apis.Util; + +namespace FirebaseAdmin.Auth +{ + internal sealed class FirebaseTokenVerifierArgs + { + public string ProjectId { get; set; } + + public string ShortName { get; set; } + + public string Operation { get; set; } + + public string Url { get; set; } + + public string Issuer { get; set; } + + public IClock Clock { get; set; } + + public IPublicKeySource PublicKeySource { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManager.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManager.cs index 3a3e86fc..805d6347 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManager.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManager.cs @@ -12,32 +12,51 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Google.Apis.Auth.OAuth2; -using Google.Apis.Http; -using Newtonsoft.Json.Linq; using System; using System.Net.Http; using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Newtonsoft.Json.Linq; namespace FirebaseAdmin.Auth { /// - /// FirebaseUserManager provides methods for interacting with the + /// FirebaseUserManager provides methods for interacting with the /// - /// Google Identity Toolkit via its REST API. This class does not hold any mutable state, + /// Google Identity Toolkit via its REST API. This class does not hold any mutable state, /// and is thread safe. /// internal class FirebaseUserManager : IDisposable { - private const string ID_TOOLKIT_URL = "https://identitytoolkit.googleapis.com/v1/projects/{0}"; + private const string IdTooklitUrl = "https://identitytoolkit.googleapis.com/v1/projects/{0}"; - private readonly ConfigurableHttpClient _httpClient; - private readonly string _baseUrl; + private readonly ConfigurableHttpClient httpClient; + private readonly string baseUrl; internal FirebaseUserManager(FirebaseUserManagerArgs args) { - _httpClient = args.ClientFactory.CreateAuthorizedHttpClient(args.Credential); - _baseUrl = string.Format(ID_TOOLKIT_URL, args.ProjectId); + this.httpClient = args.ClientFactory.CreateAuthorizedHttpClient(args.Credential); + this.baseUrl = string.Format(IdTooklitUrl, args.ProjectId); + } + + public static FirebaseUserManager Create(FirebaseApp app) + { + var projectId = app.GetProjectId(); + if (string.IsNullOrEmpty(projectId)) + { + throw new ArgumentException( + "Must initialize FirebaseApp with a project ID to manage users."); + } + + var args = new FirebaseUserManagerArgs + { + ClientFactory = new HttpClientFactory(), + Credential = app.Options.Credential, + ProjectId = projectId, + }; + + return new FirebaseUserManager(args); } /// @@ -48,7 +67,7 @@ internal FirebaseUserManager(FirebaseUserManagerArgs args) public async Task UpdateUserAsync(UserRecord user) { var updatePath = "/accounts:update"; - var resopnse = await PostAsync(updatePath, user); + var resopnse = await this.PostAsync(updatePath, user); try { @@ -64,13 +83,18 @@ public async Task UpdateUserAsync(UserRecord user) } } + public void Dispose() + { + this.httpClient.Dispose(); + } + private async Task PostAsync(string path, UserRecord user) { - var requestUri = $"{_baseUrl}{path}"; + var requestUri = $"{this.baseUrl}{path}"; HttpResponseMessage response = null; try { - response = await _httpClient.PostJsonAsync(requestUri, user, default); + response = await this.httpClient.PostJsonAsync(requestUri, user, default); var json = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) @@ -90,36 +114,5 @@ private async Task PostAsync(string path, UserRecord user) throw new FirebaseException("Error while calling Firebase Auth service", e); } } - - internal static FirebaseUserManager Create(FirebaseApp app) - { - var projectId = app.GetProjectId(); - if (string.IsNullOrEmpty(projectId)) - { - throw new ArgumentException( - "Must initialize FirebaseApp with a project ID to manage users."); - } - - var args = new FirebaseUserManagerArgs - { - ClientFactory = new HttpClientFactory(), - Credential = app.Options.Credential, - ProjectId = projectId, - }; - - return new FirebaseUserManager(args); - } - - public void Dispose() - { - _httpClient.Dispose(); - } - } - - internal sealed class FirebaseUserManagerArgs - { - public HttpClientFactory ClientFactory { get; set; } - public GoogleCredential Credential { get; set; } - public string ProjectId { get; set; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManagerArgs.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManagerArgs.cs new file mode 100644 index 00000000..034f4ffc --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManagerArgs.cs @@ -0,0 +1,28 @@ +// Copyright 2019, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; + +namespace FirebaseAdmin.Auth +{ + internal sealed class FirebaseUserManagerArgs + { + public HttpClientFactory ClientFactory { get; set; } + + public GoogleCredential Credential { get; set; } + + public string ProjectId { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FixedAccountIAMSigner.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FixedAccountIAMSigner.cs new file mode 100644 index 00000000..8398d9fb --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FixedAccountIAMSigner.cs @@ -0,0 +1,45 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Google.Apis.Util; + +namespace FirebaseAdmin.Auth +{ + /// + /// An implementation that uses the IAM service to sign data. Unlike + /// this class does not attempt to auto discover a service account ID. + /// Insterad it must be initialized with a fixed service account ID string. + /// + internal sealed class FixedAccountIAMSigner : IAMSigner + { + private readonly string keyId; + + public FixedAccountIAMSigner( + HttpClientFactory clientFactory, GoogleCredential credential, string keyId) + : base(clientFactory, credential) + { + this.keyId = keyId.ThrowIfNullOrEmpty(nameof(keyId)); + } + + public override Task GetKeyIdAsync( + CancellationToken cancellationToken = default(CancellationToken)) + { + return Task.FromResult(this.keyId); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/HttpPublicKeySource.cs b/FirebaseAdmin/FirebaseAdmin/Auth/HttpPublicKeySource.cs index ffbbd9ff..6851d190 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/HttpPublicKeySource.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/HttpPublicKeySource.cs @@ -21,8 +21,8 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Google.Apis.Json; using Google.Apis.Http; +using Google.Apis.Json; using Google.Apis.Util; #if NETSTANDARD1_5 || NETSTANDARD2_0 @@ -40,7 +40,7 @@ namespace FirebaseAdmin.Auth /// HTTP server. Retrieved keys are cached in memory according to the HTTP cache-control /// directive. /// - internal sealed class HttpPublicKeySource: IPublicKeySource + internal sealed class HttpPublicKeySource : IPublicKeySource { // Default clock skew used by most GCP libraries. This interval is subtracted from the // cache expiry time, before any expiration checks. This helps correct for minor @@ -48,44 +48,44 @@ internal sealed class HttpPublicKeySource: IPublicKeySource // pre-emptively refreshed instead of waiting until the last second. private static readonly TimeSpan ClockSkew = new TimeSpan(hours: 0, minutes: 5, seconds: 0); - private readonly string _certUrl; - private IReadOnlyList _cachedKeys; - private DateTime _expirationTime; - private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); - private readonly IClock _clock; - private readonly HttpClientFactory _clientFactory; + private readonly SemaphoreSlim cacheLock = new SemaphoreSlim(1, 1); + private readonly string certUrl; + private readonly IClock clock; + private readonly HttpClientFactory clientFactory; + private DateTime expirationTime; + private IReadOnlyList cachedKeys; public HttpPublicKeySource(string certUrl, IClock clock, HttpClientFactory clientFactory) { - _certUrl = certUrl.ThrowIfNullOrEmpty(nameof(certUrl)); - _clock = clock.ThrowIfNull(nameof(clock)); - _clientFactory = clientFactory.ThrowIfNull(nameof(clientFactory)); - _expirationTime = clock.UtcNow; + this.certUrl = certUrl.ThrowIfNullOrEmpty(nameof(certUrl)); + this.clock = clock.ThrowIfNull(nameof(clock)); + this.clientFactory = clientFactory.ThrowIfNull(nameof(clientFactory)); + this.expirationTime = clock.UtcNow; } public async Task> GetPublicKeysAsync( CancellationToken cancellationToken = default(CancellationToken)) { - if (_cachedKeys == null || _clock.UtcNow >= _expirationTime) + if (this.cachedKeys == null || this.clock.UtcNow >= this.expirationTime) { - await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); + await this.cacheLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var now = _clock.UtcNow; - if (_cachedKeys == null || now >= _expirationTime) + var now = this.clock.UtcNow; + if (this.cachedKeys == null || now >= this.expirationTime) { - using (var httpClient = _clientFactory.CreateDefaultHttpClient()) + using (var httpClient = this.clientFactory.CreateDefaultHttpClient()) { - var response = await httpClient.GetAsync(_certUrl, cancellationToken) + var response = await httpClient.GetAsync(this.certUrl, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - _cachedKeys = ParseKeys(await response.Content.ReadAsStringAsync() + this.cachedKeys = this.ParseKeys(await response.Content.ReadAsStringAsync() .ConfigureAwait(false)); var cacheControl = response.Headers.CacheControl; if (cacheControl?.MaxAge != null) { - _expirationTime = now.Add(cacheControl.MaxAge.Value) + this.expirationTime = now.Add(cacheControl.MaxAge.Value) .Subtract(ClockSkew); } } @@ -97,11 +97,11 @@ public async Task> GetPublicKeysAsync( } finally { - _lock.Release(); + this.cacheLock.Release(); } } - return _cachedKeys; + return this.cachedKeys; } private IReadOnlyList ParseKeys(string json) @@ -112,6 +112,7 @@ private IReadOnlyList ParseKeys(string json) { throw new InvalidDataException("No public keys present in the response."); } + var builder = ImmutableList.CreateBuilder(); foreach (var entry in rawKeys) { @@ -126,6 +127,7 @@ private IReadOnlyList ParseKeys(string json) #endif builder.Add(new PublicKey(entry.Key, rsa)); } + return builder.ToImmutableList(); } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/IAMSigner.cs b/FirebaseAdmin/FirebaseAdmin/Auth/IAMSigner.cs index dec55cd4..f7d810bd 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/IAMSigner.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/IAMSigner.cs @@ -36,16 +36,17 @@ internal class IAMSigner : ISigner { private const string SignBlobUrl = "https://iam.googleapis.com/v1/projects/-/serviceAccounts/{0}:signBlob"; - private const string MetadataServerUrl = + + private const string MetadataServerUrl = "http://metadata/computeMetadata/v1/instance/service-accounts/default/email"; - private readonly ConfigurableHttpClient _httpClient; - private readonly Lazy> _keyId; + private readonly ConfigurableHttpClient httpClient; + private readonly Lazy> keyId; public IAMSigner(HttpClientFactory clientFactory, GoogleCredential credential) { - _httpClient = clientFactory.CreateAuthorizedHttpClient(credential); - _keyId = new Lazy>( + this.httpClient = clientFactory.CreateAuthorizedHttpClient(credential); + this.keyId = new Lazy>( async () => await DiscoverServiceAccountIdAsync(clientFactory) .ConfigureAwait(false), true); } @@ -53,18 +54,19 @@ public IAMSigner(HttpClientFactory clientFactory, GoogleCredential credential) public async Task SignDataAsync( byte[] data, CancellationToken cancellationToken = default(CancellationToken)) { - var keyId = await GetKeyIdAsync(cancellationToken).ConfigureAwait(false); + var keyId = await this.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); var url = string.Format(SignBlobUrl, keyId); var request = new SignBlobRequest { BytesToSign = Convert.ToBase64String(data), }; + try { - var response = await _httpClient.PostJsonAsync(url, request, cancellationToken) + var response = await this.httpClient.PostJsonAsync(url, request, cancellationToken) .ConfigureAwait(false); var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - ThrowIfError(response, json); + this.ThrowIfError(response, json); var parsed = NewtonsoftJsonSerializer.Instance.Deserialize(json); return Convert.FromBase64String(parsed.Signature); } @@ -74,34 +76,12 @@ public async Task SignDataAsync( } } - private void ThrowIfError(HttpResponseMessage response, string content) - { - if (response.IsSuccessStatusCode) - { - return; - } - string error = null; - try - { - var result = NewtonsoftJsonSerializer.Instance.Deserialize(content); - error = result?.Error.Message; - } - catch (Exception) {} // Ignore any errors encountered while parsing the originl error. - if (string.IsNullOrEmpty(error)) - { - error = "Response status code does not indicate success: " - + $"{(int) response.StatusCode} ({response.StatusCode})" - + $"{Environment.NewLine}{content}"; - } - throw new FirebaseException(error); - } - public virtual async Task GetKeyIdAsync( CancellationToken cancellationToken = default(CancellationToken)) { try { - return await _keyId.Value.ConfigureAwait(false); + return await this.keyId.Value.ConfigureAwait(false); } catch (Exception e) { @@ -114,6 +94,11 @@ public virtual async Task GetKeyIdAsync( } } + public void Dispose() + { + this.httpClient.Dispose(); + } + private static async Task DiscoverServiceAccountIdAsync( HttpClientFactory clientFactory) { @@ -124,67 +109,68 @@ private static async Task DiscoverServiceAccountIdAsync( } } - public void Dispose() + private void ThrowIfError(HttpResponseMessage response, string content) { - _httpClient.Dispose(); - } - } + if (response.IsSuccessStatusCode) + { + return; + } - /// - /// Represents the sign request sent to the remote IAM service. - /// - internal class SignBlobRequest - { - [Newtonsoft.Json.JsonProperty("bytesToSign")] - public string BytesToSign { get; set; } - } + string error = null; + try + { + var result = NewtonsoftJsonSerializer.Instance.Deserialize(content); + error = result?.Error.Message; + } + catch (Exception) + { + // Ignore any errors encountered while parsing the originl error. + } - /// - /// Represents the sign response sent by the remote IAM service. - /// - internal class SignBlobResponse - { - [Newtonsoft.Json.JsonProperty("signature")] - public string Signature { get; set; } - } + if (string.IsNullOrEmpty(error)) + { + error = "Response status code does not indicate success: " + + $"{(int)response.StatusCode} ({response.StatusCode})" + + $"{Environment.NewLine}{content}"; + } - /// - /// Represents an error response sent by the remote IAM service. - /// - internal class SignBlobError - { - [Newtonsoft.Json.JsonProperty("error")] - public SignBlobErrorDetail Error { get; set; } - } + throw new FirebaseException(error); + } - /// - /// Represents the error details embedded in an IAM error response. - /// - internal class SignBlobErrorDetail - { - [Newtonsoft.Json.JsonProperty("message")] - public string Message { get; set; } - } + /// + /// Represents the sign request sent to the remote IAM service. + /// + internal class SignBlobRequest + { + [Newtonsoft.Json.JsonProperty("bytesToSign")] + public string BytesToSign { get; set; } + } - /// - /// An implementation that uses the IAM service to sign data. Unlike - /// this class does not attempt to auto discover a service account ID. - /// Insterad it must be initialized with a fixed service account ID string. - /// - internal sealed class FixedAccountIAMSigner : IAMSigner - { - private readonly string _keyId; + /// + /// Represents the sign response sent by the remote IAM service. + /// + internal class SignBlobResponse + { + [Newtonsoft.Json.JsonProperty("signature")] + public string Signature { get; set; } + } - public FixedAccountIAMSigner(HttpClientFactory clientFactory, GoogleCredential credential, - string keyId): base(clientFactory, credential) + /// + /// Represents an error response sent by the remote IAM service. + /// + private class SignBlobError { - _keyId = keyId.ThrowIfNullOrEmpty(nameof(keyId)); + [Newtonsoft.Json.JsonProperty("error")] + public SignBlobErrorDetail Error { get; set; } } - public override Task GetKeyIdAsync( - CancellationToken cancellationToken = default(CancellationToken)) + /// + /// Represents the error details embedded in an IAM error response. + /// + private class SignBlobErrorDetail { - return Task.FromResult(_keyId); + [Newtonsoft.Json.JsonProperty("message")] + public string Message { get; set; } } } -} \ No newline at end of file +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/ISigner.cs b/FirebaseAdmin/FirebaseAdmin/Auth/ISigner.cs index 2d63798e..dcacb8ee 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/ISigner.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/ISigner.cs @@ -22,7 +22,7 @@ namespace FirebaseAdmin.Auth /// Represents an object can be used to cryptographically sign data. Mainly used for signing /// custom JWT tokens issued to Firebase users. /// - internal interface ISigner: IDisposable + internal interface ISigner : IDisposable { /// /// Returns the ID (client email) of the service account used to sign payloads. diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/JwtUtils.cs b/FirebaseAdmin/FirebaseAdmin/Auth/JwtUtils.cs index 7fb5ab74..b252236f 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/JwtUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/JwtUtils.cs @@ -25,12 +25,6 @@ namespace FirebaseAdmin.Auth /// internal static class JwtUtils { - private static string Encode(object obj) - { - var json = NewtonsoftJsonSerializer.Instance.Serialize(obj); - return UrlSafeBase64Encode(Encoding.UTF8.GetBytes(json)); - } - /// /// Decodes a single JWT segment, and deserializes it into a value of type /// . @@ -44,19 +38,13 @@ public static T Decode(string value) return NewtonsoftJsonSerializer.Instance.Deserialize(json); } - private static string UrlSafeBase64Encode(byte[] bytes) - { - var base64Value = Convert.ToBase64String(bytes); - return base64Value.TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - - public static string Base64Decode(string input) + internal static string Base64Decode(string input) { var raw = Base64DecodeToBytes(input); return Encoding.UTF8.GetString(raw); } - public static byte[] Base64DecodeToBytes(string input) + internal static byte[] Base64DecodeToBytes(string input) { // undo the url safe replacements input = input.Replace('-', '+').Replace('_', '/'); @@ -65,11 +53,14 @@ public static byte[] Base64DecodeToBytes(string input) case 2: input += "=="; break; case 3: input += "="; break; } + return Convert.FromBase64String(input); } - public static async Task CreateSignedJwtAsync( - object header, object payload, ISigner signer, + internal static async Task CreateSignedJwtAsync( + object header, + object payload, + ISigner signer, CancellationToken cancellationToken = default(CancellationToken)) { string encodedHeader = Encode(header); @@ -85,5 +76,17 @@ public static async Task CreateSignedJwtAsync( assertion.Append('.').Append(UrlSafeBase64Encode(signature)); return assertion.ToString(); } + + private static string Encode(object obj) + { + var json = NewtonsoftJsonSerializer.Instance.Serialize(obj); + return UrlSafeBase64Encode(Encoding.UTF8.GetBytes(json)); + } + + private static string UrlSafeBase64Encode(byte[] bytes) + { + var base64Value = Convert.ToBase64String(bytes); + return base64Value.TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/PublicKey.cs b/FirebaseAdmin/FirebaseAdmin/Auth/PublicKey.cs index e1779633..483f5f7c 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/PublicKey.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/PublicKey.cs @@ -27,21 +27,21 @@ namespace FirebaseAdmin.Auth /// internal sealed class PublicKey { + public PublicKey(string keyId, RSAKey rsa) + { + this.Id = keyId; + this.RSA = rsa; + } + /// - /// The unique identifier of this key. + /// Gets the unique identifier of this key. /// public string Id { get; } /// - /// A instance containing the contents of + /// Gets the instance containing the contents of /// the public key. /// public RSAKey RSA { get; } - - public PublicKey(string keyId, RSAKey rsa) - { - Id = keyId; - RSA = rsa; - } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/ServiceAccountSigner.cs b/FirebaseAdmin/FirebaseAdmin/Auth/ServiceAccountSigner.cs index adfc064d..79b782e7 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/ServiceAccountSigner.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/ServiceAccountSigner.cs @@ -16,6 +16,7 @@ using System.Threading; using System.Threading.Tasks; using Google.Apis.Auth.OAuth2; +using Google.Apis.Util; namespace FirebaseAdmin.Auth { @@ -23,31 +24,27 @@ namespace FirebaseAdmin.Auth /// An implementation that uses service account credentials to sign /// data. Uses the private key present in the credential to produce signatures. /// - internal sealed class ServiceAccountSigner: ISigner + internal sealed class ServiceAccountSigner : ISigner { - private readonly ServiceAccountCredential _credential; + private readonly ServiceAccountCredential credential; public ServiceAccountSigner(ServiceAccountCredential credential) { - if (credential == null) - { - throw new ArgumentNullException("Credential must not be null."); - } - _credential = credential; + this.credential = credential.ThrowIfNull(nameof(credential)); } public Task GetKeyIdAsync(CancellationToken cancellationToken = default(CancellationToken)) { - return Task.FromResult(_credential.Id); + return Task.FromResult(this.credential.Id); } public Task SignDataAsync(byte[] data, CancellationToken cancellationToken = default(CancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); - var signature = _credential.CreateSignature(data); + var signature = this.credential.CreateSignature(data); return Task.FromResult(Convert.FromBase64String(signature)); } - public void Dispose() {} + public void Dispose() { } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs index 69505630..ec584e87 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/UserRecord.cs @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Google.Apis.Json; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Text; +using Google.Apis.Json; +using Newtonsoft.Json; namespace FirebaseAdmin.Auth { @@ -26,49 +26,49 @@ namespace FirebaseAdmin.Auth /// internal class UserRecord { - private string _uid; - private IReadOnlyDictionary _customClaims; + private string uid; + private IReadOnlyDictionary customClaims; + + public UserRecord(string uid) + { + this.Uid = uid; + } /// - /// The user ID of this user. + /// Gets the user ID of this user. /// [JsonProperty("localId")] public string Uid { - get => _uid; + get => this.uid; private set { CheckUid(value); - _uid = value; + this.uid = value; } } /// - /// Returns custom claims set on this user. + /// Gets or sets the custom claims set on this user. /// [JsonIgnore] public IReadOnlyDictionary CustomClaims { - get => _customClaims; + get => this.customClaims; set { CheckCustomClaims(value); - _customClaims = value; + this.customClaims = value; } } [JsonProperty("customAttributes")] internal string CustomClaimsString => SerializeClaims(CustomClaims); - public UserRecord(string uid) - { - Uid = uid; - } - /// /// Checks if the given user ID is valid. /// - /// The user ID. Must not be null or longer than + /// The user ID. Must not be null or longer than /// 128 characters. public static void CheckUid(string uid) { @@ -85,10 +85,10 @@ public static void CheckUid(string uid) /// /// Checks if the given set of custom claims are valid. /// - /// The custom claims. Claim names must + /// The custom claims. Claim names must /// not be null or empty and must not be reserved and the serialized /// claims have to be less than 1000 bytes. - public static void CheckCustomClaims(IReadOnlyDictionary customClaims) + internal static void CheckCustomClaims(IReadOnlyDictionary customClaims) { if (customClaims == null) { @@ -101,6 +101,7 @@ public static void CheckCustomClaims(IReadOnlyDictionary customC { throw new ArgumentException("Claim names must not be null or empty"); } + if (FirebaseTokenFactory.ReservedClaims.Contains(key)) { throw new ArgumentException($"Claim {key} is reserved and cannot be set"); diff --git a/FirebaseAdmin/FirebaseAdmin/Extensions.cs b/FirebaseAdmin/FirebaseAdmin/Extensions.cs index db5a2a02..d8d6f354 100644 --- a/FirebaseAdmin/FirebaseAdmin/Extensions.cs +++ b/FirebaseAdmin/FirebaseAdmin/Extensions.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -33,21 +34,28 @@ internal static class Extensions /// . Returns null if the GoogleCredential is not /// based on a service account. /// + /// A service account credential if available, or null. + /// The Google credential from which to extract service account + /// credentials. public static ServiceAccountCredential ToServiceAccountCredential( this GoogleCredential credential) { if (credential.UnderlyingCredential is GoogleCredential) { - return ((GoogleCredential) credential.UnderlyingCredential) + return ((GoogleCredential)credential.UnderlyingCredential) .ToServiceAccountCredential(); } + return credential.UnderlyingCredential as ServiceAccountCredential; } /// /// Creates a default (unauthenticated) from the /// factory. - /// + /// + /// An HTTP client that can be used to make unauthenticated requests. + /// The used to create + /// the HTTP client. public static ConfigurableHttpClient CreateDefaultHttpClient( this HttpClientFactory clientFactory) { @@ -58,6 +66,11 @@ public static ConfigurableHttpClient CreateDefaultHttpClient( /// Creates an authenticated from the /// factory. /// + /// An HTTP client that can be used to OAuth2 authorized requests. + /// The used to create + /// the HTTP client. + /// The Google credential that will be used to authenticate + /// outgoing HTTP requests. public static ConfigurableHttpClient CreateAuthorizedHttpClient( this HttpClientFactory clientFactory, GoogleCredential credential) { @@ -69,6 +82,14 @@ public static ConfigurableHttpClient CreateAuthorizedHttpClient( /// /// Makes a JSON POST request using the given parameters. /// + /// An representing the response to the + /// POST request. + /// Type of the object that will be serialized into JSON. + /// The used to make the request. + /// URI for the outgoing request. + /// The object that will be serialized as the JSON body. + /// A cancellation token to monitor the asynchronous + /// operation. public static async Task PostJsonAsync( this HttpClient client, string requestUri, T body, CancellationToken cancellationToken) { @@ -81,9 +102,27 @@ public static async Task PostJsonAsync( /// /// Returns a Unix-styled timestamp (seconds from epoch) from the . /// + /// Number of seconds since epoch. + /// The used to generate the timestamp. public static long UnixTimestamp(this IClock clock) { - return (long) (clock.UtcNow.Subtract(new DateTime(1970, 1, 1))).TotalSeconds; + var timeSinceEpoch = clock.UtcNow.Subtract(new DateTime(1970, 1, 1)); + return (long)timeSinceEpoch.TotalSeconds; + } + + /// + /// Creates a shallow copy of a collection of key-value pairs. + /// + public static IReadOnlyDictionary Copy( + this IEnumerable> source) + { + var copy = new Dictionary(); + foreach (var entry in source) + { + copy[entry.Key] = entry.Value; + } + + return copy; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/FirebaseAdmin.csproj b/FirebaseAdmin/FirebaseAdmin/FirebaseAdmin.csproj index e5a8a453..7ecd116f 100644 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseAdmin.csproj +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseAdmin.csproj @@ -21,11 +21,16 @@ https://github.com/Firebase/firebase-admin-dotnet git https://github.com/Firebase/firebase-admin-dotnet + ../../stylecop.ruleset + + all + + diff --git a/FirebaseAdmin/FirebaseAdmin/FirebaseApp.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseApp.cs index 0c06aed3..9f12ecd2 100644 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseApp.cs +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseApp.cs @@ -18,18 +18,19 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; using Google; -using Google.Apis.Logging; using Google.Apis.Auth.OAuth2; +using Google.Apis.Logging; -[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey="+ -"002400000480000094000000060200000024000052534131000400000100010081328559eaab41"+ -"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02"+ -"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597"+ -"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f"+ +[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey=" + +"002400000480000094000000060200000024000052534131000400000100010081328559eaab41" + +"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02" + +"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597" + +"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f" + "badddb9c")] -namespace FirebaseAdmin +namespace FirebaseAdmin { - internal delegate TResult ServiceFactory() where TResult: IFirebaseService; + internal delegate TResult ServiceFactory() + where TResult : IFirebaseService; /// /// This is the entry point to the Firebase Admin SDK. It holds configuration and state common @@ -40,138 +41,97 @@ namespace FirebaseAdmin /// public sealed class FirebaseApp { - private const string DefaultAppName = "[DEFAULT]"; - internal static readonly IReadOnlyList DefaultScopes = ImmutableList.Create( - // Enables access to Firebase Realtime Database. - "https://www.googleapis.com/auth/firebase", - - // Enables access to the email address associated with a project. - "https://www.googleapis.com/auth/userinfo.email", - - // Enables access to Google Identity Toolkit (for user management APIs). - "https://www.googleapis.com/auth/identitytoolkit", + "https://www.googleapis.com/auth/firebase", // RTDB. + "https://www.googleapis.com/auth/userinfo.email", // RTDB + "https://www.googleapis.com/auth/identitytoolkit", // User management + "https://www.googleapis.com/auth/devstorage.full_control", // Cloud Storage + "https://www.googleapis.com/auth/cloud-platform", // Cloud Firestore + "https://www.googleapis.com/auth/datastore"); - // Enables access to Google Cloud Storage. - "https://www.googleapis.com/auth/devstorage.full_control", - - // Enables access to Google Cloud Firestore - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/datastore" - ); + private const string DefaultAppName = "[DEFAULT]"; private static readonly Dictionary Apps = new Dictionary(); private static readonly ILogger Logger = ApplicationContext.Logger.ForType(); // Guards the mutable state local to an app instance. - private readonly Object _lock = new Object(); - private bool _deleted = false; - private readonly AppOptions _options; - - /// - /// A copy of the this app was created with. - /// - public AppOptions Options - { - get - { - return new AppOptions(_options); - } - } - - /// - /// Name of this app. - /// - public string Name { get; } + private readonly object appLock = new object(); + private readonly AppOptions options; // A collection of stateful services initialized using this app instance (e.g. // FirebaseAuth). Services are tracked here so they can be cleaned up when the app is // deleted. - private readonly Dictionary _services = new Dictionary(); + private readonly Dictionary services = new Dictionary(); + private bool deleted = false; private FirebaseApp(AppOptions options, string name) { - _options = new AppOptions(options); - if (_options.Credential == null) + this.options = new AppOptions(options); + if (this.options.Credential == null) { throw new ArgumentNullException("Credential must be set"); } - if (_options.Credential.IsCreateScopedRequired) + + if (this.options.Credential.IsCreateScopedRequired) { - _options.Credential = _options.Credential.CreateScoped(DefaultScopes); + this.options.Credential = this.options.Credential.CreateScoped(DefaultScopes); } - Name = name; + + this.Name = name; } /// - /// Deletes this app instance and cleans up any state associated with it. Once an app has - /// been deleted, accessing any services related to it will result in an exception. - /// If the app is already deleted, this method is a no-op. + /// Gets the default app instance. This property is null if the default app instance + /// doesn't yet exist. /// - public void Delete() + public static FirebaseApp DefaultInstance { - // Clean up local state - lock (_lock) - { - _deleted = true; - foreach (var entry in _services) - { - try - { - entry.Value.Delete(); - } - catch (Exception e) - { - Logger.Error(e, "Error while cleaning up service {0}", entry.Key); - } - } - _services.Clear(); - } - // Clean up global state - lock (Apps) + get { - Apps.Remove(Name); + return GetInstance(DefaultAppName); } } - internal T GetOrInit(string id, ServiceFactory initializer) where T : class, IFirebaseService + /// + /// Gets a copy of the this app was created with. + /// + public AppOptions Options { - lock (_lock) + get { - if (_deleted) - { - throw new InvalidOperationException("Cannot use an app after it has been deleted"); - } - IFirebaseService service; - if (!_services.TryGetValue(id, out service)) - { - service = initializer(); - _services.Add(id, service); - } - return (T) service; + return new AppOptions(this.options); } } - internal string GetProjectId() + /// + /// Gets the name of this app. + /// + public string Name { get; } + + /// + /// Returns the app instance identified by the given name. + /// + /// The instance with the specified name or null if it + /// doesn't exist. + /// If the name argument is null or empty. + /// Name of the app to retrieve. + public static FirebaseApp GetInstance(string name) { - if (!string.IsNullOrEmpty(Options.ProjectId)) - { - return Options.ProjectId; - } - var projectId = Options.Credential.ToServiceAccountCredential()?.ProjectId; - if (!String.IsNullOrEmpty(projectId)) + if (string.IsNullOrEmpty(name)) { - return projectId; + throw new ArgumentException("App name to lookup must not be null or empty"); } - foreach (var variableName in new [] {"GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"}) + + lock (Apps) { - projectId = Environment.GetEnvironmentVariable(variableName); - if (!String.IsNullOrEmpty(projectId)) + FirebaseApp app; + if (Apps.TryGetValue(name, out app)) { - return projectId; + return app; } } + return null; } @@ -226,6 +186,7 @@ public static FirebaseApp Create(AppOptions options, string name) { throw new ArgumentException("App name must not be null or empty"); } + options = options ?? GetOptionsFromEnvironment(); lock (Apps) { @@ -235,59 +196,49 @@ public static FirebaseApp Create(AppOptions options, string name) { throw new ArgumentException("The default FirebaseApp already exists."); } - else + else { throw new ArgumentException($"FirebaseApp named {name} already exists."); } } + var app = new FirebaseApp(options, name); Apps.Add(name, app); return app; } } - private static AppOptions GetOptionsFromEnvironment() - { - return new AppOptions() - { - Credential = GoogleCredential.GetApplicationDefault(), - }; - } - /// - /// The default app instance. This property is null if the default app instance - /// doesn't yet exist. + /// Deletes this app instance and cleans up any state associated with it. Once an app has + /// been deleted, accessing any services related to it will result in an exception. + /// If the app is already deleted, this method is a no-op. /// - public static FirebaseApp DefaultInstance + public void Delete() { - get + // Clean up local state + lock (this.appLock) { - return GetInstance(DefaultAppName); - } - } + this.deleted = true; + foreach (var entry in this.services) + { + try + { + entry.Value.Delete(); + } + catch (Exception e) + { + Logger.Error(e, "Error while cleaning up service {0}", entry.Key); + } + } - /// - /// Returns the app instance identified by the given name. - /// - /// The instance with the specified name or null if it - /// doesn't exist. - /// If the name argument is null or empty. - /// Name of the app to retrieve. - public static FirebaseApp GetInstance(string name) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentException("App name to lookup must not be null or empty"); + this.services.Clear(); } - lock (Apps) + + // Clean up global state + lock (Apps) { - FirebaseApp app; - if (Apps.TryGetValue(name, out app)) - { - return app; - } + Apps.Remove(this.Name); } - return null; } /// @@ -302,11 +253,74 @@ internal static void DeleteAll() { entry.Value.Delete(); } + if (Apps.Count > 0) { throw new InvalidOperationException("Failed to delete all apps"); } - } + } + } + + internal T GetOrInit(string id, ServiceFactory initializer) + where T : class, IFirebaseService + { + lock (this.appLock) + { + if (this.deleted) + { + throw new InvalidOperationException("Cannot use an app after it has been deleted"); + } + + IFirebaseService service; + if (!this.services.TryGetValue(id, out service)) + { + service = initializer(); + this.services.Add(id, service); + } + + return (T)service; + } + } + + /// + /// Returns the Google Cloud Platform project ID associated with this Firebase app. If a + /// project ID is specified in , that value is returned. If not + /// attempts to determine a project ID from the used to + /// initialize the app. Looks up the GOOGLE_CLOUD_PROJECT environment variable when all + /// else fails. + /// + /// A project ID string or null. + internal string GetProjectId() + { + if (!string.IsNullOrEmpty(this.Options.ProjectId)) + { + return this.Options.ProjectId; + } + + var projectId = this.Options.Credential.ToServiceAccountCredential()?.ProjectId; + if (!string.IsNullOrEmpty(projectId)) + { + return projectId; + } + + foreach (var variableName in new[] { "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT" }) + { + projectId = Environment.GetEnvironmentVariable(variableName); + if (!string.IsNullOrEmpty(projectId)) + { + return projectId; + } + } + + return null; + } + + private static AppOptions GetOptionsFromEnvironment() + { + return new AppOptions() + { + Credential = GoogleCredential.GetApplicationDefault(), + }; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/FirebaseException.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseException.cs index a2032d0a..e2a52733 100644 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseException.cs +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseException.cs @@ -19,10 +19,12 @@ namespace FirebaseAdmin /// /// Common error type for all exceptions raised by Firebase APIs. /// - public sealed class FirebaseException: Exception + public sealed class FirebaseException : Exception { - internal FirebaseException(string message): base(message) {} - - internal FirebaseException(string message, Exception inner): base(message, inner) {} + internal FirebaseException(string message) + : base(message) { } + + internal FirebaseException(string message, Exception inner) + : base(message, inner) { } } } diff --git a/FirebaseAdmin/FirebaseAdmin/IFirebaseService.cs b/FirebaseAdmin/FirebaseAdmin/IFirebaseService.cs index d6075f4a..3407ef9f 100644 --- a/FirebaseAdmin/FirebaseAdmin/IFirebaseService.cs +++ b/FirebaseAdmin/FirebaseAdmin/IFirebaseService.cs @@ -20,6 +20,9 @@ namespace FirebaseAdmin /// internal interface IFirebaseService { + /// + /// Cleans up any state associated with this service making it unsuitable for further use. + /// void Delete(); } } diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Action.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Action.cs new file mode 100644 index 00000000..09ff996b --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Action.cs @@ -0,0 +1,54 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents an action available to users when the notification is presented. + /// + public sealed class Action + { + /// + /// Initializes a new instance of the class. + /// + public Action() { } + + internal Action(Action action) + { + this.ActionName = action.ActionName; + this.Title = action.Title; + this.Icon = action.Icon; + } + + /// + /// Gets or sets the name of the Action. + /// + [JsonProperty("action")] + public string ActionName { get; set; } + + /// + /// Gets or sets the title text. + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Gets or sets the icon URL. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + } +} \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs new file mode 100644 index 00000000..9ead102d --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidConfig.cs @@ -0,0 +1,174 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the Android-specific options that can be included in a . + /// + public sealed class AndroidConfig + { + /// + /// Gets or sets a collapse key for the message. Collapse key serves as an identifier for a + /// group of messages that can be collapsed, so that only the last message gets sent when + /// delivery can be resumed. A maximum of 4 different collapse keys may be active at any + /// given time. + /// + [JsonProperty("collapse_key")] + public string CollapseKey { get; set; } + + /// + /// Gets or sets the priority of the message. + /// + [JsonIgnore] + public Priority? Priority { get; set; } + + /// + /// Gets or sets the time-to-live duration of the message. + /// + [JsonIgnore] + public TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the package name of the application where the registration tokens must + /// match in order to receive the message. + /// + [JsonProperty("restricted_package_name")] + public string RestrictedPackageName { get; set; } + + /// + /// Gets or sets a collection of key-value pairs that will be added to the message as data + /// fields. Keys and the values must not be null. When set, overrides any data fields set + /// on the top-level + /// . + /// + [JsonProperty("data")] + public IReadOnlyDictionary Data { get; set; } + + /// + /// Gets or sets the Android notification to be included in the message. + /// + [JsonProperty("notification")] + public AndroidNotification Notification { get; set; } + + /// + /// Gets or sets the string representation of as accepted by the FCM + /// backend service. + /// + [JsonProperty("priority")] + private string PriorityString + { + get + { + switch (this.Priority) + { + case Messaging.Priority.High: + return "high"; + case Messaging.Priority.Normal: + return "normal"; + default: + return null; + } + } + + set + { + switch (value) + { + case "high": + this.Priority = Messaging.Priority.High; + return; + case "normal": + this.Priority = Messaging.Priority.High; + return; + default: + throw new FirebaseException( + $"Invalid priority value: {value}. Only 'high' and 'normal'" + + " are allowed."); + } + } + } + + /// + /// Gets or sets the string representation of as accepted by the + /// FCM backend service. The string ends in the suffix "s" (indicating seconds) and is + /// preceded by the number of seconds, with nanoseconds expressed as fractional seconds. + /// + [JsonProperty("ttl")] + private string TtlString + { + get + { + if (this.TimeToLive == null) + { + return null; + } + + var totalSeconds = this.TimeToLive.Value.TotalSeconds; + var seconds = (long)Math.Floor(totalSeconds); + var subsecondNanos = (long)((totalSeconds - seconds) * 1e9); + if (subsecondNanos > 0) + { + return string.Format("{0}.{1:D9}s", seconds, subsecondNanos); + } + + return string.Format("{0}s", seconds); + } + + set + { + var segments = value.TrimEnd('s').Split('.'); + var seconds = long.Parse(segments[0]); + var ttl = TimeSpan.FromSeconds(seconds); + if (segments.Length == 2) + { + var subsecondNanos = long.Parse(segments[1].TrimStart('0')); + ttl = ttl.Add(TimeSpan.FromMilliseconds(subsecondNanos / 1e6)); + } + + this.TimeToLive = ttl; + } + } + + /// + /// Copies this Android config, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal AndroidConfig CopyAndValidate() + { + // Copy and validate the leaf-level properties + var copy = new AndroidConfig() + { + CollapseKey = this.CollapseKey, + Priority = this.Priority, + TimeToLive = this.TimeToLive, + RestrictedPackageName = this.RestrictedPackageName, + Data = this.Data?.Copy(), + }; + var totalSeconds = copy.TimeToLive?.TotalSeconds ?? 0; + if (totalSeconds < 0) + { + throw new ArgumentException("TTL must not be negative."); + } + + // Copy and validate the child properties + copy.Notification = this.Notification?.CopyAndValidate(); + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs new file mode 100644 index 00000000..926733ac --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/AndroidNotification.cs @@ -0,0 +1,154 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the Android-specific notification options that can be included in a + /// . + /// + public sealed class AndroidNotification + { + /// + /// Gets or sets the title of the Android notification. When provided, overrides the title + /// set via . + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Gets or sets the title of the Android notification. When provided, overrides the title + /// set via . + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// Gets or sets the icon of the Android notification. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + + /// + /// Gets or sets the notification icon color. Must be of the form #RRGGBB. + /// + [JsonProperty("color")] + public string Color { get; set; } + + /// + /// Gets or sets the sound to be played when the device receives the notification. + /// + [JsonProperty("sound")] + public string Sound { get; set; } + + /// + /// Gets or sets the notification tag. This is an identifier used to replace existing + /// notifications in the notification drawer. If not specified, each request creates a new + /// notification. + /// + [JsonProperty("tag")] + public string Tag { get; set; } + + /// + /// Gets or sets the action associated with a user click on the notification. If specified, + /// an activity with a matching Intent Filter is launched when a user clicks on the + /// notification. + /// + [JsonProperty("click_action")] + public string ClickAction { get; set; } + + /// + /// Gets or sets the key of the title string in the app's string resources to use to + /// localize the title text. + /// + [JsonProperty("title_loc_key")] + public string TitleLocKey { get; set; } + + /// + /// Gets or sets the collection of resource key strings that will be used in place of the + /// format specifiers in . + /// + [JsonProperty("title_loc_args")] + public IEnumerable TitleLocArgs { get; set; } + + /// + /// Gets or sets the key of the body string in the app's string resources to use to + /// localize the body text. + /// + [JsonProperty("body_loc_key")] + public string BodyLocKey { get; set; } + + /// + /// Gets or sets the collection of resource key strings that will be used in place of the + /// format specifiers in . + /// + [JsonProperty("body_loc_args")] + public IEnumerable BodyLocArgs { get; set; } + + /// + /// Gets or sets the Android notification channel ID (new in Android O). The app must + /// create a channel with this channel ID before any notification with this channel ID is + /// received. If you don't send this channel ID in the request, or if the channel ID + /// provided has not yet been created by the app, FCM uses the channel ID specified in the + /// app manifest. + /// + [JsonProperty("channel_id")] + public string ChannelId { get; set; } + + /// + /// Copies this notification, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal AndroidNotification CopyAndValidate() + { + var copy = new AndroidNotification() + { + Title = this.Title, + Body = this.Body, + Icon = this.Icon, + Color = this.Color, + Sound = this.Sound, + Tag = this.Tag, + ClickAction = this.ClickAction, + TitleLocKey = this.TitleLocKey, + TitleLocArgs = this.TitleLocArgs?.ToList(), + BodyLocKey = this.BodyLocKey, + BodyLocArgs = this.BodyLocArgs?.ToList(), + ChannelId = this.ChannelId, + }; + if (copy.Color != null && !Regex.Match(copy.Color, "^#[0-9a-fA-F]{6}$").Success) + { + throw new ArgumentException("Color must be in the form #RRGGBB."); + } + + if (copy.TitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.TitleLocKey)) + { + throw new ArgumentException("TitleLocKey is required when specifying TitleLocArgs."); + } + + if (copy.BodyLocArgs?.Any() == true && string.IsNullOrEmpty(copy.BodyLocKey)) + { + throw new ArgumentException("BodyLocKey is required when specifying BodyLocArgs."); + } + + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/ApnsConfig.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/ApnsConfig.cs new file mode 100644 index 00000000..2b70f7e5 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/ApnsConfig.cs @@ -0,0 +1,141 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the APNS-specific options that can be included in a . Refer + /// to + /// APNs documentation for various headers and payload fields supported by APNS. + /// + public sealed class ApnsConfig + { + private ApnsPayload payload = new ApnsPayload(); + + /// + /// Gets or sets the APNs headers. + /// + [JsonProperty("headers")] + public IReadOnlyDictionary Headers { get; set; } + + /// + /// Gets or sets the aps dictionary to be included in the APNs payload. + /// + [JsonIgnore] + public Aps Aps + { + get + { + return this.Payload.Aps; + } + + set + { + this.Payload.Aps = value; + } + } + + /// + /// Gets or sets a collection of arbitrary key-value data that will be included in the APNs + /// payload. + /// + [JsonIgnore] + public IDictionary CustomData + { + get + { + return this.Payload.CustomData; + } + + set + { + this.Payload.CustomData = value; + } + } + + /// + /// Gets or sets the APNs payload as accepted by the FCM backend servers. + /// + [JsonProperty("payload")] + private ApnsPayload Payload + { + get + { + if (this.payload.Aps != null && this.payload.CustomData?.ContainsKey("aps") == true) + { + throw new ArgumentException("Multiple specifications for ApnsConfig key: aps"); + } + + return this.payload; + } + + set + { + this.payload = value; + } + } + + /// + /// Copies this APNs config, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal ApnsConfig CopyAndValidate() + { + var copy = new ApnsConfig() + { + Headers = this.Headers?.Copy(), + Payload = this.Payload.CopyAndValidate(), + }; + return copy; + } + + /// + /// The APNs payload object as expected by the FCM backend service. + /// + private class ApnsPayload + { + [JsonProperty("aps")] + internal Aps Aps { get; set; } + + [JsonExtensionData] + internal IDictionary CustomData { get; set; } + + /// + /// Copies this APNs payload, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal ApnsPayload CopyAndValidate() + { + var copy = new ApnsPayload() + { + CustomData = this.CustomData?.ToDictionary(e => e.Key, e => e.Value), + }; + var aps = this.Aps; + if (aps == null && copy.CustomData?.ContainsKey("aps") == false) + { + throw new ArgumentException("Aps dictionary is required in ApnsConfig"); + } + + copy.Aps = aps?.CopyAndValidate(); + return copy; + } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Aps.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Aps.cs new file mode 100644 index 00000000..3b7bf69f --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Aps.cs @@ -0,0 +1,270 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the + /// aps dictionary that is part of every APNs message. + /// + public sealed class Aps + { + private static readonly NewtonsoftJsonSerializer Serializer = NewtonsoftJsonSerializer.Instance; + + /// + /// Gets or sets an advanced alert configuration to be included in the message. It is an + /// error to set both and properties + /// together. + /// + [JsonIgnore] + public ApsAlert Alert { get; set; } + + /// + /// Gets or sets the alert text to be included in the message. To specify a more advanced + /// alert configuration, use the property instead. It is an error to + /// set both and properties together. + /// + [JsonIgnore] + public string AlertString { get; set; } + + /// + /// Gets or sets the badge to be displayed with the message. Set to 0 to remove the badge. + /// When not specified, the badge will remain unchanged. + /// + [JsonProperty("badge")] + public int? Badge { get; set; } + + /// + /// Gets or sets the name of a sound file in your app's main bundle or in the + /// Library/Sounds folder of your app's container directory. Specify the + /// string default to play the system sound. It is an error to set both + /// and properties together. + /// + [JsonIgnore] + public string Sound { get; set; } + + /// + /// Gets or sets the critical alert sound to be played with the message. It is an error to + /// set both and properties together. + /// + [JsonIgnore] + public CriticalSound CriticalSound { get; set; } + + /// + /// Gets or sets a value indicating whether to configure a background update notification. + /// + [JsonIgnore] + public bool ContentAvailable { get; set; } + + /// + /// Gets or sets a value indicating whether to include the mutable-content property + /// in the message. When set, this property allows clients to modify the notification via + /// app extensions. + /// + [JsonIgnore] + public bool MutableContent { get; set; } + + /// + /// Gets or sets the type of the notification. + /// + [JsonProperty("category")] + public string Category { get; set; } + + /// + /// Gets or sets the app-specific identifier for grouping notifications. + /// + [JsonProperty("thread-id")] + public string ThreadId { get; set; } + + /// + /// Gets or sets a collection of arbitrary key-value data to be included in the aps + /// dictionary. This is exposed as an to support + /// correct deserialization of custom properties. + /// + [JsonExtensionData] + public IDictionary CustomData { get; set; } + + /// + /// Gets or sets the alert configuration of the aps dictionary. Read from either + /// or property. + /// + [JsonProperty("alert")] + private object AlertObject + { + get + { + object alert = this.AlertString; + if (string.IsNullOrEmpty(alert as string)) + { + alert = this.Alert; + } + else if (this.Alert != null) + { + throw new ArgumentException( + "Multiple specifications for alert (Alert and AlertString"); + } + + return alert; + } + + set + { + if (value == null) + { + return; + } + else if (value.GetType() == typeof(string)) + { + this.AlertString = (string)value; + } + else if (value.GetType() == typeof(ApsAlert)) + { + this.Alert = (ApsAlert)value; + } + else + { + var json = Serializer.Serialize(value); + this.Alert = Serializer.Deserialize(json); + } + } + } + + /// + /// Gets or sets the sound configuration of the aps dictionary. Read from either + /// or property. + /// + [JsonProperty("sound")] + private object SoundObject + { + get + { + object sound = this.Sound; + if (string.IsNullOrEmpty(sound as string)) + { + sound = this.CriticalSound; + } + else if (this.CriticalSound != null) + { + throw new ArgumentException( + "Multiple specifications for sound (CriticalSound and Sound"); + } + + return sound; + } + + set + { + if (value == null) + { + return; + } + else if (value.GetType() == typeof(string)) + { + this.Sound = (string)value; + } + else if (value.GetType() == typeof(CriticalSound)) + { + this.CriticalSound = (CriticalSound)value; + } + else + { + var json = Serializer.Serialize(value); + this.CriticalSound = Serializer.Deserialize(json); + } + } + } + + /// + /// Gets or sets the integer representation of the property, + /// which is how APNs expects it. + /// + [JsonProperty("content-available")] + private int? ContentAvailableInt + { + get + { + return this.ContentAvailable ? 1 : (int?)null; + } + + set + { + this.ContentAvailable = value == 1; + } + } + + /// + /// Gets or sets the integer representation of the property, + /// which is how APNs expects it. + /// + [JsonProperty("mutable-content")] + private int? MutableContentInt + { + get + { + return this.MutableContent ? 1 : (int?)null; + } + + set + { + this.MutableContent = value == 1; + } + } + + /// + /// Copies this Aps dictionary, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM and APNs services. + /// + internal Aps CopyAndValidate() + { + var copy = new Aps + { + AlertObject = this.AlertObject, + Badge = this.Badge, + ContentAvailable = this.ContentAvailable, + MutableContent = this.MutableContent, + Category = this.Category, + SoundObject = this.SoundObject, + ThreadId = this.ThreadId, + }; + + var customData = this.CustomData?.ToDictionary(e => e.Key, e => e.Value); + if (customData?.Count > 0) + { + var serializer = NewtonsoftJsonSerializer.Instance; + var json = serializer.Serialize(copy); + var standardProperties = serializer.Deserialize>(json); + var duplicates = customData.Keys + .Where(customKey => standardProperties.ContainsKey(customKey)) + .ToList(); + if (duplicates.Any()) + { + throw new ArgumentException( + $"Multiple specifications for Aps keys: {string.Join(",", duplicates)}"); + } + + copy.CustomData = customData; + } + + copy.Alert = copy.Alert?.CopyAndValidate(); + copy.CriticalSound = copy.CriticalSound?.CopyAndValidate(); + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/ApsAlert.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/ApsAlert.cs new file mode 100644 index 00000000..17c09472 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/ApsAlert.cs @@ -0,0 +1,142 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the + /// alert property that can be included in the aps dictionary of an APNs + /// payload. + /// + public sealed class ApsAlert + { + /// + /// Gets or sets the title of the alert. When provided, overrides the title set via + /// . + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Gets or sets the subtitle of the alert. + /// + [JsonProperty("subtitle")] + public string Subtitle { get; set; } + + /// + /// Gets or sets the body of the alert. When provided, overrides the body set via + /// . + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// Gets or sets the key of the body string in the app's string resources to use to + /// localize the body text. + /// + [JsonProperty("loc-key")] + public string LocKey { get; set; } + + /// + /// Gets or sets the resource key strings that will be used in place of the format + /// specifiers in . + /// + [JsonProperty("loc-args")] + public IEnumerable LocArgs { get; set; } + + /// + /// Gets or sets the key of the title string in the app's string resources to use to + /// localize the title text. + /// + [JsonProperty("title-loc-key")] + public string TitleLocKey { get; set; } + + /// + /// Gets or sets the resource key strings that will be used in place of the format + /// specifiers in . + /// + [JsonProperty("title-loc-args")] + public IEnumerable TitleLocArgs { get; set; } + + /// + /// Gets or sets the key of the subtitle string in the app's string resources to use to + /// localize the subtitle text. + /// + [JsonProperty("subtitle-loc-key")] + public string SubtitleLocKey { get; set; } + + /// + /// Gets or sets the resource key strings that will be used in place of the format + /// specifiers in . + /// + [JsonProperty("subtitle-loc-args")] + public IEnumerable SubtitleLocArgs { get; set; } + + /// + /// Gets or sets the key of the text in the app's string resources to use to localize the + /// action button text. + /// + [JsonProperty("action-loc-key")] + public string ActionLocKey { get; set; } + + /// + /// Gets or sets the launch image for the notification action. + /// + [JsonProperty("launch-image")] + public string LaunchImage { get; set; } + + /// + /// Copies this alert dictionary, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM and APNs services. + /// + internal ApsAlert CopyAndValidate() + { + var copy = new ApsAlert() + { + Title = this.Title, + Subtitle = this.Subtitle, + Body = this.Body, + LocKey = this.LocKey, + LocArgs = this.LocArgs?.ToList(), + TitleLocKey = this.TitleLocKey, + TitleLocArgs = this.TitleLocArgs?.ToList(), + SubtitleLocKey = this.SubtitleLocKey, + SubtitleLocArgs = this.SubtitleLocArgs?.ToList(), + ActionLocKey = this.ActionLocKey, + LaunchImage = this.LaunchImage, + }; + if (copy.TitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.TitleLocKey)) + { + throw new ArgumentException("TitleLocKey is required when specifying TitleLocArgs."); + } + + if (copy.SubtitleLocArgs?.Any() == true && string.IsNullOrEmpty(copy.SubtitleLocKey)) + { + throw new ArgumentException("SubtitleLocKey is required when specifying SubtitleLocArgs."); + } + + if (copy.LocArgs?.Any() == true && string.IsNullOrEmpty(copy.LocKey)) + { + throw new ArgumentException("LocKey is required when specifying LocArgs."); + } + + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/CriticalSound.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/CriticalSound.cs new file mode 100644 index 00000000..11555fec --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/CriticalSound.cs @@ -0,0 +1,96 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// The sound configuration for APNs critical alerts. + /// + public sealed class CriticalSound + { + /// + /// Gets or sets a value indicating whether to set the critical alert flag on the sound + /// configuration. + /// + [JsonIgnore] + public bool Critical { get; set; } + + /// + /// Gets or sets the name of the sound to be played. This should be a sound file in your + /// app's main bundle or in the Library/Sounds folder of your app's container + /// directory. Specify the string default to play the system sound. + /// + [JsonProperty("name")] + public string Name { get; set; } + + /// + /// Gets or sets the volume for the critical alert's sound. Must be a value between 0.0 + /// (silent) and 1.0 (full volume). + /// + [JsonProperty("volume")] + public double? Volume { get; set; } + + /// + /// Gets or sets the integer representation of the property, which + /// is how APNs expects it. + /// + [JsonProperty("critical")] + private int? CriticalInt + { + get + { + if (this.Critical) + { + return 1; + } + + return null; + } + + set + { + this.Critical = value == 1; + } + } + + /// + /// Copies this critical sound configuration, and validates the content of it to ensure + /// that it can be serialized into the JSON format expected by the FCM and APNs services. + /// + internal CriticalSound CopyAndValidate() + { + var copy = new CriticalSound() + { + Critical = this.Critical, + Name = this.Name, + Volume = this.Volume, + }; + if (string.IsNullOrEmpty(copy.Name)) + { + throw new ArgumentException("Name must be specified for CriticalSound"); + } + + if (copy.Volume < 0 || copy.Volume > 1) + { + throw new ArgumentException("Volume of CriticalSound must be in the interval [0, 1]"); + } + + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Direction.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Direction.cs new file mode 100644 index 00000000..ce95dda5 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Direction.cs @@ -0,0 +1,37 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace FirebaseAdmin.Messaging +{ + /// + /// Different directions a notification can be displayed in. + /// + public enum Direction + { + /// + /// Direction automatically determined. + /// + Auto, + + /// + /// Left to right. + /// + LeftToRight, + + /// + /// Right to left. + /// + RightToLeft, + } +} \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs new file mode 100644 index 00000000..9f46cad0 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs @@ -0,0 +1,178 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Http; + +namespace FirebaseAdmin.Messaging +{ + /// + /// This is the entry point to all server-side Firebase Cloud Messaging (FCM) operations. You + /// can get an instance of this class via FirebaseMessaging.DefaultInstance. + /// + public sealed class FirebaseMessaging : IFirebaseService + { + private readonly FirebaseMessagingClient messagingClient; + + private FirebaseMessaging(FirebaseApp app) + { + this.messagingClient = new FirebaseMessagingClient( + new HttpClientFactory(), app.Options.Credential, app.GetProjectId()); + } + + /// + /// Gets the messaging instance associated with the default Firebase app. This property is + /// null if the default app doesn't yet exist. + /// + public static FirebaseMessaging DefaultInstance + { + get + { + var app = FirebaseApp.DefaultInstance; + if (app == null) + { + return null; + } + + return GetMessaging(app); + } + } + + /// + /// Returns the messaging instance for the specified app. + /// + /// The instance associated with the specified + /// app. + /// If the app argument is null. + /// An app instance. + public static FirebaseMessaging GetMessaging(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException("App argument must not be null."); + } + + return app.GetOrInit(typeof(FirebaseMessaging).Name, () => + { + return new FirebaseMessaging(app); + }); + } + + /// + /// Sends a message to the FCM service for delivery. The message gets validated both by + /// the Admin SDK, and the remote FCM service. A successful return value indicates + /// that the message has been successfully sent to FCM, where it has been accepted by the + /// FCM service. + /// + /// A task that completes with a message ID string, which represents + /// successful handoff to FCM. + /// If the message argument is null. + /// If the message contains any invalid + /// fields. + /// If an error occurs while sending the + /// message. + /// The message to be sent. Must not be null. + public async Task SendAsync(Message message) + { + return await this.SendAsync(message, false); + } + + /// + /// Sends a message to the FCM service for delivery. The message gets validated both by + /// the Admin SDK, and the remote FCM service. A successful return value indicates + /// that the message has been successfully sent to FCM, where it has been accepted by the + /// FCM service. + /// + /// A task that completes with a message ID string, which represents + /// successful handoff to FCM. + /// If the message argument is null. + /// If the message contains any invalid + /// fields. + /// If an error occurs while sending the + /// message. + /// The message to be sent. Must not be null. + /// A cancellation token to monitor the asynchronous + /// operation. + public async Task SendAsync(Message message, CancellationToken cancellationToken) + { + return await this.SendAsync(message, false, cancellationToken); + } + + /// + /// Sends a message to the FCM service for delivery. The message gets validated both by + /// the Admin SDK, and the remote FCM service. A successful return value indicates + /// that the message has been successfully sent to FCM, where it has been accepted by the + /// FCM service. + /// If the option is set to true, the message will not be + /// actually sent to the recipients. Instead, the FCM service performs all the necessary + /// validations, and emulates the send operation. This is a good way to check if a + /// certain message will be accepted by FCM for delivery. + /// + /// A task that completes with a message ID string, which represents + /// successful handoff to FCM. + /// If the message argument is null. + /// If the message contains any invalid + /// fields. + /// If an error occurs while sending the + /// message. + /// The message to be sent. Must not be null. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + public async Task SendAsync(Message message, bool dryRun) + { + return await this.SendAsync(message, dryRun, default(CancellationToken)); + } + + /// + /// Sends a message to the FCM service for delivery. The message gets validated both by + /// the Admin SDK, and the remote FCM service. A successful return value indicates + /// that the message has been successfully sent to FCM, where it has been accepted by the + /// FCM service. + /// If the option is set to true, the message will not be + /// actually sent to the recipients. Instead, the FCM service performs all the necessary + /// validations, and emulates the send operation. This is a good way to check if a + /// certain message will be accepted by FCM for delivery. + /// + /// A task that completes with a message ID string, which represents + /// successful handoff to FCM. + /// If the message argument is null. + /// If the message contains any invalid + /// fields. + /// If an error occurs while sending the + /// message. + /// The message to be sent. Must not be null. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A cancellation token to monitor the asynchronous + /// operation. + public async Task SendAsync( + Message message, bool dryRun, CancellationToken cancellationToken) + { + return await this.messagingClient.SendAsync( + message, dryRun, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes this service instance. + /// + void IFirebaseService.Delete() + { + this.messagingClient.Dispose(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs new file mode 100644 index 00000000..858c3170 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs @@ -0,0 +1,133 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Google.Apis.Util; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// A client for making authorized HTTP calls to the FCM backend service. Handles request + /// serialization, response parsing, and HTTP error handling. + /// + internal sealed class FirebaseMessagingClient : IDisposable + { + private const string FcmUrl = "https://fcm.googleapis.com/v1/projects/{0}/messages:send"; + + private readonly ConfigurableHttpClient httpClient; + private readonly string sendUrl; + + public FirebaseMessagingClient( + HttpClientFactory clientFactory, GoogleCredential credential, string projectId) + { + if (string.IsNullOrEmpty(projectId)) + { + throw new FirebaseException( + "Project ID is required to access messaging service. Use a service account " + + "credential or set the project ID explicitly via AppOptions. Alternatively " + + "you can set the project ID via the GOOGLE_CLOUD_PROJECT environment " + + "variable."); + } + + this.httpClient = clientFactory.ThrowIfNull(nameof(clientFactory)) + .CreateAuthorizedHttpClient(credential); + this.sendUrl = string.Format(FcmUrl, projectId); + } + + /// + /// Sends a message to the FCM service for delivery. The message gets validated both by + /// the Admin SDK, and the remote FCM service. A successful return value indicates + /// that the message has been successfully sent to FCM, where it has been accepted by the + /// FCM service. + /// + /// A task that completes with a message ID string, which represents + /// successful handoff to FCM. + /// If the message argument is null. + /// If the message contains any invalid + /// fields. + /// If an error occurs while sending the + /// message. + /// The message to be sent. Must not be null. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A cancellation token to monitor the asynchronous + /// operation. + public async Task SendAsync( + Message message, + bool dryRun = false, + CancellationToken cancellationToken = default(CancellationToken)) + { + var request = new SendRequest() + { + Message = message.ThrowIfNull(nameof(message)).CopyAndValidate(), + ValidateOnly = dryRun, + }; + try + { + var response = await this.httpClient.PostJsonAsync( + this.sendUrl, request, cancellationToken).ConfigureAwait(false); + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var error = "Response status code does not indicate success: " + + $"{(int)response.StatusCode} ({response.StatusCode})" + + $"{Environment.NewLine}{json}"; + throw new FirebaseException(error); + } + + var parsed = JsonConvert.DeserializeObject(json); + return parsed.Name; + } + catch (HttpRequestException e) + { + throw new FirebaseException("Error while calling the FCM service.", e); + } + } + + public void Dispose() + { + this.httpClient.Dispose(); + } + + /// + /// Represents the envelope message accepted by the FCM backend service, including the message + /// payload and other options like validate_only. + /// + internal class SendRequest + { + [Newtonsoft.Json.JsonProperty("message")] + public Message Message { get; set; } + + [Newtonsoft.Json.JsonProperty("validate_only")] + public bool ValidateOnly { get; set; } + } + + /// + /// Represents the response messages sent by the FCM backend service. Primarily consists of the + /// message ID (Name) that indicates success handoff to FCM. + /// + internal class SendResponse + { + [Newtonsoft.Json.JsonProperty("name")] + public string Name { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs new file mode 100644 index 00000000..21c2d254 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Message.cs @@ -0,0 +1,148 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Google.Apis.Json; +using Google.Apis.Util; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents a message that can be sent via Firebase Cloud Messaging (FCM). Contains payload + /// information as well as the recipient information. The recipient information must be + /// specified by setting exactly one of the , or + /// fields. + /// + public sealed class Message + { + /// + /// Gets or sets the registration token of the device to which the message should be sent. + /// + [JsonProperty("token")] + public string Token { get; set; } + + /// + /// Gets or sets the name of the FCM topic to which the message should be sent. Topic names + /// may contain the /topics/ prefix. + /// + [JsonIgnore] + public string Topic { get; set; } + + /// + /// Gets or sets the FCM condition to which the message should be sent. Must be a valid + /// condition string such as "'foo' in topics". + /// + [JsonProperty("condition")] + public string Condition { get; set; } + + /// + /// Gets or sets a collection of key-value pairs that will be added to the message as data + /// fields. Keys and the values must not be null. + /// + [JsonProperty("data")] + public IReadOnlyDictionary Data { get; set; } + + /// + /// Gets or sets the notification information to be included in the message. + /// + [JsonProperty("notification")] + public Notification Notification { get; set; } + + /// + /// Gets or sets the Android-specific information to be included in the message. + /// + [JsonProperty("android")] + public AndroidConfig Android { get; set; } + + /// + /// Gets or sets the Webpush-specific information to be included in the message. + /// + [JsonProperty("webpush")] + public WebpushConfig Webpush { get; set; } + + /// + /// Gets or sets the APNs-specific information to be included in the message. + /// + [JsonProperty("apns")] + public ApnsConfig Apns { get; set; } + + /// + /// Gets or sets the formatted representation of the . Removes the + /// /topics/ prefix if present. This is what's ultimately sent to the FCM + /// service. + /// + [JsonProperty("topic")] + private string UnprefixedTopic + { + get + { + if (this.Topic != null && this.Topic.StartsWith("/topics/")) + { + return this.Topic.Substring("/topics/".Length); + } + + return this.Topic; + } + + set + { + this.Topic = value; + } + } + + /// + /// Copies this message, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. Each property is copied + /// before validation to guard against the original being modified in the user code + /// post-validation. + /// + internal Message CopyAndValidate() + { + // Copy and validate the leaf-level properties + var copy = new Message() + { + Token = this.Token, + Topic = this.Topic, + Condition = this.Condition, + Data = this.Data?.Copy(), + }; + var list = new List() + { + copy.Token, copy.Topic, copy.Condition, + }; + var targets = list.FindAll((target) => !string.IsNullOrEmpty(target)); + if (targets.Count != 1) + { + throw new ArgumentException( + "Exactly one of Token, Topic or Condition is required."); + } + + var topic = copy.UnprefixedTopic; + if (topic != null && !Regex.IsMatch(topic, "^[a-zA-Z0-9-_.~%]+$")) + { + throw new ArgumentException("Malformed topic name."); + } + + // Copy and validate the child properties + copy.Notification = this.Notification?.CopyAndValidate(); + copy.Android = this.Android?.CopyAndValidate(); + copy.Webpush = this.Webpush?.CopyAndValidate(); + copy.Apns = this.Apns?.CopyAndValidate(); + return copy; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Notification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Notification.cs new file mode 100644 index 00000000..017ee08d --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Notification.cs @@ -0,0 +1,49 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the notification parameters that can be included in a . + /// + public sealed class Notification + { + /// + /// Gets or sets the title of the notification. + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Gets or sets the body of the notification. + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// Copies this notification. There is nothing to be validated in this class, but we use + /// the same method name as in other classes in this namespace. + /// + internal Notification CopyAndValidate() + { + return new Notification() + { + Title = this.Title, + Body = this.Body, + }; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/Priority.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/Priority.cs new file mode 100644 index 00000000..e28b8587 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/Priority.cs @@ -0,0 +1,32 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace FirebaseAdmin.Messaging +{ + /// + /// Priority levels that can be set on an . + /// + public enum Priority + { + /// + /// High priority message. + /// + High, + + /// + /// Normal priority message. + /// + Normal, + } +} \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushConfig.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushConfig.cs new file mode 100644 index 00000000..4343c9ec --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushConfig.cs @@ -0,0 +1,61 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the Webpush protocol options that can be included in a . + /// + public sealed class WebpushConfig + { + /// + /// Gets or sets the Webpush HTTP headers. Refer + /// + /// Webpush specification for supported headers. + /// + [JsonProperty("headers")] + public IReadOnlyDictionary Headers { get; set; } + + /// + /// Gets or sets the Webpush data fields. When set, overrides any data fields set via + /// . + /// + [JsonProperty("data")] + public IReadOnlyDictionary Data { get; set; } + + /// + /// Gets or sets the Webpush notification that will be included in the message. + /// + [JsonProperty("notification")] + public WebpushNotification Notification { get; set; } + + /// + /// Copies this Webpush config, and validates the content of it to ensure that it can be + /// serialized into the JSON format expected by the FCM service. + /// + internal WebpushConfig CopyAndValidate() + { + return new WebpushConfig() + { + Headers = this.Headers?.Copy(), + Data = this.Data?.Copy(), + Notification = this.Notification?.CopyAndValidate(), + }; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs new file mode 100644 index 00000000..d41ed3a8 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/WebpushNotification.cs @@ -0,0 +1,221 @@ +// Copyright 2018, Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.Messaging +{ + /// + /// Represents the Webpush-specific notification options that can be included in a + /// . Supports most standard options defined in the + /// + /// Web Notification specification. + /// + public sealed class WebpushNotification + { + /// + /// Gets or sets the title text of the notification. + /// + [JsonProperty("title")] + public string Title { get; set; } + + /// + /// Gets or sets the body text of the notification. + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// Gets or sets the URL to the icon of the notification. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + + /// + /// Gets or sets the URL of the image used to represent the notification when there is not + /// enough space to display the notification itself. + /// + [JsonProperty("badge")] + public string Badge { get; set; } + + /// + /// Gets or sets some arbitrary data that will be included in the notification. + /// + [JsonProperty("data")] + public object Data { get; set; } + + /// + /// Gets or sets the direction in which to display the notification. + /// + [JsonIgnore] + public Direction? Direction { get; set; } + + /// + /// Gets or sets the URL of an image to be displayed in the notification. + /// + [JsonProperty("image")] + public string Image { get; set; } + + /// + /// Gets or sets the language of the notification. + /// + [JsonProperty("lang")] + public string Language { get; set; } + + /// + /// Gets or sets whether the user should be notified after a new notification replaces an + /// old one. + /// + [JsonProperty("renotify")] + public bool? Renotify { get; set; } + + /// + /// Gets or sets whether the notification should remain active until the user clicks or + /// dismisses it, rather than closing it automatically. + /// + [JsonProperty("requireInteraction")] + public bool? RequireInteraction { get; set; } + + /// + /// Gets or sets whether the notification should be silent. + /// + [JsonProperty("silent")] + public bool? Silent { get; set; } + + /// + /// Gets or sets an identifying tag for the notification. + /// + [JsonProperty("tag")] + public string Tag { get; set; } + + /// + /// Gets or sets the notification's timestamp value in milliseconds. + /// + [JsonProperty("timestamp")] + public long? TimestampMillis { get; set; } + + /// + /// Gets or sets a vibration pattern for the receiving device's vibration hardware. + /// + [JsonProperty("vibrate")] + public int[] Vibrate { get; set; } + + /// + /// Gets or sets a collection of Webpush notification actions. + /// + [JsonProperty("actions")] + public IEnumerable Actions { get; set; } + + /// + /// Gets or sets the custom key-value pairs that will be included in the + /// notification. This is exposed as an to support + /// correct deserialization of custom properties. + /// + [JsonExtensionData] + public IDictionary CustomData { get; set; } + + /// + /// Gets or sets the string representation of the property. + /// + [JsonProperty("dir")] + private string DirectionString + { + get + { + switch (this.Direction) + { + case Messaging.Direction.Auto: + return "auto"; + case Messaging.Direction.LeftToRight: + return "ltr"; + case Messaging.Direction.RightToLeft: + return "rtl"; + default: + return null; + } + } + + set + { + switch (value) + { + case "auto": + this.Direction = Messaging.Direction.Auto; + return; + case "ltr": + this.Direction = Messaging.Direction.LeftToRight; + return; + case "rtl": + this.Direction = Messaging.Direction.RightToLeft; + return; + default: + throw new FirebaseException( + $"Invalid direction value: {value}. Only 'auto', 'rtl' and 'ltr' " + + "are allowed."); + } + } + } + + /// + /// Copies this Webpush notification, and validates the content of it to ensure that it can + /// be serialized into the JSON format expected by the FCM service. + /// + internal WebpushNotification CopyAndValidate() + { + var copy = new WebpushNotification() + { + Title = this.Title, + Body = this.Body, + Icon = this.Icon, + Image = this.Image, + Language = this.Language, + Tag = this.Tag, + Direction = this.Direction, + Badge = this.Badge, + Renotify = this.Renotify, + RequireInteraction = this.RequireInteraction, + Silent = this.Silent, + Actions = this.Actions?.Select((item, _) => new Action(item)).ToList(), + Vibrate = this.Vibrate, + TimestampMillis = this.TimestampMillis, + Data = this.Data, + }; + + var customData = this.CustomData?.ToDictionary(e => e.Key, e => e.Value); + if (customData?.Count > 0) + { + var serializer = NewtonsoftJsonSerializer.Instance; + var json = serializer.Serialize(copy); + var standardProperties = serializer.Deserialize>(json); + var duplicates = customData.Keys + .Where(customKey => standardProperties.ContainsKey(customKey)) + .ToList(); + if (duplicates.Any()) + { + throw new ArgumentException( + "Multiple specifications for WebpushNotification keys: " + + string.Join(",", duplicates)); + } + + copy.CustomData = customData; + } + + return copy; + } + } +} diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 00000000..56f05687 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "documentInternalElements": false + } + } +} diff --git a/stylecop.ruleset b/stylecop.ruleset new file mode 100644 index 00000000..8a4c10a4 --- /dev/null +++ b/stylecop.ruleset @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/stylecop_test.ruleset b/stylecop_test.ruleset new file mode 100644 index 00000000..aaef9717 --- /dev/null +++ b/stylecop_test.ruleset @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + +