Skip to content

Commit 26dd21a

Browse files
author
gha-zund
authored
Customizable authorization error messages (#711)
1 parent 09e9278 commit 26dd21a

File tree

9 files changed

+203
-133
lines changed

9 files changed

+203
-133
lines changed

samples/Samples.Server/CustomErrorInfoProvider.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ namespace GraphQL.Samples.Server
1313
/// </summary>
1414
public class CustomErrorInfoProvider : DefaultErrorInfoProvider
1515
{
16-
public CustomErrorInfoProvider(IOptions<ErrorInfoProviderOptions> options) : base(options)
17-
{ }
16+
private readonly IAuthorizationErrorMessageBuilder _messageBuilder;
17+
18+
public CustomErrorInfoProvider(IOptions<ErrorInfoProviderOptions> options, IAuthorizationErrorMessageBuilder messageBuilder)
19+
: base(options)
20+
{
21+
_messageBuilder = messageBuilder;
22+
}
1823

1924
public override ErrorInfo GetInfo(ExecutionError executionError)
2025
{
@@ -30,7 +35,7 @@ public override ErrorInfo GetInfo(ExecutionError executionError)
3035
private string GetAuthorizationErrorMessage(AuthorizationError error)
3136
{
3237
var errorMessage = new StringBuilder();
33-
AuthorizationError.AppendFailureHeader(errorMessage, error.OperationType);
38+
_messageBuilder.AppendFailureHeader(errorMessage, error.OperationType);
3439

3540
foreach (var failedRequirement in error.AuthorizationResult.Failure.FailedRequirements)
3641
{
@@ -43,7 +48,7 @@ private string GetAuthorizationErrorMessage(AuthorizationError error)
4348
errorMessage.Append(" years old.");
4449
break;
4550
default:
46-
AuthorizationError.AppendFailureLine(errorMessage, failedRequirement);
51+
_messageBuilder.AppendFailureLine(errorMessage, failedRequirement);
4752
break;
4853
}
4954
}

samples/Samples.Server/Startup.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Threading.Tasks;
55
using GraphQL.Samples.Schemas.Chat;
66
using GraphQL.Server;
7+
using GraphQL.Server.Authorization.AspNetCore;
78
using GraphQL.Server.Ui.Altair;
89
using GraphQL.Server.Ui.GraphiQL;
910
using GraphQL.Server.Ui.Playground;
@@ -33,6 +34,7 @@ public Startup(IConfiguration configuration, IWebHostEnvironment environment)
3334
public void ConfigureServices(IServiceCollection services)
3435
{
3536
services.AddSingleton<IChat, Chat>();
37+
services.AddTransient<IAuthorizationErrorMessageBuilder, DefaultAuthorizationErrorMessageBuilder>(); // required by CustomErrorInfoProvider
3638

3739
MicrosoftDI.GraphQLBuilderExtensions.AddGraphQL(services)
3840
.AddServer(true)
Lines changed: 5 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
#nullable enable
2+
13
using System;
2-
using System.Linq;
3-
using System.Text;
44
using GraphQL.Language.AST;
55
using GraphQL.Validation;
66
using Microsoft.AspNetCore.Authorization;
7-
using Microsoft.AspNetCore.Authorization.Infrastructure;
87

98
namespace GraphQL.Server.Authorization.AspNetCore
109
{
@@ -13,23 +12,15 @@ namespace GraphQL.Server.Authorization.AspNetCore
1312
/// </summary>
1413
public class AuthorizationError : ValidationError
1514
{
16-
/// <summary>
17-
/// Initializes a new instance of the <see cref="AuthorizationError"/> class for a specified authorization result.
18-
/// </summary>
19-
public AuthorizationError(INode node, ValidationContext context, OperationType? operationType, AuthorizationResult result)
20-
: this(node, context, GenerateMessage(operationType, result), result)
21-
{
22-
OperationType = operationType;
23-
}
24-
2515
/// <summary>
2616
/// Initializes a new instance of the <see cref="AuthorizationError"/> class for a specified authorization result with a specific error message.
2717
/// </summary>
28-
public AuthorizationError(INode node, ValidationContext context, string message, AuthorizationResult result)
29-
: base(context.Document.OriginalQuery, "6.1.1", message, node == null ? Array.Empty<INode>() : new INode[] { node })
18+
public AuthorizationError(INode? node, ValidationContext context, string message, AuthorizationResult result, OperationType? operationType = null)
19+
: base(context.Document.OriginalQuery!, "6.1.1", message, node == null ? Array.Empty<INode>() : new INode[] { node })
3020
{
3121
Code = "authorization";
3222
AuthorizationResult = result;
23+
OperationType = operationType;
3324
}
3425

3526
/// <summary>
@@ -42,105 +33,6 @@ public AuthorizationError(INode node, ValidationContext context, string message,
4233
/// </summary>
4334
public OperationType? OperationType { get; }
4435

45-
private static string GenerateMessage(OperationType? operationType, AuthorizationResult result)
46-
{
47-
var error = new StringBuilder();
48-
AppendFailureHeader(error, operationType);
49-
50-
foreach (var failure in result.Failure.FailedRequirements)
51-
{
52-
AppendFailureLine(error, failure);
53-
}
54-
55-
return error.ToString();
56-
}
57-
58-
private static string GetOperationType(OperationType? operationType)
59-
{
60-
return operationType switch
61-
{
62-
Language.AST.OperationType.Query => "query",
63-
Language.AST.OperationType.Mutation => "mutation",
64-
Language.AST.OperationType.Subscription => "subscription",
65-
_ => "operation",
66-
};
67-
}
68-
69-
/// <summary>
70-
/// Appends the error message header for this <see cref="AuthorizationError"/> to the provided <see cref="StringBuilder"/>.
71-
/// </summary>
72-
/// <param name="error">The error message <see cref="StringBuilder"/>.</param>
73-
/// <param name="operationType">The GraphQL operation type.</param>
74-
public static void AppendFailureHeader(StringBuilder error, OperationType? operationType)
75-
{
76-
error.Append("You are not authorized to run this ")
77-
.Append(GetOperationType(operationType))
78-
.Append('.');
79-
}
8036

81-
/// <summary>
82-
/// Appends a description of the failed <paramref name="authorizationRequirement"/> to the supplied <see cref="StringBuilder"/>.
83-
/// </summary>
84-
/// <param name="error">The <see cref="StringBuilder"/> which is used to compose the error message.</param>
85-
/// <param name="authorizationRequirement">The failed <see cref="IAuthorizationRequirement"/>.</param>
86-
public static void AppendFailureLine(StringBuilder error, IAuthorizationRequirement authorizationRequirement)
87-
{
88-
error.AppendLine();
89-
90-
switch (authorizationRequirement)
91-
{
92-
case ClaimsAuthorizationRequirement claimsAuthorizationRequirement:
93-
error.Append("Required claim '");
94-
error.Append(claimsAuthorizationRequirement.ClaimType);
95-
if (claimsAuthorizationRequirement.AllowedValues == null || !claimsAuthorizationRequirement.AllowedValues.Any())
96-
{
97-
error.Append("' is not present.");
98-
}
99-
else
100-
{
101-
error.Append("' with any value of '");
102-
error.Append(string.Join(", ", claimsAuthorizationRequirement.AllowedValues));
103-
error.Append("' is not present.");
104-
}
105-
break;
106-
107-
case DenyAnonymousAuthorizationRequirement _:
108-
error.Append("The current user must be authenticated.");
109-
break;
110-
111-
case NameAuthorizationRequirement nameAuthorizationRequirement:
112-
error.Append("The current user name must match the name '");
113-
error.Append(nameAuthorizationRequirement.RequiredName);
114-
error.Append("'.");
115-
break;
116-
117-
case OperationAuthorizationRequirement operationAuthorizationRequirement:
118-
error.Append("Required operation '");
119-
error.Append(operationAuthorizationRequirement.Name);
120-
error.Append("' was not present.");
121-
break;
122-
123-
case RolesAuthorizationRequirement rolesAuthorizationRequirement:
124-
if (rolesAuthorizationRequirement.AllowedRoles == null || !rolesAuthorizationRequirement.AllowedRoles.Any())
125-
{
126-
// This should never happen.
127-
error.Append("Required roles are not present.");
128-
}
129-
else
130-
{
131-
error.Append("Required roles '");
132-
error.Append(string.Join(", ", rolesAuthorizationRequirement.AllowedRoles));
133-
error.Append("' are not present.");
134-
}
135-
break;
136-
137-
case AssertionRequirement _:
138-
default:
139-
error.Append("Requirement '");
140-
error.Append(authorizationRequirement.GetType().Name);
141-
error.Append("' was not satisfied.");
142-
break;
143-
}
144-
}
14537
}
14638
}

src/Authorization.AspNetCore/AuthorizationValidationRule.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#nullable enable
2+
13
using System.Collections.Generic;
24
using System.Linq;
35
using System.Security.Claims;
@@ -17,19 +19,25 @@ public class AuthorizationValidationRule : IValidationRule
1719
{
1820
private readonly IAuthorizationService _authorizationService;
1921
private readonly IClaimsPrincipalAccessor _claimsPrincipalAccessor;
22+
private readonly IAuthorizationErrorMessageBuilder _messageBuilder;
2023

2124
/// <summary>
2225
/// Creates an instance of <see cref="AuthorizationValidationRule"/>.
2326
/// </summary>
2427
/// <param name="authorizationService"> ASP.NET Core <see cref="IAuthorizationService"/> to authorize against. </param>
2528
/// <param name="claimsPrincipalAccessor"> The <see cref="IClaimsPrincipalAccessor"/> which provides the <see cref="ClaimsPrincipal"/> for authorization. </param>
26-
public AuthorizationValidationRule(IAuthorizationService authorizationService, IClaimsPrincipalAccessor claimsPrincipalAccessor)
29+
/// <param name="messageBuilder">The <see cref="IAuthorizationErrorMessageBuilder"/> which is used to generate the message for an <see cref="AuthorizationError"/>. </param>
30+
public AuthorizationValidationRule(
31+
IAuthorizationService authorizationService,
32+
IClaimsPrincipalAccessor claimsPrincipalAccessor,
33+
IAuthorizationErrorMessageBuilder messageBuilder)
2734
{
2835
_authorizationService = authorizationService;
2936
_claimsPrincipalAccessor = claimsPrincipalAccessor;
37+
_messageBuilder = messageBuilder;
3038
}
3139

32-
private bool ShouldBeSkipped(Operation actualOperation, ValidationContext context)
40+
private bool ShouldBeSkipped(Operation? actualOperation, ValidationContext context)
3341
{
3442
if (context.Document.Operations.Count <= 1)
3543
{
@@ -58,10 +66,10 @@ private bool ShouldBeSkipped(Operation actualOperation, ValidationContext contex
5866
} while (true);
5967
}
6068

61-
private bool FragmentBelongsToOperation(FragmentDefinition fragment, Operation operation)
69+
private bool FragmentBelongsToOperation(FragmentDefinition fragment, Operation? operation)
6270
{
6371
bool belongs = false;
64-
void Visit(INode node, int _)
72+
void Visit(INode? node, int _)
6573
{
6674
if (belongs)
6775
{
@@ -76,7 +84,7 @@ void Visit(INode node, int _)
7684
}
7785
}
7886

79-
operation.Visit(Visit, 0);
87+
operation?.Visit(Visit, 0);
8088

8189
return belongs;
8290
}
@@ -141,7 +149,7 @@ void Visit(INode node, int _)
141149
// validation rule should check that but here we should just ignore that
142150
// "unknown" field.
143151
if (context.Variables != null &&
144-
context.Variables.TryGetValue(variableRef.Name, out object input) &&
152+
context.Variables.TryGetValue(variableRef.Name, out object? input) &&
145153
input is Dictionary<string, object> fieldsValues)
146154
{
147155
foreach (var field in variableType.Fields)
@@ -156,7 +164,7 @@ void Visit(INode node, int _)
156164
);
157165
}
158166

159-
private async Task AuthorizeAsync(INode node, IProvideMetadata provider, ValidationContext context, OperationType? operationType)
167+
private async Task AuthorizeAsync(INode? node, IProvideMetadata? provider, ValidationContext context, OperationType? operationType)
160168
{
161169
var policyNames = provider?.GetPolicies();
162170

@@ -190,9 +198,10 @@ private async Task AuthorizeAsync(INode node, IProvideMetadata provider, Validat
190198
/// <summary>
191199
/// Adds an authorization failure error to the document response
192200
/// </summary>
193-
protected virtual void AddValidationError(INode node, ValidationContext context, OperationType? operationType, AuthorizationResult result)
201+
protected virtual void AddValidationError(INode? node, ValidationContext context, OperationType? operationType, AuthorizationResult result)
194202
{
195-
context.ReportError(new AuthorizationError(node, context, operationType, result));
203+
string message = _messageBuilder.GenerateMessage(operationType, result);
204+
context.ReportError(new AuthorizationError(node, context, message, result, operationType));
196205
}
197206
}
198207
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#nullable enable
2+
3+
using System.Linq;
4+
using System.Text;
5+
using GraphQL.Language.AST;
6+
using Microsoft.AspNetCore.Authorization;
7+
using Microsoft.AspNetCore.Authorization.Infrastructure;
8+
9+
namespace GraphQL.Server.Authorization.AspNetCore
10+
{
11+
public class DefaultAuthorizationErrorMessageBuilder : IAuthorizationErrorMessageBuilder
12+
{
13+
/// <inheritdoc />
14+
public virtual string GenerateMessage(OperationType? operationType, AuthorizationResult result)
15+
{
16+
if (result.Succeeded)
17+
return "Success!";
18+
19+
var error = new StringBuilder();
20+
AppendFailureHeader(error, operationType);
21+
22+
if (result.Failure != null)
23+
{
24+
foreach (var requirement in result.Failure.FailedRequirements)
25+
{
26+
AppendFailureLine(error, requirement);
27+
}
28+
}
29+
30+
return error.ToString();
31+
}
32+
33+
private string GetOperationType(OperationType? operationType)
34+
{
35+
return operationType switch
36+
{
37+
OperationType.Query => "query",
38+
OperationType.Mutation => "mutation",
39+
OperationType.Subscription => "subscription",
40+
_ => "operation",
41+
};
42+
}
43+
44+
/// <inheritdoc />
45+
public virtual void AppendFailureHeader(StringBuilder error, OperationType? operationType)
46+
{
47+
error
48+
.Append("You are not authorized to run this ")
49+
.Append(GetOperationType(operationType))
50+
.Append('.');
51+
}
52+
53+
/// <inheritdoc />
54+
public virtual void AppendFailureLine(StringBuilder error, IAuthorizationRequirement authorizationRequirement)
55+
{
56+
error.AppendLine();
57+
58+
switch (authorizationRequirement)
59+
{
60+
case ClaimsAuthorizationRequirement claimsAuthorizationRequirement:
61+
error.Append("Required claim '");
62+
error.Append(claimsAuthorizationRequirement.ClaimType);
63+
if (claimsAuthorizationRequirement.AllowedValues == null || !claimsAuthorizationRequirement.AllowedValues.Any())
64+
{
65+
error.Append("' is not present.");
66+
}
67+
else
68+
{
69+
error.Append("' with any value of '");
70+
error.Append(string.Join(", ", claimsAuthorizationRequirement.AllowedValues));
71+
error.Append("' is not present.");
72+
}
73+
break;
74+
75+
case DenyAnonymousAuthorizationRequirement _:
76+
error.Append("The current user must be authenticated.");
77+
break;
78+
79+
case NameAuthorizationRequirement nameAuthorizationRequirement:
80+
error.Append("The current user name must match the name '");
81+
error.Append(nameAuthorizationRequirement.RequiredName);
82+
error.Append("'.");
83+
break;
84+
85+
case OperationAuthorizationRequirement operationAuthorizationRequirement:
86+
error.Append("Required operation '");
87+
error.Append(operationAuthorizationRequirement.Name);
88+
error.Append("' was not present.");
89+
break;
90+
91+
case RolesAuthorizationRequirement rolesAuthorizationRequirement:
92+
if (rolesAuthorizationRequirement.AllowedRoles == null || !rolesAuthorizationRequirement.AllowedRoles.Any())
93+
{
94+
// This should never happen.
95+
error.Append("Required roles are not present.");
96+
}
97+
else
98+
{
99+
error.Append("Required roles '");
100+
error.Append(string.Join(", ", rolesAuthorizationRequirement.AllowedRoles));
101+
error.Append("' are not present.");
102+
}
103+
break;
104+
105+
case AssertionRequirement _:
106+
default:
107+
error.Append("Requirement '");
108+
error.Append(authorizationRequirement.GetType().Name);
109+
error.Append("' was not satisfied.");
110+
break;
111+
}
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)