From bc8360f4eb4a32bfacc61e296a8fd6a61a5dbb08 Mon Sep 17 00:00:00 2001 From: pavlo Date: Tue, 23 Jan 2024 04:21:31 -0500 Subject: [PATCH] feature/app-check/test --- .../FirebaseAppCheckTests.cs | 111 ++++++--- .../FirebaseAdmin/Auth/FirebaseToken.cs | 14 +- .../Jwt}/AppCheckTokenOptions.cs | 10 +- .../Auth/Jwt/FirebaseTokenFactory.cs | 66 ++++- .../Auth/Jwt/FirebaseTokenVerifier.cs | 40 ++++ .../Auth/Jwt/HttpPublicKeySource.cs | 45 +++- .../FirebaseAdmin/Check/AppCheckApiClient.cs | 141 +++++++++-- .../FirebaseAdmin/Check/AppCheckService.cs | 50 ---- .../Check/AppCheckTokenGernerator.cs | 90 ------- .../Check/AppCheckVerifyResponse.cs | 28 +++ .../FirebaseAdmin/Check/CyptoSigner.cs | 71 ------ .../FirebaseAdmin/Check/IAppCheckApiClient.cs | 37 +++ .../FirebaseAdmin/{ => Check}/Key.cs | 0 .../FirebaseAdmin/{ => Check}/KeysRoot.cs | 2 +- .../Check/VerifyAppCheckTokenOptions.cs | 27 +++ .../FirebaseAdmin/FirebaseAppCheck.cs | 225 ++++++------------ 16 files changed, 525 insertions(+), 432 deletions(-) rename FirebaseAdmin/FirebaseAdmin/{Check => Auth/Jwt}/AppCheckTokenOptions.cs (67%) delete mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckVerifyResponse.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs rename FirebaseAdmin/FirebaseAdmin/{ => Check}/Key.cs (100%) rename FirebaseAdmin/FirebaseAdmin/{ => Check}/KeysRoot.cs (91%) create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/VerifyAppCheckTokenOptions.cs diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs index 685bc334..53814bd9 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs @@ -1,76 +1,113 @@ using System; using System.Collections.Generic; using System.IO; +using System.Net.Http; +using System.Reflection.Metadata.Ecma335; 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.Linq; using Xunit; namespace FirebaseAdmin.Tests { public class FirebaseAppCheckTests : IDisposable { - [Fact] - public async Task CreateTokenFromAppId() + private readonly string appId = "1:1234:android:1234"; + private FirebaseApp mockCredentialApp; + + public FirebaseAppCheckTests() { - string filePath = @"C:\path\to\your\file.txt"; - string fileContent = File.ReadAllText(filePath); - string[] appIds = fileContent.Split(','); - foreach (string appId in appIds) + var credential = GoogleCredential.FromFile("./resources/service_account.json"); + var options = new AppOptions() { - var token = await FirebaseAppCheck.CreateToken(appId); - Assert.IsType(token.Token); - Assert.NotNull(token.Token); - Assert.IsType(token.TtlMillis); - Assert.Equal(3600000, token.TtlMillis); - } + Credential = credential, + }; + this.mockCredentialApp = FirebaseApp.Create(options); } [Fact] - public async Task CreateTokenFromAppIdAndTtlMillis() + public void CreateAppCheck() + { + FirebaseAppCheck withoutAppIdCreate = FirebaseAppCheck.Create(this.mockCredentialApp); + Assert.NotNull(withoutAppIdCreate); + } + + [Fact] + public async Task InvalidAppIdCreateToken() + { + FirebaseAppCheck invalidAppIdCreate = FirebaseAppCheck.Create(this.mockCredentialApp); + + await Assert.ThrowsAsync(() => invalidAppIdCreate.CreateToken(appId: null)); + await Assert.ThrowsAsync(() => invalidAppIdCreate.CreateToken(appId: string.Empty)); + } + + [Fact] + public void WithoutProjectIDCreate() { - string filePath = @"C:\path\to\your\file.txt"; - string fileContent = File.ReadAllText(filePath); - string[] appIds = fileContent.Split(','); - foreach (string appId in appIds) + // Project ID not set in the environment. + Environment.SetEnvironmentVariable("GOOGLE_CLOUD_PROJECT", null); + Environment.SetEnvironmentVariable("GCLOUD_PROJECT", null); + + var options = new AppOptions() { - AppCheckTokenOptions options = new (1800000); - var token = await FirebaseAppCheck.CreateToken(appId, options); - Assert.IsType(token.Token); - Assert.NotNull(token.Token); - Assert.IsType(token.TtlMillis); - Assert.Equal(1800000, token.TtlMillis); - } + Credential = GoogleCredential.FromAccessToken("token"), + }; + var app = FirebaseApp.Create(options, "1234"); + + Assert.Throws(() => FirebaseAppCheck.Create(app)); + } + + [Fact] + public async Task CreateTokenFromAppId() + { + FirebaseAppCheck createTokenFromAppId = new FirebaseAppCheck(this.mockCredentialApp); + var token = await createTokenFromAppId.CreateToken(this.appId); + Assert.IsType(token.Token); + Assert.NotNull(token.Token); + Assert.IsType(token.TtlMillis); + Assert.Equal(3600000, token.TtlMillis); } [Fact] - public async Task InvalidAppIdCreate() + public async Task CreateTokenFromAppIdAndTtlMillis() { - await Assert.ThrowsAsync(() => FirebaseAppCheck.CreateToken(appId: null)); - await Assert.ThrowsAsync(() => FirebaseAppCheck.CreateToken(appId: string.Empty)); + AppCheckTokenOptions options = new (1800000); + FirebaseAppCheck createTokenFromAppIdAndTtlMillis = new FirebaseAppCheck(this.mockCredentialApp); + + var token = await createTokenFromAppIdAndTtlMillis.CreateToken(this.appId, options); + Assert.IsType(token.Token); + Assert.NotNull(token.Token); + Assert.IsType(token.TtlMillis); + Assert.Equal(1800000, token.TtlMillis); } [Fact] - public async Task DecodeVerifyToken() + public async Task VerifyToken() { - string appId = "1234"; // '../resources/appid.txt' - AppCheckToken validToken = await FirebaseAppCheck.CreateToken(appId); - var verifiedToken = FirebaseAppCheck.Decode_and_verify(validToken.Token); - /* Assert.Equal("explicit-project", verifiedToken);*/ + FirebaseAppCheck verifyToken = new FirebaseAppCheck(this.mockCredentialApp); + + AppCheckToken validToken = await verifyToken.CreateToken(this.appId); + AppCheckVerifyResponse verifiedToken = await verifyToken.VerifyToken(validToken.Token, null); + Assert.Equal("explicit-project", verifiedToken.AppId); } [Fact] - public async Task DecodeVerifyTokenInvaild() + public async Task VerifyTokenInvaild() { - await Assert.ThrowsAsync(() => FirebaseAppCheck.Decode_and_verify(token: null)); - await Assert.ThrowsAsync(() => FirebaseAppCheck.Decode_and_verify(token: string.Empty)); + FirebaseAppCheck verifyTokenInvaild = new FirebaseAppCheck(this.mockCredentialApp); + + await Assert.ThrowsAsync(() => verifyTokenInvaild.VerifyToken(null)); + await Assert.ThrowsAsync(() => verifyTokenInvaild.VerifyToken(string.Empty)); } public void Dispose() { - FirebaseAppCheck.Delete(); + FirebaseAppCheck.DeleteAll(); } } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs index 535d3451..e9ae348a 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs @@ -26,6 +26,7 @@ public sealed class FirebaseToken { internal FirebaseToken(Args args) { + this.AppId = args.AppId; this.Issuer = args.Issuer; this.Subject = args.Subject; this.Audience = args.Audience; @@ -69,6 +70,11 @@ 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. @@ -81,6 +87,10 @@ internal FirebaseToken(Args args) /// public IReadOnlyDictionary Claims { get; } + /// + /// Defined operator string. + /// + /// FirebaseToken. public static implicit operator string(FirebaseToken v) { throw new NotImplementedException(); @@ -88,7 +98,9 @@ public static implicit operator string(FirebaseToken v) internal sealed class Args { - [JsonProperty("iss")] + public string AppId { get; internal set; } + + [JsonProperty("app_id")] internal string Issuer { get; set; } [JsonProperty("sub")] diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/AppCheckTokenOptions.cs similarity index 67% rename from FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenOptions.cs rename to FirebaseAdmin/FirebaseAdmin/Auth/Jwt/AppCheckTokenOptions.cs index 00e61daa..453f9c5e 100644 --- a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenOptions.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/AppCheckTokenOptions.cs @@ -2,21 +2,21 @@ using System.Collections.Generic; using System.Text; -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.Auth.Jwt { /// - /// Interface representing App Check token options. + /// Representing App Check token options. /// /// /// Initializes a new instance of the class. /// - /// ttlMillis. - public class AppCheckTokenOptions(int v) + /// ttlMillis. + public class AppCheckTokenOptions(int ttl) { /// /// Gets or sets the length of time, in milliseconds, for which the App Check token will /// be valid. This value must be between 30 minutes and 7 days, inclusive. /// - public int TtlMillis { get; set; } = v; + public int TtlMillis { get; set; } = ttl; } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs index 8d7dcac0..d3343213 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenFactory.cs @@ -39,6 +39,8 @@ internal class FirebaseTokenFactory : IDisposable + "google.identity.identitytoolkit.v1.IdentityToolkit"; public const int TokenDurationSeconds = 3600; + public const int OneMinuteInSeconds = 60; + public const int OneDayInMillis = 24 * 60 * 60 * 1000; public static readonly DateTime UnixEpoch = new DateTime( 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -58,7 +60,8 @@ internal class FirebaseTokenFactory : IDisposable "jti", "nbf", "nonce", - "sub"); + "sub", + "app_id"); internal FirebaseTokenFactory(Args args) { @@ -173,14 +176,75 @@ 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")] public string Uid { get; set; } + [JsonPropertyAttribute("app_id")] + public string AppId { get; set; } + [JsonPropertyAttribute("tenant_id")] public string TenantId { get; set; } + [JsonPropertyAttribute("ttl")] + public string Ttl { get; set; } + [JsonPropertyAttribute("claims")] public IDictionary Claims { get; set; } } diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs index 50fad5f7..1d37c094 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/FirebaseTokenVerifier.cs @@ -37,6 +37,8 @@ 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"; @@ -159,6 +161,44 @@ 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 b7786c94..e8f8a303 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/HttpPublicKeySource.cs @@ -15,14 +15,18 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Net; using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Threading; using System.Threading.Tasks; +using FirebaseAdmin.Check; using FirebaseAdmin.Util; using Google.Apis.Http; using Google.Apis.Util; +using Newtonsoft.Json; +using static Google.Apis.Requests.BatchRequest; using RSAKey = System.Security.Cryptography.RSA; namespace FirebaseAdmin.Auth.Jwt @@ -74,11 +78,19 @@ public async Task> GetPublicKeysAsync( Method = HttpMethod.Get, RequestUri = new Uri(this.certUrl), }; + var response = await httpClient .SendAndDeserializeAsync>(request, cancellationToken) .ConfigureAwait(false); + if (this.certUrl != "https://firebaseappcheck.googleapis.com/v1/jwks") + { + this.cachedKeys = this.ParseKeys(response); + } + else + { + this.cachedKeys = await this.ParseAppCheckKeys().ConfigureAwait(false); + } - this.cachedKeys = this.ParseKeys(response); var cacheControl = response.HttpResponse.Headers.CacheControl; if (cacheControl?.MaxAge != null) { @@ -132,6 +144,37 @@ 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/Check/AppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs index 6c54e3b7..fa909b55 100644 --- a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs @@ -6,17 +6,30 @@ 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; namespace FirebaseAdmin.Check { + /// + /// 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 { private const string ApiUrlFormat = "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; - private string appId; + /// + /// Initializes a new instance of the class. + /// + /// Initailize FirebaseApp. public AppCheckApiClient(FirebaseApp value) { if (value == null || value.Options == null) @@ -28,45 +41,115 @@ public AppCheckApiClient(FirebaseApp value) this.projectId = this.app.Options.ProjectId; } - public AppCheckApiClient(string appId) + /// + /// 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`. + public async Task ExchangeTokenAsync(string customToken, string appId) { - this.appId = appId; - } - - public async Task ExchangeToken(string customToken) - { - if (customToken == null) + if (string.IsNullOrEmpty(customToken)) { throw new ArgumentException("First argument passed to customToken must be a valid Firebase app instance."); } - if (this.appId == null) + if (string.IsNullOrEmpty(appId)) { throw new ArgumentException("Second argument passed to appId must be a valid Firebase app instance."); } - var url = this.GetUrl(this.appId); + HttpResponseMessage response = await this.GetExchangeToken(customToken, appId).ConfigureAwait(false); + + 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. + /// The Id of Firebase App. + /// HttpResponseMessage . + public async Task GetExchangeToken(string customToken, string appId) + { + 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 = new StringContent(customToken), + Content = content, }; - request.Headers.Add("X-Firebase-Client", "fire-admin-node/${utils.getSdkVersion()}"); + request.Headers.Add("X-Firebase-Client", "fire-admin-node/" + $"{FirebaseApp.GetSdkVersion()}"); + Console.WriteLine(request.Content); var httpClient = new HttpClient(); var response = await httpClient.SendAsync(request).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + var errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new InvalidOperationException($"BadRequest: {errorContent}"); + } + else if (!response.IsSuccessStatusCode) { - throw new ArgumentException("Error exchanging token."); + throw new ArgumentException("unknown-error", $"Unexpected response with status:{response.StatusCode}"); } + return response; + } + + /// + /// 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) + { + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException("invalid-argument", "`token` must be a non-empty string."); + } + + string url = this.GetVerifyTokenUrl(); + + var request = new HttpRequestMessage() + { + Method = HttpMethod.Post, + RequestUri = new Uri(url), + Content = new StringContent(token), + }; + + var httpClient = new HttpClient(); + var response = await httpClient.SendAsync(request).ConfigureAwait(false); + var responseData = JObject.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - string tokenValue = responseData["data"]["token"].ToString(); - int ttlValue = int.Parse(responseData["data"]["ttl"].ToString()); - AppCheckToken appCheckToken = new (tokenValue, ttlValue); - return appCheckToken; + bool alreadyConsumed = (bool)responseData["data"]["alreadyConsumed"]; + return alreadyConsumed; + } + + /// + /// Get Verify Token Url . + /// + /// A formatted verify token url. + private string GetVerifyTokenUrl() + { + var urlParams = new Dictionary + { + { "projectId", this.projectId }, + }; + + string baseUrl = this.FormatString(OneTimeUseTokenVerificationUrlFormat, urlParams); + return this.FormatString(baseUrl, null); } + /// + /// Get url from FirebaseApp Id . + /// + /// The FirebaseApp Id. + /// A formatted verify token url. private string GetUrl(string appId) { if (string.IsNullOrEmpty(this.projectId)) @@ -93,6 +176,28 @@ private string GetUrl(string appId) return baseUrl; } + /// + /// 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) { string formatted = str; diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs deleted file mode 100644 index 27159b05..00000000 --- a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace FirebaseAdmin.Check -{ - /// - /// Interface representing an App Check token. - /// - public class AppCheckService - { - private const long OneMinuteInMillis = 60 * 1000; // 60,000 - private const long OneDayInMillis = 24 * 60 * OneMinuteInMillis; // 1,440,000 - - /// - /// Interface representing an App Check token. - /// - /// IDictionary string, object . - /// IDictionary string object . - public static Dictionary ValidateTokenOptions(AppCheckTokenOptions options) - { - if (options == null) - { - throw new FirebaseAppCheckError( - "invalid-argument", - "AppCheckTokenOptions must be a non-null object."); - } - - if (options.TtlMillis > 0) - { - long ttlMillis = options.TtlMillis; - if (ttlMillis < (OneMinuteInMillis * 30) || ttlMillis > (OneDayInMillis * 7)) - { - throw new FirebaseAppCheckError( - "invalid-argument", - "ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive)."); - } - - return new Dictionary { { "ttl", TransformMillisecondsToSecondsString(ttlMillis) } }; - } - - return new Dictionary(); - } - - private static string TransformMillisecondsToSecondsString(long milliseconds) - { - return (milliseconds / 1000).ToString(); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs deleted file mode 100644 index 25069996..00000000 --- a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using FirebaseAdmin.Auth.Jwt; -using FirebaseAdmin.Check; -using Google.Apis.Auth; -using Google.Apis.Auth.OAuth2; -using Google.Apis.Json; - -namespace FirebaseAdmin.Check -{ - /// - /// Initializes static members of the class. - /// - public class AppCheckTokenGernerator - { - private static readonly string AppCheckAudience = "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService"; - private readonly CyptoSigner signer; - private string appId; - - /// - /// Initializes a new instance of the class. - /// - /// FirebaseApp Id. - public AppCheckTokenGernerator(string appId) - { - this.appId = appId; - } - - /// - /// Initializes static members of the class. - /// - /// FirebaseApp Id. - /// FirebaseApp AppCheckTokenOptions. - /// Created token. - public static string CreateCustomToken(string appId, AppCheckTokenOptions options) - { - var customOptions = new Dictionary(); - - if (string.IsNullOrEmpty(appId)) - { - throw new ArgumentNullException(nameof(appId)); - } - - if (options == null) - { - customOptions.Add(AppCheckService.ValidateTokenOptions(options)); - } - - CyptoSigner signer = new (appId); - string account = signer.GetAccountId(); - - var header = new Dictionary() - { - { "alg", "RS256" }, - { "typ", "JWT" }, - }; - var iat = Math.Floor(DateTime.now() / 1000); - var payload = new Dictionary() - { - { "iss", account }, - { "sub", account }, - { "app_id", appId }, - { "aud", AppCheckAudience }, - { "exp", iat + 300 }, - { "iat", iat }, - }; - - foreach (var each in customOptions) - { - payload.Add(each.Key, each.Value); - } - - string token = Encode(header) + Encode(payload); - return token; - } - - private static string Encode(object obj) - { - var json = NewtonsoftJsonSerializer.Instance.Serialize(obj); - return UrlSafeBase64Encode(Encoding.UTF8.GetBytes(json)); - } - - private static string UrlSafeBase64Encode(byte[] bytes) - { - var base64Value = Convert.ToBase64String(bytes); - return base64Value.TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckVerifyResponse.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckVerifyResponse.cs new file mode 100644 index 00000000..90ae92f4 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckVerifyResponse.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text; +using FirebaseAdmin.Auth; + +namespace FirebaseAdmin.Check +{ + /// + /// AppCheckVerifyResponse. + /// + public class AppCheckVerifyResponse(string appId, FirebaseToken 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/Check/CyptoSigner.cs b/FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs deleted file mode 100644 index d04fd544..00000000 --- a/FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using Google.Apis.Auth.OAuth2; -using Newtonsoft.Json.Linq; - -namespace FirebaseAdmin.Check -{ - /// - /// Interface representing App Check token options. - /// - public class CyptoSigner - { - private readonly RSA Rsa; - private readonly ServiceAccountCredential credential; - - /// - /// Initializes a new instance of the class. - /// Interface representing App Check token options. - /// - public CyptoSigner(string privateKeyPem) - { - if (privateKeyPem is null) - { - throw new ArgumentNullException(nameof(privateKeyPem)); - } - - this.Rsa = RSA.Create(); - this.Rsa.ImportFromPem(privateKeyPem.ToCharArray()); - } - - /// - /// Initializes a new instance of the class. - /// Cryptographically signs a buffer of data. - /// - /// To sign data. - /// A representing the asynchronous operation. - public Task Sign(byte[] buffer) - { - if (buffer is null) - { - throw new ArgumentNullException(nameof(buffer)); - } - - // Sign the buffer using the private key and SHA256 hashing algorithm - var signature = this.privateKey.SignData(buffer, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return Task.FromResult(signature); - } - - internal async Task GetAccountId() - { - var request = new HttpRequestMessage() - { - Method = HttpMethod.Get, - RequestUri = new Uri("http://metadata/computeMetadata/v1/instance/service-accounts/default/email"), - }; - request.Headers.Add("Metadata-Flavor", "Google"); - var httpClient = new HttpClient(); - var response = await httpClient.SendAsync(request).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) - { - throw new FirebaseAppCheckException("Error exchanging token."); - } - - return response.Content.ToString(); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs new file mode 100644 index 00000000..2e281079 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs @@ -0,0 +1,37 @@ +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 . + /// + internal 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); + + /// + /// Exchange a signed custom token to App Check token. + /// + /// The custom token to be exchanged. + /// The Id of Firebase App. + /// HttpResponseMessage . + public Task GetExchangeToken(string customToken, string appId); + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Key.cs b/FirebaseAdmin/FirebaseAdmin/Check/Key.cs similarity index 100% rename from FirebaseAdmin/FirebaseAdmin/Key.cs rename to FirebaseAdmin/FirebaseAdmin/Check/Key.cs diff --git a/FirebaseAdmin/FirebaseAdmin/KeysRoot.cs b/FirebaseAdmin/FirebaseAdmin/Check/KeysRoot.cs similarity index 91% rename from FirebaseAdmin/FirebaseAdmin/KeysRoot.cs rename to FirebaseAdmin/FirebaseAdmin/Check/KeysRoot.cs index 82a44c15..38913f1a 100644 --- a/FirebaseAdmin/FirebaseAdmin/KeysRoot.cs +++ b/FirebaseAdmin/FirebaseAdmin/Check/KeysRoot.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace FirebaseAdmin +namespace FirebaseAdmin.Check { /// /// Represents a cryptographic key. diff --git a/FirebaseAdmin/FirebaseAdmin/Check/VerifyAppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/Check/VerifyAppCheckTokenOptions.cs new file mode 100644 index 00000000..000adac5 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/VerifyAppCheckTokenOptions.cs @@ -0,0 +1,27 @@ +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/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs index 30642e51..0710bfa8 100644 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs @@ -1,19 +1,11 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; using System.Threading.Tasks; +using System.Xml.Linq; using FirebaseAdmin.Auth; using FirebaseAdmin.Auth.Jwt; using FirebaseAdmin.Check; -using Google.Apis.Auth; -using Newtonsoft.Json; -using RSAKey = System.Security.Cryptography.RSA; +using Google.Apis.Logging; namespace FirebaseAdmin { @@ -21,195 +13,114 @@ 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. + /// Thrown if an error occurs while creating the custom token. + /// The Firebase app instance. public sealed class FirebaseAppCheck { - private static readonly string AppCheckIssuer = "https://firebaseappcheck.googleapis.com/"; - private static readonly string ProjectId; - private static readonly string ScopedProjectId; - private static readonly string JwksUrl = "https://firebaseappcheck.googleapis.com/v1/jwks"; - private static readonly IReadOnlyList StandardClaims = - ImmutableList.Create("iss", "aud", "exp", "iat", "sub", "uid"); + private const string DefaultProjectId = "[DEFAULT]"; + private static Dictionary appChecks = new Dictionary(); - private static List cachedKeys; + private readonly AppCheckApiClient apiClient; + private readonly FirebaseTokenVerifier appCheckTokenVerifier; + private readonly FirebaseTokenFactory tokenFactory; /// - /// Creates a new {@link AppCheckToken} that can be sent back to a client. + /// Initializes a new instance of the class. /// - /// ID of Firebase App. - /// Options of FirebaseApp. - /// A representing the result of the asynchronous operation. - public static async Task CreateToken(string appId, AppCheckTokenOptions options = null) + /// Initailize FirebaseApp. + public FirebaseAppCheck(FirebaseApp value) { - if (string.IsNullOrEmpty(appId)) - { - throw new ArgumentNullException("AppId must be a non-empty string."); - } - - if (options == null) - { - var customOptions = AppCheckService.ValidateTokenOptions(options); - } - - string customToken = " "; - try - { - customToken = AppCheckTokenGernerator.CreateCustomToken(appId, options); - } - catch (Exception e) - { - throw new FirebaseAppCheckException("Error Create customToken", e.Message); - } - - AppCheckApiClient appCheckApiClient = new AppCheckApiClient(appId); - return await appCheckApiClient.ExchangeToken(customToken).ConfigureAwait(false); + this.apiClient = new AppCheckApiClient(value); + this.tokenFactory = FirebaseTokenFactory.Create(value); + this.appCheckTokenVerifier = FirebaseTokenVerifier.CreateAppCheckVerifier(value); } /// - /// Verifies the format and signature of a Firebase App Check token. + /// Initializes a new instance of the class. /// - /// The Firebase Auth JWT token to verify. - /// A representing the result of the asynchronous operation. - public static async Task> VerifyTokenAsync(string token) + /// Initailize FirebaseApp. + /// A Representing the result of the asynchronous operation. + public static FirebaseAppCheck Create(FirebaseApp app) { - if (string.IsNullOrEmpty(token)) - { - throw new ArgumentNullException("App check token " + token + " must be a non - empty string."); - } + string appId = app.Name; - try + if (app == null) { - FirebaseToken verified_claims = await Decode_and_verify(token).ConfigureAwait(false); - Dictionary appchecks = new (); - appchecks.Add(ProjectId, verified_claims); - return appchecks; + throw new ArgumentNullException("FirebaseApp must not be null or empty"); } - catch (Exception exception) - { - throw new ArgumentNullException("Verifying App Check token failed. Error:", exception); - } - } - /// - /// Get public key from jwksUrl. - /// - /// A representing the result of the asynchronous operation. - public static async Task InitializeAsync() - { - try + lock (appChecks) { - using var client = new HttpClient(); - HttpResponseMessage response = await client.GetAsync(JwksUrl).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.OK) + if (appChecks.ContainsKey(appId)) { - string responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - KeysRoot keysRoot = JsonConvert.DeserializeObject(responseString); - foreach (Key key in keysRoot.Keys) + if (appId == DefaultProjectId) { - var x509cert = new X509Certificate2(Encoding.UTF8.GetBytes(key.N)); - RSAKey rsa = x509cert.GetRSAPublicKey(); - cachedKeys.Add(new Auth.Jwt.PublicKey(key.Kid, rsa)); + throw new ArgumentException("The default FirebaseAppCheck already exists."); + } + else + { + throw new ArgumentException($"FirebaseApp named {appId} already exists."); } - - cachedKeys.ToImmutableList(); - } - else - { - throw new ArgumentNullException("Error Http request JwksUrl"); } } - catch (Exception exception) - { - throw new ArgumentNullException("Error Http request", exception); - } + + var appCheck = new FirebaseAppCheck(app); + appChecks.Add(appId, appCheck); + return appCheck; } /// - /// Decode_and_verify. + /// Creates a new AppCheckToken that can be sent back to a client. /// - /// The Firebase Auth JWT token to verify. - /// A representing the result of the asynchronous operation. - public static Task Decode_and_verify(string token) + /// 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[] segments = token.Split('.'); - if (segments.Length != 3) - { - throw new FirebaseAppCheckException("Incorrect number of segments in Token"); - } + string customToken = await this.tokenFactory.CreateCustomTokenAppIdAsync(appId, options) + .ConfigureAwait(false); - var header = JwtUtils.Decode(segments[0]); - var payload = JwtUtils.Decode(segments[1]); - var projectIdMessage = $"Make sure the comes from the same Firebase " - + "project as the credential used to initialize this SDK."; - string issuer = AppCheckIssuer + ProjectId; - string error = null; - if (header.Algorithm != "RS256") - { - error = "The provided App Check token has incorrect algorithm. Expected RS256 but got '" - + header.Algorithm + "'"; - } - else if (payload.Audience.Contains(ScopedProjectId)) - { - error = "The provided App Check token has incorrect 'aud' (audience) claim.Expected " - + $"{ScopedProjectId} but got {payload.Audience}. {projectIdMessage} "; - } - else if (!(payload.Issuer is not null) || !payload.Issuer.StartsWith(AppCheckIssuer)) - { - error = "The provided App Check token has incorrect 'iss' (issuer) claim."; - } - else if (string.IsNullOrEmpty(payload.Subject)) - { - error = $"Firebase has no or empty subject (sub) claim."; - } + return await this.apiClient.ExchangeTokenAsync(customToken, appId).ConfigureAwait(false); + } - if (error != null) + /// + /// 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 InvalidOperationException("invalid - argument" + error); + throw new ArgumentNullException("App check token " + appCheckToken + " must be a non - empty string."); } - byte[] hash; - using (var hashAlg = SHA256.Create()) + FirebaseToken verifiedToken = await this.appCheckTokenVerifier.VerifyTokenAsync(appCheckToken).ConfigureAwait(false); + bool alreadyConsumed = await this.apiClient.VerifyReplayProtection(verifiedToken).ConfigureAwait(false); + AppCheckVerifyResponse result; + + if (!alreadyConsumed) { - hash = hashAlg.ComputeHash( - Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); + result = new AppCheckVerifyResponse(verifiedToken.AppId, verifiedToken, alreadyConsumed); } - - var signature = JwtUtils.Base64DecodeToBytes(segments[2]); - var verified = cachedKeys.Any(key => - key.Id == header.KeyId && key.RSA.VerifyHash( - hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - if (verified) + else { - 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 Task.FromResult(new FirebaseToken(payload)); + result = new AppCheckVerifyResponse(verifiedToken.AppId, verifiedToken); } - return Task.FromResult(new FirebaseToken(payload)); + return result; } /// - /// Deleted all the apps created so far. Used for unit testing. + /// Deleted all the appChecks created so far. Used for unit testing. /// - public static void Delete() + internal static void DeleteAll() { - lock (cachedKeys) - { - var copy = new List(cachedKeys); - copy.Clear(); - - if (cachedKeys.Count > 0) - { - throw new InvalidOperationException("Failed to delete all apps"); - } - } + FirebaseApp.DeleteAll(); + appChecks.Clear(); } } }