첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'runtime_env.dart';
class AuditService {
static String get _baseUrl => runtimeBackendUrl();
static Future<void> logEvent({
required String userId,
required String eventType,
required String status,
String? details,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/audit');
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'user_id': userId,
'event_type': eventType,
'status': status,
'details': details,
}),
);
if (response.statusCode >= 200 && response.statusCode < 300) {
debugPrint('Audit log sent successfully');
} else {
debugPrint(
'Failed to send audit log: ${response.statusCode} ${response.body}',
);
}
} catch (e) {
debugPrint('Error sending audit log: $e');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import 'auth_token_store_stub.dart'
if (dart.library.js_interop) 'auth_token_store_web.dart';
class AuthTokenStore {
static bool hasToken() {
final token = getToken();
return token != null && token.isNotEmpty;
}
static String? getToken() => authTokenStore.getToken();
static String? getProvider() => authTokenStore.getProvider();
static bool usesCookie() => authTokenStore.usesCookie();
static void setToken(String token, {String? provider}) {
authTokenStore.setToken(token, provider: provider);
}
static void setCookieMode({String? provider}) {
authTokenStore.setCookieMode(provider: provider);
}
static String? getPendingProvider() => authTokenStore.getPendingProvider();
static void setPendingProvider(String? provider) {
authTokenStore.setPendingProvider(provider);
}
static void clearPendingProvider() {
authTokenStore.setPendingProvider(null);
}
static void skipNextCookieSessionCheck() {
authTokenStore.skipNextCookieSessionCheck();
}
static bool consumeSkipCookieSessionCheck() {
return authTokenStore.consumeSkipCookieSessionCheck();
}
static void clear() {
authTokenStore.clear();
}
}

View File

@@ -0,0 +1,124 @@
abstract class AuthTokenStorageTarget {
String? read(String key);
void write(String key, String value);
void remove(String key);
}
class AuthTokenStoreBackend {
AuthTokenStoreBackend({
required AuthTokenStorageTarget localTarget,
required AuthTokenStorageTarget sessionTarget,
}) : _targets = [localTarget, sessionTarget, _MemoryStorageTarget()];
static const _tokenKey = 'baron_auth_token';
static const _providerKey = 'baron_auth_provider';
static const _cookieModeKey = 'baron_auth_cookie_mode';
static const _pendingProviderKey = 'baron_auth_pending_provider';
static const _skipCookieSessionCheckKey =
'baron_auth_skip_cookie_session_check';
final List<AuthTokenStorageTarget> _targets;
String? getToken() => _readFirst(_tokenKey);
String? getProvider() => _readFirst(_providerKey);
bool usesCookie() => _readFirst(_cookieModeKey) == '1';
void setToken(String token, {String? provider}) {
_writeAll(_tokenKey, token);
_removeAll(_cookieModeKey);
if (provider != null) {
_writeAll(_providerKey, provider);
}
}
void setCookieMode({String? provider}) {
_writeAll(_cookieModeKey, '1');
_removeAll(_tokenKey);
if (provider != null) {
_writeAll(_providerKey, provider);
}
}
String? getPendingProvider() => _readFirst(_pendingProviderKey);
bool consumeSkipCookieSessionCheck() {
final shouldSkip = _readFirst(_skipCookieSessionCheckKey) == '1';
if (shouldSkip) {
_removeAll(_skipCookieSessionCheckKey);
}
return shouldSkip;
}
void setPendingProvider(String? provider) {
if (provider == null || provider.isEmpty) {
_removeAll(_pendingProviderKey);
return;
}
_writeAll(_pendingProviderKey, provider);
}
void clear() {
_removeAll(_tokenKey);
_removeAll(_providerKey);
_removeAll(_cookieModeKey);
_removeAll(_pendingProviderKey);
_removeAll(_skipCookieSessionCheckKey);
}
void skipNextCookieSessionCheck() {
_writeAll(_skipCookieSessionCheckKey, '1');
}
String? _readFirst(String key) {
for (final target in _targets) {
try {
final value = target.read(key);
if (value != null && value.isNotEmpty) {
return value;
}
} catch (_) {
continue;
}
}
return null;
}
void _writeAll(String key, String value) {
for (final target in _targets) {
try {
target.write(key, value);
} catch (_) {
continue;
}
}
}
void _removeAll(String key) {
for (final target in _targets) {
try {
target.remove(key);
} catch (_) {
continue;
}
}
}
}
class _MemoryStorageTarget implements AuthTokenStorageTarget {
final Map<String, String> _memory = {};
@override
String? read(String key) => _memory[key];
@override
void remove(String key) {
_memory.remove(key);
}
@override
void write(String key, String value) {
_memory[key] = value;
}
}

View File

@@ -0,0 +1,53 @@
class AuthTokenStore {
String? _token;
String? _provider;
bool _cookieMode = false;
String? _pendingProvider;
bool _skipCookieSessionCheck = false;
String? getToken() => _token;
String? getProvider() => _provider;
bool usesCookie() => _cookieMode;
void setToken(String token, {String? provider}) {
_token = token;
_cookieMode = false;
_provider = provider;
}
void setCookieMode({String? provider}) {
_cookieMode = true;
_token = null;
if (provider != null) {
_provider = provider;
}
}
String? getPendingProvider() => _pendingProvider;
bool consumeSkipCookieSessionCheck() {
final shouldSkip = _skipCookieSessionCheck;
_skipCookieSessionCheck = false;
return shouldSkip;
}
void setPendingProvider(String? provider) {
_pendingProvider = provider;
}
void skipNextCookieSessionCheck() {
_skipCookieSessionCheck = true;
}
void clear() {
_token = null;
_provider = null;
_cookieMode = false;
_pendingProvider = null;
_skipCookieSessionCheck = false;
}
}
final authTokenStore = AuthTokenStore();

View File

@@ -0,0 +1,48 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:js_interop';
import 'auth_token_store_backend.dart';
@JS('window.localStorage')
external _JSStorage get _localStorage;
@JS('window.sessionStorage')
external _JSStorage get _sessionStorage;
@JS()
extension type _JSStorage(JSObject _) implements JSObject {
external String? getItem(String key);
external void setItem(String key, String value);
external void removeItem(String key);
}
class AuthTokenStore extends AuthTokenStoreBackend {
AuthTokenStore()
: super(
localTarget: _JsStorageTarget(_localStorage),
sessionTarget: _JsStorageTarget(_sessionStorage),
);
}
class _JsStorageTarget implements AuthTokenStorageTarget {
_JsStorageTarget(this._storage);
final _JSStorage _storage;
@override
String? read(String key) {
return _storage.getItem(key);
}
@override
void remove(String key) {
_storage.removeItem(key);
}
@override
void write(String key, String value) {
_storage.setItem(key, value);
}
}
final authTokenStore = AuthTokenStore();

View File

@@ -0,0 +1,6 @@
import 'package:http/http.dart' as http;
import 'http_client_stub.dart' if (dart.library.html) 'http_client_web.dart';
http.Client createHttpClient({bool withCredentials = false}) {
return httpClientFactory.create(withCredentials: withCredentials);
}

View File

@@ -0,0 +1,9 @@
import 'package:http/http.dart' as http;
class HttpClientFactory {
http.Client create({bool withCredentials = false}) {
return http.Client();
}
}
final httpClientFactory = HttpClientFactory();

View File

@@ -0,0 +1,12 @@
import 'package:http/browser_client.dart';
import 'package:http/http.dart' as http;
class HttpClientFactory {
http.Client create({bool withCredentials = false}) {
final client = BrowserClient();
client.withCredentials = withCredentials;
return client;
}
}
final httpClientFactory = HttpClientFactory();

View File

@@ -0,0 +1,139 @@
class LogPolicy {
static const Set<String> _sensitiveKeys = {
'password',
'currentpassword',
'newpassword',
'oldpassword',
'token',
'accesstoken',
'refreshtoken',
'secret',
'clientsecret',
'authorization',
'cookie',
'setcookie',
'verificationcode',
'code',
'loginchallenge',
'loginverifier',
'sessionjwt',
'accessjwt',
'refreshjwt',
};
static bool isProductionEnv(String? appEnv) {
final env = (appEnv ?? '').trim().toLowerCase();
return env == 'prod' ||
env == 'production' ||
env == 'stage' ||
env == 'staging';
}
static ({bool enabled, bool specified}) parseOptionalBoolFlag(String? raw) {
final value = (raw ?? '').trim().toLowerCase();
if (value == '1' ||
value == 'true' ||
value == 'yes' ||
value == 'y' ||
value == 'on') {
return (enabled: true, specified: true);
}
if (value == '0' ||
value == 'false' ||
value == 'no' ||
value == 'n' ||
value == 'off') {
return (enabled: false, specified: true);
}
return (enabled: false, specified: false);
}
static bool debugEnabled({
required String? appEnv,
required String? productionDebugFlag,
}) {
if (!isProductionEnv(appEnv)) {
return true;
}
final flag = parseOptionalBoolFlag(productionDebugFlag);
return flag.specified && flag.enabled;
}
static bool shouldRelayClientLog({
required String level,
required String? appEnv,
required String? productionDebugFlag,
}) {
final flag = parseOptionalBoolFlag(productionDebugFlag);
final debugRelayEnabled = isProductionEnv(appEnv)
? flag.specified && flag.enabled
: !(flag.specified && !flag.enabled);
if (debugRelayEnabled) {
return true;
}
final normalized = level.trim().toUpperCase();
return normalized == 'SEVERE' ||
normalized == 'ERROR' ||
normalized == 'WARNING' ||
normalized == 'WARN';
}
static String sanitizeMessage(String message) {
if (message.trim().isEmpty) {
return message;
}
var sanitized = message.replaceAllMapped(
RegExp(
r'"(password|currentpassword|newpassword|oldpassword|token|accesstoken|refreshtoken|secret|clientsecret|authorization|cookie|setcookie|verificationcode|code|loginchallenge|loginverifier|sessionjwt|accessjwt|refreshjwt)"\s*:\s*"[^"]*"',
caseSensitive: false,
),
(match) {
final key = match.group(1) ?? 'sensitive';
return '"$key":"*****"';
},
);
sanitized = sanitized.replaceAllMapped(
RegExp(
r'\b(password|current_password|currentpassword|new_password|newpassword|old_password|oldpassword|token|access_token|accesstoken|refresh_token|refreshtoken|authorization|cookie|session_jwt|sessionjwt|access_jwt|accessjwt|refresh_jwt|refreshjwt)\b\s*[:=]\s*([^\s,;]+)',
caseSensitive: false,
),
(match) {
final key = match.group(1) ?? 'sensitive';
return '$key=*****';
},
);
return sanitized;
}
static Map<String, dynamic> sanitizeData(Map<String, dynamic> input) {
final output = <String, dynamic>{};
for (final entry in input.entries) {
if (_isSensitiveKey(entry.key)) {
output[entry.key] = '*****';
} else {
output[entry.key] = _sanitizeValue(entry.value);
}
}
return output;
}
static dynamic _sanitizeValue(dynamic value) {
if (value is Map<String, dynamic>) {
return sanitizeData(value);
}
if (value is List) {
return value.map(_sanitizeValue).toList(growable: false);
}
if (value is String) {
return sanitizeMessage(value);
}
return value;
}
static bool _isSensitiveKey(String key) {
var normalized = key.trim().toLowerCase();
normalized = normalized.replaceAll(RegExp(r'[-_.\s]'), '');
return _sensitiveKeys.contains(normalized);
}
}

View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart' as std_log;
import 'package:logger/logger.dart' as pretty_log;
import 'auth_proxy_service.dart';
import 'log_policy.dart';
import 'runtime_env.dart';
/// Global Logger Service for Baron SSO Frontend
class LoggerService {
static final LoggerService _instance = LoggerService._internal();
factory LoggerService() => _instance;
late final pretty_log.Logger _prettyLogger;
late final String _appEnv;
late final String _productionDebugFlag;
LoggerService._internal() {
_appEnv = envOrDefault('APP_ENV', 'dev');
_productionDebugFlag = envOrDefault(
'CLIENT_LOG_DEBUG',
envOrDefault('USERFRONT_DEBUG_LOG', ''),
);
final debugEnabled = LogPolicy.debugEnabled(
appEnv: _appEnv,
productionDebugFlag: _productionDebugFlag,
);
// 1. Initialize Pretty Logger for Dev
_prettyLogger = pretty_log.Logger(
printer: pretty_log.PrettyPrinter(
methodCount: 0,
errorMethodCount: 8,
lineLength: 120,
colors: true,
printEmojis: true,
dateTimeFormat: pretty_log.DateTimeFormat.onlyTimeAndSinceStart,
),
);
// 2. Configure Standard Logger (logging package)
std_log.Logger.root.level = debugEnabled
? std_log.Level.ALL
: std_log.Level.WARNING;
std_log.Logger.root.onRecord.listen((record) {
if (kReleaseMode) {
// [Production] Log as JSON
_logJson(record);
} else {
// [Development] Log using Pretty Printer
_logPretty(record);
}
});
}
/// Initialize the logger. Call this in main.dart
static void init() {
// Accessing the instance triggers the constructor
LoggerService();
std_log.Logger('BaronSSO').info('Logger initialized');
}
void _logPretty(std_log.LogRecord record) {
if (record.level >= std_log.Level.SEVERE) {
_prettyLogger.e(
record.message,
error: record.error,
stackTrace: record.stackTrace,
);
} else if (record.level >= std_log.Level.WARNING) {
_prettyLogger.w(record.message);
} else if (record.level >= std_log.Level.INFO) {
_prettyLogger.i(record.message);
} else {
_prettyLogger.d(record.message);
}
}
void _logJson(std_log.LogRecord record) {
final sanitizedMessage = LogPolicy.sanitizeMessage(record.message);
final logData = {
'time': record.time.toUtc().toIso8601String(), // Use UTC for consistency
'level': record.level.name,
'msg': sanitizedMessage,
'svc': 'baron-userfront',
if (record.error != null) 'error': record.error.toString(),
if (record.stackTrace != null) 'stack': record.stackTrace.toString(),
};
// 1. Print to Browser Console (F12)
debugPrint(jsonEncode(logData));
// 2. Relay to Backend (Docker Terminal)
if (LogPolicy.shouldRelayClientLog(
level: record.level.name,
appEnv: _appEnv,
productionDebugFlag: _productionDebugFlag,
)) {
AuthProxyService.sendLog(
record.level.name,
sanitizedMessage,
data: {
'client_time': record.time.toUtc().toIso8601String(),
'logger': record.loggerName,
if (record.error != null) 'error': record.error.toString(),
},
);
}
}
}

View File

@@ -0,0 +1,4 @@
import 'login_challenge_loop_guard_stub.dart'
if (dart.library.js_interop) 'login_challenge_loop_guard_web.dart';
final loginChallengeLoopGuard = createLoginChallengeLoopGuard();

View File

@@ -0,0 +1,5 @@
abstract class LoginChallengeLoopGuard {
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000});
void markAutoAcceptAttempt(String loginChallenge);
void clear(String loginChallenge);
}

