Skip to content

feat: identity - support RS256/ES256 in ExternalJWKSProvider, propagate role/group claims - #3956

Merged
MohammadHaroonAbuomar merged 6 commits into
microsoft:mainfrom
fer-marino:feature/oidc-role-group-claims
Sep 17, 2026
Merged

MohammadHaroonAbuomar merged 6 commits into
microsoft:mainfrom
fer-marino:feature/oidc-role-group-claims

Conversation

@fer-marino

Copy link
Copy Markdown
Contributor

Fixes #3954.

ExternalJWKSProvider could only verify Ed25519-signed tokens (ADR-0007's original agent-to-agent federation scheme), so it never worked against a standard OIDC provider (Keycloak, Okta, etc.) whose default signing key is RS256, not Ed25519 - confirmed against a real Keycloak realm's actual production JWKS. _verify_signature now dispatches on the JWK's own kty/crv (never the JWT header's unverified alg, to avoid algorithm-confusion) to add RS256 and ES256 alongside the existing Ed25519 path.

Separately, ExternalIdentity carried no role/group information at all, so even a successfully verified external token had no path into govern()'s policy context - FederationPolicy/TrustedEndpoint gain configurable (Keycloak-defaulted) dotted-path claim extraction, and ExternalIdentity.as_policy_kwargs() bridges the result into a governed call (safe(**identity.as_policy_kwargs(), ...)), matching this codebase's kwargs-only, no-hidden-magic design.

Also replaces docs/identity.md's dead OIDC/SAML section (referencing a non-existent agentmesh.enterprise.OIDCProvider) with a real, runnable example using this provider.

Related Issue

If no related issue is linked above, you must complete "Problem & Solution", "Impact on Your Work", and "Alternatives Considered" below.

Problem & Solution

Impact on Your Work

Timeline

Alternatives Considered

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Maintenance (dependency updates, CI/CD, refactoring)
  • Security fix

Package(s) Affected

Core & runtime:

  • agent-governance-toolkit-core
  • agent-primitives
  • agent-os
  • agent-mesh
  • agent-runtime
  • agent-sre
  • agent-compliance

Governance & security:

  • agent-mcp-governance
  • agent-rag-governance
  • agent-sandbox
  • agent-discovery
  • agt-policies
  • policy-engine

Platform & tooling:

  • agent-hypervisor
  • agent-lightning
  • agent-marketplace
  • agent-governance-toolkit-cli
  • agent-governance-toolkit-integrations
  • agent-governance-toolkit-protocols
  • agentmesh-integrations (framework integrations)

CLI plugins:

  • agent-governance CLI plugins (copilot-cli / claude-code / opencode / antigravity-cli)

Shared / other:

  • schemas
  • action (GitHub Action)
  • examples
  • docs / root

Testing

Unit Testing

Added 10 tests to test_external_jwks.py covering RS256 (real Keycloak-shaped keypair), ES256, rejection of an unsupported key type, default/per-endpoint role and group claim-path extraction, and as_policy_kwargs(). Each fails on pre-change code and passes after (verified via git stash of the implementation only).

Manual Testing

Fetched a real internal Keycloak realm's actual production JWKS (RS256, 2048-bit) and confirmed the new RSA JWK-parsing/verification path handles it correctly - no test credentials used or required.

Checklist

  • I have linked a related issue above, or completed "Problem & Solution", "Impact on Your Work", and "Alternatives Considered"
  • My code follows the project style guidelines (ruff check)
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass (pytest)
  • I have updated documentation as needed
  • I have signed the Microsoft CLA

Attribution & Prior Art

  • This contribution does not contain code copied or derived from other projects without attribution
  • Any external projects that inspired this design are credited in code comments or documentation
  • If this PR implements functionality similar to an existing open-source project, I have listed it below

Prior art / related projects (if any):

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

If AI tools materially shaped this change, briefly note what was used:
Claude Code drafted the implementation, tests, and documentation under my direction across this session (including the real-Keycloak verification); I reviewed and directed every change described above.

