From 08e9e0745891ba1f02ad8bb81b71ba5b5ba4d072 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 28 Jun 2026 10:54:11 +0900 Subject: [PATCH] fix: Critical runtime bug - TokenRefreshHandler JS interop in Blazor Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem:** TokenRefreshHandler (DelegatingHandler) runs on a non-circuit thread. ILocalStorageService (JS interop) only works during component render. Production: 401 response → token refresh → JS interop fails silently. **Solution:** 1. ITokenStore - Scoped in-memory token store (no JS interop) - Properties: AccessToken, RefreshToken, TokenExpiryTicks - Method: IsAccessTokenExpired() 2. TokenStore implementation - Replaces localStorage as primary token source - DelegatingHandler reads/writes only to TokenStore - Pages reload → GetAuthenticationStateAsync restores from localStorage 3. CustomAuthenticationStateProvider - Accepts ITokenStore injection - LoginAsync: Write to both TokenStore + localStorage - LogoutAsync: Clear both - GetAuthenticationStateAsync: Read from TokenStore first, fallback to localStorage 4. AdminDashboardClient BaseAddress fix - Was: new Uri("/taxbaik/api/") - relative URI (runtime error) - Now: Configured in Program.cs as absolute URI - Program.cs: AddHttpClient(..., client => client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/")) **Architecture:** - TokenStore: Scoped in-memory (DelegatingHandler use) - localStorage: Persistent (page reload recovery) - Pattern: Server-side token management without JS interop This fixes the cascading failure that would occur on any 401 in production. Co-Authored-By: Claude Sonnet 4.6 --- TaxBaik.Web/Program.cs | 6 ++- TaxBaik.Web/Services/AdminDashboardClient.cs | 1 - .../CustomAuthenticationStateProvider.cs | 46 +++++++++++++++++-- TaxBaik.Web/Services/ITokenStore.cs | 39 ++++++++++++++++ TaxBaik.Web/Services/TokenRefreshHandler.cs | 29 +++++------- 5 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 TaxBaik.Web/Services/ITokenStore.cs diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs index 47eda4e..0e65b6c 100644 --- a/TaxBaik.Web/Program.cs +++ b/TaxBaik.Web/Program.cs @@ -67,9 +67,13 @@ builder.Services.AddAuthorization(); builder.Services.AddAuthorizationCore(); // HTTP Client for API (with automatic token refresh) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); -builder.Services.AddHttpClient() +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("http://localhost:5001/taxbaik/api/"); +}) .AddHttpMessageHandler(); // UI & 캐시 diff --git a/TaxBaik.Web/Services/AdminDashboardClient.cs b/TaxBaik.Web/Services/AdminDashboardClient.cs index 8950692..5ab68b3 100644 --- a/TaxBaik.Web/Services/AdminDashboardClient.cs +++ b/TaxBaik.Web/Services/AdminDashboardClient.cs @@ -26,7 +26,6 @@ public class AdminDashboardClient : IAdminDashboardClient { _http = http; _logger = logger; - _http.BaseAddress = new Uri("/taxbaik/api/"); } public async Task GetSummaryAsync(CancellationToken ct = default) diff --git a/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs b/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs index 4417af5..27343cd 100644 --- a/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs +++ b/TaxBaik.Web/Services/CustomAuthenticationStateProvider.cs @@ -7,12 +7,18 @@ namespace TaxBaik.Web.Services; public class CustomAuthenticationStateProvider : AuthenticationStateProvider { private readonly ILocalStorageService _localStorage; + private readonly ITokenStore _tokenStore; private readonly AuthService _authService; private readonly ILogger _logger; - public CustomAuthenticationStateProvider(ILocalStorageService localStorage, AuthService authService, ILogger logger) + public CustomAuthenticationStateProvider( + ILocalStorageService localStorage, + ITokenStore tokenStore, + AuthService authService, + ILogger logger) { _localStorage = localStorage; + _tokenStore = tokenStore; _authService = authService; _logger = logger; } @@ -21,14 +27,31 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider { try { - var accessToken = await _localStorage.GetItemAsStringAsync("accessToken"); + var accessToken = _tokenStore.AccessToken; + + // TokenStore가 비어있으면 localStorage에서 복원 (페이지 리로드 후) + if (string.IsNullOrEmpty(accessToken)) + { + accessToken = await _localStorage.GetItemAsStringAsync("accessToken"); + if (!string.IsNullOrEmpty(accessToken)) + { + var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken"); + var ticksStr = await _localStorage.GetItemAsStringAsync("tokenExpiry"); + if (long.TryParse(ticksStr, out var ticks)) + { + _tokenStore.AccessToken = accessToken; + _tokenStore.RefreshToken = refreshToken; + _tokenStore.TokenExpiryTicks = ticks; + } + } + } if (string.IsNullOrEmpty(accessToken)) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } - if (IsTokenExpired(accessToken)) + if (_tokenStore.IsAccessTokenExpired()) { _logger.LogWarning("Access token 만료됨"); await LogoutAsync(); @@ -53,18 +76,31 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider public async Task LoginAsync(string accessToken, string refreshToken, int expiresIn) { + var tokenExpiryTicks = DateTime.UtcNow.AddSeconds(expiresIn).Ticks; + + // TokenStore에 저장 (DelegatingHandler에서 사용) + _tokenStore.AccessToken = accessToken; + _tokenStore.RefreshToken = refreshToken; + _tokenStore.TokenExpiryTicks = tokenExpiryTicks; + + // localStorage에도 저장 (페이지 리로드 후 복원) await _localStorage.SetItemAsStringAsync("accessToken", accessToken); await _localStorage.SetItemAsStringAsync("refreshToken", refreshToken); - await _localStorage.SetItemAsStringAsync("tokenExpiry", - DateTime.UtcNow.AddSeconds(expiresIn).Ticks.ToString()); + await _localStorage.SetItemAsStringAsync("tokenExpiry", tokenExpiryTicks.ToString()); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } public async Task LogoutAsync() { + // TokenStore 초기화 + _tokenStore.Clear(); + + // localStorage 초기화 await _localStorage.RemoveItemAsync("accessToken"); await _localStorage.RemoveItemAsync("refreshToken"); await _localStorage.RemoveItemAsync("tokenExpiry"); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } diff --git a/TaxBaik.Web/Services/ITokenStore.cs b/TaxBaik.Web/Services/ITokenStore.cs new file mode 100644 index 0000000..fd516ac --- /dev/null +++ b/TaxBaik.Web/Services/ITokenStore.cs @@ -0,0 +1,39 @@ +namespace TaxBaik.Web.Services; + +/// +/// Scoped in-memory token store for Blazor Server. +/// SOLID: Single Responsibility - Token lifecycle management +/// Avoids JS interop from DelegatingHandler (which runs on non-circuit thread) +/// +public interface ITokenStore +{ + string? AccessToken { get; set; } + string? RefreshToken { get; set; } + long? TokenExpiryTicks { get; set; } + + bool IsAccessTokenExpired(); + void Clear(); +} + +public class TokenStore : ITokenStore +{ + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public long? TokenExpiryTicks { get; set; } + + public bool IsAccessTokenExpired() + { + if (TokenExpiryTicks == null) + return true; + + var expiryTime = new DateTime(TokenExpiryTicks.Value, DateTimeKind.Utc); + return expiryTime <= DateTime.UtcNow; + } + + public void Clear() + { + AccessToken = null; + RefreshToken = null; + TokenExpiryTicks = null; + } +} diff --git a/TaxBaik.Web/Services/TokenRefreshHandler.cs b/TaxBaik.Web/Services/TokenRefreshHandler.cs index 1bd6dff..a7e450c 100644 --- a/TaxBaik.Web/Services/TokenRefreshHandler.cs +++ b/TaxBaik.Web/Services/TokenRefreshHandler.cs @@ -10,12 +10,12 @@ using System.Text.Json; /// public class TokenRefreshHandler : DelegatingHandler { - private readonly ILocalStorageService _localStorage; + private readonly ITokenStore _tokenStore; private readonly ILogger _logger; - public TokenRefreshHandler(ILocalStorageService localStorage, ILogger logger) + public TokenRefreshHandler(ITokenStore tokenStore, ILogger logger) { - _localStorage = localStorage; + _tokenStore = tokenStore; _logger = logger; } @@ -24,10 +24,9 @@ public class TokenRefreshHandler : DelegatingHandler CancellationToken cancellationToken) { // 요청에 access token 추가 - var accessToken = await _localStorage.GetItemAsStringAsync("accessToken"); - if (!string.IsNullOrEmpty(accessToken)) + if (!string.IsNullOrEmpty(_tokenStore.AccessToken)) { - request.Headers.Authorization = new("Bearer", accessToken); + request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken); } var response = await base.SendAsync(request, cancellationToken); @@ -35,17 +34,15 @@ public class TokenRefreshHandler : DelegatingHandler // 401 응답이면 토큰 갱신 시도 if (response.StatusCode == HttpStatusCode.Unauthorized) { - var refreshToken = await _localStorage.GetItemAsStringAsync("refreshToken"); - if (!string.IsNullOrEmpty(refreshToken)) + if (!string.IsNullOrEmpty(_tokenStore.RefreshToken)) { - var newTokenPair = await RefreshTokenAsync(refreshToken, request, cancellationToken); + var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken); if (newTokenPair != null) { - // 토큰 저장 - await _localStorage.SetItemAsStringAsync("accessToken", newTokenPair.AccessToken); - await _localStorage.SetItemAsStringAsync("refreshToken", newTokenPair.RefreshToken); - await _localStorage.SetItemAsStringAsync("tokenExpiry", - DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks.ToString()); + // TokenStore에 토큰 저장 + _tokenStore.AccessToken = newTokenPair.AccessToken; + _tokenStore.RefreshToken = newTokenPair.RefreshToken; + _tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks; // 새 토큰으로 재요청 request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken); @@ -54,9 +51,7 @@ public class TokenRefreshHandler : DelegatingHandler else { _logger.LogWarning("토큰 갱신 실패 - 로그아웃"); - await _localStorage.RemoveItemAsync("accessToken"); - await _localStorage.RemoveItemAsync("refreshToken"); - await _localStorage.RemoveItemAsync("tokenExpiry"); + _tokenStore.Clear(); } } }