SSO 로그아웃(RP-Initiated end_session) 구현 및 세션 쿠키 정리 보강

- BaronSSO.SignOutAsync: id_token_hint 기반 end_session 로그아웃 + 로컬 세션 정리
- SsoClient.LogoutAsync: end_session_endpoint 이동 후 post_logout 복귀를 WebView에서 가로채기
- BaronSSOOption.PostLogoutRedirectUri 추가
- LoginWindow: 쿠키 삭제를 ClearBrowsingDataAsync(완료 대기)로 변경해 재로그인 자동 SSO 통과 방지
- UserInfo: IsFamily/IsCenter 대소문자 무시 비교를 StringComparer로 수정(빌드 오류 해소)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 12:25:41 +09:00
parent 3de67f0052
commit 1c41230021
8 changed files with 101 additions and 57 deletions

View File

@@ -3,6 +3,8 @@ using BaronSoftware.Auth;
using BaronSoftware.Auth.Sample;
using System;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using System.Windows;
@@ -113,6 +115,7 @@ namespace BaronSoftware.SSO.Sample
sb.AppendLine($"UserId(sub) : {u.UUID}");
sb.AppendLine($"Name : {u.Name}");
sb.AppendLine($"Email : {u.Email}");
sb.AppendLine($"TenantIds : {string.Join(", ", u.AllTenantIds ?? Array.Empty<string>())}");
sb.AppendLine($"Last Auth Time : {u.LastAuthTime}");
sb.AppendLine($"Claims Start------------ \n ");
sb.AppendLine(string.Join("\n", u.Claims.Select(kv => $" {kv.Key}: {kv.Value}")));
@@ -123,15 +126,30 @@ namespace BaronSoftware.SSO.Sample
sb.AppendLine();
sb.AppendLine("==== token 엔드포인트 응답 (원본) ====");
sb.AppendLine(u.RawTokenResponse);
sb.AppendLine(PrettyJson(u.RawTokenResponse));
sb.AppendLine();
sb.AppendLine("==== userinfo 응답 ====");
sb.AppendLine(u.Raw);
sb.AppendLine(PrettyJson(u.Raw));
return sb.ToString();
}
private static readonly JsonSerializerOptions PrettyJsonOptions = new()
{
WriteIndented = true,
// 한글 등 비ASCII를 \uXXXX로 이스케이프하지 않고 그대로 표시
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>JSON 문자열을 들여쓰기(pretty) 형태로 변환. 파싱 실패 시 원본 반환.</summary>
private static string PrettyJson(string json)
{
if (string.IsNullOrWhiteSpace(json)) return json;
try { return JsonNode.Parse(json)?.ToJsonString(PrettyJsonOptions) ?? json; }
catch { return json; }
}
private async void TokenLoginBuggon_Click(object sender, RoutedEventArgs e) => await RunAsync("토큰 로그인", () => _license.SignInAsync(_license.CurrentUser.RefreshToken));
}
}

View File

@@ -1,6 +1,7 @@
using BaronSoftware;
using BaronSoftware.Auth;
using BaronSoftware.SSO;
using BaronSoftware.SSO.Sample;
using System;
using System.IO;
using System.Text.Json;
@@ -72,6 +73,7 @@ namespace BaronSoftware.Auth.Sample
Authority = Oidc.Authority,
ClientId = Oidc.ClientId,
RedirectUri = Oidc.RedirectUri,
Validator = new SimpleUserValidator()
};
}
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BaronSoftware.SSO.Sample
{
internal class SimpleUserValidator : IUserValidator
{
public bool Validate(UserInfo user)
{
// 가족사 아니면 거절
return false;
}
}
}

View File

@@ -1,9 +1,4 @@
using BaronSoftware.SSO.Exceptions;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace BaronSoftware.SSO
{
@@ -65,7 +60,14 @@ namespace BaronSoftware.SSO
throw new InvalidOperationException($"Failed to get userinfo : {token.ToString()}");
user = UserInfo.FromJson(userJson, token);
option?.Validator?.Validate(user);
if (user == null || option?.Validator == null)
throw new NullReferenceException("user or validator option is null");
// 가족사가 아니고, 인증도 실패 시
if(!user.IsFamily()&& !option.Validator.Validate(user))
throw new InvalidUserException("Failed to authorize user");
CurrentUser = user;
CurrentUser.Save();
}
@@ -76,17 +78,11 @@ namespace BaronSoftware.SSO
/// </summary>
public async Task SignOutAsync()
{
// 1) 로그아웃 전에 id_token을 확보(서버 세션 종료용 id_token_hint). Clear 이전에 추출해야 한다.
var idToken = ExtractIdToken(CurrentUser) ?? ExtractIdToken(UserInfo.FromSsoFile());
// 2) 로컬 사용자 정보 삭제
UserInfo.Clear();
CurrentUser = null;
// 3) SSO 서버 세션 종료(RP-Initiated Logout) + 로컬 WebView 세션 쿠키 정리
try
{
await client.LogoutAsync(idToken);
//SSO 서버 세션 종료(RP-Initiated Logout) + 로컬 WebView 세션 쿠키 정리
UserInfo.Clear();
await client.LogoutAsync(CurrentUser?.IdToken);
}
catch
{
@@ -94,21 +90,6 @@ namespace BaronSoftware.SSO
}
}
/// <summary>UserInfo의 원본 토큰 응답에서 id_token을 추출한다(로그아웃 id_token_hint용).</summary>
private static string ExtractIdToken(UserInfo user)
{
if (string.IsNullOrEmpty(user?.RawTokenResponse))
return null;
try
{
return JsonNode.Parse(user.RawTokenResponse)?["id_token"]?.GetValue<string>();
}
catch
{
return null;
}
}
private async Task<bool> CheckConnection()
{
try

View File

@@ -41,6 +41,6 @@ namespace BaronSoftware.SSO
/// <summary>
/// 사용자 인증에 대한 추가 검증이 필요한 경우, IUserValidator 인터페이스를 구현하여 Validator 속성에 할당할 수 있습니다.
/// </summary>
public IUserValidator? Validator { get; set; }
public required IUserValidator? Validator { get; set; }
}
}

View File

