diff --git a/android/core/src/main/java/com/mongodb/stitch/android/core/auth/StitchAuthListener.java b/android/core/src/main/java/com/mongodb/stitch/android/core/auth/StitchAuthListener.java index 4a4253a8f..2616ce4e6 100644 --- a/android/core/src/main/java/com/mongodb/stitch/android/core/auth/StitchAuthListener.java +++ b/android/core/src/main/java/com/mongodb/stitch/android/core/auth/StitchAuthListener.java @@ -16,6 +16,8 @@ package com.mongodb.stitch.android.core.auth; +import javax.annotation.Nullable; + /** * StitchAuthListener allows one to hook into authentication events as they happen in a Stitch app * client. @@ -29,10 +31,113 @@ public interface StitchAuthListener { * - When a user logs out. * - When a user is linked to another identity. * - When a listener is registered. This is to handle the case where during registration - * an event happens that the registerer would otherwise miss out on. + * an event happens that the registerer would otherwise miss out on. * - When switching users. + * * @param auth the instance of {@link StitchAuth} where the event happened. It should be used to - * infer the current state of authentication. + * infer the current state of authentication. + */ + @Deprecated + default void onAuthEvent(final StitchAuth auth) { + + } + + /** + * Called whenever a user is added to the device for the first time. If this + * is as part of a login, this method will be called before + * {@link #onUserLoggedIn}, and {@link #onActiveUserChanged} + * are called. + * + * @param auth The instance of {@link StitchAuth} where the user was added. + * It can be used to infer the current state of authentication. + * @param addedUser The user that was added to the device. + */ + default void onUserAdded(final StitchAuth auth, final StitchUser addedUser) { + } + + /** + * Called whenever a user is logged in. This will be called before + * {@link #onActiveUserChanged} is called. + * Note: if an anonymous user was already logged in on the device, and you + * log in with an {@link com.mongodb.stitch.core.auth.providers.anonymous.AnonymousCredential}, + * this method will not be called, + * as the underlying {@link StitchAuth} will reuse the anonymous user's existing + * session, and will thus only trigger {@link #onActiveUserChanged}. + * + * @param auth The instance of {@link StitchAuth} where the user was logged in. + * It can be used to infer the current state of authentication. + * @param loggedInUser The user that was logged in. + */ + default void onUserLoggedIn(final StitchAuth auth, + final StitchUser loggedInUser) { + } + + /** + * Called whenever a user is linked to a new identity. + * + * @param auth The instance of {@link StitchAuth} where the user was linked. + * It can be used to infer the current state of authentication. + * @param linkedUser The user that was linked to a new identity. + */ + default void onUserLinked(final StitchAuth auth, final StitchUser linkedUser) { + + } + + /** + * Called whenever a user is logged out. The user logged out is not + * necessarily the active user. If the user logged out was the active user, + * then {@link #onActiveUserChanged} will be called after this method. If the user + * was an anonymous user, that user will also be removed and + * {@link #onUserRemoved} will also be called. + * + * @param auth The instance of {@link StitchAuth} where the user was logged out. + * It can be used to infer the current state of authentication. + * @param loggedOutUser The user that was logged out. + */ + default void onUserLoggedOut(final StitchAuth auth, final StitchUser loggedOutUser) { + + } + + /** + * Called whenever the active user changes. This may be due to a call to + * {@link StitchAuth#loginWithCredential}, {@link StitchAuth#switchToUserWithId}, + * {@link StitchAuth#logout}, {@link StitchAuth#logoutUserWithId}, + * {@link StitchAuth#removeUser}, or {@link StitchAuth#removeUserWithId}. + * This may also occur on a normal request if a user's session is invalidated + * and they are forced to log out. + * + * @param auth The instance of {@link StitchAuth} where the active user changed. + * It can be used to infer the current state of authentication. + * @param currentActiveUser The active user after the change. + * @param previousActiveUser The active user before the change. + */ + default void onActiveUserChanged(final StitchAuth auth, + @Nullable final StitchUser currentActiveUser, + @Nullable final StitchUser previousActiveUser) { + + } + + /** + * Called whenever a user is removed from the list of users on the device. + * + * @param auth The instance of {@link StitchAuth} where the user was removed. + * It can be used to infer the current state of authentication. + * @param removedUser The user that was removed. */ - void onAuthEvent(final StitchAuth auth); + default void onUserRemoved(final StitchAuth auth, final StitchUser removedUser) { + + } + + /** + * Called whenever this listener is registered for the first time. This can + * be useful to infer the state of authentication, because any events that + * occurred before the listener was registered will not be seen by the + * listener. + * + * @param auth The instance of {@link StitchAuth} where the listener was registered. + * It can be used to infer the current state of authentication. + */ + default void onListenerRegistered(final StitchAuth auth) { + + } } diff --git a/android/core/src/main/java/com/mongodb/stitch/android/core/auth/internal/StitchAuthImpl.java b/android/core/src/main/java/com/mongodb/stitch/android/core/auth/internal/StitchAuthImpl.java index ccaacd380..c1cce95a7 100644 --- a/android/core/src/main/java/com/mongodb/stitch/android/core/auth/internal/StitchAuthImpl.java +++ b/android/core/src/main/java/com/mongodb/stitch/android/core/auth/internal/StitchAuthImpl.java @@ -37,6 +37,7 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.Callable; +import javax.annotation.Nullable; import org.bson.Document; /** @@ -195,6 +196,14 @@ public void addAuthListener(final StitchAuthListener listener) { // Trigger the onUserLoggedIn event in case some event happens and // this caller would miss out on this event other wise. onAuthEvent(listener); + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onListenerRegistered(StitchAuthImpl.this); + return null; + } + }); } public void addSynchronousAuthListener(final StitchAuthListener listener) { @@ -205,6 +214,7 @@ public void addSynchronousAuthListener(final StitchAuthListener listener) { // Trigger the onUserLoggedIn event in case some event happens and // this caller would miss out on this event other wise. onAuthEvent(listener); + listener.onListenerRegistered(this); } /** @@ -231,10 +241,141 @@ public Void call() { @Override protected void onAuthEvent() { for (final StitchAuthListener listener : listeners) { - onAuthEvent(listener); + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onAuthEvent(StitchAuthImpl.this); + return null; + } + }); } for (final StitchAuthListener listener : synchronousListeners) { listener.onAuthEvent(this); } } + + @Override + protected void onListenerInitialized() { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onListenerRegistered(StitchAuthImpl.this); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onListenerRegistered(this); + } + } + + @Override + protected void onActiveUserChanged(@Nullable final StitchUser currentActiveUser, + @Nullable final StitchUser previousActiveUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onActiveUserChanged( + StitchAuthImpl.this, currentActiveUser, previousActiveUser); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onActiveUserChanged( + this, currentActiveUser, previousActiveUser); + } + } + + @Override + protected void onUserAdded(final StitchUser createdUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onUserAdded( + StitchAuthImpl.this, createdUser); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onUserAdded(this, createdUser); + } + } + + @Override + protected void onUserLoggedIn(final StitchUser loggedInUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onUserLoggedIn( + StitchAuthImpl.this, loggedInUser); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onUserLoggedIn(this, loggedInUser); + } + } + + @Override + protected void onUserRemoved(final StitchUser removedUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onUserRemoved(StitchAuthImpl.this, removedUser); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onUserRemoved(this, removedUser); + } + } + + @Override + protected void onUserLoggedOut(final StitchUser loggedOutUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onUserLoggedOut(StitchAuthImpl.this, loggedOutUser); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onUserLoggedOut(this, loggedOutUser); + } + } + + @Override + protected void onUserLinked(final StitchUser linkedUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatchTask( + new Callable() { + @Override + public Void call() { + listener.onUserLinked(StitchAuthImpl.this, linkedUser); + return null; + } + }); + } + for (final StitchAuthListener listener : synchronousListeners) { + listener.onUserLinked(this, linkedUser); + } + } } diff --git a/android/core/src/main/java/com/mongodb/stitch/android/core/internal/StitchAppClientImpl.java b/android/core/src/main/java/com/mongodb/stitch/android/core/internal/StitchAppClientImpl.java index ddbaf114d..cdf4265e0 100644 --- a/android/core/src/main/java/com/mongodb/stitch/android/core/internal/StitchAppClientImpl.java +++ b/android/core/src/main/java/com/mongodb/stitch/android/core/internal/StitchAppClientImpl.java @@ -20,6 +20,7 @@ import com.mongodb.stitch.android.core.StitchAppClient; import com.mongodb.stitch.android.core.auth.StitchAuth; import com.mongodb.stitch.android.core.auth.StitchAuthListener; +import com.mongodb.stitch.android.core.auth.StitchUser; import com.mongodb.stitch.android.core.auth.internal.StitchAuthImpl; import com.mongodb.stitch.android.core.internal.common.MainLooperDispatcher; import com.mongodb.stitch.android.core.internal.common.TaskDispatcher; @@ -31,12 +32,15 @@ import com.mongodb.stitch.android.core.services.internal.StitchServiceClientImpl; import com.mongodb.stitch.core.StitchAppClientConfiguration; import com.mongodb.stitch.core.StitchAppClientInfo; +import com.mongodb.stitch.core.auth.internal.CoreStitchAuth; import com.mongodb.stitch.core.internal.CoreStitchAppClient; import com.mongodb.stitch.core.internal.common.AuthMonitor; import com.mongodb.stitch.core.internal.net.StitchAppRequestClientImpl; import com.mongodb.stitch.core.internal.net.StitchAppRoutes; +import com.mongodb.stitch.core.services.internal.AuthEvent; import com.mongodb.stitch.core.services.internal.CoreStitchServiceClient; import com.mongodb.stitch.core.services.internal.CoreStitchServiceClientImpl; +import com.mongodb.stitch.core.services.internal.RebindEvent; import java.io.IOException; import java.lang.ref.WeakReference; @@ -271,8 +275,17 @@ public ResultT call() { } @Override - public boolean isLoggedIn() { - return getAuth().isLoggedIn(); + public boolean isLoggedIn() throws InterruptedException { + return ((CoreStitchAuth)getAuth()).isLoggedInInterruptibly(); + } + + @Override + public boolean tryIsLoggedIn() { + try { + return ((CoreStitchAuth)getAuth()).isLoggedInInterruptibly(); + } catch (InterruptedException e) { + return false; + } } @Nullable @@ -293,8 +306,7 @@ private void bindServiceClient(final CoreStitchServiceClient coreStitchServiceCl this.serviceClients.add(new WeakReference<>(coreStitchServiceClient)); } - @Override - public void onAuthEvent(final StitchAuth auth) { + private void onRebindEvent(final RebindEvent rebindEvent) { final Iterator> iterator = this.serviceClients.iterator(); while (iterator.hasNext()) { @@ -305,11 +317,39 @@ public void onAuthEvent(final StitchAuth auth) { if (binder == null) { this.serviceClients.remove(weakReference); } else { - binder.onRebindEvent(); + binder.onRebindEvent(rebindEvent); } } } + @Override + public void onAuthEvent(final StitchAuth auth) { + } + + @Override + public void onUserLoggedIn(final StitchAuth auth, + final StitchUser loggedInUser) { + onRebindEvent(new AuthEvent.UserLoggedIn<>(loggedInUser)); + } + + @Override + public void onUserLoggedOut(final StitchAuth auth, + final StitchUser loggedOutUser) { + onRebindEvent(new AuthEvent.UserLoggedOut<>(loggedOutUser)); + } + + @Override + public void onActiveUserChanged(final StitchAuth auth, + final StitchUser currentActiveUser, + final @Nullable StitchUser previousActiveUser) { + onRebindEvent(new AuthEvent.ActiveUserChanged<>(currentActiveUser, previousActiveUser)); + } + + @Override + public void onUserRemoved(final StitchAuth auth, final StitchUser removedUser) { + onRebindEvent(new AuthEvent.UserRemoved<>(removedUser)); + } + /** * Closes the client and shuts down all background operations. */ diff --git a/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAppClientIntTests.kt b/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAppClientIntTests.kt index 28629b881..1f9e767a4 100644 --- a/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAppClientIntTests.kt +++ b/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAppClientIntTests.kt @@ -44,24 +44,24 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { val client = getAppClient(app.first) val jwt = Jwts.builder() - .setHeader( - mapOf( - "alg" to "HS256", - "typ" to "JWT" - )) - .claim("stitch_meta", - mapOf( - "email" to "name@example.com", - "name" to "Joe Bloggs", - "picture" to "https://goo.gl/xqR6Jd" - )) - .setIssuedAt(Date()) - .setNotBefore(Date()) - .setAudience(app.first.clientAppId) - .setSubject("uniqueUserID") - .setExpiration(Date(((Calendar.getInstance().timeInMillis + (5 * 60 * 1000))))) - .signWith(SignatureAlgorithm.HS256, signingKey.toByteArray()) - .compact() + .setHeader( + mapOf( + "alg" to "HS256", + "typ" to "JWT" + )) + .claim("stitch_meta", + mapOf( + "email" to "name@example.com", + "name" to "Joe Bloggs", + "picture" to "https://goo.gl/xqR6Jd" + )) + .setIssuedAt(Date()) + .setNotBefore(Date()) + .setAudience(app.first.clientAppId) + .setSubject("uniqueUserID") + .setExpiration(Date(((Calendar.getInstance().timeInMillis + (5 * 60 * 1000))))) + .signWith(SignatureAlgorithm.HS256, signingKey.toByteArray()) + .compact() val user = Tasks.await(client.auth.loginWithCredential(CustomCredential(jwt))) assertNotNull(user) @@ -79,10 +79,10 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { val app = createApp() addProvider(app.second, ProviderConfigs.Anon) addProvider(app.second, config = ProviderConfigs.Userpass( - emailConfirmationUrl = "http://emailConfirmURL.com", - resetPasswordUrl = "http://resetPasswordURL.com", - confirmEmailSubject = "email subject", - resetPasswordSubject = "password subject") + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") ) var client = getAppClient(app.first) @@ -92,9 +92,9 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { // login anonymously val anonUser = - Tasks.await(client.auth.loginWithCredential( - AnonymousCredential() - )) + Tasks.await(client.auth.loginWithCredential( + AnonymousCredential() + )) assertNotNull(anonUser) // check storage @@ -102,10 +102,8 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { assertEquals(anonUser.loggedInProviderType, AnonymousAuthProvider.TYPE) // login anonymously again and make sure user ID is the same - assertEquals(anonUser.id, - Tasks.await(client.auth.loginWithCredential( - AnonymousCredential() - )).id) + assertEquals( + anonUser.id, Tasks.await(client.auth.loginWithCredential(AnonymousCredential())).id) // check storage assertTrue(client.auth.isLoggedIn) @@ -133,9 +131,8 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { assertNull(client.auth.user) // log back into the last user - Tasks.await(client.auth.loginWithCredential( - UserPasswordCredential("test2@10gen.com", "hunter2") - )) + Tasks.await( + client.auth.loginWithCredential(UserPasswordCredential("test2@10gen.com", "hunter2"))) assertTrue(client.auth.isLoggedIn) assertEquals(client.auth.user!!.loggedInProviderType, UserPasswordAuthProvider.TYPE) @@ -225,10 +222,10 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { val app = createApp() addProvider(app.second, ProviderConfigs.Anon) addProvider(app.second, config = ProviderConfigs.Userpass( - emailConfirmationUrl = "http://emailConfirmURL.com", - resetPasswordUrl = "http://resetPasswordURL.com", - confirmEmailSubject = "email subject", - resetPasswordSubject = "password subject") + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") ) val client = getAppClient(app.first) @@ -242,7 +239,7 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { Tasks.await(userPassClient.confirmUser(conf.token, conf.tokenId)) val anonUser = Tasks.await(client.auth.loginWithCredential( - AnonymousCredential() + AnonymousCredential() )) assertNotNull(anonUser) assertEquals(anonUser.loggedInProviderType, AnonymousAuthProvider.TYPE) @@ -270,18 +267,18 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { val client = getAppClient(app.first) app.second.functions.create(FunctionCreator( - "testFunction", - "exports = function(intArg, stringArg) { " + - "return { intValue: intArg, stringValue: stringArg} " + - "}", - null, - false) + "testFunction", + "exports = function(intArg, stringArg) { " + + "return { intValue: intArg, stringValue: stringArg} " + + "}", + null, + false) ) Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) val resultDoc = Tasks.await(client.callFunction( - "testFunction", Arrays.asList(42, "hello"), Document::class.java + "testFunction", Arrays.asList(42, "hello"), Document::class.java )) assertTrue(resultDoc.containsKey("intValue")) @@ -292,16 +289,16 @@ class StitchAppClientIntTests : BaseStitchAndroidIntTest() { // Ensure that a function call with 1ms timeout fails try { Tasks.await(client.callFunction( - "testFunction", - Arrays.asList(42, "hello"), - 1L, - Document::class.java + "testFunction", + Arrays.asList(42, "hello"), + 1L, + Document::class.java )) } catch (ex: ExecutionException) { assertTrue(ex.cause is StitchRequestException) assertEquals( - (ex.cause as StitchRequestException).errorCode, - StitchRequestErrorCode.TRANSPORT_ERROR + (ex.cause as StitchRequestException).errorCode, + StitchRequestErrorCode.TRANSPORT_ERROR ) } } diff --git a/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAuthListenerIntTests.kt b/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAuthListenerIntTests.kt new file mode 100644 index 000000000..1458f2cac --- /dev/null +++ b/android/coretest/src/androidTest/java/com/mongodb/stitch/android/core/StitchAuthListenerIntTests.kt @@ -0,0 +1,286 @@ +package com.mongodb.stitch.android.core + +import android.support.test.runner.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.mongodb.stitch.android.core.auth.StitchAuth +import com.mongodb.stitch.android.core.auth.StitchAuthListener +import com.mongodb.stitch.android.core.auth.StitchUser +import com.mongodb.stitch.android.core.auth.providers.userpassword.UserPasswordAuthProviderClient +import com.mongodb.stitch.android.testutils.BaseStitchAndroidIntTest +import com.mongodb.stitch.core.admin.authProviders.ProviderConfigs +import com.mongodb.stitch.core.admin.userRegistrations.sendConfirmation +import com.mongodb.stitch.core.auth.providers.anonymous.AnonymousCredential +import com.mongodb.stitch.core.auth.providers.userpassword.UserPasswordCredential +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class StitchAuthListenerIntTests : BaseStitchAndroidIntTest() { + @Test + fun testOnUserLoggedInDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserLoggedIn(auth: StitchAuth?, loggedInUser: StitchUser?) { + assertNotNull(auth) + assertNotNull(loggedInUser) + countDownLatch.countDown() + } + }) + + assertFalse(client.auth.isLoggedIn) + assertNull(client.auth.user) + + Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnAddedUserDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserAdded(auth: StitchAuth?, addedUser: StitchUser?) { + assertNotNull(auth) + assertNotNull(addedUser) + countDownLatch.countDown() + } + }) + + assertFalse(client.auth.isLoggedIn) + assertNull(client.auth.user) + + Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnActiveUserChangedDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onActiveUserChanged( + auth: StitchAuth?, + currentActiveUser: StitchUser?, + previousActiveUser: StitchUser? + ) { + assertNotNull(auth) + assertNotNull(currentActiveUser) + assertNotNull(previousActiveUser) + countDownLatch.countDown() + } + }) + + assertFalse(client.auth.isLoggedIn) + assertNull(client.auth.user) + + Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) + registerAndLoginWithUserPass(app.second, client, "email@10gen.com", "tester10") + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnUserLoggedOutDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserLoggedOut( + auth: StitchAuth?, + loggedOutUser: StitchUser? + ) { + assertNotNull(auth) + assertNotNull(loggedOutUser) + countDownLatch.countDown() + } + }) + + assertFalse(client.auth.isLoggedIn) + assertNull(client.auth.user) + + Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) + registerAndLoginWithUserPass(app.second, client, "email@10gen.com", "tester10") + Tasks.await(client.auth.logout()) + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnUserRemovedDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserRemoved(auth: StitchAuth?, removedUser: StitchUser?) { + assertNotNull(auth) + assertNotNull(removedUser) + countDownLatch.countDown() + } + }) + + assertFalse(client.auth.isLoggedIn) + assertNull(client.auth.user) + + Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) + + Tasks.await(client.auth.removeUser()) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnUserLinkedDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserLinked(auth: StitchAuth?, linkedUser: StitchUser?) { + assertNotNull(auth) + assertNotNull(linkedUser) + countDownLatch.countDown() + } + }) + + assertFalse(client.auth.isLoggedIn) + assertNull(client.auth.user) + + val userPassClient = client.auth.getProviderClient(UserPasswordAuthProviderClient.factory) + + val email = "user@10gen.com" + val password = "password" + Tasks.await(userPassClient.registerWithEmail(email, password)) + + val conf = app.second.userRegistrations.sendConfirmation(email) + Tasks.await(userPassClient.confirmUser(conf.token, conf.tokenId)) + + val anonUser = Tasks.await(client.auth.loginWithCredential(AnonymousCredential())) + + Tasks.await(anonUser.linkWithCredential( + UserPasswordCredential(email, password))) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnListenerRegisteredDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onListenerRegistered(auth: StitchAuth?) { + assertNotNull(auth) + countDownLatch.countDown() + } + }) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } +} diff --git a/android/services/mongodb-remote/src/androidTest/java/com/mongodb/stitch/android/services/mongodb/remote/internal/SyncMongoClientIntTests.kt b/android/services/mongodb-remote/src/androidTest/java/com/mongodb/stitch/android/services/mongodb/remote/internal/SyncMongoClientIntTests.kt index 2b88b3360..d9e43a4d8 100644 --- a/android/services/mongodb-remote/src/androidTest/java/com/mongodb/stitch/android/services/mongodb/remote/internal/SyncMongoClientIntTests.kt +++ b/android/services/mongodb-remote/src/androidTest/java/com/mongodb/stitch/android/services/mongodb/remote/internal/SyncMongoClientIntTests.kt @@ -34,6 +34,7 @@ import com.mongodb.stitch.core.testutils.sync.SyncIntTestRunner import com.mongodb.stitch.android.core.StitchAppClient import com.mongodb.stitch.android.services.mongodb.local.internal.AndroidEmbeddedMongoClientFactory import com.mongodb.stitch.core.auth.internal.CoreStitchUser +import com.mongodb.stitch.core.auth.providers.userpassword.UserPasswordCredential import org.bson.BsonValue import org.bson.Document import org.bson.conversions.Bson @@ -228,6 +229,11 @@ class SyncMongoClientIntTests : BaseStitchAndroidIntTest(), SyncIntTestRunner { return SyncMethods(coll.sync()) } + override fun reloginUser2() { + Tasks.await( + client.auth.loginWithCredential(UserPasswordCredential("test1@10gen.com", "password"))) + } + override fun listUsers(): List { return client.auth.listUsers() } @@ -236,6 +242,10 @@ class SyncMongoClientIntTests : BaseStitchAndroidIntTest(), SyncIntTestRunner { client.auth.switchToUserWithId(userId) } + override fun removeUser(userId: String) { + Tasks.await(client.auth.removeUserWithId(userId)) + } + override fun currentUserId(): String? { return client.auth.user?.id } @@ -317,7 +327,6 @@ class SyncMongoClientIntTests : BaseStitchAndroidIntTest(), SyncIntTestRunner { @Test override fun testDesync() { - // TODO: there is a race here testProxy.testDesync() } diff --git a/core/admin-client/src/main/java/com/mongodb/stitch/core/admin/StitchAdminAuth.kt b/core/admin-client/src/main/java/com/mongodb/stitch/core/admin/StitchAdminAuth.kt index b0d259098..a9a0275bd 100644 --- a/core/admin-client/src/main/java/com/mongodb/stitch/core/admin/StitchAdminAuth.kt +++ b/core/admin-client/src/main/java/com/mongodb/stitch/core/admin/StitchAdminAuth.kt @@ -45,6 +45,30 @@ class StitchAdminAuth( override fun onAuthEvent() { /* do nothing */ } + override fun onActiveUserChanged( + currentActiveUser: StitchAdminUser?, + previousActiveUser: StitchAdminUser? + ) { + } + + override fun onListenerInitialized() { + } + + override fun onUserAdded(createdUser: StitchAdminUser?) { + } + + override fun onUserLoggedIn(loggedInUser: StitchAdminUser?) { + } + + override fun onUserLoggedOut(loggedOutUser: StitchAdminUser?) { + } + + override fun onUserRemoved(removedUser: StitchAdminUser?) { + } + + override fun onUserLinked(linkedUser: StitchAdminUser?) { + } + public override fun getDeviceInfo(): Document { val document = Document() document[DeviceFields.APP_ID] = "MongoDB Stitch Java/Kotlin Admin Client" @@ -52,7 +76,9 @@ class StitchAdminAuth( return document } - public override fun loginWithCredentialInternal(credential: StitchCredential?): StitchAdminUser { + public override fun loginWithCredentialInternal( + credential: StitchCredential? + ): StitchAdminUser { return super.loginWithCredentialInternal(credential) } diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuth.java b/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuth.java index 066a91276..bb32297fd 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuth.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuth.java @@ -45,8 +45,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import javax.annotation.CheckReturnValue; import javax.annotation.Nullable; @@ -74,7 +74,7 @@ public abstract class CoreStitchAuth private LinkedHashMap allUsersAuthInfo; private StitchUserT activeUser; private AuthInfo activeUserAuthInfo; - private Lock authLock; + private ReadWriteLock authLock; protected CoreStitchAuth( final StitchRequestClient requestClient, @@ -84,7 +84,7 @@ protected CoreStitchAuth( this.requestClient = requestClient; this.authRoutes = authRoutes; this.storage = storage; - this.authLock = new ReentrantLock(); + this.authLock = new ReentrantReadWriteLock(); final List allUsersAuthInfoList; try { @@ -133,6 +133,21 @@ protected CoreStitchAuth( protected abstract void onAuthEvent(); + protected abstract void onUserAdded(final StitchUserT createdUser); + + protected abstract void onUserLoggedIn(final StitchUserT loggedInUser); + + protected abstract void onUserLoggedOut(final StitchUserT loggedOutUser); + + protected abstract void onActiveUserChanged(final StitchUserT currentActiveUser, + @Nullable final StitchUserT previousActiveUser); + + protected abstract void onUserRemoved(final StitchUserT removedUser); + + protected abstract void onUserLinked(final StitchUserT linkedUser); + + protected abstract void onListenerInitialized(); + protected Document getDeviceInfo() { final Document info = new Document(); if (hasDeviceId()) { @@ -146,6 +161,21 @@ AuthInfo getAuthInfo() { return activeUserAuthInfo; } + /** + * Returns whether or not the client is logged in. + * + * @return whether or not the client is logged in. + */ + @CheckReturnValue(when = When.NEVER) + public boolean isLoggedInInterruptibly() throws InterruptedException { + authLock.readLock().lockInterruptibly(); + try { + return activeUser != null && activeUser.isLoggedIn(); + } finally { + authLock.readLock().unlock(); + } + } + /** * Returns whether or not the client is logged in. * @@ -153,29 +183,44 @@ AuthInfo getAuthInfo() { */ @CheckReturnValue(when = When.NEVER) public boolean isLoggedIn() { - return activeUser != null && activeUser.isLoggedIn(); + authLock.readLock().lock(); + try { + return activeUser != null && activeUser.isLoggedIn(); + } finally { + authLock.readLock().unlock(); + } } /** * Returns the active logged in user. - */ + */ @Nullable - public synchronized StitchUserT getUser() { - return activeUser; + public StitchUserT getUser() { + authLock.readLock().lock(); + try { + return activeUser; + } finally { + authLock.readLock().unlock(); + } } - public synchronized List listUsers() { - final ArrayList userSet = new ArrayList<>(); - for (final AuthInfo authInfo : this.allUsersAuthInfo.values()) { - userSet.add(getUserFactory().makeUser( - authInfo.getUserId(), - authInfo.getDeviceId(), - authInfo.getLoggedInProviderType(), - authInfo.getLoggedInProviderName(), - authInfo.getUserProfile(), - authInfo.isLoggedIn())); + public List listUsers() { + authLock.readLock().lock(); + try { + final ArrayList userSet = new ArrayList<>(); + for (final AuthInfo authInfo : this.allUsersAuthInfo.values()) { + userSet.add(getUserFactory().makeUser( + authInfo.getUserId(), + authInfo.getDeviceId(), + authInfo.getLoggedInProviderType(), + authInfo.getLoggedInProviderName(), + authInfo.getUserProfile(), + authInfo.isLoggedIn())); + } + return userSet; + } finally { + authLock.readLock().unlock(); } - return userSet; } /** @@ -250,11 +295,14 @@ public T doAuthenticatedRequest( } @Override - public Stream openAuthenticatedStream(final StitchAuthRequest stitchReq, - final Decoder decoder) { - if (!isLoggedIn()) { + public Stream openAuthenticatedStream( + final StitchAuthRequest stitchReq, + final Decoder decoder + ) throws InterruptedException { + if (!isLoggedInInterruptibly()) { throw new StitchClientException(StitchClientErrorCode.MUST_AUTHENTICATE_FIRST); } + final String authToken = stitchReq.getUseRefreshToken() ? getAuthInfo().getRefreshToken() : getAuthInfo().getAccessToken(); try { @@ -269,9 +317,9 @@ public Stream openAuthenticatedStream(final StitchAuthRequest stitchReq, } } - public synchronized StitchUserT switchToUserWithId(final String userId) + public StitchUserT switchToUserWithId(final String userId) throws StitchClientException { - authLock.lock(); + authLock.writeLock().lock(); try { final AuthInfo authInfo = findAuthInfoById(userId); if (!authInfo.isLoggedIn()) { @@ -287,6 +335,7 @@ public synchronized StitchUserT switchToUserWithId(final String userId) throw new StitchClientException(StitchClientErrorCode.COULD_NOT_PERSIST_AUTH_INFO); } + final StitchUserT previousUser = this.activeUser; this.activeUserAuthInfo = authInfo; this.activeUser = getUserFactory().makeUser( activeUserAuthInfo.getUserId(), @@ -296,15 +345,15 @@ public synchronized StitchUserT switchToUserWithId(final String userId) activeUserAuthInfo.getUserProfile(), activeUserAuthInfo.isLoggedIn()); onAuthEvent(); - + onActiveUserChanged(this.activeUser, previousUser); return this.activeUser; } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } protected StitchUserT loginWithCredentialInternal(final StitchCredential credential) { - authLock.lock(); + authLock.writeLock().lock(); try { if (!isLoggedIn()) { return doLogin(credential, false); @@ -320,13 +369,13 @@ protected StitchUserT loginWithCredentialInternal(final StitchCredential credent return doLogin(credential, false); } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } - protected synchronized StitchUserT linkUserWithCredentialInternal( + protected StitchUserT linkUserWithCredentialInternal( final CoreStitchUser user, final StitchCredential credential) { - authLock.lock(); + authLock.writeLock().lock(); try { if (user != activeUser) { throw new StitchClientException(StitchClientErrorCode.USER_NO_LONGER_VALID); @@ -334,22 +383,27 @@ protected synchronized StitchUserT linkUserWithCredentialInternal( return doLogin(credential, true); } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } - protected synchronized void logoutInternal() { - if (!isLoggedIn()) { - return; - } + protected void logoutInternal() { + authLock.writeLock().lock(); + try { + if (!isLoggedIn()) { + return; + } - logoutUserWithIdInternal(activeUser.getId()); + logoutUserWithIdInternal(activeUser.getId()); + } finally { + authLock.writeLock().unlock(); + } } - protected synchronized void logoutUserWithIdInternal( + protected void logoutUserWithIdInternal( final String userId ) throws StitchClientException { - authLock.lock(); + authLock.writeLock().lock(); try { final AuthInfo authInfo = findAuthInfoById(userId); if (!authInfo.isLoggedIn()) { @@ -370,20 +424,25 @@ protected synchronized void logoutUserWithIdInternal( } } } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } - protected synchronized void removeUserInternal() { - if (!isLoggedIn() || activeUser == null) { - return; - } + protected void removeUserInternal() { + authLock.writeLock().lock(); + try { + if (!isLoggedIn() || activeUser == null) { + return; + } - removeUserWithIdInternal(activeUser.getId()); + removeUserWithIdInternal(activeUser.getId()); + } finally { + authLock.writeLock().unlock(); + } } - protected synchronized void removeUserWithIdInternal(final String userId) { - authLock.lock(); + protected void removeUserWithIdInternal(final String userId) { + authLock.writeLock().lock(); try { final AuthInfo authInfo = findAuthInfoById(userId); try { @@ -402,22 +461,44 @@ protected synchronized void removeUserWithIdInternal(final String userId) { } catch (IOException e) { throw new StitchClientException(StitchClientErrorCode.COULD_NOT_PERSIST_AUTH_INFO); } + + final AuthInfo authInfoLoggedOut = authInfo.loggedOut(); + onUserRemoved( + getUserFactory().makeUser( + authInfoLoggedOut.getUserId(), + authInfoLoggedOut.getDeviceId(), + authInfoLoggedOut.getLoggedInProviderType(), + authInfoLoggedOut.getLoggedInProviderName(), + authInfoLoggedOut.getUserProfile(), + authInfoLoggedOut.isLoggedIn() + ) + ); } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } - protected synchronized boolean hasDeviceId() { - return activeUserAuthInfo.getDeviceId() != null - && !activeUserAuthInfo.getDeviceId().isEmpty() - && !activeUserAuthInfo.getDeviceId().equals("000000000000000000000000"); + protected boolean hasDeviceId() { + authLock.readLock().lock(); + try { + return activeUserAuthInfo.getDeviceId() != null + && !activeUserAuthInfo.getDeviceId().isEmpty() + && !activeUserAuthInfo.getDeviceId().equals("000000000000000000000000"); + } finally { + authLock.readLock().unlock(); + } } protected synchronized String getDeviceId() { - if (!hasDeviceId()) { - return null; + authLock.readLock().lock(); + try { + if (!hasDeviceId()) { + return null; + } + return activeUserAuthInfo.getDeviceId(); + } finally { + authLock.readLock().unlock(); } - return activeUserAuthInfo.getDeviceId(); } private static StitchAuthRequest prepareAuthRequest(final StitchAuthRequest stitchReq, @@ -440,9 +521,11 @@ private static StitchAuthRequest prepareAuthRequest(final StitchAuthRequest stit return newReq.build(); } - private Stream handleAuthFailureForStream(final StitchServiceException ex, - final StitchAuthRequest req, - final Decoder decoder) { + private Stream handleAuthFailureForStream( + final StitchServiceException ex, + final StitchAuthRequest req, + final Decoder decoder + ) throws InterruptedException { if (ex.getErrorCode() != StitchServiceErrorCode.INVALID_SESSION) { throw ex; } @@ -482,7 +565,7 @@ private synchronized Response handleAuthFailure(final StitchServiceException ex, // that should wait on the result of doing a token refresh or logoutUserWithId. This will // prevent too many refreshes happening one after the other. private void tryRefreshAccessToken(final Long reqStartedAt) { - authLock.lock(); + authLock.writeLock().lock(); try { if (!isLoggedIn()) { throw new StitchClientException(StitchClientErrorCode.LOGGED_OUT_DURING_REQUEST); @@ -500,12 +583,12 @@ private void tryRefreshAccessToken(final Long reqStartedAt) { // retry refreshAccessToken(); } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } synchronized void refreshAccessToken() { - authLock.lock(); + authLock.writeLock().lock(); try { final StitchAuthRequest.Builder reqBuilder = new StitchAuthRequest.Builder(); reqBuilder @@ -528,7 +611,7 @@ synchronized void refreshAccessToken() { throw new StitchClientException(StitchClientErrorCode.COULD_NOT_PERSIST_AUTH_INFO); } } finally { - authLock.unlock(); + authLock.writeLock().unlock(); } } @@ -541,8 +624,17 @@ private void attachAuthOptions(final Document authBody) { // callers of doLogin should be serialized before calling in. private StitchUserT doLogin(final StitchCredential credential, final boolean asLinkRequest) { final Response response = doLoginRequest(credential, asLinkRequest); + + final StitchUserT previousUser = activeUser; final StitchUserT user = processLoginResponse(credential, response, asLinkRequest); - onAuthEvent(); + + if (asLinkRequest) { + onUserLinked(user); + } else { + onUserLoggedIn(user); + onActiveUserChanged(activeUser, previousUser); + } + return user; } @@ -647,6 +739,8 @@ private StitchUserT processLoginResponse( credential.getProviderName(), profile)); + final boolean newUserAdded = !this.allUsersAuthInfo.containsKey(newAuthInfo.getUserId()); + try { AuthInfo.writeActiveUserAuthInfoToStorage(newAuthInfo, storage); @@ -661,12 +755,9 @@ private StitchUserT processLoginResponse( activeUserAuthInfo = oldActiveUserInfo; activeUser = null; - // delete the new partial auth info from the list of all users if + // this replaces auth info from the list of all users if // if the new auth info is not the same user as the older user - if (!newAuthInfo.getUserId().equals(oldActiveUserInfo.getUserId()) - && newAuthInfo.getUserId() != null) { - this.allUsersAuthInfo.remove(newAuthInfo.getUserId()); - } + this.allUsersAuthInfo.put(newAuthInfo.getUserId(), newAuthInfo); throw new StitchClientException(StitchClientErrorCode.COULD_NOT_PERSIST_AUTH_INFO); } @@ -683,6 +774,9 @@ private StitchUserT processLoginResponse( profile, activeUserAuthInfo.isLoggedIn()); + if (newUserAdded) { + onUserAdded(this.activeUser); + } return activeUser; } @@ -706,41 +800,77 @@ private void doLogout(final AuthInfo authInfo) { this.doAuthenticatedRequest(reqBuilder.build(), authInfo); } - private synchronized void clearActiveUserAuth() { - if (!isLoggedIn()) { - return; - } - - this.clearUserAuth(activeUserAuthInfo.getUserId()); - } + private void clearActiveUserAuth() { + authLock.writeLock().lock(); + try { + if (!isLoggedIn()) { + return; + } - private synchronized void clearUserAuth(final String userId) { - final AuthInfo unclearedAuthInfo = this.allUsersAuthInfo.get(userId); - if (unclearedAuthInfo == null && !this.activeUserAuthInfo.getUserId().equals(userId)) { - // this doesn't necessarily mean there's an error. we could be in a - // provisional state where the profile request failed and we're just - // trying to log out the active user. - // only throw if this ID is not the active user either - throw new StitchClientException(StitchClientErrorCode.USER_NOT_FOUND); + this.clearUserAuth(activeUserAuthInfo.getUserId()); + } finally { + authLock.writeLock().unlock(); } + } + private void clearUserAuth(final String userId) { + authLock.writeLock().lock(); try { - if (unclearedAuthInfo != null) { - this.allUsersAuthInfo.put(userId, unclearedAuthInfo.loggedOut()); - AuthInfo.writeCurrentUsersToStorage(this.allUsersAuthInfo.values(), storage); + final AuthInfo unclearedAuthInfo = this.allUsersAuthInfo.get(userId); + if (unclearedAuthInfo == null && !this.activeUserAuthInfo.getUserId().equals(userId)) { + // this doesn't necessarily mean there's an error. we could be in a + // provisional state where the profile request failed and we're just + // trying to log out the active user. + // only throw if this ID is not the active user either + throw new StitchClientException(StitchClientErrorCode.USER_NOT_FOUND); } - // if the auth info we're clearing is also the active user's auth info, - // clear the active user's auth as well - if (activeUserAuthInfo.hasUser() && activeUserAuthInfo.getUserId().equals(userId)) { - this.activeUserAuthInfo = this.activeUserAuthInfo.withClearedUser(); - this.activeUser = null; + try { + StitchUserT loggedOutUser = null; + if (unclearedAuthInfo != null + && unclearedAuthInfo.getAccessToken() != null + && unclearedAuthInfo.getRefreshToken() != null) { + this.allUsersAuthInfo.put(userId, unclearedAuthInfo.loggedOut()); + AuthInfo.writeCurrentUsersToStorage(this.allUsersAuthInfo.values(), storage); + loggedOutUser = getUserFactory().makeUser( + unclearedAuthInfo.getUserId(), + unclearedAuthInfo.getDeviceId(), + unclearedAuthInfo.getLoggedInProviderType(), + unclearedAuthInfo.getLoggedInProviderName(), + unclearedAuthInfo.getUserProfile(), + unclearedAuthInfo.isLoggedIn() + ); + } else if (unclearedAuthInfo != null && !unclearedAuthInfo.isLoggedIn()) { + // if the auth info's tokens are already cleared, there's no need to + // clear them again + return; + } + + // if the auth info we're clearing is also the active user's auth info, + // clear the active user's auth as well + boolean wasActiveUser = false; + if (activeUserAuthInfo.hasUser() && activeUserAuthInfo.getUserId().equals(userId)) { + wasActiveUser = true; + this.activeUserAuthInfo = this.activeUserAuthInfo.withClearedUser(); + this.activeUser = null; - AuthInfo.writeActiveUserAuthInfoToStorage(this.activeUserAuthInfo, this.storage); - this.onAuthEvent(); + AuthInfo.writeActiveUserAuthInfoToStorage(this.activeUserAuthInfo, this.storage); + } + + if (loggedOutUser != null) { + this.onAuthEvent(); + + this.onUserLoggedOut(loggedOutUser); + + if (wasActiveUser) { + onActiveUserChanged(null, loggedOutUser); + } + } + } catch (final IOException e) { + throw new StitchClientException(StitchClientErrorCode.COULD_NOT_PERSIST_AUTH_INFO); } - } catch (final IOException e) { - throw new StitchClientException(StitchClientErrorCode.COULD_NOT_PERSIST_AUTH_INFO); + } finally { + authLock.writeLock().unlock(); } } diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/StitchAuthRequestClient.java b/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/StitchAuthRequestClient.java index 8ce10c3ef..63822c282 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/StitchAuthRequestClient.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/auth/internal/StitchAuthRequestClient.java @@ -33,5 +33,5 @@ T doAuthenticatedRequest(final StitchAuthRequest stitchReq, final CodecRegistry codecRegistry); Stream openAuthenticatedStream(final StitchAuthRequest stitchReq, - final Decoder decoder); + final Decoder decoder) throws InterruptedException; } diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/common/AuthMonitor.java b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/common/AuthMonitor.java index 4ef75c5d7..3a4db5a06 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/common/AuthMonitor.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/common/AuthMonitor.java @@ -24,8 +24,18 @@ public interface AuthMonitor { * logged in. * * @return whether or not the application client is logged in + * @throws InterruptedException will throw interruptibly */ - boolean isLoggedIn(); + boolean isLoggedIn() throws InterruptedException; + + /** + * Get whether or not the application client is currently + * logged in. + * + * @return whether or not the application client is logged in, + * or false if the thread was interrupted + */ + boolean tryIsLoggedIn(); /** * Get the active user id from the applications diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/OkHttpEventStream.java b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/OkHttpEventStream.java index b5d64eef1..13079ff6f 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/OkHttpEventStream.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/OkHttpEventStream.java @@ -55,7 +55,6 @@ public boolean isOpen() { @Override public void close() throws IOException { this.source.close(); - this.call.cancel(); } @Override diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Stream.java b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Stream.java index 164d6ea5c..6dc7bae60 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Stream.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Stream.java @@ -47,10 +47,15 @@ public Stream(final EventStream eventStream, /** * Fetch the next event from a given stream * @return the next event - * @throws Exception any exception that could occur + * @throws IOException any io exception that could occur */ - public StitchEvent nextEvent() throws Exception { - return StitchEvent.fromEvent(this.eventStream.nextEvent(), this.decoder); + public StitchEvent nextEvent() throws IOException { + final Event nextEvent = eventStream.nextEvent(); + if (nextEvent == null) { + return null; + } + + return StitchEvent.fromEvent(nextEvent, this.decoder); } /** diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Transport.java b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Transport.java index aef377329..cb4adecb9 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Transport.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/internal/net/Transport.java @@ -16,10 +16,12 @@ package com.mongodb.stitch.core.internal.net; +import java.io.IOException; + public interface Transport { Response roundTrip(Request request) throws Exception; - EventStream stream(Request request) throws Exception; + EventStream stream(Request request) throws IOException; void close(); } diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/AuthEvent.java b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/AuthEvent.java new file mode 100644 index 000000000..90dd58462 --- /dev/null +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/AuthEvent.java @@ -0,0 +1,114 @@ +/* + * Copyright 2018-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.stitch.core.services.internal; + +import com.mongodb.stitch.core.auth.internal.CoreStitchUser; + +import javax.annotation.Nullable; + +public abstract class AuthEvent extends RebindEvent { + public enum Type { + USER_LOGGED_IN, + USER_LOGGED_OUT, + ACTIVE_USER_CHANGED, + USER_REMOVED + } + + public static class UserLoggedIn extends AuthEvent { + private final StitchUserT loggedInUser; + + public UserLoggedIn(final StitchUserT loggedInUser) { + this.loggedInUser = loggedInUser; + } + + public StitchUserT getLoggedInUser() { + return loggedInUser; + } + + @Override + public Type getAuthEventType() { + return Type.USER_LOGGED_IN; + } + } + + public static class UserLoggedOut extends AuthEvent { + private final StitchUserT loggedOutUser; + + public UserLoggedOut(final StitchUserT loggedOutUser) { + this.loggedOutUser = loggedOutUser; + } + + public StitchUserT getLoggedOutUser() { + return loggedOutUser; + } + + @Override + public Type getAuthEventType() { + return Type.USER_LOGGED_OUT; + } + } + + public static class ActiveUserChanged extends AuthEvent { + private final StitchUserT currentActiveUser; + @Nullable + private final StitchUserT previousActiveUser; + + public ActiveUserChanged(@Nullable final StitchUserT currentActiveUser, + @Nullable final StitchUserT previousActiveUser) { + this.currentActiveUser = currentActiveUser; + this.previousActiveUser = previousActiveUser; + } + + public StitchUserT getCurrentActiveUser() { + return currentActiveUser; + } + + @Nullable + public StitchUserT getPreviousActiveUser() { + return previousActiveUser; + } + + @Override + public Type getAuthEventType() { + return Type.ACTIVE_USER_CHANGED; + } + } + + public static class UserRemoved extends AuthEvent { + private final StitchUserT removedUser; + + public UserRemoved(final StitchUserT removedUser) { + this.removedUser = removedUser; + } + + public StitchUserT getRemovedUser() { + return removedUser; + } + + @Override + public Type getAuthEventType() { + return Type.USER_REMOVED; + } + } + + @Override + public final RebindEventType getType() { + return RebindEventType.AUTH_EVENT; + } + + public abstract Type getAuthEventType(); +} diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClient.java b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClient.java index e37e45cad..e0f85f2c9 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClient.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClient.java @@ -18,6 +18,7 @@ import com.mongodb.stitch.core.internal.net.Stream; +import java.io.IOException; import java.util.List; import javax.annotation.Nullable; import org.bson.codecs.Decoder; @@ -74,7 +75,7 @@ T callFunction( Stream streamFunction(String name, List args, - Decoder decoder); + Decoder decoder) throws IOException, InterruptedException; CodecRegistry getCodecRegistry(); diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClientImpl.java b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClientImpl.java index 8cff8c2f6..0c937e382 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClientImpl.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceClientImpl.java @@ -175,7 +175,7 @@ public T callFunction( @Override public Stream streamFunction(final String name, final List args, - final Decoder decoder) { + final Decoder decoder) throws InterruptedException { return requestClient.openAuthenticatedStream( getStreamServiceFunctionRequest(name, args), decoder ); @@ -199,7 +199,7 @@ public void bind(final StitchServiceBinder binder) { } @Override - public void onRebindEvent() { + public void onRebindEvent(final RebindEvent rebindEvent) { final Iterator> iterator = this.serviceBinders.iterator(); while (iterator.hasNext()) { final WeakReference weakReference = iterator.next(); @@ -207,7 +207,7 @@ public void onRebindEvent() { if (binder == null) { this.serviceBinders.remove(weakReference); } else { - binder.onRebindEvent(); + binder.onRebindEvent(rebindEvent); } } } diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/RebindEvent.java b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/RebindEvent.java new file mode 100644 index 000000000..df1ee8713 --- /dev/null +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/RebindEvent.java @@ -0,0 +1,21 @@ +/* + * Copyright 2018-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.stitch.core.services.internal; + +public abstract class RebindEvent { + public abstract RebindEventType getType(); +} diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/RebindEventType.java b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/RebindEventType.java new file mode 100644 index 000000000..23f0195d5 --- /dev/null +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/RebindEventType.java @@ -0,0 +1,21 @@ +/* + * Copyright 2018-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.stitch.core.services.internal; + +public enum RebindEventType { + AUTH_EVENT +} diff --git a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/StitchServiceBinder.java b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/StitchServiceBinder.java index 109af29d3..9202b67f3 100644 --- a/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/StitchServiceBinder.java +++ b/core/sdk/src/main/java/com/mongodb/stitch/core/services/internal/StitchServiceBinder.java @@ -26,6 +26,8 @@ public interface StitchServiceBinder { /** * Notify the binder that a rebind event has occured. * E.g., a change in authentication. + * + * @param rebindEvent the rebind event that occurred */ - void onRebindEvent(); + void onRebindEvent(final RebindEvent rebindEvent); } diff --git a/core/sdk/src/test/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuthUnitTests.java b/core/sdk/src/test/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuthUnitTests.java index 5883ab265..a6b990229 100644 --- a/core/sdk/src/test/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuthUnitTests.java +++ b/core/sdk/src/test/java/com/mongodb/stitch/core/auth/internal/CoreStitchAuthUnitTests.java @@ -34,8 +34,10 @@ import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -78,6 +80,7 @@ import java.util.List; import java.util.Map; import java.util.function.Predicate; +import javax.annotation.Nullable; import org.bson.Document; import org.bson.codecs.DocumentCodec; @@ -90,18 +93,19 @@ import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; + public class CoreStitchAuthUnitTests { @Test public void testLoginWithCredentialBlocking() { final StitchRequestClient requestClient = getMockedRequestClient(); final StitchAuthRoutes routes = new StitchAppRoutes("my_app-12345").getAuthRoutes(); - final StitchAuth auth = new StitchAuth( + final StitchAuth auth = spy(new StitchAuth( requestClient, routes, - new MemoryStorage()); + new MemoryStorage())); - final CoreStitchUser user = + final CoreStitchUserImpl user = auth.loginWithCredentialInternal(new AnonymousCredential()); final ApiCoreUserProfile profile = getTestUserProfile(); @@ -129,22 +133,24 @@ public void testLoginWithCredentialBlocking() { .withPath(routes.getProfileRoute()) .withHeaders(headers); assertEquals(expectedRequest2.build(), reqArgs.getAllValues().get(1)); + + verify(auth, times(1)).onUserLoggedIn(eq(user)); } @Test public void testLinkUserWithCredentialBlocking() { final StitchRequestClient requestClient = getMockedRequestClient(); final StitchAuthRoutes routes = new StitchAppRoutes("my_app-12345").getAuthRoutes(); - final StitchAuth auth = new StitchAuth( + final StitchAuth auth = spy(new StitchAuth( requestClient, routes, - new MemoryStorage()); + new MemoryStorage())); - final CoreStitchUser user = + final CoreStitchUserImpl user = auth.loginWithCredentialInternal(new AnonymousCredential()); verify(requestClient, times(2)).doRequest(any(StitchRequest.class)); - final CoreStitchUser linkedUser = + final CoreStitchUserImpl linkedUser = auth.linkUserWithCredentialInternal( user, new UserPasswordCredential("foo@foo.com", "bar")); @@ -175,6 +181,8 @@ public void testLinkUserWithCredentialBlocking() { .withPath(routes.getProfileRoute()) .withHeaders(headers2); assertEquals(expectedRequest2.build(), reqArgs.getAllValues().get(3)); + + verify(auth, times(1)).onUserLinked(eq(linkedUser)); } @Test @@ -197,25 +205,37 @@ public void testIsLoggedIn() { public void testLogoutBlocking() { final StitchRequestClient requestClient = getMockedRequestClient(); final StitchAuthRoutes routes = new StitchAppRoutes("my_app-12345").getAuthRoutes(); - final StitchAuth auth = new StitchAuth( + final StitchAuth auth = spy(new StitchAuth( requestClient, routes, - new MemoryStorage()); + new MemoryStorage())); assertFalse(auth.isLoggedIn()); - final CoreStitchUser user1 = + final CoreStitchUserImpl user1 = auth.loginWithCredentialInternal(new AnonymousCredential()); - final CoreStitchUser user2 = + + verify(auth, times(1)).onUserLoggedIn(eq(user1)); + + final CoreStitchUserImpl user2 = auth.loginWithCredentialInternal(new UserPasswordCredential("hi", "there")); - final CoreStitchUser user3 = + + verify(auth, times(1)).onUserLoggedIn(eq(user2)); + verify(auth, times(1)).onActiveUserChanged(eq(user2), eq(user1)); + + final CoreStitchUserImpl user3 = auth.loginWithCredentialInternal(new UserPasswordCredential("bye", "there")); + verify(auth, times(1)).onUserLoggedIn(eq(user3)); + verify(auth, times(1)).onActiveUserChanged(eq(user3), eq(user2)); + assertEquals(auth.listUsers().get(2), user3); assertTrue(auth.isLoggedIn()); auth.logoutInternal(); + verify(auth, times(1)).onUserLoggedOut(eq(user3)); + // assert that though one user is logged out, three users are still listed, and their profiles // are all non-null assertEquals(auth.listUsers().size(), 3); @@ -239,6 +259,7 @@ public boolean test(final CoreStitchUserImpl coreStitchUser) { assertFalse(auth.isLoggedIn()); auth.switchToUserWithId(user2.getId()); + verify(auth, times(1)).onActiveUserChanged(eq(user2), eq(null)); auth.logoutInternal(); verify(requestClient, times(8)).doRequest(reqArgs.capture()); @@ -283,24 +304,25 @@ public boolean test(final CoreStitchUserImpl coreStitchUser) { public void testRemoveBlocking() { final StitchRequestClient requestClient = getMockedRequestClient(); final StitchAuthRoutes routes = new StitchAppRoutes("my_app-12345").getAuthRoutes(); - final StitchAuth auth = new StitchAuth( + final StitchAuth auth = spy(new StitchAuth( requestClient, routes, - new MemoryStorage()); + new MemoryStorage())); assertFalse(auth.isLoggedIn()); - final CoreStitchUser user1 = + final CoreStitchUserImpl user1 = auth.loginWithCredentialInternal(new AnonymousCredential()); - final CoreStitchUser user2 = + final CoreStitchUserImpl user2 = auth.loginWithCredentialInternal(new UserPasswordCredential("hi", "there")); - final CoreStitchUser user3 = + final CoreStitchUserImpl user3 = auth.loginWithCredentialInternal(new UserPasswordCredential("bye", "there")); assertEquals(auth.listUsers().get(2), user3); assertTrue(auth.isLoggedIn()); auth.removeUserInternal(); + verify(auth, times(1)).onUserRemoved(user3); // assert that though one user is logged out, two users are still listed assertEquals(auth.listUsers().size(), 2); @@ -321,6 +343,7 @@ public void testRemoveBlocking() { // assert that switching to a user, and removing self works assertFalse(auth.isLoggedIn()); auth.switchToUserWithId(user2.getId()); + verify(auth, times(1)).onActiveUserChanged(eq(user2), eq(null)); assertTrue(auth.isLoggedIn()); auth.removeUserInternal(); @@ -335,6 +358,8 @@ public void testRemoveBlocking() { // assert that we can remove the user without switching to it auth.removeUserWithIdInternal(user1.getId()); + verify(auth, times(1)).onUserRemoved(user1); + assertEquals(auth.listUsers().size(), 0); assertFalse(auth.isLoggedIn()); @@ -345,6 +370,8 @@ public void testRemoveBlocking() { assertEquals( user2, auth.loginWithCredentialInternal(new UserPasswordCredential("hi", "there"))); + verify(auth, times(2)).onUserLoggedIn(user2); + assertEquals(auth.listUsers().size(), 1); assertEquals(auth.listUsers().get(0), user2); @@ -718,39 +745,45 @@ userToBeLinked, new UserPasswordCredential("hello ", "friend") public void testSwitchUser() { final StitchRequestClient requestClient = getMockedRequestClient(); final StitchAuthRoutes routes = new StitchAppRoutes("my_app-12345").getAuthRoutes(); - final StitchAuth auth = new StitchAuth( + final StitchAuth auth = spy(new StitchAuth( requestClient, routes, - new MemoryStorage()); + new MemoryStorage())); try { auth.switchToUserWithId("not_a_user_id"); fail("should have thrown error due to missing key"); } catch (StitchClientException e) { + verify(auth, times(0)).onActiveUserChanged(any(), any()); assertEquals(StitchClientErrorCode.USER_NOT_FOUND, e.getErrorCode()); } - final CoreStitchUser user = + final CoreStitchUserImpl user = auth.loginWithCredentialInternal(new UserPasswordCredential("greetings ", "friend")); // can switch to self assertEquals(user, auth.switchToUserWithId(user.getId())); assertEquals(user, auth.getUser()); + verify(auth, times(1)).onActiveUserChanged(eq(user), eq(user)); - final CoreStitchUser user2 = + final CoreStitchUserImpl user2 = auth.loginWithCredentialInternal(new UserPasswordCredential("hello ", "friend")); assertEquals(user2, auth.getUser()); assertNotEquals(user, auth.getUser()); + verify(auth, times(1)).onActiveUserChanged(eq(user2), eq(user)); // can switch back to old user assertEquals(user, auth.switchToUserWithId(user.getId())); assertEquals(user, auth.getUser()); + verify(auth, times(1)).onActiveUserChanged(eq(user), eq(user2)); // switch back to second user after logging out auth.logoutInternal(); assertEquals(2, auth.listUsers().size()); assertEquals(user2, auth.switchToUserWithId(user2.getId())); + verify(auth, times(1)).onUserLoggedOut(eq(user)); + verify(auth, times(1)).onActiveUserChanged(eq(user2), eq(null)); // assert that we can't switch to a logged out user try { @@ -759,8 +792,6 @@ public void testSwitchUser() { } catch (StitchClientException e) { assertEquals(StitchClientErrorCode.USER_NOT_LOGGED_IN, e.getErrorCode()); } - - } @Test @@ -794,16 +825,47 @@ protected static class StitchAuth extends CoreStitchAuth { @Override protected StitchUserFactory getUserFactory() { return (String id, - String deviceId, - String loggedInProviderType, - String loggedInProviderName, - StitchUserProfileImpl userProfile, - boolean isLoggedIn) -> + String deviceId, + String loggedInProviderType, + String loggedInProviderName, + StitchUserProfileImpl userProfile, + boolean isLoggedIn) -> new CoreStitchUserImpl( - id, deviceId, loggedInProviderType, loggedInProviderName, userProfile, isLoggedIn) {}; + id, deviceId, loggedInProviderType, loggedInProviderName, userProfile, isLoggedIn) { + }; + } + + @Override + protected void onAuthEvent() { + } + + @Override + protected void onUserLoggedOut(final CoreStitchUserImpl loggedOutUser) { + } + + @Override + protected void onUserRemoved(final CoreStitchUserImpl removedUser) { } @Override - protected void onAuthEvent() {} + protected void onUserLoggedIn(final CoreStitchUserImpl loggedInUser) { + } + + @Override + protected void onUserAdded(final CoreStitchUserImpl createdUser) { + } + + @Override + protected void onActiveUserChanged(final CoreStitchUserImpl currentActiveUser, + @Nullable final CoreStitchUserImpl previousActiveUser) { + } + + @Override + protected void onListenerInitialized() { + } + + @Override + protected void onUserLinked(final CoreStitchUserImpl linkedUser) { + } } } diff --git a/core/sdk/src/test/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceUnitTests.java b/core/sdk/src/test/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceUnitTests.java index d5a51e415..a48d7ac29 100644 --- a/core/sdk/src/test/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceUnitTests.java +++ b/core/sdk/src/test/java/com/mongodb/stitch/core/services/internal/CoreStitchServiceUnitTests.java @@ -32,6 +32,7 @@ import com.mongodb.stitch.core.internal.net.StitchAuthRequest; import com.mongodb.stitch.core.internal.net.Stream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -108,7 +109,7 @@ public void testCallFunction() { } @Test - public void testStreamFunction() { + public void testStreamFunction() throws InterruptedException, IOException { final String serviceName = "svc1"; final StitchServiceRoutes routes = new StitchServiceRoutes("foo"); final StitchAuthRequestClient requestClient = Mockito.mock(StitchAuthRequestClient.class); diff --git a/core/services/mongodb-local/src/main/java/com/mongodb/stitch/core/services/mongodb/local/internal/EmbeddedMongoClientFactory.java b/core/services/mongodb-local/src/main/java/com/mongodb/stitch/core/services/mongodb/local/internal/EmbeddedMongoClientFactory.java index 355e01ec8..ef477e381 100644 --- a/core/services/mongodb-local/src/main/java/com/mongodb/stitch/core/services/mongodb/local/internal/EmbeddedMongoClientFactory.java +++ b/core/services/mongodb-local/src/main/java/com/mongodb/stitch/core/services/mongodb/local/internal/EmbeddedMongoClientFactory.java @@ -60,6 +60,10 @@ public synchronized MongoClient getClient( return client; } + public synchronized void removeClient(final String key) { + instances.remove(key); + } + public synchronized void close() { for (final MongoClient instance : instances.values()) { instance.close(); diff --git a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/internal/CoreRemoteMongoClientImpl.java b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/internal/CoreRemoteMongoClientImpl.java index 7db8be859..3e4084624 100644 --- a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/internal/CoreRemoteMongoClientImpl.java +++ b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/internal/CoreRemoteMongoClientImpl.java @@ -17,7 +17,9 @@ package com.mongodb.stitch.core.services.mongodb.remote.internal; import com.mongodb.stitch.core.StitchAppClientInfo; +import com.mongodb.stitch.core.services.internal.AuthEvent; import com.mongodb.stitch.core.services.internal.CoreStitchServiceClient; +import com.mongodb.stitch.core.services.internal.RebindEvent; import com.mongodb.stitch.core.services.internal.StitchServiceBinder; import com.mongodb.stitch.core.services.mongodb.local.internal.EmbeddedMongoClientFactory; import com.mongodb.stitch.core.services.mongodb.remote.sync.internal.DataSynchronizer; @@ -62,21 +64,53 @@ public CoreRemoteMongoClientImpl(final CoreStitchServiceClient service, ); } - @Override - public void onRebindEvent() { - if (!lastActiveUserId.equals(appInfo.getAuthMonitor().getActiveUserId())) { - this.lastActiveUserId = appInfo.getAuthMonitor().getActiveUserId() != null - ? appInfo.getAuthMonitor().getActiveUserId() : ""; + private void onAuthEvent(final AuthEvent authEvent) { + switch (authEvent.getAuthEventType()) { + case USER_REMOVED: + final String userId = ((AuthEvent.UserRemoved)authEvent).getRemovedUser().getId(); + if (!SyncMongoClientFactory.deleteDatabase( + appInfo, + service.getName(), + clientFactory, + userId + )) { + System.err.println("Could not delete database for user id " + userId); + } + break; + case ACTIVE_USER_CHANGED: + if (!lastActiveUserId.equals(appInfo.getAuthMonitor().getActiveUserId())) { + this.lastActiveUserId = appInfo.getAuthMonitor().getActiveUserId() != null + ? appInfo.getAuthMonitor().getActiveUserId() : ""; + if (authEvent instanceof AuthEvent.ActiveUserChanged + && ((AuthEvent.ActiveUserChanged)authEvent).getCurrentActiveUser() != null) { + // reinitialize the DataSynchronizer entirely. + // any auth event will trigger this. + this.dataSynchronizer.reinitialize( + SyncMongoClientFactory.getClient( + appInfo, + service.getName(), + clientFactory + ) + ); + } else { + this.dataSynchronizer.stop(); + } + } + break; + default: + // no-op + break; + } + } - // reinitialize the DataSynchronizer entirely. - // any auth event will trigger this. - this.dataSynchronizer.reinitialize( - SyncMongoClientFactory.getClient( - appInfo, - service.getName(), - clientFactory - ) - ); + @Override + public void onRebindEvent(final RebindEvent rebindEvent) { + switch (rebindEvent.getType()) { + case AUTH_EVENT: + this.onAuthEvent((AuthEvent)rebindEvent); + break; + default: + break; } } diff --git a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DataSynchronizer.java b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DataSynchronizer.java index 6efa90d13..c887c0990 100644 --- a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DataSynchronizer.java +++ b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DataSynchronizer.java @@ -109,6 +109,7 @@ public class DataSynchronizer implements NetworkMonitor.StateListener { private InstanceSynchronizationConfig syncConfig; private boolean syncThreadEnabled = true; + private boolean listenersEnabled = true; private boolean isConfigured = false; private boolean isRunning = false; private Thread syncThread; @@ -305,21 +306,17 @@ public void onNetworkStateChanged() { } public void reinitialize(final MongoClient localClient) { - syncLock.lock(); ongoingOperationsGroup.blockAndWait(); - try { + this.localClient = localClient; + + initThread = new Thread(() -> { this.stop(); - this.localClient = localClient; - this.initThread = new Thread(() -> { - initialize(); - this.start(); - ongoingOperationsGroup.unblock(); - }); + initialize(); + this.start(); + ongoingOperationsGroup.unblock(); + }); - this.initThread.start(); - } finally { - syncLock.unlock(); - } + this.initThread.start(); } /** @@ -395,7 +392,10 @@ public void start() { return; } instanceChangeStreamListener.stop(); - instanceChangeStreamListener.start(); + if (listenersEnabled) { + instanceChangeStreamListener.start(); + } + if (syncThread == null) { syncThread = new Thread(new DataSynchronizerRunner( new WeakReference<>(this), @@ -420,6 +420,15 @@ public void disableSyncThread() { } } + public void disableListeners() { + syncLock.lock(); + try { + listenersEnabled = false; + } finally { + syncLock.unlock(); + } + } + /** * Stops the background data synchronization thread. */ @@ -493,7 +502,7 @@ public boolean doSyncPass() { logicalT)); return false; } - if (authMonitor == null || !authMonitor.isLoggedIn()) { + if (authMonitor == null || !authMonitor.tryIsLoggedIn()) { logger.info(String.format( Locale.US, "t='%d': doSyncPass END - Logged out", @@ -538,6 +547,7 @@ private void syncRemoteToLocal() { final Set unseenIds = nsConfig.getStaleDocumentIds(); final Set latestDocumentsFromStale = getLatestDocumentsForStaleFromRemote(nsConfig, unseenIds); + final Map latestDocumentMap = new HashMap<>(); for (final BsonDocument latestDocument : latestDocumentsFromStale) { @@ -656,7 +666,7 @@ private void syncRemoteChangeEventToLocal( logger.info(String.format( Locale.US, - "t='%d': syncRemoteChangeEventToLocal ns=%s documentId=%s processing operation='%s'", + "t='%d': syncRemoteChangeEventToLocal ns=%s documentId=%s processing remote operation='%s'", logicalT, nsConfig.getNamespace(), docConfig.getDocumentId(), @@ -951,7 +961,7 @@ private void syncLocalToRemote() { docConfig.getLastUncommittedChangeEvent(); logger.info(String.format( Locale.US, - "t='%d': syncLocalToRemote ns=%s documentId=%s processing operation='%s'", + "t='%d': syncLocalToRemote ns=%s documentId=%s processing local operation='%s'", logicalT, nsConfig.getNamespace(), docConfig.getDocumentId(), @@ -1720,8 +1730,7 @@ public void desyncDocumentsFromRemote( final BsonValue... documentIds ) { this.waitUntilInitialized(); - final Lock lock = - this.syncConfig.getNamespaceConfig(namespace).getLock().writeLock(); + final Lock lock = this.syncConfig.getNamespaceConfig(namespace).getLock().writeLock(); lock.lock(); try { ongoingOperationsGroup.enter(); @@ -1732,8 +1741,8 @@ public void desyncDocumentsFromRemote( getLocalCollection(namespace).deleteMany( new Document("_id", new Document("$in", Arrays.asList(documentIds)))); } finally { - ongoingOperationsGroup.exit(); lock.unlock(); + ongoingOperationsGroup.exit(); } triggerListeningToNamespace(namespace); diff --git a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DispatchGroup.java b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DispatchGroup.java index c0cf4af48..f072c3a10 100644 --- a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DispatchGroup.java +++ b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/DispatchGroup.java @@ -72,7 +72,7 @@ synchronized void exit() { /** * Block the group and wait for the remaining work to complete. */ - void blockAndWait() { + synchronized void blockAndWait() { isBlocked = true; while (count > 0) { try { diff --git a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListener.java b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListener.java index 5aa4f69b0..e2ca967f1 100644 --- a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListener.java +++ b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListener.java @@ -26,8 +26,8 @@ import com.mongodb.stitch.core.internal.net.Stream; import com.mongodb.stitch.core.services.internal.CoreStitchServiceClient; +import java.io.Closeable; import java.io.IOException; -import java.io.InterruptedIOException; import java.lang.ref.WeakReference; import java.util.Collections; import java.util.HashMap; @@ -46,7 +46,7 @@ import org.bson.diagnostics.Logger; import org.bson.diagnostics.Loggers; -public class NamespaceChangeStreamListener { +public class NamespaceChangeStreamListener implements Closeable { private final MongoNamespace namespace; private final NamespaceSynchronizationConfig nsConfig; private final CoreStitchServiceClient service; @@ -101,6 +101,12 @@ public void start() { * Stops the background stream thread. */ public void stop() { + if (runnerThread == null) { + return; + } + + runnerThread.interrupt(); + nsLock.writeLock().lock(); try { if (runnerThread == null) { @@ -108,6 +114,8 @@ public void stop() { } this.cancel(); + this.close(); + while (runnerThread.isAlive()) { runnerThread.interrupt(); try { @@ -147,7 +155,8 @@ private void cancel() { } } - private void close() { + @Override + public void close() { if (currentStream != null) { try { currentStream.close(); @@ -163,6 +172,7 @@ private void close() { /** * Whether or not the current stream is currently open. + * * @return true if open, false if not */ public synchronized boolean isOpen() { @@ -171,42 +181,52 @@ public synchronized boolean isOpen() { /** * Open the event stream + * * @return true if successfully opened, false if not */ - boolean openStream() throws InterruptedException { + boolean openStream() throws InterruptedException, IOException { logger.info("stream START"); + final boolean isOpen; + final Set idsToWatch = nsConfig.getSynchronizedDocumentIds(); + if (!networkMonitor.isConnected()) { logger.info("stream END - Network disconnected"); return false; } - if (!authMonitor.isLoggedIn()) { - logger.info("stream END - Logged out"); - return false; - } - if (nsConfig.getSynchronizedDocumentIds().isEmpty()) { + if (idsToWatch.isEmpty()) { logger.info("stream END - No synchronized documents"); return false; } - final Document args = new Document(); - args.put("database", namespace.getDatabaseName()); - args.put("collection", namespace.getCollectionName()); + nsLock.writeLock().lockInterruptibly(); + try { + if (!authMonitor.isLoggedIn()) { + logger.info("stream END - Logged out"); + return false; + } - final Set idsToWatch = nsConfig.getSynchronizedDocumentIds(); - args.put("ids", idsToWatch); + final Document args = new Document(); + args.put("database", namespace.getDatabaseName()); + args.put("collection", namespace.getCollectionName()); + args.put("ids", idsToWatch); - currentStream = - service.streamFunction( - "watch", - Collections.singletonList(args), - ChangeEvent.changeEventCoder); + currentStream = + service.streamFunction( + "watch", + Collections.singletonList(args), + ChangeEvent.changeEventCoder); - if (currentStream.isOpen()) { - this.nsConfig.setStale(true); + if (currentStream != null && currentStream.isOpen()) { + this.nsConfig.setStale(true); + isOpen = true; + } else { + isOpen = false; + } + } finally { + nsLock.writeLock().unlock(); } - - return currentStream.isOpen(); + return isOpen; } /** @@ -216,6 +236,10 @@ void storeNextEvent() { try { if (currentStream != null && currentStream.isOpen()) { final StitchEvent> event = currentStream.nextEvent(); + if (event == null) { + return; + } + if (event.getError() != null) { throw event.getError(); } @@ -238,15 +262,14 @@ void storeNextEvent() { watcher.onComplete(OperationResult.successfulResultOf(event.getData())); } } - } catch (final InterruptedIOException | InterruptedException ex) { - logger.error(String.format( + } catch (final InterruptedException | IOException ex) { + logger.info(String.format( Locale.US, - "NamespaceChangeStreamListener::stream ns=%s interrupted exception on " + "NamespaceChangeStreamListener::stream ns=%s interrupted on " + "fetching next event: %s", nsConfig.getNamespace(), - ex), ex); - logger.info("stream END"); - this.close(); + ex)); + logger.info("stream END – INTERRUPTED"); Thread.currentThread().interrupt(); } catch (final Exception ex) { // TODO: Emit error through DataSynchronizer as an ifc @@ -255,7 +278,7 @@ void storeNextEvent() { "NamespaceChangeStreamListener::stream ns=%s exception on fetching next event: %s", nsConfig.getNamespace(), ex), ex); - logger.info("stream END"); + logger.info("stream END – EXCEPTION"); final boolean wasInterrupted = Thread.currentThread().isInterrupted(); this.close(); if (wasInterrupted) { @@ -296,7 +319,7 @@ public Map> getEvents() { * @return the latest unprocessed change event for the given document ID, or null if none exists. */ public @Nullable ChangeEvent getUnprocessedEventForDocumentId( - final BsonValue documentId + final BsonValue documentId ) { final ChangeEvent event; nsLock.readLock().lock(); diff --git a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamRunner.java b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamRunner.java index 5240a8720..098dc6de8 100644 --- a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamRunner.java +++ b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamRunner.java @@ -19,6 +19,8 @@ import com.mongodb.MongoInterruptedException; import com.mongodb.stitch.core.internal.net.NetworkMonitor; +import java.io.Closeable; +import java.io.InterruptedIOException; import java.lang.ref.WeakReference; import org.bson.diagnostics.Logger; @@ -26,7 +28,7 @@ /** * This runner runs {@link DataSynchronizer#doSyncPass()} on a periodic interval. */ -class NamespaceChangeStreamRunner implements Runnable { +class NamespaceChangeStreamRunner implements Runnable, Closeable { private static final Long RETRY_SLEEP_MILLIS = 5000L; private final WeakReference listenerRef; @@ -55,13 +57,22 @@ public synchronized void run() { if (!isOpen) { try { isOpen = listener.openStream(); - } catch (final MongoInterruptedException | InterruptedException ex) { - logger.error("NamespaceChangeStreamRunner::run error happened while opening stream:", ex); + } catch (final MongoInterruptedException ex) { + logger.error( + "NamespaceChangeStreamRunner::run error happened while opening stream:", ex); + close(); + return; + } catch (final InterruptedException | InterruptedIOException e) { + close(); return; } catch (final Throwable t) { - logger.error("NamespaceChangeStreamRunner::run error happened while opening stream:", t); if (Thread.currentThread().isInterrupted()) { + logger.info("NamespaceChangeStreamRunner::stream interrupted:"); + close(); return; + } else { + logger.error( + "NamespaceChangeStreamRunner::run error happened while opening stream:", t); } } @@ -70,13 +81,30 @@ public synchronized void run() { wait(RETRY_SLEEP_MILLIS); } } catch (final InterruptedException e) { + close(); return; } } if (isOpen) { - listener.storeNextEvent(); + try { + listener.storeNextEvent(); + } catch (final IllegalStateException e) { + logger.info(String.format( + "NamespaceChangeStreamRunner::stream %s: ", e.getLocalizedMessage())); + return; + } } } while (networkMonitor.isConnected() && !Thread.currentThread().isInterrupted()); } + + @Override + public void close() { + final NamespaceChangeStreamListener listener = listenerRef.get(); + if (listener == null) { + return; + } + + listener.close(); + } } diff --git a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncMongoClientFactory.java b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncMongoClientFactory.java index 4b9a37419..a99e05f99 100644 --- a/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncMongoClientFactory.java +++ b/core/services/mongodb-remote/src/main/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncMongoClientFactory.java @@ -20,6 +20,8 @@ import com.mongodb.stitch.core.StitchAppClientInfo; import com.mongodb.stitch.core.services.mongodb.local.internal.EmbeddedMongoClientFactory; +import java.io.File; + public abstract class SyncMongoClientFactory { public static MongoClient getClient( final StitchAppClientInfo appInfo, @@ -31,7 +33,7 @@ public static MongoClient getClient( throw new IllegalArgumentException("StitchAppClient not configured with a data directory"); } - final String userId = appInfo.getAuthMonitor().isLoggedIn() + final String userId = appInfo.getAuthMonitor().tryIsLoggedIn() ? appInfo.getAuthMonitor().getActiveUserId() : "unbound"; final String instanceKey = String.format( "%s-%s_sync_%s_%s", appInfo.getClientAppId(), dataDir, serviceName, userId); @@ -39,4 +41,42 @@ public static MongoClient getClient( "%s/%s/sync_mongodb_%s/%s/0/", dataDir, appInfo.getClientAppId(), serviceName, userId); return clientFactory.getClient(instanceKey, dbPath, appInfo.getCodecRegistry()); } + + /** + * Delete a database for a given path and userId. + * @param appInfo the info for this application + * @param serviceName the name of the associated service + * @param clientFactory the associated factory that creates clients + * @param userId the id of the user's to delete + * @return true if successfully deleted, false if not + */ + public static boolean deleteDatabase(final StitchAppClientInfo appInfo, + final String serviceName, + final EmbeddedMongoClientFactory clientFactory, + final String userId) { + final String dataDir = appInfo.getDataDirectory(); + if (dataDir == null) { + throw new IllegalArgumentException("StitchAppClient not configured with a data directory"); + } + + final String instanceKey = String.format( + "%s-%s_sync_%s_%s", appInfo.getClientAppId(), dataDir, serviceName, userId); + final String dbPath = String.format( + "%s/%s/sync_mongodb_%s/%s/0/", dataDir, appInfo.getClientAppId(), serviceName, userId); + final MongoClient client = + clientFactory.getClient(instanceKey, dbPath, appInfo.getCodecRegistry()); + + for (final String listDatabaseName : client.listDatabaseNames()) { + try { + client.getDatabase(listDatabaseName).drop(); + } catch (Exception e) { + // do nothing + } + } + + client.close(); + clientFactory.removeClient(instanceKey); + + return new File(dbPath).delete(); + } } diff --git a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/internal/TestUtils.java b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/internal/TestUtils.java index ac355c2bd..02c1a9888 100644 --- a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/internal/TestUtils.java +++ b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/internal/TestUtils.java @@ -108,6 +108,11 @@ public boolean isLoggedIn() { public String getActiveUserId() { return "bound"; } + + @Override + public boolean tryIsLoggedIn() { + return true; + } }, new ThreadDispatcher()); } diff --git a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/CoreDocumentSynchronizationConfigUnitTests.kt b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/CoreDocumentSynchronizationConfigUnitTests.kt index 8889ead16..9bee44ff6 100644 --- a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/CoreDocumentSynchronizationConfigUnitTests.kt +++ b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/CoreDocumentSynchronizationConfigUnitTests.kt @@ -46,6 +46,10 @@ class CoreDocumentSynchronizationConfigUnitTests { override fun getActiveUserId(): String? { return "bound" } + + override fun tryIsLoggedIn(): Boolean { + return true + } } private val localClient = SyncMongoClientFactory.getClient( StitchAppClientInfo( diff --git a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListenerUnitTests.kt b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListenerUnitTests.kt index 72907e3f2..1b6d5c227 100644 --- a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListenerUnitTests.kt +++ b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/NamespaceChangeStreamListenerUnitTests.kt @@ -40,7 +40,7 @@ class NamespaceChangeStreamListenerUnitTests { // assert the stream does not open since we have no document ids ctx.isLoggedIn = true assertFalse(namespaceChangeStreamListener.openStream()) - verify(nsConfigMock, times(1)).synchronizedDocumentIds + verify(nsConfigMock, times(3)).synchronizedDocumentIds // assert and verify that our stream has opened, and that the streamFunction // method has been called with the appropriate arguments. verify that we have diff --git a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncUnitTestHarness.kt b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncUnitTestHarness.kt index a73216fc1..2d1a17e4f 100644 --- a/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncUnitTestHarness.kt +++ b/core/services/mongodb-remote/src/test/java/com/mongodb/stitch/core/services/mongodb/remote/sync/internal/SyncUnitTestHarness.kt @@ -122,6 +122,10 @@ class SyncUnitTestHarness : Closeable { return isAuthed } + override fun tryIsLoggedIn(): Boolean { + return isAuthed + } + override fun getActiveUserId(): String? { return userId } @@ -371,6 +375,7 @@ class SyncUnitTestHarness : Closeable { networkMonitor.addNetworkStateListener(dataSynchronizer) dataSynchronizer.disableSyncThread() + dataSynchronizer.disableListeners() dataSynchronizer.stop() diff --git a/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestProxy.kt b/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestProxy.kt index ac26d4754..4980735f6 100644 --- a/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestProxy.kt +++ b/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestProxy.kt @@ -1781,6 +1781,14 @@ class SyncIntTestProxy(private val syncTestRunner: SyncIntTestRunner) { assertNull(coll.find(doc1Filter).firstOrNull()) assertEquals(docToInsertUser2, coll.find(doc2Filter).firstOrNull()) assertNull(coll.find(doc3Filter).firstOrNull()) + + syncTestRunner.removeUser(syncTestRunner.userId2) + + syncTestRunner.reloginUser2() + + assertNull(coll.find(doc1Filter).firstOrNull()) + assertNull(coll.find(doc2Filter).firstOrNull()) + assertNull(coll.find(doc3Filter).firstOrNull()) } private fun watchForEvents(namespace: MongoNamespace, n: Int = 1): Semaphore { diff --git a/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestRunner.kt b/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestRunner.kt index 592624d27..717bd3bee 100644 --- a/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestRunner.kt +++ b/core/testutils/src/main/java/com/mongodb/stitch/core/testutils/sync/SyncIntTestRunner.kt @@ -46,8 +46,12 @@ interface SyncIntTestRunner { fun listUsers(): List + fun reloginUser2() + fun switchUser(userId: String) + fun removeUser(userId: String) + fun currentUserId(): String? /** diff --git a/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuth.java b/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuth.java index 2e64495e1..f2e92fbce 100644 --- a/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuth.java +++ b/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuth.java @@ -121,4 +121,19 @@ T getProviderClient( * @throws IllegalArgumentException throws if user id not found */ StitchUser switchToUserWithId(final String userId) throws IllegalArgumentException; + + /** + * Adds a listener for any important auth event. + * + * @param listener the listener to add. + * @see StitchAuthListener + */ + void addAuthListener(final StitchAuthListener listener); + + /** + * Removes a listener. + * + * @param listener the listener to remove. + */ + void removeAuthListener(final StitchAuthListener listener); } diff --git a/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuthListener.java b/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuthListener.java new file mode 100644 index 000000000..8b35686ba --- /dev/null +++ b/server/core/src/main/java/com/mongodb/stitch/server/core/auth/StitchAuthListener.java @@ -0,0 +1,142 @@ +/* + * Copyright 2018-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.stitch.server.core.auth; + +import javax.annotation.Nullable; + +/** + * StitchAuthListener allows one to hook into authentication events as they happen in a Stitch app + * client. + */ +public interface StitchAuthListener { + /** + * onAuthEvent is called any time a notable event regarding authentication happens. + * Some of these events are: + * - When a user logs in. + * - When a user logs out. + * - When a user is linked to another identity. + * - When a listener is registered. This is to handle the case where during registration + * an event happens that the registerer would otherwise miss out on. + * - When switching users. + * + * @param auth the instance of {@link StitchAuth} where the event happened. It should be used to + * infer the current state of authentication. + */ + @Deprecated + default void onAuthEvent(final StitchAuth auth) { + + } + + /** + * Called whenever a user is added to the device for the first time. If this + * is as part of a login, this method will be called before + * {@link #onUserLoggedIn}, and {@link #onActiveUserChanged} + * are called. + * + * @param auth The instance of {@link StitchAuth} where the user was added. + * It can be used to infer the current state of authentication. + * @param addedUser The user that was added to the device. + */ + default void onUserAdded(final StitchAuth auth, final StitchUser addedUser) { + } + + /** + * Called whenever a user is logged in. This will be called before + * {@link #onActiveUserChanged} is called. + * Note: if an anonymous user was already logged in on the device, and you + * log in with an {@link com.mongodb.stitch.core.auth.providers.anonymous.AnonymousCredential}, + * this method will not be called, + * as the underlying {@link StitchAuth} will reuse the anonymous user's existing + * session, and will thus only trigger {@link #onActiveUserChanged}. + * + * @param auth The instance of {@link StitchAuth} where the user was logged in. + * It can be used to infer the current state of authentication. + * @param loggedInUser The user that was logged in. + */ + default void onUserLoggedIn(final StitchAuth auth, + final StitchUser loggedInUser) { + } + + /** + * Called whenever a user is linked to a new identity. + * + * @param auth The instance of {@link StitchAuth} where the user was linked. + * It can be used to infer the current state of authentication. + * @param linkedUser The user that was linked to a new identity. + */ + default void onUserLinked(final StitchAuth auth, final StitchUser linkedUser) { + + } + + /** + * Called whenever a user is logged out. The user logged out is not + * necessarily the active user. If the user logged out was the active user, + * then {@link #onActiveUserChanged} will be called after this method. If the user + * was an anonymous user, that user will also be removed and + * {@link #onUserRemoved} will also be called. + * + * @param auth The instance of {@link StitchAuth} where the user was logged out. + * It can be used to infer the current state of authentication. + * @param loggedOutUser The user that was logged out. + */ + default void onUserLoggedOut(final StitchAuth auth, final StitchUser loggedOutUser) { + + } + + /** + * Called whenever the active user changes. This may be due to a call to + * {@link StitchAuth#loginWithCredential}, {@link StitchAuth#switchToUserWithId}, + * {@link StitchAuth#logout}, {@link StitchAuth#logoutUserWithId}, + * {@link StitchAuth#removeUser}, or {@link StitchAuth#removeUserWithId}. + * This may also occur on a normal request if a user's session is invalidated + * and they are forced to log out. + * + * @param auth The instance of {@link StitchAuth} where the active user changed. + * It can be used to infer the current state of authentication. + * @param currentActiveUser The active user after the change. + * @param previousActiveUser The active user before the change. + */ + default void onActiveUserChanged(final StitchAuth auth, + @Nullable final StitchUser currentActiveUser, + @Nullable final StitchUser previousActiveUser) { + + } + + /** + * Called whenever a user is removed from the list of users on the device. + * + * @param auth The instance of {@link StitchAuth} where the user was removed. + * It can be used to infer the current state of authentication. + * @param removedUser The user that was removed. + */ + default void onUserRemoved(final StitchAuth auth, final StitchUser removedUser) { + + } + + /** + * Called whenever this listener is registered for the first time. This can + * be useful to infer the state of authentication, because any events that + * occurred before the listener was registered will not be seen by the + * listener. + * + * @param auth The instance of {@link StitchAuth} where the listener was registered. + * It can be used to infer the current state of authentication. + */ + default void onListenerRegistered(final StitchAuth auth) { + + } +} diff --git a/server/core/src/main/java/com/mongodb/stitch/server/core/auth/internal/StitchAuthImpl.java b/server/core/src/main/java/com/mongodb/stitch/server/core/auth/internal/StitchAuthImpl.java index c5e9ab08a..e30f5ebcb 100644 --- a/server/core/src/main/java/com/mongodb/stitch/server/core/auth/internal/StitchAuthImpl.java +++ b/server/core/src/main/java/com/mongodb/stitch/server/core/auth/internal/StitchAuthImpl.java @@ -24,17 +24,31 @@ import com.mongodb.stitch.core.auth.internal.StitchAuthRoutes; import com.mongodb.stitch.core.auth.internal.StitchUserFactory; import com.mongodb.stitch.core.internal.common.Storage; +import com.mongodb.stitch.core.internal.common.ThreadDispatcher; import com.mongodb.stitch.core.internal.net.StitchRequestClient; import com.mongodb.stitch.server.core.Stitch; import com.mongodb.stitch.server.core.auth.StitchAuth; +import com.mongodb.stitch.server.core.auth.StitchAuthListener; import com.mongodb.stitch.server.core.auth.StitchUser; import com.mongodb.stitch.server.core.auth.providers.internal.AuthProviderClientFactory; import com.mongodb.stitch.server.core.auth.providers.internal.NamedAuthProviderClientFactory; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Callable; + +import javax.annotation.Nullable; + import org.bson.Document; public final class StitchAuthImpl extends CoreStitchAuth implements StitchAuth { private final StitchAppClientInfo appInfo; + private final ThreadDispatcher dispatcher; + + /** + * A set of auth listeners that should be dispatched to asynchronously + */ + private final Set listeners = new HashSet<>(); public StitchAuthImpl( final StitchRequestClient requestClient, @@ -43,6 +57,7 @@ public StitchAuthImpl( final StitchAppClientInfo appInfo) { super(requestClient, authRoutes, storage, true); this.appInfo = appInfo; + this.dispatcher = new ThreadDispatcher(); } protected StitchUserFactory getUserFactory() { @@ -90,6 +105,168 @@ public void removeUserWithId(final String userId) { removeUserWithIdInternal(userId); } + /** + * Adds a listener for any important auth event. + * + * @see StitchAuthListener + */ + @Override + public void addAuthListener(final StitchAuthListener listener) { + synchronized (this) { + listeners.add(listener); + } + + // Trigger the onUserLoggedIn event in case some event happens and + // this caller would miss out on this event other wise. + onAuthEvent(listener); + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onListenerRegistered(StitchAuthImpl.this); + return null; + } + }); + } + + /** + * Removes a listener. + * + * @see StitchAuthListener + */ + @Override + public synchronized void removeAuthListener(final StitchAuthListener listener) { + listeners.remove(listener); + } + + private void onAuthEvent(final StitchAuthListener listener) { + final StitchAuth auth = this; + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onAuthEvent(auth); + return null; + } + }); + } + + @Override + protected void onAuthEvent() { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onAuthEvent(StitchAuthImpl.this); + return null; + } + }); + } + } + + @Override + protected void onListenerInitialized() { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onListenerRegistered(StitchAuthImpl.this); + return null; + } + }); + } + } + + @Override + protected void onActiveUserChanged(@Nullable final StitchUser currentActiveUser, + @Nullable final StitchUser previousActiveUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onActiveUserChanged( + StitchAuthImpl.this, currentActiveUser, previousActiveUser); + return null; + } + }); + } + } + + @Override + protected void onUserAdded(final StitchUser createdUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onUserAdded( + StitchAuthImpl.this, createdUser); + return null; + } + }); + } + } + + @Override + protected void onUserLoggedIn(final StitchUser loggedInUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onUserLoggedIn( + StitchAuthImpl.this, loggedInUser); + return null; + } + }); + } + } + + @Override + protected void onUserRemoved(final StitchUser removedUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onUserRemoved(StitchAuthImpl.this, removedUser); + return null; + } + }); + } + } + + @Override + protected void onUserLoggedOut(final StitchUser loggedOutUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onUserLoggedOut(StitchAuthImpl.this, loggedOutUser); + return null; + } + }); + } + } + + @Override + protected void onUserLinked(final StitchUser linkedUser) { + for (final StitchAuthListener listener : listeners) { + dispatcher.dispatch( + new Callable() { + @Override + public Void call() { + listener.onUserLinked(StitchAuthImpl.this, linkedUser); + return null; + } + }); + } + } + @Override protected Document getDeviceInfo() { final Document info = new Document(); @@ -112,7 +289,4 @@ protected Document getDeviceInfo() { return info; } - - @Override - protected void onAuthEvent() {} } diff --git a/server/core/src/main/java/com/mongodb/stitch/server/core/internal/StitchAppClientImpl.java b/server/core/src/main/java/com/mongodb/stitch/server/core/internal/StitchAppClientImpl.java index 169bdd838..47af08ef5 100644 --- a/server/core/src/main/java/com/mongodb/stitch/server/core/internal/StitchAppClientImpl.java +++ b/server/core/src/main/java/com/mongodb/stitch/server/core/internal/StitchAppClientImpl.java @@ -18,6 +18,7 @@ import com.mongodb.stitch.core.StitchAppClientConfiguration; import com.mongodb.stitch.core.StitchAppClientInfo; +import com.mongodb.stitch.core.auth.internal.CoreStitchAuth; import com.mongodb.stitch.core.internal.CoreStitchAppClient; import com.mongodb.stitch.core.internal.common.AuthMonitor; import com.mongodb.stitch.core.internal.common.ThreadDispatcher; @@ -181,8 +182,17 @@ public ResultT callFunction( } @Override - public boolean isLoggedIn() { - return getAuth().isLoggedIn(); + public boolean isLoggedIn() throws InterruptedException { + return ((CoreStitchAuth)getAuth()).isLoggedInInterruptibly(); + } + + @Override + public boolean tryIsLoggedIn() { + try { + return ((CoreStitchAuth)getAuth()).isLoggedInInterruptibly(); + } catch (InterruptedException e) { + return false; + } } @Nullable diff --git a/server/coretest/src/test/java/com/mongodb/stitch/server/core/StitchAuthListenerIntTests.kt b/server/coretest/src/test/java/com/mongodb/stitch/server/core/StitchAuthListenerIntTests.kt new file mode 100644 index 000000000..ea1638fdc --- /dev/null +++ b/server/coretest/src/test/java/com/mongodb/stitch/server/core/StitchAuthListenerIntTests.kt @@ -0,0 +1,269 @@ +package com.mongodb.stitch.server.core + +import com.mongodb.stitch.core.admin.authProviders.ProviderConfigs +import com.mongodb.stitch.core.admin.userRegistrations.sendConfirmation +import com.mongodb.stitch.core.auth.providers.anonymous.AnonymousCredential +import com.mongodb.stitch.core.auth.providers.userpassword.UserPasswordCredential +import com.mongodb.stitch.server.core.auth.StitchAuth +import com.mongodb.stitch.server.core.auth.StitchAuthListener +import com.mongodb.stitch.server.core.auth.StitchUser +import com.mongodb.stitch.server.core.auth.providers.userpassword.UserPasswordAuthProviderClient +import com.mongodb.stitch.server.testutils.BaseStitchServerIntTest +import org.junit.Assert +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class StitchAuthListenerIntTests : BaseStitchServerIntTest() { + + @Test + fun testOnUserLoggedInDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onUserLoggedIn(auth: StitchAuth?, loggedInUser: StitchUser?) { + Assert.assertNotNull(auth) + Assert.assertNotNull(loggedInUser) + countDownLatch.countDown() + } + }) + + Assert.assertFalse(client.auth.isLoggedIn) + Assert.assertNull(client.auth.user) + + client.auth.loginWithCredential(AnonymousCredential()) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnAddedUserDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onUserAdded(auth: StitchAuth?, addedUser: StitchUser?) { + Assert.assertNotNull(auth) + Assert.assertNotNull(addedUser) + countDownLatch.countDown() + } + }) + + Assert.assertFalse(client.auth.isLoggedIn) + Assert.assertNull(client.auth.user) + + client.auth.loginWithCredential(AnonymousCredential()) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnActiveUserChangedDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onActiveUserChanged( + auth: StitchAuth?, + currentActiveUser: StitchUser?, + previousActiveUser: StitchUser? + ) { + Assert.assertNotNull(auth) + Assert.assertNotNull(currentActiveUser) + Assert.assertNotNull(previousActiveUser) + countDownLatch.countDown() + } + }) + + Assert.assertFalse(client.auth.isLoggedIn) + Assert.assertNull(client.auth.user) + + client.auth.loginWithCredential(AnonymousCredential()) + registerAndLoginWithUserPass(app.second, client, "email@10gen.com", "tester10") + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnUserLoggedOutDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserLoggedOut( + auth: StitchAuth?, + loggedOutUser: StitchUser? + ) { + Assert.assertNotNull(auth) + Assert.assertNotNull(loggedOutUser) + countDownLatch.countDown() + } + }) + + Assert.assertFalse(client.auth.isLoggedIn) + Assert.assertNull(client.auth.user) + + client.auth.loginWithCredential(AnonymousCredential()) + registerAndLoginWithUserPass(app.second, client, "email@10gen.com", "tester10") + client.auth.logout() + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnUserRemovedDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onUserRemoved(auth: StitchAuth?, removedUser: StitchUser?) { + Assert.assertNotNull(auth) + Assert.assertNotNull(removedUser) + countDownLatch.countDown() + } + }) + + Assert.assertFalse(client.auth.isLoggedIn) + Assert.assertNull(client.auth.user) + + client.auth.loginWithCredential(AnonymousCredential()) + + client.auth.removeUser() + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnUserLinkedDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onAuthEvent(auth: StitchAuth?) { + } + + override fun onUserLinked(auth: StitchAuth?, linkedUser: StitchUser?) { + Assert.assertNotNull(auth) + Assert.assertNotNull(linkedUser) + countDownLatch.countDown() + } + }) + + Assert.assertFalse(client.auth.isLoggedIn) + Assert.assertNull(client.auth.user) + + val userPassClient = client.auth.getProviderClient(UserPasswordAuthProviderClient.factory) + + val email = "user@10gen.com" + val password = "password" + userPassClient.registerWithEmail(email, password) + + val conf = app.second.userRegistrations.sendConfirmation(email) + userPassClient.confirmUser(conf.token, conf.tokenId) + + val anonUser = client.auth.loginWithCredential(AnonymousCredential()) + + anonUser.linkWithCredential( + UserPasswordCredential(email, password)) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } + + @Test + fun testOnListenerRegisteredDispatched() { + val app = createApp() + + addProvider(app.second, ProviderConfigs.Anon) + addProvider(app.second, config = ProviderConfigs.Userpass( + emailConfirmationUrl = "http://emailConfirmURL.com", + resetPasswordUrl = "http://resetPasswordURL.com", + confirmEmailSubject = "email subject", + resetPasswordSubject = "password subject") + ) + + val client = getAppClient(app.first) + + val countDownLatch = CountDownLatch(1) + + client.auth.addAuthListener(object : StitchAuthListener { + override fun onListenerRegistered(auth: StitchAuth?) { + Assert.assertNotNull(auth) + countDownLatch.countDown() + } + }) + + assert(countDownLatch.await(10, TimeUnit.SECONDS)) + } +}