Skip to content

Commit 0b1f7ce

Browse files
CopilotLeftofZen
andauthored
Enable ASP.NET Identity with JWT bearer token authentication (#220)
* Initial plan * Enable ASP.NET Identity authentication and authorization system Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> * Add integration tests for Identity endpoints and fix test infrastructure Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> * Address code review: improve null handling in test setup Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> * Implement JWT bearer token authentication Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> * formatting * Address code review feedback: fix middleware order, improve security, add authenticated test Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> Co-authored-by: Benjamin Sutas <benjamin.sutas@gmail.com>
1 parent 78bf89a commit 0b1f7ce

6 files changed

Lines changed: 268 additions & 49 deletions

File tree

ObjectService/Program.cs

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
using System.Threading.RateLimiting;
1010
using Definitions.ObjectModels;
1111
using Microsoft.OpenApi;
12+
using Microsoft.AspNetCore.Authentication.JwtBearer;
13+
using Microsoft.IdentityModel.Tokens;
14+
using System.Text;
15+
using Microsoft.AspNetCore.Identity;
16+
using Microsoft.AspNetCore.Authentication.BearerToken;
1217

1318
var builder = WebApplication.CreateBuilder(args);
1419

@@ -117,47 +122,57 @@
117122
};
118123
}));
119124

120-
//builder.Services
121-
//.AddIdentityApiEndpoints<TblUser>()
122-
//.AddEntityFrameworkStores<LocoDbContext>();
123-
124-
//builder.Services.AddAuthentication(options =>
125-
//{
126-
// options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
127-
// options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
128-
//})
129-
//.AddJwtBearer(options =>
130-
//{
131-
// options.TokenValidationParameters = new TokenValidationParameters
132-
// {
133-
// ValidateIssuer = true,
134-
// ValidateAudience = true,
135-
// ValidateLifetime = true,
136-
// ValidateIssuerSigningKey = true,
137-
// ValidIssuer = builder.Configuration["JwtSettings:Issuer"],
138-
// ValidAudience = builder.Configuration["JwtSettings:Audience"],
139-
// IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:Key"])),
140-
// };
141-
//});
142-
143-
//builder.Services.AddAuthorization();
144-
//builder.Services
145-
// .AddAuthorizationBuilder()
146-
// .AddPolicy(AdminPolicy.Name, AdminPolicy.Build);
125+
builder.Services
126+
.AddIdentityApiEndpoints<TblUser>()
127+
.AddEntityFrameworkStores<LocoDbContext>();
128+
129+
// Configure bearer token expiration from settings
130+
builder.Services.Configure<BearerTokenOptions>(IdentityConstants.BearerScheme, options =>
131+
{
132+
var durationInMinutes = builder.Configuration.GetValue<int?>("JwtSettings:DurationInMinutes") ?? 60;
133+
options.BearerTokenExpiration = TimeSpan.FromMinutes(durationInMinutes);
134+
});
135+
136+
builder.Services.AddAuthentication()
137+
.AddJwtBearer(options =>
138+
{
139+
options.TokenValidationParameters = new TokenValidationParameters
140+
{
141+
ValidateIssuer = true,
142+
ValidateAudience = true,
143+
ValidateLifetime = true,
144+
ValidateIssuerSigningKey = true,
145+
ValidIssuer = builder.Configuration["JwtSettings:Issuer"],
146+
ValidAudience = builder.Configuration["JwtSettings:Audience"],
147+
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:Key"] ?? throw new InvalidOperationException("JWT Key not configured"))),
148+
};
149+
});
150+
151+
builder.Services.AddAuthorization(options =>
152+
{
153+
// Configure the default policy to accept both Identity Bearer tokens and JWT tokens
154+
options.DefaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
155+
.AddAuthenticationSchemes(IdentityConstants.BearerScheme, JwtBearerDefaults.AuthenticationScheme)
156+
.RequireAuthenticatedUser()
157+
.Build();
158+
});
147159

