Skip to content

Commit 500c7bd

Browse files
committed
Merge dev into codex/improve-client-manager-multi-user
2 parents 040faf0 + b5c0724 commit 500c7bd

57 files changed

Lines changed: 3449 additions & 134 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pr.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ on:
55
# Mitigated by per-job Member Check (see "Check Write Permission" + "Validate Write Permission" steps).
66
# Reference: team Github Actions Tribal Knowledge doc.
77
pull_request_target: # zizmor: ignore[dangerous-triggers]
8-
# dpop is a temporary entry: PRs in the multi-PR DPoP rollout target this
9-
# branch. Remove once DPoP is merged back to dev.
10-
branches: [dev, master, dpop]
8+
branches: [dev, master]
119
paths-ignore:
1210
- '**/*.md'
1311
- 'LICENSE'

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# Comment line immediately above ownership line is reserved for related gus information. Please be careful while editing.
2+
#GUSINFO:Mobile SDK,Mobile SDK - Android
23
#ECCN:Open Source

docs/auth/token-lifecycle.md

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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

Comments
 (0)