Skip to content

Common: gate getAccounts shared-FOCI enumeration on caller authorizat…, Fixes AB#3687466#3187

Open
Prvnkmr337 wants to merge 2 commits into
devfrom
prsaminathan/validate-caller-service-p2
Open

Common: gate getAccounts shared-FOCI enumeration on caller authorizat…, Fixes AB#3687466#3187
Prvnkmr337 wants to merge 2 commits into
devfrom
prsaminathan/validate-caller-service-p2

Conversation

@Prvnkmr337

@Prvnkmr337 Prvnkmr337 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

…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

Copilot-Session: 10153bbe-574c-4434-9070-f87842d6098d

…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
@Prvnkmr337
Prvnkmr337 force-pushed the prsaminathan/validate-caller-service-p2 branch from 3324471 to 76aed10 Compare July 16, 2026 15:12
@github-actions github-actions Bot changed the title Common: gate getAccounts shared-FOCI enumeration on caller authorizat… Common: gate getAccounts shared-FOCI enumeration on caller authorizat…, Fixes AB#3687466 Jul 16, 2026
@Prvnkmr337
Prvnkmr337 requested a review from Copilot July 16, 2026 15:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread changelog.txt Outdated
- 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
@Prvnkmr337

Copy link
Copy Markdown
Contributor Author

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.

@Prvnkmr337
Prvnkmr337 marked this pull request as ready for review July 16, 2026 19:03
@Prvnkmr337
Prvnkmr337 requested review from a team as code owners July 16, 2026 19:03
);

if (null == targetCache) {
if (!callerAuthorizedForFoci) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 == targetCache block 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants