Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add GraphQL POC #636

Merged
merged 23 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Digdir.Domain.Dialogporten.sln
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Tool.Dialogporten.Ed
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Domain.Dialogporten.WebApi.Integration.Tests", "tests\Digdir.Domain.Dialogporten.WebApi.Integration.Tests\Digdir.Domain.Dialogporten.WebApi.Integration.Tests.csproj", "{42004236-D45C-4A1F-9FF9-CF12B7388389}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Domain.Dialogporten.GraphQL", "src\Digdir.Domain.Dialogporten.GraphQL\Digdir.Domain.Dialogporten.GraphQL.csproj", "{234FE24D-1047-4E29-A625-1EB406C37A2D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -123,6 +125,10 @@ Global
{42004236-D45C-4A1F-9FF9-CF12B7388389}.Debug|Any CPU.Build.0 = Debug|Any CPU
{42004236-D45C-4A1F-9FF9-CF12B7388389}.Release|Any CPU.ActiveCfg = Release|Any CPU
{42004236-D45C-4A1F-9FF9-CF12B7388389}.Release|Any CPU.Build.0 = Release|Any CPU
{234FE24D-1047-4E29-A625-1EB406C37A2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{234FE24D-1047-4E29-A625-1EB406C37A2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{234FE24D-1047-4E29-A625-1EB406C37A2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{234FE24D-1047-4E29-A625-1EB406C37A2D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -147,6 +153,7 @@ Global
{B6FE45A3-FB14-4528-9957-295AB2A00A46} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7}
{030909AA-5B61-46B4-9B74-0D2D779478FF} = {3C2C775D-F2D1-42A2-B53F-CC6D5FF59633}
{42004236-D45C-4A1F-9FF9-CF12B7388389} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7}
{234FE24D-1047-4E29-A625-1EB406C37A2D} = {320B47A0-5EB8-4B6E-8C84-90633A1849CA}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B2FE67FF-7622-4AFB-AD8E-961B6A39D888}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ public DialogStatus(Values id) : base(id) { }
public enum Values
{
/// <summary>
/// Dialogen er å regne som ny. Brukes typisk for enkle meldinger som ikke krever noe
/// interaksjon, eller som et initielt steg for dialoger. Dette er default.
/// Dialogen er å regne som ny. Brukes typisk for enkle meldinger som ikke krever noe
/// interaksjon, eller som et initielt steg for dialoger. Dette er default.
/// </summary>
New = 1,

/// <summary>
/// Under arbeid. Generell status som brukes for dialogtjenester der ytterligere bruker-input er
/// Under arbeid. Generell status som brukes for dialogtjenester der ytterligere bruker-input er
/// forventet.
/// </summary>
InProgress = 2,
Expand All @@ -27,8 +27,8 @@ public enum Values
Waiting = 3,

/// <summary>
/// Dialogen er i en tilstand hvor den venter på signering. Typisk siste steg etter at all
/// utfylling er gjennomført og validert.
/// Dialogen er i en tilstand hvor den venter på signering. Typisk siste steg etter at all
/// utfylling er gjennomført og validert.
/// </summary>
Signing = 4,

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using System.Diagnostics;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication;

internal static class AuthenticationBuilderExtensions
{
public static IServiceCollection AddDialogportenAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
var jwtTokenSchemas = configuration
.GetSection(GraphQlSettings.SectionName)
.Get<GraphQlSettings>()
?.Authentication
?.JwtBearerTokenSchemas;

if (jwtTokenSchemas is null || jwtTokenSchemas.Count == 0)
// Validation should have caught this.
throw new UnreachableException();

services.AddSingleton<ITokenIssuerCache, TokenIssuerCache>();

var authenticationBuilder = services.AddAuthentication();

foreach (var schema in jwtTokenSchemas)
{
authenticationBuilder.AddJwtBearer(schema.Name, options =>
{
options.MetadataAddress = schema.WellKnown;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = false,
ValidateAudience = false,
RequireExpirationTime = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(2)
};

options.Events = new JwtBearerEvents
{
OnMessageReceived = async context =>
{
var expectedIssuer = await context.HttpContext
.RequestServices
.GetRequiredService<ITokenIssuerCache>()
.GetIssuerForScheme(schema.Name);

if (context.HttpContext.Items.TryGetValue(Constants.CurrentTokenIssuer, out var tokenIssuer)
&& (string?)tokenIssuer != expectedIssuer)
{
context.NoResult();
}
}
};
});
}

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication;

public sealed class AuthenticationOptions
{
public required List<JwtBearerTokenSchemasOptions> JwtBearerTokenSchemas { get; init; }
}

public sealed class JwtBearerTokenSchemasOptions
{
public required string Name { get; init; }
public required string WellKnown { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using FluentValidation;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication;

internal sealed class AuthenticationOptionsValidator : AbstractValidator<AuthenticationOptions>
{
public AuthenticationOptionsValidator(
IValidator<JwtBearerTokenSchemasOptions> jwtTokenSchemaValidator)
{
RuleFor(x => x.JwtBearerTokenSchemas)
.NotEmpty()
.WithMessage("At least one JwtBearerTokenSchema must be configured");
RuleForEach(x => x.JwtBearerTokenSchemas)
.SetValidator(jwtTokenSchemaValidator);
}
}

internal sealed class JwtBearerTokenSchemasOptionsValidator : AbstractValidator<JwtBearerTokenSchemasOptions>
{
public JwtBearerTokenSchemasOptionsValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.WellKnown).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.IdentityModel.Tokens.Jwt;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication;

public class JwtSchemeSelectorMiddleware
{
private readonly RequestDelegate _next;

public JwtSchemeSelectorMiddleware(RequestDelegate next)
{
_next = next;
}

public Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(Constants.Authorization, out var authorizationHeader))
return _next(context);

var token = authorizationHeader.ToString()
.Split(' ')
.LastOrDefault();

if (string.IsNullOrEmpty(token))
return _next(context);

var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(token))
return _next(context);

var jwtToken = handler.ReadJwtToken(token);
context.Items[Constants.CurrentTokenIssuer] = jwtToken.Issuer;
return _next(context);
}
}

public static class JwtSchemeSelectorMiddlewareExtensions
{
public static IApplicationBuilder UseJwtSchemeSelector(this IApplicationBuilder app)
=> app.UseMiddleware<JwtSchemeSelectorMiddleware>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Security.Claims;
using Digdir.Domain.Dialogporten.Application.Externals.Presentation;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication;

internal sealed class LocalDevelopmentUser : IUser
{
private readonly ClaimsPrincipal _principal = new(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, "Local Development User"),
new Claim(ClaimTypes.NameIdentifier, "local-development-user"),
new Claim("pid", "03886595947"),
new Claim("scope", string.Join(" ", AuthorizationScope.AllScopes.Value)),
new Claim("consumer",
"""
{
"authority": "iso6523-actorid-upis",
"ID": "0192:991825827"
}
""")
}));

public ClaimsPrincipal GetPrincipal() => _principal;
}


