Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions docs/dev-info-screen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# Developer Info Screen

The Salesforce Mobile SDK ships a built-in developer / diagnostic screen accessible from the login UI overflow menu. It surfaces SDK configuration, the current user's OAuth state, and runtime configuration so developers can verify SDK behaviour without needing a debugger.

---

## Accessing the Screen

The screen is launched via `DevInfoActivity`. It appears in the overflow menu of `LoginActivity` and `SalesforceActivity` under the label **"Developer Support"** (`sf__dev_support_title_menu_item`).

---

## Architecture

### Data model — `DevSupportInfo`

`DevSupportInfo` (`com.salesforce.androidsdk.developer.support`) is a data class that carries all content for the screen. It is built by `SalesforceSDKManager.devSupportInfo` and consumed by `DevInfoActivity`.

```
typealias DevInfoList = List<Pair<String, String>>
typealias DevInfoSection = Pair<String, DevInfoList>
```

| Field | Type | UI treatment |
|---|---|---|
| `basicInfo` | `DevInfoList?` | Non-collapsible rows at the top |
| `authConfigSection` | `DevInfoSection?` | Collapsible card |
| `bootConfigSection` | `DevInfoSection?` | Collapsible card |
| `currentUserSection` | `DevInfoSection?` | Collapsible card; absent when no user is logged in |
| `runtimeConfigSection` | `DevInfoSection?` | Collapsible card |
| `additionalSections` | `MutableList<DevInfoSection>` | Extra collapsible cards; for SDK extensions or app overrides |

### UI — `DevInfoActivity` / `DevInfoScreen`

`DevInfoActivity` is a Jetpack Compose `ComponentActivity`. It reads `SalesforceSDKManager.getInstance().devSupportInfo` once in `onCreate()` and renders:

- `basicInfo` rows directly (no card)
- Each section as a `CollapsibleSection` composable (card with expand/collapse chevron)
- Each row as a `DevInfoItem` — label + value, tapping the value copies it to the clipboard

### Building the data — `SalesforceSDKManager.devSupportInfo`

`SalesforceSDKManager` exposes an `open val devSupportInfo: DevSupportInfo`. The default implementation currently delegates to `createFromLegacyDevInfos(devSupportInfos)` for backward compatibility; the commented-out block (lines 1612–1640) shows the target direct implementation for SDK 14.0 when `devSupportInfos` is removed.

Subclasses can override `devSupportInfo` to add or replace sections.

---

## Sections and Rows

### Basic Info (non-collapsible)

| Row | Source |
|-----|--------|
| SDK Version | `SalesforceSDKManager.SDK_VERSION` |
| App Type | `SalesforceSDKManager.appType` |
| User Agent | `SalesforceSDKManager.userAgent` |
| Authenticated Users | `UserAccountManager.authenticatedUsers` |

### Authentication Configuration

| Row | Source |
|-----|--------|
| Use Web Server Authentication | `SalesforceSDKManager.useWebServerAuthentication` |
| Use Hybrid Authentication Token | `SalesforceSDKManager.useHybridAuthentication` |
| Force Advanced Authentication | `SalesforceSDKManager._forceAdvancedAuthentication` |
| Browser Login Enabled | `SalesforceSDKManager.isBrowserLoginEnabled` |
| IDP Enabled | `SalesforceSDKManager.isIDPLoginFlowEnabled` |
| Identity Provider | `SalesforceSDKManager.isIdentityProvider` |

### Boot Configuration

Populated by `DevSupportInfo.parseBootConfigInfo(BootConfig)`.

| Row | Source |
|-----|--------|
| Consumer Key | `BootConfig.remoteAccessConsumerKey` |
| Redirect URI | `BootConfig.oauthRedirectURI` |
| Scopes | `BootConfig.oauthScopes` joined by space |
| Local *(Hybrid only)* | `BootConfig.isLocal` |
| Start Page *(Hybrid only)* | `BootConfig.startPage` |
| Unauthenticated Start Page *(Hybrid only)* | `BootConfig.unauthenticatedStartPage` |
| Error Page *(Hybrid only)* | `BootConfig.errorPage` |
| Should Authenticate *(Hybrid only)* | `BootConfig.shouldAuthenticate()` |
| Attempt Offline Load *(Hybrid only)* | `BootConfig.attemptOfflineLoad()` |

### Current User

Populated by `DevSupportInfo.parseUserInfoSection(UserAccount?)`. Absent when no user is logged in.

