Skip to content

Commit

Permalink
SDK-2389 Provide alternative/simpler OAuth and SSO methods (#250)
Browse files Browse the repository at this point in the history
* Add new methods for retrieving the token without requiring developers to register activity listeners. Working on Consumer, need to figure out best way to genericize for B2B and SSO

* Add new methods to B2B Oauth, Oauth Discovery, and SSO

* Add methods and test in b2b workbench

* Update OAuth/SSO tutorial

* Bump version

* Ensure we clear the continuation when activity is removed
  • Loading branch information
jhaven-stytch authored Feb 4, 2025
1 parent 58d5e02 commit 4d4cb20
Show file tree
Hide file tree
Showing 22 changed files with 851 additions and 39 deletions.
2 changes: 1 addition & 1 deletion source/sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
59 changes: 59 additions & 0 deletions source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuth.kt
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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<String>? = null,
val providerParams: Map<String, String>? = null,
)

public suspend fun getTokenForProvider(parameters: GetTokenForProviderParams): StytchResult<String>

public fun getTokenForProvider(
parameters: GetTokenForProviderParams,
callback: (StytchResult<String>) -> Unit,
)

public fun getTokenForProviderCompletable(
parameters: GetTokenForProviderParams,
): CompletableFuture<StytchResult<String>>
}

/**
Expand Down Expand Up @@ -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<String>? = null,
val providerParams: Map<String, String>? = null,
)

public suspend fun getTokenForProvider(parameters: GetTokenForProviderParams): StytchResult<String>

public fun getTokenForProvider(
parameters: GetTokenForProviderParams,
callback: (StytchResult<String>) -> Unit,
)

public fun getTokenForProviderCompletable(
parameters: GetTokenForProviderParams,
): CompletableFuture<StytchResult<String>>
}

/**
Expand Down
131 changes: 125 additions & 6 deletions source/sdk/src/main/java/com/stytch/sdk/b2b/oauth/OAuthImpl.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,29 +13,40 @@ 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,
private val dispatchers: StytchDispatchers,
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 {
Expand Down Expand Up @@ -85,6 +99,9 @@ internal class OAuthImpl(

private inner class ProviderImpl(
private val providerName: String,
private val getOAuthReceiver: (
Continuation<StytchResult<String>>,
) -> Pair<ComponentActivity?, ActivityResultLauncher<Intent>?>,
) : OAuth.Provider {
override fun start(parameters: OAuth.Provider.StartParameters) {
val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge
Expand All @@ -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<String> =
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<String>) -> Unit,
) {
externalScope.launch(dispatchers.ui) {
callback(getTokenForProvider(parameters))
}
}

override fun getTokenForProviderCompletable(
parameters: OAuth.Provider.GetTokenForProviderParams,
): CompletableFuture<StytchResult<String>> =
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<StytchResult<String>>,
) -> Pair<ComponentActivity?, ActivityResultLauncher<Intent>?>,
) : OAuth.ProviderDiscovery {
override fun start(parameters: OAuth.ProviderDiscovery.DiscoveryStartParameters) {
val pkce = pkcePairManager.generateAndReturnPKCECodePair().codeChallenge
Expand All @@ -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<String> =
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<String>) -> Unit,
) {
externalScope.launch(dispatchers.ui) {
callback(getTokenForProvider(parameters))
}
}

override fun getTokenForProviderCompletable(
parameters: OAuth.ProviderDiscovery.GetTokenForProviderParams,
): CompletableFuture<StytchResult<String>> =
externalScope
.async {
getTokenForProvider(parameters)
}.asCompletableFuture()
}

private inner class DiscoveryImpl : OAuth.Discovery {
Expand Down
37 changes: 37 additions & 0 deletions source/sdk/src/main/java/com/stytch/sdk/b2b/sso/SSO.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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<String>

public fun getTokenForProvider(
parameters: GetTokenForProviderParams,
callback: (StytchResult<String>) -> Unit,
)

public fun getTokenForProviderCompletable(
parameters: GetTokenForProviderParams,
): CompletableFuture<StytchResult<String>>

/**
* Data class used for wrapping parameters used in SSO Authenticate calls
* @property ssoToken the SSO token to authenticate
Expand Down
Loading

0 comments on commit 4d4cb20

Please sign in to comment.