Skip to content

Commit

Permalink
feat: add custom exceptions for model binding errors
Browse files Browse the repository at this point in the history
- Addds MissingClaimException that is now thrown when a claim can not be found
- Adds ClaimParsingException that is thrown when a claim can not be parsed to the specified type
  • Loading branch information
Kampfmoehre committed Jan 18, 2023
1 parent d35b10b commit ea8f09b
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 14 deletions.
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# DroidSolutions Auth Claim Binder

Custom modelbinder for ASP.NET Core MVC to allow injecting claims into controller actions.
Custom modelbinder for ASP.NET Core MVC (and Web APIs) to allow injecting claims into controller actions.

[![Coverage Status](https://coveralls.io/repos/github/droidsolutions/asp-auth-claim-binder/badge.svg?branch=main)](https://coveralls.io/github/droidsolutions/asp-auth-claim-binder?branch=main)
![Nuget](https://img.shields.io/nuget/v/DroidSolutions.Oss.AuthClaimBinder)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)

This NuGet package contains the `FromClaim` attribute that can be used in controller actions to inject a value from a claim, for example the user id or role. It also offers a ASP.NET Core Modelbinder and a Modelbinder provider.

This project wass inspired by [this blogpost](https://www.davidkaya.com/custom-from-attribute-for-controller-actions-in-asp-net-core/).
This project was inspired by [this blogpost](https://www.davidkaya.com/custom-from-attribute-for-controller-actions-in-asp-net-core/).

# Installation

You can grab this NuGet package from [NuGet.org](https://www.nuget.org/packages/DroidSolutions.Oss.AuthClaimBinder).

# How it works

The modelbinder will search available claims from the authentication for the given name you used as argument name in your controller action. Specifically the claims on the user property in the HttpContext objects are used.
The modelbinder will search available claims from the authentication for the given name you used as argument name in your controller action. Specifically the claims on the user property in the `HttpContext` objects are used.
If a claim with the given name is found the modelbinder will try to convert the value to the type you have specified. Currently the following types are supported:

- `string`
Expand All @@ -27,11 +27,11 @@ If a claim with the given name is found the modelbinder will try to convert the

# Usage

To use the attribute first the modelbinder provider must be added to the list of `ModelBinderProviders`.
To use the attribute first the `ClaimModelBinderProvider` must be added to the list of `ModelBinderProviders`.

## Register

The modelbinder provider can be added to the MVC options like this
The `ClaimModelBinderProvider` can be added to the MVC options (when using Web API projects) like this

```cs
builder.Services.AddControllers(options =>
Expand Down Expand Up @@ -59,7 +59,7 @@ The `ClaimsModelBinder` can be configured via `ClaimBinderSettings`. Those setti

If the claims you have from your authentication method are complex or you want to use other argument names in your controller actions you can provide an alias list via `ClaimBinderSettings.AliasConfig`.

This is a dictionary of string keys (the key you want to use as argument names) and a list of strings that serve as aliases. For example if you use Open ID Connect and get you claims from the JWT they might be some long strings or urls. The example below uses the key `user` and adds an alias for `System.Security.Claims.ClaimTypes.NameIdentifier`. This way the binder finds the value of the claim with the name of the `ClaimTypes.NameIdentifier` when you use `user` as the argument name.
This is a dictionary of string keys (the key you want to use as argument names) and a list of strings that serve as aliases. For example if you use Open ID Connect and get you claims from the JWT they might be some long strings or urls. The example below uses the key `role` and adds an alias for `System.Security.Claims.ClaimTypes.Role`. This way the binder finds the value of the claim with the name of the `ClaimTypes.Role` when you use `role` as the argument name.

```cs
builder.Services.Configure<ClaimBinderSettings>(o => o.AliasConfig = new Dictionary<string, List<string>>
Expand All @@ -79,6 +79,46 @@ public async Task<IActionResult> DoSomething([FromClaim] string user, [FromClaim
}
```

## Exceptions

There are special exceptions for errors during parsing of claim values which are explained below:

### MissingClaimException

When the `FromClaim` attribute is used but the claim (or it's alias) can not be found in the user claims, this exception is thrown. This is especially useful, if you want to show the caller of your API a BadRequest response or an message.

For example, let's assume you want to use a value from a special header you defined. You have set up your authentication handler to get the value from the header and put it in the user claims:
```cs
// Authorization handler
if (Request.Headers.TryGetValue("x-myvalue", out StringValues namespaceHeader))
{
claims = claims.Append(new Claim("myvalue", namespaceHeader[0]));
}

// Contoller
public async IActionResult MyMethod([FromClaim] string myvalue)
{
// do something with myvalue
}
```

This works, when the x-myvalue header is provided, but if it is not, than the exception would be thrown (probably leading to a 500 beeing returned). Since you know the exception that is thrown you can set up an [Exception Filter](https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/filters) or a special controller action that handles errors and process the `MissingClaimException`. See [the ASP.NET Core docs](https://learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors) for more info on how to set up error handling.

Thie `MissingClaimException` contains a property with the name of the claim. Be aware, that this is the name used in the controller attribute, so in case of the header example you probably need to write a custom message, indicating that the header is missing.

### ClaimParsingException

This exception is thrown when a value cannot be parsed to the specified type. For example let's assume you have a Guid user id and want to use it in your controller:
```cs
// Contoller
public async IActionResult MyMethod([FromClaim] Guid user)
{
// do something with user Id
}
```

Dependent on how you get the user claim it could be possible that it is not a valid Guid. In this case the `ClaimModelBinder` would throw a `ClaimParsingException` with the name of the claim ("user" in this case) and the destination type (`Guid`). This can help you set up special error handling for those cases.

# Development

If you want to add a feature or fix a bug, be sure to read the [contribution guidelines](./CONTRIBUTING.md) first before open a pull request.
Expand Down
16 changes: 12 additions & 4 deletions src/DroidSolutions.Oss.AuthClaimBinder/ClaimModelBinder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Claims;

using DroidSolutions.Oss.AuthClaimBinder.Exceptions;
using DroidSolutions.Oss.AuthClaimBinder.Settings;

using Microsoft.AspNetCore.Mvc.ModelBinding;
Expand Down Expand Up @@ -37,7 +38,7 @@ public Task BindModelAsync(ModelBindingContext bindingContext)

_logger.LogError("The claim {FieldName} could not be extracted from the user.", bindingContext.FieldName);

throw new InvalidOperationException($"The claim {bindingContext.FieldName} could not be extracted from the user, the value is null.");
throw new MissingClaimException(bindingContext.FieldName);
}

if (bindingContext.ModelType == typeof(Guid))
Expand All @@ -51,15 +52,22 @@ public Task BindModelAsync(ModelBindingContext bindingContext)
bindingContext.Result = ModelBindingResult.Failed();
_logger.LogError(ex, "The claim {FieldName} could not be parsed to a Guid!", bindingContext.FieldName);

throw new InvalidOperationException($"The claim {bindingContext.FieldName} could not be parsed to a Guid!", ex);
throw new ClaimParsingException(
$"The claim {bindingContext.FieldName} could not be parsed to a Guid!",
ex,
bindingContext.FieldName,
bindingContext.ModelType);
}
}
else if (bindingContext.ModelType.IsEnum)
{
if (!Enum.TryParse(bindingContext.ModelType, claim.Value, false, out var value))
{
throw new InvalidOperationException(
$"The value {claim.Value} of the claim {bindingContext.FieldName} could not be parsed to the enum {bindingContext.ModelType.Name}.");
throw new ClaimParsingException(
$"The value {claim.Value} of the claim {bindingContext.FieldName} could not be parsed to the enum {bindingContext.ModelType.Name}.",
null,
bindingContext.FieldName,
bindingContext.ModelType);
}

bindingContext.Result = ModelBindingResult.Success(value);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Runtime.Serialization;

namespace DroidSolutions.Oss.AuthClaimBinder.Exceptions;

/// <summary>
/// Special exception when a claim can not be parsed to the destination type.
/// </summary>
[Serializable]
public class ClaimParsingException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
public ClaimParsingException()
{
ClaimName = string.Empty;
}

/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
/// <param name="name">The name of the claim.</param>
/// <param name="type">The type that the claim should have been parsed to.</param>
public ClaimParsingException(string name, Type? type)
: this($"The claim \"{name}\" was not found.", null, name, type)
{ }

/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
/// <param name="message">The message of the exception.</param>
public ClaimParsingException(string? message)
: this(message, (Exception?)null)
{ }

/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="name">The name of the claim.</param>
/// <param name="type">The type that the claim should have been parsed to.</param>
public ClaimParsingException(string message, string name, Type? type)
: this(message, null, name, type)
{ }

/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception, if any.</param>
public ClaimParsingException(string? message, Exception? inner)
: base(message, inner)
{
ClaimName = string.Empty;
}

/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception, if any.</param>
/// <param name="name">The name of the claim.</param>
/// <param name="type">The type that the claim should have been parsed to.</param>
public ClaimParsingException(string? message, Exception? inner, string name, Type? type)
: base(message, inner)
{
ClaimName = name;
ClaimType = type;
}

/// <summary>
/// Initializes a new instance of the <see cref="ClaimParsingException"/> class.
/// </summary>
/// <param name="info">Runtime serialization info.</param>
/// <param name="context">Streaming context for serialization.</param>
protected ClaimParsingException(
SerializationInfo info,
StreamingContext context)
: base(info, context)
{
ClaimName = info.GetString(nameof(ClaimName)) ?? string.Empty;

string? typeName = info.GetString(nameof(ClaimType));
if (!string.IsNullOrEmpty(typeName))
{
ClaimType = Type.GetType(typeName);
}
}

/// <summary>
/// Gets the name of the claim that could not be parsed.
/// </summary>
public string ClaimName { get; }

/// <summary>
/// Gets the type the claim should have been parsed to.
/// </summary>
public Type? ClaimType { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Runtime.Serialization;

namespace DroidSolutions.Oss.AuthClaimBinder.Exceptions;

/// <summary>
/// Special exception when a claim that is bound via model binder can not be found.
/// </summary>
[Serializable]
public class MissingClaimException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MissingClaimException"/> class.
/// </summary>
public MissingClaimException()
{
ClaimName = string.Empty;
}

/// <summary>
/// Initializes a new instance of the <see cref="MissingClaimException"/> class.
/// </summary>
/// <param name="name">The name of the missing claim.</param>
public MissingClaimException(string name)
: this($"The claim \"{name}\" was not found.", name)
{ }

/// <summary>
/// Initializes a new instance of the <see cref="MissingClaimException"/> class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="name">The name of the missing claim.</param>
public MissingClaimException(string message, string name)
: base(message)
{
ClaimName = name;
}

/// <summary>
/// Initializes a new instance of the <see cref="MissingClaimException"/> class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception, if any.</param>
public MissingClaimException(string? message, Exception? inner)
: base(message, inner)
{
ClaimName = string.Empty;
}

/// <summary>
/// Initializes a new instance of the <see cref="MissingClaimException"/> class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="inner">The inner exception, if any.</param>
/// <param name="name">The name of the missing claim.</param>
public MissingClaimException(string? message, Exception? inner, string name)
: base(message, inner)
{
ClaimName = name;
}

/// <summary>
/// Initializes a new instance of the <see cref="MissingClaimException"/> class.
/// </summary>
/// <param name="info">Runtime serialization info.</param>
/// <param name="context">Streaming context for serialization.</param>
protected MissingClaimException(
SerializationInfo info,
StreamingContext context)
: base(info, context)
{
ClaimName = info.GetString(nameof(ClaimName)) ?? string.Empty;
}

/// <summary>
/// Gets the name of the claim that was not found.
/// </summary>
public string ClaimName { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading.Tasks;

using DroidSolutions.Oss.AuthClaimBinder;
using DroidSolutions.Oss.AuthClaimBinder.Exceptions;
using DroidSolutions.Oss.AuthClaimBinder.Settings;
using DroidSolutions.Oss.AuthClaimBinderTest.Fixture;

Expand Down Expand Up @@ -43,7 +44,7 @@ public async Task BindModelAsync_ShouldThrowInvalidOperationException_WhenClaimI
bindingContext.Setup(bc => bc.HttpContext).Returns(httpContext.Object);

var claimModelBinder = new ClaimModelBinder(_logMock.Object, null);
await Assert.ThrowsAsync<InvalidOperationException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
await Assert.ThrowsAsync<MissingClaimException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
}

[Fact]
Expand All @@ -65,7 +66,7 @@ public async Task BindModelAsync_ShouldThrowInvalidOperationException_WhenClaimI
bindingContext.Setup(bc => bc.ModelType).Returns(typeof(Guid));

var claimModelBinder = new ClaimModelBinder(_logMock.Object, null);
await Assert.ThrowsAsync<InvalidOperationException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
await Assert.ThrowsAsync<ClaimParsingException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
}

[Fact]
Expand Down Expand Up @@ -169,7 +170,7 @@ public async Task BindModelAsync_ShouldNotFail_IfNoAliasIsFound()
AliasConfig = new Dictionary<string, List<string>> { { "user", new List<string> { "username", ClaimTypes.NameIdentifier } }, },
});

await Assert.ThrowsAsync<InvalidOperationException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
await Assert.ThrowsAsync<MissingClaimException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
}

[Fact]
Expand Down Expand Up @@ -217,6 +218,6 @@ public async Task BindingModelAsync_ShouldThrowInvalidOperationException_WhenEnu

var claimModelBinder = new ClaimModelBinder(_logMock.Object, null);

await Assert.ThrowsAsync<InvalidOperationException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
await Assert.ThrowsAsync<ClaimParsingException>(() => claimModelBinder.BindModelAsync(bindingContext.Object));
}
}

0 comments on commit ea8f09b

Please sign in to comment.