Skip to content

Commit

Permalink
feature/AppcheckTest
Browse files Browse the repository at this point in the history
  • Loading branch information
pavlo committed Jan 19, 2024
1 parent 8aa2ee6 commit 7760b87
Show file tree
Hide file tree
Showing 10 changed files with 553 additions and 55 deletions.
76 changes: 76 additions & 0 deletions FirebaseAdmin/FirebaseAdmin.Tests/FirebaseAppCheckTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
1 change: 1 addition & 0 deletions FirebaseAdmin/FirebaseAdmin.Tests/resources/appid.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1234,project,Appcheck
6 changes: 6 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Auth/FirebaseToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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")]
Expand Down
107 changes: 107 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Check/AppCheckApiClient.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
50 changes: 50 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Check/AppCheckService.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
27 changes: 27 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Check/AppCheckToken.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
90 changes: 90 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Check/AppCheckTokenGernerator.cs
Original file line number Diff line number Diff line change
@@ -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('/', '_');
}
}
}
Loading

0 comments on commit 7760b87

Please sign in to comment.