IP, Patents, and Licensing

  • This contribution does not implement patent-pending or patent-encumbered techniques
  • This contribution does not require an NDA or licensing agreement to understand or use
  • Any AI tools used have terms compatible with the MIT License

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests agent-mesh agent-mesh package size/L Large PR (< 500 lines) labels Sep 14, 2026
@github-actions

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • DCO: the single commit 79950a5 carries no Signed-off-by trailer (checked via pulls/3956/commits; the DCO Check run is fork-gated/action_required so it is invisible). Fix: git commit --amend -s with the same author identity and force-push.
  • agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py:209-211 (verify) — no aud check. This PR turns the ADR-0007 agent-token verifier into a general OIDC access-token verifier (docs now present it as such), so any RS256 token the realm mints for any client (e.g. a token stolen from a browser SPA) verifies and its roles flow into policy. Probe: payload aud="some-other-webapp" -> IDENTITY returned. Also nbf is not checked (probe: nbf = now+3000 -> IDENTITY). Fix: add audience: Optional[str] (or list[str]) to TrustedEndpoint/FederationPolicy; when set, reject tokens whose aud (string or list) does not contain it, fail closed; check nbf <= now next to the exp check; in docs/identity.md state that without audience every token from the issuer is accepted. Add tests for aud match, aud mismatch, aud-as-list, and future nbf.
  • Minor, same file: line 169 class docstring still says "validating the Ed25519 signature" — update to list Ed25519/RS256/ES256. Line 475-476: endpoint.role_claim_path or default treats "" as unset (probe: role_claim_path="" still extracts Keycloak defaults), so extraction cannot be disabled per endpoint; use is None and let "" mean none. _extract_claim_list (line 490) cannot address Keycloak resource_access.<client>.roles when the client id contains a dot (probe: path resource_access.my.dotted.client.roles -> []); document the limit or accept a list-of-segments form. Docs line 344: note that realms signing with PS256/RS512/ES384 are rejected (probed: all -> None), since only RS256/ES256/EdDSA dispatch exists.

Comment thread agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py Outdated
Comment thread agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py Outdated
Comment thread agent-governance-python/agent-mesh/docs/identity.md
Comment thread agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py Outdated
@github-actions github-actions Bot added size/XL Extra large PR (500+ lines) and removed size/L Large PR (< 500 lines) labels Sep 15, 2026
@fer-marino

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review - fixed in d48413a, plus a DCO amend on the original commit.

The audience/nbf gap was the one that mattered most, so: TrustedEndpoint.audience (str or list[str]) checked against the token's own aud (str or list per RFC 7519) via a new _audience_satisfied helper, fail-closed when configured but the token has no aud at all. nbf checked next to the existing exp check. Both documented, including that leaving audience unset accepts a token minted for any client the issuer trusts, per your ask.

For caller_role: removed it entirely rather than adding a precedence list - as_policy_kwargs() now returns caller_roles/caller_groups as dicts ({"admin": True, ...}) instead of lists, which turns out to solve both problems at once: no more position-dependence, and caller_roles.admin now evaluates through PolicyRule._eval_expression's bare-boolean-attribute branch as a real, order-independent membership check, since the YAML DSL has no list-membership operator to give a list value any usable signal. Added the order-independence test and the end-to-end govern() test you asked for against #3954.

JWK use/key_ops: rejects use not in (None, "sig") and key_ops missing "verify" before dispatching on kty. TypeError added to the except tuple, verified with a JWK carrying a numeric n and null e. role_claim_path="" now takes an explicit is not None check instead of falling through or. role_claim_path/group_claim_path now also accept a pre-split list[str], for a dotted-client-id case like resource_access.my.dotted.client.roles. Docstring updated to name RS256/ES256 alongside Ed25519 and that anything else is rejected.

docs/identity.md's example: added the missing govern import, made read_doc accept **policy_ctx, and pinned the corrected version with a test that runs it verbatim end to end (mocked JWKS fetch, real govern() call).

Comment thread agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py Outdated
@fer-marino

Copy link
Copy Markdown
Contributor Author

All four fixed in 307cd57.

key_ops: now checks isinstance(key_ops, list) before the "verify" not in check, so a non-list value fails closed instead of raising, and a malformed string like "noverify" can't sneak past via substring match anymore. Parametrized test covers int/bool/the malformed-string case.

nbf/exp symmetry: nbf now fails closed on a non-numeric value the same way exp already did.

