From b0b4165a6b4654c3434ac7c10617bcedeedd6c73 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Wed, 17 Jan 2024 13:59:36 +0530 Subject: [PATCH] feat: Support for login and registration via a browser custom tab This change adds support for logging in and registering a new account using the browser. This can be useful for cases where the only way to log into the instatance is via a custom third-party auth provider. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b..e2c208d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,6 +45,12 @@ + + + + + + diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b..6e5089e 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -3,6 +3,7 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration import android.graphics.Color +import android.net.Uri import android.os.Bundle import android.view.View import android.view.WindowManager @@ -56,6 +57,14 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private var _insetCutout = 0 private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + private val authCode: String? + get() { + val data = intent?.data + if (data is Uri && data.scheme == BuildConfig.APPLICATION_ID && data.host == "oauth2Callback") { + return data.getQueryParameter("code") + } + return null + } override fun onSaveInstanceState(outState: Bundle) { outState.putInt(TOP_INSET, topInset) @@ -119,10 +128,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { if (savedInstanceState == null) { when { corePreferencesManager.user == null -> { - if (viewModel.isLogistrationEnabled) { + val authCode = authCode; + if (viewModel.isLogistrationEnabled && authCode == null) { addFragment(LogistrationFragment()) } else { - addFragment(SignInFragment()) + val bundle = Bundle() + bundle.putString("auth_code", authCode) + val fragment = SignInFragment() + fragment.arguments = bundle + addFragment(fragment) } } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 16a30c0..0d3feb4 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -21,6 +21,7 @@ import org.openedx.app.system.notifier.AppNotifier import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.sso.FacebookAuthHelper import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper @@ -180,5 +181,6 @@ val appModule = module { factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } + factory { BrowserAuthHelper(get()) } factory { OAuthHelper(get(), get(), get()) } } diff --git a/auth/build.gradle b/auth/build.gradle index 7cf4d0a..b66db95 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -55,6 +55,7 @@ android { dependencies { implementation project(path: ':core') + implementation 'androidx.browser:browser:1.7.0' implementation "androidx.credentials:credentials:1.2.0" implementation "androidx.credentials:credentials-play-services-auth:1.2.0" implementation "com.facebook.android:facebook-login:16.2.0" diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index 903cbd6..6d40554 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -32,6 +32,14 @@ interface AuthApi { @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, ): AuthResponse + @FormUrlEncoded + @POST(ApiConstants.URL_ACCESS_TOKEN) + suspend fun getAccessTokenFromCode( + @Field("grant_type") grantType: String, + @Field("client_id") clientId: String, + @Field("code") code: String, + ): AuthResponse + @FormUrlEncoded @POST(ApiConstants.URL_ACCESS_TOKEN) fun refreshAccessToken( diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt index 5addd62..c56ba0c 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt @@ -13,4 +13,5 @@ enum class AuthType(val postfix: String, val methodName: String) { GOOGLE(ApiConstants.AUTH_TYPE_GOOGLE, "Google"), FACEBOOK(ApiConstants.AUTH_TYPE_FB, "Facebook"), MICROSOFT(ApiConstants.AUTH_TYPE_MICROSOFT, "Microsoft"), + BROWSER(ApiConstants.AUTH_TYPE_BROWSER, "Browser") } diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 6cf54a7..a7d364a 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -1,5 +1,6 @@ package org.openedx.auth.data.repository +import android.util.Log import org.openedx.auth.data.api.AuthApi import org.openedx.auth.data.model.AuthType import org.openedx.auth.data.model.ValidationFields @@ -43,6 +44,14 @@ class AuthRepository( .processAuthResponse() } + suspend fun browserAuthCodeLogin(code: String) { + api.getAccessTokenFromCode( + grantType = ApiConstants.GRANT_TYPE_CODE, + clientId = config.getOAuthClientId(), + code = code, + ).mapToDomain().processAuthResponse() + } + suspend fun getRegistrationFields(): List { return api.getRegistrationFields().fields?.map { it.mapToDomain() } ?: emptyList() } diff --git a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt index 00fe509..d81c51e 100644 --- a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt +++ b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt @@ -18,6 +18,10 @@ class AuthInteractor(private val repository: AuthRepository) { repository.socialLogin(token, authType) } + suspend fun loginAuthCode(authCode: String) { + repository.browserAuthCodeLogin(authCode) + } + suspend fun getRegistrationFields(): List { return repository.getRegistrationFields() } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 738364c..0b615f3 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -1,6 +1,8 @@ package org.openedx.auth.presentation.logistration +import android.content.Intent import android.content.res.Configuration +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -41,6 +43,9 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.auth.R +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape @@ -48,6 +53,7 @@ import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.UrlUtils import org.openedx.core.ui.theme.compose.LogistrationLogoView class LogistrationFragment : Fragment() { @@ -55,6 +61,8 @@ class LogistrationFragment : Fragment() { private val viewModel: LogistrationViewModel by viewModel { parametersOf(arguments?.getString(ARG_COURSE_ID, "") ?: "") } + private val router: AuthRouter by inject() + private val config: Config by inject() override fun onCreateView( inflater: LayoutInflater, @@ -70,6 +78,15 @@ class LogistrationFragment : Fragment() { }, onRegisterClick = { viewModel.navigateToSignUp(parentFragmentManager) + if (config.isBrowserRegistrationEnabled()) { + UrlUtils.openInBrowser( + activity = context, + apiHostUrl = config.getApiHostURL(), + url = "/register", + ) + } else { + router.navigateToSignUp(parentFragmentManager, courseId) + } }, onSearchClick = { querySearch -> viewModel.navigateToDiscovery(parentFragmentManager, querySearch) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index fabd8a4..e89c000 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -1,6 +1,7 @@ package org.openedx.auth.presentation.signin import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.runtime.LaunchedEffect @@ -43,6 +44,11 @@ class SignInFragment : Fragment() { val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) if (appUpgradeEvent == null) { + val authCode = arguments?.getString("auth_code") + if (authCode is String && !state.loginFailure && !state.loginSuccess) { + arguments?.remove("auth_code") + viewModel.signInAuthCode(authCode) + } LoginScreen( windowSize = windowSize, state = state, @@ -59,6 +65,10 @@ class SignInFragment : Fragment() { viewModel.navigateToForgotPassword(parentFragmentManager) } + AuthEvent.SignInBrowser -> { + viewModel.signInBrowser(requireActivity()) + } + AuthEvent.RegisterClick -> { viewModel.navigateToSignUp(parentFragmentManager) } @@ -107,6 +117,7 @@ internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent data class SocialSignIn(val authType: AuthType) : AuthEvent data class OpenLink(val links: Map, val link: String) : AuthEvent + object SignInBrowser : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index 9ce5cfc..8954c1f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -17,8 +17,11 @@ internal data class SignInUIState( val isGoogleAuthEnabled: Boolean = false, val isMicrosoftAuthEnabled: Boolean = false, val isSocialAuthEnabled: Boolean = false, + val isBrowserLoginEnabled: Boolean = false, + val isBrowserRegistrationEnabled: Boolean = false, val isLogistrationEnabled: Boolean = false, val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, + val loginFailure: Boolean = false, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 7ebc5a5..4e6db6a 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -17,6 +17,7 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter @@ -49,6 +50,11 @@ class SignInViewModel( private val whatsNewGlobalManager: WhatsNewGlobalManager, agreementProvider: AgreementProvider, config: Config, + private val facebookAuthHelper: FacebookAuthHelper, + private val googleAuthHelper: GoogleAuthHelper, + private val microsoftAuthHelper: MicrosoftAuthHelper, + private val browserAuthHelper: BrowserAuthHelper, + val config: Config, val courseId: String?, val infoType: String?, ) : BaseViewModel() { @@ -60,6 +66,8 @@ class SignInViewModel( isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), + isBrowserLoginEnabled = config.isBrowserLoginEnabled(), + isBrowserRegistrationEnabled = config.isBrowserRegistrationEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), @@ -144,11 +152,42 @@ class SignInViewModel( } } + fun signInBrowser(activityContext: Activity) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + browserAuthHelper.signIn(activityContext) + }.onFailure { + logger.e { "Browser auth error: $it" } + } + } + } + fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, null, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } + fun signInAuthCode(authCode: String) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + interactor.loginAuthCode(authCode) + } + .onFailure { + logger.e { "OAuth2 code error: $it" } + onUnknownError() + _uiState.update { it.copy(loginFailure = true) } + }.onSuccess { + logger.d { "Browser login success" } + _uiState.update { it.copy(loginSuccess = true) } + setUserId() + analytics.userLoginEvent(AuthType.BROWSER.methodName) + _uiState.update { it.copy(showProgress = false) } + } + } + } + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 77e2909..642ab2f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -218,55 +218,60 @@ private fun AuthForm( var password by rememberSaveable { mutableStateOf("") } Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoginTextField( - modifier = Modifier - .fillMaxWidth(), - title = stringResource(id = R.string.auth_email_username), - description = stringResource(id = R.string.auth_enter_email_username), - onValueChanged = { - login = it - }) + if (!state.isBrowserLoginEnabled) { + LoginTextField( + modifier = Modifier + .fillMaxWidth(), + title = stringResource(id = R.string.auth_email_username), + description = stringResource(id = R.string.auth_enter_email_username),onValueChanged = { + login = it + }) - Spacer(modifier = Modifier.height(18.dp)) - PasswordTextField( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - password = it - }, - onPressDone = { - onEvent(AuthEvent.SignIn(login = login, password = password)) - } - ) + Spacer(modifier = Modifier.height(18.dp)) + PasswordTextField( + modifier = Modifier + .fillMaxWidth(), + onValueChanged = { + password = it + }, + onPressDone = { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } + ) + } else { + Spacer(modifier = Modifier.height(40.dp)) + } Row( Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 36.dp) ) { - if (state.isLogistrationEnabled.not()) { - Text( - modifier = Modifier + if (!state.isBrowserLoginEnabled) { + if (state.isLogistrationEnabled.not()) { + Text( + modifier = Modifier .testTag("txt_register") .noRippleClickable { onEvent(AuthEvent.RegisterClick) }, - text = stringResource(id = coreR.string.core_register), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - } - Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier + text = stringResource(id = coreR.string.core_register), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier .testTag("txt_forgot_password") .noRippleClickable { onEvent(AuthEvent.ForgotPasswordClick) }, - text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) + text = stringResource(id = R.string.auth_forgot_password), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } } if (state.showProgress) { @@ -276,7 +281,11 @@ private fun AuthForm( modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), onClick = { - onEvent(AuthEvent.SignIn(login = login, password = password)) + if(state.isBrowserLoginEnabled) { + onEvent(AuthEvent.SignInBrowser) + } else { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } } ) } @@ -365,6 +374,24 @@ private fun SignInScreenPreview() { } } +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun SignInUsingBrowserScreenPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = SignInUIState().copy( + isBrowserLoginEnabled = true, + ), + uiMessage = null, + onEvent = {}, + ) + } +} + @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) @Composable diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt new file mode 100644 index 0000000..5822bab --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt @@ -0,0 +1,32 @@ +package org.openedx.auth.presentation.sso + +import android.app.Activity +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.net.Uri +import androidx.annotation.WorkerThread +import androidx.browser.customtabs.CustomTabsIntent +import org.openedx.core.config.Config +import org.openedx.core.utils.Logger + +class BrowserAuthHelper(private val config: Config) { + + private val logger = Logger(TAG) + + @WorkerThread + suspend fun signIn(activityContext: Activity) { + logger.d { "Browser-based auth initiated" } + val uri = Uri.parse("${config.getApiHostURL()}/oauth2/authorize").buildUpon() + .appendQueryParameter("client_id", config.getOAuthClientId()) + .appendQueryParameter("redirect_uri", "${activityContext.packageName}://oauth2Callback") + .appendQueryParameter("response_type", "code").build() + val intent = + CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build() + intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK) + logger.d { "Launching custom tab with ${uri.toString()}"} + intent.launchUrl(activityContext, uri) + } + + private companion object { + const val TAG = "BrowserAuthHelper" + } +} diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index b36aabb..0d22d93 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -25,6 +25,7 @@ import org.openedx.auth.R import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.UIMessage @@ -61,6 +62,7 @@ class SignInViewModelTest { private val oAuthHelper = mockk() private val router = mockk() private val whatsNewGlobalManager = mockk() + private val browserAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -85,6 +87,8 @@ class SignInViewModelTest { every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { config.isBrowserLoginEnabled() } returns false + every { config.isBrowserRegistrationEnabled() } returns false } @After @@ -110,6 +114,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -143,6 +148,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -177,6 +183,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -210,6 +217,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -245,6 +253,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -281,6 +290,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -319,6 +329,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -357,6 +368,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 786d63c..86d678c 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -12,6 +12,7 @@ object ApiConstants { const val URL_PASSWORD_RESET = "/password_reset/" const val GRANT_TYPE_PASSWORD = "password" + const val GRANT_TYPE_CODE = "authorization_code" const val TOKEN_TYPE_BEARER = "Bearer" const val TOKEN_TYPE_JWT = "jwt" @@ -27,6 +28,7 @@ object ApiConstants { const val AUTH_TYPE_GOOGLE = "google-oauth2" const val AUTH_TYPE_FB = "facebook" const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" + const val AUTH_TYPE_BROWSER = "browser" const val COURSE_KEY = "course_key" diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 4b40fbc..186c6d3 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -111,6 +111,14 @@ class Config(context: Context) { return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) } + fun isBrowserLoginEnabled(): Boolean { + return getBoolean(BROWSER_LOGIN, false) + } + + fun isBrowserRegistrationEnabled(): Boolean { + return getBoolean(BROWSER_REGISTRATION, false) + } + private fun getString(key: String, defaultValue: String): String { val element = getObject(key) return if (element != null) { @@ -162,6 +170,8 @@ class Config(context: Context) { private const val GOOGLE = "GOOGLE" private const val MICROSOFT = "MICROSOFT" private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" + private const val BROWSER_LOGIN = "BROWSER_LOGIN" + private const val BROWSER_REGISTRATION = "BROWSER_REGISTRATION" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val BRANCH = "BRANCH" diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index e1582bf..9347d27 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f7afc7b..fa71747 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f7afc7b..fa71747 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false --- app/src/main/AndroidManifest.xml | 6 ++ .../main/java/org/openedx/app/AppActivity.kt | 18 +++- .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 2 + auth/build.gradle | 1 + .../java/org/openedx/auth/data/api/AuthApi.kt | 9 ++ .../org/openedx/auth/data/model/AuthType.kt | 1 + .../auth/data/repository/AuthRepository.kt | 9 ++ .../auth/domain/interactor/AuthInteractor.kt | 4 + .../logistration/LogistrationFragment.kt | 18 +++- .../logistration/LogistrationViewModel.kt | 22 +++++ .../presentation/signin/SignInFragment.kt | 10 ++ .../auth/presentation/signin/SignInUIState.kt | 3 + .../presentation/signin/SignInViewModel.kt | 37 ++++++- .../presentation/signin/compose/SignInView.kt | 98 ++++++++++++------- .../presentation/signup/SignUpFragment.kt | 1 + .../presentation/sso/BrowserAuthHelper.kt | 32 ++++++ .../auth/presentation/sso/OAuthHelper.kt | 1 + .../signin/SignInViewModelTest.kt | 12 +++ .../java/org/openedx/core/ApiConstants.kt | 2 + .../java/org/openedx/core/config/Config.kt | 15 +++ default_config/dev/config.yaml | 4 + default_config/prod/config.yaml | 4 + default_config/stage/config.yaml | 4 + .../0001-strategy-for-data-streams.rst | 0 docs/how-tos/auth-using-browser.rst | 45 +++++++++ docs/how-tos/index.rst | 8 ++ 27 files changed, 328 insertions(+), 40 deletions(-) create mode 100644 auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt rename docs/{ => decisions}/0001-strategy-for-data-streams.rst (100%) create mode 100644 docs/how-tos/auth-using-browser.rst create mode 100644 docs/how-tos/index.rst diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8020f6b74..e2c208dde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,6 +45,12 @@ + + + + + + diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 5ab0d0b0e..6e5089e1c 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -3,6 +3,7 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration import android.graphics.Color +import android.net.Uri import android.os.Bundle import android.view.View import android.view.WindowManager @@ -56,6 +57,14 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private var _insetCutout = 0 private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + private val authCode: String? + get() { + val data = intent?.data + if (data is Uri && data.scheme == BuildConfig.APPLICATION_ID && data.host == "oauth2Callback") { + return data.getQueryParameter("code") + } + return null + } override fun onSaveInstanceState(outState: Bundle) { outState.putInt(TOP_INSET, topInset) @@ -119,10 +128,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { if (savedInstanceState == null) { when { corePreferencesManager.user == null -> { - if (viewModel.isLogistrationEnabled) { + val authCode = authCode; + if (viewModel.isLogistrationEnabled && authCode == null) { addFragment(LogistrationFragment()) } else { - addFragment(SignInFragment()) + val bundle = Bundle() + bundle.putString("auth_code", authCode) + val fragment = SignInFragment() + fragment.arguments = bundle + addFragment(fragment) } } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 16a30c0c6..0d3feb43a 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -21,6 +21,7 @@ import org.openedx.app.system.notifier.AppNotifier import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.sso.FacebookAuthHelper import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper @@ -180,5 +181,6 @@ val appModule = module { factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } + factory { BrowserAuthHelper(get()) } factory { OAuthHelper(get(), get(), get()) } } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 4efd1a19e..2cfefc37d 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -76,6 +76,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } @@ -92,6 +93,7 @@ val screenModule = module { get(), get(), get(), + get(), courseId, infoType, ) diff --git a/auth/build.gradle b/auth/build.gradle index 7cf4d0a86..b66db95d3 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -55,6 +55,7 @@ android { dependencies { implementation project(path: ':core') + implementation 'androidx.browser:browser:1.7.0' implementation "androidx.credentials:credentials:1.2.0" implementation "androidx.credentials:credentials-play-services-auth:1.2.0" implementation "com.facebook.android:facebook-login:16.2.0" diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index 903cbd62e..4ecc3fa5d 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -32,6 +32,15 @@ interface AuthApi { @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, ): AuthResponse + @FormUrlEncoded + @POST(ApiConstants.URL_ACCESS_TOKEN) + suspend fun getAccessTokenFromCode( + @Field("grant_type") grantType: String, + @Field("client_id") clientId: String, + @Field("code") code: String, + @Field("redirect_uri") redirectUri: String + ): AuthResponse + @FormUrlEncoded @POST(ApiConstants.URL_ACCESS_TOKEN) fun refreshAccessToken( diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt index 5addd621c..c56ba0cf1 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt @@ -13,4 +13,5 @@ enum class AuthType(val postfix: String, val methodName: String) { GOOGLE(ApiConstants.AUTH_TYPE_GOOGLE, "Google"), FACEBOOK(ApiConstants.AUTH_TYPE_FB, "Facebook"), MICROSOFT(ApiConstants.AUTH_TYPE_MICROSOFT, "Microsoft"), + BROWSER(ApiConstants.AUTH_TYPE_BROWSER, "Browser") } diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 6cf54a7f1..ddf1b69d0 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -43,6 +43,15 @@ class AuthRepository( .processAuthResponse() } + suspend fun browserAuthCodeLogin(code: String) { + api.getAccessTokenFromCode( + grantType = ApiConstants.GRANT_TYPE_CODE, + clientId = config.getOAuthClientId(), + code = code, + redirectUri = "${config.getApplicationID()}://oauth2Callback" + ).mapToDomain().processAuthResponse() + } + suspend fun getRegistrationFields(): List { return api.getRegistrationFields().fields?.map { it.mapToDomain() } ?: emptyList() } diff --git a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt index 00fe509af..d81c51eaf 100644 --- a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt +++ b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt @@ -18,6 +18,10 @@ class AuthInteractor(private val repository: AuthRepository) { repository.socialLogin(token, authType) } + suspend fun loginAuthCode(authCode: String) { + repository.browserAuthCodeLogin(authCode) + } + suspend fun getRegistrationFields(): List { return repository.getRegistrationFields() } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 738364c34..242bf5aec 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -49,6 +49,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.theme.compose.LogistrationLogoView +import org.openedx.core.utils.UrlUtils class LogistrationFragment : Fragment() { @@ -66,10 +67,23 @@ class LogistrationFragment : Fragment() { OpenEdXTheme { LogistrationScreen( onSignInClick = { - viewModel.navigateToSignIn(parentFragmentManager) + if(viewModel.isBrowserLoginEnabled) { + viewModel.signInBrowser(requireActivity()) + } else { + viewModel.navigateToSignIn(parentFragmentManager) + } + }, onRegisterClick = { - viewModel.navigateToSignUp(parentFragmentManager) + if (viewModel.isBrowserRegistrationEnabled) { + UrlUtils.openInBrowser( + activity = context, + apiHostUrl = viewModel.apiHostUrl, + url = "/register", + ) + } else { + viewModel.navigateToSignUp(parentFragmentManager) + } }, onSearchClick = { querySearch -> viewModel.navigateToDiscovery(parentFragmentManager, querySearch) diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index e48a5e8be..438a42f11 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -1,28 +1,50 @@ package org.openedx.auth.presentation.logistration +import android.app.Activity import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.core.BaseViewModel import org.openedx.core.config.Config import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.utils.Logger class LogistrationViewModel( private val courseId: String, private val router: AuthRouter, private val config: Config, private val analytics: AuthAnalytics, + private val browserAuthHelper: BrowserAuthHelper, ) : BaseViewModel() { + private val logger = Logger("LogistrationViewModel") + private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + val isBrowserRegistrationEnabled get() = config.isBrowserRegistrationEnabled() + val isBrowserLoginEnabled get() = config.isBrowserLoginEnabled() + val apiHostUrl get() = config.getApiHostURL() fun navigateToSignIn(parentFragmentManager: FragmentManager) { router.navigateToSignIn(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) } + fun signInBrowser(activityContext: Activity) { + viewModelScope.launch { + runCatching { + browserAuthHelper.signIn(activityContext) + }.onFailure { + logger.e { "Browser auth error: $it" } + } + } + } + fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index fabd8a40b..b31a6a715 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -43,6 +43,11 @@ class SignInFragment : Fragment() { val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) if (appUpgradeEvent == null) { + val authCode = arguments?.getString("auth_code") + if (authCode is String && !state.loginFailure && !state.loginSuccess) { + arguments?.remove("auth_code") + viewModel.signInAuthCode(authCode) + } LoginScreen( windowSize = windowSize, state = state, @@ -59,6 +64,10 @@ class SignInFragment : Fragment() { viewModel.navigateToForgotPassword(parentFragmentManager) } + AuthEvent.SignInBrowser -> { + viewModel.signInBrowser(requireActivity()) + } + AuthEvent.RegisterClick -> { viewModel.navigateToSignUp(parentFragmentManager) } @@ -107,6 +116,7 @@ internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent data class SocialSignIn(val authType: AuthType) : AuthEvent data class OpenLink(val links: Map, val link: String) : AuthEvent + object SignInBrowser : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index 9ce5cfc98..8954c1fd1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -17,8 +17,11 @@ internal data class SignInUIState( val isGoogleAuthEnabled: Boolean = false, val isMicrosoftAuthEnabled: Boolean = false, val isSocialAuthEnabled: Boolean = false, + val isBrowserLoginEnabled: Boolean = false, + val isBrowserRegistrationEnabled: Boolean = false, val isLogistrationEnabled: Boolean = false, val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, + val loginFailure: Boolean = false, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 7ebc5a569..eaa6f4dae 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -1,5 +1,6 @@ package org.openedx.auth.presentation.signin +import android.app.Activity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData @@ -17,6 +18,7 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter @@ -48,7 +50,8 @@ class SignInViewModel( private val router: AuthRouter, private val whatsNewGlobalManager: WhatsNewGlobalManager, agreementProvider: AgreementProvider, - config: Config, + private val browserAuthHelper: BrowserAuthHelper, + val config: Config, val courseId: String?, val infoType: String?, ) : BaseViewModel() { @@ -60,6 +63,8 @@ class SignInViewModel( isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), + isBrowserLoginEnabled = config.isBrowserLoginEnabled(), + isBrowserRegistrationEnabled = config.isBrowserRegistrationEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), @@ -144,11 +149,41 @@ class SignInViewModel( } } + fun signInBrowser(activityContext: Activity) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + browserAuthHelper.signIn(activityContext) + }.onFailure { + logger.e { "Browser auth error: $it" } + } + } + } + fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, null, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } + fun signInAuthCode(authCode: String) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + interactor.loginAuthCode(authCode) + } + .onFailure { + logger.e { "OAuth2 code error: $it" } + onUnknownError() + _uiState.update { it.copy(loginFailure = true) } + }.onSuccess { + logger.d { "Browser login success" } + _uiState.update { it.copy(loginSuccess = true) } + setUserId() + _uiState.update { it.copy(showProgress = false) } + } + } + } + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 77e290994..e13fc71cd 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -218,55 +218,61 @@ private fun AuthForm( var password by rememberSaveable { mutableStateOf("") } Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoginTextField( - modifier = Modifier - .fillMaxWidth(), - title = stringResource(id = R.string.auth_email_username), - description = stringResource(id = R.string.auth_enter_email_username), - onValueChanged = { - login = it - }) + if (!state.isBrowserLoginEnabled) { + LoginTextField( + modifier = Modifier + .fillMaxWidth(), + title = stringResource(id = R.string.auth_email_username), + description = stringResource(id = R.string.auth_enter_email_username), + onValueChanged = { + login = it + }) - Spacer(modifier = Modifier.height(18.dp)) - PasswordTextField( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - password = it - }, - onPressDone = { - onEvent(AuthEvent.SignIn(login = login, password = password)) - } - ) + Spacer(modifier = Modifier.height(18.dp)) + PasswordTextField( + modifier = Modifier + .fillMaxWidth(), + onValueChanged = { + password = it + }, + onPressDone = { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } + ) + } else { + Spacer(modifier = Modifier.height(40.dp)) + } Row( Modifier .fillMaxWidth() .padding(top = 20.dp, bottom = 36.dp) ) { - if (state.isLogistrationEnabled.not()) { - Text( - modifier = Modifier + if (!state.isBrowserLoginEnabled) { + if (state.isLogistrationEnabled.not()) { + Text( + modifier = Modifier .testTag("txt_register") .noRippleClickable { onEvent(AuthEvent.RegisterClick) }, - text = stringResource(id = coreR.string.core_register), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - } - Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier + text = stringResource(id = coreR.string.core_register), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier .testTag("txt_forgot_password") .noRippleClickable { onEvent(AuthEvent.ForgotPasswordClick) }, - text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) + text = stringResource(id = R.string.auth_forgot_password), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } } if (state.showProgress) { @@ -276,7 +282,11 @@ private fun AuthForm( modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), onClick = { - onEvent(AuthEvent.SignIn(login = login, password = password)) + if(state.isBrowserLoginEnabled) { + onEvent(AuthEvent.SignInBrowser) + } else { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } } ) } @@ -365,6 +375,24 @@ private fun SignInScreenPreview() { } } +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun SignInUsingBrowserScreenPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = SignInUIState().copy( + isBrowserLoginEnabled = true, + ), + uiMessage = null, + onEvent = {}, + ) + } +} + @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) @Composable diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index fa27d7d60..7cc961abc 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -66,6 +66,7 @@ class SignUpFragment : Fragment() { this@SignUpFragment, authType ) + AuthType.BROWSER -> null } }, onFieldUpdated = { key, value -> diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt new file mode 100644 index 000000000..5822babd6 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt @@ -0,0 +1,32 @@ +package org.openedx.auth.presentation.sso + +import android.app.Activity +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.net.Uri +import androidx.annotation.WorkerThread +import androidx.browser.customtabs.CustomTabsIntent +import org.openedx.core.config.Config +import org.openedx.core.utils.Logger + +class BrowserAuthHelper(private val config: Config) { + + private val logger = Logger(TAG) + + @WorkerThread + suspend fun signIn(activityContext: Activity) { + logger.d { "Browser-based auth initiated" } + val uri = Uri.parse("${config.getApiHostURL()}/oauth2/authorize").buildUpon() + .appendQueryParameter("client_id", config.getOAuthClientId()) + .appendQueryParameter("redirect_uri", "${activityContext.packageName}://oauth2Callback") + .appendQueryParameter("response_type", "code").build() + val intent = + CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build() + intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK) + logger.d { "Launching custom tab with ${uri.toString()}"} + intent.launchUrl(activityContext, uri) + } + + private companion object { + const val TAG = "BrowserAuthHelper" + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt index 776df7c46..ccb094fae 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt @@ -21,6 +21,7 @@ class OAuthHelper( AuthType.GOOGLE -> googleAuthHelper.socialAuth(fragment.requireActivity()) AuthType.FACEBOOK -> facebookAuthHelper.socialAuth(fragment) AuthType.MICROSOFT -> microsoftAuthHelper.socialAuth(fragment.requireActivity()) + AuthType.BROWSER -> null } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index b36aabb10..0d22d931c 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -25,6 +25,7 @@ import org.openedx.auth.R import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.UIMessage @@ -61,6 +62,7 @@ class SignInViewModelTest { private val oAuthHelper = mockk() private val router = mockk() private val whatsNewGlobalManager = mockk() + private val browserAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -85,6 +87,8 @@ class SignInViewModelTest { every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { config.isBrowserLoginEnabled() } returns false + every { config.isBrowserRegistrationEnabled() } returns false } @After @@ -110,6 +114,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -143,6 +148,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -177,6 +183,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -210,6 +217,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -245,6 +253,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -281,6 +290,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -319,6 +329,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) @@ -357,6 +368,7 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, + browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", ) diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 786d63cc4..86d678c9e 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -12,6 +12,7 @@ object ApiConstants { const val URL_PASSWORD_RESET = "/password_reset/" const val GRANT_TYPE_PASSWORD = "password" + const val GRANT_TYPE_CODE = "authorization_code" const val TOKEN_TYPE_BEARER = "Bearer" const val TOKEN_TYPE_JWT = "jwt" @@ -27,6 +28,7 @@ object ApiConstants { const val AUTH_TYPE_GOOGLE = "google-oauth2" const val AUTH_TYPE_FB = "facebook" const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" + const val AUTH_TYPE_BROWSER = "browser" const val COURSE_KEY = "course_key" diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 4b40fbc29..e4aebf3f7 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -31,6 +31,10 @@ class Config(context: Context) { return getString(URI_SCHEME, "") } + fun getApplicationID(): String { + return getString(APPLICATION_ID, "") + } + fun getOAuthClientId(): String { return getString(OAUTH_CLIENT_ID, "") } @@ -111,6 +115,14 @@ class Config(context: Context) { return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) } + fun isBrowserLoginEnabled(): Boolean { + return getBoolean(BROWSER_LOGIN, false) + } + + fun isBrowserRegistrationEnabled(): Boolean { + return getBoolean(BROWSER_REGISTRATION, false) + } + private fun getString(key: String, defaultValue: String): String { val element = getObject(key) return if (element != null) { @@ -148,6 +160,7 @@ class Config(context: Context) { companion object { private const val API_HOST_URL = "API_HOST_URL" private const val URI_SCHEME = "URI_SCHEME" + private const val APPLICATION_ID = "APPLICATION_ID" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" private const val TOKEN_TYPE = "TOKEN_TYPE" private const val FAQ_URL = "FAQ_URL" @@ -162,6 +175,8 @@ class Config(context: Context) { private const val GOOGLE = "GOOGLE" private const val MICROSOFT = "MICROSOFT" private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" + private const val BROWSER_LOGIN = "BROWSER_LOGIN" + private const val BROWSER_REGISTRATION = "BROWSER_REGISTRATION" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val BRANCH = "BRANCH" diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index e1582bfcf..9347d274d 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f7afc7bed..fa71747b0 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f7afc7bed..fa71747b0 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT" WHATS_NEW_ENABLED: false #feature flag enable Social Login buttons SOCIAL_AUTH_ENABLED: false +#feature flag to do the authentication flow in the browser to log in +BROWSER_LOGIN: false +#feature flag to do the registration for in the browser +BROWSER_REGISTRATION: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/docs/0001-strategy-for-data-streams.rst b/docs/decisions/0001-strategy-for-data-streams.rst similarity index 100% rename from docs/0001-strategy-for-data-streams.rst rename to docs/decisions/0001-strategy-for-data-streams.rst diff --git a/docs/how-tos/auth-using-browser.rst b/docs/how-tos/auth-using-browser.rst new file mode 100644 index 000000000..f59c5d60f --- /dev/null +++ b/docs/how-tos/auth-using-browser.rst @@ -0,0 +1,45 @@ +How to user Browser-based Login and Registration +================================================ + +Introduction +------------ + +If your Open edX instance is set up with a custom authentication system that requires logging in +via the browser, you can use the ``BROWSER_LOGIN`` and ``BROWSER_REGISTRATION`` flags to redirect +login and registration to the browser. + +The ``BROWSER_LOGIN`` flag is used to redirect login to the browser. In this case clicking on the +login button will open the authorization flow in an Android custom browser tab and redirect back to +the application. + +The ``BROWSER_REGISTRATION`` flag is used to redirect registration to the browser. In this case +clicking on the registration button will open the registration page in a regular browser tab. Once +registered, the user will **not** be automatically redirected to the application. + +Usage +----- + +In order to use the ``BROWSER_LOGIN`` feature, you need to set up an OAuth2 provider via +``/admin/oauth2_provider/application/`` that has a redirect URL with the following format + + ``://oauth2Callback`` + +Here application ID is the ID for the Android application and defaults to ``"org.openedx.app"``. This +URI scheme is handled by the application and will be used by the app to get the OAuth2 token for +using the APIs. + +Note that normally the Django OAuth Toolkit doesn't allow custom schemes like the above as redirect +URIs, so you will need to explicitly allow the by adding this URI scheme to +``ALLOWED_REDIRECT_URI_SCHEMES`` in the Django OAuth Toolkit settings in ``OAUTH2_PROVIDER``. You +can add the following line to your django settings python file: + +.. code-block:: python + + OAUTH2_PROVIDER["ALLOWED_REDIRECT_URI_SCHEMES"] = ["https", "org.openedx.app"] + +Replace ``"org.openedx.app"`` with the correct id for your application. You must list all allowed +schemes here, including ``"https"`` and ``"http"``. + +The authentication will then redirect to the browser in a custom tab that redirects back to the app. + +NOTE: If a user logs out from the application, they might still be logged in, in the browser. \ No newline at end of file diff --git a/docs/how-tos/index.rst b/docs/how-tos/index.rst new file mode 100644 index 000000000..202bad08b --- /dev/null +++ b/docs/how-tos/index.rst @@ -0,0 +1,8 @@ +"How-To" Guides +############### + + +.. toctree:: +:glob: + +*