From 3e8adbfbfdba789ada423e49e693b84ec2e98c4a Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 14:09:21 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EB=B0=B1=EC=B1=84=EB=84=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=95=84=EC=9B=83=20URI=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 26 ++++++++++++++++--- .../features/clients/ClientGeneralPage.tsx | 25 +++++++++++++++--- devfront/src/locales/en.toml | 2 +- devfront/src/locales/ko.toml | 2 +- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 0b32a39a..e64e2e61 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "net/url" "os" @@ -2749,16 +2750,35 @@ func validateBackchannelLogoutURI(raw string) error { case "https": return nil case "http": - host := strings.ToLower(parsed.Hostname()) - if host == "localhost" || host == "127.0.0.1" { + if isAllowedLocalBackchannelLogoutHost(parsed.Hostname()) { return nil } - return fmt.Errorf("backchannelLogoutUri must use https outside localhost development") + return fmt.Errorf("backchannelLogoutUri must use https outside local development") default: return fmt.Errorf("backchannelLogoutUri must use http or https") } } +func isAllowedLocalBackchannelLogoutHost(rawHost string) bool { + host := strings.ToLower(strings.TrimSpace(rawHost)) + if host == "" { + return false + } + + switch host { + case "localhost", "127.0.0.1", "::1", "host.docker.internal": + return true + } + + if ip := net.ParseIP(host); ip != nil { + return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() + } + + // Docker service names and other single-label local hostnames are + // permitted only for local HTTP development workflows. + return !strings.Contains(host, ".") +} + func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { if metadata == nil { return metadata, nil diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index b189abdd..8e3ace74 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -288,7 +288,26 @@ function isValidBackchannelLogoutUrl(value: string): boolean { if (url.protocol !== "http:") { return false; } - return url.hostname === "localhost" || url.hostname === "127.0.0.1"; + const host = url.hostname.toLowerCase(); + if ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "host.docker.internal" + ) { + return true; + } + if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return ( + host.startsWith("10.") || + host.startsWith("192.168.") || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(host) || + host.startsWith("169.254.") + ); + } + // Docker service names and other single-label local hosts are allowed + // only for HTTP local development use. + return !host.includes("."); } catch { return false; } @@ -949,7 +968,7 @@ function ClientGeneralPage() { throw new Error( t( "msg.dev.clients.general.backchannel_logout.invalid", - "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.", + "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.", ), ); } @@ -1590,7 +1609,7 @@ function ClientGeneralPage() {

{t( "msg.dev.clients.general.backchannel_logout.invalid", - "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.", + "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.", )}

) : null} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index a4d69010..f7daf9d7 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -435,7 +435,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after [msg.dev.clients.general.backchannel_logout] uri_help = "RP endpoint that receives Baron's session termination event via server-to-server POST." -invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1." +invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1, host.docker.internal, Docker service names, and private IPs." session_required_help = "Use this when the RP should process logout_token only if the sid claim is included." session_required_on = "On: process logout only when the logout_token contains a sid." session_required_off = "Off: process logout using sub even if sid is missing." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index eecb25aa..678f9a7d 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -435,7 +435,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 [msg.dev.clients.general.backchannel_logout] uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다." -invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다." +invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다." session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다." session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리" session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능" From 53cad429a16795d8da78f2d1865f5e81c3c2985e Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 14:09:44 +0900 Subject: [PATCH 02/11] =?UTF-8?q?PCKE=20=EA=B5=AC=ED=98=84=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/pkce-backchannel-logout-guide.md | 321 ++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/pkce-backchannel-logout-guide.md diff --git a/docs/pkce-backchannel-logout-guide.md b/docs/pkce-backchannel-logout-guide.md new file mode 100644 index 00000000..6385ab7a --- /dev/null +++ b/docs/pkce-backchannel-logout-guide.md @@ -0,0 +1,321 @@ +# PKCE RP Back-Channel Logout 구현 가이드 + +이 문서는 Baron SSO와 연동하는 PKCE RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다. + +## 목적 + +PKCE RP도 OIDC `Authorization Code + PKCE` 흐름을 사용하면서 Baron SSO의 원격 세션 종료 이벤트를 받을 수 있어야 합니다. 다만 `Back-Channel Logout`은 브라우저가 아니라 OP(Baron)가 RP 서버로 직접 `logout_token`을 보내는 방식이므로, **순수 frontend-only PKCE 앱만으로는 구현할 수 없습니다.** + +즉, PKCE RP가 `Back-Channel Logout`을 사용하려면 다음 둘을 모두 가져야 합니다. + +1. PKCE 로그인 플로우를 시작하고 callback을 처리하는 RP +2. `logout_token`을 수신하는 서버 endpoint + +## 적용 대상 + +이 가이드는 다음 경우를 대상으로 합니다. + +- 브라우저에서 `Authorization Code + PKCE`를 사용하는 RP +- RP가 자체 세션 또는 BFF 세션을 보유하는 경우 +- RP가 `Back-Channel Logout URI`를 등록하고 Baron의 세션 종료 이벤트를 직접 수신하려는 경우 + +다음 경우는 이 가이드의 직접 대상이 아닙니다. + +- 순수 frontend-only SPA +- 서버 없이 `localStorage`/`sessionStorage`만 사용하는 PKCE 앱 + +이 경우에는 `Back-Channel Logout` 대신 front-channel logout, 세션 재검증, 짧은 token TTL 같은 별도 전략을 사용해야 합니다. + +## devfront 등록 기준 + +PKCE RP는 devfront에서 아래 항목을 등록합니다. + +1. `Type`: `pkce` +2. `Redirect URI`: RP callback URL +3. `Back-Channel Logout URI`: RP 서버 endpoint +4. 필요 시 `SID Claim Required` + +예시: + +```text +Type: pkce +Redirect URI: https://rp.example.com/callback +Back-Channel Logout URI: https://rp.example.com/backchannel-logout +SID Claim Required: off +``` + +로컬 Docker 개발 예시: + +```text +Redirect URI: http://localhost:3333/callback +Back-Channel Logout URI: http://baron-sso-login-demo:3333/backchannel-logout +``` + +주의: + +- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다. +- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, Docker 서비스명이나 사설 IP를 사용해야 할 수 있습니다. + +## 구현 요구사항 + +PKCE RP는 최소한 아래를 구현해야 합니다. + +### 1. 로그인 후 세션 매핑 저장 + +RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다. + +- `sid -> rpSessionId` +- `sub -> rpSessionId` + +권장 순서는 다음과 같습니다. + +1. `sid`를 우선 저장 +2. `sub`도 함께 저장 +3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정 + +예시: + +```text +sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a +sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30 +rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj +``` + +### 2. `POST /backchannel-logout` endpoint + +RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다. + +예: + +```text +POST /backchannel-logout +Content-Type: application/x-www-form-urlencoded +Body: logout_token= +``` + +RP는 이 endpoint에서: + +1. `logout_token` 존재 여부 확인 +2. JWT 서명 및 claim 검증 +3. `sid` 또는 `sub`로 로컬 세션 탐색 +4. 세션 스토어에서 직접 세션 파기 +5. 성공 시 `2xx` 응답 + +을 수행해야 합니다. + +### 3. `logout_token` 검증 + +RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다. + +현재 Baron의 JWKS endpoint 예시는 다음과 같습니다. + +```text +GET /api/v1/auth/backchannel/jwks.json +``` + +검증 필수 항목: + +1. JWT 서명 검증 +2. `iss`가 Baron OIDC issuer와 일치 +3. `aud`에 현재 RP `client_id` 포함 +4. `iat` 존재 +5. `jti` 존재 +6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함 +7. `nonce`가 없어야 함 +8. `sid` 또는 `sub`가 있어야 함 + +추가 권장 항목: + +- `jti` replay 방지 캐시 +- 시계 오차 허용 범위 설정 +- 검증 실패 시 `400` + +## 세션 종료 기준 + +### 권장 순서 + +1. `sid`로 매칭 시도 +2. 매칭 실패 시 `sub`로 fallback + +이 기준은 `SID Claim Required` 정책에 따라 달라집니다. + +### `SID Claim Required = true` + +- `logout_token`에 `sid`가 있어야만 처리 +- `sub` fallback 금지 +- 세션 모델이 `sid` 중심으로 안정적으로 유지되는 RP에 적합 + +### `SID Claim Required = false` + +- `sid`가 있으면 우선 사용 +- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능 +- 실제 운영에서는 이 모드가 더 현실적일 수 있음 + +## 세션 파기 방식 + +`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다. +반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다. + +예: + +```text +store.destroy(rpSessionId) +``` + +필수 조건: + +- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함 +- 이미 삭제된 세션은 idempotent success 처리 + +## 권장 로그 항목 + +RP는 아래 정도의 로그를 남기는 것을 권장합니다. + +1. 요청 수신 +2. 토큰 검증 성공/실패 +3. `sid`, `sub`, `jti` +4. 매칭된 `rpSessionId` 목록 +5. 세션 파기 성공/실패 수 + +예시: + +```text +[백채널 로그아웃] 요청 수신 +[백채널 로그아웃] 토큰 검증 성공 +[백채널 로그아웃] 세션 탐색 결과 +[백채널 로그아웃] 세션 파기 완료 +[백채널 로그아웃] 처리 완료 +``` + +주의: + +- raw `logout_token` 전체를 로그에 남기지 않습니다. +- access token, refresh token, cookie raw value도 남기지 않습니다. + +## 테스트 체크리스트 + +### 기본 성공 시나리오 + +1. PKCE RP 로그인 +2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인 +3. UserFront에서 `세션 종료` +4. Baron이 RP의 `Back-Channel Logout URI`로 POST +5. RP가 `logout_token` 검증 성공 +6. RP 세션 파기 성공 +7. 보호 페이지 접근 시 비로그인 상태 확인 + +### 확인 포인트 + +1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가 +2. Baron backend가 해당 URI에 실제로 도달 가능한가 +3. RP 로그에 `요청 수신`과 `토큰 검증 성공`이 찍히는가 +4. 세션 스토어에서 실제 세션이 삭제됐는가 +5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가 + +## 구현 예시 구조 + +Node.js/Express 기준 최소 구조 예시는 다음과 같습니다. + +```text +GET /login +GET /callback +GET /profile +GET /logout +POST /backchannel-logout +``` + +내부 저장 예시: + +```text +sidToSessionIds: Map> +subToSessionIds: Map> +sessionIdToBinding: Map +``` + +실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다. + +- 백채널 로그아웃 모듈: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/backchannel-logout.js` +- 데모 앱 엔트리포인트: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/app.js` + +이 데모는: + +1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록 +2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결 +3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출 + +구조로 동작합니다. + +## 자주 생기는 문제 + +### 1. `localhost`로는 안 되는데 입력은 저장됨 + +입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다. + +예: + +```text +http://localhost:3333/backchannel-logout +``` + +이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다. + +### 2. `sid`가 로그인 시 값과 다름 + +실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다. + +따라서: + +1. `sid` 우선 +2. `sub` fallback + +구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다. + +### 3. 순수 frontend-only PKCE인데 endpoint를 만들 수 없음 + +그 경우는 `Back-Channel Logout` 자체를 구현할 수 없습니다. 최소한 logout 수신용 서버 컴포넌트를 추가해야 합니다. + +## 로직 흐름 + +```mermaid +sequenceDiagram + autonumber + participant Browser as 브라우저 + participant RP as PKCE RP + participant Baron as Baron SSO + participant Store as 세션 스토어 + + Browser->>RP: GET /login 호출 + RP->>Browser: Baron authorize endpoint로 리다이렉트 + Browser->>Baron: Authorization Code + PKCE 로그인 + Baron->>Browser: /callback?code=... 으로 리다이렉트 + Browser->>RP: GET /callback 호출 + RP->>Baron: code_verifier 포함 token 요청 + Baron-->>RP: ID Token / Access Token 반환 + RP->>Store: RP 세션 생성 + RP->>RP: registerSessionBinding(sessionId, sid, sub) + RP-->>Browser: 로그인 완료 응답 + + Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료 + Baron->>RP: POST /backchannel-logout (logout_token) + RP->>Baron: Back-Channel JWKS로 logout_token 검증 + Baron-->>RP: 서명 / issuer / audience 검증 기준 제공 + RP->>RP: sid 또는 sub로 sessionId 탐색 + RP->>Store: destroy(sessionId) + RP->>RP: removeSessionBinding(sessionId) + RP-->>Baron: 200 OK + + Browser->>RP: GET /profile 호출 + RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답 +``` + +## 권장 결론 + +PKCE RP에서 `Back-Channel Logout`을 쓰려면, 다음 원칙을 따르십시오. + +1. PKCE 로그인 플로우는 그대로 유지 +2. logout 수신용 서버 endpoint 별도 구현 +3. `sid`와 `sub`를 모두 저장 +4. 세션 스토어에서 직접 세션 파기 +5. 로컬 개발 시 Baron backend가 도달 가능한 URI를 사용 + +이 다섯 가지가 갖춰져야 Baron의 원격 세션 종료가 RP 로컬 세션 종료까지 이어집니다. From cbaa208f794091351800b4a1a34d71e5554159da Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 15:58:24 +0900 Subject: [PATCH 03/11] =?UTF-8?q?server=20side=20app=20=EB=B0=B1=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B0=80=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erver-side-app-backchannel-logout-guide.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/server-side-app-backchannel-logout-guide.md diff --git a/docs/server-side-app-backchannel-logout-guide.md b/docs/server-side-app-backchannel-logout-guide.md new file mode 100644 index 00000000..93f18761 --- /dev/null +++ b/docs/server-side-app-backchannel-logout-guide.md @@ -0,0 +1,322 @@ +# Server-Side App RP Back-Channel Logout 구현 가이드 + +이 문서는 Baron SSO와 연동하는 `server-side-app` RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다. + +## 목적 + +`server-side-app` RP는 confidential client로 동작하면서, Baron SSO의 원격 세션 종료 이벤트를 받아 RP 로컬 세션을 즉시 정리할 수 있어야 합니다. + +즉, `server-side-app` RP는 다음 둘을 모두 구현해야 합니다. + +1. OIDC Authorization Code 로그인과 callback 처리 +2. `logout_token`을 수신하는 `Back-Channel Logout URI` + +## 적용 대상 + +이 가이드는 다음 경우를 대상으로 합니다. + +- `server-side-app` 타입 RP +- confidential client +- `client_secret_basic` 또는 `client_secret_post`를 사용하는 RP +- 자체 서버 세션 또는 BFF 세션을 보유하는 RP + +다음 경우는 이 가이드의 직접 대상이 아닙니다. + +- 순수 frontend-only SPA +- public client 기반 PKCE 앱 + +## devfront 등록 기준 + +`server-side-app` RP는 devfront에서 아래 항목을 등록합니다. + +1. `Type`: `server-side-app` +2. `Redirect URI`: RP callback URL +3. `Back-Channel Logout URI`: RP 서버 endpoint +4. 필요 시 `SID Claim Required` + +예시: + +```text +Type: server-side-app +Redirect URI: http://localhost:4444/callback +Back-Channel Logout URI: http://172.16.9.208:4444/backchannel-logout +SID Claim Required: off +``` + +주의: +- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다. +- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다. + +## 구현 요구사항 + +`server-side-app` RP는 최소한 아래를 구현해야 합니다. + +### 1. confidential client 구성 + +RP는 일반적으로 아래 중 하나의 인증 방식을 사용합니다. + +1. `client_secret_basic` +2. `client_secret_post` + +즉 token 교환 시: + +- `client_id` +- `client_secret` + +가 함께 사용됩니다. + +PKCE와 달리 `code_verifier`, `code_challenge`는 필수가 아닙니다. + +### 2. 로그인 후 세션 매핑 저장 + +RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다. + +- `sid -> rpSessionId` +- `sub -> rpSessionId` + +권장 순서는 다음과 같습니다. + +1. `sid`를 우선 저장 +2. `sub`도 함께 저장 +3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정 + +예시: + +```text +sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a +sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30 +rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj +``` + +### 3. `POST /backchannel-logout` endpoint + +RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다. + +예: + +```text +POST /backchannel-logout +Content-Type: application/x-www-form-urlencoded +Body: logout_token= +``` + +RP는 이 endpoint에서: + +1. `logout_token` 존재 여부 확인 +2. JWT 서명 및 claim 검증 +3. `sid` 또는 `sub`로 로컬 세션 탐색 +4. 세션 스토어에서 직접 세션 파기 +5. 성공 시 `2xx` 응답 + +을 수행해야 합니다. + +### 4. `logout_token` 검증 + +RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다. + +현재 Baron의 JWKS endpoint 예시는 다음과 같습니다. + +```text +GET /api/v1/auth/backchannel/jwks.json +``` + +검증 필수 항목: + +1. JWT 서명 검증 +2. `iss`가 Baron OIDC issuer와 일치 +3. `aud`에 현재 RP `client_id` 포함 +4. `iat` 존재 +5. `jti` 존재 +6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함 +7. `nonce`가 없어야 함 +8. `sid` 또는 `sub`가 있어야 함 + +추가 권장 항목: + +- `jti` replay 방지 캐시 +- 시계 오차 허용 범위 설정 +- 검증 실패 시 `400` + +## 세션 종료 기준 + +### 권장 순서 + +1. `sid`로 매칭 시도 +2. 매칭 실패 시 `sub`로 fallback + +이 기준은 `SID Claim Required` 정책에 따라 달라집니다. + +### `SID Claim Required = true` + +- `logout_token`에 `sid`가 있어야만 처리 +- `sub` fallback 금지 +- `sid` 중심 세션 모델을 운영하는 RP에 적합 + +### `SID Claim Required = false` + +- `sid`가 있으면 우선 사용 +- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능 +- 실제 운영에서는 이 모드가 더 유연할 수 있음 + +## 세션 파기 방식 + +`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다. +반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다. + +예: + +```text +store.destroy(rpSessionId) +``` + +필수 조건: + +- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함 +- 이미 삭제된 세션은 idempotent success 처리 + +## 권장 로그 항목 + +RP는 아래 정도의 로그를 남기는 것을 권장합니다. + +1. 요청 수신 +2. 토큰 검증 성공/실패 +3. `sid`, `sub`, `jti` +4. 매칭된 `rpSessionId` 목록 +5. 세션 파기 성공/실패 수 + +예시: + +```text +[백채널 로그아웃] 요청 수신 +[백채널 로그아웃] 토큰 검증 성공 +[백채널 로그아웃] 세션 탐색 결과 +[백채널 로그아웃] 세션 파기 완료 +[백채널 로그아웃] 처리 완료 +``` + +주의: +- raw `logout_token` 전체를 로그에 남기지 않습니다. +- access token, refresh token, cookie raw value도 남기지 않습니다. + +## 테스트 체크리스트 + +### 기본 성공 시나리오 + +1. server-side-app RP 로그인 +2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인 +3. UserFront에서 `세션 종료` +4. Baron이 RP의 `Back-Channel Logout URI`로 POST +5. RP가 `logout_token` 검증 성공 +6. RP 세션 파기 성공 +7. 보호 페이지 접근 시 비로그인 상태 확인 + +### 확인 포인트 + +1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가 +2. Baron backend가 해당 URI에 실제로 도달 가능한가 +3. RP 로그에 `요청 수신`과 `토큰 검증 성공`이 찍히는가 +4. 세션 스토어에서 실제 세션이 삭제됐는가 +5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가 + +## 구현 예시 구조 + +Node.js/Express 기준 최소 구조 예시는 다음과 같습니다. + +```text +GET /login +GET /callback +GET /profile +GET /logout +POST /backchannel-logout +``` + +내부 저장 예시: + +```text +sidToSessionIds: Map> +subToSessionIds: Map> +sessionIdToBinding: Map +``` + +실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다. + +- 백채널 로그아웃 모듈: `/home/kyy/workspace/baron-sso-server-side-demo/backchannel-logout.js` +- 데모 앱 엔트리포인트: `/home/kyy/workspace/baron-sso-server-side-demo/app.js` + +이 데모는: + +1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록 +2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결 +3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출 + +구조로 동작합니다. + +## 자주 생기는 문제 + +### 1. `localhost`로는 안 되는데 입력은 저장됨 + +입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다. + +예: + +```text +http://localhost:4444/backchannel-logout +``` + +이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다. + +### 2. `sid`가 로그인 시 값과 다름 + +실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다. + +따라서: + +1. `sid` 우선 +2. `sub` fallback + +구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다. + +### 3. `client_secret` 또는 auth method가 잘못되어 callback에서 실패함 + +`server-side-app`은 confidential client이므로 아래 값이 정확해야 합니다. + +1. `client_id` +2. `client_secret` +3. `token_endpoint_auth_method` +4. `redirect_uri` + +이 중 하나라도 다르면 authorization code 교환 단계에서 실패할 수 있습니다. + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + participant Browser as 브라우저 + participant RP as Server-Side RP + participant Baron as Baron SSO + participant Store as 세션 스토어 + + Browser->>RP: GET /login 호출 + RP->>Browser: Baron authorize endpoint로 리다이렉트 + Browser->>Baron: Authorization Code 로그인 + Baron->>Browser: /callback?code=... 으로 리다이렉트 + Browser->>RP: GET /callback 호출 + RP->>Baron: client_secret 포함 token 요청 + Baron-->>RP: ID Token / Access Token 반환 + RP->>Store: RP 세션 생성 + RP->>RP: registerSessionBinding(sessionId, sid, sub) + RP-->>Browser: 로그인 완료 응답 + + Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료 + Baron->>RP: POST /backchannel-logout (logout_token) + RP->>Baron: Back-Channel JWKS로 logout_token 검증 + Baron-->>RP: 서명 / issuer / audience 검증 기준 제공 + RP->>RP: sid 또는 sub로 sessionId 탐색 + RP->>Store: destroy(sessionId) + RP->>RP: removeSessionBinding(sessionId) + RP-->>Baron: 200 OK + + Browser->>RP: GET /profile 호출 + RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답 +``` From 9a87af93f18984f4d3d803700d697bd9e97f1eea Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 16:31:18 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EC=A0=84=EB=AC=B8=20locale=20fallback=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=81=EB=AC=B8=20=EB=B3=B8=EB=AC=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 181 +++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 8cf6946c..135dc419 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -838,11 +838,19 @@ class _SignupScreenState extends State { static String _resolveAgreementText( String key, { required String fallback, + String? englishFallback, required Set placeholders, }) { final localized = tr(key, fallback: '').trim(); - if (localized.isEmpty || placeholders.contains(localized)) { - return fallback; + final hasCorruptedEscapes = RegExp(r'\\{3,}').hasMatch(localized); + final preferredLocaleCode = resolvePreferredLocaleCode(); + final useEnglishFallback = + preferredLocaleCode.startsWith('en') && englishFallback != null; + if ( + localized.isEmpty || + placeholders.contains(localized) || + hasCorruptedEscapes) { + return useEnglishFallback ? englishFallback : fallback; } return localized; } @@ -918,10 +926,106 @@ class _SignupScreenState extends State { 본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다. 부칙 본 약관은 2024년 10월 1일부터 시행됩니다. +"""; + const englishFallback = """ +Baron Software Terms of Service + +Chapter 1. General Provisions +Article 1 (Purpose) +These Terms of Service define the rights, obligations, responsibilities, and other necessary matters between Baron Consultant Co., Ltd. (the "Company") and users in connection with the use of Baron Software and related services (the "Service"). + +Article 2 (Definitions) +1. "Service" means the software and related services provided by the Company. +2. "User" means any member or non-member who accesses and uses the Service. +3. "Member" means a person who agrees to these Terms and enters into a service agreement with the Company. +4. "Non-member" means a person who uses part of the Service without registering as a member. + +Article 3 (Effect and Amendment of the Terms) +These Terms take effect when the User agrees to them and the Company accepts the registration. The Company may amend these Terms when necessary, and amended Terms become effective after notice is provided through the Service. + +Article 4 (Governing Rules) +Matters not expressly provided in these Terms shall be governed by applicable laws of the Republic of Korea and general commercial practice. + +Chapter 2. Service Agreement +Article 5 (Formation of the Agreement) +The service agreement is formed when the User agrees to these Terms, submits the registration form provided by the Company, and the Company approves the registration. + +Article 6 (Reservation or Refusal of Registration) +The Company may reserve or refuse registration if the application contains false information or if it is technically difficult to provide the Service. + +Article 7 (Changes to User Information) +Members may review and edit their information at any time through the account management menu. Members must promptly update changed information and are responsible for problems arising from failure to do so. + +Chapter 3. Privacy Protection +Article 8 (Principles of Privacy Protection) +The Company protects Members' personal information in accordance with applicable laws. Detailed privacy matters are governed by the separate Privacy Policy. + +Article 9 (Compliance with the Privacy Policy) +The collection, use, disclosure, retention, and protection of personal information are governed by the Privacy Policy, which Users may review at any time. + +Article 10 (Children Under 14) +If the Company collects personal information from a child under the age of 14, the consent of a legal guardian is required. + +Chapter 4. Use of the Service +Article 11 (Provision of the Service) +The Company begins providing the Service once a registration request is approved. In principle, the Service is available 24 hours a day, 7 days a week. + +Article 12 (Change or Suspension of the Service) +The Company may change or suspend the Service after prior notice when provision of the Service becomes difficult. + +Chapter 5. Information and Advertising +Article 13 (Information and Advertising) +The Company may provide information and advertising considered necessary during use of the Service. Members may opt out of unwanted communications where permitted. + +Chapter 6. User Content +Article 14 (Management of Content) +The Company may delete content posted by a Member if it is illegal or violates these Terms. + +Article 15 (Copyright) +Copyright in content posted by Members belongs to the Member, but the Company may use such content for service promotion and improvement where permitted by law. + +Chapter 7. Termination and Restrictions +Article 16 (Termination) +Members may request termination of the agreement at any time, and the Company will process the request promptly. + +Article 17 (Restriction of Use) +The Company may restrict access to the Service if a Member violates these Terms. + +Chapter 8. Damages and Disclaimer +Article 18 (Damages) +The Company is not liable for damages arising from free services unless required by law. + +Article 19 (Disclaimer) +The Company is not liable where the Service cannot be provided due to force majeure such as natural disasters. + +Chapter 9. Paid Services +Article 20 (Use of Paid Services) +The Company may provide certain services for a fee. Pricing, payment methods, and refund procedures will be described on the service information page and payment screen. Fees are generally prepaid. + +Article 21 (Refund Policy) +Users may receive a full refund if they do not start using a paid service within 7 days after payment. Partial refunds may apply when service suspension occurs for reasons not attributable to the User. + +Article 22 (Suspension and Cancellation of Paid Services) +Members who wish to cancel a paid service must submit a cancellation request through customer support. The Company may immediately suspend and terminate paid services if the Member violates these Terms or uses the service improperly. + +Chapter 10. No Assignment +Article 23 (No Assignment) +Members may not assign, transfer, donate, or pledge their right to use the Service or their contractual status to a third party. + +Chapter 11. Governing Court +Article 24 (Dispute Resolution) +If a dispute arises in connection with the use of the Service, the Company and the Member shall make good-faith efforts to resolve it. + +Article 25 (Jurisdiction) +Any dispute arising under these Terms shall be subject to the exclusive jurisdiction of the Seoul Central District Court. + +Supplementary Provision +These Terms take effect on October 1, 2024. """; return _resolveAgreementText( 'msg.userfront.signup.tos_full', fallback: fallback, + englishFallback: englishFallback, placeholders: {'서비스 이용약관 전문...', 'Tos Full'}, ); } @@ -1035,10 +1139,83 @@ class _SignupScreenState extends State { 회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다. 제8조 (기타) 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. +"""; + const englishFallback = """ +Consent to Collection and Use of Personal Information + +Baron Service Privacy Policy + +Article 1 (Purpose) +Baron Consultant Co., Ltd. (the "Company") establishes this Privacy Policy to protect the personal information of customers and users of Baron Service (the "Service") and to fulfill its duties under the Personal Information Protection Act and other applicable laws. + +Article 2 (Purposes of Processing Personal Information) +The Company processes personal information for the following purposes: +- identity verification for registration and account management +- communication by phone or email +- provision of notices and operation of the Service +- delivery of product materials +- consultation and demo requests +- event participation and seminar guidance +- delivery of security guidance materials +- technical support +- service improvement feedback +- marketing communications for users who have separately agreed + +Article 3 (Retention Period) +The Company retains and uses personal information within the period required by law or agreed to by the data subject. +- member information: from registration until 1 year after account deletion +- promotional, consultation, and contract-related information: 2 years + +Article 4 (Provision to Third Parties) +The Company processes personal information only within the scope described in this Policy and provides it to third parties only where consent has been obtained or where required by law. + +Article 5 (Entrustment of Processing) +The Company does not currently entrust personal information processing to external processors for the core scope described here. If outsourcing becomes necessary, the Company will provide notice and obtain consent where required. + +Article 6 (Rights of Data Subjects) +Data subjects may request access, correction, deletion, suspension of processing, and other rights permitted by law. Requests may be submitted in writing, by email, or by facsimile. The Company may verify the identity or authority of the requester. + +Article 7 (Items of Personal Information Processed) +The Company may process the following items: +- required: name, mobile phone number, email address +- optional: company telephone number, inquiry details +- collection channels: website, phone, email + +Article 8 (Destruction of Personal Information) +When personal information is no longer needed due to expiration of the retention period or achievement of the processing purpose, the Company destroys it without delay. Electronic records are deleted using technically appropriate methods, and paper documents are shredded or incinerated. + +Article 9 (Security Measures) +The Company implements administrative, technical, and physical safeguards, including internal management plans, employee training, access control, encryption where appropriate, security software, and restricted access to facilities. + +Article 10 (Automatic Collection Devices) +The Company does not use cookies for this Service in the scope described here. + +Article 11 (Chief Privacy Officer) +The Company designates a privacy officer responsible for overall personal information protection and complaint handling. + +Article 12 (Requests for Access) +Data subjects may submit requests for access to personal information to the department designated by the Company, and the Company will make reasonable efforts to respond promptly. + +Article 13 (Remedies for Rights Infringement) +Data subjects may seek dispute resolution or consultation from competent authorities and institutions handling personal information disputes and complaints. + +Article 14 (Changes to This Privacy Policy) +If this Policy is added to, deleted from, or otherwise modified due to changes in law, policy, or security technology, the Company will provide advance notice before the effective date. + +Supplementary Provisions +1. Effective Date +This Privacy Policy takes effect on October 1, 2024. +2. Notice of Amendments +The Company will notify users of amendments through service notices, the website, or email as appropriate. +3. Severability +If any part of this Policy is held invalid or unenforceable, the remaining provisions will remain effective. +4. Miscellaneous +Matters not expressly provided in this Policy are governed by the Company's internal policies and applicable laws. """; return _resolveAgreementText( 'msg.userfront.signup.privacy_full', fallback: fallback, + englishFallback: englishFallback, placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'}, ); } From 64cdef81a6a1c60807e2dc245c13e1d9eb05ab21 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 16:31:40 +0900 Subject: [PATCH 05/11] =?UTF-8?q?i18n=20=EB=88=84=EB=9D=BD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B9=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/ForbiddenMessage.tsx | 16 ++--- devfront/src/components/layout/AppLayout.tsx | 36 +++++----- .../lib/core/constants/error_whitelist.dart | 23 +++--- .../lib/core/services/auth_proxy_service.dart | 46 ++++++------ .../auth/presentation/error_screen.dart | 43 ++++++----- userfront/lib/i18n_data.dart | 71 +++++++++++++++++++ 6 files changed, 154 insertions(+), 81 deletions(-) diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index 97c2af01..43dee424 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -14,34 +14,34 @@ export function ForbiddenMessage({ resourceToken }: Props) { let explanation = t( "msg.dev.forbidden.default", - "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.", + "You do not have permission to access this resource. Contact your administrator.", ); if (role === "rp_admin") { explanation = t( "msg.dev.forbidden.rp_admin", - "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.", + "RP administrators can only access resources for their assigned applications.", ); } else if (role === "tenant_admin") { explanation = t( "msg.dev.forbidden.tenant_admin", - "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.", + "Your tenant administrator permission is missing, misconfigured, or expired.", ); } else if (role === "user" || role === "tenant_member") { if (resourceToken === "consents") { explanation = t( "msg.dev.forbidden.user.consents", - "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Viewing consent records for this application requires an RP administrator, consent read, or consent revoke relationship. Request access from an administrator if needed.", ); } else if (resourceToken === "audit") { explanation = t( "msg.dev.forbidden.user.audit", - "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Viewing audit logs for this application requires an RP administrator or audit read relationship. Request access from an administrator if needed.", ); } else { explanation = t( "msg.dev.forbidden.user.clients", - "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target RP. Request access from an administrator if needed.", ); } } @@ -51,9 +51,9 @@ export function ForbiddenMessage({ resourceToken }: Props) { ? t("ui.dev.audit.title", "Audit Logs") : resourceToken === "consents" ? t("ui.dev.clients.consents.title", "User Consent Grants") - : t("ui.dev.clients.registry.subtitle", "연동 앱"); + : t("ui.dev.clients.registry.subtitle", "Connected Applications"); - const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", { + const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", { resource: resourceLabel, }); diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index be1da833..0f5e1c71 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -32,7 +32,7 @@ const navItems = [ }, { labelKey: "ui.dev.nav.developer_request", - labelFallback: "개발자 권한 신청", + labelFallback: "Developer Access Request", to: "/developer-requests", icon: ClipboardCheck, }, @@ -71,7 +71,11 @@ function AppLayout() { }); const handleLogout = () => { - if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) { + if ( + window.confirm( + t("msg.dev.logout_confirm", "Are you sure you want to log out?"), + ) + ) { auth.removeUser(); navigate("/login"); } @@ -136,7 +140,7 @@ function AppLayout() { try { await auth.signinSilent(); } catch (error) { - console.error("세션 자동 연장에 실패했습니다.", error); + console.error("Silent session renewal failed.", error); } finally { isRenewInFlightRef.current = false; } @@ -184,7 +188,7 @@ function AppLayout() { try { await auth.signinSilent(); } catch (error) { - console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + console.error("Unlimited session keepalive renewal failed.", error); } finally { isRenewInFlightRef.current = false; } @@ -241,7 +245,7 @@ function AppLayout() { void auth .signinSilent() .catch((error) => { - console.error("세션 자동 연장에 실패했습니다.", error); + console.error("Silent session renewal failed.", error); }) .finally(() => { isRenewInFlightRef.current = false; @@ -289,15 +293,15 @@ function AppLayout() { let sessionToneClass = "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let sessionText = t("ui.dev.session.active", "세션 활성"); + let sessionText = t("ui.dev.session.active", "Session active"); if (remainingMs === null) { sessionToneClass = "border-border bg-card text-muted-foreground"; - sessionText = t("ui.dev.session.unknown", "알 수 없음"); + sessionText = t("ui.dev.session.unknown", "Unknown"); } else if (remainingMs <= 0) { sessionToneClass = "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - sessionText = t("ui.dev.session.expired", "세션 만료"); + sessionText = t("ui.dev.session.expired", "Session expired"); } else if ( remainingMinutes !== null && remainingSeconds !== null && @@ -307,7 +311,7 @@ function AppLayout() { "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; sessionText = t( "ui.dev.session.expiring", - "만료 임박: {{minutes}}분 {{seconds}}초 남음", + "Expiring soon: {{minutes}}m {{seconds}}s left", { minutes: remainingMinutes, seconds: remainingSeconds, @@ -316,7 +320,7 @@ function AppLayout() { } else { sessionText = t( "ui.dev.session.remaining", - "만료 예정: {{minutes}}분 {{seconds}}초 남음", + "Expires in {{minutes}}m {{seconds}}s", { minutes: remainingMinutes ?? 0, seconds: remainingSeconds ?? 0, @@ -343,7 +347,7 @@ function AppLayout() {

- {t("ui.dev.brand", "Baron 로그인")} + {t("ui.dev.brand", "Baron Sign In")}

{t("ui.dev.console_title", "Developer Console")} @@ -423,7 +427,7 @@ function AppLayout() { type="button" onClick={toggleTheme} className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20" - aria-label={t("ui.common.theme_toggle", "테마 전환")} + aria-label={t("ui.common.theme_toggle", "Toggle theme")} > {theme === "light" ? : } {theme === "light" @@ -447,7 +451,7 @@ function AppLayout() { className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20" aria-haspopup="menu" aria-expanded={isProfileMenuOpen} - aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")} + aria-label={t("ui.dev.profile.menu_aria", "Open account menu")} >
{profileInitial} @@ -496,14 +500,14 @@ function AppLayout() {

- {t("ui.dev.session.auto_extend", "세션 만료 관리")} + {t("ui.dev.session.auto_extend", "Session expiry")}

{isSessionExpiryEnabled ? sessionText : t( "ui.dev.session.disabled", - "세션 만료 비활성화", + "Session expiry disabled", )}

@@ -539,7 +543,7 @@ function AppLayout() { }} > - {t("ui.dev.profile.title", "내 정보")} + {t("ui.dev.profile.title", "My Profile")}