Skip to content

Common: gate silent shared-FOCI refresh-token redemption on caller authorization (AB#3687466), Fixes AB#3687466#3188

Open
Prvnkmr337 wants to merge 3 commits into
devfrom
prsaminathan/validate-caller-service-p3
Open

Common: gate silent shared-FOCI refresh-token redemption on caller authorization (AB#3687466), Fixes AB#3687466#3188
Prvnkmr337 wants to merge 3 commits into
devfrom
prsaminathan/validate-caller-service-p3

Conversation

@Prvnkmr337

@Prvnkmr337 Prvnkmr337 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

BaseController.getAccountWithFRTIfAvailable redeems the device-wide shared family-of-client-id (FOCI) refresh token for any silent request whose named client id belongs to a token family, without checking whether the calling app is authorized to share FOCI tokens. On the brokered silent path this lets an unauthorized local app mint a token from another app's shared FOCI refresh token 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: gate device-wide FOCI account enumeration on getAccounts.
  • Phase 3 (this PR): 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

Carry the caller's FOCI authorization on the silent command parameters and gate the shared-FOCI redemption on it, while leaving the caller's own UID-partitioned cache path untouched:

  • Add SilentTokenCommandParameters.callerAuthorizedForFoci, a @Builder.Default boolean defaulting to true. Default-true keeps non-brokered silent flows (e.g. LocalMSALController, which owns its tokens and has no cross-app "authorized to share" concept) and every existing caller unaffected; no interface change.
  • In BaseController.getAccountWithFRTIfAvailable, before redeeming the shared family refresh token, return null when callerAuthorizedForFoci is false. This falls through to the caller's own UID-partitioned cache only (same as the "no FRT found" path), so an unauthorized caller never has the device-wide shared FOCI RT redeemed on its behalf.

The authorization decision itself is evaluated in the broker layer and set on the parameters; this change is a pure, backward-compatible controller-layer gate with no schema change.

Testing

Unit tests (BaseControllerFociRedemptionGateTest):

  • callerAuthorizedForFoci = false -> shared FoCI RT redemption skipped (returns null, falls through to the caller's own cache);
  • callerAuthorizedForFoci = true -> redemption proceeds (no regression for legitimate family apps and non-brokered callers).

Work item: AB#3687466

Copilot-Session: 0436fb4a-df54-48de-a086-55aadb1eb200

…thorization (AB#3687466)

Problem
-------
BaseController.getAccountWithFRTIfAvailable redeems the device-wide shared
family-of-client-id (FOCI) refresh token for any silent request whose named
client id belongs to a token family, without checking whether the calling app
is authorized to share FOCI tokens. On the brokered silent path this lets an
unauthorized local app mint a token from another app's shared FOCI refresh
token 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: gate device-wide FOCI account enumeration on getAccounts.
  - Phase 3 (this PR): 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
--------
Carry the caller's FOCI authorization on the silent command parameters and
gate the shared-FOCI redemption on it, while leaving the caller's own
UID-partitioned cache path untouched:

- Add SilentTokenCommandParameters.callerAuthorizedForFoci, a @Builder.Default
  boolean defaulting to true. Default-true keeps non-brokered silent flows
  (e.g. LocalMSALController, which owns its tokens and has no cross-app
  "authorized to share" concept) and every existing caller unaffected; no
  interface change.
- In BaseController.getAccountWithFRTIfAvailable, before redeeming the shared
  family refresh token, return null when callerAuthorizedForFoci is false. This
  falls through to the caller's own UID-partitioned cache only (same as the
  "no FRT found" path), so an unauthorized caller never has the device-wide
  shared FOCI RT redeemed on its behalf.

The authorization decision itself is evaluated in the broker layer and set on
the parameters; this change is a pure, backward-compatible controller-layer
gate with no schema change.

Testing
-------
Unit tests (BaseControllerFociRedemptionGateTest):
- callerAuthorizedForFoci = false -> shared FoCI RT redemption skipped
  (returns null, falls through to the caller's own cache);
- callerAuthorizedForFoci = true -> redemption proceeds (no regression for
  legitimate family apps and non-brokered callers).

Work item: AB#3687466

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0436fb4a-df54-48de-a086-55aadb1eb200
@github-actions

Copy link
Copy Markdown

✅ Work item link check complete. Description contains link AB#3687466 to an Azure Boards work item.

@github-actions

Copy link
Copy Markdown

❌ Work item link check failed. Description contains AB#3687466 but the Bot could not link it to an Azure Boards work item.

Click here to learn more.

@github-actions github-actions Bot changed the title Common: gate silent shared-FOCI refresh-token redemption on caller authorization (AB#3687466) Common: gate silent shared-FOCI refresh-token redemption on caller authorization (AB#3687466), Fixes AB#3687466 Jul 16, 2026
@Prvnkmr337
Prvnkmr337 requested a review from Copilot July 16, 2026 22:36
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Some pipeline(s) encountered errors during trigger evaluation.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…FoCI redemption gate (AB#3687466)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0436fb4a-df54-48de-a086-55aadb1eb200

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@Prvnkmr337
Prvnkmr337 marked this pull request as ready for review July 17, 2026 20:44
@Prvnkmr337
Prvnkmr337 requested review from a team as code owners July 17, 2026 20:44
Mockito.when(parameters.getAccount()).thenReturn(account);
Mockito.when(parameters.getClientId()).thenReturn(CLIENT_ID);
Mockito.when(parameters.getRedirectUri()).thenReturn(REDIRECT_URI);
Mockito.when(parameters.isCallerAuthorizedForFoci()).thenReturn(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.

Both tests stub isCallerAuthorizedForFoci() on a mock, so the actual @Builder.Default = true never gets exercised. The whole "non-brokered callers unaffected" thing only holds if that default stays true, and right now nothing here would catch it if the @Builder.Default gets dropped or some construction path skips the builder (silent would start failing closed for everyone). Can we add a test that builds a real SilentTokenCommandParameters without setting caller-auth and asserts it comes back true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — you're right that mocking the getter never exercises the real @Builder.Default = true. Added silentTokenCommandParameters_builtWithoutCallerAuth_defaultsToAuthorized, which builds a real SilentTokenCommandParameters via the builder (no caller-auth set) and asserts isCallerAuthorizedForFoci() returns true. That now fails closed at test time if the default is ever dropped or a construction path skips the builder. Thanks!

* See AB#3687466.
*/
@Builder.Default
private boolean callerAuthorizedForFoci = true;

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.

Should this get @Expose like callerPackageName/callerSignature? Without it the redacted logParameters output (serializeExposedFieldsOfObjectToJsonString) won't show callerAuthorizedForFoci, so when the gate denies a caller we can't see the deciding flag unless PII logging is on. Kinda useful to have when debugging a "why did silent fail for this app" case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — this is the deciding flag for the gate and it's a non-sensitive boolean, so surfacing it in the redacted logParameters output is genuinely useful for "why did silent fail for this app" triage without needing PII logging. Added @Expose() on callerAuthorizedForFoci (matching callerPackageName/callerSignature). Thanks!

* this caller's behalf and falls through to the caller's own UID-partitioned cache only.
* <p>
* Defaults to {@code true} (permissive) so non-brokered silent flows (e.g. {@code LocalMSALController},
* which owns its tokens and has no cross-app "authorized to share" concept) are unaffected. The Broker

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.

Why would we even look at/use this parameter for non-brokered flows?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair question — non-brokered flows don't do anything special with it. The redemption gate lives in shared BaseController.getAccountWithFRTIfAvailable (reached via getCachedAccountRecordFromAllCaches), which LocalMSALController also executes, so the field has to live on the shared SilentTokenCommandParameters. Non-brokered callers never set it — they inherit the secure default true, so the gate is a pure no-op for them (same as pre-fix). Only the Broker sets it from isAuthorizedToShareTokens(callingUid). I've tweaked the KDoc to make that "shared code path" point explicit rather than implying non-brokered flows have special handling.

…demption gate (AB#3687466)

Addresses @shahzaibj's review comments:

- Add a real-builder test (silentTokenCommandParameters_builtWithoutCallerAuth_defaultsToAuthorized)
  that builds a SilentTokenCommandParameters without setting caller-auth and asserts
  isCallerAuthorizedForFoci() returns true. The existing tests mock the getter, so the
  security-critical @Builder.Default = true invariant was never exercised; if it were
  dropped, Lombok's @SuperBuilder would default the primitive to false and silent FOCI
  redemption would fail closed for every non-brokered caller. This now catches that at
  test time.
- Add @expose to callerAuthorizedForFoci so the deciding flag appears in the redacted
  logParameters output (serializeExposedFieldsOfObjectToJsonString), matching
  callerPackageName/callerSignature. Useful for "why did silent fail for this app"
  triage without needing PII logging.
- Clarify the field KDoc: the gate lives in shared BaseController code that non-brokered
  flows also execute; those callers never set the flag and inherit the permissive
  default, so the gate is a no-op for them.

Work item: AB#3687466

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0436fb4a-df54-48de-a086-55aadb1eb200
@Prvnkmr337
Prvnkmr337 requested a review from shahzaibj July 21, 2026 22:28
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