mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
Mobile: custom proxy headers + small login UX fixes (#1748)
* Add mobile custom proxy headers * Clear login placeholders on focus Email/password fields ship with example values pre-filled. Tapping the field now clears the placeholder so users don't have to delete it manually. Skips clearing if the user has already edited the value. * Push Configuration as a route from Sign in Opening Configuration from the Sign in screen now uses Navigator.push instead of toggling a state flag, so Android back returns to Sign in instead of quitting the app. Saving the URL auto-pops the route. * Address PR review on custom proxy headers - Test Connection no longer leaves global ApiConfig headers mutated; unsaved edits are restored in a finally block after the probe. - _loadSavedUrl / _loadCustomHeaders wrap storage reads in try/catch and always finish initialization with sensible defaults. - Sanitization is now a single CustomProxyHeader.sanitize() reused by ApiConfig.setCustomProxyHeaders and CustomProxyHeadersService. - Brief comment on redactedValue explaining the length-obscuring design. * Harden custom proxy header validation and load path - validateValue now rejects ASCII control characters (CR/LF/tab/etc.) to prevent header-injection via crafted values. - loadHeaders moves the secure-storage read inside the try block so platform exceptions are caught the same way JSON parse errors are.
This commit is contained in:
@@ -257,12 +257,6 @@ class _AppWrapperState extends State<AppWrapper> with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
|
||||
void _goToBackendConfig() {
|
||||
setState(() {
|
||||
_hasBackendUrl = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isCheckingConfig) {
|
||||
@@ -314,9 +308,7 @@ class _AppWrapperState extends State<AppWrapper> with WidgetsBindingObserver {
|
||||
return const SsoOnboardingScreen();
|
||||
}
|
||||
|
||||
return LoginScreen(
|
||||
onGoToSettings: _goToBackendConfig,
|
||||
);
|
||||
return const LoginScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
90
mobile/lib/models/custom_proxy_header.dart
Normal file
90
mobile/lib/models/custom_proxy_header.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
class CustomProxyHeader {
|
||||
static final RegExp _headerNamePattern = RegExp(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$");
|
||||
// Reject ASCII control bytes in values to block CR/LF header injection.
|
||||
static final RegExp _headerValueControlChars = RegExp(r'[\x00-\x1F\x7F]');
|
||||
static const Set<String> _reservedNames = {
|
||||
'accept',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'x-api-key',
|
||||
};
|
||||
|
||||
final String name;
|
||||
final String value;
|
||||
|
||||
CustomProxyHeader({
|
||||
required String name,
|
||||
required String value,
|
||||
}) : name = name.trim(),
|
||||
value = value.trim();
|
||||
|
||||
factory CustomProxyHeader.fromJson(Map<String, dynamic> json) {
|
||||
return CustomProxyHeader(
|
||||
name: json['name'] as String? ?? '',
|
||||
value: json['value'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'name': name,
|
||||
'value': value,
|
||||
};
|
||||
|
||||
String get normalizedName => name.toLowerCase();
|
||||
|
||||
// Length is intentionally obscured: short values get a fixed 4-bullet mask
|
||||
// and longer values get a fixed 6-bullet prefix + last 4 chars. Keeping the
|
||||
// last 4 lets users sanity-check what they entered without leaking length.
|
||||
String get redactedValue {
|
||||
if (value.isEmpty) return '';
|
||||
if (value.length <= 4) return '••••';
|
||||
return '••••••${value.substring(value.length - 4)}';
|
||||
}
|
||||
|
||||
/// Drops headers with empty/invalid name or value, then dedupes by
|
||||
/// case-insensitive name (last write wins). Single source of truth used by
|
||||
/// both `ApiConfig.setCustomProxyHeaders` and the persistence service.
|
||||
static List<CustomProxyHeader> sanitize(List<CustomProxyHeader> headers) {
|
||||
final byName = <String, CustomProxyHeader>{};
|
||||
for (final header in headers) {
|
||||
if (!header.isComplete) continue;
|
||||
if (validateName(header.name) != null) continue;
|
||||
if (validateValue(header.value) != null) continue;
|
||||
byName[header.normalizedName] = header;
|
||||
}
|
||||
return byName.values.toList(growable: false);
|
||||
}
|
||||
|
||||
bool get isComplete => name.isNotEmpty && value.isNotEmpty;
|
||||
|
||||
static String? validateName(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return 'Header name is required';
|
||||
if (!_headerNamePattern.hasMatch(trimmed)) {
|
||||
return 'Use a valid HTTP header name';
|
||||
}
|
||||
if (_reservedNames.contains(trimmed.toLowerCase())) {
|
||||
return 'This header is managed by the app';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validateValue(String value) {
|
||||
if (value.trim().isEmpty) return 'Header value is required';
|
||||
if (_headerValueControlChars.hasMatch(value)) {
|
||||
return 'Header value contains control characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
other is CustomProxyHeader &&
|
||||
name == other.name &&
|
||||
value == other.value;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, value);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/custom_proxy_header.dart';
|
||||
import '../services/api_config.dart';
|
||||
import '../services/custom_proxy_headers_service.dart';
|
||||
import '../widgets/custom_proxy_headers_editor.dart';
|
||||
|
||||
class BackendConfigScreen extends StatefulWidget {
|
||||
final VoidCallback? onConfigSaved;
|
||||
@@ -17,8 +20,10 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
final _urlController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _isTesting = false;
|
||||
bool _hasLoadedConfig = false;
|
||||
String? _errorMessage;
|
||||
String? _successMessage;
|
||||
List<CustomProxyHeader> _customHeaders = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,16 +38,27 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadSavedUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString('backend_url');
|
||||
final urlToShow = (savedUrl != null && savedUrl.isNotEmpty)
|
||||
? savedUrl
|
||||
: ApiConfig.baseUrl;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_urlController.text = urlToShow;
|
||||
});
|
||||
String urlToShow = ApiConfig.baseUrl;
|
||||
List<CustomProxyHeader> headers = const [];
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString('backend_url');
|
||||
headers = await CustomProxyHeadersService.instance.loadHeaders();
|
||||
if (savedUrl != null && savedUrl.isNotEmpty) {
|
||||
urlToShow = savedUrl;
|
||||
}
|
||||
} catch (e, stack) {
|
||||
// Swallow storage failures so the screen still becomes interactive with
|
||||
// sensible defaults; the user can re-enter and re-save.
|
||||
debugPrint('BackendConfigScreen: failed to load saved config: $e\n$stack');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_urlController.text = urlToShow;
|
||||
_customHeaders = headers;
|
||||
_hasLoadedConfig = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +71,7 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
_successMessage = null;
|
||||
});
|
||||
|
||||
final previousHeaders = ApiConfig.customProxyHeaders;
|
||||
try {
|
||||
// Normalize base URL by removing trailing slashes
|
||||
final normalizedUrl = _urlController.text.trim().replaceAll(
|
||||
@@ -62,10 +79,14 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
'',
|
||||
);
|
||||
|
||||
// Apply the unsaved edits only for the duration of this probe so the
|
||||
// test reflects what the user is about to save. Restored in `finally`.
|
||||
ApiConfig.setCustomProxyHeaders(_customHeaders);
|
||||
|
||||
// Check /sessions/new page to verify it's a Sure backend
|
||||
final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new');
|
||||
final sessionsResponse = await http
|
||||
.get(sessionsUrl, headers: {'Accept': 'text/html'})
|
||||
.get(sessionsUrl, headers: ApiConfig.htmlHeaders())
|
||||
.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
@@ -98,6 +119,7 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
ApiConfig.setCustomProxyHeaders(previousHeaders);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isTesting = false;
|
||||
@@ -125,6 +147,10 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('backend_url', normalizedUrl);
|
||||
|
||||
// Save custom proxy headers
|
||||
await CustomProxyHeadersService.instance.saveHeaders(_customHeaders);
|
||||
ApiConfig.setCustomProxyHeaders(_customHeaders);
|
||||
|
||||
// Update ApiConfig
|
||||
ApiConfig.setBaseUrl(normalizedUrl);
|
||||
|
||||
@@ -334,6 +360,41 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
|
||||
validator: _validateUrl,
|
||||
onFieldSubmitted: (_) => _saveAndContinue(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.http_outlined),
|
||||
title: const Text('Custom proxy headers'),
|
||||
subtitle: Text(
|
||||
_customHeaders.isEmpty
|
||||
? 'Optional headers for a reverse proxy or auth gateway'
|
||||
: '${_customHeaders.length} configured',
|
||||
),
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
if (_hasLoadedConfig)
|
||||
CustomProxyHeadersEditor(
|
||||
initialHeaders: _customHeaders,
|
||||
onChanged: (headers) {
|
||||
setState(() => _customHeaders = headers);
|
||||
},
|
||||
)
|
||||
else
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Headers are sent by the app with API requests. External browser SSO pages may not receive them.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Test Connection Button
|
||||
|
||||
@@ -5,21 +5,40 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../services/api_config.dart';
|
||||
import 'backend_config_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
final VoidCallback? onGoToSettings;
|
||||
|
||||
const LoginScreen({super.key, this.onGoToSettings});
|
||||
|
||||
void _openSettings(BuildContext context) {
|
||||
if (onGoToSettings != null) {
|
||||
onGoToSettings!();
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (routeContext) => BackendConfigScreen(
|
||||
onConfigSaved: () => Navigator.of(routeContext).pop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController(text: 'user@example.com');
|
||||
final _passwordController = TextEditingController(text: 'Password1!');
|
||||
static const _emailPlaceholder = 'user@example.com';
|
||||
static const _passwordPlaceholder = 'Password1!';
|
||||
final _emailController = TextEditingController(text: _emailPlaceholder);
|
||||
final _passwordController = TextEditingController(text: _passwordPlaceholder);
|
||||
final _otpController = TextEditingController();
|
||||
final _emailFocus = FocusNode();
|
||||
final _passwordFocus = FocusNode();
|
||||
bool _obscurePassword = true;
|
||||
late final TapGestureRecognizer _signUpTapRecognizer;
|
||||
|
||||
@@ -27,6 +46,17 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_signUpTapRecognizer = TapGestureRecognizer()..onTap = _openSignUpPage;
|
||||
_emailFocus.addListener(() => _clearPlaceholderOnFocus(
|
||||
_emailFocus, _emailController, _emailPlaceholder));
|
||||
_passwordFocus.addListener(() => _clearPlaceholderOnFocus(
|
||||
_passwordFocus, _passwordController, _passwordPlaceholder));
|
||||
}
|
||||
|
||||
void _clearPlaceholderOnFocus(
|
||||
FocusNode node, TextEditingController controller, String placeholder) {
|
||||
if (node.hasFocus && controller.text == placeholder) {
|
||||
controller.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -35,6 +65,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_otpController.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passwordFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -261,6 +293,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// Email Field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
focusNode: _emailFocus,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
@@ -291,6 +324,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// Password Field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: showOtp
|
||||
? TextInputAction.next
|
||||
@@ -442,7 +476,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
|
||||
// Backend URL info
|
||||
InkWell(
|
||||
onTap: widget.onGoToSettings,
|
||||
onTap: () => widget._openSettings(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -494,7 +528,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Backend Settings',
|
||||
onPressed: widget.onGoToSettings,
|
||||
onPressed: () => widget._openSettings(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,10 @@ import '../services/biometric_service.dart';
|
||||
import '../services/preferences_service.dart';
|
||||
import '../services/user_service.dart';
|
||||
import 'log_viewer_screen.dart';
|
||||
import '../models/custom_proxy_header.dart';
|
||||
import '../services/api_config.dart';
|
||||
import '../services/custom_proxy_headers_service.dart';
|
||||
import '../widgets/custom_proxy_headers_editor.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -27,6 +31,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _biometricSupported = false;
|
||||
bool _biometricEnabled = false;
|
||||
bool _isTogglingBiometric = false;
|
||||
List<CustomProxyHeader> _customHeaders = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -34,6 +39,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_loadPreferences();
|
||||
_loadAppVersion();
|
||||
_loadBiometricState();
|
||||
_loadCustomHeaders();
|
||||
}
|
||||
|
||||
Future<void> _loadBiometricState() async {
|
||||
@@ -93,6 +99,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCustomHeaders() async {
|
||||
try {
|
||||
final headers = await CustomProxyHeadersService.instance.loadHeaders();
|
||||
if (mounted) {
|
||||
setState(() => _customHeaders = headers);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
debugPrint('SettingsScreen: failed to load custom headers: $e\n$stack');
|
||||
// Keep the existing _customHeaders state so the screen remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleClearLocalData(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -318,6 +336,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showCustomHeadersDialog() async {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() => _customHeaders = latestHeaders);
|
||||
var draftHeaders = List<CustomProxyHeader>.from(latestHeaders);
|
||||
|
||||
final saved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Custom proxy headers'),
|
||||
content: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
CustomProxyHeadersEditor(
|
||||
initialHeaders: draftHeaders,
|
||||
onChanged: (headers) => draftHeaders = headers,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Headers are sent by the app with API requests. External browser SSO pages may not receive them.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (formKey.currentState?.validate() != true) return;
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (saved != true) return;
|
||||
|
||||
try {
|
||||
await CustomProxyHeadersService.instance.saveHeaders(draftHeaders);
|
||||
ApiConfig.setCustomProxyHeaders(draftHeaders);
|
||||
if (!mounted) return;
|
||||
setState(() => _customHeaders = draftHeaders);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Custom proxy headers saved')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to save custom proxy headers: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -482,6 +573,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const Divider(),
|
||||
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
'Connection',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
ListTile(
|
||||
leading: const Icon(Icons.http_outlined),
|
||||
title: const Text('Custom proxy headers'),
|
||||
subtitle: Text(
|
||||
_customHeaders.isEmpty
|
||||
? 'Optional headers for a reverse proxy or auth gateway'
|
||||
: '${_customHeaders.length} configured',
|
||||
),
|
||||
onTap: _showCustomHeadersDialog,
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Data Management Section
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/custom_proxy_header.dart';
|
||||
import 'custom_proxy_headers_service.dart';
|
||||
|
||||
class ApiConfig {
|
||||
// Base URL for the API - can be changed to point to different environments
|
||||
// For local development, use: http://10.0.2.2:3000 (Android emulator)
|
||||
@@ -32,14 +35,53 @@ class ApiConfig {
|
||||
_apiKeyValue = null;
|
||||
}
|
||||
|
||||
// Custom proxy headers
|
||||
static List<CustomProxyHeader> _customProxyHeaders = [];
|
||||
|
||||
static List<CustomProxyHeader> get customProxyHeaders =>
|
||||
List.unmodifiable(_customProxyHeaders);
|
||||
|
||||
static void setCustomProxyHeaders(List<CustomProxyHeader> headers) {
|
||||
_customProxyHeaders = CustomProxyHeader.sanitize(headers);
|
||||
}
|
||||
|
||||
static Map<String, String> get customProxyHeaderMap {
|
||||
return {
|
||||
for (final header in _customProxyHeaders) header.name: header.value,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, String> jsonHeaders() {
|
||||
return {
|
||||
...customProxyHeaderMap,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, String> htmlHeaders() {
|
||||
return {
|
||||
...customProxyHeaderMap,
|
||||
'Accept': 'text/html',
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the correct auth headers based on the current auth mode.
|
||||
/// In API key mode, uses X-Api-Key header.
|
||||
/// In token mode, uses Authorization: Bearer header.
|
||||
static Map<String, String> getAuthHeaders(String token) {
|
||||
if (_isApiKeyAuth && _apiKeyValue != null) {
|
||||
return {'X-Api-Key': _apiKeyValue!, 'Accept': 'application/json'};
|
||||
return {
|
||||
...customProxyHeaderMap,
|
||||
'X-Api-Key': _apiKeyValue!,
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
return {'Authorization': 'Bearer $token', 'Accept': 'application/json'};
|
||||
return {
|
||||
...customProxyHeaderMap,
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize the API configuration by loading the backend URL from storage
|
||||
@@ -51,6 +93,7 @@ class ApiConfig {
|
||||
|
||||
if (savedUrl != null && savedUrl.isNotEmpty) {
|
||||
_baseUrl = savedUrl;
|
||||
_customProxyHeaders = await CustomProxyHeadersService.instance.loadHeaders();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -58,6 +101,7 @@ class ApiConfig {
|
||||
// go straight to login while still letting users override it later.
|
||||
_baseUrl = _defaultBaseUrl;
|
||||
await prefs.setString(_backendUrlKey, _defaultBaseUrl);
|
||||
_customProxyHeaders = await CustomProxyHeadersService.instance.loadHeaders();
|
||||
return true;
|
||||
} catch (e) {
|
||||
// If initialization fails, keep the default URL
|
||||
|
||||
@@ -36,10 +36,7 @@ class AuthService {
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
@@ -145,10 +142,7 @@ class AuthService {
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
@@ -227,10 +221,7 @@ class AuthService {
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'refresh_token': refreshToken,
|
||||
'device': deviceInfo,
|
||||
@@ -301,6 +292,7 @@ class AuthService {
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
...ApiConfig.customProxyHeaderMap,
|
||||
'X-Api-Key': apiKey,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
@@ -398,10 +390,7 @@ class AuthService {
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({'code': code}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
@@ -463,10 +452,7 @@ class AuthService {
|
||||
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'linking_code': linkingCode,
|
||||
'email': email,
|
||||
@@ -523,10 +509,7 @@ class AuthService {
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
headers: ApiConfig.jsonHeaders(),
|
||||
body: jsonEncode(body),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
|
||||
47
mobile/lib/services/custom_proxy_headers_service.dart
Normal file
47
mobile/lib/services/custom_proxy_headers_service.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../models/custom_proxy_header.dart';
|
||||
|
||||
class CustomProxyHeadersService {
|
||||
static const String storageKey = 'custom_proxy_headers';
|
||||
|
||||
static CustomProxyHeadersService? _instance;
|
||||
|
||||
CustomProxyHeadersService._();
|
||||
|
||||
static CustomProxyHeadersService get instance {
|
||||
_instance ??= CustomProxyHeadersService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<List<CustomProxyHeader>> loadHeaders() async {
|
||||
const storage = FlutterSecureStorage();
|
||||
try {
|
||||
final raw = await storage.read(key: storageKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return [];
|
||||
|
||||
return CustomProxyHeader.sanitize(
|
||||
decoded
|
||||
.whereType<Map>()
|
||||
.map((item) => CustomProxyHeader.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList(),
|
||||
);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveHeaders(List<CustomProxyHeader> headers) async {
|
||||
const storage = FlutterSecureStorage();
|
||||
final sanitized = CustomProxyHeader.sanitize(headers);
|
||||
await storage.write(
|
||||
key: storageKey,
|
||||
value: jsonEncode(sanitized.map((header) => header.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
145
mobile/lib/widgets/custom_proxy_headers_editor.dart
Normal file
145
mobile/lib/widgets/custom_proxy_headers_editor.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/custom_proxy_header.dart';
|
||||
|
||||
class CustomProxyHeadersEditor extends StatefulWidget {
|
||||
final List<CustomProxyHeader> initialHeaders;
|
||||
final ValueChanged<List<CustomProxyHeader>> onChanged;
|
||||
|
||||
const CustomProxyHeadersEditor({
|
||||
super.key,
|
||||
required this.initialHeaders,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomProxyHeadersEditor> createState() => _CustomProxyHeadersEditorState();
|
||||
}
|
||||
|
||||
class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
|
||||
late List<_HeaderDraft> _drafts;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_drafts = widget.initialHeaders
|
||||
.map((header) => _HeaderDraft(name: header.name, value: header.value))
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _notifyChanged() {
|
||||
widget.onChanged(
|
||||
_drafts
|
||||
.map((draft) => CustomProxyHeader(name: draft.name.text, value: draft.value.text))
|
||||
.where((header) => header.isComplete)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void _addHeader() {
|
||||
setState(() => _drafts.add(_HeaderDraft()));
|
||||
}
|
||||
|
||||
void _removeHeader(int index) {
|
||||
setState(() {
|
||||
final draft = _drafts.removeAt(index);
|
||||
draft.dispose();
|
||||
});
|
||||
_notifyChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final draft in _drafts) {
|
||||
draft.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < _drafts.length; index++) ...[
|
||||
_HeaderRow(
|
||||
draft: _drafts[index],
|
||||
onChanged: _notifyChanged,
|
||||
onRemove: () => _removeHeader(index),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _addHeader,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add header'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderRow extends StatelessWidget {
|
||||
final _HeaderDraft draft;
|
||||
final VoidCallback onChanged;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _HeaderRow({
|
||||
required this.draft,
|
||||
required this.onChanged,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: draft.name,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Header name',
|
||||
hintText: 'X-Auth-Token',
|
||||
),
|
||||
validator: (value) => CustomProxyHeader.validateName(value ?? ''),
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: draft.value,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Header value',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) => CustomProxyHeader.validateValue(value ?? ''),
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Remove header',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderDraft {
|
||||
final TextEditingController name;
|
||||
final TextEditingController value;
|
||||
|
||||
_HeaderDraft({String name = '', String value = ''})
|
||||
: name = TextEditingController(text: name),
|
||||
value = TextEditingController(text: value);
|
||||
|
||||
void dispose() {
|
||||
name.dispose();
|
||||
value.dispose();
|
||||
}
|
||||
}
|
||||
47
mobile/test/models/custom_proxy_header_test.dart
Normal file
47
mobile/test/models/custom_proxy_header_test.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sure_mobile/models/custom_proxy_header.dart';
|
||||
|
||||
void main() {
|
||||
group('CustomProxyHeader', () {
|
||||
test('serializes trimmed header name and value', () {
|
||||
final header = CustomProxyHeader(name: ' X-Auth-Id ', value: ' abc ');
|
||||
|
||||
expect(header.name, 'X-Auth-Id');
|
||||
expect(header.value, 'abc');
|
||||
expect(header.toJson(), {
|
||||
'name': 'X-Auth-Id',
|
||||
'value': 'abc',
|
||||
});
|
||||
expect(CustomProxyHeader.fromJson(header.toJson()), header);
|
||||
});
|
||||
|
||||
test('rejects empty, malformed, and reserved names', () {
|
||||
expect(CustomProxyHeader.validateName(''), isNotNull);
|
||||
expect(CustomProxyHeader.validateName('Bad Header'), isNotNull);
|
||||
expect(CustomProxyHeader.validateName('Bad:Header'), isNotNull);
|
||||
expect(CustomProxyHeader.validateName('Authorization'), isNotNull);
|
||||
expect(CustomProxyHeader.validateName('X-Api-Key'), isNotNull);
|
||||
expect(CustomProxyHeader.validateName('Accept'), isNotNull);
|
||||
expect(CustomProxyHeader.validateName('Content-Type'), isNotNull);
|
||||
});
|
||||
|
||||
test('allows custom header names with hyphens', () {
|
||||
expect(CustomProxyHeader.validateName('X-Auth-Id'), isNull);
|
||||
expect(CustomProxyHeader.validateName('X-Auth-Secret'), isNull);
|
||||
});
|
||||
|
||||
test('rejects values containing control characters (header injection)', () {
|
||||
expect(CustomProxyHeader.validateValue('abc\r\nInjected: 1'), isNotNull);
|
||||
expect(CustomProxyHeader.validateValue('abc\tdef'), isNotNull);
|
||||
expect(CustomProxyHeader.validateValue('abc\x7Fdef'), isNotNull);
|
||||
expect(CustomProxyHeader.validateValue('plain value with spaces'), isNull);
|
||||
});
|
||||
|
||||
test('redacts values for display', () {
|
||||
expect(
|
||||
CustomProxyHeader(name: 'X-Auth-Secret', value: '1234567890').redactedValue,
|
||||
'••••••7890',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
78
mobile/test/services/api_config_headers_test.dart
Normal file
78
mobile/test/services/api_config_headers_test.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sure_mobile/models/custom_proxy_header.dart';
|
||||
import 'package:sure_mobile/services/api_config.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ApiConfig.clearApiKeyAuth();
|
||||
ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl);
|
||||
ApiConfig.setCustomProxyHeaders([]);
|
||||
});
|
||||
|
||||
test('adds custom proxy headers to token auth headers', () {
|
||||
ApiConfig.setCustomProxyHeaders([
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'id'),
|
||||
CustomProxyHeader(name: 'X-Auth-Secret', value: 'secret'),
|
||||
]);
|
||||
|
||||
expect(ApiConfig.getAuthHeaders('token'), {
|
||||
'X-Auth-Id': 'id',
|
||||
'X-Auth-Secret': 'secret',
|
||||
'Authorization': 'Bearer token',
|
||||
'Accept': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
test('adds custom proxy headers to unauthenticated json headers', () {
|
||||
ApiConfig.setCustomProxyHeaders([
|
||||
CustomProxyHeader(name: 'X-Mobile-Bypass', value: 'pass'),
|
||||
]);
|
||||
|
||||
expect(ApiConfig.jsonHeaders(), {
|
||||
'X-Mobile-Bypass': 'pass',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
test('drops headers with reserved names', () {
|
||||
ApiConfig.setCustomProxyHeaders([
|
||||
CustomProxyHeader(name: 'Accept', value: 'text/plain'),
|
||||
CustomProxyHeader(name: 'Authorization', value: 'should-be-dropped'),
|
||||
CustomProxyHeader(name: 'X-Api-Key', value: 'should-be-dropped-too'),
|
||||
CustomProxyHeader(name: 'Content-Type', value: 'application/xml'),
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'id'),
|
||||
]);
|
||||
|
||||
final result = ApiConfig.customProxyHeaders;
|
||||
expect(result.length, 1);
|
||||
expect(result.first.name, 'X-Auth-Id');
|
||||
});
|
||||
|
||||
test('deduplicates headers by normalized name keeping the last value', () {
|
||||
ApiConfig.setCustomProxyHeaders([
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'first'),
|
||||
CustomProxyHeader(name: 'x-auth-id', value: 'second'),
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'third'),
|
||||
]);
|
||||
|
||||
final result = ApiConfig.customProxyHeaders;
|
||||
expect(result.length, 1);
|
||||
expect(result.first.name, 'X-Auth-Id');
|
||||
expect(result.first.value, 'third');
|
||||
});
|
||||
|
||||
test('app managed headers win over custom headers', () {
|
||||
ApiConfig.setCustomProxyHeaders([
|
||||
CustomProxyHeader(name: 'Accept', value: 'text/plain'),
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'id'),
|
||||
]);
|
||||
|
||||
expect(ApiConfig.htmlHeaders(), {
|
||||
'X-Auth-Id': 'id',
|
||||
'Accept': 'text/html',
|
||||
});
|
||||
});
|
||||
}
|
||||
47
mobile/test/services/custom_proxy_headers_service_test.dart
Normal file
47
mobile/test/services/custom_proxy_headers_service_test.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:sure_mobile/models/custom_proxy_header.dart';
|
||||
import 'package:sure_mobile/services/custom_proxy_headers_service.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('saves and loads custom proxy headers', () async {
|
||||
final service = CustomProxyHeadersService.instance;
|
||||
final headers = [
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'id'),
|
||||
CustomProxyHeader(name: 'X-Auth-Secret', value: 'secret'),
|
||||
];
|
||||
|
||||
await service.saveHeaders(headers);
|
||||
|
||||
expect(await service.loadHeaders(), headers);
|
||||
});
|
||||
|
||||
test('drops incomplete and duplicate headers, keeping the last value', () async {
|
||||
final service = CustomProxyHeadersService.instance;
|
||||
|
||||
await service.saveHeaders([
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'old'),
|
||||
CustomProxyHeader(name: '', value: 'ignored'),
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'new'),
|
||||
CustomProxyHeader(name: 'X-Empty', value: ''),
|
||||
]);
|
||||
|
||||
expect(await service.loadHeaders(), [
|
||||
CustomProxyHeader(name: 'X-Auth-Id', value: 'new'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns an empty list for invalid stored json', () async {
|
||||
const storage = FlutterSecureStorage();
|
||||
await storage.write(
|
||||
key: CustomProxyHeadersService.storageKey,
|
||||
value: 'not json',
|
||||
);
|
||||
|
||||
expect(await CustomProxyHeadersService.instance.loadHeaders(), isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user