Skip to content

Commit

Permalink
[OIDC] Evaluate federated credential and generate API key (service la…
Browse files Browse the repository at this point in the history
…yer) (#10286)
  • Loading branch information
joelverhagen authored Dec 4, 2024
1 parent e9b7179 commit fbb8bfe
Show file tree
Hide file tree
Showing 6 changed files with 639 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#nullable enable

using System;

namespace NuGetGallery.Services.Authentication
{
public interface IFederatedCredentialConfiguration
Expand All @@ -12,10 +14,17 @@ public interface IFederatedCredentialConfiguration
/// service itself (not shared between multiple services). This is used only for Entra ID token validation.
/// </summary>
string? EntraIdAudience { get; }

/// <summary>
/// How long the short lived API keys should last.
/// </summary>
TimeSpan ShortLivedApiKeyDuration { get; }
}

public class FederatedCredentialConfiguration : IFederatedCredentialConfiguration
{
public string? EntraIdAudience { get; set; }

public TimeSpan ShortLivedApiKeyDuration { get; set; } = TimeSpan.FromMinutes(15);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Data;
using System.Threading.Tasks;
using NuGet.Services.Entities;
using NuGetGallery.Authentication;
using NuGetGallery.Infrastructure.Authentication;

#nullable enable

namespace NuGetGallery.Services.Authentication
{
public interface IFederatedCredentialService
{
/// <summary>
/// Generates a short-lived API key for the user based on the provided bearer token. The user's federated
/// credential policies are used to evaluate the bearer token and find desired API key settings.
/// </summary>
/// <param name="username">The username of the user account that owns the federated credential policy.</param>
/// <param name="bearerToken">The bearer token to use for federated credential evaluation.</param>
/// <returns>The result, successful if <see cref="GenerateApiKeyResult.Type"/> is <see cref="GenerateApiKeyResultType.Created"/>.</returns>
Task<GenerateApiKeyResult> GenerateApiKeyAsync(string username, string bearerToken);
}

public class FederatedCredentialService : IFederatedCredentialService
{
private readonly IUserService _userService;
private readonly IFederatedCredentialRepository _repository;
private readonly IFederatedCredentialEvaluator _evaluator;
private readonly ICredentialBuilder _credentialBuilder;
private readonly IAuthenticationService _authenticationService;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IFeatureFlagService _featureFlagService;
private readonly IFederatedCredentialConfiguration _configuration;

public FederatedCredentialService(
IUserService userService,
IFederatedCredentialRepository repository,
IFederatedCredentialEvaluator evaluator,
ICredentialBuilder credentialBuilder,
IAuthenticationService authenticationService,
IDateTimeProvider dateTimeProvider,
IFeatureFlagService featureFlagService,
IFederatedCredentialConfiguration configuration)
{
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
_credentialBuilder = credentialBuilder ?? throw new ArgumentNullException(nameof(credentialBuilder));
_authenticationService = authenticationService ?? throw new ArgumentNullException(nameof(authenticationService));
_dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider));
_featureFlagService = featureFlagService ?? throw new ArgumentNullException(nameof(featureFlagService));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}

public async Task<GenerateApiKeyResult> GenerateApiKeyAsync(string username, string bearerToken)
{
var currentUser = _userService.FindByUsername(username, includeDeleted: false);
if (currentUser is null)
{
return NoMatchingPolicy(username);
}

var policies = _repository.GetPoliciesCreatedByUser(currentUser.Key);
var policyEvaluation = await _evaluator.GetMatchingPolicyAsync(policies, bearerToken);
switch (policyEvaluation.Type)
{
case EvaluatedFederatedCredentialPoliciesType.BadToken:
return GenerateApiKeyResult.Unauthorized(policyEvaluation.UserError);
case EvaluatedFederatedCredentialPoliciesType.NoMatchingPolicy:
return NoMatchingPolicy(username);
case EvaluatedFederatedCredentialPoliciesType.MatchedPolicy:
break;
default:
throw new NotImplementedException("Unexpected result type: " + policyEvaluation.Type);
}

// perform validations after the policy evaluation to avoid leaking information about the related users

var currentUserError = ValidateCurrentUser(currentUser);
if (currentUserError != null)
{
return currentUserError;
}

var packageOwner = _userService.FindByKey(policyEvaluation.MatchedPolicy.PackageOwnerUserKey);
policyEvaluation.MatchedPolicy.PackageOwner = packageOwner;
var packageOwnerError = ValidatePackageOwner(packageOwner);
if (packageOwnerError != null)
{
return packageOwnerError;
}

var apiKeyCredential = _credentialBuilder.CreateShortLivedApiKey(
_configuration.ShortLivedApiKeyDuration,
policyEvaluation.MatchedPolicy,
out var plaintextApiKey);
if (!_credentialBuilder.VerifyScopes(currentUser, apiKeyCredential.Scopes))
{
return GenerateApiKeyResult.BadRequest(
$"The scopes on the generated API key are not valid. " +
$"Confirm that you still have permissions to operate on behalf of package owner '{packageOwner.Username}'.");
}

var saveError = await SaveAndRejectReplayAsync(currentUser, policyEvaluation, apiKeyCredential);
if (saveError is not null)
{
return saveError;
}

return GenerateApiKeyResult.Created(plaintextApiKey, apiKeyCredential.Expires!.Value);
}

private static GenerateApiKeyResult NoMatchingPolicy(string username)
{
return GenerateApiKeyResult.Unauthorized($"No matching federated credential trust policy owned by user '{username}' was found.");
}

private async Task<GenerateApiKeyResult?> SaveAndRejectReplayAsync(
User currentUser,
EvaluatedFederatedCredentialPolicies evaluation,
Credential apiKeyCredential)
{
evaluation.MatchedPolicy.LastMatched = _dateTimeProvider.UtcNow;

await _repository.SaveFederatedCredentialAsync(evaluation.FederatedCredential, saveChanges: false);

try
{
await _authenticationService.AddCredential(currentUser, apiKeyCredential);
}
catch (DataException ex) when (ex.IsSqlUniqueConstraintViolation())
{
return GenerateApiKeyResult.Unauthorized("This bearer token has already been used. A new bearer token must be used for each request.");
}

return null;
}

private static GenerateApiKeyResult? ValidateCurrentUser(User currentUser)
{
if (currentUser is Organization)
{
return GenerateApiKeyResult.BadRequest(
"Generating fetching tokens directly for organizations is not supported. " +
"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.");
}

var error = GetUserStateError(currentUser);
if (error != null)
{
return error;
}

return null;
}

private GenerateApiKeyResult? ValidatePackageOwner(User? packageOwner)
{
if (packageOwner is null)
{
return GenerateApiKeyResult.BadRequest("The package owner of the match federated credential trust policy not longer exists.");
}

var error = GetUserStateError(packageOwner);
if (error != null)
{
return error;
}

if (!_featureFlagService.CanUseFederatedCredentials(packageOwner))
{
return GenerateApiKeyResult.BadRequest(NotInFlightMessage(packageOwner));
}

return null;
}

private static string NotInFlightMessage(User packageOwner)
{
return $"The package owner '{packageOwner.Username}' is not enabled to use federated credentials.";
}

private static GenerateApiKeyResult? GetUserStateError(User user)
{
var orgOrUser = user is Organization ? "organization" : "user";

if (user.IsDeleted)
{
return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' is deleted.");
}

if (user.IsLocked)
{
return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' is locked.");
}

if (!user.Confirmed)
{
return GenerateApiKeyResult.BadRequest($"The {orgOrUser} '{user.Username}' does not have a confirmed email address.");
}

return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;

#nullable enable

namespace NuGetGallery.Services.Authentication
{
public enum GenerateApiKeyResultType
{
Created,
BadRequest,
Unauthorized,
}

public class GenerateApiKeyResult
{
private readonly string? _userMessage;
private readonly string? _plaintextApiKey;
private readonly DateTimeOffset? _expires;

private GenerateApiKeyResult(
GenerateApiKeyResultType type,
string? userMessage = null,
string? plaintextApiKey = null,
DateTimeOffset? expires = null)
{
Type = type;
_userMessage = userMessage;
_plaintextApiKey = plaintextApiKey;
_expires = expires;
}

public GenerateApiKeyResultType Type { get; }
public string UserMessage => _userMessage ?? throw new InvalidOperationException();
public string PlaintextApiKey => _plaintextApiKey ?? throw new InvalidOperationException();
public DateTimeOffset Expires => _expires ?? throw new InvalidOperationException();

public static GenerateApiKeyResult Created(string plaintextApiKey, DateTimeOffset expires)
=> new(GenerateApiKeyResultType.Created, plaintextApiKey: plaintextApiKey, expires: expires);

public static GenerateApiKeyResult BadRequest(string userMessage) => new(GenerateApiKeyResultType.BadRequest, userMessage);
public static GenerateApiKeyResult Unauthorized(string userMessage) => new(GenerateApiKeyResultType.Unauthorized, userMessage);
}
}
5 changes: 5 additions & 0 deletions src/NuGetGallery/App_Start/DefaultDependenciesModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,11 @@ private static void ConfigureFederatedCredentials(ContainerBuilder builder, Conf
.RegisterType<FederatedCredentialEvaluator>()
.As<IFederatedCredentialEvaluator>()
.InstancePerLifetimeScope();

builder
.RegisterType<FederatedCredentialService>()
.As<IFederatedCredentialService>()
.InstancePerLifetimeScope();
}

// Internal for testing purposes
Expand Down
1 change: 1 addition & 0 deletions src/NuGetGallery/Web.config
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@
<add key="PackageDelete.MaximumDownloadsForPackageVersion" value=""/>
<add key="Gallery.BlockLegacyLicenseUrl" value="false"/>
<add key="Gallery.AllowLicenselessPackages" value="true"/>
<add key="FederatedCredential.ShortLivedApiKeyDuration" value="00:20:00"/>
<add key="FederatedCredential.EntraIdAudience" value=""/>
</appSettings>
<connectionStrings>
Expand Down
Loading

0 comments on commit fbb8bfe

Please sign in to comment.