Skip to content

Commit c620fd9

Browse files
davidortinauCopilot
andcommitted
feat(auth): add Identity auth service, login/register pages (#82, #83, #84)
C1: IdentityAuthService - JWT-based auth against API Identity endpoints - Implements IAuthService with email/password sign-in - Token storage via ISecureStorageService (auth_jwt, auth_refresh, auth_expires) - Auto-refresh on GetAccessTokenAsync when token is expired - JWT claim parsing for UserName - Added SignInAsync(email, password) and RegisterAsync to IAuthService - Updated DevAuthService (returns null) and MsalAuthService (throws NotSupportedException) C2: Login and Register Blazor pages - /auth/login - email/password form with error handling - /auth/register - registration form with email confirmation flow - Updated /auth page with Sign In / Create Account buttons + local user selection C3: Updated AuthenticatedHttpMessageHandler - Removed scope-length guard so Identity tokens (no scopes) are attached - Updated ServiceCollectionExtentions to register IdentityAuthService when Auth:UseEntraId is false, and register AuthClient named HttpClient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f8d1df0 commit c620fd9

File tree

10 files changed

+551
-13
lines changed

10 files changed

+551
-13
lines changed

src/SentenceStudio.AppLib/SentenceStudio.AppLib.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
<PackageReference Include="ElevenLabs-DotNet" Version="3.7.1" />
5353
<PackageReference Include="NAudio" Version="2.2.1" />
5454
<PackageReference Include="NLayer.NAudioSupport" Version="1.4.0" />
55+
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
5556
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
5657
<PackageReference Include="AsyncFixer" Version="1.6.0">
5758
<PrivateAssets>all</PrivateAssets>

src/SentenceStudio.AppLib/ServiceCollectionExtentions.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,19 @@ public static IServiceCollection AddAuthServices(this IServiceCollection service
7171
}
7272
else
7373
{
74-
services.AddSingleton<IAuthService, DevAuthService>();
74+
services.AddSingleton<IAuthService, IdentityAuthService>();
75+
}
76+
77+
// Register a named HttpClient for auth endpoints (login, register, refresh).
78+
// Uses the same API base URL as other clients but without the auth handler
79+
// to avoid a circular dependency (auth client cannot require auth).
80+
var apiBaseUrl = configuration.GetValue<string>("ApiBaseUrl");
81+
if (!string.IsNullOrEmpty(apiBaseUrl))
82+
{
83+
services.AddHttpClient("AuthClient", client =>
84+
{
85+
client.BaseAddress = new Uri(apiBaseUrl);
86+
});
7587
}
7688

7789
services.AddTransient<AuthenticatedHttpMessageHandler>();

src/SentenceStudio.AppLib/Services/AuthenticatedHttpMessageHandler.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ namespace SentenceStudio.Services;
99

1010
/// <summary>
1111
/// Delegating handler that attaches a Bearer token to outgoing requests.
12-
/// Attempts token acquisition unconditionally. If it returns null, the
13-
/// request proceeds without an Authorization header so the server's
14-
/// DevAuthHandler handles unauthenticated requests during development.
12+
/// For Entra ID, uses configured scopes. For Identity auth (no scopes configured),
13+
/// requests the token with an empty scope array — IdentityAuthService returns the
14+
/// stored JWT regardless of scopes.
15+
/// If token acquisition returns null, the request proceeds unauthenticated so the
16+
/// server's DevAuthHandler can handle it during development.
1517
/// </summary>
1618
public class AuthenticatedHttpMessageHandler : DelegatingHandler
1719
{
@@ -34,9 +36,6 @@ public AuthenticatedHttpMessageHandler(
3436
protected override async Task<HttpResponseMessage> SendAsync(
3537
HttpRequestMessage request, CancellationToken cancellationToken)
3638
{
37-
if (_defaultScopes.Length == 0)
38-
return await base.SendAsync(request, cancellationToken);
39-
4039
try
4140
{
4241
var token = await _authService.GetAccessTokenAsync(_defaultScopes);

src/SentenceStudio.AppLib/Services/DevAuthService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ public class DevAuthService : IAuthService
1313
public string? UserName => "dev@localhost";
1414

1515
public Task<AuthResult?> SignInAsync() => Task.FromResult<AuthResult?>(null);
16+
public Task<AuthResult?> SignInAsync(string email, string password) => Task.FromResult<AuthResult?>(null);
17+
public Task<AuthResult?> RegisterAsync(string email, string password, string displayName) => Task.FromResult<AuthResult?>(null);
1618
public Task SignOutAsync() => Task.CompletedTask;
1719
public Task<string?> GetAccessTokenAsync(string[] scopes) => Task.FromResult<string?>(null);
1820
}

src/SentenceStudio.AppLib/Services/IAuthService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ namespace SentenceStudio.Services;
55
public interface IAuthService
66
{
77
Task<AuthResult?> SignInAsync();
8+
Task<AuthResult?> SignInAsync(string email, string password);
9+
Task<AuthResult?> RegisterAsync(string email, string password, string displayName);
810
Task SignOutAsync();
911
Task<string?> GetAccessTokenAsync(string[] scopes);
1012
bool IsSignedIn { get; }
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Net.Http;
3+
using System.Net.Http.Json;
4+
using System.Security.Claims;
5+
using Microsoft.Extensions.Logging;
6+
using SentenceStudio.Abstractions;
7+
8+
namespace SentenceStudio.Services;
9+
10+
/// <summary>
11+
/// Auth service that authenticates against the API's ASP.NET Identity endpoints
12+
/// using email/password credentials and JWT tokens.
13+
/// </summary>
14+
public sealed class IdentityAuthService : IAuthService
15+
{
16+
private const string JwtKey = "auth_jwt";
17+
private const string RefreshKey = "auth_refresh";
18+
private const string ExpiresKey = "auth_expires";
19+
20+
private readonly HttpClient _http;
21+
private readonly ISecureStorageService _secureStorage;
22+
private readonly ILogger<IdentityAuthService> _logger;
23+
24+
private string? _cachedToken;
25+
private DateTimeOffset _cachedExpires;
26+
private string? _cachedUserName;
27+
28+
public IdentityAuthService(
29+
IHttpClientFactory httpClientFactory,
30+
ISecureStorageService secureStorage,
31+
ILogger<IdentityAuthService> logger)
32+
{
33+
_http = httpClientFactory.CreateClient("AuthClient");
34+
_secureStorage = secureStorage;
35+
_logger = logger;
36+
}
37+
38+
public bool IsSignedIn => _cachedToken is not null && _cachedExpires > DateTimeOffset.UtcNow;
39+
40+
public string? UserName => _cachedUserName;
41+
42+
/// <summary>
43+
/// Silent sign-in: tries to restore a session from stored refresh token.
44+
/// Returns null if no stored token or refresh fails (UI should show login).
45+
/// </summary>
46+
public async Task<AuthResult?> SignInAsync()
47+
{
48+
try
49+
{
50+
var refreshToken = await _secureStorage.GetAsync(RefreshKey);
51+
if (string.IsNullOrEmpty(refreshToken))
52+
return null;
53+
54+
return await RefreshTokenAsync(refreshToken);
55+
}
56+
catch (Exception ex)
57+
{
58+
_logger.LogWarning(ex, "Silent sign-in failed");
59+
return null;
60+
}
61+
}
62+
63+
/// <summary>
64+
/// Sign in with email and password against POST /api/auth/login.
65+
/// </summary>
66+
public async Task<AuthResult?> SignInAsync(string email, string password)
67+
{
68+
try
69+
{
70+
var response = await _http.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
71+
72+
if (!response.IsSuccessStatusCode)
73+
{
74+
_logger.LogWarning("Login failed with status {Status}", response.StatusCode);
75+
return null;
76+
}
77+
78+
var authResponse = await response.Content.ReadFromJsonAsync<AuthResponseDto>();
79+
if (authResponse is null)
80+
return null;
81+
82+
await StoreTokens(authResponse);
83+
return ToAuthResult(authResponse);
84+
}
85+
catch (Exception ex)
86+
{
87+
_logger.LogError(ex, "Sign-in with credentials failed");
88+
return null;
89+
}
90+
}
91+
92+
/// <summary>
93+
/// Register a new account via POST /api/auth/register.
94+
/// On success returns an AuthResult if the API auto-logs-in, or null
95+
/// if the user needs to confirm their email first.
96+
/// </summary>
97+
public async Task<AuthResult?> RegisterAsync(string email, string password, string displayName)
98+
{
99+
try
100+
{
101+
var response = await _http.PostAsJsonAsync("/api/auth/register", new
102+
{
103+
Email = email,
104+
Password = password,
105+
DisplayName = displayName
106+
});
107+
108+
if (!response.IsSuccessStatusCode)
109+
{
110+
_logger.LogWarning("Registration failed with status {Status}", response.StatusCode);
111+
return null;
112+
}
113+
114+
// Some APIs return tokens on register; try to read them
115+
try
116+
{
117+
var authResponse = await response.Content.ReadFromJsonAsync<AuthResponseDto>();
118+
if (authResponse?.Token is not null)
119+
{
120+
await StoreTokens(authResponse);
121+
return ToAuthResult(authResponse);
122+
}
123+
}
124+
catch
125+
{
126+
// Registration succeeded but no token returned (email confirmation required)
127+
}
128+
129+
return null;
130+
}
131+
catch (Exception ex)
132+
{
133+
_logger.LogError(ex, "Registration failed");
134+
return null;
135+
}
136+
}
137+
138+
public async Task SignOutAsync()
139+
{
140+
_cachedToken = null;
141+
_cachedExpires = DateTimeOffset.MinValue;
142+
_cachedUserName = null;
143+
144+
_secureStorage.Remove(JwtKey);
145+
_secureStorage.Remove(RefreshKey);
146+
_secureStorage.Remove(ExpiresKey);
147+
148+
_logger.LogInformation("Signed out, tokens cleared");
149+
}
150+
151+
/// <summary>
152+
/// Returns a valid JWT access token. If the cached token is expired,
153+
/// attempts a refresh. Returns null if no valid token is available.
154+
/// </summary>
155+
public async Task<string?> GetAccessTokenAsync(string[] scopes)
156+
{
157+
// Return cached token if still valid (with 60s buffer)
158+
if (_cachedToken is not null && _cachedExpires > DateTimeOffset.UtcNow.AddSeconds(60))
159+
return _cachedToken;
160+
161+
// Try refresh
162+
try
163+
{
164+
var refreshToken = await _secureStorage.GetAsync(RefreshKey);
165+
if (string.IsNullOrEmpty(refreshToken))
166+
return null;
167+
168+
var result = await RefreshTokenAsync(refreshToken);
169+
return result?.AccessToken;
170+
}
171+
catch (Exception ex)
172+
{
173+
_logger.LogWarning(ex, "Token refresh failed");
174+
return null;
175+
}
176+
}
177+
178+
private async Task<AuthResult?> RefreshTokenAsync(string refreshToken)
179+
{
180+
var response = await _http.PostAsJsonAsync("/api/auth/refresh", new { RefreshToken = refreshToken });
181+
182+
if (!response.IsSuccessStatusCode)
183+
{
184+
_logger.LogWarning("Token refresh returned {Status}", response.StatusCode);
185+
// Clear invalid refresh token
186+
_secureStorage.Remove(RefreshKey);
187+
_cachedToken = null;
188+
_cachedExpires = DateTimeOffset.MinValue;
189+
_cachedUserName = null;
190+
return null;
191+
}
192+
193+
var authResponse = await response.Content.ReadFromJsonAsync<AuthResponseDto>();
194+
if (authResponse is null)
195+
return null;
196+
197+
await StoreTokens(authResponse);
198+
return ToAuthResult(authResponse);
199+
}
200+
201+
private async Task StoreTokens(AuthResponseDto response)
202+
{
203+
_cachedToken = response.Token;
204+
_cachedExpires = new DateTimeOffset(response.ExpiresAt, TimeSpan.Zero);
205+
_cachedUserName = response.UserName ?? ExtractUserNameFromJwt(response.Token);
206+
207+
await _secureStorage.SetAsync(JwtKey, response.Token);
208+
await _secureStorage.SetAsync(RefreshKey, response.RefreshToken);
209+
await _secureStorage.SetAsync(ExpiresKey, response.ExpiresAt.ToString("O"));
210+
211+
_logger.LogInformation("Tokens stored, expires at {Expires}", _cachedExpires);
212+
}
213+
214+
private AuthResult ToAuthResult(AuthResponseDto response)
215+
{
216+
return new AuthResult(
217+
response.Token,
218+
response.UserName ?? ExtractUserNameFromJwt(response.Token),
219+
new DateTimeOffset(response.ExpiresAt, TimeSpan.Zero));
220+
}
221+
222+
private static string? ExtractUserNameFromJwt(string token)
223+
{
224+
try
225+
{
226+
var handler = new JwtSecurityTokenHandler();
227+
var jwt = handler.ReadJwtToken(token);
228+
return jwt.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value
229+
?? jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value
230+
?? jwt.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value
231+
?? jwt.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
232+
}
233+
catch
234+
{
235+
return null;
236+
}
237+
}
238+
239+
/// <summary>
240+
/// Maps the API's AuthResponse JSON shape.
241+
/// </summary>
242+
private sealed record AuthResponseDto(
243+
string Token,
244+
string RefreshToken,
245+
DateTime ExpiresAt,
246+
string? UserName);
247+
}

src/SentenceStudio.AppLib/Services/MsalAuthService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ public MsalAuthService(IConfiguration configuration, ILogger<MsalAuthService> lo
5757
}
5858
}
5959

60+
public Task<AuthResult?> SignInAsync(string email, string password)
61+
=> throw new NotSupportedException("Email/password sign-in is not supported with Entra ID. Use interactive sign-in.");
62+
63+
public Task<AuthResult?> RegisterAsync(string email, string password, string displayName)
64+
=> throw new NotSupportedException("Registration is not supported with Entra ID.");
65+
6066
public async Task SignOutAsync()
6167
{
6268
try

src/SentenceStudio.UI/Pages/Auth.razor

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@
66
<div class="card card-ss p-4 mt-4">
77
<h1 class="ss-title1 mb-2">Welcome to SentenceStudio</h1>
88
<p class="ss-body2 text-secondary-ss mb-4">
9-
Select a user to continue, or register to create a new account.
9+
Sign in to your account or select a local user to continue.
1010
</p>
1111

12+
<div class="d-grid gap-2 mb-4">
13+
<a href="/auth/login" class="btn btn-ss-primary btn-lg">
14+
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
15+
</a>
16+
<a href="/auth/register" class="btn btn-ss-secondary btn-lg">
17+
<i class="bi bi-person-plus me-2"></i>Create Account
18+
</a>
19+
</div>
20+
1221
@if (_profiles.Count > 0)
1322
{
14-
<h2 class="ss-title3 mb-2">Choose a user</h2>
23+
<hr class="my-3" />
24+
<h2 class="ss-title3 mb-2">Or choose a local user</h2>
1525
<div class="d-grid gap-2 mb-3">
1626
@foreach (var profile in _profiles)
1727
{
@@ -27,12 +37,11 @@
2737
</button>
2838
}
2939
</div>
30-
<hr class="my-3" />
3140
}
3241

3342
<div class="d-grid gap-2">
34-
<button class="btn btn-ss-primary btn-lg" @onclick="RegisterAsync">
35-
<i class="bi bi-plus-circle me-2"></i>Create New User
43+
<button class="btn btn-outline-secondary btn-lg" @onclick="RegisterLocalAsync">
44+
<i class="bi bi-plus-circle me-2"></i>Create Local User
3645
</button>
3746
</div>
3847
</div>
@@ -65,7 +74,7 @@
6574
NavManager.NavigateTo("/", forceLoad: true);
6675
}
6776

68-
private void RegisterAsync()
77+
private void RegisterLocalAsync()
6978
{
7079
Preferences.Set(AuthenticatedPreferenceKey, true);
7180
Preferences.Set("is_onboarded", false);

0 commit comments

Comments
 (0)