Skip to content

Let a role behind an authentication context actually activate - #197

Merged
FrodeHus merged 3 commits into
mainfrom
fix/auth-context-activation
Sep 19, 2026
Merged

FrodeHus merged 3 commits into
mainfrom
fix/auth-context-activation

Conversation

@FrodeHus

@FrodeHus FrodeHus commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

A role whose PIM policy carries AuthenticationContext_EndUser_Assignment could not be activated at all. The request was refused, the browser step-up was completed, and the retry was refused again with the same message — "This role requires the Conditional Access authentication context … and the sign-in did not satisfy it" — however many times it was repeated.

Two faults on the path between the step-up and the retry, and both had to go.

The client never said it could handle a claims challenge

Elevate did not declare the cp1 client capability, so its tokens carried no xms_cc claim. PIM will not honour an authentication context from a client that has not said it understands a claims challenge: it answers with RoleAssignmentRequestAcrsValidationFailed and re-issues the same challenge, even for a token that plainly carries the context.

Confirmed against a live tenant. The token being refused had everything the policy asked for:

tid=<target tenant>  aud=https://graph.microsoft.com  amr=fido,rsa,mfa
acrs=c10,p1,pfdr     scp=… RoleAssignmentSchedule.ReadWrite.Directory …

— and no xms_cc. Elevate has always handled claims challenges, so it now says so: through MSAL's client configuration on macOS, Windows and the CLI, and as an xms_cc claims request on every token the loopback providers mint themselves, refreshes included, since a refreshed token would otherwise lose it.

The step-up token was thrown away

The retry asked MSAL silently for "the" token for those scopes. MSAL bypasses its access-token cache whenever a claims request is specified and makes no promise to write the result back into it, so the retry could go out with the token from before the step-up. The same live trace showed it plainly — the held token carried acrs=c10, MSAL's silent call for the same account and scopes returned one without it:

interactive returned acrs=c10,p1,pfdr; retrying
AccessToken: serving held step-up token, acrs=c10,p1,pfdr   ← the retry
AccessToken: MSAL silent, source=Broker, acrs=p1,pfdr       ← MSAL's own cache

StepUpTokenCache holds the step-up token for its own lifetime and serves it to the retry, which also spares the second and third role behind the same context their own browser round trip. A forced refresh still goes past it, as it goes past MSAL's cache, so the propagation probes are unchanged.

Verification

  • Live tenant, Windows: the activation that had been refused indefinitely now succeeds.
  • Tests: 668 pass on this base (503 Core + 165 App), plus 224 CLI; all three projects build with warnings as errors and 0 warnings.
  • macOS, verified on a Mac. swift build and swift test are clean: 413 ElevateCore tests pass. The Xcode target — which is the only thing that compiles MSALTokenProvider.swift, the cp1 capability and the step-up cache wiring — builds and its app tests pass (xcodebuild … build / … test, Swift 6.4 / Xcode 26). No new warnings: the two in MSALTokenProvider.swift sit on the pre-existing app.signout and app.acquireToken callbacks. StepUpTokenCache matches the C# one field for field, including the 60s skew and the 5-minute opaque-token lifetime.
  • Still unverified: the macOS loopback path against a real server. It is the one part no test exercises, and it needs a tenant with an authentication-context policy to exercise it.

Worth a reviewer's judgment

cp1 also declares CAE-readiness, so Graph may begin issuing long-lived CAE tokens and sending 401 claims challenges. Elevate already parses those and re-acquires against them, which is why declaring it is accurate rather than a workaround — but it is a real behavioural change beyond the PIM path.

🤖 Generated with Claude Code

A role whose policy carries AuthenticationContext_EndUser_Assignment could
not be activated at all: the request was refused, the browser step-up was
completed, and the retry was refused again with the same message, however
many times it was repeated. Two faults on the path between the step-up and
the retry, and both had to go.

Elevate never declared the cp1 client capability, so its tokens carried no
xms_cc claim. PIM will not honour an authentication context from a client
that has not said it understands a claims challenge: it answers with
RoleAssignmentRequestAcrsValidationFailed and re-issues the same challenge,
even for a token that plainly carries the context — confirmed against a live
tenant, where a token with acrs=c10, amr=fido,rsa,mfa and the right scopes
was refused until cp1 was declared. Elevate has always handled claims
challenges, so it now says so: through MSAL's client configuration on macOS,
Windows and the CLI, and as an xms_cc claims request on every token the
loopback providers mint themselves, refreshes included, since a refreshed
token would otherwise lose it.

And the token the step-up produced was thrown away, the retry asking MSAL
silently for "the" token for those scopes instead. MSAL bypasses its
access-token cache whenever a claims request is specified and makes no
promise to write the result back into it, so the retry could go out with the
token from before the step-up. The same live trace showed it plainly: the
held token carried acrs=c10, MSAL's silent call for the same account and
scopes returned one without it. StepUpTokenCache holds the step-up token for
its own lifetime and serves it to the retry, which also spares the second and
third role behind the same context their own browser round trip. A forced
refresh still goes past it, as it goes past MSAL's cache, so the propagation
probes are unchanged.

Verified against a live tenant on Windows: the activation that had been
refused indefinitely now succeeds.

NOT COMPILED on macOS. Written on a Windows machine with no Swift toolchain,
so 'swift test' has never run against any of it. The Windows and CLI suites
pass (965 tests) and the Windows app was built and driven by hand; the macOS
half mirrors it and needs a run on a Mac. The loopback change there — the
capability on every token request — is the one part no test exercises against
a real server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FrodeHus
FrodeHus enabled auto-merge (squash) September 19, 2026 09:28
@FrodeHus
FrodeHus merged commit 1f01166 into main Sep 19, 2026
17 checks passed
@FrodeHus
FrodeHus deleted the fix/auth-context-activation branch September 19, 2026 11:32
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.

1 participant