View File

@@ -0,0 +1,37 @@
import 'login_challenge_loop_guard_base.dart';
class _InMemoryLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
final Map<String, int> _lastAttemptAtMs = <String, int>{};
@override
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return false;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
final lastMs = _lastAttemptAtMs[challenge];
if (lastMs == null) {
return true;
}
return nowMs - lastMs > cooldownMs;
}
@override
void markAutoAcceptAttempt(String loginChallenge) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return;
}
_lastAttemptAtMs[challenge] = DateTime.now().millisecondsSinceEpoch;
}
@override
void clear(String loginChallenge) {
_lastAttemptAtMs.remove(loginChallenge.trim());
}
}
LoginChallengeLoopGuard createLoginChallengeLoopGuard() {
return _InMemoryLoginChallengeLoopGuard();
}

View File

@@ -0,0 +1,69 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:js_interop';
import 'login_challenge_loop_guard_base.dart';
@JS('window.sessionStorage')
external _JSStorage get _sessionStorage;
@JS()
extension type _JSStorage(JSObject _) implements JSObject {
external String? getItem(String key);
external void setItem(String key, String value);
external void removeItem(String key);
}
class _WebLoginChallengeLoopGuard implements LoginChallengeLoopGuard {
static const String _keyPrefix = 'baron_oidc_auto_accept_last:';
String _key(String challenge) => '$_keyPrefix$challenge';
@override
bool shouldAllowAutoAccept(String loginChallenge, {int cooldownMs = 15000}) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return false;
}
try {
final raw = _sessionStorage.getItem(_key(challenge));
if (raw == null || raw.isEmpty) {
return true;
}
final lastMs = int.tryParse(raw);
if (lastMs == null) {
return true;
}
final nowMs = DateTime.now().millisecondsSinceEpoch;
return nowMs - lastMs > cooldownMs;
} catch (_) {
return true;
}
}
@override
void markAutoAcceptAttempt(String loginChallenge) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return;
}
try {
final nowMs = DateTime.now().millisecondsSinceEpoch;
_sessionStorage.setItem(_key(challenge), nowMs.toString());
} catch (_) {}
}
@override
void clear(String loginChallenge) {
final challenge = loginChallenge.trim();
if (challenge.isEmpty) {
return;
}
try {
_sessionStorage.removeItem(_key(challenge));
} catch (_) {}
}
}
LoginChallengeLoopGuard createLoginChallengeLoopGuard() {
return _WebLoginChallengeLoopGuard();
}

