Skip to content

feat(federation): Add external JWT verification - #1458

Merged
gtema merged 1 commit into
openstack-experimental:mainfrom
thinhphucnguyen:thinh-branch
Oct 23, 2026
Merged

gtema merged 1 commit into
openstack-experimental:mainfrom
thinhphucnguyen:thinh-branch

Conversation

@thinhphucnguyen

@thinhphucnguyen thinhphucnguyen commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds the pieces to verify an external JWT (the subject_token of the token exchange, part of #945 / #1018) against its issuer.

  • extract_issuer reads iss from the unverified payload. It is only used to pick an entry from the trusted issuers, verify_jwt still checks the signed iss.
  • verify_external_jwt checks the issuer against the trusted list before any network call. The keys come from the issuer's jwks_url if it has one, otherwise from OIDC discovery. An empty audience list is rejected.
  • New OidcError variants: MalformedToken, MissingIssuer, UntrustedIssuer and AudienceRequired.

Nothing calls verify_external_jwt yet, it will be wired into the token exchange endpoint next, so it is #[allow(dead_code)] for now.

Done as part of the BU EC528 class project (ec528-fall26/keystone-ng#6).

Test plan

21 new unit tests in oidc_utils: valid token, tampered payload and signature, wrong issuer, untrusted issuer, missing or non-string iss, malformed tokens, empty audience list, static jwks_url preferred over discovery, discovery issuer mismatch, and no network call for an untrusted issuer.

  • cargo test -p openstack-keystone --lib federation::api::oidc_utils: 63 passed, 0 failed

Security review checklist

Required if this diff touches authentication, scope, delegation, tokens,
credentials, EC2, trusts, application credentials, or OPA policy input.

Delete this section entirely if it doesn't apply. See
doc/src/contributor/security-model.md
§7 for the full context behind each item.

This adds JWT signature verification only. It does not read or build scope, delegation, Credentials or OPA input, and nothing calls it yet. Answers below are N/A for that reason.

  • Does any delegation/authorization decision read the scope
    (project_id, ScopeInfo) where it should read the chain
    (authentication_context(), delegation object)? (I1/I2)
    N/A, no scope or delegation is read.
  • New delegation-sensitive policy rule? Is the fact it needs projected
    onto Credentials from the chain, and does the rule anchor on
    delegated_project_id + carry the scope-drift tripwire? (I2/I3)
    N/A, no policy changes.
  • New scope shape or redemption path for a delegated auth? Are effective
    roles still bounded by the delegation? Added a test that a restricted
    delegation cannot exceed its roles via the new path? (I4)
    N/A, no new scope shape or redemption path.
  • New ScopeInfo variant or auth method? Updated
    validate_scope_boundaries(), calculate_effective_roles(),
    fully_resolved(), and Credentials::try_from — all without adding a
    wildcard _ => arm? (I5, Gate J)
    N/A, no new variant or auth method. The only new match arms are in From<OidcError> for KeystoneApiError.
  • New lookup by a client-derivable key (like sha256(access))? Does it
    re-assert type/shape after fetch? (I6)
    N/A. The issuer is client-supplied, but it is matched against an allowlist before any lookup or network call.
  • Does any new data reaching OPA include secrets/decrypted blobs? (I7,
    Gate I)
    N/A, nothing reaches OPA.
  • New list/collection endpoint? Does it re-check each item with the
    per-item read policy? (I8)
    N/A, no endpoint.
  • Same endpoint: can an unprivileged caller request the whole collection
    and leave the per-item pass to sort it out? Is the value the
    collection policy gates on the same one the driver actually filters
    by? (I8a)
    N/A, no endpoint.
  • Does the change let a narrow auth method be broadened by a
    request-supplied scope? (I5)
    N/A, no scope handling.
  • Are there negative tests proving the escape is blocked, not just
    positive tests proving the happy path works?
    Yes, for the checks this code adds: tampered payload and signature, wrong issuer, untrusted issuer, missing or non-string iss, malformed tokens.
  • Does the test drive ValidatedSecurityContext::new_for_scope()
    end-to-end, not just the inner helper (calculate_effective_roles,
    validate_scope_boundaries) in isolation?
    N/A, no security context is built here. This will matter when the token exchange endpoint is wired in.

@gtema

gtema commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Questions:

* Where should the trusted issuer list come from? My guess is `bound_issuer` on the identity provider record.

yes, bound_issuer is the natural source

* `verify_jwt` requires `sub` and `aud`. Is that right for RFC 8693 subject tokens?

the RFC does not require sub/aud on the subject token, but we need them: sub is necessary for the mapping, aud is safer. We definitely should ensure aud doesn't get optional by accident

* Should this use discovery or the static `jwks_url` / `jwt_validation_pubkeys` on the IdP?

should support both with preferring static keys to save a roundtrip

let metadata = discover(trusted, client).await?;
let jwks = fetch_jwks(&metadata.jwks_uri, client).await?;

verify_jwt(token, &jwks, Some(&issuer), None, audiences)

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.

accepting here the empty audiences results in a complete skip audience verification (see build_validation above). For the token exchange this is the confused-deputy vulnerabilities. Let's through an error when audiences is empty to prevent own mistakes

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.

Got it! I changed it so that verify_external_jwt now returns an AudienceRequired error instead of skipping the aud check. A test is also written to check it.

let metadata = discover(trusted, client).await?;
let jwks = fetch_jwks(&metadata.jwks_uri, client).await?;

verify_jwt(token, &jwks, Some(&issuer), None, audiences)

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.

Perhaps it is more logical to use the trusted issuer rather than simply the issuer as a claim from the token. We do verify it above, so it looks more sane to use the verified/trusted issuer

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.

Fixed. verify_jwt now gets the allowlisted issuer, not the string from the token.

Comment thread crates/keystone/src/federation/api/oidc_utils.rs
Comment thread crates/keystone/src/federation/api/error.rs
Comment thread crates/keystone/src/federation/api/oidc_utils.rs
)
.await;
assert!(
result.is_err(),

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.

let's check for JwtDecode error here explicitly. The tampered-n approach is also fragile, because a bad modulus may fail in DecodingKey::from_jwk rather than at signature verification.

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.

I changed it so that the it now uses a valid second RSA key under the same kid and asserts JwtDecode, so it can’t pass because of a bad modulus.

@gtema
gtema marked this pull request as draft October 12, 2026 11:09
@thinhphucnguyen thinhphucnguyen changed the title feat(federation): Add external JWT issuer extraction and verification feat(federation): Add external JWT verification Oct 18, 2026
@thinhphucnguyen
thinhphucnguyen marked this pull request as ready for review October 20, 2026 00:00
Comment thread crates/keystone/src/federation/api/oidc_utils.rs Fixed
Comment thread crates/keystone/src/federation/api/oidc_utils.rs Fixed

@gtema gtema 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.

you asked whether it should implement validation with static keys only, or also with discovery. The code now only implements static keys, so you should either change the PR description or also implement discovery

Comment thread crates/keystone/src/federation/api/error.rs
Comment thread crates/keystone/src/federation/api/oidc_utils.rs Fixed
@gtema

gtema commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

you need to drop "merge" commit, rebase the branch and then force-push. Merge commits are failing the commit message check and should not be used since they break change logs. Unfortunately there is no way to disable it in the UI

Add helpers to verify an external JWT, such as the subject_token of an
RFC 8693 token exchange, against a list of trusted issuers.

The issuer is read from the unverified payload only to pick an entry in
the allowlist. Discovery and JWKS requests go to the allowlisted values,
and verify_jwt checks the signed iss claim against the same entry. A
static JWKS URL is preferred over discovery to save a round trip.

An empty audience list is rejected, because verify_jwt skips the aud
check in that case. The untrusted issuer is kept out of the error and
the logs.

Signed-off-by: Thinh Phuc Nguyen <nguyenthinhphuc1901@gmail.com>
@thinhphucnguyen

Copy link
Copy Markdown
Contributor Author

you asked whether it should implement validation with static keys only, or also with discovery. The code now only implements static keys, so you should either change the PR description or also implement discovery

Thank you for the feedback! Both are are in. In the code, verify_external_jwt uses the issuer's jwks_url if it has one, and if not it does discovery (the match on trusted.jwks_url in oidc_utils.rs). There's a test for each path.

I also changed the dead_code attributes back to allow, added the blank line in error.rs, and rebased so the merge commit is gone.

@thinhphucnguyen
thinhphucnguyen requested a review from gtema October 22, 2026 19:12

@gtema gtema 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.

so is the discovery of the jwks deferred to a separate PR? If so please state this in the PR

@thinhphucnguyen

Copy link
Copy Markdown
Contributor Author

so is the discovery of the jwks deferred to a separate PR? If so please state this in the PR

It's not deferred, it's in this PR. verify_external_jwt uses the issuer's jwks_url if it has one, and if not it runs discovery on the issuer and takes the keys from jwks_uri (the match on trusted.jwks_url). verify_external_jwt_prefers_static_jwks_url_over_discovery covers the static path, and verify_external_jwt_accepts_valid_token goes through discovery.

If you meant something else, tell me which line and I'll take a look asap!

@gtema gtema 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.

ah right, I missed that from previous reviews. Now we have a potential issue with caching (or technically with lack hereof), but that is something for the followup.

@gtema
gtema added this pull request to the merge queue Oct 23, 2026
Merged via the queue into openstack-experimental:main with commit 983a4f7 Oct 23, 2026
39 checks passed
@thinhphucnguyen
thinhphucnguyen deleted the thinh-branch branch October 23, 2026 12:03
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