| Row | Source |
|-----|--------|
| Username | `UserAccount.username` |
| Consumer Key | `UserAccount.clientId` |
| Scopes | `UserAccount.scope` |
| Instance URL | `UserAccount.instanceServer` |
| Token Format | `UserAccount.tokenFormat` (blank → "Opaque") |
| Access Token Expiration | Decoded from JWT when `tokenFormat == "jwt"`, else "Unknown" |
| Beacon Child Consumer Key | `UserAccount.beaconChildConsumerKey` (null → "None") |
| OAuth Token Type | `UserAccount.tokenType` ("Bearer" or "DPoP") |
| DPoP Nonce *(DPoP sessions only)* | `DPoPNonceCache.get(credentialsIdentifier, host)` |
| DPoP Key Thumbprint *(DPoP sessions only)* | JWK SHA-256 thumbprint of the EC P-256 public key from `DPoPKeyManager` |

### Runtime Configuration

Populated by `DevSupportInfo.parseRuntimeConfig(RuntimeConfig)`.

| Row | Source |
|-----|--------|
| Managed App | `RuntimeConfig.isManagedApp` |
| OAuth ID *(managed only)* | `RuntimeConfig.ManagedAppOAuthID` |
| Callback URL *(managed only)* | `RuntimeConfig.ManagedAppCallbackURL` |
| Require Cert Auth *(managed only)* | `RuntimeConfig.RequireCertAuth` |
| Only Show Authorized Hosts *(managed only)* | `RuntimeConfig.OnlyShowAuthorizedHosts` |

---

## Adding New Rows

### To an existing section

Modify the relevant companion-object parse function in `DevSupportInfo.kt`:

- `parseBootConfigInfo(BootConfig)` — Boot Configuration section
- `parseUserInfoSection(UserAccount?)` — Current User section
- `parseRuntimeConfig(RuntimeConfig)` — Runtime Configuration section

Example — adding a row to the Current User section:

```kotlin
fun parseUserInfoSection(currentUser: UserAccount?): DevInfoSection? {
if (currentUser == null) return null
// ... existing rows ...
val rows = mutableListOf(
"Username" to currentUser.username,
// existing rows
"My New Field" to someValue,
)
return "Current User" to rows
}
```

### As a new section

Use `additionalSections` on the returned `DevSupportInfo`, either by overriding `devSupportInfo` in a `SalesforceSDKManager` subclass or by calling `devSupportInfo.additionalSections.add(...)` before the screen is shown:

```kotlin
override val devSupportInfo: DevSupportInfo
get() = super.devSupportInfo.also { info ->
info.additionalSections.add(
"My Section" to listOf(
"Key" to "value",
)
)
}
```

---

## Key Thumbprint (DPoP)

For DPoP sessions, the EC P-256 public key thumbprint (RFC 7638 JWK SHA-256) identifies which key the server has bound to the user's tokens. It is computed from the JWK `{"crv":"P-256","kty":"EC","x":"...","y":"..."}` canonical form:

```kotlin
fun jwkThumbprint(publicKey: ECPublicKey): String {
val point = publicKey.w
val x = base64url(toUnsigned32(point.affineX.toByteArray()))
val y = base64url(toUnsigned32(point.affineY.toByteArray()))
// RFC 7638: members sorted lexicographically, no whitespace
val canonical = """{"crv":"P-256","kty":"EC","x":"$x","y":"$y"}"""
val digest = MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8))
return base64url(digest)
}
```

The thumbprint displayed in the dev info screen should match the `jkt` claim the server embedded in the issued access token.

---

## Related Files

| File | Purpose |
|------|---------|
| `libs/SalesforceSDK/.../developer/support/DevSupportInfo.kt` | Data model; parse functions for each section |
| `libs/SalesforceSDK/.../ui/DevInfoActivity.kt` | Compose UI; renders `DevSupportInfo` |
| `libs/SalesforceSDK/.../app/SalesforceSDKManager.kt` | Exposes `devSupportInfo`; builds the data |
| `libs/SalesforceSDK/.../auth/dpop/DPoPKeyManager.kt` | EC key pair generation/load; `aliasForCredentialsIdentifier()` |
| `libs/SalesforceSDK/.../auth/dpop/DPoPProofBuilder.kt` | JWK construction (`buildJwk()`); used for thumbprint computation |
| `libs/SalesforceSDK/.../auth/dpop/DPoPNonceCache.kt` | In-memory nonce cache; `get(credentialsId, host)` |
| `libs/test/.../developer/support/DevSupportInfoTest.kt` | Unit tests for `DevSupportInfo` parse functions |
| `libs/test/.../ui/DevInfoActivityTest.kt` | UI tests for `DevInfoActivity` |
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ import java.security.interfaces.ECPublicKey