View File

@@ -0,0 +1,39 @@
import '../notifiers/auth_notifier.dart';
import 'auth_proxy_service.dart';
import 'auth_token_store.dart';
typedef CurrentSessionLoader = Future<String?> Function();
typedef SessionRevoker = Future<void> Function(String sessionId);
typedef LogoutCallback = void Function();
class LogoutService {
LogoutService({
CurrentSessionLoader? loadCurrentSessionId,
SessionRevoker? revokeSession,
LogoutCallback? clearAuth,
LogoutCallback? notifyAuthChanged,
}) : _loadCurrentSessionId =
loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId,
_revokeSession = revokeSession ?? AuthProxyService.revokeSession,
_clearAuth = clearAuth ?? AuthTokenStore.clear,
_notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify;
final CurrentSessionLoader _loadCurrentSessionId;
final SessionRevoker _revokeSession;
final LogoutCallback _clearAuth;
final LogoutCallback _notifyAuthChanged;
Future<void> logout() async {
try {
final currentSessionId = await _loadCurrentSessionId();
if (currentSessionId != null && currentSessionId.isNotEmpty) {
await _revokeSession(currentSessionId);
}
} catch (_) {
// 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다.
} finally {
_clearAuth();
_notifyAuthChanged();
}
}
}