internal static class AuthorizationScope
{
public const string EndUser = "digdir:dialogporten";
public const string ServiceProvider = "digdir:dialogporten.serviceprovider";
public const string ServiceProviderSearch = "digdir:dialogporten.serviceprovider.search";
public const string Testing = "digdir:dialogporten.developer.test";

internal static readonly Lazy<IReadOnlyCollection<string>> AllScopes = new(GetAll);

private static ReadOnlyCollection<string> GetAll() =>
typeof(AuthorizationScope)
.GetFields()
.Where(x => x.IsLiteral && !x.IsInitOnly && x.DeclaringType == typeof(string))
.Select(x => (string)x.GetRawConstantValue()!)
.ToList()
.AsReadOnly();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Protocols;
using Microsoft.Extensions.Options;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication;

public interface ITokenIssuerCache
{
public Task<string?> GetIssuerForScheme(string schemeName);
}

public sealed class TokenIssuerCache : ITokenIssuerCache, IDisposable
{
private readonly Dictionary<string, string> _issuerMappings = new();
private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
private bool _initialized;
private readonly IReadOnlyCollection<JwtBearerTokenSchemasOptions> _jwtTokenSchemas;

public TokenIssuerCache(IOptions<GraphQlSettings> apiSettings)
{
_jwtTokenSchemas = apiSettings
.Value
.Authentication
.JwtBearerTokenSchemas
?? throw new ArgumentException("JwtBearerTokenSchemas is required.");
}

public async Task<string?> GetIssuerForScheme(string schemeName)
{
await EnsureInitializedAsync();

return _issuerMappings.TryGetValue(schemeName, out var issuer)
? issuer : null;
}

private async Task EnsureInitializedAsync()
{
if (_initialized) return;
await _initializationSemaphore.WaitAsync();
if (_initialized) return;

try
{
foreach (var schema in _jwtTokenSchemas)
{
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
schema.WellKnown, new OpenIdConnectConfigurationRetriever());
var config = await configManager.GetConfigurationAsync();
_issuerMappings[schema.Name] = config.Issuer;
}

_initialized = true;
}
finally
{
_initializationSemaphore.Release();
}
}

public void Dispose()
{
_initializationSemaphore.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization;

/// <summary>
/// This authorisation handler will bypass all requirements
/// </summary>
public class AllowAnonymousHandler : IAuthorizationHandler
{
public Task HandleAsync(AuthorizationHandlerContext context)
{
foreach (var requirement in context.PendingRequirements)
{
//Simply pass all requirements
context.Succeed(requirement);
}

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization;

internal sealed class AuthorizationOptionsSetup : IConfigureOptions<AuthorizationOptions>
{
private readonly GraphQlSettings _options;

public AuthorizationOptionsSetup(IOptions<GraphQlSettings> options)
{
_options = options.Value;
}

public void Configure(AuthorizationOptions options)
{
var authenticationSchemas = _options
.Authentication
.JwtBearerTokenSchemas
.Select(x => x.Name)
.ToArray();

options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(authenticationSchemas)
.RequireValidConsumerClaim()
.Build();

options.AddPolicy(AuthorizationPolicy.EndUser, builder => builder
.Combine(options.DefaultPolicy)
.RequireScope(AuthorizationScope.EndUser));

options.AddPolicy(AuthorizationPolicy.ServiceProvider, builder => builder
.Combine(options.DefaultPolicy)
.RequireScope(AuthorizationScope.ServiceProvider));

options.AddPolicy(AuthorizationPolicy.ServiceProviderSearch, builder => builder
.Combine(options.DefaultPolicy)
.RequireScope(AuthorizationScope.ServiceProviderSearch));

options.AddPolicy(AuthorizationPolicy.Testing, builder => builder
.Combine(options.DefaultPolicy)
.RequireScope(AuthorizationScope.Testing));
}
}
Loading