Common: gate getAccounts shared-FOCI enumeration on caller authorizat…, Fixes AB#3687466#3187
Common: gate getAccounts shared-FOCI enumeration on caller authorizat…, Fixes AB#3687466#3187Prvnkmr337 wants to merge 2 commits into
Conversation
…ion (AB#3687466) Problem ------- BrokerOAuth2TokenCache.getAccountsWithAggregatedAccountData reads the device-wide shared family-of-client-id (FOCI) cache whenever the requested client id belongs to a token family, without checking whether the calling app is authorized to share FOCI tokens. Consumed by the broker getAccounts path, this lets an unauthorized local app enumerate other apps' shared FOCI accounts (usernames, home account ids, tenant ids) by naming a first-party family client id. Phased delivery --------------- AB#3687466 is delivered in phases, each as its own independently reviewable and independently flighted PR: - Phase 1: re-pin the silent bound-service caller on acquireTokenSilently. - Phase 2 (this PR): gate device-wide FOCI account enumeration on getAccounts. - Phase 3: gate silent shared-FOCI token redemption (broker-side backstop). - Phase 4: staged flight rollout (observe -> enforce). Cross-repo changes merge common-first, then broker; this Common change is the base dependency for the corresponding broker PR. Solution -------- Thread the caller's FOCI authorization into the cache read so that only the shared FOCI cache inclusion is gated, while the caller's own UID-partitioned accounts are always returned: - Add a broker-specific overload getAccountsWithAggregatedAccountData( environment, clientId, callerAuthorizedForFoci). The existing 2-arg method delegates with callerAuthorizedForFoci = true, preserving behavior for all existing callers (no interface change; the OAuth2TokenCache interface methods are untouched). - Add a gated private overload getTokenCachesForClientId(clientId, callerAuthorizedForFoci) that adds the shared mFociCache only when the caller is authorized; the caller's own uid-partitioned cache is added regardless. - Fail closed: when the caller is not FOCI-authorized, has no client-specific cache, and an environment is supplied, return an empty list rather than falling back to the shared FOCI cache. The authorization decision itself is evaluated in the broker layer and passed in as a boolean; this change is a pure, backward-compatible cache-layer gate with no schema change. Testing ------- Unit tests (BrokerOAuth2TokenCacheTest), simulating a caller under a different uid than the app that saved the shared FOCI account: - authorized caller -> shared FOCI accounts included; - unauthorized caller -> shared FOCI accounts excluded; - unauthorized non-FOCI caller -> own uid-partitioned accounts still returned (the critical regression guard); - unauthorized + environment + no owned cache -> empty (fail-closed); - 2-arg overload -> shared FOCI accounts still included (back-compat). Work item: AB#3687466 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10153bbe-574c-4434-9070-f87842d6098d
3324471 to
76aed10
Compare
There was a problem hiding this comment.
Pull request overview
This PR hardens the Common broker token cache layer to prevent unauthorized callers from enumerating device-wide shared FoCI accounts via the broker getAccounts pathway, by threading a caller-authorization signal into the shared-FoCI cache inclusion logic (AB#3687466, Phase 2).
Changes:
- Added a broker-specific
getAccountsWithAggregatedAccountData(environment, clientId, callerAuthorizedForFoci)overload to gate inclusion of the shared FoCI cache while preserving existing behavior for current callers. - Updated the token-cache selection logic to only include the shared FoCI cache when the caller is authorized, and to fail closed (no FoCI fallback) on the environment-scoped path when unauthorized.
- Added unit tests covering authorized vs unauthorized FoCI enumeration, fail-closed behavior, and backward compatibility for the existing 2-arg overload; added a vNext changelog entry.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| common4j/src/main/com/microsoft/identity/common/java/cache/BrokerOAuth2TokenCache.java | Adds FoCI-authorization-gated overload + gated cache selection and fail-closed fallback behavior. |
| common/src/test/java/com/microsoft/identity/common/BrokerOAuth2TokenCacheTest.java | Adds unit tests validating the new FoCI authorization gate and backward compatibility. |
| changelog.txt | Documents the new overload and gating behavior under vNext. |
- Use Collections.emptyList() for the fail-closed FoCI-unauthorized return in getAccountsWithAggregatedAccountData, matching the existing empty-return style in this class (consistency + avoids an allocation). - Clarify the shared-FoCI gate comment in getTokenCachesForClientId: the gate suppresses only the shared FoCI cache; the caller's own app-specific / UID-partitioned cache is added independently by the non-FoCI metadata branch and is never affected by this check. - Replace changelog placeholder (#TBD) with (#3187). Doc/changelog/style only; no functional change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f51fcf81-5334-42cf-a3fa-9102a97f7276
|
The red Common4j – Compare Code Coverage (PR vs. Dev) check is a non-blocking −0.02% delta (37.42% vs 37.44%), not a test failure: BrokerOAuth2TokenCache lives in common4j but is exercised via Robolectric tests in the common module, so the new gating branches count as uncovered in the common4j -only report. All functional tests pass and this check is not a required merge gate. |
| ); | ||
|
|
||
| if (null == targetCache) { | ||
| if (!callerAuthorizedForFoci) { |
There was a problem hiding this comment.
This fail-closed only runs when targetCache is null. But for a caller that already has its own FoCI metadata row, targetCache won't be null. it'll be the shared FoCI cache itself.
getTokenCacheForClient(clientId, environment, mUid) -> getMetadata(clientId, environment, mUid) -> getTokenCacheForClient(metadata), and that last one returns mFociCache directly when metadata.getFoci() != null (line 1737). So for an unauthorized caller:
- caller has a FoCI metadata row for (clientId, environment, its own uid)
- targetCache resolves to mFociCache, non-null
- this whole
null == targetCacheblock is skipped, so callerAuthorizedForFoci never gets looked at - we return mFociCache.getAccountsWithAggregatedAccountData(environment, clientId), i.e. the device-wide shared FoCI accounts
environment is read straight off the caller's request bundle, so an unauthorized app can just pass a non-null environment to steer into this branch. And the authorization flag is a TSL allowlist check, independent of whether the app has a FoCI row, so unauthorized + owns-a-foci-row is a real combination, not a contradiction.
The no-env branch is fine because it gates the actual result.add(mFociCache). Here we gate the null fallback instead of the cache selection, so the shared cache slips through whenever it gets resolved as the caller's own targetCache. Even for a clientId the caller legitimately has a row for, that shared cache still holds other apps'/users' accounts under the same family id.
Can we gate the selection itself instead? After resolving targetCache: if !callerAuthorizedForFoci && targetCache == mFociCache -> return emptyList. Or push the check down into getTokenCacheForClient so every path that lands on mFociCache is covered.
| final BrokerOAuth2TokenCache callerCache = newBrokerCacheForUid(TEST_APP_UID + 1); | ||
|
|
||
| final List<ICacheRecord> unauthorized = | ||
| callerCache.getAccountsWithAggregatedAccountData(ENVIRONMENT, clientId, false); |
There was a problem hiding this comment.
This unauthorized-env case uses a caller uid that saved nothing, so getMetadata returns null and it trips the fail-closed on the trivial null-targetCache path. The bypass is the other case. caller has its own FoCI row for clientId under its own uid.
Can we add one where the caller uid saves a FoCI token first, then assert getAccountsWithAggregatedAccountData(ENVIRONMENT, clientId, false) still comes back empty? That one fails against the current code.
…ion (AB#3687466)
Problem
BrokerOAuth2TokenCache.getAccountsWithAggregatedAccountData reads the device-wide shared family-of-client-id (FOCI) cache whenever the requested client id belongs to a token family, without checking whether the calling app is authorized to share FOCI tokens. Consumed by the broker getAccounts path, this lets an unauthorized local app enumerate other apps' shared FOCI accounts (usernames, home account ids, tenant ids) by naming a first-party family client id.
Phased delivery
AB#3687466 is delivered in phases, each as its own independently reviewable and independently flighted PR:
Solution
Thread the caller's FOCI authorization into the cache read so that only the shared FOCI cache inclusion is gated, while the caller's own UID-partitioned accounts are always returned:
The authorization decision itself is evaluated in the broker layer and passed in as a boolean; this change is a pure, backward-compatible cache-layer gate with no schema change.
Testing
Unit tests (BrokerOAuth2TokenCacheTest), simulating a caller under a different uid than the app that saved the shared FOCI account:
Work item: AB#3687466
Copilot-Session: 10153bbe-574c-4434-9070-f87842d6098d