From 4ebf218443c56d71dc2e1dc8035ea9d64a75d4b4 Mon Sep 17 00:00:00 2001 From: Jordan Haven Date: Thu, 30 Nov 2023 11:43:16 -0500 Subject: [PATCH 1/4] Add a fallback to reset encryptedsharedpreferences when the key is borked; Change name of "activeSessionExists" for clarity; Add session auth/updater calls to app initialization --- sdk/build.gradle | 2 +- .../com/stytch/sdk/b2b/StytchB2BClient.kt | 7 +++++ .../sdk/b2b/sessions/B2BSessionStorage.kt | 2 +- .../stytch/sdk/common/EncryptionManager.kt | 28 +++++++++++++++---- .../com/stytch/sdk/consumer/StytchClient.kt | 7 +++++ .../sdk/consumer/magicLinks/MagicLinksImpl.kt | 2 +- .../com/stytch/sdk/consumer/otp/OTPImpl.kt | 6 ++-- .../sdk/consumer/passkeys/PasskeysImpl.kt | 3 +- .../sessions/ConsumerSessionStorage.kt | 2 +- .../consumer/magicLinks/MagicLinksImplTest.kt | 6 ++-- .../stytch/sdk/consumer/otp/OTPImplTest.kt | 18 ++++++------ .../sdk/consumer/passkeys/PasskeysImplTest.kt | 10 +++---- 12 files changed, 62 insertions(+), 31 deletions(-) diff --git a/sdk/build.gradle b/sdk/build.gradle index 994ceb6ba..8279398ff 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -7,7 +7,7 @@ plugins { ext { PUBLISH_GROUP_ID = 'com.stytch.sdk' - PUBLISH_VERSION = '0.16.0' + PUBLISH_VERSION = '0.17.0' PUBLISH_ARTIFACT_ID = 'sdk' } diff --git a/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt b/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt index e073f3e32..340f47f11 100644 --- a/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt +++ b/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt @@ -5,6 +5,7 @@ import android.content.Context import android.net.Uri import com.stytch.sdk.b2b.discovery.Discovery import com.stytch.sdk.b2b.discovery.DiscoveryImpl +import com.stytch.sdk.b2b.extensions.launchSessionUpdater import com.stytch.sdk.b2b.magicLinks.B2BMagicLinks import com.stytch.sdk.b2b.magicLinks.B2BMagicLinksImpl import com.stytch.sdk.b2b.member.Member @@ -84,6 +85,12 @@ public object StytchB2BClient { bootstrapData.dfpProtectedAuthEnabled, bootstrapData.dfpProtectedAuthMode ) + // if there are session identifiers on device start the auto updater to ensure it is still valid + if (sessionStorage.persistedSessionIdentifiersExist) { + StytchB2BApi.Sessions.authenticate(null).apply { + launchSessionUpdater(dispatchers, sessionStorage) + } + } } } catch (ex: Exception) { throw StytchExceptions.Critical(ex) diff --git a/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt b/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt index d18836f77..50428ecc2 100644 --- a/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt +++ b/sdk/src/main/java/com/stytch/sdk/b2b/sessions/B2BSessionStorage.kt @@ -45,7 +45,7 @@ internal class B2BSessionStorage(private val storageHelper: StorageHelper) { } } - val activeSessionExists: Boolean + val persistedSessionIdentifiersExist: Boolean get() = sessionToken != null || sessionJwt != null /** diff --git a/sdk/src/main/java/com/stytch/sdk/common/EncryptionManager.kt b/sdk/src/main/java/com/stytch/sdk/common/EncryptionManager.kt index 332e492c8..13de589fd 100644 --- a/sdk/src/main/java/com/stytch/sdk/common/EncryptionManager.kt +++ b/sdk/src/main/java/com/stytch/sdk/common/EncryptionManager.kt @@ -1,20 +1,25 @@ package com.stytch.sdk.common import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.os.Build import com.google.crypto.tink.Aead import com.google.crypto.tink.KeyTemplates import com.google.crypto.tink.aead.AeadConfig import com.google.crypto.tink.integration.android.AndroidKeysetManager import com.google.crypto.tink.shaded.protobuf.ByteString +import com.google.crypto.tink.shaded.protobuf.InvalidProtocolBufferException import com.google.crypto.tink.signature.SignatureConfig import com.stytch.sdk.common.extensions.hexStringToByteArray import com.stytch.sdk.common.extensions.toBase64DecodedByteArray import com.stytch.sdk.common.extensions.toBase64EncodedString import com.stytch.sdk.common.extensions.toHexString import com.stytch.sdk.common.network.StytchErrorType +import java.io.File import java.security.MessageDigest import java.security.SecureRandom import kotlin.random.Random +import org.bouncycastle.asn1.x500.style.RFC4519Style.name import org.bouncycastle.crypto.Signer import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters @@ -36,11 +41,24 @@ internal object EncryptionManager { } private fun getOrGenerateNewAES256KeysetManager(context: Context, keyAlias: String): AndroidKeysetManager { - return AndroidKeysetManager.Builder() - .withSharedPref(context, keyAlias, PREF_FILE_NAME) - .withKeyTemplate(KeyTemplates.get("AES256_GCM")) - .withMasterKeyUri(MASTER_KEY_URI) - .build() + return try { + AndroidKeysetManager.Builder() + .withSharedPref(context, keyAlias, PREF_FILE_NAME) + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri(MASTER_KEY_URI) + .build() + } catch (_: InvalidProtocolBufferException) { + // possible that the signing key was changed (happens when we're testing, shouldn't happen for developers) + // but if it does, the app gets in a bad state, so we need to destroy and recreate the preferences file + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + context.deleteSharedPreferences(PREF_FILE_NAME) + } else { + context.getSharedPreferences(PREF_FILE_NAME, MODE_PRIVATE).edit().clear().apply() + val dir = File(context.applicationInfo.dataDir, "shared_prefs") + File(dir, "$name.xml").delete() + } + return getOrGenerateNewAES256KeysetManager(context, keyAlias) + } } /** diff --git a/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt b/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt index cd02c3bdf..055ef61df 100644 --- a/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt +++ b/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt @@ -23,6 +23,7 @@ import com.stytch.sdk.common.stytchError import com.stytch.sdk.consumer.biometrics.Biometrics import com.stytch.sdk.consumer.biometrics.BiometricsImpl import com.stytch.sdk.consumer.biometrics.BiometricsProviderImpl +import com.stytch.sdk.consumer.extensions.launchSessionUpdater import com.stytch.sdk.consumer.magicLinks.MagicLinks import com.stytch.sdk.consumer.magicLinks.MagicLinksImpl import com.stytch.sdk.consumer.network.StytchApi @@ -89,6 +90,12 @@ public object StytchClient { bootstrapData.dfpProtectedAuthEnabled, bootstrapData.dfpProtectedAuthMode ) + // if there are session identifiers on device start the auto updater to ensure it is still valid + if (sessionStorage.persistedSessionIdentifiersExist) { + StytchApi.Sessions.authenticate(null).apply { + launchSessionUpdater(dispatchers, sessionStorage) + } + } } } catch (ex: Exception) { throw StytchExceptions.Critical(ex) diff --git a/sdk/src/main/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImpl.kt b/sdk/src/main/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImpl.kt index 7f2c27cc2..52a0c5551 100644 --- a/sdk/src/main/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImpl.kt +++ b/sdk/src/main/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImpl.kt @@ -112,7 +112,7 @@ internal class MagicLinksImpl internal constructor( } catch (ex: Exception) { return@withContext StytchResult.Error(StytchExceptions.Critical(ex)) } - if (sessionStorage.activeSessionExists) { + if (sessionStorage.persistedSessionIdentifiersExist) { api.sendSecondary( email = parameters.email, loginMagicLinkUrl = parameters.loginMagicLinkUrl, diff --git a/sdk/src/main/java/com/stytch/sdk/consumer/otp/OTPImpl.kt b/sdk/src/main/java/com/stytch/sdk/consumer/otp/OTPImpl.kt index c7109c09c..49e1d6dd8 100644 --- a/sdk/src/main/java/com/stytch/sdk/consumer/otp/OTPImpl.kt +++ b/sdk/src/main/java/com/stytch/sdk/consumer/otp/OTPImpl.kt @@ -72,7 +72,7 @@ internal class OTPImpl internal constructor( override suspend fun send(parameters: OTP.SmsOTP.Parameters): OTPSendResponse = withContext(dispatchers.io) { - if (sessionStorage.activeSessionExists) { + if (sessionStorage.persistedSessionIdentifiersExist) { api.sendOTPWithSMSSecondary( phoneNumber = parameters.phoneNumber, expirationMinutes = parameters.expirationMinutes, @@ -120,7 +120,7 @@ internal class OTPImpl internal constructor( override suspend fun send(parameters: OTP.WhatsAppOTP.Parameters): OTPSendResponse = withContext(dispatchers.io) { - if (sessionStorage.activeSessionExists) { + if (sessionStorage.persistedSessionIdentifiersExist) { api.sendOTPWithWhatsAppSecondary( phoneNumber = parameters.phoneNumber, expirationMinutes = parameters.expirationMinutes, @@ -167,7 +167,7 @@ internal class OTPImpl internal constructor( } override suspend fun send(parameters: OTP.EmailOTP.Parameters): OTPSendResponse = withContext(dispatchers.io) { - if (sessionStorage.activeSessionExists) { + if (sessionStorage.persistedSessionIdentifiersExist) { api.sendOTPWithEmailSecondary( email = parameters.email, expirationMinutes = parameters.expirationMinutes, diff --git a/sdk/src/main/java/com/stytch/sdk/consumer/passkeys/PasskeysImpl.kt b/sdk/src/main/java/com/stytch/sdk/consumer/passkeys/PasskeysImpl.kt index 5f5bc6ad1..ecf8ab412 100644 --- a/sdk/src/main/java/com/stytch/sdk/consumer/passkeys/PasskeysImpl.kt +++ b/sdk/src/main/java/com/stytch/sdk/consumer/passkeys/PasskeysImpl.kt @@ -25,7 +25,6 @@ import com.stytch.sdk.consumer.sessions.ConsumerSessionStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.bouncycastle.asn1.x500.style.RFC4519Style.name internal interface PasskeysProvider { suspend fun createPublicKeyCredential( @@ -131,7 +130,7 @@ internal class PasskeysImpl internal constructor( if (!isSupported) return StytchResult.Error(StytchExceptions.Input("Passkeys are not supported")) return try { withContext(dispatchers.io) { - val startResponse = if (sessionStorage.activeSessionExists) { + val startResponse = if (sessionStorage.persistedSessionIdentifiersExist) { api.authenticateStartSecondary( domain = parameters.domain, isPasskey = true diff --git a/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt b/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt index 8830d2d28..89a3f7542 100644 --- a/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt +++ b/sdk/src/main/java/com/stytch/sdk/consumer/sessions/ConsumerSessionStorage.kt @@ -47,7 +47,7 @@ internal class ConsumerSessionStorage(private val storageHelper: StorageHelper) } } - val activeSessionExists: Boolean + val persistedSessionIdentifiersExist: Boolean get() = sessionToken != null || sessionJwt != null /** diff --git a/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt b/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt index 3cc44c989..56c1bb290 100644 --- a/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/consumer/magicLinks/MagicLinksImplTest.kt @@ -130,7 +130,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email send with active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns true + every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendSecondary(any(), any(), any(), any(), any(), any(), any(), any()) } returns successfulBaseResponse @@ -146,7 +146,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email send with no active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendPrimary(any(), any(), any(), any(), any(), any(), any(), any()) } returns successfulBaseResponse @@ -162,7 +162,7 @@ internal class MagicLinksImplTest { @Test fun `MagicLinksImpl email send with callback calls callback method`() { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendPrimary(any(), any(), any(), any(), any(), any(), any(), any()) } returns successfulBaseResponse diff --git a/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt b/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt index d62dbf804..bfcca4b8a 100644 --- a/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/consumer/otp/OTPImplTest.kt @@ -106,7 +106,7 @@ internal class OTPImplTest { @Test fun `OTPImpl sms send with active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns true + every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendOTPWithSMSSecondary(any(), any()) } returns mockk(relaxed = true) impl.sms.send( OTP.SmsOTP.Parameters( @@ -119,7 +119,7 @@ internal class OTPImplTest { @Test fun `OTPImpl sms send with no active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithSMSPrimary(any(), any()) } returns mockk(relaxed = true) impl.sms.send( OTP.SmsOTP.Parameters( @@ -132,7 +132,7 @@ internal class OTPImplTest { @Test fun `OTPImpl sms send with callback calls callback method`() { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithSMSPrimary(any(), any()) } returns mockk(relaxed = true) val mockCallback = spyk<(OTPSendResponse) -> Unit>() impl.sms.send( @@ -162,7 +162,7 @@ internal class OTPImplTest { @Test fun `OTPImpl whatsapp send with no active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithWhatsAppPrimary(any(), any()) } returns mockk(relaxed = true) impl.whatsapp.send( OTP.WhatsAppOTP.Parameters( @@ -175,7 +175,7 @@ internal class OTPImplTest { @Test fun `OTPImpl whatsapp send with active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns true + every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendOTPWithWhatsAppSecondary(any(), any()) } returns mockk(relaxed = true) impl.whatsapp.send( OTP.WhatsAppOTP.Parameters( @@ -188,7 +188,7 @@ internal class OTPImplTest { @Test fun `OTPImpl whatsapp send with callback calls callback method`() { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithWhatsAppPrimary(any(), any()) } returns mockk(relaxed = true) val mockCallback = spyk<(OTPSendResponse) -> Unit>() impl.whatsapp.send( @@ -218,7 +218,7 @@ internal class OTPImplTest { @Test fun `OTPImpl email send with no active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithEmailPrimary(any(), any(), any(), any()) } returns mockk(relaxed = true) impl.email.send( OTP.EmailOTP.Parameters( @@ -233,7 +233,7 @@ internal class OTPImplTest { @Test fun `OTPImpl email send with active session delegates to api`() = runTest { - every { mockSessionStorage.activeSessionExists } returns true + every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.sendOTPWithEmailSecondary(any(), any(), any(), any()) } returns mockk(relaxed = true) impl.email.send( OTP.EmailOTP.Parameters( @@ -248,7 +248,7 @@ internal class OTPImplTest { @Test fun `OTPImpl email send with callback calls callback method`() { - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.sendOTPWithEmailPrimary(any(), any(), any(), any()) } returns mockk(relaxed = true) val mockCallback = spyk<(OTPSendResponse) -> Unit>() impl.email.send( diff --git a/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt b/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt index 2055bda0e..bd1854f37 100644 --- a/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/consumer/passkeys/PasskeysImplTest.kt @@ -158,7 +158,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if authenticateStartSecondary api call fails`() = runTest { every { impl.isSupported } returns true - every { mockSessionStorage.activeSessionExists } returns true + every { mockSessionStorage.persistedSessionIdentifiersExist } returns true coEvery { mockApi.authenticateStartSecondary(any(), any()) } returns StytchResult.Error(mockk()) val result = impl.authenticate(mockk(relaxed = true)) assert(result is StytchResult.Error) @@ -171,7 +171,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if authenticateStartPrimary api call fails`() = runTest { every { impl.isSupported } returns true - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.authenticateStartPrimary(any(), any()) } returns StytchResult.Error(mockk()) val result = impl.authenticate(mockk(relaxed = true)) assert(result is StytchResult.Error) @@ -184,7 +184,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if getPublicKeyCredential call fails`() = runTest { every { impl.isSupported } returns true - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.authenticateStartPrimary(any(), any()) } returns StytchResult.Success(mockk(relaxed = true)) coEvery { mockPasskeysProvider.getPublicKeyCredential(any(), any(), any()) } throws Exception() val result = impl.authenticate(mockk(relaxed = true)) @@ -198,7 +198,7 @@ internal class PasskeysImplTest { @Test fun `authenticate returns error if authenticate api call fails`() = runTest { every { impl.isSupported } returns true - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.authenticateStartPrimary(any(), any()) } returns StytchResult.Success(mockk(relaxed = true)) coEvery { mockPasskeysProvider.getPublicKeyCredential(any(), any(), any()) } returns mockk(relaxed = true) coEvery { mockApi.authenticate(any(), any()) } returns StytchResult.Error(mockk(relaxed = true)) @@ -213,7 +213,7 @@ internal class PasskeysImplTest { @Test fun `authenticate calls launchSessionUpdater and returns success if authentication flow succeeds`() = runTest { every { impl.isSupported } returns true - every { mockSessionStorage.activeSessionExists } returns false + every { mockSessionStorage.persistedSessionIdentifiersExist } returns false coEvery { mockApi.authenticateStartPrimary(any(), any()) } returns StytchResult.Success(mockk(relaxed = true)) coEvery { mockPasskeysProvider.getPublicKeyCredential(any(), any(), any()) } returns mockk(relaxed = true) val mockSuccessResponse = mockk(relaxed = true) From 1a6aa3b42fa4cb2127ebb67866c609c70839a7b4 Mon Sep 17 00:00:00 2001 From: Jordan Haven Date: Thu, 30 Nov 2023 12:25:21 -0500 Subject: [PATCH 2/4] Add tests --- .../stytch/sdk/b2b/network/StytchB2BApi.kt | 2 +- .../com/stytch/sdk/b2b/StytchB2BClientTest.kt | 30 ++++++++++++++++++- .../stytch/sdk/consumer/StytchClientTest.kt | 28 ++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/com/stytch/sdk/b2b/network/StytchB2BApi.kt b/sdk/src/main/java/com/stytch/sdk/b2b/network/StytchB2BApi.kt index e0a69b09b..f488a436f 100644 --- a/sdk/src/main/java/com/stytch/sdk/b2b/network/StytchB2BApi.kt +++ b/sdk/src/main/java/com/stytch/sdk/b2b/network/StytchB2BApi.kt @@ -195,7 +195,7 @@ internal object StytchB2BApi { internal object Sessions { suspend fun authenticate( - sessionDurationMinutes: UInt? + sessionDurationMinutes: UInt? = null ): StytchResult = safeB2BApiCall { apiService.authenticateSessions( CommonRequests.Sessions.AuthenticateRequest( diff --git a/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt b/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt index c5f062c3e..c2d44840f 100644 --- a/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt @@ -3,8 +3,10 @@ package com.stytch.sdk.b2b import android.app.Application import android.content.Context import android.net.Uri +import com.stytch.sdk.b2b.extensions.launchSessionUpdater import com.stytch.sdk.b2b.magicLinks.B2BMagicLinks import com.stytch.sdk.b2b.network.StytchB2BApi +import com.stytch.sdk.b2b.network.models.IB2BAuthData import com.stytch.sdk.common.DeeplinkHandledStatus import com.stytch.sdk.common.DeeplinkResponse import com.stytch.sdk.common.DeviceInfo @@ -12,6 +14,7 @@ import com.stytch.sdk.common.EncryptionManager import com.stytch.sdk.common.StorageHelper import com.stytch.sdk.common.StytchDispatchers import com.stytch.sdk.common.StytchExceptions +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.extensions.getDeviceInfo import com.stytch.sdk.common.network.StytchErrorType import com.stytch.sdk.common.stytchError @@ -57,7 +60,10 @@ internal class StytchB2BClientTest { fun before() { Dispatchers.setMain(mainThreadSurrogate) mockkStatic(KeyStore::class) - mockkStatic("com.stytch.sdk.common.extensions.ContextExtKt") + mockkStatic( + "com.stytch.sdk.common.extensions.ContextExtKt", + "com.stytch.sdk.b2b.extensions.StytchResultExtKt" + ) mockkObject(EncryptionManager) every { EncryptionManager.createNewKeys(any(), any()) } returns Unit val mockApplication: Application = mockk { @@ -70,10 +76,12 @@ internal class StytchB2BClientTest { every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) mockkObject(StorageHelper) mockkObject(StytchB2BApi) + mockkObject(StytchB2BApi.Sessions) every { StorageHelper.initialize(any()) } just runs every { StorageHelper.loadValue(any()) } returns "" every { StorageHelper.generateHashedCodeChallenge() } returns Pair("", "") MockKAnnotations.init(this, true, true) + coEvery { StytchB2BApi.getBootstrapData() } returns StytchResult.Error(mockk()) StytchB2BClient.magicLinks = mockMagicLinks StytchB2BClient.externalScope = TestScope() StytchB2BClient.dispatchers = StytchDispatchers(dispatcher, dispatcher) @@ -130,6 +138,26 @@ internal class StytchB2BClientTest { } } + @Test + fun `should validate persisted sessions if applicable when calling StytchB2BClient configure`() { + runBlocking { + val mockResponse: StytchResult = mockk { + every { launchSessionUpdater(any(), any()) } just runs + } + coEvery { StytchB2BApi.Sessions.authenticate(any()) } returns mockResponse + // no session data == no authentication/updater + every { StorageHelper.loadValue(any()) } returns null + StytchB2BClient.configure(mContextMock, "") + coVerify(exactly = 0) { StytchB2BApi.Sessions.authenticate(any()) } + verify(exactly = 0) { mockResponse.launchSessionUpdater(any(), any()) } + // yes session data == yes authentication/updater + every { StorageHelper.loadValue(any()) } returns "some-session-data" + StytchB2BClient.configure(mContextMock, "") + coVerify(exactly = 1) { StytchB2BApi.Sessions.authenticate() } + verify(exactly = 1) { mockResponse.launchSessionUpdater(any(), any()) } + } + } + @Test(expected = StytchExceptions.Critical::class) fun `an exception in StytchB2BClient configure throws a Critical exception`() { every { StorageHelper.initialize(any()) } throws RuntimeException("Test") diff --git a/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt b/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt index 5158c05a9..3b67685b3 100644 --- a/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt @@ -14,8 +14,10 @@ import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.extensions.getDeviceInfo import com.stytch.sdk.common.network.StytchErrorType import com.stytch.sdk.common.stytchError +import com.stytch.sdk.consumer.extensions.launchSessionUpdater import com.stytch.sdk.consumer.magicLinks.MagicLinks import com.stytch.sdk.consumer.network.StytchApi +import com.stytch.sdk.consumer.network.models.AuthData import com.stytch.sdk.consumer.oauth.OAuth import io.mockk.MockKAnnotations import io.mockk.clearAllMocks @@ -62,7 +64,10 @@ internal class StytchClientTest { fun before() { Dispatchers.setMain(mainThreadSurrogate) mockkStatic(KeyStore::class) - mockkStatic("com.stytch.sdk.common.extensions.ContextExtKt") + mockkStatic( + "com.stytch.sdk.common.extensions.ContextExtKt", + "com.stytch.sdk.consumer.extensions.StytchResultExtKt", + ) mockkObject(EncryptionManager) every { EncryptionManager.createNewKeys(any(), any()) } returns Unit val mockApplication: Application = mockk { @@ -75,6 +80,7 @@ internal class StytchClientTest { every { KeyStore.getInstance(any()) } returns mockk(relaxed = true) mockkObject(StorageHelper) mockkObject(StytchApi) + mockkObject(StytchApi.Sessions) every { StorageHelper.initialize(any()) } just runs every { StorageHelper.loadValue(any()) } returns "some-value" every { StorageHelper.generateHashedCodeChallenge() } returns Pair("", "") @@ -137,6 +143,26 @@ internal class StytchClientTest { } } + @Test + fun `should validate persisted sessions if applicable when calling StytchClient configure`() { + runBlocking { + val mockResponse: StytchResult = mockk { + every { launchSessionUpdater(any(), any()) } just runs + } + coEvery { StytchApi.Sessions.authenticate(any()) } returns mockResponse + // no session data == no authentication/updater + every { StorageHelper.loadValue(any()) } returns null + StytchClient.configure(mContextMock, "") + coVerify(exactly = 0) { StytchApi.Sessions.authenticate() } + verify(exactly = 0) { mockResponse.launchSessionUpdater(any(), any()) } + // yes session data == yes authentication/updater + every { StorageHelper.loadValue(any()) } returns "some-session-data" + StytchClient.configure(mContextMock, "") + coVerify(exactly = 1) { StytchApi.Sessions.authenticate() } + verify(exactly = 1) { mockResponse.launchSessionUpdater(any(), any()) } + } + } + @Test(expected = StytchExceptions.Critical::class) fun `an exception in StytchClient configure throws a Critical exception`() { every { StorageHelper.initialize(any()) } throws RuntimeException("Test") From 9b5ba66a681a925da8dde2e068ab377be726d6c2 Mon Sep 17 00:00:00 2001 From: Jordan Haven Date: Mon, 4 Dec 2023 13:10:56 -0500 Subject: [PATCH 3/4] Add callback and stateflow to report configuration state; Add tests --- .../com/stytch/sdk/b2b/StytchB2BClient.kt | 15 ++++++++++- .../com/stytch/sdk/consumer/StytchClient.kt | 15 ++++++++++- .../com/stytch/sdk/b2b/StytchB2BClientTest.kt | 25 +++++++++++++++++++ .../stytch/sdk/consumer/StytchClientTest.kt | 25 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt b/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt index 340f47f11..fb9643189 100644 --- a/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt +++ b/sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt @@ -39,6 +39,9 @@ import com.stytch.sdk.common.network.models.BootstrapData import com.stytch.sdk.common.stytchError import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -55,15 +58,23 @@ public object StytchB2BClient { internal lateinit var dfpProvider: DFPProvider + /** + * Exposes a flow that reports the initialization state of the SDK. You can use this, or the optional callback in + * the `configure()` method, to know when the Stytch SDK has been fully initialized and is ready for use + */ + private var _isInitialized: MutableStateFlow = MutableStateFlow(false) + public val isInitialized: StateFlow = _isInitialized.asStateFlow() + /** * This configures the API for authenticating requests and the encrypted storage helper for persisting session data * across app launches. * You must call this method before making any Stytch authentication requests. * @param context The applicationContext of your app * @param publicToken Available via the Stytch dashboard in the API keys section + * @param callback An optional callback that is triggered after configuration and initialization has completed * @throws StytchExceptions.Critical - if we failed to generate new encryption keys */ - public fun configure(context: Context, publicToken: String) { + public fun configure(context: Context, publicToken: String, callback: ((Boolean) -> Unit) = {}) { try { val deviceInfo = context.getDeviceInfo() StorageHelper.initialize(context) @@ -91,6 +102,8 @@ public object StytchB2BClient { launchSessionUpdater(dispatchers, sessionStorage) } } + _isInitialized.value = true + callback(_isInitialized.value) } } catch (ex: Exception) { throw StytchExceptions.Critical(ex) diff --git a/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt b/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt index 055ef61df..ca105ff49 100644 --- a/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt +++ b/sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt @@ -43,6 +43,9 @@ import com.stytch.sdk.consumer.userManagement.UserManagement import com.stytch.sdk.consumer.userManagement.UserManagementImpl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -59,15 +62,23 @@ public object StytchClient { internal lateinit var dfpProvider: DFPProvider + /** + * Exposes a flow that reports the initialization state of the SDK. You can use this, or the optional callback in + * the `configure()` method, to know when the Stytch SDK has been fully initialized and is ready for use + */ + private var _isInitialized: MutableStateFlow = MutableStateFlow(false) + public val isInitialized: StateFlow = _isInitialized.asStateFlow() + /** * This configures the API for authenticating requests and the encrypted storage helper for persisting session data * across app launches. * You must call this method before making any Stytch authentication requests. * @param context The applicationContext of your app * @param publicToken Available via the Stytch dashboard in the API keys section + * @param callback An optional callback that is triggered after configuration and initialization has completed * @throws StytchExceptions.Critical - if we failed to generate new encryption keys */ - public fun configure(context: Context, publicToken: String) { + public fun configure(context: Context, publicToken: String, callback: ((Boolean) -> Unit) = {}) { try { val deviceInfo = context.getDeviceInfo() StorageHelper.initialize(context) @@ -96,6 +107,8 @@ public object StytchClient { launchSessionUpdater(dispatchers, sessionStorage) } } + _isInitialized.value = true + callback(_isInitialized.value) } } catch (ex: Exception) { throw StytchExceptions.Critical(ex) diff --git a/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt b/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt index c2d44840f..dda0f3751 100644 --- a/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/b2b/StytchB2BClientTest.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -138,6 +139,14 @@ internal class StytchB2BClientTest { } } + @Test + fun `configures DFP when calling StytchB2BClient configure`() { + runBlocking { + StytchB2BClient.configure(mContextMock, "") + verify(exactly = 1) { StytchB2BApi.configureDFP(any(), any(), any(), any()) } + } + } + @Test fun `should validate persisted sessions if applicable when calling StytchB2BClient configure`() { runBlocking { @@ -158,6 +167,22 @@ internal class StytchB2BClientTest { } } + @Test + fun `should report the initialization state after configuration and initialization is complete`() { + runTest { + val mockResponse: StytchResult = mockk { + every { launchSessionUpdater(any(), any()) } just runs + } + coEvery { StytchB2BApi.Sessions.authenticate(any()) } returns mockResponse + val callback = spyk<(Boolean) -> Unit>() + StytchB2BClient.configure(mContextMock, "", callback) + // callback is called with expected value + verify(exactly = 1) { callback(true) } + // isInitialized has fired + assert(StytchB2BClient.isInitialized.value) + } + } + @Test(expected = StytchExceptions.Critical::class) fun `an exception in StytchB2BClient configure throws a Critical exception`() { every { StorageHelper.initialize(any()) } throws RuntimeException("Test") diff --git a/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt b/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt index 3b67685b3..a61849f77 100644 --- a/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt +++ b/sdk/src/test/java/com/stytch/sdk/consumer/StytchClientTest.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -143,6 +144,14 @@ internal class StytchClientTest { } } + @Test + fun `configures DFP when calling StytchClient configure`() { + runBlocking { + StytchClient.configure(mContextMock, "") + verify(exactly = 1) { StytchApi.configureDFP(any(), any(), any(), any()) } + } + } + @Test fun `should validate persisted sessions if applicable when calling StytchClient configure`() { runBlocking { @@ -163,6 +172,22 @@ internal class StytchClientTest { } } + @Test + fun `should report the initialization state after configuration and initialization is complete`() { + runTest { + val mockResponse: StytchResult = mockk { + every { launchSessionUpdater(any(), any()) } just runs + } + coEvery { StytchApi.Sessions.authenticate(any()) } returns mockResponse + val callback = spyk<(Boolean) -> Unit>() + StytchClient.configure(mContextMock, "", callback) + // callback is called with expected value + verify(exactly = 1) { callback(true) } + // isInitialized has fired + assert(StytchClient.isInitialized.value) + } + } + @Test(expected = StytchExceptions.Critical::class) fun `an exception in StytchClient configure throws a Critical exception`() { every { StorageHelper.initialize(any()) } throws RuntimeException("Test") From 06536d6f09519dfdcc504a8396c434af2b34ff89 Mon Sep 17 00:00:00 2001 From: Jordan Haven Date: Mon, 4 Dec 2023 13:16:37 -0500 Subject: [PATCH 4/4] Update consumer example app with examples of the callback and flow reporting --- .../main/java/com/stytch/exampleapp/App.kt | 4 +++- .../com/stytch/exampleapp/ui/AppScreen.kt | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/consumerExampleApp/src/main/java/com/stytch/exampleapp/App.kt b/consumerExampleApp/src/main/java/com/stytch/exampleapp/App.kt index 1ab1bfd03..9fd2cb88f 100644 --- a/consumerExampleApp/src/main/java/com/stytch/exampleapp/App.kt +++ b/consumerExampleApp/src/main/java/com/stytch/exampleapp/App.kt @@ -13,6 +13,8 @@ class App : Application() { StytchClient.configure( context = this, publicToken = BuildConfig.STYTCH_PUBLIC_TOKEN - ) + ) { + println("Stytch has been initialized and configured and is ready for use") + } } } diff --git a/consumerExampleApp/src/main/java/com/stytch/exampleapp/ui/AppScreen.kt b/consumerExampleApp/src/main/java/com/stytch/exampleapp/ui/AppScreen.kt index 1b53872f2..1f60bc5b9 100644 --- a/consumerExampleApp/src/main/java/com/stytch/exampleapp/ui/AppScreen.kt +++ b/consumerExampleApp/src/main/java/com/stytch/exampleapp/ui/AppScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Lock import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +37,7 @@ import androidx.navigation.compose.rememberNavController import com.stytch.exampleapp.HomeViewModel import com.stytch.exampleapp.OAuthViewModel import com.stytch.exampleapp.R +import com.stytch.sdk.consumer.StytchClient val items = listOf( Screen.Main, @@ -51,6 +53,7 @@ fun AppScreen( oAuthViewModel: OAuthViewModel, ) { val navController = rememberNavController() + val stytchIsInitialized = StytchClient.isInitialized.collectAsState() Scaffold( modifier = Modifier .fillMaxHeight() @@ -86,12 +89,16 @@ fun AppScreen( } }, content = { padding -> - NavHost(navController, startDestination = Screen.Main.route, Modifier.padding(padding)) { - composable(Screen.Main.route) { MainScreen(viewModel = homeViewModel) } - composable(Screen.Passwords.route) { PasswordsScreen(navController = navController) } - composable(Screen.Biometrics.route) { BiometricsScreen(navController = navController) } - composable(Screen.OAuth.route) { OAuthScreen(viewModel = oAuthViewModel) } - composable(Screen.Passkeys.route) { PasskeysScreen(navController = navController) } + if (stytchIsInitialized.value) { + NavHost(navController, startDestination = Screen.Main.route, Modifier.padding(padding)) { + composable(Screen.Main.route) { MainScreen(viewModel = homeViewModel) } + composable(Screen.Passwords.route) { PasswordsScreen(navController = navController) } + composable(Screen.Biometrics.route) { BiometricsScreen(navController = navController) } + composable(Screen.OAuth.route) { OAuthScreen(viewModel = oAuthViewModel) } + composable(Screen.Passkeys.route) { PasskeysScreen(navController = navController) } + } + } else { + // maybe show a loading state while stytch sets up } } )