Skip to content

Multi-tenancy #152

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions FirebaseAdmin/FirebaseAdmin.Tests/Auth/FirebaseTokenFactoryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,16 @@ public async Task CreateCustomToken()
var clock = new MockClock();
var factory = new FirebaseTokenFactory(new MockSigner(), clock);
var token = await factory.CreateCustomTokenAsync("user1");
VerifyCustomToken(token, "user1", null);
VerifyCustomToken(token, "user1", null, null);
}

[Fact]
public async Task CreateCustomTenantToken()
{
var clock = new MockClock();
var factory = new FirebaseTokenFactory(new MockSigner(), clock, "test-01abc");
var token = await factory.CreateCustomTokenAsync("user1");
VerifyCustomToken(token, "user1", null, "test-01abc");
}

[Fact]
Expand All @@ -46,7 +55,7 @@ public async Task CreateCustomTokenWithEmptyClaims()
var factory = new FirebaseTokenFactory(new MockSigner(), clock);
var token = await factory.CreateCustomTokenAsync(
"user1", new Dictionary<string, object>());
VerifyCustomToken(token, "user1", null);
VerifyCustomToken(token, "user1", null, null);
}

[Fact]
Expand All @@ -61,7 +70,7 @@ public async Task CreateCustomTokenWithClaims()
{ "magicNumber", 42L },
};
var token = await factory.CreateCustomTokenAsync("user2", developerClaims);
VerifyCustomToken(token, "user2", developerClaims);
VerifyCustomToken(token, "user2", developerClaims, null);
}

[Fact]
Expand Down Expand Up @@ -92,7 +101,7 @@ await Assert.ThrowsAsync<ArgumentException>(
}

private static void VerifyCustomToken(
string token, string uid, Dictionary<string, object> claims)
string token, string uid, Dictionary<string, object> claims, string tenantId)
{
string[] segments = token.Split(".");
Assert.Equal(3, segments.Length);
Expand All @@ -108,6 +117,7 @@ private static void VerifyCustomToken(
Assert.Equal(MockSigner.KeyIdString, payload.Subject);
Assert.Equal(uid, payload.Uid);
Assert.Equal(FirebaseTokenFactory.FirebaseAudience, payload.Audience);
Assert.Equal(tenantId, payload.TenantId);
if (claims == null)
{
Assert.Null(payload.Claims);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,28 @@ public class FirebaseTokenVerifierTest : IDisposable

private static readonly FirebaseTokenVerifier TokenVerifier = new FirebaseTokenVerifier(
new FirebaseTokenVerifierArgs()
{
ProjectId = "test-project",
ShortName = "ID token",
Operation = "VerifyIdTokenAsync()",
Url = "https://firebase.google.com/docs/auth/admin/verify-id-tokens",
Issuer = "https://securetoken.google.com/",
Clock = Clock,
PublicKeySource = KeySource,
});
{
ProjectId = "test-project",
ShortName = "ID token",
Operation = "VerifyIdTokenAsync()",
Url = "https://firebase.google.com/docs/auth/admin/verify-id-tokens",
Issuer = "https://securetoken.google.com/",
Clock = Clock,
PublicKeySource = KeySource,
});

private static readonly FirebaseTokenVerifier TenantTokenVerifier = new FirebaseTokenVerifier(
new FirebaseTokenVerifierArgs()
{
ProjectId = "test-project",
ShortName = "ID token",
Operation = "VerifyIdTokenAsync()",
Url = "https://firebase.google.com/docs/auth/admin/verify-id-tokens",
Issuer = "https://securetoken.google.com/",
Clock = Clock,
PublicKeySource = KeySource,
TenantId = "test-01abc",
});

private static readonly GoogleCredential MockCredential =
GoogleCredential.FromAccessToken("test-token");
Expand Down Expand Up @@ -76,6 +89,31 @@ public async Task ValidToken()
Assert.Equal("bar", value);
}

[Fact]
public async Task ValidTenantToken()
{
var payload = new Dictionary<string, object>()
{
{ "firebase", new { tenant = "test-01abc" } },
};
var idToken = await CreateTestTokenAsync(payloadOverrides: payload);
var decoded = await TenantTokenVerifier.VerifyTokenAsync(idToken);
Assert.Equal("test-01abc", ((Newtonsoft.Json.Linq.JObject)decoded.Claims["firebase"]).GetValue("tenant").ToString());
}

[Fact]
public async Task InvalidTenantToken()
{
var idToken = await CreateTestTokenAsync(payloadOverrides: new Dictionary<string, object>()
{
{ "firebase", new { tenant = "test-01abc" } },
});
await Assert.ThrowsAsync<FirebaseAuthException>(async () => await TokenVerifier.VerifyTokenAsync(idToken));

idToken = await CreateTestTokenAsync(payloadOverrides: new Dictionary<string, object>());
await Assert.ThrowsAsync<FirebaseAuthException>(async () => await TenantTokenVerifier.VerifyTokenAsync(idToken));
}

[Fact]
public async Task InvalidArgument()
{
Expand Down
30 changes: 23 additions & 7 deletions FirebaseAdmin/FirebaseAdmin/Auth/FirebaseAuth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ internal FirebaseAuth(FirebaseAuthArgs args)
this.tokenFactory = args.TokenFactory.ThrowIfNull(nameof(args.TokenFactory));
this.idTokenVerifier = args.IdTokenVerifier.ThrowIfNull(nameof(args.IdTokenVerifier));
this.userManager = args.UserManager.ThrowIfNull(nameof(args.UserManager));
this.TenantId = args.TenantId;
}

/// <summary>
Expand All @@ -59,23 +60,35 @@ public static FirebaseAuth DefaultInstance
}
}

