diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e5127d03..893e89a4 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1504,6 +1504,30 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { } // PasswordLogin - Authenticate a user with login ID and password. +func logOidcRedirectSummary(source, redirectTo string) { + parsed, err := url.Parse(redirectTo) + if err != nil { + slog.Warn( + "OIDC redirect parse failed", + "source", source, + "redirectToLength", len(redirectTo), + "error", err, + ) + return + } + + query := parsed.Query() + slog.Info( + "OIDC redirect summary", + "source", source, + "redirectToLength", len(redirectTo), + "redirectToHost", parsed.Host, + "redirectToPath", parsed.Path, + "redirectHasLoginVerifier", query.Has("login_verifier"), + "redirectHasRedirectURI", query.Has("redirect_uri"), + ) +} + func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { startTime := time.Now() ale := logger.NewAuditLogEntry(c, "login") @@ -1586,7 +1610,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { slog.Error("failed to accept hydra login request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } - slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo) + logOidcRedirectSummary("password_login", acceptResp.RedirectTo) return c.JSON(fiber.Map{ "redirectTo": acceptResp.RedirectTo, }) @@ -3841,6 +3865,7 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { slog.Error("failed to accept hydra login request", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } + logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo) return c.JSON(acceptResp) } diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index dd261e17..9845e738 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -188,10 +188,13 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) { t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) } - var got map[string]string + var got map[string]interface{} json.NewDecoder(resp.Body).Decode(&got) if got["redirectTo"] != "http://rp/cb" { - t.Errorf("expected redirectTo http://rp/cb, got %s", got["redirectTo"]) + t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"]) + } + if _, ok := got["sessionJwt"]; ok { + t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"]) } } diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index b3d44dcf..70a555d4 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -3,7 +3,6 @@ import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:userfront/i18n.dart'; import 'http_client.dart'; -import 'web_window.dart'; import 'auth_token_store.dart'; class AuthProxyService { @@ -273,17 +272,11 @@ class AuthProxyService { if (response.statusCode == 200) { final data = jsonDecode(response.body); - if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) { - webWindow.redirectTo(data['redirectTo']); - } return data; } else { final errorBody = jsonDecode(response.body); throw Exception( - errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.login_failed', - ), + errorBody['error'] ?? tr('err.userfront.auth_proxy.login_failed'), ); } } @@ -304,10 +297,7 @@ class AuthProxyService { } else { final errorBody = jsonDecode(response.body); throw Exception( - errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.consent_fetch', - ), + errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'), ); } } @@ -333,10 +323,7 @@ class AuthProxyService { } else { final errorBody = jsonDecode(response.body); throw Exception( - errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.consent_accept', - ), + errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'), ); } } @@ -358,10 +345,7 @@ class AuthProxyService { } else { final errorBody = jsonDecode(response.body); throw Exception( - errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.consent_reject', - ), + errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'), ); } } @@ -388,10 +372,7 @@ class AuthProxyService { } else { final errorBody = jsonDecode(response.body); throw Exception( - errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.oidc_accept', - ), + errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'), ); } } finally { @@ -419,9 +400,7 @@ class AuthProxyService { final errorBody = jsonDecode(response.body); throw Exception( errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.password_reset_init', - ), + tr('err.userfront.auth_proxy.password_reset_init'), ); } } @@ -453,9 +432,7 @@ class AuthProxyService { final errorBody = jsonDecode(response.body); throw Exception( errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.password_reset_complete', - ), + tr('err.userfront.auth_proxy.password_reset_complete'), ); } } @@ -785,9 +762,7 @@ class AuthProxyService { final errorBody = jsonDecode(response.body); throw Exception( errorBody['error'] ?? - tr( - 'err.userfront.auth_proxy.linked_app_revoke', - ), + tr('err.userfront.auth_proxy.linked_app_revoke'), ); } } finally { diff --git a/userfront/lib/core/services/oidc_redirect_guard.dart b/userfront/lib/core/services/oidc_redirect_guard.dart new file mode 100644 index 00000000..b0db4c79 --- /dev/null +++ b/userfront/lib/core/services/oidc_redirect_guard.dart @@ -0,0 +1,68 @@ +class OidcRedirectCheckResult { + final Uri? uri; + final bool isValid; + final String reason; + final int length; + final String host; + final String path; + final bool hasLoginVerifier; + + const OidcRedirectCheckResult({ + required this.uri, + required this.isValid, + required this.reason, + required this.length, + required this.host, + required this.path, + required this.hasLoginVerifier, + }); +} + +OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) { + final trimmed = redirectTo.trim(); + if (trimmed.isEmpty) { + return const OidcRedirectCheckResult( + uri: null, + isValid: false, + reason: 'empty', + length: 0, + host: '', + path: '', + hasLoginVerifier: false, + ); + } + + Uri parsed; + try { + parsed = Uri.parse(trimmed); + } catch (_) { + return OidcRedirectCheckResult( + uri: null, + isValid: false, + reason: 'parse_error', + length: trimmed.length, + host: '', + path: '', + hasLoginVerifier: false, + ); + } + + final scheme = parsed.scheme.toLowerCase(); + final isHttpScheme = scheme == 'http' || scheme == 'https'; + final isAbsolute = parsed.hasScheme && parsed.host.isNotEmpty; + final isValid = isHttpScheme && isAbsolute; + + final reason = isValid + ? 'ok' + : (isAbsolute ? 'unsupported_scheme' : 'not_absolute'); + + return OidcRedirectCheckResult( + uri: isValid ? parsed : null, + isValid: isValid, + reason: reason, + length: trimmed.length, + host: parsed.host, + path: parsed.path, + hasLoginVerifier: parsed.queryParameters.containsKey('login_verifier'), + ); +} diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index a6c6579f..530ffef2 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -3,13 +3,13 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:userfront/i18n.dart'; import '../../../core/widgets/language_selector.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; +import '../../../core/services/oidc_redirect_guard.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../../core/services/web_window.dart'; @@ -65,9 +65,7 @@ class _LoginScreenState extends ConsumerState bool _verificationApproved = false; bool _dismissedOverlays = false; String _verificationMessage = ''; - String _verificationTitle = tr( - 'ui.userfront.login.verification.title', - ); + String _verificationTitle = tr('ui.userfront.login.verification.title'); String _verificationPageTitle = tr( 'ui.userfront.login.verification.page_title', ); @@ -127,11 +125,7 @@ class _LoginScreenState extends ConsumerState if (!_noticeHandled && notice == 'qr_login_required') { _noticeHandled = true; - _showInfo( - tr( - 'msg.userfront.login.qr_login_required', - ), - ); + _showInfo(tr('msg.userfront.login.qr_login_required')); } if (!_verificationOnly) { @@ -234,9 +228,7 @@ class _LoginScreenState extends ConsumerState ); final redirectTo = res['redirectTo'] as String?; if (redirectTo != null && redirectTo.isNotEmpty) { - debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo"); - webWindow.redirectTo(redirectTo); - return true; + return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login'); } } catch (e) { debugPrint("[Auth] OIDC login auto-accept failed: $e"); @@ -244,6 +236,23 @@ class _LoginScreenState extends ConsumerState return false; } + bool _redirectToOidcTarget(String redirectTo, {required String source}) { + final checked = validateOidcRedirectTarget(redirectTo); + debugPrint( + "[Auth] OIDC redirect check ($source): valid=${checked.isValid}, reason=${checked.reason}, len=${checked.length}, host=${checked.host}, path=${checked.path}, has_login_verifier=${checked.hasLoginVerifier}", + ); + + if (!checked.isValid || checked.uri == null) { + if (mounted) { + _showError(tr('msg.userfront.login.oidc_failed')); + } + return false; + } + + webWindow.redirectTo(checked.uri.toString()); + return true; + } + void _resetLinkLoginState() { _linkPendingRef = null; _lastLinkLoginId = null; @@ -307,9 +316,7 @@ class _LoginScreenState extends ConsumerState setState(() { _linkExpired = true; }); - _showInfo( - tr('msg.userfront.login.link_timeout'), - ); + _showInfo(tr('msg.userfront.login.link_timeout')); } }); } @@ -387,9 +394,7 @@ class _LoginScreenState extends ConsumerState setState(() { _qrExpired = true; }); - _showInfo( - tr('msg.userfront.login.qr_expired'), - ); + _showInfo(tr('msg.userfront.login.qr_expired')); } } return; @@ -440,9 +445,7 @@ class _LoginScreenState extends ConsumerState _qrExpired = true; }); } - _showError( - tr('msg.userfront.login.qr_expired'), - ); + _showError(tr('msg.userfront.login.qr_expired')); return; } @@ -454,11 +457,7 @@ class _LoginScreenState extends ConsumerState if (token is String && token.isNotEmpty) { _completeLoginFromToken(token); } else { - _showError( - tr( - 'msg.userfront.login.token_missing', - ), - ); + _showError(tr('msg.userfront.login.token_missing')); } } } catch (e) { @@ -533,14 +532,11 @@ class _LoginScreenState extends ConsumerState Duration redirectDelay = const Duration(seconds: 2), }) { if (!mounted) return; - final resolvedTitle = - title ?? tr('ui.userfront.login.verification.title'); + final resolvedTitle = title ?? tr('ui.userfront.login.verification.title'); final resolvedPageTitle = - pageTitle ?? - tr('ui.userfront.login.verification.page_title'); + pageTitle ?? tr('ui.userfront.login.verification.page_title'); final resolvedActionLabel = - actionLabel ?? - tr('ui.userfront.login.verification.action_label'); + actionLabel ?? tr('ui.userfront.login.verification.action_label'); setState(() { _verificationApproved = true; _verificationMessage = message; @@ -581,9 +577,7 @@ class _LoginScreenState extends ConsumerState const SizedBox(height: 12), Text( _verificationMessage.isEmpty - ? tr( - 'msg.userfront.login.verification.success', - ) + ? tr('msg.userfront.login.verification.success') : _verificationMessage, textAlign: TextAlign.center, style: const TextStyle(color: Colors.black54), @@ -613,9 +607,7 @@ class _LoginScreenState extends ConsumerState Future _verifyToken(String token) async { debugPrint("[Auth] Starting verification for token: $token"); - final approvedMessage = tr( - 'msg.userfront.login.verification.approved', - ); + final approvedMessage = tr('msg.userfront.login.verification.approved'); final localSessionMessage = tr( 'msg.userfront.login.verification.approved_local', ); @@ -675,15 +667,11 @@ class _LoginScreenState extends ConsumerState debugPrint( "[Auth] Starting code verification for loginId: $sanitizedLoginId", ); - final approvedMessage = tr( - 'msg.userfront.login.verification.approved', - ); + final approvedMessage = tr('msg.userfront.login.verification.approved'); final localSessionMessage = tr( 'msg.userfront.login.verification.approved_local', ); - final linkLoginMessage = tr( - 'msg.userfront.login.link.approved', - ); + final linkLoginMessage = tr('msg.userfront.login.link.approved'); try { final res = await AuthProxyService.verifyLoginCode( sanitizedLoginId, @@ -721,12 +709,8 @@ class _LoginScreenState extends ConsumerState _markVerificationApproved( linkLoginMessage, title: tr('ui.userfront.login.link.title'), - pageTitle: tr( - 'ui.userfront.login.link.page_title', - ), - actionLabel: tr( - 'ui.userfront.login.link.action_label', - ), + pageTitle: tr('ui.userfront.login.link.page_title'), + actionLabel: tr('ui.userfront.login.link.action_label'), actionPath: '/signin', autoRedirect: true, ); @@ -755,9 +739,7 @@ class _LoginScreenState extends ConsumerState final sanitized = shortCode.trim().toUpperCase(); if (sanitized.isEmpty) return; debugPrint("[Auth] Starting short code verification for code: $sanitized"); - final approvedMessage = tr( - 'msg.userfront.login.verification.approved', - ); + final approvedMessage = tr('msg.userfront.login.verification.approved'); final localSessionMessage = tr( 'msg.userfront.login.verification.approved_local', ); @@ -829,11 +811,7 @@ class _LoginScreenState extends ConsumerState final input = _passwordLoginIdController.text.trim(); final password = _passwordController.text.trim(); if (input.isEmpty || password.isEmpty) { - _showError( - tr( - 'msg.userfront.login.password.missing_credentials', - ), - ); + _showError(tr('msg.userfront.login.password.missing_credentials')); return; } @@ -856,7 +834,7 @@ class _LoginScreenState extends ConsumerState final redirectTo = res['redirectTo'] as String?; if (redirectTo != null && redirectTo.isNotEmpty) { - webWindow.redirectTo(redirectTo); + _redirectToOidcTarget(redirectTo, source: 'password_login'); return; } @@ -938,12 +916,8 @@ class _LoginScreenState extends ConsumerState _dismissOverlays(); _showInfo( isEmail - ? tr( - 'msg.userfront.login.link_sent_email', - ) - : tr( - 'msg.userfront.login.link_sent_phone', - ), + ? tr('msg.userfront.login.link_sent_email') + : tr('msg.userfront.login.link_sent_phone'), ); final initialInterval = (interval is int && interval > 0) @@ -1009,11 +983,7 @@ class _LoginScreenState extends ConsumerState if (result['error'] == 'expired_token') { if (mounted) { Navigator.of(context).pop(); - _showError( - tr( - 'msg.userfront.login.link_timeout', - ), - ); + _showError(tr('msg.userfront.login.link_timeout')); } return; } @@ -1034,11 +1004,7 @@ class _LoginScreenState extends ConsumerState if (mounted && Navigator.canPop(context)) { Navigator.of(context).pop(); } - _showError( - tr( - 'msg.userfront.login.token_missing', - ), - ); + _showError(tr('msg.userfront.login.token_missing')); return; } } catch (e) { @@ -1049,9 +1015,7 @@ class _LoginScreenState extends ConsumerState if (mounted) { debugPrint("[Auth] Polling timed out for ref: $pendingRef"); Navigator.of(context).pop(); - _showError( - tr('msg.userfront.login.link_timeout'), - ); + _showError(tr('msg.userfront.login.link_timeout')); } } @@ -1124,25 +1088,23 @@ class _LoginScreenState extends ConsumerState ); final redirectTo = res['redirectTo'] as String?; if (redirectTo != null && redirectTo.isNotEmpty) { - debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo"); - webWindow.redirectTo(redirectTo); + _redirectToOidcTarget( + redirectTo, + source: 'on_login_success_accept_oidc', + ); return; } } catch (e) { - _showError( - tr( - 'msg.userfront.login.oidc_failed', - ), - ); + _showError(tr('msg.userfront.login.oidc_failed')); return; } } final uri = Uri.base; final redirectParam = - uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url']; - final hasRedirectParam = - redirectParam != null && redirectParam.isNotEmpty; + uri.queryParameters['redirect_uri'] ?? + uri.queryParameters['redirect_url']; + final hasRedirectParam = redirectParam != null && redirectParam.isNotEmpty; if (WebAuthIntegration.isPopup() || hasRedirectParam) { debugPrint( @@ -1163,14 +1125,8 @@ class _LoginScreenState extends ConsumerState showDialog( context: context, builder: (context) => AlertDialog( - title: Text( - tr('ui.userfront.login.unregistered.title'), - ), - content: Text( - tr( - 'msg.userfront.login.unregistered.body', - ), - ), + title: Text(tr('ui.userfront.login.unregistered.title')), + content: Text(tr('msg.userfront.login.unregistered.body')), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -1182,9 +1138,7 @@ class _LoginScreenState extends ConsumerState _resetLinkLoginState(); context.push('/signup'); }, - child: Text( - tr('ui.userfront.login.unregistered.action'), - ), + child: Text(tr('ui.userfront.login.unregistered.action')), ), ], ), @@ -1249,9 +1203,7 @@ class _LoginScreenState extends ConsumerState const SizedBox(width: 8), Expanded( child: Text( - tr( - 'msg.userfront.login.dry_send', - ), + tr('msg.userfront.login.dry_send'), style: const TextStyle( color: Color(0xFF8A6D3B), fontSize: 12, @@ -1267,21 +1219,9 @@ class _LoginScreenState extends ConsumerState TabBar( controller: _tabController, tabs: [ - Tab( - text: tr( - 'ui.userfront.login.tabs.password', - ), - ), - Tab( - text: tr( - 'ui.userfront.login.tabs.link', - ), - ), - Tab( - text: tr( - 'ui.userfront.login.tabs.qr', - ), - ), + Tab(text: tr('ui.userfront.login.tabs.password')), + Tab(text: tr('ui.userfront.login.tabs.link')), + Tab(text: tr('ui.userfront.login.tabs.qr')), ], ), const SizedBox(height: 24), @@ -1330,9 +1270,7 @@ class _LoginScreenState extends ConsumerState minimumSize: const Size.fromHeight(50), ), child: Text( - tr( - 'ui.userfront.login.action.submit', - ), + tr('ui.userfront.login.action.submit'), ), ), ], @@ -1365,16 +1303,12 @@ class _LoginScreenState extends ConsumerState minimumSize: const Size.fromHeight(50), ), child: Text( - tr( - 'ui.userfront.login.link.send', - ), + tr('ui.userfront.login.link.send'), ), ), const SizedBox(height: 24), Text( - tr( - 'msg.userfront.login.link.helper', - ), + tr('msg.userfront.login.link.helper'), style: const TextStyle( color: Colors.grey, fontSize: 12, @@ -1398,8 +1332,9 @@ class _LoginScreenState extends ConsumerState setState(_resetLinkLoginState); }, style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), + minimumSize: const Size.fromHeight( + 45, + ), ), child: Text(tr('ui.common.refresh')), ), @@ -1458,15 +1393,15 @@ class _LoginScreenState extends ConsumerState ), suffixText: _linkExpireSeconds > 0 - ? tr( - 'ui.userfront.login.short_code.expire_time', - params: { - 'time': _formatTime( - _linkExpireSeconds, - ), - }, - ) - : null, + ? tr( + 'ui.userfront.login.short_code.expire_time', + params: { + 'time': _formatTime( + _linkExpireSeconds, + ), + }, + ) + : null, ), maxLength: 6, ), @@ -1495,8 +1430,9 @@ class _LoginScreenState extends ConsumerState _verifyShortCode(prefix + digits); }, style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), + minimumSize: const Size.fromHeight( + 45, + ), ), child: Text( tr( @@ -1549,9 +1485,7 @@ class _LoginScreenState extends ConsumerState ), }, ) - : tr( - 'ui.common.resend', - ), + : tr('ui.common.resend'), ), ), if (!_lastLinkIsEmail) ...[ @@ -1627,8 +1561,9 @@ class _LoginScreenState extends ConsumerState FilledButton( onPressed: _startQrFlow, style: FilledButton.styleFrom( - minimumSize: - const Size.fromHeight(45), + minimumSize: const Size.fromHeight( + 45, + ), ), child: Text(tr('ui.common.refresh')), ), @@ -1679,9 +1614,7 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 8), Text( - tr( - 'msg.userfront.login.qr.scan_hint', - ), + tr('msg.userfront.login.qr.scan_hint'), textAlign: TextAlign.center, style: const TextStyle( color: Colors.grey, @@ -1691,18 +1624,14 @@ class _LoginScreenState extends ConsumerState TextButton( onPressed: _startQrFlow, child: Text( - tr( - 'ui.userfront.login.qr.refresh', - ), + tr('ui.userfront.login.qr.refresh'), ), ), ], ) else Text( - tr( - 'msg.userfront.login.qr.load_failed', - ), + tr('msg.userfront.login.qr.load_failed'), textAlign: TextAlign.center, ), ], @@ -1716,18 +1645,14 @@ class _LoginScreenState extends ConsumerState TextButton( onPressed: () => context.push('/forgot-password'), child: Text( - tr( - 'ui.userfront.login.forgot_password', - ), + tr('ui.userfront.login.forgot_password'), ), ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - tr( - 'msg.userfront.login.no_account', - ), + tr('msg.userfront.login.no_account'), style: const TextStyle( color: Colors.grey, fontSize: 14, @@ -1735,11 +1660,7 @@ class _LoginScreenState extends ConsumerState ), TextButton( onPressed: () => context.push('/signup'), - child: Text( - tr( - 'ui.userfront.login.signup', - ), - ), + child: Text(tr('ui.userfront.login.signup')), ), ], ), diff --git a/userfront/test/oidc_redirect_guard_test.dart b/userfront/test/oidc_redirect_guard_test.dart new file mode 100644 index 00000000..70e8f868 --- /dev/null +++ b/userfront/test/oidc_redirect_guard_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/core/services/oidc_redirect_guard.dart'; + +void main() { + group('oidc_redirect_guard', () { + test('http/https 절대 URL만 허용', () { + final ok = validateOidcRedirectTarget( + 'https://sso-test.hmac.kr/oidc/oauth2/auth?client_id=devfront&login_verifier=abc', + ); + expect(ok.isValid, isTrue); + expect(ok.reason, 'ok'); + expect(ok.host, 'sso-test.hmac.kr'); + expect(ok.path, '/oidc/oauth2/auth'); + expect(ok.hasLoginVerifier, isTrue); + + final relative = validateOidcRedirectTarget('/oidc/oauth2/auth'); + expect(relative.isValid, isFalse); + expect(relative.reason, 'not_absolute'); + + final js = validateOidcRedirectTarget('javascript:alert(1)'); + expect(js.isValid, isFalse); + expect(js.reason, 'not_absolute'); + }); + + test('빈 문자열과 파싱 실패를 차단', () { + final empty = validateOidcRedirectTarget(' '); + expect(empty.isValid, isFalse); + expect(empty.reason, 'empty'); + expect(empty.length, 0); + + final malformed = validateOidcRedirectTarget('https://[broken'); + expect(malformed.isValid, isFalse); + expect(malformed.reason, 'parse_error'); + }); + }); +}