Added single sign on using OpenID and our Keycloak test instance

This commit is contained in:
Sylvain Berfini 2023-10-30 12:15:34 +01:00
parent 325feb5637
commit a919f5edbc
9 changed files with 443 additions and 8 deletions

View file

@ -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+'
}

View file

@ -78,6 +78,11 @@
android:launchMode="singleTask"
android:resizeableActivity="true" />
<activity
android:name=".ui.sso.OpenIdActivity"
android:launchMode="singleTask"
android:resizeableActivity="true"/>
<activity
android:name=".ui.call.CallActivity"
android:launchMode="singleTask"

View file

@ -40,6 +40,7 @@ import org.linphone.databinding.AssistantLoginFragmentBinding
import org.linphone.ui.assistant.AssistantActivity
import org.linphone.ui.assistant.model.AcceptConditionsAndPolicyDialogModel
import org.linphone.ui.assistant.viewmodel.AccountLoginViewModel
import org.linphone.ui.sso.OpenIdActivity
import org.linphone.utils.DialogUtils
import org.linphone.utils.PhoneNumberUtils
@ -105,6 +106,11 @@ class LoginFragment : Fragment() {
}
}
binding.setSingleSignOnClickListener {
startActivity(Intent(requireContext(), OpenIdActivity::class.java))
requireActivity().finish()
}
binding.setQrCodeClickListener {
val action = LoginFragmentDirections.actionLoginFragmentToQrCodeScannerFragment()
findNavController().navigate(action)

View file

@ -37,6 +37,13 @@ import org.linphone.databinding.AssistantPermissionsFragmentBinding
class PermissionsFragment : Fragment() {
companion object {
private const val TAG = "[Permissions Fragment]"
private val PERMISSIONS = arrayOf(
Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.READ_CONTACTS,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA
)
}
private lateinit var binding: AssistantPermissionsFragmentBinding
@ -70,6 +77,11 @@ class PermissionsFragment : Fragment() {
binding.lifecycleOwner = viewLifecycleOwner
if (areAllPermissionsGranted()) {
Log.i("$TAG All permissions have been granted, skipping")
goToLoginFragment()
}
binding.setBackClickListener {
findNavController().popBackStack()
}
@ -82,12 +94,7 @@ class PermissionsFragment : Fragment() {
binding.setGrantAllClickListener {
Log.i("$TAG Requesting all permissions")
requestPermissionLauncher.launch(
arrayOf(
Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.READ_CONTACTS,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA
)
PERMISSIONS
)
}
@ -104,4 +111,13 @@ class PermissionsFragment : Fragment() {
val action = PermissionsFragmentDirections.actionPermissionsFragmentToLoginFragment()
findNavController().navigate(action)
}
private fun areAllPermissionsGranted(): Boolean {
for (permission in PERMISSIONS) {
if (ContextCompat.checkSelfPermission(requireContext(), permission) != PackageManager.PERMISSION_GRANTED) {
return false
}
}
return true
}
}

View file

@ -0,0 +1,295 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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
}
}
}
}

View file

@ -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

View file

@ -14,6 +14,9 @@
<variable
name="forgottenPasswordClickListener"
type="View.OnClickListener" />
<variable
name="singleSignOnClickListener"
type="View.OnClickListener" />
<variable
name="thirdPartySipAccountLoginClickListener"
type="View.OnClickListener" />
@ -218,13 +221,30 @@
app:layout_constraintTop_toTopOf="@id/or"
app:layout_constraintBottom_toBottomOf="@id/or"/>
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{singleSignOnClickListener}"
style="@style/primary_button_label_style"
android:id="@+id/single_sign_on"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="@string/assistant_login_using_single_sign_on"
app:layout_constraintWidth_max="@dimen/button_max_width"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/or" />
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{qrCodeClickListener}"
style="@style/secondary_button_label_style"
android:id="@+id/scan_qr_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="22dp"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:paddingStart="20dp"
@ -236,7 +256,7 @@
app:layout_constraintWidth_max="@dimen/button_max_width"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/or" />
app:layout_constraintTop_toBottomOf="@id/single_sign_on" />
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{thirdPartySipAccountLoginClickListener}"

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<variable
name="singleSignOnClickListener"
type="View.OnClickListener" />
<variable
name="refreshTokenClickListener"
type="View.OnClickListener" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.sso.OpenIdActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatTextView
style="@style/primary_button_label_style"
android:id="@+id/sso"
android:onClick="@{singleSignOnClickListener}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:text="Single sign on"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_800"
android:id="@+id/token_expires"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
style="@style/primary_button_label_style"
android:id="@+id/token_refresh"
android:onClick="@{refreshTokenClickListener}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="15dp"
android:layout_marginEnd="15dp"
android:text="Refresh token"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/token_expires"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View file

@ -131,6 +131,7 @@
<string name="assistant_qr_code_provisioning_done">Configuration successfully applied</string>
<string name="assistant_qr_code_provisioning_error">Remote configuration failed!</string>
<string name="assistant_login_third_party_sip_account">Use a third party SIP account</string>
<string name="assistant_login_using_single_sign_on">Single sign on</string>
<string name="assistant_no_account_yet">No account yet?</string>
<string name="assistant_account_register">Register</string>
<string name="assistant_scan_qr_code_title">Scan a QR code</string>