/// <summary>
/// Gets the tenant ID for this auth instance.
/// </summary>
public string TenantId { get; }

/// <summary>
/// Returns the auth instance for the specified app.
/// </summary>
/// <returns>The <see cref="FirebaseAuth"/> instance associated with the specified
/// app.</returns>
/// <exception cref="System.ArgumentNullException">If the app argument is null.</exception>
/// <exception cref="System.ArgumentException">If the tenantId argument is invalid.</exception>
/// <param name="app">An app instance.</param>
public static FirebaseAuth GetAuth(FirebaseApp app)
/// <param name="tenantId">The tenant ID to manage (optional).</param>
public static FirebaseAuth GetAuth(FirebaseApp app, string tenantId = null)
{
if (app == null)
{
throw new ArgumentNullException("App argument must not be null.");
}

return app.GetOrInit<FirebaseAuth>(typeof(FirebaseAuth).Name, () =>
if (tenantId != null && !System.Text.RegularExpressions.Regex.IsMatch(tenantId, "^[a-zA-Z0-9-]+$"))
{
return new FirebaseAuth(FirebaseAuthArgs.Create(app));
throw new ArgumentException("The tenant ID must be null or a valid non-empty string.", "tenantId");
}

return app.GetOrInit<FirebaseAuth>(typeof(FirebaseAuth).Name + tenantId, () =>
{
return new FirebaseAuth(FirebaseAuthArgs.Create(app, tenantId));
});
}

Expand Down Expand Up @@ -586,16 +599,19 @@ internal sealed class FirebaseAuthArgs

internal Lazy<FirebaseUserManager> UserManager { get; set; }

internal static FirebaseAuthArgs Create(FirebaseApp app)
internal string TenantId { get; set; }

internal static FirebaseAuthArgs Create(FirebaseApp app, string tenantId)
{
return new FirebaseAuthArgs()
{
TokenFactory = new Lazy<FirebaseTokenFactory>(
() => FirebaseTokenFactory.Create(app), true),
() => FirebaseTokenFactory.Create(app, tenantId), true),
IdTokenVerifier = new Lazy<FirebaseTokenVerifier>(
() => FirebaseTokenVerifier.CreateIDTokenVerifier(app), true),
() => FirebaseTokenVerifier.CreateIDTokenVerifier(app, tenantId), true),
UserManager = new Lazy<FirebaseUserManager>(
() => FirebaseUserManager.Create(app), true),
() => FirebaseUserManager.Create(app, tenantId), true),
TenantId = tenantId,
};
}
}
Expand Down
9 changes: 9 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,14 @@ internal sealed class FirebaseTokenArgs

