diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.CreateToken.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.CreateToken.cs index 79613d3c0a..d588b32be1 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.CreateToken.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.CreateToken.cs @@ -668,11 +668,16 @@ internal static void WriteJwsPayload( // Duplicates are resolved according to the following priority: // SecurityTokenDescriptor.{Audience/Audiences, Issuer, Expires, IssuedAt, NotBefore}, SecurityTokenDescriptor.Claims, SecurityTokenDescriptor.Subject.Claims // SecurityTokenDescriptor.Claims are KeyValuePairs, whereas SecurityTokenDescriptor.Subject.Claims are System.Security.Claims.Claim and are processed differently. - + bool isActorFound = false; if (tokenDescriptor.Claims != null && tokenDescriptor.Claims.Count > 0) { foreach (KeyValuePair kvp in tokenDescriptor.Claims) { + if (kvp.Key.Equals(tokenDescriptor.ActorClaimType, StringComparison.Ordinal)) + { + isActorFound = true; + continue; + } if (!descriptorClaimsAudienceChecked && kvp.Key.Equals(JwtRegisteredClaimNames.Aud, StringComparison.Ordinal)) { descriptorClaimsAudienceChecked = true; @@ -754,6 +759,8 @@ internal static void WriteJwsPayload( JsonPrimitives.WriteObject(ref writer, kvp.Key, kvp.Value); } } + if (isActorFound || tokenDescriptor.Subject?.Actor != null) + WriteActorToken(writer, tokenDescriptor, setDefaultTimesOnTokenCreation, tokenLifetimeInMinutes); AddSubjectClaims(ref writer, tokenDescriptor, audienceSet, issuerSet, ref expSet, ref iatSet, ref nbfSet); @@ -1071,6 +1078,74 @@ internal static byte[] WriteJweHeader(SecurityTokenDescriptor tokenDescriptor) } } } + internal static void WriteActorToken( + Utf8JsonWriter writer, + SecurityTokenDescriptor tokenDescriptor, + bool setDefaultTimesOnTokenCreation, + int tokenLifetimeInMinutes) + { + if (tokenDescriptor == null) + throw new ArgumentNullException(nameof(tokenDescriptor)); + + var actorTokenDescriptor = CreateActorTokenDescriptor(tokenDescriptor); + if (actorTokenDescriptor == null || actorTokenDescriptor.Subject == null) + return; + + writer.WritePropertyName(tokenDescriptor.ActorClaimType); + WriteJwsPayload(ref writer, actorTokenDescriptor, setDefaultTimesOnTokenCreation, tokenLifetimeInMinutes); + } + + private static void ValidateActorChainDepth(SecurityTokenDescriptor tokenDescriptor) + { + if (tokenDescriptor.ActorChainDepth >= tokenDescriptor.MaxActorChainLength) + { + throw LogHelper.LogExceptionMessage( + new SecurityTokenException(LogHelper.FormatInvariant( + LogMessages.IDX14313, + LogHelper.MarkAsNonPII(tokenDescriptor.ActorChainDepth), + LogHelper.MarkAsNonPII(tokenDescriptor.MaxActorChainLength)))); + } + } + + private static SecurityTokenDescriptor CreateActorTokenDescriptor(SecurityTokenDescriptor tokenDescriptor) + { + SecurityTokenDescriptor actorTokenDescriptor = null; + + if (tokenDescriptor.Claims?.TryGetValue(tokenDescriptor.ActorClaimType, out object actorValue) == true) + { + if (actorValue is not ClaimsIdentity actor) + { + throw LogHelper.LogExceptionMessage(new SecurityTokenException( + LogHelper.FormatInvariant( + LogMessages.IDX14315, + LogHelper.MarkAsNonPII(tokenDescriptor.ActorClaimType), + LogHelper.MarkAsNonPII(actorValue?.GetType().ToString() ?? "null")))); + } + + actorTokenDescriptor = new SecurityTokenDescriptor + { + Subject = actor, + }; + + } + // Then check for actor in subject + else if (tokenDescriptor.Subject?.Actor != null) + { + actorTokenDescriptor = new SecurityTokenDescriptor + { + Subject = tokenDescriptor.Subject.Actor, + }; + } + if (actorTokenDescriptor != null) + { + ValidateActorChainDepth(tokenDescriptor); + actorTokenDescriptor.MaxActorChainLength = tokenDescriptor.MaxActorChainLength; + actorTokenDescriptor.ActorClaimType = tokenDescriptor.ActorClaimType; + actorTokenDescriptor.ActorChainDepth = tokenDescriptor.ActorChainDepth + 1; + } + + return actorTokenDescriptor; + } internal static byte[] CompressToken(byte[] utf8Bytes, string compressionAlgorithm) { diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs index 2a722a0cbd..12e66e4f6e 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Security.Claims; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -212,8 +213,8 @@ protected virtual ClaimsIdentity CreateClaimsIdentity(JsonWebToken jwtToken, Tok private ClaimsIdentity CreateClaimsIdentityWithMapping(JsonWebToken jwtToken, TokenValidationParameters validationParameters, string issuer) { - _ = validationParameters ?? throw LogHelper.LogArgumentNullException(nameof(validationParameters)); + _ = validationParameters ?? throw LogHelper.LogArgumentNullException(nameof(validationParameters)); ClaimsIdentity identity = validationParameters.CreateClaimsIdentity(jwtToken, issuer); foreach (Claim jwtClaim in jwtToken.Claims) { @@ -222,19 +223,14 @@ private ClaimsIdentity CreateClaimsIdentityWithMapping(JsonWebToken jwtToken, To if (!wasMapped) claimType = jwtClaim.Type; - if (claimType == ClaimTypes.Actor) + if (claimType.Equals(validationParameters.ActorClaimType) || claimType.Equals("actort")) { if (identity.Actor != null) throw LogHelper.LogExceptionMessage(new InvalidOperationException(LogHelper.FormatInvariant( LogMessages.IDX14112, - LogHelper.MarkAsNonPII(JwtRegisteredClaimNames.Actort), + LogHelper.MarkAsNonPII(claimType), jwtClaim.Value))); - - if (CanReadToken(jwtClaim.Value)) - { - JsonWebToken actor = ReadToken(jwtClaim.Value) as JsonWebToken; - identity.Actor = CreateClaimsIdentity(actor, validationParameters); - } + identity.Actor = CreateClaimsIdentityActor(jwtToken, jwtClaim.Value, validationParameters, claimType.Equals(validationParameters.ActorClaimType)); } if (wasMapped) @@ -287,16 +283,11 @@ private ClaimsIdentity CreateClaimsIdentityPrivate(JsonWebToken jwtToken, TokenV foreach (Claim jwtClaim in jwtToken.Claims) { string claimType = jwtClaim.Type; - if (claimType == ClaimTypes.Actor) + if (claimType == validationParameters.ActorClaimType || claimType.Equals("actort")) { if (identity.Actor != null) - throw LogHelper.LogExceptionMessage(new InvalidOperationException(LogHelper.FormatInvariant(LogMessages.IDX14112, LogHelper.MarkAsNonPII(JwtRegisteredClaimNames.Actort), jwtClaim.Value))); - - if (CanReadToken(jwtClaim.Value)) - { - JsonWebToken actor = ReadToken(jwtClaim.Value) as JsonWebToken; - identity.Actor = CreateClaimsIdentity(actor, validationParameters, issuer); - } + throw LogHelper.LogExceptionMessage(new InvalidOperationException(LogHelper.FormatInvariant(LogMessages.IDX14112, LogHelper.MarkAsNonPII(claimType), jwtClaim.Value))); + identity.Actor = CreateClaimsIdentityActor(jwtToken, jwtClaim.Value, validationParameters, claimType.Equals(validationParameters.ActorClaimType)); } if (jwtClaim.Properties.Count == 0) @@ -621,5 +612,133 @@ private static TokenValidationResult ReadToken(string token, TokenValidationPara IsValid = true }; } + + /// + /// Creates a ClaimsIdentity from an actor claim string. + /// + /// + /// The actor claim string. + /// The token validation parameters. + /// This tells us if we want to deserialize it as a JWT or Json Object. If this is set to true then we deserialize as JsonObject else as JWT + /// A ClaimsIdentity representing the actor. + /// Thrown if or is null. + private ClaimsIdentity CreateClaimsIdentityActor( + JsonWebToken jwtToken, + string actorString, + TokenValidationParameters tokenValidationParameters, + bool isStandardAct = false) + { + if (string.IsNullOrEmpty(actorString)) + throw LogHelper.LogArgumentNullException(nameof(actorString)); + + if (tokenValidationParameters == null) + throw LogHelper.LogArgumentNullException(nameof(tokenValidationParameters)); + + if (isStandardAct) + { + if (jwtToken.TryGetPayloadValue(tokenValidationParameters.ActorClaimType, out JsonElement actClaim)) + { + if (tokenValidationParameters.ActClaimRetrieverDelegate != null) + { + try + { + return tokenValidationParameters.ActClaimRetrieverDelegate(actClaim, tokenValidationParameters); + } + catch (Exception ex) + { + throw LogHelper.LogExceptionMessage(new SecurityTokenDecryptionFailedException(LogHelper.FormatInvariant( + LogMessages.IDX14314, + LogHelper.MarkAsNonPII(ex.ToString())))); + } + } + else + { + return CreateActorClaimsIdentityFromJsonElement(actClaim, tokenValidationParameters); + } + } + + } + else + { + if (CanReadToken(actorString)) + { + JsonWebToken actor = ReadToken(actorString) as JsonWebToken; + return CreateClaimsIdentity(actor, tokenValidationParameters); + } + } + + return null; + } + + /// + /// Creates a ClaimsIdentity from a JsonElement that represents an actor token. + /// + /// The JsonElement containing actor claims. + /// These parameters have details like nested actor chain length and max permissible actor length + /// The issuer for the claims. + /// A ClaimsIdentity containing claims from the JsonElement. + public static ClaimsIdentity CreateActorClaimsIdentityFromJsonElement( + JsonElement jsonElement, + TokenValidationParameters tokenValidationParameters, + string issuer = null) + { + if (tokenValidationParameters == null) + throw LogHelper.LogArgumentNullException(nameof(tokenValidationParameters)); + + if (tokenValidationParameters.ActorChainDepth >= tokenValidationParameters.MaxActorChainLength) + { + throw LogHelper.LogExceptionMessage( + new SecurityTokenException(LogHelper.FormatInvariant( + LogMessages.IDX14313, + LogHelper.MarkAsNonPII(tokenValidationParameters.ActorChainDepth), + LogHelper.MarkAsNonPII(tokenValidationParameters.MaxActorChainLength)))); + } + + if (jsonElement.ValueKind != JsonValueKind.Object) + throw LogHelper.LogExceptionMessage(new ArgumentException("Actor token must be a JSON object")); + + // Use CaseSensitiveClaimsIdentity for consistent behavior with the rest of the library + var identity = new CaseSensitiveClaimsIdentity(); + + issuer = issuer ?? ClaimsIdentity.DefaultIssuer; + + foreach (var property in jsonElement.EnumerateObject()) + { + string claimType = property.Name; + JsonElement value = property.Value; + + // Special handling for nested actor claim + if (claimType == tokenValidationParameters.ActorClaimType) + { + if (value.ValueKind == JsonValueKind.Object) + { + tokenValidationParameters.ActorChainDepth++; + // Recursively create nested actor identity + identity.Actor = CreateActorClaimsIdentityFromJsonElement( + value, tokenValidationParameters, issuer); + } + continue; + } + + // For all other claims, create and add them + if (value.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement element in value.EnumerateArray()) + { + var claim = JsonClaimSet.CreateClaimFromJsonElement(claimType, issuer, element); + if (claim != null) + identity.AddClaim(claim); + } + } + else + { + var claim = JsonClaimSet.CreateClaimFromJsonElement(claimType, issuer, value); + if (claim != null) + identity.AddClaim(claim); + } + } + + return identity; + } } } diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/LogMessages.cs b/src/Microsoft.IdentityModel.JsonWebTokens/LogMessages.cs index edd5cddd5b..a39363427e 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/LogMessages.cs +++ b/src/Microsoft.IdentityModel.JsonWebTokens/LogMessages.cs @@ -51,5 +51,8 @@ internal static class LogMessages internal const string IDX14310 = "IDX14310: JWE authentication tag is missing."; internal const string IDX14311 = "IDX14311: Unable to decode the authentication tag as a Base64Url encoded string."; internal const string IDX14312 = "IDX14312: Unable to decode the cipher text as a Base64Url encoded string."; + internal const string IDX14313 = "IDX14313: Unable to serialize/deserialize act claim. Maximum actor token depth reached. Current nesting depth is {0} while max depth set is {1}"; + internal const string IDX14314 = "IDX14314: Unable to deserialize act claim. Exception faced while using custom delegate to deserialize act claim. Nested exception is :{0}"; + internal const string IDX14315 = "IDX14315: Encountered an exception while processing the actor claim. Actor claim {0} is not a claims identity. It is of type {1}."; } } diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/Microsoft.IdentityModel.JsonWebTokens.csproj b/src/Microsoft.IdentityModel.JsonWebTokens/Microsoft.IdentityModel.JsonWebTokens.csproj index b6f7239a7a..2b92c71616 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/Microsoft.IdentityModel.JsonWebTokens.csproj +++ b/src/Microsoft.IdentityModel.JsonWebTokens/Microsoft.IdentityModel.JsonWebTokens.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt index aed7d583d3..24a363c2fa 100644 --- a/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.JsonWebTokens/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ -Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DecryptTokenWithConfigurationAsync(Microsoft.IdentityModel.JsonWebTokens.JsonWebToken jwtToken, Microsoft.IdentityModel.Tokens.TokenValidationParameters validationParameters, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task \ No newline at end of file +static Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement(System.Text.Json.JsonElement jsonElement, Microsoft.IdentityModel.Tokens.TokenValidationParameters tokenValidationParameters, string issuer = null) -> System.Security.Claims.ClaimsIdentity +Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DecryptTokenWithConfigurationAsync(Microsoft.IdentityModel.JsonWebTokens.JsonWebToken jwtToken, Microsoft.IdentityModel.Tokens.TokenValidationParameters validationParameters, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task diff --git a/src/Microsoft.IdentityModel.Tokens/Delegates.cs b/src/Microsoft.IdentityModel.Tokens/Delegates.cs index 42cc1d210b..bc13dc8729 100644 --- a/src/Microsoft.IdentityModel.Tokens/Delegates.cs +++ b/src/Microsoft.IdentityModel.Tokens/Delegates.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; @@ -194,4 +195,12 @@ namespace Microsoft.IdentityModel.Tokens /// The claim value that was read and parsed from the reader. /// True, if the claim value was read successfully; false otherwise. public delegate bool TryReadJwtClaim(ref Utf8JsonReader reader, JwtSegmentType jwtSegmentType, string claimName, out object claimValue); + + /// + /// Delegate to validate the 'act' claim and create actor's ClaimsIdentity. + /// + /// The JSON element representing the 'act' claim. + /// Opitonal validation parameters if needed + /// A ClaimsIdentity representing the actor. + public delegate ClaimsIdentity ActClaimRetrieverDelegate(JsonElement actClaim, TokenValidationParameters tokenValidationParameters = null); } diff --git a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt index 4194681d26..dbcb928158 100644 --- a/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/InternalAPI.Unshipped.txt @@ -1,4 +1,8 @@ +const Microsoft.IdentityModel.Tokens.AppContextSwitches.EnableActClaimSupportSwitch = "Switch.Microsoft.IdentityModel.EnableActClaimSupportSwitch" -> string const Microsoft.IdentityModel.Tokens.AppContextSwitches.UseCapitalizedXMLTypeAttrSwitch = "Switch.Microsoft.IdentityModel.UseCapitalizedXMLTypeAttr" -> string +const Microsoft.IdentityModel.Tokens.LogMessages.IDX11027 = "IDX11027: Invalid JsonWebToken handler configuration parameter value provided for {0}" -> string +static Microsoft.IdentityModel.Tokens.AppContextSwitches.EnableActClaimSupport.get -> bool +static Microsoft.IdentityModel.Tokens.AppContextSwitches.UseCapitalizedXMLTypeAttr.get -> bool const Microsoft.IdentityModel.Tokens.LogMessages.IDX10278 = "IDX10278: Unable to retrieve configuration from authority: '{0}'. \nProceeding with token decryption in case the relevant properties have been set manually on the TokenValidationParameters. Exception caught: \n {1}. See https://aka.ms/validate-using-configuration-manager for additional information." -> string static Microsoft.IdentityModel.Tokens.AppContextSwitches.UseCapitalizedXMLTypeAttr.get -> bool const Microsoft.IdentityModel.Telemetry.TelemetryConstants.BlockingTypeTag = "Blocking" -> string @@ -109,4 +113,4 @@ static readonly Microsoft.IdentityModel.Tokens.IssuerValidationSource.NotValidat virtual Microsoft.IdentityModel.Tokens.TokenHandler.CreateClaimsIdentityInternal(Microsoft.IdentityModel.Tokens.SecurityToken securityToken, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, string issuer) -> System.Security.Claims.ClaimsIdentity virtual Microsoft.IdentityModel.Tokens.TokenHandler.ValidateTokenAsync(Microsoft.IdentityModel.Tokens.SecurityToken token, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task> virtual Microsoft.IdentityModel.Tokens.TokenHandler.ValidateTokenAsync(string token, Microsoft.IdentityModel.Tokens.Experimental.ValidationParameters validationParameters, Microsoft.IdentityModel.Tokens.CallContext callContext, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task> -virtual Microsoft.IdentityModel.Tokens.ValidationError.CreateException() -> System.Exception +virtual Microsoft.IdentityModel.Tokens.ValidationError.CreateException() -> System.Exception \ No newline at end of file diff --git a/src/Microsoft.IdentityModel.Tokens/LogMessages.cs b/src/Microsoft.IdentityModel.Tokens/LogMessages.cs index 2cf87bc84b..3882709dcb 100644 --- a/src/Microsoft.IdentityModel.Tokens/LogMessages.cs +++ b/src/Microsoft.IdentityModel.Tokens/LogMessages.cs @@ -317,6 +317,7 @@ internal static class LogMessages public const string IDX11023 = "IDX11023: Expecting json reader to be positioned on '{0}', reader was positioned at: '{1}', Reading: '{2}', Position: '{3}', CurrentDepth: '{4}', BytesConsumed: '{5}'."; public const string IDX11025 = "IDX11025: Cannot serialize object of type: '{0}' into property: '{1}'."; public const string IDX11026 = "IDX11026: Unable to get claim value as a string from claim type:'{0}', value type was:'{1}'. Acceptable types are String, IList, and System.Text.Json.JsonElement."; + public const string IDX11027 = "IDX11027: Invalid JsonWebToken handler configuration parameter value provided for {0}"; #pragma warning restore 1591 } diff --git a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt index 0fe36c2ef5..e3a2baea04 100644 --- a/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt +++ b/src/Microsoft.IdentityModel.Tokens/PublicAPI.Unshipped.txt @@ -323,3 +323,19 @@ static Microsoft.IdentityModel.Tokens.Validators.ValidateTokenType(string? type, ~override Microsoft.IdentityModel.Tokens.Experimental.ValidationResult.Equals(object obj) -> bool ~Microsoft.IdentityModel.Tokens.Experimental.ValidationResult.Equals(Microsoft.IdentityModel.Tokens.Experimental.ValidationResult other) -> bool ~static Microsoft.IdentityModel.Tokens.Experimental.ValidationResult.operator ==(Microsoft.IdentityModel.Tokens.Experimental.ValidationResult left, Microsoft.IdentityModel.Tokens.Experimental.ValidationResult right) -> bool +Microsoft.IdentityModel.Tokens.TokenValidationParameters.ActorChainDepth.get -> int +Microsoft.IdentityModel.Tokens.TokenValidationParameters.ActorChainDepth.set -> void +Microsoft.IdentityModel.Tokens.TokenValidationParameters.ActorClaimType.get -> string +Microsoft.IdentityModel.Tokens.TokenValidationParameters.ActorClaimType.set -> void +Microsoft.IdentityModel.Tokens.TokenValidationParameters.MaxActorChainLength.get -> int +Microsoft.IdentityModel.Tokens.TokenValidationParameters.MaxActorChainLength.set -> void +Microsoft.IdentityModel.Tokens.ActClaimRetrieverDelegate +Microsoft.IdentityModel.Tokens.TokenValidationParameters.ActClaimRetrieverDelegate.get -> Microsoft.IdentityModel.Tokens.ActClaimRetrieverDelegate +Microsoft.IdentityModel.Tokens.TokenValidationParameters.ActClaimRetrieverDelegate.set -> void +Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor.ActorChainDepth.get -> int +Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor.ActorChainDepth.set -> void +Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor.ActorClaimType.get -> string +Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor.ActorClaimType.set -> void +Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor.MaxActorChainLength.get -> int +Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor.MaxActorChainLength.set -> void +~virtual Microsoft.IdentityModel.Tokens.ActClaimRetrieverDelegate.Invoke(System.Text.Json.JsonElement actClaim, Microsoft.IdentityModel.Tokens.TokenValidationParameters tokenValidationParameters = null) -> System.Security.Claims.ClaimsIdentity diff --git a/src/Microsoft.IdentityModel.Tokens/SecurityTokenDescriptor.cs b/src/Microsoft.IdentityModel.Tokens/SecurityTokenDescriptor.cs index 3b327a48b4..5ce495903b 100644 --- a/src/Microsoft.IdentityModel.Tokens/SecurityTokenDescriptor.cs +++ b/src/Microsoft.IdentityModel.Tokens/SecurityTokenDescriptor.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Security.Claims; using System.Threading; +using Microsoft.IdentityModel.Logging; namespace Microsoft.IdentityModel.Tokens { @@ -15,6 +16,9 @@ namespace Microsoft.IdentityModel.Tokens public class SecurityTokenDescriptor { private List _audiences; + private string _actorClaimType = "act"; + private int _actorClainDepth; + private int _maxActorChainLength = 4; /// /// Gets or sets the value of the {"": audience} claim. Will be combined with and any "Aud" claims in @@ -116,5 +120,86 @@ public class SecurityTokenDescriptor /// [DefaultValue(true)] public bool IncludeKeyIdInHeader { get; set; } = true; + + /// + /// Gets or sets the maximum depth allowed when processing nested actor tokens. + /// This prevents excessive recursion when handling deeply nested actor tokens. + /// The value must be at least 0. Value 0 would mean that no actor token nesting is allowed. + /// The maximum allowed value is 4 to prevent security issues with excessively deep actor chains. + /// + /// + /// Default value is 4. + /// During token validation and creation, an exception will be thrown if the actor nesting exceeds this limit. + /// This limit applies to both token creation and validation processes. + /// + /// Thrown if the value is less than 0 or greater than 4. + public int MaxActorChainLength + { + get => _maxActorChainLength; + set + { + if (value < 0 || value > 4) + throw LogHelper.LogExceptionMessage( + new ArgumentOutOfRangeException( + LogHelper.FormatInvariant( + LogMessages.IDX11027, + LogHelper.MarkAsNonPII("MaxActorChainLength")) + + ". Permissible values are integers in range 0 to 4")); + + _maxActorChainLength = value; + } + } + + /// + /// Gets or sets the claim type that identifies the actor claim in tokens. + /// and "act" when the switch is on. + /// This property determines which claim in a token contains the actor information during token + /// validation and creation. + /// For JWT tokens, this is the claim name in the payload that holds the actor object. + /// + /// + /// Thrown if the value is null or empty. + /// + /// + /// To use the newer JSON object-based actor format, set AppContext.SetSwitch(AppContextSwitches.EnableActClaimSupportSwitch, true) + /// and use "act" as the claim type. + /// To use the legacy string-based actor token format, leave the switch off and use "actort". + /// + public string ActorClaimType + { + get => _actorClaimType; + set + { + if (string.IsNullOrEmpty(value) || string.Equals(value.Trim(), "actort", StringComparison.OrdinalIgnoreCase)) + throw LogHelper.LogExceptionMessage( + new ArgumentOutOfRangeException( + LogHelper.FormatInvariant( + LogMessages.IDX11027, + LogHelper.MarkAsNonPII("ActorClaimType")) + + ". ActorClaimType cannot be empty or equal to actort")); + _actorClaimType = value; + } + } + + /// + /// Gets or sets the current depth in the actor chain being processed. + /// This is used internally to track the nesting level during recursive processing + /// of nested actor tokens. + /// The value starts at 0 and is incremented for each level of actor nesting. + /// + /// + /// This value is compared against to prevent excessive + /// recursion or deeply nested actor tokens. + /// In most scenarios, users don't need to set this property as it's managed internally + /// by the token validation and creation process. + /// + public int ActorChainDepth + { + get => _actorClainDepth; + set + { + _actorClainDepth = value; + } + } } } diff --git a/src/Microsoft.IdentityModel.Tokens/TokenValidationParameters.cs b/src/Microsoft.IdentityModel.Tokens/TokenValidationParameters.cs index fa6b589762..af400084f1 100644 --- a/src/Microsoft.IdentityModel.Tokens/TokenValidationParameters.cs +++ b/src/Microsoft.IdentityModel.Tokens/TokenValidationParameters.cs @@ -40,6 +40,24 @@ public partial class TokenValidationParameters /// 250 KB (kilobytes). public const int DefaultMaximumTokenSizeInBytes = 1024 * 250; + /// + /// Default for permissible max actor chain length. + /// + /// 5 as max level of nesting + private int maxActorChainLength = 4; + + /// + /// Default for actor claim name. + /// + /// If not explicitly set the default name for actor claim is 'act'. Only needed when EnableActClaimSupportSwitch is turned on + private string actorClaimType = "act"; + + /// + /// This variable is used during recursion calls that are needed for deserializing act claim. + /// + /// Default value is 0 + private int _actorClainDepth; + /// /// Copy constructor for . /// @@ -111,6 +129,10 @@ protected TokenValidationParameters(TokenValidationParameters other) ValidIssuer = other.ValidIssuer; ValidIssuers = other.ValidIssuers; ValidTypes = other.ValidTypes; + ActClaimRetrieverDelegate = other.ActClaimRetrieverDelegate; + MaxActorChainLength = other.MaxActorChainLength; + ActorChainDepth = other.ActorChainDepth; + ActorClaimType = other.ActorClaimType; } /// @@ -762,5 +784,107 @@ public string RoleClaimType /// The default is null. /// public IEnumerable ValidTypes { get; set; } + + /// + /// Gets or sets the claim type that identifies the actor claim in tokens. + /// This property determines which claim in a token contains the actor information during token + /// validation and creation. + /// For JWT tokens, this is the claim name in the payload that holds the actor object. + /// + /// + /// Thrown if the value is null or empty. + /// + /// + /// To use the newer JSON object-based actor format, set AppContext.SetSwitch(AppContextSwitches.EnableActClaimSupportSwitch, true) + /// and use "act" as the claim type. + /// To use the legacy string-based actor token format, leave the switch off and use "actort". + /// + public string ActorClaimType + { + get => actorClaimType; + set + { + if (string.IsNullOrEmpty(value) || string.Equals(value.Trim(), "actort", StringComparison.OrdinalIgnoreCase)) + throw LogHelper.LogExceptionMessage( + new ArgumentOutOfRangeException( + LogHelper.FormatInvariant( + LogMessages.IDX11027, + LogHelper.MarkAsNonPII("ActorClaimType")) + + $". ActorClaimType cannot be empty or equal to actort.")); + actorClaimType = value; + } + } + + /// + /// Gets or sets the current depth in the actor chain being processed. + /// This is used internally to track the nesting level during recursive processing + /// of nested actor tokens. + /// The value starts at 0 and is incremented for each level of actor nesting. + /// + /// + /// This value is compared against to prevent excessive + /// recursion or deeply nested actor tokens. + /// In most scenarios, users don't need to set this property as it's managed internally + /// by the token validation and creation process. + /// + public int ActorChainDepth + { + get => _actorClainDepth; + set + { + _actorClainDepth = value; + } + } + + /// + /// Gets or sets the delegate that will be used to convert the 'act' claim JSON into a ClaimsIdentity. + /// This delegate is invoked during token validation when an actor claim is encountered in a token. + /// The delegate receives a representing the actor claim + /// and should return a that represents the actor. + /// + /// + /// When this delegate is provided, it replaces the default actor claim processing logic. + /// This is useful for custom actor claim formats or when special processing is needed for the actor claims. + /// The delegate can also handle nested actors by recursively creating actor identities and setting the Actor property. + /// + /// validationParameters.ActClaimRetrieverDelegate = (JsonElement element,TokenValidationParameters tokenValidationParameters) => { + /// var identity = new ClaimsIdentity("CustomActor"); + /// // Extract claims from the JsonElement + /// if (element.TryGetProperty("sub", out var sub)) + /// identity.AddClaim(new Claim("sub", sub.GetString())); + /// return identity; + /// }; + /// + /// + public ActClaimRetrieverDelegate ActClaimRetrieverDelegate { get; set; } + + /// + /// Gets or sets the maximum depth allowed when processing nested actor tokens. + /// This prevents excessive recursion when handling deeply nested actor tokens. + /// The value must be at least 0. Value 0 would mean that no actor token nesting is allowed. + /// The maximum allowed value is 4 to prevent security issues with excessively deep actor chains. + /// + /// + /// Default value is 4. + /// During token validation and creation, an exception will be thrown if the actor nesting exceeds this limit. + /// This limit applies to both token creation and validation processes. + /// + /// Thrown if the value is less than 0 or greater than 4. + public int MaxActorChainLength + { + get => maxActorChainLength; + set + { + if (value < 0 || value > 4) + throw LogHelper.LogExceptionMessage( + new ArgumentOutOfRangeException( + LogHelper.FormatInvariant( + LogMessages.IDX11027, + LogHelper.MarkAsNonPII("MaxActorChainLength")) + + ". Permissible values are integers in range 0 to 4")); + + maxActorChainLength = value; + } + } } } diff --git a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/ActClaimTests/ActClaimDeserializationTests.cs b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/ActClaimTests/ActClaimDeserializationTests.cs new file mode 100644 index 0000000000..79a0621691 --- /dev/null +++ b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/ActClaimTests/ActClaimDeserializationTests.cs @@ -0,0 +1,973 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.IdentityModel.TestUtils; +using Microsoft.IdentityModel.Tokens; +using Xunit; +using System.Threading.Tasks; +using System.Linq; +namespace Microsoft.IdentityModel.JsonWebTokens.Tests.ActClaimTests +{ + public class ActClaimDeserializationTests + { + [Fact] + public void BasicJsonElementShouldCreateClaimsIdentityCorrectly() + { + var context = new CompareContext($"{this}.BasicJsonElementShouldCreateClaimsIdentityCorrectly"); + try + { + // Create a simple JSON Element that represents an actor token + string actorJson = @"{ + ""sub"": ""actor-subject-id"", + ""name"": ""Actor Name"", + ""role"": ""admin"" + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var validationParameters = new TokenValidationParameters() + { + ActorClaimType = "act", + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + validationParameters); + + // Assert + Assert.NotNull(identity); + Assert.Equal("Actor", identity.AuthenticationType); + Assert.IsType(identity); + + // Verify claims values + Assert.Equal("actor-subject-id", identity.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Actor Name", identity.Claims.First(c => c.Type == "name").Value); + Assert.Equal("admin", identity.Claims.First(c => c.Type == "role").Value); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void NestedActorInJsonElementShouldCreateNestedClaimsIdentity() + { + var context = new CompareContext($"{this}.NestedActorInJsonElementShouldCreateNestedClaimsIdentity"); + try + { + // Create nested actor JSON structure + string actorJson = @"{ + ""sub"": ""actor-subject-id"", + ""name"": ""Actor Name"", + ""act"": { + ""sub"": ""nested-actor-id"", + ""name"": ""Nested Actor"" + } + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + // Verify main identity + Assert.NotNull(identity); + Assert.Equal("actor-subject-id", identity.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Actor Name", identity.Claims.First(c => c.Type == "name").Value); + + // Verify nested actor identity + Assert.NotNull(identity.Actor); + Assert.Equal("nested-actor-id", identity.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Nested Actor", identity.Actor.Claims.First(c => c.Type == "name").Value); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void MultiLevelNestedActorJsonShouldHandleProperDepth() + { + var context = new CompareContext($"{this}.MultiLevelNestedActorJsonShouldHandleProperDepth"); + try + { + // Create a three-level nested actor JSON structure + string actorJson = @"{ + ""sub"": ""level1-subject"", + ""name"": ""Level 1 Actor"", + ""act"": { + ""sub"": ""level2-subject"", + ""name"": ""Level 2 Actor"", + ""act"": { + ""sub"": ""level3-subject"", + ""name"": ""Level 3 Actor"" + } + } + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + MaxActorChainLength = 3, + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + // Verify level 1 + Assert.NotNull(identity); + Assert.Equal("level1-subject", identity.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Level 1 Actor", identity.Claims.First(c => c.Type == "name").Value); + + // Verify level 2 + Assert.NotNull(identity.Actor); + Assert.Equal("level2-subject", identity.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Level 2 Actor", identity.Actor.Claims.First(c => c.Type == "name").Value); + + // Verify level 3 + Assert.NotNull(identity.Actor.Actor); + Assert.Equal("level3-subject", identity.Actor.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Level 3 Actor", identity.Actor.Actor.Claims.First(c => c.Type == "name").Value); + + // No level 4 + Assert.Null(identity.Actor.Actor.Actor); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void NestedActorExceedingMaxDepth_ThrowsException() + { + var context = new CompareContext($"{this}.NestedActorExceedingMaxDepth_ThrowsException"); + try + { + // Create a three-level nested actor but set max depth to 2 + string actorJson = @"{ + ""sub"": ""level1-subject"", + ""name"": ""Level 1 Actor"", + ""act"": { + ""sub"": ""level2-subject"", + ""name"": ""Level 2 Actor"", + ""act"": { + ""sub"": ""level3-subject"", + ""name"": ""Level 3 Actor"" + } + } + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + MaxActorChainLength = 2, + ActorChainDepth = 1, + }; + + // Act - This should throw a SecurityTokenException + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + context.Diffs.Add("Expected exception was not thrown."); + TestUtilities.AssertFailIfErrors(context); + } + catch (SecurityTokenException ex) + { + // Assert - Verify the exception message contains the expected content + if (!ex.Message.Contains("IDX14313")) + { + context.Diffs.Add($"Exception message does not contain expected content. Message: {ex.Message}"); + } + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + // Unexpected exception type + context.Diffs.Add($"Unexpected exception type: {ex.GetType()}, Message: {ex.Message}"); + TestUtilities.AssertFailIfErrors(context); + } + } + + [Fact] + public void JsonElementWithArrayValuesShouldProcessCorrectly() + { + var context = new CompareContext($"{this}.JsonElementWithArrayValuesShouldProcessCorrectly"); + try + { + // Create JSON with array value + string actorJson = @"{ + ""sub"": ""actor-subject-id"", + ""name"": ""Actor Name"", + ""roles"": [""admin"", ""user"", ""manager""] + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + // Verify identity and simple claims + Assert.NotNull(identity); + Assert.Equal("actor-subject-id", identity.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Actor Name", identity.Claims.First(c => c.Type == "name").Value); + + // Verify array values were processed into multiple claims + var roleClaims = identity.Claims.Where(c => c.Type == "roles").ToList(); + Assert.Equal(3, roleClaims.Count); + Assert.Contains(roleClaims, c => c.Value == "admin"); + Assert.Contains(roleClaims, c => c.Value == "user"); + Assert.Contains(roleClaims, c => c.Value == "manager"); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void JsonElementWithComplexTypesShouldHandleCorrectly() + { + var context = new CompareContext($"{this}.JsonElementWithComplexTypesShouldHandleCorrectly"); + try + { + // Create JSON with complex types (objects) + string actorJson = @"{ + ""sub"": ""actor-subject-id"", + ""name"": ""Actor Name"", + ""metadata"": { + ""created"": ""2023-10-15"", + ""system"": ""test-system"" + }, + ""numbers"": [1, 2, 3] + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + // Verify identity and simple claims + Assert.NotNull(identity); + Assert.Equal("actor-subject-id", identity.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Actor Name", identity.Claims.First(c => c.Type == "name").Value); + + // Verify the JSON object was serialized to a claim + var metadataClaim = identity.Claims.First(c => c.Type == "metadata"); + Assert.NotNull(metadataClaim); + Assert.Contains("created", metadataClaim.Value); + Assert.Contains("test-system", metadataClaim.Value); + + // Verify number array was handled + var numberClaims = identity.Claims.Where(c => c.Type == "numbers").ToList(); + Assert.Equal(3, numberClaims.Count); + Assert.Contains(numberClaims, c => c.Value == "1"); + Assert.Contains(numberClaims, c => c.Value == "2"); + Assert.Contains(numberClaims, c => c.Value == "3"); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void NonObjectJsonElement_ThrowsException() + { + var context = new CompareContext($"{this}.NonObjectJsonElement_ThrowsException"); + try + { + // Create a non-object JSON Element (string) + string actorJson = @"""This is just a string, not an object"""; + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + }; + + // Act - This should throw an ArgumentException + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + context.Diffs.Add("Expected exception was not thrown."); + TestUtilities.AssertFailIfErrors(context); + } + catch (ArgumentException ex) + { + // Expected exception type + Assert.Contains("Actor token must be a JSON object", ex.Message); + } + catch (Exception ex) + { + // Unexpected exception type + context.Diffs.Add($"Unexpected exception type: {ex.GetType()}, Message: {ex.Message}"); + TestUtilities.AssertFailIfErrors(context); + } + } + + [Fact] + public void NullValidationParameters_ThrowsException() + { + var context = new CompareContext($"{this}.NullValidationParameters_ThrowsException"); + try + { + // Create a simple JSON Element + string actorJson = @"{ ""sub"": ""actor-subject-id"" }"; + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + + // Act - This should throw an ArgumentNullException + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + null); // Null validation parameters + + context.Diffs.Add("Expected exception was not thrown."); + TestUtilities.AssertFailIfErrors(context); + } + catch (ArgumentNullException ex) + { + // Expected exception type + Assert.Equal("tokenValidationParameters", ex.ParamName); + } + catch (Exception ex) + { + // Unexpected exception type + context.Diffs.Add($"Unexpected exception type: {ex.GetType()}, Message: {ex.Message}"); + TestUtilities.AssertFailIfErrors(context); + } + } + + [Fact] + public void CustomActorClaimNameShouldBeRespected() + { + var context = new CompareContext($"{this}.CustomActorClaimNameShouldBeRespected"); + try + { + // Create JSON with custom actor claim name + string actorJson = @"{ + ""sub"": ""actor-subject-id"", + ""name"": ""Actor Name"", + ""actort"": { + ""sub"": ""nested-actor-id"", + ""name"": ""Nested Actor"" + } + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "actort", + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + // Verify main identity + Assert.NotNull(identity); + Assert.Equal("actor-subject-id", identity.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Actor Name", identity.Claims.First(c => c.Type == "name").Value); + + // Verify nested actor was found using custom claim name + Assert.NotNull(identity.Actor); + Assert.Equal("nested-actor-id", identity.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.Equal("Nested Actor", identity.Actor.Claims.First(c => c.Type == "name").Value); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void ActorChainDepthShouldBeIncremented() + { + var context = new CompareContext($"{this}.ActorChainDepthShouldBeIncremented"); + try + { + // Create actor JSON with nested actor + string actorJson = @"{ + ""sub"": ""actor-subject-id"", + ""name"": ""Actor Name"", + ""act"": { + ""sub"": ""nested-actor-id"", + ""name"": ""Nested Actor"" + } + }"; + + var jsonElement = JsonDocument.Parse(actorJson).RootElement; + var tokenValidationParameters = new TokenValidationParameters + { + ActorClaimType = "act", + MaxActorChainLength = 4, + ActorChainDepth = 2, + }; + + // Create ClaimsIdentity from JsonElement + var identity = JsonWebTokenHandler.CreateActorClaimsIdentityFromJsonElement( + jsonElement, + tokenValidationParameters); + + // Verify depth was incremented (2 + 1 = 3) + Assert.Equal(3, tokenValidationParameters.ActorChainDepth); + + // Verify both levels of actors exist + Assert.NotNull(identity); + Assert.NotNull(identity.Actor); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public async Task ValidateTokenAsync_WithActorInToken_ProvidesActorClaimsIdentity() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_WithActorInToken_ProvidesActorClaimsIdentity"); + try + { + // Create a token with an actor claim + var handler = new JsonWebTokenHandler(); + + var actorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorIdentity.AddClaim(new Claim("name", "Actor Name")); + actorIdentity.AddClaim(new Claim("role", "admin")); + + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + { "act", actorIdentity} + }, + ActorClaimType = "act", + MaxActorChainLength = 4, + }; + string token = handler.CreateToken(tokenDescriptor); + handler.MapInboundClaims = true; + + // Validate token + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true, + ActorClaimType = "act", + }; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + + // Verify validation succeeded + Assert.True(result.IsValid); + Assert.NotNull(result.SecurityToken); + Assert.NotNull(result.ClaimsIdentity); + + // Verify main claims + var mainClaim = result.ClaimsIdentity.Claims.FirstOrDefault(c => c.Type == "name"); + Assert.NotNull(mainClaim); + Assert.Equal("Main User", mainClaim.Value); + Console.WriteLine($"Verified main claims"); + + // Verify actor claims identity + Assert.NotNull(result.ClaimsIdentity.Actor); + var actorSubClaim = result.ClaimsIdentity.Actor.Claims.FirstOrDefault(c => c.Type == "sub"); + var actorNameClaim = result.ClaimsIdentity.Actor.Claims.FirstOrDefault(c => c.Type == "name"); + var actorRoleClaim = result.ClaimsIdentity.Actor.Claims.FirstOrDefault(c => c.Type == "role"); + Assert.NotNull(actorSubClaim); + Assert.NotNull(actorNameClaim); + Assert.NotNull(actorRoleClaim); + Console.WriteLine($"Verified actor claim"); + + Assert.Equal("actor-subject-id", actorSubClaim.Value); + Assert.Equal("Actor Name", actorNameClaim.Value); + Assert.Equal("admin", actorRoleClaim.Value); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + TestUtilities.AssertFailIfErrors(context); + } + } + + [Fact] + public async Task ValidateTokenAsync_CustomDelegate_WorksWithSimpleAndNestedActors() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_CustomDelegate_WorksWithSimpleAndNestedActors"); + + int delegateCallCount = 0; + ClaimsIdentity CustomDelegate(JsonElement element, TokenValidationParameters tokenValidationParameters = null) + { + delegateCallCount++; + var id = new CaseSensitiveClaimsIdentity("CustomActorAuth"); + if (element.TryGetProperty("sub", out var sub)) + id.AddClaim(new Claim("sub", sub.GetString())); + if (element.TryGetProperty("name", out var name)) + id.AddClaim(new Claim("name", name.GetString())); + if (element.TryGetProperty("act", out var nested) && nested.ValueKind == JsonValueKind.Object) + id.Actor = CustomDelegate(nested); + return id; + } + + var nestedActor = new CaseSensitiveClaimsIdentity("NestedActorAuth"); + nestedActor.AddClaim(new Claim("sub", "nested-actor-id")); + nestedActor.AddClaim(new Claim("name", "Nested Actor")); + + var actor = new CaseSensitiveClaimsIdentity("ActorAuth"); + actor.AddClaim(new Claim("sub", "actor-subject-id")); + actor.AddClaim(new Claim("name", "Actor Name")); + actor.Actor = nestedActor; + + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + var handler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary { { "act", actor } }, + }; + var token = handler.CreateToken(tokenDescriptor); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true, + ActorClaimType = "act", + MaxActorChainLength = 3, + ActClaimRetrieverDelegate = CustomDelegate, + }; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + Assert.True(result.IsValid); + Assert.NotNull(result.ClaimsIdentity.Actor); + Assert.Equal("actor-subject-id", result.ClaimsIdentity.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.NotNull(result.ClaimsIdentity.Actor.Actor); + Assert.Equal("nested-actor-id", result.ClaimsIdentity.Actor.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.True(delegateCallCount >= 2); + + TestUtilities.AssertFailIfErrors(context); + } + + [Fact] + public async Task ValidateTokenAsync_NestedActors_DefaultDelegate_CreatesProperClaimsIdentity() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_NestedActors_DefaultDelegate_CreatesProperClaimsIdentity"); + var nestedActor = new CaseSensitiveClaimsIdentity("NestedActorAuth"); + nestedActor.AddClaim(new Claim("sub", "nested-actor-id")); + nestedActor.AddClaim(new Claim("name", "Nested Actor")); + + var actor = new CaseSensitiveClaimsIdentity("ActorAuth"); + actor.AddClaim(new Claim("sub", "actor-subject-id")); + actor.AddClaim(new Claim("name", "Actor Name")); + actor.Actor = nestedActor; + + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + var handler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary { { "act", actor } }, + }; + var token = handler.CreateToken(tokenDescriptor); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true, + ActorClaimType = "act", + MaxActorChainLength = 2, + }; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + Assert.True(result.IsValid); + Assert.NotNull(result.ClaimsIdentity.Actor); + Assert.Equal("actor-subject-id", result.ClaimsIdentity.Actor.Claims.First(c => c.Type == "sub").Value); + Assert.NotNull(result.ClaimsIdentity.Actor.Actor); + Assert.Equal("nested-actor-id", result.ClaimsIdentity.Actor.Actor.Claims.First(c => c.Type == "sub").Value); + + TestUtilities.AssertFailIfErrors(context); + } + + [Fact] + public async Task ValidateTokenAsync_NestingBeyondMaxActorChain_ThrowsException() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_NestingBeyondMaxActorChain_ThrowsException"); + + try + { + var level3Actor = new CaseSensitiveClaimsIdentity("Level3Auth"); + level3Actor.AddClaim(new Claim("sub", "level3-actor")); + + var level2Actor = new CaseSensitiveClaimsIdentity("Level2Auth"); + level2Actor.AddClaim(new Claim("sub", "level2-actor")); + level2Actor.Actor = level3Actor; + + var level1Actor = new CaseSensitiveClaimsIdentity("Level1Auth"); + level1Actor.AddClaim(new Claim("sub", "level1-actor")); + level1Actor.Actor = level2Actor; + + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + + var handler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary { { "act", level1Actor } }, + }; + var token = handler.CreateToken(tokenDescriptor); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true, + ActorClaimType = "act", + MaxActorChainLength = 2, + }; + handler.MapInboundClaims = true; + var result = await handler.ValidateTokenAsync(token, validationParameters); + Assert.Null(result.ClaimsIdentity.Actor); + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + Assert.Contains("IDX14313", ex.ToString()); + } + } + + [Fact] + public async Task ValidateTokenAsync_CustomDelegate_ThrowsExceptionIfDelegateFails() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_CustomDelegate_ThrowsIfDelegateFails"); + + ClaimsIdentity CustomDelegate(JsonElement element, TokenValidationParameters tokenValidationParameters = null) + { + throw new InvalidOperationException("Delegate failure"); + } + + try + { + var actor = new CaseSensitiveClaimsIdentity("ActorAuth"); + actor.AddClaim(new Claim("sub", "actor-subject-id")); + + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + + var handler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary { { "act", actor } }, + }; + var token = handler.CreateToken(tokenDescriptor); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true, + ActorClaimType = "act", + MaxActorChainLength = 2, + ActClaimRetrieverDelegate = CustomDelegate, + }; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + Assert.Null(result.ClaimsIdentity.Actor); + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + Assert.Contains("IDX14314", ex.ToString()); + } + } + + [Fact] + public async Task ValidateTokenAsync_ActorAsSubjectAndClaimsDictionary_DefaultAndCustomDelegate() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_ActorAsSubjectAndClaimsDictionary_DefaultAndCustomDelegate"); + + ClaimsIdentity CustomDelegate(JsonElement element, TokenValidationParameters tokenValidationParameters = null) + { + var id = new CaseSensitiveClaimsIdentity("CustomActorAuth"); + if (element.TryGetProperty("sub", out var sub)) + id.AddClaim(new Claim("sub", sub.GetString())); + return id; + } + + // Actor as Subject + var actorAsSubject = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorAsSubject.AddClaim(new Claim("sub", "actor-subject-id")); + + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.Actor = actorAsSubject; + + var handler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + }; + var token = handler.CreateToken(tokenDescriptor); + + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true, + ActorClaimType = "act", + }; + + // Default delegate + var result = await handler.ValidateTokenAsync(token, validationParameters); + Assert.True(result.IsValid); + Assert.NotNull(result.ClaimsIdentity.Actor); + Assert.Equal("actor-subject-id", result.ClaimsIdentity.Actor.Claims.First(c => c.Type == "sub").Value); + + // Custom delegate + validationParameters.ActClaimRetrieverDelegate = CustomDelegate; + var result2 = await handler.ValidateTokenAsync(token, validationParameters); + Assert.True(result2.IsValid); + Assert.NotNull(result2.ClaimsIdentity.Actor); + Assert.Equal("actor-subject-id", result2.ClaimsIdentity.Actor.Claims.First(c => c.Type == "sub").Value); + + // Actor in both Subject and Claims dictionary, Claims dictionary should take precedence + var subjectActor = new CaseSensitiveClaimsIdentity("SubjectActorAuth"); + subjectActor.AddClaim(new Claim("sub", "subject-actor-id")); + + var claimsActor = new CaseSensitiveClaimsIdentity("ClaimsActorAuth"); + claimsActor.AddClaim(new Claim("sub", "claims-actor-id")); + + var mainIdentity2 = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity2.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity2.Actor = subjectActor; + + var tokenDescriptor2 = new SecurityTokenDescriptor + { + Subject = mainIdentity2, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary { { "act", claimsActor } }, + }; + var token2 = handler.CreateToken(tokenDescriptor2); + + var result3 = await handler.ValidateTokenAsync(token2, validationParameters); + Assert.True(result3.IsValid); + Assert.NotNull(result3.ClaimsIdentity.Actor); + Assert.Equal("claims-actor-id", result3.ClaimsIdentity.Actor.Claims.First(c => c.Type == "sub").Value); + + TestUtilities.AssertFailIfErrors(context); + } + + [Fact] + public async Task ValidateTokenAsync_WithActortClaim_HandlesJwtStringNotJson() + { + var context = new CompareContext($"{this}.ValidateTokenAsync_WithActortClaim_HandlesJwtStringNotJson"); + try + { + // ARRANGE + // First create a JWT token to use as the actor token string + var innerHandler = new JsonWebTokenHandler(); + var actorJwtIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorJwtIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorJwtIdentity.AddClaim(new Claim("name", "Actor Name")); + + var actorJwtDescriptor = new SecurityTokenDescriptor + { + Subject = actorJwtIdentity, + Issuer = "https://actor.example.com", + Audience = "https://api.example.com", + SigningCredentials = Default.AsymmetricSigningCredentials + }; + + // Create the actor token as a JWT string + string actorJwtString = innerHandler.CreateToken(actorJwtDescriptor); + + // Now create the main token with the actort claim containing the JWT string + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + var handler = new JsonWebTokenHandler(); + var mainTokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + // Use actort claim with JWT string + { "actort", actorJwtString } + } + }; + + // Create the main token + string mainToken = handler.CreateToken(mainTokenDescriptor); + + // ACT + // Validate the token with actort claim type + var validationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + IssuerSigningKey = Default.AsymmetricSigningKey, + ValidateIssuerSigningKey = true + }; + + var result = await handler.ValidateTokenAsync(mainToken, validationParameters); + + // ASSERT + // Verify validation succeeded + Assert.True(result.IsValid); + Assert.NotNull(result.ClaimsIdentity); + + // Verify actor is processed as a JWT + Assert.NotNull(result.ClaimsIdentity.Actor); + + // The actor should have claims from the JWT token + var actorSubClaim = result.ClaimsIdentity.Actor.Claims.FirstOrDefault(c => c.Type == "sub"); + var actorNameClaim = result.ClaimsIdentity.Actor.Claims.FirstOrDefault(c => c.Type == "name"); + + Assert.NotNull(actorSubClaim); + Assert.NotNull(actorNameClaim); + Assert.Equal("actor-subject-id", actorSubClaim.Value); + Assert.Equal("Actor Name", actorNameClaim.Value); + + // For comparison, create another token with 'act' claim as JSON + var jsonActor = new CaseSensitiveClaimsIdentity("ActorAuth"); + jsonActor.AddClaim(new Claim("sub", "json-actor-id")); + jsonActor.AddClaim(new Claim("name", "JSON Actor")); + + var jsonTokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + { "actor_claim_name", jsonActor } + }, + ActorClaimType = "actor_claim_name", + }; + + string jsonToken = handler.CreateToken(jsonTokenDescriptor); + validationParameters.ActorClaimType = "actor_claim_name"; + var jsonResult = await handler.ValidateTokenAsync(jsonToken, validationParameters); + + // Verify different processing method + Assert.NotNull(jsonResult.ClaimsIdentity.Actor); + Assert.Equal("json-actor-id", jsonResult.ClaimsIdentity.Actor.Claims.First(c => c.Type == "sub").Value); + + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + TestUtilities.AssertFailIfErrors(context); + + } + } + } +} diff --git a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/ActClaimTests/ActClaimSerializationTests.cs b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/ActClaimTests/ActClaimSerializationTests.cs new file mode 100644 index 0000000000..156be4fe4e --- /dev/null +++ b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/ActClaimTests/ActClaimSerializationTests.cs @@ -0,0 +1,587 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.IdentityModel.TestUtils; +using Microsoft.IdentityModel.Tokens; +using Xunit; +namespace Microsoft.IdentityModel.JsonWebTokens.Tests.ActClaimTests +{ + public class ActClaimSerializationTests + { + [Fact] + public void ActorTokenInClaimsDictionaryShouldBeProperlySerialized() + { + var context = new CompareContext($"{this}.ActorTokenInClaimsDictionaryShouldBeProperlySerialized"); + string actorname = "act"; + try + { + // Create a ClaimsIdentity for the actor + var actorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorIdentity.AddClaim(new Claim("name", "Actor Name")); + actorIdentity.AddClaim(new Claim("role", "admin")); + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + // Create a token with JsonWebTokenHandler where actor is in Claims dictionary + var tokenHandler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + { actorname, actorIdentity } + }, + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + JsonWebToken decodedToken = tokenHandler.ReadJsonWebToken(token); + + // Verify actor claim exists in the token + Assert.True(decodedToken.Payload.HasClaim(tokenDescriptor.ActorClaimType), "JWT token should contain 'actort' claim"); + // Verify the actor object directly + var actorObject = decodedToken.Payload.GetValue(tokenDescriptor.ActorClaimType); + Assert.Equal(JsonValueKind.Object, actorObject.ValueKind); + + // Verify actor claims directly from the JSON object + Assert.Equal("actor-subject-id", actorObject.GetProperty("sub").GetString()); + Assert.Equal("Actor Name", actorObject.GetProperty("name").GetString()); + Assert.Equal("admin", actorObject.GetProperty("role").GetString()); + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void ActorTokenAsSubjectShouldBeProperlySerialized() + { + var context = new CompareContext($"{this}.ActorTokenAsSubjectShouldBeProperlySerialized"); + try + { + // Create actor identity + var actorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorIdentity.AddClaim(new Claim("name", "Actor Name")); + actorIdentity.AddClaim(new Claim("role", "admin")); + + // Create the main identity with Actor set via Identity.Actor + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + mainIdentity.Actor = actorIdentity; + + // Create a token with JsonWebTokenHandler + var tokenHandler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + JsonWebToken decodedToken = tokenHandler.ReadJsonWebToken(token); + + // Verify actor claim exists in the token + Assert.True(decodedToken.Payload.HasClaim(tokenDescriptor.ActorClaimType), "JWT token should contain 'act' claim"); + + // Verify actor claim exists in the token + Assert.True(decodedToken.Payload.HasClaim(tokenDescriptor.ActorClaimType), "JWT token should contain actor claim"); + + // Verify the actor object directly + var actorObject = decodedToken.Payload.GetValue(tokenDescriptor.ActorClaimType); + Assert.Equal(JsonValueKind.Object, actorObject.ValueKind); + + // Verify actor claims directly from the JSON object + Assert.Equal("actor-subject-id", actorObject.GetProperty("sub").GetString()); + Assert.Equal("Actor Name", actorObject.GetProperty("name").GetString()); + Assert.Equal("admin", actorObject.GetProperty("role").GetString()); + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void ActorTokenInBothClaimsAndSubjectShouldPreferClaimsValue() + { + var context = new CompareContext($"{this}.ActorTokenInBothClaimsAndSubjectShouldPreferClaimsValue"); + string actorname = "act"; + + try + { + // Create actor identity for Subject.Actor (should be ignored) + var subjectActorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + subjectActorIdentity.AddClaim(new Claim("sub", "subject-actor-id")); + subjectActorIdentity.AddClaim(new Claim("name", "Subject Actor")); + + // Create actor identity for Claims dictionary (should be used) + var claimsActorIdentity = new CaseSensitiveClaimsIdentity("ClaimsActorAuth"); + claimsActorIdentity.AddClaim(new Claim("sub", "claims-actor-id")); + claimsActorIdentity.AddClaim(new Claim("name", "Claims Actor")); + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + mainIdentity.Actor = subjectActorIdentity; // Set the actor that should be ignored + + // Create a token with JsonWebTokenHandler + var tokenHandler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + // Add Claims actor that should take precedence + Claims = new Dictionary + { + { actorname, claimsActorIdentity } + }, + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + JsonWebToken decodedToken = tokenHandler.ReadJsonWebToken(token); + + // Verify actor claim exists + Assert.True(decodedToken.Payload.HasClaim(actorname), "JWT token should contain actor claim"); + + // Verify actor claim exists and is a JSON object + var actorObject = decodedToken.Payload.GetValue("act"); + Assert.Equal(JsonValueKind.Object, actorObject.ValueKind); + + // Verify Claims dictionary actor was used, not Subject.Actor + Assert.Equal("claims-actor-id", actorObject.GetProperty("sub").GetString()); + Assert.Equal("Claims Actor", actorObject.GetProperty("name").GetString()); + Assert.NotEqual("subject-actor-id", actorObject.GetProperty("sub").GetString()); + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void NestedActorTokenInClaimsDictionaryShouldBeProperlySerialized() + { + var context = new CompareContext($"{this}.NestedActorTokenInClaimsDictionaryShouldBeProperlySerialized"); + + try + { + // Create nested actor identity + var nestedActorIdentity = new CaseSensitiveClaimsIdentity("NestedActorAuth"); + nestedActorIdentity.AddClaim(new Claim("sub", "nested-actor-id")); + nestedActorIdentity.AddClaim(new Claim("name", "Nested Actor")); + + // Create actor identity with nested actor + var actorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorIdentity.AddClaim(new Claim("name", "Actor Name")); + actorIdentity.Actor = nestedActorIdentity; // Set nested actor + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + // Create a token with JsonWebTokenHandler where actor is in Claims dictionary + var tokenHandler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + { "act", actorIdentity } + }, + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + JsonWebToken decodedToken = tokenHandler.ReadJsonWebToken(token); + + // Verify actor claim exists + Assert.True(decodedToken.Payload.HasClaim("act"), "JWT token should contain 'actort' claim"); + + // Verify the actor object + var actorObject = decodedToken.Payload.GetValue("act"); + Assert.Equal(JsonValueKind.Object, actorObject.ValueKind); + + // Verify main actor claims directly from JSON object + Assert.Equal("actor-subject-id", actorObject.GetProperty("sub").GetString()); + Assert.Equal("Actor Name", actorObject.GetProperty("name").GetString()); + + // Verify nested actor exists and is a JSON object + Assert.True(actorObject.TryGetProperty(tokenDescriptor.ActorClaimType, out var nestedActorElement)); + Assert.Equal(JsonValueKind.Object, nestedActorElement.ValueKind); + + // Verify nested actor claims directly from JSON object + Assert.Equal("nested-actor-id", nestedActorElement.GetProperty("sub").GetString()); + Assert.Equal("Nested Actor", nestedActorElement.GetProperty("name").GetString()); + TestUtilities.AssertFailIfErrors(context); + } + catch (Exception ex) + { + context.Diffs.Add($"Exception: {ex}"); + } + } + + [Fact] + public void NestedActorTokenAsSubjectShouldBeProperlySerialized() + { + var context = new CompareContext($"{this}.NestedActorTokenAsSubjectShouldBeProperlySerialized"); + + // Create nested actor + var nestedActorIdentity = new CaseSensitiveClaimsIdentity("NestedActorAuth"); + nestedActorIdentity.AddClaim(new Claim("sub", "nested-actor-id")); + nestedActorIdentity.AddClaim(new Claim("name", "Nested Actor")); + + // Create actor identity with nested actor + var actorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorIdentity.AddClaim(new Claim("name", "Actor Name")); + actorIdentity.Actor = nestedActorIdentity; + + // Create the main identity with Actor set + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + mainIdentity.Actor = actorIdentity; + + // Create a token with JsonWebTokenHandler + var tokenHandler = new JsonWebTokenHandler(); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + Expires = DateTime.UtcNow.AddHours(1), + SigningCredentials = Default.AsymmetricSigningCredentials, + ActorClaimType = "act", + }; + var token = tokenHandler.CreateToken(tokenDescriptor); + JsonWebToken decodedToken = tokenHandler.ReadJsonWebToken(token); + + // Verify actor claim exists + Assert.True(decodedToken.Payload.HasClaim("act"), "JWT token should contain 'act' claim"); + + // Verify the actor object structure + var actorObject = decodedToken.Payload.GetValue("act"); + Assert.Equal(JsonValueKind.Object, actorObject.ValueKind); + + // Verify main actor claims directly from JSON object + Assert.Equal("actor-subject-id", actorObject.GetProperty("sub").GetString()); + Assert.Equal("Actor Name", actorObject.GetProperty("name").GetString()); + + // Verify nested actor exists and is a JSON object + Assert.True(actorObject.TryGetProperty(tokenDescriptor.ActorClaimType, out var nestedActorElement)); + Assert.Equal(JsonValueKind.Object, nestedActorElement.ValueKind); + + // Verify nested actor claims directly from JSON object + Assert.Equal("nested-actor-id", nestedActorElement.GetProperty("sub").GetString()); + Assert.Equal("Nested Actor", nestedActorElement.GetProperty("name").GetString()); + TestUtilities.AssertFailIfErrors(context); + } + + [Fact] + public void MaxActorChainLength_RejectsNegativeValues() + { + // Arrange + SecurityTokenDescriptor tokenDescriptor = new SecurityTokenDescriptor + { + Subject = null, + Issuer = "https://example.com", + Audience = "https://api.example.com", + SigningCredentials = Default.AsymmetricSigningCredentials, + }; + + tokenDescriptor.ActorClaimType = "act"; // Set the actor claim name to "act" for testing + int originalValue = tokenDescriptor.MaxActorChainLength; + tokenDescriptor.ActorClaimType = "act"; // Set the actor claim name to "act" for testing + // Act & Assert - Valid value 0 should not throw + tokenDescriptor.MaxActorChainLength = 0; + Assert.Equal(0, tokenDescriptor.MaxActorChainLength); + + // Act & Assert - Negative value + var ex = Assert.Throws(() => + tokenDescriptor.MaxActorChainLength = -5); + Assert.Contains("IDX11027", ex.Message); + + // Act & Assert - Valid value 1 should not throw + tokenDescriptor.MaxActorChainLength = 1; + Assert.Equal(1, tokenDescriptor.MaxActorChainLength); + + ex = Assert.Throws(() => + tokenDescriptor.MaxActorChainLength = 10); + Assert.Contains("IDX11027", ex.Message); + } + + [Fact] + public void NestedSubjectActorTokens_ExceedingMaxDepth_ThrowsException() + { + var context = new CompareContext($"{this}.NestedActorTokens_ExceedingMaxDepth_ThrowsException"); + + try + { + // Arrange + var handler = new JsonWebTokenHandler(); + + // Create nested actor identities (3 levels, but we'll set MaxActorChainLength to 2) + var level3Actor = new CaseSensitiveClaimsIdentity("Level3Auth"); + level3Actor.AddClaim(new Claim("sub", "level3-actor")); + level3Actor.AddClaim(new Claim("name", "Level 3 Actor")); + + var level2Actor = new CaseSensitiveClaimsIdentity("Level2Auth"); + level2Actor.AddClaim(new Claim("sub", "level2-actor")); + level2Actor.AddClaim(new Claim("name", "Level 2 Actor")); + level2Actor.Actor = level3Actor; // This will cause exception due to MaxActorChainLength=2 + + var level1Actor = new CaseSensitiveClaimsIdentity("Level1Auth"); + level1Actor.AddClaim(new Claim("sub", "level1-actor")); + level1Actor.AddClaim(new Claim("name", "Level 1 Actor")); + level1Actor.Actor = level2Actor; + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + mainIdentity.Actor = level1Actor; + + // Create token descriptor + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + SigningCredentials = Default.AsymmetricSigningCredentials, + ActorClaimType = "act", + MaxActorChainLength = 2, + }; + + // Act - This should throw a SecurityTokenException + var token = handler.CreateToken(tokenDescriptor); + context.Diffs.Add("Expected exception was not thrown."); + + TestUtilities.AssertFailIfErrors(context); + } + catch (SecurityTokenException ex) + { + // Assert - Verify the exception message contains the expected content + if (!ex.Message.Contains("IDX14313")) + { + context.Diffs.Add($"Exception message does not contain expected content. Message: {ex.Message}"); + } + } + catch (Exception ex) + { + // Unexpected exception type + context.Diffs.Add($"Unexpected exception type: {ex.GetType()}, Message: {ex.Message}"); + } + } + + [Fact] + public void NestedClaimsDictionaryActorTokens_ExceedingMaxDepth_ThrowsException() + { + var context = new CompareContext($"{this}.NestedClaimsDictionaryActorTokens_ExceedingMaxDepth_ThrowsException"); + + try + { + // Arrange + var handler = new JsonWebTokenHandler(); + string actorname = "act"; + // Create nested actor identities + var nestedActorIdentity = new CaseSensitiveClaimsIdentity("NestedActorAuth"); + nestedActorIdentity.AddClaim(new Claim("sub", "nested-actor-id")); + nestedActorIdentity.AddClaim(new Claim("name", "Nested Actor")); + + // Create actor identity with nested actor + var actorIdentity = new CaseSensitiveClaimsIdentity("ActorAuth"); + actorIdentity.AddClaim(new Claim("sub", "actor-subject-id")); + actorIdentity.AddClaim(new Claim("name", "Actor Name")); + actorIdentity.Actor = nestedActorIdentity; // This should be ignored due to MaxActorChainLength + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + + // Create token with actor in Claims dictionary + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + { actorname, actorIdentity } + }, + ActorClaimType = actorname, + MaxActorChainLength = 1 + }; + + // Act + var token = handler.CreateToken(tokenDescriptor); + context.Diffs.Add("Expected exception was not thrown."); + TestUtilities.AssertFailIfErrors(context); + } + catch (SecurityTokenException ex) + { + // Assert - Verify the exception message contains the expected content + if (!ex.Message.Contains("IDX14313")) + { + context.Diffs.Add($"Exception message does not contain expected content. Message: {ex.Message}"); + } + } + catch (Exception ex) + { + // Unexpected exception type + context.Diffs.Add($"Unexpected exception type: {ex.GetType()}, Message: {ex.Message}"); + } + } + + [Fact] + public void ActorTokens_MixedSourceRespectMaxActorChainLength() + { + // Arrange + var handler = new JsonWebTokenHandler(); + string actorname = "act"; + // Create level 2 actor (will be in claims dictionary) + var level2Actor = new CaseSensitiveClaimsIdentity("Level2Auth"); + level2Actor.AddClaim(new Claim("sub", "level2-actor")); + level2Actor.AddClaim(new Claim("name", "Level 2 Actor")); + + // Create nested actors that should be truncated + var level3Actor = new CaseSensitiveClaimsIdentity("Level3Auth"); + level3Actor.AddClaim(new Claim("sub", "level3-actor")); + level3Actor.AddClaim(new Claim("name", "Level 3 Actor")); + + // Create level 1 actor with nested actor + var level1Actor = new CaseSensitiveClaimsIdentity("Level1Auth"); + level1Actor.AddClaim(new Claim("sub", "level1-actor")); + level1Actor.AddClaim(new Claim("name", "Level 1 Actor")); + level1Actor.Actor = level3Actor; // This should be ignored due to MaxActorChainLength + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + mainIdentity.Actor = level1Actor; + + // Create a token with additional actor in Claims dictionary + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + SigningCredentials = Default.AsymmetricSigningCredentials, + // Add level 2 actor in claims dictionary to replace level 1's actor + Claims = new Dictionary + { + { actorname, level2Actor } + }, + ActorClaimType = actorname, + MaxActorChainLength = 1, + }; + + var token = handler.CreateToken(tokenDescriptor); + var jwtToken = handler.ReadJsonWebToken(token); + + // Assert - Check actor object structure + Assert.True(jwtToken.Payload.HasClaim(actorname), "JWT token should contain 'act' claim"); + var actorObject = jwtToken.Payload.GetValue(tokenDescriptor.ActorClaimType); + + Assert.Equal(JsonValueKind.Object, actorObject.ValueKind); + + // Verify we get the actor from Claims dictionary (should be level2Actor) + Assert.Equal("level2-actor", actorObject.GetProperty("sub").GetString()); + Assert.Equal("Level 2 Actor", actorObject.GetProperty("name").GetString()); + + // There should be no nested actor because we're already at max depth + Assert.False(actorObject.TryGetProperty("act", out _), "There should be no nested actor claim due to MaxActorChainLength"); + } + + [Fact] + public void NestedClaimTokens_ExceedingMaxDepth_ThrowsException() + { + var context = new CompareContext($"{this}.NestedActorTokens_ExceedingMaxDepth_ThrowsException"); + var actorname = "act"; + try + { + // Arrange + var handler = new JsonWebTokenHandler(); + + // Create nested actor identities (3 levels, but we'll set MaxActorChainLength to 2) + var level3Actor = new CaseSensitiveClaimsIdentity("Level3Auth"); + level3Actor.AddClaim(new Claim("sub", "level3-actor")); + level3Actor.AddClaim(new Claim("name", "Level 3 Actor")); + + var level2Actor = new CaseSensitiveClaimsIdentity("Level2Auth"); + level2Actor.AddClaim(new Claim("sub", "level2-actor")); + level2Actor.AddClaim(new Claim("name", "Level 2 Actor")); + level2Actor.Actor = level3Actor; // This will cause exception due to MaxActorChainLength=2 + + var level1Actor = new CaseSensitiveClaimsIdentity("Level1Auth"); + level1Actor.AddClaim(new Claim("sub", "level1-actor")); + level1Actor.AddClaim(new Claim("name", "Level 1 Actor")); + level1Actor.Actor = level2Actor; + + // Create the main identity + var mainIdentity = new CaseSensitiveClaimsIdentity("Bearer"); + mainIdentity.AddClaim(new Claim("sub", "main-subject-id")); + mainIdentity.AddClaim(new Claim("name", "Main User")); + // Create token descriptor + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = mainIdentity, + Issuer = "https://example.com", + Audience = "https://api.example.com", + SigningCredentials = Default.AsymmetricSigningCredentials, + Claims = new Dictionary + { + { "act", level1Actor } + }, + ActorClaimType = actorname, + MaxActorChainLength = 1, + }; + + // Act - This should throw a SecurityTokenException + var token = handler.CreateToken(tokenDescriptor); + context.Diffs.Add("Expected exception was not thrown."); + TestUtilities.AssertFailIfErrors(context); + } + catch (SecurityTokenException ex) + { + // Assert - Verify the exception message contains the expected content + if (!ex.Message.Contains("IDX14313")) + { + context.Diffs.Add($"Exception message does not contain expected content. Message: {ex.Message}"); + } + } + catch (Exception ex) + { + // Unexpected exception type + context.Diffs.Add($"Unexpected exception type: {ex.GetType()}, Message: {ex.Message}"); + } + } + } +} diff --git a/test/Microsoft.IdentityModel.TestUtils/ValidationDelegates.cs b/test/Microsoft.IdentityModel.TestUtils/ValidationDelegates.cs index 2ca873b58b..63d4b7083a 100644 --- a/test/Microsoft.IdentityModel.TestUtils/ValidationDelegates.cs +++ b/test/Microsoft.IdentityModel.TestUtils/ValidationDelegates.cs @@ -250,5 +250,10 @@ public static string TypeValidator(string type, SecurityToken securityToken, Tok { return type; } + + public static readonly ActClaimRetrieverDelegate ActClaimRetrieverDelegate = (actorClaim, tokenValidationParameters) => + { + return null; + }; } } diff --git a/test/Microsoft.IdentityModel.Tokens.Tests/TokenValidationParametersTests.cs b/test/Microsoft.IdentityModel.Tokens.Tests/TokenValidationParametersTests.cs index c728e0afa4..f7d0584d54 100644 --- a/test/Microsoft.IdentityModel.Tokens.Tests/TokenValidationParametersTests.cs +++ b/test/Microsoft.IdentityModel.Tokens.Tests/TokenValidationParametersTests.cs @@ -15,7 +15,7 @@ namespace Microsoft.IdentityModel.Tokens.Tests { public class TokenValidationParametersTests { - int ExpectedPropertyCount = 62; + int ExpectedPropertyCount = 66; // GetSets() compares the total property count which includes internal properties, against a list of public properties, minus delegates. // This allows us to keep track of any properties we are including in the total that are not public nor delegates. @@ -198,6 +198,8 @@ public void GetSets() { PropertyNamesAndSetGetValue = new List>> { + new KeyValuePair>("ActorClaimType", new List{"act"}), + new KeyValuePair>("ActorChainDepth", new List{0,1}), new KeyValuePair>("ActorValidationParameters", new List{(TokenValidationParameters)null, new TokenValidationParameters(), new TokenValidationParameters()}), new KeyValuePair>("AuthenticationType", new List{(string)null, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()}), new KeyValuePair>("ClockSkew", new List{TokenValidationParameters.DefaultClockSkew, TimeSpan.FromHours(2), TimeSpan.FromMinutes(1)}), @@ -212,6 +214,7 @@ public void GetSets() new KeyValuePair>("IssuerSigningKeys", new List{(IEnumerable)null, new List{KeyingMaterial.DefaultX509Key_2048, KeyingMaterial.RsaSecurityKey_1024}, new List()}), new KeyValuePair>("LogTokenId", new List{true, false, true}), new KeyValuePair>("LogValidationExceptions", new List{true, false, true}), + new KeyValuePair>("MaxActorChainLength", new List{4,2}), new KeyValuePair>("NameClaimType", new List{ClaimsIdentity.DefaultNameClaimType, Guid.NewGuid().ToString(), Guid.NewGuid().ToString()}), new KeyValuePair>("PropertyBag", new List{(IDictionary)null, new Dictionary {{"CustomKey", "CustomValue"}}, new Dictionary()}), new KeyValuePair>("RefreshBeforeValidation", new List{false, true, false}), @@ -312,6 +315,7 @@ private TokenValidationParameters CreateTokenValidationParameters() validationParameters.TransformBeforeSignatureValidation = ValidationDelegates.TransformBeforeSignatureValidation; validationParameters.TryReadJwtClaim = ValidationDelegates.TryReadJwtClaim; validationParameters.TypeValidator = ValidationDelegates.TypeValidator; + validationParameters.ActClaimRetrieverDelegate = ValidationDelegates.ActClaimRetrieverDelegate; validationParameters.ActorValidationParameters = new TokenValidationParameters(); validationParameters.ClockSkew = TimeSpan.FromSeconds(42);