Skip to content

Add inbound PoP (SHR) token validation to the Entra sidecar - #4008

Merged
Tanuj Sood (soodt) merged 13 commits into
masterfrom
tanujsood/sidecar-shr-pop
Sep 2, 2026
Merged

Tanuj Sood (soodt) merged 13 commits into
masterfrom
tanujsood/sidecar-shr-pop

Conversation

@soodt

@soodt Tanuj Sood (soodt) commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Add inbound PoP (SHR) token validation to the Entra sidecar

  • You've read the Contributor Guide and Code of Conduct.
  • You've included unit or integration tests for your change, where applicable.
  • You've included inline docs for your change, where applicable.
  • There's an open issue for the PR that you are making. If you'd like to propose a new feature or change, please open an issue to discuss the change or find an existing issue.

Summary of the changes (Less than 80 chars)

Description

Adds inbound Signed HTTP Request (SHR) proof-of-possession validation to the Microsoft.Identity.Web.Sidecar. The /Validate endpoint now accepts either a Bearer token (unchanged) or, for app-only (client-credentials) tokens, the PoP scheme, returning protocol: "PoP" with the embedded token's claims.

Scope

App-only tokens only — no OBO/actor, no user/delegated, no PFT/CDT-over-PoP, no mTLS-PoP.
Timestamp-only freshness; no server nonce.

Tests

  • Unit: tamper / expiry / bad-signature / wrong-issuer / wrong-audience / missing-cnf
    negatives, plus default options-mapping.
  • Round-trip: mock-IdP JWKS end-to-end, and real-Entra E2E pipeline tests.

Docs & samples

Sidecar README, appsettings.json sample block, .http sample, and PoP examples added
to the Python and TypeScript dev-adapter sample clients.

Tanuj Sood (soodt) and others added 5 commits August 28, 2026 11:35
Re-hosts MISE.AuthN's SignedHttpRequestHandler validation to validate
inbound app-only Signed HTTP Request (SHR) Proof-of-Possession tokens at
the sidecar's /Validate endpoint, additively alongside the unchanged
Bearer path.

- Register a second "PoP" AuthenticationHandler; the global default scheme
  stays "Bearer". Only /Validate opts into a [Bearer, PoP] authorization
  policy, so every other endpoint and the Bearer path are unchanged.
- Validate the embedded access token with the same AzureAd/JwtBearer
  TokenValidationParameters (one source of truth for issuer/audience/
  signing-keys/lifetime); clone per request and propagate the
  ConfigurationManager for order-independent JWKS key resolution.
- Timestamp-only (ts) freshness with MISE-compatible default flags
  (m/u/p/ts on; q/h/b off; 5-min lifetime), operator-configurable via a
  new Sidecar:PopValidation options block (AOT-safe: bind DTO, map in code).
- Emit WWW-Authenticate: PoP error="invalid_token" only for PoP-scheme
  requests; log the failure reason server-side at Information (parity with
  Bearer). No error_description over the wire.
- Tests: ARM-shaped round-trip via an in-process mock IdP (live discovery/
  JWKS), outer-SHR and inner-AT negative matrix, config default/override
  mapping, server-side-log assertion, and a Bearer-unchanged guard.

Scope: app-only tokens, timestamp-only. Server nonce, OBO/actor, PFT/CDT,
and mTLS PoP are out of scope. Throwaway spike - not for production; no
PR/merge intended.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses review feedback on the inbound SHR PoP spike.

- Positively assert the embedded access token is app-only in
  ShrPopValidationService: reject a token carrying "scp" (or idtyp != "app")
  before reporting Protocol:"PoP". SHR validation authenticates the inner
  token but does not distinguish app-only from delegated, so without this a
  valid cnf-bound *delegated* token wrapped in an SHR would be accepted - and
  because the /Validate PoP branch skips the delegated-scope gate, it would
  bypass the AzureAd:Scopes check a Bearer caller faces. Fail-closed: the
  handler logs the reason (PopValidationFailed) and returns 401.
- Correct the /Validate comment that claimed skipping the scope check for PoP
  is "the same effective outcome as an app-only Bearer under ACL" - only true
  when AzureAd:Scopes is unset. Document the divergence and a production open
  item (whether PoP needs a symmetric app-permission/roles gate).
- Tests: add CreateDelegatedAccessToken and a
  Validate_WithDelegatedEmbeddedToken_ReturnsUnauthorizedAsync negative
  proving a delegated token in an otherwise-valid SHR is rejected.

ShrPopValidationTests 19/19; sidecar Release build 0 warnings (AOT clean).
Throwaway spike - not for production.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds SidecarPopEndpointsE2ETests (design-doc S9 "E2E (pipeline)" deliverable),
gated behind #if !FROM_GITHUB_ACTION like the sibling Bearer E2E suite. Each
test acquires a REAL app-only token from Entra via MSIdWeb
(GetAuthenticationResultForAppAsync + PoPAuthenticationConfiguration over an
ARM-shaped request line) and drives it through the sidecar's real AzureAd
discovery/JWKS path.