object DPoPProofBuilder {

fun jwkThumbprint(publicKey: ECPublicKey): String {
val point = publicKey.w
val x = base64url(toUnsigned32(point.affineX.toByteArray()))
val y = base64url(toUnsigned32(point.affineY.toByteArray()))
// RFC 7638: members sorted lexicographically, no whitespace
val canonical = """{"crv":"P-256","kty":"EC","x":"$x","y":"$y"}"""
val digest = MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8))
return base64url(digest)
}

fun buildProof(
httpMethod: String,
htu: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ package com.salesforce.androidsdk.developer.support
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.auth.JwtAccessToken
import com.salesforce.androidsdk.auth.dpop.DPoPKeyManager
import com.salesforce.androidsdk.auth.dpop.DPoPNonceCache
import com.salesforce.androidsdk.auth.dpop.DPoPProofBuilder
import com.salesforce.androidsdk.config.BootConfig
import com.salesforce.androidsdk.config.RuntimeConfig
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.security.interfaces.ECPublicKey
import java.text.SimpleDateFormat
import java.util.Locale

Expand Down Expand Up @@ -95,6 +100,9 @@ data class DevSupportInfo(
"Token Format",
"Access Token Expiration",
"Beacon Child Consumer Key",
"OAuth Token Type",
"DPoP Nonce",
"DPoP Key Thumbprint",
)
val runtimeConfigSection = legacyDevInfo.createSection(
sectionTitle = "Runtime Configuration",
Expand Down Expand Up @@ -152,15 +160,32 @@ data class DevSupportInfo(
}
}

return "Current User" to listOf(
val rows = mutableListOf(
"Username" to currentUser.username,
"Consumer Key" to currentUser.clientId,
"Scopes" to currentUser.scope,
"Instance URL" to currentUser.instanceServer,
"Token Format" to (currentUser.tokenFormat?.ifBlank { "Opaque" } ?: "Opaque"),
"Access Token Expiration" to accessTokenExpiration,
"Beacon Child Consumer Key" to (currentUser.beaconChildConsumerKey ?: "None"),
"OAuth Token Type" to (currentUser.tokenType?.ifBlank { "Bearer" } ?: "Bearer"),
)
if (currentUser.tokenType == "DPoP") {
val credId = currentUser.credentialsIdentifier
val host = currentUser.instanceServer
?.toHttpUrlOrNull()?.host ?: ""
val nonce = credId?.let { DPoPNonceCache.get(it, host) }
rows.add("DPoP Nonce" to (nonce?.ifEmpty { "None" } ?: "None"))
val thumbprint = credId?.let {
runCatching {
val alias = DPoPKeyManager.aliasForCredentialsIdentifier(it)
val keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias)
DPoPProofBuilder.jwkThumbprint(keyPair.public as ECPublicKey)
}.getOrElse { "Unavailable" }
} ?: "Unavailable"
rows.add("DPoP Key Thumbprint" to thumbprint)
}
return "Current User" to rows
}

fun parseRuntimeConfig(config: RuntimeConfig): DevInfoList {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ package com.salesforce.androidsdk.developer.support
import android.os.Bundle
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.salesforce.androidsdk.accounts.UserAccount
import com.salesforce.androidsdk.auth.dpop.DPoPProofBuilder
import com.salesforce.androidsdk.config.BootConfig
import com.salesforce.androidsdk.config.RuntimeConfig
import io.mockk.every
Expand Down Expand Up @@ -655,6 +656,7 @@ class DevSupportInfoTest {
"Token Format", "oauth2",
"Access Token Expiration", "Unknown",
"Beacon Child Consumer Key", user.beaconChildConsumerKey ?: "None",
"OAuth Token Type", "Bearer",
))

// Add runtime config
Expand All @@ -672,6 +674,54 @@ class DevSupportInfoTest {
assertEquals(fromSecondaryConstructor, fromLegacy)
}

@Test
fun currentUserSection_BearerSession_ShowsTokenTypeNoDPoPRows() {
val user = createMockUserAccount(tokenType = "Bearer")
val section = DevSupportInfo.parseUserInfoSection(user)!!.second

assertEquals("Bearer", section.find { it.first == "OAuth Token Type" }?.second)
assertFalse(section.any { it.first == "DPoP Nonce" })
assertFalse(section.any { it.first == "DPoP Key Thumbprint" })
}

