diff --git a/backend/cmd/server/error_handler.go b/backend/cmd/server/error_handler.go new file mode 100644 index 00000000..7e531b75 --- /dev/null +++ b/backend/cmd/server/error_handler.go @@ -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()) + } +} diff --git a/backend/cmd/server/error_handler_test.go b/backend/cmd/server/error_handler_test.go new file mode 100644 index 00000000..ba6c2d6a --- /dev/null +++ b/backend/cmd/server/error_handler_test.go @@ -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"]) + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ee0ad215..ccf938e5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -7,11 +7,9 @@ import ( "baron-sso-backend/internal/idp" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/middleware" - "baron-sso-backend/internal/response" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/validator" - "errors" "fmt" "log" "log/slog" @@ -272,34 +270,7 @@ func main() { AppName: "Baron SSO Backend", DisableStartupMessage: true, // Clean logs ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응) - // Global Error Handler for Production Masking - 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()) - }, + ErrorHandler: newErrorHandler(appEnv), }) // Middleware diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 47068c72..10ced54f 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -849,6 +849,11 @@ components: properties: error: type: string + code: + type: string + details: + type: object + additionalProperties: true MessageResponse: type: object diff --git a/backend/internal/response/error_response_test.go b/backend/internal/response/error_response_test.go new file mode 100644 index 00000000..a4491cf4 --- /dev/null +++ b/backend/internal/response/error_response_test.go @@ -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) + } + }) + } +} diff --git a/docs/API_DESIGN_POLICY.md b/docs/API_DESIGN_POLICY.md index 4e038bcf..721a5155 100644 --- a/docs/API_DESIGN_POLICY.md +++ b/docs/API_DESIGN_POLICY.md @@ -86,11 +86,13 @@ ```json { "error": "사람이 읽을 수 있는 에러 메시지", - "code": "MACHINE_READABLE_CODE", // 선택적 (예: USER_NOT_FOUND, HYDRA_CONN_ERR) + "code": "MACHINE_READABLE_CODE", // 1차 표준 필드 (예: invalid_session, rate_limited) "details": { ... } // 선택적 (Validation error 필드별 상세 등) } ``` +- `code`는 프론트 분기 기준이므로 신규/변경 API에서는 포함을 기본값으로 합니다. + ## 5. 헤더 및 보안 (Headers & Security) ### 5.1 인증 (Authentication) diff --git a/docs/i18n.md b/docs/i18n.md index ed4400ab..8a78bee2 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -165,6 +165,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다. #### 5.2.4 Flutter (User) 구현 가이드 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`)**: ```yaml dependencies: diff --git a/docs/userfront_error_handling_policy.md b/docs/userfront_error_handling_policy.md new file mode 100644 index 00000000..c673c236 --- /dev/null +++ b/docs/userfront_error_handling_policy.md @@ -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)` diff --git a/locales/en.toml b/locales/en.toml index c60f9b62..11e4eade 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -356,6 +356,29 @@ type = "Type" [msg.userfront.error.whitelist] 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] description = "Description" diff --git a/locales/ko.toml b/locales/ko.toml index cab3306a..783f4a94 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -356,6 +356,29 @@ type = "오류 종류: {{type}}" [msg.userfront.error.whitelist] 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] description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." diff --git a/locales/template.toml b/locales/template.toml index c11280bb..2531cdd4 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -356,6 +356,29 @@ type = "" [msg.userfront.error.whitelist] 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] description = "" diff --git a/scripts/verify_userfront_error_i18n.sh b/scripts/verify_userfront_error_i18n.sh new file mode 100755 index 00000000..352833b2 --- /dev/null +++ b/scripts/verify_userfront_error_i18n.sh @@ -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 키 검증이 완료되었습니다." diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 31bac5e7..e33f46ee 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -80,6 +80,29 @@ type = "Type" [msg.userfront.error.whitelist] 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] description = "Description" @@ -96,7 +119,7 @@ link_send_failed = "Link Send Failed" link_sent_email = "Link Sent Email" link_sent_phone = "Link Sent Phone" link_timeout = "Time expired." -no_account = "No Account" +no_account = "New to Baron?" oidc_failed = "OIDC Failed" qr_expired = "Time expired." qr_init_failed = "QR Init Failed" @@ -297,7 +320,7 @@ save = "Save" search = "Search" show_more = "Show More" language = "Language" -language_ko = "Korean" +language_ko = "한국어" language_en = "English" theme_dark = "Dark" theme_light = "Light" @@ -534,3 +557,4 @@ verify = "Verify" [ui.userfront.signup.success] action = "Action" + diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 9b67b361..ee1df42f 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -80,6 +80,29 @@ type = "오류 종류: {type}" [msg.userfront.error.whitelist] 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] description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다." @@ -298,7 +321,7 @@ search = "검색" show_more = "+ 더보기" language = "언어" language_ko = "한국어" -language_en = "영어" +language_en = "English" theme_dark = "Dark" theme_light = "Light" theme_toggle = "테마 전환" @@ -534,3 +557,4 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 90155a9d..9ee024cb 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -80,6 +80,29 @@ type = "" [msg.userfront.error.whitelist] 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] description = "" diff --git a/userfront/lib/core/constants/error_whitelist.dart b/userfront/lib/core/constants/error_whitelist.dart index 27c8678f..07a4ee87 100644 --- a/userfront/lib/core/constants/error_whitelist.dart +++ b/userfront/lib/core/constants/error_whitelist.dart @@ -1,11 +1,27 @@ -const Map errorWhitelistMessages = { +const Map internalErrorWhitelistMessages = { 'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.', 'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.', 'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.', 'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.', 'recovery_invalid': '재설정 링크가 유효하지 않습니다.', - 'consent_required': '앱 접근 동의가 필요합니다.', 'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.', 'not_found': '요청한 페이지를 찾을 수 없습니다.', 'bad_request': '입력값을 확인해 주세요.', + 'password_or_email_mismatch': '이메일 혹은 비밀번호가 일치하지 않습니다.', +}; + +const Set 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', }; diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart index d469b5ed..0caada68 100644 --- a/userfront/lib/features/auth/presentation/error_screen.dart +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -24,10 +24,13 @@ class ErrorScreen extends StatelessWidget { final isProd = isProdOverride ?? AuthProxyService.isProdEnv; final normalizedCode = (errorCode ?? '').trim(); final hasCode = normalizedCode.isNotEmpty; - final whitelistFallback = errorWhitelistMessages[normalizedCode]; - final isWhitelisted = whitelistFallback != null; + final internalWhitelistFallback = + internalErrorWhitelistMessages[normalizedCode]; + final isInternalWhitelisted = internalWhitelistFallback != null; + final isOryBypass = hasCode && oryBypassErrorCodes.contains(normalizedCode); + final isKnownProdCode = hasCode && (isInternalWhitelisted || isOryBypass); final errorType = isProd - ? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error') + ? (isKnownProdCode ? normalizedCode : 'unknown_error') : (hasCode ? normalizedCode : 'unknown_error'); final title = isProd ? tr('msg.userfront.error.title') @@ -40,14 +43,22 @@ class ErrorScreen extends StatelessWidget { 'msg.userfront.error.title_generic', )); final detail = isProd - ? (isWhitelisted + ? (isInternalWhitelisted ? tr( 'msg.userfront.error.whitelist.$normalizedCode', - fallback: whitelistFallback, + fallback: internalWhitelistFallback, ) - : tr( - 'msg.userfront.error.detail_contact', - )) + : (isOryBypass + ? 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! : (hasCode diff --git a/userfront/test/error_screen_test.dart b/userfront/test/error_screen_test.dart index 28de4646..58dd8987 100644 --- a/userfront/test/error_screen_test.dart +++ b/userfront/test/error_screen_test.dart @@ -74,7 +74,7 @@ void main() { ); final detail = tr( 'msg.userfront.error.whitelist.settings_disabled', - fallback: errorWhitelistMessages['settings_disabled']!, + fallback: internalErrorWhitelistMessages['settings_disabled']!, ); final type = tr( 'msg.userfront.error.type', @@ -88,6 +88,34 @@ void main() { 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 { await _pumpErrorScreen( tester,