Files
ChrisandClaude Opus 4.5 c832502c49 Add default MA port 8095 for local IP addresses
When users enter a local IP address without specifying a port,
auth detection now defaults to port 8095 (MA's default port).
This fixes connection issues for HAOS add-on users who previously
had to manually specify :8095.

Behavior:
- 192.168.4.178 → http://192.168.4.178:8095
- 192.168.4.178:8095 → http://192.168.4.178:8095 (unchanged)
- 192.168.4.178:80 → http://192.168.4.178:80 (explicit port preserved)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:29:48 +00:00

543 lines
19 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'auth_strategy.dart';
import 'no_auth_strategy.dart';
import 'basic_auth_strategy.dart';
import 'authelia_strategy.dart';
import 'ma_auth_strategy.dart';
import '../debug_logger.dart';
import '../../constants/network.dart';
/// Central authentication manager
/// Manages auth strategy selection, auto-detection, and credential lifecycle
class AuthManager {
final _logger = DebugLogger();
// Available auth strategies
final List<AuthStrategy> _strategies = [
NoAuthStrategy(),
BasicAuthStrategy(),
AutheliaStrategy(),
MusicAssistantAuthStrategy(),
];
AuthStrategy? _currentStrategy;
AuthCredentials? _currentCredentials;
/// Get current auth strategy (null if none selected)
AuthStrategy? get currentStrategy => _currentStrategy;
/// Get current credentials (null if none stored)
AuthCredentials? get currentCredentials => _currentCredentials;
/// Auto-detect authentication requirements for a server
/// Returns null if server is unreachable or auth method unknown
/// Also returns the final URL used (in case of HTTP fallback) via the callback
String? _lastSuccessfulUrl;
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
_lastSuccessfulUrl = null;
// Normalize URL
var baseUrl = _normalizeUrl(serverUrl);
_logger.log('Normalized URL: $baseUrl');
// Try detection with the initial URL
var result = await _tryDetectAuth(baseUrl);
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
if (result == null && baseUrl.startsWith('https://')) {
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
result = await _tryDetectAuth(httpUrl);
if (result != null) {
baseUrl = httpUrl;
}
}
if (result != null) {
_lastSuccessfulUrl = baseUrl;
_logger.log('✅ Auth detection successful with URL: $baseUrl');
}
return result;
}
/// Normalize a server URL with appropriate protocol and port
/// For local IP addresses without an explicit port, defaults to MA port 8095
String _normalizeUrl(String url) {
var baseUrl = url.trim();
// Check if URL already has a port specified (before adding protocol)
// Port can be specified as host:port or after protocol as http://host:port
bool hasExplicitPort = _hasExplicitPort(baseUrl);
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
// Use http for local IPs, https for domains
if (_isLocalAddress(baseUrl)) {
baseUrl = 'http://$baseUrl';
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
baseUrl = 'http://$baseUrl';
_logger.log('🔗 Detected Tailscale URL, using HTTP');
} else {
baseUrl = 'https://$baseUrl';
}
}
// For local addresses without explicit port, add default MA port
// This handles the common case of MA as HAOS add-on where port 8095 is needed
if (!hasExplicitPort) {
final uri = Uri.parse(baseUrl);
if (_isLocalAddress(uri.host)) {
baseUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: NetworkConstants.defaultWsPort,
path: uri.path.isEmpty ? null : uri.path,
).toString();
_logger.log('🔗 Local address without port - defaulting to MA port ${NetworkConstants.defaultWsPort}');
}
}
return baseUrl;
}
/// Check if a URL string has an explicit port specified
bool _hasExplicitPort(String url) {
// Remove protocol if present
var hostPart = url;
if (hostPart.startsWith('http://')) {
hostPart = hostPart.substring(7);
} else if (hostPart.startsWith('https://')) {
hostPart = hostPart.substring(8);
}
// Remove path if present
final pathIndex = hostPart.indexOf('/');
if (pathIndex != -1) {
hostPart = hostPart.substring(0, pathIndex);
}
// Check for port (host:port pattern)
// Need to handle IPv6 addresses like [::1]:8095
if (hostPart.startsWith('[')) {
// IPv6 address
final closeBracket = hostPart.indexOf(']');
if (closeBracket != -1 && closeBracket < hostPart.length - 1) {
return hostPart[closeBracket + 1] == ':';
}
return false;
} else {
// IPv4 or hostname - check for colon followed by digits
final colonIndex = hostPart.lastIndexOf(':');
if (colonIndex != -1) {
final portPart = hostPart.substring(colonIndex + 1);
return int.tryParse(portPart) != null;
}
return false;
}
}
/// Check if an address is a local/private address (without protocol)
bool _isLocalAddress(String address) {
// Remove port if present for checking
var host = address;
final colonIndex = host.lastIndexOf(':');
if (colonIndex != -1 && int.tryParse(host.substring(colonIndex + 1)) != null) {
host = host.substring(0, colonIndex);
}
return host.startsWith('192.168.') ||
host.startsWith('192.') ||
host.startsWith('10.') ||
host.startsWith('172.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Check if a hostname is a local/private IP address
bool _isLocalIpAddress(String host) {
return host.startsWith('192.168.') ||
host.startsWith('10.') ||
host.startsWith('172.16.') ||
host.startsWith('172.17.') ||
host.startsWith('172.18.') ||
host.startsWith('172.19.') ||
host.startsWith('172.2') ||
host.startsWith('172.30.') ||
host.startsWith('172.31.') ||
host == 'localhost' ||
host.startsWith('127.') ||
host.endsWith('.local') ||
host.endsWith('.ts.net');
}
/// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
// First, check if this is a Music Assistant server with native auth
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
if (maAuthResult != null) {
if (maAuthResult == 'required') {
_logger.log('✓ Detected Music Assistant native authentication');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
} else if (maAuthResult == 'none') {
_logger.log('✓ Music Assistant server - no auth required');
return _strategies.firstWhere((s) => s.name == 'none');
}
}
// Try no-auth (direct connection without any auth)
_logger.log('Testing no-auth strategy...');
final canConnect = await _canConnectWithoutAuth(baseUrl);
_logger.log('No-auth result: $canConnect');
if (canConnect) {
_logger.log('✓ Server does not require authentication');
return _strategies.firstWhere((s) => s.name == 'none');
}
// Probe server for auth requirements (reverse proxy scenarios)
try {
_logger.log('Probing server for auth headers...');
// Test root endpoint
final probeUrl = baseUrl;
_logger.log('Probe URL: $probeUrl');
final response = await http.get(
Uri.parse(probeUrl),
).timeout(const Duration(seconds: 10));
_logger.log('Probe status: ${response.statusCode}');
// Check for redirect (common with reverse proxy auth)
if (response.statusCode == 302 || response.statusCode == 307) {
final location = response.headers['location'];
_logger.log('Redirect to: $location');
// If redirects to Authelia login, it's Authelia
if (location != null && location.contains('authelia')) {
_logger.log('✓ Detected Authelia (redirect)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
}
// Server returned 401 Unauthorized - check WWW-Authenticate header
if (response.statusCode == 401) {
final wwwAuth = response.headers['www-authenticate']?.toLowerCase();
_logger.log('401 with WWW-Authenticate: $wwwAuth');
// Check for Basic Auth
if (wwwAuth != null && wwwAuth.contains('basic')) {
_logger.log('✓ Detected Basic Authentication');
return _strategies.firstWhere((s) => s.name == 'basic');
}
// Check for Bearer token - could be MA native auth
if (wwwAuth != null && wwwAuth.contains('bearer')) {
_logger.log('✓ Detected Bearer auth - likely Music Assistant native');
return _strategies.firstWhere((s) => s.name == 'music_assistant');
}
}
// If we got here and there's any auth challenge, assume Authelia
// (since we're behind a reverse proxy that requires auth)
if (response.statusCode >= 300) {
_logger.log('Server requires authentication (status ${response.statusCode})');
_logger.log('Assuming Authelia (default for authenticated reverse proxy)');
return _strategies.firstWhere((s) => s.name == 'authelia');
}
} catch (e) {
_logger.log('✗ Auth detection error: $e');
return null; // Return null to trigger fallback
}
_logger.log('⚠️ Could not determine auth method');
return null;
}
/// Check if server is a Music Assistant server with native auth
/// Returns 'required' if auth needed, 'none' if no auth, null if not MA or error
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
try {
final uri = Uri.parse(baseUrl);
// Use the port from URL if specified, otherwise use default ports (80/443)
// Don't default to 8095 here - that's only for direct connections without reverse proxy
final apiUrl = Uri(
scheme: uri.scheme,
host: uri.host,
port: uri.hasPort ? uri.port : null,
path: '/api',
);
_logger.log('Checking MA API at: $apiUrl');
// Use IOClient to control redirect behavior for local IPs
final isLocalIp = _isLocalIpAddress(uri.host);
http.Response response;
if (isLocalIp) {
// For local IPs, don't follow redirects - handle them explicitly
final client = HttpClient();
try {
final request = await client.postUrl(apiUrl);
request.followRedirects = false;
request.headers.set('Content-Type', 'application/json');
request.write(jsonEncode({'command': 'info'}));
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
// Check for redirect to HTTPS
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
final location = ioResponse.headers.value('location');
_logger.log('Local IP redirects to: $location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
// Don't follow the redirect - it will likely fail with cert error
// Instead, return null to trigger fallback logic
client.close();
return null;
}
}
final body = await ioResponse.transform(utf8.decoder).join();
response = http.Response(body, ioResponse.statusCode);
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.post which follows redirects
response = await http.post(
apiUrl,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'command': 'info',
}),
).timeout(const Duration(seconds: 5));
}
_logger.log('MA API response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}');
// 401/403 means auth required - check this first
if (response.statusCode == 401 || response.statusCode == 403) {
_logger.log('MA API returned ${response.statusCode} - auth required');
return 'required';
}
// Also check for "Authentication required" text response
if (response.body.toLowerCase().contains('authentication required')) {
_logger.log('MA API returned auth required message');
return 'required';
}
if (response.statusCode == 200) {
// Try to parse as JSON
try {
final data = jsonDecode(response.body) as Map<String, dynamic>;
// Check if we got server info successfully (no auth required)
if (data.containsKey('result')) {
final result = data['result'] as Map<String, dynamic>?;
final serverVersion = result?['server_version'] as String?;
final schemaVersion = result?['schema_version'] as int?;
if (serverVersion != null) {
_logger.log('MA server version: $serverVersion, schema: $schemaVersion');
// Schema 28+ requires auth, but if we got here without auth, it's not enabled
return 'none';
}
}
// Check for auth error in JSON
if (data.containsKey('error_code')) {
final errorCode = data['error_code'] as int?;
// Error 50 = AuthenticationRequired
if (errorCode == 50) {
_logger.log('MA server requires authentication (error 50)');
return 'required';
}
}
} catch (jsonError) {
_logger.log('MA API response not JSON: $jsonError');
}
}
return null;
} catch (e) {
_logger.log('MA auth check error: $e');
return null;
}
}
/// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try {
final uri = Uri.parse(serverUrl);
_logger.log('No-auth test URL: $serverUrl');
final isLocalIp = _isLocalIpAddress(uri.host);
int statusCode;
if (isLocalIp) {
// For local IPs, don't follow redirects to avoid HTTPS cert issues
final client = HttpClient();
try {
final request = await client.getUrl(uri);
request.followRedirects = false;
final response = await request.close().timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
// Check for redirect to HTTPS
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
final location = response.headers.value('location');
if (location != null && location.startsWith('https://')) {
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
client.close();
return false; // Can't connect - would need HTTPS which has cert issues
}
}
client.close();
} catch (e) {
client.close();
rethrow;
}
} else {
// For non-local URLs, use standard http.get
final response = await http.get(uri).timeout(const Duration(seconds: 10));
statusCode = response.statusCode;
}
_logger.log('No-auth status: $statusCode');
// 200 = no auth required
// 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required
if (statusCode == 200) {
return true;
}
return false;
} catch (e) {
_logger.log('No-auth error: $e');
return false;
}
}
/// Attempt login with specified strategy
/// Returns true if login successful and credentials stored
/// authServerUrl is optional - only used for Authelia when auth is on a different domain
Future<bool> login(
String serverUrl,
String username,
String password,
AuthStrategy strategy, {
String? authServerUrl,
}) async {
_logger.log('Attempting login with ${strategy.name} strategy');
// Use auth server URL if provided, otherwise use main server URL
final loginUrl = authServerUrl?.isNotEmpty == true ? authServerUrl! : serverUrl;
_logger.log('Login URL: $loginUrl');
final credentials = await strategy.login(loginUrl, username, password);
if (credentials != null) {
_currentStrategy = strategy;
_currentCredentials = credentials;
_logger.log('✓ Login successful with ${strategy.name}');
return true;
}
_logger.log('✗ Login failed with ${strategy.name}');
return false;
}
/// Validate current credentials are still valid
Future<bool> validateCurrentCredentials(String serverUrl) async {
if (_currentStrategy == null || _currentCredentials == null) {
return false;
}
return await _currentStrategy!.validateCredentials(
serverUrl,
_currentCredentials!,
);
}
/// Get WebSocket connection headers for current credentials
Map<String, dynamic> getWebSocketHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
return {};
}
return _currentStrategy!.buildWebSocketHeaders(_currentCredentials!);
}
/// Get HTTP streaming headers for current credentials
Map<String, String> getStreamingHeaders() {
if (_currentStrategy == null || _currentCredentials == null) {
return {};
}
return _currentStrategy!.buildStreamingHeaders(_currentCredentials!);
}
/// Serialize current credentials for persistent storage
Map<String, dynamic>? serializeCredentials() {
if (_currentStrategy == null || _currentCredentials == null) {
return null;
}
return {
'strategy': _currentStrategy!.name,
'data': _currentStrategy!.serializeCredentials(_currentCredentials!),
};
}
/// Deserialize and restore credentials from persistent storage
void deserializeCredentials(Map<String, dynamic> stored) {
final strategyName = stored['strategy'] as String?;
final data = stored['data'] as Map<String, dynamic>?;
if (strategyName == null || data == null) {
return;
}
// Find matching strategy
try {
final strategy = _strategies.firstWhere((s) => s.name == strategyName);
_currentStrategy = strategy;
_currentCredentials = strategy.deserializeCredentials(data);
_logger.log('✓ Restored ${strategy.name} credentials from storage');
} catch (e) {
_logger.log('✗ Could not restore credentials: $e');
}
}
/// Clear current authentication state
void logout() {
_currentStrategy = null;
_currentCredentials = null;
_logger.log('Logged out - cleared auth state');
}
/// Get strategy by name (for manual selection)
AuthStrategy? getStrategyByName(String name) {
try {
return _strategies.firstWhere((s) => s.name == name);
} catch (e) {
return null;
}
}
}