Common: gate silent shared-FOCI refresh-token redemption on caller authorization (AB#3687466), Fixes AB#3687466#3188
Conversation
…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
|
✅ Work item link check complete. Description contains link AB#3687466 to an Azure Boards work item. |
|
❌ 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. |
|
Azure Pipelines: Some pipeline(s) encountered errors during trigger evaluation. |
…FoCI redemption gate (AB#3687466) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0436fb4a-df54-48de-a086-55aadb1eb200
| 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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Why would we even look at/use this parameter for non-brokered flows?
There was a problem hiding this comment.
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
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:
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:
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):
Work item: AB#3687466
Copilot-Session: 0436fb4a-df54-48de-a086-55aadb1eb200