diff --git a/CHANGELOG.md b/CHANGELOG.md index 1752072d0..b32f47824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ * Added support for realm list of nullable primitive types, ie. `RealmList`. ([#163](https://github.com/realm/realm-dart/issues/163)) * Allow null arguments on query. ([#871](https://github.com/realm/realm-dart/issues/871)) +* Added support for API key authentication. (Issue [#432](https://github.com/realm/realm-dart/issues/432)) + * Expose `User.apiKeys` client - this client can be used to create, fetch, and delete API keys. + * Expose `Credentials.apiKey` that enable authentication with API keys. +* Exposed `User.accessToken` and `User.refreshToken` - these tokens can be used to authenticate against the server when calling HTTP API outside of the Dart/Flutter SDK. For example, if you want to use the GraphQL or the data access API. (PR [#919](https://github.com/realm/realm-dart/pull/919)) + ### Fixed * Previously removeAt did not truncate length. ([#883](https://github.com/realm/realm-dart/issues/883)) * List.length= now throws, if you try to increase length. This previously succeeded silently. ([#894](https://github.com/realm/realm-dart/pull/894)). diff --git a/lib/src/app.dart b/lib/src/app.dart index 7eb3822ca..f1c9f898a 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -188,4 +188,22 @@ extension AppInternal on App { AppHandle get handle => _handle; static App create(AppHandle handle) => App._(handle); + + static AppException createException(String message, String? linkToLogs, int statusCode) => AppException._(message, linkToLogs, statusCode); +} + +/// An exception thrown from operations interacting with a Atlas App Services app. +class AppException extends RealmException { + /// A link to the server logs associated with this exception if available. + final String? linkToServerLogs; + + /// The HTTP status code returned by the server for this exception. + final int statusCode; + + AppException._(super.message, this.linkToServerLogs, this.statusCode); + + @override + String toString() { + return "AppException: $message, link to server logs: $linkToServerLogs"; + } } diff --git a/lib/src/cli/atlas_apps/baas_client.dart b/lib/src/cli/atlas_apps/baas_client.dart index d9547a1d6..30cff1762 100644 --- a/lib/src/cli/atlas_apps/baas_client.dart +++ b/lib/src/cli/atlas_apps/baas_client.dart @@ -185,6 +185,8 @@ class BaasClient { "runResetFunction": true }'''); + await enableProvider(app, 'api-key'); + if (publicRSAKey.isNotEmpty) { String publicRSAKeyEncoded = jsonEncode(publicRSAKey); final dynamic createSecretResult = await _post('groups/$_groupId/apps/$appId/secrets', '{"name":"rsPublicKey","value":$publicRSAKeyEncoded}'); @@ -247,7 +249,7 @@ class BaasClient { "authFunctionName": "authFunc", "authFunctionId": "$authFuncId" }'''); - + const facebookSecret = "876750ac6d06618b323dee591602897f"; final dynamic createFacebookSecretResult = await _post('groups/$_groupId/apps/$appId/secrets', '{"name":"facebookSecret","value":"$facebookSecret"}'); String facebookClientSecretKeyName = createFacebookSecretResult['name'] as String; @@ -292,7 +294,7 @@ class BaasClient { "name": "picture" }'''); } - + print('Creating database db_$name$_appSuffix'); await _createMongoDBService(app, '''{ @@ -353,6 +355,15 @@ class BaasClient { } } + Future createApiKey(BaasApp app, String name, bool enabled) async { + final dynamic result = await _post('groups/$_groupId/apps/${app.appId}/api_keys', '{ "name":"$name" }'); + if (!enabled) { + await _put('groups/$_groupId/apps/${app.appId}/api_keys/${result['_id']}/disable', ''); + } + + return result['key'] as String; + } + Future _authenticate(String provider, String credentials) async { dynamic response = await _post('auth/providers/$provider/login', credentials); diff --git a/lib/src/credentials.dart b/lib/src/credentials.dart index e130d4c2b..9be1967f3 100644 --- a/lib/src/credentials.dart +++ b/lib/src/credentials.dart @@ -21,37 +21,54 @@ import 'dart:ffi'; import 'native/realm_core.dart'; import 'app.dart'; +import 'user.dart'; /// An enum containing all authentication providers. These have to be enabled manually for the application before they can be used. /// [Authentication Providers Docs](https://docs.mongodb.com/realm/authentication/providers/) /// {@category Application} enum AuthProviderType { /// For authenticating without credentials. - anonymous, + anonymous(0), /// For authenticating without credentials using a new anonymous user. - anonymousNoReuse, + anonymousNoReuse(1), /// Authenticate with Apple Id - apple, + apple(2), /// Authenticate with Facebook account. - facebook, + facebook(3), /// Authenticate with Google account - google, + google(4), /// For authenticating with JSON web token. - jwt, + jwt(5), /// For authenticating with an email and a password. - emailPassword, + emailPassword(6), /// For authenticating with custom function with payload argument. - function, + function(7), - _userApiKey, - _serverApiKey + /// For authenticating with an API key. + apiKey(8); + + const AuthProviderType(this._value); + + final int _value; +} + +extension AuthProviderTypeInternal on AuthProviderType { + static AuthProviderType getByValue(int value) { + for (final type in AuthProviderType.values) { + if (type._value == value) { + return type; + } + } + + throw ArgumentError('Invalid AuthProviderType value: $value'); + } } /// A class, representing the credentials used for authenticating a [User] @@ -106,6 +123,12 @@ class Credentials implements Finalizable { Credentials.function(String payload) : _handle = realmCore.createAppCredentialsFunction(payload), provider = AuthProviderType.function; + + /// Returns a [Credentials] object that can be used to authenticate a user with an API key. + /// To generate an API key, use [ApiKeyClient.create] or the App Services web UI. + Credentials.apiKey(String key) + : _handle = realmCore.createAppCredentialsApiKey(key), + provider = AuthProviderType.apiKey; } /// @nodoc diff --git a/lib/src/native/realm_core.dart b/lib/src/native/realm_core.dart index 51b5fdfbf..e60c73d6f 100644 --- a/lib/src/native/realm_core.dart +++ b/lib/src/native/realm_core.dart @@ -1126,6 +1126,13 @@ class _RealmCore { }); } + RealmAppCredentialsHandle createAppCredentialsApiKey(String key) { + return using((arena) { + final keyPtr = key.toCharPtr(arena); + return RealmAppCredentialsHandle._(_realmLib.realm_app_credentials_new_user_api_key(keyPtr)); + }); + } + RealmHttpTransportHandle _createHttpTransport(HttpClient httpClient) { final requestCallback = Pointer.fromFunction)>(_request_callback); final requestCallbackUserdata = _realmLib.realm_dart_userdata_async_new(httpClient, requestCallback.cast(), scheduler.handle._pointer); @@ -1142,7 +1149,7 @@ class _RealmCore { // we explicitly call realm_http_transport_complete_request to // mark request as completed later. // - // Therefor we need to copy everything out of request before returning. + // Therefore we need to copy everything out of request before returning. // We cannot clone request on the native side with realm_clone, // since realm_http_request does not inherit from WrapC. @@ -1152,7 +1159,7 @@ class _RealmCore { final url = Uri.parse(request.url.cast().toRealmDartString()!); - final body = request.body.cast().toRealmDartString(length: request.body_size)!; + final body = request.body.cast().toRealmDartString(length: request.body_size); final headers = {}; for (int i = 0; i < request.num_headers; ++i) { @@ -1170,7 +1177,7 @@ class _RealmCore { HttpClient client, int requestMethod, Uri url, - String body, + String? body, Map headers, Pointer request_context, ) async { @@ -1206,7 +1213,9 @@ class _RealmCore { request.headers.add(header.key, header.value); } - request.add(utf8.encode(body)); + if (body != null) { + request.add(utf8.encode(body)); + } // Do the call.. final response = await request.close(); @@ -1300,8 +1309,7 @@ class _RealmCore { } if (error != nullptr) { - final message = error.ref.message.cast().toRealmDartString()!; - completer.completeError(RealmException(message)); + completer.completeWithAppError(error); return; } @@ -1335,8 +1343,7 @@ class _RealmCore { } if (error != nullptr) { - final message = error.ref.message.cast().toRealmDartString()!; - completer.completeError(RealmException(message)); + completer.completeWithAppError(error); return; } @@ -1462,8 +1469,7 @@ class _RealmCore { } if (error != nullptr) { - final message = error.ref.message.cast().toRealmDartString()!; - completer.completeError(RealmException(message)); + completer.completeWithAppError(error); return; } @@ -1626,7 +1632,7 @@ class _RealmCore { final identity = identitiesPtr.elementAt(i).ref; result.add(UserIdentityInternal.create( - identity.id.cast().toRealmDartString(freeRealmMemory: true)!, AuthProviderType.values.fromIndex(identity.provider_type))); + identity.id.cast().toRealmDartString(freeRealmMemory: true)!, AuthProviderTypeInternal.getByValue(identity.provider_type))); } return result; @@ -1644,7 +1650,7 @@ class _RealmCore { AuthProviderType userGetAuthProviderType(User user) { final provider = _realmLib.realm_user_get_auth_provider(user.handle._pointer); - return AuthProviderType.values.fromIndex(provider); + return AuthProviderTypeInternal.getByValue(provider); } UserProfile userGetProfileData(User user) { @@ -1653,6 +1659,16 @@ class _RealmCore { return UserProfile(profileData as Map); } + String userGetRefreshToken(User user) { + final token = _realmLib.invokeGetPointer(() => _realmLib.realm_user_get_refresh_token(user.handle._pointer)); + return token.cast().toRealmDartString(freeRealmMemory: true)!; + } + + String userGetAccessToken(User user) { + final token = _realmLib.invokeGetPointer(() => _realmLib.realm_user_get_access_token(user.handle._pointer)); + return token.cast().toRealmDartString(freeRealmMemory: true)!; + } + SessionHandle realmGetSession(Realm realm) { return SessionHandle._(_realmLib.invokeGetPointer(() => _realmLib.realm_sync_session_get(realm.handle._pointer))); } @@ -1863,6 +1879,124 @@ class _RealmCore { return null; }); } + + static void _app_api_key_completion_callback(Pointer userdata, Pointer apiKey, Pointer error) { + final Completer? completer = userdata.toObject(isPersistent: true); + if (completer == null) { + return; + } + + if (error != nullptr) { + completer.completeWithAppError(error); + return; + } + + final id = apiKey.ref.id.toDart(); + final name = apiKey.ref.name.cast().toDartString(); + final value = apiKey.ref.key.cast().toRealmDartString(treatEmptyAsNull: true); + final isEnabled = !apiKey.ref.disabled; + + completer.complete(UserInternal.createApiKey(id, name, value, isEnabled)); + } + + static void _app_api_key_array_completion_callback(Pointer userdata, Pointer apiKey, int size, Pointer error) { + final Completer>? completer = userdata.toObject(isPersistent: true); + if (completer == null) { + return; + } + + if (error != nullptr) { + completer.completeWithAppError(error); + return; + } + + final result = []; + + for (var i = 0; i < size; i++) { + final id = apiKey[i].id.toDart(); + final name = apiKey[i].name.cast().toDartString(); + final value = apiKey[i].key.cast().toRealmDartString(treatEmptyAsNull: true); + final isEnabled = !apiKey[i].disabled; + + result.add(UserInternal.createApiKey(id, name, value, isEnabled)); + } + + completer.complete(result); + } + + Future createApiKey(User user, String name) { + return using((Arena arena) { + final namePtr = name.toCharPtr(arena); + final completer = Completer(); + _realmLib.invokeGetBool(() => _realmLib.realm_app_user_apikey_provider_client_create_apikey(user.app.handle._pointer, user.handle._pointer, namePtr, + Pointer.fromFunction(_app_api_key_completion_callback), completer.toPersistentHandle(), _realmLib.addresses.realm_dart_delete_persistent_handle)); + + return completer.future; + }); + } + + Future fetchApiKey(User user, ObjectId id) { + return using((Arena arena) { + final completer = Completer(); + final native_id = id.toNative(arena); + _realmLib.invokeGetBool(() => _realmLib.realm_app_user_apikey_provider_client_fetch_apikey(user.app.handle._pointer, user.handle._pointer, native_id.ref, + Pointer.fromFunction(_app_api_key_completion_callback), completer.toPersistentHandle(), _realmLib.addresses.realm_dart_delete_persistent_handle)); + + return completer.future; + }); + } + + Future> fetchAllApiKeys(User user) { + return using((Arena arena) { + final completer = Completer>(); + _realmLib.invokeGetBool(() => _realmLib.realm_app_user_apikey_provider_client_fetch_apikeys( + user.app.handle._pointer, + user.handle._pointer, + Pointer.fromFunction(_app_api_key_array_completion_callback), + completer.toPersistentHandle(), + _realmLib.addresses.realm_dart_delete_persistent_handle)); + + return completer.future; + }); + } + + Future deleteApiKey(User user, ObjectId id) { + return using((Arena arena) { + final completer = Completer(); + final native_id = id.toNative(arena); + _realmLib.invokeGetBool(() => _realmLib.realm_app_user_apikey_provider_client_delete_apikey(user.app.handle._pointer, user.handle._pointer, native_id.ref, + Pointer.fromFunction(void_completion_callback), completer.toPersistentHandle(), _realmLib.addresses.realm_dart_delete_persistent_handle)); + + return completer.future; + }); + } + + Future disableApiKey(User user, ObjectId objectId) { + return using((Arena arena) { + final completer = Completer(); + final native_id = objectId.toNative(arena); + _realmLib.invokeGetBool(() => _realmLib.realm_app_user_apikey_provider_client_disable_apikey( + user.app.handle._pointer, + user.handle._pointer, + native_id.ref, + Pointer.fromFunction(void_completion_callback), + completer.toPersistentHandle(), + _realmLib.addresses.realm_dart_delete_persistent_handle)); + + return completer.future; + }); + } + + Future enableApiKey(User user, ObjectId objectId) { + return using((Arena arena) { + final completer = Completer(); + final native_id = objectId.toNative(arena); + _realmLib.invokeGetBool(() => _realmLib.realm_app_user_apikey_provider_client_enable_apikey(user.app.handle._pointer, user.handle._pointer, native_id.ref, + Pointer.fromFunction(void_completion_callback), completer.toPersistentHandle(), _realmLib.addresses.realm_dart_delete_persistent_handle)); + + return completer.future; + }); + } } class LastError { @@ -2278,16 +2412,6 @@ extension on Object { } } -extension on List { - AuthProviderType fromIndex(int index) { - if (!AuthProviderType.values.any((value) => value.index == index)) { - throw RealmError("Unknown AuthProviderType $index"); - } - - return AuthProviderType.values[index]; - } -} - extension on List { UserState fromIndex(int index) { if (!UserState.values.any((value) => value.index == index)) { @@ -2309,6 +2433,14 @@ extension on realm_property_info { } } +extension on Completer { + void completeWithAppError(Pointer error) { + final message = error.ref.message.cast().toRealmDartString()!; + final linkToLogs = error.ref.link_to_server_logs.cast().toRealmDartString(); + completeError(AppInternal.createException(message, linkToLogs, error.ref.http_status_code)); + } +} + enum _CustomErrorCode { noError(0), unknownHttp(998), diff --git a/lib/src/realm_class.dart b/lib/src/realm_class.dart index bd22380dd..a7311edf8 100644 --- a/lib/src/realm_class.dart +++ b/lib/src/realm_class.dart @@ -60,7 +60,7 @@ export 'package:realm_common/realm_common.dart' Uuid; // always expose with `show` to explicitly control the public API surface -export 'app.dart' show AppConfiguration, MetadataPersistenceMode, App; +export 'app.dart' show AppConfiguration, MetadataPersistenceMode, App, AppException; export "configuration.dart" show Configuration, @@ -80,7 +80,7 @@ export 'realm_object.dart' show RealmEntity, RealmException, UserCallbackExcepti export 'realm_property.dart'; export 'results.dart' show RealmResults, RealmResultsChanges; export 'subscription.dart' show Subscription, SubscriptionSet, SubscriptionSetState, MutableSubscriptionSet; -export 'user.dart' show User, UserState, UserIdentity; +export 'user.dart' show User, UserState, UserIdentity, ApiKeyClient, ApiKey; export 'session.dart' show Session, SessionState, ConnectionState, ProgressDirection, ProgressMode, SyncProgress, ConnectionStateChange; /// A [Realm] instance represents a `Realm` database. diff --git a/lib/src/user.dart b/lib/src/user.dart index 2fbf2f173..b9469f5b3 100644 --- a/lib/src/user.dart +++ b/lib/src/user.dart @@ -39,6 +39,11 @@ class User { return _app ??= AppInternal.create(realmCore.userGetApp(_handle)); } + /// Gets an [ApiKeyClient] instance that exposes functionality for managing + /// user API keys. + /// [API Keys Authentication Docs](https://docs.mongodb.com/realm/authentication/api-key/) + late final ApiKeyClient apiKeys = ApiKeyClient._(this); + User._(this._handle, this._app); /// The current state of this [User]. @@ -76,6 +81,18 @@ class User { return realmCore.userGetProfileData(this); } + /// Gets the refresh token for this [User]. This is the user's credential for + /// accessing Atlas App Services and should be treated as sensitive data. + String get refreshToken { + return realmCore.userGetRefreshToken(this); + } + + /// Gets the access token for this [User]. This is the user's credential for + /// accessing Atlas App Services and should be treated as sensitive data. + String get accessToken { + return realmCore.userGetAccessToken(this); + } + /// The custom user data associated with this [User]. dynamic get customData { final data = realmCore.userGetCustomData(this); @@ -183,6 +200,73 @@ class UserProfile { const UserProfile(this._data); } +/// A class exposing functionality for users to manage API keys from the client. It is always scoped +/// to a particular [User] and can only be accessed via [User.apiKeys] +class ApiKeyClient { + final User _user; + + ApiKeyClient._(this._user); + + /// Creates a new API key with the given name. The value of the returned key + /// must be persisted as this is the only time it is available. + Future create(String name) async { + return realmCore.createApiKey(_user, name); + } + + /// Fetches a specific API key by id. + Future fetch(ObjectId id) { + return realmCore.fetchApiKey(_user, id).handle404(); + } + + /// Fetches all API keys associated with the user. + Future> fetchAll() async { + return realmCore.fetchAllApiKeys(_user); + } + + /// Deletes a specific API key by id. + Future delete(ObjectId objectId) { + return realmCore.deleteApiKey(_user, objectId).handle404(); + } + + /// Disables an API key by id. + Future disable(ObjectId objectId) { + return realmCore.disableApiKey(_user, objectId).handle404(id: objectId); + } + + /// Enables an API key by id. + Future enable(ObjectId objectId) { + return realmCore.enableApiKey(_user, objectId).handle404(id: objectId); + } +} + +/// A class representing an API key for a [User]. It can be used to represent the user when logging in +/// instead of their regular credentials. These keys are created or fetched through [User.apiKeys]. +class ApiKey { + /// The unique idenitifer for this [ApiKey]. + final ObjectId id; + + /// The name of this [ApiKey]. + final String name; + + /// The value of this [ApiKey]. This is only returned when the ApiKey is created via [ApiKeyClient.create]. + /// In all other cases, it'll be `null`. + final String? value; + + /// A value indicating whether the ApiKey is enabled. If this is false, then the ApiKey cannot be used to + /// authenticate the user. + final bool isEnabled; + + ApiKey._(this.id, this.name, this.value, this.isEnabled); + + @override + bool operator ==(Object other) { + return identical(this, other) || (other is ApiKey && other.id == id); + } + + @override + int get hashCode => id.hashCode; +} + /// @nodoc extension UserIdentityInternal on UserIdentity { static UserIdentity create(String identity, AuthProviderType provider) => UserIdentity._(identity, provider); @@ -199,4 +283,39 @@ extension UserInternal on User { UserHandle get handle => _handle; static User create(UserHandle handle, [App? app]) => User._(handle, app); + + static ApiKey createApiKey(ObjectId id, String name, String? value, bool isEnabled) => ApiKey._(id, name, value, isEnabled); +} + +extension on Future { + Future handle404({ObjectId? id}) async { + try { + await this; + } on AppException catch (e) { + if (e.statusCode == 404) { + // If we have an id, we can provide a more specific error message. Otherwise, we ignore the exception + if (id != null) { + throw AppInternal.createException("Failed to execute operation because ApiKey with Id: $id doesn't exist.", e.linkToServerLogs, 404); + } + + return; + } + + rethrow; + } + } +} + +extension on Future { + Future handle404() async { + try { + return await this; + } on AppException catch (e) { + if (e.statusCode == 404) { + return null; + } + + rethrow; + } + } } diff --git a/src/realm-core b/src/realm-core index 0a7af199a..f5cfe3de1 160000 --- a/src/realm-core +++ b/src/realm-core @@ -1 +1 @@ -Subproject commit 0a7af199aff9d1893ab5f2b3dfd809ea172d71a7 +Subproject commit f5cfe3de10e425d0f475a4691b20b160d64e3d2a diff --git a/test/app_test.dart b/test/app_test.dart index 279eb3d55..c4265dd88 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -110,6 +110,8 @@ Future main([List? args]) async { final credentials = Credentials.anonymous(); final user = await app.logIn(credentials); expect(user.state, UserState.loggedIn); + expect(user.refreshToken, isNotEmpty); + expect(user.accessToken, isNotEmpty); }); test('Application get all users', () { @@ -228,7 +230,7 @@ Future main([List? args]) async { await app.deleteUser(user); expect(user.state, UserState.removed); - expect(() async => await loginWithRetry(app, Credentials.emailPassword(username, strongPassword)), throws("invalid username/password")); + await expectLater(() => loginWithRetry(app, Credentials.emailPassword(username, strongPassword)), throws("invalid username/password")); }); } diff --git a/test/credentials_test.dart b/test/credentials_test.dart index ad610ece4..1d040407a 100644 --- a/test/credentials_test.dart +++ b/test/credentials_test.dart @@ -49,11 +49,11 @@ Future main([List? args]) async { final app = App(configuration); final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@realm.io"; - expect(() async { + await expectLater(() { // For confirmationType = 'runConfirmationFunction' as it is by default // only usernames that contain 'realm_tests_do_autoverify' are confirmed. - await authProvider.registerUser(username, strongPassword); - }, throws("failed to confirm user")); + return authProvider.registerUser(username, strongPassword); + }, throws("failed to confirm user")); }); baasTest('Email/Password - register user', (configuration) async { @@ -82,29 +82,21 @@ Future main([List? args]) async { final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@realm.io"; await authProvider.registerUser(username, strongPassword); - expect(() async { - await authProvider.registerUser(username, strongPassword); - }, throws("name already in use")); + await expectLater(() => authProvider.registerUser(username, strongPassword), throws("name already in use")); }, appName: AppNames.autoConfirm); baasTest('Email/Password - register user with weak/empty password throws', (configuration) async { final app = App(configuration); final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@realm.io"; - expect(() async { - await authProvider.registerUser(username, "pwd"); - }, throws("password must be between 6 and 128 characters")); - expect(() async { - await authProvider.registerUser(username, ""); - }, throws("password must be between 6 and 128 characters")); + await expectLater(() => authProvider.registerUser(username, "pwd"), throws("password must be between 6 and 128 characters")); + await expectLater(() => authProvider.registerUser(username, ""), throws("password must be between 6 and 128 characters")); }, appName: AppNames.autoConfirm); baasTest('Email/Password - register user with empty email throws', (configuration) async { final app = App(configuration); final authProvider = EmailPasswordAuthProvider(app); - expect(() async { - await authProvider.registerUser("", "password"); - }, throws("email invalid")); + await expectLater(() => authProvider.registerUser("", "password"), throws("email invalid")); }, appName: AppNames.autoConfirm); baasTest('Email/Password - confirm user token expired', (configuration) async { @@ -112,11 +104,11 @@ Future main([List? args]) async { final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@hotmail.com"; await authProvider.registerUser(username, strongPassword); - expect(() async { - await authProvider.confirmUser( - "0e6340a446e68fe02a1af1b53c34d5f630b601ebf807d73d10a7fed5c2e996d87d04a683030377ac6058824d8555b24c1417de79019b40f1299aada7ef37fddc", - "6268f7dd73fafea76b730fc9"); - }, throws("userpass token is expired or invalid")); + await expectLater( + () => authProvider.confirmUser( + "0e6340a446e68fe02a1af1b53c34d5f630b601ebf807d73d10a7fed5c2e996d87d04a683030377ac6058824d8555b24c1417de79019b40f1299aada7ef37fddc", + "6268f7dd73fafea76b730fc9"), + throws("userpass token is expired or invalid")); }, appName: AppNames.emailConfirm); baasTest('Email/Password - confirm user token invalid', (configuration) async { @@ -124,9 +116,7 @@ Future main([List? args]) async { final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@hotmail.com"; await authProvider.registerUser(username, strongPassword); - expect(() async { - await authProvider.confirmUser("abc", "123"); - }, throws("invalid token data")); + await expectLater(() => authProvider.confirmUser("abc", "123"), throws("invalid token data")); }, appName: AppNames.emailConfirm); // The tests in this group are for manual testing, since they require interaction with mail box. @@ -177,18 +167,14 @@ Future main([List? args]) async { // Custom confirmation function confirms automatically username with 'realm_tests_do_autoverify'. await authProvider.registerUser(username, strongPassword); - expect(() async { - await authProvider.retryCustomConfirmationFunction(username); - }, throws("already confirmed")); + await expectLater(() => authProvider.retryCustomConfirmationFunction(username), throws("already confirmed")); }); baasTest('Email/Password - retry custom confirmation for not registered user', (configuration) async { final app = App(configuration); final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@realm.io"; - expect(() async { - await authProvider.retryCustomConfirmationFunction(username); - }, throws("user not found")); + await expectLater(() => authProvider.retryCustomConfirmationFunction(username), throws("user not found")); }); // The tests in this group are for manual testing, since they require interaction with mail box. @@ -226,9 +212,7 @@ Future main([List? args]) async { final app = App(configuration); final authProvider = EmailPasswordAuthProvider(app); String username = "${generateRandomString(5)}@realm.io"; - expect(() async { - await authProvider.resetPassword(username); - }, throws("user not found")); + await expectLater(() => authProvider.resetPassword(username), throws("user not found")); }, appName: AppNames.emailConfirm); // The tests in this group are for manual testing, since they require interaction with mail box. @@ -291,9 +275,7 @@ Future main([List? args]) async { await authProvider.registerUser(username, strongPassword); await authProvider.callResetPasswordFunction(username, newPassword, functionArgs: ['success']); await app.logIn(Credentials.emailPassword(username, newPassword)); - expect(() async { - await app.logIn(Credentials.emailPassword(username, strongPassword)); - }, throws("invalid username/password")); + await expectLater(() => app.logIn(Credentials.emailPassword(username, strongPassword)), throws("invalid username/password")); }, appName: AppNames.autoConfirm); baasTest('Email/Password - call reset password function with no additional arguments', (configuration) async { @@ -302,12 +284,12 @@ Future main([List? args]) async { const String newPassword = "!@#!DQXQWD!223eda"; final authProvider = EmailPasswordAuthProvider(app); await authProvider.registerUser(username, strongPassword); - expect(() async { + await expectLater(() { // Calling this function with no additional arguments fails for the test // because of the specific implementation of resetFunc in the cloud. // resetFunc returns status 'fail' in case no other status is passed. - await authProvider.callResetPasswordFunction(username, newPassword); - }, throws("failed to reset password for user $username")); + return authProvider.callResetPasswordFunction(username, newPassword); + }, throws("failed to reset password for user $username")); }, appName: AppNames.autoConfirm); /// JWT Payload data @@ -377,7 +359,7 @@ Future main([List? args]) async { // Always register jwt_user@#r@D@realm.io as a new user. try { await authProvider.registerUser(username, strongPassword); - } on RealmException catch (e) { + } on AppException catch (e) { { if (e.message.contains("name already in use")) { // If the user exists, delete it and register a new one with the same name and empty profile @@ -444,9 +426,7 @@ Future main([List? args]) async { var token = "eyJraWQiOiIxIiwiYWxnIjoiUlMyNTYiLCJ0eXAiOiJKV1QifQ.eyJzdWIiOiI2MmYzOTY4ODhhZjg3MjBiMzczZmYwNmEiLCJlbWFpbCI6Indvbmdfc2lnbml0dXJlX2tleUByZWFsbS5pbyIsImlhdCI6MTY2MDE0MjIxNSwiZXhwIjo0ODEzNzQyMjE1LCJhdWQiOiJtb25nb2RiLmNvbSIsImlzcyI6Imh0dHBzOi8vcmVhbG0uaW8ifQ.Af--ZUCL_KC7lAhrD_d1lq91O7qVwu7GqXifwxKojkLCkbjmAER9K2Xa7BPO8xNstFeX8m9uBo4BCD5B6XmngSmyCj5OZWdiG5LTR_uhA3MnpqcV3Vu40K4Yx8XrjPuCL39xVPnEfPKLGz5TjEcMLa8xMPqo51byX0q3mR2eSS4w1A7c5TiTNuQ23_SCO8aK95SyXwuUmU4mH0iR4sHPtf64WyoAXkx8w5twXExzky1_h473CwtAERdMsBhwz1YzFKP0kxU31pg5SRciF5Ly66sK1fSPTMQPuVdS_wKvAYll8_trWnWS83M3_PWs4UxzOdjSpoK0uqhN-_IC38YOGg"; final credentials = Credentials.jwt(token); - expect(() async { - await app.logIn(credentials); - }, throws("crypto/rsa: verification error")); + await expectLater(() => app.logIn(credentials), throws("crypto/rsa: verification error")); }); ///See test/README.md section 'Manually configure Facebook, Google and Apple authentication providers'" @@ -465,7 +445,7 @@ Future main([List? args]) async { final app = App(configuration); final accessToken = 'invalid or expired token'; final credentials = Credentials.facebook(accessToken); - expect(() async => await app.logIn(credentials), throws("error fetching info from OAuth2 provider")); + await expectLater(() => app.logIn(credentials), throws("error fetching info from OAuth2 provider")); }); baasTest('Function credentials - wrong payload', (configuration) { diff --git a/test/test.dart b/test/test.dart index 53bba9d2d..1a8900f15 100644 --- a/test/test.dart +++ b/test/test.dart @@ -426,6 +426,7 @@ extension on Map { } } +BaasClient? _baasClient; Future setupBaas() async { final baasUrl = arguments[argBaasUrl]; if (baasUrl == null) { @@ -446,6 +447,7 @@ Future setupBaas() async { var apps = await client.getOrCreateApps(); baasApps.addAll(apps); + _baasClient = client; } @isTest @@ -494,6 +496,12 @@ Future getIntegrationUser(App app) async { return await loginWithRetry(app, Credentials.emailPassword(email, password)); } +Future createServerApiKey(App app, String name, {bool enabled = true}) async { + final baasApp = baasApps.values.firstWhere((ba) => ba.clientAppId == app.id); + final client = _baasClient ?? (throw StateError("No BAAS client")); + return await client.createApiKey(baasApp, name, enabled); +} + Future getIntegrationRealm({App? app, ObjectId? differentiator}) async { app ??= App(await getAppConfig()); final user = await getIntegrationUser(app); diff --git a/test/user_test.dart b/test/user_test.dart index f5ecf4135..41c948551 100644 --- a/test/user_test.dart +++ b/test/user_test.dart @@ -116,4 +116,319 @@ Future main([List? args]) async { final user = await app.logIn(Credentials.emailPassword(testUsername, testPassword)); expect(user.profile.email, testUsername); }); + + baasTest('User.apiKeys.create creates and reveals value', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + final apiKey = await user.apiKeys.create('my-api-key'); + + expect(apiKey.isEnabled, true); + expect(apiKey.name, 'my-api-key'); + expect(apiKey.value, isNotNull); + expect(apiKey.id, isNot(ObjectId.fromValues(0, 0, 0))); + }); + + baasTest('User.apiKeys.create with invalid name returns error', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + await expectLater( + () => user.apiKeys.create('Spaces are not allowed'), + throwsA(isA() + .having((e) => e.message, 'message', contains('can only contain ASCII letters, numbers, underscores, and hyphens')) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')) + .having((e) => e.statusCode, 'statusCode', 400))); + }); + + baasTest('User.apiKeys.create with duplicate name returns error', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + await user.apiKeys.create('my-api-key'); + await expectLater( + () => user.apiKeys.create('my-api-key'), + throwsA(isA() + .having((e) => e.message, 'message', contains('API key with name already exists')) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')) + .having((e) => e.statusCode, 'statusCode', 409))); + }); + + baasTest('User.apiKeys.fetch with non existent returns null', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.fetch(ObjectId()); + expect(key, isNull); + }); + + void expectApiKey(ApiKey? fetched, ApiKey expected) { + expect(fetched, isNotNull); + expect(fetched!.id, expected.id); + expect(fetched.isEnabled, expected.isEnabled); + expect(fetched.name, expected.name); + expect(fetched.value, isNull); + } + + baasTest('User.apiKeys.fetch with existent returns result', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + final apiKey = await user.apiKeys.create('my-api-key'); + + final refetched = await user.apiKeys.fetch(apiKey.id); + + expectApiKey(refetched, apiKey); + }); + + baasTest('User.apiKeys.fetchAll with no keys returns empty', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + final apiKeys = await user.apiKeys.fetchAll(); + + expect(apiKeys.length, 0); + }); + + baasTest('User.apiKeys.fetchAll with one key returns it', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final original = await user.apiKeys.create('my-api-key'); + + final apiKeys = await user.apiKeys.fetchAll(); + + expect(apiKeys.length, 1); + expect(apiKeys.single, original); + }); + + baasTest('User.apiKeys.fetchAll with multiple keys returns all', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final original = []; + for (var i = 0; i < 5; i++) { + original.add(await user.apiKeys.create('my-api-key-$i')); + } + + final fetched = await user.apiKeys.fetchAll(); + + for (var i = 0; i < 5; i++) { + expectApiKey(fetched[i], original[i]); + } + }); + + baasTest('User.apiKeys.delete with non-existent key', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.create('key'); + + await user.apiKeys.delete(ObjectId()); + + final allKeys = await user.apiKeys.fetchAll(); + expect(allKeys.length, 1); + expectApiKey(allKeys.single, key); + }); + + baasTest('User.apiKeys.delete with existent key', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final toDelete = await user.apiKeys.create('to-delete'); + final toRemain = await user.apiKeys.create('to-remain'); + + await user.apiKeys.delete(toDelete.id); + + final fetched = await user.apiKeys.fetch(toDelete.id); + expect(fetched, isNull); + + final allKeys = await user.apiKeys.fetchAll(); + expect(allKeys.length, 1); + expectApiKey(allKeys.single, toRemain); + }); + + baasTest('User.apiKeys.disable with non-existent throws', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + await expectLater( + () => user.apiKeys.disable(ObjectId()), + throwsA(isA() + .having((e) => e.message, 'message', contains("doesn't exist")) + .having((e) => e.statusCode, 'statusCode', 404) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')))); + }); + + baasTest('User.apiKeys.enable with non-existent throws', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + await expectLater( + () => user.apiKeys.enable(ObjectId()), + throwsA(isA() + .having((e) => e.message, 'message', contains("doesn't exist")) + .having((e) => e.statusCode, 'statusCode', 404) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')))); + }); + + baasTest('User.apiKeys.enable when enabled is a no-op', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.create('my-key'); + + expect(key.isEnabled, true); + + await user.apiKeys.enable(key.id); + + final fetched = await user.apiKeys.fetch(key.id); + + expect(fetched!.isEnabled, true); + }); + + baasTest('User.apiKeys.disable when disabled is a no-op', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.create('my-key'); + + expect(key.isEnabled, true); + + await user.apiKeys.disable(key.id); + + final fetched = await user.apiKeys.fetch(key.id); + expect(fetched!.isEnabled, false); + + await user.apiKeys.disable(key.id); + + final refetched = await user.apiKeys.fetch(key.id); + expect(refetched!.isEnabled, false); + }); + + baasTest('User.apiKeys.disable disables key', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final first = await user.apiKeys.create('first'); + final second = await user.apiKeys.create('second'); + + expect(first.isEnabled, true); + expect(second.isEnabled, true); + + await user.apiKeys.disable(first.id); + + final fetched = await user.apiKeys.fetchAll(); + expect(fetched[0].id, first.id); + expect(fetched[0].isEnabled, false); + + expect(fetched[1].id, second.id); + expect(fetched[1].isEnabled, true); + }); + + baasTest('User.apiKeys.enable reenables key', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final first = await user.apiKeys.create('first'); + final second = await user.apiKeys.create('second'); + + expect(first.isEnabled, true); + expect(second.isEnabled, true); + + await user.apiKeys.disable(first.id); + + final fetched = await user.apiKeys.fetchAll(); + expect(fetched[0].id, first.id); + expect(fetched[0].isEnabled, false); + + expect(fetched[1].id, second.id); + expect(fetched[1].isEnabled, true); + + await user.apiKeys.enable(first.id); + + final refetched = await user.apiKeys.fetchAll(); + expect(refetched[0].id, first.id); + expect(refetched[0].isEnabled, true); + + expect(refetched[1].id, second.id); + expect(refetched[1].isEnabled, true); + }); + + baasTest('User.apiKeys can login with generated key', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.create('my-key'); + final credentials = Credentials.apiKey(key.value!); + + final apiKeyUser = await app.logIn(credentials); + expect(apiKeyUser.provider, AuthProviderType.apiKey); + expect(apiKeyUser.id, user.id); + expect(apiKeyUser.refreshToken, isNot(user.refreshToken)); + }); + + baasTest('User.apiKeys can login with reenabled key', (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.create('my-key'); + + await user.apiKeys.disable(key.id); + + final credentials = Credentials.apiKey(key.value!); + + await expectLater( + () => app.logIn(credentials), + throwsA(isA() + .having((e) => e.message, 'message', contains('invalid API key')) + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')))); + + await user.apiKeys.enable(key.id); + + final apiKeyUser = await app.logIn(credentials); + expect(apiKeyUser.provider, AuthProviderType.apiKey); + expect(apiKeyUser.id, user.id); + expect(apiKeyUser.refreshToken, isNot(user.refreshToken)); + }); + + baasTest("User.apiKeys can't login with deleted key", (configuration) async { + final app = App(configuration); + final user = await getIntegrationUser(app); + + final key = await user.apiKeys.create('my-key'); + + await user.apiKeys.delete(key.id); + + final credentials = Credentials.apiKey(key.value!); + + await expectLater( + () => app.logIn(credentials), + throwsA(isA() + .having((e) => e.message, 'message', contains('invalid API key')) + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')))); + }); + + baasTest("Credentials.apiKey with server-generated can login user", (configuration) async { + final app = App(configuration); + + final apiKey = await createServerApiKey(app, ObjectId().toString()); + final credentials = Credentials.apiKey(apiKey); + + final apiKeyUser = await app.logIn(credentials); + + expect(apiKeyUser.provider, AuthProviderType.apiKey); + expect(apiKeyUser.state, UserState.loggedIn); + }); + + baasTest("Credentials.apiKey with disabled server api key throws an error", (configuration) async { + final app = App(configuration); + + final apiKey = await createServerApiKey(app, ObjectId().toString(), enabled: false); + final credentials = Credentials.apiKey(apiKey); + + await expectLater( + () async => await app.logIn(credentials), + throwsA(isA() + .having((e) => e.message, 'message', 'invalid API key') + .having((e) => e.statusCode, 'statusCode', 401) + .having((e) => e.linkToServerLogs, 'linkToServerLogs', contains('logs?co_id=')))); + }); }