import 'dart:convert'; import 'package:http/http.dart' as http; import '../../../model/endpoint_data.dart'; import '../../http_errors.dart'; import '../../marianumconnect/auth/device_token_name.dart'; /// Nextcloud Login Flow v2 (`/index.php/login/v2`): interactive browser login /// that yields an app password. It is the only way to obtain working Nextcloud /// credentials when the account is protected by two-factor authentication — /// Basic auth with the real password is rejected server-side in that case. class LoginFlowApi { final http.Client _client; LoginFlowApi({http.Client? client}) : _client = client ?? http.Client(); /// Starts a new flow. Nextcloud displays the request's User-Agent as the /// token name in the user's security settings, so the device token label is /// sent (`"Marianum Fulda App (Pixel 10)"`). Future start() async { final userAgent = await DeviceTokenName.resolve(); final uri = _initUri(); const label = 'Nextcloud login flow init'; final response = (await sendGuarded( label, () => _client.post( uri, headers: {'Accept': 'application/json', 'User-Agent': userAgent}, ), ))!; if (response.statusCode < 200 || response.statusCode >= 300) { throwForStatus( response.statusCode, httpErrorDetail(label, response.body, response.statusCode), ); } return LoginFlowInit.fromJson( jsonDecode(utf8.decode(response.bodyBytes)) as Map, ); } /// Polls for the flow result: `null` while the browser login has not been /// completed yet (HTTP 404), the final credentials once it has. Future poll(LoginFlowInit flow) async { const label = 'Nextcloud login flow poll'; final response = (await sendGuarded( label, () => _client.post( Uri.parse(flow.pollEndpoint), headers: {'Accept': 'application/json'}, body: {'token': flow.pollToken}, ), ))!; if (response.statusCode == 404) return null; if (response.statusCode < 200 || response.statusCode >= 300) { throwForStatus( response.statusCode, httpErrorDetail(label, response.body, response.statusCode), ); } return LoginFlowCredentials.fromJson( jsonDecode(utf8.decode(response.bodyBytes)) as Map, ); } static Uri _initUri() { final endpoint = EndpointData().nextcloud(); return Uri.https(endpoint.domain, '${endpoint.path}/index.php/login/v2'); } /// Whether the login name reported by the completed flow belongs to the /// account this app session expects — the browser login could have been /// completed with a different Nextcloud account. static bool loginNameMatches({ required String expected, required String actual, }) => actual.trim().toLowerCase() == expected.trim().toLowerCase(); } /// Response of the flow init call: the URL the user opens in the browser plus /// the token/endpoint pair the app polls until the login is confirmed. class LoginFlowInit { final String loginUrl; final String pollToken; final String pollEndpoint; const LoginFlowInit({ required this.loginUrl, required this.pollToken, required this.pollEndpoint, }); factory LoginFlowInit.fromJson(Map json) { final poll = json['poll']; final loginUrl = json['login'] as String?; final token = poll is Map ? poll['token'] as String? : null; final endpoint = poll is Map ? poll['endpoint'] as String? : null; if (loginUrl == null || loginUrl.isEmpty) { throw const FormatException('login flow init: missing login url'); } if (token == null || token.isEmpty || endpoint == null || endpoint.isEmpty) { throw const FormatException('login flow init: missing poll token/endpoint'); } return LoginFlowInit( loginUrl: loginUrl, pollToken: token, pollEndpoint: endpoint, ); } } /// Credentials returned once the user confirmed the login in the browser. class LoginFlowCredentials { final String server; final String loginName; final String appPassword; const LoginFlowCredentials({ required this.server, required this.loginName, required this.appPassword, }); factory LoginFlowCredentials.fromJson(Map json) { final loginName = json['loginName'] as String?; final appPassword = json['appPassword'] as String?; if (loginName == null || loginName.isEmpty) { throw const FormatException('login flow poll: missing loginName'); } if (appPassword == null || appPassword.isEmpty) { throw const FormatException('login flow poll: missing appPassword'); } return LoginFlowCredentials( server: json['server'] as String? ?? '', loginName: loginName, appPassword: appPassword, ); } }