Skip to content

Commit d703823

Browse files
glucaciCopilot
andcommitted
Add Bewit endpoint authorization filter; implement route handler and group builder methods, enhance README with usage examples, and introduce unit tests for validation scenarios
Co-authored-by: Copilot <copilot@github.com>
1 parent 009f162 commit d703823

4 files changed

Lines changed: 343 additions & 3 deletions

File tree

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,12 +307,26 @@ app.UseBewitTokenExtraction();
307307
dotnet add package Bewit.Http
308308
```
309309

310-
Protect minimal API or middleware-based endpoints:
310+
### Minimal API — Endpoint Filter (recommended)
311+
312+
Apply authorization to individual routes or route groups:
311313
```csharp
312-
app.UseBewitEndpointAuthorization<MyPayload>();
314+
app.MapGet("/files/{id}", (string id) => ...)
315+
.AddBewitAuthorization<MyPayload>();
316+
317+
// or protect a group of endpoints:
318+
app.MapGroup("/api/files")
319+
.AddBewitAuthorization<MyPayload>();
313320
```
314321

315-
The middleware validates the token from the configured header or query parameter and makes the payload available via `GetBewitPayload<T>()`.
322+
The filter validates the token from the configured header, query parameter, or pre-extracted `HttpContext.Items` entry, and makes the payload available via `GetBewitPayload<T>()`.
323+
324+
### Middleware (global)
325+
326+
Protect all endpoints via middleware:
327+
```csharp
328+
app.UseBewitEndpointAuthorization<MyPayload>();
329+
```
316330

317331
## Token Extraction
318332

src/Extensions.Http/BewitEndpointExtensions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using Bewit.Http;
22
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Routing;
35

46
namespace Microsoft.Extensions.DependencyInjection;
57

@@ -11,4 +13,18 @@ public static IApplicationBuilder UseBewitEndpointAuthorization<T>(
1113
{
1214
return app.UseMiddleware<BewitEndpointMiddleware<T>>();
1315
}
16+
17+
public static RouteHandlerBuilder AddBewitAuthorization<T>(this RouteHandlerBuilder builder)
18+
where T : notnull
19+
{
20+
return builder.AddEndpointFilter<BewitEndpointFilter<T>>();
21+
}
22+
23+
public static RouteGroupBuilder AddBewitAuthorization<T>(this RouteGroupBuilder builder)
24+
where T : notnull
25+
{
26+
builder.AddEndpointFilter<BewitEndpointFilter<T>>();
27+
28+
return builder;
29+
}
1430
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Bewit.Exceptions;
2+
using Bewit.Validation;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace Bewit.Http;
8+
9+
public sealed class BewitEndpointFilter<T> : IEndpointFilter
10+
where T : notnull
11+
{
12+
public async ValueTask<object?> InvokeAsync(
13+
EndpointFilterInvocationContext context,
14+
EndpointFilterDelegate next)
15+
{
16+
var httpContext = context.HttpContext;
17+
var options = httpContext.RequestServices
18+
.GetRequiredService<IOptions<BewitTokenExtractionOptions>>();
19+
20+
BewitTokenExtractionOptions config = options.Value;
21+
22+
string? tokenString = null;
23+
24+
if (httpContext.Items.TryGetValue(config.ContextKey, out var tokenObj))
25+
{
26+
tokenString = tokenObj as string;
27+
}
28+
29+
if (string.IsNullOrWhiteSpace(tokenString)
30+
&& httpContext.Request.Headers.TryGetValue(
31+
config.HeaderName, out var headerValues)
32+
&& headerValues.Count > 0)
33+
{
34+
tokenString = headerValues[0];
35+
}
36+
37+
if (string.IsNullOrWhiteSpace(tokenString))
38+
{
39+
tokenString = httpContext.Request.Query[config.QueryParamName];
40+
}
41+
42+
if (string.IsNullOrWhiteSpace(tokenString))
43+
{
44+
return Results.Unauthorized();
45+
}
46+
47+
try
48+
{
49+
var validator = httpContext.RequestServices
50+
.GetRequiredService<IBewitTokenValidator<T>>();
51+
52+
var payload = await validator.ValidateBewitTokenAsync(
53+
new BewitToken<T>(tokenString),
54+
httpContext.RequestAborted);
55+
56+
var httpContextAccessor = httpContext.RequestServices
57+
.GetRequiredService<IHttpContextAccessor>();
58+
59+
httpContextAccessor.SetBewitPayload(payload);
60+
}
61+
catch (BewitException)
62+
{
63+
return Results.Forbid();
64+
}
65+
66+
return await next(context);
67+
}
68+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
using Bewit.Exceptions;
2+
using Bewit.Validation;
3+
using FluentAssertions;
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Http.HttpResults;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Options;
8+
using Moq;
9+
using Xunit;
10+
11+
namespace Bewit.Extensions.Http.Tests;
12+
13+
public class BewitEndpointFilterTests
14+
{
15+
private static IOptions<BewitTokenExtractionOptions> CreateOptions(
16+
Action<BewitTokenExtractionOptions>? configure = null)
17+
{
18+
var options = new BewitTokenExtractionOptions();
19+
configure?.Invoke(options);
20+
21+
return Options.Create(options);
22+
}
23+
24+
private static (DefaultHttpContext Context, ServiceProvider Services) CreateContext(
25+
IBewitTokenValidator<string>? validator = null,
26+
string? headerName = null,
27+
string? headerValue = null,
28+
string? queryString = null,
29+
Action<BewitTokenExtractionOptions>? configureOptions = null)
30+
{
31+
var serviceCollection = new ServiceCollection()
32+
.AddHttpContextAccessor()
33+
.AddSingleton(CreateOptions(configureOptions));
34+
35+
if (validator is not null)
36+
{
37+
serviceCollection.AddSingleton(validator);
38+
}
39+
40+
var services = serviceCollection.BuildServiceProvider();
41+
var context = new DefaultHttpContext { RequestServices = services };
42+
43+
if (headerName is not null && headerValue is not null)
44+
{
45+
context.Request.Headers[headerName] = headerValue;
46+
}
47+
48+
if (queryString is not null)
49+
{
50+
context.Request.QueryString = new QueryString(queryString);
51+
}
52+
53+
var httpContextAccessor = services.GetRequiredService<IHttpContextAccessor>();
54+
httpContextAccessor.HttpContext = context;
55+
56+
return (context, services);
57+
}
58+
59+
[Fact]
60+
public async Task InvokeAsync_WithNoToken_ShouldReturnUnauthorized()
61+
{
62+
var (context, _) = CreateContext();
63+
64+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
65+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
66+
67+
var result = await filter.InvokeAsync(
68+
invocationContext, _ => ValueTask.FromResult<object?>(Results.Ok()));
69+
70+
result.Should().BeOfType<UnauthorizedHttpResult>();
71+
}
72+
73+
[Fact]
74+
public async Task InvokeAsync_WithHeaderToken_ShouldValidateAndCallNext()
75+
{
76+
var validatorMock = new Mock<IBewitTokenValidator<string>>();
77+
78+
validatorMock
79+
.Setup(v => v.ValidateBewitTokenAsync(
80+
It.IsAny<BewitToken<string>>(), It.IsAny<CancellationToken>()))
81+
.ReturnsAsync("payload-value");
82+
83+
var (context, _) = CreateContext(
84+
validator: validatorMock.Object,
85+
headerName: "bewitToken",
86+
headerValue: "valid-token");
87+
88+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
89+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
90+
var nextCalled = false;
91+
92+
var result = await filter.InvokeAsync(
93+
invocationContext,
94+
_ =>
95+
{
96+
nextCalled = true;
97+
98+
return ValueTask.FromResult<object?>(Results.Ok());
99+
});
100+
101+
nextCalled.Should().BeTrue();
102+
}
103+
104+
[Fact]
105+
public async Task InvokeAsync_WithQueryToken_ShouldValidateAndCallNext()
106+
{
107+
var validatorMock = new Mock<IBewitTokenValidator<string>>();
108+
109+
validatorMock
110+
.Setup(v => v.ValidateBewitTokenAsync(
111+
It.IsAny<BewitToken<string>>(), It.IsAny<CancellationToken>()))
112+
.ReturnsAsync("payload-value");
113+
114+
var (context, _) = CreateContext(
115+
validator: validatorMock.Object,
116+
queryString: "?bewit=valid-token");
117+
118+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
119+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
120+
var nextCalled = false;
121+
122+
var result = await filter.InvokeAsync(
123+
invocationContext,
124+
_ =>
125+
{
126+
nextCalled = true;
127+
128+
return ValueTask.FromResult<object?>(Results.Ok());
129+
});
130+
131+
nextCalled.Should().BeTrue();
132+
}
133+
134+
[Fact]
135+
public async Task InvokeAsync_WithPreExtractedToken_ShouldValidateAndCallNext()
136+
{
137+
var validatorMock = new Mock<IBewitTokenValidator<string>>();
138+
139+
validatorMock
140+
.Setup(v => v.ValidateBewitTokenAsync(
141+
It.IsAny<BewitToken<string>>(), It.IsAny<CancellationToken>()))
142+
.ReturnsAsync("payload-value");
143+
144+
var (context, _) = CreateContext(validator: validatorMock.Object);
145+
context.Items["bewitToken"] = "pre-extracted-token";
146+
147+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
148+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
149+
var nextCalled = false;
150+
151+
var result = await filter.InvokeAsync(
152+
invocationContext,
153+
_ =>
154+
{
155+
nextCalled = true;
156+
157+
return ValueTask.FromResult<object?>(Results.Ok());
158+
});
159+
160+
nextCalled.Should().BeTrue();
161+
}
162+
163+
[Fact]
164+
public async Task InvokeAsync_WithInvalidToken_ShouldReturnForbid()
165+
{
166+
var validatorMock = new Mock<IBewitTokenValidator<string>>();
167+
168+
validatorMock
169+
.Setup(v => v.ValidateBewitTokenAsync(
170+
It.IsAny<BewitToken<string>>(), It.IsAny<CancellationToken>()))
171+
.ThrowsAsync(new BewitNotFoundException());
172+
173+
var (context, _) = CreateContext(
174+
validator: validatorMock.Object,
175+
queryString: "?bewit=invalid-token");
176+
177+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
178+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
179+
180+
var result = await filter.InvokeAsync(
181+
invocationContext, _ => ValueTask.FromResult<object?>(Results.Ok()));
182+
183+
result.Should().BeOfType<ForbidHttpResult>();
184+
}
185+
186+
[Fact]
187+
public async Task InvokeAsync_WithCustomQueryParam_ShouldReadFromCustomParam()
188+
{
189+
var validatorMock = new Mock<IBewitTokenValidator<string>>();
190+
191+
validatorMock
192+
.Setup(v => v.ValidateBewitTokenAsync(
193+
It.IsAny<BewitToken<string>>(), It.IsAny<CancellationToken>()))
194+
.ReturnsAsync("payload-value");
195+
196+
var (context, _) = CreateContext(
197+
validator: validatorMock.Object,
198+
queryString: "?token=custom-token",
199+
configureOptions: o => o.QueryParamName = "token");
200+
201+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
202+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
203+
var nextCalled = false;
204+
205+
var result = await filter.InvokeAsync(
206+
invocationContext,
207+
_ =>
208+
{
209+
nextCalled = true;
210+
211+
return ValueTask.FromResult<object?>(Results.Ok());
212+
});
213+
214+
nextCalled.Should().BeTrue();
215+
}
216+
217+
[Fact]
218+
public async Task InvokeAsync_WithValidToken_ShouldSetPayloadOnAccessor()
219+
{
220+
var validatorMock = new Mock<IBewitTokenValidator<string>>();
221+
222+
validatorMock
223+
.Setup(v => v.ValidateBewitTokenAsync(
224+
It.IsAny<BewitToken<string>>(), It.IsAny<CancellationToken>()))
225+
.ReturnsAsync("expected-payload");
226+
227+
var (context, services) = CreateContext(
228+
validator: validatorMock.Object,
229+
headerName: "bewitToken",
230+
headerValue: "valid-token");
231+
232+
var filter = new Bewit.Http.BewitEndpointFilter<string>();
233+
var invocationContext = new DefaultEndpointFilterInvocationContext(context);
234+
235+
await filter.InvokeAsync(
236+
invocationContext, _ => ValueTask.FromResult<object?>(Results.Ok()));
237+
238+
var accessor = services.GetRequiredService<IHttpContextAccessor>();
239+
var payload = accessor.GetBewitPayload<string>();
240+
payload.Should().Be("expected-payload");
241+
}
242+
}

0 commit comments

Comments
 (0)