[JsonIgnore]
public IReadOnlyDictionary<string, object> Claims { get; set; }

[JsonProperty("firebase")]
internal FirebaseClaims Firebase { get; set; }

internal sealed class FirebaseClaims
{
[JsonProperty("tenant")]
public string TenantId { get; set; }
}
}
}
12 changes: 9 additions & 3 deletions FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,16 @@ internal class FirebaseTokenFactory : IDisposable

private readonly ISigner signer;
private readonly IClock clock;
private readonly string tenantId;

public FirebaseTokenFactory(ISigner signer, IClock clock)
public FirebaseTokenFactory(ISigner signer, IClock clock, string tenantId = null)
{
this.signer = signer.ThrowIfNull(nameof(signer));
this.clock = clock.ThrowIfNull(nameof(clock));
this.tenantId = tenantId;
}

public static FirebaseTokenFactory Create(FirebaseApp app)
public static FirebaseTokenFactory Create(FirebaseApp app, string tenantId)
{
ISigner signer = null;
var serviceAccount = app.Options.Credential.ToServiceAccountCredential();
Expand All @@ -90,7 +92,7 @@ public static FirebaseTokenFactory Create(FirebaseApp app)
signer = FixedAccountIAMSigner.Create(app);
}

return new FirebaseTokenFactory(signer, SystemClock.Default);
return new FirebaseTokenFactory(signer, SystemClock.Default, tenantId);
}

