Endurain: Support SSO when logging into Endurain

This commit is contained in:
Arjan Schrijver
2026-06-19 23:31:23 +02:00
parent 67ee952634
commit 8b9b64b513
11 changed files with 455 additions and 34 deletions
+1
View File
@@ -262,6 +262,7 @@ dependencies {
implementation libs.androidx.work.runtime.ktx implementation libs.androidx.work.runtime.ktx
implementation libs.androidx.lifecycle.process implementation libs.androidx.lifecycle.process
implementation libs.androidx.security.crypto implementation libs.androidx.security.crypto
implementation libs.androidx.browser
implementation libs.material implementation libs.material
implementation libs.flexbox implementation libs.flexbox
+17
View File
@@ -1124,6 +1124,23 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".activities.endurain.EndurainOAuthCallbackActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Deep link scheme for OAuth callback -->
<data
android:scheme="${appAuthRedirectScheme}"
android:host="endurain"
android:pathPrefix="/oauth/callback" />
</intent-filter>
</activity>
<!-- For supported versions through Android 13, create an activity to show the rationale <!-- For supported versions through Android 13, create an activity to show the rationale
of Health Connect permissions once users click the privacy policy link. --> of Health Connect permissions once users click the privacy policy link. -->
<activity <activity
@@ -37,6 +37,7 @@ data class LoginResponse(
val access_token: String? = null, val access_token: String? = null,
val refresh_token: String? = null, val refresh_token: String? = null,
val expires_in: Int? = null, val expires_in: Int? = null,
val refresh_token_expires_in: Int? = null,
val token_type: String? = null, val token_type: String? = null,
val mfa_required: Boolean? = null, val mfa_required: Boolean? = null,
val username: String? = null, val username: String? = null,
@@ -52,6 +53,12 @@ data class TokenExchangeRequest(
val code_verifier: String val code_verifier: String
) )
data class IdentityProvider(
val id: String,
val name: String,
val slug: String
)
class EndurainApiClient( class EndurainApiClient(
private val baseUrl: String, private val baseUrl: String,
private val tokenManager: EndurainTokenManager private val tokenManager: EndurainTokenManager
@@ -200,6 +207,28 @@ class EndurainApiClient(
} }
} }
/**
* Get list of available identity providers
*/
fun getIdentityProviders(): List<IdentityProvider>? {
try {
val uri = "$baseUrl/api/v1/public/idp".toUri()
val responseText = InternetUtils.doStringRequest(uri = uri)
return if (responseText != null) {
val type = object : com.google.gson.reflect.TypeToken<List<IdentityProvider>>() {}.type
gson.fromJson(responseText, type)
} else {
LOG.error("Failed to fetch identity providers")
null
}
} catch (e: Exception) {
LOG.error("Error fetching identity providers", e)
return null
}
}
/** /**
* Exchange OAuth session for tokens (PKCE flow) * Exchange OAuth session for tokens (PKCE flow)
*/ */
@@ -220,6 +249,8 @@ class EndurainApiClient(
body = body body = body
) )
LOG.debug("OAuth token result: $responseText")
return if (responseText != null) { return if (responseText != null) {
gson.fromJson(responseText, LoginResponse::class.java) gson.fromJson(responseText, LoginResponse::class.java)
} else { } else {
@@ -0,0 +1,84 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import org.slf4j.LoggerFactory
/**
* Handles OAuth callback deep links from the browser
*/
class EndurainOAuthCallbackActivity : AbstractGBActivity() {
private val LOG = LoggerFactory.getLogger(EndurainOAuthCallbackActivity::class.java)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Bring us back to front to dismiss the custom tab
val dismissIntent = Intent(applicationContext, EndurainPreferencesActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(dismissIntent)
// Handle OAuth callback intent
val uri = intent.data
if (uri != null) {
LOG.info("OAuth callback received: $uri")
handleOAuthCallback(uri)
} else {
LOG.error("No URI in OAuth callback")
Toast.makeText(this,
getString(R.string.endurain_invalid_oauth_callback), Toast.LENGTH_SHORT).show()
finish()
}
}
private fun handleOAuthCallback(uri: Uri) {
// Expected format: nodomain.freeyourgadget.gadgetbridge://endurain/oauth/callback?session_id={uuid}
val sessionId = uri.getQueryParameter("session_id")
if (sessionId != null) {
LOG.info("SSO successful, session_id: $sessionId")
// Broadcast the session ID so the setup bottom sheet can handle it
val resultIntent = Intent("nodomain.freeyourgadget.gadgetbridge.ENDURAIN_SSO_CALLBACK")
resultIntent.putExtra("session_id", sessionId)
resultIntent.putExtra("success", true)
sendBroadcast(resultIntent)
Toast.makeText(this,
getString(R.string.endurain_oauth_completing_login), Toast.LENGTH_SHORT).show()
} else {
LOG.error("SSO failed or missing session_id")
Toast.makeText(this, getString(R.string.endurain_sso_login_failed), Toast.LENGTH_SHORT).show()
// Broadcast failure
val resultIntent = Intent("nodomain.freeyourgadget.gadgetbridge.ENDURAIN_SSO_CALLBACK")
resultIntent.putExtra("success", false)
sendBroadcast(resultIntent)
}
finish()
}
}
@@ -109,11 +109,11 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
vm.logout { success -> vm.logout { success ->
activity?.runOnUiThread { activity?.runOnUiThread {
if (success) { if (success) {
GB.toast("Logged out successfully", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_logged_out_successfully), Toast.LENGTH_SHORT, GB.INFO)
updateStatus() updateStatus()
updateLogoutPreferenceVisibility() updateLogoutPreferenceVisibility()
} else { } else {
GB.toast("Logout failed", Toast.LENGTH_SHORT, GB.WARN) GB.toast(getString(R.string.endurain_logout_failed), Toast.LENGTH_SHORT, GB.WARN)
} }
} }
} }
@@ -129,12 +129,13 @@ class EndurainPreferencesActivity : AbstractSettingsActivityV2() {
val server = GBApplication.getPrefs().preferences.getString("endurain_server", null) val server = GBApplication.getPrefs().preferences.getString("endurain_server", null)
val tokenExpiresAt = DateTimeUtils.parseTimeStamp(vm.tokenManager.getRefreshTokenExpiresAt()) val tokenExpiresAt = DateTimeUtils.parseTimeStamp(vm.tokenManager.getRefreshTokenExpiresAt())
var summaryText = "Not logged in, integration is disabled" var summaryText = getString(R.string.endurain_not_logged_in_integration_disabled)
if (vm.tokenManager.isLoggedIn() && server != null) { if (vm.tokenManager.isLoggedIn() && server != null) {
summaryText = "Logged in to $server\nRefresh token expires: $tokenExpiresAt" summaryText =
getString(R.string.endurain_logged_in_refresh_token, server, tokenExpiresAt)
} }
if (vm.serverVersion != null) { if (vm.serverVersion != null) {
summaryText += "\nServer version: ${vm.serverVersion}" summaryText += getString(R.string.endurain_server_version, vm.serverVersion)
} }
statusPref?.summary = summaryText statusPref?.summary = summaryText
} }
@@ -16,11 +16,17 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */ along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@@ -32,9 +38,11 @@ import com.google.android.material.textfield.TextInputLayout
import nodomain.freeyourgadget.gadgetbridge.GBApplication import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.util.GB import nodomain.freeyourgadget.gadgetbridge.util.GB
import org.slf4j.LoggerFactory
class EndurainSetupBottomSheet : BottomSheetDialogFragment() { class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private val LOG = LoggerFactory.getLogger(EndurainSetupBottomSheet::class.java)
private val prefs get() = GBApplication.getPrefs().preferences private val prefs get() = GBApplication.getPrefs().preferences
private val vm: EndurainSetupViewModel by viewModels() private val vm: EndurainSetupViewModel by viewModels()
@@ -52,6 +60,24 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private lateinit var progress: View private lateinit var progress: View
private lateinit var next: MaterialButton private lateinit var next: MaterialButton
// Broadcast receiver for SSO callback
private val ssoCallbackReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val sessionId = intent?.getStringExtra("session_id")
val success = intent?.getBooleanExtra("success", true) ?: true
if (sessionId != null && success) {
handleSsoCallback(sessionId)
} else {
activity?.runOnUiThread {
showProgress(false)
Toast.makeText(requireContext(),
getString(R.string.endurain_sso_login_failed), Toast.LENGTH_SHORT).show()
}
}
}
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -63,26 +89,31 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
) )
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Initialize views
serverLayout = view.findViewById(R.id.server_layout) serverLayout = view.findViewById(R.id.server_layout)
serverInput = view.findViewById(R.id.server_input) serverInput = view.findViewById(R.id.server_input)
loginTypeGroup = view.findViewById(R.id.login_type_group) loginTypeGroup = view.findViewById(R.id.login_type_group)
localButton = view.findViewById(R.id.local_login_button) localButton = view.findViewById(R.id.local_login_button)
ssoButton = view.findViewById(R.id.sso_login_button) ssoButton = view.findViewById(R.id.sso_login_button)
userLayout = view.findViewById(R.id.user_layout) userLayout = view.findViewById(R.id.user_layout)
passLayout = view.findViewById(R.id.password_layout) passLayout = view.findViewById(R.id.password_layout)
userInput = view.findViewById(R.id.user_input) userInput = view.findViewById(R.id.user_input)
passInput = view.findViewById(R.id.password_input) passInput = view.findViewById(R.id.password_input)
mfaLayout = view.findViewById(R.id.mfa_layout) mfaLayout = view.findViewById(R.id.mfa_layout)
mfaInput = view.findViewById(R.id.mfa_input) mfaInput = view.findViewById(R.id.mfa_input)
progress = view.findViewById(R.id.progress) progress = view.findViewById(R.id.progress)
next = view.findViewById(R.id.next_button) next = view.findViewById(R.id.next_button)
serverInput.setText(prefs.getString("endurain_server", "")) serverInput.setText(prefs.getString("endurain_server", ""))
// Register broadcast receiver for SSO callbacks
ContextCompat.registerReceiver(
requireContext(),
ssoCallbackReceiver,
IntentFilter("nodomain.freeyourgadget.gadgetbridge.ENDURAIN_SSO_CALLBACK"),
ContextCompat.RECEIVER_EXPORTED
)
next.setOnClickListener { next.setOnClickListener {
when (vm.step) { when (vm.step) {
EndurainSetupViewModel.Step.SERVER -> handleServerStep() EndurainSetupViewModel.Step.SERVER -> handleServerStep()
@@ -102,10 +133,95 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
mfaLayout.visibility = View.GONE mfaLayout.visibility = View.GONE
} }
R.id.sso_login_button -> { R.id.sso_login_button -> {
vm.step = EndurainSetupViewModel.Step.SSO_LOGIN vm.step = EndurainSetupViewModel.Step.SSO_PROVIDERS
// TODO: Launch SSO flow startSsoFlow()
GB.toast("SSO not yet implemented", Toast.LENGTH_SHORT, GB.INFO) }
}
}
}
override fun onDestroyView() {
super.onDestroyView()
try {
requireContext().unregisterReceiver(ssoCallbackReceiver)
} catch (e: Exception) {
// Already unregistered
}
}
private fun startSsoFlow() {
showProgress(true)
vm.fetchSsoProviders { success ->
activity?.runOnUiThread {
showProgress(false)
if (success && vm.availableProviders.size == 1) {
launchSsoLogin(vm.availableProviders[0])
} else if (success && vm.availableProviders.isNotEmpty()) {
showProviderSelection()
} else {
Toast.makeText(
requireContext(),
getString(R.string.endurain_no_sso_providers_available),
Toast.LENGTH_SHORT
).show()
}
}
}
}
private fun showProviderSelection() {
EndurainSsoProviderDialog(requireContext(), vm.availableProviders) { provider ->
launchSsoLogin(provider)
}.show()
}
private fun launchSsoLogin(provider: IdentityProvider) {
val ssoUrl = vm.generateSsoUrl(provider.slug)
LOG.info("Launching secure browser for SSO URL: $ssoUrl")
// Launch Custom Tab (secure browser)
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.setUrlBarHidingEnabled(false)
.build()
showProgress(true)
try {
customTabsIntent.launchUrl(requireContext(), ssoUrl.toUri())
} catch (e: Exception) {
showProgress(false)
Toast.makeText(
requireContext(),
getString(R.string.endurain_failed_to_open_browser, e.message),
Toast.LENGTH_SHORT
).show()
}
}
private fun handleSsoCallback(sessionId: String) {
showProgress(true)
vm.exchangeSsoSession(sessionId) { success ->
activity?.runOnUiThread {
showProgress(false)
if (success) {
Toast.makeText(
requireContext(),
getString(R.string.endurain_sso_login_successful),
Toast.LENGTH_SHORT
).show()
parentFragmentManager.setFragmentResult(
"endurain_login_result",
Bundle().apply { putBoolean("success", true) }
)
dismiss() dismiss()
} else {
Toast.makeText(
requireContext(),
getString(R.string.endurain_sso_login_failed),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@@ -114,7 +230,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private fun handleServerStep() { private fun handleServerStep() {
val uri = serverInput.text.toString().toUri() val uri = serverInput.text.toString().toUri()
if (uri.scheme == null || uri.host == null) { if (uri.scheme == null || uri.host == null) {
serverLayout.error = "Invalid server URL" serverLayout.error = getString(R.string.endurain_invalid_server_url)
return return
} }
serverLayout.error = null serverLayout.error = null
@@ -127,7 +243,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
activity?.runOnUiThread { activity?.runOnUiThread {
showProgress(false) showProgress(false)
if (!ok) { if (!ok) {
GB.toast("Failed to connect to server", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_failed_to_connect_to_server), Toast.LENGTH_SHORT, GB.INFO)
return@runOnUiThread return@runOnUiThread
} }
vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE vm.step = EndurainSetupViewModel.Step.LOGIN_TYPE
@@ -151,14 +267,14 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
var hasError = false var hasError = false
if (user.isBlank()) { if (user.isBlank()) {
userLayout.error = "Required" userLayout.error = getString(R.string.required)
hasError = true hasError = true
} else { } else {
userLayout.error = null userLayout.error = null
} }
if (pass.isBlank()) { if (pass.isBlank()) {
passLayout.error = "Required" passLayout.error = getString(R.string.required)
hasError = true hasError = true
} else { } else {
passLayout.error = null passLayout.error = null
@@ -176,11 +292,11 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
userLayout.visibility = View.GONE userLayout.visibility = View.GONE
passLayout.visibility = View.GONE passLayout.visibility = View.GONE
mfaLayout.visibility = View.VISIBLE mfaLayout.visibility = View.VISIBLE
next.text = "Verify MFA" next.text = getString(R.string.endurain_verify_mfa)
GB.toast("Enter your MFA code", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_enter_your_mfa_code), Toast.LENGTH_SHORT, GB.INFO)
} }
success -> { success -> {
GB.toast("Login successful", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_login_successful), Toast.LENGTH_SHORT, GB.INFO)
// Send result to parent fragment // Send result to parent fragment
parentFragmentManager.setFragmentResult( parentFragmentManager.setFragmentResult(
"endurain_login_result", "endurain_login_result",
@@ -189,7 +305,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
dismiss() dismiss()
} }
else -> { else -> {
GB.toast("Login failed", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_login_failed), Toast.LENGTH_SHORT, GB.INFO)
} }
} }
} }
@@ -199,7 +315,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
private fun handleMfaStep() { private fun handleMfaStep() {
val mfaCode = mfaInput.text.toString() val mfaCode = mfaInput.text.toString()
if (mfaCode.isBlank()) { if (mfaCode.isBlank()) {
mfaLayout.error = "Required" mfaLayout.error = getString(R.string.required)
return return
} }
mfaLayout.error = null mfaLayout.error = null
@@ -209,7 +325,7 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
activity?.runOnUiThread { activity?.runOnUiThread {
showProgress(false) showProgress(false)
if (success) { if (success) {
GB.toast("MFA verification successful", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_mfa_verification_successful), Toast.LENGTH_SHORT, GB.INFO)
// Send result to parent fragment // Send result to parent fragment
parentFragmentManager.setFragmentResult( parentFragmentManager.setFragmentResult(
"endurain_login_result", "endurain_login_result",
@@ -217,8 +333,8 @@ class EndurainSetupBottomSheet : BottomSheetDialogFragment() {
) )
dismiss() dismiss()
} else { } else {
mfaLayout.error = "Invalid MFA code" mfaLayout.error = getString(R.string.endurain_invalid_mfa_code)
GB.toast("MFA verification failed", Toast.LENGTH_SHORT, GB.INFO) GB.toast(getString(R.string.endurain_mfa_verification_failed), Toast.LENGTH_SHORT, GB.INFO)
} }
} }
} }
@@ -17,8 +17,12 @@
package nodomain.freeyourgadget.gadgetbridge.activities.endurain package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.app.Application import android.app.Application
import android.content.Context
import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import nodomain.freeyourgadget.gadgetbridge.BuildConfig
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils import nodomain.freeyourgadget.gadgetbridge.util.InternetUtils
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -26,13 +30,14 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
private val LOG = LoggerFactory.getLogger(EndurainSetupViewModel::class.java) private val LOG = LoggerFactory.getLogger(EndurainSetupViewModel::class.java)
private lateinit var apiClient: EndurainApiClient private lateinit var apiClient: EndurainApiClient
private val pkceHelper = PkceHelper()
enum class Step { enum class Step {
SERVER, SERVER,
LOGIN_TYPE, LOGIN_TYPE,
LOCAL_LOGIN, LOCAL_LOGIN,
MFA_VERIFY, MFA_VERIFY,
SSO_LOGIN SSO_PROVIDERS
} }
val tokenManager = EndurainTokenManager(application) val tokenManager = EndurainTokenManager(application)
@@ -41,6 +46,7 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
var localLoginEnabled = false var localLoginEnabled = false
var ssoEnabled = false var ssoEnabled = false
var pendingMfaUsername: String? = null var pendingMfaUsername: String? = null
var availableProviders: List<IdentityProvider> = emptyList()
var serverVersion: String? = null var serverVersion: String? = null
/** /**
@@ -103,6 +109,101 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
}.start() }.start()
} }
/**
* Fetch available SSO providers
*/
fun fetchSsoProviders(callback: (Boolean) -> Unit) {
Thread {
try {
apiClient = EndurainApiClient(server, tokenManager)
val providers = apiClient.getIdentityProviders()
if (providers != null && providers.isNotEmpty()) {
availableProviders = providers
callback(true)
} else {
LOG.error("No SSO providers available")
callback(false)
}
} catch (e: Exception) {
LOG.error("Error fetching SSO providers", e)
callback(false)
}
}.start()
}
/**
* Generate SSO URL with PKCE for Custom Tabs
* Returns the URL and also stores the verifier for later
*/
fun generateSsoUrl(idpSlug: String): String {
val codeVerifier = pkceHelper.generateCodeVerifier()
val codeChallenge = pkceHelper.generateCodeChallenge(codeVerifier)
LOG.debug("Initiating PKCE with verifier=$codeVerifier and challenge=$codeChallenge")
// Store verifier in shared preferences for the callback activity
getApplication<GBApplication>().getSharedPreferences("endurain_oauth_temp", Context.MODE_PRIVATE)
.edit {
putString("code_verifier", codeVerifier)
}
// Build OAuth URL with custom redirect URI for deep linking
return "$server/api/v1/public/idp/login/$idpSlug".toUri()
.buildUpon()
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect", BuildConfig.APPLICATION_ID + "://endurain/oauth/callback")
.build()
.toString()
}
/**
* Exchange OAuth session for tokens
*/
fun exchangeSsoSession(sessionId: String, callback: (Boolean) -> Unit) {
Thread {
try {
// Retrieve the stored code verifier
val prefs = getApplication<GBApplication>()
.getSharedPreferences("endurain_oauth_temp", Context.MODE_PRIVATE)
val verifier = prefs.getString("code_verifier", null)
if (verifier == null) {
LOG.error("No pending code verifier")
callback(false)
return@Thread
}
// Clear the verifier
prefs.edit { remove("code_verifier") }
if (!::apiClient.isInitialized) {
apiClient = EndurainApiClient(server, tokenManager)
}
val response = apiClient.exchangeOAuthSession(sessionId, verifier)
if (response?.access_token != null) {
LOG.info("SSO login successful")
tokenManager.saveTokens(
response.access_token,
response.refresh_token!!,
response.expires_in!!,
response.refresh_token_expires_in!!
)
callback(true)
} else {
LOG.error("SSO login failed")
callback(false)
}
} catch (e: Exception) {
LOG.error("SSO session exchange error", e)
callback(false)
}
}.start()
}
/** /**
* Perform local username/password login * Perform local username/password login
*/ */
@@ -133,7 +234,8 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
tokenManager.saveTokens( tokenManager.saveTokens(
response.access_token, response.access_token,
response.refresh_token!!, response.refresh_token!!,
response.expires_in!! response.expires_in!!,
response.refresh_token_expires_in!!
) )
callback(true) callback(true)
} }
@@ -164,12 +266,15 @@ class EndurainSetupViewModel(application: Application) : AndroidViewModel(applic
val response = apiClient.verifyMfa(username, mfaCode) val response = apiClient.verifyMfa(username, mfaCode)
LOG.debug("MFA verification response: {}", response)
if (response?.access_token != null) { if (response?.access_token != null) {
LOG.info("MFA verification successful") LOG.info("MFA verification successful")
tokenManager.saveTokens( tokenManager.saveTokens(
response.access_token, response.access_token,
response.refresh_token!!, response.refresh_token!!,
response.expires_in!! response.expires_in!!,
response.refresh_token_expires_in!!
) )
pendingMfaUsername = null pendingMfaUsername = null
callback(true) callback(true)
@@ -0,0 +1,41 @@
/* Copyright (C) 2026 Arjan Schrijver
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.endurain
import android.app.AlertDialog
import android.content.Context
import nodomain.freeyourgadget.gadgetbridge.R
class EndurainSsoProviderDialog(
private val context: Context,
private val providers: List<IdentityProvider>,
private val onProviderSelected: (IdentityProvider) -> Unit
) {
fun show() {
val providerNames = providers.map { it.name }.toTypedArray()
AlertDialog.Builder(context)
.setTitle(context.getString(R.string.endurain_select_identity_provider))
.setItems(providerNames) { dialog, which ->
onProviderSelected(providers[which])
dialog.dismiss()
}
.setNegativeButton(context.getString(R.string.cancel), null)
.show()
}
}
@@ -37,15 +37,15 @@ class EndurainTokenManager(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) )
fun saveTokens(accessToken: String, refreshToken: String, accessTokenExpiresAt: Int) { fun saveTokens(accessToken: String, refreshToken: String, accessTokenExpiry: Int, refreshTokenExpiry: Int) {
val accessTokenExpiryTs = if (accessTokenExpiry < 1000000000) ((System.currentTimeMillis() / 1000) + accessTokenExpiry).toInt() else accessTokenExpiry
val refreshTokenExpiryTs = if (refreshTokenExpiry < 1000000000) ((System.currentTimeMillis() / 1000) + refreshTokenExpiry).toInt() else refreshTokenExpiry
sharedPreferences.edit { sharedPreferences.edit {
putString("access_token", accessToken) putString("access_token", accessToken)
.putString("refresh_token", refreshToken) .putString("refresh_token", refreshToken)
.putInt("access_token_expires_at", accessTokenExpiresAt) .putInt("access_token_expires_at", accessTokenExpiryTs)
.putInt("refresh_token_expires_at", .putInt("refresh_token_expires_at", refreshTokenExpiryTs
((System.currentTimeMillis() / 1000) + (7 * 24 * 60 * 60)).toInt() )
) // FIXME: 7 days is the Endurain default for refresh token expiry
// https://github.com/endurain-project/endurain/issues/514
} }
} }
@@ -87,7 +87,8 @@ class EndurainTokenManager(context: Context) {
saveTokens( saveTokens(
response.access_token, response.access_token,
response.refresh_token!!, response.refresh_token!!,
response.expires_in!! response.expires_in!!,
response.refresh_token_expires_in!!
) )
callback(true) callback(true)
} }
+22
View File
@@ -4938,4 +4938,26 @@
<string name="media_source_audio_card">Audio card</string> <string name="media_source_audio_card">Audio card</string>
<string name="media_source_aux">AUX</string> <string name="media_source_aux">AUX</string>
<string name="sinilink_prompt_tone">Prompt tone</string> <string name="sinilink_prompt_tone">Prompt tone</string>
<string name="required">Required</string>
<string name="endurain_sso_login_failed">SSO login failed</string>
<string name="endurain_no_sso_providers_available">No SSO providers available</string>
<string name="endurain_failed_to_open_browser">Failed to open browser: %1$s</string>
<string name="endurain_sso_login_successful">SSO login successful</string>
<string name="endurain_invalid_server_url">Invalid server URL</string>
<string name="endurain_failed_to_connect_to_server">Failed to connect to server</string>
<string name="endurain_verify_mfa">Verify MFA</string>
<string name="endurain_enter_your_mfa_code">Enter your MFA code</string>
<string name="endurain_login_successful">Login successful</string>
<string name="endurain_login_failed">Login failed</string>
<string name="endurain_mfa_verification_successful">MFA verification successful</string>
<string name="endurain_invalid_mfa_code">Invalid MFA code</string>
<string name="endurain_mfa_verification_failed">MFA verification failed</string>
<string name="endurain_select_identity_provider">Select Identity Provider</string>
<string name="endurain_invalid_oauth_callback">Invalid OAuth callback</string>
<string name="endurain_oauth_completing_login">Completing login…</string>
<string name="endurain_not_logged_in_integration_disabled">Not logged in, integration is disabled</string>
<string name="endurain_logged_in_refresh_token">Logged in to %1$s\nRefresh token expires: %2$s</string>
<string name="endurain_server_version">\nServer version: %1$s</string>
<string name="endurain_logout_failed">Logout failed</string>
<string name="endurain_logged_out_successfully">Logged out successfully</string>
</resources> </resources>
+2
View File
@@ -51,6 +51,7 @@ searchpreference = "2.7.3"
slf4j = "2.0.18" slf4j = "2.0.18"
solarpositioning = "0.1.10" solarpositioning = "0.1.10"
security-crypto = "1.1.0" security-crypto = "1.1.0"
browser = "1.10.0"
[libraries] [libraries]
ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" } ahocorasick = { group = "org.ahocorasick", name = "ahocorasick", version.ref = "ahocorasick" }
@@ -114,6 +115,7 @@ searchpreference = { group = "com.github.ByteHamster", name = "SearchPreference"
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
solarpositioning = { group = "net.e175.klaus", name = "solarpositioning", version.ref = "solarpositioning" } solarpositioning = { group = "net.e175.klaus", name = "solarpositioning", version.ref = "solarpositioning" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
[bundles] [bundles]
android-emojify = [ android-emojify = [