Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 78 additions & 0 deletions docs/content/client-sdk/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,44 @@ A wildcard is not a substitute for satisfying independent security policies, aut

## 4. Check Authorization

`CheckAsync` evaluates whether the current authenticated user is authorized to perform a specific action against a resource.

It provides an **on-demand, server-side authorization decision** based on the current authentication context, permissions, roles, and applicable authorization policies.

Unlike operations that perform protected actions, `CheckAsync` only evaluates access. It does not execute the requested operation, modify permissions, grant access, or reserve authorization for a future request.

### When to Use CheckAsync

Use `CheckAsync` when your application needs to:

- Determine whether an authenticated user can perform an action before presenting it in the UI.
- Enable, disable, or hide controls based on the current authorization decision.
- Explain why an operation is unavailable, using the returned denial reason where appropriate.
- Determine whether an operation requires reauthentication.
- Evaluate resource-specific access when the applicable policies support it.

For example, an application may check whether the current user can delete a particular order before displaying the Delete button.

### What CheckAsync Does Not Guarantee

**A successful authorization check is not a guarantee that a subsequent operation will succeed.**

Authorization is evaluated against the security context available at the time of the check. That context may change before the actual operation is performed.

For example:

- An administrator may revoke the user's permissions.
- The user's session may expire or become invalid.
- The target resource may be deleted or modified.
- The resource's ownership or tenant context may change.
- Additional business rules may prevent the operation.

The protected operation must independently enforce authorization using its current context.

**Never use a previous `CheckAsync` result as proof of authorization for a later request.**

`CheckAsync` is primarily a developer-experience and user-experience API. It helps applications understand and present authorization decisions without replacing the security enforcement performed by protected endpoints and application services.

Use `CheckAsync` to ask the server whether the current user is authorized to perform an action.

```csharp
Expand Down Expand Up @@ -230,6 +268,46 @@ Do not assume that sending `ResourceId` automatically enables ownership checking
| `RequiresReauthentication` | Whether the decision indicates reauthentication is required |
| `DenyReason` | Optional denial reason code |

### Authorization Decisions vs. HTTP Results

`CheckAsync` separates the outcome of the API request from the authorization decision itself.

The method returns `UAuthResult<AuthorizationResult>`:

- `UAuthResult.IsSuccess` indicates whether the authorization check request completed successfully.
- `AuthorizationResult.IsAllowed` indicates whether the requested action is currently authorized.
- `AuthorizationResult.RequiresReauthentication` indicates whether additional authentication is required.
- `AuthorizationResult.DenyReason` provides an optional reason for denial.

A denied authorization decision is a valid result of a successful authorization check.

| Scenario | HTTP Status | Authorization Result |
|---|---|---|
| Action allowed | `200 OK` | `IsAllowed = true` |
| Action denied | `200 OK` | `IsAllowed = false` |
| Reauthentication required | `200 OK` | `RequiresReauthentication = true` |
| Caller not authenticated | `401 Unauthorized` | No authorization decision |
| Invalid request | `400 Bad Request` | No authorization decision |
| Server-side evaluation failure | Appropriate error status | No authorization decision |

**HTTP 200 means the authorization evaluation completed successfully. It does not mean access was granted.**

Only authenticated callers can use the current `CheckAsync` endpoint. However, callers do not need permission to perform the action they are checking.

This distinction is intentional: the purpose of `CheckAsync` is to determine whether the action is permitted.

### Handling Denied Decisions

Applications should distinguish between authorization denial, reauthentication requirements, and request failures.

A denied decision may be used to disable an action or display an appropriate message.

A reauthentication requirement may be used to initiate the application's supported reauthentication flow.

A failed request must not be interpreted as permission to proceed.

**Fail closed:** If the authorization decision is unavailable, treat the operation as unavailable in the UI and rely on server-side authorization when the actual operation is requested.

### Handle All Decision Outcomes

```csharp
Expand Down
2 changes: 2 additions & 0 deletions docs/content/how-to-guides/protect-a-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,8 @@ That endpoint must independently validate the caller and enforce the required au

For Blazor Server, the same principle applies to sensitive server-side operations, even when they are invoked without an HTTP call from the component.

> Use [UAuthAuthorize] to declare authorization requirements for routed pages and UAuthStateView for conditional UI rendering. Use UAuthClient.Authorization.CheckAsync() when you need an explicit, on-demand server-side authorization decision for a specific action or resource. These mechanisms serve different purposes and do not replace authorization enforcement on protected server operations.

### UAuthAuthorize is currently a Blazor attribute

