1
0
forked from baron/baron-sso

리다이렉트 후속 로직 업데이트

This commit is contained in:
Lectom C Han
2026-02-19 12:40:56 +09:00
parent 1a5b04d688
commit 6fd0e5c800
6 changed files with 228 additions and 200 deletions

View File

@@ -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)
}

View File

@@ -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"])
}
}

View File

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

View File

@@ -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'),
);
}

View File

@@ -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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
);
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
_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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
Future<void> _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<LoginScreen>
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<LoginScreen>
_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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
_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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
);
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<LoginScreen>
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<LoginScreen>
_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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
),
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<LoginScreen>
_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<LoginScreen>
),
},
)
: tr(
'ui.common.resend',
),
: tr('ui.common.resend'),
),
),
if (!_lastLinkIsEmail) ...[
@@ -1627,8 +1561,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
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<LoginScreen>
),
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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
),
TextButton(
onPressed: () => context.push('/signup'),
child: Text(
tr(
'ui.userfront.login.signup',
),
),
child: Text(tr('ui.userfront.login.signup')),
),
],
),

View File

@@ -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');
});
});
}