Skip to content

Commit c2d63f7

Browse files
davidortinauCopilot
andcommitted
feat: add IdentityJwt scheme to CoreSync + auth integration tests (#85, #86)
D1: Add IdentityJwt Bearer scheme to CoreSync Web server (Program.cs) - Register JwtBearer 'IdentityJwt' scheme alongside Entra ID / DevAuth - Add Microsoft.AspNetCore.Authentication.JwtBearer package to Web.csproj - Validates tokens using Jwt:SigningKey/Issuer/Audience from config D2: Fix and extend auth integration tests - Update TestJwtGenerator issuer/audience to match Identity JWT defaults - Fix test factories: use UseSetting for config, Development environment, isolated SQLite DB with EnsureCreated for Identity tables - JwtBearerApiFactory overrides default scheme to IdentityJwt - Add IdentityAuthTests: register, login (unconfirmed/wrong pw), confirm email, refresh token flow - Align package versions (JwtBearer/Mvc.Testing to 10.0.2) All 16 tests pass. AppHost build succeeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a6cfe1d commit c2d63f7

File tree

7 files changed

+245
-33
lines changed

7 files changed

+245
-33
lines changed

src/SentenceStudio.Web/Program.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11

2+
using System.Text;
23
using CoreSync;
34
using CoreSync.Http.Server;
45
using Microsoft.AspNetCore.Authentication;
6+
using Microsoft.AspNetCore.Authentication.JwtBearer;
57
using Microsoft.Identity.Web;
8+
using Microsoft.IdentityModel.Tokens;
69
using SentenceStudio.Data;
710
using SentenceStudio.Web;
811
using SentenceStudio.Web.Auth;
@@ -37,6 +40,24 @@
3740
builder.Services.AddAuthorization();
3841
}
3942

43+
// Accept Identity JWTs alongside Entra ID / DevAuth
44+
builder.Services.AddAuthentication()
45+
.AddJwtBearer("IdentityJwt", options =>
46+
{
47+
options.TokenValidationParameters = new TokenValidationParameters
48+
{
49+
ValidateIssuer = true,
50+
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "SentenceStudio",
51+
ValidateAudience = true,
52+
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "SentenceStudio.Api",
53+
ValidateIssuerSigningKey = true,
54+
IssuerSigningKey = new SymmetricSecurityKey(
55+
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]
56+
?? "DevelopmentSigningKey-AtLeast32Chars!!")),
57+
ValidateLifetime = true
58+
};
59+
});
60+
4061
builder.Services.AddDataServices(databasePath);
4162
builder.Services.AddSyncServices(databasePath);
4263