Empty audience: added a field_validator on TrustedEndpoint.audience rejecting both "" and [] at construction, with the reasoning for each (empty string would match a token's own empty aud; empty list can never intersect anything and silently locks out every token).

docs/identity.md: added the missing None check before the identity.as_policy_kwargs() call, and a test running the example through both branches.

For caller_groups/caller_roles still not covering group paths or hyphenated names - agreed this is real, and I went with your option (a) rather than touching the shared DSL: documented the exact name grammar (\w+, no dots/slashes/hyphens) on as_policy_kwargs() and in the docs, and added a test pinning both failure directions through govern() itself (an allow rule against "/engineering" silently denies; a deny rule against "default-roles-company" silently lets the call through) so the limit is explicit rather than a silent surprise. Left the DSL change to #3924 since it's cross-cutting and already tracked there.

87/87 tests pass. Also caught two new cspell misses from this round's own comments (footgun, noverify) plus OAEP from the fix itself - verified with the actual CI gate, exits 0.

Comment thread agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py Outdated
@fer-marino

Copy link
Copy Markdown
Contributor Author

Fixed in b5fc7f0 - _audience_not_empty now splits into members (wrapping a bare string) and rejects if the list is empty or any member is "", so [""] / ["", "x"] are caught the same as ""/[]. Extended test_trusted_endpoint_rejects_empty_audience with both cases per your repro.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified at b5fc7f0: the audience validator now rejects a list containing an empty member, and a token signed with aud "" or aud [""] no longer verifies against any configured audience. The earlier fixes (roles and groups emitted as dicts, aud and nbf checks, JWK use and key_ops, TypeError handling) are unchanged; 58 tests, ruff and cspell pass; DCO present on every single-parent commit.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • tests/test_external_jwks.py::test_as_policy_kwargs_group_paths_and_hyphenated_roles_never_match fails on the merged head in all three test (agent-mesh) jobs: GovernanceDenied, rule 'deny-default-role' matched. The test asserts that a group path or hyphenated role name can never match a YAML rule, but main's evaluator changed under #3924 (anchored and quote-matched operators), so the expectation no longer holds against current main. Please rebase, re-run the file, and either fix the expectation or the emitted kwargs so the intended behaviour (group paths and hyphenated names do not silently match) is actually what the evaluator does today.
  • License headers: the 'Check license headers' jobs fail on agent-governance-python/agent-mesh/src/agentmesh/identity/external_jwks.py and agent-governance-python/agent-mesh/tests/test_external_jwks.py. Please add the repository's standard header (copy the first lines of any sibling module under agentmesh/identity/).
  • Security Audit Required: this PR touches core security surfaces (identity verification), so the gate needs docs/security/audits/2026-09-15-.md covering what changed and why, the threat-model impact (new attack surfaces and mitigations: RS256/ES256 dispatch, aud and nbf checks, JWK use/key_ops, role and group claim propagation) and the test coverage for the security-relevant behaviour. Existing files in docs/security/audits/ show the expected shape.

…te role/group claims

Fixes microsoft#3954.

ExternalJWKSProvider could only verify Ed25519-signed tokens
(ADR-0007's original agent-to-agent federation scheme), so it never
worked against a standard OIDC provider (Keycloak, Okta, etc.) whose
default signing key is RS256, not Ed25519 - confirmed against a real
Keycloak realm's actual production JWKS. _verify_signature now
dispatches on the JWK's own kty/crv (never the JWT header's unverified
alg, to avoid algorithm-confusion) to add RS256 and ES256 alongside
the existing Ed25519 path.

Separately, ExternalIdentity carried no role/group information at all,
so even a successfully verified external token had no path into
govern()'s policy context - FederationPolicy/TrustedEndpoint gain
configurable (Keycloak-defaulted) dotted-path claim extraction, and
ExternalIdentity.as_policy_kwargs() bridges the result into a governed
call (safe(**identity.as_policy_kwargs(), ...)), matching this
codebase's kwargs-only, no-hidden-magic design.

Also replaces docs/identity.md's dead OIDC/SAML section (referencing
a non-existent agentmesh.enterprise.OIDCProvider) with a real,
runnable example using this provider.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…nd four other gaps

MohammadHaroonAbuomar's review found several real gaps, each fixed here:

Audience (aud) and not-before (nbf) - the critical one. A verified
RS256 signature only proves the issuer minted the token, not that it
was minted for this verifier: without an audience check, a token
issued for any other client the issuer trusts (e.g. one lifted from a
browser SPA) verified identically to one this integration actually
requested. Added TrustedEndpoint.audience (str or list[str]); when
set, the token's own aud (also str-or-list per RFC 7519 §4.1.3) must
intersect it, checked via the new _audience_satisfied helper. Also
added the missing nbf check next to the existing exp check. Both are
documented on TrustedEndpoint and in docs/identity.md, including that
leaving audience unset accepts a token minted for any client.

