Skip to content

Commit f38a812

Browse files
authored
Merge pull request #1303 from json-api-dotnet/auth-scopes-example
Add example for scopes-based authorization
2 parents 396123c + 9691a04 commit f38a812

16 files changed

+1664
-3
lines changed

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume
3434
return null;
3535
}
3636

37-
public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
37+
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
3838
{
3939
return expression;
4040
}

test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ private sealed class FilterWalker : QueryExpressionRewriter<object?>
180180
{
181181
public bool HasFilterOnArchivedAt { get; private set; }
182182

183-
public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
183+
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
184184
{
185185
if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt))
186186
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
9+
public sealed class Actor : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[Attr]
15+
public DateTime BornAt { get; set; }
16+
17+
[HasMany]
18+
public ISet<Movie> ActsIn { get; set; } = new HashSet<Movie>();
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using System.Text;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.Extensions.Primitives;
8+
9+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
10+
11+
internal sealed class AuthScopeSet
12+
{
13+
private const StringSplitOptions ScopesHeaderSplitOptions = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
14+
15+
public const string ScopesHeaderName = "X-Auth-Scopes";
16+
17+
private readonly Dictionary<string, Permission> _scopes = new();
18+
19+
public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders)
20+
{
21+
var requestedScopes = new AuthScopeSet();
22+
23+
// In a real application, the scopes would be read from the signed ticket in the Authorization HTTP header.
24+
// For simplicity, this sample allows the client to send them directly, which is obviously insecure.
25+
26+
if (requestHeaders.TryGetValue(ScopesHeaderName, out StringValues headerValue))
27+
{
28+
foreach (string scopeValue in headerValue.ToString().Split(' ', ScopesHeaderSplitOptions))
29+
{
30+
string[] scopeParts = scopeValue.Split(':', 2, ScopesHeaderSplitOptions);
31+
32+
if (scopeParts.Length == 2 && Enum.TryParse(scopeParts[0], true, out Permission permission) && Enum.IsDefined(permission))
33+
{
34+
requestedScopes.Include(scopeParts[1], permission);
35+
}
36+
}
37+
}
38+
39+
return requestedScopes;
40+
}
41+
42+
public void IncludeFrom(IJsonApiRequest request, ITargetedFields targetedFields)
43+
{
44+
Permission permission = request.IsReadOnly ? Permission.Read : Permission.Write;
45+
46+
if (request.PrimaryResourceType != null)
47+
{
48+
Include(request.PrimaryResourceType, permission);
49+
}
50+
51+
if (request.SecondaryResourceType != null)
52+
{
53+
Include(request.SecondaryResourceType, permission);
54+
}
55+
56+
if (request.Relationship != null)
57+
{
58+
Include(request.Relationship, permission);
59+
}
60+
61+
foreach (RelationshipAttribute relationship in targetedFields.Relationships)
62+
{
63+
Include(relationship, permission);
64+
}
65+
}
66+
67+
public void Include(ResourceType resourceType, Permission permission)
68+
{
69+
Include(resourceType.PublicName, permission);
70+
}
71+
72+
public void Include(RelationshipAttribute relationship, Permission permission)
73+
{
74+
Include(relationship.LeftType, permission);
75+
Include(relationship.RightType, permission);
76+
}
77+
78+
private void Include(string name, Permission permission)
79+
{
80+
// Unify with existing entries. For example, adding read:movies when write:movies already exists is a no-op.
81+
82+
if (_scopes.TryGetValue(name, out Permission value))
83+
{
84+
if (value >= permission)
85+
{
86+
return;
87+
}
88+
}
89+
90+
_scopes[name] = permission;
91+
}
92+
93+
public bool ContainsAll(AuthScopeSet other)
94+
{
95+
foreach (string otherName in other._scopes.Keys)
96+
{
97+
if (!_scopes.TryGetValue(otherName, out Permission thisPermission))
98+
{
99+
return false;
100+
}
101+
102+
if (thisPermission < other._scopes[otherName])
103+
{
104+
return false;
105+
}
106+
}
107+
108+
return true;
109+
}
110+
111+
public override string ToString()
112+
{
113+
var builder = new StringBuilder();
114+
115+
foreach ((string name, Permission permission) in _scopes.OrderBy(scope => scope.Key))
116+
{
117+
if (builder.Length > 0)
118+
{
119+
builder.Append(' ');
120+
}
121+
122+
builder.Append($"{permission.ToString().ToLowerInvariant()}:{name}");
123+
}
124+
125+
return builder.ToString();
126+
}
127+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
9+
public sealed class Genre : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[HasMany]
15+
public ISet<Movie> Movies { get; set; } = new HashSet<Movie>();
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
9+
public sealed class Movie : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Title { get; set; } = null!;
13+
14+
[Attr]
15+
public int ReleaseYear { get; set; }
16+
17+
[Attr]
18+
public int DurationInSeconds { get; set; }
19+
20+
[HasOne]
21+
public Genre Genre { get; set; } = null!;
22+
23+
[HasMany]
24+
public ISet<Actor> Cast { get; set; } = new HashSet<Actor>();
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Net;
2+
using JsonApiDotNetCore.AtomicOperations;
3+
using JsonApiDotNetCore.Configuration;
4+
using JsonApiDotNetCore.Controllers;
5+
using JsonApiDotNetCore.Middleware;
6+
using JsonApiDotNetCore.Resources;
7+
using JsonApiDotNetCore.Serialization.Objects;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
12+
13+
public sealed class OperationsController : JsonApiOperationsController
14+
{
15+
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
16+
IJsonApiRequest request, ITargetedFields targetedFields)
17+
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
18+
{
19+
}
20+
21+
public override async Task<IActionResult> PostOperationsAsync(IList<OperationContainer> operations, CancellationToken cancellationToken)
22+
{
23+
AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(HttpContext.Request.Headers);
24+
AuthScopeSet requiredScopes = GetRequiredScopes(operations);
25+
26+
if (!requestedScopes.ContainsAll(requiredScopes))
27+
{
28+
return Error(new ErrorObject(HttpStatusCode.Unauthorized)
29+
{
30+
Title = "Insufficient permissions to perform this request.",
31+
Detail = $"Performing this request requires the following scopes: {requiredScopes}.",
32+
Source = new ErrorSource
33+
{
34+
Header = AuthScopeSet.ScopesHeaderName
35+
}
36+
});
37+
}
38+
39+
return await base.PostOperationsAsync(operations, cancellationToken);
40+
}
41+
42+
private AuthScopeSet GetRequiredScopes(IEnumerable<OperationContainer> operations)
43+
{
44+
var requiredScopes = new AuthScopeSet();
45+
46+
foreach (OperationContainer operation in operations)
47+
{
48+
requiredScopes.IncludeFrom(operation.Request, operation.TargetedFields);
49+
}
50+
51+
return requiredScopes;
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
2+
3+
internal enum Permission
4+
{
5+
Read,
6+
7+
// Write access implicitly includes read access, because POST/PATCH in JSON:API may return the changed resource.
8+
Write
9+
}

0 commit comments

Comments
 (0)