1
0
forked from baron/baron-sso

디버깅 로그 추가

This commit is contained in:
Lectom C Han
2026-02-19 13:25:45 +09:00
parent 6fd0e5c800
commit f617467082
5 changed files with 334 additions and 23 deletions

View File

@@ -3,19 +3,90 @@ class OidcRedirectCheckResult {
final bool isValid;
final String reason;
final int length;
final String scheme;
final String host;
final String path;
final int queryParamCount;
final List<String> queryKeys;
final bool hasLoginVerifier;
final int loginVerifierLength;
final bool hasState;
final int stateLength;
final bool hasClientId;
final String clientId;
final bool hasCodeChallenge;
final int codeChallengeLength;
final String codeChallengeMethod;
final bool hasRedirectUri;
final int redirectUriLength;
final String redirectUriScheme;
final String redirectUriHost;
final int redirectUriPort;
final String redirectUriPath;
final String responseType;
final int scopeCount;
final bool isOidcAuthPath;
const OidcRedirectCheckResult({
required this.uri,
required this.isValid,
required this.reason,
required this.length,
required this.scheme,
required this.host,
required this.path,
required this.queryParamCount,
required this.queryKeys,
required this.hasLoginVerifier,
required this.loginVerifierLength,
required this.hasState,
required this.stateLength,
required this.hasClientId,
required this.clientId,
required this.hasCodeChallenge,
required this.codeChallengeLength,
required this.codeChallengeMethod,
required this.hasRedirectUri,
required this.redirectUriLength,
required this.redirectUriScheme,
required this.redirectUriHost,
required this.redirectUriPort,
required this.redirectUriPath,
required this.responseType,
required this.scopeCount,
required this.isOidcAuthPath,
});
Map<String, Object?> toDiagnostics() {
return {
'is_valid': isValid,
'reason': reason,
'length': length,
'scheme': scheme,
'host': host,
'path': path,
'is_oidc_auth_path': isOidcAuthPath,
'query_param_count': queryParamCount,
'query_keys': queryKeys,
'has_login_verifier': hasLoginVerifier,
'login_verifier_len': loginVerifierLength,
'has_state': hasState,
'state_len': stateLength,
'has_client_id': hasClientId,
'client_id': clientId,
'has_code_challenge': hasCodeChallenge,
'code_challenge_len': codeChallengeLength,
'code_challenge_method': codeChallengeMethod,
'has_redirect_uri': hasRedirectUri,
'redirect_uri_len': redirectUriLength,
'redirect_uri_scheme': redirectUriScheme,
'redirect_uri_host': redirectUriHost,
'redirect_uri_port': redirectUriPort,
'redirect_uri_path': redirectUriPath,
'response_type': responseType,
'scope_count': scopeCount,
};
}
}
OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
@@ -26,9 +97,29 @@ OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
isValid: false,
reason: 'empty',
length: 0,
scheme: '',
host: '',
path: '',
queryParamCount: 0,
queryKeys: [],
hasLoginVerifier: false,
loginVerifierLength: 0,
hasState: false,
stateLength: 0,
hasClientId: false,
clientId: '',
hasCodeChallenge: false,
codeChallengeLength: 0,
codeChallengeMethod: '',
hasRedirectUri: false,
redirectUriLength: 0,
redirectUriScheme: '',
redirectUriHost: '',
redirectUriPort: 0,
redirectUriPath: '',
responseType: '',
scopeCount: 0,
isOidcAuthPath: false,
);
}
@@ -41,9 +132,29 @@ OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
isValid: false,
reason: 'parse_error',
length: trimmed.length,
scheme: '',
host: '',
path: '',
queryParamCount: 0,
queryKeys: [],
hasLoginVerifier: false,
loginVerifierLength: 0,
hasState: false,
stateLength: 0,
hasClientId: false,
clientId: '',
hasCodeChallenge: false,
codeChallengeLength: 0,
codeChallengeMethod: '',
hasRedirectUri: false,
redirectUriLength: 0,
redirectUriScheme: '',
redirectUriHost: '',
redirectUriPort: 0,
redirectUriPath: '',
responseType: '',
scopeCount: 0,
isOidcAuthPath: false,
);
}
@@ -51,6 +162,27 @@ OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
final isHttpScheme = scheme == 'http' || scheme == 'https';
final isAbsolute = parsed.hasScheme && parsed.host.isNotEmpty;
final isValid = isHttpScheme && isAbsolute;
final query = parsed.queryParameters;
final queryKeys = query.keys.toList()..sort();
final loginVerifier = query['login_verifier'] ?? '';
final state = query['state'] ?? '';
final clientId = query['client_id'] ?? '';
final codeChallenge = query['code_challenge'] ?? '';
final codeChallengeMethod = query['code_challenge_method'] ?? '';
final redirectUriValue = query['redirect_uri'] ?? query['redirect_url'] ?? '';
final responseType = query['response_type'] ?? '';
final scope = query['scope'] ?? '';
final Uri? redirectUriParsed = redirectUriValue.isEmpty
? null
: Uri.tryParse(redirectUriValue);
final redirectUriScheme = redirectUriParsed?.scheme ?? '';
final redirectUriHost = redirectUriParsed?.host ?? '';
final redirectUriPort = redirectUriParsed?.port ?? 0;
final redirectUriPath = redirectUriParsed?.path ?? '';
final scopeCount = scope.isEmpty
? 0
: scope.split(RegExp(r'\s+')).where((s) => s.isNotEmpty).length;
final reason = isValid
? 'ok'
@@ -61,8 +193,28 @@ OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
isValid: isValid,
reason: reason,
length: trimmed.length,
scheme: scheme,
host: parsed.host,
path: parsed.path,
hasLoginVerifier: parsed.queryParameters.containsKey('login_verifier'),
queryParamCount: query.length,
queryKeys: queryKeys,
hasLoginVerifier: loginVerifier.isNotEmpty,
loginVerifierLength: loginVerifier.length,
hasState: state.isNotEmpty,
stateLength: state.length,
hasClientId: clientId.isNotEmpty,
clientId: clientId,
hasCodeChallenge: codeChallenge.isNotEmpty,
codeChallengeLength: codeChallenge.length,
codeChallengeMethod: codeChallengeMethod,
hasRedirectUri: redirectUriValue.isNotEmpty,
redirectUriLength: redirectUriValue.length,
redirectUriScheme: redirectUriScheme,
redirectUriHost: redirectUriHost,
redirectUriPort: redirectUriPort,
redirectUriPath: redirectUriPath,
responseType: responseType,
scopeCount: scopeCount,
isOidcAuthPath: parsed.path == '/oidc/oauth2/auth',
);
}