caller_role order-dependence. as_policy_kwargs() picked roles[0] as a
single "primary" role, but role order in a verified token is whatever
the issuer happened to serialize - a YAML rule keyed on caller_role
gave different decisions for the same role set depending on iteration
order, so a deny-by-role rule was bypassable just by how roles sorted
that request. Removed caller_role. caller_roles/caller_groups are now
dicts ({"admin": True, ...}) instead of lists: GovernedCallable
passes a dict kwarg through to the policy context as-is, and
PolicyRule._eval_expression's bare-boolean-attribute branch evaluates
caller_roles.<role> as a plain, order-independent truthiness check -
the YAML DSL has no list-membership operator, so a list value gave
policies no usable signal at all. New tests cover order-independence
directly and end to end through govern(), per the linked issue microsoft#3954.

JWK use/key_ops ignored. A realm's JWKS can publish encryption keys
(use="enc", e.g. RSA-OAEP) alongside signing keys; _verify_signature
now rejects use not in (None, "sig") and key_ops missing "verify"
before dispatching on kty, per RFC 7517 §4.2/4.3.

except (..., ValueError) missed TypeError - a JWKS entry with a
non-string numeric member (n: 12345, e: null) raised out of
rsa.RSAPublicNumbers/verify() instead of failing closed like every
other malformed-key case. Added TypeError to the tuple.

role_claim_path="" was being treated as unset (`or` folds "" and None
together) rather than "disable extraction for this endpoint" -
switched to an explicit `is not None` check.

resource_access.<client_id>.roles can't be expressed as a dotted
string when the client id itself contains a dot - splitting on "."
can't tell that dot from the path separator. role_claim_path/
group_claim_path now also accept a pre-split list[str] of literal
segments.

Docstring/docs: the class docstring named only Ed25519; updated to
name all three supported algorithms and that everything else (PS256,
RS512, ES384, ...) is rejected, not silently skipped. docs/identity.md's
worked example didn't import govern, and read_doc didn't accept the
kwargs as_policy_kwargs() spreads into it (TypeError on the very call
the doc shows) - fixed both and pinned the corrected example with a
test that runs it verbatim.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…pty audience, docs None-deref, group-path limit

Four more real gaps from MohammadHaroonAbuomar's follow-up review:

key_ops gate ran before the try/except and used a bare "verify" not in
key_ops check: a non-list value (123, true) raised TypeError instead
of failing closed, and a malformed string like "noverify" passed the
check via substring match ("verify" IS a substring of "noverify"),
letting an encryption-only key verify a signature anyway. Now requires
isinstance(key_ops, list) before the membership check, so any
non-list shape fails closed the same way the rest of _verify_signature
already does for a malformed JWKS entry. Parametrized test covers all
three shapes (int, bool, malformed string).

nbf was checked asymmetrically with exp: a non-numeric nbf ("9999999999")
was silently treated as absent instead of failing closed like a
non-numeric exp already does. Made the two checks match.

TrustedEndpoint(audience="") would match a token whose own aud is also
"" - silently trusting a token that asserts no audience at all - and
audience=[] can never intersect anything, rejecting every token with
no signal the field is misconfigured rather than intentionally
locking down. Added a field_validator rejecting both at construction.

docs/identity.md's worked example said "None if verification fails"
but then dereferenced identity unconditionally two lines later -
AttributeError on any rejected token, not the PermissionError a reader
would expect. Added the missing check and a test that runs the doc's
example through both branches (success and rejection).

