diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj index 5c839131..278c7551 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj @@ -1,7 +1,7 @@ - netcoreapp2.1 + net7.0 latest false true @@ -30,4 +30,4 @@ - + \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj index 75cedf49..b9161a93 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj @@ -1,7 +1,7 @@ - netcoreapp2.1 + net7.0 false true ../../stylecop_test.ruleset diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs new file mode 100644 index 00000000..9ab0ecdf --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using FirebaseAdmin.Messaging; +using FirebaseAdmin.Tests; +using FirebaseAdmin.Tests.AppCheck; +using FirebaseAdmin.Util; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Newtonsoft.Json; +using Xunit; + +namespace FirebaseAdmin.AppCheck.Tests +{ + public class AppCheckApiClientTest + { + private static readonly GoogleCredential MockCredential = + GoogleCredential.FromFile("./resources/service_account.json"); + + private readonly string appId = "1:1234:android:1234"; + + [Fact] + public void NoProjectId() + { + var args = new AppCheckApiClient.Args() + { + ClientFactory = new HttpClientFactory(), + Credential = null, + }; + + args.ProjectId = null; + Assert.Throws(() => new AppCheckApiClient(args)); + + args.ProjectId = string.Empty; + Assert.Throws(() => new AppCheckApiClient(args)); + } + + [Fact] + public void NoCredential() + { + var args = new AppCheckApiClient.Args() + { + ClientFactory = new HttpClientFactory(), + Credential = null, + ProjectId = "test-project", + }; + + Assert.Throws(() => new AppCheckApiClient(args)); + } + + [Fact] + public void NoClientFactory() + { + var args = new AppCheckApiClient.Args() + { + ClientFactory = null, + Credential = MockCredential, + ProjectId = "test-project", + }; + + Assert.Throws(() => new AppCheckApiClient(args)); + } + + [Fact] + public async Task ExchangeToken() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var response = await client.ExchangeTokenAsync(customToken, this.appId); + + Assert.Equal("test-token", response.Token); + Assert.Equal(36000000, response.TtlMillis); + + var req = JsonConvert.DeserializeObject(handler.LastRequestBody); + Assert.Equal("test-token", req.CustomToken); + Assert.Equal(1, handler.Calls); + this.CheckHeaders(handler.LastRequestHeaders); + } + + [Fact] + public async Task ExchangeTokenWithEmptyAppId() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var ex = await Assert.ThrowsAsync( + async () => await client.ExchangeTokenAsync(customToken, string.Empty)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("appId must be a non-empty string.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + Assert.Null(ex.HttpResponse); + Assert.Null(ex.InnerException); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task ExchangeTokenWithNullAppId() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var ex = await Assert.ThrowsAsync( + async () => await client.ExchangeTokenAsync(customToken, null)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("appId must be a non-empty string.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + Assert.Null(ex.HttpResponse); + Assert.Null(ex.InnerException); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task ExchangeTokenWithEmptyCustomToken() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + var ex = await Assert.ThrowsAsync( + async () => await client.ExchangeTokenAsync(string.Empty, this.appId)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("customToken must be a non-empty string.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + Assert.Null(ex.HttpResponse); + Assert.Null(ex.InnerException); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task ExchangeTokenWithNullCustomToken() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + var ex = await Assert.ThrowsAsync( + async () => await client.ExchangeTokenAsync(null, this.appId)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("customToken must be a non-empty string.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + Assert.Null(ex.HttpResponse); + Assert.Null(ex.InnerException); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task ExchangeTokenWithErrorNoTtlResponse() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var ex = await Assert.ThrowsAsync( + async () => await client.ExchangeTokenAsync(customToken, this.appId)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("`ttl` must be a valid duration string with the suffix `s`.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + Assert.Null(ex.HttpResponse); + Assert.Null(ex.InnerException); + Assert.Equal(1, handler.Calls); + } + + [Fact] + public async Task ExchangeTokenWithErrorNoTokenResponse() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Ttl = "36000s", + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var ex = await Assert.ThrowsAsync( + async () => await client.ExchangeTokenAsync(customToken, this.appId)); + + Assert.Equal(ErrorCode.PermissionDenied, ex.ErrorCode); + Assert.Equal("Token is not valid", ex.Message); + Assert.Equal(AppCheckErrorCode.AppCheckTokenExpired, ex.AppCheckErrorCode); + Assert.Null(ex.HttpResponse); + Assert.Null(ex.InnerException); + Assert.Equal(1, handler.Calls); + } + + [Fact] + public async Task VerifyReplayProtectionWithTrue() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.VerifyTokenResponse() + { + AlreadyConsumed = true, + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var response = await client.VerifyReplayProtectionAsync(customToken); + + Assert.True(response); + + var req = JsonConvert.DeserializeObject(handler.LastRequestBody); + Assert.Equal(1, handler.Calls); + this.CheckHeaders(handler.LastRequestHeaders); + } + + [Fact] + public async Task VerifyReplayProtectionWithFalse() + { + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.VerifyTokenResponse() + { + AlreadyConsumed = false, + }, + }; + + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); + + string customToken = "test-token"; + + var response = await client.VerifyReplayProtectionAsync(customToken); + + Assert.False(response); + + var req = JsonConvert.DeserializeObject(handler.LastRequestBody); + Assert.Equal(1, handler.Calls); + this.CheckHeaders(handler.LastRequestHeaders); + } + + private AppCheckApiClient CreateAppCheckApiClient(HttpClientFactory factory) + { + return new AppCheckApiClient(new AppCheckApiClient.Args() + { + ClientFactory = factory, + Credential = MockCredential, + ProjectId = "test-project", + RetryOptions = RetryOptions.NoBackOff, + }); + } + + private void CheckHeaders(HttpRequestHeaders header) + { + var versionHeader = header.GetValues("X-Firebase-Client").First(); + Assert.Equal(AppCheckApiClient.ClientVersion, versionHeader); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckErrorHandlerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckErrorHandlerTest.cs new file mode 100644 index 00000000..d270e2f6 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckErrorHandlerTest.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using FirebaseAdmin.Util; +using Xunit; + +namespace FirebaseAdmin.AppCheck.Tests +{ + public class AppCheckErrorHandlerTest + { + public static readonly IEnumerable AppCheckErrorCodes = + new List() + { + new object[] + { + "ABORTED", + ErrorCode.Aborted, + AppCheckErrorCode.Aborted, + }, + new object[] + { + "INVALID_ARGUMENT", + ErrorCode.InvalidArgument, + AppCheckErrorCode.InvalidArgument, + }, + new object[] + { + "INVALID_CREDENTIAL", + ErrorCode.InvalidArgument, + AppCheckErrorCode.InvalidCredential, + }, + new object[] + { + "PERMISSION_DENIED", + ErrorCode.PermissionDenied, + AppCheckErrorCode.PermissionDenied, + }, + new object[] + { + "UNAUTHENTICATED", + ErrorCode.Unauthenticated, + AppCheckErrorCode.Unauthenticated, + }, + new object[] + { + "NOT_FOUND", + ErrorCode.NotFound, + AppCheckErrorCode.NotFound, + }, + new object[] + { + "UNKNOWN", + ErrorCode.Unknown, + AppCheckErrorCode.UnknownError, + }, + }; + + [Theory] + [MemberData(nameof(AppCheckErrorCodes))] + public void KnownErrorCode( + string code, ErrorCode expectedCode, AppCheckErrorCode expectedAppCheckCode) + { + var json = $@"{{ + ""error"": {{ + ""message"": ""{code}"", + }} + }}"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.ServiceUnavailable, + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + var error = AppCheckErrorHandler.Instance.HandleHttpErrorResponse(resp, json); + + Assert.Equal(expectedCode, error.ErrorCode); + Assert.Equal(expectedAppCheckCode, error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Null(error.InnerException); + Assert.EndsWith($" ({code}).", error.Message); + } + + [Theory] + [MemberData(nameof(AppCheckErrorCodes))] + public void KnownErrorCodeWithDetails( + string code, ErrorCode expectedCode, AppCheckErrorCode expectedAppCheckCode) + { + var json = $@"{{ + ""error"": {{ + ""message"": ""{code}: Some details."", + }} + }}"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.ServiceUnavailable, + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + var error = AppCheckErrorHandler.Instance.HandleHttpErrorResponse(resp, json); + + Assert.Equal(expectedCode, error.ErrorCode); + Assert.Equal(expectedAppCheckCode, error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Null(error.InnerException); + Assert.EndsWith($" ({code}).: Some details.", error.Message); + } + + [Fact] + public void UnknownErrorCode() + { + var json = $@"{{ + ""error"": {{ + ""message"": ""SOMETHING_UNUSUAL"", + }} + }}"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.InternalServerError, + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + var error = AppCheckErrorHandler.Instance.HandleHttpErrorResponse(resp, json); + + Assert.Equal(ErrorCode.Internal, error.ErrorCode); + Assert.Equal( + $"Unexpected HTTP response with status: 500 (InternalServerError){Environment.NewLine}{json}", + error.Message); + Assert.Null(error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Null(error.InnerException); + } + + [Fact] + public void UnspecifiedErrorCode() + { + var json = $@"{{ + ""error"": {{}} + }}"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.InternalServerError, + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + var error = AppCheckErrorHandler.Instance.HandleHttpErrorResponse(resp, json); + + Assert.Equal(ErrorCode.Internal, error.ErrorCode); + Assert.Equal( + $"Unexpected HTTP response with status: 500 (InternalServerError){Environment.NewLine}{json}", + error.Message); + Assert.Null(error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Null(error.InnerException); + } + + [Fact] + public void NoDetails() + { + var json = @"{}"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.ServiceUnavailable, + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + var error = AppCheckErrorHandler.Instance.HandleHttpErrorResponse(resp, json); + + Assert.Equal(ErrorCode.Unavailable, error.ErrorCode); + Assert.Equal( + $"Unexpected HTTP response with status: 503 (ServiceUnavailable){Environment.NewLine}{{}}", + error.Message); + Assert.Null(error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Null(error.InnerException); + } + + [Fact] + public void NonJson() + { + var text = "plain text"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.ServiceUnavailable, + Content = new StringContent(text, Encoding.UTF8, "text/plain"), + }; + + var error = AppCheckErrorHandler.Instance.HandleHttpErrorResponse(resp, text); + + Assert.Equal(ErrorCode.Unavailable, error.ErrorCode); + Assert.Equal( + $"Unexpected HTTP response with status: 503 (ServiceUnavailable){Environment.NewLine}{text}", + error.Message); + Assert.Null(error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Null(error.InnerException); + } + + [Fact] + public void DeserializeException() + { + var text = "plain text"; + var resp = new HttpResponseMessage() + { + StatusCode = HttpStatusCode.ServiceUnavailable, + Content = new StringContent(text, Encoding.UTF8, "text/plain"), + }; + var inner = new Exception("Deserialization error"); + + var error = AppCheckErrorHandler.Instance.HandleDeserializeException( + inner, new ResponseInfo(resp, text)); + + Assert.Equal(ErrorCode.Unknown, error.ErrorCode); + Assert.Equal( + $"Error while parsing AppCheck service response. Deserialization error: {text}", + error.Message); + Assert.Equal(AppCheckErrorCode.UnknownError, error.AppCheckErrorCode); + Assert.Same(resp, error.HttpResponse); + Assert.Same(inner, error.InnerException); + } + + [Fact] + public void HttpRequestException() + { + var exception = new HttpRequestException("network error"); + + var error = AppCheckErrorHandler.Instance.HandleHttpRequestException(exception); + + Assert.Equal(ErrorCode.Unknown, error.ErrorCode); + Assert.Equal( + "Unknown error while making a remote service call: network error", error.Message); + Assert.Null(error.AppCheckErrorCode); + Assert.Null(error.HttpResponse); + Assert.Same(exception, error.InnerException); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenFactoryTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenFactoryTest.cs new file mode 100644 index 00000000..4bb10de9 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenFactoryTest.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.AppCheck; +using FirebaseAdmin.Auth.Jwt; +using FirebaseAdmin.Auth.Jwt.Tests; +using FirebaseAdmin.Tests; +using Google.Apis.Auth; +using Google.Apis.Auth.OAuth2; +using Newtonsoft.Json.Linq; +using Xunit; + +#pragma warning disable SYSLIB0027 + +namespace FirebaseAdmin.AppCheck.Tests +{ + public class AppCheckTokenFactoryTest + { + public static readonly IEnumerable InvalidStrings = new List + { + new object[] { null }, + new object[] { string.Empty }, + }; + + private const int ThirtyMinInMs = 1800000; + private const int SevenDaysInMs = 604800000; + private static readonly MockClock Clock = new MockClock(); + private static readonly MockSigner Signer = new MockSigner(); + private readonly string appId = "test-app-id"; + + public string Private { get; private set; } + + public string Public { get; private set; } + + [Fact] + public async Task CreateCustomToken() + { + var factory = CreateTokenFactory(); + + var token = await factory.CreateCustomTokenAsync("user1"); + + MockCustomTokenVerifier.WithTenant().Verify(token); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public async Task InvalidAppId(string appId) + { + var factory = CreateTokenFactory(); + await Assert.ThrowsAsync(() => factory.CreateCustomTokenAsync(appId)).ConfigureAwait(false); + } + + [Fact] + public async Task RejectedOption() + { + int[] ttls = new int[] { -100, -1, 0, 10, 1799999, 604800001, 1209600000 }; + foreach (var ttl in ttls) + { + var option = new AppCheckTokenOptions(ttl); + + var factory = CreateTokenFactory(); + + await Assert.ThrowsAsync(() => + factory.CreateCustomTokenAsync("user1", option)).ConfigureAwait(false); + } + } + + [Fact] + public async Task FullFilledOption() + { + int[] ttls = new int[] { -100, -1, 0, 10, 1799999, 604800001, 1209600000 }; + foreach (var ttl in ttls) + { + var option = new AppCheckTokenOptions(ttl); + + var factory = CreateTokenFactory(); + + await Assert.ThrowsAsync(() => + factory.CreateCustomTokenAsync("user1", option)).ConfigureAwait(false); + } + } + + [Fact] + public void Dispose() + { + FirebaseApp.DeleteAll(); + } + + [Fact] + public async Task DecodedPayload() + { + List<(int Milliseconds, string ExpectedResult)> ttls = new List<(int, string)> + { + (ThirtyMinInMs, "1800s"), + (ThirtyMinInMs + 1, "1800.001000000s"), + (SevenDaysInMs / 2, "302400s"), + (SevenDaysInMs - 1, "604799.999000000s"), + (SevenDaysInMs, "604800s"), + }; + + foreach (var value in ttls) + { + var factory = CreateTokenFactory(); + + var option = new AppCheckTokenOptions(value.Milliseconds); + + string token = await factory.CreateCustomTokenAsync(this.appId, option).ConfigureAwait(false); + string[] segments = token.Split("."); + Assert.Equal(3, segments.Length); + var payload = JwtUtils.Decode(segments[1]); + Assert.Equal(value.ExpectedResult, payload.Ttl); + } + } + + [Fact] + public async Task CorrectHeader() + { + var factory = CreateTokenFactory(); + + var option = new AppCheckTokenOptions(ThirtyMinInMs + 3000); + + string token = await factory.CreateCustomTokenAsync(this.appId, option).ConfigureAwait(false); + string[] segments = token.Split("."); + Assert.Equal(3, segments.Length); + var header = JwtUtils.Decode(segments[0]); + Assert.Equal("RS256", header.Algorithm); + Assert.Equal("JWT", header.Type); + } + + private static AppCheckTokenFactory CreateTokenFactory() + { + var args = new AppCheckTokenFactory.Args + { + Signer = Signer, + Clock = Clock, + }; + return new AppCheckTokenFactory(args); + } + + private abstract class AppCheckCustomTokenVerifier + { + private readonly string issuer; + + internal AppCheckCustomTokenVerifier(string issuer) + { + this.issuer = issuer; + } + + internal static AppCheckCustomTokenVerifier ForServiceAccount( + string clientEmail, byte[] publicKey) + { + return new RSACustomTokenVerifier(clientEmail, publicKey); + } + + internal void Verify(string token, IDictionary claims = null) + { + string[] segments = token.Split("."); + Assert.Equal(3, segments.Length); + + var header = JwtUtils.Decode(segments[0]); + this.AssertHeader(header); + + var payload = JwtUtils.Decode(segments[1]); + Assert.Equal(this.issuer, payload.Issuer); + Assert.Equal(this.issuer, payload.Subject); + Assert.Equal(AppCheckTokenFactory.FirebaseAppCheckAudience, payload.Audience); + this.AssertSignature($"{segments[0]}.{segments[1]}", segments[2]); + } + + protected virtual void AssertHeader(JsonWebSignature.Header header) + { + Assert.Equal("RS256", header.Algorithm); + Assert.Equal("JWT", header.Type); + } + + protected abstract void AssertSignature(string tokenData, string signature); + + private sealed class RSACustomTokenVerifier : AppCheckCustomTokenVerifier + { + private readonly RSA rsa; + + internal RSACustomTokenVerifier(string issuer, byte[] publicKey) + : base(issuer) + { + var x509cert = new X509Certificate2(publicKey); + this.rsa = (RSA)x509cert.PublicKey.Key; + } + + protected override void AssertSignature(string tokenData, string signature) + { + var tokenDataBytes = Encoding.UTF8.GetBytes(tokenData); + var signatureBytes = JwtUtils.Base64DecodeToBytes(signature); + var verified = this.rsa.VerifyData( + tokenDataBytes, + signatureBytes, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + Assert.True(verified); + } + } + + private sealed class EmulatorCustomTokenVerifier : AppCheckCustomTokenVerifier + { + internal EmulatorCustomTokenVerifier(string tenantId) + : base("firebase-auth-emulator@example.com") { } + + protected override void AssertHeader(JsonWebSignature.Header header) + { + Assert.Equal("none", header.Algorithm); + Assert.Equal("JWT", header.Type); + } + + protected override void AssertSignature(string tokenData, string signature) + { + Assert.Empty(signature); + } + } + } + + private sealed class MockCustomTokenVerifier : AppCheckCustomTokenVerifier + { + private readonly string expectedSignature; + + private MockCustomTokenVerifier(string issuer, string signature) + : base(issuer) + { + this.expectedSignature = signature; + } + + internal static MockCustomTokenVerifier WithTenant() + { + return new MockCustomTokenVerifier( + MockSigner.KeyIdString, MockSigner.Signature); + } + + protected override void AssertSignature(string tokenData, string signature) + { + Assert.Equal(this.expectedSignature, JwtUtils.Base64Decode(signature)); + } + } + + private sealed class MockSigner : ISigner + { + public const string KeyIdString = "mock-key-id"; + public const string Signature = "signature"; + + public string Algorithm => JwtUtils.AlgorithmRS256; + + public Task GetKeyIdAsync(CancellationToken cancellationToken) + { + return Task.FromResult(KeyIdString); + } + + public Task SignDataAsync(byte[] data, CancellationToken cancellationToken) + { + return Task.FromResult(Encoding.UTF8.GetBytes(Signature)); + } + + public void Dispose() { } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs new file mode 100644 index 00000000..92abfd93 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Auth.Jwt; +using FirebaseAdmin.Auth.Jwt.Tests; +using Google.Apis.Auth; +using Google.Apis.Util; +using Newtonsoft.Json; +using Xunit; + +namespace FirebaseAdmin.AppCheck.Tests +{ + public class AppCheckTokenVerifierTest + { + public static readonly IEnumerable InvalidStrings = new List + { + new object[] { null }, + new object[] { string.Empty }, + }; + + public static readonly IEnumerable InvalidTokens = new List + { + new object[] { "TestToken" }, + new object[] { "Test.Token" }, + new object[] { "Test.Token.Test.Token" }, + }; + + public static readonly IEnumerable InvalidAudiences = new List + { + new object[] { new List { "incorrectAudience" } }, + new object[] { new List { "12345678", "project_id" } }, + new object[] { new List { "projects/" + "12345678", "project_id" } }, + }; + + private readonly string appId = "1:1234:android:1234"; + + [Fact] + public void NullKeySource() + { + var args = FullyPopulatedArgs(); + args.KeySource = null; + + Assert.Throws(() => new AppCheckTokenVerifier(args)); + } + + [Fact] + public void ProjectId() + { + var args = FullyPopulatedArgs(); + + var verifier = new AppCheckTokenVerifier(args); + + Assert.Equal("test-project", verifier.ProjectId); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public async Task VerifyWithNullEmptyToken(string token) + { + var args = FullyPopulatedArgs(); + var tokenVerifier = new AppCheckTokenVerifier(args); + + var ex = await Assert.ThrowsAsync( + async () => await tokenVerifier.VerifyTokenAsync(token)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("App Check token must not be null or empty.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public async Task VerifyWithInvalidProjectId(string projectId) + { + var args = FullyPopulatedArgs(); + args.ProjectId = projectId; + var tokenVerifier = new AppCheckTokenVerifier(args); + + string token = "test-token"; + + var ex = await Assert.ThrowsAsync( + async () => await tokenVerifier.VerifyTokenAsync(token)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("Must initialize app with a cert credential or set your Firebase project ID as the GOOGLE_CLOUD_PROJECT environment variable to verify an App Check token.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidCredential, ex.AppCheckErrorCode); + } + + [Theory] + [MemberData(nameof(InvalidTokens))] + public async Task VerifyWithInvalidToken(string token) + { + var args = FullyPopulatedArgs(); + var tokenVerifier = new AppCheckTokenVerifier(args); + + var ex = await Assert.ThrowsAsync( + async () => await tokenVerifier.VerifyTokenAsync(token)); + + Assert.Equal(ErrorCode.InvalidArgument, ex.ErrorCode); + Assert.Equal("Incorrect number of segments in app check token.", ex.Message); + Assert.Equal(AppCheckErrorCode.InvalidArgument, ex.AppCheckErrorCode); + } + + [Theory] + [MemberData(nameof(InvalidAudiences))] + public async Task CheckInvalidAudience(List aud) + { + string token = await this.GeneratorAppCheckTokenAsync(aud).ConfigureAwait(false); + string expected = "The provided app check token has incorrect \"aud\" (audience) claim"; + var args = FullyPopulatedArgs(); + AppCheckTokenVerifier verifier = new AppCheckTokenVerifier(args); + var result = await Assert.ThrowsAsync(() => verifier.VerifyTokenAsync(token)).ConfigureAwait(false); + Assert.Contains(expected, result.Message); + } + + [Fact] + public async Task CheckEmptyAudience() + { + string token = await this.GeneratorAppCheckTokenAsync([]).ConfigureAwait(false); + var args = FullyPopulatedArgs(); + AppCheckTokenVerifier verifier = new AppCheckTokenVerifier(args); + var result = await Assert.ThrowsAsync(() => verifier.VerifyTokenAsync(token)).ConfigureAwait(false); + Assert.Equal("Failed to verify app check signature.", result.Message); + } + + [Fact] + public async Task VerifyToken() + { + List aud = new List { "12345678", "projects/test-project" }; + string token = await this.GeneratorAppCheckTokenAsync(aud).ConfigureAwait(false); + + var args = FullyPopulatedArgs(); + AppCheckTokenVerifier verifier = new AppCheckTokenVerifier(args); + await Assert.ThrowsAsync(() => verifier.VerifyTokenAsync(token)).ConfigureAwait(false); + } + + private static AppCheckTokenVerifier.Args FullyPopulatedArgs() + { + return new AppCheckTokenVerifier.Args + { + ProjectId = "test-project", + Clock = null, + KeySource = JwtTestUtils.DefaultKeySource, + }; + } + + private async Task GeneratorAppCheckTokenAsync(List audience) + { + DateTime unixEpoch = new DateTime( + 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var header = new JsonWebSignature.Header() + { + Algorithm = "RS256", + KeyId = "FGQdnRlzAmKyKr6-Hg_kMQrBkj_H6i6ADnBQz4OI6BU", + }; + + var signer = EmulatorSigner.Instance; + CancellationToken cancellationToken = default; + int issued = (int)(SystemClock.Default.UtcNow - unixEpoch).TotalSeconds; + var keyId = await signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); + var payload = new CustomTokenPayload() + { + Subject = this.appId, + Issuer = "https://firebaseappcheck.googleapis.com/" + this.appId, + AppId = this.appId, + Audience = audience, + ExpirationTimeSeconds = 60, + IssuedAtTimeSeconds = issued, + Ttl = "180000", + }; + + return await JwtUtils.CreateSignedJwtAsync( + header, payload, signer).ConfigureAwait(false); + } + } + + internal class CustomTokenPayload : JsonWebToken.Payload + { + [JsonPropertyAttribute("app_id")] + public string AppId { get; set; } + + [JsonPropertyAttribute("ttl")] + public string Ttl { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/CountingAppCheckHandler.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/CountingAppCheckHandler.cs new file mode 100644 index 00000000..caf50f1c --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/CountingAppCheckHandler.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace FirebaseAdmin.Tests.AppCheck +{ + internal abstract class CountingAppCheckHandler : HttpMessageHandler + { + private int calls; + + public int Calls + { + get => this.calls; + } + + protected sealed override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var count = Interlocked.Increment(ref this.calls); + return this.DoSendAsync(request, count, cancellationToken); + } + + protected abstract Task DoSendAsync( + HttpRequestMessage request, int count, CancellationToken cancellationToken); + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/FirebaseAppCheckTests.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/FirebaseAppCheckTests.cs new file mode 100644 index 00000000..70139e87 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/FirebaseAppCheckTests.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.AppCheck; +using FirebaseAdmin.Auth; +using FirebaseAdmin.Auth.Jwt; +using FirebaseAdmin.Tests; +using FirebaseAdmin.Tests.AppCheck; +using FirebaseAdmin.Util; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Moq; +using Xunit; + +namespace FirebaseAdmin.AppCheck.Tests +{ + public class FirebaseAppCheckTests : IDisposable + { + private static readonly GoogleCredential MockCredential = GoogleCredential.FromFile("./resources/service_account.json"); + private static readonly string ProjectId = "test-project"; + private static readonly string AppId = "1:1234:android:1234"; + + private string noProjectId = "Project ID is required to access app check 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."; + + [Fact] + public void GetAppCheckWithoutApp() + { + Assert.Null(FirebaseAppCheck.DefaultInstance); + } + + [Fact] + public void GetAppCheckWithoutProjectId() + { + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential }); + var res = Assert.Throws(() => new FirebaseAppCheck(app)); + Assert.Equal(this.noProjectId, res.Message); + app.Delete(); + } + + [Fact] + public void GetDefaultAppCheck() + { + var app = FirebaseApp.Create(new AppOptions() { Credential = MockCredential, ProjectId = ProjectId }); + FirebaseAppCheck appCheck = FirebaseAppCheck.DefaultInstance; + Assert.NotNull(appCheck); + Assert.Same(appCheck, FirebaseAppCheck.DefaultInstance); + app.Delete(); + Assert.Null(FirebaseAppCheck.DefaultInstance); + } + + [Fact] + public void GetAppCheck() + { + var app = FirebaseApp.Create(new AppOptions { Credential = MockCredential, ProjectId = ProjectId }, "MyApp"); + FirebaseAppCheck appCheck = FirebaseAppCheck.GetAppCheck(app); + Assert.NotNull(appCheck); + Assert.Same(appCheck, FirebaseAppCheck.GetAppCheck(app)); + app.Delete(); + Assert.Throws(() => FirebaseAppCheck.GetAppCheck(app)); + } + + [Fact] + public async Task GetAppCheckWithApiClientFactory() + { + var bytes = Encoding.UTF8.GetBytes("signature"); + var handler = new MockMessageHandler() + { + Response = new CreatTokenResponse() + { + Signature = Convert.ToBase64String(bytes), + Token = "test-token", + Ttl = "36000s", + }, + }; + var factory = new MockHttpClientFactory(handler); + + var app = FirebaseApp.Create( + new AppOptions() + { + Credential = GoogleCredential.FromAccessToken("test-token"), + HttpClientFactory = factory, + ProjectId = ProjectId, + }, + AppId); + + FirebaseAppCheck appCheck = FirebaseAppCheck.GetAppCheck(app); + Assert.NotNull(appCheck); + Assert.Same(appCheck, FirebaseAppCheck.GetAppCheck(app)); + + var response = await appCheck.CreateTokenAsync(app.Name); + Assert.Equal("test-token", response.Token); + Assert.Equal(36000000, response.TtlMillis); + } + + [Fact] + public async Task UseAfterDelete() + { + var app = FirebaseApp.Create(new AppOptions() + { + Credential = MockCredential, + ProjectId = ProjectId, + }); + + FirebaseAppCheck appCheck = FirebaseAppCheck.DefaultInstance; + app.Delete(); + await Assert.ThrowsAsync( + async () => await appCheck.CreateTokenAsync(AppId)); + } + + [Fact] + public async Task CreateTokenSuccess() + { + var bytes = Encoding.UTF8.GetBytes("signature"); + var handler = new MockMessageHandler() + { + Response = new CreatTokenResponse() + { + Signature = Convert.ToBase64String(bytes), + Token = "test-token", + Ttl = "36000s", + }, + }; + var factory = new MockHttpClientFactory(handler); + + var app = FirebaseApp.Create( + new AppOptions() + { + Credential = GoogleCredential.FromAccessToken("test-token"), + HttpClientFactory = factory, + ProjectId = ProjectId, + }, + AppId); + + FirebaseAppCheck appCheck = FirebaseAppCheck.GetAppCheck(app); + + var response = await appCheck.CreateTokenAsync(app.Name); + Assert.Equal("test-token", response.Token); + Assert.Equal(36000000, response.TtlMillis); + } + + public void Dispose() + { + FirebaseApp.DeleteAll(); + } + + private sealed class CreatTokenResponse + { + [Newtonsoft.Json.JsonProperty("signedBlob")] + public string Signature { get; set; } + + [Newtonsoft.Json.JsonProperty("token")] + public string Token { get; set; } + + [Newtonsoft.Json.JsonProperty("ttl")] + public string Ttl { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/MockAppCheckHandler.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/MockAppCheckHandler.cs new file mode 100644 index 00000000..5839cb51 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/MockAppCheckHandler.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Google.Apis.Json; + +namespace FirebaseAdmin.Tests.AppCheck +{ + internal sealed class MockAppCheckHandler : CountingAppCheckHandler + { + private readonly List requests = new List(); + + public MockAppCheckHandler() + { + this.StatusCode = HttpStatusCode.OK; + } + + public delegate void SetHeaders(HttpResponseHeaders respHeaders, HttpContentHeaders contentHeaders); + + public delegate object GetResponse(IncomingRequest request); + + public delegate HttpStatusCode GetStatusCode(IncomingRequest request); + + public IReadOnlyList Requests + { + get => this.requests; + } + + public string LastRequestBody + { + get => this.requests.LastOrDefault()?.Body; + } + + public HttpRequestHeaders LastRequestHeaders + { + get => this.requests.LastOrDefault()?.Headers; + } + + public HttpStatusCode StatusCode { get; set; } + + public object Response { get; set; } + + public Exception Exception { get; set; } + + public SetHeaders ApplyHeaders { get; set; } + + public GetResponse GenerateResponse { get; set; } + + public GetStatusCode GenerateStatusCode { get; set; } + + protected override async Task DoSendAsync( + HttpRequestMessage request, int count, CancellationToken cancellationToken) + { + var incomingRequest = await IncomingRequest.CreateAsync(request); + this.requests.Add(incomingRequest); + + var tcs = new TaskCompletionSource(); + if (this.Exception != null) + { + tcs.SetException(this.Exception); + return await tcs.Task; + } + + if (this.GenerateResponse != null) + { + this.Response = this.GenerateResponse(incomingRequest); + } + + if (this.GenerateStatusCode != null) + { + this.StatusCode = this.GenerateStatusCode(incomingRequest); + } + + string json; + if (this.Response is byte[]) + { + json = Encoding.UTF8.GetString(this.Response as byte[]); + } + else if (this.Response is string) + { + json = this.Response as string; + } + else if (this.Response is IList) + { + json = (this.Response as IList)[count - 1]; + } + else + { + json = NewtonsoftJsonSerializer.Instance.Serialize(this.Response); + } + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var resp = new HttpResponseMessage(); + resp.StatusCode = this.StatusCode; + resp.Content = content; + if (this.ApplyHeaders != null) + { + this.ApplyHeaders(resp.Headers, content.Headers); + } + + tcs.SetResult(resp); + return await tcs.Task; + } + + internal sealed class IncomingRequest + { + internal HttpMethod Method { get; private set; } + + internal Uri Url { get; private set; } + + internal HttpRequestHeaders Headers { get; private set; } + + internal string Body { get; private set; } + + internal static async Task CreateAsync(HttpRequestMessage request) + { + return new IncomingRequest() + { + Method = request.Method, + Url = request.RequestUri, + Headers = request.Headers, + Body = request.Content != null ? await request.Content.ReadAsStringAsync() : null, + }; + } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs index 314b28d7..d572a2eb 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs @@ -18,6 +18,7 @@ using System.Text; using Google.Apis.Auth; using Xunit; +#pragma warning disable SYSLIB0027 namespace FirebaseAdmin.Auth.Jwt.Tests { diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs index db24f856..e57bbc46 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs @@ -20,6 +20,7 @@ using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; +using FirebaseAdmin.Auth.Jwt; using FirebaseAdmin.Auth.Tests; using FirebaseAdmin.Tests; using FirebaseAdmin.Util; @@ -27,6 +28,8 @@ using Google.Apis.Util; using Xunit; +#pragma warning disable SYSLIB0027 + namespace FirebaseAdmin.Auth.Jwt.Tests { public sealed class JwtTestUtils diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs index 630b688a..41ce2009 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs @@ -20,6 +20,7 @@ using System.Threading.Tasks; using Google.Apis.Auth.OAuth2; using Xunit; +#pragma warning disable SYSLIB0027 namespace FirebaseAdmin.Auth.Jwt.Tests { diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj index 974f2237..c17a0b9f 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj @@ -1,41 +1,41 @@  + + net7.0 + latest + false + ../../FirebaseAdmin.snk + true + true + ../../stylecop_test.ruleset + - - netcoreapp2.1 - latest - false - ../../FirebaseAdmin.snk - true - true - ../../stylecop_test.ruleset - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - - - - - - - - - PreserveNewest - - + + + + + + PreserveNewest + + diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt b/FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt new file mode 100644 index 00000000..233eb2b8 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt @@ -0,0 +1 @@ +1234,project,Appcheck \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin.sln b/FirebaseAdmin/FirebaseAdmin.sln index ab8b6e33..c2efda21 100644 --- a/FirebaseAdmin/FirebaseAdmin.sln +++ b/FirebaseAdmin/FirebaseAdmin.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34511.84 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FirebaseAdmin", "FirebaseAdmin\FirebaseAdmin.csproj", "{20D3B9D9-7461-441A-A798-6B124417F7A3}" EndProject diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs new file mode 100644 index 00000000..cad60e61 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using FirebaseAdmin.Util; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Google.Apis.Json; +using Google.Apis.Util; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Class that facilitates sending requests to the Firebase App Check backend API. + /// + internal sealed class AppCheckApiClient : IDisposable + { + private const string AppCheckUrlFormat = "https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken"; + private const string OneTimeUseTokenVerificationUrlFormat = "https://firebaseappcheck.googleapis.com/v1beta/projects/{projectId}:verifyAppCheckToken"; + + private readonly ErrorHandlingHttpClient httpClient; + private readonly string projectId; + + internal AppCheckApiClient(Args args) + { + if (string.IsNullOrEmpty(args.ProjectId)) + { + string noProjectId = "Project ID is required to access app check 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."; + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + noProjectId, + AppCheckErrorCode.InvalidArgument); + } + + this.httpClient = new ErrorHandlingHttpClient( + new ErrorHandlingHttpClientArgs() + { + HttpClientFactory = args.ClientFactory.ThrowIfNull(nameof(args.ClientFactory)), + Credential = args.Credential.ThrowIfNull(nameof(args.Credential)), + RequestExceptionHandler = AppCheckErrorHandler.Instance, + ErrorResponseHandler = AppCheckErrorHandler.Instance, + DeserializeExceptionHandler = AppCheckErrorHandler.Instance, + RetryOptions = args.RetryOptions, + }); + this.projectId = args.ProjectId; + } + + internal static string ClientVersion + { + get + { + return $"fire-admin-dotnet/{FirebaseApp.GetSdkVersion()}"; + } + } + + public void Dispose() + { + this.httpClient.Dispose(); + } + + /// + /// Exchange a signed custom token to App Check token. + /// + /// The custom token to be exchanged. + /// The mobile App ID. + /// A promise that fulfills with a `AppCheckToken`. + public async Task ExchangeTokenAsync(string customToken, string appId) + { + if (string.IsNullOrEmpty(customToken)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "customToken must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); + } + + if (string.IsNullOrEmpty(appId)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "appId must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); + } + + var body = new ExchangeTokenRequest() + { + CustomToken = customToken, + }; + + var url = this.GetUrl(appId); + var request = new HttpRequestMessage() + { + Method = HttpMethod.Post, + RequestUri = new Uri(url), + Content = NewtonsoftJsonSerializer.Instance.CreateJsonHttpContent(body), + }; + AddCommonHeaders(request); + + try + { + var response = await this.httpClient + .SendAndDeserializeAsync(request) + .ConfigureAwait(false); + + var appCheck = this.ToAppCheckToken(response.Result); + + return appCheck; + } + catch (HttpRequestException ex) + { + throw AppCheckErrorHandler.Instance.HandleHttpRequestException(ex); + } + } + + public async Task VerifyReplayProtectionAsync(string token) + { + if (string.IsNullOrEmpty(token)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`tokne` must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); + } + + var body = new VerifyTokenRequest() + { + AppCheckToken = token, + }; + + string url = this.GetVerifyTokenUrl(); + var request = new HttpRequestMessage() + { + Method = HttpMethod.Post, + RequestUri = new Uri(url), + Content = NewtonsoftJsonSerializer.Instance.CreateJsonHttpContent(body), + }; + AddCommonHeaders(request); + + bool ret = false; + + try + { + var response = await this.httpClient + .SendAndDeserializeAsync(request) + .ConfigureAwait(false); + + ret = response.Result.AlreadyConsumed; + } + catch (HttpRequestException e) + { + AppCheckErrorHandler.Instance.HandleHttpRequestException(e); + } + + return ret; + } + + internal static AppCheckApiClient Create(FirebaseApp app) + { + var args = new Args + { + ClientFactory = app.Options.HttpClientFactory, + Credential = app.Options.Credential, + ProjectId = app.Options.ProjectId, + RetryOptions = RetryOptions.Default, + }; + + return new AppCheckApiClient(args); + } + + private static void AddCommonHeaders(HttpRequestMessage request) + { + request.Headers.Add("X-Firebase-Client", ClientVersion); + } + + private AppCheckToken ToAppCheckToken(ExchangeTokenResponse resp) + { + if (resp == null || string.IsNullOrEmpty(resp.Token)) + { + throw new FirebaseAppCheckException( + ErrorCode.PermissionDenied, + "Token is not valid", + AppCheckErrorCode.AppCheckTokenExpired); + } + + if (string.IsNullOrEmpty(resp.Ttl) || !resp.Ttl.EndsWith("s")) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`ttl` must be a valid duration string with the suffix `s`.", + AppCheckErrorCode.InvalidArgument); + } + + return new AppCheckToken(resp.Token, this.StringToMilliseconds(resp.Ttl)); + } + + private string GetUrl(string appId) + { + if (string.IsNullOrEmpty(this.projectId)) + { + string errorMessage = "Failed to determine project ID. Initialize the SDK with service account " + + "credentials or set project ID as an app option. Alternatively, set the " + + "GOOGLE_CLOUD_PROJECT environment variable."; + + throw new FirebaseAppCheckException( + ErrorCode.Unknown, + errorMessage, + AppCheckErrorCode.UnknownError); + } + + var urlParams = new Dictionary + { + { "projectId", this.projectId }, + { "appId", appId }, + }; + + return StringUtils.ReplacePlaceholders(AppCheckUrlFormat, urlParams); + } + + private int StringToMilliseconds(string duration) + { + string modifiedString = duration.Remove(duration.Length - 1); + return int.Parse(modifiedString) * 1000; + } + + private string GetVerifyTokenUrl() + { + if (string.IsNullOrEmpty(this.projectId)) + { + string errorMessage = "Failed to determine project ID. Initialize the SDK with service account " + + "credentials or set project ID as an app option. Alternatively, set the " + + "GOOGLE_CLOUD_PROJECT environment variable."; + + throw new FirebaseAppCheckException( + ErrorCode.Unknown, + errorMessage, + AppCheckErrorCode.UnknownError); + } + + var urlParams = new Dictionary + { + { "projectId", this.projectId }, + }; + + return StringUtils.ReplacePlaceholders(OneTimeUseTokenVerificationUrlFormat, urlParams); + } + + internal sealed class Args + { + internal HttpClientFactory ClientFactory { get; set; } + + internal GoogleCredential Credential { get; set; } + + internal string ProjectId { get; set; } + + internal RetryOptions RetryOptions { get; set; } + } + + internal class ExchangeTokenRequest + { + [Newtonsoft.Json.JsonProperty("customToken")] + public string CustomToken { get; set; } + } + + internal class ExchangeTokenResponse + { + [Newtonsoft.Json.JsonProperty("token")] + public string Token { get; set; } + + [Newtonsoft.Json.JsonProperty("ttl")] + public string Ttl { get; set; } + } + + internal class VerifyTokenRequest + { + [Newtonsoft.Json.JsonProperty("appCheckToken")] + public string AppCheckToken { get; set; } + } + + internal class VerifyTokenResponse + { + [Newtonsoft.Json.JsonProperty("alreadyConsumed")] + public bool AlreadyConsumed { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs new file mode 100644 index 00000000..63c15eba --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Interface representing a decoded Firebase App Check token, returned from the {@link AppCheck.verifyToken} method.. + /// + public class AppCheckDecodedToken + { + internal AppCheckDecodedToken(Args args) + { + this.AppId = args.AppId; + this.Issuer = args.Issuer; + this.Subject = args.Subject; + this.Audience = args.Audience; + this.ExpirationTimeSeconds = (int)args.ExpirationTimeSeconds; + this.IssuedAtTimeSeconds = (int)args.IssuedAtTimeSeconds; + } + + /// + /// Gets or sets the issuer identifier for the issuer of the response. + /// + public string Issuer { get; set; } + + /// + /// Gets or sets the Firebase App ID corresponding to the app the token belonged to. + /// As a convenience, this value is copied over to the {@link AppCheckDecodedToken.app_id | app_id} property. + /// + public string Subject { get; set; } + + /// + /// Gets or sets the audience for which this token is intended. + /// This value is a JSON array of two strings, the first is the project number of your + /// Firebase project, and the second is the project ID of the same project. + /// + public string[] Audience { get; set; } + + /// + /// Gets or sets the App Check token's c time, in seconds since the Unix epoch. That is, the + /// time at which this App Check token expires and should no longer be considered valid. + /// + public int ExpirationTimeSeconds { get; set; } + + /// + /// Gets or sets the App Check token's issued-at time, in seconds since the Unix epoch. That is, the + /// time at which this App Check token was issued and should start to be considered valid. + /// + public int IssuedAtTimeSeconds { get; set; } + + /// + /// Gets or sets the App ID corresponding to the App the App Check token belonged to. + /// This value is not actually one of the JWT token claims. It is added as a + /// convenience, and is set as the value of the {@link AppCheckDecodedToken.sub | sub} property. + /// + public string AppId { get; set; } + + /// + /// Gets or sets key . + /// + public Dictionary Key { get; set; } + ////[key: string]: any; + + internal sealed class Args + { + public string AppId { get; internal set; } + + [JsonProperty("app_id")] + internal string Issuer { get; set; } + + [JsonProperty("sub")] + internal string Subject { get; set; } + + [JsonProperty("aud")] + internal string[] Audience { get; set; } + + [JsonProperty("exp")] + internal long ExpirationTimeSeconds { get; set; } + + [JsonProperty("iat")] + internal long IssuedAtTimeSeconds { get; set; } + + [JsonIgnore] + internal IReadOnlyDictionary Claims { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs new file mode 100644 index 00000000..32b6645b --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs @@ -0,0 +1,53 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Error codes that can be raised by the Firebase App Check APIs. + /// + public enum AppCheckErrorCode + { + /// + /// Process is aborted + /// + Aborted, + + /// + /// Argument is not valid + /// + InvalidArgument, + + /// + /// Credential is not valid + /// + InvalidCredential, + + /// + /// The server internal error + /// + InternalError, + + /// + /// Permission is denied + /// + PermissionDenied, + + /// + /// Unauthenticated + /// + Unauthenticated, + + /// + /// Resource is not found + /// + NotFound, + + /// + /// App Check Token is expired + /// + AppCheckTokenExpired, + + /// + /// Unknown Error + /// + UnknownError, + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs new file mode 100644 index 00000000..7fe1f7c0 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using FirebaseAdmin.Util; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Parses error responses received from the Auth service, and creates instances of + /// . + /// + internal sealed class AppCheckErrorHandler + : HttpErrorHandler, + IHttpRequestExceptionHandler, + IDeserializeExceptionHandler + { + internal static readonly AppCheckErrorHandler Instance = new AppCheckErrorHandler(); + + private static readonly IReadOnlyDictionary CodeToErrorInfo = + new Dictionary() + { + { + "ABORTED", + new ErrorInfo( + ErrorCode.Aborted, + AppCheckErrorCode.Aborted, + "App check is aborted") + }, + { + "INVALID_ARGUMENT", + new ErrorInfo( + ErrorCode.InvalidArgument, + AppCheckErrorCode.InvalidArgument, + "An argument is not valid") + }, + { + "INVALID_CREDENTIAL", + new ErrorInfo( + ErrorCode.InvalidArgument, + AppCheckErrorCode.InvalidCredential, + "The credential is not valid") + }, + { + "PERMISSION_DENIED", + new ErrorInfo( + ErrorCode.PermissionDenied, + AppCheckErrorCode.PermissionDenied, + "The permission is denied") + }, + { + "UNAUTHENTICATED", + new ErrorInfo( + ErrorCode.Unauthenticated, + AppCheckErrorCode.Unauthenticated, + "Unauthenticated") + }, + { + "NOT_FOUND", + new ErrorInfo( + ErrorCode.NotFound, + AppCheckErrorCode.NotFound, + "The resource is not found") + }, + { + "UNKNOWN", + new ErrorInfo( + ErrorCode.Unknown, + AppCheckErrorCode.UnknownError, + "unknown-error") + }, + }; + + private AppCheckErrorHandler() { } + + public FirebaseAppCheckException HandleHttpRequestException( + HttpRequestException exception) + { + var temp = exception.ToFirebaseException(); + return new FirebaseAppCheckException( + temp.ErrorCode, + temp.Message, + inner: temp.InnerException, + response: temp.HttpResponse); + } + + public FirebaseAppCheckException HandleDeserializeException( + Exception exception, ResponseInfo responseInfo) + { + return new FirebaseAppCheckException( + ErrorCode.Unknown, + $"Error while parsing AppCheck service response. Deserialization error: {responseInfo.Body}", + AppCheckErrorCode.UnknownError, + inner: exception, + response: responseInfo.HttpResponse); + } + + protected sealed override FirebaseExceptionArgs CreateExceptionArgs( + HttpResponseMessage response, string body) + { + var appCheckError = this.ParseAppCheckError(body); + + ErrorInfo info; + CodeToErrorInfo.TryGetValue(appCheckError.Code, out info); + + var defaults = base.CreateExceptionArgs(response, body); + return new FirebaseAppCheckExceptionArgs() + { + Code = info?.ErrorCode ?? defaults.Code, + Message = info?.GetMessage(appCheckError) ?? defaults.Message, + HttpResponse = response, + ResponseBody = body, + AppCheckErrorCode = info?.AppCheckErrorCode, + }; + } + + protected override FirebaseAppCheckException CreateException(FirebaseExceptionArgs args) + { + return new FirebaseAppCheckException( + args.Code, + args.Message, + (args as FirebaseAppCheckExceptionArgs).AppCheckErrorCode, + response: args.HttpResponse); + } + + private AppCheckError ParseAppCheckError(string body) + { + try + { + var parsed = NewtonsoftJsonSerializer.Instance.Deserialize(body); + return parsed.Error ?? new AppCheckError(); + } + catch + { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json body. + return new AppCheckError(); + } + } + + /// + /// Describes a class of errors that can be raised by the Firebase Auth backend API. + /// + private sealed class ErrorInfo + { + private readonly string message; + + internal ErrorInfo(ErrorCode code, AppCheckErrorCode appCheckErrorCode, string message) + { + this.ErrorCode = code; + this.AppCheckErrorCode = appCheckErrorCode; + this.message = message; + } + + internal ErrorCode ErrorCode { get; private set; } + + internal AppCheckErrorCode AppCheckErrorCode { get; private set; } + + internal string GetMessage(AppCheckError appCheckError) + { + var message = $"{this.message} ({appCheckError.Code})."; + if (!string.IsNullOrEmpty(appCheckError.Detail)) + { + return $"{message}: {appCheckError.Detail}"; + } + + return $"{message}"; + } + } + + private sealed class FirebaseAppCheckExceptionArgs : FirebaseExceptionArgs + { + internal AppCheckErrorCode? AppCheckErrorCode { get; set; } + } + + private sealed class AppCheckError + { + [JsonProperty("message")] + internal string Message { get; set; } + + /// + /// Gets the Firebase Auth error code extracted from the response. Returns empty string + /// if the error code cannot be determined. + /// + internal string Code + { + get + { + var separator = this.GetSeparator(); + if (separator != -1) + { + return this.Message.Substring(0, separator); + } + + return this.Message ?? string.Empty; + } + } + + /// + /// Gets the error detail sent by the Firebase Auth API. May be null. + /// + internal string Detail + { + get + { + var separator = this.GetSeparator(); + if (separator != -1) + { + return this.Message.Substring(separator + 1).Trim(); + } + + return null; + } + } + + private int GetSeparator() + { + return this.Message?.IndexOf(':') ?? -1; + } + } + + private sealed class AppCheckErrorResponse + { + [JsonProperty("error")] + internal AppCheckError Error { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs new file mode 100644 index 00000000..deb2abd6 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs @@ -0,0 +1,20 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Interface representing an App Check token. + /// + /// Generator from custom token. + /// TTl value . + public class AppCheckToken(string tokenValue, int ttlValue) + { + /// + /// Gets or sets the Firebase App Check token. + /// + public string Token { get; set; } = tokenValue; + + /// + /// Gets or sets the time-to-live duration of the token in milliseconds. + /// + public int TtlMillis { get; set; } = ttlValue; + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs new file mode 100644 index 00000000..c2f6db25 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs @@ -0,0 +1,161 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Auth; +using FirebaseAdmin.Auth.Jwt; +using FirebaseAdmin.Messaging.Util; +using Google.Apis.Auth; +using Google.Apis.Util; +using Newtonsoft.Json; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// A helper class that creates Firebase custom tokens. + /// + internal class AppCheckTokenFactory : IDisposable + { + public const string FirebaseAppCheckAudience = "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService"; + + public const int OneMinuteInSeconds = 60; + public const int OneMinuteInMills = OneMinuteInSeconds * 1000; + public const int OneDayInMills = 24 * 60 * 60 * 1000; + + public static readonly DateTime UnixEpoch = new DateTime( + 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + internal AppCheckTokenFactory(Args args) + { + args.ThrowIfNull(nameof(args)); + + this.Clock = args.Clock ?? SystemClock.Default; + this.IsEmulatorMode = args.IsEmulatorMode; + this.Signer = this.IsEmulatorMode ? + EmulatorSigner.Instance : args.Signer.ThrowIfNull(nameof(args.Signer)); + } + + internal ISigner Signer { get; } + + internal IClock Clock { get; } + + internal bool IsEmulatorMode { get; } + + public void Dispose() + { + this.Signer.Dispose(); + } + + /// + /// Creates a new custom token that can be exchanged to an App Check token. + /// + /// The mobile App ID. + /// Options for AppCheckToken with ttl. Possibly null. + /// A cancellation token to monitor the asynchronous. + /// A Promise fulfilled with a custom token signed with a service account key that can be exchanged to an App Check token. + public async Task CreateCustomTokenAsync( + string appId, + AppCheckTokenOptions options = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(appId)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`appId` must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); + } + + string customOptions = " "; + if (options != null) + { + customOptions = this.ValidateTokenOptions(options); + } + + var header = new JsonWebSignature.Header() + { + Algorithm = this.Signer.Algorithm, + Type = "JWT", + }; + + var issued = (int)(this.Clock.UtcNow - UnixEpoch).TotalSeconds; + var keyId = await this.Signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); + var payload = new CustomTokenPayload() + { + AppId = appId, + Issuer = keyId, + Subject = keyId, + Audience = FirebaseAppCheckAudience, + IssuedAtTimeSeconds = issued, + ExpirationTimeSeconds = issued + (OneMinuteInSeconds * 5), + Ttl = customOptions, + }; + + return await JwtUtils.CreateSignedJwtAsync( + header, payload, this.Signer).ConfigureAwait(false); + } + + internal static AppCheckTokenFactory Create(FirebaseApp app) + { + ISigner signer = null; + var serviceAccount = app.Options.Credential.ToServiceAccountCredential(); + if (serviceAccount != null) + { + signer = new ServiceAccountSigner(serviceAccount); + } + else if (string.IsNullOrEmpty(app.Options.ServiceAccountId)) + { + signer = IAMSigner.Create(app); + } + else + { + signer = FixedAccountIAMSigner.Create(app); + } + + var args = new Args + { + Signer = signer, + IsEmulatorMode = Utils.IsEmulatorModeFromEnvironment, + }; + return new AppCheckTokenFactory(args); + } + + private string ValidateTokenOptions(AppCheckTokenOptions options) + { + if (options == null) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "AppCheckTokenOptions must be a non-null object.", + AppCheckErrorCode.InvalidArgument); + } + + if (options.TtlMillis < (OneMinuteInMills * 30) || options.TtlMillis > (OneDayInMills * 7)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).", + AppCheckErrorCode.InvalidArgument); + } + + return TimeConverter.LongMillisToString(options.TtlMillis); + } + + internal class CustomTokenPayload : JsonWebToken.Payload + { + [JsonPropertyAttribute("app_id")] + public string AppId { get; set; } + + [JsonPropertyAttribute("ttl")] + public string Ttl { get; set; } + } + + internal sealed class Args + { + internal ISigner Signer { get; set; } + + internal IClock Clock { get; set; } + + internal bool IsEmulatorMode { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs new file mode 100644 index 00000000..2f4cd730 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs @@ -0,0 +1,18 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Representing App Check token options. + /// + /// + /// Initializes a new instance of the class. + /// + /// ttlMillis. + public class AppCheckTokenOptions(int ttl) + { + /// + /// Gets or sets the length of time, in milliseconds, for which the App Check token will + /// be valid. This value must be between 30 minutes and 7 days, inclusive. + /// + public int TtlMillis { get; set; } = ttl; + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs new file mode 100644 index 00000000..7c108a03 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Auth.Jwt; +using Google.Apis.Auth; +using Google.Apis.Util; + +namespace FirebaseAdmin.AppCheck +{ + internal class AppCheckTokenVerifier + { + private const string AppCheckIssuer = "https://firebaseappcheck.googleapis.com/"; + private const string JWKSURL = "https://firebaseappcheck.googleapis.com/v1/jwks"; + + private static readonly IReadOnlyList StandardClaims = + ImmutableList.Create("iss", "aud", "exp", "iat", "sub", "uid"); + + internal AppCheckTokenVerifier(Args args) + { + args.ThrowIfNull(nameof(args)); + this.ProjectId = args.ProjectId; + this.KeySource = args.KeySource.ThrowIfNull(nameof(args.KeySource)); + this.Clock = args.Clock ?? SystemClock.Default; + } + + internal IClock Clock { get; } + + internal string ProjectId { get; } + + internal IPublicKeySource KeySource { get; } + + /// + /// Verifies the format and signature of a Firebase App Check token. + /// + /// The Firebase Auth JWT token to verify. + /// A cancellation token to monitor the asynchronous operation. + /// A task that completes with a representing + /// a user with the specified user ID. + public async Task VerifyTokenAsync( + string token, CancellationToken cancellationToken = default(CancellationToken)) + { + if (string.IsNullOrEmpty(token)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "App Check token must not be null or empty.", + AppCheckErrorCode.InvalidArgument); + } + + if (string.IsNullOrEmpty(this.ProjectId)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "Must initialize app with a cert credential or set your Firebase project ID as the GOOGLE_CLOUD_PROJECT environment variable to verify an App Check token.", + AppCheckErrorCode.InvalidCredential); + } + + string[] segments = token.Split('.'); + if (segments.Length != 3) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "Incorrect number of segments in app check token.", + AppCheckErrorCode.InvalidArgument); + } + + var header = JwtUtils.Decode(segments[0]); + var payload = JwtUtils.Decode(segments[1]); + + var projectIdMessage = $"Incorrect number of segments in app check Token." + + "project as the credential used to initialize this SDK."; + var scopedProjectId = $"projects/{this.ProjectId}"; + string errorMessage = string.Empty; + + if (header.Algorithm != "RS256") + { + errorMessage = "The provided app check token has incorrect algorithm. Expected 'RS256'" + + " but got " + $"{header.Algorithm}" + "."; + } + else if (payload.Audience.Length > 0 || payload.Audience.Contains(scopedProjectId)) + { + errorMessage = "The provided app check token has incorrect \"aud\" (audience) claim. Expected " + + scopedProjectId + "but got" + payload.Audience + "." + projectIdMessage; + } + else if (payload.Issuer.StartsWith(AppCheckIssuer)) + { + errorMessage = $"The provided app check token has incorrect \"iss\" (issuer) claim."; + } + else if (payload.Subject == null) + { + errorMessage = "The provided app check token has no \"sub\" (subject) claim."; + } + else if (payload.Subject == string.Empty) + { + errorMessage = "The provided app check token has an empty string \"sub\" (subject) claim."; + } + + if (!string.IsNullOrEmpty(errorMessage)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + errorMessage, + AppCheckErrorCode.InvalidArgument); + } + + 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 AppCheckDecodedToken(payload); + } + + internal static AppCheckTokenVerifier Create(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + var projectId = app.GetProjectId(); + if (string.IsNullOrEmpty(projectId)) + { + throw new ArgumentException( + "Must initialize FirebaseApp with a project ID to verify session cookies."); + } + + IPublicKeySource keySource = new HttpPublicKeySource( + JWKSURL, SystemClock.Default, app.Options.HttpClientFactory); + + var args = new Args + { + ProjectId = projectId, + KeySource = keySource, + }; + return new AppCheckTokenVerifier(args); + } + + /// + /// 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). + /// + private async Task VerifySignatureAsync( + string[] segments, string keyId, CancellationToken cancellationToken) + { + byte[] hash; + using (var hashAlg = SHA256.Create()) + { + hash = hashAlg.ComputeHash( + Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); + } + + var signature = JwtUtils.Base64DecodeToBytes(segments[2]); + var keys = await this.KeySource.GetPublicKeysAsync(cancellationToken) + .ConfigureAwait(false); + var verified = keys.Any(key => + key.Id == keyId && key.RSA.VerifyHash( + hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + if (!verified) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "Failed to verify app check signature.", + AppCheckErrorCode.InvalidCredential); + } + } + + internal sealed class Args + { + internal IClock Clock { get; set; } + + internal string ProjectId { get; set; } + + internal IPublicKeySource KeySource { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs new file mode 100644 index 00000000..5daafc96 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs @@ -0,0 +1,26 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Class representing options for the AppCheck.VerifyToken method. + /// + public class AppCheckVerifyTokenOptions + { + /// + /// Gets or sets a value indicating whether to use the replay protection feature, set this to true. The AppCheck.VerifyToken + /// method will mark the token as consumed after verifying it. + /// + /// Tokens that are found to be already consumed will be marked as such in the response. + /// + /// Tokens are only considered to be consumed if it is sent to App Check backend by calling the + /// AppCheck.VerifyToken method with this field set to true; other uses of the token + /// do not consume it. + /// + /// This replay protection feature requires an additional network call to the App Check backend + /// and forces your clients to obtain a fresh attestation from your chosen attestation providers. + /// This can therefore negatively impact performance and can potentially deplete your attestation + /// providers' quotas faster. We recommend that you use this feature only for protecting + /// low volume, security critical, or expensive operations. + /// + public bool Consume { get; set; } = false; + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs new file mode 100644 index 00000000..397caba5 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs @@ -0,0 +1,23 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Interface representing a verified App Check token response. + /// + public class AppCheckVerifyTokenResponse + { + /// + /// Gets or sets App ID corresponding to the App the App Check token belonged to. + /// + public string AppId { get; set; } + + /// + /// Gets or sets decoded Firebase App Check token. + /// + public AppCheckDecodedToken Token { get; set; } + + /// + /// Gets or sets a value indicating whether already conumed. + /// + public bool AlreadyConsumed { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs new file mode 100644 index 00000000..1a61b1f9 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Asynchronously creates a new Firebase App Check token for the specified Firebase app. + /// + /// A task that completes with the creation of a new App Check token. + /// Thrown if an error occurs while creating the custom token. + /// The Firebase app instance. + public sealed class FirebaseAppCheck : IFirebaseService + { + private readonly AppCheckApiClient appCheckApiClient; + private readonly AppCheckTokenFactory appCheckTokenFactory; + private readonly AppCheckTokenVerifier appCheckTokenVerifier; + + /// + /// Initializes a new instance of the class. + /// + /// Initailize FirebaseApp. + public FirebaseAppCheck(FirebaseApp app) + { + this.appCheckApiClient = AppCheckApiClient.Create(app); + this.appCheckTokenFactory = AppCheckTokenFactory.Create(app); + this.appCheckTokenVerifier = AppCheckTokenVerifier.Create(app); + } + + /// + /// Gets the messaging instance associated with the default Firebase app. This property is + /// null if the default app doesn't yet exist. + /// + public static FirebaseAppCheck DefaultInstance + { + get + { + var app = FirebaseApp.DefaultInstance; + if (app == null) + { + return null; + } + + return GetAppCheck(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 FirebaseAppCheck GetAppCheck(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException("App argument must not be null."); + } + + return app.GetOrInit(typeof(FirebaseAppCheck).Name, () => + { + return new FirebaseAppCheck(app); + }); + } + + /// + /// Creates a new AppCheckToken that can be sent back to a client. + /// + /// The app ID to use as the JWT app_id. + /// Optional options object when creating a new App Check Token. + /// A A promise that fulfills with a `AppCheckToken`. + public async Task CreateTokenAsync(string appId, AppCheckTokenOptions options = null) + { + string customToken = await this.appCheckTokenFactory.CreateCustomTokenAsync(appId, options, default(CancellationToken)).ConfigureAwait(false); + + return await this.appCheckApiClient.ExchangeTokenAsync(customToken, appId).ConfigureAwait(false); + } + + /// + /// Verifies a Firebase App Check token (JWT). If the token is valid, the promise is + /// fulfilled with the token's decoded claims; otherwise, the promise is + /// rejected. + /// + /// TThe App Check token to verify. + /// Optional {@link VerifyAppCheckTokenOptions} object when verifying an App Check Token. + /// A A promise fulfilled with the token's decoded claims if the App Check token is valid; otherwise, a rejected promise. + public async Task VerifyTokenAsync(string appCheckToken, AppCheckVerifyTokenOptions options = null) + { + if (string.IsNullOrEmpty(appCheckToken)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + $"App check token {appCheckToken} must be a non - empty string.", + AppCheckErrorCode.InvalidArgument); + } + + AppCheckDecodedToken decodedToken = await this.appCheckTokenVerifier.VerifyTokenAsync(appCheckToken).ConfigureAwait(false); + + if (options.Consume) + { + bool alreadyConsumed = await this.appCheckApiClient.VerifyReplayProtectionAsync(appCheckToken).ConfigureAwait(false); + + return new AppCheckVerifyTokenResponse() + { + AppId = decodedToken.AppId, + AlreadyConsumed = alreadyConsumed, + Token = decodedToken, + }; + } + + return new AppCheckVerifyTokenResponse() + { + AppId = decodedToken.AppId, + Token = decodedToken, + }; + } + + /// + /// Deletes this service instance. + /// + void IFirebaseService.Delete() + { + this.appCheckApiClient.Dispose(); + this.appCheckTokenFactory.Dispose(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs new file mode 100644 index 00000000..55127d66 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs @@ -0,0 +1,27 @@ +using System; +using System.Net.Http; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Exception type raised by Firebase AppCheck APIs. + /// + public sealed class FirebaseAppCheckException : FirebaseException + { + internal FirebaseAppCheckException( + ErrorCode code, + string message, + AppCheckErrorCode? fcmCode = null, + Exception inner = null, + HttpResponseMessage response = null) + : base(code, message, inner, response) + { + this.AppCheckErrorCode = fcmCode; + } + + /// + /// Gets the Firease AppCheck error code associated with this exception. May be null. + /// + public AppCheckErrorCode? AppCheckErrorCode { get; private set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs index 101ea38c..fa33bf65 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -80,6 +81,15 @@ internal FirebaseToken(Args args) /// public IReadOnlyDictionary Claims { get; } + /// + /// Defined operator string. + /// + /// FirebaseToken. + public static implicit operator string(FirebaseToken v) + { + return v.Uid; + } + internal sealed class Args { [JsonProperty("iss")] diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs index 8d7dcac0..45070c8b 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs @@ -39,6 +39,8 @@ internal class FirebaseTokenFactory : IDisposable + "google.identity.identitytoolkit.v1.IdentityToolkit"; public const int TokenDurationSeconds = 3600; + public const int OneMinuteInSeconds = 60; + public const int OneDayInMillis = 24 * 60 * 60 * 1000; public static readonly DateTime UnixEpoch = new DateTime( 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -58,7 +60,8 @@ internal class FirebaseTokenFactory : IDisposable "jti", "nbf", "nonce", - "sub"); + "sub", + "app_id"); internal FirebaseTokenFactory(Args args) { @@ -178,9 +181,15 @@ internal class CustomTokenPayload : JsonWebToken.Payload [JsonPropertyAttribute("uid")] public string Uid { get; set; } + [JsonPropertyAttribute("app_id")] + public string AppId { get; set; } + [JsonPropertyAttribute("tenant_id")] public string TenantId { get; set; } + [JsonPropertyAttribute("ttl")] + public string Ttl { get; set; } + [JsonPropertyAttribute("claims")] public IDictionary Claims { get; set; } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs index b7786c94..fce692f0 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Net; using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Text; @@ -23,6 +24,8 @@ using FirebaseAdmin.Util; using Google.Apis.Http; using Google.Apis.Util; +using Newtonsoft.Json; +using static Google.Apis.Requests.BatchRequest; using RSAKey = System.Security.Cryptography.RSA; namespace FirebaseAdmin.Auth.Jwt @@ -74,11 +77,15 @@ public async Task> GetPublicKeysAsync( Method = HttpMethod.Get, RequestUri = new Uri(this.certUrl), }; + var response = await httpClient .SendAndDeserializeAsync>(request, cancellationToken) .ConfigureAwait(false); + if (this.certUrl != "https://firebaseappcheck.googleapis.com/v1/jwks") + { + this.cachedKeys = this.ParseKeys(response); + } - this.cachedKeys = this.ParseKeys(response); var cacheControl = response.HttpResponse.Headers.CacheControl; if (cacheControl?.MaxAge != null) { diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs index b601af26..9387cc3a 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs @@ -13,9 +13,15 @@ // limitations under the License. using System; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, " + + "PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93" + + "bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113b" + + "e11e6a7d3113e92484cf7045cc7")] + namespace FirebaseAdmin.Auth.Jwt { /// diff --git a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs index 8b57c7ec..55419bdc 100644 --- a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs @@ -15,6 +15,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Text.RegularExpressions; using Google.Apis.Json; using Newtonsoft.Json.Linq; diff --git a/FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs b/FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs new file mode 100644 index 00000000..fc626ee7 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace FirebaseAdmin.Util +{ + internal class StringUtils + { + public static string ReplacePlaceholders(string str, Dictionary urlParams) + { + string formatted = str; + foreach (var key in urlParams.Keys) + { + formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + } + + return formatted; + } + } +}