From 02611bfc1395d93b8e846b4355fbd5f5810fcaa2 Mon Sep 17 00:00:00 2001 From: DenBond7 Date: Fri, 7 Aug 2020 19:16:26 +0300 Subject: [PATCH] Applied appauth libs for OAuth.| #716 --- FlowCrypt/build.gradle | 1 + FlowCrypt/src/main/AndroidManifest.xml | 13 ++- .../flowcrypt/email/api/oauth/OAuth2Helper.kt | 32 ++++--- .../email/api/retrofit/ApiRepository.kt | 5 +- .../email/api/retrofit/ApiService.kt | 6 +- .../api/retrofit/FlowcryptApiRepository.kt | 11 +-- .../oauth2/MicrosoftAccountResponse.kt | 49 ----------- .../OAuth2AuthCredentialsViewModel.kt | 21 ++--- .../fragment/AddOtherAccountFragment.kt | 87 ++++++++++++------- FlowCrypt/src/main/res/values/strings.xml | 2 +- 10 files changed, 96 insertions(+), 131 deletions(-) delete mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/oauth2/MicrosoftAccountResponse.kt diff --git a/FlowCrypt/build.gradle b/FlowCrypt/build.gradle index 35dfb80118..5544c1fc53 100644 --- a/FlowCrypt/build.gradle +++ b/FlowCrypt/build.gradle @@ -425,4 +425,5 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" implementation 'ja.burhanrashid52:photoeditor:1.0.0' implementation "org.jetbrains.kotlin:kotlin-reflect:1.3.72" + implementation 'net.openid:appauth:0.7.1' } diff --git a/FlowCrypt/src/main/AndroidManifest.xml b/FlowCrypt/src/main/AndroidManifest.xml index e7c5046cb3..523aa583bf 100644 --- a/FlowCrypt/src/main/AndroidManifest.xml +++ b/FlowCrypt/src/main/AndroidManifest.xml @@ -47,16 +47,21 @@ - + android:screenOrientation="portrait" /> + + + - - + diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/oauth/OAuth2Helper.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/oauth/OAuth2Helper.kt index a63ae9c8bc..5bc0927137 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/oauth/OAuth2Helper.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/oauth/OAuth2Helper.kt @@ -5,9 +5,10 @@ package com.flowcrypt.email.api.oauth -import android.content.Intent import android.net.Uri -import okhttp3.HttpUrl.Companion.toHttpUrl +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues /** * @author Denis Bondarenko @@ -17,10 +18,6 @@ import okhttp3.HttpUrl.Companion.toHttpUrl */ class OAuth2Helper { companion object { - const val QUERY_PARAMETER_STATE = "state" - const val QUERY_PARAMETER_CODE = "code" - const val QUERY_PARAMETER_ERROR = "error" - const val QUERY_PARAMETER_ERROR_DESCRIPTION = "error_description" const val OAUTH2_GRANT_TYPE = "authorization_code" const val OAUTH2_GRANT_TYPE_REFRESH_TOKEN = "refresh_token" @@ -41,19 +38,20 @@ class OAuth2Helper { const val MICROSOFT_OAUTH2_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" const val MICROSOFT_REDIRECT_URI = "msauth://com.flowcrypt.email.denys/04gM%2BEAfhnq4ALbhOX8jG5oRuow%3D" const val MICROSOFT_AZURE_APP_ID = "3be51534-5f76-4970-9a34-40ef197aa018" + const val MICROSOFT_OAUTH2_SCHEMA = "msauth" - fun getMicrosoftOAuth2Intent(state: String): Intent { - val authorizeUrl = MICROSOFT_OAUTH2_AUTHORIZE_URL.toHttpUrl() - .newBuilder() - .addQueryParameter("client_id", MICROSOFT_AZURE_APP_ID) - .addQueryParameter("response_type", "code") - .addQueryParameter("redirect_uri", MICROSOFT_REDIRECT_URI) - .addQueryParameter("response_mode", "query") - .addQueryParameter("scope", "$SCOPE_MICROSOFT_OAUTH2_FOR_PROFILE $SCOPE_MICROSOFT_OAUTH2_FOR_MAIL") - .addQueryParameter(QUERY_PARAMETER_STATE, state) - .build() + fun getMicrosoftAuthorizationRequest(): AuthorizationRequest { + val configuration = AuthorizationServiceConfiguration(Uri.parse(MICROSOFT_OAUTH2_AUTHORIZE_URL), Uri.parse(MICROSOFT_OAUTH2_TOKEN_URL)) - return Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(authorizeUrl.toUrl().toString()) } + return AuthorizationRequest.Builder( + configuration, + MICROSOFT_AZURE_APP_ID, + ResponseTypeValues.CODE, + Uri.parse(MICROSOFT_REDIRECT_URI)) + .setScope("$SCOPE_MICROSOFT_OAUTH2_FOR_PROFILE $SCOPE_MICROSOFT_OAUTH2_FOR_MAIL") + .build() } + + val SUPPORTED_SCHEMAS = listOf(MICROSOFT_OAUTH2_SCHEMA) } } \ No newline at end of file diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt index bc8a9ae2e6..13bcb386df 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiRepository.kt @@ -21,7 +21,6 @@ import com.flowcrypt.email.api.retrofit.response.attester.LookUpEmailsResponse import com.flowcrypt.email.api.retrofit.response.attester.PubResponse import com.flowcrypt.email.api.retrofit.response.attester.TestWelcomeResponse import com.flowcrypt.email.api.retrofit.response.base.Result -import com.flowcrypt.email.api.retrofit.response.oauth2.MicrosoftAccountResponse import com.flowcrypt.email.api.retrofit.response.oauth2.MicrosoftOAuth2TokenResponse /** @@ -86,7 +85,5 @@ interface ApiRepository : BaseApiRepository { * @param context Interface to global information about an application environment. * @param authorizeCode A code which will be used to retrieve an access token. */ - suspend fun getMicrosoftOAuth2Token(requestCode: Long = 0L, context: Context, authorizeCode: String, scopes: String): Result - - suspend fun getMicrosoftAccountInfo(requestCode: Long = 0L, context: Context, bearerToken: String): Result + suspend fun getMicrosoftOAuth2Token(requestCode: Long = 0L, context: Context, authorizeCode: String, scopes: String, codeVerifier: String): Result } \ No newline at end of file diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt index 8e1bd699ef..6b14ad4abd 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/ApiService.kt @@ -20,7 +20,6 @@ import com.flowcrypt.email.api.retrofit.response.attester.InitialLegacySubmitRes import com.flowcrypt.email.api.retrofit.response.attester.LookUpEmailResponse import com.flowcrypt.email.api.retrofit.response.attester.LookUpEmailsResponse import com.flowcrypt.email.api.retrofit.response.attester.TestWelcomeResponse -import com.flowcrypt.email.api.retrofit.response.oauth2.MicrosoftAccountResponse import com.flowcrypt.email.api.retrofit.response.oauth2.MicrosoftOAuth2TokenResponse import retrofit2.Call import retrofit2.Response @@ -144,6 +143,7 @@ interface ApiService { suspend fun getMicrosoftOAuth2Token( @Field("code") code: String, @Field("scope") scope: String, + @Field("code_verifier") codeVerifier: String, @Field("client_id") clientId: String = OAuth2Helper.MICROSOFT_AZURE_APP_ID, @Field("redirect_uri") redirect_uri: String = OAuth2Helper.MICROSOFT_REDIRECT_URI, @Field("grant_type") grant_type: String = OAuth2Helper.OAUTH2_GRANT_TYPE): Response @@ -155,8 +155,4 @@ interface ApiService { @Field("scope") scope: String, @Field("client_id") clientId: String = OAuth2Helper.MICROSOFT_AZURE_APP_ID, @Field("grant_type") grant_type: String = OAuth2Helper.OAUTH2_GRANT_TYPE_REFRESH_TOKEN): Response - - - @GET("https://graph.microsoft.com/v1.0/me/") - suspend fun getMicrosoftAccountInfo(@Header("Authorization") bearerToken: String): Response } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt index 025392bfe0..f29fb63106 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/FlowcryptApiRepository.kt @@ -20,7 +20,6 @@ import com.flowcrypt.email.api.retrofit.response.attester.LookUpEmailsResponse import com.flowcrypt.email.api.retrofit.response.attester.PubResponse import com.flowcrypt.email.api.retrofit.response.attester.TestWelcomeResponse import com.flowcrypt.email.api.retrofit.response.base.Result -import com.flowcrypt.email.api.retrofit.response.oauth2.MicrosoftAccountResponse import com.flowcrypt.email.api.retrofit.response.oauth2.MicrosoftOAuth2TokenResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -95,16 +94,10 @@ class FlowcryptApiRepository : ApiRepository { } override suspend fun getMicrosoftOAuth2Token(requestCode: Long, context: Context, - authorizeCode: String, scopes: String): + authorizeCode: String, scopes: String, codeVerifier: String): Result = withContext(Dispatchers.IO) { val apiService = ApiHelper.getInstance(context).retrofit.create(ApiService::class.java) - getResult { apiService.getMicrosoftOAuth2Token(authorizeCode, scopes) } - } - - override suspend fun getMicrosoftAccountInfo(requestCode: Long, context: Context, bearerToken: String): Result = - withContext(Dispatchers.IO) { - val apiService = ApiHelper.getInstance(context).retrofit.create(ApiService::class.java) - getResult { apiService.getMicrosoftAccountInfo("Bearer $bearerToken") } + getResult { apiService.getMicrosoftOAuth2Token(authorizeCode, scopes, codeVerifier) } } } \ No newline at end of file diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/oauth2/MicrosoftAccountResponse.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/oauth2/MicrosoftAccountResponse.kt deleted file mode 100644 index 281b762494..0000000000 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/oauth2/MicrosoftAccountResponse.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 - */ - -package com.flowcrypt.email.api.retrofit.response.oauth2 - -import android.os.Parcel -import android.os.Parcelable -import com.flowcrypt.email.api.retrofit.response.base.ApiError -import com.flowcrypt.email.api.retrofit.response.base.ApiResponse -import com.google.gson.annotations.Expose - -/** - * @author Denis Bondarenko - * Date: 8/4/20 - * Time: 2:39 PM - * E-mail: DenBond7@gmail.com - */ -data class MicrosoftAccountResponse constructor( - @Expose val displayName: String? = null, - @Expose val userPrincipalName: String? = null -) : ApiResponse { - - override val apiError: ApiError? = null - - constructor(parcel: Parcel) : this( - parcel.readString(), - parcel.readString()) - - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeString(displayName) - parcel.writeString(userPrincipalName) - } - - override fun describeContents(): Int { - return 0 - } - - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): MicrosoftAccountResponse { - return MicrosoftAccountResponse(parcel) - } - - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } - } -} \ No newline at end of file diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/OAuth2AuthCredentialsViewModel.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/OAuth2AuthCredentialsViewModel.kt index f91583dc38..5dc03532e4 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/OAuth2AuthCredentialsViewModel.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/jetpack/viewmodel/OAuth2AuthCredentialsViewModel.kt @@ -15,6 +15,7 @@ import com.flowcrypt.email.api.retrofit.ApiRepository import com.flowcrypt.email.api.retrofit.FlowcryptApiRepository import com.flowcrypt.email.api.retrofit.response.base.Result import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationRequest /** @@ -27,14 +28,15 @@ class OAuth2AuthCredentialsViewModel(application: Application) : BaseAndroidView private val apiRepository: ApiRepository = FlowcryptApiRepository() val microsoftOAuth2TokenLiveData = MutableLiveData>() - fun getMicrosoftOAuth2Token(requestCode: Long = 0L, authorizeCode: String) { + fun getMicrosoftOAuth2Token(requestCode: Long = 0L, authorizeCode: String, authRequest: AuthorizationRequest) { viewModelScope.launch { microsoftOAuth2TokenLiveData.postValue(Result.loading()) val microsoftOAuth2TokenResponseResultForProfile = apiRepository.getMicrosoftOAuth2Token( requestCode = requestCode, context = getApplication(), authorizeCode = authorizeCode, - scopes = OAuth2Helper.SCOPE_MICROSOFT_OAUTH2_FOR_PROFILE + scopes = OAuth2Helper.SCOPE_MICROSOFT_OAUTH2_FOR_PROFILE, + codeVerifier = authRequest.codeVerifier ?: "" ) if (microsoftOAuth2TokenResponseResultForProfile.status != Result.Status.SUCCESS) { @@ -54,19 +56,17 @@ class OAuth2AuthCredentialsViewModel(application: Application) : BaseAndroidView return@launch } + //validate id_token + + val tokenForProfile = microsoftOAuth2TokenResponseResultForProfile.data?.accessToken if (tokenForProfile == null) { microsoftOAuth2TokenLiveData.postValue(Result.exception(NullPointerException("token is null"))) return@launch } - val microsoftAccount = apiRepository.getMicrosoftAccountInfo( - requestCode = requestCode, - context = getApplication(), - bearerToken = tokenForProfile - ) - val userEmailAddress = microsoftAccount.data?.userPrincipalName + val userEmailAddress = "user@outlook.com"//microsoftAccount.data?.userPrincipalName if (userEmailAddress == null) { microsoftOAuth2TokenLiveData.postValue(Result.exception(NullPointerException("User email is null"))) return@launch @@ -76,7 +76,8 @@ class OAuth2AuthCredentialsViewModel(application: Application) : BaseAndroidView requestCode = requestCode, context = getApplication(), authorizeCode = authorizeCode, - scopes = OAuth2Helper.SCOPE_MICROSOFT_OAUTH2_FOR_MAIL + scopes = OAuth2Helper.SCOPE_MICROSOFT_OAUTH2_FOR_MAIL, + codeVerifier = authRequest.codeVerifier ?: "" ) if (microsoftOAuth2TokenResponseResultForEmail.status != Result.Status.SUCCESS) { @@ -104,7 +105,7 @@ class OAuth2AuthCredentialsViewModel(application: Application) : BaseAndroidView } val recommendAuthCredentials = EmailProviderSettingsHelper.getBaseSettings( - microsoftAccount.data.userPrincipalName, tokenForEmail)?.copy(useOAuth2 = true) + "microsoftAccount.data.userPrincipalName", tokenForEmail)?.copy(useOAuth2 = true) microsoftOAuth2TokenLiveData.postValue(Result.success(recommendAuthCredentials!!)) } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/AddOtherAccountFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/AddOtherAccountFragment.kt index ec53c835e7..53dccd4d4e 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/AddOtherAccountFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/AddOtherAccountFragment.kt @@ -6,6 +6,7 @@ package com.flowcrypt.email.ui.activity.fragment import android.app.Activity +import android.app.PendingIntent import android.content.Intent import android.os.Bundle import android.text.Editable @@ -20,7 +21,6 @@ import android.widget.CheckBox import android.widget.EditText import android.widget.Spinner import android.widget.Toast -import androidx.browser.customtabs.CustomTabsIntent import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels @@ -66,11 +66,13 @@ import com.google.gson.JsonSyntaxException import com.sun.mail.util.MailConnectException import kotlinx.android.synthetic.main.fragment_screenshot_editor.* import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService import java.net.SocketTimeoutException -import java.util.* import java.util.regex.Pattern import javax.mail.AuthenticationFailedException -import kotlin.collections.ArrayList /** * @author Denis Bondarenko @@ -98,7 +100,7 @@ class AddOtherAccountFragment : BaseSingInFragment(), ProgressBehaviour, private var isImapSpinnerRestored: Boolean = false private var isSmtpSpinnerRestored: Boolean = false - private var uuidForOAuth: String? = null + private var authRequest: AuthorizationRequest? = null private val privateKeysViewModel: PrivateKeysViewModel by viewModels() private val oAuth2AuthCredentialsViewModel: OAuth2AuthCredentialsViewModel by viewModels() @@ -131,10 +133,7 @@ class AddOtherAccountFragment : BaseSingInFragment(), ProgressBehaviour, override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - savedInstanceState?.let { - uuidForOAuth = it.getString(KEY_UUID_FOR_OAUTH) - } + savedInstanceState?.let { restoreAuthRequest(it) } subscribeToCheckAccountSettings() subscribeToAuthorizeAndSearchBackups() @@ -163,7 +162,7 @@ class AddOtherAccountFragment : BaseSingInFragment(), ProgressBehaviour, override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString(KEY_UUID_FOR_OAUTH, uuidForOAuth) + outState.putString(KEY_AUTH_REQUEST, authRequest?.jsonSerializeString()) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -257,25 +256,40 @@ class AddOtherAccountFragment : BaseSingInFragment(), ProgressBehaviour, } fun handleOAuth2Intent(intent: Intent?) { - val state = intent?.data?.getQueryParameter(OAuth2Helper.QUERY_PARAMETER_STATE) - val code = intent?.data?.getQueryParameter(OAuth2Helper.QUERY_PARAMETER_CODE) - if (uuidForOAuth.equals(state)) { - if (code != null) { - oAuth2AuthCredentialsViewModel.getMicrosoftOAuth2Token(authorizeCode = code) - } else { - val error = intent?.data?.getQueryParameter(OAuth2Helper.QUERY_PARAMETER_ERROR) - val errorDescription = intent?.data?.getQueryParameter(OAuth2Helper - .QUERY_PARAMETER_ERROR_DESCRIPTION) + intent?.let { + val schema = intent.data?.scheme + if (schema !in OAuth2Helper.SUPPORTED_SCHEMAS) { + return + } + val authResponse = AuthorizationResponse.fromIntent(intent) + val authException = AuthorizationException.fromIntent(intent) + if (authResponse != null) { + val code = authResponse.authorizationCode + if (code != null) { + authRequest?.let { request -> + when (schema) { + OAuth2Helper.MICROSOFT_OAUTH2_SCHEMA -> { + oAuth2AuthCredentialsViewModel.getMicrosoftOAuth2Token(authorizeCode = code, authRequest = request) + } + } + } + } else { + showInfoDialog( + dialogTitle = "", + dialogMsg = getString(R.string.could_not_verify_response), + useLinkify = true + ) + } + } else if (authException != null) { showInfoDialog( - dialogTitle = getString(R.string.error_with_value, error), - dialogMsg = errorDescription, + dialogTitle = getString(R.string.error_with_value, authException.error), + dialogMsg = authException.errorDescription, useLinkify = true ) + } else { + return@let } - } else { - showInfoDialog(dialogTitle = "", dialogMsg = getString(R.string.could_not_varify_response, - getString(R.string.support_email)), useLinkify = true) } } @@ -373,14 +387,12 @@ class AddOtherAccountFragment : BaseSingInFragment(), ProgressBehaviour, } view.findViewById(R.id.buttonSignInWithOutlook)?.setOnClickListener { - uuidForOAuth = UUID.randomUUID().toString() - uuidForOAuth?.let { state -> - val intent = OAuth2Helper.getMicrosoftOAuth2Intent(state) - if (intent.resolveActivity(requireContext().packageManager) != null) { - intent.data?.let { - CustomTabsIntent.Builder().build().launchUrl(requireContext(), it) - } - } + authRequest = OAuth2Helper.getMicrosoftAuthorizationRequest() + authRequest?.let { request -> + AuthorizationService(requireContext()) + .performAuthorizationRequest( + request, + PendingIntent.getActivity(requireContext(), 0, Intent(requireContext(), SignInActivity::class.java), 0)) } } } @@ -814,8 +826,19 @@ class AddOtherAccountFragment : BaseSingInFragment(), ProgressBehaviour, return false } + private fun restoreAuthRequest(state: Bundle) { + val serializedAuthorizationRequest = state.getString(KEY_AUTH_REQUEST) + serializedAuthorizationRequest?.let { jsonString -> + try { + authRequest = AuthorizationRequest.jsonDeserialize(jsonString) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + companion object { - private val KEY_UUID_FOR_OAUTH = + private val KEY_AUTH_REQUEST = GeneralUtil.generateUniqueExtraKey("KEY_UUID_FOR_OAUTH", AddOtherAccountFragment::class.java) private const val REQUEST_CODE_ADD_NEW_ACCOUNT = 10 diff --git a/FlowCrypt/src/main/res/values/strings.xml b/FlowCrypt/src/main/res/values/strings.xml index 6aa8eca99e..45e4013d73 100644 --- a/FlowCrypt/src/main/res/values/strings.xml +++ b/FlowCrypt/src/main/res/values/strings.xml @@ -466,6 +466,6 @@ Connect your email account\n using OAuth 2.0 Continue with Outlook, Hotmail Loading the account details … - Sorry, we couldn\'t verify the response. Please try again or write to us at %1$s + Sorry, we couldn\'t verify the response. Please try again or write to us at %1$s Error: %1$s