Coverage mirrors the Bearer E2E surface and adds the PoP-specific guarantees
that only a real token proves end-to-end (9 tests):
- Happy: real PoP -> Protocol "PoP"; real Bearer control -> Protocol "Bearer".
- Bad credential: garbage PoP -> 401 with PoP invalid_token challenge.
- Request-line BINDING vs a real SHR: tampered method, tampered URI, and
  missing request-line headers -> 401 (anti-replay guarantee).
- Scheme ISOLATION with real tokens: real SHR on Bearer -> 401; real Bearer
  on PoP -> 401 (schemes never cross-validate).
- No credential -> 401 advertising Bearer.

Verified: all 9 PASS against the real lab tenant. Confirms the lab agent app
issues cnf-bound PoP tokens for the sidecar scope. Uses CurrentUser/My locally
to avoid the LocalMachine private-key ACL/elevation requirement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Refine the inbound Signed HTTP Request (SHR) proof-of-possession validation
for the sidecar: professionalize comments and documentation to repo
conventions and address review feedback.

- Guard the SHR validation catch against OperationCanceledException
- Remove the unused original-IP header constant
- Clarify the reused JwtBearer TokenValidationParameters / ConfigurationManager fallback
- Document the /Validate PoP path, trusted-header and replay-window caveats in the README
- Align test certificate store and test naming with repo conventions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lients

Update the sidecar documentation surface for inbound app-only PoP (Signed HTTP
Request) validation on /Validate, and extend the dev-adapter sample clients to
exercise the PoP path alongside Bearer.

- README: note the WWW-Authenticate: PoP invalid_token challenge on PoP failure.
- appsettings.json: add a commented Sidecar:PopValidation sample block.
- PopValidationOptions / ValidateAuthorizationHeaderResult: clarify XML docs and
  document the Protocol values (Bearer or PoP).
- .http: add a PoP /Validate sample request with original-method/original-uri.
- Python sample client + CLI: accept optional original-method/original-uri and
  document a PoP validate example.
- TypeScript sample: forward the request line for PoP tokens and document it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@soodt
Tanuj Sood (soodt) requested a review from a team as a code owner August 31, 2026 10:45
Comment thread src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs Outdated
Comment thread src/Microsoft.Identity.Web.Sidecar/Configuration/PopValidationOptions.cs Outdated
Comment thread src/Microsoft.Identity.Web.Sidecar/appsettings.json Outdated

@bgavrilMS Bogdan Gavril (bgavrilMS) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved with comments.

Comment thread src/Microsoft.Identity.Web.Sidecar/Pop/ShrPopValidationService.cs Outdated
Tanuj Sood (soodt) and others added 6 commits September 1, 2026 16:21
The sidecar receives only the request line (method + URI) from the caller,
never the SHR-signed headers, so the h claim can never be validated against
them. ValidateH/AcceptUnsignedHeaders were therefore removed from the
Sidecar:PopValidation surface; the secure defaults (h binding off, unsigned
headers accepted) are now hardcoded in the validation parameters. Query (q)
binding stays configurable because the query rides inside original-uri.

Updates the options doc-comments, README and appsettings sample, and reframes
the mapping test to prove the removed keys are now inert. Addresses PR #4008
review feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ValidateTs is removed from the Sidecar:PopValidation surface and hardcoded to
true. Timestamp validation is the only replay/freshness guard in the nonce-less
design, so it must stay on and there is no reason to expose it. Updates the
options doc-comment, the README and appsettings sample, and the mapping test,
which now proves a ValidateTs=false config key is ignored. Addresses PR #4008
review feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add an IValidateOptions<SidecarOptions> validator that bounds
SignedHttpRequestLifetime greater than zero and register it with
ValidateOnStart, so a misconfigured lifetime fails the host boot instead of
throwing on the first PoP request. Also move the
SignedHttpRequestValidationContext construction inside the guarded try in
ValidateAsync, so any residual throw returns a clean 401 rather than a 500.
Adds unit tests for the validator. Addresses PR #4008 review feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reword the /Validate PoP-branch comment and README so it is clear the app-only restriction is a token-type admission check, not an authorization decision. AzureAd:Scopes enforcement targets delegated 'scp' claims (Bearer only); app-only PoP tokens carry none, so authorizing the returned app identity remains the caller's responsibility. Documentation-only; no behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@soodt
Tanuj Sood (soodt) merged commit 966575b into master Sep 2, 2026
9 checks passed
@soodt
Tanuj Sood (soodt) deleted the tanujsood/sidecar-shr-pop branch September 2, 2026 16:57
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