Skip to content

Commit af2aca9

Browse files
authored
[OIDC] Add allow list for Entra ID tenants acceptable for federation (#10287)
1 parent 9279e51 commit af2aca9

File tree

6 files changed

+117
-1
lines changed

6 files changed

+117
-1
lines changed

src/NuGetGallery.Services/Authentication/Federated/EntraIdTokenValidator.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.IdentityModel.Protocols;
99
using Microsoft.IdentityModel.Tokens;
1010
using Microsoft.IdentityModel.Validators;
11+
using System.Linq;
1112

1213
#nullable enable
1314

@@ -18,6 +19,11 @@ namespace NuGetGallery.Services.Authentication
1819
/// </summary>
1920
public interface IEntraIdTokenValidator
2021
{
22+
/// <summary>
23+
/// Determines if a given Entra tenant ID GUID is in the allow list.
24+
/// </summary>
25+
bool IsTenantAllowed(Guid tenantId);
26+
2127
/// <summary>
2228
/// Perform minimal validation of the token to ensure it was issued by Entra ID. Validations:
2329
/// - Expected issuer (Entra ID)
@@ -49,6 +55,23 @@ public EntraIdTokenValidator(
4955
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
5056
}
5157

58+
public bool IsTenantAllowed(Guid tenantId)
59+
{
60+
if (_configuration.AllowedEntraIdTenants.Length == 0)
61+
{
62+
return false;
63+
}
64+
65+
if (_configuration.AllowedEntraIdTenants.Length == 1
66+
&& _configuration.AllowedEntraIdTenants[0] == "all")
67+
{
68+
return true;
69+
}
70+
71+
var tenantIdString = tenantId.ToString();
72+
return _configuration.AllowedEntraIdTenants.Contains(tenantIdString, StringComparer.OrdinalIgnoreCase);
73+
}
74+
5275
public async Task<TokenValidationResult> ValidateAsync(JsonWebToken token)
5376
{
5477
if (string.IsNullOrWhiteSpace(_configuration.EntraIdAudience))

src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialConfiguration.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
#nullable enable
55

66
using System;
7+
using System.ComponentModel;
8+
using NuGet.Services.Configuration;
9+
using NuGet.Services.Entities;
710

811
namespace NuGetGallery.Services.Authentication
912
{
@@ -19,12 +22,24 @@ public interface IFederatedCredentialConfiguration
1922
/// How long the short lived API keys should last.
2023
/// </summary>
2124
TimeSpan ShortLivedApiKeyDuration { get; }
25+
26+
/// <summary>
27+
/// The list of all Entra ID tenant GUIDs that are allowed for <see cref="FederatedCredentialType.EntraIdServicePrincipal"/>
28+
/// federated credential policies. If this list is empty, no tenants are allowed. If this list has a single
29+
/// item "all", then all Entra ID tenants are allowed.
30+
///
31+
/// Values are separated by a semicolon when provided in the configuration file.
32+
/// </summary>
33+
string[] AllowedEntraIdTenants { get; }
2234
}
2335

2436
public class FederatedCredentialConfiguration : IFederatedCredentialConfiguration
2537
{
2638
public string? EntraIdAudience { get; set; }
2739

2840
public TimeSpan ShortLivedApiKeyDuration { get; set; } = TimeSpan.FromMinutes(15);
41+
42+
[TypeConverter(typeof(StringArrayConverter))]
43+
public string[] AllowedEntraIdTenants { get; set; } = [];
2944
}
3045
}

src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialEvaluator.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ public ValidatedJwt(JsonWebToken jwt, string identifier, RecognizedIssuer recogn
289289
return $"The JSON web token must have a {ClaimConstants.Tid} claim that matches the policy.";
290290
}
291291

292+
if (!_entraIdTokenValidator.IsTenantAllowed(parsedTid))
293+
{
294+
return "The tenant ID in the JSON web token is not in allow list.";
295+
}
296+
292297
if (string.IsNullOrWhiteSpace(oid) || !Guid.TryParse(oid, out var parsedOid) || parsedOid != criteria.ObjectId)
293298
{
294299
return $"The JSON web token must have a {ClaimConstants.Oid} claim that matches the policy.";

src/NuGetGallery/Web.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@
205205
<add key="Gallery.AllowLicenselessPackages" value="true"/>
206206
<add key="FederatedCredential.ShortLivedApiKeyDuration" value="00:20:00"/>
207207
<add key="FederatedCredential.EntraIdAudience" value=""/>
208+
<add key="FederatedCredential.AllowedEntraIdTenants" value="all"/>
208209
</appSettings>
209210
<connectionStrings>
210211
<add name="Gallery.SqlServer" connectionString="Data Source=(localdb)\mssqllocaldb; Initial Catalog=NuGetGallery; Integrated Security=True; MultipleActiveResultSets=True" providerName="System.Data.SqlClient"/>

tests/NuGetGallery.Facts/Authentication/Federated/EntraIdTokenValidatorFacts.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,55 @@ namespace NuGetGallery.Services.Authentication
1717
{
1818
public class EntraIdTokenValidatorFacts
1919
{
20+
public class TheIsTenantAllowedMethod : EntraIdTokenValidatorFacts
21+
{
22+
[Fact]
23+
public void AllowsTenantIdWhenInAllowList()
24+
{
25+
// Act
26+
var allowed = Target.IsTenantAllowed(new Guid(AllowedTenantIds[0]));
27+
28+
// Assert
29+
Assert.True(allowed);
30+
}
31+
32+
[Fact]
33+
public void RejectsTenantIdWhenInAllowList()
34+
{
35+
// Act
36+
var allowed = Target.IsTenantAllowed(new Guid("b3ad8ee4-f667-4a19-9091-206ef363beb1"));
37+
38+
// Assert
39+
Assert.False(allowed);
40+
}
41+
42+
[Fact]
43+
public void AllowsTenantIdWhenAllAreAllowed()
44+
{
45+
// Arrange
46+
AllowedTenantIds[0] = "all";
47+
48+
// Act
49+
var allowed = Target.IsTenantAllowed(new Guid("b3ad8ee4-f667-4a19-9091-206ef363beb1"));
50+
51+
// Assert
52+
Assert.True(allowed);
53+
}
54+
55+
[Fact]
56+
public void AllTenantIdsAreNotAllowedWhenAllIsNotOnlyArrayItem()
57+
{
58+
// Arrange
59+
AllowedTenantIds = ["all", "c311b905-19a2-483e-a014-41d0fcdc99cf"];
60+
61+
// Act
62+
var allowed = Target.IsTenantAllowed(new Guid("b3ad8ee4-f667-4a19-9091-206ef363beb1"));
63+
64+
// Assert
65+
Assert.False(allowed);
66+
}
67+
}
68+
2069
public class TheValidateAsyncMethod : EntraIdTokenValidatorFacts
2170
{
2271
[Fact]
@@ -136,10 +185,13 @@ public EntraIdTokenValidatorFacts()
136185
ConfigurationRetriever.Object);
137186
JsonWebTokenHandler = new Mock<JsonWebTokenHandler>();
138187
Configuration = new Mock<IFederatedCredentialConfiguration>();
139-
Configuration.Setup(x => x.EntraIdAudience).Returns("nuget-audience");
140188

141189
TenantId = "c311b905-19a2-483e-a014-41d0fcdc99cf";
142190
Issuer = $"https://login.microsoftonline.com/{TenantId}/v2.0";
191+
AllowedTenantIds = ["c311b905-19a2-483e-a014-41d0fcdc99cf"];
192+
193+
Configuration.Setup(x => x.EntraIdAudience).Returns("nuget-audience");
194+
Configuration.Setup(x => x.AllowedEntraIdTenants).Returns(() => AllowedTenantIds);
143195

144196
Target = new EntraIdTokenValidator(
145197
OidcConfigManager.Object,
@@ -154,6 +206,7 @@ public EntraIdTokenValidatorFacts()
154206
public Mock<IFederatedCredentialConfiguration> Configuration { get; }
155207
public string TenantId { get; }
156208
public string Issuer { get; set; }
209+
public string[] AllowedTenantIds { get; set; }
157210

158211
public JsonWebToken Token
159212
{

tests/NuGetGallery.Facts/Authentication/Federated/FederatedCredentialEvaluatorFacts.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,22 @@ public async Task RejectsWrongTenantId()
329329
Assert.Equal(FederatedCredentialPolicyResultType.Unauthorized, Assert.Single(evaluation.Results).Type);
330330
}
331331

332+
[Fact]
333+
public async Task RejectsNotAllowedTenantId()
334+
{
335+
// Arrange
336+
EntraIdTokenValidator
337+
.Setup(x => x.IsTenantAllowed(TenantId))
338+
.Returns(() => false);
339+
340+
// Act
341+
var evaluation = await Target.GetMatchingPolicyAsync(Policies, BearerToken);
342+
343+
// Assert
344+
Assert.Equal(EvaluatedFederatedCredentialPoliciesType.NoMatchingPolicy, evaluation.Type);
345+
Assert.Equal(FederatedCredentialPolicyResultType.Unauthorized, Assert.Single(evaluation.Results).Type);
346+
}
347+
332348
[Fact]
333349
public async Task RejectsWrongObjectId()
334350
{
@@ -402,6 +418,9 @@ public FederatedCredentialEvaluatorFacts()
402418
UtcNow = new DateTimeOffset(2024, 10, 10, 13, 35, 0, TimeSpan.Zero);
403419
Expires = new DateTimeOffset(2024, 10, 11, 0, 0, 0, TimeSpan.Zero);
404420

421+
EntraIdTokenValidator
422+
.Setup(x => x.IsTenantAllowed(TenantId))
423+
.Returns(() => true);
405424
EntraIdTokenValidator
406425
.Setup(x => x.ValidateAsync(It.IsAny<JsonWebToken>()))
407426
.ReturnsAsync(() => EntraIdTokenResult);

0 commit comments

Comments
 (0)