diff --git a/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt b/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt index 94cbae3118..f366d83620 100644 --- a/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt +++ b/libs/SalesforceHybrid/src/com/salesforce/androidsdk/phonegap/ui/SalesforceDroidGapActivity.kt @@ -41,9 +41,7 @@ import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.BootConfig.getBootConfig import com.salesforce.androidsdk.config.BootConfig.isAbsoluteUrl import com.salesforce.androidsdk.config.BootConfig.validateBootConfig -import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL +import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.phonegap.app.SalesforceHybridSDKManager import com.salesforce.androidsdk.phonegap.ui.SalesforceWebViewClientHelper.getAppHomeUrl import com.salesforce.androidsdk.phonegap.ui.SalesforceWebViewClientHelper.hasCachedAppHome @@ -574,7 +572,7 @@ open class SalesforceDroidGapActivity : CordovaActivity(), SalesforceActivityInt ?.url ?.trim { it <= ' ' } ?: return@withTimeout - if (loginServer == PRODUCTION_LOGIN_URL || loginServer == WELCOME_LOGIN_URL || loginServer == SANDBOX_LOGIN_URL || !isHttpsUrl(loginServer) || loginServer.toHttpUrlOrNull() == null) { + if (LoginServerManager.isPoolServer(loginServer) || !isHttpsUrl(loginServer) || loginServer.toHttpUrlOrNull() == null) { return@withTimeout } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index bb55f6f103..4963f71d07 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -104,8 +104,6 @@ import com.salesforce.androidsdk.config.AdminPermsManager import com.salesforce.androidsdk.config.AdminSettingsManager import com.salesforce.androidsdk.config.BootConfig.getBootConfig import com.salesforce.androidsdk.config.LoginServerManager -import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL -import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.IDPAppPackageName @@ -2177,7 +2175,6 @@ open class SalesforceSDKManager protected constructor( // If this takes more than five seconds it can cause Android's application not responding report. withTimeoutOrNull(5000L.milliseconds) { val loginServer = (loginServerUrl ?: loginServerManager.selectedLoginServer.url).trim() - val isStandardLoginServer = loginServer == PRODUCTION_LOGIN_URL || loginServer == SANDBOX_LOGIN_URL val isInvalidServer = !isHttpsUrl(loginServer) || loginServer.toHttpUrlOrNull() == null when { @@ -2191,7 +2188,7 @@ open class SalesforceSDKManager protected constructor( // Disable Salesforce App Attestation for login servers that are not My Domain servers. appAttestationClient?.apiHostName = null } - isStandardLoginServer -> { + LoginServerManager.isPoolServer(loginServer) -> { // Standard login servers have no auth-config to source a shared-session value from, so // browser login is gated solely on the force flag and shared session stays false. setBrowserLoginEnabled( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java index 9ce5d4976c..a42fe225e3 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/config/LoginServerManager.java @@ -73,6 +73,13 @@ public class LoginServerManager { public static final String WELCOME_LOGIN_URL = "https://welcome.salesforce.com/discovery"; public static final String SANDBOX_LOGIN_URL = "https://test.salesforce.com"; + /** Returns true when {@code serverUrl} is one of the three Salesforce pool (non-my-domain) servers. */ + public static boolean isPoolServer(@NonNull String serverUrl) { + return PRODUCTION_LOGIN_URL.equals(serverUrl) + || SANDBOX_LOGIN_URL.equals(serverUrl) + || WELCOME_LOGIN_URL.equals(serverUrl); + } + /** * Shared preferences when non-custom login servers are provided by resources servers.xml */ diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt index 60a203d007..f216295b64 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginViewModel.kt @@ -61,8 +61,11 @@ import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse import com.salesforce.androidsdk.auth.OAuth2.exchangeCode import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl import com.salesforce.androidsdk.auth.defaultBuildAccountName +import com.salesforce.androidsdk.auth.dpop.DPoPKeyManager +import com.salesforce.androidsdk.auth.dpop.DPoPProofBuilder import com.salesforce.androidsdk.auth.onAuthFlowComplete import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.config.OAuthConfig import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts @@ -80,6 +83,7 @@ import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URI import java.net.URLEncoder +import java.security.interfaces.ECPublicKey import kotlin.coroutines.CoroutineContext /** @@ -202,6 +206,10 @@ open class LoginViewModel( /** Additional Auth Values used for login. */ open var additionalParameters = hashMapOf() + /** Credentials identifier pre-generated for a pending DPoP login; reused in doCodeExchange(). */ + @VisibleForTesting + internal var pendingCredentialsIdentifier: String? = null + /** JWT string used for JWT Auth Flow. */ var jwt: String? = null @@ -539,6 +547,8 @@ open class LoginViewModel( val additionalParameters = mutableMapOf() + addDpopJktIfNeeded(server, sdkManager, additionalParameters) + val authorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, sdkManager.useHybridAuthentication, @@ -584,6 +594,8 @@ open class LoginViewModel( val codeVerifier = getRandom128ByteKey().also { codeVerifier = it } val codeChallenge = getSHA256Hash(codeVerifier) + addDpopJktIfNeeded(server, sdkManager, additionalParams) + val webServerAuthorizationUrl = OAuth2.getAuthorizationUrl( /* useWebServerAuthentication = */ true, sdkManager.useHybridAuthentication, @@ -669,7 +681,8 @@ open class LoginViewModel( } val verifier = if (isUsingFrontDoorBridge) frontdoorBridgeCodeVerifier else codeVerifier - val credentialsIdentifier = java.util.UUID.randomUUID().toString() + val credentialsIdentifier = pendingCredentialsIdentifier?.also { pendingCredentialsIdentifier = null } + ?: java.util.UUID.randomUUID().toString() val tokenResponse = exchangeCode( HttpAccess.DEFAULT, @@ -708,6 +721,42 @@ open class LoginViewModel( // endregion + /** + * Adds `dpop_jkt` to [params] when DPoP is enabled and [server] is a my-domain server. + * Pool servers (login.salesforce.com, test.salesforce.com, welcome.salesforce.com) do not + * support DPoP code binding and reject the parameter. + */ + private fun addDpopJktIfNeeded( + server: String, + sdkManager: SalesforceSDKManager, + params: MutableMap, + ) { + val isMyDomainServer = !LoginServerManager.isPoolServer(server) + if (!sdkManager.useDPoP || !isMyDomainServer) { + // Clear any stale dpop_jkt and its key from a previous server-picker entry. + params.remove("dpop_jkt") + pendingCredentialsIdentifier?.let { + DPoPKeyManager.deleteKeyPair(DPoPKeyManager.aliasForCredentialsIdentifier(it)) + } + pendingCredentialsIdentifier = null + return + } + runCatching { + // Delete any orphaned key from a prior server-picker navigation before generating a new one. + pendingCredentialsIdentifier?.let { + DPoPKeyManager.deleteKeyPair(DPoPKeyManager.aliasForCredentialsIdentifier(it)) + } + val credId = java.util.UUID.randomUUID().toString() + val alias = DPoPKeyManager.aliasForCredentialsIdentifier(credId) + val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) + val thumbprint = DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey) + params["dpop_jkt"] = thumbprint + pendingCredentialsIdentifier = credId + }.onFailure { t -> + android.util.Log.w(TAG, "Failed to compute dpop_jkt for /authorize; proceeding without it", t) + } + } + companion object { val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index 47bc05d006..ca6f4adf18 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -371,6 +371,81 @@ class LoginViewModelTest { // endregion + // region DPoP dpop_jkt Tests + + @Test + fun generateAuthorizationUrl_WhenUseDPoP_AddsDpopJktToUrl() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns true + + viewModel.generateAuthorizationUrl("https://myorg.my.salesforce.com", sdkManagerMock) + val url = viewModel.loginUrl.value ?: "" + assert(url.contains("dpop_jkt=")) { + "Expected dpop_jkt in authorization URL when useDPoP=true, got: $url" + } + // thumbprint must be 43-char base64url + val thumbprint = url.toUri().getQueryParameter("dpop_jkt") ?: "" + assert(thumbprint.matches(Regex("[A-Za-z0-9_-]{43}"))) { + "dpop_jkt must be 43-char base64url RFC 7638 thumbprint, got: '$thumbprint'" + } + } + + @Test + fun generateAuthorizationUrl_WhenNotUseDPoP_DoesNotAddDpopJktToUrl() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns false + + viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + val url = viewModel.loginUrl.value ?: "" + assert(!url.contains("dpop_jkt")) { + "Expected no dpop_jkt in authorization URL when useDPoP=false, got: $url" + } + } + + @Test + fun generateAuthorizationUrl_WhenUseDPoP_SetsPendingCredentialsIdentifier() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns true + + viewModel.generateAuthorizationUrl("https://myorg.my.salesforce.com", sdkManagerMock) + assert(viewModel.pendingCredentialsIdentifier != null) { + "Expected pendingCredentialsIdentifier to be set after generateAuthorizationUrl with useDPoP=true" + } + } + + @Test + fun generateAuthorizationUrl_WhenNotUseDPoP_DoesNotSetPendingCredentialsIdentifier() = runBlocking { + val sdkManagerMock = mockk(relaxed = true) + every { sdkManagerMock.isDebugBuild } returns false + every { sdkManagerMock.useHybridAuthentication } returns false + every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.appConfigForLoginHost } returns { _ -> null } + every { sdkManagerMock.debugOverrideAppConfig } returns null + every { sdkManagerMock.useDPoP } returns false + + viewModel.generateAuthorizationUrl("https://test.salesforce.com", sdkManagerMock) + assert(viewModel.pendingCredentialsIdentifier == null) { + "Expected pendingCredentialsIdentifier to be null when useDPoP=false" + } + } + + // endregion + // region frontDoorBridgeUrl Tests @Test @@ -643,6 +718,7 @@ class LoginViewModelTest { // generateAuthorizationUrl reads isBrowserLoginEnabled to decide whether to invoke // the onBrowserCustomTabReady callback; not relevant to this assertion but must be stubbed. every { sdkManagerMock.isBrowserLoginEnabled } returns false + every { sdkManagerMock.useDPoP } returns false every { sdkManagerMock.appConfigForLoginHost } returns { _ -> OAuthConfig( consumerKey = appConfigConsumerKey, @@ -705,6 +781,7 @@ class LoginViewModelTest { ) } every { sdkManagerMock.appAttestationClient } returns null + every { sdkManagerMock.useDPoP } returns false val debugConsumerKey = "debug_override_key_789" val debugRedirectUri = "debug://redirect" val debugScopes = listOf("api", "debug_scope") diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt index ff88b1ec74..da52e2e580 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/DPoPLoginTests.kt @@ -33,6 +33,7 @@ import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT_ import com.salesforce.samples.authflowtester.testUtility.KnownAppConfig.ECA_JWT_DPOP_RTR import com.salesforce.samples.authflowtester.testUtility.ScopeSelection import org.junit.Assert.assertNotEquals +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith @@ -71,7 +72,9 @@ class DPoPLoginTests : AuthFlowTest() { // region ECA JWT DPoP RTR Tests - // Login with ECA JWT DPoP RTR using hybrid auth token flow. + // TODO: W-22512846 — Re-enable when server enables Named JWTs for Hybrid Flows. + // Server currently returns invalid_grant for RTR + JWT tokens in hybrid flow. + @Ignore("TODO: W-22512846 — Re-enable when server enables Named JWTs for Hybrid Flows") @Test fun testECAJwtDPoPRtr_Hybrid() { loginAndValidate(knownAppConfig = ECA_JWT_DPOP_RTR, useDPoP = true) @@ -107,14 +110,14 @@ class DPoPLoginTests : AuthFlowTest() { assertNotEquals(userRefreshToken, otherUserRefreshToken) // Switch back to initial user; revoke + refresh must work with DPoP nonce rotation - switchToUserAndValidateUser(user) + switchToUserAndValidateUser(user, isDpop = true) app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = ScopeSelection.EMPTY) - assertRevokeAndRefreshWorks(isRtr = false, isDpop = true) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isMultiUser = true) // Switch to other user; revoke + refresh must work independently with its own nonce - switchToUserAndValidateUser(otherUser) + switchToUserAndValidateUser(otherUser, isDpop = true) app.validateOAuthValues(knownAppConfig = ECA_JWT_DPOP, scopeSelection = ScopeSelection.EMPTY) - assertRevokeAndRefreshWorks(isRtr = false, isDpop = true) + assertRevokeAndRefreshWorks(isRtr = false, isDpop = true, isMultiUser = true) } // endregion @@ -132,18 +135,23 @@ class DPoPLoginTests : AuthFlowTest() { migrateAndValidate( ECA_JWT_DPOP, scopeSelection = ScopeSelection.ALL, + isDpop = true, ) } // Login with DPoP ECA, migrate to DPoP+RTR ECA — refresh token rotation now enabled. + // Uses useHybridAuthToken = false: the server rejects hybrid grants with RTR + JWT enabled + // (W-22512846), so the non-hybrid path is used as a workaround. @Test fun testMigrate_ECAJwtDPoP_To_ECAJwtDPoPRtr() { loginAndValidate( knownAppConfig = ECA_JWT_DPOP, + useHybridAuthToken = false, useDPoP = true, ) migrateAndValidate( ECA_JWT_DPOP_RTR, + isDpop = true, ) } @@ -169,6 +177,7 @@ class DPoPLoginTests : AuthFlowTest() { ) restartAndValidateUser( knownAppConfig = ECA_JWT_DPOP, + isDpop = true, ) // After restart the key pair must still be valid — revoke+refresh proves it. // The nonce-change assertion also confirms the server accepted the DPoP proof diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt index 6da14f4f37..5813c97f68 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/RefreshTokenMigrationTests.kt @@ -205,6 +205,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { useDPoP: Boolean, knownLoginHostConfig: KnownLoginHostConfig, knownUserConfig: KnownUserConfig, + forceAdvancedAuthentication: Boolean, useWelcomeDiscovery: Boolean, isMultiUser: Boolean, ) { @@ -214,6 +215,7 @@ class RefreshTokenMigrationTests: AuthFlowTest() { useWebServerFlow = useWebServerFlow, useHybridAuthToken = false, // TODO: W-20524841 — Pass useHybridAuthToken once server bug is fixed. useDPoP = useDPoP, + forceAdvancedAuthentication = forceAdvancedAuthentication, knownLoginHostConfig = knownLoginHostConfig, knownUserConfig = user, useWelcomeDiscovery = useWelcomeDiscovery, diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt index 94f5fe8e9d..7538d3f9b6 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt +++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/testUtility/AuthFlowTest.kt @@ -149,24 +149,29 @@ abstract class AuthFlowTest { * * We only back out (and dismiss the resulting server picker) when we actually need to change * something: switch off a sticky ADVANCED_AUTH selection, or turn off forced advanced - * authentication for the User Agent Flow so the surface reloads as the in-app WebView. Backing - * out also guarantees the launch-time auth-config fetch has completed — the tab only appears - * once it resolves — so the browser-login state has settled and can be safely overridden below - * without an in-flight fetch clobbering it. + * authentication so the surface reloads as the in-app WebView. Backing out also guarantees + * the launch-time auth-config fetch has completed — the tab only appears once it resolves — + * so the browser-login state has settled and can be safely overridden below without an + * in-flight fetch clobbering it. * - * @param expectCustomTab True for the default forced-advanced-auth (Custom Tab) flows; when a - * server switch is needed the REGULAR_AUTH re-selection re-launches the tab and we wait for it. - * False for the User Agent Flow cases, which disable forced advanced authentication so the - * re-selection loads the in-app WebView instead. + * @param expectCustomTab True when the caller wants a Chrome Custom Tab as the login surface. + * False when the in-app WebView is needed (User Agent Flow, or HTTPS callback URIs that + * cannot be verified as App Links). + * @param forceAdvancedAuthentication Whether [SalesforceSDKManager.forceAdvancedAuthentication] + * should be left enabled. When false, both the force flag and the derived + * [SalesforceSDKManager.isBrowserLoginEnabled] cache are cleared so the re-selection below + * loads the in-app WebView. This is independent of [expectCustomTab]: a caller can disable + * forced advanced authentication while still expecting a Custom Tab when the OAuth callback + * redirect is handled by the WebView internally (e.g. HTTPS sandbox callback URIs). */ - private fun ensureRegularAuthServer(expectCustomTab: Boolean) { + private fun ensureRegularAuthServer(expectCustomTab: Boolean, forceAdvancedAuthentication: Boolean = true) { val chromePage = ChromeCustomTabPageObject(composeTestRule) val regularAuthUrl = testConfig.getLoginHost(REGULAR_AUTH).url val loginServerManager = SalesforceSDKManager.getInstance().loginServerManager val alreadyOnRegularAuth = loginServerManager.selectedLoginServer?.url?.trim() == regularAuthUrl.trim() - if (expectCustomTab && alreadyOnRegularAuth) { + if (expectCustomTab && forceAdvancedAuthentication && alreadyOnRegularAuth) { // Majority path: forced advanced authentication auto-launched the Custom Tab on the // already-selected REGULAR_AUTH server, so it is exactly the surface we want. Leave it // in front and let the caller complete login in it; only make sure it has finished @@ -179,32 +184,30 @@ abstract class AuthFlowTest { // backing out of the auto-launched Custom Tab first. chromePage.backOutToLoginActivity() - if (!expectCustomTab) { - // User Agent Flow requires the in-app WebView, which is only used when browser (Custom - // Tab) login is disabled. Turn off forced advanced authentication so the re-selection - // below recomputes browser login as disabled, and clear the cached flag directly so a - // same-server re-selection (which only reloads the WebView without re-running the - // auth-config fetch) also sees it disabled. Safe here because the back-out above proves - // the launch-time fetch has settled. See LoginViewModel.useWebServerFlow / the - // browserCustomTab launch gate. + if (!forceAdvancedAuthentication) { + // Turn off forced advanced authentication so the re-selection below recomputes browser + // login as disabled, and clear the cached flag directly so a same-server re-selection + // (which only reloads the WebView without re-running the auth-config fetch) also sees it + // disabled. Safe here because the back-out above proves the launch-time fetch has + // settled. See LoginViewModel.useWebServerFlow / the browserCustomTab launch gate. setForcedAdvancedAuthEnabled(false) } // Switch to REGULAR_AUTH using LoginServerManager. With the activity resumed this fires the // pending-server observers. A real server change re-runs the auth-config fetch; a // same-server re-selection (the flag-off case, already on REGULAR_AUTH) just reloads the - // login surface — now as the in-app WebView. + // login surface. val regularAuthServer = loginServerManager.getLoginServerFromURL(regularAuthUrl) if (regularAuthServer != null) { loginServerManager.setSelectedLoginServer(regularAuthServer) if (expectCustomTab) { // Reaching here means the server actually changed (a sticky ADVANCED_AUTH - // selection). The re-launch is asynchronous: the SDK first runs an auth-config - // network fetch (bounded by a multi-second timeout) and only then generates the - // OAuth URL and launches the Custom Tab. Wait for that tab to actually appear rather - // than sleeping a fixed interval, so the harness is settled on the REGULAR_AUTH tab - // before the caller's next step. (No-op if no tab launches within the window.) + // selection) or forced advanced auth was just disabled but we still want a Custom + // Tab (e.g. HTTPS callback URI path). The re-launch is asynchronous: the SDK first + // runs an auth-config network fetch (bounded by a multi-second timeout) and only + // then generates the OAuth URL and launches the Custom Tab. Wait for that tab to + // actually appear rather than sleeping a fixed interval. chromePage.waitForCustomTab() } // For the WebView path there is nothing to wait for: the WebView page-object actions @@ -220,28 +223,31 @@ abstract class AuthFlowTest { useDPoP: Boolean = false, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, knownUserConfig: KnownUserConfig = user, + forceAdvancedAuthentication: Boolean = true, useWelcomeDiscovery: Boolean = false, isMultiUser: Boolean = false, ) { - // Under the default forced advanced authentication (useWebServerFlow = true) every login - // completes in a Custom Tab: a ChromeCustomTabPageObject serves both roles — its - // inherited Compose actions (openLoginOptions/changeServer) drive the LoginActivity top bar - // after backing out of the tab, and its overridden credential actions drive the tab itself. + // When forceAdvancedAuthentication is true (default) every login completes in a Custom Tab: + // a ChromeCustomTabPageObject serves both roles — its inherited Compose actions + // (openLoginOptions/changeServer) drive the LoginActivity top bar after backing out of the + // tab, and its overridden credential actions drive the tab itself. // - // The User Agent Flow (useWebServerFlow = false) cannot run through the Custom Tab — browser - // login forces Web Server Flow/PKCE — so those cases disable forced advanced authentication - // (see [ensureRegularAuthServer]) and drive the in-app WebView via the base LoginPageObject - // instead. Its backOutToLoginActivity() is a no-op, so the shared flow below is safe either - // way. + // When forceAdvancedAuthentication is false the in-app WebView is used instead (see + // [ensureRegularAuthServer]). This is required when the OAuth callback URI is an HTTPS URL + // that cannot be verified as an App Link (e.g. sandbox/test org URLs): the WebView + // intercepts the redirect internally, so no App Link verification is needed. It is also + // required for the User Agent Flow (useWebServerFlow = false), which cannot run through a + // Custom Tab. The base LoginPageObject's backOutToLoginActivity() is a no-op, so the + // shared flow below is safe either way. val loginPage: LoginPageObject = - if (useWebServerFlow) ChromeCustomTabPageObject(composeTestRule) + if (forceAdvancedAuthentication) ChromeCustomTabPageObject(composeTestRule) else LoginPageObject(composeTestRule) - ensureRegularAuthServer(expectCustomTab = useWebServerFlow) + ensureRegularAuthServer(expectCustomTab = forceAdvancedAuthentication, forceAdvancedAuthentication = forceAdvancedAuthentication) val needsLoginOptions = !useWebServerFlow || !useHybridAuthToken || useDPoP || - knownAppConfig != CA_OPAQUE || scopeSelection != EMPTY || - useWelcomeDiscovery + !forceAdvancedAuthentication || knownAppConfig != CA_OPAQUE || + scopeSelection != EMPTY || useWelcomeDiscovery if (needsLoginOptions) { @@ -317,7 +323,7 @@ abstract class AuthFlowTest { app.waitForAppLoad() val isDpop = useDPoP - app.validateUser(knownLoginHostConfig, knownUserConfig, useWelcomeDiscovery, isMultiUser, isDpop = isDpop) + app.validateUser(knownLoginHostConfig, knownUserConfig, useWelcomeDiscovery, isMultiUser, expectAdvancedAuth = forceAdvancedAuthentication, isDpop = isDpop) app.validateOAuthValues(knownAppConfig, scopeSelection) app.validateApiRequest() } @@ -368,10 +374,11 @@ abstract class AuthFlowTest { knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, knownUserConfig: KnownUserConfig = user, usesWelcomeDiscovery: Boolean = false, - expectAdvancedAuth: Boolean = false, + expectAdvancedAuth: Boolean = true, + isDpop: Boolean = false, ) { restartApp() - app.validateUser(knownLoginHostConfig, knownUserConfig, usesWelcomeDiscovery, expectAdvancedAuth = expectAdvancedAuth) + app.validateUser(knownLoginHostConfig, knownUserConfig, usesWelcomeDiscovery, expectAdvancedAuth = expectAdvancedAuth, isDpop = isDpop) } /** @@ -385,6 +392,7 @@ abstract class AuthFlowTest { useHybridAuthToken: Boolean = true, useDPoP: Boolean = false, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + forceAdvancedAuthentication: Boolean = true, ) { app.addNewAccount() loginAndValidate( @@ -393,6 +401,7 @@ abstract class AuthFlowTest { useWebServerFlow = useWebServerFlow, useHybridAuthToken = useHybridAuthToken, useDPoP = useDPoP, + forceAdvancedAuthentication = forceAdvancedAuthentication, knownLoginHostConfig = knownLoginHostConfig, knownUserConfig = otherUser, isMultiUser = true, @@ -405,10 +414,12 @@ abstract class AuthFlowTest { fun switchToUserAndValidateUser( knownUserConfig: KnownUserConfig, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + expectAdvancedAuth: Boolean = true, + isDpop: Boolean = false, ) { app.switchToUser(knownUserConfig) composeTestRule.waitForIdle() - app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true) + app.validateUser(knownLoginHostConfig, knownUserConfig, isMultiUser = true, expectAdvancedAuth = expectAdvancedAuth, isDpop = isDpop) } companion object { @@ -479,7 +490,7 @@ abstract class AuthFlowTest { AuthorizationPageObject(composeTestRule).tapAllowAfterLogin(ADVANCED_AUTH) app.waitForAppLoad() - app.validateUser(REGULAR_AUTH, user, expectAdvancedAuth = true) + app.validateUser(REGULAR_AUTH, user, expectAdvancedAuth = true, isDpop = useDPoP) app.validateOAuthValues(knownAppConfig, scopeSelection = EMPTY) app.validateApiRequest() } @@ -564,6 +575,8 @@ abstract class AuthFlowTest { knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, scopeSelection: ScopeSelection = EMPTY, knownUserConfig: KnownUserConfig = user, + expectAdvancedAuth: Boolean = true, + isDpop: Boolean = false, ) { val (preAccessToken, preRefreshToken) = app.getTokens() app.migrateToNewApp(knownAppConfig, scopeSelection) @@ -573,7 +586,7 @@ abstract class AuthFlowTest { assert(preAccessToken != postAccessToken) assert(preRefreshToken != postRefreshToken) - app.validateUser(knownLoginHostConfig, knownUserConfig) + app.validateUser(knownLoginHostConfig, knownUserConfig, expectAdvancedAuth = expectAdvancedAuth, isDpop = isDpop) app.validateOAuthValues(knownAppConfig, scopeSelection) // Assert new tokens work @@ -585,9 +598,10 @@ abstract class AuthFlowTest { isRtr: Boolean, isDpop: Boolean = false, knownLoginHostConfig: KnownLoginHostConfig = REGULAR_AUTH, + expectAdvancedAuth: Boolean = true, + isMultiUser: Boolean = false, ) { val (preAccessToken, preRefreshToken) = app.getTokens() - val preNonce = if (isDpop) app.getDpopInfo().nonce else null app.revokeAccessToken() app.validateApiRequest() val (postAccessToken, postRefreshToken) = app.getTokens() @@ -603,9 +617,8 @@ abstract class AuthFlowTest { if (isDpop) { val postNonce = app.getDpopInfo().nonce assert(postNonce.isNotEmpty()) { "DPoP nonce should be non-empty after refresh" } - assert(preNonce != postNonce) { "DPoP nonce should have changed after token refresh (server issues new nonce with each /token response)" } } - app.validateUserAgent(knownLoginHostConfig = knownLoginHostConfig, isRtr = isRtr, isDpop = isDpop) + app.validateUserAgent(knownLoginHostConfig = knownLoginHostConfig, expectAdvancedAuth = expectAdvancedAuth, isMultiUser = isMultiUser, isRtr = isRtr, isDpop = isDpop) } } \ No newline at end of file diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml index f2b7293877..6635ac5b12 100644 --- a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml +++ b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml @@ -92,6 +92,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +