Skip to content

Commit

Permalink
Add bearer authentication concern (#506)
Browse files Browse the repository at this point in the history
* First draft for bearer auth

* Additional coverage and documentation

* Fix test
  • Loading branch information
Kaliumhexacyanoferrat authored Jul 1, 2024
1 parent 938e559 commit 0c9a8f9
Show file tree
Hide file tree
Showing 6 changed files with 439 additions and 0 deletions.
167 changes: 167 additions & 0 deletions Modules/Authentication/Bearer/BearerAuthenticationConcern.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

using GenHTTP.Api.Content;
using GenHTTP.Api.Protocol;

using Microsoft.IdentityModel.Logging;
using Microsoft.IdentityModel.Tokens;

namespace GenHTTP.Modules.Authentication.Bearer
{

#region Supporting data structures

internal class OpenIDConfiguration
{

[JsonPropertyName("jwks_uri")]
public string? KeySetUrl { get; set; }

}

#endregion

internal sealed class BearerAuthenticationConcern : IConcern
{
private ICollection<SecurityKey>? _IssuerKeys = null;

#region Get-/Setters

public IHandler Content { get; }

public IHandler Parent { get; }

private TokenValidationOptions ValidationOptions { get; }

#endregion

#region Initialization

internal BearerAuthenticationConcern(IHandler parent, Func<IHandler, IHandler> contentFactory, TokenValidationOptions validationOptions)
{
Parent = parent;
Content = contentFactory(this);

ValidationOptions = validationOptions;
}

#endregion

#region Functionality

public ValueTask PrepareAsync() => Content.PrepareAsync();

public IAsyncEnumerable<ContentElement> GetContentAsync(IRequest request) => Content.GetContentAsync(request);

public async ValueTask<IResponse?> HandleAsync(IRequest request)
{
IdentityModelEventSource.LogCompleteSecurityArtifact = true;

if (!request.Headers.TryGetValue("Authorization", out var authHeader))
{
throw new ProviderException(ResponseStatus.Unauthorized, "This endpoint requires authorization");
}

if (!authHeader.StartsWith("Bearer "))
{
throw new ProviderException(ResponseStatus.Unauthorized, "This endpoint requires bearer token authentication");
}

var tokenString = authHeader[7..];

var tokenHandler = new JwtSecurityTokenHandler();

if (!tokenHandler.CanReadToken(tokenString))
{
throw new ProviderException(ResponseStatus.BadRequest, "Malformed authentication token");
}

var issuer = ValidationOptions.Issuer;

var audience = ValidationOptions.Audience;

if ((issuer != null) && (_IssuerKeys == null))
{
_IssuerKeys = await FetchSigningKeys(issuer);
}

var validationParameters = new TokenValidationParameters()
{
ValidIssuer = issuer,
ValidateIssuer = (issuer != null),
IssuerSigningKeys = _IssuerKeys,
ValidAudience = audience,
ValidateAudience = (audience != null),
ValidateLifetime = ValidationOptions.Lifetime
};

if (issuer == null)
{
validationParameters.SignatureValidator = (string token, TokenValidationParameters p) => new JwtSecurityToken(tokenString);
}

try
{
tokenHandler.ValidateToken(tokenString, validationParameters, out var validated);

var jwt = (JwtSecurityToken)validated;

if (ValidationOptions.CustomValidator != null)
{
await ValidationOptions.CustomValidator.Invoke(jwt);
}

if (ValidationOptions.UserMapping != null)
{
var user = await ValidationOptions.UserMapping.Invoke(request, jwt);

if (user != null)
{
request.SetUser(user);
}
}

return await Content.HandleAsync(request);
}
catch (SecurityTokenValidationException ex)
{
throw new ProviderException(ResponseStatus.Unauthorized, $"Authorization failed: {ex.Message}", ex);
}
}

private static async Task<ICollection<SecurityKey>> FetchSigningKeys(string issuer)
{
try
{
var configUrl = $"{issuer}/.well-known/openid-configuration";

using var httpClient = new HttpClient();

var configResponse = await httpClient.GetStringAsync(configUrl);

var config = JsonSerializer.Deserialize<OpenIDConfiguration>(configResponse)
?? throw new InvalidOperationException($"Unable to discover configuration via '{configUrl}'");

var keyResponse = await httpClient.GetStringAsync(config.KeySetUrl);

var keySet = new JsonWebKeySet(keyResponse);

return keySet.GetSigningKeys();
}
catch (Exception ex)
{
throw new ProviderException(ResponseStatus.InternalServerError, "Unable to fetch signing issuer signing keys", ex);
}
}

}

#endregion

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;

using GenHTTP.Api.Content;
using GenHTTP.Api.Content.Authentication;
using GenHTTP.Api.Protocol;

namespace GenHTTP.Modules.Authentication.Bearer
{

public sealed class BearerAuthenticationConcernBuilder : IConcernBuilder
{
private readonly TokenValidationOptions _Options = new();

#region Functionality

/// <summary>
/// Sets the expected issuer. Tokens that are not issued by this
/// party will be declined.
/// </summary>
/// <param name="issuer">The URL of the exepcted issuer</param>
/// <remarks>
/// Setting the issuer will cause the concern to download and cache
/// the signing keys that are used to ensure that the party actually
/// issued a token.
/// </remarks>
public BearerAuthenticationConcernBuilder Issuer(string issuer)
{
_Options.Issuer = issuer;
return this;
}

/// <summary>
/// Sets the expected audience that should be accepted.
/// </summary>
/// <param name="audience">The audience to check for</param>
public BearerAuthenticationConcernBuilder Audience(string audience)
{
_Options.Audience = audience;
return this;
}

/// <summary>
/// Adds a custom validator that can analyze the token read from the
/// request and can perform additional checks.
/// </summary>
/// <param name="validator">The custom validator to be used</param>
/// <remarks>
/// This validator will be invoked after the regular checks (such as the
/// issuer) have been performed.
///
/// If you would like to deny user access within a custom validator,
/// you can throw a <see cref="ProviderException" />.
/// </remarks>
public BearerAuthenticationConcernBuilder Validation(Func<JwtSecurityToken, Task> validator)
{
_Options.CustomValidator = validator;
return this;
}

/// <summary>
/// Optionally register a function that will compute the user that
/// should be set for the request.
/// </summary>
/// <param name="userMapping">The user mapping to be used</param>
/// <remarks>
/// The usage of this mechanism allows to inject the user into
/// service methods via the user injector class. Returning null
/// within the delegate will not deny user access - if you would
/// like to prevent such user, you can throw a <see cref="ProviderException" />.
/// </remarks>
public BearerAuthenticationConcernBuilder UserMapping(Func<IRequest, JwtSecurityToken, ValueTask<IUser?>> userMapping)
{
_Options.UserMapping = userMapping;
return this;
}

/// <summary>
/// If enabled, tokens that have expired or are not valid yet are
/// still accepted. This should be used for testing purposes only.
/// </summary>
public BearerAuthenticationConcernBuilder AllowExpired()
{
_Options.Lifetime = false;
return this;
}

public IConcern Build(IHandler parent, Func<IHandler, IHandler> contentFactory)
{
return new BearerAuthenticationConcern(parent, contentFactory, _Options);
}

#endregion

}

}
26 changes: 26 additions & 0 deletions Modules/Authentication/Bearer/TokenValidationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;

using GenHTTP.Api.Content.Authentication;
using GenHTTP.Api.Protocol;

namespace GenHTTP.Modules.Authentication.Bearer
{

internal sealed class TokenValidationOptions
{

internal string? Audience { get; set; }

internal string? Issuer { get; set; }

internal bool Lifetime { get; set; } = true;

internal Func<JwtSecurityToken, Task>? CustomValidator { get; set; }

internal Func<IRequest, JwtSecurityToken, ValueTask<IUser?>>? UserMapping { get; set; }

}

}
19 changes: 19 additions & 0 deletions Modules/Authentication/BearerAuthentication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using GenHTTP.Modules.Authentication.Bearer;

namespace GenHTTP.Modules.Authentication
{

public static class BearerAuthentication
{

/// <summary>
/// Creates a concern that will read an access token from
/// the authorization headers and validate it according to
/// its configuration.
/// </summary>
/// <returns>The newly created concern</returns>
public static BearerAuthenticationConcernBuilder Create() => new();

}

}
4 changes: 4 additions & 0 deletions Modules/Authentication/GenHTTP.Modules.Authentication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@
<ItemGroup>

<ProjectReference Include="..\..\API\GenHTTP.Api.csproj" />

<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.6.2" />

<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />

<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />

</ItemGroup>

Expand Down
Loading

0 comments on commit 0c9a8f9

Please sign in to comment.