1
0
forked from baron/baron-sso

feat(i18n): apply ORY bypass whitelist policy and add error-code tests

This commit is contained in:
Lectom C Han
2026-02-13 10:47:33 +09:00
parent c1645b2d4b
commit db71364e80
18 changed files with 636 additions and 45 deletions

View File

@@ -0,0 +1,33 @@
package main
import (
"baron-sso-backend/internal/response"
"errors"
"log/slog"
"github.com/gofiber/fiber/v2"
)
func newErrorHandler(appEnv string) fiber.ErrorHandler {
return func(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
var e *fiber.Error
if errors.As(err, &e) {
code = e.Code
}
if appEnv == "production" || appEnv == "stage" {
if code >= 500 {
slog.Error("Internal Server Error",
"error", err.Error(),
"path", c.Path(),
"method", c.Method(),
)
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
}
}
return response.Error(c, code, response.StatusCode(code), err.Error())
}
}

View File

@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func decodeJSONBody(t *testing.T, resp *http.Response) map[string]any {
t.Helper()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode response body: %v", err)
}
return body
}
func TestNewErrorHandler_ProductionMasksServerError(t *testing.T) {
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
app.Get("/boom", func(c *fiber.Ctx) error {
return errors.New("database connection failed")
})
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("unexpected status code: %d", resp.StatusCode)
}
body := decodeJSONBody(t, resp)
if body["error"] != "Internal Server Error" {
t.Fatalf("unexpected error message: %v", body["error"])
}
if body["code"] != "internal_error" {
t.Fatalf("unexpected error code: %v", body["code"])
}
}
func TestNewErrorHandler_ProductionPassesClientError(t *testing.T) {
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
app.Get("/bad", func(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "bad request payload")
})
req := httptest.NewRequest(http.MethodGet, "/bad", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("unexpected status code: %d", resp.StatusCode)
}
body := decodeJSONBody(t, resp)
if body["error"] != "bad request payload" {
t.Fatalf("unexpected error message: %v", body["error"])
}
if body["code"] != "bad_request" {
t.Fatalf("unexpected error code: %v", body["code"])
}
}
func TestNewErrorHandler_DevelopmentReturnsOriginalServerError(t *testing.T) {
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("dev")})
app.Get("/boom", func(c *fiber.Ctx) error {
return errors.New("database connection failed")
})
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("unexpected status code: %d", resp.StatusCode)
}
body := decodeJSONBody(t, resp)
if body["error"] != "database connection failed" {
t.Fatalf("unexpected error message: %v", body["error"])
}
if body["code"] != "internal_error" {
t.Fatalf("unexpected error code: %v", body["code"])
}
}
func TestNewErrorHandler_MapsUnauthorizedCode(t *testing.T) {
app := fiber.New(fiber.Config{ErrorHandler: newErrorHandler("production")})
app.Get("/unauthorized", func(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusUnauthorized, "missing token")
})
req := httptest.NewRequest(http.MethodGet, "/unauthorized", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unexpected status code: %d", resp.StatusCode)
}
body := decodeJSONBody(t, resp)
if body["code"] != "invalid_session" {
t.Fatalf("unexpected error code: %v", body["code"])
}
}

View File

@@ -7,11 +7,9 @@ import (
"baron-sso-backend/internal/idp" "baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger" "baron-sso-backend/internal/logger"
"baron-sso-backend/internal/middleware" "baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/response"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/validator" "baron-sso-backend/internal/validator"
"errors"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
@@ -272,34 +270,7 @@ func main() {
AppName: "Baron SSO Backend", AppName: "Baron SSO Backend",
DisableStartupMessage: true, // Clean logs DisableStartupMessage: true, // Clean logs
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응) ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
// Global Error Handler for Production Masking ErrorHandler: newErrorHandler(appEnv),
ErrorHandler: func(c *fiber.Ctx, err error) error {
// Default status code
code := fiber.StatusInternalServerError
// Check if it's a known fiber.Error
var e *fiber.Error
if errors.As(err, &e) {
code = e.Code
}
// In production or stage, mask detailed 500+ errors
if appEnv == "production" || appEnv == "stage" {
if code >= 500 {
// Log the actual error for developers
slog.Error("Internal Server Error",
"error", err.Error(),
"path", c.Path(),
"method", c.Method(),
)
// Return masked message
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
}
}
// For development or non-500 errors, return the actual error message
return response.Error(c, code, response.StatusCode(code), err.Error())
},
}) })
// Middleware // Middleware

