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(GraphQL): Add DialogToken requirement for subscriptions #1124

Merged
merged 40 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
cef705a
--wip-- [skip-ci]
knuhau Sep 16, 2024
a451a39
--wip-- [skip-ci]
knuhau Sep 17, 2024
b9e64b9
delet
oskogstad Sep 18, 2024
5e4fcf2
msg
oskogstad Sep 18, 2024
ba6d9f4
aps
oskogstad Sep 18, 2024
7eb139b
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 18, 2024
53bdb64
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 18, 2024
346509e
--wip-- [skip ci]
oskogstad Sep 20, 2024
05c92ee
mrg
oskogstad Sep 20, 2024
4d9cff3
foo
oskogstad Sep 20, 2024
878ce53
sup
oskogstad Sep 21, 2024
e9b2be9
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 21, 2024
67cc040
--wip-- [skip ci]
oskogstad Sep 23, 2024
9a3c24a
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 23, 2024
1f39ff0
cln
oskogstad Sep 23, 2024
78c5947
cln
oskogstad Sep 23, 2024
4522611
cln
oskogstad Sep 23, 2024
a619b75
cln
oskogstad Sep 23, 2024
8baae4e
cln
oskogstad Sep 23, 2024
16bd197
space
knuhau Sep 23, 2024
03b7f7e
rabbito
oskogstad Sep 23, 2024
2a918c4
check definition count
oskogstad Sep 23, 2024
5d88888
"cln"
oskogstad Sep 23, 2024
4059ccf
add jwt null check
oskogstad Sep 24, 2024
910fa8e
Throw if EndUser policy is not found
oskogstad Sep 24, 2024
94584d8
extract vars
oskogstad Sep 24, 2024
21824a3
Create dialog token issuer version constant
oskogstad Sep 24, 2024
03dc1f4
Add XML comment
oskogstad Sep 24, 2024
26138ce
Split ternary
oskogstad Sep 24, 2024
b45d4b3
Use constant
oskogstad Sep 24, 2024
55e3d14
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 24, 2024
b958194
Move issuer version constant to V1
oskogstad Sep 24, 2024
f37c052
let it NRE
oskogstad Sep 25, 2024
3acbf67
add dialogId claim only
oskogstad Sep 25, 2024
01eed88
simplify auth assertion, move stuff to extension on auth context
oskogstad Sep 25, 2024
3bb0bd7
extract method
oskogstad Sep 25, 2024
e215ecf
woops
oskogstad Sep 25, 2024
3999e14
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 25, 2024
d8ea3bf
Merge branch 'main' into chore/add-dialog-token-requirement-on-subscr…
oskogstad Sep 25, 2024
0b1faa3
check fer nul
oskogstad Sep 26, 2024
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: 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)
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}

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";
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
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
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
{
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));
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}
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;
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

public Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue(DialogTokenHeader, out var dialogToken))
{
return _next(context);
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

var token = dialogToken.FirstOrDefault();
var tokenHandler = new JwtSecurityTokenHandler();
try
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
{
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidateAudience = false,
ValidIssuer = _issuer,
SignatureValidator = ValidateSignature
}, out var securityToken);

if (securityToken is not JwtSecurityToken jwt)
{
return _next(context);
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

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

context.User.AddIdentity(new ClaimsIdentity([dialogIdClaim]));
oskogstad marked this conversation as resolved.
Show resolved Hide resolved

return _next(context);
}
catch (Exception)
{
return _next(context);
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}

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)
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
{
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);
}
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
}
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.")]
oskogstad marked this conversation as resolved.
Show resolved Hide resolved
[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
Loading