Skip to content

Commit 088e9da

Browse files
authored
[OIDC] Add basic Entra ID token validation (#10251)
This handles issuer, audience, expiration, and signature validation
1 parent 2fdec88 commit 088e9da

6 files changed

Lines changed: 324 additions & 2 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Threading.Tasks;
6+
using Microsoft.IdentityModel.JsonWebTokens;
7+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
8+
using Microsoft.IdentityModel.Protocols;
9+
using Microsoft.IdentityModel.Tokens;
10+
using Microsoft.IdentityModel.Validators;
11+
12+
#nullable enable
13+
14+
namespace NuGetGallery.Services.Authentication
15+
{
16+
/// <summary>
17+
/// This interface is used to ensure a given token is issued by Entra ID.
18+
/// </summary>
19+
public interface IEntraIdTokenValidator
20+
{
21+
/// <summary>
22+
/// Perform minimal validation of the token to ensure it was issued by Entra ID. Validations:
23+
/// - Expected issuer (Entra ID)
24+
/// - Expected audience
25+
/// - Valid signature
26+
/// - Not expired
27+
/// </summary>
28+
/// <param name="token">The parsed JWT</param>
29+
/// <returns>The token validation result, check the <see cref="TokenValidationResult.IsValid"/> for success or failure.</returns>
30+
Task<TokenValidationResult> ValidateAsync(JsonWebToken token);
31+
}
32+
33+
public class EntraIdTokenValidator : IEntraIdTokenValidator
34+
{
35+
private static string EntraIdAuthority { get; } = "https://login.microsoftonline.com/common/v2.0";
36+
public static string MetadataAddress { get; } = $"{EntraIdAuthority}/.well-known/openid-configuration";
37+
38+
private readonly ConfigurationManager<OpenIdConnectConfiguration> _oidcConfigManager;
39+
private readonly JsonWebTokenHandler _jsonWebTokenHandler;
40+
private readonly IFederatedCredentialConfiguration _configuration;
41+
42+
public EntraIdTokenValidator(
43+
ConfigurationManager<OpenIdConnectConfiguration> oidcConfigManager,
44+
JsonWebTokenHandler jsonWebTokenHandler,
45+
IFederatedCredentialConfiguration configuration)
46+
{
47+
_oidcConfigManager = oidcConfigManager ?? throw new ArgumentNullException(nameof(oidcConfigManager));
48+
_jsonWebTokenHandler = jsonWebTokenHandler ?? throw new ArgumentNullException(nameof(jsonWebTokenHandler));
49+
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
50+
}
51+
52+
public async Task<TokenValidationResult> ValidateAsync(JsonWebToken token)
53+
{
54+
if (string.IsNullOrWhiteSpace(_configuration.EntraIdAudience))
55+
{
56+
throw new InvalidOperationException("Unable to validate Entra ID token. Entra ID audience is not configured.");
57+
}
58+
59+
var tokenValidationParameters = new TokenValidationParameters
60+
{
61+
IssuerValidator = AadIssuerValidator.GetAadIssuerValidator(EntraIdAuthority).Validate,
62+
ValidAudience = _configuration.EntraIdAudience,
63+
ConfigurationManager = _oidcConfigManager,
64+
};
65+
66+
tokenValidationParameters.EnableAadSigningKeyIssuerValidation();
67+
68+
var result = await _jsonWebTokenHandler.ValidateTokenAsync(token, tokenValidationParameters);
69+
70+
return result;
71+
}
72+
}
73+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
#nullable enable
5+
6+
namespace NuGetGallery.Services.Authentication
7+
{
8+
public interface IFederatedCredentialConfiguration
9+
{
10+
/// <summary>
11+
/// The expected audience for the incoming token. This is the "aud" claim and should be specific to the gallery
12+
/// service itself (not shared between multiple services). This is used only for Entra ID token validation.
13+
/// </summary>
14+
string? EntraIdAudience { get; }
15+
}
16+
17+
public class FederatedCredentialConfiguration : IFederatedCredentialConfiguration
18+
{
19+
public string? EntraIdAudience { get; set; }
20+
}
21+
}

src/NuGetGallery.Services/Configuration/ConfigurationService.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -14,6 +14,7 @@
1414
using NuGet.Services.Configuration;
1515
using NuGet.Services.KeyVault;
1616
using NuGetGallery.Configuration.SecretReader;
17+
using NuGetGallery.Services.Authentication;
1718

1819
namespace NuGetGallery.Configuration
1920
{
@@ -23,6 +24,7 @@ public class ConfigurationService : IGalleryConfigurationService, IConfiguration
2324
protected const string FeaturePrefix = "Feature.";
2425
protected const string ServiceBusPrefix = "AzureServiceBus.";
2526
protected const string PackageDeletePrefix = "PackageDelete.";
27+
protected const string FederatedCredentialPrefix = "FederatedCredential.";
2628

2729
private readonly Lazy<string> _httpSiteRootThunk;
2830
private readonly Lazy<string> _httpsSiteRootThunk;
@@ -31,6 +33,7 @@ public class ConfigurationService : IGalleryConfigurationService, IConfiguration
3133
private readonly Lazy<FeatureConfiguration> _lazyFeatureConfiguration;
3234
private readonly Lazy<IServiceBusConfiguration> _lazyServiceBusConfiguration;
3335
private readonly Lazy<IPackageDeleteConfiguration> _lazyPackageDeleteConfiguration;
36+
private readonly Lazy<FederatedCredentialConfiguration> _lazyFederatedCredentialConfiguration;
3437

3538
private static readonly HashSet<string> NotInjectedSettingNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase) {
3639
SettingPrefix + "SqlServer",
@@ -66,6 +69,7 @@ public ConfigurationService()
6669
_lazyFeatureConfiguration = new Lazy<FeatureConfiguration>(() => ResolveFeatures().Result);
6770
_lazyServiceBusConfiguration = new Lazy<IServiceBusConfiguration>(() => ResolveServiceBus().Result);
6871
_lazyPackageDeleteConfiguration = new Lazy<IPackageDeleteConfiguration>(() => ResolvePackageDelete().Result);
72+
_lazyFederatedCredentialConfiguration = new Lazy<FederatedCredentialConfiguration>(() => ResolveFederatedCredential().Result);
6973
}
7074

7175
public static IEnumerable<PropertyDescriptor> GetConfigProperties<T>(T instance)
@@ -81,6 +85,8 @@ public static IEnumerable<PropertyDescriptor> GetConfigProperties<T>(T instance)
8185

8286
public IPackageDeleteConfiguration PackageDelete => _lazyPackageDeleteConfiguration.Value;
8387

88+
public FederatedCredentialConfiguration FederatedCredential => _lazyFederatedCredentialConfiguration.Value;
89+
8490
/// <summary>
8591
/// Gets the site root using the specified protocol
8692
/// </summary>
@@ -206,6 +212,11 @@ private async Task<IPackageDeleteConfiguration> ResolvePackageDelete()
206212
return await ResolveConfigObject(new PackageDeleteConfiguration(), PackageDeletePrefix);
207213
}
208214

215+
private async Task<FederatedCredentialConfiguration> ResolveFederatedCredential()
216+
{
217+
return await ResolveConfigObject(new FederatedCredentialConfiguration(), FederatedCredentialPrefix);
218+
}
219+
209220
protected virtual string GetAppSetting(string settingName)
210221
{
211222
return WebConfigurationManager.AppSettings[settingName];
@@ -273,4 +284,4 @@ private void CheckValidSiteRoot(string siteRoot)
273284
}
274285
}
275286
}
276-
}
287+
}