@Test
fun currentUserSection_NullTokenType_ShowsBearerNoDPoPRows() {
val user = createMockUserAccount(tokenType = null)
val section = DevSupportInfo.parseUserInfoSection(user)!!.second

assertEquals("Bearer", section.find { it.first == "OAuth Token Type" }?.second)
assertFalse(section.any { it.first == "DPoP Nonce" })
assertFalse(section.any { it.first == "DPoP Key Thumbprint" })
}

@Test
fun currentUserSection_DPoPSession_ShowsDPoPRows() {
// credentialsIdentifier is null here so thumbprint/nonce fall back to "Unavailable"/"None"
val user = createMockUserAccount(tokenType = "DPoP", credentialsIdentifier = null)
val section = DevSupportInfo.parseUserInfoSection(user)!!.second

assertEquals("DPoP", section.find { it.first == "OAuth Token Type" }?.second)
assertTrue(section.any { it.first == "DPoP Nonce" })
assertTrue(section.any { it.first == "DPoP Key Thumbprint" })
assertEquals("None", section.find { it.first == "DPoP Nonce" }?.second)
assertEquals("Unavailable", section.find { it.first == "DPoP Key Thumbprint" }?.second)
}

@Test
fun jwkThumbprint_ProducesValidBase64UrlString() {
// Generate a key pair using the standard Java security API (no Android Keystore needed in unit tests)
val keyPairGenerator = java.security.KeyPairGenerator.getInstance("EC")
keyPairGenerator.initialize(java.security.spec.ECGenParameterSpec("secp256r1"))
val keyPair = keyPairGenerator.generateKeyPair()
val publicKey = keyPair.public as java.security.interfaces.ECPublicKey

val thumbprint = DPoPProofBuilder.jwkThumbprint(publicKey)

// base64url of a 32-byte SHA-256 digest = 43 characters (no padding)
assertEquals(43, thumbprint.length)
assertTrue(thumbprint.matches(Regex("[A-Za-z0-9_-]+")))
}

// Helper methods

private fun createMockRuntimeConfig(
Expand All @@ -697,7 +747,9 @@ class DevSupportInfoTest {
scope: String = "api web",
instanceServer: String = "https://test.salesforce.com",
tokenFormat: String = "oauth2",
authToken: String = "test_token"
authToken: String = "test_token",
tokenType: String? = null,
credentialsIdentifier: String? = null,
): UserAccount {
return UserAccount(
Bundle().apply {
Expand Down Expand Up @@ -729,6 +781,8 @@ class DevSupportInfoTest {
putString(UserAccount.LOCALE, "en_US")
putString(UserAccount.SCOPE, scope)
putString(UserAccount.TOKEN_FORMAT, tokenFormat)
tokenType?.let { putString(UserAccount.TOKEN_TYPE, it) }
credentialsIdentifier?.let { putString(UserAccount.CREDENTIALS_IDENTIFIER, it) }
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.salesforce.samples.authflowtester.SCROLL_CONTAINER_CONTENT_DESC
import com.salesforce.samples.authflowtester.USER_AGENT_CONTENT_DESC
import com.salesforce.samples.authflowtester.components.ACCESS_TOKEN
import com.salesforce.samples.authflowtester.components.CLIENT_ID
import com.salesforce.samples.authflowtester.components.DPOP_KEY_THUMBPRINT
import com.salesforce.samples.authflowtester.components.DPOP_NONCE
import com.salesforce.samples.authflowtester.components.REFRESH_TOKEN
import com.salesforce.samples.authflowtester.components.SCOPES
Expand All @@ -82,6 +83,7 @@ data class Tokens(
data class DpopInfo(
val tokenType: String,
val nonce: String,
val keyThumbprint: String,
)

/**
Expand Down Expand Up @@ -277,6 +279,7 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject
return DpopInfo(
tokenType = getText(OAUTH_TOKEN_TYPE),
nonce = getSensitiveValue(DPOP_NONCE),
keyThumbprint = getText(DPOP_KEY_THUMBPRINT),
)
}

Expand Down Expand Up @@ -347,6 +350,9 @@ class AuthFlowTesterPageObject(composeTestRule: ComposeTestRule): BasePageObject
val dpopInfo = getDpopInfo()
assertEquals("DPoP", dpopInfo.tokenType)
assert(dpopInfo.nonce.isNotEmpty()) { "Expected non-empty DPoP nonce after token exchange" }
assert(dpopInfo.keyThumbprint.matches(Regex("[A-Za-z0-9_-]{43}"))) {
"DPoP key thumbprint must be a 43-char base64url string; got: '${dpopInfo.keyThumbprint}'"
}
}
}

Expand Down
Loading
Loading