src/SentenceStudio.Web/SentenceStudio.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1515
<PrivateAssets>all</PrivateAssets>
1616
</PackageReference>
17+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
1718
<PackageReference Include="Microsoft.Identity.Web" Version="3.8.2" />
1819
</ItemGroup>
1920

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using Microsoft.AspNetCore.Identity;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using SentenceStudio.Api.Auth;
6+
using SentenceStudio.Api.Tests.Infrastructure;
7+
using SentenceStudio.Shared.Models;
8+
9+
namespace SentenceStudio.Api.Tests;
10+
11+
/// <summary>
12+
/// Integration tests for the Identity auth endpoints (register, login, confirm-email, refresh).
13+
/// Uses JwtBearerApiFactory so Identity services and JWT signing are configured.
14+
/// </summary>
15+
public class IdentityAuthTests : IClassFixture<JwtBearerApiFactory>
16+
{
17+
private readonly JwtBearerApiFactory _factory;
18+
private readonly HttpClient _client;
19+
20+
public IdentityAuthTests(JwtBearerApiFactory factory)
21+
{
22+
_factory = factory;
23+
_client = factory.CreateClient();
24+
}
25+
26+
[Fact]
27+
public async Task Register_ReturnsOk()
28+
{
29+
var email = $"register-ok-{Guid.NewGuid():N}@test.local";
30+
31+
var response = await _client.PostAsJsonAsync("/api/auth/register", new
32+
{
33+
Email = email,
34+
Password = "Test1234!",
35+
DisplayName = "Tester"
36+
});
37+
38+
response.StatusCode.Should().Be(HttpStatusCode.OK,
39+
"register should succeed for a new user");
40+
}
41+
42+
[Fact]
43+
public async Task Login_UnconfirmedEmail_Returns401()
44+
{
45+
var email = $"unconfirmed-{Guid.NewGuid():N}@test.local";
46+
47+
// Register but do NOT confirm email
48+
var reg = await _client.PostAsJsonAsync("/api/auth/register", new
49+
{
50+
Email = email,
51+
Password = "Test1234!",
52+
});
53+
reg.EnsureSuccessStatusCode();
54+
55+
var response = await _client.PostAsJsonAsync("/api/auth/login", new
56+
{
57+
Email = email,
58+
Password = "Test1234!"
59+
});
60+
61+
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
62+
"login should be rejected when email is not confirmed");
63+
}
64+
65+
[Fact]
66+
public async Task Login_WrongPassword_Returns401()
67+
{
68+
var email = $"wrongpw-{Guid.NewGuid():N}@test.local";
69+
70+
// Register and confirm
71+
await RegisterAndConfirmAsync(email, "Test1234!");
72+
73+
var response = await _client.PostAsJsonAsync("/api/auth/login", new
74+
{
75+
Email = email,
76+
Password = "WrongPassword99!"
77+
});
78+
79+
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized,
80+
"login should be rejected with wrong password");
81+
}
82+
83+
[Fact]
84+
public async Task ConfirmEmail_ThenLogin_Succeeds()
85+
{
86+
var email = $"confirm-{Guid.NewGuid():N}@test.local";
87+
const string password = "Test1234!";
88+
89+
// Register
90+
var reg = await _client.PostAsJsonAsync("/api/auth/register", new
91+
{
92+
Email = email,
93+
Password = password,
94+
});
95+
reg.EnsureSuccessStatusCode();
96+
97+
// Confirm email via UserManager
98+
await ConfirmEmailDirectlyAsync(email);
99+
100+
// Login should now succeed
101+
var response = await _client.PostAsJsonAsync("/api/auth/login", new
102+
{
103+
Email = email,
104+
Password = password
105+
});
106+
107+
response.StatusCode.Should().Be(HttpStatusCode.OK,
108+
"login should succeed after email confirmation");
109+
110+
var auth = await response.Content.ReadFromJsonAsync<AuthResponse>();
111+
auth.Should().NotBeNull();
112+
auth!.Token.Should().NotBeNullOrWhiteSpace("JWT should be returned");
113+
auth.RefreshToken.Should().NotBeNullOrWhiteSpace("refresh token should be returned");
114+
}
115+
116+
[Fact]
117+
public async Task RefreshToken_ReturnsNewTokens()
118+
{
119+
var email = $"refresh-{Guid.NewGuid():N}@test.local";
120+
const string password = "Test1234!";
121+
122+
await RegisterAndConfirmAsync(email, password);
123+
124+
// Login to get initial tokens
125+
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
126+
{
127+
Email = email,
128+
Password = password
129+
});
130+
loginResponse.EnsureSuccessStatusCode();
131+
132+
var initial = await loginResponse.Content.ReadFromJsonAsync<AuthResponse>();
133+
initial.Should().NotBeNull();
134+
135+
// Use refresh token to get new tokens
136+
var refreshResponse = await _client.PostAsJsonAsync("/api/auth/refresh", new
137+
{
138+
RefreshToken = initial!.RefreshToken
139+
});
140+
141+
refreshResponse.StatusCode.Should().Be(HttpStatusCode.OK,
142+
"refresh should return new tokens");
143+
144+
var refreshed = await refreshResponse.Content.ReadFromJsonAsync<AuthResponse>();
145+
refreshed.Should().NotBeNull();
146+
refreshed!.Token.Should().NotBeNullOrWhiteSpace("new JWT should be returned");
147+
refreshed.RefreshToken.Should().NotBeNullOrWhiteSpace("new refresh token should be returned");
148+
refreshed.RefreshToken.Should().NotBe(initial.RefreshToken,
149+
"refresh token should be rotated");
150+
}
151+
152+
// -- helpers --
153+
154+
private async Task RegisterAndConfirmAsync(string email, string password)
155+
{
156+
var reg = await _client.PostAsJsonAsync("/api/auth/register", new
157+
{
158+
Email = email,
159+
Password = password,
160+
});
161+
reg.EnsureSuccessStatusCode();
162+
await ConfirmEmailDirectlyAsync(email);
163+
}
164+
165+
private async Task ConfirmEmailDirectlyAsync(string email)
166+
{
167+
using var scope = _factory.Services.CreateScope();
168+
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
169+
var user = await userManager.FindByEmailAsync(email);
170+
user.Should().NotBeNull($"user {email} should exist after registration");
171+
172+
var token = await userManager.GenerateEmailConfirmationTokenAsync(user!);
173+
var result = await userManager.ConfirmEmailAsync(user!, token);
174+
result.Succeeded.Should().BeTrue("email confirmation should succeed");
175+
}
176+
}

tests/SentenceStudio.Api.Tests/Infrastructure/DevAuthApiFactory.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,14 @@ namespace SentenceStudio.Api.Tests.Infrastructure;
77
/// <summary>
88
/// Test host using the default DevAuthHandler configuration.
99
/// All requests are auto-authenticated with dev claims.
10-
/// Explicitly sets Auth:UseEntraId=false (local development mode).
10+
/// Uses Development environment so DevAuthHandler path is active.
1111
/// </summary>
1212
public class DevAuthApiFactory : WebApplicationFactory<Program>
1313
{
1414
protected override void ConfigureWebHost(IWebHostBuilder builder)
1515
{
16-
builder.UseEnvironment("Testing");
16+
builder.UseEnvironment("Development");
1717

18-
builder.ConfigureAppConfiguration((context, config) =>
19-
{
20-
config.AddInMemoryCollection(new Dictionary<string, string?>
21-
{
22-
["Auth:UseEntraId"] = "false"
23-
});
24-
});
18+
builder.UseSetting("Auth:UseEntraId", "false");
2519
}
2620
}

