From 8aa2ee6a12b85aae3a27e06fb88f31b3628e5c7a Mon Sep 17 00:00:00 2001 From: pavlo <taeul930217@skiff.com> Date: Tue, 16 Jan 2024 02:21:03 -0500 Subject: [PATCH 1/6] migrate appcheck --- .../FirebaseAdmin/FirebaseAppCheck.cs | 167 ++++++++++++++++++ FirebaseAdmin/FirebaseAdmin/Key.cs | 39 ++++ FirebaseAdmin/FirebaseAdmin/KeysRoot.cs | 16 ++ 3 files changed, 222 insertions(+) create mode 100644 FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Key.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/KeysRoot.cs diff --git a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs new file mode 100644 index 00000000..764429a2 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs @@ -0,0 +1,167 @@ +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 FirebaseAdmin.Auth; +using FirebaseAdmin.Auth.Jwt; +using Google.Apis.Auth; +using Newtonsoft.Json; +using RSAKey = System.Security.Cryptography.RSA; + +namespace FirebaseAdmin +{ + internal class FirebaseAppCheck + { + private readonly string appCheckIssuer = "https://firebaseappcheck.googleapis.com/"; + private readonly string jwksUrl = "https://firebaseappcheck.googleapis.com/v1/jwks"; + private Dictionary<string, FirebaseToken> appCheck = new Dictionary<string, FirebaseToken>(); + private string projectId; + private string scopedProjectId; + private List<Auth.Jwt.PublicKey> cachedKeys; + private IReadOnlyList<string> standardClaims = + ImmutableList.Create<string>("iss", "aud", "exp", "iat", "sub", "uid"); + + private FirebaseAppCheck(FirebaseApp app) + { + this.scopedProjectId = "projects/" + this.projectId; + FirebaseTokenVerifier tokenVerifier = FirebaseTokenVerifier.CreateIdTokenVerifier(app); + this.projectId = tokenVerifier.ProjectId; + } + + public static async Task<FirebaseAppCheck> CreateAsync(FirebaseApp app) + { + FirebaseAppCheck appCheck = new (app); + bool result = await appCheck.Init().ConfigureAwait(false); // If Init fails, handle it accordingly + if (!result) + { + return appCheck; + throw new ArgumentException("Error App check initilaization "); + } + + return appCheck; + } + + public async Task<bool> Init() + { + try + { + using var client = new HttpClient(); + HttpResponseMessage response = await client.GetAsync(this.jwksUrl).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.OK) + { + string responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + KeysRoot keysRoot = JsonConvert.DeserializeObject<KeysRoot>(responseString); + foreach (Key key in keysRoot.Keys) + { + var x509cert = new X509Certificate2(Encoding.UTF8.GetBytes(key.N)); + RSAKey rsa = x509cert.GetRSAPublicKey(); + this.cachedKeys.Add(new Auth.Jwt.PublicKey(key.Kid, rsa)); + } + + this.cachedKeys.ToImmutableList(); + return true; + } + else + { + throw new ArgumentException("Error Http request JwksUrl"); + } + } + catch (Exception exception) + { + throw new ArgumentException("Error Http request", exception); + } + } + + public async Task<Dictionary<string, FirebaseToken>> VerifyTokenAsync(string token) + { + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException("App check token " + token + " must be a non - empty string."); + } + + try + { + FirebaseToken verified_claims = await this.Decode_and_verify(token).ConfigureAwait(false); + Dictionary<string, FirebaseToken> appchecks = new (); + appchecks.Add(this.projectId, verified_claims); + return appchecks; + } + catch (Exception exception) + { + throw new ArgumentException("Verifying App Check token failed. Error:", exception); + } + } + + private Task<FirebaseToken> Decode_and_verify(string token) + { + string[] segments = token.Split('.'); + if (segments.Length != 3) + { + throw new ArgumentException("Incorrect number of segments in Token"); + } + + var header = JwtUtils.Decode<JsonWebSignature.Header>(segments[0]); + var payload = JwtUtils.Decode<FirebaseToken.Args>(segments[1]); + var projectIdMessage = $"Make sure the comes from the same Firebase " + + "project as the credential used to initialize this SDK."; + string issuer = this.appCheckIssuer + this.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(this.scopedProjectId)) + { + error = "The provided App Check token has incorrect 'aud' (audience) claim.Expected " + + $"{this.scopedProjectId} but got {payload.Audience}. {projectIdMessage} "; + } + else if (!(payload.Issuer is not null) || !payload.Issuer.StartsWith(this.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."; + } + + if (error != null) + { + throw new ArgumentException("invalid - argument" + error); + } + + byte[] hash; + using (var hashAlg = SHA256.Create()) + { + hash = hashAlg.ComputeHash( + Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); + } + + var signature = JwtUtils.Base64DecodeToBytes(segments[2]); + var verified = this.cachedKeys.Any(key => + key.Id == header.KeyId && key.RSA.VerifyHash( + hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + if (verified) + { + var allClaims = JwtUtils.Decode<Dictionary<string, object>>(segments[1]); + + // Remove standard claims, so that only custom claims would remain. + foreach (var claim in this.standardClaims) + { + allClaims.Remove(claim); + } + + payload.Claims = allClaims.ToImmutableDictionary(); + return Task.FromResult(new FirebaseToken(payload)); + } + + return Task.FromResult(new FirebaseToken(payload)); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Key.cs b/FirebaseAdmin/FirebaseAdmin/Key.cs new file mode 100644 index 00000000..e664acc0 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Key.cs @@ -0,0 +1,39 @@ +using System; + +namespace FirebaseAdmin +{ /// <summary> + /// Represents a cryptographic key. + /// </summary> + public class Key + { + /// <summary> + /// Gets or sets the key type. + /// </summary> + public string Kty { get; set; } + + /// <summary> + /// Gets or sets the intended use of the key. + /// </summary> + public string Use { get; set; } + + /// <summary> + /// Gets or sets the algorithm associated with the key. + /// </summary> + public string Alg { get; set; } + + /// <summary> + /// Gets or sets the key ID. + /// </summary> + public string Kid { get; set; } + + /// <summary> + /// Gets or sets the modulus for the RSA public key. + /// </summary> + public string N { get; set; } + + /// <summary> + /// Gets or sets the exponent for the RSA public key. + /// </summary> + public string E { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/KeysRoot.cs b/FirebaseAdmin/FirebaseAdmin/KeysRoot.cs new file mode 100644 index 00000000..82a44c15 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/KeysRoot.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace FirebaseAdmin +{ + /// <summary> + /// Represents a cryptographic key. + /// </summary> + public class KeysRoot + { + /// <summary> + /// Gets or sets represents a cryptographic key. + /// </summary> + public List<Key> Keys { get; set; } + } +} From 7760b874055b72d23c15fc5281aaeb94c9a6a7be Mon Sep 17 00:00:00 2001 From: pavlo <taeul930217@skiff.com> Date: Fri, 19 Jan 2024 03:20:03 -0500 Subject: [PATCH 2/6] feature/AppcheckTest --- .../FirebaseAppCheckTests.cs | 76 +++++++++ .../FirebaseAdmin.Tests/resources/appid.txt | 1 + .../FirebaseAdmin/Auth/FirebaseToken.cs | 6 + .../FirebaseAdmin/Check/AppCheckApiClient.cs | 107 ++++++++++++ .../FirebaseAdmin/Check/AppCheckService.cs | 50 ++++++ .../FirebaseAdmin/Check/AppCheckToken.cs | 27 +++ .../Check/AppCheckTokenGernerator.cs | 90 ++++++++++ .../Check/AppCheckTokenOptions.cs | 22 +++ .../FirebaseAdmin/Check/CyptoSigner.cs | 71 ++++++++ .../FirebaseAdmin/FirebaseAppCheck.cs | 158 ++++++++++++------ 10 files changed, 553 insertions(+), 55 deletions(-) create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenOptions.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs new file mode 100644 index 00000000..685bc334 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using FirebaseAdmin; +using FirebaseAdmin.Auth; +using FirebaseAdmin.Check; +using Google.Apis.Auth.OAuth2; +using Xunit; + +namespace FirebaseAdmin.Tests +{ + public class FirebaseAppCheckTests : IDisposable + { + [Fact] + public async Task CreateTokenFromAppId() + { + string filePath = @"C:\path\to\your\file.txt"; + string fileContent = File.ReadAllText(filePath); + string[] appIds = fileContent.Split(','); + foreach (string appId in appIds) + { + var token = await FirebaseAppCheck.CreateToken(appId); + Assert.IsType<string>(token.Token); + Assert.NotNull(token.Token); + Assert.IsType<int>(token.TtlMillis); + Assert.Equal<int>(3600000, token.TtlMillis); + } + } + + [Fact] + public async Task CreateTokenFromAppIdAndTtlMillis() + { + string filePath = @"C:\path\to\your\file.txt"; + string fileContent = File.ReadAllText(filePath); + string[] appIds = fileContent.Split(','); + foreach (string appId in appIds) + { + AppCheckTokenOptions options = new (1800000); + var token = await FirebaseAppCheck.CreateToken(appId, options); + Assert.IsType<string>(token.Token); + Assert.NotNull(token.Token); + Assert.IsType<int>(token.TtlMillis); + Assert.Equal<int>(1800000, token.TtlMillis); + } + } + + [Fact] + public async Task InvalidAppIdCreate() + { + await Assert.ThrowsAsync<ArgumentException>(() => FirebaseAppCheck.CreateToken(appId: null)); + await Assert.ThrowsAsync<ArgumentException>(() => FirebaseAppCheck.CreateToken(appId: string.Empty)); + } + + [Fact] + public async Task DecodeVerifyToken() + { + 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);*/ + } + + [Fact] + public async Task DecodeVerifyTokenInvaild() + { + await Assert.ThrowsAsync<ArgumentException>(() => FirebaseAppCheck.Decode_and_verify(token: null)); + await Assert.ThrowsAsync<ArgumentException>(() => FirebaseAppCheck.Decode_and_verify(token: string.Empty)); + } + + public void Dispose() + { + FirebaseAppCheck.Delete(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt b/FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt new file mode 100644 index 00000000..233eb2b8 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt @@ -0,0 +1 @@ +1234,project,Appcheck \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs index 101ea38c..535d3451 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Collections.Generic; using Newtonsoft.Json; @@ -80,6 +81,11 @@ internal FirebaseToken(Args args) /// </summary> public IReadOnlyDictionary<string, object> Claims { get; } + public static implicit operator string(FirebaseToken v) + { + throw new NotImplementedException(); + } + internal sealed class Args { [JsonProperty("iss")] diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs new file mode 100644 index 00000000..6c54e3b7 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs @@ -0,0 +1,107 @@ +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 Newtonsoft.Json.Linq; + +namespace FirebaseAdmin.Check +{ + internal class AppCheckApiClient + { + private const string ApiUrlFormat = "https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken"; + private readonly FirebaseApp app; + private string projectId; + private string appId; + + public AppCheckApiClient(FirebaseApp value) + { + if (value == null || value.Options == null) + { + throw new ArgumentException("Argument passed to admin.appCheck() must be a valid Firebase app instance."); + } + + this.app = value; + this.projectId = this.app.Options.ProjectId; + } + + public AppCheckApiClient(string appId) + { + this.appId = appId; + } + + public async Task<AppCheckToken> ExchangeToken(string customToken) + { + if (customToken == null) + { + throw new ArgumentException("First argument passed to customToken must be a valid Firebase app instance."); + } + + if (this.appId == null) + { + throw new ArgumentException("Second argument passed to appId must be a valid Firebase app instance."); + } + + var url = this.GetUrl(this.appId); + var request = new HttpRequestMessage() + { + Method = HttpMethod.Post, + RequestUri = new Uri(url), + Content = new StringContent(customToken), + }; + request.Headers.Add("X-Firebase-Client", "fire-admin-node/${utils.getSdkVersion()}"); + var httpClient = new HttpClient(); + var response = await httpClient.SendAsync(request).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new ArgumentException("Error exchanging token."); + } + + 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; + } + + private string GetUrl(string appId) + { + if (string.IsNullOrEmpty(this.projectId)) + { + this.projectId = this.app.GetProjectId(); + } + + 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); + } + + var urlParams = new Dictionary<string, string> + { + { "projectId", this.projectId }, + { "appId", appId }, + }; + string baseUrl = this.FormatString(ApiUrlFormat, urlParams); + return baseUrl; + } + + private string FormatString(string str, Dictionary<string, string> urlParams) + { + string formatted = str; + foreach (var key in urlParams.Keys) + { + formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + } + + return formatted; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs new file mode 100644 index 00000000..27159b05 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace FirebaseAdmin.Check +{ + /// <summary> + /// Interface representing an App Check token. + /// </summary> + public class AppCheckService + { + private const long OneMinuteInMillis = 60 * 1000; // 60,000 + private const long OneDayInMillis = 24 * 60 * OneMinuteInMillis; // 1,440,000 + + /// <summary> + /// Interface representing an App Check token. + /// </summary> + /// <param name="options"> IDictionary string, object .</param> + /// <returns>IDictionary string object .</returns> + public static Dictionary<string, object> 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<string, object> { { "ttl", TransformMillisecondsToSecondsString(ttlMillis) } }; + } + + return new Dictionary<string, object>(); + } + + private static string TransformMillisecondsToSecondsString(long milliseconds) + { + return (milliseconds / 1000).ToString(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs new file mode 100644 index 00000000..1441631c --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace FirebaseAdmin.Check +{ + /// <summary> + /// Interface representing an App Check token. + /// </summary> + /// <remarks> + /// Initializes a new instance of the <see cref="AppCheckToken"/> class. + /// </remarks> + /// <param name="tokenValue">Generator from custom token.</param> + /// <param name="ttlValue">TTl value .</param> + public class AppCheckToken(string tokenValue, int ttlValue) + { + /// <summary> + /// Gets the Firebase App Check token. + /// </summary> + public string Token { get; } = tokenValue; + + /// <summary> + /// Gets or sets the time-to-live duration of the token in milliseconds. + /// </summary> + public int TtlMillis { get; set; } = ttlValue; + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs new file mode 100644 index 00000000..25069996 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs @@ -0,0 +1,90 @@ +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 +{ + /// <summary> + /// Initializes static members of the <see cref="AppCheckTokenGernerator"/> class. + /// </summary> + public class AppCheckTokenGernerator + { + private static readonly string AppCheckAudience = "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService"; + private readonly CyptoSigner signer; + private string appId; + + /// <summary> + /// Initializes a new instance of the <see cref="AppCheckTokenGernerator"/> class. + /// </summary> + /// <param name="appId">FirebaseApp Id.</param> + public AppCheckTokenGernerator(string appId) + { + this.appId = appId; + } + + /// <summary> + /// Initializes static members of the <see cref="AppCheckTokenGernerator"/> class. + /// </summary> + /// <param name="appId"> FirebaseApp Id.</param> + /// <param name="options"> FirebaseApp AppCheckTokenOptions.</param> + /// <returns> Created token.</returns> + public static string CreateCustomToken(string appId, AppCheckTokenOptions options) + { + var customOptions = new Dictionary<string, string>(); + + 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<string, string>() + { + { "alg", "RS256" }, + { "typ", "JWT" }, + }; + var iat = Math.Floor(DateTime.now() / 1000); + var payload = new Dictionary<string, string>() + { + { "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/AppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenOptions.cs new file mode 100644 index 00000000..00e61daa --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenOptions.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace FirebaseAdmin.Check +{ + /// <summary> + /// Interface representing App Check token options. + /// </summary> + /// <remarks> + /// Initializes a new instance of the <see cref="AppCheckTokenOptions"/> class. + /// </remarks> + /// <param name="v">ttlMillis.</param> + public class AppCheckTokenOptions(int v) + { + /// <summary> + /// 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. + /// </summary> + public int TtlMillis { get; set; } = v; + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs b/FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs new file mode 100644 index 00000000..d04fd544 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Check/CyptoSigner.cs @@ -0,0 +1,71 @@ +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 +{ + /// <summary> + /// Interface representing App Check token options. + /// </summary> + public class CyptoSigner + { + private readonly RSA Rsa; + private readonly ServiceAccountCredential credential; + + /// <summary> + /// Initializes a new instance of the <see cref="CyptoSigner"/> class. + /// Interface representing App Check token options. + /// </summary> + public CyptoSigner(string privateKeyPem) + { + if (privateKeyPem is null) + { + throw new ArgumentNullException(nameof(privateKeyPem)); + } + + this.Rsa = RSA.Create(); + this.Rsa.ImportFromPem(privateKeyPem.ToCharArray()); + } + + /// <summary> + /// Initializes a new instance of the <see cref="CyptoSigner"/> class. + /// Cryptographically signs a buffer of data. + /// </summary> + /// <param name="buffer">To sign data.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + public Task<byte[]> 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<string> 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/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs index 764429a2..30642e51 100644 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs @@ -10,49 +10,96 @@ using System.Threading.Tasks; using FirebaseAdmin.Auth; using FirebaseAdmin.Auth.Jwt; +using FirebaseAdmin.Check; using Google.Apis.Auth; using Newtonsoft.Json; using RSAKey = System.Security.Cryptography.RSA; namespace FirebaseAdmin { - internal class FirebaseAppCheck + /// <summary> + /// Asynchronously creates a new Firebase App Check token for the specified Firebase app. + /// </summary> + /// <returns>A task that completes with the creation of a new App Check token.</returns> + /// <exception cref="FirebaseAppCheckException">Thrown if an error occurs while creating the custom token.</exception> + public sealed class FirebaseAppCheck { - private readonly string appCheckIssuer = "https://firebaseappcheck.googleapis.com/"; - private readonly string jwksUrl = "https://firebaseappcheck.googleapis.com/v1/jwks"; - private Dictionary<string, FirebaseToken> appCheck = new Dictionary<string, FirebaseToken>(); - private string projectId; - private string scopedProjectId; - private List<Auth.Jwt.PublicKey> cachedKeys; - private IReadOnlyList<string> standardClaims = + 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<string> StandardClaims = ImmutableList.Create<string>("iss", "aud", "exp", "iat", "sub", "uid"); - private FirebaseAppCheck(FirebaseApp app) + private static List<Auth.Jwt.PublicKey> cachedKeys; + + /// <summary> + /// Creates a new {@link AppCheckToken} that can be sent back to a client. + /// </summary> + /// <param name="appId">ID of Firebase App.</param> + /// <param name="options">Options of FirebaseApp.</param> + /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> + public static async Task<AppCheckToken> CreateToken(string appId, AppCheckTokenOptions options = null) { - this.scopedProjectId = "projects/" + this.projectId; - FirebaseTokenVerifier tokenVerifier = FirebaseTokenVerifier.CreateIdTokenVerifier(app); - this.projectId = tokenVerifier.ProjectId; + 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); } - public static async Task<FirebaseAppCheck> CreateAsync(FirebaseApp app) + /// <summary> + /// Verifies the format and signature of a Firebase App Check token. + /// </summary> + /// <param name="token"> The Firebase Auth JWT token to verify.</param> + /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> + public static async Task<Dictionary<string, FirebaseToken>> VerifyTokenAsync(string token) { - FirebaseAppCheck appCheck = new (app); - bool result = await appCheck.Init().ConfigureAwait(false); // If Init fails, handle it accordingly - if (!result) + if (string.IsNullOrEmpty(token)) { - return appCheck; - throw new ArgumentException("Error App check initilaization "); + throw new ArgumentNullException("App check token " + token + " must be a non - empty string."); } - return appCheck; + try + { + FirebaseToken verified_claims = await Decode_and_verify(token).ConfigureAwait(false); + Dictionary<string, FirebaseToken> appchecks = new (); + appchecks.Add(ProjectId, verified_claims); + return appchecks; + } + catch (Exception exception) + { + throw new ArgumentNullException("Verifying App Check token failed. Error:", exception); + } } - public async Task<bool> Init() + /// <summary> + /// Get public key from jwksUrl. + /// </summary> + /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> + public static async Task InitializeAsync() { try { using var client = new HttpClient(); - HttpResponseMessage response = await client.GetAsync(this.jwksUrl).ConfigureAwait(false); + HttpResponseMessage response = await client.GetAsync(JwksUrl).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.OK) { string responseString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -61,68 +108,52 @@ public async Task<bool> Init() { var x509cert = new X509Certificate2(Encoding.UTF8.GetBytes(key.N)); RSAKey rsa = x509cert.GetRSAPublicKey(); - this.cachedKeys.Add(new Auth.Jwt.PublicKey(key.Kid, rsa)); + cachedKeys.Add(new Auth.Jwt.PublicKey(key.Kid, rsa)); } - this.cachedKeys.ToImmutableList(); - return true; + cachedKeys.ToImmutableList(); } else { - throw new ArgumentException("Error Http request JwksUrl"); + throw new ArgumentNullException("Error Http request JwksUrl"); } } catch (Exception exception) { - throw new ArgumentException("Error Http request", exception); + throw new ArgumentNullException("Error Http request", exception); } } - public async Task<Dictionary<string, FirebaseToken>> VerifyTokenAsync(string token) - { - if (string.IsNullOrEmpty(token)) - { - throw new ArgumentException("App check token " + token + " must be a non - empty string."); - } - - try - { - FirebaseToken verified_claims = await this.Decode_and_verify(token).ConfigureAwait(false); - Dictionary<string, FirebaseToken> appchecks = new (); - appchecks.Add(this.projectId, verified_claims); - return appchecks; - } - catch (Exception exception) - { - throw new ArgumentException("Verifying App Check token failed. Error:", exception); - } - } - - private Task<FirebaseToken> Decode_and_verify(string token) + /// <summary> + /// Decode_and_verify. + /// </summary> + /// <param name="token">The Firebase Auth JWT token to verify.</param> + /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> + public static Task<FirebaseToken> Decode_and_verify(string token) { string[] segments = token.Split('.'); if (segments.Length != 3) { - throw new ArgumentException("Incorrect number of segments in Token"); + throw new FirebaseAppCheckException("Incorrect number of segments in Token"); } var header = JwtUtils.Decode<JsonWebSignature.Header>(segments[0]); var payload = JwtUtils.Decode<FirebaseToken.Args>(segments[1]); var projectIdMessage = $"Make sure the comes from the same Firebase " + "project as the credential used to initialize this SDK."; - string issuer = this.appCheckIssuer + this.projectId; + 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(this.scopedProjectId)) + else if (payload.Audience.Contains(ScopedProjectId)) { error = "The provided App Check token has incorrect 'aud' (audience) claim.Expected " - + $"{this.scopedProjectId} but got {payload.Audience}. {projectIdMessage} "; + + $"{ScopedProjectId} but got {payload.Audience}. {projectIdMessage} "; } - else if (!(payload.Issuer is not null) || !payload.Issuer.StartsWith(this.appCheckIssuer)) + else if (!(payload.Issuer is not null) || !payload.Issuer.StartsWith(AppCheckIssuer)) { error = "The provided App Check token has incorrect 'iss' (issuer) claim."; } @@ -133,7 +164,7 @@ private Task<FirebaseToken> Decode_and_verify(string token) if (error != null) { - throw new ArgumentException("invalid - argument" + error); + throw new InvalidOperationException("invalid - argument" + error); } byte[] hash; @@ -144,7 +175,7 @@ private Task<FirebaseToken> Decode_and_verify(string token) } var signature = JwtUtils.Base64DecodeToBytes(segments[2]); - var verified = this.cachedKeys.Any(key => + var verified = cachedKeys.Any(key => key.Id == header.KeyId && key.RSA.VerifyHash( hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); if (verified) @@ -152,7 +183,7 @@ private Task<FirebaseToken> Decode_and_verify(string token) var allClaims = JwtUtils.Decode<Dictionary<string, object>>(segments[1]); // Remove standard claims, so that only custom claims would remain. - foreach (var claim in this.standardClaims) + foreach (var claim in StandardClaims) { allClaims.Remove(claim); } @@ -163,5 +194,22 @@ private Task<FirebaseToken> Decode_and_verify(string token) return Task.FromResult(new FirebaseToken(payload)); } + + /// <summary> + /// Deleted all the apps created so far. Used for unit testing. + /// </summary> + public static void Delete() + { + lock (cachedKeys) + { + var copy = new List<Auth.Jwt.PublicKey>(cachedKeys); + copy.Clear(); + + if (cachedKeys.Count > 0) + { + throw new InvalidOperationException("Failed to delete all apps"); + } + } + } } } From bc8360f4eb4a32bfacc61e296a8fd6a61a5dbb08 Mon Sep 17 00:00:00 2001 From: pavlo <taeul930217@skiff.com> Date: Tue, 23 Jan 2024 04:21:31 -0500 Subject: [PATCH 3/6] 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<string>(token.Token); - Assert.NotNull(token.Token); - Assert.IsType<int>(token.TtlMillis); - Assert.Equal<int>(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<ArgumentException>(() => invalidAppIdCreate.CreateToken(appId: null)); + await Assert.ThrowsAsync<ArgumentException>(() => 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<string>(token.Token); - Assert.NotNull(token.Token); - Assert.IsType<int>(token.TtlMillis); - Assert.Equal<int>(1800000, token.TtlMillis); - } + Credential = GoogleCredential.FromAccessToken("token"), + }; + var app = FirebaseApp.Create(options, "1234"); + + Assert.Throws<ArgumentException>(() => FirebaseAppCheck.Create(app)); + } + + [Fact] + public async Task CreateTokenFromAppId() + { + FirebaseAppCheck createTokenFromAppId = new FirebaseAppCheck(this.mockCredentialApp); + var token = await createTokenFromAppId.CreateToken(this.appId); + Assert.IsType<string>(token.Token); + Assert.NotNull(token.Token); + Assert.IsType<int>(token.TtlMillis); + Assert.Equal<int>(3600000, token.TtlMillis); } [Fact] - public async Task InvalidAppIdCreate() + public async Task CreateTokenFromAppIdAndTtlMillis() { - await Assert.ThrowsAsync<ArgumentException>(() => FirebaseAppCheck.CreateToken(appId: null)); - await Assert.ThrowsAsync<ArgumentException>(() => 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<string>(token.Token); + Assert.NotNull(token.Token); + Assert.IsType<int>(token.TtlMillis); + Assert.Equal<int>(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<ArgumentException>(() => FirebaseAppCheck.Decode_and_verify(token: null)); - await Assert.ThrowsAsync<ArgumentException>(() => FirebaseAppCheck.Decode_and_verify(token: string.Empty)); + FirebaseAppCheck verifyTokenInvaild = new FirebaseAppCheck(this.mockCredentialApp); + + await Assert.ThrowsAsync<ArgumentException>(() => verifyTokenInvaild.VerifyToken(null)); + await Assert.ThrowsAsync<ArgumentException>(() => 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) /// </summary> public string Uid { get; } + /// <summary> + /// Gets the Id of the Firebase . + /// </summary> + public string AppId { get; } + /// <summary> /// 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) /// </summary> public IReadOnlyDictionary<string, object> Claims { get; } + /// <summary> + /// Defined operator string. + /// </summary> + /// <param name="v">FirebaseToken.</param> 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 { /// <summary> - /// Interface representing App Check token options. + /// Representing App Check token options. /// </summary> /// <remarks> /// Initializes a new instance of the <see cref="AppCheckTokenOptions"/> class. /// </remarks> - /// <param name="v">ttlMillis.</param> - public class AppCheckTokenOptions(int v) + /// <param name="ttl">ttlMillis.</param> + public class AppCheckTokenOptions(int ttl) { /// <summary> /// 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. /// </summary> - 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<string> CreateCustomTokenAsync( header, payload, this.Signer, cancellationToken).ConfigureAwait(false); } + internal async Task<string> 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<string, object> 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<FirebaseToken> 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<IReadOnlyList<PublicKey>> GetPublicKeysAsync( Method = HttpMethod.Get, RequestUri = new Uri(this.certUrl), }; + var response = await httpClient .SendAndDeserializeAsync<Dictionary<string, string>>(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<PublicKey> ParseKeys(DeserializedResponseInfo<Dictionary<s return builder.ToImmutableList(); } + private async Task<IReadOnlyList<PublicKey>> 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<KeysRoot>(responseString); + var builder = ImmutableList.CreateBuilder<PublicKey>(); + 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<FirebaseAuthException>, IHttpRequestExceptionHandler<FirebaseAuthException>, 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 { + /// <summary> + /// Class that facilitates sending requests to the Firebase App Check backend API. + /// </summary> + /// <returns>A task that completes with the creation of a new App Check token.</returns> + /// <exception cref="ArgumentNullException">Thrown if an error occurs while creating the custom token.</exception> + /// <value>The Firebase app instance.</value> 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; + /// <summary> + /// Initializes a new instance of the <see cref="AppCheckApiClient"/> class. + /// </summary> + /// <param name="value"> Initailize FirebaseApp. </param> 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) + /// <summary> + /// Exchange a signed custom token to App Check token. + /// </summary> + /// <param name="customToken"> The custom token to be exchanged. </param> + /// <param name="appId"> The mobile App ID.</param> + /// <returns>A <see cref="Task{AppCheckToken}"/> A promise that fulfills with a `AppCheckToken`.</returns> + public async Task<AppCheckToken> ExchangeTokenAsync(string customToken, string appId) { - this.appId = appId; - } - - public async Task<AppCheckToken> 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; + } + + /// <summary> + /// Exchange a signed custom token to App Check token. + /// </summary> + /// <param name="customToken"> The custom token to be exchanged. </param> + /// <param name="appId"> The Id of Firebase App. </param> + /// <returns> HttpResponseMessage .</returns> + public async Task<HttpResponseMessage> 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; + } + + /// <summary> + /// Exchange a signed custom token to App Check token. + /// </summary> + /// <param name="token"> The custom token to be exchanged. </param> + /// <returns>A alreadyConsumed is true.</returns> + public async Task<bool> 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; + } + + /// <summary> + /// Get Verify Token Url . + /// </summary> + /// <returns>A formatted verify token url.</returns> + private string GetVerifyTokenUrl() + { + var urlParams = new Dictionary<string, string> + { + { "projectId", this.projectId }, + }; + + string baseUrl = this.FormatString(OneTimeUseTokenVerificationUrlFormat, urlParams); + return this.FormatString(baseUrl, null); } + /// <summary> + /// Get url from FirebaseApp Id . + /// </summary> + /// <param name="appId">The FirebaseApp Id.</param> + /// <returns>A formatted verify token url.</returns> private string GetUrl(string appId) { if (string.IsNullOrEmpty(this.projectId)) @@ -93,6 +176,28 @@ private string GetUrl(string appId) return baseUrl; } + /// <summary> + /// Converts a duration string with the suffix `s` to milliseconds. + /// </summary> + /// <param name="duration">The duration as a string with the suffix "s" preceded by the number of seconds.</param> + /// <returns> The duration in milliseconds.</returns> + 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; + } + + /// <summary> + /// Formats a string of form 'project/{projectId}/{api}' and replaces with corresponding arguments {projectId: '1234', api: 'resource'}. + /// </summary> + /// <param name="str">The original string where the param need to be replaced.</param> + /// <param name="urlParams">The optional parameters to replace in thestring.</param> + /// <returns> The resulting formatted string. </returns> private string FormatString(string str, Dictionary<string, string> 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 -{ - /// <summary> - /// Interface representing an App Check token. - /// </summary> - public class AppCheckService - { - private const long OneMinuteInMillis = 60 * 1000; // 60,000 - private const long OneDayInMillis = 24 * 60 * OneMinuteInMillis; // 1,440,000 - - /// <summary> - /// Interface representing an App Check token. - /// </summary> - /// <param name="options"> IDictionary string, object .</param> - /// <returns>IDictionary string object .</returns> - public static Dictionary<string, object> 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<string, object> { { "ttl", TransformMillisecondsToSecondsString(ttlMillis) } }; - } - - return new Dictionary<string, object>(); - } - - 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 -{ - /// <summary> - /// Initializes static members of the <see cref="AppCheckTokenGernerator"/> class. - /// </summary> - public class AppCheckTokenGernerator - { - private static readonly string AppCheckAudience = "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService"; - private readonly CyptoSigner signer; - private string appId; - - /// <summary> - /// Initializes a new instance of the <see cref="AppCheckTokenGernerator"/> class. - /// </summary> - /// <param name="appId">FirebaseApp Id.</param> - public AppCheckTokenGernerator(string appId) - { - this.appId = appId; - } - - /// <summary> - /// Initializes static members of the <see cref="AppCheckTokenGernerator"/> class. - /// </summary> - /// <param name="appId"> FirebaseApp Id.</param> - /// <param name="options"> FirebaseApp AppCheckTokenOptions.</param> - /// <returns> Created token.</returns> - public static string CreateCustomToken(string appId, AppCheckTokenOptions options) - { - var customOptions = new Dictionary<string, string>(); - - 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<string, string>() - { - { "alg", "RS256" }, - { "typ", "JWT" }, - }; - var iat = Math.Floor(DateTime.now() / 1000); - var payload = new Dictionary<string, string>() - { - { "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 +{ + /// <summary> + /// AppCheckVerifyResponse. + /// </summary> + public class AppCheckVerifyResponse(string appId, FirebaseToken verifiedToken, bool alreadyConsumed = false) + { + /// <summary> + /// Gets or sets a value indicating whether gets the Firebase App Check token. + /// </summary> + public bool AlreadyConsumed { get; set; } = alreadyConsumed; + + /// <summary> + /// Gets or sets the Firebase App Check token. + /// </summary> + public string AppId { get; set; } = appId; + + /// <summary> + /// Gets or sets the Firebase App Check VerifiedToken. + /// </summary> + 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 -{ - /// <summary> - /// Interface representing App Check token options. - /// </summary> - public class CyptoSigner - { - private readonly RSA Rsa; - private readonly ServiceAccountCredential credential; - - /// <summary> - /// Initializes a new instance of the <see cref="CyptoSigner"/> class. - /// Interface representing App Check token options. - /// </summary> - public CyptoSigner(string privateKeyPem) - { - if (privateKeyPem is null) - { - throw new ArgumentNullException(nameof(privateKeyPem)); - } - - this.Rsa = RSA.Create(); - this.Rsa.ImportFromPem(privateKeyPem.ToCharArray()); - } - - /// <summary> - /// Initializes a new instance of the <see cref="CyptoSigner"/> class. - /// Cryptographically signs a buffer of data. - /// </summary> - /// <param name="buffer">To sign data.</param> - /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> - public Task<byte[]> 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<string> 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 +{ + /// <summary> + /// Interface of Firebase App Check backend API . + /// </summary> + internal interface IAppCheckApiClient + { + /// <summary> + /// Exchange a signed custom token to App Check token. + /// </summary> + /// <param name="customToken">The custom token to be exchanged.</param> + /// <param name="appId">The mobile App ID.</param> + /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> + public Task<AppCheckToken> ExchangeTokenAsync(string customToken, string appId); + + /// <summary> + /// Exchange a signed custom token to App Check token. + /// </summary> + /// <param name="token"> The custom token to be exchanged. </param> + /// <returns>A alreadyConsumed is true.</returns> + public Task<bool> VerifyReplayProtection(string token); + + /// <summary> + /// Exchange a signed custom token to App Check token. + /// </summary> + /// <param name="customToken"> The custom token to be exchanged. </param> + /// <param name="appId"> The Id of Firebase App. </param> + /// <returns> HttpResponseMessage .</returns> + public Task<HttpResponseMessage> 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 { /// <summary> /// 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; + +/// <summary> +/// Class representing options for the AppCheck.VerifyToken method. +/// </summary> +public class VerifyAppCheckTokenOptions +{ + /// <summary> + /// 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. + /// </summary> + 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. /// </summary> /// <returns>A task that completes with the creation of a new App Check token.</returns> - /// <exception cref="FirebaseAppCheckException">Thrown if an error occurs while creating the custom token.</exception> + /// <exception cref="ArgumentNullException">Thrown if an error occurs while creating the custom token.</exception> + /// <value>The Firebase app instance.</value> 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<string> StandardClaims = - ImmutableList.Create<string>("iss", "aud", "exp", "iat", "sub", "uid"); + private const string DefaultProjectId = "[DEFAULT]"; + private static Dictionary<string, FirebaseAppCheck> appChecks = new Dictionary<string, FirebaseAppCheck>(); - private static List<Auth.Jwt.PublicKey> cachedKeys; + private readonly AppCheckApiClient apiClient; + private readonly FirebaseTokenVerifier appCheckTokenVerifier; + private readonly FirebaseTokenFactory tokenFactory; /// <summary> - /// Creates a new {@link AppCheckToken} that can be sent back to a client. + /// Initializes a new instance of the <see cref="FirebaseAppCheck"/> class. /// </summary> - /// <param name="appId">ID of Firebase App.</param> - /// <param name="options">Options of FirebaseApp.</param> - /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> - public static async Task<AppCheckToken> CreateToken(string appId, AppCheckTokenOptions options = null) + /// <param name="value"> Initailize FirebaseApp. </param> + 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); } /// <summary> - /// Verifies the format and signature of a Firebase App Check token. + /// Initializes a new instance of the <see cref="FirebaseAppCheck"/> class. /// </summary> - /// <param name="token"> The Firebase Auth JWT token to verify.</param> - /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> - public static async Task<Dictionary<string, FirebaseToken>> VerifyTokenAsync(string token) + /// <param name="app"> Initailize FirebaseApp. </param> + /// <returns>A <see cref="Task{FirebaseAppCheck}"/> Representing the result of the asynchronous operation.</returns> + 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<string, FirebaseToken> 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); - } - } - /// <summary> - /// Get public key from jwksUrl. - /// </summary> - /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns> - 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<KeysRoot>(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; } /// <summary> - /// Decode_and_verify. + /// Creates a new AppCheckToken that can be sent back to a client. /// </summary> - /// <param name="token">The Firebase Auth JWT token to verify.</param> - /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> - public static Task<FirebaseToken> Decode_and_verify(string token) + /// <param name="appId">The app ID to use as the JWT app_id.</param> + /// <param name="options">Optional options object when creating a new App Check Token.</param> + /// <returns>A <see cref="Task{AppCheckToken}"/> Representing the result of the asynchronous operation.</returns> + public async Task<AppCheckToken> 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<JsonWebSignature.Header>(segments[0]); - var payload = JwtUtils.Decode<FirebaseToken.Args>(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) + /// <summary> + /// 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. + /// </summary> + /// <param name="appCheckToken"> TThe App Check token to verify.</param> + /// <param name="options"> Optional VerifyAppCheckTokenOptions object when verifying an App Check Token.</param> + /// <returns>A <see cref="Task{VerifyAppCheckTokenResponse}"/> representing the result of the asynchronous operation.</returns> + public async Task<AppCheckVerifyResponse> 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<Dictionary<string, object>>(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; } /// <summary> - /// Deleted all the apps created so far. Used for unit testing. + /// Deleted all the appChecks created so far. Used for unit testing. /// </summary> - public static void Delete() + internal static void DeleteAll() { - lock (cachedKeys) - { - var copy = new List<Auth.Jwt.PublicKey>(cachedKeys); - copy.Clear(); - - if (cachedKeys.Count > 0) - { - throw new InvalidOperationException("Failed to delete all apps"); - } - } + FirebaseApp.DeleteAll(); + appChecks.Clear(); } } } From e2fabacd85959d605ee3641bd91ba95ec12d4fc1 Mon Sep 17 00:00:00 2001 From: pavlo <taeul930217@skiff.com> Date: Thu, 25 Jan 2024 00:08:54 -0500 Subject: [PATCH 4/6] feature/appcheck/unit-test --- .../FirebaseAdmin.IntegrationTests.csproj | 4 +- .../FirebaseAdmin.Snippets.csproj | 2 +- .../AppCheck/AppCheckApiClientTest.cs | 211 ++++++++++++++ .../AppCheck/AppCheckTokenGeneratorTest.cs | 112 ++++++++ .../AppCheck/AppCheckTokenVerifierTest.cs | 138 +++++++++ .../Auth/Jwt/CustomTokenVerifier.cs | 1 + .../Auth/Jwt/JwtTestUtils.cs | 3 + .../Auth/Jwt/ServiceAccountSignerTest.cs | 1 + .../FirebaseAdmin.Tests.csproj | 72 ++--- .../FirebaseAppCheckTests.cs | 137 +++++++-- .../{Check => AppCheck}/AppCheckApiClient.cs | 32 +-- .../{Check => AppCheck}/AppCheckToken.cs | 0 .../AppCheck/AppCheckTokenGenerator.cs | 190 +++++++++++++ .../Jwt => AppCheck}/AppCheckTokenOptions.cs | 0 .../AppCheck/AppCheckTokenVerify.cs | 263 ++++++++++++++++++ .../AppCheckVerifyResponse.cs | 2 +- .../{Check => AppCheck}/IAppCheckApiClient.cs | 10 +- .../AppCheck/IAppCheckTokenGenerator.cs | 34 +++ .../AppCheck/IAppCheckTokenVerify.cs | 31 +++ .../FirebaseAdmin/{Check => AppCheck}/Key.cs | 0 .../{Check => AppCheck}/KeysRoot.cs | 0 .../VerifyAppCheckTokenOptions.cs | 0 .../FirebaseAdmin/Auth/Jwt/ISigner.cs | 6 + .../FirebaseAdmin/FirebaseAppCheck.cs | 15 +- 24 files changed, 1159 insertions(+), 105 deletions(-) create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/AppCheckApiClient.cs (89%) rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/AppCheckToken.cs (100%) create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs rename FirebaseAdmin/FirebaseAdmin/{Auth/Jwt => AppCheck}/AppCheckTokenOptions.cs (100%) create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/AppCheckVerifyResponse.cs (86%) rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/IAppCheckApiClient.cs (69%) create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/Key.cs (100%) rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/KeysRoot.cs (100%) rename FirebaseAdmin/FirebaseAdmin/{Check => AppCheck}/VerifyAppCheckTokenOptions.cs (100%) diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj index 5c839131..278c7551 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseAdmin.IntegrationTests.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netcoreapp2.1</TargetFramework> + <TargetFramework>net7.0</TargetFramework> <LangVersion>latest</LangVersion> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> @@ -30,4 +30,4 @@ </None> </ItemGroup> -</Project> +</Project> \ No newline at end of file diff --git a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj index 75cedf49..b9161a93 100644 --- a/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj +++ b/FirebaseAdmin/FirebaseAdmin.Snippets/FirebaseAdmin.Snippets.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netcoreapp2.1</TargetFramework> + <TargetFramework>net7.0</TargetFramework> <IsPackable>false</IsPackable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <CodeAnalysisRuleSet>../../stylecop_test.ruleset</CodeAnalysisRuleSet> diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs new file mode 100644 index 00000000..c6f0b584 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckApiClientTest.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FirebaseAdmin.Check; +using Google.Apis.Auth.OAuth2; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Moq; +using Xunit; + +namespace FirebaseAdmin.Tests.AppCheck +{ + public class AppCheckApiClientTest + { + 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() + { + Assert.Throws<ArgumentException>(() => new AppCheckApiClient(null)); + } + + [Fact] + public async Task ExchangeTokenNoProjectId() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException(this.noProjectId)); + var result = await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + Assert.Equal(this.noProjectId, result.Message); + } + + [Fact] + public async Task ExchangeTokenInvalidAppId() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException(this.noProjectId)); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, string.Empty)); + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, null)); + } + + [Fact] + public async Task ExchangeTokenInvalidCustomTokenAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException(this.noProjectId)); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(string.Empty, this.appId)); + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(null, this.appId)); + } + + [Fact] + public async Task ExchangeTokenFullErrorResponseAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException("not-found", "Requested entity not found")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + } + + [Fact] + public async Task ExchangeTokenErrorCodeAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException("unknown-error", "Unknown server error: {}")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + } + + [Fact] + public async Task ExchangeTokenFullNonJsonAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException("unknown-error", "Unexpected response with status: 404 and body: not json")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + } + + [Fact] + public async Task ExchangeTokenAppErrorAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .Throws(new ArgumentException("network-error", "socket hang up")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(string.Empty, this.appId)); + } + + [Fact] + public async Task ExchangeTokenOnSuccessAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .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); + } + + [Fact] + public async Task VerifyReplayNoProjectIdAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException(this.noProjectId)); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + } + + [Fact] + public async Task VerifyReplayInvaildTokenAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException(this.noProjectId)); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(string.Empty)); + } + + [Fact] + public async Task VerifyReplayFullErrorAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException("not-found", "Requested entity not found")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + } + + [Fact] + public async Task VerifyReplayErrorCodeAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException("unknown-error", "Unknown server error: {}")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + } + + [Fact] + public async Task VerifyReplayNonJsonAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException("unknown-error", "Unexpected response with status: 404 and body: not json")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + } + + [Fact] + public async Task VerifyReplayFirebaseAppErrorAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException("network-error", "socket hang up")); + + await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + } + + [Fact] + public async Task VerifyReplayAlreadyTrueAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .ReturnsAsync(true); + + bool res = await appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange).ConfigureAwait(false); + Assert.True(res); + } + + [Fact] + public async Task VerifyReplayAlreadyFlaseAsync() + { + var appCheckApiClient = new Mock<IAppCheckApiClient>(); + + appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .ReturnsAsync(true); + + bool res = await appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange).ConfigureAwait(false); + Assert.True(res); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs new file mode 100644 index 00000000..96712720 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs @@ -0,0 +1,112 @@ +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<object[]> InvalidStrings = new List<object[]> + { + 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<ArgumentException>(() => tokenGenerator.CreateCustomTokenAsync(string.Empty)); + await Assert.ThrowsAsync<ArgumentException>(() => 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<ArgumentException>(() => + 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 new file mode 100644 index 00000000..80bc34c9 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenVerifierTest.cs @@ -0,0 +1,138 @@ +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 Xunit; + +namespace FirebaseAdmin.Tests.AppCheck +{ + public class AppCheckTokenVerifierTest + { + public static readonly IEnumerable<object[]> InvalidStrings = new List<object[]> + { + new object[] { null }, + new object[] { string.Empty }, + }; + + private static readonly GoogleCredential MockCredential = + GoogleCredential.FromAccessToken("test-token"); + + [Fact] + public void ProjectIdFromOptions() + { + var app = FirebaseApp.Create(new AppOptions() + { + Credential = MockCredential, + ProjectId = "explicit-project-id", + }); + var verifier = AppCheckTokenVerify.Create(app); + Assert.Equal("explicit-project-id", 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); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public void InvalidProjectId(string projectId) + { + var args = FullyPopulatedArgs(); + args.ProjectId = projectId; + + Assert.Throws<ArgumentException>(() => new AppCheckTokenVerify(args)); + } + + [Fact] + public void NullKeySource() + { + var args = FullyPopulatedArgs(); + args.PublicKeySource = null; + + Assert.Throws<ArgumentNullException>(() => new AppCheckTokenVerify(args)); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public void InvalidShortName(string shortName) + { + var args = FullyPopulatedArgs(); + args.ShortName = shortName; + + Assert.Throws<ArgumentException>(() => new AppCheckTokenVerify(args)); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public void InvalidIssuer(string issuer) + { + var args = FullyPopulatedArgs(); + args.Issuer = issuer; + + Assert.Throws<ArgumentException>(() => new AppCheckTokenVerify(args)); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public void InvalidOperation(string operation) + { + var args = FullyPopulatedArgs(); + args.Operation = operation; + + Assert.Throws<ArgumentException>(() => new AppCheckTokenVerify(args)); + } + + [Theory] + [MemberData(nameof(InvalidStrings))] + public void InvalidUrl(string url) + { + var args = FullyPopulatedArgs(); + args.Url = url; + + Assert.Throws<ArgumentException>(() => new AppCheckTokenVerify(args)); + } + + [Fact] + public void ProjectId() + { + var args = FullyPopulatedArgs(); + + var verifier = new AppCheckTokenVerify(args); + + Assert.Equal("test-project", verifier.ProjectId); + } + + [Fact] + public void Dispose() + { + FirebaseApp.DeleteAll(); + } + + private static FirebaseTokenVerifierArgs FullyPopulatedArgs() + { + return new FirebaseTokenVerifierArgs + { + ProjectId = "test-project", + ShortName = "short name", + Operation = "VerifyToken()", + Url = "https://firebase.google.com", + Issuer = "https://firebase.google.com/", + PublicKeySource = JwtTestUtils.DefaultKeySource, + }; + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs index 314b28d7..d572a2eb 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/CustomTokenVerifier.cs @@ -18,6 +18,7 @@ using System.Text; using Google.Apis.Auth; using Xunit; +#pragma warning disable SYSLIB0027 namespace FirebaseAdmin.Auth.Jwt.Tests { diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs index db24f856..e57bbc46 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/JwtTestUtils.cs @@ -20,6 +20,7 @@ using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; +using FirebaseAdmin.Auth.Jwt; using FirebaseAdmin.Auth.Tests; using FirebaseAdmin.Tests; using FirebaseAdmin.Util; @@ -27,6 +28,8 @@ using Google.Apis.Util; using Xunit; +#pragma warning disable SYSLIB0027 + namespace FirebaseAdmin.Auth.Jwt.Tests { public sealed class JwtTestUtils diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs index 630b688a..41ce2009 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Auth/Jwt/ServiceAccountSignerTest.cs @@ -20,6 +20,7 @@ using System.Threading.Tasks; using Google.Apis.Auth.OAuth2; using Xunit; +#pragma warning disable SYSLIB0027 namespace FirebaseAdmin.Auth.Jwt.Tests { diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj index 974f2237..c17a0b9f 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAdmin.Tests.csproj @@ -1,41 +1,41 @@ <Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net7.0</TargetFramework> + <LangVersion>latest</LangVersion> + <IsPackable>false</IsPackable> + <AssemblyOriginatorKeyFile>../../FirebaseAdmin.snk</AssemblyOriginatorKeyFile> + <SignAssembly>true</SignAssembly> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + <CodeAnalysisRuleSet>../../stylecop_test.ruleset</CodeAnalysisRuleSet> + </PropertyGroup> - <PropertyGroup> - <TargetFramework>netcoreapp2.1</TargetFramework> - <LangVersion>latest</LangVersion> - <IsPackable>false</IsPackable> - <AssemblyOriginatorKeyFile>../../FirebaseAdmin.snk</AssemblyOriginatorKeyFile> - <SignAssembly>true</SignAssembly> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - <CodeAnalysisRuleSet>../../stylecop_test.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> + <ItemGroup> + <PackageReference Include="coverlet.msbuild" Version="2.9.0"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Google.Apis.Auth" Version="1.49.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> + <PackageReference Include="Moq" Version="4.20.70" /> + <PackageReference Include="System.Linq.Async" Version="4.1.1" /> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" /> + <PackageReference Include="StyleCop.Analyzers" Version="1.1.1-beta.61"> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> - <ItemGroup> - <PackageReference Include="coverlet.msbuild" Version="2.9.0"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <PackageReference Include="Google.Apis.Auth" Version="1.49.0" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> - <PackageReference Include="System.Linq.Async" Version="4.1.1" /> - <PackageReference Include="xunit" Version="2.4.1" /> - <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.1-beta.61"> - <PrivateAssets>all</PrivateAssets> - </PackageReference> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\FirebaseAdmin\FirebaseAdmin.csproj" /> - </ItemGroup> - <ItemGroup> - <None Update=".\resources\*"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\FirebaseAdmin\FirebaseAdmin.csproj" /> + </ItemGroup> + <ItemGroup> + <None Update=".\resources\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> </Project> diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs index 53814bd9..7ffa46a0 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs @@ -1,18 +1,28 @@ 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 @@ -31,19 +41,16 @@ public FirebaseAppCheckTests() } [Fact] - public void CreateAppCheck() + public void CreateInvalidApp() { - FirebaseAppCheck withoutAppIdCreate = FirebaseAppCheck.Create(this.mockCredentialApp); - Assert.NotNull(withoutAppIdCreate); + Assert.Throws<ArgumentNullException>(() => FirebaseAppCheck.Create(null)); } [Fact] - public async Task InvalidAppIdCreateToken() + public void CreateAppCheck() { - FirebaseAppCheck invalidAppIdCreate = FirebaseAppCheck.Create(this.mockCredentialApp); - - await Assert.ThrowsAsync<ArgumentException>(() => invalidAppIdCreate.CreateToken(appId: null)); - await Assert.ThrowsAsync<ArgumentException>(() => invalidAppIdCreate.CreateToken(appId: string.Empty)); + FirebaseAppCheck withoutAppIdCreate = FirebaseAppCheck.Create(this.mockCredentialApp); + Assert.NotNull(withoutAppIdCreate); } [Fact] @@ -63,37 +70,115 @@ public void WithoutProjectIDCreate() } [Fact] - public async Task CreateTokenFromAppId() + public void FailedSignCreateToken() { + string expected = "sign error"; + var createTokenMock = new Mock<ISigner>(); + + // Setup the mock to throw an exception when SignDataAsync is called + createTokenMock.Setup(service => service.SignDataAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>())) + .Throws(new ArgumentException(expected)); + + var options = new AppOptions() + { + Credential = GoogleCredential.FromAccessToken("token"), + }; + var app = FirebaseApp.Create(options, "4321"); + + Assert.Throws<ArgumentException>(() => FirebaseAppCheck.Create(app)); + } + + [Fact] + public async Task CreateTokenApiError() + { + var createTokenMock = new Mock<IAppCheckApiClient>(); + + createTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())).Throws(new ArgumentException("INTERAL_ERROR")); + FirebaseAppCheck createTokenFromAppId = new FirebaseAppCheck(this.mockCredentialApp); - var token = await createTokenFromAppId.CreateToken(this.appId); - Assert.IsType<string>(token.Token); - Assert.NotNull(token.Token); - Assert.IsType<int>(token.TtlMillis); - Assert.Equal<int>(3600000, token.TtlMillis); + + await Assert.ThrowsAsync<HttpRequestException>(() => createTokenFromAppId.CreateToken(this.appId)); } [Fact] - public async Task CreateTokenFromAppIdAndTtlMillis() + public async Task CreateTokenApiErrorOptions() { + var createTokenMock = new Mock<IAppCheckApiClient>(); + + createTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())).Throws(new ArgumentException("INTERAL_ERROR")); + AppCheckTokenOptions options = new (1800000); FirebaseAppCheck createTokenFromAppIdAndTtlMillis = new FirebaseAppCheck(this.mockCredentialApp); - var token = await createTokenFromAppIdAndTtlMillis.CreateToken(this.appId, options); - Assert.IsType<string>(token.Token); - Assert.NotNull(token.Token); - Assert.IsType<int>(token.TtlMillis); - Assert.Equal<int>(1800000, token.TtlMillis); + await Assert.ThrowsAsync<HttpRequestException>(() => createTokenFromAppIdAndTtlMillis.CreateToken(this.appId)); } [Fact] - public async Task VerifyToken() + public async Task CreateTokenAppCheckTokenSuccess() { + string createdCustomToken = "custom-token"; + + AppCheckTokenGenerator tokenFactory = AppCheckTokenGenerator.Create(this.mockCredentialApp); + + var createCustomTokenMock = new Mock<IAppCheckTokenGenerator>(); + + createCustomTokenMock.Setup(service => service.CreateCustomTokenAsync(It.IsAny<string>(), It.IsAny<AppCheckTokenOptions>(), It.IsAny<CancellationToken>())) + .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<IAppCheckApiClient>(); + createExchangeTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) + .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<IAppCheckApiClient>(); + createTokenMock.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) + .Throws(new ArgumentException("INTERAL_ERROR")); + 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); + await Assert.ThrowsAsync<ArgumentException>(() => 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<IAppCheckTokenVerify>(); + verifyTokenMock.Setup(service => service.VerifyTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .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] @@ -101,8 +186,8 @@ public async Task VerifyTokenInvaild() { FirebaseAppCheck verifyTokenInvaild = new FirebaseAppCheck(this.mockCredentialApp); - await Assert.ThrowsAsync<ArgumentException>(() => verifyTokenInvaild.VerifyToken(null)); - await Assert.ThrowsAsync<ArgumentException>(() => verifyTokenInvaild.VerifyToken(string.Empty)); + await Assert.ThrowsAsync<ArgumentNullException>(() => verifyTokenInvaild.VerifyToken(null)); + await Assert.ThrowsAsync<ArgumentNullException>(() => verifyTokenInvaild.VerifyToken(string.Empty)); } public void Dispose() diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs similarity index 89% rename from FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs index fa909b55..13e039b4 100644 --- a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs @@ -51,31 +51,14 @@ public async Task<AppCheckToken> ExchangeTokenAsync(string customToken, string a { if (string.IsNullOrEmpty(customToken)) { - throw new ArgumentException("First argument passed to customToken must be a valid Firebase app instance."); + throw new ArgumentNullException("First argument passed to customToken must be a valid Firebase app instance."); } if (string.IsNullOrEmpty(appId)) { - throw new ArgumentException("Second argument passed to appId must be a valid Firebase app instance."); + throw new ArgumentNullException("Second argument passed to appId must be a valid Firebase app instance."); } - 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; - } - - /// <summary> - /// Exchange a signed custom token to App Check token. - /// </summary> - /// <param name="customToken"> The custom token to be exchanged. </param> - /// <param name="appId"> The Id of Firebase App. </param> - /// <returns> HttpResponseMessage .</returns> - public async Task<HttpResponseMessage> 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() @@ -85,7 +68,6 @@ public async Task<HttpResponseMessage> GetExchangeToken(string customToken, stri Content = content, }; 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.StatusCode == System.Net.HttpStatusCode.BadRequest) @@ -95,10 +77,14 @@ public async Task<HttpResponseMessage> GetExchangeToken(string customToken, stri } else if (!response.IsSuccessStatusCode) { - throw new ArgumentException("unknown-error", $"Unexpected response with status:{response.StatusCode}"); + throw new HttpRequestException("network error"); } - return response; + 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; } /// <summary> @@ -128,7 +114,7 @@ public async Task<bool> VerifyReplayProtection(string token) var responseData = JObject.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); bool alreadyConsumed = (bool)responseData["data"]["alreadyConsumed"]; return alreadyConsumed; - } + } /// <summary> /// Get Verify Token Url . diff --git a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs similarity index 100% rename from FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs new file mode 100644 index 00000000..6c0e7bf1 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs @@ -0,0 +1,190 @@ +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 Google.Apis.Auth; +using Google.Apis.Util; +using Newtonsoft.Json; + +[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey=" + +"002400000480000094000000060200000024000052534131000400000100010081328559eaab41" + +"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02" + +"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597" + +"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f" + +"badddb9c")] + +namespace FirebaseAdmin.Check +{ + /// <summary> + /// A helper class that creates Firebase custom tokens. + /// </summary> + internal class AppCheckTokenGenerator + { + 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; + public const int OneMinuteInMills = OneMinuteInSeconds * 1000; + public const int OneDayInMills = 24 * 60 * 60 * 1000; + + public static readonly DateTime UnixEpoch = new DateTime( + 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public static readonly ImmutableList<string> 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) + { + args.ThrowIfNull(nameof(args)); + + this.Clock = args.Clock ?? SystemClock.Default; + this.IsEmulatorMode = args.IsEmulatorMode; + this.Signer = this.IsEmulatorMode ? + EmulatorSigner.Instance : args.Signer.ThrowIfNull(nameof(args.Signer)); + } + + internal ISigner Signer { get; } + + internal IClock Clock { get; } + + internal string TenantId { get; } + + internal bool IsEmulatorMode { get; } + + 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<string> 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"); + } + + string customOptions = " "; + if (options != null) + { + customOptions = this.ValidateTokenOptions(options).ToString(); + } + + var header = new JsonWebSignature.Header() + { + Algorithm = this.Signer.Algorithm, + Type = "JWT", + }; + + var issued = (int)(this.Clock.UtcNow - UnixEpoch).TotalSeconds; + var keyId = await this.Signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false); + var payload = new CustomTokenPayload() + { + AppId = appId, + Issuer = keyId, + Subject = keyId, + Audience = FirebaseAppCheckAudience, + IssuedAtTimeSeconds = issued, + ExpirationTimeSeconds = issued + (OneMinuteInSeconds * 5), + Ttl = customOptions, + }; + + return await JwtUtils.CreateSignedJwtAsync( + header, payload, this.Signer).ConfigureAwait(false); + } + + private int ValidateTokenOptions(AppCheckTokenOptions options) + { + if (options == null) + { + throw new ArgumentException("invalid-argument", "AppCheckTokenOptions must be a non-null object."); + } + + 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)."); + } + + return options.TtlMillis; + } + + internal class CustomTokenPayload : JsonWebToken.Payload + { + [JsonPropertyAttribute("app_id")] + public string AppId { get; set; } + + [JsonPropertyAttribute("ttl")] + public string Ttl { get; set; } + } + + internal sealed class Args + { + internal ISigner Signer { get; set; } + + internal IClock Clock { get; set; } + + internal string TenantId { get; set; } + + internal bool IsEmulatorMode { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/AppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs similarity index 100% rename from FirebaseAdmin/FirebaseAdmin/Auth/Jwt/AppCheckTokenOptions.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs new file mode 100644 index 00000000..b11334b3 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs @@ -0,0 +1,263 @@ +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<string> StandardClaims = + ImmutableList.Create<string>("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<FirebaseToken> 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<JsonWebSignature.Header>(segments[0]); + var payload = JwtUtils.Decode<FirebaseToken.Args>(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<Dictionary<string, object>>(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); + } + + /// <summary> + /// 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). + /// </summary> + [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/Check/AppCheckVerifyResponse.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs similarity index 86% rename from FirebaseAdmin/FirebaseAdmin/Check/AppCheckVerifyResponse.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs index 90ae92f4..8cc05641 100644 --- a/FirebaseAdmin/FirebaseAdmin/Check/AppCheckVerifyResponse.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs @@ -8,7 +8,7 @@ namespace FirebaseAdmin.Check /// <summary> /// AppCheckVerifyResponse. /// </summary> - public class AppCheckVerifyResponse(string appId, FirebaseToken verifiedToken, bool alreadyConsumed = false) + public class AppCheckVerifyResponse(string appId, string verifiedToken, bool alreadyConsumed = false) { /// <summary> /// Gets or sets a value indicating whether gets the Firebase App Check token. diff --git a/FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs similarity index 69% rename from FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs index 2e281079..0ea2eb8a 100644 --- a/FirebaseAdmin/FirebaseAdmin/Check/IAppCheckApiClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs @@ -9,7 +9,7 @@ namespace FirebaseAdmin.Check /// <summary> /// Interface of Firebase App Check backend API . /// </summary> - internal interface IAppCheckApiClient + public interface IAppCheckApiClient { /// <summary> /// Exchange a signed custom token to App Check token. @@ -25,13 +25,5 @@ internal interface IAppCheckApiClient /// <param name="token"> The custom token to be exchanged. </param> /// <returns>A alreadyConsumed is true.</returns> public Task<bool> VerifyReplayProtection(string token); - - /// <summary> - /// Exchange a signed custom token to App Check token. - /// </summary> - /// <param name="customToken"> The custom token to be exchanged. </param> - /// <param name="appId"> The Id of Firebase App. </param> - /// <returns> HttpResponseMessage .</returns> - public Task<HttpResponseMessage> GetExchangeToken(string customToken, string appId); } } diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs new file mode 100644 index 00000000..a24554b9 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Auth.Jwt; + +namespace FirebaseAdmin.Check +{ + /// <summary> + /// App Check Token generator. + /// </summary> + internal interface IAppCheckTokenGenerator + { + /// <summary> + /// Verifies the integrity of a JWT by validating its signature. + /// </summary> + /// <param name="app">Appcheck Generate Token.</param> + /// <returns>AppCheckTokenVerify.</returns> + AppCheckTokenGenerator Create(FirebaseApp app); + + /// <summary> + /// Verifies the integrity of a JWT by validating its signature. + /// </summary> + /// <param name="appId">The Id of FirebaseApp.</param> + /// <param name="options">AppCheck Token Option.</param> + /// <param name="cancellationToken">Cancelation Token.</param> + /// <returns>A <see cref="Task{FirebaseToken}"/> representing the result of the asynchronous operation.</returns> + Task<string> CreateCustomTokenAsync( + string appId, + AppCheckTokenOptions options = null, + CancellationToken cancellationToken = default); + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs new file mode 100644 index 00000000..cc01d99b --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Auth; + +namespace FirebaseAdmin.Check +{ + /// <summary> + /// App Check Verify. + /// </summary> + internal interface IAppCheckTokenVerify + { + /// <summary> + /// Verifies the integrity of a JWT by validating its signature. + /// </summary> + /// <param name="app">Appcheck Generate Token.</param> + /// <returns>AppCheckTokenVerify.</returns> + AppCheckTokenVerify Create(FirebaseApp app); + + /// <summary> + /// Verifies the integrity of a JWT by validating its signature. + /// </summary> + /// <param name="token">Appcheck Generate Token.</param> + /// <param name="cancellationToken">cancellaton Token.</param> + /// <returns>A <see cref="Task{FirebaseToken}"/> representing the result of the asynchronous operation.</returns> + Task<FirebaseToken> VerifyTokenAsync( + string token, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/Check/Key.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs similarity index 100% rename from FirebaseAdmin/FirebaseAdmin/Check/Key.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs diff --git a/FirebaseAdmin/FirebaseAdmin/Check/KeysRoot.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs similarity index 100% rename from FirebaseAdmin/FirebaseAdmin/Check/KeysRoot.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs diff --git a/FirebaseAdmin/FirebaseAdmin/Check/VerifyAppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs similarity index 100% rename from FirebaseAdmin/FirebaseAdmin/Check/VerifyAppCheckTokenOptions.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs diff --git a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs index b601af26..9387cc3a 100644 --- a/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs +++ b/FirebaseAdmin/FirebaseAdmin/Auth/Jwt/ISigner.cs @@ -13,9 +13,15 @@ // limitations under the License. using System; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, " + + "PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93" + + "bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113b" + + "e11e6a7d3113e92484cf7045cc7")] + namespace FirebaseAdmin.Auth.Jwt { /// <summary> diff --git a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs index 0710bfa8..b1a6d836 100644 --- a/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs +++ b/FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using FirebaseAdmin.Auth; @@ -21,8 +22,8 @@ public sealed class FirebaseAppCheck private static Dictionary<string, FirebaseAppCheck> appChecks = new Dictionary<string, FirebaseAppCheck>(); private readonly AppCheckApiClient apiClient; - private readonly FirebaseTokenVerifier appCheckTokenVerifier; - private readonly FirebaseTokenFactory tokenFactory; + private readonly AppCheckTokenVerify appCheckTokenVerifier; + private readonly AppCheckTokenGenerator tokenFactory; /// <summary> /// Initializes a new instance of the <see cref="FirebaseAppCheck"/> class. @@ -31,8 +32,8 @@ public sealed class FirebaseAppCheck public FirebaseAppCheck(FirebaseApp value) { this.apiClient = new AppCheckApiClient(value); - this.tokenFactory = FirebaseTokenFactory.Create(value); - this.appCheckTokenVerifier = FirebaseTokenVerifier.CreateAppCheckVerifier(value); + this.tokenFactory = AppCheckTokenGenerator.Create(value); + this.appCheckTokenVerifier = AppCheckTokenVerify.Create(value); } /// <summary> @@ -42,13 +43,13 @@ public FirebaseAppCheck(FirebaseApp value) /// <returns>A <see cref="Task{FirebaseAppCheck}"/> Representing the result of the asynchronous operation.</returns> public static FirebaseAppCheck Create(FirebaseApp app) { - string appId = app.Name; - if (app == null) { throw new ArgumentNullException("FirebaseApp must not be null or empty"); } + string appId = app.Name; + lock (appChecks) { if (appChecks.ContainsKey(appId)) @@ -77,7 +78,7 @@ public static FirebaseAppCheck Create(FirebaseApp app) /// <returns>A <see cref="Task{AppCheckToken}"/> Representing the result of the asynchronous operation.</returns> public async Task<AppCheckToken> CreateToken(string appId, AppCheckTokenOptions options = null) { - string customToken = await this.tokenFactory.CreateCustomTokenAppIdAsync(appId, options) + string customToken = await this.tokenFactory.CreateCustomTokenAsync(appId, options, default(CancellationToken)) .ConfigureAwait(false); return await this.apiClient.ExchangeTokenAsync(customToken, appId).ConfigureAwait(false); From 28f60b9218b624e1217d73fa9b0517d73524b42c Mon Sep 17 00:00:00 2001 From: pavlo <taeul930217@skiff.com> Date: Sat, 27 Jan 2024 10:31:33 -0500 Subject: [PATCH 5/6] feature/appcheck/final --- .../AppCheck/AppCheckApiClientTest.cs | 369 ++++++++++++------ .../AppCheck/AppCheckErrorHandlerTest.cs | 238 +++++++++++ .../AppCheck/AppCheckTokenFactoryTest.cs | 267 +++++++++++++ .../AppCheck/AppCheckTokenGeneratorTest.cs | 112 ------ .../AppCheck/AppCheckTokenVerifierTest.cs | 180 +++++---- .../AppCheck/CountingAppCheckHandler.cs | 30 ++ .../AppCheck/FirebaseAppCheckTests.cs | 165 ++++++++ .../AppCheck/MockAppCheckHandler.cs | 132 +++++++ .../FirebaseAppCheckTests.cs | 198 ---------- FirebaseAdmin/FirebaseAdmin.sln | 4 +- .../AppCheck/AppCheckApiClient.cs | 296 +++++++++----- .../AppCheck/AppCheckDecodedToken.cs | 87 +++++ .../AppCheck/AppCheckErrorCode.cs | 53 +++ .../AppCheck/AppCheckErrorHandler.cs | 229 +++++++++++ .../FirebaseAdmin/AppCheck/AppCheckToken.cs | 13 +- ...enGenerator.cs => AppCheckTokenFactory.cs} | 133 +++---- .../AppCheck/AppCheckTokenOptions.cs | 6 +- .../AppCheck/AppCheckTokenVerifier.cs | 188 +++++++++ .../AppCheck/AppCheckTokenVerify.cs | 263 ------------- .../AppCheck/AppCheckVerifyResponse.cs | 28 -- .../AppCheck/AppCheckVerifyTokenOptions.cs | 26 ++ .../AppCheck/AppCheckVerifyTokenResponse.cs | 23 ++ .../AppCheck/FirebaseAppCheck.cs | 129 ++++++ .../AppCheck/FirebaseAppCheckException.cs | 27 ++ .../AppCheck/IAppCheckApiClient.cs | 29 -- .../AppCheck/IAppCheckTokenGenerator.cs | 34 -- .../AppCheck/IAppCheckTokenVerify.cs | 31 -- FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs | 39 -- .../FirebaseAdmin/AppCheck/KeysRoot.cs | 16 - .../AppCheck/VerifyAppCheckTokenOptions.cs | 27 -- .../FirebaseAdmin/Auth/FirebaseToken.cs | 12 +- .../Auth/Jwt/FirebaseTokenFactory.cs | 55 --- .../Auth/Jwt/FirebaseTokenVerifier.cs | 40 -- .../Auth/Jwt/HttpPublicKeySource.cs | 36 -- .../FirebaseAdmin/FirebaseAppCheck.cs | 127 ------ FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs | 12 + 36 files changed, 2214 insertions(+), 1440 deletions(-) create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckErrorHandlerTest.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenFactoryTest.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/AppCheckTokenGeneratorTest.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/CountingAppCheckHandler.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/FirebaseAppCheckTests.cs create mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/AppCheck/MockAppCheckHandler.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs rename FirebaseAdmin/FirebaseAdmin/AppCheck/{AppCheckTokenGenerator.cs => AppCheckTokenFactory.cs} (60%) create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs create mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs delete mode 100644 FirebaseAdmin/FirebaseAdmin/FirebaseAppCheck.cs 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<ArgumentException>(() => new AppCheckApiClient(null)); - } + var args = new AppCheckApiClient.Args() + { + ClientFactory = new HttpClientFactory(), + Credential = null, + }; - [Fact] - public async Task ExchangeTokenNoProjectId() - { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); + args.ProjectId = null; + Assert.Throws<FirebaseAppCheckException>(() => new AppCheckApiClient(args)); - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException(this.noProjectId)); - var result = await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); - Assert.Equal(this.noProjectId, result.Message); + args.ProjectId = string.Empty; + Assert.Throws<FirebaseAppCheckException>(() => new AppCheckApiClient(args)); } [Fact] - public async Task ExchangeTokenInvalidAppId() + public void NoCredential() { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, string.Empty)); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, null)); + var args = new AppCheckApiClient.Args() + { + ClientFactory = new HttpClientFactory(), + Credential = null, + ProjectId = "test-project", + }; + + Assert.Throws<ArgumentNullException>(() => new AppCheckApiClient(args)); } [Fact] - public async Task ExchangeTokenInvalidCustomTokenAsync() + public void NoClientFactory() { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(string.Empty, this.appId)); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(null, this.appId)); + var args = new AppCheckApiClient.Args() + { + ClientFactory = null, + Credential = MockCredential, + ProjectId = "test-project", + }; + + Assert.Throws<ArgumentNullException>(() => new AppCheckApiClient(args)); } [Fact] - public async Task ExchangeTokenFullErrorResponseAsync() + public async Task ExchangeToken() { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.ExchangeTokenResponse() + { + Token = "test-token", + Ttl = "36000s", + }, + }; - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException("not-found", "Requested entity not found")); + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); - } + string customToken = "test-token"; - [Fact] - public async Task ExchangeTokenErrorCodeAsync() - { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); + var response = await client.ExchangeTokenAsync(customToken, this.appId); - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException("unknown-error", "Unknown server error: {}")); + Assert.Equal("test-token", response.Token); + Assert.Equal(36000000, response.TtlMillis); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.ExchangeTokenAsync(this.testTokenToExchange, this.appId)); + var req = JsonConvert.DeserializeObject<AppCheckApiClient.ExchangeTokenRequest>(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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException("unknown-error", "Unexpected response with status: 404 and body: not json")); - - await Assert.ThrowsAsync<ArgumentException>(() => 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<FirebaseAppCheckException>( + 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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .Throws(new ArgumentException("network-error", "socket hang up")); - - await Assert.ThrowsAsync<ArgumentException>(() => 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<FirebaseAppCheckException>( + 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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .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<FirebaseAppCheckException>( + 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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync<ArgumentException>(() => 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<FirebaseAppCheckException>( + 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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException(this.noProjectId)); - - await Assert.ThrowsAsync<ArgumentException>(() => 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<FirebaseAppCheckException>( + 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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException("not-found", "Requested entity not found")); - - await Assert.ThrowsAsync<ArgumentException>(() => 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<FirebaseAppCheckException>( + 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<IAppCheckApiClient>(); + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.VerifyTokenResponse() + { + AlreadyConsumed = true, + }, + }; - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException("unknown-error", "Unknown server error: {}")); + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); - } + string customToken = "test-token"; - [Fact] - public async Task VerifyReplayNonJsonAsync() - { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); + var response = await client.VerifyReplayProtectionAsync(customToken); - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException("unknown-error", "Unexpected response with status: 404 and body: not json")); + Assert.True(response); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); + var req = JsonConvert.DeserializeObject<AppCheckApiClient.ExchangeTokenRequest>(handler.LastRequestBody); + Assert.Equal(1, handler.Calls); + this.CheckHeaders(handler.LastRequestHeaders); } [Fact] - public async Task VerifyReplayFirebaseAppErrorAsync() + public async Task VerifyReplayProtectionWithFalse() { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); + var handler = new MockAppCheckHandler() + { + Response = new AppCheckApiClient.VerifyTokenResponse() + { + AlreadyConsumed = false, + }, + }; - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException("network-error", "socket hang up")); + var factory = new MockHttpClientFactory(handler); + var client = this.CreateAppCheckApiClient(factory); - await Assert.ThrowsAsync<ArgumentException>(() => appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange)); - } + string customToken = "test-token"; - [Fact] - public async Task VerifyReplayAlreadyTrueAsync() - { - var appCheckApiClient = new Mock<IAppCheckApiClient>(); + var response = await client.VerifyReplayProtectionAsync(customToken); - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .ReturnsAsync(true); + Assert.False(response); - bool res = await appCheckApiClient.Object.VerifyReplayProtection(this.testTokenToExchange).ConfigureAwait(false); - Assert.True(res); + var req = JsonConvert.DeserializeObject<AppCheckApiClient.ExchangeTokenRequest>(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<IAppCheckApiClient>(); - - appCheckApiClient.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .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<object[]> AppCheckErrorCodes = + new List<object[]>() + { + 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<object[]> InvalidStrings = new List<object[]> + { + 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<FirebaseAppCheckException>(() => 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<FirebaseAppCheckException>(() => + 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<FirebaseAppCheckException>(() => + 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<AppCheckTokenFactory.CustomTokenPayload>(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<JsonWebSignature.Header>(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<string, object> claims = null) + { + string[] segments = token.Split("."); + Assert.Equal(3, segments.Length); + + var header = JwtUtils.Decode<JsonWebSignature.Header>(segments[0]); + this.AssertHeader(header); + + var payload = JwtUtils.Decode<AppCheckTokenFactory.CustomTokenPayload>(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<string> GetKeyIdAsync(CancellationToken cancellationToken) + { + return Task.FromResult(KeyIdString); + } + + public Task<byte[]> 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<object[]> InvalidStrings = new List<object[]> - { - 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<ArgumentException>(() => tokenGenerator.CreateCustomTokenAsync(string.Empty)); - await Assert.ThrowsAsync<ArgumentException>(() => 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<ArgumentException>(() => - 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<object[]> InvalidTokens = new List<object[]> { - 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<object[]> InvalidAudiences = new List<object[]> { - 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<string> { "incorrectAudience" } }, + new object[] { new List<string> { "12345678", "project_id" } }, + new object[] { new List<string> { "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<ArgumentException>(() => new AppCheckTokenVerify(args)); + Assert.Throws<ArgumentNullException>(() => new AppCheckTokenVerifier(args)); } [Fact] - public void NullKeySource() + public void ProjectId() { var args = FullyPopulatedArgs(); - args.PublicKeySource = null; - Assert.Throws<ArgumentNullException>(() => 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<FirebaseAppCheckException>( + async () => await tokenVerifier.VerifyTokenAsync(token)); - Assert.Throws<ArgumentException>(() => 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<FirebaseAppCheckException>( + async () => await tokenVerifier.VerifyTokenAsync(token)); - Assert.Throws<ArgumentException>(() => 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<FirebaseAppCheckException>( + async () => await tokenVerifier.VerifyTokenAsync(token)); - Assert.Throws<ArgumentException>(() => 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<string> 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<ArgumentException>(() => new AppCheckTokenVerify(args)); + AppCheckTokenVerifier verifier = new AppCheckTokenVerifier(args); + var result = await Assert.ThrowsAsync<FirebaseAppCheckException>(() => 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<FirebaseAppCheckException>(() => 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<string> aud = new List<string> { "12345678", "projects/test-project" }; + string token = await this.GeneratorAppCheckTokenAsync(aud).ConfigureAwait(false); + + var args = FullyPopulatedArgs(); + AppCheckTokenVerifier verifier = new AppCheckTokenVerifier(args); + await Assert.ThrowsAsync<FirebaseAppCheckException>(() => 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<string> GeneratorAppCheckTokenAsync(List<string> 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<HttpResponseMessage> SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var count = Interlocked.Increment(ref this.calls); + return this.DoSendAsync(request, count, cancellationToken); + } + + protected abstract Task<HttpResponseMessage> 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<FirebaseAppCheckException>(() => 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<InvalidOperationException>(() => 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<ObjectDisposedException>( + 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<IncomingRequest> requests = new List<IncomingRequest>(); + + 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<IncomingRequest> 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<HttpResponseMessage> DoSendAsync( + HttpRequestMessage request, int count, CancellationToken cancellationToken) + { + var incomingRequest = await IncomingRequest.CreateAsync(request); + this.requests.Add(incomingRequest); + + var tcs = new TaskCompletionSource<HttpResponseMessage>(); + 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<string>) + { + json = (this.Response as IList<string>)[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<IncomingRequest> 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<ArgumentNullException>(() => 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<ArgumentException>(() => FirebaseAppCheck.Create(app)); - } - - [Fact] - public void FailedSignCreateToken() - { - string expected = "sign error"; - var createTokenMock = new Mock<ISigner>(); - - // Setup the mock to throw an exception when SignDataAsync is called - createTokenMock.Setup(service => service.SignDataAsync(It.IsAny<byte[]>(), It.IsAny<CancellationToken>())) - .Throws(new ArgumentException(expected)); - - var options = new AppOptions() - { - Credential = GoogleCredential.FromAccessToken("token"), - }; - var app = FirebaseApp.Create(options, "4321"); - - Assert.Throws<ArgumentException>(() => FirebaseAppCheck.Create(app)); - } - - [Fact] - public async Task CreateTokenApiError() - { - var createTokenMock = new Mock<IAppCheckApiClient>(); - - createTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())).Throws(new ArgumentException("INTERAL_ERROR")); - - FirebaseAppCheck createTokenFromAppId = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync<HttpRequestException>(() => createTokenFromAppId.CreateToken(this.appId)); - } - - [Fact] - public async Task CreateTokenApiErrorOptions() - { - var createTokenMock = new Mock<IAppCheckApiClient>(); - - createTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())).Throws(new ArgumentException("INTERAL_ERROR")); - - AppCheckTokenOptions options = new (1800000); - FirebaseAppCheck createTokenFromAppIdAndTtlMillis = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync<HttpRequestException>(() => createTokenFromAppIdAndTtlMillis.CreateToken(this.appId)); - } - - [Fact] - public async Task CreateTokenAppCheckTokenSuccess() - { - string createdCustomToken = "custom-token"; - - AppCheckTokenGenerator tokenFactory = AppCheckTokenGenerator.Create(this.mockCredentialApp); - - var createCustomTokenMock = new Mock<IAppCheckTokenGenerator>(); - - createCustomTokenMock.Setup(service => service.CreateCustomTokenAsync(It.IsAny<string>(), It.IsAny<AppCheckTokenOptions>(), It.IsAny<CancellationToken>())) - .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<IAppCheckApiClient>(); - createExchangeTokenMock.Setup(service => service.ExchangeTokenAsync(It.IsAny<string>(), It.IsAny<string>())) - .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<IAppCheckApiClient>(); - createTokenMock.Setup(service => service.VerifyReplayProtection(It.IsAny<string>())) - .Throws(new ArgumentException("INTERAL_ERROR")); - - FirebaseAppCheck verifyToken = new FirebaseAppCheck(this.mockCredentialApp); - - await Assert.ThrowsAsync<ArgumentException>(() => 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<IAppCheckTokenVerify>(); - verifyTokenMock.Setup(service => service.VerifyTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) - .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<ArgumentNullException>(() => verifyTokenInvaild.VerifyToken(null)); - await Assert.ThrowsAsync<ArgumentNullException>(() => 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 { /// <summary> /// Class that facilitates sending requests to the Firebase App Check backend API. /// </summary> - /// <returns>A task that completes with the creation of a new App Check token.</returns> - /// <exception cref="ArgumentNullException">Thrown if an error occurs while creating the custom token.</exception> - /// <value>The Firebase app instance.</value> - 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; - /// <summary> - /// Initializes a new instance of the <see cref="AppCheckApiClient"/> class. - /// </summary> - /// <param name="value"> Initailize FirebaseApp. </param> - public AppCheckApiClient(FirebaseApp value) + private readonly ErrorHandlingHttpClient<FirebaseAppCheckException> 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<FirebaseAppCheckException>( + new ErrorHandlingHttpClientArgs<FirebaseAppCheckException>() + { + 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(); } /// <summary> /// Exchange a signed custom token to App Check token. /// </summary> - /// <param name="customToken"> The custom token to be exchanged. </param> - /// <param name="appId"> The mobile App ID.</param> - /// <returns>A <see cref="Task{AppCheckToken}"/> A promise that fulfills with a `AppCheckToken`.</returns> + /// <param name="customToken">The custom token to be exchanged.</param> + /// <param name="appId">The mobile App ID.</param> + /// <returns>A promise that fulfills with a `AppCheckToken`.</returns> public async Task<AppCheckToken> 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<ExchangeTokenResponse>(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; } - /// <summary> - /// Exchange a signed custom token to App Check token. - /// </summary> - /// <param name="token"> The custom token to be exchanged. </param> - /// <returns>A alreadyConsumed is true.</returns> - public async Task<bool> VerifyReplayProtection(string token) + public async Task<bool> 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<VerifyTokenResponse>(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; } - /// <summary> - /// Get Verify Token Url . - /// </summary> - /// <returns>A formatted verify token url.</returns> - private string GetVerifyTokenUrl() + internal static AppCheckApiClient Create(FirebaseApp app) { - var urlParams = new Dictionary<string, string> + 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); } - /// <summary> - /// Get url from FirebaseApp Id . - /// </summary> - /// <param name="appId">The FirebaseApp Id.</param> - /// <returns>A formatted verify token url.</returns> - 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<string, string> @@ -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); } - /// <summary> - /// Converts a duration string with the suffix `s` to milliseconds. - /// </summary> - /// <param name="duration">The duration as a string with the suffix "s" preceded by the number of seconds.</param> - /// <returns> The duration in milliseconds.</returns> 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; } - /// <summary> - /// Formats a string of form 'project/{projectId}/{api}' and replaces with corresponding arguments {projectId: '1234', api: 'resource'}. - /// </summary> - /// <param name="str">The original string where the param need to be replaced.</param> - /// <param name="urlParams">The optional parameters to replace in thestring.</param> - /// <returns> The resulting formatted string. </returns> - private string FormatString(string str, Dictionary<string, string> 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<string, string> + { + { "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 +{ + /// <summary> + /// Interface representing a decoded Firebase App Check token, returned from the {@link AppCheck.verifyToken} method.. + /// </summary> + 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; + } + + /// <summary> + /// Gets or sets the issuer identifier for the issuer of the response. + /// </summary> + public string Issuer { get; set; } + + /// <summary> + /// 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. + /// </summary> + public string Subject { get; set; } + + /// <summary> + /// 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. + /// </summary> + public string[] Audience { get; set; } + + /// <summary> + /// 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. + /// </summary> + public int ExpirationTimeSeconds { get; set; } + + /// <summary> + /// 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. + /// </summary> + public int IssuedAtTimeSeconds { get; set; } + + /// <summary> + /// 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. + /// </summary> + public string AppId { get; set; } + + /// <summary> + /// Gets or sets key . + /// </summary> + public Dictionary<string, string> 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<string, object> 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 +{ + /// <summary> + /// Error codes that can be raised by the Firebase App Check APIs. + /// </summary> + public enum AppCheckErrorCode + { + /// <summary> + /// Process is aborted + /// </summary> + Aborted, + + /// <summary> + /// Argument is not valid + /// </summary> + InvalidArgument, + + /// <summary> + /// Credential is not valid + /// </summary> + InvalidCredential, + + /// <summary> + /// The server internal error + /// </summary> + InternalError, + + /// <summary> + /// Permission is denied + /// </summary> + PermissionDenied, + + /// <summary> + /// Unauthenticated + /// </summary> + Unauthenticated, + + /// <summary> + /// Resource is not found + /// </summary> + NotFound, + + /// <summary> + /// App Check Token is expired + /// </summary> + AppCheckTokenExpired, + + /// <summary> + /// Unknown Error + /// </summary> + 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 +{ + /// <summary> + /// Parses error responses received from the Auth service, and creates instances of + /// <see cref="FirebaseAppCheckException"/>. + /// </summary> + internal sealed class AppCheckErrorHandler + : HttpErrorHandler<FirebaseAppCheckException>, + IHttpRequestExceptionHandler<FirebaseAppCheckException>, + IDeserializeExceptionHandler<FirebaseAppCheckException> + { + internal static readonly AppCheckErrorHandler Instance = new AppCheckErrorHandler(); + + private static readonly IReadOnlyDictionary<string, ErrorInfo> CodeToErrorInfo = + new Dictionary<string, ErrorInfo>() + { + { + "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<AppCheckErrorResponse>(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(); + } + } + + /// <summary> + /// Describes a class of errors that can be raised by the Firebase Auth backend API. + /// </summary> + 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; } + + /// <summary> + /// Gets the Firebase Auth error code extracted from the response. Returns empty string + /// if the error code cannot be determined. + /// </summary> + internal string Code + { + get + { + var separator = this.GetSeparator(); + if (separator != -1) + { + return this.Message.Substring(0, separator); + } + + return this.Message ?? string.Empty; + } + } + + /// <summary> + /// Gets the error detail sent by the Firebase Auth API. May be null. + /// </summary> + 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 { /// <summary> /// Interface representing an App Check token. /// </summary> - /// <remarks> - /// Initializes a new instance of the <see cref="AppCheckToken"/> class. - /// </remarks> /// <param name="tokenValue">Generator from custom token.</param> /// <param name="ttlValue">TTl value .</param> public class AppCheckToken(string tokenValue, int ttlValue) { /// <summary> - /// Gets the Firebase App Check token. + /// Gets or sets the Firebase App Check token. /// </summary> - public string Token { get; } = tokenValue; + public string Token { get; set; } = tokenValue; /// <summary> /// 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 { /// <summary> /// A helper class that creates Firebase custom tokens. /// </summary> - 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<string> 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<string> CreateCustomTokenAsync( + /// <summary> + /// Creates a new custom token that can be exchanged to an App Check token. + /// </summary> + /// <param name="appId">The mobile App ID.</param> + /// <param name="options">Options for AppCheckToken with ttl. Possibly null.</param> + /// <param name="cancellationToken">A cancellation token to monitor the asynchronous.</param> + /// <returns>A Promise fulfilled with a custom token signed with a service account key that can be exchanged to an App Check token.</returns> + public async Task<string> 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<string> 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 { /// <summary> /// 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<string> StandardClaims = + ImmutableList.Create<string>("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; } + + /// <summary> + /// Verifies the format and signature of a Firebase App Check token. + /// </summary> + /// <param name="token">The Firebase Auth JWT token to verify.</param> + /// <param name="cancellationToken">A cancellation token to monitor the asynchronous operation.</param> + /// <returns>A task that completes with a <see cref="AppCheckDecodedToken"/> representing + /// a user with the specified user ID.</returns> + public async Task<AppCheckDecodedToken> 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<JsonWebSignature.Header>(segments[0]); + var payload = JwtUtils.Decode<AppCheckDecodedToken.Args>(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<Dictionary<string, object>>(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); + } + + /// <summary> + /// 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). + /// </summary> + 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<string> StandardClaims = - ImmutableList.Create<string>("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<FirebaseToken> 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<JsonWebSignature.Header>(segments[0]); - var payload = JwtUtils.Decode<FirebaseToken.Args>(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<Dictionary<string, object>>(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); - } - - /// <summary> - /// 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). - /// </summary> - [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 -{ - /// <summary> - /// AppCheckVerifyResponse. - /// </summary> - public class AppCheckVerifyResponse(string appId, string verifiedToken, bool alreadyConsumed = false) - { - /// <summary> - /// Gets or sets a value indicating whether gets the Firebase App Check token. - /// </summary> - public bool AlreadyConsumed { get; set; } = alreadyConsumed; - - /// <summary> - /// Gets or sets the Firebase App Check token. - /// </summary> - public string AppId { get; set; } = appId; - - /// <summary> - /// Gets or sets the Firebase App Check VerifiedToken. - /// </summary> - 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 +{ + /// <summary> + /// Class representing options for the AppCheck.VerifyToken method. + /// </summary> + public class AppCheckVerifyTokenOptions + { + /// <summary> + /// 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. + /// </summary> + 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 +{ + /// <summary> + /// Interface representing a verified App Check token response. + /// </summary> + public class AppCheckVerifyTokenResponse + { + /// <summary> + /// Gets or sets App ID corresponding to the App the App Check token belonged to. + /// </summary> + public string AppId { get; set; } + + /// <summary> + /// Gets or sets decoded Firebase App Check token. + /// </summary> + public AppCheckDecodedToken Token { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether already conumed. + /// </summary> + 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 +{ + /// <summary> + /// Asynchronously creates a new Firebase App Check token for the specified Firebase app. + /// </summary> + /// <returns>A task that completes with the creation of a new App Check token.</returns> + /// <exception cref="ArgumentNullException">Thrown if an error occurs while creating the custom token.</exception> + /// <value>The Firebase app instance.</value> + public sealed class FirebaseAppCheck : IFirebaseService + { + private readonly AppCheckApiClient appCheckApiClient; + private readonly AppCheckTokenFactory appCheckTokenFactory; + private readonly AppCheckTokenVerifier appCheckTokenVerifier; + + /// <summary> + /// Initializes a new instance of the <see cref="FirebaseAppCheck"/> class. + /// </summary> + /// <param name="app"> Initailize FirebaseApp. </param> + public FirebaseAppCheck(FirebaseApp app) + { + this.appCheckApiClient = AppCheckApiClient.Create(app); + this.appCheckTokenFactory = AppCheckTokenFactory.Create(app); + this.appCheckTokenVerifier = AppCheckTokenVerifier.Create(app); + } + + /// <summary> + /// Gets the messaging instance associated with the default Firebase app. This property is + /// <c>null</c> if the default app doesn't yet exist. + /// </summary> + public static FirebaseAppCheck DefaultInstance + { + get + { + var app = FirebaseApp.DefaultInstance; + if (app == null) + { + return null; + } + + return GetAppCheck(app); + } + } + + /// <summary> + /// Returns the messaging instance for the specified app. + /// </summary> + /// <returns>The <see cref="FirebaseAppCheck"/> instance associated with the specified + /// app.</returns> + /// <exception cref="System.ArgumentNullException">If the app argument is null.</exception> + /// <param name="app">An app instance.</param> + public static FirebaseAppCheck GetAppCheck(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException("App argument must not be null."); + } + + return app.GetOrInit<FirebaseAppCheck>(typeof(FirebaseAppCheck).Name, () => + { + return new FirebaseAppCheck(app); + }); + } + + /// <summary> + /// Creates a new AppCheckToken that can be sent back to a client. + /// </summary> + /// <param name="appId">The app ID to use as the JWT app_id.</param> + /// <param name="options">Optional options object when creating a new App Check Token.</param> + /// <returns>A <see cref="Task{AppCheckToken}"/> A promise that fulfills with a `AppCheckToken`.</returns> + public async Task<AppCheckToken> 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); + } + + /// <summary> + /// 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. + /// </summary> + /// <param name="appCheckToken"> TThe App Check token to verify.</param> + /// <param name="options"> Optional {@link VerifyAppCheckTokenOptions} object when verifying an App Check Token.</param> + /// <returns>A <see cref="Task{VerifyAppCheckTokenResponse}"/> A promise fulfilled with the token's decoded claims if the App Check token is valid; otherwise, a rejected promise.</returns> + public async Task<AppCheckVerifyTokenResponse> 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, + }; + } + + /// <summary> + /// Deletes this <see cref="AppCheckApiClient"/> service instance. + /// </summary> + 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 +{ + /// <summary> + /// Exception type raised by Firebase AppCheck APIs. + /// </summary> + 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; + } + + /// <summary> + /// Gets the Firease AppCheck error code associated with this exception. May be null. + /// </summary> + 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 -{ - /// <summary> - /// Interface of Firebase App Check backend API . - /// </summary> - public interface IAppCheckApiClient - { - /// <summary> - /// Exchange a signed custom token to App Check token. - /// </summary> - /// <param name="customToken">The custom token to be exchanged.</param> - /// <param name="appId">The mobile App ID.</param> - /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns> - public Task<AppCheckToken> ExchangeTokenAsync(string customToken, string appId); - - /// <summary> - /// Exchange a signed custom token to App Check token. - /// </summary> - /// <param name="token"> The custom token to be exchanged. </param> - /// <returns>A alreadyConsumed is true.</returns> - public Task<bool> 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 -{ - /// <summary> - /// App Check Token generator. - /// </summary> - internal interface IAppCheckTokenGenerator - { - /// <summary> - /// Verifies the integrity of a JWT by validating its signature. - /// </summary> - /// <param name="app">Appcheck Generate Token.</param> - /// <returns>AppCheckTokenVerify.</returns> - AppCheckTokenGenerator Create(FirebaseApp app); - - /// <summary> - /// Verifies the integrity of a JWT by validating its signature. - /// </summary> - /// <param name="appId">The Id of FirebaseApp.</param> - /// <param name="options">AppCheck Token Option.</param> - /// <param name="cancellationToken">Cancelation Token.</param> - /// <returns>A <see cref="Task{FirebaseToken}"/> representing the result of the asynchronous operation.</returns> - Task<string> 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 -{ - /// <summary> - /// App Check Verify. - /// </summary> - internal interface IAppCheckTokenVerify - { - /// <summary> - /// Verifies the integrity of a JWT by validating its signature. - /// </summary> - /// <param name="app">Appcheck Generate Token.</param> - /// <returns>AppCheckTokenVerify.</returns> - AppCheckTokenVerify Create(FirebaseApp app); - - /// <summary> - /// Verifies the integrity of a JWT by validating its signature. - /// </summary> - /// <param name="token">Appcheck Generate Token.</param> - /// <param name="cancellationToken">cancellaton Token.</param> - /// <returns>A <see cref="Task{FirebaseToken}"/> representing the result of the asynchronous operation.</returns> - Task<FirebaseToken> 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 -{ /// <summary> - /// Represents a cryptographic key. - /// </summary> - public class Key - { - /// <summary> - /// Gets or sets the key type. - /// </summary> - public string Kty { get; set; } - - /// <summary> - /// Gets or sets the intended use of the key. - /// </summary> - public string Use { get; set; } - - /// <summary> - /// Gets or sets the algorithm associated with the key. - /// </summary> - public string Alg { get; set; } - - /// <summary> - /// Gets or sets the key ID. - /// </summary> - public string Kid { get; set; } - - /// <summary> - /// Gets or sets the modulus for the RSA public key. - /// </summary> - public string N { get; set; } - - /// <summary> - /// Gets or sets the exponent for the RSA public key. - /// </summary> - 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 -{ - /// <summary> - /// Represents a cryptographic key. - /// </summary> - public class KeysRoot - { - /// <summary> - /// Gets or sets represents a cryptographic key. - /// </summary> - public List<Key> 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; - -/// <summary> -/// Class representing options for the AppCheck.VerifyToken method. -/// </summary> -public class VerifyAppCheckTokenOptions -{ - /// <summary> - /// 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. - /// </summary> - 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) /// </summary> public string Uid { get; } - /// <summary> - /// Gets the Id of the Firebase . - /// </summary> - public string AppId { get; } - /// <summary> /// 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) /// <param name="v">FirebaseToken.</param> 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<string> CreateCustomTokenAsync( header, payload, this.Signer, cancellationToken).ConfigureAwait(false); } - internal async Task<string> 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<FirebaseToken> 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<IReadOnlyList<PublicKey>> 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<PublicKey> ParseKeys(DeserializedResponseInfo<Dictionary<s return builder.ToImmutableList(); } - private async Task<IReadOnlyList<PublicKey>> 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<KeysRoot>(responseString); - var builder = ImmutableList.CreateBuilder<PublicKey>(); - 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<FirebaseAuthException>, IHttpRequestExceptionHandler<FirebaseAuthException>, 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 -{ - /// <summary> - /// Asynchronously creates a new Firebase App Check token for the specified Firebase app. - /// </summary> - /// <returns>A task that completes with the creation of a new App Check token.</returns> - /// <exception cref="ArgumentNullException">Thrown if an error occurs while creating the custom token.</exception> - /// <value>The Firebase app instance.</value> - public sealed class FirebaseAppCheck - { - private const string DefaultProjectId = "[DEFAULT]"; - private static Dictionary<string, FirebaseAppCheck> appChecks = new Dictionary<string, FirebaseAppCheck>(); - - private readonly AppCheckApiClient apiClient; - private readonly AppCheckTokenVerify appCheckTokenVerifier; - private readonly AppCheckTokenGenerator tokenFactory; - - /// <summary> - /// Initializes a new instance of the <see cref="FirebaseAppCheck"/> class. - /// </summary> - /// <param name="value"> Initailize FirebaseApp. </param> - public FirebaseAppCheck(FirebaseApp value) - { - this.apiClient = new AppCheckApiClient(value); - this.tokenFactory = AppCheckTokenGenerator.Create(value); - this.appCheckTokenVerifier = AppCheckTokenVerify.Create(value); - } - - /// <summary> - /// Initializes a new instance of the <see cref="FirebaseAppCheck"/> class. - /// </summary> - /// <param name="app"> Initailize FirebaseApp. </param> - /// <returns>A <see cref="Task{FirebaseAppCheck}"/> Representing the result of the asynchronous operation.</returns> - 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; - } - - /// <summary> - /// Creates a new AppCheckToken that can be sent back to a client. - /// </summary> - /// <param name="appId">The app ID to use as the JWT app_id.</param> - /// <param name="options">Optional options object when creating a new App Check Token.</param> - /// <returns>A <see cref="Task{AppCheckToken}"/> Representing the result of the asynchronous operation.</returns> - public async Task<AppCheckToken> 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); - } - - /// <summary> - /// 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. - /// </summary> - /// <param name="appCheckToken"> TThe App Check token to verify.</param> - /// <param name="options"> Optional VerifyAppCheckTokenOptions object when verifying an App Check Token.</param> - /// <returns>A <see cref="Task{VerifyAppCheckTokenResponse}"/> representing the result of the asynchronous operation.</returns> - public async Task<AppCheckVerifyResponse> 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; - } - - /// <summary> - /// Deleted all the appChecks created so far. Used for unit testing. - /// </summary> - 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<string, string> urlParams) + { + string formatted = str; + foreach (var key in urlParams.Keys) + { + formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + } + + return formatted; + } + internal static string EncodeQueryParams(IDictionary<string, object> queryParams) { var queryString = string.Empty; From ec540fde685425dd533920373ddcf450c6421cca Mon Sep 17 00:00:00 2001 From: pavlo <taeul930217@skiff.com> Date: Mon, 29 Jan 2024 00:55:56 -0500 Subject: [PATCH 6/6] feature/app-check/ReplacePlaceholders --- .../AppCheck/AppCheckApiClient.cs | 10 ++++----- FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs | 11 ---------- .../FirebaseAdmin/Util/StringUtils.cs | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs index 7b8c3446..cad60e61 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs @@ -23,12 +23,12 @@ internal sealed class AppCheckApiClient : IDisposable internal AppCheckApiClient(Args args) { - string noProjectId = "Project ID is required to access app check service. Use a service account " + if (string.IsNullOrEmpty(args.ProjectId)) + { + string noProjectId = "Project ID is required to access app check service. Use a service account " + "credential or set the project ID explicitly via AppOptions. Alternatively " + "you can set the project ID via the GOOGLE_CLOUD_PROJECT environment " + "variable."; - if (string.IsNullOrEmpty(args.ProjectId)) - { throw new FirebaseAppCheckException( ErrorCode.InvalidArgument, noProjectId, @@ -216,7 +216,7 @@ private string GetUrl(string appId) { "appId", appId }, }; - return HttpUtils.FormatString(AppCheckUrlFormat, urlParams); + return StringUtils.ReplacePlaceholders(AppCheckUrlFormat, urlParams); } private int StringToMilliseconds(string duration) @@ -244,7 +244,7 @@ private string GetVerifyTokenUrl() { "projectId", this.projectId }, }; - return HttpUtils.FormatString(OneTimeUseTokenVerificationUrlFormat, urlParams); + return StringUtils.ReplacePlaceholders(OneTimeUseTokenVerificationUrlFormat, urlParams); } internal sealed class Args diff --git a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs index c523f533..55419bdc 100644 --- a/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs +++ b/FirebaseAdmin/FirebaseAdmin/Util/HttpUtils.cs @@ -30,17 +30,6 @@ internal sealed class HttpUtils private HttpUtils() { } - public static string FormatString(string str, Dictionary<string, string> urlParams) - { - string formatted = str; - foreach (var key in urlParams.Keys) - { - formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); - } - - return formatted; - } - internal static string EncodeQueryParams(IDictionary<string, object> queryParams) { var queryString = string.Empty; diff --git a/FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs b/FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs new file mode 100644 index 00000000..fc626ee7 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/Util/StringUtils.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace FirebaseAdmin.Util +{ + internal class StringUtils + { + public static string ReplacePlaceholders(string str, Dictionary<string, string> urlParams) + { + string formatted = str; + foreach (var key in urlParams.Keys) + { + formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + } + + return formatted; + } + } +}