diff --git a/app/build.gradle b/app/build.gradle
index f1d3aadc9..617698bc3 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -105,6 +105,9 @@ dependencies {
implementation platform('com.google.firebase:firebase-bom:32.3.1')
implementation 'com.google.firebase:firebase-messaging'
+ implementation 'net.openid:appauth:0.11.1'
+ android.defaultConfig.manifestPlaceholders = [appAuthRedirectScheme: 'org.linphone.sso']
+
//noinspection GradleDynamicVersion
implementation 'org.linphone:linphone-sdk-android:5.3+'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5cd99d3df..816507750 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -78,6 +78,11 @@
android:launchMode="singleTask"
android:resizeableActivity="true" />
+
+
.
+ */
+package org.linphone.ui.sso
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import androidx.annotation.UiThread
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.lifecycleScope
+import java.io.File
+import kotlinx.coroutines.launch
+import net.openid.appauth.AuthState
+import net.openid.appauth.AuthState.AuthStateAction
+import net.openid.appauth.AuthorizationException
+import net.openid.appauth.AuthorizationRequest
+import net.openid.appauth.AuthorizationResponse
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.AuthorizationServiceConfiguration
+import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback
+import net.openid.appauth.ResponseTypeValues
+import org.linphone.R
+import org.linphone.core.tools.Log
+import org.linphone.databinding.SingleSignOnOpenIdActivityBinding
+import org.linphone.utils.FileUtils
+import org.linphone.utils.TimestampUtils
+
+@UiThread
+class OpenIdActivity : AppCompatActivity() {
+ companion object {
+ private const val TAG = "[Open ID Activity]"
+
+ private const val WELL_KNOWN = "https://auth.linphone.org:8443/realms/sip.linphone.org/.well-known/openid-configuration"
+ private const val CLIENT_ID = "account"
+ private const val SCOPE = "openid email profile"
+ private const val REDIRECT_URI = "org.linphone.sso:/openidcallback"
+ private const val ACTIVITY_RESULT_ID = 666
+ }
+
+ private lateinit var binding: SingleSignOnOpenIdActivityBinding
+
+ private lateinit var authState: AuthState
+ private lateinit var authService: AuthorizationService
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding = DataBindingUtil.setContentView(this, R.layout.single_sign_on_open_id_activity)
+ binding.lifecycleOwner = this
+
+ lifecycleScope.launch {
+ authState = getAuthState()
+ updateTokenInfo()
+ }
+
+ binding.setSingleSignOnClickListener {
+ lifecycleScope.launch {
+ singleSignOn()
+ }
+ }
+
+ binding.setRefreshTokenClickListener {
+ lifecycleScope.launch {
+ performRefreshToken()
+ }
+ }
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (requestCode == ACTIVITY_RESULT_ID && data != null) {
+ val resp = AuthorizationResponse.fromIntent(data)
+ val ex = AuthorizationException.fromIntent(data)
+
+ if (::authState.isInitialized) {
+ Log.i("$TAG Updating AuthState object after authorization response")
+ authState.update(resp, ex)
+ }
+
+ if (resp != null) {
+ Log.i("$TAG Response isn't null, performing request token")
+ performRequestToken(resp)
+ } else {
+ Log.e("$TAG Can't perform request token [$ex]")
+ }
+ }
+
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ private fun singleSignOn() {
+ Log.i("$TAG Fetch from issuer")
+ AuthorizationServiceConfiguration.fetchFromUrl(
+ Uri.parse(WELL_KNOWN),
+ RetrieveConfigurationCallback { serviceConfiguration, ex ->
+ if (ex != null) {
+ Log.e("$TAG Failed to fetch configuration")
+ return@RetrieveConfigurationCallback
+ }
+ if (serviceConfiguration == null) {
+ Log.e("$TAG Service configuration is null!")
+ return@RetrieveConfigurationCallback
+ }
+
+ if (!::authState.isInitialized) {
+ Log.i("$TAG Initializing AuthState object")
+ authState = AuthState(serviceConfiguration)
+ storeAuthStateAsJsonFile()
+ }
+
+ val authRequestBuilder = AuthorizationRequest.Builder(
+ serviceConfiguration, // the authorization service configuration
+ CLIENT_ID, // the client ID, typically pre-registered and static
+ ResponseTypeValues.CODE, // the response_type value: we want a code
+ Uri.parse(REDIRECT_URI) // the redirect URI to which the auth response is sent
+ )
+
+ val authRequest = authRequestBuilder
+ .setScope(SCOPE)
+ .build()
+ authService = AuthorizationService(this)
+ val authIntent = authService.getAuthorizationRequestIntent(authRequest)
+ startActivityForResult(authIntent, ACTIVITY_RESULT_ID)
+ }
+ )
+ }
+
+ private fun performRequestToken(response: AuthorizationResponse) {
+ if (::authService.isInitialized) {
+ Log.i("$TAG Starting perform token request")
+ authService.performTokenRequest(
+ response.createTokenExchangeRequest()
+ ) { resp, ex ->
+ if (resp != null) {
+ Log.i("$TAG Token exchange succeeded!")
+
+ if (::authState.isInitialized) {
+ Log.i("$TAG Updating AuthState object after token response")
+ authState.update(resp, ex)
+ storeAuthStateAsJsonFile()
+ }
+
+ useToken()
+ } else {
+ Log.e("$TAG Failed to perform token request [$ex]")
+ }
+ }
+ }
+ }
+
+ private fun performRefreshToken() {
+ if (::authState.isInitialized) {
+ if (!::authService.isInitialized) {
+ authService = AuthorizationService(this)
+ }
+
+ Log.i("$TAG Starting refresh token request")
+ authService.performTokenRequest(
+ authState.createTokenRefreshRequest()
+ ) { resp, ex ->
+ if (resp != null) {
+ Log.i("$TAG Token refresh succeeded!")
+
+ if (::authState.isInitialized) {
+ Log.i("$TAG Updating AuthState object after refresh token response")
+ authState.update(resp, ex)
+ storeAuthStateAsJsonFile()
+ }
+ updateTokenInfo()
+ } else {
+ Log.e("$TAG Failed to perform token refresh [$ex]")
+ }
+ }
+ }
+ }
+
+ private fun useToken() {
+ if (::authState.isInitialized && ::authService.isInitialized) {
+ if (authState.needsTokenRefresh && authState.refreshToken.isNullOrEmpty()) {
+ Log.e("$TAG Attempted to take an unauthorized action without a refresh token!")
+ return
+ }
+
+ Log.i("$TAG Performing action with fresh token")
+ authState.performActionWithFreshTokens(
+ authService,
+ AuthStateAction { accessToken, idToken, ex ->
+ if (ex != null) {
+ Log.e("$TAG Failed to use token [$ex]")
+ return@AuthStateAction
+ }
+
+ Log.i("$$TAG Access & id tokens are now available")
+ Log.d("$TAG Access token [$accessToken], id token [$idToken]")
+
+ storeAuthStateAsJsonFile()
+ }
+ )
+ }
+ }
+
+ private suspend fun getAuthState(): AuthState {
+ val file = File(applicationContext.filesDir.absolutePath, "auth_state.json")
+ if (file.exists()) {
+ val content = FileUtils.readFile(file)
+ if (content.isNotEmpty()) {
+ Log.i("$TAG Initializing AuthState from local JSON file")
+ Log.d("$TAG Local JSON file contains [$content]")
+ try {
+ return AuthState.jsonDeserialize(content)
+ } catch (exception: Exception) {
+ Log.e("$TAG Failed to use serialized AuthState [$exception]")
+ }
+ }
+ }
+
+ return AuthState()
+ }
+
+ private fun storeAuthStateAsJsonFile() {
+ Log.i("$TAG Trying to save serialized authState as JSON file")
+ val data = authState.jsonSerializeString()
+ Log.d("$TAG Date to save is [$data]")
+ val file = File(applicationContext.filesDir.absolutePath, "auth_state.json")
+ lifecycleScope.launch {
+ if (FileUtils.dumpStringToFile(data, file)) {
+ Log.i("$TAG Service configuration saved as JSON as [${file.absolutePath}]")
+ } else {
+ Log.i(
+ "$TAG Failed to save service configuration as JSON as [${file.absolutePath}]"
+ )
+ }
+ }
+ }
+
+ private fun updateTokenInfo() {
+ if (::authState.isInitialized) {
+ if (authState.isAuthorized) {
+ Log.i("$TAG User is already authenticated!")
+ binding.sso.visibility = View.GONE
+ binding.tokenRefresh.visibility = View.GONE
+ binding.tokenExpires.visibility = View.VISIBLE
+
+ val expiration = authState.accessTokenExpirationTime
+ if (expiration != null) {
+ if (expiration < System.currentTimeMillis()) {
+ Log.w("$TAG Access token is expired")
+ binding.tokenExpires.text = "Token expired!"
+ binding.tokenRefresh.visibility = View.VISIBLE
+ } else {
+ val date = if (TimestampUtils.isToday(expiration, timestampInSecs = false)) {
+ "today"
+ } else {
+ TimestampUtils.toString(
+ expiration,
+ onlyDate = true,
+ timestampInSecs = false
+ )
+ }
+ val time = TimestampUtils.toString(expiration, timestampInSecs = false)
+ Log.i("$TAG Access token expires [$date] [$time]")
+ binding.tokenExpires.text = "Token expires $date at $time"
+ }
+ } else {
+ Log.w("$TAG Access token expiration info not available")
+ binding.tokenExpires.text = "Can't access token expiration!"
+ }
+ } else {
+ Log.w("$TAG User isn't authenticated yet!")
+ binding.sso.visibility = View.VISIBLE
+ binding.tokenRefresh.visibility = View.GONE
+ binding.tokenExpires.visibility = View.GONE
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt
index 27da4e78b..d6f8a30f4 100644
--- a/app/src/main/java/org/linphone/utils/FileUtils.kt
+++ b/app/src/main/java/org/linphone/utils/FileUtils.kt
@@ -176,6 +176,25 @@ class FileUtils {
return false
}
+ suspend fun readFile(file: File): String {
+ Log.i("$TAG Trying to read file [${file.absoluteFile}]")
+ val stringBuilder = StringBuilder()
+ try {
+ withContext(Dispatchers.IO) {
+ FileInputStream(file).use { inputStream ->
+ val buffer = ByteArray(4096)
+ while (inputStream.read(buffer) >= 0) {
+ stringBuilder.append(String(buffer))
+ }
+ }
+ }
+ return stringBuilder.toString()
+ } catch (e: IOException) {
+ Log.e("$TAG Failed to read file [$file] as plain text: $e")
+ }
+ return stringBuilder.toString()
+ }
+
@AnyThread
private fun getFileStorageDir(isPicture: Boolean = false): File {
var path: File? = null
diff --git a/app/src/main/res/layout/assistant_login_fragment.xml b/app/src/main/res/layout/assistant_login_fragment.xml
index bd1c5b0c0..131a95aa3 100644
--- a/app/src/main/res/layout/assistant_login_fragment.xml
+++ b/app/src/main/res/layout/assistant_login_fragment.xml
@@ -14,6 +14,9 @@
+
@@ -218,13 +221,30 @@
app:layout_constraintTop_toTopOf="@id/or"
app:layout_constraintBottom_toBottomOf="@id/or"/>
+
+
+ app:layout_constraintTop_toBottomOf="@id/single_sign_on" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a207d9f7f..1d4c0ce0b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -131,6 +131,7 @@
Configuration successfully applied
Remote configuration failed!
Use a third party SIP account
+ Single sign on
No account yet?
Register
Scan a QR code