diff --git a/source/sdk/build.gradle.kts b/source/sdk/build.gradle.kts index 4eef0ced9..a0ef13dd9 100644 --- a/source/sdk/build.gradle.kts +++ b/source/sdk/build.gradle.kts @@ -12,7 +12,7 @@ plugins { } extra["PUBLISH_GROUP_ID"] = "com.stytch.sdk" -extra["PUBLISH_VERSION"] = "0.35.1" +extra["PUBLISH_VERSION"] = "0.36.0" extra["PUBLISH_ARTIFACT_ID"] = "sdk" apply("${rootProject.projectDir}/scripts/publish-module.gradle") diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuth.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuth.kt index 6155b8d06..95d7a8ffe 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuth.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuth.kt @@ -1,9 +1,11 @@ package com.stytch.sdk.b2b.oauth import android.app.Activity +import androidx.activity.ComponentActivity import com.stytch.sdk.b2b.OAuthAuthenticateResponse import com.stytch.sdk.b2b.OAuthDiscoveryAuthenticateResponse import com.stytch.sdk.common.DEFAULT_SESSION_TIME_MINUTES +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.network.models.Locale import java.util.concurrent.CompletableFuture @@ -12,6 +14,8 @@ import java.util.concurrent.CompletableFuture * configured them within your Stytch Dashboard. */ public interface OAuth { + public fun setOAuthReceiverActivity(activity: ComponentActivity?) + /** * An interface describing the methods and parameters available for starting an OAuth or OAuth discovery flow * for a specific provider @@ -59,6 +63,28 @@ public interface OAuth { * Exposes an instance of the [ProviderDiscovery] interface */ public val discovery: ProviderDiscovery + + public data class GetTokenForProviderParams + @JvmOverloads + constructor( + val organizationId: String? = null, + val organizationSlug: String? = null, + val loginRedirectUrl: String? = null, + val signupRedirectUrl: String? = null, + val customScopes: List? = null, + val providerParams: Map? = null, + ) + + public suspend fun getTokenForProvider(parameters: GetTokenForProviderParams): StytchResult + + public fun getTokenForProvider( + parameters: GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) + + public fun getTokenForProviderCompletable( + parameters: GetTokenForProviderParams, + ): CompletableFuture> } /** @@ -94,6 +120,39 @@ public interface OAuth { * @param parameters the parameters necessary to start the flow */ public fun start(parameters: DiscoveryStartParameters) + + /** + * Data class used for wrapping parameters to start a third party OAuth flow and retrieve a token + * @property loginRedirectUrl The url an existing user is redirected to after authenticating with the identity + * provider. This should be a url that redirects back to your app. If this value is not passed, the default + * login redirect URL set in the Stytch Dashboard is used. If you have not set a default login redirect URL, + * an error is returned. + * @property signupRedirectUrl The url a new user is redirected to after authenticating with the identity + * provider. + * This should be a url that redirects back to your app. If this value is not passed, the default sign-up + * redirect URL set in the Stytch Dashboard is used. If you have not set a default sign-up redirect URL, an + * error is returned. + * @property customScopes Any additional scopes to be requested from the identity provider + * @property providerParams An optional mapping of provider specific values to pass through to the OAuth provider. + */ + public data class GetTokenForProviderParams + @JvmOverloads + constructor( + val discoveryRedirectUrl: String? = null, + val customScopes: List? = null, + val providerParams: Map? = null, + ) + + public suspend fun getTokenForProvider(parameters: GetTokenForProviderParams): StytchResult + + public fun getTokenForProvider( + parameters: GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) + + public fun getTokenForProviderCompletable( + parameters: GetTokenForProviderParams, + ): CompletableFuture> } /** diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuthImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuthImpl.kt index d2097c08c..36455f811 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuthImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuthImpl.kt @@ -1,5 +1,8 @@ package com.stytch.sdk.b2b.oauth +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import com.stytch.sdk.b2b.OAuthAuthenticateResponse import com.stytch.sdk.b2b.OAuthDiscoveryAuthenticateResponse import com.stytch.sdk.b2b.StytchB2BClient @@ -10,16 +13,21 @@ import com.stytch.sdk.common.LIVE_API_URL import com.stytch.sdk.common.StytchDispatchers import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.TEST_API_URL +import com.stytch.sdk.common.errors.NoActivityProvided import com.stytch.sdk.common.errors.StytchMissingPKCEError import com.stytch.sdk.common.pkcePairManager.PKCEPairManager +import com.stytch.sdk.common.sso.ProvidedReceiverManager import com.stytch.sdk.common.sso.SSOManagerActivity +import com.stytch.sdk.common.sso.SSOManagerActivity.Companion.URI_KEY import com.stytch.sdk.common.utils.buildUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.concurrent.CompletableFuture +import kotlin.coroutines.Continuation internal class OAuthImpl( private val externalScope: CoroutineScope, @@ -27,12 +35,18 @@ internal class OAuthImpl( private val sessionStorage: B2BSessionStorage, private val api: StytchB2BApi.OAuth, private val pkcePairManager: PKCEPairManager, + private val providedReceiverManager: ProvidedReceiverManager = ProvidedReceiverManager, ) : OAuth { - override val google: OAuth.Provider = ProviderImpl("google") - override val microsoft: OAuth.Provider = ProviderImpl("microsoft") - override val hubspot: OAuth.Provider = ProviderImpl("hubspot") - override val slack: OAuth.Provider = ProviderImpl("slack") - override val github: OAuth.Provider = ProviderImpl("github") + override fun setOAuthReceiverActivity(activity: ComponentActivity?) { + ProvidedReceiverManager.configureReceiver(activity) + } + + override val google: OAuth.Provider = ProviderImpl("google", providedReceiverManager::getReceiverConfiguration) + override val microsoft: OAuth.Provider = + ProviderImpl("microsoft", providedReceiverManager::getReceiverConfiguration) + override val hubspot: OAuth.Provider = ProviderImpl("hubspot", providedReceiverManager::getReceiverConfiguration) + override val slack: OAuth.Provider = ProviderImpl("slack", providedReceiverManager::getReceiverConfiguration) + override val github: OAuth.Provider = ProviderImpl("github", providedReceiverManager::getReceiverConfiguration) override val discovery: OAuth.Discovery = DiscoveryImpl() override suspend fun authenticate(parameters: OAuth.AuthenticateParameters): OAuthAuthenticateResponse { @@ -85,6 +99,9 @@ internal class OAuthImpl( private inner class ProviderImpl( private val providerName: String, + private val getOAuthReceiver: ( + Continuation>, + ) -> Pair?>, ) : OAuth.Provider { override fun start(parameters: OAuth.Provider.StartParameters) { val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge @@ -110,11 +127,67 @@ internal class OAuthImpl( parameters.context.startActivityForResult(intent, parameters.oAuthRequestIdentifier) } - override val discovery: OAuth.ProviderDiscovery = ProviderDiscoveryImpl(providerName) + override suspend fun getTokenForProvider( + parameters: OAuth.Provider.GetTokenForProviderParams, + ): StytchResult = + suspendCancellableCoroutine { continuation -> + getOAuthReceiver(continuation).let { (activity, launcher) -> + if (activity == null || launcher == null) { + continuation.resume( + StytchResult.Error(NoActivityProvided), + ) { cause, _, _ -> continuation.cancel(cause) } + return@suspendCancellableCoroutine + } + val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge + val host = + StytchB2BClient.bootstrapData.cnameDomain?.let { + "https://$it/" + } ?: if (StytchB2BApi.isTestToken) TEST_API_URL else LIVE_API_URL + val baseUrl = "${host}b2b/public/oauth/$providerName/start" + val urlParams = + mapOf( + "public_token" to StytchB2BApi.publicToken, + "pkce_code_challenge" to pkce, + "organization_id" to parameters.organizationId, + "slug" to parameters.organizationSlug, + "custom_scopes" to parameters.customScopes, + "provider_params" to parameters.providerParams, + "login_redirect_url" to parameters.loginRedirectUrl, + "signup_redirect_url" to parameters.signupRedirectUrl, + ) + val requestUri = buildUri(baseUrl, urlParams) + val intent = SSOManagerActivity.createBaseIntent(activity) + intent.putExtra(SSOManagerActivity.URI_KEY, requestUri.toString()) + launcher.launch(intent) + } + } + + override fun getTokenForProvider( + parameters: OAuth.Provider.GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) { + externalScope.launch(dispatchers.ui) { + callback(getTokenForProvider(parameters)) + } + } + + override fun getTokenForProviderCompletable( + parameters: OAuth.Provider.GetTokenForProviderParams, + ): CompletableFuture> = + externalScope + .async { + getTokenForProvider(parameters) + }.asCompletableFuture() + + override val discovery: OAuth.ProviderDiscovery = + ProviderDiscoveryImpl(providerName, providedReceiverManager::getReceiverConfiguration) } private inner class ProviderDiscoveryImpl( private val providerName: String, + private val getOAuthReceiver: ( + Continuation>, + ) -> Pair?>, ) : OAuth.ProviderDiscovery { override fun start(parameters: OAuth.ProviderDiscovery.DiscoveryStartParameters) { val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge @@ -133,6 +206,52 @@ internal class OAuthImpl( intent.putExtra(SSOManagerActivity.URI_KEY, requestUri.toString()) parameters.context.startActivityForResult(intent, parameters.oAuthRequestIdentifier) } + + override suspend fun getTokenForProvider( + parameters: OAuth.ProviderDiscovery.GetTokenForProviderParams, + ): StytchResult = + suspendCancellableCoroutine { continuation -> + getOAuthReceiver(continuation).let { (activity, launcher) -> + if (activity == null || launcher == null) { + continuation.resume( + StytchResult.Error(NoActivityProvided), + ) { cause, _, _ -> continuation.cancel(cause) } + return@suspendCancellableCoroutine + } + val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge + val host = if (StytchB2BApi.isTestToken) TEST_API_URL else LIVE_API_URL + val baseUrl = "${host}b2b/public/oauth/$providerName/discovery/start" + val urlParams = + mapOf( + "public_token" to StytchB2BApi.publicToken, + "pkce_code_challenge" to pkce, + "discovery_redirect_url" to parameters.discoveryRedirectUrl, + "custom_scopes" to parameters.customScopes, + "provider_params" to parameters.providerParams, + ) + val requestUri = buildUri(baseUrl, urlParams) + val intent = SSOManagerActivity.createBaseIntent(activity) + intent.putExtra(SSOManagerActivity.URI_KEY, requestUri.toString()) + launcher.launch(intent) + } + } + + override fun getTokenForProvider( + parameters: OAuth.ProviderDiscovery.GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) { + externalScope.launch(dispatchers.ui) { + callback(getTokenForProvider(parameters)) + } + } + + override fun getTokenForProviderCompletable( + parameters: OAuth.ProviderDiscovery.GetTokenForProviderParams, + ): CompletableFuture> = + externalScope + .async { + getTokenForProvider(parameters) + }.asCompletableFuture() } private inner class DiscoveryImpl : OAuth.Discovery { diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSO.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSO.kt index e26589f67..964ef7f42 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSO.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSO.kt @@ -1,6 +1,7 @@ package com.stytch.sdk.b2b.sso import android.app.Activity +import androidx.activity.ComponentActivity import com.stytch.sdk.b2b.B2BSSODeleteConnectionResponse import com.stytch.sdk.b2b.B2BSSOGetConnectionsResponse import com.stytch.sdk.b2b.B2BSSOOIDCCreateConnectionResponse @@ -13,6 +14,7 @@ import com.stytch.sdk.b2b.SSOAuthenticateResponse import com.stytch.sdk.b2b.network.models.ConnectionRoleAssignment import com.stytch.sdk.b2b.network.models.GroupRoleAssignment import com.stytch.sdk.common.DEFAULT_SESSION_TIME_MINUTES +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.network.models.Locale import java.util.concurrent.CompletableFuture @@ -26,6 +28,8 @@ import java.util.concurrent.CompletableFuture * - SAML */ public interface SSO { + public fun setSSOReceiverActivity(activity: ComponentActivity?) + /** * Data class used for wrapping parameters used in SSO start calls * @property context @@ -56,6 +60,39 @@ public interface SSO { */ public fun start(params: StartParams) + /** + * Data class used for wrapping parameters to start a third party OAuth flow and retrieve a token + * @property loginRedirectUrl The url an existing user is redirected to after authenticating with the identity + * provider. This should be a url that redirects back to your app. If this value is not passed, the default + * login redirect URL set in the Stytch Dashboard is used. If you have not set a default login redirect URL, + * an error is returned. + * @property signupRedirectUrl The url a new user is redirected to after authenticating with the identity + * provider. + * This should be a url that redirects back to your app. If this value is not passed, the default sign-up + * redirect URL set in the Stytch Dashboard is used. If you have not set a default sign-up redirect URL, an + * error is returned. + * @property customScopes Any additional scopes to be requested from the identity provider + * @property providerParams An optional mapping of provider specific values to pass through to the OAuth provider. + */ + public data class GetTokenForProviderParams + @JvmOverloads + constructor( + val connectionId: String, + val loginRedirectUrl: String? = null, + val signupRedirectUrl: String? = null, + ) + + public suspend fun getTokenForProvider(parameters: GetTokenForProviderParams): StytchResult + + public fun getTokenForProvider( + parameters: GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) + + public fun getTokenForProviderCompletable( + parameters: GetTokenForProviderParams, + ): CompletableFuture> + /** * Data class used for wrapping parameters used in SSO Authenticate calls * @property ssoToken the SSO token to authenticate diff --git a/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSOImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSOImpl.kt index 121de91eb..1ec3a0027 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSOImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSOImpl.kt @@ -1,5 +1,6 @@ package com.stytch.sdk.b2b.sso +import androidx.activity.ComponentActivity import com.stytch.sdk.b2b.B2BSSODeleteConnectionResponse import com.stytch.sdk.b2b.B2BSSOGetConnectionsResponse import com.stytch.sdk.b2b.B2BSSOOIDCCreateConnectionResponse @@ -17,14 +18,17 @@ import com.stytch.sdk.common.LIVE_API_URL import com.stytch.sdk.common.StytchDispatchers import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.TEST_API_URL +import com.stytch.sdk.common.errors.NoActivityProvided import com.stytch.sdk.common.errors.StytchMissingPKCEError import com.stytch.sdk.common.pkcePairManager.PKCEPairManager +import com.stytch.sdk.common.sso.ProvidedReceiverManager import com.stytch.sdk.common.sso.SSOManagerActivity import com.stytch.sdk.common.utils.buildUri import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.concurrent.CompletableFuture @@ -34,7 +38,12 @@ internal class SSOImpl( private val sessionStorage: B2BSessionStorage, private val api: StytchB2BApi.SSO, private val pkcePairManager: PKCEPairManager, + private val providedReceiverManager: ProvidedReceiverManager = ProvidedReceiverManager, ) : SSO { + override fun setSSOReceiverActivity(activity: ComponentActivity?) { + providedReceiverManager.configureReceiver(activity) + } + override fun start(params: SSO.StartParams) { val host = StytchB2BClient.bootstrapData.cnameDomain?.let { @@ -54,6 +63,51 @@ internal class SSOImpl( params.context.startActivityForResult(intent, params.ssoAuthRequestIdentifier) } + override suspend fun getTokenForProvider(parameters: SSO.GetTokenForProviderParams): StytchResult = + suspendCancellableCoroutine { continuation -> + providedReceiverManager.getReceiverConfiguration(continuation).let { (activity, launcher) -> + if (activity == null || launcher == null) { + continuation.resume( + StytchResult.Error(NoActivityProvided), + ) { cause, _, _ -> continuation.cancel(cause) } + return@suspendCancellableCoroutine + } + val host = + StytchB2BClient.bootstrapData.cnameDomain?.let { + "https://$it/v1/" + } ?: if (StytchB2BApi.isTestToken) TEST_API_URL else LIVE_API_URL + val potentialParameters = + mapOf( + "connection_id" to parameters.connectionId, + "public_token" to StytchB2BApi.publicToken, + "pkce_code_challenge" to pkcePairManager.generateAndReturnPKCECodePair().codeChallenge, + "login_redirect_url" to parameters.loginRedirectUrl, + "signup_redirect_url" to parameters.signupRedirectUrl, + ) + val requestUri = buildUri("${host}public/sso/start", potentialParameters) + val intent = SSOManagerActivity.createBaseIntent(activity) + intent.putExtra(SSOManagerActivity.URI_KEY, requestUri.toString()) + launcher.launch(intent) + } + } + + override fun getTokenForProvider( + parameters: SSO.GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) { + externalScope.launch(dispatchers.ui) { + callback(getTokenForProvider(parameters)) + } + } + + override fun getTokenForProviderCompletable( + parameters: SSO.GetTokenForProviderParams, + ): CompletableFuture> = + externalScope + .async { + getTokenForProvider(parameters) + }.asCompletableFuture() + override suspend fun authenticate(params: SSO.AuthenticateParams): SSOAuthenticateResponse { val codeVerifier = pkcePairManager.getPKCECodePair()?.codeVerifier ?: return StytchResult.Error(StytchMissingPKCEError(null)) diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/errors/StytchSDKError.kt b/source/sdk/src/main/java/com/stytch/sdk/common/errors/StytchSDKError.kt index 7960e5ed2..dad075ae5 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/common/errors/StytchSDKError.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/common/errors/StytchSDKError.kt @@ -194,3 +194,13 @@ public data class UnexpectedCredentialType( ) : StytchSDKError( message = "Unexpected type of credential: $credentialType", ) + +public data object NoBrowserFound : StytchSDKError("No supported browser was found on this device") + +public data object NoURIFound : StytchSDKError("No OAuth URI could be found in the bundle") + +public data object UserCanceled : StytchSDKError("The user canceled the OAuth flow") + +public data object NoActivityProvided : StytchSDKError("You must supply a receiver activity before calling this method") + +public data object UnknownOAuthOrSSOError : StytchSDKError("The OAuth or SSO flow completed unexpectedly") diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/sso/ProvidedReceiverManager.kt b/source/sdk/src/main/java/com/stytch/sdk/common/sso/ProvidedReceiverManager.kt new file mode 100644 index 000000000..0e53f328c --- /dev/null +++ b/source/sdk/src/main/java/com/stytch/sdk/common/sso/ProvidedReceiverManager.kt @@ -0,0 +1,60 @@ +package com.stytch.sdk.common.sso + +import android.app.Activity.RESULT_OK +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.stytch.sdk.common.QUERY_TOKEN +import com.stytch.sdk.common.StytchResult +import com.stytch.sdk.common.errors.NoBrowserFound +import com.stytch.sdk.common.errors.NoURIFound +import com.stytch.sdk.common.errors.UnknownOAuthOrSSOError +import com.stytch.sdk.common.errors.UserCanceled +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +internal object ProvidedReceiverManager { + private var activity: ComponentActivity? = null + private var continuation: Continuation>? = null + private var launcher: ActivityResultLauncher? = null + + internal fun getReceiverConfiguration( + continuation: Continuation>, + ): Pair?> { + this.continuation = continuation + return Pair(activity, launcher) + } + + internal fun configureReceiver(activity: ComponentActivity?) { + this.activity = activity + launcher = + activity?.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val response = + when (result.resultCode) { + RESULT_OK -> { + result.data?.data?.getQueryParameter(QUERY_TOKEN)?.let { + StytchResult.Success(it) + } ?: StytchResult.Error(NoURIFound) + } + + else -> { + val error = + result.data?.extras?.getSerializable(SSOError.SSO_EXCEPTION)?.let { + when (it as SSOError) { + is SSOError.UserCanceled -> UserCanceled + is SSOError.NoBrowserFound -> NoBrowserFound + is SSOError.NoURIFound -> NoURIFound + } + } ?: UnknownOAuthOrSSOError + StytchResult.Error(error) + } + } + continuation?.resume(response) + continuation = null + } + if (activity == null) { + continuation = null + } + } +} diff --git a/source/sdk/src/main/java/com/stytch/sdk/common/sso/SSOManagerActivity.kt b/source/sdk/src/main/java/com/stytch/sdk/common/sso/SSOManagerActivity.kt index 487428895..4006beea1 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/common/sso/SSOManagerActivity.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/common/sso/SSOManagerActivity.kt @@ -146,9 +146,7 @@ internal class SSOManagerActivity : Activity() { return intent } - internal fun createBaseIntent(context: Context): Intent { - return Intent(context, SSOManagerActivity::class.java) - } + internal fun createBaseIntent(context: Context): Intent = Intent(context, SSOManagerActivity::class.java) internal const val URI_KEY = "uri" private const val KEY_AUTHORIZATION_STARTED = "authStarted" diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuth.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuth.kt index 1dc84c4ad..c259d9cdd 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuth.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuth.kt @@ -1,7 +1,9 @@ package com.stytch.sdk.consumer.oauth import android.app.Activity +import androidx.activity.ComponentActivity import com.stytch.sdk.common.DEFAULT_SESSION_TIME_MINUTES +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.consumer.NativeOAuthResponse import com.stytch.sdk.consumer.OAuthAuthenticatedResponse import java.util.concurrent.CompletableFuture @@ -167,6 +169,8 @@ public interface OAuth { public fun signOut(activity: Activity) } + public fun setOAuthReceiverActivity(activity: ComponentActivity?) + /** * Provides a method for starting Third Party OAuth authentications */ @@ -220,6 +224,40 @@ public interface OAuth { * @param parameters required to start the OAuth flow */ public fun start(parameters: StartParameters) + + /** + * Data class used for wrapping parameters to start a third party OAuth flow and retrieve a token + * @property loginRedirectUrl The url an existing user is redirected to after authenticating with the identity + * provider. This should be a url that redirects back to your app. If this value is not passed, the default + * login redirect URL set in the Stytch Dashboard is used. If you have not set a default login redirect URL, + * an error is returned. + * @property signupRedirectUrl The url a new user is redirected to after authenticating with the identity + * provider. + * This should be a url that redirects back to your app. If this value is not passed, the default sign-up + * redirect URL set in the Stytch Dashboard is used. If you have not set a default sign-up redirect URL, an + * error is returned. + * @property customScopes Any additional scopes to be requested from the identity provider + * @property providerParams An optional mapping of provider specific values to pass through to the OAuth provider. + */ + public data class GetTokenForProviderParams + @JvmOverloads + constructor( + val loginRedirectUrl: String? = null, + val signupRedirectUrl: String? = null, + val customScopes: List? = null, + val providerParams: Map? = null, + ) + + public suspend fun getTokenForProvider(parameters: GetTokenForProviderParams): StytchResult + + public fun getTokenForProvider( + parameters: GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) + + public fun getTokenForProviderCompletable( + parameters: GetTokenForProviderParams, + ): CompletableFuture> } /** diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuthImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuthImpl.kt index 6ce014055..c5f4e269d 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuthImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuthImpl.kt @@ -1,9 +1,11 @@ package com.stytch.sdk.consumer.oauth +import androidx.activity.ComponentActivity import com.stytch.sdk.common.StytchDispatchers import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.errors.StytchMissingPKCEError import com.stytch.sdk.common.pkcePairManager.PKCEPairManager +import com.stytch.sdk.common.sso.ProvidedReceiverManager import com.stytch.sdk.consumer.OAuthAuthenticatedResponse import com.stytch.sdk.consumer.StytchClient import com.stytch.sdk.consumer.extensions.launchSessionUpdater @@ -22,7 +24,12 @@ internal class OAuthImpl( private val sessionStorage: ConsumerSessionStorage, private val api: StytchApi.OAuth, private val pkcePairManager: PKCEPairManager, + private val providedReceiverManager: ProvidedReceiverManager = ProvidedReceiverManager, ) : OAuth { + override fun setOAuthReceiverActivity(activity: ComponentActivity?) { + ProvidedReceiverManager.configureReceiver(activity) + } + override val googleOneTap: OAuth.GoogleOneTap = GoogleOneTapImpl( externalScope, @@ -32,25 +39,158 @@ internal class OAuthImpl( GoogleCredentialManagerProviderImpl(), pkcePairManager, ) - override val apple: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "apple") - override val amazon: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "amazon") - override val bitbucket: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "bitbucket") - override val coinbase: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "coinbase") - override val discord: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "discord") - override val facebook: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "facebook") - override val figma: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "figma") - override val github: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "github") - override val gitlab: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "gitlab") - override val google: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "google") - override val linkedin: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "linkedin") - override val microsoft: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "microsoft") - override val salesforce: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "salesforce") - override val slack: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "slack") - override val snapchat: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "snapchat") - override val tiktok: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "tiktok") - override val twitch: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "twitch") - override val twitter: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "twitter") - override val yahoo: OAuth.ThirdParty = ThirdPartyOAuthImpl(pkcePairManager, providerName = "yahoo") + override val apple: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "apple", + providedReceiverManager::getReceiverConfiguration, + ) + override val amazon: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "amazon", + providedReceiverManager::getReceiverConfiguration, + ) + override val bitbucket: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "bitbucket", + providedReceiverManager::getReceiverConfiguration, + ) + override val coinbase: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "coinbase", + providedReceiverManager::getReceiverConfiguration, + ) + override val discord: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "discord", + providedReceiverManager::getReceiverConfiguration, + ) + override val facebook: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "facebook", + providedReceiverManager::getReceiverConfiguration, + ) + override val figma: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "figma", + providedReceiverManager::getReceiverConfiguration, + ) + override val github: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "github", + providedReceiverManager::getReceiverConfiguration, + ) + override val gitlab: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "gitlab", + providedReceiverManager::getReceiverConfiguration, + ) + override val google: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "google", + providedReceiverManager::getReceiverConfiguration, + ) + override val linkedin: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "linkedin", + providedReceiverManager::getReceiverConfiguration, + ) + override val microsoft: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "microsoft", + providedReceiverManager::getReceiverConfiguration, + ) + override val salesforce: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "salesforce", + providedReceiverManager::getReceiverConfiguration, + ) + override val slack: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "slack", + providedReceiverManager::getReceiverConfiguration, + ) + override val snapchat: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "snapchat", + providedReceiverManager::getReceiverConfiguration, + ) + override val tiktok: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "tiktok", + providedReceiverManager::getReceiverConfiguration, + ) + override val twitch: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "twitch", + providedReceiverManager::getReceiverConfiguration, + ) + override val twitter: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "twitter", + providedReceiverManager::getReceiverConfiguration, + ) + override val yahoo: OAuth.ThirdParty = + ThirdPartyOAuthImpl( + externalScope, + dispatchers, + pkcePairManager, + providerName = "yahoo", + providedReceiverManager::getReceiverConfiguration, + ) override suspend fun authenticate(parameters: OAuth.ThirdParty.AuthenticateParameters): OAuthAuthenticatedResponse { return withContext(dispatchers.io) { diff --git a/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/ThirdPartyOAuthImpl.kt b/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/ThirdPartyOAuthImpl.kt index 38aa52f86..dc7a10d8c 100644 --- a/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/ThirdPartyOAuthImpl.kt +++ b/source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/ThirdPartyOAuthImpl.kt @@ -1,17 +1,35 @@ package com.stytch.sdk.consumer.oauth +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import com.stytch.sdk.common.LIVE_API_URL +import com.stytch.sdk.common.StytchDispatchers +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.TEST_API_URL +import com.stytch.sdk.common.errors.NoActivityProvided import com.stytch.sdk.common.pkcePairManager.PKCEPairManager import com.stytch.sdk.common.sso.SSOManagerActivity import com.stytch.sdk.common.sso.SSOManagerActivity.Companion.URI_KEY import com.stytch.sdk.common.utils.buildUri import com.stytch.sdk.consumer.StytchClient import com.stytch.sdk.consumer.network.StytchApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.Continuation internal class ThirdPartyOAuthImpl( + private val externalScope: CoroutineScope, + private val dispatchers: StytchDispatchers, private val pkcePairManager: PKCEPairManager, override val providerName: String, + private val getOAuthReceiver: ( + Continuation>, + ) -> Pair?>, ) : OAuth.ThirdParty { override fun start(parameters: OAuth.ThirdParty.StartParameters) { val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge @@ -35,4 +53,55 @@ internal class ThirdPartyOAuthImpl( intent.putExtra(URI_KEY, requestUri.toString()) parameters.context.startActivityForResult(intent, parameters.oAuthRequestIdentifier) } + + override suspend fun getTokenForProvider( + parameters: OAuth.ThirdParty.GetTokenForProviderParams, + ): StytchResult = + suspendCancellableCoroutine { continuation -> + getOAuthReceiver(continuation).let { (activity, launcher) -> + if (activity == null || launcher == null) { + continuation.resume( + StytchResult.Error(NoActivityProvided), + ) { cause, _, _ -> continuation.cancel(cause) } + return@suspendCancellableCoroutine + } + val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge + val token = StytchApi.publicToken + val host = + StytchClient.bootstrapData.cnameDomain?.let { + "https://$it/v1/" + } ?: if (StytchApi.isTestToken) TEST_API_URL else LIVE_API_URL + val baseUrl = "${host}public/oauth/$providerName/start" + val potentialParameters = + mapOf( + "public_token" to token, + "code_challenge" to pkce, + "login_redirect_url" to parameters.loginRedirectUrl, + "signup_redirect_url" to parameters.signupRedirectUrl, + "custom_scopes" to parameters.customScopes, + "provider_params" to parameters.providerParams, + ) + val requestUri = buildUri(baseUrl, potentialParameters) + val intent = SSOManagerActivity.createBaseIntent(activity) + intent.putExtra(URI_KEY, requestUri.toString()) + launcher.launch(intent) + } + } + + override fun getTokenForProvider( + parameters: OAuth.ThirdParty.GetTokenForProviderParams, + callback: (StytchResult) -> Unit, + ) { + externalScope.launch(dispatchers.ui) { + callback(getTokenForProvider(parameters)) + } + } + + override fun getTokenForProviderCompletable( + parameters: OAuth.ThirdParty.GetTokenForProviderParams, + ): CompletableFuture> = + externalScope + .async { + getTokenForProvider(parameters) + }.asCompletableFuture() } diff --git a/tutorials/OAuth.md b/tutorials/OAuth.md index 40ee0cc83..4a8bcfabd 100644 --- a/tutorials/OAuth.md +++ b/tutorials/OAuth.md @@ -1,21 +1,25 @@ -# OAuth -The Stytch Android SDK supports two types of OAuth flows: Third Party (redirect) and Native (Google OneTap/CredentialManager), both of which can be configured in the Stytch Dashboard. +# OAuth / SSO +The Stytch Android SDK supports browser-based redirect flows for OAuth and SSO (B2B SDK only), as well as Native OAuth (Google OneTap/CredentialManager; Consumer SDK only) The configuration necessary for each type of flow is different, so read on to see how to set each up. -To see all of the currently supported providers, check the properties of the [OAuth interface](../source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuth.kt). +To see all of the currently supported OAuth providers, check the properties of the [Consumer ](../source/sdk/src/main/java/com/stytch/sdk/consumer/oauth/OAuth.kt) and [B2B](../source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuth.kt) OAuth interfaces. -## Third Party (Redirect) -Third-party/Redirect OAuth is the OAuth you may be most familiar with on the web: You click a button, are redirected to the selected IdP, login, and are redirected back to the original webpage. The same concept applies with the Stytch Android SDK, except the redirects are handled in a browser activity, and the backstack is managed by the SDK. +## Redirect +Redirect OAuth/SSO is the OAuth/SSO you may be most familiar with on the web: You click a button, are redirected to the selected IdP, login, and are redirected back to the original webpage. The same concept applies with the Stytch Android SDK, except the redirects are handled in a browser activity, and the backstack is managed by the SDK. To accomplish this, the SDK uses a multi-activity pattern that handles all of the redirect and backstack management logic to ensure the backstack stays clean, and activities are finished in the correct order. If you were wondering why, in the getting started [README](../README.md), you needed to add manifest-placeholders for `stytchOAuthRedirectScheme` and `stytchOAuthRedirectHost`, it's to enable this functionality. Let's dig into those placeholders a little more. -When you provide these placeholders, the SDK registers an activity intent-filter for our "receiver" activity. When you `start` a third-party OAuth flow, the SDK launches a "manager" activity, which launches an "authorization" activity (the best system browser we can detect on device). Depending on the outcome of that authorization activity (user sign in, user canceled, etc), the authorization activity will either `finish` itself and return to the manager activity, or launch our receiver activity (identified by the manifest-placeholders), which will in turn launch our manager activity in such a way that ensures all previous activities in the flow are removed from the backstack. The manager activity will then report an activity intent result back to your activity, which you can listen for to authenticate the user and direct them as appropriate. +When you provide these placeholders, the SDK registers an activity intent-filter for our "receiver" activity. When you start a redirect-based OAuth flow, the SDK launches a "manager" activity, which launches an "authorization" activity (the best system browser we can detect on device). Depending on the outcome of that authorization activity (user sign in, user canceled, etc), the authorization activity will either `finish` itself and return to the manager activity, or launch our receiver activity (identified by the manifest-placeholders), which will in turn launch our manager activity in such a way that ensures all previous activities in the flow are removed from the backstack. For a deeper understanding of the third-party OAuth architecture, check out the [OAuth/SSO README](../source/sdk/src/main/java/com/stytch/sdk/common/sso/README.md). +There are two ways to listen for the result of this flow: +1. **Activity Result Callback -** Define an activity result callback in your activity, and use the appropriate `.start()` method. The manager activity will then report back to your activity, which you can listen for to authenticate the user and direct them as appropriate. +2. **Direct Token Capture -** Configure the OAuth/SSO client with your currently running activity (using `oauth.setOAuthReceiverActivity()`/`sso.setSSOReceiverActivity()`, and use the appropriate `getTokenForProvider()` method. Under the hood, this works the same way as the first option, but will return the final token to the coroutine/callback/CompletableFuture. This method is a little easier to setup, as you don't need to specify an activity result listener, or parse the returned link manually + ### Example Code -For this example, we're going to use a redirect path of `my-app://oauth?type={}`, so make sure to add that as a valid redirect URL for the `Login` and `Signup` types in your Stytch Dashboard's [Redirect URL settings](stytch.com/dashboard/redirect-urls). You will also need to configure an [OAuth provider](https://stytch.com/dashboard/oauth). In this example, we are using GitHub. +For both flows in the following examples, we're going to use a redirect path of `my-app://oauth?type={}`, so make sure to add that as a valid redirect URL for the `Login` and `Signup` types in your Stytch Dashboard's [Redirect URL settings](stytch.com/dashboard/redirect-urls). You will also need to configure an [OAuth provider](https://stytch.com/dashboard/oauth). In this example, we are using GitHub. Next, in your app's `build.gradle(.kts)`, add the appropriate manifest placeholders: ```gradle @@ -30,8 +34,8 @@ android { } } ``` -Third, create the activity result handler in your main activity: - +#### Activity Result Callback flow: +For this flow, you will need to create an activity result handler in your main activity: ```kotlin class MainActivity : FragmentActivity() { ... @@ -49,7 +53,7 @@ class MainActivity : FragmentActivity() { } } ``` -Fourth, jump into the viewmodel to define your `start` and `authenticate` handlers: +Then, jump into the viewmodel to define your `start` and `authenticate` handlers: ```kotlin class MyViewModel : ViewModel() { // you'll call this method from your activity and pass in the activity context in order to launch intents @@ -102,6 +106,65 @@ Put it all together by calling your start method from your activity: viewModel.loginWithGitHub(this) ``` +#### Direct Token Capture flow: +For the Direct Token Capture flow, you will need to configure the OAuth/SSO client with a compatible `ComponentActivity` (required for using the `registerForActivityResult` API) and use the `getTokenForProvider()` method (instead of `start()`). + +First, configure the Stytch client from your calling activity: +```kotlin +class MainActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + StytchClient.oauth.setOAuthReceiverActivity(this) + ... + } + override fun onDestroy() { + super.onDestroy() + StytchClient.oauth.setOAuthReceiverActivity(null) + ... + } + ... +} +``` + +Second, configure your authentication flow in your viewmodel: +```kotlin +class MyViewModel : ViewModel() { + fun loginWithGithub() { + val startParameters = + OAuth.ThirdParty.GetTokenForProviderParams( + loginRedirectUrl = "app://oauth?type={}", + signupRedirectUrl = "app://oauth?type={}", + ) + viewModelScope.launch { + when (val token = StytchClient.oauth.github.getTokenForProvider(startParameters)) { + is StytchResult.Success -> { + val authenticateParameters = + OAuth.ThirdParty.AuthenticateParameters( + token = token.value, + sessionDurationMinutes = 30, + ) + when (val result = StytchClient.oauth.authenticate(authenticateParameters)) { + is StytchResult.Success -> { + // OAuth authentication succeeded + } + is StytchResult.Error -> { + // OAuth authentication failed + } + } + } + is StytchResult.Error -> { + // OAuth start flow failed + } + } + } + } +} +``` +Lastly, call the method from your UI: +```kotlin +viewmodel.loginWithGitHub() +``` + ## Native (Google OneTap/CredentialManager) Native OAuth is a little bit different from, and quite a bit simpler than, Third Party OAuth, in which we use Google Credential Manager (formerly Google OneTap) to launch and authenticate an "OAuth ID Token" flow. After configuring your application in Google Developer Console (following the guides in the Stytch Dashboard), ensuring you have created _two_ client IDs (a "web" one, which Stytch uses to authenticate with Google, and an "Android" one, which your application uses to authenticate with Google), there is only one method you need to call: ```kotlin diff --git a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/HeadlessWorkbenchActivity.kt b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/HeadlessWorkbenchActivity.kt index dd7d2e3f7..1603742c4 100644 --- a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/HeadlessWorkbenchActivity.kt +++ b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/HeadlessWorkbenchActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.viewModels import androidx.fragment.app.FragmentActivity import com.stytch.exampleapp.b2b.theme.AppTheme import com.stytch.exampleapp.b2b.ui.AppScreen +import com.stytch.sdk.b2b.StytchB2BClient internal const val SSO_REQUEST_ID = 2 internal const val B2B_OAUTH_REQUEST = 3 @@ -26,6 +27,8 @@ class HeadlessWorkbenchActivity : FragmentActivity() { private val scimViewModel: SCIMViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { + StytchB2BClient.oauth.setOAuthReceiverActivity(this) + StytchB2BClient.sso.setSSOReceiverActivity(this) super.onCreate(savedInstanceState) setContent { AppTheme { @@ -49,6 +52,12 @@ class HeadlessWorkbenchActivity : FragmentActivity() { } } + override fun onDestroy() { + super.onDestroy() + StytchB2BClient.oauth.setOAuthReceiverActivity(null) + StytchB2BClient.sso.setSSOReceiverActivity(null) + } + private fun handleIntent(intent: Intent) { intent.data?.let { appLinkData -> Toast.makeText(this, getString(R.string.deeplink_received_toast), Toast.LENGTH_LONG).show() diff --git a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/OAuthViewModel.kt b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/OAuthViewModel.kt index 4938df24f..6594d8373 100644 --- a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/OAuthViewModel.kt +++ b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/OAuthViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.stytch.sdk.b2b.StytchB2BClient import com.stytch.sdk.b2b.oauth.OAuth +import com.stytch.sdk.common.StytchResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DisposableHandle import kotlinx.coroutines.flow.MutableStateFlow @@ -46,6 +47,59 @@ class OAuthViewModel : ViewModel() { } } + fun startGoogleOauthFlowOneShot() { + viewModelScope.launchAndToggleLoadingState { + val result = + StytchB2BClient.oauth.google.getTokenForProvider( + OAuth.Provider.GetTokenForProviderParams( + organizationId = BuildConfig.STYTCH_B2B_ORG_ID, + loginRedirectUrl = "app://b2bOAuth", + signupRedirectUrl = "app://b2bOAuth", + ), + ) + _currentResponse.value = + when (result) { + is StytchResult.Success -> { + StytchB2BClient.oauth + .authenticate( + OAuth.AuthenticateParameters( + oauthToken = result.value, + sessionDurationMinutes = 30, + ), + ).toFriendlyDisplay() + } + is StytchResult.Error -> { + result.toFriendlyDisplay() + } + } + } + } + + fun startGoogleDiscoveryOauthFlowOneShot() { + viewModelScope.launchAndToggleLoadingState { + val result = + StytchB2BClient.oauth.google.discovery.getTokenForProvider( + OAuth.ProviderDiscovery.GetTokenForProviderParams( + discoveryRedirectUrl = "app://b2bOAuth", + ), + ) + _currentResponse.value = + when (result) { + is StytchResult.Success -> { + StytchB2BClient.oauth.discovery + .authenticate( + OAuth.Discovery.DiscoveryAuthenticateParameters( + discoveryOauthToken = result.value, + ), + ).toFriendlyDisplay() + } + is StytchResult.Error -> { + result.toFriendlyDisplay() + } + } + } + } + private fun CoroutineScope.launchAndToggleLoadingState(block: suspend () -> Unit): DisposableHandle = launch { _loadingState.value = true diff --git a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/SSOViewModel.kt b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/SSOViewModel.kt index c9186f1e1..94d8655e0 100644 --- a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/SSOViewModel.kt +++ b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/SSOViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import com.stytch.sdk.b2b.StytchB2BClient import com.stytch.sdk.b2b.sso.SSO import com.stytch.sdk.common.DeeplinkHandledStatus +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.sso.SSOError import com.stytch.sdk.consumer.StytchClient import kotlinx.coroutines.CoroutineScope @@ -136,6 +137,28 @@ class SSOViewModel : ViewModel() { StytchB2BClient.sso.start(params) } + fun startSSOOneShot() { + viewModelScope.launchAndToggleLoadingState { + val response = + StytchB2BClient.sso.getTokenForProvider( + SSO.GetTokenForProviderParams(connectionId = ssoConnectionId.text), + ) + _currentResponse.value = + when (response) { + is StytchResult.Success -> { + StytchB2BClient.sso + .authenticate( + SSO.AuthenticateParams( + ssoToken = response.value, + sessionDurationMinutes = 30, + ), + ).toFriendlyDisplay() + } + is StytchResult.Error -> response.toFriendlyDisplay() + } + } + } + fun authenticateSSO( resultCode: Int, intent: Intent?, diff --git a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/OAuthScreen.kt b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/OAuthScreen.kt index 2e762e0f0..3192b4e5b 100644 --- a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/OAuthScreen.kt +++ b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/OAuthScreen.kt @@ -41,6 +41,16 @@ fun OAuthScreen(viewModel: OAuthViewModel) { text = stringResource(id = R.string.oauth_discovery_start), onClick = { viewModel.startGoogleDiscoveryOAuthFlow(context) }, ) + StytchButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.oauth_start_oneshot), + onClick = { viewModel.startGoogleOauthFlowOneShot() }, + ) + StytchButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.oauth_discovery_start_oneshot), + onClick = { viewModel.startGoogleDiscoveryOauthFlowOneShot() }, + ) } Column( modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).weight(1F, false), diff --git a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/SSOScreen.kt b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/SSOScreen.kt index 63b2f41b3..42bdabaf4 100644 --- a/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/SSOScreen.kt +++ b/workbench-apps/b2b-workbench/src/main/java/com/stytch/exampleapp/b2b/ui/SSOScreen.kt @@ -76,6 +76,11 @@ fun SSOScreen(viewModel: SSOViewModel) { text = stringResource(id = R.string.sso_start), onClick = { viewModel.startSSO(context) }, ) + StytchButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.sso_start_oneshot), + onClick = { viewModel.startSSOOneShot() }, + ) StytchButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.sso_get_connections), diff --git a/workbench-apps/b2b-workbench/src/main/res/values/strings.xml b/workbench-apps/b2b-workbench/src/main/res/values/strings.xml index 21dad5c2b..aa7d13c84 100644 --- a/workbench-apps/b2b-workbench/src/main/res/values/strings.xml +++ b/workbench-apps/b2b-workbench/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ SSO Connection ID Start SSO Flow + Start SSO Oneshot Flow Metadata URL Certificate ID Get Connections @@ -77,6 +78,8 @@ OAuth Start Google OAuth flow Start Google Discovery OAuth flow + Start Google OAuth Oneshot flow + Start Google Discovery OAuth Oneshot flow Search Orgs By Slug Search Member By Email & Org Id Get PKCE Code Pair diff --git a/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/MainActivity.kt b/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/MainActivity.kt index 0bac3ecf4..75fd19b44 100644 --- a/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/MainActivity.kt +++ b/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.fragment.app.FragmentActivity import com.google.android.gms.auth.api.phone.SmsRetriever import com.stytch.exampleapp.theme.AppTheme import com.stytch.exampleapp.ui.AppScreen +import com.stytch.sdk.consumer.StytchClient private const val SMS_CONSENT_REQUEST = 2 const val THIRD_PARTY_OAUTH_REQUEST = 4 @@ -20,6 +21,7 @@ class MainActivity : FragmentActivity() { private val oauthViewModel: OAuthViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { + StytchClient.oauth.setOAuthReceiverActivity(this) super.onCreate(savedInstanceState) setContent { AppTheme { @@ -31,6 +33,11 @@ class MainActivity : FragmentActivity() { } } + override fun onDestroy() { + super.onDestroy() + StytchClient.oauth.setOAuthReceiverActivity(null) + } + private fun handleIntent(intent: Intent) { intent.data?.let { appLinkData -> Toast.makeText(this, getString(R.string.deeplink_received_toast), Toast.LENGTH_LONG).show() diff --git a/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/OAuthViewModel.kt b/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/OAuthViewModel.kt index 557c19836..94f16b8ca 100644 --- a/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/OAuthViewModel.kt +++ b/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/OAuthViewModel.kt @@ -7,6 +7,7 @@ import android.content.Intent import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.stytch.sdk.common.DeeplinkHandledStatus +import com.stytch.sdk.common.StytchResult import com.stytch.sdk.common.sso.SSOError import com.stytch.sdk.consumer.StytchClient import com.stytch.sdk.consumer.oauth.OAuth @@ -89,6 +90,53 @@ class OAuthViewModel( } } + fun loginWithThirdPartyOAuthOneShot(provider: OAuthProvider) { + val startParameters = + OAuth.ThirdParty.GetTokenForProviderParams( + loginRedirectUrl = "app://oauth", + signupRedirectUrl = "app://oauth", + ) + viewModelScope + .launch { + _loadingState.value = true + val result = + when (provider) { + OAuthProvider.APPLE -> StytchClient.oauth.apple.getTokenForProvider(startParameters) + OAuthProvider.AMAZON -> StytchClient.oauth.amazon.getTokenForProvider(startParameters) + OAuthProvider.BITBUCKET -> StytchClient.oauth.bitbucket.getTokenForProvider(startParameters) + OAuthProvider.COINBASE -> StytchClient.oauth.coinbase.getTokenForProvider(startParameters) + OAuthProvider.DISCORD -> StytchClient.oauth.discord.getTokenForProvider(startParameters) + OAuthProvider.FACEBOOK -> StytchClient.oauth.facebook.getTokenForProvider(startParameters) + OAuthProvider.GOOGLE -> StytchClient.oauth.google.getTokenForProvider(startParameters) + OAuthProvider.GITHUB -> StytchClient.oauth.github.getTokenForProvider(startParameters) + OAuthProvider.GITLAB -> StytchClient.oauth.gitlab.getTokenForProvider(startParameters) + OAuthProvider.LINKEDIN -> StytchClient.oauth.linkedin.getTokenForProvider(startParameters) + OAuthProvider.MICROSOFT -> StytchClient.oauth.microsoft.getTokenForProvider(startParameters) + OAuthProvider.SALESFORCE -> StytchClient.oauth.salesforce.getTokenForProvider(startParameters) + OAuthProvider.SLACK -> StytchClient.oauth.slack.getTokenForProvider(startParameters) + OAuthProvider.TWITCH -> StytchClient.oauth.twitch.getTokenForProvider(startParameters) + OAuthProvider.YAHOO -> StytchClient.oauth.yahoo.getTokenForProvider(startParameters) + } + _currentResponse.value = + when (result) { + is StytchResult.Success -> { + StytchClient.oauth + .authenticate( + OAuth.ThirdParty.AuthenticateParameters( + token = result.value, + sessionDurationMinutes = 30, + ), + ).toFriendlyDisplay() + } + is StytchResult.Error -> { + result.toFriendlyDisplay() + } + } + }.invokeOnCompletion { + _loadingState.value = false + } + } + fun authenticateThirdPartyOAuth( resultCode: Int, intent: Intent, diff --git a/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/ui/OAuthScreen.kt b/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/ui/OAuthScreen.kt index 06b77c111..6ba1b7bce 100644 --- a/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/ui/OAuthScreen.kt +++ b/workbench-apps/consumer-workbench/src/main/java/com/stytch/exampleapp/ui/OAuthScreen.kt @@ -77,6 +77,11 @@ fun OAuthScreen(viewModel: OAuthViewModel) { text = stringResource(id = R.string.oauth_github), onClick = { viewModel.loginWithThirdPartyOAuth(context, OAuthProvider.GITHUB) }, ) + StytchButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.oauth_github_oneshot), + onClick = { viewModel.loginWithThirdPartyOAuthOneShot(OAuthProvider.GITHUB) }, + ) StytchButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.oauth_gitlab), diff --git a/workbench-apps/consumer-workbench/src/main/res/values/strings.xml b/workbench-apps/consumer-workbench/src/main/res/values/strings.xml index edfaee530..87facde26 100644 --- a/workbench-apps/consumer-workbench/src/main/res/values/strings.xml +++ b/workbench-apps/consumer-workbench/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ Login with Google OneTap Login with Google (Legacy) Login with Github + Login with Github (Oneshot) Login With Gitlab Login With Linkedin Login With Microsoft