View File

@@ -849,6 +849,11 @@ components:
properties: properties:
error: error:
type: string type: string
code:
type: string
details:
type: object
additionalProperties: true
MessageResponse: MessageResponse:
type: object type: object

View File

@@ -0,0 +1,85 @@
package response
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func parseBody(t *testing.T, resp *http.Response) map[string]any {
t.Helper()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("failed to parse response body: %v", err)
}
return body
}
func TestErrorWithDetailsResponseShape(t *testing.T) {
app := fiber.New()
app.Get("/test", func(c *fiber.Ctx) error {
return ErrorWithDetails(
c,
fiber.StatusConflict,
"conflict",
"resource already exists",
map[string]any{"field": "email"},
)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusConflict {
t.Fatalf("unexpected status code: %d", resp.StatusCode)
}
body := parseBody(t, resp)
if body["error"] != "resource already exists" {
t.Fatalf("unexpected error value: %v", body["error"])
}
if body["code"] != "conflict" {
t.Fatalf("unexpected code value: %v", body["code"])
}
details, ok := body["details"].(map[string]any)
if !ok {
t.Fatalf("details should be map, got: %T", body["details"])
}
if details["field"] != "email" {
t.Fatalf("unexpected details value: %v", details["field"])
}
}
func TestStatusCodeMapping(t *testing.T) {
tests := []struct {
name string
status int
expected string
}{
{name: "bad request", status: fiber.StatusBadRequest, expected: "bad_request"},
{name: "unauthorized", status: fiber.StatusUnauthorized, expected: "invalid_session"},
{name: "forbidden", status: fiber.StatusForbidden, expected: "forbidden"},
{name: "not found", status: fiber.StatusNotFound, expected: "not_found"},
{name: "conflict", status: fiber.StatusConflict, expected: "conflict"},
{name: "too many requests", status: fiber.StatusTooManyRequests, expected: "rate_limited"},
{name: "service unavailable", status: fiber.StatusServiceUnavailable, expected: "service_unavailable"},
{name: "fallback", status: fiber.StatusInternalServerError, expected: "internal_error"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := StatusCode(tc.status)
if got != tc.expected {
t.Fatalf("unexpected code: got=%s expected=%s", got, tc.expected)
}
})
}
}

View File

@@ -86,11 +86,13 @@
```json ```json
{ {
"error": "사람이 읽을 수 있는 에러 메시지", "error": "사람이 읽을 수 있는 에러 메시지",
"code": "MACHINE_READABLE_CODE", // 선택적 (예: USER_NOT_FOUND, HYDRA_CONN_ERR) "code": "MACHINE_READABLE_CODE", // 1차 표준 필드 (예: invalid_session, rate_limited)
"details": { ... } // 선택적 (Validation error 필드별 상세 등) "details": { ... } // 선택적 (Validation error 필드별 상세 등)
} }
``` ```
- `code`는 프론트 분기 기준이므로 신규/변경 API에서는 포함을 기본값으로 합니다.
## 5. 헤더 및 보안 (Headers & Security) ## 5. 헤더 및 보안 (Headers & Security)
### 5.1 인증 (Authentication) ### 5.1 인증 (Authentication)

View File

@@ -165,6 +165,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
#### 5.2.4 Flutter (User) 구현 가이드 #### 5.2.4 Flutter (User) 구현 가이드
Flutter는 런타임에 TOML을 파싱하기 위해 `toml` 패키지와 `easy_localization`의 커스텀 로더를 사용합니다. Flutter는 런타임에 TOML을 파싱하기 위해 `toml` 패키지와 `easy_localization`의 커스텀 로더를 사용합니다.
#### 5.2.5 UserFront 에러 표시 정책 (Production)
UserFront(`/error`)는 프로덕션에서 다음 규칙으로 에러를 표시합니다.
1. **Internal whitelist 코드**
- `msg.userfront.error.whitelist.{code}` 메시지를 노출합니다.
2. **ORY bypass 코드**
- `msg.userfront.error.ory.{code}` 메시지를 노출합니다.
3. **그 외 코드**
- `unknown_error`로 처리하고 일반 안내 문구(`msg.userfront.error.detail_contact`)를 노출합니다.
코드 집합은 `userfront/lib/core/constants/error_whitelist.dart`를 단일 기준으로 유지합니다.
* **패키지 추가 (`pubspec.yaml`)**: * **패키지 추가 (`pubspec.yaml`)**:
```yaml ```yaml
dependencies: dependencies:

View File

@@ -0,0 +1,72 @@
# UserFront Error Handling Policy
## 1. 목적
- UserFront의 `/error` 화면에서 에러 코드 노출 정책을 일관되게 유지합니다.
- Ory에서 전달되는 표준 에러 코드는 별도 bypass 규칙으로 처리합니다.
- 내부 코드와 번역 리소스의 누락을 자동 검증합니다.
## 2. 처리 원칙
1. 프로덕션에서 에러 분기 기준은 `error` 문자열이 아니라 `code`입니다.
2. `ORY error code`는 bypass 규칙으로 처리합니다.
3. ORY 코드 외 항목은 내부 whitelist를 기준으로 노출합니다.
4. whitelist/bypass에 없는 코드는 `unknown_error`로 처리합니다.
## 3. 코드 분류
기준 파일: `userfront/lib/core/constants/error_whitelist.dart`
### 3.1 Internal whitelist
- `settings_disabled`
- `invalid_session`
- `verification_required`
- `recovery_expired`
- `recovery_invalid`
- `rate_limited`
- `not_found`
- `bad_request`
- `password_or_email_mismatch`
### 3.2 ORY bypass
- `access_denied`
- `consent_required`
- `interaction_required`
- `invalid_client`
- `invalid_grant`
- `invalid_request`
- `invalid_scope`
- `login_required`
- `request_forbidden`
- `server_error`
- `temporarily_unavailable`
- `unauthorized_client`
- `unsupported_response_type`
## 4. i18n 키 구조
- 내부 whitelist: `msg.userfront.error.whitelist.{code}`
- ORY bypass: `msg.userfront.error.ory.{code}`
리소스는 아래 파일 모두에 키가 있어야 합니다.
- `locales/template.toml`
- `locales/ko.toml`
- `locales/en.toml`
- `userfront/assets/translations/template.toml`
- `userfront/assets/translations/ko.toml`
- `userfront/assets/translations/en.toml`
## 5. 검증
키 누락 검증 스크립트:
```bash
./scripts/verify_userfront_error_i18n.sh
```
화면 동작 테스트:
```bash
cd userfront
flutter test test/error_screen_test.dart
```
## 6. 관련 이슈
- `#164` `[UserFront] 에러 노출 whitelist 정의 및 적용`
- `#259` `백엔드 i18n/에러 메시지 fallback 정책 재정리 및 반영 계획 수립`
- `#260` `[Backend] 에러 응답 code 통일 구현 계획 (phase rollout)`

View File

@@ -356,6 +356,29 @@ type = "Type"
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
settings_disabled = "Account settings are currently unavailable." settings_disabled = "Account settings are currently unavailable."
invalid_session = "Your session has expired. Please sign in again."
verification_required = "Additional verification is required. Please follow the instructions."
recovery_expired = "The recovery link has expired. Please request a new one."
recovery_invalid = "The recovery link is invalid."
rate_limited = "Too many requests. Please try again later."
not_found = "The requested page could not be found."
bad_request = "Please check your input."
password_or_email_mismatch = "Email or password does not match."
[msg.userfront.error.ory]
access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again."
invalid_client = "Client authentication failed."
invalid_grant = "The authorization grant is invalid or expired."
invalid_request = "The request is invalid."
invalid_scope = "The requested scope is invalid."
login_required = "Login is required."
request_forbidden = "The request was forbidden."
server_error = "An authentication server error occurred."
temporarily_unavailable = "The authentication server is temporarily unavailable."
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "Description" description = "Description"

View File

@@ -356,6 +356,29 @@ type = "오류 종류: {{type}}"
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
settings_disabled = "현재 계정 설정 화면은 준비 중입니다." settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
bad_request = "입력값을 확인해 주세요."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
[msg.userfront.error.ory]
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."

View File

@@ -356,6 +356,29 @@ type = ""
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
settings_disabled = "" settings_disabled = ""
invalid_session = ""
verification_required = ""
recovery_expired = ""
recovery_invalid = ""
rate_limited = ""
not_found = ""
bad_request = ""
password_or_email_mismatch = ""
[msg.userfront.error.ory]
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.forgot] [msg.userfront.forgot]
description = "" description = ""

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
whitelist_file="$repo_root/userfront/lib/core/constants/error_whitelist.dart"
extract_internal_codes() {
awk '
/internalErrorWhitelistMessages/ { in_map=1; next }
in_map && /\};/ { in_map=0 }
in_map {
if (match($0, /'\''([a-z0-9_]+)'\''[[:space:]]*:/, m)) {
print m[1]
}
}
' "$whitelist_file"
}
extract_ory_codes() {
awk '
/oryBypassErrorCodes/ { in_set=1; next }
in_set && /\};/ { in_set=0 }
in_set {
if (match($0, /'\''([a-z0-9_]+)'\''/, m)) {
print m[1]
}
}
' "$whitelist_file"
}
extract_toml_section_keys() {
local file="$1"
local section="$2"
awk -v section="$section" '
/^[[:space:]]*\[[^]]+\][[:space:]]*$/ {
current=$0
gsub(/^[[:space:]]*\[/, "", current)
gsub(/\][[:space:]]*$/, "", current)
in_section=(current == section)
next
}
in_section {
if (match($0, /^[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*=/, m)) {
print m[1]
}
}
' "$file"
}
check_section_keys() {
local expected_keys="$1"
local section="$2"
local file="$3"
local missing
missing="$(comm -23 \
<(printf '%s\n' "$expected_keys" | sed '/^$/d' | sort -u) \
<(extract_toml_section_keys "$file" "$section" | sort -u) \
)"
if [[ -n "$missing" ]]; then
echo "[FAIL] ${file} -> [${section}] 누락 키:"
printf '%s\n' "$missing" | sed 's/^/ - /'
return 1
fi
echo "[OK] ${file} -> [${section}]"
}
internal_codes="$(extract_internal_codes)"
ory_codes="$(extract_ory_codes)"
if [[ -z "$internal_codes" ]]; then
echo "[FAIL] 내부 whitelist 코드를 찾지 못했습니다."
exit 1
fi
if [[ -z "$ory_codes" ]]; then
echo "[FAIL] ORY bypass 코드를 찾지 못했습니다."
exit 1
fi
files=(
"$repo_root/locales/template.toml"
"$repo_root/locales/ko.toml"
"$repo_root/locales/en.toml"
"$repo_root/userfront/assets/translations/template.toml"
"$repo_root/userfront/assets/translations/ko.toml"
"$repo_root/userfront/assets/translations/en.toml"
)
for file in "${files[@]}"; do
check_section_keys "$internal_codes" "msg.userfront.error.whitelist" "$file"
check_section_keys "$ory_codes" "msg.userfront.error.ory" "$file"
done
echo "모든 에러 코드 i18n 키 검증이 완료되었습니다."