148160
// Used for the Identity stuff to send emails to users
149161
// disabling this line effectively disables all email sending, as a default NoOpEmailSender is used in place
150-
//builder.Services.AddTransient<IEmailSender, EmailSender>();
162+
// builder.Services.AddTransient<IEmailSender, EmailSender>();
151163

152164
var app = builder.Build();
153165

154166
app.UseForwardedHeaders();
155167
app.UseHttpLogging();
156168
app.UseRateLimiter();
157-
//app.MapLocoIdentityApi<TblUser>();
169+
app.UseAuthentication();
170+
app.UseAuthorization();
158171

159-
// defining routes here, after MapLocoIdentityApi, will overwrite them, allowing us to customise them
160-
//app.MapPost("/register", () => Results.Ok());
172+
app.MapIdentityApi<TblUser>();
173+
174+
// defining routes here, after MapIdentityApi, will overwrite them, allowing us to customise them
175+
// app.MapPost("/register", () => Results.Ok());
161176

162177
_ = app
163178
.MapHealthChecks("/health")
@@ -181,15 +196,11 @@
181196
_ = options
182197
.WithTitle("OpenLoco Object Service")
183198
.WithTheme(ScalarTheme.Solarized)
184-
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
185-
186-
//.AddPreferredSecuritySchemes("Bearer");
199+
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
200+
.AddPreferredSecuritySchemes("Bearer");
187201
});
188202
}
189203

190-
//app.UseAuthentication();
191-
//app.UseAuthorization();
192-
193204
app.Run();
194205

195206
#pragma warning disable CA1050 // Declare types in namespaces

ObjectService/RouteHandlers/V1RouteBuilderExtensions.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@ public static IEndpointConventionBuilder MapV2Routes(this IEndpointRouteBuilder
2121
{
2222
var v2 = endpoints.MapGroup(RoutesV2.Prefix);
2323
_ = v2.MapServerRoutes();
24-
25-
#if DEBUG
26-
// not ready for prime time yet
2724
_ = v2.MapAdminRoutes().RequireAuthorization();
28-
#endif
2925

3026
return v2;
3127
}

ObjectService/appsettings.Development.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,11 @@
44
"Default": "Information",
55
"Microsoft.AspNetCore": "Warning"
66
}
7+
},
8+
"JwtSettings": {
9+
"Key": "ThisIsAVerySecretKeyThatShouldBeAtLeast32CharactersLongAndStoredSecurelyInProduction",
10+
"Issuer": "https://openloco.leftofzen.dev",
11+
"Audience": "https://openloco.leftofzen.dev",
12+
"DurationInMinutes": 60
713
}
814
}

