Skip to content

Commit ec52555

Browse files
authored
[OIDC] Find matching federated credential policy (policy evaluation) (#10269)
This is a big chunk of the OIDC feature's business logic. A new IFederatedCredentialEvaluator is added which takes a list of federated credential policies and a bearer token and evaluates which trust policy, if any matches the given bearer token. This is a read-only operation. The creation of the short-lived API key will be performed at a higher level.
1 parent b9466ae commit ec52555

6 files changed

Lines changed: 875 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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.Text.Json.Serialization;
6+
7+
#nullable enable
8+
9+
namespace NuGetGallery.Services.Authentication
10+
{
11+
public class EntraIdServicePrincipalCriteria
12+
{
13+
[JsonConstructor]
14+
public EntraIdServicePrincipalCriteria(Guid tenantId, Guid objectId)
15+
{
16+
TenantId = tenantId == Guid.Empty ? throw new ArgumentOutOfRangeException(nameof(tenantId)) : tenantId;
17+
ObjectId = objectId == Guid.Empty ? throw new ArgumentOutOfRangeException(nameof(objectId)) : objectId;
18+
}
19+
20+
[JsonPropertyName("tid")]
21+
public Guid TenantId { get; set; }
22+
23+
[JsonPropertyName("oid")]
24+
public Guid ObjectId { get; set; }
25+
}
26+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
using System;
7+
using System.Collections.Generic;
8+
using NuGet.Services.Entities;
9+
10+
namespace NuGetGallery.Services.Authentication
11+
{
12+
public enum EvaluatedFederatedCredentialPoliciesType
13+
{
14+
BadToken,
15+
MatchedPolicy,
16+
NoMatchingPolicy,
17+
}
18+
19+
public class EvaluatedFederatedCredentialPolicies
20+
{
21+
private readonly string? _userError;
22+
private readonly IReadOnlyList<FederatedCredentialPolicyResult>? _results;
23+
private readonly FederatedCredentialPolicy? _matchedPolicy;
24+
private readonly FederatedCredential? _federatedCredential;
25+
26+
private EvaluatedFederatedCredentialPolicies(
27+
EvaluatedFederatedCredentialPoliciesType type,
28+
string? userError = null,
29+
IReadOnlyList<FederatedCredentialPolicyResult>? results = null,
30+
FederatedCredentialPolicy? policy = null,
31+
FederatedCredential? federatedCredential = null)
32+
{
33+
Type = type;
34+
_userError = userError;
35+
_results = results;
36+
_matchedPolicy = policy;
37+
_federatedCredential = federatedCredential;
38+
}
39+
40+
public EvaluatedFederatedCredentialPoliciesType Type { get; }
41+
public string UserError => _userError ?? throw new InvalidOperationException();
42+
public IReadOnlyList<FederatedCredentialPolicyResult> Results => _results ?? throw new InvalidOperationException();
43+
public FederatedCredentialPolicy MatchedPolicy => _matchedPolicy ?? throw new InvalidOperationException();
44+
public FederatedCredential FederatedCredential => _federatedCredential ?? throw new InvalidOperationException();
45+
46+
public static EvaluatedFederatedCredentialPolicies BadToken(string userError)
47+
=> new(EvaluatedFederatedCredentialPoliciesType.BadToken, userError);
48+
49+
public static EvaluatedFederatedCredentialPolicies NewMatchedPolicy(IReadOnlyList<FederatedCredentialPolicyResult> results, FederatedCredentialPolicy matchedPolicy, FederatedCredential federatedCredential)
50+
=> new(EvaluatedFederatedCredentialPoliciesType.MatchedPolicy, userError: null, results, matchedPolicy, federatedCredential);
51+
52+
public static EvaluatedFederatedCredentialPolicies NoMatchingPolicy(IReadOnlyList<FederatedCredentialPolicyResult> results)
53+
=> new(EvaluatedFederatedCredentialPoliciesType.NoMatchingPolicy, userError: null, results);
54+
}
55+
}
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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+
using System.Linq;
7+
using System.Text.Json;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.Logging;
10+
using Microsoft.Identity.Web;
11+
using Microsoft.IdentityModel.JsonWebTokens;
12+
using Microsoft.IdentityModel.Tokens;
13+
using NuGet.Services.Entities;
14+
15+
#nullable enable
16+
17+
namespace NuGetGallery.Services.Authentication
18+
{
19+
public interface IFederatedCredentialEvaluator
20+
{
21+
Task<EvaluatedFederatedCredentialPolicies> GetMatchingPolicyAsync(IReadOnlyCollection<FederatedCredentialPolicy> policies, string bearerToken);
22+
}
23+
24+
public class FederatedCredentialEvaluator : IFederatedCredentialEvaluator
25+
{
26+
private readonly IEntraIdTokenValidator _entraIdTokenValidator;
27+
private readonly IDateTimeProvider _dateTimeProvider;
28+
private readonly ILogger<FederatedCredentialEvaluator> _logger;
29+
30+
public FederatedCredentialEvaluator(
31+
IEntraIdTokenValidator entraIdTokenValidator,
32+
IDateTimeProvider dateTimeProvider,
33+
ILogger<FederatedCredentialEvaluator> logger)
34+
{
35+
_entraIdTokenValidator = entraIdTokenValidator ?? throw new ArgumentNullException(nameof(entraIdTokenValidator));
36+
_dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider));
37+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
38+
}
39+
40+
public async Task<EvaluatedFederatedCredentialPolicies> GetMatchingPolicyAsync(IReadOnlyCollection<FederatedCredentialPolicy> policies, string bearerToken)
41+
{
42+
// perform basic validations not specific to any federated credential policy
43+
// the error message is user-facing and should not leak sensitive information
44+
var (userError, validatedJwt) = await ValidateJwtByIssuer(bearerToken);
45+
if (userError is not null)
46+
{
47+
return EvaluatedFederatedCredentialPolicies.BadToken(userError);
48+
}
49+
50+
if (validatedJwt is null)
51+
{
52+
throw new InvalidOperationException("The validated JWT must be set.");
53+
}
54+
55+
var results = new List<FederatedCredentialPolicyResult>();
56+
57+
// sort the policy results by creation date, so older policy results are preferred
58+
foreach (var policy in policies.OrderBy(x => x.Created))
59+
{
60+
var result = EvaluatePolicy(policy, validatedJwt);
61+
62+
results.Add(result);
63+
64+
if (result.Type == FederatedCredentialPolicyResultType.Success)
65+
{
66+
return EvaluatedFederatedCredentialPolicies.NewMatchedPolicy(results, result.Policy, result.FederatedCredential);
67+
}
68+
}
69+
70+
return EvaluatedFederatedCredentialPolicies.NoMatchingPolicy(results);
71+
}
72+
73+
private FederatedCredentialPolicyResult EvaluatePolicy(FederatedCredentialPolicy policy, ValidatedJwt validatedJwt)
74+
{
75+
// Evaluate the policy and return an unauthorized result if the policy does not match.
76+
// The reason is not shared with the caller to prevent leaking sensitive information.
77+
string? unauthorizedReason = (validatedJwt.RecognizedIssuer, policy.Type) switch
78+
{
79+
(RecognizedIssuer.EntraId, FederatedCredentialType.EntraIdServicePrincipal) => EvaluateEntraIdServicePrincipal(policy, validatedJwt.Jwt),
80+
_ => "The policy type does not match the token issuer.",
81+
};
82+
83+
FederatedCredentialPolicyResult result;
84+
if (unauthorizedReason is not null)
85+
{
86+
result = FederatedCredentialPolicyResult.Unauthorized(policy, unauthorizedReason);
87+
}
88+
else
89+
{
90+
result = FederatedCredentialPolicyResult.Success(policy, new FederatedCredential
91+
{
92+
Identity = validatedJwt.Identifier,
93+
FederatedCredentialPolicyKey = policy.Key,
94+
Type = policy.Type,
95+
Created = _dateTimeProvider.UtcNow,
96+
Expires = validatedJwt.Jwt.ValidTo.ToUniversalTime(),
97+
});
98+
}
99+
100+
_logger.LogInformation(
101+
"Evaluated policy key {PolicyKey} of type {PolicyType}. Result type: {ResultType}. Reason: {Reason}",
102+
result.Policy.Key,
103+
result.Policy.Type,
104+
result.Type,
105+
unauthorizedReason ?? "policy matched token");
106+
107+
return result;
108+
}
109+
110+
private enum RecognizedIssuer
111+
{
112+
None,
113+
EntraId,
114+
}
115+
116+
private class ValidatedJwt
117+
{
118+
public ValidatedJwt(JsonWebToken jwt, string identifier, RecognizedIssuer recognizedIssuer)
119+
{
120+
Jwt = jwt;
121+
Identifier = identifier;
122+
RecognizedIssuer = recognizedIssuer;
123+
}
124+
125+
public JsonWebToken Jwt { get; }
126+
127+
/// <summary>
128+
/// This should be the unique token identifier (uti for Entra ID or jti otherwise).
129+
/// Used to prevent replay and persisted on the <see cref="FederatedCredential"/> entity.
130+
/// </summary>
131+
public string Identifier { get; }
132+
133+
public RecognizedIssuer RecognizedIssuer { get; }
134+
}
135+
136+
/// <summary>
137+
/// Parse the bearer token as a JWT, perform basic validation, and find the applicable that apply to all issuers.
138+
/// </summary>
139+
private async Task<(string? UserError, ValidatedJwt?)> ValidateJwtByIssuer(string bearerToken)
140+
{
141+
JsonWebToken jwt;
142+
try
143+
{
144+
jwt = new JsonWebToken(bearerToken);
145+
}
146+
catch (ArgumentException)
147+
{
148+
return ("The bearer token could not be parsed as a JSON web token.", null);
149+
}
150+
151+
if (jwt.Audiences.Count() != 1)
152+
{
153+
return ("The JSON web token must have exactly one aud claim value.", null);
154+
}
155+
156+
if (string.IsNullOrWhiteSpace(jwt.Audiences.Single()))
157+
{
158+
return ("The JSON web token must have an aud claim.", null);
159+
}
160+
161+
if (string.IsNullOrWhiteSpace(jwt.Issuer))
162+
{
163+
return ("The JSON web token must have an iss claim.", null);
164+
}
165+
166+
if (!Uri.TryCreate(jwt.Issuer, UriKind.Absolute, out var issuerUrl)
167+
|| issuerUrl.Scheme != "https")
168+
{
169+
return ("The JSON web token iss claim must be a valid HTTPS URL.", null);
170+
}
171+
172+
RecognizedIssuer issuer;
173+
string? userError;
174+
string? identifier;
175+
TokenValidationResult? validationResult;
176+
switch (issuerUrl.Authority)
177+
{
178+
case "login.microsoftonline.com":
179+
issuer = RecognizedIssuer.EntraId;
180+
(userError, identifier, validationResult) = await GetEntraIdValidationResultAsync(jwt);
181+
break;
182+
default:
183+
return ("The JSON web token iss claim is not supported.", null);
184+
}
185+
186+
if (userError is not null)
187+
{
188+
return (userError, null);
189+
}
190+
191+
if (string.IsNullOrWhiteSpace(identifier) || validationResult is null)
192+
{
193+
throw new InvalidOperationException("The identifier and validation result must be set.");
194+
}
195+
196+
if (validationResult.IsValid)
197+
{
198+
return (null, new ValidatedJwt(jwt, identifier!, issuer));
199+
}
200+
201+
var validationException = validationResult.Exception;
202+
203+
userError = validationException switch
204+
{
205+
SecurityTokenExpiredException => "The JSON web token has expired.",
206+
SecurityTokenInvalidAudienceException => "The JSON web token has an incorrect audience.",
207+
SecurityTokenInvalidSignatureException => "The JSON web token has an invalid signature.",
208+
_ => "The JSON web token could not be validated.",
209+
};
210+
211+
_logger.LogInformation(validationException, "The JSON web token with recognized issuer {Issuer} could not be validated.", issuer);
212+
213+
return (userError, null);
214+
}
215+
216+
private async Task<(string? UserError, string? Identifier, TokenValidationResult? Result)> GetEntraIdValidationResultAsync(JsonWebToken jwt)
217+
{
218+
const string UniqueTokenIdentifierClaim = "uti"; // unique token identifier (equivalent to jti)
219+
220+
if (!jwt.TryGetPayloadValue<string>(UniqueTokenIdentifierClaim, out var uti)
221+
|| string.IsNullOrWhiteSpace(uti))
222+
{
223+
return ($"The JSON web token must have a {UniqueTokenIdentifierClaim} claim.", null, null);
224+
}
225+
226+
var tokenValidationResult = await _entraIdTokenValidator.ValidateAsync(jwt);
227+
return (null, uti, tokenValidationResult);
228+
}
229+
230+
private string? EvaluateEntraIdServicePrincipal(FederatedCredentialPolicy policy, JsonWebToken jwt)
231+
{
232+
// See https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference
233+
const string ClientCredentialTypeClaim = "azpacr";
234+
const string ClientCertificateType = "2"; // 2 indicates a client certificate (or managed identity) was used
235+
const string IdentityTypeClaim = "idtyp";
236+
const string AppIdentityType = "app";
237+
const string VersionClaim = "ver";
238+
const string Version2 = "2.0";
239+
240+
if (!jwt.TryGetPayloadValue<string>(ClaimConstants.Tid, out var tid))
241+
{
242+
return $"The JSON web token is missing the {ClaimConstants.Tid} claim.";
243+
}
244+
245+
if (!jwt.TryGetPayloadValue<string>(ClaimConstants.Oid, out var oid))
246+
{
247+
return $"The JSON web token is missing the {ClaimConstants.Oid} claim.";
248+
}
249+
250+
if (!jwt.TryGetPayloadValue<string>(ClientCredentialTypeClaim, out var azpacr))
251+
{
252+
return $"The JSON web token is missing the {ClientCredentialTypeClaim} claim.";
253+
}
254+
255+
if (azpacr != ClientCertificateType)
256+
{
257+
return $"The JSON web token must have an {ClientCredentialTypeClaim} claim with a value of {ClientCertificateType}.";
258+
}
259+
260+
if (!jwt.TryGetPayloadValue<string>(IdentityTypeClaim, out var idtyp))
261+
{
262+
return $"The JSON web token is missing the {IdentityTypeClaim} claim.";
263+
}
264+
265+
if (idtyp != AppIdentityType)
266+
{
267+
return $"The JSON web token must have an {IdentityTypeClaim} claim with a value of {AppIdentityType}.";
268+
}
269+
270+
if (!jwt.TryGetPayloadValue<string>(VersionClaim, out var ver))
271+
{
272+
return $"The JSON web token is missing the {VersionClaim} claim.";
273+
}
274+
275+
if (ver != Version2)
276+
{
277+
return $"The JSON web token must have a {VersionClaim} claim with a value of {Version2}.";
278+
}
279+
280+
if (jwt.Subject != oid)
281+
{
282+
return $"The JSON web token {ClaimConstants.Sub} claim must match the {ClaimConstants.Oid} claim.";
283+
}
284+
285+
var criteria = DeserializePolicy<EntraIdServicePrincipalCriteria>(policy);
286+
287+
if (string.IsNullOrWhiteSpace(tid) || !Guid.TryParse(tid, out var parsedTid) || parsedTid != criteria.TenantId)
288+
{
289+
return $"The JSON web token must have a {ClaimConstants.Tid} claim that matches the policy.";
290+
}
291+
292+
if (string.IsNullOrWhiteSpace(oid) || !Guid.TryParse(oid, out var parsedOid) || parsedOid != criteria.ObjectId)
293+
{
294+
return $"The JSON web token must have a {ClaimConstants.Oid} claim that matches the policy.";
295+
}
296+
297+
return null;
298+
}
299+
300+
private static T DeserializePolicy<T>(FederatedCredentialPolicy policy)
301+
{
302+
var criteria = JsonSerializer.Deserialize<T>(policy.Criteria);
303+
if (criteria is null)
304+
{
305+
throw new InvalidOperationException("The policy criteria must be a valid JSON object.");
306+
}
307+
308+
return criteria;
309+
}
310+
}
311+
}

0 commit comments

Comments
 (0)