diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs index c6f0b584..9ab0ecdf 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs @@ -1,211 +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.Check; +using FirebaseAdmin.Messaging; +using FirebaseAdmin.Tests; +using FirebaseAdmin.Tests.AppCheck; +using FirebaseAdmin.Util; using Google.Apis.Auth.OAuth2; -using Microsoft.VisualStudio.TestPlatform.ObjectModel; -using Moq; +using Google.Apis.Http; +using Newtonsoft.Json; using Xunit; -namespace FirebaseAdmin.Tests.AppCheck +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"; - private readonly string testTokenToExchange = "signed-custom-token"; - private readonly string noProjectId = "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."; [Fact] - public void CreateInvalidApp() + public void NoProjectId() { - Assert.Throws(() => new AppCheckApiClient(null)); - } + var args = new AppCheckApiClient.Args() + { + ClientFactory = new HttpClientFactory(), + Credential = null, + }; - [Fact] - public async Task ExchangeTokenNoProjectId() - { - var appCheckApiClient = new Mock(); + args.ProjectId = null; + Assert.Throws(() => new AppCheckApiClient(args)); - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException(this.noProjectId)); - var result = await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); - Assert.Equal(this.noProjectId, result.Message); + args.ProjectId = string.Empty; + Assert.Throws(() => new AppCheckApiClient(args)); } [Fact] - public async Task ExchangeTokenInvalidAppId() + public void NoCredential() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, string.Empty)); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, null)); + var args = new AppCheckApiClient.Args() + { + ClientFactory = new HttpClientFactory(), + Credential = null, + ProjectId = "test-project", + }; + + Assert.Throws(() => new AppCheckApiClient(args)); } [Fact] - public async Task ExchangeTokenInvalidCustomTokenAsync() + public void NoClientFactory() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(string.Empty, this.appId)); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(null, this.appId)); + var args = new AppCheckApiClient.Args() + { + ClientFactory = null, + Credential = MockCredential, + ProjectId = "test-project", + }; + + Assert.Throws(() => new AppCheckApiClient(args)); } [Fact] - public async Task ExchangeTokenFullErrorResponseAsync() + public async Task ExchangeToken() { - var appCheckApiClient = new Mock(); + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException("not-found", "Requested entity not found")); + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); - } + string customToken = "test-token"; - [Fact] - public async Task ExchangeTokenErrorCodeAsync() - { - var appCheckApiClient = new Mock(); + var response = await client.ExchangeTokenAsync(customToken, this.appId); - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException("unknown-error", "Unknown server error: {}")); + Assert.Equal("test-token", response.Token); + Assert.Equal(36000000, response.TtlMillis); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + 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 ExchangeTokenFullNonJsonAsync() + public async Task ExchangeTokenWithEmptyAppId() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException("unknown-error", "Unexpected response with status: 404 and body: not json")); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + 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 ExchangeTokenAppErrorAsync() + public async Task ExchangeTokenWithNullAppId() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException("network-error", "socket hang up")); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.ExchangeTokenAsync(string.Empty, this.appId)); + 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 ExchangeTokenOnSuccessAsync() + public async Task ExchangeTokenWithEmptyCustomToken() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AppCheckToken("token", 3000)); - - var result = await appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId).ConfigureAwait(false); - Assert.NotNull(result); - Assert.Equal("token", result.Token); - Assert.Equal(3000, result.TtlMillis); + 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 VerifyReplayNoProjectIdAsync() + public async Task ExchangeTokenWithNullCustomToken() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + 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 VerifyReplayInvaildTokenAsync() + public async Task ExchangeTokenWithErrorNoTtlResponse() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.VerifyReplayProtection(string.Empty)); + 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 VerifyReplayFullErrorAsync() + public async Task ExchangeTokenWithErrorNoTokenResponse() { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException("not-found", "Requested entity not found")); - - await Assert.ThrowsAsync(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + 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 VerifyReplayErrorCodeAsync() + public async Task VerifyReplayProtectionWithTrue() { - var appCheckApiClient = new Mock(); + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.VerifyTokenResponse() + { + AlreadyConsumed = true, + }, + }; - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException("unknown-error", "Unknown server error: {}")); + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); - } + string customToken = "test-token"; - [Fact] - public async Task VerifyReplayNonJsonAsync() - { - var appCheckApiClient = new Mock(); + var response = await client.VerifyReplayProtectionAsync(customToken); - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException("unknown-error", "Unexpected response with status: 404 and body: not json")); + Assert.True(response); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + var req = JsonConvert.DeserializeObject(handler.LastRequestBody); + Assert.Equal(1, handler.Calls); + this.CheckHeaders(handler.LastRequestHeaders); } [Fact] - public async Task VerifyReplayFirebaseAppErrorAsync() + public async Task VerifyReplayProtectionWithFalse() { - var appCheckApiClient = new Mock(); + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.VerifyTokenResponse() + { + AlreadyConsumed = false, + }, + }; - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException("network-error", "socket hang up")); + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); - await Assert.ThrowsAsync(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); - } + string customToken = "test-token"; - [Fact] - public async Task VerifyReplayAlreadyTrueAsync() - { - var appCheckApiClient = new Mock(); + var response = await client.VerifyReplayProtectionAsync(customToken); - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .ReturnsAsync(true); + Assert.False(response); - bool res = await appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange).ConfigureAwait(false); - Assert.True(res); + var req = JsonConvert.DeserializeObject(handler.LastRequestBody); + Assert.Equal(1, handler.Calls); + this.CheckHeaders(handler.LastRequestHeaders); } - [Fact] - public async Task VerifyReplayAlreadyFlaseAsync() + private AppCheckApiClient CreateAppCheckApiClient(HttpClientFactory factory) { - var appCheckApiClient = new Mock(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .ReturnsAsync(true); + return new AppCheckApiClient(new AppCheckApiClient.Args() + { + ClientFactory = factory, + Credential = MockCredential, + ProjectId = "test-project", + RetryOptions = RetryOptions.NoBackOff, + }); + } - bool res = await appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange).ConfigureAwait(false); - Assert.True(res); + 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/AppCheckTokenGeneratorTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs deleted file mode 100644 index 96712720..00000000 --- a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth.Jwt; -using FirebaseAdmin.Check; -using Google.Apis.Auth.OAuth2; -using Moq; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace FirebaseAdmin.Tests.AppCheck -{ - public class AppCheckTokenGeneratorTest - { - 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 GoogleCredential MockCredential = - GoogleCredential.FromAccessToken("test-token"); - - private readonly string appId = "test-app-id"; - - [Fact] - public void ProjectIdFromOptions() - { - var app = FirebaseApp.Create(new AppOptions() - { - Credential = MockCredential, - ProjectId = "explicit-project-id1", - }); - var verifier = AppCheckTokenVerify.Create(app); - Assert.Equal("explicit-project-id1", verifier.ProjectId); - } - - [Fact] - public void ProjectIdFromServiceAccount() - { - var app = FirebaseApp.Create(new AppOptions() - { - Credential = GoogleCredential.FromFile("./resources/service_account.json"), - }); - var verifier = AppCheckTokenVerify.Create(app); - Assert.Equal("test-project", verifier.ProjectId); - } - - [Fact] - public async Task InvalidAppId() - { - var options = new AppOptions() - { - Credential = GoogleCredential.FromAccessToken("token"), - }; - var app = FirebaseApp.Create(options, "123"); - - AppCheckTokenGenerator tokenGenerator = AppCheckTokenGenerator.Create(app); - await Assert.ThrowsAsync(() => tokenGenerator.CreateCustomTokenAsync(string.Empty)); - await Assert.ThrowsAsync(() => tokenGenerator.CreateCustomTokenAsync(null)); - } - - [Fact] - public async Task InvalidOptions() - { - var options = new AppOptions() - { - Credential = GoogleCredential.FromAccessToken("token"), - }; - var app = FirebaseApp.Create(options, "1234"); - var tokenGernerator = AppCheckTokenGenerator.Create(app); - int[] ttls = new int[] { -100, -1, 0, 10, 1799999, 604800001, 1209600000 }; - foreach (var ttl in ttls) - { - var option = new AppCheckTokenOptions(ttl); - - var result = await Assert.ThrowsAsync(() => - tokenGernerator.CreateCustomTokenAsync(this.appId, option)); - } - } - - [Fact] - public void ValidOptions() - { - var options = new AppOptions() - { - Credential = GoogleCredential.FromAccessToken("token"), - }; - var app = FirebaseApp.Create(options, "12356"); - var tokenGernerator = AppCheckTokenGenerator.Create(app); - int[] ttls = new int[] { ThirtyMinInMs, ThirtyMinInMs + 1, SevenDaysInMs / 2, SevenDaysInMs - 1, SevenDaysInMs }; - foreach (var ttl in ttls) - { - var option = new AppCheckTokenOptions(ttl); - - var result = tokenGernerator.CreateCustomTokenAsync(this.appId, option); - Assert.NotNull(result); - } - } - - [Fact] - public void Dispose() - { - FirebaseApp.DeleteAll(); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs index 80bc34c9..92abfd93 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs @@ -1,17 +1,15 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using FirebaseAdmin.Auth.Jwt; using FirebaseAdmin.Auth.Jwt.Tests; -using FirebaseAdmin.Check; -using Google.Apis.Auth.OAuth2; -using Moq; +using Google.Apis.Auth; +using Google.Apis.Util; +using Newtonsoft.Json; using Xunit; -namespace FirebaseAdmin.Tests.AppCheck +namespace FirebaseAdmin.AppCheck.Tests { public class AppCheckTokenVerifierTest { @@ -21,118 +19,168 @@ public class AppCheckTokenVerifierTest new object[] { string.Empty }, }; - private static readonly GoogleCredential MockCredential = - GoogleCredential.FromAccessToken("test-token"); - - [Fact] - public void ProjectIdFromOptions() + public static readonly IEnumerable InvalidTokens = new List { - var app = FirebaseApp.Create(new AppOptions() - { - Credential = MockCredential, - ProjectId = "explicit-project-id", - }); - var verifier = AppCheckTokenVerify.Create(app); - Assert.Equal("explicit-project-id", verifier.ProjectId); - } + new object[] { "TestToken" }, + new object[] { "Test.Token" }, + new object[] { "Test.Token.Test.Token" }, + }; - [Fact] - public void ProjectIdFromServiceAccount() + public static readonly IEnumerable InvalidAudiences = new List { - var app = FirebaseApp.Create(new AppOptions() - { - Credential = GoogleCredential.FromFile("./resources/service_account.json"), - }); - var verifier = AppCheckTokenVerify.Create(app); - Assert.Equal("test-project", verifier.ProjectId); - } + new object[] { new List { "incorrectAudience" } }, + new object[] { new List { "12345678", "project_id" } }, + new object[] { new List { "projects/" + "12345678", "project_id" } }, + }; - [Theory] - [MemberData(nameof(InvalidStrings))] - public void InvalidProjectId(string projectId) + private readonly string appId = "1:1234:android:1234"; + + [Fact] + public void NullKeySource() { var args = FullyPopulatedArgs(); - args.ProjectId = projectId; + args.KeySource = null; - Assert.Throws(() => new AppCheckTokenVerify(args)); + Assert.Throws(() => new AppCheckTokenVerifier(args)); } [Fact] - public void NullKeySource() + public void ProjectId() { var args = FullyPopulatedArgs(); - args.PublicKeySource = null; - Assert.Throws(() => new AppCheckTokenVerify(args)); + var verifier = new AppCheckTokenVerifier(args); + + Assert.Equal("test-project", verifier.ProjectId); } [Theory] [MemberData(nameof(InvalidStrings))] - public void InvalidShortName(string shortName) + public async Task VerifyWithNullEmptyToken(string token) { var args = FullyPopulatedArgs(); - args.ShortName = shortName; + var tokenVerifier = new AppCheckTokenVerifier(args); + + var ex = await Assert.ThrowsAsync( + async () => await tokenVerifier.VerifyTokenAsync(token)); - Assert.Throws(() => new AppCheckTokenVerify(args)); + 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 void InvalidIssuer(string issuer) + public async Task VerifyWithInvalidProjectId(string projectId) { var args = FullyPopulatedArgs(); - args.Issuer = issuer; + args.ProjectId = projectId; + var tokenVerifier = new AppCheckTokenVerifier(args); + + string token = "test-token"; + + var ex = await Assert.ThrowsAsync( + async () => await tokenVerifier.VerifyTokenAsync(token)); - Assert.Throws(() => new AppCheckTokenVerify(args)); + 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(InvalidStrings))] - public void InvalidOperation(string operation) + [MemberData(nameof(InvalidTokens))] + public async Task VerifyWithInvalidToken(string token) { var args = FullyPopulatedArgs(); - args.Operation = operation; + var tokenVerifier = new AppCheckTokenVerifier(args); + + var ex = await Assert.ThrowsAsync( + async () => await tokenVerifier.VerifyTokenAsync(token)); - Assert.Throws(() => new AppCheckTokenVerify(args)); + 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(InvalidStrings))] - public void InvalidUrl(string url) + [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(); - args.Url = url; - - Assert.Throws(() => new AppCheckTokenVerify(args)); + AppCheckTokenVerifier verifier = new AppCheckTokenVerifier(args); + var result = await Assert.ThrowsAsync(() => verifier.VerifyTokenAsync(token)).ConfigureAwait(false); + Assert.Contains(expected, result.Message); } [Fact] - public void ProjectId() + public async Task CheckEmptyAudience() { + string token = await this.GeneratorAppCheckTokenAsync([]).ConfigureAwait(false); var args = FullyPopulatedArgs(); - - var verifier = new AppCheckTokenVerify(args); - - Assert.Equal("test-project", verifier.ProjectId); + 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 void Dispose() + public async Task VerifyToken() { - FirebaseApp.DeleteAll(); + 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 FirebaseTokenVerifierArgs FullyPopulatedArgs() + private static AppCheckTokenVerifier.Args FullyPopulatedArgs() { - return new FirebaseTokenVerifierArgs + return new AppCheckTokenVerifier.Args { ProjectId = "test-project", - ShortName = "short name", - Operation = "VerifyToken()", - Url = "https://firebase.google.com", - Issuer = "https://firebase.google.com/", - PublicKeySource = JwtTestUtils.DefaultKeySource, + 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/FirebaseAppCheckTests.cs b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs deleted file mode 100644 index 7ffa46a0..00000000 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Reflection.Metadata.Ecma335; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin; -using FirebaseAdmin.Auth; -using FirebaseAdmin.Auth.Jwt; -using FirebaseAdmin.Auth.Tests; -using FirebaseAdmin.Check; -using Google.Apis.Auth.OAuth2; -using Moq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; - -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, " + - "PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93" + - "bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113b" + - "e11e6a7d3113e92484cf7045cc7")] - -namespace FirebaseAdmin.Tests -{ - public class FirebaseAppCheckTests : IDisposable - { - private readonly string appId = "1:1234:android:1234"; - private FirebaseApp mockCredentialApp; - - public FirebaseAppCheckTests() - { - var credential = GoogleCredential.FromFile("./resources/service_account.json"); - var options = new AppOptions() - { - Credential = credential, - }; - this.mockCredentialApp = FirebaseApp.Create(options); - } - - [Fact] - public void CreateInvalidApp() - { - Assert.Throws(() => FirebaseAppCheck.Create(null)); - } - - [Fact] - public void CreateAppCheck() - { - FirebaseAppCheck withoutAppIdCreate = FirebaseAppCheck.Create(this.mockCredentialApp); - Assert.NotNull(withoutAppIdCreate); - } - - [Fact] - public void WithoutProjectIDCreate() - { - // Project ID not set in the environment. - Environment.SetEnvironmentVariable("GOOGLE_CLOUD_PROJECT", null); - Environment.SetEnvironmentVariable("GCLOUD_PROJECT", null); - - var options = new AppOptions() - { - Credential = GoogleCredential.FromAccessToken("token"), - }; - var app = FirebaseApp.Create(options, "1234"); - - Assert.Throws(() => FirebaseAppCheck.Create(app)); - } - - [Fact] - public void FailedSignCreateToken() - { - string expected = "sign error"; - var createTokenMock = new Mock(); - - // Setup the mock to throw an exception when SignDataAsync is called - createTokenMock.Setup(service => service.SignDataAsync(It.IsAny(), It.IsAny())) - .Throws(new ArgumentException(expected)); - - var options = new AppOptions() - { - Credential = GoogleCredential.FromAccessToken("token"), - }; - var app = FirebaseApp.Create(options, "4321"); - - Assert.Throws(() => FirebaseAppCheck.Create(app)); - } - - [Fact] - public async Task CreateTokenApiError() - { - var createTokenMock = new Mock(); - - createTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())).Throws(new ArgumentException("INTERAL_ERROR")); - - FirebaseAppCheck createTokenFromAppId = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync(() => createTokenFromAppId.CreateToken(this.appId)); - } - - [Fact] - public async Task CreateTokenApiErrorOptions() - { - var createTokenMock = new Mock(); - - createTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())).Throws(new ArgumentException("INTERAL_ERROR")); - - AppCheckTokenOptions options = new (1800000); - FirebaseAppCheck createTokenFromAppIdAndTtlMillis = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync(() => createTokenFromAppIdAndTtlMillis.CreateToken(this.appId)); - } - - [Fact] - public async Task CreateTokenAppCheckTokenSuccess() - { - string createdCustomToken = "custom-token"; - - AppCheckTokenGenerator tokenFactory = AppCheckTokenGenerator.Create(this.mockCredentialApp); - - var createCustomTokenMock = new Mock(); - - createCustomTokenMock.Setup(service => service.CreateCustomTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(createdCustomToken); - - var customRes = await createCustomTokenMock.Object.CreateCustomTokenAsync(this.appId).ConfigureAwait(false); - Assert.Equal(createdCustomToken, customRes); - - AppCheckToken expected = new ("token", 3000); - var createExchangeTokenMock = new Mock(); - createExchangeTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(expected); - - AppCheckTokenOptions options = new (3000); - - AppCheckToken res = await createExchangeTokenMock.Object.ExchangeTokenAsync("custom-token", this.appId).ConfigureAwait(false); - Assert.Equal("token", res.Token); - Assert.Equal(3000, res.TtlMillis); - } - - [Fact] - public async Task VerifyTokenApiError() - { - var createTokenMock = new Mock(); - createTokenMock.Setup(service => service.VerifyReplayProtection(It.IsAny())) - .Throws(new ArgumentException("INTERAL_ERROR")); - - FirebaseAppCheck verifyToken = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync(() => createTokenMock.Object.VerifyReplayProtection("token")); - } - - [Fact] - public async Task VerifyTokenSuccess() - { - // Create an instance of FirebaseToken.Args and set its properties. - var args = new FirebaseToken.Args - { - AppId = "1234", - Issuer = "issuer", - Subject = "subject", - Audience = "audience", - ExpirationTimeSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 3600, // 1 hour from now - IssuedAtTimeSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - }; - FirebaseToken mockFirebaseToken = new FirebaseToken(args); - - var verifyTokenMock = new Mock(); - verifyTokenMock.Setup(service => service.VerifyTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(mockFirebaseToken); - - var verifyRes = await verifyTokenMock.Object.VerifyTokenAsync(this.appId).ConfigureAwait(false); - - Assert.Equal(verifyRes.AppId, mockFirebaseToken.AppId); - Assert.Equal(verifyRes.Issuer, mockFirebaseToken.Issuer); - Assert.Equal(verifyRes.Subject, mockFirebaseToken.Subject); - Assert.Equal(verifyRes.Audience, mockFirebaseToken.Audience); - Assert.Equal(verifyRes.ExpirationTimeSeconds, mockFirebaseToken.ExpirationTimeSeconds); - Assert.Equal(verifyRes.IssuedAtTimeSeconds, mockFirebaseToken.IssuedAtTimeSeconds); - } - - [Fact] - public async Task VerifyTokenInvaild() - { - FirebaseAppCheck verifyTokenInvaild = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync(() => verifyTokenInvaild.VerifyToken(null)); - await Assert.ThrowsAsync(() => verifyTokenInvaild.VerifyToken(string.Empty)); - } - - public void Dispose() - { - FirebaseAppCheck.DeleteAll(); - } - } -} 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 index 13e039b4..7b8c3446 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs @@ -1,156 +1,213 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; using System.Threading.Tasks; -using FirebaseAdmin.Auth; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using static Google.Apis.Requests.BatchRequest; +using FirebaseAdmin.Util; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Google.Apis.Json; +using Google.Apis.Util; -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.AppCheck { /// /// Class that facilitates sending requests to the Firebase App Check backend API. /// - /// 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. - internal class AppCheckApiClient + internal sealed class AppCheckApiClient : IDisposable { - private const string ApiUrlFormat = "https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken"; + 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 FirebaseApp app; - private string projectId; - /// - /// Initializes a new instance of the class. - /// - /// Initailize FirebaseApp. - public AppCheckApiClient(FirebaseApp value) + private readonly ErrorHandlingHttpClient httpClient; + private readonly string projectId; + + internal AppCheckApiClient(Args args) + { + 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."; + if (string.IsNullOrEmpty(args.ProjectId)) + { + 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 { - if (value == null || value.Options == null) + get { - throw new ArgumentException("Argument passed to admin.appCheck() must be a valid Firebase app instance."); + return $"fire-admin-dotnet/{FirebaseApp.GetSdkVersion()}"; } + } - this.app = value; - this.projectId = this.app.Options.ProjectId; + 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 A promise that fulfills with a `AppCheckToken`. + /// 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 ArgumentNullException("First argument passed to customToken must be a valid Firebase app instance."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "customToken must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } if (string.IsNullOrEmpty(appId)) { - throw new ArgumentNullException("Second argument passed to appId must be a valid Firebase app instance."); + 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 content = new StringContent(JsonConvert.SerializeObject(new { customToken }), Encoding.UTF8, "application/json"); var request = new HttpRequestMessage() { Method = HttpMethod.Post, RequestUri = new Uri(url), - Content = content, + Content = NewtonsoftJsonSerializer.Instance.CreateJsonHttpContent(body), }; - request.Headers.Add("X-Firebase-Client", "fire-admin-node/" + $"{FirebaseApp.GetSdkVersion()}"); - var httpClient = new HttpClient(); - var response = await httpClient.SendAsync(request).ConfigureAwait(false); - if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) + AddCommonHeaders(request); + + try { - var errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new InvalidOperationException($"BadRequest: {errorContent}"); + var response = await this.httpClient + .SendAndDeserializeAsync(request) + .ConfigureAwait(false); + + var appCheck = this.ToAppCheckToken(response.Result); + + return appCheck; } - else if (!response.IsSuccessStatusCode) + catch (HttpRequestException ex) { - throw new HttpRequestException("network error"); + throw AppCheckErrorHandler.Instance.HandleHttpRequestException(ex); } - - JObject responseData = JObject.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - string tokenValue = responseData["data"]["token"].ToString(); - int ttlValue = this.StringToMilliseconds(responseData["data"]["ttl"].ToString()); - AppCheckToken appCheckToken = new (tokenValue, ttlValue); - return appCheckToken; } - /// - /// Exchange a signed custom token to App Check token. - /// - /// The custom token to be exchanged. - /// A alreadyConsumed is true. - public async Task VerifyReplayProtection(string token) + public async Task VerifyReplayProtectionAsync(string token) { if (string.IsNullOrEmpty(token)) { - throw new ArgumentException("invalid-argument", "`token` must be a non-empty string."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`tokne` must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } - string url = this.GetVerifyTokenUrl(); + var body = new VerifyTokenRequest() + { + AppCheckToken = token, + }; + string url = this.GetVerifyTokenUrl(); var request = new HttpRequestMessage() { Method = HttpMethod.Post, RequestUri = new Uri(url), - Content = new StringContent(token), + Content = NewtonsoftJsonSerializer.Instance.CreateJsonHttpContent(body), }; + AddCommonHeaders(request); + + bool ret = false; - var httpClient = new HttpClient(); - var response = await httpClient.SendAsync(request).ConfigureAwait(false); + try + { + var response = await this.httpClient + .SendAndDeserializeAsync(request) + .ConfigureAwait(false); - var responseData = JObject.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - bool alreadyConsumed = (bool)responseData["data"]["alreadyConsumed"]; - return alreadyConsumed; + ret = response.Result.AlreadyConsumed; + } + catch (HttpRequestException e) + { + AppCheckErrorHandler.Instance.HandleHttpRequestException(e); + } + + return ret; } - /// - /// Get Verify Token Url . - /// - /// A formatted verify token url. - private string GetVerifyTokenUrl() + internal static AppCheckApiClient Create(FirebaseApp app) { - var urlParams = new Dictionary + var args = new Args { - { "projectId", this.projectId }, + ClientFactory = app.Options.HttpClientFactory, + Credential = app.Options.Credential, + ProjectId = app.Options.ProjectId, + RetryOptions = RetryOptions.Default, }; - string baseUrl = this.FormatString(OneTimeUseTokenVerificationUrlFormat, urlParams); - return this.FormatString(baseUrl, null); + return new AppCheckApiClient(args); } - /// - /// Get url from FirebaseApp Id . - /// - /// The FirebaseApp Id. - /// A formatted verify token url. - private string GetUrl(string appId) + private static void AddCommonHeaders(HttpRequestMessage request) { - if (string.IsNullOrEmpty(this.projectId)) + request.Headers.Add("X-Firebase-Client", ClientVersion); + } + + private AppCheckToken ToAppCheckToken(ExchangeTokenResponse resp) + { + if (resp == null || string.IsNullOrEmpty(resp.Token)) { - this.projectId = this.app.GetProjectId(); + 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 ArgumentException( - "unknown-error", - errorMessage); + + throw new FirebaseAppCheckException( + ErrorCode.Unknown, + errorMessage, + AppCheckErrorCode.UnknownError); } var urlParams = new Dictionary @@ -158,41 +215,74 @@ private string GetUrl(string appId) { "projectId", this.projectId }, { "appId", appId }, }; - string baseUrl = this.FormatString(ApiUrlFormat, urlParams); - return baseUrl; + + return HttpUtils.FormatString(AppCheckUrlFormat, urlParams); } - /// - /// Converts a duration string with the suffix `s` to milliseconds. - /// - /// The duration as a string with the suffix "s" preceded by the number of seconds. - /// The duration in milliseconds. private int StringToMilliseconds(string duration) { - if (string.IsNullOrEmpty(duration) || !duration.EndsWith("s")) - { - throw new ArgumentException("invalid-argument", "`ttl` must be a valid duration string with the suffix `s`."); - } - string modifiedString = duration.Remove(duration.Length - 1); return int.Parse(modifiedString) * 1000; } - /// - /// Formats a string of form 'project/{projectId}/{api}' and replaces with corresponding arguments {projectId: '1234', api: 'resource'}. - /// - /// The original string where the param need to be replaced. - /// The optional parameters to replace in thestring. - /// The resulting formatted string. - private string FormatString(string str, Dictionary urlParams) + private string GetVerifyTokenUrl() { - string formatted = str; - foreach (var key in urlParams.Keys) + if (string.IsNullOrEmpty(this.projectId)) { - formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + 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); } - return formatted; + var urlParams = new Dictionary + { + { "projectId", this.projectId }, + }; + + return HttpUtils.FormatString(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 index 1441631c..deb2abd6 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs @@ -1,23 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.AppCheck { /// /// Interface representing an App Check token. /// - /// - /// Initializes a new instance of the class. - /// /// Generator from custom token. /// TTl value . public class AppCheckToken(string tokenValue, int ttlValue) { /// - /// Gets the Firebase App Check token. + /// Gets or sets the Firebase App Check token. /// - public string Token { get; } = tokenValue; + public string Token { get; set; } = tokenValue; /// /// Gets or sets the time-to-live duration of the token in milliseconds. diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs similarity index 60% rename from FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs index 6c0e7bf1..c2f6db25 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs @@ -1,34 +1,20 @@ using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; 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; -[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey=" + -"002400000480000094000000060200000024000052534131000400000100010081328559eaab41" + -"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02" + -"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597" + -"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f" + -"badddb9c")] - -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.AppCheck { /// /// A helper class that creates Firebase custom tokens. /// - internal class AppCheckTokenGenerator + internal class AppCheckTokenFactory : IDisposable { - public const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" - + "google.identity.identitytoolkit.v1.IdentityToolkit"; - public const string FirebaseAppCheckAudience = "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService"; public const int OneMinuteInSeconds = 60; @@ -38,25 +24,7 @@ internal class AppCheckTokenGenerator 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"); - - internal AppCheckTokenGenerator(Args args) + internal AppCheckTokenFactory(Args args) { args.ThrowIfNull(nameof(args)); @@ -70,8 +38,6 @@ internal AppCheckTokenGenerator(Args args) internal IClock Clock { get; } - internal string TenantId { get; } - internal bool IsEmulatorMode { get; } public void Dispose() @@ -79,54 +45,30 @@ public void Dispose() this.Signer.Dispose(); } - internal static AppCheckTokenGenerator 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 = IAMSigner.Create(app); - } - else - { - // If a service account ID is specified, invoke the IAM service with it. - signer = FixedAccountIAMSigner.Create(app); - } - - var args = new Args - { - Signer = signer, - IsEmulatorMode = Utils.IsEmulatorModeFromEnvironment, - }; - return new AppCheckTokenGenerator(args); - } - - internal async Task CreateCustomTokenAsync( + /// + /// 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 ArgumentException("appId must not be null or empty"); - } - else if (appId.Length > 128) - { - throw new ArgumentException("appId must not be longer than 128 characters"); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`appId` must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } string customOptions = " "; if (options != null) { - customOptions = this.ValidateTokenOptions(options).ToString(); + customOptions = this.ValidateTokenOptions(options); } var header = new JsonWebSignature.Header() @@ -152,19 +94,50 @@ internal async Task CreateCustomTokenAsync( header, payload, this.Signer).ConfigureAwait(false); } - private int ValidateTokenOptions(AppCheckTokenOptions options) + 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 ArgumentException("invalid-argument", "AppCheckTokenOptions must be a non-null object."); + 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 ArgumentException("invalid-argument", "ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive)."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).", + AppCheckErrorCode.InvalidArgument); } - return options.TtlMillis; + return TimeConverter.LongMillisToString(options.TtlMillis); } internal class CustomTokenPayload : JsonWebToken.Payload @@ -182,8 +155,6 @@ internal sealed class Args internal IClock Clock { get; set; } - internal string TenantId { get; set; } - internal bool IsEmulatorMode { get; set; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs index 453f9c5e..2f4cd730 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace FirebaseAdmin.Auth.Jwt +namespace FirebaseAdmin.AppCheck { /// /// Representing App Check token options. 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/AppCheckTokenVerify.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs deleted file mode 100644 index b11334b3..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs +++ /dev/null @@ -1,263 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth; -using FirebaseAdmin.Auth.Jwt; -using Google.Apis.Auth; -using Google.Apis.Util; - -namespace FirebaseAdmin.Check -{ - internal class AppCheckTokenVerify - { - /*private const string IdTokenCertUrl = "https://www.googleapis.com/robot/v1/metadata/x509/" - + "securetoken@system.gserviceaccount.com"; - - private const string SessionCookieCertUrl = "https://www.googleapis.com/identitytoolkit/v3/" - + "relyingparty/publicKeys"; -*/ - private const string AppCheckIuuser = "https://firebaseappcheck.googleapis.com/"; - private const string JWKSURL = "https://firebaseappcheck.googleapis.com/v1/jwks"; - private const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" - + "google.identity.identitytoolkit.v1.IdentityToolkit"; - - private const long ClockSkewSeconds = 5 * 60; - - // See http://oid-info.com/get/2.16.840.1.101.3.4.2.1 - private const string Sha256Oid = "2.16.840.1.101.3.4.2.1"; - - private static readonly IReadOnlyList StandardClaims = - ImmutableList.Create("iss", "aud", "exp", "iat", "sub", "uid"); - - 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; - private readonly AuthErrorCode invalidTokenCode; - private readonly AuthErrorCode expiredIdTokenCode; - - internal AppCheckTokenVerify(FirebaseTokenVerifierArgs args) - { - 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 ?? SystemClock.Default; - this.keySource = args.PublicKeySource.ThrowIfNull(nameof(args.PublicKeySource)); - this.invalidTokenCode = args.InvalidTokenCode; - this.expiredIdTokenCode = args.ExpiredTokenCode; - this.IsEmulatorMode = args.IsEmulatorMode; - if ("aeiou".Contains(this.shortName.ToLower().Substring(0, 1))) - { - this.articledShortName = $"an {this.shortName}"; - } - else - { - this.articledShortName = $"a {this.shortName}"; - } - } - - internal string ProjectId { get; } - - internal bool IsEmulatorMode { get; } - - internal static AppCheckTokenVerify 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."); - } - - var keySource = new HttpPublicKeySource( - JWKSURL, SystemClock.Default, app.Options.HttpClientFactory); - return Create(projectId, keySource); - } - - internal static AppCheckTokenVerify Create( - string projectId, - IPublicKeySource keySource, - IClock clock = null) - { - var args = new FirebaseTokenVerifierArgs() - { - ProjectId = projectId, - ShortName = "session cookie", - Operation = "VerifySessionCookieAsync()", - Url = "https://firebase.google.com/docs/auth/admin/manage-cookies", - Issuer = "https://session.firebase.google.com/", - Clock = clock, - PublicKeySource = keySource, - InvalidTokenCode = AuthErrorCode.InvalidSessionCookie, - ExpiredTokenCode = AuthErrorCode.ExpiredSessionCookie, - }; - return new AppCheckTokenVerify(args); - } - - internal async Task VerifyTokenAsync( - string token, CancellationToken cancellationToken = default(CancellationToken)) - { - if (string.IsNullOrEmpty(token)) - { - throw new ArgumentException($"{this.shortName} must not be null or empty."); - } - - string[] segments = token.Split('.'); - if (segments.Length != 3) - { - throw this.CreateException($"Incorrect number of segments in {this.shortName}."); - } - - var header = JwtUtils.Decode(segments[0]); - var payload = JwtUtils.Decode(segments[1]); - var projectIdMessage = $"Make sure the {this.shortName} comes from the same Firebase " - + "project as the credential used to initialize this SDK."; - var verifyTokenMessage = $"See {this.url} for details on how to retrieve a value " - + $"{this.shortName}."; - var issuer = this.issuer + this.ProjectId; - string error = null; - var errorCode = this.invalidTokenCode; - var currentTimeInSeconds = this.clock.UnixTimestamp(); - - if (!this.IsEmulatorMode && string.IsNullOrEmpty(header.KeyId)) - { - if (payload.Audience == FirebaseAudience) - { - error = $"{this.operation} expects {this.articledShortName}, but was given a custom " - + "token."; - } - else if (header.Algorithm == "HS256") - { - error = $"{this.operation} expects {this.articledShortName}, but was given a legacy " - + "custom token."; - } - else - { - error = $"Firebase {this.shortName} has no 'kid' claim."; - } - } - else if (!this.IsEmulatorMode && header.Algorithm != "RS256") - { - error = $"Firebase {this.shortName} has incorrect algorithm. Expected RS256 but got " - + $"{header.Algorithm}. {verifyTokenMessage}"; - } - else if (this.ProjectId != payload.Audience) - { - error = $"Firebase {this.shortName} has incorrect audience (aud) claim. Expected " - + $"{this.ProjectId} but got {payload.Audience}. {projectIdMessage} " - + $"{verifyTokenMessage}"; - } - else if (payload.Issuer != issuer) - { - error = $"Firebase {this.shortName} has incorrect issuer (iss) claim. Expected " - + $"{issuer} but got {payload.Issuer}. {projectIdMessage} {verifyTokenMessage}"; - } - else if (payload.IssuedAtTimeSeconds - ClockSkewSeconds > currentTimeInSeconds) - { - error = $"Firebase {this.shortName} issued at future timestamp " - + $"{payload.IssuedAtTimeSeconds}. Expected to be less than " - + $"{currentTimeInSeconds}."; - } - else if (payload.ExpirationTimeSeconds + ClockSkewSeconds < currentTimeInSeconds) - { - error = $"Firebase {this.shortName} expired at {payload.ExpirationTimeSeconds}. " - + $"Expected to be greater than {currentTimeInSeconds}."; - errorCode = this.expiredIdTokenCode; - } - else if (string.IsNullOrEmpty(payload.Subject)) - { - error = $"Firebase {this.shortName} has no or empty subject (sub) claim."; - } - else if (payload.Subject.Length > 128) - { - error = $"Firebase {this.shortName} has a subject claim longer than 128 characters."; - } - - if (error != null) - { - throw this.CreateException(error, errorCode); - } - - 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); - } - - /// - /// 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) - { - if (this.IsEmulatorMode) - { - cancellationToken.ThrowIfCancellationRequested(); - return; - } - - 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 this.CreateException($"Failed to verify {this.shortName} signature."); - } - } - - private FirebaseAuthException CreateException( - string message, AuthErrorCode? errorCode = null) - { - if (errorCode == null) - { - errorCode = this.invalidTokenCode; - } - - return new FirebaseAuthException(ErrorCode.InvalidArgument, message, errorCode); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs deleted file mode 100644 index 8cc05641..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using FirebaseAdmin.Auth; - -namespace FirebaseAdmin.Check -{ - /// - /// AppCheckVerifyResponse. - /// - public class AppCheckVerifyResponse(string appId, string verifiedToken, bool alreadyConsumed = false) - { - /// - /// Gets or sets a value indicating whether gets the Firebase App Check token. - /// - public bool AlreadyConsumed { get; set; } = alreadyConsumed; - - /// - /// Gets or sets the Firebase App Check token. - /// - public string AppId { get; set; } = appId; - - /// - /// Gets or sets the Firebase App Check VerifiedToken. - /// - public string VerifiedToken { get; set; } = verifiedToken; - } -} 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/AppCheck/IAppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs deleted file mode 100644 index 0ea2eb8a..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; - -namespace FirebaseAdmin.Check -{ - /// - /// Interface of Firebase App Check backend API . - /// - public interface IAppCheckApiClient - { - /// - /// Exchange a signed custom token to App Check token. - /// - /// The custom token to be exchanged. - /// The mobile App ID. - /// A representing the result of the asynchronous operation. - public Task ExchangeTokenAsync(string customToken, string appId); - - /// - /// Exchange a signed custom token to App Check token. - /// - /// The custom token to be exchanged. - /// A alreadyConsumed is true. - public Task VerifyReplayProtection(string token); - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs deleted file mode 100644 index a24554b9..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth.Jwt; - -namespace FirebaseAdmin.Check -{ - /// - /// App Check Token generator. - /// - internal interface IAppCheckTokenGenerator - { - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// Appcheck Generate Token. - /// AppCheckTokenVerify. - AppCheckTokenGenerator Create(FirebaseApp app); - - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// The Id of FirebaseApp. - /// AppCheck Token Option. - /// Cancelation Token. - /// A representing the result of the asynchronous operation. - Task CreateCustomTokenAsync( - string appId, - AppCheckTokenOptions options = null, - CancellationToken cancellationToken = default); - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs deleted file mode 100644 index cc01d99b..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth; - -namespace FirebaseAdmin.Check -{ - /// - /// App Check Verify. - /// - internal interface IAppCheckTokenVerify - { - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// Appcheck Generate Token. - /// AppCheckTokenVerify. - AppCheckTokenVerify Create(FirebaseApp app); - - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// Appcheck Generate Token. - /// cancellaton Token. - /// A representing the result of the asynchronous operation. - Task VerifyTokenAsync( - string token, CancellationToken cancellationToken = default(CancellationToken)); - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs deleted file mode 100644 index e664acc0..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; - -namespace FirebaseAdmin -{ /// - /// Represents a cryptographic key. - /// - public class Key - { - /// - /// Gets or sets the key type. - /// - public string Kty { get; set; } - - /// - /// Gets or sets the intended use of the key. - /// - public string Use { get; set; } - - /// - /// Gets or sets the algorithm associated with the key. - /// - public string Alg { get; set; } - - /// - /// Gets or sets the key ID. - /// - public string Kid { get; set; } - - /// - /// Gets or sets the modulus for the RSA public key. - /// - public string N { get; set; } - - /// - /// Gets or sets the exponent for the RSA public key. - /// - public string E { get; set; } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs deleted file mode 100644 index 38913f1a..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace FirebaseAdmin.Check -{ - /// - /// Represents a cryptographic key. - /// - public class KeysRoot - { - /// - /// Gets or sets represents a cryptographic key. - /// - public List Keys { get; set; } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs deleted file mode 100644 index 000adac5..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -/// -/// Class representing options for the AppCheck.VerifyToken method. -/// -public class VerifyAppCheckTokenOptions -{ - /// - /// 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/Auth/FirebaseToken.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs index e9ae348a..fa33bf65 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs @@ -26,7 +26,6 @@ public sealed class FirebaseToken { internal FirebaseToken(Args args) { - this.AppId = args.AppId; this.Issuer = args.Issuer; this.Subject = args.Subject; this.Audience = args.Audience; @@ -70,11 +69,6 @@ internal FirebaseToken(Args args) /// public string Uid { get; } - /// - /// Gets the Id of the Firebase . - /// - public string AppId { get; } - /// /// Gets the ID of the tenant the user belongs to, if available. Returns null if the ID /// token is not scoped to a tenant. @@ -93,14 +87,12 @@ internal FirebaseToken(Args args) /// FirebaseToken. public static implicit operator string(FirebaseToken v) { - throw new NotImplementedException(); + return v.Uid; } internal sealed class Args { - public string AppId { get; internal set; } - - [JsonProperty("app_id")] + [JsonProperty("iss")] internal string Issuer { get; set; } [JsonProperty("sub")] diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs index d3343213..45070c8b 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs @@ -176,61 +176,6 @@ internal async Task CreateCustomTokenAsync( header, payload, this.Signer, cancellationToken).ConfigureAwait(false); } - internal async Task CreateCustomTokenAppIdAsync( - string appId, - AppCheckTokenOptions options = null, - CancellationToken cancellationToken = default(CancellationToken)) - { - if (string.IsNullOrEmpty(appId)) - { - throw new ArgumentException("uid must not be null or empty"); - } - else if (appId.Length > 128) - { - throw new ArgumentException("uid must not be longer than 128 characters"); - } - - var header = new JsonWebSignature.Header() - { - Algorithm = this.Signer.Algorithm, - Type = "JWT", - }; - - var issued = (int)(this.Clock.UtcNow - UnixEpoch).TotalSeconds / 1000; - var keyId = await this.Signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); - var payload = new CustomTokenPayload() - { - AppId = appId, - Issuer = keyId, - Subject = keyId, - Audience = FirebaseAudience, - IssuedAtTimeSeconds = issued, - ExpirationTimeSeconds = issued + (OneMinuteInSeconds * 5), - }; - - if (options != null) - { - this.ValidateTokenOptions(options); - payload.Ttl = options.TtlMillis.ToString(); - } - - return await JwtUtils.CreateSignedJwtAsync( - header, payload, this.Signer, cancellationToken).ConfigureAwait(false); - } - - internal void ValidateTokenOptions(AppCheckTokenOptions options) - { - if (options.TtlMillis == 0) - { - throw new ArgumentException("TtlMillis must be a duration in milliseconds."); - } - - if (options.TtlMillis < OneMinuteInSeconds * 30 || options.TtlMillis > OneDayInMillis * 7) - { - throw new ArgumentException("ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive)."); - } - } - internal class CustomTokenPayload : JsonWebToken.Payload { [JsonPropertyAttribute("uid")] diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs index 1d37c094..50fad5f7 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs @@ -37,8 +37,6 @@ internal sealed class FirebaseTokenVerifier private const string SessionCookieCertUrl = "https://www.googleapis.com/identitytoolkit/v3/" + "relyingparty/publicKeys"; - private const string AppCheckCertUrl = "https://firebaseappcheck.googleapis.com/v1/jwks"; - private const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" + "google.identity.identitytoolkit.v1.IdentityToolkit"; @@ -161,44 +159,6 @@ internal static FirebaseTokenVerifier CreateSessionCookieVerifier( return new FirebaseTokenVerifier(args); } - internal static FirebaseTokenVerifier CreateAppCheckVerifier(FirebaseApp app) - { - var projectId = app.GetProjectId(); - if (string.IsNullOrEmpty(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 ArgumentException( - "unknown-error", - errorMessage); - } - - var keySource = new HttpPublicKeySource( - AppCheckCertUrl, SystemClock.Default, app.Options.HttpClientFactory); - return CreateAppCheckVerifier(projectId, keySource); - } - - internal static FirebaseTokenVerifier CreateAppCheckVerifier( - string projectId, - IPublicKeySource keySource, - IClock clock = null) - { - var args = new FirebaseTokenVerifierArgs() - { - ProjectId = projectId, - ShortName = "app check", - Operation = "VerifyAppCheckAsync()", - Url = "https://firebase.google.com/docs/app-check/", - Issuer = "https://firebaseappcheck.googleapis.com/", - Clock = clock, - PublicKeySource = keySource, - InvalidTokenCode = AuthErrorCode.InvalidSessionCookie, - ExpiredTokenCode = AuthErrorCode.ExpiredSessionCookie, - }; - return new FirebaseTokenVerifier(args); - } - internal async Task VerifyTokenAsync( string token, CancellationToken cancellationToken = default(CancellationToken)) { diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs index e8f8a303..fce692f0 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs @@ -21,7 +21,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using FirebaseAdmin.Check; using FirebaseAdmin.Util; using Google.Apis.Http; using Google.Apis.Util; @@ -86,10 +85,6 @@ public async Task> GetPublicKeysAsync( { this.cachedKeys = this.ParseKeys(response); } - else - { - this.cachedKeys = await this.ParseAppCheckKeys().ConfigureAwait(false); - } var cacheControl = response.HttpResponse.Headers.CacheControl; if (cacheControl?.MaxAge != null) @@ -144,37 +139,6 @@ private IReadOnlyList ParseKeys(DeserializedResponseInfo> ParseAppCheckKeys() - { - try - { - using var client = new HttpClient(); - HttpResponseMessage response = await client.GetAsync(this.certUrl).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.OK) - { - string responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - KeysRoot keysRoot = JsonConvert.DeserializeObject(responseString); - var builder = ImmutableList.CreateBuilder(); - foreach (Key key in keysRoot.Keys) - { - var x509cert = new X509Certificate2(Encoding.UTF8.GetBytes(key.N)); - RSAKey rsa = x509cert.GetRSAPublicKey(); - builder.Add(new PublicKey(key.Kid, rsa)); - } - - return builder.ToImmutableList(); - } - else - { - throw new ArgumentNullException("Error Http request JwksUrl"); - } - } - catch (Exception exception) - { - throw new ArgumentNullException("Error Http request", exception); - } - } - private class HttpKeySourceErrorHandler : HttpErrorHandler, IHttpRequestExceptionHandler, diff --git a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs deleted file mode 100644 index b1a6d836..00000000 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using System.Xml.Linq; -using FirebaseAdmin.Auth; -using FirebaseAdmin.Auth.Jwt; -using FirebaseAdmin.Check; -using Google.Apis.Logging; - -namespace FirebaseAdmin -{ - /// - /// 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 - { - private const string DefaultProjectId = "[DEFAULT]"; - private static Dictionary appChecks = new Dictionary(); - - private readonly AppCheckApiClient apiClient; - private readonly AppCheckTokenVerify appCheckTokenVerifier; - private readonly AppCheckTokenGenerator tokenFactory; - - /// - /// Initializes a new instance of the class. - /// - /// Initailize FirebaseApp. - public FirebaseAppCheck(FirebaseApp value) - { - this.apiClient = new AppCheckApiClient(value); - this.tokenFactory = AppCheckTokenGenerator.Create(value); - this.appCheckTokenVerifier = AppCheckTokenVerify.Create(value); - } - - /// - /// Initializes a new instance of the class. - /// - /// Initailize FirebaseApp. - /// A Representing the result of the asynchronous operation. - public static FirebaseAppCheck Create(FirebaseApp app) - { - if (app == null) - { - throw new ArgumentNullException("FirebaseApp must not be null or empty"); - } - - string appId = app.Name; - - lock (appChecks) - { - if (appChecks.ContainsKey(appId)) - { - if (appId == DefaultProjectId) - { - throw new ArgumentException("The default FirebaseAppCheck already exists."); - } - else - { - throw new ArgumentException($"FirebaseApp named {appId} already exists."); - } - } - } - - var appCheck = new FirebaseAppCheck(app); - appChecks.Add(appId, appCheck); - return appCheck; - } - - /// - /// 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 Representing the result of the asynchronous operation. - public async Task CreateToken(string appId, AppCheckTokenOptions options = null) - { - string customToken = await this.tokenFactory.CreateCustomTokenAsync(appId, options, default(CancellationToken)) - .ConfigureAwait(false); - - return await this.apiClient.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 VerifyAppCheckTokenOptions object when verifying an App Check Token. - /// A representing the result of the asynchronous operation. - public async Task VerifyToken(string appCheckToken, VerifyAppCheckTokenOptions options = null) - { - if (string.IsNullOrEmpty(appCheckToken)) - { - throw new ArgumentNullException("App check token " + appCheckToken + " must be a non - empty string."); - } - - FirebaseToken verifiedToken = await this.appCheckTokenVerifier.VerifyTokenAsync(appCheckToken).ConfigureAwait(false); - bool alreadyConsumed = await this.apiClient.VerifyReplayProtection(verifiedToken).ConfigureAwait(false); - AppCheckVerifyResponse result; - - if (!alreadyConsumed) - { - result = new AppCheckVerifyResponse(verifiedToken.AppId, verifiedToken, alreadyConsumed); - } - else - { - result = new AppCheckVerifyResponse(verifiedToken.AppId, verifiedToken); - } - - return result; - } - - /// - /// Deleted all the appChecks created so far. Used for unit testing. - /// - internal static void DeleteAll() - { - FirebaseApp.DeleteAll(); - appChecks.Clear(); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs index 8b57c7ec..c523f533 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; @@ -29,6 +30,17 @@ internal sealed class HttpUtils private HttpUtils() { } + public static string FormatString(string str, Dictionary urlParams) + { + string formatted = str; + foreach (var key in urlParams.Keys) + { + formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + } + + return formatted; + } + internal static string EncodeQueryParams(IDictionary queryParams) { var queryString = string.Empty;