ObjectService/appsettings.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@
3333
"TokensReplenishedPerPeriod": 50,
3434
"AutoReplenishment": true
3535
}
36-
},
37-
//"JwtSettings": {
38-
// "Key": "ThisIsAVerySecretKeyThatShouldBeAtLeast32CharactersLongAndStoredSecurelyInProduction", // MUST be strong and secret!
39-
// "Issuer": "https://openloco.leftofzen.dev", // Your API's URL
40-
// "Audience": "???", // The client's URL or a logical name for the audience
41-
// "DurationInMinutes": 60 // How long the token is valid
42-
//},
36+
}
37+
// JWT Settings should be configured via environment variables or user secrets in production:
38+
// - JwtSettings:Key (required, minimum 32 characters)
39+
// - JwtSettings:Issuer (required)
40+
// - JwtSettings:Audience (required)
41+
// - JwtSettings:DurationInMinutes (optional, defaults to 60)
42+
// For development, these are configured in appsettings.Development.json
4343
//"SmtpSettings": {
4444
// "Host": "smtp.yourprovider.com", // e.g., smtp.gmail.com, smtp.sendgrid.net
4545
// "Port": "587", // Often 587 for TLS, or 465 for SSL
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using Definitions.Database;
2+
using Definitions.DTO.Identity;
3+
using Definitions.Web;
4+
using NUnit.Framework;
5+
using System.Net;
6+
using System.Net.Http.Json;
7+
8+
namespace ObjectService.Tests.Integration;
9+
10+
[TestFixture]
11+
public class IdentityRoutesTest : BaseRouteHandlerTestFixture
12+
{
13+
public override string BaseRoute => string.Empty;
14+
15+
protected override Task SeedDataCoreAsync(LocoDbContext db)
16+
{
17+
// No seed data needed for identity tests
18+
return Task.CompletedTask;
19+
}
20+
21+
[Test]
22+
[Ignore("Not applicable for identity endpoints")]
23+
public override async Task ListAsync()
24+
{
25+
// Not applicable for identity endpoints
26+
await Task.CompletedTask;
27+
}
28+
29+
[Test]
30+
[Ignore("Not applicable for identity endpoints")]
31+
public override async Task PostAsync()
32+
{
33+
// Not applicable for identity endpoints
34+
await Task.CompletedTask;
35+
}
36+
37+
[Test]
38+
[Ignore("Not applicable for identity endpoints")]
39+
public override async Task GetAsync()
40+
{
41+
// Not applicable for identity endpoints
42+
await Task.CompletedTask;
43+
}
44+
45+
[Test]
46+
[Ignore("Not applicable for identity endpoints")]
47+
public override async Task PutAsync()
48+
{
49+
// Not applicable for identity endpoints
50+
await Task.CompletedTask;
51+
}
52+
53+
[Test]
54+
[Ignore("Not applicable for identity endpoints")]
55+
public override async Task DeleteAsync()
56+
{
57+
// Not applicable for identity endpoints
58+
await Task.CompletedTask;
59+
}
60+
61+
[Test]
62+
public async Task Register_ShouldSucceed()
63+
{
64+
// arrange
65+
var registerRequest = new DtoRegisterRequest(
66+
Email: "test@example.com",
67+
UserName: "testuser",
68+
Password: "TestPassword123!"
69+
);
70+
71+
// act
72+
var response = await HttpClient!.PostAsJsonAsync("/register", registerRequest);
73+
74+
// assert
75+
Assert.That(response.IsSuccessStatusCode, Is.True);
76+
}
77+
78+
[Test]
79+
public async Task Register_WithInvalidEmail_ShouldFail()
80+
{
81+
// arrange
82+
var registerRequest = new DtoRegisterRequest(
83+
Email: "invalid-email",
84+
UserName: "testuser",
85+
Password: "TestPassword123!"
86+
);
87+
88+
// act
89+
var response = await HttpClient!.PostAsJsonAsync("/register", registerRequest);
90+
91+
// assert
92+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
93+
}
94+
95+
[Test]
96+
public async Task Login_WithValidCredentials_ShouldSucceed()
97+
{
98+
// arrange - First register a user
99+
var registerRequest = new DtoRegisterRequest(
100+
Email: "login@example.com",
101+
UserName: "loginuser",
102+
Password: "TestPassword123!"
103+
);
104+
_ = await HttpClient!.PostAsJsonAsync("/register", registerRequest);
105+
106+
var loginRequest = new
107+
{
108+
Email = "login@example.com",
109+
Password = "TestPassword123!"
110+
};
111+
112+
// act
113+
var response = await HttpClient!.PostAsJsonAsync("/login?useCookies=false", loginRequest);
114+
115+
// assert
116+
Assert.That(response.IsSuccessStatusCode, Is.True);
117+
var result = await response.Content.ReadAsStringAsync();
118+
Assert.That(result, Is.Not.Empty);
119+
}
120+
121+
[Test]
122+
public async Task Login_WithInvalidCredentials_ShouldFail()
123+
{
124+
// arrange
125+
var loginRequest = new
126+
{
127+
Email = "nonexistent@example.com",
128+
Password = "WrongPassword123!"
129+
};
130+
131+
// act
132+
var response = await HttpClient!.PostAsJsonAsync("/login?useCookies=false", loginRequest);
133+
134+
// assert
135+
Assert.That(response.IsSuccessStatusCode, Is.False);
136+
}
137+
138+
[Test]
139+
public async Task Users_WithoutAuthentication_ShouldReturnUnauthorized()
140+
{
141+
// act
142+
var response = await HttpClient!.GetAsync($"{RoutesV2.Prefix}{RoutesV2.Users}");
143+
144+
// assert
145+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
146+
}
147+
148+
[Test]
149+
public async Task Roles_WithoutAuthentication_ShouldReturnUnauthorized()
150+
{
151+
// act
152+
var response = await HttpClient!.GetAsync($"{RoutesV2.Prefix}{RoutesV2.Roles}");
153+
154+
// assert
155+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
156+
}
157+
158+
[Test]
159+
public async Task Users_WithAuthentication_ShouldSucceed()
160+
{
161+
// arrange - Register a user
162+
var registerRequest = new DtoRegisterRequest(
163+
Email: "authtest@example.com",
164+
UserName: "authuser",
165+
Password: "TestPassword123!"
166+
);
167+
_ = await HttpClient!.PostAsJsonAsync("/register", registerRequest);
168+
169+
// Login to get bearer token
170+
var loginRequest = new
171+
{
172+
Email = "authtest@example.com",
173+
Password = "TestPassword123!"
174+
};
175+
var loginResponse = await HttpClient!.PostAsJsonAsync("/login?useCookies=false", loginRequest);
176+
Assert.That(loginResponse.IsSuccessStatusCode, Is.True);
177+
178+
var loginResult = await loginResponse.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
179+
var accessToken = loginResult.GetProperty("accessToken").GetString();
180+
Assert.That(accessToken, Is.Not.Null.And.Not.Empty);
181+
182+
// Add token to Authorization header
183+
HttpClient!.DefaultRequestHeaders.Authorization =
184+
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
185+
186+
// act - Call protected endpoint with valid bearer token
187+
var response = await HttpClient!.GetAsync($"{RoutesV2.Prefix}{RoutesV2.Users}");
188+
189+
// assert - Should succeed with valid authentication
190+
Assert.That(response.IsSuccessStatusCode, Is.True);
191+
}
192+
}

