|
| 1 | +# Token Lifecycle: Code Exchange, Refresh, DPoP, and RTR |
| 2 | + |
| 3 | +This document describes how the Android SDK acquires and renews OAuth tokens, how DPoP |
| 4 | +proof JWTs are attached, how nonces are managed, and how Refresh Token Rotation (RTR) is |
| 5 | +handled safely under concurrency. |
| 6 | + |
| 7 | +The primary classes are: |
| 8 | +- **`OAuth2.java`** — all calls to the Salesforce token and identity endpoints |
| 9 | +- **`ClientManager.java`** (`AccMgrAuthTokenProvider`) — RTR-safe token refresh coordination |
| 10 | +- **`RestClient.java`** (`OAuthRefreshInterceptor`) — per-request auth header attachment and |
| 11 | + automatic refresh on 401 |
| 12 | +- **`DPoPProofBuilder.kt`**, **`DPoPKeyManager.kt`**, **`DPoPURLHelper.kt`**, |
| 13 | + **`DPoPNonceCache.kt`** — DPoP proof generation and nonce caching |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## 1. Code Exchange (initial login) |
| 18 | + |
| 19 | +**Entry point:** `OAuth2.exchangeCode()` |
| 20 | + |
| 21 | +After the user completes the OAuth2 authorization code flow in the login WebView, |
| 22 | +`LoginActivity` calls `exchangeCode()` with the authorization `code` and `code_verifier` |
| 23 | +(PKCE). This method: |
| 24 | + |
| 25 | +1. Builds a `POST /services/oauth2/token` form body with `grant_type=authorization_code` |
| 26 | + (or `hybrid_auth_code` when hybrid authentication is enabled). |
| 27 | +2. If `isUseDPoP()` is `true` and a `credentialsIdentifier` was generated at login start: |
| 28 | + - Looks up (or generates) the EC keypair via `DPoPKeyManager.generateOrLoadKeyPair()`. |
| 29 | + - Proactively reads any cached nonce via `DPoPNonceCache.get(credentialsIdentifier, tokenHost)` — null on first ever login. |
| 30 | + - Builds a DPoP proof JWT via `DPoPProofBuilder.buildProof()` and attaches it as the `DPoP` header. |
| 31 | +3. Sends the request via `makeTokenEndpointRequest()` (shared with refresh). |
| 32 | +4. On success: the response carries an `access_token`, `refresh_token`, and `token_type` |
| 33 | + (`"DPoP"` or `"Bearer"`). A `DPoP-Nonce` header may also be present — it is harvested |
| 34 | + into the cache immediately. |
| 35 | +5. On `use_dpop_nonce` (400/401 with that error body): the nonce was just harvested (step 4); |
| 36 | + rebuild the proof with it and retry once. Fail-closed on a second nonce failure. |
| 37 | +6. `TokenEndpointResponse` is returned to `LoginActivity`, which persists the tokens into |
| 38 | + `AccountManager` and calls `callIdentityService()` to fetch the user's identity record. |
| 39 | + |
| 40 | +**DPoP note:** the `credentialsIdentifier` is a UUID generated at login start and stored on |
| 41 | +`UserAccount` as the `dpopScope` AccountManager extra. It is the stable key for the DPoP |
| 42 | +keypair and nonce cache for this user's session. |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## 2. Token Refresh |
| 47 | + |
| 48 | +**Entry point:** `OAuth2.refreshAuthToken()` |
| 49 | + |
| 50 | +When an API call returns 401, `OAuthRefreshInterceptor` calls `refreshAccessToken()` which |
| 51 | +delegates to `AccMgrAuthTokenProvider.getNewAuthToken()`. That method: |
| 52 | + |
| 53 | +1. **Matches the account** by scanning `AccountManager` for the one whose stored refresh token |
| 54 | + equals this provider's `refreshToken`. Fails early (returns null) if the account has been |
| 55 | + removed — this ensures a removed-account path never accidentally sets `refreshing = true` |
| 56 | + and deadlocks waiting threads. |
| 57 | + |
| 58 | +2. **Acquires the RTR lock** — see §4 below. |
| 59 | + |
| 60 | +3. **Recheck-under-lock guardrail:** before making a network call, re-reads the current tokens |
| 61 | + from `AccountManager`. If either the access token or the refresh token in storage has |
| 62 | + already advanced past what this provider last used, a concurrent winner already refreshed — |
| 63 | + adopt their result without a redundant network POST. This is a correctness guardrail under |
| 64 | + RTR, not an optimisation: every needless POST rotates the refresh token and widens the |
| 65 | + stale-token logout window. |
| 66 | + |
| 67 | +4. **`refreshStaleToken()`** → **`OAuth2.refreshAuthToken()`** builds a |
| 68 | + `POST /services/oauth2/token` form body with `grant_type=refresh_token`. DPoP proof |
| 69 | + and nonce handling here are identical to code exchange: proactive nonce inclusion, |
| 70 | + harvest from response, retry-once on `use_dpop_nonce`. |
| 71 | + |
| 72 | +5. On success: broadcasts `ACCESS_TOKEN_REFRESH_INTENT` (or `INSTANCE_URL_UPDATE_INTENT` if |
| 73 | + the instance URL changed), publishes the new tokens to the per-account `RefreshState`, |
| 74 | + and wakes waiting losers. |
| 75 | + |
| 76 | +6. On terminal failure (`invalid_grant`, `client_blocked`): broadcasts |
| 77 | + `ACCESS_TOKEN_REVOKE_INTENT` and calls `SalesforceSDKManager.logout()`. |
| 78 | + |
| 79 | +--- |
| 80 | + |
| 81 | +## 3. DPoP Proof Attachment |
| 82 | + |
| 83 | +Every outgoing API call goes through `OAuthRefreshInterceptor.buildAuthenticatedRequest()`, |
| 84 | +which calls `attachDPoPProofIfNeeded()`: |
| 85 | + |
| 86 | +``` |
| 87 | +attachDPoPProofIfNeeded(builder, method, url): |
| 88 | + if tokenType != "DPoP" → return (Bearer path, unmodified) |
| 89 | + if !isUseDPoP() → return |
| 90 | + if credentialsIdentifier == null → return |
| 91 | + htu = DPoPURLHelper.canonicalize(url) // strips query + fragment |
| 92 | + host = HttpUrl.get(url).host() |
| 93 | + alias = DPoPKeyManager.aliasForCredentialsIdentifier(credentialsIdentifier) |
| 94 | + keyPair = DPoPKeyManager.generateOrLoadKeyPair(alias) |
| 95 | + nonce = DPoPNonceCache.get(credentialsIdentifier, host) // null until token exchange completes |
| 96 | + proof = DPoPProofBuilder.buildProof(method, htu, keyPair, nonce, authToken) |
| 97 | + builder.header("DPoP", proof) |
| 98 | +``` |
| 99 | + |
| 100 | +`setAuthHeader()` also sets `Authorization: DPoP <accessToken>` (instead of Bearer) when |
| 101 | +`tokenType == "DPoP"`. |
| 102 | + |
| 103 | +**The interceptor never writes to `DPoPNonceCache`** — it only reads. All cache writes are |
| 104 | +done by `OAuth2.java` after token-endpoint and identity-endpoint responses. |
| 105 | + |
| 106 | +--- |
| 107 | + |
| 108 | +## 4. Refresh Token Rotation (RTR) — Concurrency Model |
| 109 | + |
| 110 | +RTR means the server issues a new refresh token on every refresh. The old one is immediately |
| 111 | +invalidated. Two threads simultaneously refreshing with the same refresh token will cause one |
| 112 | +to receive `invalid_grant` → logout. `AccMgrAuthTokenProvider.getNewAuthToken()` prevents |
| 113 | +this with a per-account coordination primitive. |
| 114 | + |
| 115 | +### State |
| 116 | + |
| 117 | +```java |
| 118 | +// One entry per (userId:orgId); survives across getNewAuthToken() calls. |
| 119 | +static final ConcurrentHashMap<String, RefreshState> REFRESH_STATES |
| 120 | + |
| 121 | +class RefreshState { |
| 122 | + final Object lock // coordination primitive |
| 123 | + boolean refreshing // true while winner is in-flight |
| 124 | + long publishGeneration // incremented only on successful publish |
| 125 | + String newAuthToken // last successfully refreshed token |
| 126 | + String newInstanceUrl |
| 127 | + String rotatedRefreshToken // refresh token as rotated by the last winner |
| 128 | + String newTokenType |
| 129 | + long lastRefreshTime // wall-clock time of last successful publish |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +### Flow |
| 134 | + |
| 135 | +``` |
| 136 | +Thread A Thread B (same account) |
| 137 | +────────────────────────────────────────────────────────────────── |
| 138 | +synchronized(state.lock) |
| 139 | + state.refreshing == false |
| 140 | + → become winner |
| 141 | + state.refreshing = true |
| 142 | +lock released |
| 143 | + synchronized(state.lock) |
| 144 | + state.refreshing == true |
| 145 | + snapshot startGeneration |
| 146 | + state.lock.wait(...) ← parked |
| 147 | + lock released (by wait) |
| 148 | + |
| 149 | +refreshStaleToken() |
| 150 | + → OAuth2.refreshAuthToken() |
| 151 | + → POST /token (DPoP proof + nonce) |
| 152 | + → 400 use_dpop_nonce → cache nonce → retry |
| 153 | + → 200: new access_token, rotated refresh_token |
| 154 | +broadcast ACCESS_TOKEN_REFRESH_INTENT |
| 155 | + |
| 156 | +synchronized(state.lock) |
| 157 | + state.refreshing = false |
| 158 | + state.newAuthToken = <new> |
| 159 | + state.publishGeneration++ ← edge: B detects this |
| 160 | + state.lock.notifyAll() |
| 161 | +lock released |
| 162 | + Thread B wakes |
| 163 | + synchronized(state.lock) |
| 164 | + publishGeneration changed → adopt |
| 165 | + lock released |
| 166 | + return state.newAuthToken (no /token call) |
| 167 | +``` |
| 168 | + |
| 169 | +### Key design decisions |
| 170 | + |
| 171 | +**Election on a generation edge, not a level.** Losers snapshot `publishGeneration` before |
| 172 | +parking and wake when it advances — not when `refreshing` becomes `false`. This handles the |
| 173 | +consecutive-cycle race: if a new winner has already re-set `refreshing = true` by the time a |
| 174 | +loser wakes, the loser still detects the prior winner's publish via the generation edge and |
| 175 | +adopts that result correctly. |
| 176 | + |
| 177 | +**Loser timeout.** Losers wait at most 30 s. If the winner hasn't published by then, the loser |
| 178 | +returns `null` rather than attempting a second concurrent refresh. The caller's request fails |
| 179 | +and can retry; the in-flight winner (if merely slow) still completes normally. |
| 180 | + |
| 181 | +**Recheck-under-lock guardrail.** Before making a network call the winner re-reads |
| 182 | +`AccountManager`. If storage has already advanced (a prior winner refreshed), the winner |
| 183 | +adopts without posting. This closes the window where two threads both elected themselves |
| 184 | +winner in different `RestClient` instances sharing the same account. |
| 185 | + |
| 186 | +**Failure publish.** On failure, `publishGeneration` is NOT incremented. A loser that woke |
| 187 | +during a failed cycle sees an unchanged generation and returns `null`. A loser that started |
| 188 | +waiting before an earlier successful cycle can still adopt that result. `lastRefreshTime` is |
| 189 | +also left unchanged on failure so fresh arrivers cannot wrongly adopt a stale token via the |
| 190 | +recency window. |
| 191 | + |
| 192 | +**Lock is never held during network I/O.** The lock is acquired twice: once briefly for |
| 193 | +the election (a few microseconds), then released before any network calls, then reacquired |
| 194 | +briefly to publish the result. DPoP nonce retry inside `makeTokenEndpointRequest()` — two |
| 195 | +HTTP calls in the worst case — happens entirely outside the lock. |
| 196 | + |
| 197 | +--- |
| 198 | + |
| 199 | +## 5. DPoP Nonce Lifecycle |
| 200 | + |
| 201 | +Nonces are issued exclusively by the **`/token` endpoint** (confirmed by the Salesforce DPoP |
| 202 | +implementation team). Resource servers do not issue nonces. |
| 203 | + |
| 204 | +``` |
| 205 | +DPoPNonceCache |
| 206 | + Key: credentialsIdentifier + ":" + host |
| 207 | + Value: most recent nonce received from that host's /token response |
| 208 | + Type: ConcurrentHashMap (thread-safe singleton) |
| 209 | +``` |
| 210 | +
|
| 211 | +### Write path (OAuth2.java only) |
| 212 | +
|
| 213 | +Both `makeTokenEndpointRequest()` and `callIdentityService()` harvest `DPoP-Nonce` from |
| 214 | +**every** response (success or error) before inspecting the status code: |
| 215 | +
|
| 216 | +``` |
| 217 | +response = httpClient.newCall(request).execute() |
| 218 | +nonce = response.header("DPoP-Nonce") |
| 219 | +if nonce != null → DPoPNonceCache.store(credentialsIdentifier, host, nonce) |
| 220 | +if isNonceChallenge(response): |
| 221 | + rebuild proof with DPoPNonceCache.get(credentialsIdentifier, host) |
| 222 | + retry once |
| 223 | + if second attempt also fails → throw OAuthFailedException |
| 224 | +``` |
| 225 | +
|
| 226 | +### Read path (interceptor + OAuth2.java) |
| 227 | +
|
| 228 | +Every DPoP proof — whether in `attachDPoPProofIfNeeded()` (API calls) or |
| 229 | +`makeTokenEndpointRequest()` (token requests) — reads the cache before calling |
| 230 | +`buildProof()`. On a warm path (cache hit) the nonce is included proactively and no |
| 231 | +extra round-trip occurs. On a cold path (first login, or nonce rotated) the proof goes |
| 232 | +out without a nonce; the token endpoint's challenge-retry handles it transparently. |
| 233 | + |
| 234 | +### Interaction with the RTR lock |
| 235 | + |
| 236 | +Because the interceptor never writes the nonce cache and never retries on nonce challenges, |
| 237 | +nonce handling has no interaction with the RTR lock. Nonce writes happen inside |
| 238 | +`makeTokenEndpointRequest()`, which runs only on the winner thread, after the lock has |
| 239 | +been released. |
| 240 | + |
| 241 | +### Logout |
| 242 | + |
| 243 | +`SalesforceSDKManager.removeAccount()` calls: |
| 244 | +- `DPoPKeyManager.deleteKeyPair(alias)` — destroys the EC keypair from the Android Keystore |
| 245 | +- `DPoPNonceCache.clear(credentialsIdentifier)` — evicts cached nonces for this session |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## 6. End-to-End: API Call Scenarios |
| 250 | + |
| 251 | +### 6.1 Happy path (warm, DPoP with cached nonce) |
| 252 | + |
| 253 | +``` |
| 254 | +intercept() |
| 255 | + buildAuthenticatedRequest() |
| 256 | + Authorization: DPoP <accessToken> |
| 257 | + DPoP: <proof with cached nonce + ath> |
| 258 | + chain.proceed() → 200 |
| 259 | + return response |
| 260 | +``` |
| 261 | + |
| 262 | +### 6.2 Access token expired |
| 263 | + |
| 264 | +``` |
| 265 | +intercept() |
| 266 | + buildAuthenticatedRequest() (cached nonce included) |
| 267 | + chain.proceed() → 401 |
| 268 | + shouldRefresh() → true |
| 269 | + refreshAccessToken() |
| 270 | + getNewAuthToken() |
| 271 | + synchronized(state.lock): become winner, set refreshing=true; lock released |
| 272 | + refreshStaleToken() |
| 273 | + OAuth2.refreshAuthToken() |
| 274 | + POST /token (DPoP proof + cached nonce) |
| 275 | + harvest DPoP-Nonce from response |
| 276 | + 200: new access_token, rotated refresh_token |
| 277 | + synchronized(state.lock): publish, publishGeneration++, notifyAll(); lock released |
| 278 | + buildAuthenticatedRequest() (new token + nonce) |
| 279 | + chain.proceed() → 200 |
| 280 | + return response |
| 281 | +``` |
| 282 | + |
| 283 | +### 6.3 Access token expired + nonce missing or expired |
| 284 | + |
| 285 | +``` |
| 286 | +intercept() |
| 287 | + chain.proceed() → 401 |
| 288 | + refreshAccessToken() |
| 289 | + getNewAuthToken() |
| 290 | + lock: become winner; lock released |
| 291 | + OAuth2.refreshAuthToken() |
| 292 | + POST /token (DPoP proof, no nonce or stale nonce) |
| 293 | + harvest DPoP-Nonce → DPoPNonceCache.store(...) |
| 294 | + isNonceChallenge() → true → retry with fresh nonce |
| 295 | + POST /token (DPoP proof + correct nonce) |
| 296 | + harvest DPoP-Nonce from success response (server may rotate) |
| 297 | + 200: new access_token |
| 298 | + lock: publish, notifyAll(); lock released |
| 299 | + buildAuthenticatedRequest() (new token + nonce now in cache) |
| 300 | + chain.proceed() → 200 |
| 301 | + return response |
| 302 | +``` |
| 303 | + |
| 304 | +Note: cases 6.2 and 6.3 collapse into the same code path. The distinction is invisible to |
| 305 | +`intercept()` — it always sees a single 401 from the resource server and a single successful |
| 306 | +token after `refreshAccessToken()` returns. |
| 307 | + |
| 308 | +### 6.4 Concurrent 401s from two threads (same account, RTR enabled) |
| 309 | + |
| 310 | +``` |
| 311 | +Thread A Thread B |
| 312 | +chain.proceed() → 401 chain.proceed() → 401 |
| 313 | +refreshAccessToken() refreshAccessToken() |
| 314 | + getNewAuthToken() getNewAuthToken() |
| 315 | + lock: winner, refreshing=true lock: loser, park on state.lock.wait() |
| 316 | + lock released |
| 317 | + POST /token → 200 |
| 318 | + lock: publish, notifyAll() |
| 319 | + lock released wakes; publishGeneration advanced |
| 320 | + → adopt winner's token (no /token call) |
| 321 | + buildAuthenticatedRequest() buildAuthenticatedRequest() |
| 322 | + chain.proceed() → 200 chain.proceed() → 200 |
| 323 | +``` |
0 commit comments