The current `[UAuthAuthorize]` attribute is interpreted by UltimateAuth's Blazor authorization pipeline.
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public AuthorizationEndpointHandler(
public async Task<IResult> CheckAsync(HttpContext ctx)
{
var flow = _authFlow.Current;

if (!flow.IsAuthenticated)
return Results.Unauthorized();

Expand All @@ -54,12 +55,7 @@ public async Task<IResult> CheckAsync(HttpContext ctx)

var result = await _authorization.AuthorizeAsync(accessContext, ctx.RequestAborted);

if (result.RequiresReauthentication)
return Results.StatusCode(StatusCodes.Status428PreconditionRequired);

return result.IsAllowed
? Results.Ok(result)
: Results.Forbid();
return Results.Ok(result);
}

public async Task<IResult> GetMyRolesAsync(HttpContext ctx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,20 @@ namespace CodeBeam.UltimateAuth.Client.Services;
public interface IAuthorizationClient
{
/// <summary>
/// Evaluates whether the current user is authorized to perform a specific action.
/// Evaluates whether the current authenticated user is authorized to perform a specific action.
/// </summary>
/// <remarks>
/// This method performs a server-side authorization check based on the current session,
/// roles, and assigned permissions.
/// <para>
/// Performs an on-demand, server-side authorization evaluation using the current authentication context and applicable policies.
/// </para>
/// <para>
/// A successful request returns an <see cref="AuthorizationResult"/> even when authorization is denied or reauthentication is required.
/// The authorization decision is separate from the API request result.
/// </para>
/// <para>
/// This method does not execute the requested operation or grant authorization for subsequent requests.
/// Protected operations must independently enforce authorization when executed.
/// </para>
/// </remarks>
Task<UAuthResult<AuthorizationResult>> CheckAsync(AuthorizationCheckRequest request);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ await GrantAsync(
}

[Fact]
public async Task AuthorizationCheck_WithoutPermission_ShouldDeny()
public async Task AuthorizationCheck_WithoutPermission_ShouldReturnDenial()
{
using var factory = NewFactory();

Expand All @@ -620,7 +620,14 @@ public async Task AuthorizationCheck_WithoutPermission_ShouldDeny()
Resource = "authorization.roles"
});

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
response.StatusCode.Should().Be(HttpStatusCode.OK);

var result = await response.Content
.ReadFromJsonAsync<AuthorizationResult>();

result.Should().NotBeNull();
result!.IsAllowed.Should().BeFalse();
result.RequiresReauthentication.Should().BeFalse();
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public async Task CheckAsync_WhenAllowed_CreatesExpectedAccessContextAndReturnsO
}

[Fact]
public async Task CheckAsync_WhenDenied_ReturnsForbidden()
public async Task CheckAsync_WhenDenied_ReturnsOkWithDenial()
{
var f = new Fixture();
var accessContext = f.AccessContext("orders.delete");
Expand All @@ -144,7 +144,14 @@ public async Task CheckAsync_WhenDenied_ReturnsForbidden()

var result = await f.Sut.CheckAsync(ctx);

result.Should().BeOfType<Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult>();
var ok = result.Should()
.BeOfType<Microsoft.AspNetCore.Http.HttpResults.Ok<AuthorizationResult>>()
.Subject;

ok.Value.Should().NotBeNull();
ok.Value!.IsAllowed.Should().BeFalse();
ok.Value.DenyReason.Should().Be("access_denied");
ok.Value.RequiresReauthentication.Should().BeFalse();

f.Authorization.Verify(x => x.AuthorizeAsync(
accessContext,
Expand All @@ -153,7 +160,7 @@ public async Task CheckAsync_WhenDenied_ReturnsForbidden()
}

[Fact]
public async Task CheckAsync_WhenReauthenticationIsRequired_Returns428()
public async Task CheckAsync_WhenReauthenticationIsRequired_ReturnsOkWithReauthentication()
{
var f = new Fixture();
var accessContext = f.AccessContext("orders.delete");
Expand All @@ -179,9 +186,18 @@ public async Task CheckAsync_WhenReauthenticationIsRequired_Returns428()

var result = await f.Sut.CheckAsync(ctx);

AssertStatusCode(
result,
StatusCodes.Status428PreconditionRequired);
var ok = result.Should()
.BeOfType<Microsoft.AspNetCore.Http.HttpResults.Ok<AuthorizationResult>>()
.Subject;

ok.Value.Should().NotBeNull();
ok.Value!.IsAllowed.Should().BeFalse();
ok.Value.RequiresReauthentication.Should().BeTrue();

f.Authorization.Verify(x => x.AuthorizeAsync(
accessContext,
ctx.RequestAborted),
Times.Once);
}

// =========================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,48 @@ public async Task DeleteRole_Should_Publish_Event_On_Success()
await client.Authorization.DeleteRoleAsync(request);
Events.Verify(x => x.PublishAsync(It.Is<UAuthStateEventArgs>(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Check_Should_Return_Structured_Denial(bool requiresReauthentication)
{
var expected = requiresReauthentication
? AuthorizationResult.ReauthRequired()
: AuthorizationResult.Deny("access_denied");

Request.Setup(x => x.SendJsonAsync(
"/auth/me/authorization/check",
It.IsAny<object>()))
.ReturnsAsync(SuccessJson(expected));

var client = CreateClient();

var request = new AuthorizationCheckRequest
{
Action = "orders.delete",
Resource = "orders",
ResourceId = "order-123"
};

var result = await client.Authorization.CheckAsync(request);

result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();

result.Value!.IsAllowed.Should().BeFalse();
result.Value.RequiresReauthentication
.Should().Be(requiresReauthentication);

result.Value.DenyReason
.Should().Be(expected.DenyReason);

Request.Verify(x => x.SendJsonAsync(
"/auth/me/authorization/check",
It.Is<AuthorizationCheckRequest>(r =>
r.Action == request.Action &&
r.Resource == request.Resource &&
r.ResourceId == request.ResourceId)),
Times.Once);
}
}
Loading