Tests/ObjectServiceIntegrationTests/TestWebApplicationFactory.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,29 @@ public class TestWebApplicationFactory<TProgram>
4343
return testDirectory;
4444
}
4545

46+
static void CreateDummyPaletteFile(string path)
47+
{
48+
// Create a 16x16 pixel PNG file for testing (palette map expects 16x16)
49+
using var image = new SixLabors.ImageSharp.Image<SixLabors.ImageSharp.PixelFormats.Rgba32>(16, 16);
50+
using var stream = File.Create(path);
51+
image.Save(stream, new SixLabors.ImageSharp.Formats.Png.PngEncoder());
52+
}
53+
4654
protected override void ConfigureWebHost(IWebHostBuilder builder)
4755
{
4856
var testFolder = MakeServerFolderManagerTestDirectories();
57+
ArgumentNullException.ThrowIfNull(testFolder, nameof(testFolder));
58+
59+
// Create a dummy palette file for testing
60+
var dummyPaletteFile = Path.Combine(testFolder.FullName, "palette.png");
61+
CreateDummyPaletteFile(dummyPaletteFile);
4962

5063
var testConfigurationBuilder =
5164
new ConfigurationBuilder()
5265
.AddInMemoryCollection(
5366
[
54-
new("ObjectService:RootFolder", testFolder?.FullName),
67+
new("ObjectService:RootFolder", testFolder.FullName),
68+
new("ObjectService:PaletteMapFile", dummyPaletteFile),
5569
new("ObjectService:ShowScalar", "False"),
5670
])
5771
.Build();

0 commit comments

Comments
 (0)