-
Notifications
You must be signed in to change notification settings - Fork 384
/
OAuthWebviewHelper.kt
1424 lines (1283 loc) · 55 KB
/
OAuthWebviewHelper.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
* Copyright (c) 2011-present, salesforce.com, inc.
* All rights reserved.
* Redistribution and use of this software in source and binary forms, with or
* without modification, are permitted provided that the following conditions
* are met:
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of salesforce.com, inc. nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission of salesforce.com, inc.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package com.salesforce.androidsdk.ui
import android.R.anim.slide_in_left
import android.R.anim.slide_out_right
import android.app.Activity
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.getActivity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory.decodeResource
import android.net.Uri.parse
import android.net.http.SslError
import android.net.http.SslError.SSL_EXPIRED
import android.net.http.SslError.SSL_IDMISMATCH
import android.net.http.SslError.SSL_NOTYETVALID
import android.net.http.SslError.SSL_UNTRUSTED
import android.os.Bundle
import android.security.KeyChain.getCertificateChain
import android.security.KeyChain.getPrivateKey
import android.security.KeyChainAliasCallback
import android.text.TextUtils.isEmpty
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.webkit.ClientCertRequest
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.ProgressBar
import android.widget.RelativeLayout
import android.widget.Toast.LENGTH_LONG
import android.widget.Toast.makeText
import androidx.browser.customtabs.CustomTabsIntent
import com.salesforce.androidsdk.R.color.sf__primary_color
import com.salesforce.androidsdk.R.drawable.sf__action_back
import com.salesforce.androidsdk.R.id.sf__bio_login_button
import com.salesforce.androidsdk.R.id.sf__idp_login_button
import com.salesforce.androidsdk.R.id.sf__loading_spinner
import com.salesforce.androidsdk.R.string.oauth_display_type
import com.salesforce.androidsdk.R.string.sf__biometric_signout_user
import com.salesforce.androidsdk.R.string.sf__generic_authentication_error
import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title
import com.salesforce.androidsdk.R.string.sf__generic_error
import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error
import com.salesforce.androidsdk.R.string.sf__managed_app_error
import com.salesforce.androidsdk.R.string.sf__pick_server
import com.salesforce.androidsdk.R.string.sf__ssl_error
import com.salesforce.androidsdk.R.string.sf__ssl_expired
import com.salesforce.androidsdk.R.string.sf__ssl_id_mismatch
import com.salesforce.androidsdk.R.string.sf__ssl_not_yet_valid
import com.salesforce.androidsdk.R.string.sf__ssl_unknown_error
import com.salesforce.androidsdk.R.string.sf__ssl_untrusted
import com.salesforce.androidsdk.R.style.SalesforceSDK
import com.salesforce.androidsdk.R.style.SalesforceSDK_Dark_Login
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.accounts.UserAccountBuilder
import com.salesforce.androidsdk.accounts.UserAccountManager
import com.salesforce.androidsdk.analytics.EventBuilderHelper.createAndStoreEventSync
import com.salesforce.androidsdk.app.Features.FEATURE_BIOMETRIC_AUTH
import com.salesforce.androidsdk.app.Features.FEATURE_SCREEN_LOCK
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.auth.HttpAccess.DEFAULT
import com.salesforce.androidsdk.auth.OAuth2.IdServiceResponse
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
import com.salesforce.androidsdk.auth.OAuth2.addAuthorizationHeader
import com.salesforce.androidsdk.auth.OAuth2.callIdentityService
import com.salesforce.androidsdk.auth.OAuth2.exchangeCode
import com.salesforce.androidsdk.auth.OAuth2.getAuthorizationUrl
import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl
import com.salesforce.androidsdk.auth.OAuth2.revokeRefreshToken
import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens
import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig
import com.salesforce.androidsdk.push.PushMessaging.register
import com.salesforce.androidsdk.rest.ClientManager
import com.salesforce.androidsdk.rest.ClientManager.LoginOptions
import com.salesforce.androidsdk.rest.RestClient.clearCaches
import com.salesforce.androidsdk.security.BiometricAuthenticationManager
import com.salesforce.androidsdk.security.BiometricAuthenticationManager.Companion.isBiometricAuthenticationEnabled
import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getRandom128ByteKey
import com.salesforce.androidsdk.security.SalesforceKeyGenerator.getSHA256Hash
import com.salesforce.androidsdk.ui.LoginActivity.Companion.PICK_SERVER_REQUEST_CODE
import com.salesforce.androidsdk.ui.OAuthWebviewHelper.AccountOptions.Companion.fromBundle
import com.salesforce.androidsdk.util.EventsObservable
import com.salesforce.androidsdk.util.EventsObservable.EventType.AuthWebViewPageFinished
import com.salesforce.androidsdk.util.MapUtil
import com.salesforce.androidsdk.util.MapUtil.addBundleToMap
import com.salesforce.androidsdk.util.SalesforceSDKLogger.d
import com.salesforce.androidsdk.util.SalesforceSDKLogger.e
import com.salesforce.androidsdk.util.SalesforceSDKLogger.w
import com.salesforce.androidsdk.util.UriFragmentParser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request.Builder
import org.json.JSONArray
import org.json.JSONObject
import java.lang.String.format
import java.net.URI
import java.net.URI.create
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.function.Consumer
/**
* A helper class to manage a web view going through the OAuth login process.
* The basic flow is:
* a) Load and show the login page to the user
* b) User login and app authorization
* c) Navigate to the authentication completion URL and token fetch
* d) Call the id service to obtain additional info about the user
* e) Create a local account and return an authentication result bundle
*
* @Deprecated This class will no longer be public starting in Mobile SDK 13.0. It
* is no longer necessary to extend or change LoginActivity's instance of this class
* to support multi-factor authentication. If there are other uses cases please
* inform the team via Github or our Trailblazer community.
*/
@Deprecated(
"This class will no longer be public starting in Mobile SDK 13.0.",
level = DeprecationLevel.WARNING,
)
open class OAuthWebviewHelper : KeyChainAliasCallback {
private var codeVerifier: String? = null
/**
* The host activity/fragment should pass in an implementation of this
* interface so that it can notify it of things it needs to do as part of
* the oauth process.
*/
interface OAuthWebviewHelperEvents {
/** Called when web view starts loading the login page */
fun loadingLoginPage(loginUrl: String)
/**
* Called when the authentication process completes and the
* authentication result bundle is returned to the authenticator
*/
fun onAccountAuthenticatorResult(authResult: Bundle)
/** Called when the host activity can be finished and closed */
fun finish(userAccount: UserAccount?)
}
/**
* Construct a new instance and performs initial configuration of the web
* view.
*
* @param activity The activity using this instance
* @param callback The callbacks for key events
* @param loginOptions The log in options
* @param webView The web view
* @param savedInstanceState The saved instance state
*/
@JvmOverloads
constructor(
activity: Activity,
callback: OAuthWebviewHelperEvents,
loginOptions: LoginOptions,
webView: WebView,
savedInstanceState: Bundle?,
shouldReloadPage: Boolean = false,
) {
this.activity = activity
this.callback = callback
this.context = webView.context
this.webView = webView
this.loginOptions = loginOptions
webView.apply {
webView.settings.apply {
javaScriptEnabled = true
userAgentString = format(
"%s %s",
SalesforceSDKManager.getInstance().userAgent,
userAgentString ?: ""
)
}
webViewClient = makeWebViewClient()
webChromeClient = makeWebChromeClient()
}
activity.setTheme(
when {
SalesforceSDKManager.getInstance().isDarkTheme -> SalesforceSDK_Dark_Login
else -> SalesforceSDK
}
)
/*
* Restore web view state if available. This ensures the user is not
* forced to type in credentials again once the authentication process
* kicks off.
*/
when (savedInstanceState) {
null -> clearCookies()
else -> {
webView.restoreState(savedInstanceState)
accountOptions = fromBundle(
savedInstanceState.getBundle(ACCOUNT_OPTIONS)
)
}
}
}
constructor(
context: Context,
callback: OAuthWebviewHelperEvents,
loginOptions: LoginOptions
) {
this.context = context
this.callback = callback
this.loginOptions = loginOptions
this.webView = null
this.activity = null
}
private val callback: OAuthWebviewHelperEvents
protected val loginOptions: LoginOptions
val webView: WebView?
private var accountOptions: AccountOptions? = null
protected val context: Context
private val activity: Activity?
private var key: PrivateKey? = null
private var certChain: Array<X509Certificate>? = null
/**
* This value is no longer needed to support Multi-Factor Authentication via
* standard or advanced authentication flows.
*
* @Deprecated This value is no longer used.
*/
var shouldReloadPage: Boolean = false
private set
fun saveState(outState: Bundle) {
val accountOptions = accountOptions
webView?.saveState(outState)
if (accountOptions != null) {
// The authentication flow is complete but an account has not been created since a pin is needed
outState.putBundle(
ACCOUNT_OPTIONS,
accountOptions.asBundle()
)
}
}
fun clearCookies() =
CookieManager.getInstance().removeAllCookies(null)
fun clearView() =
webView?.loadUrl("about:blank")
/**
* A factory method for the web view client. This can be overridden as
* needed.
*/
@Suppress("MemberVisibilityCanBePrivate")
protected fun makeWebViewClient() = AuthWebViewClient()
/**
* A factory method for the web Chrome client. This can be overridden as
* needed
*/
@Suppress("MemberVisibilityCanBePrivate")
protected fun makeWebChromeClient() = WebChromeClient()
/**
* A callback when the user facing part of the authentication flow completed
* with an error.
*
* Show the user an error and end the activity.
*
* @param error The error
* @param errorDesc The error description
* @param e The exception
*/
fun onAuthFlowError(
error: String,
errorDesc: String?,
e: Throwable?
) {
val instance = SalesforceSDKManager.getInstance()
e(TAG, "$error: $errorDesc", e)
// Broadcast a notification that the authentication flow failed
instance.appContext.sendBroadcast(
Intent(AUTHENTICATION_FAILED_INTENT).apply {
if (e is OAuthFailedException) {
putExtra(
HTTP_ERROR_RESPONSE_CODE_INTENT,
e.httpStatusCode
)
putExtra(
RESPONSE_ERROR_INTENT,
e.tokenErrorResponse.error
)
putExtra(
RESPONSE_ERROR_DESCRIPTION_INTENT,
e.tokenErrorResponse.errorDescription
)
}
})
// Displays the error in a toast, clears cookies and reloads the login page
activity?.runOnUiThread {
webView?.let { webView ->
makeText(
webView.context,
"$error : $errorDesc",
LENGTH_LONG
).let { toast ->
webView.postDelayed({
clearCookies()
loadLoginPage()
}, toast.duration.toLong())
toast.show()
}
}
}
}
@Suppress("MemberVisibilityCanBePrivate")
protected fun showError(exception: Throwable) {
makeText(
context,
context.getString(
sf__generic_error,
exception.toString()
),
LENGTH_LONG
).show()
}
/**
* Reloads the authorization page in the web view. Also, updates the window
* title so it's easier to identify the login system.
*/
fun loadLoginPage() = loginOptions.let { loginOptions ->
when {
isEmpty(loginOptions.jwt) -> {
loginOptions.loginUrl = this@OAuthWebviewHelper.loginUrl
doLoadPage()
}
else -> CoroutineScope(IO).launch {
SwapJWTForAccessTokenTask().execute(loginOptions)
}
}
}
private fun doLoadPage() {
val instance = SalesforceSDKManager.getInstance()
runCatching {
var uri = getAuthorizationUrl(
useWebServerAuthentication = instance.isBrowserLoginEnabled || instance.useWebServerAuthentication,
useHybridAuthentication = instance.shouldUseHybridAuthentication()
)
callback.loadingLoginPage(loginOptions.loginUrl)
when {
instance.isBrowserLoginEnabled -> {
if (!instance.isShareBrowserSessionEnabled) {
uri = URI("$uri$PROMPT_LOGIN")
}
loadLoginPageInCustomTab(uri)
}
else -> webView?.loadUrl(uri.toString())
}
}.onFailure { throwable ->
showError(throwable)
}
}
private fun loadLoginPageInCustomTab(uri: URI) {
val activity = activity ?: return
val customTabsIntent = CustomTabsIntent.Builder().apply {
/*
* Set a custom animation to slide in and out for Chrome custom tab
* so it doesn't look like a swizzle out of the app and back in
*/
activity.let { activity ->
setStartAnimations(
activity,
slide_in_left,
slide_out_right
)
setExitAnimations(
activity,
slide_in_left,
slide_out_right
)
}
// Replace the default 'Close Tab' button with a custom back arrow instead of 'x'
setCloseButtonIcon(
decodeResource(
activity.resources,
sf__action_back
)
)
setToolbarColor(context.getColor(sf__primary_color))
// Add a menu item to change the server
addMenuItem(
activity.getString(sf__pick_server),
getActivity(
activity,
PICK_SERVER_REQUEST_CODE,
Intent(activity, ServerPickerActivity::class.java),
FLAG_CANCEL_CURRENT or FLAG_IMMUTABLE
)
)
}.build()
/*
* Set the package explicitly to the browser configured by the
* application if any.
* NB: The default browser on the device is used:
* - If getCustomTabBrowser() returns null
* - Or if the specified browser is not installed
*/
val customTabBrowser = SalesforceSDKManager.getInstance().customTabBrowser
if (doesBrowserExist(customTabBrowser)) {
customTabsIntent.intent.setPackage(customTabBrowser)
}
runCatching {
customTabsIntent.launchUrl(
activity,
parse(uri.toString())
)
}.onFailure { throwable ->
e(TAG, "Unable to launch Advanced Authentication, Chrome browser not installed.", throwable)
makeText(context, "To log in, install Chrome.", LENGTH_LONG).show()
callback.finish(null)
}
}
private fun doesBrowserExist(customTabBrowser: String?) =
when (customTabBrowser) {
null -> false
else -> runCatching {
activity?.packageManager?.getApplicationInfo(customTabBrowser, 0) != null
}.onFailure { throwable ->
w(TAG, "$customTabBrowser does not exist on this device", throwable)
}.getOrDefault(false)
}
@Suppress("MemberVisibilityCanBePrivate")
protected val oAuthClientId: String
get() = loginOptions.oauthClientId
@Suppress("MemberVisibilityCanBePrivate")
protected fun getAuthorizationUrl(
useWebServerAuthentication: Boolean,
useHybridAuthentication: Boolean
): URI {
val loginOptions = loginOptions
val oAuthClientId = oAuthClientId
val authorizationDisplayType = authorizationDisplayType
val jwtFlow = !isEmpty(loginOptions.jwt)
val additionalParams = when {
jwtFlow -> null
else -> loginOptions.additionalParameters
}
// NB code verifier / code challenge are only used when useWebServerAuthentication is true
val codeVerifier = getRandom128ByteKey().also { codeVerifier = it }
val codeChallenge = getSHA256Hash(codeVerifier)
val authorizationUrl = getAuthorizationUrl(
useWebServerAuthentication,
useHybridAuthentication,
URI(loginOptions.loginUrl),
oAuthClientId,
loginOptions.oauthCallbackUrl,
loginOptions.oauthScopes,
authorizationDisplayType,
codeChallenge,
additionalParams
)
return when {
jwtFlow -> getFrontdoorUrl(
authorizationUrl,
loginOptions.jwt,
loginOptions.loginUrl,
loginOptions.additionalParameters
)
else -> authorizationUrl
}
}
/**
* Override to replace the default login web view's display parameter with a
* custom display parameter. Override by either subclassing this class or
* adding
* "<string name="sf__oauth_display_type">desiredDisplayParam</string>"
* to the app's resources so it overrides the default value in the
* Salesforce Mobile SDK library.
*
* See the OAuth docs for the complete list of valid values
*/
@Suppress("MemberVisibilityCanBePrivate")
protected val authorizationDisplayType
get() = context.getString(oauth_display_type)
/** Override to customize the login url */
protected val loginUrl: String?
get() = SalesforceSDKManager.getInstance().loginServerManager.selectedLoginServer?.url?.run {
trim { it <= ' ' }
}
/**
* A web view client which intercepts the redirect to the OAuth callback
* URL. That redirect marks the end of the user facing portion of the
* authentication flow.
*/
protected inner class AuthWebViewClient : WebViewClient() {
override fun onPageFinished(
view: WebView,
url: String
) {
// Hide spinner / show web view
val parentView = view.parent as? RelativeLayout
parentView?.run {
findViewById<ProgressBar>(
sf__loading_spinner
)?.visibility = INVISIBLE
}
view.visibility = VISIBLE
// Remove the native login buttons (biometric, IDP) once on the allow/deny screen
if (url.contains("frontdoor.jsp")) {
parentView?.run {
findViewById<Button>(
sf__idp_login_button
)?.visibility = INVISIBLE
findViewById<Button>(
sf__bio_login_button
)?.visibility = INVISIBLE
}
}
EventsObservable.get().notifyEvent(AuthWebViewPageFinished, url)
super.onPageFinished(view, url)
}
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val activity = activity
val instance = SalesforceSDKManager.getInstance()
val loginOptions = loginOptions
val loginUrl = loginUrl
// The login web view's embedded button has sent the signal to show the biometric prompt
if (request.url.toString() == BIOMETRIC_PROMPT) {
instance.biometricAuthenticationManager?.run {
if (hasBiometricOptedIn() && hasBiometricOptedIn()) {
(activity as? LoginActivity)?.presentBiometric()
}
}
return true
}
// Check if user entered a custom domain
val host = request.url.host
val customDomainPattern = instance.customDomainInferencePattern
if (host != null && loginUrl?.contains(host) != true && customDomainPattern != null && customDomainPattern.matcher(request.url.toString()).find()) {
runCatching {
val baseUrl = "https://${request.url.host}"
val serverManager = instance.loginServerManager
// Check if the URL is already in the server list
when (val loginServer = serverManager.getLoginServerFromURL(baseUrl)) {
null ->
// Add also sets as selected
serverManager.addCustomLoginServer("Custom Domain", baseUrl)
else ->
serverManager.selectedLoginServer = loginServer
}
// Set title to the new login URL
loginOptions.loginUrl = baseUrl
// Check the configuration for the selected login server
instance.fetchAuthenticationConfiguration {
onAuthConfigFetched()
}
}.onFailure { throwable ->
e(TAG, "Unable to retrieve auth config.", throwable)
}
}
val formattedUrl = request.url.toString().replace("///", "/").lowercase()
val callbackUrl = loginOptions.oauthCallbackUrl.replace("///", "/").lowercase()
val authFlowFinished = formattedUrl.startsWith(callbackUrl)
if (authFlowFinished) {
val params = UriFragmentParser.parse(request.url)
val error = params["error"]
// Did we fail?
when {
error != null -> onAuthFlowError(
error,
params["error_description"],
null
)
else -> when {
instance.useWebServerAuthentication -> onWebServerFlowComplete(params["code"])
else -> onAuthFlowComplete(TokenEndpointResponse(params))
}
}
}
return authFlowFinished
}
override fun onReceivedSslError(
view: WebView,
handler: SslErrorHandler,
error: SslError
) {
val primErrorStringId = when (error.primaryError) {
SSL_EXPIRED -> sf__ssl_expired
SSL_IDMISMATCH -> sf__ssl_id_mismatch
SSL_NOTYETVALID -> sf__ssl_not_yet_valid
SSL_UNTRUSTED -> sf__ssl_untrusted
else -> sf__ssl_unknown_error
}
// Build the text message
val text = context.getString(
sf__ssl_error,
context.getString(primErrorStringId)
)
e(TAG, "Received SSL error for server: $text")
// Show the toast
makeText(context, text, LENGTH_LONG).show()
handler.cancel()
}
override fun onReceivedClientCertRequest(
view: WebView,
request: ClientCertRequest
) {
d(TAG, "Received client certificate request from server")
request.proceed(key, certChain)
}
@Suppress("MemberVisibilityCanBePrivate")
fun onAuthConfigFetched() {
if (SalesforceSDKManager.getInstance().isBrowserLoginEnabled) {
// This load will trigger advanced auth and do all necessary setup
doLoadPage()
}
}
}
/**
* Called when the user facing part of the authentication flow completed
* successfully. The last step is to call the identity service to get the
* username.
*/
fun onAuthFlowComplete(tr: TokenEndpointResponse?, nativeLogin: Boolean = false) {
d(TAG, "token response -> $tr")
CoroutineScope(IO).launch {
FinishAuthTask().execute(tr, nativeLogin)
}
}
fun onWebServerFlowComplete(code: String?) =
CoroutineScope(IO).launch {
doCodeExchangeEndpoint(code)
}
private suspend fun doCodeExchangeEndpoint(
code: String?
) = withContext(IO) {
var tokenResponse: TokenEndpointResponse? = null
runCatching {
tokenResponse = exchangeCode(
DEFAULT,
create(loginOptions.loginUrl),
loginOptions.oauthClientId,
code,
codeVerifier,
loginOptions.oauthCallbackUrl
)
}.onFailure { throwable ->
e(TAG, "Exception occurred while making token request", throwable)
onAuthFlowError("Token Request Error", throwable.message, throwable)
}
onAuthFlowComplete(tokenResponse)
}
private inner class SwapJWTForAccessTokenTask : BaseFinishAuthFlowTask<LoginOptions?>() {
override fun doInBackground(
request: LoginOptions?
) = performRequest(loginOptions)
override fun performRequest(
param: LoginOptions?
) = runCatching {
swapJWTForTokens(
DEFAULT,
URI(param?.loginUrl),
param?.jwt
)
}.onFailure { throwable ->
backgroundException = throwable
}.getOrNull()
override fun onPostExecute(tr: TokenEndpointResponse?, nativeLogin: Boolean) {
if (backgroundException != null) {
handleJWTError()
loginOptions.jwt = null
return
}
when {
tr?.authToken != null -> {
loginOptions.jwt = tr.authToken
doLoadPage()
}
else -> {
doLoadPage()
handleJWTError()
}
}
loginOptions.jwt = null
}
private fun handleJWTError() =
onAuthFlowError(
context.getString(sf__generic_authentication_error_title),
context.getString(sf__jwt_authentication_error),
backgroundException
)
}
/**
* A abstract class for objects that, provided an authentication response,
* can finish the authentication flow, publish progress and handle errors.
*
* By overriding the background work and post-work methods, it is possible
* to use this class to model other types of background work.
*
* The parameter type generic parameter models the type provided by the
* authentication flow, such as a token endpoint response. It can also
* model generic types for implementations that handle tasks other than
* authentication. The actual type is provided by the implementation.
*
* This was once Android's now deprecated async task. It has been severed
* from inheriting from deprecated classes. The implementation now uses
* Kotlin Coroutines for concurrency needs. The overall class and method
* structure remains very similar to that provided by earlier versions, for
* compatibility and ease of adoption.
*/
protected abstract inner class BaseFinishAuthFlowTask<Parameter> {
/**
* Finishes the authentication flow.
* @param request The authentication response
*/
internal suspend fun execute(request: Parameter?, nativeLogin: Boolean = false) = withContext(IO) {
onPostExecute(doInBackground(request), nativeLogin)
}
/** The exception that occurred during background work, if applicable */
protected var backgroundException: Throwable? = null
protected var id: IdServiceResponse? = null
/**
* Indicates if authentication is blocked for the current user due to
* the block Salesforce integration user option.
*/
protected var shouldBlockSalesforceIntegrationUser = false
open fun doInBackground(request: Parameter?) =
runCatching {
publishProgress(true)
performRequest(request)
}.onFailure { throwable ->
handleException(throwable)
}.getOrNull()
@Suppress("unused")
@Throws(Exception::class)
protected abstract fun performRequest(
param: Parameter?
): TokenEndpointResponse?
open fun onPostExecute(tr: TokenEndpointResponse?, nativeLogin: Boolean) {
val instance = SalesforceSDKManager.getInstance()
// Failure cases
if (shouldBlockSalesforceIntegrationUser) {
/*
* Salesforce integration users are prohibited from successfully
* completing authentication. This alleviates the Restricted
* Product Approval requirement on Salesforce Integration add-on
* SKUs and conforms to Legal and Product Strategy requirements
*/
w(TAG, "Salesforce integration users are prohibited from successfully authenticating.")
onAuthFlowError( // Issue the generic authentication error
context.getString(sf__generic_authentication_error_title),
context.getString(sf__generic_authentication_error), backgroundException
)
callback.finish(null)
return
}
if (backgroundException != null) {
w(TAG, "Exception thrown while retrieving token response", backgroundException)
onAuthFlowError(
context.getString(sf__generic_authentication_error_title),
context.getString(sf__generic_authentication_error),
backgroundException
)
callback.finish(null)
return
}
id?.let { id ->
val mustBeManagedApp = id.customPermissions?.optBoolean(MUST_BE_MANAGED_APP_PERM)
if (mustBeManagedApp == true && !getRuntimeConfig(context).isManagedApp) {
onAuthFlowError(
context.getString(sf__generic_authentication_error_title),
context.getString(sf__managed_app_error), backgroundException
)
callback.finish(null)
return
}
}
// Put together all the information needed to create the new account
accountOptions = AccountOptions(
id?.username,
tr?.refreshToken,
tr?.authToken,
tr?.idUrl,
tr?.instanceUrl,
tr?.orgId,
tr?.userId,
tr?.communityId,
tr?.communityUrl,
id?.firstName,
id?.lastName,
id?.displayName,
id?.email,
id?.pictureUrl,
id?.thumbnailUrl,
tr?.additionalOauthValues ?: mapOf(),
tr?.lightningDomain,
tr?.lightningSid,
tr?.vfDomain,
tr?.vfSid,
tr?.contentDomain,
tr?.contentSid,
tr?.csrfToken,
nativeLogin,
id?.language,
id?.locale,
)
// Set additional administrator prefs if they exist
val account = UserAccountBuilder.getInstance()
.authToken(accountOptions?.authToken)
.refreshToken(accountOptions?.refreshToken)
.loginServer(loginOptions.loginUrl)
.idUrl(accountOptions?.identityUrl)
.instanceServer(accountOptions?.instanceUrl)
.orgId(accountOptions?.orgId)
.userId(accountOptions?.userId)
.username(accountOptions?.username)
.accountName(
buildAccountName(
accountOptions?.username,
accountOptions?.instanceUrl
)
).communityId(accountOptions?.communityId)
.communityUrl(accountOptions?.communityUrl)
.firstName(accountOptions?.firstName)
.lastName(accountOptions?.lastName)
.displayName(accountOptions?.displayName)
.email(accountOptions?.email)
.photoUrl(accountOptions?.photoUrl)
.thumbnailUrl(accountOptions?.thumbnailUrl)
.lightningDomain(accountOptions?.lightningDomain)
.lightningSid(accountOptions?.lightningSid)
.vfDomain(accountOptions?.vfDomain)
.vfSid(accountOptions?.vfSid)
.contentDomain(accountOptions?.contentDomain)
.contentSid(accountOptions?.contentSid)
.csrfToken(accountOptions?.csrfToken)
.additionalOauthValues(accountOptions?.additionalOauthValues)
.nativeLogin(accountOptions?.nativeLogin)
.language(accountOptions?.language)
.locale(accountOptions?.locale)
.build()
account.downloadProfilePhoto()
id?.customAttributes?.let { customAttributes ->
instance.adminSettingsManager?.setPrefs(customAttributes, account)
}
id?.customPermissions?.let { customPermissions ->
instance.adminPermsManager?.setPrefs(customPermissions, account)
}
instance.userAccountManager.authenticatedUsers?.let { existingUsers ->
// Check if the user already exists
if (existingUsers.contains(account)) {
val duplicateUserAccount = existingUsers.removeAt(existingUsers.indexOf(account))
clearCaches()
UserAccountManager.getInstance().clearCachedCurrentUser()
// Revoke existing refresh token
if (account.refreshToken != duplicateUserAccount.refreshToken) {
runCatching {
URI(duplicateUserAccount.instanceServer)
}.onFailure { throwable ->
w(TAG, "Revoking token failed", throwable)
}.onSuccess { uri ->
// The user authenticated via webview again, unlock the app.
if (isBiometricAuthenticationEnabled(duplicateUserAccount)) {
(SalesforceSDKManager.getInstance().biometricAuthenticationManager
as? BiometricAuthenticationManager)?.onUnlock()
}
CoroutineScope(IO).launch {
revokeRefreshToken(
DEFAULT,
uri,
duplicateUserAccount.refreshToken
)
}
}
}
}
// If this account has biometric authentication enabled remove any others that also have it