tests/SentenceStudio.Api.Tests/Infrastructure/JwtBearerApiFactory.cs

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,64 @@
22
using Microsoft.AspNetCore.Authentication.JwtBearer;
33
using Microsoft.AspNetCore.Hosting;
44
using Microsoft.AspNetCore.Mvc.Testing;
5+
using Microsoft.EntityFrameworkCore;
56
using Microsoft.Extensions.Configuration;
67
using Microsoft.Extensions.DependencyInjection;
8+
using SentenceStudio.Data;
79

810
namespace SentenceStudio.Api.Tests.Infrastructure;
911

1012
/// <summary>
11-
/// Test host configured with JWT Bearer authentication.
12-
/// Replaces DevAuthHandler with real JWT validation using a test signing key.
13-
/// Explicitly sets Auth:UseEntraId=true to simulate the production Entra ID path.
13+
/// Test host configured with IdentityJwt Bearer authentication.
14+
/// Uses an isolated SQLite database per factory instance.
15+
/// Sets Auth:UseEntraId=false with Development environment, then overrides
16+
/// the default auth scheme to IdentityJwt.
1417
/// </summary>
1518
public class JwtBearerApiFactory : WebApplicationFactory<Program>
1619
{
20+
private readonly string _dbPath = Path.Combine(Path.GetTempPath(),
21+
$"sentencestudio_test_{Guid.NewGuid():N}.db");
22+
1723
protected override void ConfigureWebHost(IWebHostBuilder builder)
1824
{
19-
builder.UseEnvironment("Testing");
25+
builder.UseEnvironment("Development");
2026

21-
builder.ConfigureAppConfiguration((context, config) =>
22-
{
23-
config.AddInMemoryCollection(new Dictionary<string, string?>
24-
{
25-
["Auth:UseEntraId"] = "true"
26-
});
27-
});
27+
builder.UseSetting("Auth:UseEntraId", "false");
28+
builder.UseSetting("Jwt:SigningKey", TestJwtGenerator.TestSigningKeyValue);
29+
builder.UseSetting("Jwt:Issuer", TestJwtGenerator.TestIssuer);
30+
builder.UseSetting("Jwt:Audience", TestJwtGenerator.TestAudience);
2831

2932
builder.ConfigureServices(services =>
3033
{
31-
// Override auth to use JWT Bearer instead of DevAuthHandler
34+
// Replace the production DbContext with a test-specific SQLite file
35+
var descriptor = services.SingleOrDefault(
36+
d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
37+
if (descriptor != null)
38+
services.Remove(descriptor);
39+
40+
services.AddDbContext<ApplicationDbContext>(options =>
41+
options.UseSqlite($"Data Source={_dbPath}"));
42+
43+
// Override default auth to use IdentityJwt scheme (already registered by the API
44+
// because we provide Jwt:SigningKey via UseSetting above)
3245
services.Configure<AuthenticationOptions>(options =>
3346
{
34-
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
35-
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
47+
options.DefaultAuthenticateScheme = "IdentityJwt";
48+
options.DefaultChallengeScheme = "IdentityJwt";
3649
});
3750

38-
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
39-
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
40-
{
41-
options.TokenValidationParameters = TestJwtGenerator.CreateValidationParameters();
42-
});
51+
// Ensure the database schema is created
52+
var sp = services.BuildServiceProvider();
53+
using var scope = sp.CreateScope();
54+
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
55+
db.Database.EnsureCreated();
4356
});
4457
}
58+
59+
protected override void Dispose(bool disposing)
60+
{
61+
base.Dispose(disposing);
62+
if (File.Exists(_dbPath))
63+
File.Delete(_dbPath);
64+
}
4565
}

tests/SentenceStudio.Api.Tests/Infrastructure/TestJwtGenerator.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ namespace SentenceStudio.Api.Tests.Infrastructure;
1212
/// </summary>
1313
public static class TestJwtGenerator
1414
{
15-
private const string TestSigningKeyValue = "SuperSecretTestKey-AtLeast32Characters!!";
15+
public const string TestSigningKeyValue = "SuperSecretTestKey-AtLeast32Characters!!";
1616
public static readonly SymmetricSecurityKey SecurityKey =
1717
new(Encoding.UTF8.GetBytes(TestSigningKeyValue));
1818

19-
public const string TestIssuer = "https://test-sts.sentencestudio.local";
20-
public const string TestAudience = "api://sentencestudio-test";
19+
public const string TestIssuer = "SentenceStudio";
20+
public const string TestAudience = "SentenceStudio.Api";
2121

2222
public const string DefaultTenantId = "49c0cd14-bc68-4c6d-b87b-9d65a56fa6df";
2323
public const string DefaultUserId = "test-user-oid-12345";

tests/SentenceStudio.Api.Tests/SentenceStudio.Api.Tests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
13-
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
12+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
13+
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
1414
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
1515
<PackageReference Include="FluentAssertions" Version="6.12.0" />
1616
<PackageReference Include="xunit" Version="2.9.2" />

0 commit comments

Comments
 (0)