View File

@@ -80,6 +80,29 @@ type = "Type"
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
settings_disabled = "Account settings are currently unavailable." settings_disabled = "Account settings are currently unavailable."
invalid_session = "Your session has expired. Please sign in again."
verification_required = "Additional verification is required. Please follow the instructions."
recovery_expired = "The recovery link has expired. Please request a new one."
recovery_invalid = "The recovery link is invalid."
rate_limited = "Too many requests. Please try again later."
not_found = "The requested page could not be found."
bad_request = "Please check your input."
password_or_email_mismatch = "Email or password does not match."
[msg.userfront.error.ory]
access_denied = "The user denied the consent request."
consent_required = "Consent is required to continue."
interaction_required = "Additional interaction is required. Please try again."
invalid_client = "Client authentication failed."
invalid_grant = "The authorization grant is invalid or expired."
invalid_request = "The request is invalid."
invalid_scope = "The requested scope is invalid."
login_required = "Login is required."
request_forbidden = "The request was forbidden."
server_error = "An authentication server error occurred."
temporarily_unavailable = "The authentication server is temporarily unavailable."
unauthorized_client = "The client is not authorized for this request."
unsupported_response_type = "The response type is not supported."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "Description" description = "Description"
@@ -96,7 +119,7 @@ link_send_failed = "Link Send Failed"
link_sent_email = "Link Sent Email" link_sent_email = "Link Sent Email"
link_sent_phone = "Link Sent Phone" link_sent_phone = "Link Sent Phone"
link_timeout = "Time expired." link_timeout = "Time expired."
no_account = "No Account" no_account = "New to Baron?"
oidc_failed = "OIDC Failed" oidc_failed = "OIDC Failed"
qr_expired = "Time expired." qr_expired = "Time expired."
qr_init_failed = "QR Init Failed" qr_init_failed = "QR Init Failed"
@@ -297,7 +320,7 @@ save = "Save"
search = "Search" search = "Search"
show_more = "Show More" show_more = "Show More"
language = "Language" language = "Language"
language_ko = "Korean" language_ko = "한국어"
language_en = "English" language_en = "English"
theme_dark = "Dark" theme_dark = "Dark"
theme_light = "Light" theme_light = "Light"
@@ -534,3 +557,4 @@ verify = "Verify"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "Action" action = "Action"

