Skip to content

Commit

Permalink
feat(GraphQL): Add DialogToken requirement for subscriptions (#1124)
Browse files Browse the repository at this point in the history
<!--- Provide a general summary of your changes in the Title above -->

## Description

DialogEvents subscription now requires a valid DialogToken

<!--- Describe your changes in detail -->

## Related Issue(s)

- #1104 

## Verification

- [x] **Your** code builds clean without any errors or warnings
- [ ] Manual testing done (required)
- [ ] Relevant automated test added (if you find this hard, leave it and
we'll help out)

## Documentation

- [ ] Documentation is updated (either in `docs`-directory, Altinnpedia
or a separate linked PR in
[altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if
applicable)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Enhanced security for `dialogEvents` subscription with new
authorization requirements.
	- Introduced `DialogTokenMiddleware` for handling JWT in requests.
	- Added methods to extract dialog ID from subscription operations.
	- New constant for dialog token issuer version introduced.

- **Bug Fixes**
- Improved authorization policies with added null checks and
validations.

- **Documentation**
- Updated configuration settings for local development to enable
authentication and adjust JWT generation settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Magnus Sandgren <5285192+MagnusSandgren@users.noreply.github.com>
Co-authored-by: Knut Haug <knut.espen.haug@digdir.no>
  • Loading branch information
3 people authored Sep 30, 2024
1 parent d5729de commit 651ca62
Show file tree
Hide file tree
Showing 11 changed files with 164 additions and 11 deletions.
7 changes: 4 additions & 3 deletions docs/schema/V1/schema.verified.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
scalar UUID @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc4122")
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,11 @@ public sealed class Ed25519Generator : ICompactJwsGenerator
public Ed25519Generator(IOptions<ApplicationSettings> applicationSettings)
{
_applicationSettings = applicationSettings.Value;
InitSigningKey();
}

public string GetCompactJws(Dictionary<string, object?> claims)
{
InitSigningKey();

var header = JsonSerializer.SerializeToUtf8Bytes(new
{
alg = "EdDSA",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Digdir.Domain.Dialogporten.Application.Features.V1.Common.Authorization;

public static class Constants
{
public const string DialogTokenIssuerVersion = "/api/v1";
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using OneOf;
using static Digdir.Domain.Dialogporten.Application.Features.V1.Common.Authorization.Constants;

namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Get;

Expand Down Expand Up @@ -134,7 +135,7 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo
dialogDto.DialogToken = _dialogTokenGenerator.GetDialogToken(
dialog,
authorizationResult,
"/api/v1"
DialogTokenIssuerVersion
);

DecorateWithAuthorization(dialogDto, authorizationResult);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using MediatR;
using Digdir.Domain.Dialogporten.Application.Common.Authorization;
using MediatR;
using Microsoft.Extensions.Options;
using Constants = Digdir.Domain.Dialogporten.Application.Features.V1.Common.Authorization.Constants;

namespace Digdir.Domain.Dialogporten.Application.Features.V1.WellKnown.OauthAuthorizationServer.Queries.Get;

Expand All @@ -17,7 +19,7 @@ public GetOauthAuthorizationServerQueryHandler(

public async Task<GetOauthAuthorizationServerDto> Handle(GetOauthAuthorizationServerQuery request, CancellationToken cancellationToken)
{
var issuerUrl = _applicationSettings.Dialogporten.BaseUri.AbsoluteUri.TrimEnd('/') + "/api/v1";
var issuerUrl = _applicationSettings.Dialogporten.BaseUri.AbsoluteUri.TrimEnd('/') + Constants.DialogTokenIssuerVersion;
return await Task.FromResult(new GetOauthAuthorizationServerDto
{
Issuer = issuerUrl,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Digdir.Domain.Dialogporten.Application.Common;
using Digdir.Domain.Dialogporten.Application.Common.Extensions;
using Digdir.Domain.Dialogporten.GraphQL.Common.Extensions.HotChocolate;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using AuthorizationOptions = Microsoft.AspNetCore.Authorization.AuthorizationOptions;

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

Expand All @@ -9,7 +13,7 @@ internal sealed class AuthorizationOptionsSetup : IConfigureOptions<Authorizatio

public AuthorizationOptionsSetup(IOptions<GraphQlSettings> options)
{
_options = options.Value;
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}

public void Configure(AuthorizationOptions options)
Expand Down Expand Up @@ -41,5 +45,13 @@ 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 =>
context.TryGetDialogEventsSubscriptionDialogId(out var dialogIdTopic)
&& context.User.TryGetClaimValue(DialogTokenClaimTypes.DialogId, out var dialogIdClaimValue)
&& Guid.TryParse(dialogIdClaimValue, out var dialogIdClaim)
&& dialogIdTopic == dialogIdClaim));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Digdir.Domain.Dialogporten.Application;
using Digdir.Domain.Dialogporten.Application.Common;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NSec.Cryptography;
using static Digdir.Domain.Dialogporten.Application.Features.V1.Common.Authorization.Constants;

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

public sealed class DialogTokenMiddleware
{
public const string DialogTokenHeader = "DigDir-Dialog-Token";
private readonly RequestDelegate _next;
private readonly PublicKey _publicKey;
private readonly string _issuer;

public DialogTokenMiddleware(RequestDelegate next, IOptions<ApplicationSettings> applicationSettings)
{
_next = next;

var keyPair = applicationSettings.Value.Dialogporten.Ed25519KeyPairs.Primary;
_publicKey = PublicKey.Import(SignatureAlgorithm.Ed25519,
Base64Url.Decode(keyPair.PublicComponent), KeyBlobFormat.RawPublicKey);
_issuer = applicationSettings.Value.Dialogporten.BaseUri.AbsoluteUri.TrimEnd('/') + DialogTokenIssuerVersion;
}

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
{
ValidateAudience = false,
ValidIssuer = _issuer,
SignatureValidator = ValidateSignature
}, out var securityToken);

if (securityToken is not JwtSecurityToken jwt)
{
return _next(context);
}

var dialogIdClaim = jwt.Claims.FirstOrDefault(x => x.Type == DialogTokenClaimTypes.DialogId);
if (dialogIdClaim is null)
{
return _next(context);
}

context.User.AddIdentity(new ClaimsIdentity([dialogIdClaim]));

return _next(context);
}
catch (Exception)
{
return _next(context);
}
}

private JwtSecurityToken ValidateSignature(string encodedToken, object _)
{
var jwt = new JwtSecurityToken(encodedToken);

var signature = Base64Url.Decode(jwt.RawSignature);
var signatureIsValid = SignatureAlgorithm.Ed25519
.Verify(_publicKey, Encoding.UTF8.GetBytes(jwt.EncodedHeader + '.' + jwt.EncodedPayload), signature);

if (signatureIsValid)
{
return jwt;
}

throw new SecurityTokenInvalidSignatureException("Invalid token signature.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById;
using HotChocolate.Authorization;
using HotChocolate.Language;
using Microsoft.AspNetCore.Authorization;

namespace Digdir.Domain.Dialogporten.GraphQL.Common.Extensions.HotChocolate;

public static class AuthorizationHandlerContextExtensions
{
private const string DialogIdArgumentName = "dialogId";
private const string DialogEventsFieldName = nameof(Subscriptions.DialogEvents);

/// <summary>
/// Attempts to extract the dialog ID from a DialogEvents subscription operation.
/// </summary>
/// <param name="context">The authorization handler context</param>
/// <param name="dialogId">When this method returns, contains the extracted dialog ID if found; otherwise, Guid.Empty.</param>
/// <returns>True if the dialog ID was successfully extracted; otherwise, false.</returns>
public static bool TryGetDialogEventsSubscriptionDialogId(this AuthorizationHandlerContext context, out Guid dialogId)
{
dialogId = Guid.Empty;

if (context.Resource is not AuthorizationContext authContext) return false;

if (authContext.Document.Definitions.Count == 0) return false;

var definition = authContext.Document.Definitions[0];

if (definition is not OperationDefinitionNode operationDefinition) return false;

if (operationDefinition.Operation != OperationType.Subscription) return false;

var dialogEventsSelection = operationDefinition.SelectionSet.Selections.FirstOrDefault(x =>
x is FieldNode fieldNode && fieldNode.Name.Value
.Equals(DialogEventsFieldName, StringComparison.OrdinalIgnoreCase));

if (dialogEventsSelection is not FieldNode fieldNode) return false;

var dialogIdArgument = fieldNode.Arguments.FirstOrDefault(x => x.Name.Value.Equals(DialogIdArgumentName, StringComparison.OrdinalIgnoreCase));

if (dialogIdArgument?.Value.Value is null) return false;

return Guid.TryParse(dialogIdArgument.Value.Value.ToString(), out dialogId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/Digdir.Domain.Dialogporten.GraphQL/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ static void BuildAndRun(string[] args)
app.UseJwtSchemeSelector()
.UseAuthentication()
.UseAuthorization()
.UseMiddleware<DialogTokenMiddleware>()
.UseSerilogRequestLogging()
.UseAzureConfiguration();

Expand Down

0 comments on commit 651ca62

Please sign in to comment.