Skip to content

Commit

Permalink
Read Token: Remove Exceptions (#2702)
Browse files Browse the repository at this point in the history
* Added new version of ReadToken that receives a CallContext and returns a result wrapping exceptions and logs.
  • Loading branch information
iNinja authored Jul 17, 2024
1 parent 245c831 commit a7ef799
Show file tree
Hide file tree
Showing 5 changed files with 345 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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
{
/// <remarks>This partial class contains methods and logic related to the validation of tokens.</remarks>
public partial class JsonWebTokenHandler : TokenHandler
{
#nullable enable
/// <summary>
/// Converts a string into an instance of <see cref="JsonWebToken"/>, returned inside of a <see cref="TokenReadingResult"/>.
/// </summary>
/// <param name="token">A JSON Web Token (JWT) in JWS or JWE Compact Serialization format.</param>
/// <param name="callContext"></param>
/// <returns>A <see cref="TokenReadingResult"/> with the <see cref="JsonWebToken"/> if valid, or an Exception.</returns>
/// <exception cref="ArgumentNullException">returned if <paramref name="token"/> is null or empty.</exception>
/// <exception cref="SecurityTokenMalformedException">returned if the validationParameters.TokenReader delegate is not able to parse/read the token as a valid <see cref="JsonWebToken"/>.</exception>
/// <exception cref="SecurityTokenMalformedException">returned if <paramref name="token"/> is not a valid JWT, <see cref="JsonWebToken"/>.</exception>
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;

#nullable enable
namespace Microsoft.IdentityModel.Tokens
{
/// <summary>
/// Contains the result of reading a <see cref="SecurityToken"/>.
/// The <see cref="TokenValidationResult"/> contains a collection of <see cref="ValidationResult"/> for each step in the token validation.
/// </summary>
internal class TokenReadingResult : ValidationResult
{
private Exception? _exception;
private SecurityToken? _securityToken;

/// <summary>
/// Creates an instance of <see cref="TokenReadingResult"/>.
/// </summary>
/// <paramref name="tokenInput"/> is the string from which the <see cref="SecurityToken"/> was created.
/// <paramref name="securityToken"/> is the <see cref="SecurityToken"/> that was created.
public TokenReadingResult(SecurityToken securityToken, string tokenInput)
: base(ValidationFailureType.ValidationSucceeded)
{
IsValid = true;
TokenInput = tokenInput;
_securityToken = securityToken;
}

/// <summary>
/// Creates an instance of <see cref="TokenReadingResult"/>
/// </summary>
/// <paramref name="tokenInput"/> is the string that failed to create a <see cref="SecurityToken"/>.
/// <paramref name="validationFailure"/> is the <see cref="ValidationFailureType"/> that occurred during reading.
/// <paramref name="exceptionDetail"/> is the <see cref="ExceptionDetail"/> that occurred during reading.
public TokenReadingResult(string? tokenInput, ValidationFailureType validationFailure, ExceptionDetail exceptionDetail)
: base(validationFailure, exceptionDetail)
{
TokenInput = tokenInput;
IsValid = false;
}

/// <summary>
/// Gets the <see cref="SecurityToken"/> that was read.
/// </summary>
/// <exception cref="InvalidOperationException"/> if the <see cref="SecurityToken"/> is null.
/// <remarks>It is expected that the caller would check <see cref="ValidationResult.IsValid"/> returns true before accessing this.</remarks>
public SecurityToken SecurityToken()
{
if (_securityToken is null)
throw new InvalidOperationException("Attempted to retrieve the SecurityToken from a failed TokenReading result.");

return _securityToken;
}

/// <summary>
/// Gets the <see cref="Exception"/> that occurred during reading.
/// </summary>
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;
}
}

/// <summary>
/// Gets the string from which the <see cref="SecurityToken"/> was read.
/// </summary>
public string? TokenInput { get; }
}
}
#nullable restore
Original file line number Diff line number Diff line change
Expand Up @@ -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) { } }

/// <summary>
/// Defines a type that represents that a token could not be read.
/// </summary>
public static readonly ValidationFailureType TokenReadingFailed = new TokenReadingFailure("TokenReadingFailed");
private class TokenReadingFailure : ValidationFailureType { internal TokenReadingFailure(string name) : base(name) { } }

/// <summary>
/// Defines a type that represents that no evaluation has taken place.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() => tokenReadingResult.SecurityToken());
}

public static TheoryData<TokenReadingTheoryData> JsonWebTokenHandlerReadTokenTestCases
{
get
{
var validToken = EncodedJwts.LiveJwt;
return new TheoryData<TokenReadingTheoryData>
{
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; }
}
}
70 changes: 70 additions & 0 deletions test/Microsoft.IdentityModel.TestUtils/IdentityComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit a7ef799

Please sign in to comment.