Skip to content

Commit 71123ad

Browse files
authored
fix(authorization): return structured results from CheckAsync (#105)
1 parent dcca59e commit 71123ad

9 files changed

Lines changed: 193 additions & 21 deletions

File tree

‎docs/content/client-sdk/authorization.md‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,44 @@ A wildcard is not a substitute for satisfying independent security policies, aut
164164

165165
## 4. Check Authorization
166166

167+
`CheckAsync` evaluates whether the current authenticated user is authorized to perform a specific action against a resource.
168+
169+
It provides an **on-demand, server-side authorization decision** based on the current authentication context, permissions, roles, and applicable authorization policies.
170+
171+
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.
172+
173+
### When to Use CheckAsync
174+
175+
Use `CheckAsync` when your application needs to:
176+
177+
- Determine whether an authenticated user can perform an action before presenting it in the UI.
178+
- Enable, disable, or hide controls based on the current authorization decision.
179+
- Explain why an operation is unavailable, using the returned denial reason where appropriate.
180+
- Determine whether an operation requires reauthentication.
181+
- Evaluate resource-specific access when the applicable policies support it.
182+
183+
For example, an application may check whether the current user can delete a particular order before displaying the Delete button.
184+
185+
### What CheckAsync Does Not Guarantee
186+
187+
**A successful authorization check is not a guarantee that a subsequent operation will succeed.**
188+
189+
Authorization is evaluated against the security context available at the time of the check. That context may change before the actual operation is performed.
190+
191+
For example:
192+
193+
- An administrator may revoke the user's permissions.
194+
- The user's session may expire or become invalid.
195+
- The target resource may be deleted or modified.
196+
- The resource's ownership or tenant context may change.
197+
- Additional business rules may prevent the operation.
198+
199+
The protected operation must independently enforce authorization using its current context.
200+
201+
**Never use a previous `CheckAsync` result as proof of authorization for a later request.**
202+
203+
`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.
204+
167205
Use `CheckAsync` to ask the server whether the current user is authorized to perform an action.
168206

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

271+
### Authorization Decisions vs. HTTP Results
272+
273+
`CheckAsync` separates the outcome of the API request from the authorization decision itself.
274+
275+
The method returns `UAuthResult<AuthorizationResult>`:
276+
277+
- `UAuthResult.IsSuccess` indicates whether the authorization check request completed successfully.
278+
- `AuthorizationResult.IsAllowed` indicates whether the requested action is currently authorized.
279+
- `AuthorizationResult.RequiresReauthentication` indicates whether additional authentication is required.
280+
- `AuthorizationResult.DenyReason` provides an optional reason for denial.
281+
282+
A denied authorization decision is a valid result of a successful authorization check.
283+
284+
| Scenario | HTTP Status | Authorization Result |
285+
|---|---|---|
286+
| Action allowed | `200 OK` | `IsAllowed = true` |
287+
| Action denied | `200 OK` | `IsAllowed = false` |
288+
| Reauthentication required | `200 OK` | `RequiresReauthentication = true` |
289+
| Caller not authenticated | `401 Unauthorized` | No authorization decision |
290+
| Invalid request | `400 Bad Request` | No authorization decision |
291+
| Server-side evaluation failure | Appropriate error status | No authorization decision |
292+
293+
**HTTP 200 means the authorization evaluation completed successfully. It does not mean access was granted.**
294+
295+
Only authenticated callers can use the current `CheckAsync` endpoint. However, callers do not need permission to perform the action they are checking.
296+
297+
This distinction is intentional: the purpose of `CheckAsync` is to determine whether the action is permitted.
298+
299+
### Handling Denied Decisions
300+
301+
Applications should distinguish between authorization denial, reauthentication requirements, and request failures.
302+
303+
A denied decision may be used to disable an action or display an appropriate message.
304+
305+
A reauthentication requirement may be used to initiate the application's supported reauthentication flow.
306+
307+
A failed request must not be interpreted as permission to proceed.
308+
309+
**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.
310+
233311
### Handle All Decision Outcomes
234312

235313
```csharp

‎docs/content/how-to-guides/protect-a-page.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,8 @@ That endpoint must independently validate the caller and enforce the required au
739739

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

742+
> 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.
743+
742744
### UAuthAuthorize is currently a Blazor attribute
743745

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

‎docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/authorization.json‎

Lines changed: 22 additions & 2 deletions
Large diffs are not rendered by default.

‎docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/how-to-guides/protect-a-page.json‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public AuthorizationEndpointHandler(
3535
public async Task<IResult> CheckAsync(HttpContext ctx)
3636
{
3737
var flow = _authFlow.Current;
38+
3839
if (!flow.IsAuthenticated)
3940
return Results.Unauthorized();
4041

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

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

57-
if (result.RequiresReauthentication)
58-
return Results.StatusCode(StatusCodes.Status428PreconditionRequired);
59-
60-
return result.IsAllowed
61-
? Results.Ok(result)
62-
: Results.Forbid();
58+
return Results.Ok(result);
6359
}
6460

6561
public async Task<IResult> GetMyRolesAsync(HttpContext ctx)

‎src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,20 @@ namespace CodeBeam.UltimateAuth.Client.Services;
3535
public interface IAuthorizationClient
3636
{
3737
/// <summary>
38-
/// Evaluates whether the current user is authorized to perform a specific action.
38+
/// Evaluates whether the current authenticated user is authorized to perform a specific action.
3939
/// </summary>
4040
/// <remarks>
41-
/// This method performs a server-side authorization check based on the current session,
42-
/// roles, and assigned permissions.
41+
/// <para>
42+
/// Performs an on-demand, server-side authorization evaluation using the current authentication context and applicable policies.
43+
/// </para>
44+
/// <para>
45+
/// A successful request returns an <see cref="AuthorizationResult"/> even when authorization is denied or reauthentication is required.
46+
/// The authorization decision is separate from the API request result.
47+
/// </para>
48+
/// <para>
49+
/// This method does not execute the requested operation or grant authorization for subsequent requests.
50+
/// Protected operations must independently enforce authorization when executed.
51+
/// </para>
4352
/// </remarks>
4453
Task<UAuthResult<AuthorizationResult>> CheckAsync(AuthorizationCheckRequest request);
4554

‎tests/CodeBeam.UltimateAuth.Tests.Integration/AuthorizationManagementTests.cs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ await GrantAsync(
604604
}
605605

606606
[Fact]
607-
public async Task AuthorizationCheck_WithoutPermission_ShouldDeny()
607+
public async Task AuthorizationCheck_WithoutPermission_ShouldReturnDenial()
608608
{
609609
using var factory = NewFactory();
610610

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

623-
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
623+
response.StatusCode.Should().Be(HttpStatusCode.OK);
624+
625+
var result = await response.Content
626+
.ReadFromJsonAsync<AuthorizationResult>();
627+
628+
result.Should().NotBeNull();
629+
result!.IsAllowed.Should().BeFalse();
630+
result.RequiresReauthentication.Should().BeFalse();
624631
}
625632

626633
[Fact]

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public async Task CheckAsync_WhenAllowed_CreatesExpectedAccessContextAndReturnsO
118118
}
119119

120120
[Fact]
121-
public async Task CheckAsync_WhenDenied_ReturnsForbidden()
121+
public async Task CheckAsync_WhenDenied_ReturnsOkWithDenial()
122122
{
123123
var f = new Fixture();
124124
var accessContext = f.AccessContext("orders.delete");
@@ -144,7 +144,14 @@ public async Task CheckAsync_WhenDenied_ReturnsForbidden()
144144

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

147-
result.Should().BeOfType<Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult>();
147+
var ok = result.Should()
148+
.BeOfType<Microsoft.AspNetCore.Http.HttpResults.Ok<AuthorizationResult>>()
149+
.Subject;
150+
151+
ok.Value.Should().NotBeNull();
152+
ok.Value!.IsAllowed.Should().BeFalse();
153+
ok.Value.DenyReason.Should().Be("access_denied");
154+
ok.Value.RequiresReauthentication.Should().BeFalse();
148155

149156
f.Authorization.Verify(x => x.AuthorizeAsync(
150157
accessContext,
@@ -153,7 +160,7 @@ public async Task CheckAsync_WhenDenied_ReturnsForbidden()
153160
}
154161

155162
[Fact]
156-
public async Task CheckAsync_WhenReauthenticationIsRequired_Returns428()
163+
public async Task CheckAsync_WhenReauthenticationIsRequired_ReturnsOkWithReauthentication()
157164
{
158165
var f = new Fixture();
159166
var accessContext = f.AccessContext("orders.delete");
@@ -179,9 +186,18 @@ public async Task CheckAsync_WhenReauthenticationIsRequired_Returns428()
179186

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

182-
AssertStatusCode(
183-
result,
184-
StatusCodes.Status428PreconditionRequired);
189+
var ok = result.Should()
190+
.BeOfType<Microsoft.AspNetCore.Http.HttpResults.Ok<AuthorizationResult>>()
191+
.Subject;
192+
193+
ok.Value.Should().NotBeNull();
194+
ok.Value!.IsAllowed.Should().BeFalse();
195+
ok.Value.RequiresReauthentication.Should().BeTrue();
196+
197+
f.Authorization.Verify(x => x.AuthorizeAsync(
198+
accessContext,
199+
ctx.RequestAborted),
200+
Times.Once);
185201
}
186202

187203
// =========================================================

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,48 @@ public async Task DeleteRole_Should_Publish_Event_On_Success()
274274
await client.Authorization.DeleteRoleAsync(request);
275275
Events.Verify(x => x.PublishAsync(It.Is<UAuthStateEventArgs>(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once);
276276
}
277+
278+
[Theory]
279+
[InlineData(false)]
280+
[InlineData(true)]
281+
public async Task Check_Should_Return_Structured_Denial(bool requiresReauthentication)
282+
{
283+
var expected = requiresReauthentication
284+
? AuthorizationResult.ReauthRequired()
285+
: AuthorizationResult.Deny("access_denied");
286+
287+
Request.Setup(x => x.SendJsonAsync(
288+
"/auth/me/authorization/check",
289+
It.IsAny<object>()))
290+
.ReturnsAsync(SuccessJson(expected));
291+
292+
var client = CreateClient();
293+
294+
var request = new AuthorizationCheckRequest
295+
{
296+
Action = "orders.delete",
297+
Resource = "orders",
298+
ResourceId = "order-123"
299+
};
300+
301+
var result = await client.Authorization.CheckAsync(request);
302+
303+
result.IsSuccess.Should().BeTrue();
304+
result.Value.Should().NotBeNull();
305+
306+
result.Value!.IsAllowed.Should().BeFalse();
307+
result.Value.RequiresReauthentication
308+
.Should().Be(requiresReauthentication);
309+
310+
result.Value.DenyReason
311+
.Should().Be(expected.DenyReason);
312+
313+
Request.Verify(x => x.SendJsonAsync(
314+
"/auth/me/authorization/check",
315+
It.Is<AuthorizationCheckRequest>(r =>
316+
r.Action == request.Action &&
317+
r.Resource == request.Resource &&
318+
r.ResourceId == request.ResourceId)),
319+
Times.Once);
320+
}
277321
}

0 commit comments

Comments
 (0)