From dcc9f867010bf48b5d4929031a2b9f2954e755cf Mon Sep 17 00:00:00 2001 From: pavlo Date: Sat, 27 Jan 2024 10:27:09 -0500 Subject: [PATCH] feature/appcheck/final --- .../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 -- 20 files changed, 1011 insertions(+), 666 deletions(-) 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 diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs index 13e039b4..7b8c3446 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckApiClient.cs @@ -1,156 +1,213 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; using System.Threading.Tasks; -using FirebaseAdmin.Auth; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using static Google.Apis.Requests.BatchRequest; +using FirebaseAdmin.Util; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Http; +using Google.Apis.Json; +using Google.Apis.Util; -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.AppCheck { /// /// Class that facilitates sending requests to the Firebase App Check backend API. /// - /// A task that completes with the creation of a new App Check token. - /// Thrown if an error occurs while creating the custom token. - /// The Firebase app instance. - internal class AppCheckApiClient + internal sealed class AppCheckApiClient : IDisposable { - private const string ApiUrlFormat = "https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken"; + private const string AppCheckUrlFormat = "https://firebaseappcheck.googleapis.com/v1/projects/{projectId}/apps/{appId}:exchangeCustomToken"; private const string OneTimeUseTokenVerificationUrlFormat = "https://firebaseappcheck.googleapis.com/v1beta/projects/{projectId}:verifyAppCheckToken"; - private readonly FirebaseApp app; - private string projectId; - /// - /// Initializes a new instance of the class. - /// - /// Initailize FirebaseApp. - public AppCheckApiClient(FirebaseApp value) + private readonly ErrorHandlingHttpClient httpClient; + private readonly string projectId; + + internal AppCheckApiClient(Args args) + { + string noProjectId = "Project ID is required to access app check service. Use a service account " + + "credential or set the project ID explicitly via AppOptions. Alternatively " + + "you can set the project ID via the GOOGLE_CLOUD_PROJECT environment " + + "variable."; + if (string.IsNullOrEmpty(args.ProjectId)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + noProjectId, + AppCheckErrorCode.InvalidArgument); + } + + this.httpClient = new ErrorHandlingHttpClient( + new ErrorHandlingHttpClientArgs() + { + HttpClientFactory = args.ClientFactory.ThrowIfNull(nameof(args.ClientFactory)), + Credential = args.Credential.ThrowIfNull(nameof(args.Credential)), + RequestExceptionHandler = AppCheckErrorHandler.Instance, + ErrorResponseHandler = AppCheckErrorHandler.Instance, + DeserializeExceptionHandler = AppCheckErrorHandler.Instance, + RetryOptions = args.RetryOptions, + }); + this.projectId = args.ProjectId; + } + + internal static string ClientVersion { - if (value == null || value.Options == null) + get { - throw new ArgumentException("Argument passed to admin.appCheck() must be a valid Firebase app instance."); + return $"fire-admin-dotnet/{FirebaseApp.GetSdkVersion()}"; } + } - this.app = value; - this.projectId = this.app.Options.ProjectId; + public void Dispose() + { + this.httpClient.Dispose(); } /// /// Exchange a signed custom token to App Check token. /// - /// The custom token to be exchanged. - /// The mobile App ID. - /// A A promise that fulfills with a `AppCheckToken`. + /// The custom token to be exchanged. + /// The mobile App ID. + /// A promise that fulfills with a `AppCheckToken`. public async Task ExchangeTokenAsync(string customToken, string appId) { if (string.IsNullOrEmpty(customToken)) { - throw new ArgumentNullException("First argument passed to customToken must be a valid Firebase app instance."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "customToken must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } if (string.IsNullOrEmpty(appId)) { - throw new ArgumentNullException("Second argument passed to appId must be a valid Firebase app instance."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "appId must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } + var body = new ExchangeTokenRequest() + { + CustomToken = customToken, + }; + var url = this.GetUrl(appId); - var content = new StringContent(JsonConvert.SerializeObject(new { customToken }), Encoding.UTF8, "application/json"); var request = new HttpRequestMessage() { Method = HttpMethod.Post, RequestUri = new Uri(url), - Content = content, + Content = NewtonsoftJsonSerializer.Instance.CreateJsonHttpContent(body), }; - request.Headers.Add("X-Firebase-Client", "fire-admin-node/" + $"{FirebaseApp.GetSdkVersion()}"); - var httpClient = new HttpClient(); - var response = await httpClient.SendAsync(request).ConfigureAwait(false); - if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) + AddCommonHeaders(request); + + try { - var errorContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - throw new InvalidOperationException($"BadRequest: {errorContent}"); + var response = await this.httpClient + .SendAndDeserializeAsync(request) + .ConfigureAwait(false); + + var appCheck = this.ToAppCheckToken(response.Result); + + return appCheck; } - else if (!response.IsSuccessStatusCode) + catch (HttpRequestException ex) { - throw new HttpRequestException("network error"); + throw AppCheckErrorHandler.Instance.HandleHttpRequestException(ex); } - - JObject responseData = JObject.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - string tokenValue = responseData["data"]["token"].ToString(); - int ttlValue = this.StringToMilliseconds(responseData["data"]["ttl"].ToString()); - AppCheckToken appCheckToken = new (tokenValue, ttlValue); - return appCheckToken; } - /// - /// Exchange a signed custom token to App Check token. - /// - /// The custom token to be exchanged. - /// A alreadyConsumed is true. - public async Task VerifyReplayProtection(string token) + public async Task VerifyReplayProtectionAsync(string token) { if (string.IsNullOrEmpty(token)) { - throw new ArgumentException("invalid-argument", "`token` must be a non-empty string."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`tokne` must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } - string url = this.GetVerifyTokenUrl(); + var body = new VerifyTokenRequest() + { + AppCheckToken = token, + }; + string url = this.GetVerifyTokenUrl(); var request = new HttpRequestMessage() { Method = HttpMethod.Post, RequestUri = new Uri(url), - Content = new StringContent(token), + Content = NewtonsoftJsonSerializer.Instance.CreateJsonHttpContent(body), }; + AddCommonHeaders(request); + + bool ret = false; - var httpClient = new HttpClient(); - var response = await httpClient.SendAsync(request).ConfigureAwait(false); + try + { + var response = await this.httpClient + .SendAndDeserializeAsync(request) + .ConfigureAwait(false); - var responseData = JObject.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); - bool alreadyConsumed = (bool)responseData["data"]["alreadyConsumed"]; - return alreadyConsumed; + ret = response.Result.AlreadyConsumed; + } + catch (HttpRequestException e) + { + AppCheckErrorHandler.Instance.HandleHttpRequestException(e); + } + + return ret; } - /// - /// Get Verify Token Url . - /// - /// A formatted verify token url. - private string GetVerifyTokenUrl() + internal static AppCheckApiClient Create(FirebaseApp app) { - var urlParams = new Dictionary + var args = new Args { - { "projectId", this.projectId }, + ClientFactory = app.Options.HttpClientFactory, + Credential = app.Options.Credential, + ProjectId = app.Options.ProjectId, + RetryOptions = RetryOptions.Default, }; - string baseUrl = this.FormatString(OneTimeUseTokenVerificationUrlFormat, urlParams); - return this.FormatString(baseUrl, null); + return new AppCheckApiClient(args); } - /// - /// Get url from FirebaseApp Id . - /// - /// The FirebaseApp Id. - /// A formatted verify token url. - private string GetUrl(string appId) + private static void AddCommonHeaders(HttpRequestMessage request) { - if (string.IsNullOrEmpty(this.projectId)) + request.Headers.Add("X-Firebase-Client", ClientVersion); + } + + private AppCheckToken ToAppCheckToken(ExchangeTokenResponse resp) + { + if (resp == null || string.IsNullOrEmpty(resp.Token)) { - this.projectId = this.app.GetProjectId(); + throw new FirebaseAppCheckException( + ErrorCode.PermissionDenied, + "Token is not valid", + AppCheckErrorCode.AppCheckTokenExpired); } + if (string.IsNullOrEmpty(resp.Ttl) || !resp.Ttl.EndsWith("s")) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`ttl` must be a valid duration string with the suffix `s`.", + AppCheckErrorCode.InvalidArgument); + } + + return new AppCheckToken(resp.Token, this.StringToMilliseconds(resp.Ttl)); + } + + private string GetUrl(string appId) + { if (string.IsNullOrEmpty(this.projectId)) { string errorMessage = "Failed to determine project ID. Initialize the SDK with service account " + "credentials or set project ID as an app option. Alternatively, set the " + "GOOGLE_CLOUD_PROJECT environment variable."; - throw new ArgumentException( - "unknown-error", - errorMessage); + + throw new FirebaseAppCheckException( + ErrorCode.Unknown, + errorMessage, + AppCheckErrorCode.UnknownError); } var urlParams = new Dictionary @@ -158,41 +215,74 @@ private string GetUrl(string appId) { "projectId", this.projectId }, { "appId", appId }, }; - string baseUrl = this.FormatString(ApiUrlFormat, urlParams); - return baseUrl; + + return HttpUtils.FormatString(AppCheckUrlFormat, urlParams); } - /// - /// Converts a duration string with the suffix `s` to milliseconds. - /// - /// The duration as a string with the suffix "s" preceded by the number of seconds. - /// The duration in milliseconds. private int StringToMilliseconds(string duration) { - if (string.IsNullOrEmpty(duration) || !duration.EndsWith("s")) - { - throw new ArgumentException("invalid-argument", "`ttl` must be a valid duration string with the suffix `s`."); - } - string modifiedString = duration.Remove(duration.Length - 1); return int.Parse(modifiedString) * 1000; } - /// - /// Formats a string of form 'project/{projectId}/{api}' and replaces with corresponding arguments {projectId: '1234', api: 'resource'}. - /// - /// The original string where the param need to be replaced. - /// The optional parameters to replace in thestring. - /// The resulting formatted string. - private string FormatString(string str, Dictionary urlParams) + private string GetVerifyTokenUrl() { - string formatted = str; - foreach (var key in urlParams.Keys) + if (string.IsNullOrEmpty(this.projectId)) { - formatted = Regex.Replace(formatted, $"{{{key}}}", urlParams[key]); + string errorMessage = "Failed to determine project ID. Initialize the SDK with service account " + + "credentials or set project ID as an app option. Alternatively, set the " + + "GOOGLE_CLOUD_PROJECT environment variable."; + + throw new FirebaseAppCheckException( + ErrorCode.Unknown, + errorMessage, + AppCheckErrorCode.UnknownError); } - return formatted; + var urlParams = new Dictionary + { + { "projectId", this.projectId }, + }; + + return HttpUtils.FormatString(OneTimeUseTokenVerificationUrlFormat, urlParams); + } + + internal sealed class Args + { + internal HttpClientFactory ClientFactory { get; set; } + + internal GoogleCredential Credential { get; set; } + + internal string ProjectId { get; set; } + + internal RetryOptions RetryOptions { get; set; } + } + + internal class ExchangeTokenRequest + { + [Newtonsoft.Json.JsonProperty("customToken")] + public string CustomToken { get; set; } + } + + internal class ExchangeTokenResponse + { + [Newtonsoft.Json.JsonProperty("token")] + public string Token { get; set; } + + [Newtonsoft.Json.JsonProperty("ttl")] + public string Ttl { get; set; } + } + + internal class VerifyTokenRequest + { + [Newtonsoft.Json.JsonProperty("appCheckToken")] + public string AppCheckToken { get; set; } + } + + internal class VerifyTokenResponse + { + [Newtonsoft.Json.JsonProperty("alreadyConsumed")] + public bool AlreadyConsumed { get; set; } } } } diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs new file mode 100644 index 00000000..63c15eba --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckDecodedToken.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Interface representing a decoded Firebase App Check token, returned from the {@link AppCheck.verifyToken} method.. + /// + public class AppCheckDecodedToken + { + internal AppCheckDecodedToken(Args args) + { + this.AppId = args.AppId; + this.Issuer = args.Issuer; + this.Subject = args.Subject; + this.Audience = args.Audience; + this.ExpirationTimeSeconds = (int)args.ExpirationTimeSeconds; + this.IssuedAtTimeSeconds = (int)args.IssuedAtTimeSeconds; + } + + /// + /// Gets or sets the issuer identifier for the issuer of the response. + /// + public string Issuer { get; set; } + + /// + /// Gets or sets the Firebase App ID corresponding to the app the token belonged to. + /// As a convenience, this value is copied over to the {@link AppCheckDecodedToken.app_id | app_id} property. + /// + public string Subject { get; set; } + + /// + /// Gets or sets the audience for which this token is intended. + /// This value is a JSON array of two strings, the first is the project number of your + /// Firebase project, and the second is the project ID of the same project. + /// + public string[] Audience { get; set; } + + /// + /// Gets or sets the App Check token's c time, in seconds since the Unix epoch. That is, the + /// time at which this App Check token expires and should no longer be considered valid. + /// + public int ExpirationTimeSeconds { get; set; } + + /// + /// Gets or sets the App Check token's issued-at time, in seconds since the Unix epoch. That is, the + /// time at which this App Check token was issued and should start to be considered valid. + /// + public int IssuedAtTimeSeconds { get; set; } + + /// + /// Gets or sets the App ID corresponding to the App the App Check token belonged to. + /// This value is not actually one of the JWT token claims. It is added as a + /// convenience, and is set as the value of the {@link AppCheckDecodedToken.sub | sub} property. + /// + public string AppId { get; set; } + + /// + /// Gets or sets key . + /// + public Dictionary Key { get; set; } + ////[key: string]: any; + + internal sealed class Args + { + public string AppId { get; internal set; } + + [JsonProperty("app_id")] + internal string Issuer { get; set; } + + [JsonProperty("sub")] + internal string Subject { get; set; } + + [JsonProperty("aud")] + internal string[] Audience { get; set; } + + [JsonProperty("exp")] + internal long ExpirationTimeSeconds { get; set; } + + [JsonProperty("iat")] + internal long IssuedAtTimeSeconds { get; set; } + + [JsonIgnore] + internal IReadOnlyDictionary Claims { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs new file mode 100644 index 00000000..32b6645b --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorCode.cs @@ -0,0 +1,53 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Error codes that can be raised by the Firebase App Check APIs. + /// + public enum AppCheckErrorCode + { + /// + /// Process is aborted + /// + Aborted, + + /// + /// Argument is not valid + /// + InvalidArgument, + + /// + /// Credential is not valid + /// + InvalidCredential, + + /// + /// The server internal error + /// + InternalError, + + /// + /// Permission is denied + /// + PermissionDenied, + + /// + /// Unauthenticated + /// + Unauthenticated, + + /// + /// Resource is not found + /// + NotFound, + + /// + /// App Check Token is expired + /// + AppCheckTokenExpired, + + /// + /// Unknown Error + /// + UnknownError, + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs new file mode 100644 index 00000000..7fe1f7c0 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckErrorHandler.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using FirebaseAdmin.Util; +using Google.Apis.Json; +using Newtonsoft.Json; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Parses error responses received from the Auth service, and creates instances of + /// . + /// + internal sealed class AppCheckErrorHandler + : HttpErrorHandler, + IHttpRequestExceptionHandler, + IDeserializeExceptionHandler + { + internal static readonly AppCheckErrorHandler Instance = new AppCheckErrorHandler(); + + private static readonly IReadOnlyDictionary CodeToErrorInfo = + new Dictionary() + { + { + "ABORTED", + new ErrorInfo( + ErrorCode.Aborted, + AppCheckErrorCode.Aborted, + "App check is aborted") + }, + { + "INVALID_ARGUMENT", + new ErrorInfo( + ErrorCode.InvalidArgument, + AppCheckErrorCode.InvalidArgument, + "An argument is not valid") + }, + { + "INVALID_CREDENTIAL", + new ErrorInfo( + ErrorCode.InvalidArgument, + AppCheckErrorCode.InvalidCredential, + "The credential is not valid") + }, + { + "PERMISSION_DENIED", + new ErrorInfo( + ErrorCode.PermissionDenied, + AppCheckErrorCode.PermissionDenied, + "The permission is denied") + }, + { + "UNAUTHENTICATED", + new ErrorInfo( + ErrorCode.Unauthenticated, + AppCheckErrorCode.Unauthenticated, + "Unauthenticated") + }, + { + "NOT_FOUND", + new ErrorInfo( + ErrorCode.NotFound, + AppCheckErrorCode.NotFound, + "The resource is not found") + }, + { + "UNKNOWN", + new ErrorInfo( + ErrorCode.Unknown, + AppCheckErrorCode.UnknownError, + "unknown-error") + }, + }; + + private AppCheckErrorHandler() { } + + public FirebaseAppCheckException HandleHttpRequestException( + HttpRequestException exception) + { + var temp = exception.ToFirebaseException(); + return new FirebaseAppCheckException( + temp.ErrorCode, + temp.Message, + inner: temp.InnerException, + response: temp.HttpResponse); + } + + public FirebaseAppCheckException HandleDeserializeException( + Exception exception, ResponseInfo responseInfo) + { + return new FirebaseAppCheckException( + ErrorCode.Unknown, + $"Error while parsing AppCheck service response. Deserialization error: {responseInfo.Body}", + AppCheckErrorCode.UnknownError, + inner: exception, + response: responseInfo.HttpResponse); + } + + protected sealed override FirebaseExceptionArgs CreateExceptionArgs( + HttpResponseMessage response, string body) + { + var appCheckError = this.ParseAppCheckError(body); + + ErrorInfo info; + CodeToErrorInfo.TryGetValue(appCheckError.Code, out info); + + var defaults = base.CreateExceptionArgs(response, body); + return new FirebaseAppCheckExceptionArgs() + { + Code = info?.ErrorCode ?? defaults.Code, + Message = info?.GetMessage(appCheckError) ?? defaults.Message, + HttpResponse = response, + ResponseBody = body, + AppCheckErrorCode = info?.AppCheckErrorCode, + }; + } + + protected override FirebaseAppCheckException CreateException(FirebaseExceptionArgs args) + { + return new FirebaseAppCheckException( + args.Code, + args.Message, + (args as FirebaseAppCheckExceptionArgs).AppCheckErrorCode, + response: args.HttpResponse); + } + + private AppCheckError ParseAppCheckError(string body) + { + try + { + var parsed = NewtonsoftJsonSerializer.Instance.Deserialize(body); + return parsed.Error ?? new AppCheckError(); + } + catch + { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json body. + return new AppCheckError(); + } + } + + /// + /// Describes a class of errors that can be raised by the Firebase Auth backend API. + /// + private sealed class ErrorInfo + { + private readonly string message; + + internal ErrorInfo(ErrorCode code, AppCheckErrorCode appCheckErrorCode, string message) + { + this.ErrorCode = code; + this.AppCheckErrorCode = appCheckErrorCode; + this.message = message; + } + + internal ErrorCode ErrorCode { get; private set; } + + internal AppCheckErrorCode AppCheckErrorCode { get; private set; } + + internal string GetMessage(AppCheckError appCheckError) + { + var message = $"{this.message} ({appCheckError.Code})."; + if (!string.IsNullOrEmpty(appCheckError.Detail)) + { + return $"{message}: {appCheckError.Detail}"; + } + + return $"{message}"; + } + } + + private sealed class FirebaseAppCheckExceptionArgs : FirebaseExceptionArgs + { + internal AppCheckErrorCode? AppCheckErrorCode { get; set; } + } + + private sealed class AppCheckError + { + [JsonProperty("message")] + internal string Message { get; set; } + + /// + /// Gets the Firebase Auth error code extracted from the response. Returns empty string + /// if the error code cannot be determined. + /// + internal string Code + { + get + { + var separator = this.GetSeparator(); + if (separator != -1) + { + return this.Message.Substring(0, separator); + } + + return this.Message ?? string.Empty; + } + } + + /// + /// Gets the error detail sent by the Firebase Auth API. May be null. + /// + internal string Detail + { + get + { + var separator = this.GetSeparator(); + if (separator != -1) + { + return this.Message.Substring(separator + 1).Trim(); + } + + return null; + } + } + + private int GetSeparator() + { + return this.Message?.IndexOf(':') ?? -1; + } + } + + private sealed class AppCheckErrorResponse + { + [JsonProperty("error")] + internal AppCheckError Error { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs index 1441631c..deb2abd6 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckToken.cs @@ -1,23 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.AppCheck { /// /// Interface representing an App Check token. /// - /// - /// Initializes a new instance of the class. - /// /// Generator from custom token. /// TTl value . public class AppCheckToken(string tokenValue, int ttlValue) { /// - /// Gets the Firebase App Check token. + /// Gets or sets the Firebase App Check token. /// - public string Token { get; } = tokenValue; + public string Token { get; set; } = tokenValue; /// /// Gets or sets the time-to-live duration of the token in milliseconds. diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs similarity index 60% rename from FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs rename to FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs index 6c0e7bf1..c2f6db25 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenGenerator.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenFactory.cs @@ -1,34 +1,20 @@ using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; using System.Threading; using System.Threading.Tasks; using FirebaseAdmin.Auth; using FirebaseAdmin.Auth.Jwt; +using FirebaseAdmin.Messaging.Util; using Google.Apis.Auth; using Google.Apis.Util; using Newtonsoft.Json; -[assembly: InternalsVisibleToAttribute("FirebaseAdmin.Tests,PublicKey=" + -"002400000480000094000000060200000024000052534131000400000100010081328559eaab41" + -"055b84af73469863499d81625dcbba8d8decb298b69e0f783a0958cf471fd4f76327b85a7d4b02" + -"3003684e85e61cf15f13150008c81f0b75a252673028e530ea95d0c581378da8c6846526ab9597" + -"4c6d0bc66d2462b51af69968a0e25114bde8811e0d6ee1dc22d4a59eee6a8bba4712cba839652f" + -"badddb9c")] - -namespace FirebaseAdmin.Check +namespace FirebaseAdmin.AppCheck { /// /// A helper class that creates Firebase custom tokens. /// - internal class AppCheckTokenGenerator + internal class AppCheckTokenFactory : IDisposable { - public const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" - + "google.identity.identitytoolkit.v1.IdentityToolkit"; - public const string FirebaseAppCheckAudience = "https://firebaseappcheck.googleapis.com/google.firebase.appcheck.v1.TokenExchangeService"; public const int OneMinuteInSeconds = 60; @@ -38,25 +24,7 @@ internal class AppCheckTokenGenerator public static readonly DateTime UnixEpoch = new DateTime( 1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - public static readonly ImmutableList ReservedClaims = ImmutableList.Create( - "acr", - "amr", - "at_hash", - "aud", - "auth_time", - "azp", - "cnf", - "c_hash", - "exp", - "firebase", - "iat", - "iss", - "jti", - "nbf", - "nonce", - "sub"); - - internal AppCheckTokenGenerator(Args args) + internal AppCheckTokenFactory(Args args) { args.ThrowIfNull(nameof(args)); @@ -70,8 +38,6 @@ internal AppCheckTokenGenerator(Args args) internal IClock Clock { get; } - internal string TenantId { get; } - internal bool IsEmulatorMode { get; } public void Dispose() @@ -79,54 +45,30 @@ public void Dispose() this.Signer.Dispose(); } - internal static AppCheckTokenGenerator Create(FirebaseApp app) - { - ISigner signer = null; - var serviceAccount = app.Options.Credential.ToServiceAccountCredential(); - if (serviceAccount != null) - { - // If the app was initialized with a service account, use it to sign - // tokens locally. - signer = new ServiceAccountSigner(serviceAccount); - } - else if (string.IsNullOrEmpty(app.Options.ServiceAccountId)) - { - // If no service account ID is specified, attempt to discover one and invoke the - // IAM service with it. - signer = IAMSigner.Create(app); - } - else - { - // If a service account ID is specified, invoke the IAM service with it. - signer = FixedAccountIAMSigner.Create(app); - } - - var args = new Args - { - Signer = signer, - IsEmulatorMode = Utils.IsEmulatorModeFromEnvironment, - }; - return new AppCheckTokenGenerator(args); - } - - internal async Task CreateCustomTokenAsync( + /// + /// Creates a new custom token that can be exchanged to an App Check token. + /// + /// The mobile App ID. + /// Options for AppCheckToken with ttl. Possibly null. + /// A cancellation token to monitor the asynchronous. + /// A Promise fulfilled with a custom token signed with a service account key that can be exchanged to an App Check token. + public async Task CreateCustomTokenAsync( string appId, AppCheckTokenOptions options = null, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(appId)) { - throw new ArgumentException("appId must not be null or empty"); - } - else if (appId.Length > 128) - { - throw new ArgumentException("appId must not be longer than 128 characters"); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "`appId` must be a non-empty string.", + AppCheckErrorCode.InvalidArgument); } string customOptions = " "; if (options != null) { - customOptions = this.ValidateTokenOptions(options).ToString(); + customOptions = this.ValidateTokenOptions(options); } var header = new JsonWebSignature.Header() @@ -152,19 +94,50 @@ internal async Task CreateCustomTokenAsync( header, payload, this.Signer).ConfigureAwait(false); } - private int ValidateTokenOptions(AppCheckTokenOptions options) + internal static AppCheckTokenFactory Create(FirebaseApp app) + { + ISigner signer = null; + var serviceAccount = app.Options.Credential.ToServiceAccountCredential(); + if (serviceAccount != null) + { + signer = new ServiceAccountSigner(serviceAccount); + } + else if (string.IsNullOrEmpty(app.Options.ServiceAccountId)) + { + signer = IAMSigner.Create(app); + } + else + { + signer = FixedAccountIAMSigner.Create(app); + } + + var args = new Args + { + Signer = signer, + IsEmulatorMode = Utils.IsEmulatorModeFromEnvironment, + }; + return new AppCheckTokenFactory(args); + } + + private string ValidateTokenOptions(AppCheckTokenOptions options) { if (options == null) { - throw new ArgumentException("invalid-argument", "AppCheckTokenOptions must be a non-null object."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "AppCheckTokenOptions must be a non-null object.", + AppCheckErrorCode.InvalidArgument); } if (options.TtlMillis < (OneMinuteInMills * 30) || options.TtlMillis > (OneDayInMills * 7)) { - throw new ArgumentException("invalid-argument", "ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive)."); + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "ttlMillis must be a duration in milliseconds between 30 minutes and 7 days (inclusive).", + AppCheckErrorCode.InvalidArgument); } - return options.TtlMillis; + return TimeConverter.LongMillisToString(options.TtlMillis); } internal class CustomTokenPayload : JsonWebToken.Payload @@ -182,8 +155,6 @@ internal sealed class Args internal IClock Clock { get; set; } - internal string TenantId { get; set; } - internal bool IsEmulatorMode { get; set; } } } diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs index 453f9c5e..2f4cd730 100644 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenOptions.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace FirebaseAdmin.Auth.Jwt +namespace FirebaseAdmin.AppCheck { /// /// Representing App Check token options. diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs new file mode 100644 index 00000000..7c108a03 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerifier.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FirebaseAdmin.Auth.Jwt; +using Google.Apis.Auth; +using Google.Apis.Util; + +namespace FirebaseAdmin.AppCheck +{ + internal class AppCheckTokenVerifier + { + private const string AppCheckIssuer = "https://firebaseappcheck.googleapis.com/"; + private const string JWKSURL = "https://firebaseappcheck.googleapis.com/v1/jwks"; + + private static readonly IReadOnlyList StandardClaims = + ImmutableList.Create("iss", "aud", "exp", "iat", "sub", "uid"); + + internal AppCheckTokenVerifier(Args args) + { + args.ThrowIfNull(nameof(args)); + this.ProjectId = args.ProjectId; + this.KeySource = args.KeySource.ThrowIfNull(nameof(args.KeySource)); + this.Clock = args.Clock ?? SystemClock.Default; + } + + internal IClock Clock { get; } + + internal string ProjectId { get; } + + internal IPublicKeySource KeySource { get; } + + /// + /// Verifies the format and signature of a Firebase App Check token. + /// + /// The Firebase Auth JWT token to verify. + /// A cancellation token to monitor the asynchronous operation. + /// A task that completes with a representing + /// a user with the specified user ID. + public async Task VerifyTokenAsync( + string token, CancellationToken cancellationToken = default(CancellationToken)) + { + if (string.IsNullOrEmpty(token)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "App Check token must not be null or empty.", + AppCheckErrorCode.InvalidArgument); + } + + if (string.IsNullOrEmpty(this.ProjectId)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "Must initialize app with a cert credential or set your Firebase project ID as the GOOGLE_CLOUD_PROJECT environment variable to verify an App Check token.", + AppCheckErrorCode.InvalidCredential); + } + + string[] segments = token.Split('.'); + if (segments.Length != 3) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "Incorrect number of segments in app check token.", + AppCheckErrorCode.InvalidArgument); + } + + var header = JwtUtils.Decode(segments[0]); + var payload = JwtUtils.Decode(segments[1]); + + var projectIdMessage = $"Incorrect number of segments in app check Token." + + "project as the credential used to initialize this SDK."; + var scopedProjectId = $"projects/{this.ProjectId}"; + string errorMessage = string.Empty; + + if (header.Algorithm != "RS256") + { + errorMessage = "The provided app check token has incorrect algorithm. Expected 'RS256'" + + " but got " + $"{header.Algorithm}" + "."; + } + else if (payload.Audience.Length > 0 || payload.Audience.Contains(scopedProjectId)) + { + errorMessage = "The provided app check token has incorrect \"aud\" (audience) claim. Expected " + + scopedProjectId + "but got" + payload.Audience + "." + projectIdMessage; + } + else if (payload.Issuer.StartsWith(AppCheckIssuer)) + { + errorMessage = $"The provided app check token has incorrect \"iss\" (issuer) claim."; + } + else if (payload.Subject == null) + { + errorMessage = "The provided app check token has no \"sub\" (subject) claim."; + } + else if (payload.Subject == string.Empty) + { + errorMessage = "The provided app check token has an empty string \"sub\" (subject) claim."; + } + + if (!string.IsNullOrEmpty(errorMessage)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + errorMessage, + AppCheckErrorCode.InvalidArgument); + } + + await this.VerifySignatureAsync(segments, header.KeyId, cancellationToken) + .ConfigureAwait(false); + var allClaims = JwtUtils.Decode>(segments[1]); + + // Remove standard claims, so that only custom claims would remain. + foreach (var claim in StandardClaims) + { + allClaims.Remove(claim); + } + + payload.Claims = allClaims.ToImmutableDictionary(); + return new AppCheckDecodedToken(payload); + } + + internal static AppCheckTokenVerifier Create(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + var projectId = app.GetProjectId(); + if (string.IsNullOrEmpty(projectId)) + { + throw new ArgumentException( + "Must initialize FirebaseApp with a project ID to verify session cookies."); + } + + IPublicKeySource keySource = new HttpPublicKeySource( + JWKSURL, SystemClock.Default, app.Options.HttpClientFactory); + + var args = new Args + { + ProjectId = projectId, + KeySource = keySource, + }; + return new AppCheckTokenVerifier(args); + } + + /// + /// Verifies the integrity of a JWT by validating its signature. The JWT must be specified + /// as an array of three segments (header, body and signature). + /// + private async Task VerifySignatureAsync( + string[] segments, string keyId, CancellationToken cancellationToken) + { + byte[] hash; + using (var hashAlg = SHA256.Create()) + { + hash = hashAlg.ComputeHash( + Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); + } + + var signature = JwtUtils.Base64DecodeToBytes(segments[2]); + var keys = await this.KeySource.GetPublicKeysAsync(cancellationToken) + .ConfigureAwait(false); + var verified = keys.Any(key => + key.Id == keyId && key.RSA.VerifyHash( + hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + if (!verified) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + "Failed to verify app check signature.", + AppCheckErrorCode.InvalidCredential); + } + } + + internal sealed class Args + { + internal IClock Clock { get; set; } + + internal string ProjectId { get; set; } + + internal IPublicKeySource KeySource { get; set; } + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs deleted file mode 100644 index b11334b3..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckTokenVerify.cs +++ /dev/null @@ -1,263 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth; -using FirebaseAdmin.Auth.Jwt; -using Google.Apis.Auth; -using Google.Apis.Util; - -namespace FirebaseAdmin.Check -{ - internal class AppCheckTokenVerify - { - /*private const string IdTokenCertUrl = "https://www.googleapis.com/robot/v1/metadata/x509/" - + "securetoken@system.gserviceaccount.com"; - - private const string SessionCookieCertUrl = "https://www.googleapis.com/identitytoolkit/v3/" - + "relyingparty/publicKeys"; -*/ - private const string AppCheckIuuser = "https://firebaseappcheck.googleapis.com/"; - private const string JWKSURL = "https://firebaseappcheck.googleapis.com/v1/jwks"; - private const string FirebaseAudience = "https://identitytoolkit.googleapis.com/" - + "google.identity.identitytoolkit.v1.IdentityToolkit"; - - private const long ClockSkewSeconds = 5 * 60; - - // See http://oid-info.com/get/2.16.840.1.101.3.4.2.1 - private const string Sha256Oid = "2.16.840.1.101.3.4.2.1"; - - private static readonly IReadOnlyList StandardClaims = - ImmutableList.Create("iss", "aud", "exp", "iat", "sub", "uid"); - - private readonly string shortName; - private readonly string articledShortName; - private readonly string operation; - private readonly string url; - private readonly string issuer; - private readonly IClock clock; - private readonly IPublicKeySource keySource; - private readonly AuthErrorCode invalidTokenCode; - private readonly AuthErrorCode expiredIdTokenCode; - - internal AppCheckTokenVerify(FirebaseTokenVerifierArgs args) - { - this.ProjectId = args.ProjectId.ThrowIfNullOrEmpty(nameof(args.ProjectId)); - this.shortName = args.ShortName.ThrowIfNullOrEmpty(nameof(args.ShortName)); - this.operation = args.Operation.ThrowIfNullOrEmpty(nameof(args.Operation)); - this.url = args.Url.ThrowIfNullOrEmpty(nameof(args.Url)); - this.issuer = args.Issuer.ThrowIfNullOrEmpty(nameof(args.Issuer)); - this.clock = args.Clock ?? SystemClock.Default; - this.keySource = args.PublicKeySource.ThrowIfNull(nameof(args.PublicKeySource)); - this.invalidTokenCode = args.InvalidTokenCode; - this.expiredIdTokenCode = args.ExpiredTokenCode; - this.IsEmulatorMode = args.IsEmulatorMode; - if ("aeiou".Contains(this.shortName.ToLower().Substring(0, 1))) - { - this.articledShortName = $"an {this.shortName}"; - } - else - { - this.articledShortName = $"a {this.shortName}"; - } - } - - internal string ProjectId { get; } - - internal bool IsEmulatorMode { get; } - - internal static AppCheckTokenVerify Create(FirebaseApp app) - { - if (app == null) - { - throw new ArgumentNullException(nameof(app)); - } - - var projectId = app.GetProjectId(); - if (string.IsNullOrEmpty(projectId)) - { - throw new ArgumentException( - "Must initialize FirebaseApp with a project ID to verify session cookies."); - } - - var keySource = new HttpPublicKeySource( - JWKSURL, SystemClock.Default, app.Options.HttpClientFactory); - return Create(projectId, keySource); - } - - internal static AppCheckTokenVerify Create( - string projectId, - IPublicKeySource keySource, - IClock clock = null) - { - var args = new FirebaseTokenVerifierArgs() - { - ProjectId = projectId, - ShortName = "session cookie", - Operation = "VerifySessionCookieAsync()", - Url = "https://firebase.google.com/docs/auth/admin/manage-cookies", - Issuer = "https://session.firebase.google.com/", - Clock = clock, - PublicKeySource = keySource, - InvalidTokenCode = AuthErrorCode.InvalidSessionCookie, - ExpiredTokenCode = AuthErrorCode.ExpiredSessionCookie, - }; - return new AppCheckTokenVerify(args); - } - - internal async Task VerifyTokenAsync( - string token, CancellationToken cancellationToken = default(CancellationToken)) - { - if (string.IsNullOrEmpty(token)) - { - throw new ArgumentException($"{this.shortName} must not be null or empty."); - } - - string[] segments = token.Split('.'); - if (segments.Length != 3) - { - throw this.CreateException($"Incorrect number of segments in {this.shortName}."); - } - - var header = JwtUtils.Decode(segments[0]); - var payload = JwtUtils.Decode(segments[1]); - var projectIdMessage = $"Make sure the {this.shortName} comes from the same Firebase " - + "project as the credential used to initialize this SDK."; - var verifyTokenMessage = $"See {this.url} for details on how to retrieve a value " - + $"{this.shortName}."; - var issuer = this.issuer + this.ProjectId; - string error = null; - var errorCode = this.invalidTokenCode; - var currentTimeInSeconds = this.clock.UnixTimestamp(); - - if (!this.IsEmulatorMode && string.IsNullOrEmpty(header.KeyId)) - { - if (payload.Audience == FirebaseAudience) - { - error = $"{this.operation} expects {this.articledShortName}, but was given a custom " - + "token."; - } - else if (header.Algorithm == "HS256") - { - error = $"{this.operation} expects {this.articledShortName}, but was given a legacy " - + "custom token."; - } - else - { - error = $"Firebase {this.shortName} has no 'kid' claim."; - } - } - else if (!this.IsEmulatorMode && header.Algorithm != "RS256") - { - error = $"Firebase {this.shortName} has incorrect algorithm. Expected RS256 but got " - + $"{header.Algorithm}. {verifyTokenMessage}"; - } - else if (this.ProjectId != payload.Audience) - { - error = $"Firebase {this.shortName} has incorrect audience (aud) claim. Expected " - + $"{this.ProjectId} but got {payload.Audience}. {projectIdMessage} " - + $"{verifyTokenMessage}"; - } - else if (payload.Issuer != issuer) - { - error = $"Firebase {this.shortName} has incorrect issuer (iss) claim. Expected " - + $"{issuer} but got {payload.Issuer}. {projectIdMessage} {verifyTokenMessage}"; - } - else if (payload.IssuedAtTimeSeconds - ClockSkewSeconds > currentTimeInSeconds) - { - error = $"Firebase {this.shortName} issued at future timestamp " - + $"{payload.IssuedAtTimeSeconds}. Expected to be less than " - + $"{currentTimeInSeconds}."; - } - else if (payload.ExpirationTimeSeconds + ClockSkewSeconds < currentTimeInSeconds) - { - error = $"Firebase {this.shortName} expired at {payload.ExpirationTimeSeconds}. " - + $"Expected to be greater than {currentTimeInSeconds}."; - errorCode = this.expiredIdTokenCode; - } - else if (string.IsNullOrEmpty(payload.Subject)) - { - error = $"Firebase {this.shortName} has no or empty subject (sub) claim."; - } - else if (payload.Subject.Length > 128) - { - error = $"Firebase {this.shortName} has a subject claim longer than 128 characters."; - } - - if (error != null) - { - throw this.CreateException(error, errorCode); - } - - await this.VerifySignatureAsync(segments, header.KeyId, cancellationToken) - .ConfigureAwait(false); - var allClaims = JwtUtils.Decode>(segments[1]); - - // Remove standard claims, so that only custom claims would remain. - foreach (var claim in StandardClaims) - { - allClaims.Remove(claim); - } - - payload.Claims = allClaims.ToImmutableDictionary(); - return new FirebaseToken(payload); - } - - /// - /// Verifies the integrity of a JWT by validating its signature. The JWT must be specified - /// as an array of three segments (header, body and signature). - /// - [SuppressMessage( - "StyleCop.Analyzers", - "SA1009:ClosingParenthesisMustBeSpacedCorrectly", - Justification = "Use of directives.")] - [SuppressMessage( - "StyleCop.Analyzers", - "SA1111:ClosingParenthesisMustBeOnLineOfLastParameter", - Justification = "Use of directives.")] - private async Task VerifySignatureAsync( - string[] segments, string keyId, CancellationToken cancellationToken) - { - if (this.IsEmulatorMode) - { - cancellationToken.ThrowIfCancellationRequested(); - return; - } - - byte[] hash; - using (var hashAlg = SHA256.Create()) - { - hash = hashAlg.ComputeHash( - Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}")); - } - - var signature = JwtUtils.Base64DecodeToBytes(segments[2]); - var keys = await this.keySource.GetPublicKeysAsync(cancellationToken) - .ConfigureAwait(false); - var verified = keys.Any(key => - key.Id == keyId && key.RSA.VerifyHash( - hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) - ); - if (!verified) - { - throw this.CreateException($"Failed to verify {this.shortName} signature."); - } - } - - private FirebaseAuthException CreateException( - string message, AuthErrorCode? errorCode = null) - { - if (errorCode == null) - { - errorCode = this.invalidTokenCode; - } - - return new FirebaseAuthException(ErrorCode.InvalidArgument, message, errorCode); - } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs deleted file mode 100644 index 8cc05641..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyResponse.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using FirebaseAdmin.Auth; - -namespace FirebaseAdmin.Check -{ - /// - /// AppCheckVerifyResponse. - /// - public class AppCheckVerifyResponse(string appId, string verifiedToken, bool alreadyConsumed = false) - { - /// - /// Gets or sets a value indicating whether gets the Firebase App Check token. - /// - public bool AlreadyConsumed { get; set; } = alreadyConsumed; - - /// - /// Gets or sets the Firebase App Check token. - /// - public string AppId { get; set; } = appId; - - /// - /// Gets or sets the Firebase App Check VerifiedToken. - /// - public string VerifiedToken { get; set; } = verifiedToken; - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs new file mode 100644 index 00000000..5daafc96 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenOptions.cs @@ -0,0 +1,26 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Class representing options for the AppCheck.VerifyToken method. + /// + public class AppCheckVerifyTokenOptions + { + /// + /// Gets or sets a value indicating whether to use the replay protection feature, set this to true. The AppCheck.VerifyToken + /// method will mark the token as consumed after verifying it. + /// + /// Tokens that are found to be already consumed will be marked as such in the response. + /// + /// Tokens are only considered to be consumed if it is sent to App Check backend by calling the + /// AppCheck.VerifyToken method with this field set to true; other uses of the token + /// do not consume it. + /// + /// This replay protection feature requires an additional network call to the App Check backend + /// and forces your clients to obtain a fresh attestation from your chosen attestation providers. + /// This can therefore negatively impact performance and can potentially deplete your attestation + /// providers' quotas faster. We recommend that you use this feature only for protecting + /// low volume, security critical, or expensive operations. + /// + public bool Consume { get; set; } = false; + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs new file mode 100644 index 00000000..397caba5 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/AppCheckVerifyTokenResponse.cs @@ -0,0 +1,23 @@ +namespace FirebaseAdmin.AppCheck +{ + /// + /// Interface representing a verified App Check token response. + /// + public class AppCheckVerifyTokenResponse + { + /// + /// Gets or sets App ID corresponding to the App the App Check token belonged to. + /// + public string AppId { get; set; } + + /// + /// Gets or sets decoded Firebase App Check token. + /// + public AppCheckDecodedToken Token { get; set; } + + /// + /// Gets or sets a value indicating whether already conumed. + /// + public bool AlreadyConsumed { get; set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs new file mode 100644 index 00000000..1a61b1f9 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheck.cs @@ -0,0 +1,129 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Asynchronously creates a new Firebase App Check token for the specified Firebase app. + /// + /// A task that completes with the creation of a new App Check token. + /// Thrown if an error occurs while creating the custom token. + /// The Firebase app instance. + public sealed class FirebaseAppCheck : IFirebaseService + { + private readonly AppCheckApiClient appCheckApiClient; + private readonly AppCheckTokenFactory appCheckTokenFactory; + private readonly AppCheckTokenVerifier appCheckTokenVerifier; + + /// + /// Initializes a new instance of the class. + /// + /// Initailize FirebaseApp. + public FirebaseAppCheck(FirebaseApp app) + { + this.appCheckApiClient = AppCheckApiClient.Create(app); + this.appCheckTokenFactory = AppCheckTokenFactory.Create(app); + this.appCheckTokenVerifier = AppCheckTokenVerifier.Create(app); + } + + /// + /// Gets the messaging instance associated with the default Firebase app. This property is + /// null if the default app doesn't yet exist. + /// + public static FirebaseAppCheck DefaultInstance + { + get + { + var app = FirebaseApp.DefaultInstance; + if (app == null) + { + return null; + } + + return GetAppCheck(app); + } + } + + /// + /// Returns the messaging instance for the specified app. + /// + /// The instance associated with the specified + /// app. + /// If the app argument is null. + /// An app instance. + public static FirebaseAppCheck GetAppCheck(FirebaseApp app) + { + if (app == null) + { + throw new ArgumentNullException("App argument must not be null."); + } + + return app.GetOrInit(typeof(FirebaseAppCheck).Name, () => + { + return new FirebaseAppCheck(app); + }); + } + + /// + /// Creates a new AppCheckToken that can be sent back to a client. + /// + /// The app ID to use as the JWT app_id. + /// Optional options object when creating a new App Check Token. + /// A A promise that fulfills with a `AppCheckToken`. + public async Task CreateTokenAsync(string appId, AppCheckTokenOptions options = null) + { + string customToken = await this.appCheckTokenFactory.CreateCustomTokenAsync(appId, options, default(CancellationToken)).ConfigureAwait(false); + + return await this.appCheckApiClient.ExchangeTokenAsync(customToken, appId).ConfigureAwait(false); + } + + /// + /// Verifies a Firebase App Check token (JWT). If the token is valid, the promise is + /// fulfilled with the token's decoded claims; otherwise, the promise is + /// rejected. + /// + /// TThe App Check token to verify. + /// Optional {@link VerifyAppCheckTokenOptions} object when verifying an App Check Token. + /// A A promise fulfilled with the token's decoded claims if the App Check token is valid; otherwise, a rejected promise. + public async Task VerifyTokenAsync(string appCheckToken, AppCheckVerifyTokenOptions options = null) + { + if (string.IsNullOrEmpty(appCheckToken)) + { + throw new FirebaseAppCheckException( + ErrorCode.InvalidArgument, + $"App check token {appCheckToken} must be a non - empty string.", + AppCheckErrorCode.InvalidArgument); + } + + AppCheckDecodedToken decodedToken = await this.appCheckTokenVerifier.VerifyTokenAsync(appCheckToken).ConfigureAwait(false); + + if (options.Consume) + { + bool alreadyConsumed = await this.appCheckApiClient.VerifyReplayProtectionAsync(appCheckToken).ConfigureAwait(false); + + return new AppCheckVerifyTokenResponse() + { + AppId = decodedToken.AppId, + AlreadyConsumed = alreadyConsumed, + Token = decodedToken, + }; + } + + return new AppCheckVerifyTokenResponse() + { + AppId = decodedToken.AppId, + Token = decodedToken, + }; + } + + /// + /// Deletes this service instance. + /// + void IFirebaseService.Delete() + { + this.appCheckApiClient.Dispose(); + this.appCheckTokenFactory.Dispose(); + } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs new file mode 100644 index 00000000..55127d66 --- /dev/null +++ b/FirebaseAdmin/FirebaseAdmin/AppCheck/FirebaseAppCheckException.cs @@ -0,0 +1,27 @@ +using System; +using System.Net.Http; + +namespace FirebaseAdmin.AppCheck +{ + /// + /// Exception type raised by Firebase AppCheck APIs. + /// + public sealed class FirebaseAppCheckException : FirebaseException + { + internal FirebaseAppCheckException( + ErrorCode code, + string message, + AppCheckErrorCode? fcmCode = null, + Exception inner = null, + HttpResponseMessage response = null) + : base(code, message, inner, response) + { + this.AppCheckErrorCode = fcmCode; + } + + /// + /// Gets the Firease AppCheck error code associated with this exception. May be null. + /// + public AppCheckErrorCode? AppCheckErrorCode { get; private set; } + } +} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs deleted file mode 100644 index 0ea2eb8a..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckApiClient.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; - -namespace FirebaseAdmin.Check -{ - /// - /// Interface of Firebase App Check backend API . - /// - public interface IAppCheckApiClient - { - /// - /// Exchange a signed custom token to App Check token. - /// - /// The custom token to be exchanged. - /// The mobile App ID. - /// A representing the result of the asynchronous operation. - public Task ExchangeTokenAsync(string customToken, string appId); - - /// - /// Exchange a signed custom token to App Check token. - /// - /// The custom token to be exchanged. - /// A alreadyConsumed is true. - public Task VerifyReplayProtection(string token); - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs deleted file mode 100644 index a24554b9..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenGenerator.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth.Jwt; - -namespace FirebaseAdmin.Check -{ - /// - /// App Check Token generator. - /// - internal interface IAppCheckTokenGenerator - { - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// Appcheck Generate Token. - /// AppCheckTokenVerify. - AppCheckTokenGenerator Create(FirebaseApp app); - - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// The Id of FirebaseApp. - /// AppCheck Token Option. - /// Cancelation Token. - /// A representing the result of the asynchronous operation. - Task CreateCustomTokenAsync( - string appId, - AppCheckTokenOptions options = null, - CancellationToken cancellationToken = default); - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs deleted file mode 100644 index cc01d99b..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/IAppCheckTokenVerify.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using FirebaseAdmin.Auth; - -namespace FirebaseAdmin.Check -{ - /// - /// App Check Verify. - /// - internal interface IAppCheckTokenVerify - { - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// Appcheck Generate Token. - /// AppCheckTokenVerify. - AppCheckTokenVerify Create(FirebaseApp app); - - /// - /// Verifies the integrity of a JWT by validating its signature. - /// - /// Appcheck Generate Token. - /// cancellaton Token. - /// A representing the result of the asynchronous operation. - Task VerifyTokenAsync( - string token, CancellationToken cancellationToken = default(CancellationToken)); - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs deleted file mode 100644 index e664acc0..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/Key.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; - -namespace FirebaseAdmin -{ /// - /// Represents a cryptographic key. - /// - public class Key - { - /// - /// Gets or sets the key type. - /// - public string Kty { get; set; } - - /// - /// Gets or sets the intended use of the key. - /// - public string Use { get; set; } - - /// - /// Gets or sets the algorithm associated with the key. - /// - public string Alg { get; set; } - - /// - /// Gets or sets the key ID. - /// - public string Kid { get; set; } - - /// - /// Gets or sets the modulus for the RSA public key. - /// - public string N { get; set; } - - /// - /// Gets or sets the exponent for the RSA public key. - /// - public string E { get; set; } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs deleted file mode 100644 index 38913f1a..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/KeysRoot.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace FirebaseAdmin.Check -{ - /// - /// Represents a cryptographic key. - /// - public class KeysRoot - { - /// - /// Gets or sets represents a cryptographic key. - /// - public List Keys { get; set; } - } -} diff --git a/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs b/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs deleted file mode 100644 index 000adac5..00000000 --- a/FirebaseAdmin/FirebaseAdmin/AppCheck/VerifyAppCheckTokenOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -/// -/// Class representing options for the AppCheck.VerifyToken method. -/// -public class VerifyAppCheckTokenOptions -{ - /// - /// Gets or sets a value indicating whether to use the replay protection feature, set this to true. The AppCheck.VerifyToken - /// method will mark the token as consumed after verifying it. - /// - /// Tokens that are found to be already consumed will be marked as such in the response. - /// - /// Tokens are only considered to be consumed if it is sent to App Check backend by calling the - /// AppCheck.VerifyToken method with this field set to true; other uses of the token - /// do not consume it. - /// - /// This replay protection feature requires an additional network call to the App Check backend - /// and forces your clients to obtain a fresh attestation from your chosen attestation providers. - /// This can therefore negatively impact performance and can potentially deplete your attestation - /// providers' quotas faster. We recommend that you use this feature only for protecting - /// low volume, security critical, or expensive operations. - /// - public bool Consume { get; set; } = false; -}