Files
최준영 f178d69a99 로그아웃 시 WebView 창/에러 페이지 노출 방지 (오프스크린 end_session)
- LoginWindow.EndSessionAsync 추가: 1×1 화면 밖 WebView2로 end_session을 수행해
  로그아웃 시 창이나 네비게이션 에러 페이지(ERR_ABORTED 등)가 보이지 않게 함.
  post_logout_redirect_uri 복귀를 NavigationStarting에서 가로채 종료하고,
  복귀를 못 가로채도 timeout 후 조용히 반환.
- SsoClient.LogoutAsync: 보이는 AuthenticateAsync 대신 EndSessionAsync 사용.
- BaronSSO.SignOutAsync: CurrentUser가 null이면 파일에서 로드 후 로그아웃.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 12:11:14 +09:00

187 lines
8.5 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using System.Windows;
namespace BaronSoftware.SSO
{
internal class SsoClient
{
private static readonly HttpClient httpclient = new();
private BaronSSOOption options;
private SsoClient() { }
public static SsoClient Create(BaronSSOOption option)
{
SsoClient result = new();
result.options = option;
return result;
}
/// <summary>
/// 웹뷰 기반 인증: 로그인 창을 띄워 Authorization Code + PKCE로 토큰을 받아온다. (대화형)
/// </summary>
public async Task<TokenResponse> LoginAsync()
{
var disc = await GetDiscoveryAsync();
var verifier = PKCEUtil.NewVerifier();
var state = PKCEUtil.RandomToken();
var nonce = PKCEUtil.RandomToken();
var authorizeUrl =
$"{disc["authorization_endpoint"]}?client_id={Uri.EscapeDataString(options.ClientId)}" +
$"&redirect_uri={Uri.EscapeDataString(options.RedirectUri)}" +
$"&response_type=code" +
$"&code_challenge={PKCEUtil.Challenge(verifier)}&code_challenge_method=S256" +
$"&state={state}&nonce={nonce}";
// 전용 STA 스레드에서 로그인 창을 띄운다(호출 스레드가 MTA/STA 무엇이든 동작).
var redirected = await LoginWindow.AuthenticateAsync(authorizeUrl, options.RedirectUri);
var q = PKCEUtil.ParseQuery(redirected);
if (q.TryGetValue("error", out var error))
throw new InvalidOperationException($"인증 거부: {error} {q.GetValueOrDefault("error_description")}".Trim());
if (!q.TryGetValue("state", out var returnedState) || returnedState != state)
throw new SecurityException("state 불일치 (CSRF 의심) — 로그인을 중단합니다.");
if (!q.TryGetValue("code", out var code) || string.IsNullOrEmpty(code))
throw new InvalidOperationException("authorization code를 받지 못했습니다.");
var tokens = await ExchangeAsync(disc, new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = options.RedirectUri,
["client_id"] = options.ClientId,
["code_verifier"] = verifier,
});
// nonce 검증 (id_token replay 방지)
var payload = new JwtSecurityTokenHandler().ReadJwtToken(tokens.IdToken).Payload;
payload.TryGetValue("nonce", out var nonceClaim);
if (nonceClaim?.ToString() != nonce)
throw new SecurityException("nonce 불일치 — id_token이 이 요청에 대한 것이 아닙니다.");
return tokens;
}
/// <summary>
/// refreshToken 기반 인증: 저장된 refresh_token으로 창 없이(無窓) 토큰을 재발급한다.
/// refresh_token이 만료/무효 등으로 실패하면 웹뷰 대화형 로그인(LoginWindow)으로 폴백한다.
/// </summary>
public async Task<TokenResponse> LoginAsync(string refreshToken)
{
if (string.IsNullOrWhiteSpace(refreshToken))
return await LoginAsync();
try
{
var disc = await GetDiscoveryAsync();
return await ExchangeAsync(disc, new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = options.ClientId,
});
}
catch
{
// 저장된 refresh_token이 만료/폐기/무효 → 자동 로그인 실패.
// 사용자가 다시 로그인할 수 있도록 웹뷰 로그인 창을 띄운다.
return await LoginAsync();
}
}
/// <summary>.well-known/openid-configuration 문서를 1회 로드 후 캐시.</summary>
public async Task<JsonNode> GetDiscoveryAsync()
{
var json = await httpclient.GetStringAsync(GlobalConfigs.SsoDiscoveryUri);
var discovery = JsonNode.Parse(json) ?? throw new InvalidOperationException("Pasing error");
return discovery;
}
/// <summary>로그인 WebView의 세션 쿠키(SSO 세션)를 삭제한다.</summary>
public Task ClearWebSessionAsync() => LoginWindow.ClearSessionCookiesAsync();
/// <summary>
/// SSO 로그아웃(RP-Initiated Logout): end_session_endpoint로 이동해 Baron SSO 세션을 종료한다.
/// PostLogoutRedirectUri가 설정돼 있으면 그 주소로의 복귀를 WebView에서 가로채 창을 닫고,
/// 마지막으로 로컬 WebView 세션 쿠키까지 정리한다.
/// </summary>
public async Task LogoutAsync(string idToken)
{
try
{
var disc = await GetDiscoveryAsync();
var endSession = disc["end_session_endpoint"]?.ToString();
// end_session_endpoint와 id_token_hint, 그리고 복귀 주소(PostLogoutRedirectUri)가 모두 있어야
// 임베디드 WebView에서 깔끔하게 로그아웃 → 복귀를 가로챌 수 있다.
if (!string.IsNullOrEmpty(endSession)
&& !string.IsNullOrEmpty(idToken)
&& !string.IsNullOrWhiteSpace(options.PostLogoutRedirectUri))
{
var url =
$"{endSession}?id_token_hint={Uri.EscapeDataString(idToken)}" +
$"&post_logout_redirect_uri={Uri.EscapeDataString(options.PostLogoutRedirectUri)}" +
$"&state={PKCEUtil.RandomToken()}";
// 화면 밖(오프스크린) WebView2로 end_session을 수행해
// 로그아웃 시 WebView 창/에러 페이지가 사용자에게 보이지 않게 한다.
await LoginWindow.EndSessionAsync(url, options.PostLogoutRedirectUri);
}
}
catch
{
// end_session 호출 실패(네트워크/디스커버리 등)해도 로컬 쿠키 정리는 시도한다.
}
finally
{
await ClearWebSessionAsync();
}
}
/// <summary>access_token으로 userinfo 호출 (granted scope에 따른 프로필/소속 클레임).</summary>
public async Task<string> GetUserInfoAsync(string accessToken)
{
var disc = await GetDiscoveryAsync();
var endpoint = disc["userinfo_endpoint"]?.ToString()
?? throw new InvalidOperationException("discovery hasn't userinfo_endpoint.");
using var req = new HttpRequestMessage(HttpMethod.Get, endpoint);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var resp = await httpclient.SendAsync(req);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Failed to call userinfo {(int)resp.StatusCode}: {body}");
return body;
}
private static async Task<TokenResponse> ExchangeAsync(JsonNode disc, Dictionary<string, string> form)
{
var tokenEndpoint = disc["token_endpoint"]?.ToString()
?? throw new InvalidOperationException("discovery hasn't userinfo_endpoint.");
using var resp = await httpclient.PostAsync(tokenEndpoint, new FormUrlEncodedContent(form));
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"Failed to exchange token {(int)resp.StatusCode}: {body}");
var j = JsonNode.Parse(body) ?? throw new InvalidOperationException("empty token");
return new TokenResponse(
j["id_token"]?.ToString() ?? throw new InvalidOperationException("Response has not id_token."),
j["access_token"]?.ToString() ?? string.Empty,
j["refresh_token"]?.ToString(),
j["expires_in"]?.GetValue<int>() ?? 3600,
body);
}
}
}