diff --git a/packages/at_auth/lib/at_auth.dart b/packages/at_auth/lib/at_auth.dart index fed67374..fa3f65be 100644 --- a/packages/at_auth/lib/at_auth.dart +++ b/packages/at_auth/lib/at_auth.dart @@ -14,11 +14,14 @@ export 'src/enroll/at_enrollment_base.dart'; export 'src/enroll/at_enrollment_response.dart'; // The abstract class contains fields related to enrollment request export 'src/enroll/base_enrollment_request.dart'; + /// The class contains fields to submit enrollment request for APKAM keys which generate keys for /// an application with restricted access to the namespaces. export 'src/enroll/enrollment_request.dart'; + /// This class serves as the entity responsible for either approving or denying an enrollment request export 'src/enroll/enrollment_request_decision.dart'; + /// The class stores enrollment request details. It notifies the approving app upon receiving a /// request from the requesting app, for approval or denial. export 'src/enroll/enrollment_server_response.dart'; @@ -26,6 +29,7 @@ export 'src/exception/at_auth_exceptions.dart'; export 'src/keys/at_auth_keys.dart'; export 'src/onboard/at_onboarding_request.dart'; export 'src/onboard/at_onboarding_response.dart'; +export 'src/registrar/registrar.dart'; /// Global constant to access [AtAuthInterface]. /// diff --git a/packages/at_auth/lib/src/registrar/registrar.dart b/packages/at_auth/lib/src/registrar/registrar.dart new file mode 100644 index 00000000..633ab81d --- /dev/null +++ b/packages/at_auth/lib/src/registrar/registrar.dart @@ -0,0 +1,4 @@ +export './registrar_service_base.dart'; +export './registrar_service_impl.dart'; +export './registrar_exception.dart'; +export './registrar_validate_person_response.dart'; diff --git a/packages/at_auth/lib/src/registrar/registrar_exception.dart b/packages/at_auth/lib/src/registrar/registrar_exception.dart new file mode 100644 index 00000000..02b83e4b --- /dev/null +++ b/packages/at_auth/lib/src/registrar/registrar_exception.dart @@ -0,0 +1,12 @@ +class RegistrarException implements Exception { + RegistrarException({required this.error, required this.message}); + + /// Error message. Internal use only. + final String error; + + /// Helpful message to display to end-user. + final String message; + + @override + String toString() => 'RegistrarException - Error: $error, Message: $message'; +} diff --git a/packages/at_auth/lib/src/registrar/registrar_service_base.dart b/packages/at_auth/lib/src/registrar/registrar_service_base.dart new file mode 100644 index 00000000..93326c37 --- /dev/null +++ b/packages/at_auth/lib/src/registrar/registrar_service_base.dart @@ -0,0 +1,29 @@ +import 'registrar_validate_person_response.dart'; + +abstract interface class RegistrarServiceBase { + /// Gets a free atSign from the registrar. + Future getFreeAtSign(); + + /// This request is used to register an atSign by assigning it to an email address. + /// This request accepts an [atSign] and an [email] address. + /// A one-time password will be sent to the email address provided. + Future registerPerson({required String atSign, required String email}); + + /// This request is used to validate the person registering for an atSign by verifying the one-time password that was + /// sent to the email address provided. The one-time password is valid for 15 minutes. + Future validatePerson( + {required String atSign, required String email, required String otp}); + + /// This request is used to check whether the person attempting to activate an atSign is its rightful owner. + /// The request takes an atSign and sends a one-time password to the email address and/or phone number associated + /// with that atSign. + Future authenticateAtSign({required String atSign}); + + /// This request is used to check whether the person attempting to activate an atSign is its rightful owner. + /// The request takes an atSign and a one-time password then provides the cramkey once verified. + Future authenticateAtSignAndActivate( + {required String atSign, required String otp}); + + /// Weblink to registrar service. + String get registrarUrlSite; +} diff --git a/packages/at_auth/lib/src/registrar/registrar_service_impl.dart b/packages/at_auth/lib/src/registrar/registrar_service_impl.dart new file mode 100644 index 00000000..f41f067c --- /dev/null +++ b/packages/at_auth/lib/src/registrar/registrar_service_impl.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:at_auth/src/registrar/registrar_service_base.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +import 'registrar_validate_person_response.dart'; +import 'registrar_exception.dart'; + +class RegistrarServiceImpl implements RegistrarServiceBase { + RegistrarServiceImpl.dev({ + required this.apiKey, + int version = 3, + bool bypassCertificate = true, + this.maxRetries = 3, + this.retryDelayMs = 2000, + }) : _httpClient = + IOClient(_createHttpClient(bypassCertificate: bypassCertificate)), + _apiPath = '/api/app/v$version', + rootDomain = 'my.atsign.wtf', + weblink = 'https://atsign.wtf', + assert(version >= 1 && version <= 3, 'Version must be between 1 and 3'), + assert(maxRetries > 0, 'Max retries must be greater than 0'), + assert(retryDelayMs > 0, 'Retry delay must be greater than 0'); + + RegistrarServiceImpl.prod({ + required this.apiKey, + int version = 3, + this.maxRetries = 3, + this.retryDelayMs = 2000, + }) : _httpClient = IOClient(_createHttpClient(bypassCertificate: false)), + _apiPath = '/api/app/v$version', + rootDomain = 'my.atsign.com', + weblink = 'https://atsign.com', + assert(version >= 1 && version <= 3, 'Version must be between 1 and 3'), + assert(maxRetries > 0, 'Max retries must be greater than 0'), + assert(retryDelayMs > 0, 'Retry delay must be greater than 0'); + + RegistrarServiceImpl.custom({ + required this.apiKey, + required this.rootDomain, + required this.weblink, + required bool bypassCertificate, + int version = 3, + this.maxRetries = 3, + this.retryDelayMs = 2000, + IOClient? httpClient, + }) : _httpClient = httpClient ?? + IOClient(_createHttpClient(bypassCertificate: bypassCertificate)), + _apiPath = '/api/app/v$version', + assert(version >= 1 && version <= 3, 'Version must be between 1 and 3'), + assert(maxRetries > 0, 'Max retries must be greater than 0'), + assert(retryDelayMs > 0, 'Retry delay must be greater than 0'); + + final IOClient _httpClient; + final String rootDomain; + final String _apiPath; + final int maxRetries; + final int retryDelayMs; + final String apiKey; + final String weblink; + + /// Creates an `HttpClient` with a certificate bypass in non-production environments. + static HttpClient _createHttpClient({required bool bypassCertificate}) { + final client = HttpClient(); + if (bypassCertificate) { + client.badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } + return client; + } + + /// Sends a POST request to the registrar API with retry logic. + Future _postRequest( + String path, Map? data) async { + final url = Uri.https(rootDomain, '$_apiPath/$path'); + final body = data != null ? json.encode(data) : null; + + return _retryRequest(() async { + final response = await _httpClient.post( + url, + body: body, + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return response; + } + throw RegistrarException( + error: 'Request failed: ${response.statusCode} - ${response.body}', + message: 'Request failed with status code ${response.statusCode}', + ); + }); + } + + /// Sends a GET request to the registrar API with retry logic. + Future _getRequest(String path) async { + final url = Uri.https(rootDomain, '$_apiPath/$path'); + + return _retryRequest(() async { + final response = await _httpClient.get( + url, + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return response; + } + throw RegistrarException( + error: '${response.statusCode} - ${response.body}', + message: 'Request failed with status code ${response.statusCode}', + ); + }); + } + + /// Retries a function `_maxRetries` times with a delay. + Future _retryRequest(Future Function() request) async { + for (int attempt = 0; attempt < maxRetries; attempt++) { + try { + return await request(); + } catch (e) { + // Would be cleaner to move this logic outside the for loop but + // keeping this logic so that the error can be reported in exception. + if (attempt == maxRetries - 1) { + final message = e is RegistrarException ? e.message : e.toString(); + throw RegistrarException( + error: 'Request failed after $maxRetries attempts: $message', + message: message, + ); + } + await Future.delayed(Duration(milliseconds: retryDelayMs)); + } + } + throw Exception('Unexpected error in _retryRequest'); + } + + @override + Future getFreeAtSign() async { + final response = await _getRequest('get-free-atsign/'); + + final body = jsonDecode(response.body); + if (body is Map && body['data'] is Map) { + final data = body['data'] as Map; + if (data['atsign'] is String) { + return data['atsign'] as String; + } + } + throw RegistrarException( + error: 'Invalid response format: $body', + message: 'Invalid response format', + ); + } + + @override + Future registerPerson( + {required String atSign, required String email}) async { + final response = await _postRequest('register-person/', { + 'atsign': atSign, + 'email': email, + }); + + final body = jsonDecode(response.body); + if (body is Map && body['message'] is String) { + if (body['message'] == 'Sent Successfully') { + return; + } else { + throw RegistrarException( + error: body['message'], + message: body['message'], + ); + } + } + throw RegistrarException( + error: 'Invalid response format: $body', + message: 'Invalid response format', + ); + } + + @override + Future validatePerson({ + required String atSign, + required String email, + required String otp, + }) async { + final response = await _postRequest('validate-person/', { + 'atsign': atSign, + 'email': email, + 'otp': otp, + }); + + final body = jsonDecode(response.body); + + if (body is Map) { + return ValidatePersonResponse.fromJson(body); + } + + throw RegistrarException( + error: 'Invalid response format: $body', + message: 'Invalid response format', + ); + } + + @override + Future authenticateAtSign({required String atSign}) async { + final response = await _postRequest('authenticate/atsign', { + 'atsign': atSign, + }); + + final body = jsonDecode(response.body); + + if (body is Map) { + final message = body['message']; + if (message == 'Sent Successfully') { + return; + } else { + throw RegistrarException( + error: 'authenticate/atsign failed: $message', + message: message, + ); + } + } + + throw RegistrarException( + error: 'Invalid response format: $body', + message: 'Invalid response format', + ); + } + + @override + Future authenticateAtSignAndActivate( + {required String atSign, required String otp}) async { + final response = await _postRequest('authenticate/atsign/activate', { + 'atsign': atSign, + 'otp': otp, + }); + + final body = jsonDecode(response.body); + + if (body is Map) { + final cramKey = (body['cramkey'] as String?)?.split(':')[1]; + if (cramKey != null) { + return cramKey; + } else { + final message = body['message'] as String; + throw RegistrarException( + error: 'authenticate/atsign/activate failed: $message', + message: message, + ); + } + } + + throw RegistrarException( + error: 'Invalid response format: $body', + message: 'Invalid response format', + ); + } + + @override + String get registrarUrlSite => weblink; +} diff --git a/packages/at_auth/lib/src/registrar/registrar_validate_person_response.dart b/packages/at_auth/lib/src/registrar/registrar_validate_person_response.dart new file mode 100644 index 00000000..c61db93d --- /dev/null +++ b/packages/at_auth/lib/src/registrar/registrar_validate_person_response.dart @@ -0,0 +1,63 @@ +import 'registrar_exception.dart'; + +class ValidatePersonResponse { + const ValidatePersonResponse({ + this.existingAtSigns = const [], + this.errorMessage, + this.cramKey, + this.newAtSign, + }); + + final List existingAtSigns; + final String? errorMessage; + final String? cramKey; + final String? newAtSign; + + factory ValidatePersonResponse.fromJson(Map json) { + if (json.containsKey('success') && json['success'] == true) { + final data = (json['cramKey'] as String?)?.split(':'); + final atsign = data?[0]; + final cramKey = data?[1]; + return ValidatePersonResponse( + cramKey: cramKey, + newAtSign: atsign, + ); + } + + // If message is present and not null then it means it's an error + if (json.containsKey('message') && json['message'] != null) { + return ValidatePersonResponse( + errorMessage: json['message'] as String?, + ); + } + + if (json.containsKey('data') && + json['data'] != null && + (json['data'] as Map).isNotEmpty) { + final data = json['data'] as Map; + final atSigns = (data['atsigns'] as List?) + ?.map((e) => e as String) + .toList() ?? + []; + final newAtSign = data['newAtsign'] as String?; + + return ValidatePersonResponse( + existingAtSigns: atSigns, + newAtSign: newAtSign, + ); + } + + if (json.containsKey('status') && json['status'] == "error") { + return ValidatePersonResponse( + errorMessage: json['message'] as String?, + ); + } + + throw RegistrarException( + error: 'Invalid response format', + message: 'Unexpected response structure: $json', + ); + } + + bool get success => newAtSign != null || cramKey != null; +} diff --git a/packages/at_auth/pubspec.yaml b/packages/at_auth/pubspec.yaml index 5cae010c..a6da7c5b 100644 --- a/packages/at_auth/pubspec.yaml +++ b/packages/at_auth/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: meta: ^1.8.0 at_demo_data: ^1.0.3 crypton: ^2.2.1 + http: ^1.3.0 dev_dependencies: lints: ^5.0.0 diff --git a/packages/at_auth/test/registrar_test.dart b/packages/at_auth/test/registrar_test.dart new file mode 100644 index 00000000..79d98320 --- /dev/null +++ b/packages/at_auth/test/registrar_test.dart @@ -0,0 +1,255 @@ +import 'dart:convert'; +import 'package:at_auth/src/registrar/registrar_service_impl.dart'; +import 'package:at_auth/src/registrar/registrar_exception.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +// Mock class using mocktail +class MockHttpClient extends Mock implements IOClient {} + +void main() { + late MockHttpClient mockHttpClient; + late RegistrarServiceImpl registrarService; + + setUp(() { + mockHttpClient = MockHttpClient(); + registrarService = RegistrarServiceImpl.custom( + apiKey: 'test-api-key', + bypassCertificate: true, + rootDomain: 'example.com', + weblink: 'https://example.com', + retryDelayMs: 500, + httpClient: mockHttpClient, + ); + + // Register fallback values for mocktail + registerFallbackValue(Uri.parse('https://example.com')); + }); + + group('RegistrarServiceImpl - HTTP Request Tests', () { + test('getFreeAtSign returns an atSign on success', () async { + final mockResponse = jsonEncode({ + "success": true, + "data": {"atsign": "wisefrog"} + }); + when(() => mockHttpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + final atSign = await registrarService.getFreeAtSign(); + + expect(atSign, 'wisefrog'); + }); + + test('getFreeAtSign throws exception on invalid response', () async { + when(() => mockHttpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer( + (_) async => http.Response( + jsonEncode({ + "message": + "Oops, this option is not available at the moment. Please try again later.", + "status": "error" + }), + 200, + ), + ); + + expect(() async => await registrarService.getFreeAtSign(), + throwsA(isA())); + }); + + test('registerPerson succeeds on valid response', () async { + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => + http.Response(jsonEncode({'message': 'Sent Successfully'}), 200)); + + await registrarService.registerPerson( + atSign: '@testuser', email: 'test@example.com'); + }); + + test('registerPerson throws exception on API failure', () async { + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response( + jsonEncode({'message': 'Oops, atSign is required.'}), 400)); + + expect( + () async => await registrarService.registerPerson( + atSign: '@testuser', email: 'test@example.com'), + throwsA(isA())); + }); + + test('authenticateAtSignAndActivate returns cramKey on success', () async { + final testKey = + '7ca5f65fga49c7c667251d6f0cb2b0416dbc580b9712d943203ae644ae1b158bcdc02c6bc6453c33b51859773f05c6h5dd9b8a3c017d92cb87cf2ba3371a9d1f'; + final mockResponse = + jsonEncode({'message': 'Verified', 'cramkey': '@ashish:$testKey'}); + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + final cramKey = await registrarService.authenticateAtSignAndActivate( + atSign: '@testuser', otp: '123456'); + + expect(cramKey, testKey); + }); + + test('authenticateAtSignAndActivate throws exception on API failure', + () async { + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))).thenAnswer( + (_) async => http.Response( + jsonEncode({ + 'message': + 'Please enter the 4-character verification code that was sent to your email address' + }), + 400, + ), + ); + + expect( + () async => await registrarService.authenticateAtSignAndActivate( + atSign: '@testuser', otp: '123456'), + throwsA(isA())); + }); + + test('_retryRequest retries failed requests up to maxRetries', () async { + int attemptCount = 0; + + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), + body: any(named: 'body'))).thenAnswer((_) async { + attemptCount++; + return http.Response('Server error', 500); + }); + + await expectLater( + () async => await registrarService.registerPerson( + atSign: '@testuser', email: 'test@example.com'), + throwsA(isA())); + + expect(attemptCount, equals(registrarService.maxRetries)); + }); + + test('returns ValidatePersonResponse with cramKey when successful', + () async { + final mockResponse = + jsonEncode({"success": true, "cramKey": "@newatsign:cramKey123"}); + + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + final response = await registrarService.validatePerson( + atSign: '@testuser', + email: 'test@example.com', + otp: '123456', + ); + + expect(response.cramKey, equals('cramKey123')); // Removes prefix + expect(response.newAtSign, '@newatsign'); + expect(response.success, isTrue); + }); + + test('returns ValidatePersonResponse with existing atSigns', () async { + final mockResponse = jsonEncode({ + "data": { + "atsigns": ["oldAtSign1", "oldAtSign2"], + "newAtsign": "newAtSign" + } + }); + + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + final response = await registrarService.validatePerson( + atSign: '@testuser', + email: 'test@example.com', + otp: '123456', + ); + + expect( + response.existingAtSigns, containsAll(["oldAtSign1", "oldAtSign2"])); + expect(response.newAtSign, equals("newAtSign")); + expect(response.success, isTrue); + }); + + test('returns ValidatePersonResponse with error message on failure', + () async { + final mockResponse = + jsonEncode({"status": "error", "message": "Invalid OTP", "data": {}}); + + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + final response = await registrarService.validatePerson( + atSign: '@testuser', + email: 'test@example.com', + otp: 'wrongOTP', + ); + + expect(response.errorMessage, equals("Invalid OTP")); + expect(response.success, isFalse); + }); + + test( + 'returns ValidatePersonResponse with error message on more than 10 atsigns registered', + () async { + final mockResponse = jsonEncode({ + 'data': { + 'atsigns': [ + "looo", + "2467wonderful", + "birdwatcher", + "sunset8wild", + "modernwrestling", + "bowlinglonely6", + "apple33", + "highpeak31", + "aliveanteater", + "blueoctopus12" + ], + }, + 'message': + 'Oops! You already have the maximum number of free atSigns. Please select one of your existing atSigns.', + }); + + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + final response = await registrarService.validatePerson( + atSign: '@testuser', + email: 'test@example.com', + otp: '123456', + ); + + expect( + response.errorMessage, + equals( + "Oops! You already have the maximum number of free atSigns. Please select one of your existing atSigns.")); + expect(response.success, isFalse); + }); + + test('throws RegistrarException for invalid response format', () async { + final mockResponse = jsonEncode({"unexpected": "data"}); + + when(() => mockHttpClient.post(any(), + headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(mockResponse, 200)); + + expect( + () async => await registrarService.validatePerson( + atSign: '@testuser', + email: 'test@example.com', + otp: '123456', + ), + throwsA(isA()), + ); + }); + }); +}