View File

@@ -80,6 +80,29 @@ type = "오류 종류: {type}"
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
settings_disabled = "현재 계정 설정 화면은 준비 중입니다." settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
bad_request = "입력값을 확인해 주세요."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
[msg.userfront.error.ory]
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.forgot] [msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
@@ -298,7 +321,7 @@ search = "검색"
show_more = "+ 더보기" show_more = "+ 더보기"
language = "언어" language = "언어"
language_ko = "한국어" language_ko = "한국어"
language_en = "영어" language_en = "English"
theme_dark = "Dark" theme_dark = "Dark"
theme_light = "Light" theme_light = "Light"
theme_toggle = "테마 전환" theme_toggle = "테마 전환"
@@ -534,3 +557,4 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"

View File

@@ -80,6 +80,29 @@ type = ""
[msg.userfront.error.whitelist] [msg.userfront.error.whitelist]
settings_disabled = "" settings_disabled = ""
invalid_session = ""
verification_required = ""
recovery_expired = ""
recovery_invalid = ""
rate_limited = ""
not_found = ""
bad_request = ""
password_or_email_mismatch = ""
[msg.userfront.error.ory]
access_denied = ""
consent_required = ""
interaction_required = ""
invalid_client = ""
invalid_grant = ""
invalid_request = ""
invalid_scope = ""
login_required = ""
request_forbidden = ""
server_error = ""
temporarily_unavailable = ""
unauthorized_client = ""
unsupported_response_type = ""
[msg.userfront.forgot] [msg.userfront.forgot]
description = "" description = ""

View File

@@ -1,11 +1,27 @@
const Map<String, String> errorWhitelistMessages = { const Map<String, String> internalErrorWhitelistMessages = {
'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.', 'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.',
'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.', 'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.',
'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.', 'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.',
'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.', 'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.',
'recovery_invalid': '재설정 링크가 유효하지 않습니다.', 'recovery_invalid': '재설정 링크가 유효하지 않습니다.',
'consent_required': '앱 접근 동의가 필요합니다.',
'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.', 'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.',
'not_found': '요청한 페이지를 찾을 수 없습니다.', 'not_found': '요청한 페이지를 찾을 수 없습니다.',
'bad_request': '입력값을 확인해 주세요.', 'bad_request': '입력값을 확인해 주세요.',
'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.',
};
const Set<String> oryBypassErrorCodes = {
'access_denied',
'consent_required',
'interaction_required',
'invalid_client',
'invalid_grant',
'invalid_request',
'invalid_scope',
'login_required',
'request_forbidden',
'server_error',
'temporarily_unavailable',
'unauthorized_client',
'unsupported_response_type',
}; };

View File

@@ -24,10 +24,13 @@ class ErrorScreen extends StatelessWidget {
final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim(); final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty; final hasCode = normalizedCode.isNotEmpty;
final whitelistFallback = errorWhitelistMessages[normalizedCode]; final internalWhitelistFallback =
final isWhitelisted = whitelistFallback != null; internalErrorWhitelistMessages[normalizedCode];
final isInternalWhitelisted = internalWhitelistFallback != null;
final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode);
final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass);
final errorType = isProd final errorType = isProd
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error') ? (isKnownProdCode ? normalizedCode : 'unknown_error')
: (hasCode ? normalizedCode : 'unknown_error'); : (hasCode ? normalizedCode : 'unknown_error');
final title = isProd final title = isProd
? tr('msg.userfront.error.title') ? tr('msg.userfront.error.title')
@@ -40,14 +43,22 @@ class ErrorScreen extends StatelessWidget {
'msg.userfront.error.title_generic', 'msg.userfront.error.title_generic',
)); ));
final detail = isProd final detail = isProd
? (isWhitelisted ? (isInternalWhitelisted
? tr( ? tr(
'msg.userfront.error.whitelist.$normalizedCode', 'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback, fallback: internalWhitelistFallback,
) )
: tr( : (isOryBypass
'msg.userfront.error.detail_contact', ? tr(
)) 'msg.userfront.error.ory.$normalizedCode',
fallback:
(description?.isNotEmpty == true)
? description
: tr('msg.userfront.error.detail_request'),
)
: tr(
'msg.userfront.error.detail_contact',
)))
: ((description?.isNotEmpty == true) : ((description?.isNotEmpty == true)
? description! ? description!
: (hasCode : (hasCode

View File

@@ -74,7 +74,7 @@ void main() {
); );
final detail = tr( final detail = tr(
'msg.userfront.error.whitelist.settings_disabled', 'msg.userfront.error.whitelist.settings_disabled',
fallback: errorWhitelistMessages['settings_disabled']!, fallback: internalErrorWhitelistMessages['settings_disabled']!,
); );
final type = tr( final type = tr(
'msg.userfront.error.type', 'msg.userfront.error.type',
@@ -88,6 +88,34 @@ void main() {
expect(find.text(type), findsOneWidget); expect(find.text(type), findsOneWidget);
}); });
testWidgets('프로덕션은 ORY 코드를 bypass 처리한다', (WidgetTester tester) async {
await _pumpErrorScreen(
tester,
errorCode: 'access_denied',
description: '원문 메시지',
isProdOverride: true,
);
final title = tr(
'msg.userfront.error.title',
fallback: '인증 과정에서 오류가 발생했습니다',
);
final detail = tr(
'msg.userfront.error.ory.access_denied',
fallback: '사용자가 동의를 거부했습니다.',
);
final type = tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': 'access_denied'},
);
expect(find.text(title), findsOneWidget);
expect(find.text(detail), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing);
expect(find.text(type), findsOneWidget);
});
testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async { testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async {
await _pumpErrorScreen( await _pumpErrorScreen(
tester, tester,