src/NuGetGallery/App_Start/DefaultDependenciesModule.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
using Microsoft.Extensions.DependencyInjection;
2626
using Microsoft.Extensions.Http;
2727
using Microsoft.Extensions.Logging;
28+
using Microsoft.IdentityModel.JsonWebTokens;
29+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
30+
using Microsoft.IdentityModel.Protocols;
2831
using Microsoft.WindowsAzure.ServiceRuntime;
2932
using NuGet.Services.Configuration;
3033
using NuGet.Services.Entities;
@@ -55,6 +58,7 @@
5558
using NuGetGallery.Infrastructure.Search.Correlation;
5659
using NuGetGallery.Security;
5760
using NuGetGallery.Services;
61+
using NuGetGallery.Services.Authentication;
5862
using Role = NuGet.Services.Entities.Role;
5963

6064
namespace NuGetGallery
@@ -151,6 +155,8 @@ protected override void Load(ContainerBuilder builder)
151155
builder.Register(c => configuration.PackageDelete)
152156
.As<IPackageDeleteConfiguration>();
153157

158+
ConfigureFederatedCredentials(builder, configuration);
159+
154160
var telemetryService = new TelemetryService(
155161
new TraceDiagnosticsSource(nameof(TelemetryService), telemetryClient),
156162
telemetryClient);
@@ -531,6 +537,39 @@ protected override void Load(ContainerBuilder builder)
531537
builder.Populate(services);
532538
}
533539

540+
private static void ConfigureFederatedCredentials(ContainerBuilder builder, ConfigurationService configuration)
541+
{
542+
builder
543+
.Register(c => configuration.FederatedCredential)
544+
.As<IFederatedCredentialConfiguration>();
545+
546+
builder
547+
.Register(_ => new OpenIdConnectConfigurationRetriever())
548+
.As<IConfigurationRetriever<OpenIdConnectConfiguration>>();
549+
550+
const string EntraIdKey = "EntraId";
551+
552+
// this is a singleton to provide caching of the OIDC metadata
553+
builder
554+
.Register(p => new ConfigurationManager<OpenIdConnectConfiguration>(
555+
metadataAddress: EntraIdTokenValidator.MetadataAddress,
556+
p.Resolve<IConfigurationRetriever<OpenIdConnectConfiguration>>()))
557+
.SingleInstance()
558+
.Keyed<ConfigurationManager<OpenIdConnectConfiguration>>(EntraIdKey);
559+
560+
builder
561+
.RegisterType<JsonWebTokenHandler>()
562+
.InstancePerLifetimeScope();
563+
564+
builder
565+
.Register(p => new EntraIdTokenValidator(
566+
p.ResolveKeyed<ConfigurationManager<OpenIdConnectConfiguration>>(EntraIdKey),
567+
p.Resolve<JsonWebTokenHandler>(),
568+
p.Resolve<IFederatedCredentialConfiguration>()))
569+
.As<IEntraIdTokenValidator>()
570+
.InstancePerLifetimeScope();
571+
}
572+
534573
// Internal for testing purposes
535574
internal static ApplicationInsightsConfiguration ConfigureApplicationInsights(
536575
IAppConfiguration configuration,

src/NuGetGallery/Web.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@
203203
<add key="PackageDelete.MaximumDownloadsForPackageVersion" value=""/>
204204
<add key="Gallery.BlockLegacyLicenseUrl" value="false"/>
205205
<add key="Gallery.AllowLicenselessPackages" value="true"/>
206+
<add key="FederatedCredential.EntraIdAudience" value=""/>
206207
</appSettings>
207208
<connectionStrings>
208209
<add name="Gallery.SqlServer" connectionString="Data Source=(localdb)\mssqllocaldb; Initial Catalog=NuGetGallery; Integrated Security=True; MultipleActiveResultSets=True" providerName="System.Data.SqlClient"/>

0 commit comments

Comments
 (0)