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

@@ -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"

View File

@@ -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 = "로그인하기"

View File

@@ -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 = ""

View File

@@ -1,11 +1,27 @@
const Map<String, String> errorWhitelistMessages = {
const Map<String, String> 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<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 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

View File

@@ -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,