Files
taxbaik/TaxBaik.Web/Services/TokenRefreshHandler.cs
kjh2064 08e9e07458
TaxBaik CI/CD / build-and-deploy (push) Successful in 47s
fix: Critical runtime bug - TokenRefreshHandler JS interop in Blazor Server
**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 <noreply@anthropic.com>
2026-06-28 10:54:11 +09:00

104 lines
3.9 KiB
C#

namespace TaxBaik.Web.Services;
using System.Net;
using System.Text.Json;
/// <summary>
/// HTTP 요청 시 자동으로 access token을 추가하고,
/// 401 응답을 받으면 refresh token으로 새 토큰을 획득한 후 재시도합니다.
/// SOLID: Single Responsibility - 토큰 갱신 로직만 담당
/// </summary>
public class TokenRefreshHandler : DelegatingHandler
{
private readonly ITokenStore _tokenStore;
private readonly ILogger<TokenRefreshHandler> _logger;
public TokenRefreshHandler(ITokenStore tokenStore, ILogger<TokenRefreshHandler> logger)
{
_tokenStore = tokenStore;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
// 요청에 access token 추가
if (!string.IsNullOrEmpty(_tokenStore.AccessToken))
{
request.Headers.Authorization = new("Bearer", _tokenStore.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
// 401 응답이면 토큰 갱신 시도
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
if (!string.IsNullOrEmpty(_tokenStore.RefreshToken))
{
var newTokenPair = await RefreshTokenAsync(_tokenStore.RefreshToken, request, cancellationToken);
if (newTokenPair != null)
{
// TokenStore에 토큰 저장
_tokenStore.AccessToken = newTokenPair.AccessToken;
_tokenStore.RefreshToken = newTokenPair.RefreshToken;
_tokenStore.TokenExpiryTicks = DateTime.UtcNow.AddSeconds(newTokenPair.ExpiresIn).Ticks;
// 새 토큰으로 재요청
request.Headers.Authorization = new("Bearer", newTokenPair.AccessToken);
response = await base.SendAsync(request, cancellationToken);
}
else
{
_logger.LogWarning("토큰 갱신 실패 - 로그아웃");
_tokenStore.Clear();
}
}
}
return response;
}
private async Task<AuthTokenPair?> RefreshTokenAsync(string refreshToken, HttpRequestMessage originalRequest, CancellationToken ct)
{
try
{
// 원래 요청의 호스트 정보 추출
var authority = originalRequest.RequestUri?.Authority ?? "localhost:5001";
var scheme = originalRequest.RequestUri?.Scheme ?? "http";
using var httpClient = new HttpClient();
var refreshUri = new Uri($"{scheme}://{authority}/taxbaik/api/auth/refresh");
var json = JsonSerializer.Serialize(new { refreshToken });
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(refreshUri, content, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning($"Token refresh failed with status {response.StatusCode}");
return null;
}
var responseContent = await response.Content.ReadAsStringAsync(ct);
var result = JsonSerializer.Deserialize<AuthTokenResponse>(responseContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return result != null
? new AuthTokenPair(result.AccessToken, result.RefreshToken, result.ExpiresIn)
: null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during token refresh");
return null;
}
}
}
internal class AuthTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
}