View File

@@ -0,0 +1,26 @@
import '../i18n/locale_utils.dart';
String? computeNullCheckRecoveryTarget({
required Object exception,
required Uri uri,
required String preferredLocaleCode,
}) {
final message = exception.toString();
if (!message.contains('Null check operator used on a null value')) {
return null;
}
final localeCode =
extractLocaleFromPath(uri) ?? normalizeLocaleCode(preferredLocaleCode);
final path = uri.path;
final localeRootPath = '/$localeCode';
if (path != '/' && path != localeRootPath) {
return null;
}
final target = '/$localeCode/signin';
if (path == target) {
return null;
}
return target;
}

View File

@@ -0,0 +1,220 @@
class OidcRedirectCheckResult {
final Uri? uri;
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) {
final trimmed = redirectTo.trim();
if (trimmed.isEmpty) {
return const OidcRedirectCheckResult(
uri: null,
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,
);
}
Uri parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {
return OidcRedirectCheckResult(
uri: null,
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,
);
}
final scheme = parsed.scheme.toLowerCase();
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'
: (isAbsolute ? 'unsupported_scheme' : 'not_absolute');
return OidcRedirectCheckResult(
uri: isValid ? parsed : null,
isValid: isValid,
reason: reason,
length: trimmed.length,
scheme: scheme,
host: parsed.host,
path: parsed.path,
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,37 @@
const _compileTimeEnv = {
'APP_ENV': String.fromEnvironment('APP_ENV'),
'BACKEND_URL': String.fromEnvironment('BACKEND_URL'),
'CLIENT_LOG_DEBUG': String.fromEnvironment('CLIENT_LOG_DEBUG'),
'USERFRONT_DEBUG_LOG': String.fromEnvironment('USERFRONT_DEBUG_LOG'),
'USERFRONT_URL': String.fromEnvironment('USERFRONT_URL'),
};
String runtimeOriginFallback() {
try {
final origin = Uri.base.origin;
if (origin.isNotEmpty && origin != 'null') {
return origin;
}
} catch (_) {}
return '';
}
String envOrDefault(String key, String fallback) {
final compileTimeValue = _compileTimeEnv[key];
if (compileTimeValue != null && compileTimeValue.trim().isNotEmpty) {
return compileTimeValue;
}
return fallback;
}
String sanitizedUrl(String value) {
return value.replaceAll(r'$', '').trim().replaceAll(RegExp(r'/$'), '');
}
String runtimeBackendUrl() {
return sanitizedUrl(envOrDefault('BACKEND_URL', runtimeOriginFallback()));
}
String runtimeUserfrontUrl() {
return sanitizedUrl(envOrDefault('USERFRONT_URL', runtimeOriginFallback()));
}

View File

@@ -0,0 +1,13 @@
import 'web_auth_integration_stub.dart'
if (dart.library.js_interop) 'web_auth_integration_web.dart';
abstract class WebAuthIntegration {
static void sendLoginSuccess(String token) {
// Platform-specific implementation
implSendLoginSuccess(token);
}
static bool isPopup() {
return implIsPopup();
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/foundation.dart';
void implSendLoginSuccess(String token) {
// No-op on non-web platforms
debugPrint('Not on web: Login Success with token: $token');
}
bool implIsPopup() {
return false;
}

View File

@@ -0,0 +1,98 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:async';
import 'dart:convert';
import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart';
import 'dart:js_interop';
import 'auth_token_store.dart';
import '../i18n/locale_utils.dart';
void implSendLoginSuccess(String token) {
var effectiveToken = token;
if (effectiveToken.isEmpty) {
effectiveToken = AuthTokenStore.getToken() ?? "";
}
final fullUrl = web.window.location.href;
final uri = Uri.base;
// Try to find redirect_uri from standard parsing first, then manual string search
String? redirectUri =
uri.queryParameters['redirect_uri'] ??
uri.queryParameters['redirect_url'];
if (redirectUri == null) {
// Manual fallback for cases where Uri.base misses params
final searchParams = web.window.location.search;
if (searchParams.isNotEmpty) {
final sUri = Uri.parse(
'?${searchParams.startsWith('?') ? searchParams.substring(1) : searchParams}',
);
redirectUri =
sUri.queryParameters['redirect_uri'] ??
sUri.queryParameters['redirect_url'];
}
}
// Final fallback: regex or manual search in fullUrl
if (redirectUri == null) {
for (final key in ['redirect_uri=', 'redirect_url=']) {
if (fullUrl.contains(key)) {
final start = fullUrl.indexOf(key) + key.length;
var end = fullUrl.indexOf('&', start);
if (end == -1) end = fullUrl.length;
final raw = fullUrl.substring(start, end);
try {
redirectUri = Uri.decodeComponent(raw);
break;
} catch (_) {}
}
}
}
if (redirectUri != null && redirectUri.isNotEmpty) {
// Redirection flow
final target = Uri.parse(redirectUri);
final query = Map<String, String>.from(target.queryParameters);
query['token'] = effectiveToken;
final finalUri = target.replace(queryParameters: query);
debugPrint('Redirecting to: ${finalUri.toString()}');
web.window.location.href = finalUri.toString();
return;
}
final message = {'type': 'LOGIN_SUCCESS', 'token': effectiveToken};
final opener = web.window.opener;
if (opener != null) {
try {
// Use JSON string for safer cross-origin/WASM messaging if direct object fails
final jsonMsg = jsonEncode(message);
(opener as web.Window).postMessage(jsonMsg.toJS, '*'.toJS);
debugPrint('Sent login success message to opener');
} catch (e) {
debugPrint('Failed to postMessage: $e');
}
// Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () {
try {
web.window.close();
} catch (e) {
debugPrint('Failed to close window: $e');
}
});
return;
}
// No opener and no redirect: fall back to local navigation
final fallbackTarget = buildLocalizedHomePath(Uri.base);
debugPrint('No opener found. Redirecting to $fallbackTarget.');
web.window.location.href = fallbackTarget;
}
bool implIsPopup() {
return web.window.opener != null;
}

View File

@@ -0,0 +1 @@
export 'web_window_web.dart';

View File

@@ -0,0 +1,27 @@
class WebWindow {
void setTitle(String title) {}
void redirectTo(String url) {}
String currentHref() {
return '';
}
String currentSearch() {
return '';
}
void alert(String message) {}
void close() {}
bool hasOpener() {
return false;
}
bool redirectOpenerTo(String url) {
return false;
}
}
final webWindow = WebWindow();

View File

@@ -0,0 +1,82 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import 'package:web/web.dart' as web;
import 'package:flutter/foundation.dart';
import 'dart:async';
class WebWindow {
void setTitle(String title) {
try {
web.document.title = title;
} catch (_) {}
}
void redirectTo(String url) {
final currentHref = web.window.location.href;
debugPrint(
"[WebWindow] redirectTo start: current=$currentHref, target=$url",
);
// Most direct and safe way for WASM: location.href assignment via package:web
Future.delayed(Duration.zero, () {
try {
web.window.location.href = url;
} catch (e) {
debugPrint("[WebWindow] CRITICAL JS ERROR: $e");
}
});
// Check after delay
Future<void>.delayed(const Duration(milliseconds: 800), () {
final nowHref = web.window.location.href;
if (nowHref == currentHref) {
debugPrint(
"[WebWindow] redirectTo no-op detected: current URL did not change",
);
}
});
}
String currentHref() {
return web.window.location.href;
}
String currentSearch() {
return web.window.location.search;
}
void alert(String message) {
try {
web.window.alert(message);
} catch (_) {}
}
void close() {
try {
web.window.close();
} catch (_) {}
}
bool hasOpener() {
try {
return web.window.opener != null;
} catch (_) {
return false;
}
}
bool redirectOpenerTo(String url) {
try {
final opener = web.window.opener;
if (opener == null) return false;
// In package:web, Window is not directly accessible from JSObject opener
// This is a known tricky part for WASM. We'll use a safer approach.
(opener as web.Window).location.href = url;
return true;
} catch (_) {
return false;
}
}
}
final webWindow = WebWindow();