The as_policy_kwargs() dict-shape fix from the previous round doesn't
fully close the gap it was meant to: PolicyRule's bare-attribute
matcher still can't address a Keycloak-shaped "/engineering" group
path or a hyphenated role like "default-roles-company" (it splits on
"." and requires \w+ segments), so those still never match - silently,
not with an error. Rather than adding a list-membership operator to
the shared policy DSL (a larger, cross-cutting change tracked
separately in microsoft#3924), documented the exact name grammar on
as_policy_kwargs() and in docs/identity.md, and added a test pinning
both failure directions (an allow rule against a group path silently
denies; a deny rule against a hyphenated role silently lets the call
through) so the limit stays visible instead of reappearing as a
surprise.

Also added OAEP/footgun/noverify to .cspell-repo-terms.txt for words
introduced by this round's own comments and tests - verified against
the actual CI gate (scripts/ci/changed_lines.py --base origin/main
--mode added-lines piped into cspell@8.17.3 --config .cspell.json),
exits 0.

87/87 tests pass (test_external_jwks.py + test_govern.py).

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
MohammadHaroonAbuomar's third review round on the empty-audience
guard: len(v) == 0 only catches "" and [], not a list that contains
an empty-string member ([""], ["", "x"]) - that slips through and
still matches a token whose own aud claim is "".

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
…t, security audit doc

Rebased onto main (post-microsoft#3924) surfaced a stale assumption in
test_as_policy_kwargs_group_paths_and_hyphenated_roles_never_match:
policy.py's unrecognized-condition fallback now fails closed for any
non-allow rule, so the deny-rule half of the pinned scenario denies
instead of letting the call through. Updated the test and the
as_policy_kwargs()/docs/identity.md documentation to describe the
current (still-limited, but no-longer-fail-open) behavior accurately.

Also adds the missing repo-standard license header to external_jwks.py
and its test file, and a security-audit doc under docs/security/audits/
covering the identity-surface changes across all three review rounds,
per the CI gate and MohammadHaroonAbuomar's review.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
@github-actions github-actions Bot added the security Security-related issues label Sep 16, 2026
@fer-marino

Copy link
Copy Markdown
Contributor Author

Fixed in 920ca89: rebased onto main (post-#3924). The pinned test's deny-rule expectation was stale: policy.py's unrecognized-condition fallback now fails closed for any non-allow rule, so deny_on_role now correctly asserts GovernanceDenied instead of == "executed". Updated the as_policy_kwargs() and docs/identity.md commentary to describe this accurately - the DSL still can't address the hyphenated/group-path name itself, the deny only fires via the general fail-closed fallback. Also added the repo-standard license header to external_jwks.py and test_external_jwks.py, and docs/security/audits/2026-09-15-oidc-external-jwks-role-group-federation.md covering all three review rounds. Ran the license-headers script, security-audit-required.sh, and the cspell added-lines gate locally - all pass, plus the full test_external_jwks.py/test_govern.py suite (91 passed).

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  • docs/security/audits/2026-09-15-oidc-external-jwks-role-group-federation.md: the Spell Check job (cspell 8.17.3, added-lines mode via scripts/ci/changed_lines.py, config .cspell.json) fails on this head with 5 unknown words, all in the new doc: line 30 'Haroon', 'Abuomar' (from '(MohammadHaroonAbuomar)'); lines 47 and 57 'footguns'; line 65 'unaddressable'. Fix: drop the reviewer name on line 30 (write 'three rounds of maintainer review'), replace 'footguns' with 'misconfigurations' or 'footgun cases' ('footgun' is already in .cspell-repo-terms.txt), and 'unaddressable' with 'not addressable'; or add the terms to .cspell-repo-terms.txt. Also correct line 73, which claims the cspell added-lines gate is satisfied, and line 65, which cites pre-rebase commit d48413a (now db2172c on this branch).

…curity audit doc

The audit doc itself failed the Spell Check job: the reviewer's name
written out ("MohammadHaroonAbuomar"), and "footguns"/"unaddressable"
weren't in the wordlist. Reworded rather than adding to the wordlist,
and fixed a stale pre-rebase commit hash (d48413a -> db2172c) plus
the round-3 fix-commit reference and the cspell-status claim, both
left inaccurate by the rebase.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
@fer-marino

Copy link
Copy Markdown
Contributor Author

Fixed in a066453 - the audit doc itself failed its own spell-check gate. Dropped the reviewer's name ("three rounds of maintainer review" instead), reworded the two flagged terms instead of adding to the wordlist, and fixed the stale pre-rebase commit reference (d48413a -> db2172c, still noting the old hash for anyone diffing against pre-rebase history) plus the cspell-status claim your review caught. Re-verified: cspell added-lines gate exits 0, and the full test_external_jwks.py/test_govern.py suite still passes (91 passed) since this was a docs-only change.

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified at a066453: the audit doc no longer trips the spell-check job (reproduced with the pinned cspell in added-lines mode, on the head and on a simulated merge with main) and the two stale statements are corrected. Code unchanged from the previously verified head: aud and nbf checks, JWK use and key_ops, the audience validator, role and group dicts. 58 tests pass.

@MohammadHaroonAbuomar
MohammadHaroonAbuomar merged commit d6798bf into microsoft:main Sep 17, 2026
127 checks passed
Yuvraj Singh (yuvrajsingh2428) pushed a commit to yuvrajsingh2428/agent-governance-toolkit that referenced this pull request Oct 1, 2026
…te role/group claims (microsoft#3956)

* feat: identity - support RS256/ES256 in ExternalJWKSProvider, propagate role/group claims

Fixes microsoft#3954.

ExternalJWKSProvider could only verify Ed25519-signed tokens
(ADR-0007's original agent-to-agent federation scheme), so it never
worked against a standard OIDC provider (Keycloak, Okta, etc.) whose
default signing key is RS256, not Ed25519 - confirmed against a real
Keycloak realm's actual production JWKS. _verify_signature now
dispatches on the JWK's own kty/crv (never the JWT header's unverified
alg, to avoid algorithm-confusion) to add RS256 and ES256 alongside
the existing Ed25519 path.

Separately, ExternalIdentity carried no role/group information at all,
so even a successfully verified external token had no path into
govern()'s policy context - FederationPolicy/TrustedEndpoint gain
configurable (Keycloak-defaulted) dotted-path claim extraction, and
ExternalIdentity.as_policy_kwargs() bridges the result into a governed
call (safe(**identity.as_policy_kwargs(), ...)), matching this
codebase's kwargs-only, no-hidden-magic design.

Also replaces docs/identity.md's dead OIDC/SAML section (referencing
a non-existent agentmesh.enterprise.OIDCProvider) with a real,
runnable example using this provider.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address review — audience/nbf checks, role-order bug, JWK use, and four other gaps

MohammadHaroonAbuomar's review found several real gaps, each fixed here:

Audience (aud) and not-before (nbf) - the critical one. A verified
RS256 signature only proves the issuer minted the token, not that it
was minted for this verifier: without an audience check, a token
issued for any other client the issuer trusts (e.g. one lifted from a
browser SPA) verified identically to one this integration actually
requested. Added TrustedEndpoint.audience (str or list[str]); when
set, the token's own aud (also str-or-list per RFC 7519 §4.1.3) must
intersect it, checked via the new _audience_satisfied helper. Also
added the missing nbf check next to the existing exp check. Both are
documented on TrustedEndpoint and in docs/identity.md, including that
leaving audience unset accepts a token minted for any client.

caller_role order-dependence. as_policy_kwargs() picked roles[0] as a
single "primary" role, but role order in a verified token is whatever
the issuer happened to serialize - a YAML rule keyed on caller_role
gave different decisions for the same role set depending on iteration
order, so a deny-by-role rule was bypassable just by how roles sorted
that request. Removed caller_role. caller_roles/caller_groups are now
dicts ({"admin": True, ...}) instead of lists: GovernedCallable
passes a dict kwarg through to the policy context as-is, and
PolicyRule._eval_expression's bare-boolean-attribute branch evaluates
caller_roles.<role> as a plain, order-independent truthiness check -
the YAML DSL has no list-membership operator, so a list value gave
policies no usable signal at all. New tests cover order-independence
directly and end to end through govern(), per the linked issue microsoft#3954.

JWK use/key_ops ignored. A realm's JWKS can publish encryption keys
(use="enc", e.g. RSA-OAEP) alongside signing keys; _verify_signature
now rejects use not in (None, "sig") and key_ops missing "verify"
before dispatching on kty, per RFC 7517 §4.2/4.3.

except (..., ValueError) missed TypeError - a JWKS entry with a
non-string numeric member (n: 12345, e: null) raised out of
rsa.RSAPublicNumbers/verify() instead of failing closed like every
other malformed-key case. Added TypeError to the tuple.

role_claim_path="" was being treated as unset (`or` folds "" and None
together) rather than "disable extraction for this endpoint" -
switched to an explicit `is not None` check.

resource_access.<client_id>.roles can't be expressed as a dotted
string when the client id itself contains a dot - splitting on "."
can't tell that dot from the path separator. role_claim_path/
group_claim_path now also accept a pre-split list[str] of literal
segments.

Docstring/docs: the class docstring named only Ed25519; updated to
name all three supported algorithms and that everything else (PS256,
RS512, ES384, ...) is rejected, not silently skipped. docs/identity.md's
worked example didn't import govern, and read_doc didn't accept the
kwargs as_policy_kwargs() spreads into it (TypeError on the very call
the doc shows) - fixed both and pinned the corrected example with a
test that runs it verbatim.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address second review — key_ops fail-open, nbf/exp asymmetry, empty audience, docs None-deref, group-path limit

Four more real gaps from MohammadHaroonAbuomar's follow-up review:

key_ops gate ran before the try/except and used a bare "verify" not in
key_ops check: a non-list value (123, true) raised TypeError instead
of failing closed, and a malformed string like "noverify" passed the
check via substring match ("verify" IS a substring of "noverify"),
letting an encryption-only key verify a signature anyway. Now requires
isinstance(key_ops, list) before the membership check, so any
non-list shape fails closed the same way the rest of _verify_signature
already does for a malformed JWKS entry. Parametrized test covers all
three shapes (int, bool, malformed string).

nbf was checked asymmetrically with exp: a non-numeric nbf ("9999999999")
was silently treated as absent instead of failing closed like a
non-numeric exp already does. Made the two checks match.

TrustedEndpoint(audience="") would match a token whose own aud is also
"" - silently trusting a token that asserts no audience at all - and
audience=[] can never intersect anything, rejecting every token with
no signal the field is misconfigured rather than intentionally
locking down. Added a field_validator rejecting both at construction.

docs/identity.md's worked example said "None if verification fails"
but then dereferenced identity unconditionally two lines later -
AttributeError on any rejected token, not the PermissionError a reader
would expect. Added the missing check and a test that runs the doc's
example through both branches (success and rejection).

The as_policy_kwargs() dict-shape fix from the previous round doesn't
fully close the gap it was meant to: PolicyRule's bare-attribute
matcher still can't address a Keycloak-shaped "/engineering" group
path or a hyphenated role like "default-roles-company" (it splits on
"." and requires \w+ segments), so those still never match - silently,
not with an error. Rather than adding a list-membership operator to
the shared policy DSL (a larger, cross-cutting change tracked
separately in microsoft#3924), documented the exact name grammar on
as_policy_kwargs() and in docs/identity.md, and added a test pinning
both failure directions (an allow rule against a group path silently
denies; a deny rule against a hyphenated role silently lets the call
through) so the limit stays visible instead of reappearing as a
surprise.

Also added OAEP/footgun/noverify to .cspell-repo-terms.txt for words
introduced by this round's own comments and tests - verified against
the actual CI gate (scripts/ci/changed_lines.py --base origin/main
--mode added-lines piped into cspell@8.17.3 --config .cspell.json),
exits 0.

87/87 tests pass (test_external_jwks.py + test_govern.py).

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: reject audience lists containing an empty-string member

MohammadHaroonAbuomar's third review round on the empty-audience
guard: len(v) == 0 only catches "" and [], not a list that contains
an empty-string member ([""], ["", "x"]) - that slips through and
still matches a token whose own aud claim is "".

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address third review — license headers, stale DSL-limitation test, security audit doc

Rebased onto main (post-microsoft#3924) surfaced a stale assumption in
test_as_policy_kwargs_group_paths_and_hyphenated_roles_never_match:
policy.py's unrecognized-condition fallback now fails closed for any
non-allow rule, so the deny-rule half of the pinned scenario denies
instead of letting the call through. Updated the test and the
as_policy_kwargs()/docs/identity.md documentation to describe the
current (still-limited, but no-longer-fail-open) behavior accurately.

Also adds the missing repo-standard license header to external_jwks.py
and its test file, and a security-audit doc under docs/security/audits/
covering the identity-surface changes across all three review rounds,
per the CI gate and MohammadHaroonAbuomar's review.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

* fix: address fourth review — cspell misses and stale commit ref in security audit doc

The audit doc itself failed the Spell Check job: the reviewer's name
written out ("MohammadHaroonAbuomar"), and "footguns"/"unaddressable"
weren't in the wordlist. Reworded rather than adding to the wordlist,
and fixed a stale pre-rebase commit hash (d48413a -> db2172c) plus
the round-3 fix-commit reference and the cspell-status claim, both
left inaccurate by the rebase.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>

---------

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
Signed-off-by: yuvrajsingh2428 <offcyuvi2428@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-mesh agent-mesh package documentation Improvements or additions to documentation security Security-related issues size/XL Extra large PR (500+ lines) tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: ExternalJWKSProvider (generic OIDC) doesn't extract role/group claims into govern() policy context

2 participants