Files
noo/lib/services/login_flow_service.dart
T
ayushyaandClaude Sonnet 5 f62d912ef7 Rebuild Noo as a full Nextcloud client: auth, media, and Material You UI
Replaces the starter template with Login Flow v2 auth, WebDAV-backed
Files/Photos/Activity tabs, a swipeable media viewer with PDF/text/video
preview, Material You dynamic theming, a frosted-glass floating nav bar,
and a rebranded app icon/identity (com.example -> family.nasa).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:07:02 -04:00

118 lines
3.5 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
class LoginFlowInit {
final Uri loginUrl;
final Uri pollEndpoint;
final String pollToken;
const LoginFlowInit({
required this.loginUrl,
required this.pollEndpoint,
required this.pollToken,
});
}
class LoginFlowResult {
final String serverUrl;
final String loginName;
final String appPassword;
const LoginFlowResult({
required this.serverUrl,
required this.loginName,
required this.appPassword,
});
}
/// Implements Nextcloud's "Login Flow v2": the app never sees the user's
/// real password. It asks the server for a one-time login URL, the user
/// authorizes in their browser, and the app polls until the server hands
/// back a scoped app password.
/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
class LoginFlowService {
static const _userAgent = 'Nextcloud-Flutter-Client/1.0';
static String normalizeServerUrl(String input) {
var url = input.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://$url';
}
if (url.endsWith('/')) {
url = url.substring(0, url.length - 1);
}
return url;
}
static Future<LoginFlowInit> initiate(String serverUrl) async {
final cleanUrl = normalizeServerUrl(serverUrl);
final endpoint = Uri.parse('$cleanUrl/index.php/login/v2');
debugPrint('[LoginFlow] Initiating login flow at $endpoint');
final response = await http.post(
endpoint,
headers: {'User-Agent': _userAgent, 'OCS-APIRequest': 'true'},
);
if (response.statusCode != 200) {
throw Exception(
'Could not start login flow (HTTP ${response.statusCode}). '
'Check the server address and that it is a reachable Nextcloud instance.',
);
}
final data = jsonDecode(response.body);
final loginUrl = data['login'] as String?;
final poll = data['poll'] as Map?;
final pollToken = poll?['token'] as String?;
final pollEndpoint = poll?['endpoint'] as String?;
if (loginUrl == null || pollToken == null || pollEndpoint == null) {
throw Exception('Server response did not include login flow details.');
}
return LoginFlowInit(
loginUrl: Uri.parse(loginUrl),
pollEndpoint: Uri.parse(pollEndpoint),
pollToken: pollToken,
);
}
/// Performs a single poll attempt. Returns null while the user has not
/// finished authorizing yet (server responds 404 during that window).
static Future<LoginFlowResult?> poll(Uri pollEndpoint, String token) async {
final response = await http.post(
pollEndpoint,
headers: {
'User-Agent': _userAgent,
'OCS-APIRequest': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {'token': token},
);
if (response.statusCode == 404) {
return null;
}
if (response.statusCode != 200) {
throw Exception('Login poll failed (HTTP ${response.statusCode}).');
}
final data = jsonDecode(response.body);
final server = data['server'] as String?;
final loginName = data['loginName'] as String?;
final appPassword = data['appPassword'] as String?;
if (server == null || loginName == null || appPassword == null) {
throw Exception('Server response did not include app credentials.');
}
return LoginFlowResult(
serverUrl: server,
loginName: loginName,
appPassword: appPassword,
);
}
}