Skip to content

Commit 901f23f

Browse files
committed
feat: Add support for browser-based logins
For sites that only support logging in using a third-party login provider, allow logging in via a browser. This new flow will open a custom tab where a user can log in using the supported mechanisms of the site, then have the client id redirect back to to the app using a callback URL. The auth code returned by the callback URL can then be used instead of a username and password to log in.
1 parent a78857b commit 901f23f

10 files changed

Lines changed: 139 additions & 16 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535

3636
<category android:name="android.intent.category.LAUNCHER" />
3737
</intent-filter>
38+
<intent-filter>
39+
<action android:name="android.intent.action.VIEW" />
40+
<category android:name="android.intent.category.DEFAULT" />
41+
<category android:name="android.intent.category.BROWSABLE" />
42+
<data android:scheme="${applicationId}" />
43+
</intent-filter>
3844
</activity>
3945

4046
<provider

app/src/main/java/org/openedx/app/AppActivity.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package org.openedx.app
33
import android.content.pm.ActivityInfo
44
import android.content.res.Configuration
55
import android.graphics.Color
6+
import android.net.Uri
67
import android.os.Bundle
78
import android.view.View
89
import android.view.WindowManager
@@ -49,6 +50,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH
4950

5051
private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact)
5152

53+
private val authCode: String?
54+
get() {
55+
val data = intent?.data
56+
if (data is Uri && data.scheme == BuildConfig.APPLICATION_ID && data.host == "oauth2Callback") {
57+
return data.getQueryParameter("code")
58+
}
59+
return null
60+
}
61+
5262
override fun onSaveInstanceState(outState: Bundle) {
5363
outState.putInt(TOP_INSET, topInset)
5464
outState.putInt(BOTTOM_INSET, bottomInset)
@@ -103,8 +113,12 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH
103113
.add(R.id.container, MainFragment())
104114
.commit()
105115
} else {
116+
val fragment = SignInFragment()
117+
val bundle = Bundle()
118+
bundle.putString("auth_code", authCode)
119+
fragment.arguments = bundle
106120
supportFragmentManager.beginTransaction()
107-
.add(R.id.container, SignInFragment())
121+
.add(R.id.container, fragment)
108122
.commit()
109123
}
110124
}