public async Task<string> CreateCustomTokenAsync(
Expand Down Expand Up @@ -135,6 +137,7 @@ public async Task<string> CreateCustomTokenAsync(
Audience = FirebaseAudience,
IssuedAtTimeSeconds = issued,
ExpirationTimeSeconds = issued + TokenDurationSeconds,
TenantId = this.tenantId,
};

if (developerClaims != null && developerClaims.Count > 0)
Expand All @@ -156,6 +159,9 @@ internal class CustomTokenPayload : JsonWebToken.Payload
[Newtonsoft.Json.JsonPropertyAttribute("uid")]
public string Uid { get; set; }

[Newtonsoft.Json.JsonPropertyAttribute("tenant_id")]
public string TenantId { get; set; }

[Newtonsoft.Json.JsonPropertyAttribute("claims")]
public IDictionary<string, object> Claims { get; set; }
}
Expand Down
13 changes: 12 additions & 1 deletion FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ internal sealed class FirebaseTokenVerifier
internal FirebaseTokenVerifier(FirebaseTokenVerifierArgs args)
{
this.ProjectId = args.ProjectId.ThrowIfNullOrEmpty(nameof(args.ProjectId));
this.TenantId = args.TenantId;
this.shortName = args.ShortName.ThrowIfNullOrEmpty(nameof(args.ShortName));
this.operation = args.Operation.ThrowIfNullOrEmpty(nameof(args.Operation));
this.url = args.Url.ThrowIfNullOrEmpty(nameof(args.Url));
Expand All @@ -75,7 +76,9 @@ internal FirebaseTokenVerifier(FirebaseTokenVerifierArgs args)

public string ProjectId { get; }

internal static FirebaseTokenVerifier CreateIDTokenVerifier(FirebaseApp app)
public string TenantId { get; }

internal static FirebaseTokenVerifier CreateIDTokenVerifier(FirebaseApp app, string tenantId = null)
{
var projectId = app.GetProjectId();
if (string.IsNullOrEmpty(projectId))
Expand All @@ -89,6 +92,7 @@ internal static FirebaseTokenVerifier CreateIDTokenVerifier(FirebaseApp app)
var args = new FirebaseTokenVerifierArgs()
{
ProjectId = projectId,
TenantId = tenantId,
ShortName = "ID token",
Operation = "VerifyIdTokenAsync()",
Url = "https://firebase.google.com/docs/auth/admin/verify-id-tokens",
Expand Down Expand Up @@ -152,6 +156,13 @@ internal async Task<FirebaseToken> VerifyTokenAsync(
error = $"{this.shortName} has incorrect audience (aud) claim. Expected {this.ProjectId} "
+ $"but got {payload.Audience}. {projectIdMessage} {verifyTokenMessage}";
}
else if (this.TenantId != payload.Firebase?.TenantId)
{
var expectedTenantId = this.TenantId == null ? "null" : this.TenantId;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var expectedTenantId = this.TenantId == null ? "null" : this.TenantId;
var expectedTenantId = this.TenantId ?? "null";

var actualTenantId = payload.Firebase?.TenantId == null ? "null" : payload.Firebase.TenantId;
error = $"{this.shortName} has incorrect tenant ID (firebase.tenant) claim. Expected {expectedTenantId} "
+ $"but got {actualTenantId}. {verifyTokenMessage}";
}
else if (payload.Issuer != issuer)
{
error = $"{this.shortName} has incorrect issuer (iss) claim. Expected {issuer} but "
Expand Down
2 changes: 2 additions & 0 deletions FirebaseAdmin/FirebaseAdmin/Auth/FirebaseTokenVerifierArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ internal sealed class FirebaseTokenVerifierArgs
{
public string ProjectId { get; set; }

public string TenantId { get; set; }

public string ShortName { get; set; }

public string Operation { get; set; }
Expand Down
9 changes: 7 additions & 2 deletions FirebaseAdmin/FirebaseAdmin/Auth/FirebaseUserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ internal class FirebaseUserManager : IDisposable

private const string IdTooklitUrl = "https://identitytoolkit.googleapis.com/v1/projects/{0}";

private const string IdTooklitTenantUrl = "https://identitytoolkit.googleapis.com/v1/projects/{0}/tenants/{1}";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be the v2 endpoint. v1 doesn't support tenant management.


private readonly ErrorHandlingHttpClient<FirebaseAuthException> httpClient;
private readonly string baseUrl;

Expand All @@ -63,22 +65,23 @@ internal FirebaseUserManager(Args args)
DeserializeExceptionHandler = AuthErrorHandler.Instance,
RetryOptions = args.RetryOptions,
});
this.baseUrl = string.Format(IdTooklitUrl, args.ProjectId);
this.baseUrl = string.Format(args.TenantId != null ? IdTooklitTenantUrl : IdTooklitUrl, args.ProjectId, args.TenantId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expand this out for readability.

Suggested change
this.baseUrl = string.Format(args.TenantId != null ? IdTooklitTenantUrl : IdTooklitUrl, args.ProjectId, args.TenantId);
if (!string.IsNullOrEmpty(args.TenantId)
{
this.baseUrl = string.Format(IdTooklitTenantUrl, args.ProjectId, args.TenantId);
}
else
{
this.baseUrl = string.Format(IdTooklitUrl, args.ProjectId);
}

}

public void Dispose()
{
this.httpClient.Dispose();
}

internal static FirebaseUserManager Create(FirebaseApp app)
internal static FirebaseUserManager Create(FirebaseApp app, string tenantId)
{
var args = new Args
{
ClientFactory = app.Options.HttpClientFactory,
Credential = app.Options.Credential,
ProjectId = app.GetProjectId(),
RetryOptions = RetryOptions.Default,
TenantId = tenantId,
};

return new FirebaseUserManager(args);
Expand Down Expand Up @@ -295,6 +298,8 @@ internal sealed class Args

internal string ProjectId { get; set; }

internal string TenantId { get; set; }

internal RetryOptions RetryOptions { get; set; }
}

Expand Down