Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
44ec1bf
W-22695293: Add Android OAuth2 token exchange analysis for DPoP imple…
wmathurin Jun 25, 2026
8afc2b6
W-22695293: Move OAuth2 token exchange analysis to workspace specs
wmathurin Jun 25, 2026
1b1ef00
Update pull_request_target branches in pr.yaml
wmathurin Jun 29, 2026
bf33e21
feat(W-22695293): DPoP proof JWT at token exchange (Phase 2)
wmathurin Jun 29, 2026
aabf1c2
ci: trigger PR workflow
wmathurin Jun 29, 2026
4efd4a6
review: address PR feedback on W-22695293 DPoP implementation
wmathurin Jun 29, 2026
23d451f
review: rename dpopScope to credentialsIdentifier (round 2)
wmathurin Jun 29, 2026
e4ceb62
Merge pull request #2944 from wmathurin/dpop
wmathurin Jun 30, 2026
084b0e9
Merge branch 'dpop' of github.com:forcedotcom/SalesforceMobileSDK-And…
wmathurin Jun 30, 2026
bec4b7e
feat(W-22697744): DPoP proof JWT on API calls (Phase 3)
wmathurin Jun 30, 2026
782e88d
review: address PR feedback on W-22697744 Phase 3
wmathurin Jun 30, 2026
29a1617
W-22697878: DPoP nonce support (Phase 4)
wmathurin Jun 30, 2026
0d595b4
Fix DPoP fields not persisted in AccountManager
wmathurin Jun 30, 2026
eb486f3
Fix DPoP fields not persisted in AccountManager
wmathurin Jun 30, 2026
17bf5ff
Add regression tests for DPoP fields persisted in AccountManager
wmathurin Jul 1, 2026
d26cb65
Add regression tests for DPoP fields persisted in AccountManager
wmathurin Jul 1, 2026
e7af0cc
Fix DPoP nonce collision: key cache by (credentialsIdentifier, host)
wmathurin Jul 1, 2026
f980913
Fix UserAccountTest serialization tests broken by DPoP fields
wmathurin Jul 1, 2026
fb3ed14
Merge branch 'dpop-phase3' into dpop-phase4
wmathurin Jul 1, 2026
d9cd846
Allow dpop as valid base branch in TestOrchestrator Danger check
wmathurin Jul 1, 2026
223fdc3
Fix test failures caused by DPoP phase-3 production changes
wmathurin Jul 1, 2026
0e1d6b8
Fix ClientManagerMockTest: stub isUseDPoP() on SalesforceSDKManager mock
wmathurin Jul 1, 2026
a8fc6ac
Merge pull request #2949 from wmathurin/dpop-phase3
wmathurin Jul 2, 2026
544e26c
Merge branch 'dev' of github.com:wmathurin/SalesforceMobileSDK-Androi…
wmathurin Jul 2, 2026
cb8cd34
Add dpop to pull_request_target branches allowlist
wmathurin Jul 1, 2026
eaa9a08
Allow dpop as valid base branch in TestOrchestrator Danger check
wmathurin Jul 1, 2026
667534d
Fix test failures caused by DPoP phase-3 production changes
wmathurin Jul 1, 2026
26d4e1f
Fix ClientManagerMockTest: stub isUseDPoP() on SalesforceSDKManager mock
wmathurin Jul 1, 2026
ba5170c
@W-19280949: [Android] Stop persisting plaintext credentials in push …
JohnsonEricAtSalesforce Jul 1, 2026
428d5f1
feat(W-23195021): register RT per-user flag on Refresh Token Rotation…
wmathurin Jul 1, 2026
0a64191
feat(W-23195021): add RT flag assertion to validateUserAgent and asse…
wmathurin Jul 1, 2026
f01f495
feat(W-23195021): add else clauses to WD, MU, RT flag assertions in v…
wmathurin Jul 1, 2026
4cd22f7
feat(W-23195021): always call validateUserAgent in assertRevokeAndRef…
wmathurin Jul 1, 2026
eed7896
fix(W-23195021): stub two-param registerUsedAppFeature in ClientManag…
wmathurin Jul 1, 2026
87623d9
Merge upstream/dev; fix two-param registerUsedAppFeature stub
wmathurin Jul 2, 2026
7031b3a
Merge branch 'dpop' of github.com:wmathurin/SalesforceMobileSDK-Andro…
wmathurin Jul 2, 2026
b8e713d
fix: registerUsedAppFeature(any(), any()) mock returns Unit not Boolean
wmathurin Jul 2, 2026
553b539
Mirror iOS: nonce issued by /token only, not by resource server
wmathurin Jul 2, 2026
9358ed2
docs: add token-lifecycle.md covering auth, DPoP, RTR concurrency
wmathurin Jul 2, 2026
70d5fdc
fix(dpop): address review nits in callIdentityService nonce handling
wmathurin Jul 6, 2026
323a17c
Merge branch 'dev' of github.com:forcedotcom/SalesforceMobileSDK-Andr…
wmathurin Jul 7, 2026
a1974c8
Merge pull request #2955 from wmathurin/dpop-phase4
wmathurin Jul 7, 2026
cf229b8
feat(W-22698013): DPoP UI tests — RTR-style pattern with LoginOptions…
wmathurin Jul 7, 2026
0494846
fix(W-22698013): fix exhaustive when and override signature for DPOP_…
wmathurin Jul 7, 2026
b64705d
feat(W-22698013): add DPoP test coverage across RTR, multi-user, and …
wmathurin Jul 8, 2026
3c6bca4
feat(W-22698013): add testECAJwtDPoP_WithRestart to LoginWithRestartT…
wmathurin Jul 8, 2026
0b950ce
Update AuthFlowTester README for DPoP UI tests (W-22698013)
wmathurin Jul 8, 2026
8d84167
Surface DPoP nonce in credentials card; assert tokenType and nonce in…
wmathurin Jul 8, 2026
0db4dd2
Fix README test tables: add 4 missing tests, annotate @Ignore RTR test
wmathurin Jul 8, 2026
5b24828
Convert useDPoP to a Kotlin property for consistency with forceAdvanc…
wmathurin Jul 8, 2026
9713349
Consolidate all DPoP tests into DPoPLoginTests
wmathurin Jul 8, 2026
deec900
Add testLoginForAdmin_DPoP to DPoPLoginTests
wmathurin Jul 8, 2026
edafd26
Fix unit test build: update MockK stubs for useDPoP Kotlin property
wmathurin Jul 8, 2026
793b5ce
feat(W-23195012): register DP per-user feature flag for DPoP sessions
wmathurin Jul 9, 2026
4ed55a9
fix(W-23195012): derive isDpop from useDPoP; add DP flag unit test
wmathurin Jul 9, 2026
1b78eb8
fix(W-22698013): address PR review comments — positional args and use…
wmathurin Jul 10, 2026
533afe7
Merge branch 'dpop-phase5' into dpop-phase6
wmathurin Jul 10, 2026
2e796c5
Merge pull request #2959 from wmathurin/dpop-phase5
wmathurin Jul 10, 2026
7d4c1d9
Merge remote-tracking branch 'upstream/dpop' into dpop-phase6
wmathurin Jul 10, 2026
f8ccdda
feat(W-23192897): surface DPoP token type, nonce, and key thumbprint …
wmathurin Jul 10, 2026
5f983ea
fix(W-23192897): use toHttpUrlOrNull().host instead of deprecated Htt…
wmathurin Jul 10, 2026
4bccf09
fix(W-23192897): add OAuth Token Type to legacy flat list in equality…
wmathurin Jul 10, 2026
1515818
fix(dpop): remove inaccurate comment claiming Android identity servic…
wmathurin Jul 10, 2026
0041a36
fix(dpop): remove dead harvest+retry from callIdentityService — nonce…
wmathurin Jul 10, 2026
8bd223f
Merge branch 'dpop-phase6' into dpop-phase6b
wmathurin Jul 10, 2026
96ffb9d
Merge branch 'dev' of github.com:forcedotcom/SalesforceMobileSDK-Andr…
wmathurin Jul 10, 2026
be1c90d
Merge pull request #2962 from wmathurin/dpop-phase6
wmathurin Jul 10, 2026
d1dfbea
feat(dpop): surface DPoP key thumbprint in AuthFlowTester user creden…
wmathurin Jul 10, 2026
b89a8ba
Merge remote-tracking branch 'upstream/dpop' into dpop-phase6b
wmathurin Jul 10, 2026
9b4bc94
feat(dpop): send dpop_jkt in /authorize for my-domain servers (W-2340…
wmathurin Jul 11, 2026
40c2cf1
fix: revert accidental _forceAdvancedAuthentication=false (W-23406836)
wmathurin Jul 11, 2026
d2cf9b9
Merge pull request #2963 from wmathurin/dpop-phase6b
wmathurin Jul 13, 2026
22b19d3
refactor(dpop): extract maybeAddDpopJkt helper to remove duplication …
wmathurin Jul 13, 2026
f60c90c
refactor(dpop): rename maybeAddDpopJkt → addDpopJktIfNeeded; fix test…
wmathurin Jul 13, 2026
a62f5b3
fix(dpop): clear stale dpop_jkt and delete orphaned keystore key on s…
wmathurin Jul 14, 2026
f9b9d5b
refactor: extract LoginServerManager.isPoolServer() helper (W-23406836)
wmathurin Jul 16, 2026
7d3b035
refactor: add forceAdvancedAuthentication param to loginAndValidate (…
wmathurin Jul 16, 2026
a5ad297
fix(test): fix BW/DP/MU flag assertions and missing intent filters in…
wmathurin Jul 16, 2026
dd46b50
Merge pull request #2965 from wmathurin/dpop-phase7
wmathurin Jul 16, 2026
fb29b86
chore(ci): remove temporary dpop branch from pr.yaml trigger (W-23384…
wmathurin Jul 16, 2026
a9e493b
fix(test): fix MockKException race in NativeLoginManagerTest (W-23384…
wmathurin Jul 17, 2026
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
4 changes: 1 addition & 3 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ on:
# Mitigated by per-job Member Check (see "Check Write Permission" + "Validate Write Permission" steps).
# Reference: team Github Actions Tribal Knowledge doc.
pull_request_target: # zizmor: ignore[dangerous-triggers]
# dpop is a temporary entry: PRs in the multi-PR DPoP rollout target this
# branch. Remove once DPoP is merged back to dev.
branches: [dev, master, dpop]
branches: [dev, master]
paths-ignore:
- '**/*.md'
- 'LICENSE'
Expand Down
323 changes: 323 additions & 0 deletions docs/auth/token-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
# Token Lifecycle: Code Exchange, Refresh, DPoP, and RTR

This document describes how the Android SDK acquires and renews OAuth tokens, how DPoP
proof JWTs are attached, how nonces are managed, and how Refresh Token Rotation (RTR) is
handled safely under concurrency.

The primary classes are:
- **`OAuth2.java`** — all calls to the Salesforce token and identity endpoints
- **`ClientManager.java`** (`AccMgrAuthTokenProvider`) — RTR-safe token refresh coordination
- **`RestClient.java`** (`OAuthRefreshInterceptor`) — per-request auth header attachment and
automatic refresh on 401
- **`DPoPProofBuilder.kt`**, **`DPoPKeyManager.kt`**, **`DPoPURLHelper.kt`**,
**`DPoPNonceCache.kt`** — DPoP proof generation and nonce caching

---

## 1. Code Exchange (initial login)

**Entry point:** `OAuth2.exchangeCode()`

After the user completes the OAuth2 authorization code flow in the login WebView,
`LoginActivity` calls `exchangeCode()` with the authorization `code` and `code_verifier`
(PKCE). This method:

1. Builds a `POST /services/oauth2/token` form body with `grant_type=authorization_code`
(or `hybrid_auth_code` when hybrid authentication is enabled).
2. If `isUseDPoP()` is `true` and a `credentialsIdentifier` was generated at login start:
- Looks up (or generates) the EC keypair via `DPoPKeyManager.generateOrLoadKeyPair()`.
- Proactively reads any cached nonce via `DPoPNonceCache.get(credentialsIdentifier, tokenHost)` — null on first ever login.
- Builds a DPoP proof JWT via `DPoPProofBuilder.buildProof()` and attaches it as the `DPoP` header.
3. Sends the request via `makeTokenEndpointRequest()` (shared with refresh).
4. On success: the response carries an `access_token`, `refresh_token`, and `token_type`
(`"DPoP"` or `"Bearer"`). A `DPoP-Nonce` header may also be present — it is harvested
into the cache immediately.
5. On `use_dpop_nonce` (400/401 with that error body): the nonce was just harvested (step 4);
rebuild the proof with it and retry once. Fail-closed on a second nonce failure.
6. `TokenEndpointResponse` is returned to `LoginActivity`, which persists the tokens into
`AccountManager` and calls `callIdentityService()` to fetch the user's identity record.

**DPoP note:** the `credentialsIdentifier` is a UUID generated at login start and stored on
`UserAccount` as the `dpopScope` AccountManager extra. It is the stable key for the DPoP
keypair and nonce cache for this user's session.

---

## 2. Token Refresh

**Entry point:** `OAuth2.refreshAuthToken()`

When an API call returns 401, `OAuthRefreshInterceptor` calls `refreshAccessToken()` which
delegates to `AccMgrAuthTokenProvider.getNewAuthToken()`. That method:

1. **Matches the account** by scanning `AccountManager` for the one whose stored refresh token
equals this provider's `refreshToken`. Fails early (returns null) if the account has been
removed — this ensures a removed-account path never accidentally sets `refreshing = true`
and deadlocks waiting threads.

2. **Acquires the RTR lock** — see §4 below.

3. **Recheck-under-lock guardrail:** before making a network call, re-reads the current tokens
from `AccountManager`. If either the access token or the refresh token in storage has
already advanced past what this provider last used, a concurrent winner already refreshed —
adopt their result without a redundant network POST. This is a correctness guardrail under
RTR, not an optimisation: every needless POST rotates the refresh token and widens the
stale-token logout window.

4. **`refreshStaleToken()`** → **`OAuth2.refreshAuthToken()`** builds a
`POST /services/oauth2/token` form body with `grant_type=refresh_token`. DPoP proof
and nonce handling here are identical to code exchange: proactive nonce inclusion,
harvest from response, retry-once on `use_dpop_nonce`.

5. On success: broadcasts `ACCESS_TOKEN_REFRESH_INTENT` (or `INSTANCE_URL_UPDATE_INTENT` if
the instance URL changed), publishes the new tokens to the per-account `RefreshState`,
and wakes waiting losers.

6. On terminal failure (`invalid_grant`, `client_blocked`): broadcasts
`ACCESS_TOKEN_REVOKE_INTENT` and calls `SalesforceSDKManager.logout()`.

---

## 3. DPoP Proof Attachment

Every outgoing API call goes through `OAuthRefreshInterceptor.buildAuthenticatedRequest()`,
which calls `attachDPoPProofIfNeeded()`:

```
attachDPoPProofIfNeeded(builder, method, url):
if tokenType != "DPoP" → return (Bearer path, unmodified)
if !isUseDPoP() → return
if credentialsIdentifier == null → return
htu = DPoPURLHelper.canonicalize(url) // strips query + fragment
host = HttpUrl.get(url).host()
alias = DPoPKeyManager.aliasForCredentialsIdentifier(credentialsIdentifier)
keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias)
nonce = DPoPNonceCache.get(credentialsIdentifier, host) // null until token exchange completes
proof = DPoPProofBuilder.buildProof(method, htu, keyPair, nonce, authToken)
builder.header("DPoP", proof)
```

`setAuthHeader()` also sets `Authorization: DPoP <accessToken>` (instead of Bearer) when
`tokenType == "DPoP"`.

**The interceptor never writes to `DPoPNonceCache`** — it only reads. All cache writes are
done by `OAuth2.java` after token-endpoint and identity-endpoint responses.

---

## 4. Refresh Token Rotation (RTR) — Concurrency Model

RTR means the server issues a new refresh token on every refresh. The old one is immediately
invalidated. Two threads simultaneously refreshing with the same refresh token will cause one
to receive `invalid_grant` → logout. `AccMgrAuthTokenProvider.getNewAuthToken()` prevents
this with a per-account coordination primitive.

### State

```java
// One entry per (userId:orgId); survives across getNewAuthToken() calls.
static final ConcurrentHashMap<String, RefreshState> REFRESH_STATES

class RefreshState {
final Object lock // coordination primitive
boolean refreshing // true while winner is in-flight
long publishGeneration // incremented only on successful publish
String newAuthToken // last successfully refreshed token
String newInstanceUrl
String rotatedRefreshToken // refresh token as rotated by the last winner
String newTokenType
long lastRefreshTime // wall-clock time of last successful publish
}
```

### Flow

```
Thread A Thread B (same account)
──────────────────────────────────────────────────────────────────
synchronized(state.lock)
state.refreshing == false
→ become winner
state.refreshing = true
lock released
synchronized(state.lock)
state.refreshing == true
snapshot startGeneration
state.lock.wait(...) ← parked
lock released (by wait)

refreshStaleToken()
→ OAuth2.refreshAuthToken()
→ POST /token (DPoP proof + nonce)
→ 400 use_dpop_nonce → cache nonce → retry
→ 200: new access_token, rotated refresh_token
broadcast ACCESS_TOKEN_REFRESH_INTENT

synchronized(state.lock)
state.refreshing = false
state.newAuthToken = <new>
state.publishGeneration++ ← edge: B detects this
state.lock.notifyAll()
lock released
Thread B wakes
synchronized(state.lock)
publishGeneration changed → adopt
lock released
return state.newAuthToken (no /token call)
```

### Key design decisions

**Election on a generation edge, not a level.** Losers snapshot `publishGeneration` before
parking and wake when it advances — not when `refreshing` becomes `false`. This handles the
consecutive-cycle race: if a new winner has already re-set `refreshing = true` by the time a
loser wakes, the loser still detects the prior winner's publish via the generation edge and
adopts that result correctly.

**Loser timeout.** Losers wait at most 30 s. If the winner hasn't published by then, the loser
returns `null` rather than attempting a second concurrent refresh. The caller's request fails
and can retry; the in-flight winner (if merely slow) still completes normally.

**Recheck-under-lock guardrail.** Before making a network call the winner re-reads
`AccountManager`. If storage has already advanced (a prior winner refreshed), the winner
adopts without posting. This closes the window where two threads both elected themselves
winner in different `RestClient` instances sharing the same account.

**Failure publish.** On failure, `publishGeneration` is NOT incremented. A loser that woke
during a failed cycle sees an unchanged generation and returns `null`. A loser that started
waiting before an earlier successful cycle can still adopt that result. `lastRefreshTime` is
also left unchanged on failure so fresh arrivers cannot wrongly adopt a stale token via the
recency window.

**Lock is never held during network I/O.** The lock is acquired twice: once briefly for
the election (a few microseconds), then released before any network calls, then reacquired
briefly to publish the result. DPoP nonce retry inside `makeTokenEndpointRequest()` — two
HTTP calls in the worst case — happens entirely outside the lock.

---

## 5. DPoP Nonce Lifecycle

Nonces are issued exclusively by the **`/token` endpoint** (confirmed by the Salesforce DPoP
implementation team). Resource servers do not issue nonces.

```
DPoPNonceCache
Key: credentialsIdentifier + ":" + host
Value: most recent nonce received from that host's /token response
Type: ConcurrentHashMap (thread-safe singleton)
```

### Write path (OAuth2.java only)

Both `makeTokenEndpointRequest()` and `callIdentityService()` harvest `DPoP-Nonce` from
**every** response (success or error) before inspecting the status code:

```
response = httpClient.newCall(request).execute()
nonce = response.header("DPoP-Nonce")
if nonce != null → DPoPNonceCache.store(credentialsIdentifier, host, nonce)
if isNonceChallenge(response):
rebuild proof with DPoPNonceCache.get(credentialsIdentifier, host)
retry once
if second attempt also fails → throw OAuthFailedException
```

### Read path (interceptor + OAuth2.java)

Every DPoP proof — whether in `attachDPoPProofIfNeeded()` (API calls) or
`makeTokenEndpointRequest()` (token requests) — reads the cache before calling
`buildProof()`. On a warm path (cache hit) the nonce is included proactively and no
extra round-trip occurs. On a cold path (first login, or nonce rotated) the proof goes
out without a nonce; the token endpoint's challenge-retry handles it transparently.

### Interaction with the RTR lock

Because the interceptor never writes the nonce cache and never retries on nonce challenges,
nonce handling has no interaction with the RTR lock. Nonce writes happen inside
`makeTokenEndpointRequest()`, which runs only on the winner thread, after the lock has
been released.

### Logout

`SalesforceSDKManager.removeAccount()` calls:
- `DPoPKeyManager.deleteKeyPair(alias)` — destroys the EC keypair from the Android Keystore
- `DPoPNonceCache.clear(credentialsIdentifier)` — evicts cached nonces for this session

---

## 6. End-to-End: API Call Scenarios

### 6.1 Happy path (warm, DPoP with cached nonce)

```
intercept()
buildAuthenticatedRequest()
Authorization: DPoP <accessToken>
DPoP: <proof with cached nonce + ath>
chain.proceed() → 200
return response
```

### 6.2 Access token expired

```
intercept()
buildAuthenticatedRequest() (cached nonce included)
chain.proceed() → 401
shouldRefresh() → true
refreshAccessToken()
getNewAuthToken()
synchronized(state.lock): become winner, set refreshing=true; lock released
refreshStaleToken()
OAuth2.refreshAuthToken()
POST /token (DPoP proof + cached nonce)
harvest DPoP-Nonce from response
200: new access_token, rotated refresh_token
synchronized(state.lock): publish, publishGeneration++, notifyAll(); lock released
buildAuthenticatedRequest() (new token + nonce)
chain.proceed() → 200
return response
```

### 6.3 Access token expired + nonce missing or expired

```
intercept()
chain.proceed() → 401
refreshAccessToken()
getNewAuthToken()
lock: become winner; lock released
OAuth2.refreshAuthToken()
POST /token (DPoP proof, no nonce or stale nonce)
harvest DPoP-Nonce → DPoPNonceCache.store(...)
isNonceChallenge() → true → retry with fresh nonce
POST /token (DPoP proof + correct nonce)
harvest DPoP-Nonce from success response (server may rotate)
200: new access_token
lock: publish, notifyAll(); lock released
buildAuthenticatedRequest() (new token + nonce now in cache)
chain.proceed() → 200
return response
```

Note: cases 6.2 and 6.3 collapse into the same code path. The distinction is invisible to
`intercept()` — it always sees a single 401 from the resource server and a single successful
token after `refreshAccessToken()` returns.

### 6.4 Concurrent 401s from two threads (same account, RTR enabled)

```
Thread A Thread B
chain.proceed() → 401 chain.proceed() → 401
refreshAccessToken() refreshAccessToken()
getNewAuthToken() getNewAuthToken()
lock: winner, refreshing=true lock: loser, park on state.lock.wait()
lock released
POST /token → 200
lock: publish, notifyAll()
lock released wakes; publishGeneration advanced
→ adopt winner's token (no /token call)
buildAuthenticatedRequest() buildAuthenticatedRequest()
chain.proceed() → 200 chain.proceed() → 200
```
Loading
Loading