Skip to content

Commit

Permalink
Merge pull request #106 from stytchauth/jordan/SDK-1296-isInitialized…
Browse files Browse the repository at this point in the history
…-and-session-authentication

SDK-1296 Validate persistent sessions and add ability to report configuration status
  • Loading branch information
jhaven-stytch authored Dec 6, 2023
2 parents 1fc3bfe + 06536d6 commit a08ec8b
Show file tree
Hide file tree
Showing 17 changed files with 213 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -51,6 +53,7 @@ fun AppScreen(
oAuthViewModel: OAuthViewModel,
) {
val navController = rememberNavController()
val stytchIsInitialized = StytchClient.isInitialized.collectAsState()
Scaffold(
modifier = Modifier
.fillMaxHeight()
Expand Down Expand Up @@ -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
}
}
)
Expand Down
2 changes: 1 addition & 1 deletion sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
22 changes: 21 additions & 1 deletion sdk/src/main/java/com/stytch/sdk/b2b/StytchB2BClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,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

Expand All @@ -54,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<Boolean> = MutableStateFlow(false)
public val isInitialized: StateFlow<Boolean> = _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)
Expand All @@ -84,6 +96,14 @@ 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)
}
}
_isInitialized.value = true
callback(_isInitialized.value)
}
} catch (ex: Exception) {
throw StytchExceptions.Critical(ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ internal object StytchB2BApi {

internal object Sessions {
suspend fun authenticate(
sessionDurationMinutes: UInt?
sessionDurationMinutes: UInt? = null
): StytchResult<IB2BAuthData> = safeB2BApiCall {
apiService.authenticateSessions(
CommonRequests.Sessions.AuthenticateRequest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ internal class B2BSessionStorage(private val storageHelper: StorageHelper) {
}
}

val activeSessionExists: Boolean
val persistedSessionIdentifiersExist: Boolean
get() = sessionToken != null || sessionJwt != null

/**
Expand Down
28 changes: 23 additions & 5 deletions sdk/src/main/java/com/stytch/sdk/common/EncryptionManager.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
}

/**
Expand Down
22 changes: 21 additions & 1 deletion sdk/src/main/java/com/stytch/sdk/consumer/StytchClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,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

Expand All @@ -58,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<Boolean> = MutableStateFlow(false)
public val isInitialized: StateFlow<Boolean> = _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)
Expand All @@ -89,6 +101,14 @@ 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)
}
}
_isInitialized.value = true
callback(_isInitialized.value)
}
} catch (ex: Exception) {
throw StytchExceptions.Critical(ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions sdk/src/main/java/com/stytch/sdk/consumer/otp/OTPImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ internal class ConsumerSessionStorage(private val storageHelper: StorageHelper)
}
}

val activeSessionExists: Boolean
val persistedSessionIdentifiersExist: Boolean
get() = sessionToken != null || sessionJwt != null

/**
Expand Down
Loading

0 comments on commit a08ec8b

Please sign in to comment.