View File

@@ -0,0 +1,23 @@
enum PasswordLoginNextAction { redirectToOidc, acceptOidc, localLogin, invalid }
PasswordLoginNextAction decidePasswordLoginNextAction({
required bool hasLoginChallenge,
required String? redirectTo,
required String? jwt,
}) {
final hasRedirectTo = redirectTo != null && redirectTo.isNotEmpty;
if (hasRedirectTo) {
return PasswordLoginNextAction.redirectToOidc;
}
if (hasLoginChallenge) {
return PasswordLoginNextAction.acceptOidc;
}
final hasJwt = jwt != null && jwt.isNotEmpty;
if (hasJwt) {
return PasswordLoginNextAction.localLogin;
}
return PasswordLoginNextAction.invalid;
}

View File

@@ -11,6 +11,7 @@ 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 '../domain/password_login_flow_policy.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart';
@@ -167,11 +168,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _onCookieLoginSuccess(String provider) async {
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
AuthNotifier.instance.notify();
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
if (_hasLoginChallenge) {
final accepted = await _acceptOidcLoginAndRedirect();
if (accepted) {
return;
}
if (mounted) {
_showError(tr('msg.userfront.login.oidc_failed'));
}
return;
}
final token = AuthTokenStore.getToken();
@@ -238,6 +243,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _redirectToOidcTarget(String redirectTo, {required String source}) {
final checked = validateOidcRedirectTarget(redirectTo);
_logOidcRedirectDiagnostics(source: source, checked: checked);
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}",
);
@@ -249,8 +255,42 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return false;
}
webWindow.redirectTo(checked.uri.toString());
return true;
try {
debugPrint(
"[Auth] OIDC redirect execute ($source): host=${checked.host}, path=${checked.path}, redirect_uri_host=${checked.redirectUriHost}, redirect_uri_port=${checked.redirectUriPort}, state_len=${checked.stateLength}, login_verifier_len=${checked.loginVerifierLength}",
);
webWindow.redirectTo(checked.uri.toString());
return true;
} catch (e) {
debugPrint("[Auth] OIDC redirect failed ($source): $e");
if (mounted) {
_showError(tr('msg.userfront.login.oidc_failed'));
}
return false;
}
}
bool get _hasLoginChallenge =>
_loginChallenge != null && _loginChallenge!.isNotEmpty;
void _logOidcRedirectDiagnostics({
required String source,
required OidcRedirectCheckResult checked,
}) {
final current = Uri.base;
final currentQueryKeys = current.queryParameters.keys.toList()..sort();
final payload = <String, Object?>{
'source': source,
'current_path': current.path,
'current_query_param_count': current.queryParameters.length,
'current_query_keys': currentQueryKeys,
'has_login_challenge': _hasLoginChallenge,
'login_challenge_len': _loginChallenge?.length ?? 0,
...checked.toDiagnostics(),
};
debugPrint("[Auth] OIDC redirect diagnostics: ${jsonEncode(payload)}");
}
void _resetLinkLoginState() {
@@ -829,17 +869,44 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
password,
loginChallenge: _loginChallenge,
);
final jwt = res['sessionJwt'] ?? res['sessionToken'] ?? res['token'];
final jwtRaw = res['sessionJwt'] ?? res['sessionToken'] ?? res['token'];
final jwt = jwtRaw?.toString();
final provider = res['provider'] as String?;
final redirectTo = res['redirectTo'] as String?;
final hasJwt = jwt != null && jwt.isNotEmpty;
final nextAction = decidePasswordLoginNextAction(
hasLoginChallenge: _hasLoginChallenge,
redirectTo: redirectTo,
jwt: jwt,
);
if (redirectTo != null && redirectTo.isNotEmpty) {
_redirectToOidcTarget(redirectTo, source: 'password_login');
return;
}
debugPrint(
"[Auth] Password login outcome: has_login_challenge=$_hasLoginChallenge, next_action=$nextAction, has_jwt=$hasJwt",
);
if (jwt != null) {
_onLoginSuccess(jwt, provider: provider);
switch (nextAction) {
case PasswordLoginNextAction.redirectToOidc:
_redirectToOidcTarget(redirectTo!, source: 'password_login');
return;
case PasswordLoginNextAction.acceptOidc:
final accepted = await _acceptOidcLoginAndRedirect(
token: hasJwt ? jwt : null,
);
if (accepted) {
return;
}
if (mounted) {
_showError(tr('msg.userfront.login.oidc_failed'));
}
return;
case PasswordLoginNextAction.localLogin:
_onLoginSuccess(jwt!, provider: provider);
return;
case PasswordLoginNextAction.invalid:
if (mounted) {
_showError(tr('msg.userfront.login.password.failed'));
}
return;
}
} catch (e) {
if (e.toString().contains("User not registered")) {
@@ -1080,20 +1147,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Failed to pre-fetch profile: $e");
}
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
if (_hasLoginChallenge) {
try {
final res = await AuthProxyService.acceptOidcLogin(
_loginChallenge!,
token: token,
);
final redirectTo = res['redirectTo'] as String?;
if (redirectTo != null && redirectTo.isNotEmpty) {
_redirectToOidcTarget(
redirectTo,
source: 'on_login_success_accept_oidc',
);
final accepted = await _acceptOidcLoginAndRedirect(token: token);
if (accepted) {
return;
}
if (mounted) {
_showError(tr('msg.userfront.login.oidc_failed'));
}
return;
} catch (e) {
_showError(tr('msg.userfront.login.oidc_failed'));
return;

View File

@@ -5,13 +5,36 @@ 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',
'https://sso-test.hmac.kr/oidc/oauth2/auth?client_id=devfront&login_verifier=abc&state=xyz&code_challenge=ccc&code_challenge_method=S256&response_type=code&scope=openid%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A5174%2Fcallback',
);
expect(ok.isValid, isTrue);
expect(ok.reason, 'ok');
expect(ok.scheme, 'https');
expect(ok.host, 'sso-test.hmac.kr');
expect(ok.path, '/oidc/oauth2/auth');
expect(ok.isOidcAuthPath, isTrue);
expect(ok.queryParamCount, 8);
expect(
ok.queryKeys,
containsAll(['client_id', 'login_verifier', 'state']),
);
expect(ok.hasLoginVerifier, isTrue);
expect(ok.loginVerifierLength, 3);
expect(ok.hasState, isTrue);
expect(ok.stateLength, 3);
expect(ok.hasClientId, isTrue);
expect(ok.clientId, 'devfront');
expect(ok.hasCodeChallenge, isTrue);
expect(ok.codeChallengeLength, 3);
expect(ok.codeChallengeMethod, 'S256');
expect(ok.hasRedirectUri, isTrue);
expect(ok.redirectUriScheme, 'http');
expect(ok.redirectUriHost, 'localhost');
expect(ok.redirectUriPort, 5174);
expect(ok.redirectUriPath, '/callback');
expect(ok.responseType, 'code');
expect(ok.scopeCount, 2);
expect(ok.toDiagnostics()['client_id'], 'devfront');
final relative = validateOidcRedirectTarget('/oidc/oauth2/auth');
expect(relative.isValid, isFalse);
@@ -27,10 +50,13 @@ void main() {
expect(empty.isValid, isFalse);
expect(empty.reason, 'empty');
expect(empty.length, 0);
expect(empty.queryParamCount, 0);
expect(empty.hasRedirectUri, isFalse);
final malformed = validateOidcRedirectTarget('https://[broken');
expect(malformed.isValid, isFalse);
expect(malformed.reason, 'parse_error');
expect(malformed.queryParamCount, 0);
});
});
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/auth/domain/password_login_flow_policy.dart';
void main() {
group('password_login_flow_policy', () {
test('redirectTo가 있으면 OIDC redirect를 우선한다', () {
final action = decidePasswordLoginNextAction(
hasLoginChallenge: true,
redirectTo:
'https://sso-test.hmac.kr/oidc/oauth2/auth?login_verifier=a',
jwt: 'jwt-token',
);
expect(action, PasswordLoginNextAction.redirectToOidc);
});
test('OIDC challenge가 있고 redirectTo가 없으면 accept를 시도한다', () {
final action = decidePasswordLoginNextAction(
hasLoginChallenge: true,
redirectTo: null,
jwt: 'jwt-token',
);
expect(action, PasswordLoginNextAction.acceptOidc);
});
test('OIDC challenge가 없고 jwt가 있으면 로컬 로그인 완료로 진행한다', () {
final action = decidePasswordLoginNextAction(
hasLoginChallenge: false,
redirectTo: null,
jwt: 'jwt-token',
);
expect(action, PasswordLoginNextAction.localLogin);
});
test('redirectTo/jwt 모두 없으면 invalid로 처리한다', () {
final action = decidePasswordLoginNextAction(
hasLoginChallenge: false,
redirectTo: null,
jwt: null,
);
expect(action, PasswordLoginNextAction.invalid);
});
});
}