auth/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ android {
5454

5555
dependencies {
5656
implementation project(path: ':core')
57+
implementation 'androidx.browser:browser:1.6.0'
5758

5859
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
5960
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ interface AuthApi {
2424
password: String,
2525
): AuthResponse
2626

27+
@FormUrlEncoded
28+
@POST(ApiConstants.URL_ACCESS_TOKEN)
29+
suspend fun getAccessToken(
30+
@Field("grant_type")
31+
grantType: String,
32+
@Field("client_id")
33+
clientId: String,
34+
@Field("code")
35+
username: String,
36+
): AuthResponse
37+
2738
@FormUrlEncoded
2839
@POST(ApiConstants.URL_ACCESS_TOKEN)
2940
fun refreshAccessToken(

auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.openedx.auth.data.repository
22

33
import org.openedx.auth.data.api.AuthApi
4+
import org.openedx.auth.data.model.AuthResponse
45
import org.openedx.auth.data.model.ValidationFields
56
import org.openedx.core.ApiConstants
67
import org.openedx.core.data.storage.CorePreferences
@@ -12,6 +13,16 @@ class AuthRepository(
1213
private val preferencesManager: CorePreferences,
1314
) {
1415

16+
private suspend fun processAuthResponse(authResponse: AuthResponse) {
17+
if (authResponse.error != null) {
18+
throw EdxError.UnknownException(authResponse.error!!)
19+
}
20+
preferencesManager.accessToken = authResponse.accessToken ?: ""
21+
preferencesManager.refreshToken = authResponse.refreshToken ?: ""
22+
val user = api.getProfile().mapToDomain()
23+
preferencesManager.user = user
24+
}
25+
1526
suspend fun login(
1627
username: String,
1728
password: String,
@@ -22,13 +33,16 @@ class AuthRepository(
2233
username,
2334
password
2435
)
25-
if (authResponse.error != null) {
26-
throw EdxError.UnknownException(authResponse.error!!)
27-
}
28-
preferencesManager.accessToken = authResponse.accessToken ?: ""
29-
preferencesManager.refreshToken = authResponse.refreshToken ?: ""
30-
val user = api.getProfile().mapToDomain()
31-
preferencesManager.user = user
36+
processAuthResponse(authResponse)
37+
}
38+
39+
suspend fun login(code: String) {
40+
val authResponse = api.getAccessToken(
41+
ApiConstants.GRANT_TYPE_CODE,
42+
org.openedx.core.BuildConfig.CLIENT_ID,
43+
code,
44+
)
45+
processAuthResponse(authResponse)
3246
}
3347

3448
suspend fun getRegistrationFields(): List<RegistrationField> {

auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ class AuthInteractor(private val repository: AuthRepository) {
1313
repository.login(username, password)
1414
}
1515

16+
suspend fun login(code: String) {
17+
repository.login(code)
18+
}
19+
1620
suspend fun getRegistrationFields(): List<RegistrationField> {
1721
return repository.getRegistrationFields()
1822
}

auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package org.openedx.auth.presentation.signin
22

33
import android.content.res.Configuration.UI_MODE_NIGHT_NO
44
import android.content.res.Configuration.UI_MODE_NIGHT_YES
5+
import android.net.Uri
56
import android.os.Bundle
7+
import android.util.Log
68
import android.view.LayoutInflater
79
import android.view.ViewGroup
10+
import androidx.browser.customtabs.CustomTabsIntent
811
import androidx.compose.foundation.Image
912
import androidx.compose.foundation.background
1013
import androidx.compose.foundation.layout.*
@@ -42,6 +45,7 @@ import org.openedx.core.ui.theme.appShapes
4245
import org.openedx.core.ui.theme.appTypography
4346
import org.koin.android.ext.android.inject
4447
import org.koin.androidx.viewmodel.ext.android.viewModel
48+
import org.openedx.core.BuildConfig
4549

4650
class SignInFragment : Fragment() {
4751

@@ -53,6 +57,10 @@ class SignInFragment : Fragment() {
5357
container: ViewGroup?,
5458
savedInstanceState: Bundle?,
5559
) = ComposeView(requireContext()).apply {
60+
val authCode = arguments?.getString("auth_code")
61+
if (authCode is String) {
62+
viewModel.login(authCode)
63+
}
5664
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
5765
setContent {
5866
OpenEdXTheme {
@@ -62,10 +70,12 @@ class SignInFragment : Fragment() {
6270
val uiMessage by viewModel.uiMessage.observeAsState()
6371
val loginSuccess by viewModel.loginSuccess.observeAsState(initial = false)
6472

73+
Log.d("TEST111", context.packageName)
6574
LoginScreen(
6675
windowSize = windowSize,
6776
showProgress = showProgress,
6877
uiMessage = uiMessage,
78+
browserLogin = BuildConfig.BROWSER_LOGIN,
6979
onLoginClick = { login, password ->
7080
viewModel.login(login, password)
7181
},
@@ -76,6 +86,20 @@ class SignInFragment : Fragment() {
7686
onForgotPasswordClick = {
7787
viewModel.forgotPasswordClickedEvent()
7888
router.navigateToRestorePassword(parentFragmentManager)
89+
},
90+
onLoginClickOauth = {
91+
val uri = Uri.parse("${BuildConfig.BASE_URL}oauth2/authorize")
92+
.buildUpon()
93+
.appendQueryParameter("client_id", BuildConfig.CLIENT_ID)
94+
.appendQueryParameter("redirect_uri", "${context.packageName}://oauth2Callback")
95+
.appendQueryParameter("response_type", "code")
96+
.build()
97+
val intent = CustomTabsIntent.Builder()
98+
.setUrlBarHidingEnabled(true)
99+
.setShowTitle(true)
100+
.build()
101+
intent.launchUrl(context, uri)
102+
79103
}
80104
)
81105

@@ -94,7 +118,9 @@ private fun LoginScreen(
94118
windowSize: WindowSize,
95119
showProgress: Boolean,
96120
uiMessage: UIMessage?,
121+
browserLogin: Boolean,
97122
onLoginClick: (login: String, password: String) -> Unit,
123+
onLoginClickOauth: () -> Unit,
98124
onRegisterClick: () -> Unit,
99125
onForgotPasswordClick: () -> Unit
100126
) {
@@ -187,13 +213,27 @@ private fun LoginScreen(
187213
style = MaterialTheme.appTypography.titleSmall
188214
)
189215
Spacer(modifier = Modifier.height(24.dp))
190-
AuthForm(
191-
buttonWidth,
192-
showProgress,
193-
onLoginClick,
194-
onRegisterClick,
195-
onForgotPasswordClick
196-
)
216+
if (browserLogin) {
217+
Column(
218+
modifier = Modifier.fillMaxSize(),
219+
verticalArrangement = Arrangement.SpaceAround,
220+
horizontalAlignment = Alignment.CenterHorizontally,
221+
) {
222+
OpenEdXButton(
223+
width = buttonWidth,
224+
text = stringResource(id = R.string.auth_sign_in),
225+
onClick = onLoginClickOauth,
226+
)
227+
}
228+
} else {
229+
AuthForm(
230+
buttonWidth,
231+
showProgress,
232+
onLoginClick,
233+
onRegisterClick,
234+
onForgotPasswordClick,
235+
)
236+
}
197237
}
198238
}
199239
}
@@ -334,9 +374,11 @@ private fun SignInScreenPreview() {
334374
windowSize = WindowSize(WindowType.Compact, WindowType.Compact),
335375
showProgress = false,
336376
uiMessage = null,
377+
browserLogin = true,
337378
onLoginClick = { _, _ ->
338379

339380
},
381+
onLoginClickOauth = {},
340382
onRegisterClick = {},
341383
onForgotPasswordClick = {}
342384
)
@@ -353,9 +395,11 @@ private fun SignInScreenTabletPreview() {
353395
windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded),
354396
showProgress = false,
355397
uiMessage = null,
398+
browserLogin = true,
356399
onLoginClick = { _, _ ->
357400

358401
},
402+
onLoginClickOauth = {},
359403
onRegisterClick = {},
360404
onForgotPasswordClick = {}
361405
)

auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,30 @@ class SignInViewModel(
7272
}
7373
}
7474

75+
fun login(code: String) {
76+
viewModelScope.launch {
77+
try {
78+
interactor.login(code)
79+
_loginSuccess.value = true
80+
setUserId()
81+
analytics.userLoginEvent(LoginMethod.BROWSER.methodName)
82+
} catch (e: Exception) {
83+
if (e is EdxError.InvalidGrantException) {
84+
_uiMessage.value =
85+
UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_invalid_grant))
86+
} else if (e.isInternetError()) {
87+
_uiMessage.value =
88+
UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_no_connection))
89+
} else {
90+
_uiMessage.value =
91+
UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_unknown_error))
92+
}
93+
}
94+
_showProgress.value = false
95+
}
96+
97+
}
98+
7599
fun signUpClickedEvent() {
76100
analytics.signUpClickedEvent()
77101
}
@@ -91,6 +115,7 @@ private enum class LoginMethod(val methodName: String) {
91115
PASSWORD("Password"),
92116
FACEBOOK("Facebook"),
93117
GOOGLE("Google"),
94-
MICROSOFT("Microsoft")
118+
MICROSOFT("Microsoft"),
119+
BROWSER("Browser"),
95120
}
96121

core/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ android {
4949
buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\""
5050
buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\""
5151
buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\""
52+
buildConfigField "boolean", "BROWSER_LOGIN", "${ envMap.value.LOGIN_VIA_BROWSER }"
5253
resValue "string", "google_app_id", firebase.appId
5354
resValue "string", "platform_name", config.platformName
5455
resValue "string", "platform_full_name", config.platformFullName
@@ -70,6 +71,7 @@ android {
7071
buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\""
7172
buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\""
7273
buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\""
74+
buildConfigField "boolean", "BROWSER_LOGIN", "${ envMap.value.LOGIN_VIA_BROWSER }"
7375
resValue "string", "google_app_id", firebase.appId
7476
resValue "string", "platform_name", config.platformName
7577
resValue "string", "platform_full_name", config.platformFullName
@@ -91,6 +93,7 @@ android {
9193
buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\""
9294
buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\""
9395
buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\""
96+
buildConfigField "boolean", "BROWSER_LOGIN", "${ envMap.value.LOGIN_VIA_BROWSER }"
9497
resValue "string", "google_app_id", firebase.appId
9598
resValue "string", "platform_name", config.platformName
9699
resValue "string", "platform_full_name", config.platformFullName

core/src/main/java/org/openedx/core/ApiConstants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ object ApiConstants {
1111
const val URL_PASSWORD_RESET = "/password_reset/"
1212

1313
const val GRANT_TYPE_PASSWORD = "password"
14+
const val GRANT_TYPE_CODE = "authorization_code"
1415

1516
const val TOKEN_TYPE_REFRESH = "refresh_token"
1617

0 commit comments

Comments
 (0)