Skip to content

Commit fbb8bfe

Browse files
authored
[OIDC] Evaluate federated credential and generate API key (service layer) (#10286)
1 parent e9b7179 commit fbb8bfe

6 files changed

Lines changed: 639 additions & 0 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
#nullable enable
55

6+
using System;
7+
68
namespace NuGetGallery.Services.Authentication
79
{
810
public interface IFederatedCredentialConfiguration
@@ -12,10 +14,17 @@ public interface IFederatedCredentialConfiguration
1214
/// service itself (not shared between multiple services). This is used only for Entra ID token validation.
1315
/// </summary>
1416
string? EntraIdAudience { get; }
17+
18+
/// <summary>
19+
/// How long the short lived API keys should last.
20+
/// </summary>
21+
TimeSpan ShortLivedApiKeyDuration { get; }
1522
}
1623

1724
public class FederatedCredentialConfiguration : IFederatedCredentialConfiguration
1825
{
1926
public string? EntraIdAudience { get; set; }
27+
28+
public TimeSpan ShortLivedApiKeyDuration { get; set; } = TimeSpan.FromMinutes(15);
2029
}
2130
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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.Data;
6+
using System.Threading.Tasks;
7+
using NuGet.Services.Entities;
8+
using NuGetGallery.Authentication;
9+
using NuGetGallery.Infrastructure.Authentication;
10+
11+
#nullable enable
12+
13+
namespace NuGetGallery.Services.Authentication
14+
{
15+
public interface IFederatedCredentialService
16+
{
17+
/// <summary>
18+
/// Generates a short-lived API key for the user based on the provided bearer token. The user's federated
19+
/// credential policies are used to evaluate the bearer token and find desired API key settings.
20+
/// </summary>
21+
/// <param name="username">The username of the user account that owns the federated credential policy.</param>
22+
/// <param name="bearerToken">The bearer token to use for federated credential evaluation.</param>
23+
/// <returns>The result, successful if <see cref="GenerateApiKeyResult.Type"/> is <see cref="GenerateApiKeyResultType.Created"/>.</returns>
24+
Task<GenerateApiKeyResult> GenerateApiKeyAsync(string username, string bearerToken);
25+
}
26+
27+
public class FederatedCredentialService : IFederatedCredentialService
28+
{
29+
private readonly IUserService _userService;
30+
private readonly IFederatedCredentialRepository _repository;
31+
private readonly IFederatedCredentialEvaluator _evaluator;
32+
private readonly ICredentialBuilder _credentialBuilder;
33+
private readonly IAuthenticationService _authenticationService;
34+
private readonly IDateTimeProvider _dateTimeProvider;
35+
private readonly IFeatureFlagService _featureFlagService;
36+
private readonly IFederatedCredentialConfiguration _configuration;
37+
38+
public FederatedCredentialService(
39+
IUserService userService,
40+
IFederatedCredentialRepository repository,
41+
IFederatedCredentialEvaluator evaluator,
42+
ICredentialBuilder credentialBuilder,
43+
IAuthenticationService authenticationService,
44+
IDateTimeProvider dateTimeProvider,
45+
IFeatureFlagService featureFlagService,
46+
IFederatedCredentialConfiguration configuration)
47+
{
48+
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
49+
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
50+
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
51+
_credentialBuilder = credentialBuilder ?? throw new ArgumentNullException(nameof(credentialBuilder));
52+
_authenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService));
53+
_dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider));
54+
_featureFlagService = featureFlagService ?? throw new ArgumentNullException(nameof(featureFlagService));
55+
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
56+
}
57+
58+
public async Task<GenerateApiKeyResult> GenerateApiKeyAsync(string username, string bearerToken)
59+
{
60+
var currentUser = _userService.FindByUsername(username, includeDeleted: false);
61+
if (currentUser is null)
62+
{
63+
return NoMatchingPolicy(username);
64+
}
65+
66+
var policies = _repository.GetPoliciesCreatedByUser(currentUser.Key);
67+
var policyEvaluation = await _evaluator.GetMatchingPolicyAsync(policies, bearerToken);
68+
switch (policyEvaluation.Type)
69+
{
70+
case EvaluatedFederatedCredentialPoliciesType.BadToken:
71+
return GenerateApiKeyResult.Unauthorized(policyEvaluation.UserError);
72+
case EvaluatedFederatedCredentialPoliciesType.NoMatchingPolicy:
73+
return NoMatchingPolicy(username);
74+
case EvaluatedFederatedCredentialPoliciesType.MatchedPolicy:
75+
break;
76+
default:
77+
throw new NotImplementedException("Unexpected result type: " + policyEvaluation.Type);
78+
}
79+
80+
// perform validations after the policy evaluation to avoid leaking information about the related users
81+
82+
var currentUserError = ValidateCurrentUser(currentUser);
83+
if (currentUserError != null)
84+
{
85+
return currentUserError;
86+
}
87+
88+
var packageOwner = _userService.FindByKey(policyEvaluation.MatchedPolicy.PackageOwnerUserKey);
89+
policyEvaluation.MatchedPolicy.PackageOwner = packageOwner;
90+
var packageOwnerError = ValidatePackageOwner(packageOwner);
91+
if (packageOwnerError != null)
92+
{
93+
return packageOwnerError;
94+
}
95+
96+
var apiKeyCredential = _credentialBuilder.CreateShortLivedApiKey(
97+
_configuration.ShortLivedApiKeyDuration,
98+
policyEvaluation.MatchedPolicy,
99+
out var plaintextApiKey);
100+
if (!_credentialBuilder.VerifyScopes(currentUser, apiKeyCredential.Scopes))
101+
{
102+
return GenerateApiKeyResult.BadRequest(
103+
$"The scopes on the generated API key are not valid. " +
104+
$"Confirm that you still have permissions to operate on behalf of package owner '{packageOwner.Username}'.");
105+
}
106+
107+
var saveError = await SaveAndRejectReplayAsync(currentUser, policyEvaluation, apiKeyCredential);
108+
if (saveError is not null)
109+
{
110+
return saveError;
111+
}
112+
113+
return GenerateApiKeyResult.Created(plaintextApiKey, apiKeyCredential.Expires!.Value);
114+
}
115+
116+
private static GenerateApiKeyResult NoMatchingPolicy(string username)
117+
{
118+
return GenerateApiKeyResult.Unauthorized($"No matching federated credential trust policy owned by user '{username}' was found.");
119+
}
120+
121+
private async Task<GenerateApiKeyResult?> SaveAndRejectReplayAsync(
122+
User currentUser,
123+
EvaluatedFederatedCredentialPolicies evaluation,
124+
Credential apiKeyCredential)
125+
{
126+
evaluation.MatchedPolicy.LastMatched = _dateTimeProvider.UtcNow;
127+
128+
await _repository.SaveFederatedCredentialAsync(evaluation.FederatedCredential, saveChanges: false);
129+
130+
try
131+
{
132+
await _authenticationService.AddCredential(currentUser, apiKeyCredential);
133+
}
134+
catch (DataException ex) when (ex.IsSqlUniqueConstraintViolation())
135+
{
136+
return GenerateApiKeyResult.Unauthorized("This bearer token has already been used. A new bearer token must be used for each request.");
137+
}
138+
139+
return null;
140+
}
141+
142+
private static GenerateApiKeyResult? ValidateCurrentUser(User currentUser)
143+
{
144+
if (currentUser is Organization)
145+
{
146+
return GenerateApiKeyResult.BadRequest(
147+
"Generating fetching tokens directly for organizations is not supported. " +
148+
"The federated credential trust policy is created on the profile of one of the organization's administrators and is scoped to the organization in the policy.");
149+
}
150+
151+
var error = GetUserStateError(currentUser);
152+
if (error != null)
153+
{
154+
return error;
155+
}
156+
157+
return null;
158+
}
159+
160+
private GenerateApiKeyResult? ValidatePackageOwner(User? packageOwner)
161+
{
162+
if (packageOwner is null)
163+
{
164+
return GenerateApiKeyResult.BadRequest("The package owner of the match federated credential trust policy not longer exists.");
165+
}
166+
167+
var error = GetUserStateError(packageOwner);
168+
if (error != null)
169+
{
170+
return error;
171+
}
172+
173+
if (!_featureFlagService.CanUseFederatedCredentials(packageOwner))
174+
{
175+
return GenerateApiKeyResult.BadRequest(NotInFlightMessage(packageOwner));
176+
}
177+
178+
return null;
179+
}
180+
181+
private static string NotInFlightMessage(User packageOwner)
182+
{
183+
return $"The package owner '{packageOwner.Username}' is not enabled to use federated credentials.";
184+
}
185+
186+
private static GenerateApiKeyResult? GetUserStateError(User user)
187+
{
188+
var orgOrUser = user is Organization ? "organization" : "user";
189+
190+
if (user.IsDeleted)
191+
{
192+
return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' is deleted.");
193+
}
194+
195+
if (user.IsLocked)
196+
{
197+
return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' is locked.");
198+
}
199+
200+
if (!user.Confirmed)
201+
{
202+
return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' does not have a confirmed email address.");
203+
}
204+
205+
return null;
206+
}
207+
}
208+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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.Collections.Generic;
6+
7+
#nullable enable
8+
9+
namespace NuGetGallery.Services.Authentication
10+
{
11+
public enum GenerateApiKeyResultType
12+
{
13+
Created,
14+
BadRequest,
15+
Unauthorized,
16+
}
17+
18+
public class GenerateApiKeyResult
19+
{
20+
private readonly string? _userMessage;
21+
private readonly string? _plaintextApiKey;
22+
private readonly DateTimeOffset? _expires;
23+
24+
private GenerateApiKeyResult(
25+
GenerateApiKeyResultType type,
26+
string? userMessage = null,
27+
string? plaintextApiKey = null,
28+
DateTimeOffset? expires = null)
29+
{
30+
Type = type;
31+
_userMessage = userMessage;
32+
_plaintextApiKey = plaintextApiKey;
33+
_expires = expires;
34+
}
35+
36+
public GenerateApiKeyResultType Type { get; }
37+
public string UserMessage => _userMessage ?? throw new InvalidOperationException();
38+
public string PlaintextApiKey => _plaintextApiKey ?? throw new InvalidOperationException();
39+
public DateTimeOffset Expires => _expires ?? throw new InvalidOperationException();
40+
41+
public static GenerateApiKeyResult Created(string plaintextApiKey, DateTimeOffset expires)
42+
=> new(GenerateApiKeyResultType.Created, plaintextApiKey: plaintextApiKey, expires: expires);
43+
44+
public static GenerateApiKeyResult BadRequest(string userMessage) => new(GenerateApiKeyResultType.BadRequest, userMessage);
45+
public static GenerateApiKeyResult Unauthorized(string userMessage) => new(GenerateApiKeyResultType.Unauthorized, userMessage);
46+
}
47+
}

src/NuGetGallery/App_Start/DefaultDependenciesModule.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,11 @@ private static void ConfigureFederatedCredentials(ContainerBuilder builder, Conf
588588
.RegisterType<FederatedCredentialEvaluator>()
589589
.As<IFederatedCredentialEvaluator>()
590590
.InstancePerLifetimeScope();
591+
592+
builder
593+
.RegisterType<FederatedCredentialService>()
594+
.As<IFederatedCredentialService>()
595+
.InstancePerLifetimeScope();
591596
}
592597

593598
// Internal for testing purposes

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.ShortLivedApiKeyDuration" value="00:20:00"/>
206207
<add key="FederatedCredential.EntraIdAudience" value=""/>
207208
</appSettings>
208209
<connectionStrings>

0 commit comments

Comments
 (0)