@@ -27,25 +27,9 @@ namespace BaronSoftware.SSO
{
try
{
#if DEBUG
await webview.EnsureCoreWebView2Async();
webview.CoreWebView2.NavigationStarting += OnNavigationStarting;
webview.CoreWebView2.Navigate(_authorizeUrl);
#else
// 1. 웹뷰 환경 설정 객체 생성
var environment = await CoreWebView2Environment.CreateAsync(null, null, null);
// 2. 컨트롤러 옵션 생성 및 InPrivate 모드 활성화
var options = environment.CreateCoreWebView2ControllerOptions();
options.IsInPrivateModeEnabled = true; // 핵심 설정
// 3. 인프라이빗 옵션을 적용하여 WebView2 초기화
await webview.EnsureCoreWebView2Async(environment, options);
webview.CoreWebView2.NavigationStarting += OnNavigationStarting;
webview.CoreWebView2.Navigate(_authorizeUrl);
#endif
}
catch (Exception ex)
{

View File

@@ -4,6 +4,10 @@ namespace BaronSoftware.SSO
/// <summary>id_token을 검증(서명/발급자/대상/만료)하고 파싱된 JWT를 반환합니다.</summary>
public interface IUserValidator
{
public void Validate(UserInfo user);
/// <summary>
/// 인증 실패 시 예외를 던지세요
/// </summary>
/// <param name="user"></param>
public bool Validate(UserInfo user);
}
}

View File

@@ -7,6 +7,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace BaronSoftware.SSO
{
@@ -16,12 +17,16 @@ namespace BaronSoftware.SSO
public string Name { get; private set; }
public string Email { get; private set; }
public string[] SubEmails { get; private set; }
public bool IsFamily { get; private set; }
public string RefreshToken { get; private set; }
public string TenantId { get; private set; }
public string[] JoinedTenantIds { get; private set; }
public DateTime LastAuthTime {get; private set; }
/// <summary>'tenants' 클레임 안에 등장하는 모든 테넌트 id 목록(상위/조상 테넌트 포함, 중복 제거).</summary>
public string[] AllTenantIds { get; private set; }
public long LastAuthUnixTimeStamp { get; private set; }
public DateTime LastAuthTime => DateTimeOffset.FromUnixTimeSeconds(LastAuthUnixTimeStamp).LocalDateTime;
public string IdToken { get; private set; }
public string Raw { get; private set; }
public string RawTokenResponse { get; private set; }
public Dictionary<string, object> Claims { get; private set; }
@@ -32,17 +37,40 @@ namespace BaronSoftware.SSO
var result = new UserInfo();
result.Raw = rawjson;
result.RawTokenResponse = reposeToken.RawJson;
result.IdToken = reposeToken.IdToken;
result.RefreshToken = reposeToken.RefreshToken ?? "";
result.UUID = userjson["sub"]?.GetValue<string>() ?? "";
result.Name = userjson["name"]?.GetValue<string>() ?? "";
result.Email = userjson["email"]?.GetValue<string>() ?? "";
//result.SubEmails = userjson["sub_emails"]?.AsArray().Select(x => x.GetValue<string>()).ToArray() ?? Array.Empty<string>();
result.TenantId = userjson["tenant_id"]?.GetValue<string>() ?? "";
result.JoinedTenantIds = userjson["joined_tenants"]?.AsArray().Select(x => x.GetValue<string>()).ToArray() ?? Array.Empty<string>();
result.AllTenantIds = ExtractTenantIds(userjson);
result.Claims = userjson["rp_claims"]?.AsObject().ToDictionary(x => x.Key, x => (object)x.Value["value"]) ?? new Dictionary<string, object>();
result.LastAuthTime = DateTimeOffset.FromUnixTimeSeconds(userjson["auth_time"]?.GetValue<long>() ?? 0).DateTime;
result.LastAuthUnixTimeStamp = userjson["auth_time"]?.GetValue<long>() ?? 0;
return result;
}
/// <summary>
/// 'tenants' 객체 안에 등장하는 모든 테넌트 id를 정규식으로 수집한다(상위/조상 포함, 중복 제거).
/// </summary>
private static string[] ExtractTenantIds(JsonNode userjson)
{
// 'tenants' 하위의 모든 "id":"..." 값을 뽑는 정규식 (상위 테넌트 + ancestors 포함)
Regex regex = new("\"id\"\\s*:\\s*\"([^\"]+)\"", RegexOptions.Compiled);
var tenantsJson = userjson?["tenants"]?.ToJsonString();
if (string.IsNullOrEmpty(tenantsJson))
return Array.Empty<string>();
var ids = new List<string>();
var seen = new HashSet<string>();
foreach (Match m in regex.Matches(tenantsJson))
{
var id = m.Groups[1].Value;
if (!string.IsNullOrEmpty(id) && seen.Add(id))
ids.Add(id);
}
return ids.ToArray();
}
//// root = Ory Hydra id_token payload 또는 /userinfo 응답.
//// 클레임은 (Descope의 customAttributes 하위가 아니라) 최상위에 평탄하게 들어오며,
//// 상세 항목은 profile 스코프가 동의되었을 때만 포함됩니다.
@@ -99,5 +127,15 @@ namespace BaronSoftware.SSO
if (File.Exists(GlobalConfigs.SsoFilePath))
File.Delete(GlobalConfigs.SsoFilePath);
}
public bool IsCenter()
{
return AllTenantIds.Contains(GlobalConfigs.CenterTanant_UUID, StringComparer.OrdinalIgnoreCase);
}
public bool IsFamily()
{
return AllTenantIds.Contains(GlobalConfigs.FamilyTanant_UUID, StringComparer.OrdinalIgnoreCase);
}
}
}