Initial commit: BARON SSO 샘플 (WebView OIDC PKCE 인증 라이브러리 + 데모 앱)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
188
BaronSoftware.SSO/OIDC/SsoClient.cs
Normal file
188
BaronSoftware.SSO/OIDC/SsoClient.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
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}";
|
||||
|
||||
var window = new LoginWindow(authorizeUrl, options.RedirectUri);
|
||||
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, window))
|
||||
window.Owner = owner;
|
||||
|
||||
var redirected = await window.ShowAndGetRedirectAsync();
|
||||
|
||||
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으로 창 없이(無窓) 토큰을 재발급한다.
|
||||
/// </summary>
|
||||
public async Task<TokenResponse> LoginAsync(string refreshToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
return await LoginAsync();
|
||||
|
||||
var disc = await GetDiscoveryAsync();
|
||||
return await ExchangeAsync(disc, new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "refresh_token",
|
||||
["refresh_token"] = refreshToken,
|
||||
["client_id"] = options.ClientId,
|
||||
});
|
||||
}
|
||||
|
||||
/// <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()}";
|
||||
|
||||
var window = new LoginWindow(url, options.PostLogoutRedirectUri);
|
||||
if (Application.Current?.MainWindow is { } owner && !ReferenceEquals(owner, window))
|
||||
window.Owner = owner;
|
||||
|
||||
try
|
||||
{
|
||||
await window.ShowAndGetRedirectAsync();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 사용자가 로그아웃 창을 닫아도 로컬 정리는 계속 진행한다.
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user