diff --git a/docs/schema/V1/schema.verified.graphql b/docs/schema/V1/schema.verified.graphql index 1eb3d865f..76293900a 100644 --- a/docs/schema/V1/schema.verified.graphql +++ b/docs/schema/V1/schema.verified.graphql @@ -211,8 +211,9 @@ type SeenLog { isCurrentEndUser: Boolean! } -type Subscriptions @authorize(policy: "enduser") { - dialogEvents(dialogId: UUID!): DialogEventPayload! +type Subscriptions { + "Requires a dialog token in the 'DigDir-Dialog-Token' header." + dialogEvents(dialogId: UUID!): DialogEventPayload! @authorize(policy: "enduserSubscription", apply: VALIDATION) } type Transmission { @@ -361,4 +362,4 @@ scalar DateTime @specifiedBy(url: "https:\/\/www.graphql-scalars.com\/date-time" scalar URL @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc3986") -scalar UUID @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc4122") \ No newline at end of file +scalar UUID @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc4122") diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/ICompactJwsGenerator.cs b/src/Digdir.Domain.Dialogporten.Application/Common/ICompactJwsGenerator.cs index a33798425..5e60e6317 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Common/ICompactJwsGenerator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Common/ICompactJwsGenerator.cs @@ -1,4 +1,5 @@ using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; @@ -22,12 +23,11 @@ public sealed class Ed25519Generator : ICompactJwsGenerator public Ed25519Generator(IOptions applicationSettings) { _applicationSettings = applicationSettings.Value; + InitSigningKey(); } public string GetCompactJws(Dictionary claims) { - InitSigningKey(); - var header = JsonSerializer.SerializeToUtf8Bytes(new { alg = "EdDSA", @@ -79,6 +79,7 @@ public bool VerifyCompactJws(string compactJws) var signature = Base64Url.Decode(parts[2]); return SignatureAlgorithm.Ed25519.Verify(_publicKey!, Encoding.UTF8.GetBytes(parts[0] + '.' + parts[1]), signature); + } private void InitSigningKey() diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs index 3af13c46e..3f4c47cac 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs @@ -1,5 +1,9 @@ -using Microsoft.AspNetCore.Authorization; +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.GraphQL.Common.Extensions.HotChocolate; +using HotChocolate.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; +using AuthorizationOptions = Microsoft.AspNetCore.Authorization.AuthorizationOptions; namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; @@ -9,7 +13,7 @@ internal sealed class AuthorizationOptionsSetup : IConfigureOptions options) { - _options = options.Value; + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); } public void Configure(AuthorizationOptions options) @@ -41,5 +45,23 @@ public void Configure(AuthorizationOptions options) options.AddPolicy(AuthorizationPolicy.Testing, builder => builder .Combine(options.DefaultPolicy) .RequireScope(AuthorizationScope.Testing)); + + options.AddPolicy(AuthorizationPolicy.EndUserSubscription, policy => policy + .Combine(options.GetPolicy(AuthorizationPolicy.EndUser)!) + .RequireAssertion(context => + { + if (context.Resource is not AuthorizationContext authContext) + { + return false; + } + + if (!authContext.Document.Definitions.TryGetSubscriptionDialogId(out var dialogId)) + { + return false; + } + + context.User.TryGetClaimValue("dialogId", out var dialogIdClaimValue); + return dialogId.ToString() == dialogIdClaimValue; + })); } } diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs index 9a227445a..1b0c35c75 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs @@ -5,6 +5,7 @@ namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; internal static class AuthorizationPolicy { public const string EndUser = "enduser"; + public const string EndUserSubscription = "enduserSubscription"; public const string ServiceProvider = "serviceprovider"; public const string ServiceProviderSearch = "serviceproviderSearch"; public const string Testing = "testing"; diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/DialogTokenMiddleware.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/DialogTokenMiddleware.cs new file mode 100644 index 000000000..ffd099ba6 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/DialogTokenMiddleware.cs @@ -0,0 +1,55 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Digdir.Domain.Dialogporten.Application.Common; +using Microsoft.IdentityModel.Tokens; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; + +public sealed class DialogTokenMiddleware +{ + public const string DialogTokenHeader = "DigDir-Dialog-Token"; + private readonly RequestDelegate _next; + private readonly ICompactJwsGenerator _compactJwsGenerator; + + public DialogTokenMiddleware(RequestDelegate next, ICompactJwsGenerator compactJwsGenerator) + { + _next = next; + _compactJwsGenerator = compactJwsGenerator; + } + + public Task InvokeAsync(HttpContext context) + { + if (!context.Request.Headers.TryGetValue(DialogTokenHeader, out var dialogToken)) + { + return _next(context); + } + + var token = dialogToken.FirstOrDefault(); + var tokenHandler = new JwtSecurityTokenHandler(); + try + { + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + // ValidateLifetime = false, + ValidateIssuerSigningKey = false, + SignatureValidator = (token, parameters) => + { + var jwt = new JwtSecurityToken(token); + return jwt; + }, + }, out var securityToken); + + var jwt = securityToken as JwtSecurityToken; + context.User.AddIdentity(new ClaimsIdentity(jwt!.Claims)); + + return _next(context); + } + catch (Exception e) + { + Console.WriteLine(e); + return _next(context); + } + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Extensions/HotChocolate/DefinitionNodeExtensions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Extensions/HotChocolate/DefinitionNodeExtensions.cs new file mode 100644 index 000000000..5128eda65 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Extensions/HotChocolate/DefinitionNodeExtensions.cs @@ -0,0 +1,48 @@ +using HotChocolate.Language; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Extensions.HotChocolate; + +public static class DefinitionNodeExtensions +{ + public static bool TryGetSubscriptionDialogId(this IReadOnlyList definitions, out Guid dialogId) + { + dialogId = Guid.Empty; + + foreach (var definition in definitions) + { + if (definition is not OperationDefinitionNode operationDefinition) + { + continue; + } + + if (operationDefinition.Operation != OperationType.Subscription) + { + continue; + } + + if (operationDefinition.SelectionSet.Selections[0] is not FieldNode fieldNode) + { + continue; + } + + var dialogIdArgument = fieldNode.Arguments.SingleOrDefault(x => x.Name.Value == "dialogId"); + + if (dialogIdArgument is null) + { + continue; + } + + if (dialogIdArgument.Value.Value is null) + { + continue; + } + + if (Guid.TryParse(dialogIdArgument.Value.Value.ToString(), out dialogId)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs index 9cd836902..fa15ac5cb 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs @@ -6,10 +6,11 @@ namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById; -[Authorize(Policy = AuthorizationPolicy.EndUser)] public sealed class Subscriptions { [Subscribe] + [Authorize(AuthorizationPolicy.EndUserSubscription, ApplyPolicy.Validation)] + [GraphQLDescription($"Requires a dialog token in the '{DialogTokenMiddleware.DialogTokenHeader}' header.")] [Topic($"{Constants.DialogEventsTopic}{{{nameof(dialogId)}}}")] public DialogEventPayload DialogEvents(Guid dialogId, [EventMessage] DialogEventPayload eventMessage) diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs index bc73a3cf4..e85ab7ac3 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs @@ -100,6 +100,7 @@ static void BuildAndRun(string[] args) app.UseJwtSchemeSelector() .UseAuthentication() .UseAuthorization() + .UseMiddleware() .UseSerilogRequestLogging() .UseAzureConfiguration(); diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs index 3340e3071..098207b51 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs @@ -12,8 +12,8 @@ public static IServiceCollection AddDialogportenGraphQl(this IServiceCollection return services .AddGraphQLServer() // This assumes that subscriptions have been set up by the infrastructure - .AddSubscriptionType() .AddAuthorization() + .AddSubscriptionType() .RegisterDbContext() .AddDiagnosticEventListener() .AddQueryType() diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json b/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json index 64bae4621..cef3437a3 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json +++ b/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json @@ -55,10 +55,11 @@ "LocalDevelopment": { "UseLocalDevelopmentUser": true, "UseLocalDevelopmentResourceRegister": true, - "UseLocalDevelopmentOrganizationRegister": true, + "UseLocalDevelopmentOrganizationRegister":true, "UseLocalDevelopmentNameRegister": true, "UseLocalDevelopmentAltinnAuthorization": true, "UseLocalDevelopmentCloudEventBus": true, + "UseLocalDevelopmentCompactJwsGenerator": true, "DisableShortCircuitOutboxDispatcher": true, "DisableCache": false, "DisableAuth": true