diff --git a/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.ReadToken.cs b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.ReadToken.cs new file mode 100644 index 0000000000..66c9b194f9 --- /dev/null +++ b/src/Microsoft.IdentityModel.JsonWebTokens/JsonWebTokenHandler.ReadToken.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.Tokens; +using TokenLogMessages = Microsoft.IdentityModel.Tokens.LogMessages; + +namespace Microsoft.IdentityModel.JsonWebTokens +{ + /// This partial class contains methods and logic related to the validation of tokens. + public partial class JsonWebTokenHandler : TokenHandler + { +#nullable enable + /// + /// Converts a string into an instance of , returned inside of a . + /// + /// A JSON Web Token (JWT) in JWS or JWE Compact Serialization format. + /// + /// A with the if valid, or an Exception. + /// returned if is null or empty. + /// returned if the validationParameters.TokenReader delegate is not able to parse/read the token as a valid . + /// returned if is not a valid JWT, . + internal static TokenReadingResult ReadToken( + string token, +#pragma warning disable CA1801 // TODO: remove pragma disable once callContext is used for logging + CallContext? callContext) +#pragma warning disable CA1801 // TODO: remove pragma disable once callContext is used for logging + { + if (String.IsNullOrEmpty(token)) + { + return new TokenReadingResult( + token, + ValidationFailureType.NullArgument, + new ExceptionDetail( + new MessageDetail( + TokenLogMessages.IDX10000, + LogHelper.MarkAsNonPII(nameof(token))), + typeof(ArgumentNullException), + new System.Diagnostics.StackFrame())); + } + + try + { + JsonWebToken jsonWebToken = new JsonWebToken(token); + return new TokenReadingResult(jsonWebToken, token); + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + return new TokenReadingResult( + token, + ValidationFailureType.TokenReadingFailed, + new ExceptionDetail( + new MessageDetail(LogMessages.IDX14107), + ex.GetType(), + new System.Diagnostics.StackFrame(), + ex)); + } + } + } +} +#nullable restore diff --git a/src/Microsoft.IdentityModel.Tokens/Validation/TokenReadingResult.cs b/src/Microsoft.IdentityModel.Tokens/Validation/TokenReadingResult.cs new file mode 100644 index 0000000000..38002bd071 --- /dev/null +++ b/src/Microsoft.IdentityModel.Tokens/Validation/TokenReadingResult.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +#nullable enable +namespace Microsoft.IdentityModel.Tokens +{ + /// + /// Contains the result of reading a . + /// The contains a collection of for each step in the token validation. + /// + internal class TokenReadingResult : ValidationResult + { + private Exception? _exception; + private SecurityToken? _securityToken; + + /// + /// Creates an instance of . + /// + /// is the string from which the was created. + /// is the that was created. + public TokenReadingResult(SecurityToken securityToken, string tokenInput) + : base(ValidationFailureType.ValidationSucceeded) + { + IsValid = true; + TokenInput = tokenInput; + _securityToken = securityToken; + } + + /// + /// Creates an instance of + /// + /// is the string that failed to create a . + /// is the that occurred during reading. + /// is the that occurred during reading. + public TokenReadingResult(string? tokenInput, ValidationFailureType validationFailure, ExceptionDetail exceptionDetail) + : base(validationFailure, exceptionDetail) + { + TokenInput = tokenInput; + IsValid = false; + } + + /// + /// Gets the that was read. + /// + /// if the is null. + /// It is expected that the caller would check returns true before accessing this. + public SecurityToken SecurityToken() + { + if (_securityToken is null) + throw new InvalidOperationException("Attempted to retrieve the SecurityToken from a failed TokenReading result."); + + return _securityToken; + } + + /// + /// Gets the that occurred during reading. + /// + public override Exception? Exception + { + get + { + if (_exception != null || ExceptionDetail == null) + return _exception; + + HasValidOrExceptionWasRead = true; + _exception = ExceptionDetail.GetException(); + + if (_exception is SecurityTokenException securityTokenException) + { + securityTokenException.Source = "Microsoft.IdentityModel.Tokens"; + securityTokenException.ExceptionDetail = ExceptionDetail; + } + + return _exception; + } + } + + /// + /// Gets the string from which the was read. + /// + public string? TokenInput { get; } + } +} +#nullable restore diff --git a/src/Microsoft.IdentityModel.Tokens/Validation/ValidationFailureType.cs b/src/Microsoft.IdentityModel.Tokens/Validation/ValidationFailureType.cs index 52af888ad2..f185d73eac 100644 --- a/src/Microsoft.IdentityModel.Tokens/Validation/ValidationFailureType.cs +++ b/src/Microsoft.IdentityModel.Tokens/Validation/ValidationFailureType.cs @@ -69,6 +69,12 @@ private class LifetimeValidationFailure : ValidationFailureType { internal Lifet public static readonly ValidationFailureType TokenReplayValidationFailed = new TokenReplayValidationFailure("TokenReplayValidationFailed"); private class TokenReplayValidationFailure : ValidationFailureType { internal TokenReplayValidationFailure(string name) : base(name) { } } + /// + /// Defines a type that represents that a token could not be read. + /// + public static readonly ValidationFailureType TokenReadingFailed = new TokenReadingFailure("TokenReadingFailed"); + private class TokenReadingFailure : ValidationFailureType { internal TokenReadingFailure(string name) : base(name) { } } + /// /// Defines a type that represents that no evaluation has taken place. /// diff --git a/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandler.ReadTokenTests.cs b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandler.ReadTokenTests.cs new file mode 100644 index 0000000000..3e21280c17 --- /dev/null +++ b/test/Microsoft.IdentityModel.JsonWebTokens.Tests/JsonWebTokenHandler.ReadTokenTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IdentityModel.Tokens.Jwt.Tests; +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.TestUtils; +using Microsoft.IdentityModel.Tokens; +using Xunit; +using TokenLogMessages = Microsoft.IdentityModel.Tokens.LogMessages; + +namespace Microsoft.IdentityModel.JsonWebTokens.Tests +{ + public class JsonWebTokenHandlerReadTokenTests + { + [Theory, MemberData(nameof(JsonWebTokenHandlerReadTokenTestCases), DisableDiscoveryEnumeration = true)] + public void ReadToken(TokenReadingTheoryData theoryData) + { + CompareContext context = TestUtilities.WriteHeader($"{this}.JsonWebTokenHandlerReadTokenTests", theoryData); + TokenReadingResult tokenReadingResult = JsonWebTokenHandler.ReadToken( + theoryData.Token, + new CallContext()); + + if (tokenReadingResult.Exception != null) + theoryData.ExpectedException.ProcessException(tokenReadingResult.Exception); + else + theoryData.ExpectedException.ProcessNoException(); + + IdentityComparer.AreTokenReadingResultsEqual( + tokenReadingResult, + theoryData.TokenReadingResult, + context); + + TestUtilities.AssertFailIfErrors(context); + } + + [Fact] + public void ReadToken_ThrowsIfAccessingSecurityTokenOnFailedRead() + { + TokenReadingResult tokenReadingResult = JsonWebTokenHandler.ReadToken( + null, + new CallContext()); + + Assert.Throws(() => tokenReadingResult.SecurityToken()); + } + + public static TheoryData JsonWebTokenHandlerReadTokenTestCases + { + get + { + var validToken = EncodedJwts.LiveJwt; + return new TheoryData + { + new TokenReadingTheoryData + { + TestId = "Valid_Jwt", + Token = validToken, + TokenReadingResult = new TokenReadingResult( + new JsonWebToken(validToken), + validToken) + }, + new TokenReadingTheoryData + { + TestId = "Invalid_NullToken", + Token = null, + ExpectedException = ExpectedException.ArgumentNullException("IDX10000:"), + TokenReadingResult = new TokenReadingResult( + null, + ValidationFailureType.NullArgument, + new ExceptionDetail( + new MessageDetail( + TokenLogMessages.IDX10000, + LogHelper.MarkAsNonPII("token")), + typeof(ArgumentNullException), + new System.Diagnostics.StackFrame())) + }, + new TokenReadingTheoryData + { + TestId = "Invalid_EmptyToken", + Token = string.Empty, + ExpectedException = ExpectedException.ArgumentNullException("IDX10000:"), + TokenReadingResult = new TokenReadingResult( + string.Empty, + ValidationFailureType.NullArgument, + new ExceptionDetail( + new MessageDetail( + TokenLogMessages.IDX10000, + LogHelper.MarkAsNonPII("token")), + typeof(ArgumentNullException), + new System.Diagnostics.StackFrame())) + }, + new TokenReadingTheoryData + { + TestId = "Invalid_MalformedToken", + Token = "malformed-token", + ExpectedException = ExpectedException.SecurityTokenMalformedTokenException( + "IDX14107:", + typeof(SecurityTokenMalformedException)), + TokenReadingResult = new TokenReadingResult( + "malformed-token", + ValidationFailureType.TokenReadingFailed, + new ExceptionDetail( + new MessageDetail( + LogMessages.IDX14107, + LogHelper.MarkAsNonPII("token")), + typeof(SecurityTokenMalformedException), + new System.Diagnostics.StackFrame())) + } + }; + } + } + } + + public class TokenReadingTheoryData : TheoryDataBase + { + public string Token { get; set; } + public object TokenReadingResult { get; set; } + } +} diff --git a/test/Microsoft.IdentityModel.TestUtils/IdentityComparer.cs b/test/Microsoft.IdentityModel.TestUtils/IdentityComparer.cs index 2e061f2ce2..47b868d8a4 100644 --- a/test/Microsoft.IdentityModel.TestUtils/IdentityComparer.cs +++ b/test/Microsoft.IdentityModel.TestUtils/IdentityComparer.cs @@ -1007,6 +1007,76 @@ internal static bool AreTokenTypeValidationResultsEqual( return context.Merge(localContext); } + public static bool AreTokenReadingResultsEqual(object object1, object object2, CompareContext context) + { + var localContext = new CompareContext(context); + if (!ContinueCheckingEquality(object1, object2, context)) + return context.Merge(localContext); + + return AreTokenReadingResultsEqual( + object1 as TokenReadingResult, + object2 as TokenReadingResult, + "TokenReadingResult1", + "TokenReadingResult2", + null, + context); + } + + internal static bool AreTokenReadingResultsEqual( + TokenReadingResult tokenReadingResult1, + TokenReadingResult tokenReadingResult2, + string name1, + string name2, + string stackPrefix, + CompareContext context) + { + var localContext = new CompareContext(context); + if (!ContinueCheckingEquality(tokenReadingResult1, tokenReadingResult2, localContext)) + return context.Merge(localContext); + + if (tokenReadingResult1.IsValid != tokenReadingResult2.IsValid) + localContext.Diffs.Add($"TokenReadingResult1.IsValid: {tokenReadingResult1.IsValid} != TokenReadingResult2.IsValid: {tokenReadingResult2.IsValid}"); + + if (tokenReadingResult1.TokenInput != tokenReadingResult2.TokenInput) + localContext.Diffs.Add($"TokenReadingResult1.TokenInput: '{tokenReadingResult1.TokenInput}' != TokenReadingResult2.TokenInput: '{tokenReadingResult2.TokenInput}'"); + + // Only compare the security token if both are valid. + if (tokenReadingResult1.IsValid && (tokenReadingResult1.SecurityToken().ToString() != tokenReadingResult2.SecurityToken().ToString())) + localContext.Diffs.Add($"TokenReadingResult1.SecurityToken: '{tokenReadingResult1.SecurityToken()}' != TokenReadingResult2.SecurityToken: '{tokenReadingResult2.SecurityToken()}'"); + + if (tokenReadingResult1.ValidationFailureType != tokenReadingResult2.ValidationFailureType) + localContext.Diffs.Add($"TokenReadingResult1.ValidationFailureType: {tokenReadingResult1.ValidationFailureType} != TokenReadingResult2.ValidationFailureType: {tokenReadingResult2.ValidationFailureType}"); + + // true => both are not null. + if (ContinueCheckingEquality(tokenReadingResult1.Exception, tokenReadingResult2.Exception, localContext)) + { + AreStringsEqual( + tokenReadingResult1.Exception.Message, + tokenReadingResult2.Exception.Message, + $"({name1}).Exception.Message", + $"({name2}).Exception.Message", + localContext); + + AreStringsEqual( + tokenReadingResult1.Exception.Source, + tokenReadingResult2.Exception.Source, + $"({name1}).Exception.Source", + $"({name2}).Exception.Source", + localContext); + + if (!string.IsNullOrEmpty(stackPrefix)) + AreStringPrefixesEqual( + tokenReadingResult1.Exception.StackTrace.Trim(), + tokenReadingResult2.Exception.StackTrace.Trim(), + $"({name1}).Exception.StackTrace", + $"({name2}).Exception.StackTrace", + stackPrefix.Trim(), + localContext); + } + + return context.Merge(localContext); + } + public static bool AreJArraysEqual(object object1, object object2, CompareContext context) { var localContext = new CompareContext(context);