Skip to content

Commit

Permalink
Merge pull request #1303 from json-api-dotnet/auth-scopes-example
Browse files Browse the repository at this point in the history
Add example for scopes-based authorization
  • Loading branch information
bkoelman committed Sep 4, 2023
2 parents 396123c + 9691a04 commit f38a812
Show file tree
Hide file tree
Showing 16 changed files with 1,664 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume
return null;
}

public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
{
return expression;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ private sealed class FilterWalker : QueryExpressionRewriter<object?>
{
public bool HasFilterOnArchivedAt { get; private set; }

public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
{
if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
public sealed class Actor : Identifiable<long>
{
[Attr]
public string Name { get; set; } = null!;

[Attr]
public DateTime BornAt { get; set; }

[HasMany]
public ISet<Movie> ActsIn { get; set; } = new HashSet<Movie>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Text;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

internal sealed class AuthScopeSet
{
private const StringSplitOptions ScopesHeaderSplitOptions = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;

public const string ScopesHeaderName = "X-Auth-Scopes";

private readonly Dictionary<string, Permission> _scopes = new();

public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders)
{
var requestedScopes = new AuthScopeSet();

// In a real application, the scopes would be read from the signed ticket in the Authorization HTTP header.
// For simplicity, this sample allows the client to send them directly, which is obviously insecure.

if (requestHeaders.TryGetValue(ScopesHeaderName, out StringValues headerValue))
{
foreach (string scopeValue in headerValue.ToString().Split(' ', ScopesHeaderSplitOptions))
{
string[] scopeParts = scopeValue.Split(':', 2, ScopesHeaderSplitOptions);

if (scopeParts.Length == 2 && Enum.TryParse(scopeParts[0], true, out Permission permission) && Enum.IsDefined(permission))
{
requestedScopes.Include(scopeParts[1], permission);
}
}
}

return requestedScopes;
}

public void IncludeFrom(IJsonApiRequest request, ITargetedFields targetedFields)
{
Permission permission = request.IsReadOnly ? Permission.Read : Permission.Write;

if (request.PrimaryResourceType != null)
{
Include(request.PrimaryResourceType, permission);
}

if (request.SecondaryResourceType != null)
{
Include(request.SecondaryResourceType, permission);
}

if (request.Relationship != null)
{
Include(request.Relationship, permission);
}

foreach (RelationshipAttribute relationship in targetedFields.Relationships)
{
Include(relationship, permission);
}
}

public void Include(ResourceType resourceType, Permission permission)
{
Include(resourceType.PublicName, permission);
}

public void Include(RelationshipAttribute relationship, Permission permission)
{
Include(relationship.LeftType, permission);
Include(relationship.RightType, permission);
}

private void Include(string name, Permission permission)
{
// Unify with existing entries. For example, adding read:movies when write:movies already exists is a no-op.

if (_scopes.TryGetValue(name, out Permission value))
{
if (value >= permission)
{
return;
}
}

_scopes[name] = permission;
}

public bool ContainsAll(AuthScopeSet other)
{
foreach (string otherName in other._scopes.Keys)
{
if (!_scopes.TryGetValue(otherName, out Permission thisPermission))
{
return false;
}

if (thisPermission < other._scopes[otherName])
{
return false;
}
}

return true;
}

public override string ToString()
{
var builder = new StringBuilder();

foreach ((string name, Permission permission) in _scopes.OrderBy(scope => scope.Key))
{
if (builder.Length > 0)
{
builder.Append(' ');
}

builder.Append($"{permission.ToString().ToLowerInvariant()}:{name}");
}

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
public sealed class Genre : Identifiable<long>
{
[Attr]
public string Name { get; set; } = null!;

[HasMany]
public ISet<Movie> Movies { get; set; } = new HashSet<Movie>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
public sealed class Movie : Identifiable<long>
{
[Attr]
public string Title { get; set; } = null!;

[Attr]
public int ReleaseYear { get; set; }

[Attr]
public int DurationInSeconds { get; set; }

[HasOne]
public Genre Genre { get; set; } = null!;

[HasMany]
public ISet<Actor> Cast { get; set; } = new HashSet<Actor>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Net;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
IJsonApiRequest request, ITargetedFields targetedFields)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
{
}

public override async Task<IActionResult> PostOperationsAsync(IList<OperationContainer> operations, CancellationToken cancellationToken)
{
AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(HttpContext.Request.Headers);
AuthScopeSet requiredScopes = GetRequiredScopes(operations);

if (!requestedScopes.ContainsAll(requiredScopes))
{
return Error(new ErrorObject(HttpStatusCode.Unauthorized)
{
Title = "Insufficient permissions to perform this request.",
Detail = $"Performing this request requires the following scopes: {requiredScopes}.",
Source = new ErrorSource
{
Header = AuthScopeSet.ScopesHeaderName
}
});
}

return await base.PostOperationsAsync(operations, cancellationToken);
}

private AuthScopeSet GetRequiredScopes(IEnumerable<OperationContainer> operations)
{
var requiredScopes = new AuthScopeSet();

foreach (OperationContainer operation in operations)
{
requiredScopes.IncludeFrom(operation.Request, operation.TargetedFields);
}

return requiredScopes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

internal enum Permission
{
Read,

// Write access implicitly includes read access, because POST/PATCH in JSON:API may return the changed resource.
Write
}

0 comments on commit f38a812

Please sign in to comment.