Skip to content

feat(oauth2): Add shared OAuth2 subject-token validation - #1560

Draft
DomMurphy-git wants to merge 4 commits into
openstack-experimental:mainfrom
millionsouls:doms_branch
Draft

DomMurphy-git wants to merge 4 commits into
openstack-experimental:mainfrom
millionsouls:doms_branch

Conversation

@DomMurphy-git

Copy link
Copy Markdown

Summary

Add profile-driven JWT validation for OAuth2 subject tokens, providing a shared
verification path for Keystone-native tokens and short-lived external
assertions.

The Keystone-native OpenStack access-token verifier now uses the shared
validation function while retaining its existing token-use, revocation, and
delegation checks. The external-assertion profile constrains issuers and
audience, requires the configured claims, and limits token age and lifetime to
300 seconds with 60 seconds of clock skew.

The shared verifier explicitly rejects missing required claims, including
custom claims such as jti that the JWT library does not require by itself.
Delegation checks also recognize ServiceGrant and enforce its delegated
project boundary, with a regression test for scope drift.

This is the profile/API prerequisite for
#1355 and the external subject-token work in
#1018. It does not wire profile selection into
the token endpoint, fetch external issuer keys, persist replay state, or invoke
the mapping engine.

ADR 0036 §6.4 describes the shared prerequisite and the remaining work.

Test plan

  • git diff --check — passed.
  • cargo fmt --all -- --check — passed. Stable rustfmt prints warnings for
    nightly-only options in rustfmt.toml, but reports no formatting diffs.
  • cd crates/core && cargo test -p openstack-keystone-core verify::tests —
    passed: 46 tests.
  • cargo clippy -p openstack-keystone-core --all-targets — passed. Clippy
    emitted existing dead-code warnings in crates/core/src/rate_limit.rs.

Added or updated focused tests for valid assertions, untrusted issuers,
incorrect and additional audiences, missing jti, invalid profile
configuration, future issuance, excessive age or lifetime, invalid signatures,
and algorithm mismatch. Added coverage for ServiceGrant scope-drift rejection.
The native-verifier tests also cover accepted and rejected audiences,
revocation, and delegation checks.

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.

  • No delegation/authorization decision was changed to read token scope
    instead of the authentication chain. This change verifies JWT claims and
    enforces the existing delegation boundary. (I1/I2)
  • No delegation-sensitive policy rule was added. (I2/I3)
  • No delegated-auth scope or redemption path was added. (I4)
  • No ScopeInfo variant or authentication method was added. (I5, Gate J)
  • No lookup by a client-derivable key was added. (I6)
  • No new data is sent to OPA; this change does not modify policy input.
    (I7, Gate I)
  • No list or collection endpoint was added. (I8)
  • No collection filter or per-item authorization behavior was changed.
    (I8a)
  • No scope-selection or rescope behavior was changed. (I5)
  • Negative tests cover rejected issuers, audiences, missing claims,
    invalid time claims, bad signatures, algorithm mismatch, and
    ServiceGrant scope drift.
  • ValidatedSecurityContext::new_for_scope() is not involved; this change
    does not build a security context or change scope handling.

Introduce reusable validation profiles and a shared JWT verifier for
subject tokens. Preserve the existing Keystone-native audience,
revocation, and delegation checks while adding a stricter profile for
short-lived external assertions.

Add tests for issuer and audience restrictions, required claims, time
limits, invalid profiles, signatures, and signing algorithms. This
provides validation groundwork for token exchange without wiring the
exchange endpoint or replay checking.

Signed-off-by: Dominic Murphy <ddmurphy@bu.edu>
Re-export the subject-token validation profile and shared JWT verifier
from the OAuth2 client module so callers can use the new API without
depending on the verifier's internal module path.

Signed-off-by: Dominic Murphy <ddmurphy@bu.edu>
Update ADR 0036 to record the shared validation-profile API and its
Keystone-native and external-assertion profiles. Clarify that endpoint
dispatch, external issuer key discovery, replay-cache checks, and
mapping-engine integration remain separate work.

Signed-off-by: Dominic Murphy <ddmurphy@bu.edu>
The verifier no longer compiled after access-token claims added
 and . Specify the claims type, initialize ,
and include service grants in delegated-project validation.

Also enforce profile-required claims after JWT decoding, since the JWT
library does not check custom required claims such as . Update
the missing-claim assertion and move the future- test beyond the
60-second clock-skew boundary to avoid timing-dependent failures.

running 46 tests
test oauth2_client::verify::tests::subject_token_profile_requires_jti ... ok
test oauth2_client::verify::tests::test_expired_token_rejected ... ok
test oauth2_client::verify::tests::test_delegated_non_project_scope_rejected ... ok
test oauth2_client::verify::tests::subject_token_profile_rejects_empty_issuer_allowlist ... ok
test oauth2_client::verify::tests::subject_token_profile_rejects_bad_signature_and_algorithm ... ok
test oauth2_client::verify::tests::test_algorithm_mismatch_rejected ... ok
test oauth2_client::verify::tests::subject_token_profile_accepts_short_lived_assertion ... ok
test oauth2_client::verify::tests::subject_token_profile_requires_exact_endpoint_audience ... ok
test oauth2_client::verify::tests::subject_token_profile_rejects_untrusted_issuer ... ok
test oauth2_client::verify::tests::test_id_token_structurally_rejected ... ok
test oauth2_client::verify::tests::test_id_token_hint_accepts_an_expired_id_token ... ok
test oauth2_client::verify::tests::test_id_token_hint_rejects_a_token_signed_by_another_key ... ok
test oauth2_client::verify::tests::test_introspectable_oidc_token ... ok
test oauth2_client::verify::tests::test_introspectable_openstack_token_any_client ... ok
test oauth2_client::verify::tests::test_id_token_hint_rejects_access_tokens_and_foreign_issuers ... ok
test oauth2_client::verify::tests::test_issuer_outside_allowlist_rejected_despite_valid_signature ... ok
test oauth2_client::verify::tests::test_introspectable_revoked_jti_rejected ... ok
test oauth2_client::verify::tests::test_introspectable_untrusted_issuer_rejected ... ok
test oauth2_client::verify::tests::test_introspectable_narrowed_openstack_token ... ok
test oauth2_client::verify::tests::test_oidc_access_token_expired ... ok
test oauth2_client::verify::tests::subject_token_profile_enforces_maximum_lifetime_and_age ... ok
test oauth2_client::verify::tests::test_oidc_access_token_ok ... ok
test oauth2_client::verify::tests::test_oidc_access_token_foreign_key ... ok
test oauth2_client::verify::tests::test_oidc_access_token_requires_openid_scope ... ok
test oauth2_client::verify::tests::test_oidc_access_token_rejects_openstack_token ... ok
test oauth2_client::verify::tests::test_oidc_access_token_revoked ... ok
test oauth2_client::verify::tests::test_revocable_mixed_audience_rejected ... ok
test oauth2_client::verify::tests::test_revocable_id_token_rejected ... ok
test oauth2_client::verify::tests::test_revocable_foreign_client_rejected ... ok
test oauth2_client::verify::tests::test_revocable_narrowed_openstack_token_owned_by_client ... ok
test oauth2_client::verify::tests::test_revocable_oidc_token_owned_by_aud ... ok
test oauth2_client::verify::tests::test_multi_issuer_service_validates_against_each_issuers_own_jwks ... ok
test oauth2_client::verify::tests::test_oidc_access_token_wrong_issuer_and_token_use ... ok
test oauth2_client::verify::tests::test_revoked_jti_rejected ... ok
test oauth2_client::verify::tests::test_retired_domain_bound_audience_rejected ... ok
test oauth2_client::verify::tests::test_unknown_kid_rejected ... ok
test oauth2_client::verify::tests::test_revocable_openstack_token_owned_by_client ... ok
test oauth2_client::verify::tests::test_narrowed_token_accepted_only_by_matching_service ... ok
test oauth2_client::verify::tests::test_service_grant_scope_drift_tripwire_trips ... ok
test oauth2_client::verify::tests::test_scope_drift_tripwire_trips ... ok
test oauth2_client::verify::tests::test_revocable_token_reads_the_family_from_fid_and_legacy_sid ... ok
test oauth2_client::verify::tests::test_unnarrowed_token_accepted_by_every_service ... ok
test oauth2_client::verify::tests::test_wrong_audience_rejected ... ok
test oauth2_client::verify::tests::test_valid_token_verifies ... ok
test oauth2_client::verify::tests::test_untrusted_issuer_rejected ... ok
test oauth2_client::verify::tests::test_scope_is_independent_of_issuer_domain ... ok

test result: ok. 46 passed; 0 failed; 0 ignored; 0 measured; 1019 filtered out; finished in 0.13s: 46 passed.

SecurityImpact

Signed-off-by: Dominic Murphy <ddmurphy@bu.edu>
@DomMurphy-git DomMurphy-git changed the title Add shared OAuth2 subject-token validation feat(oauth2): Add shared OAuth2 subject-token validation Oct 19, 2026
exist. Only after every deployment that shares a database with Python
Keystone has moved trust creation off it.

### Phase 1 implementation notes

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.

I don't think it was intended

// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
// Crypto inventory: TOK-4; see doc/src/contributor/crypto-inventory.md

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.

this should stay

/// A service grant token (ADR 0036 §5.2) verifies with its `act` claim
/// and is held to its own project like any delegated token.
#[test]
fn test_service_grant_token_round_trip_and_tripwire() {

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 dropped the positive part of the test verifying ServiceGrant token with act should still verify


let parts: Vec<_> = token.split('.').collect();
assert_eq!(parts.len(), 3);
let [header, payload, signature] = parts.as_slice() else {

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.

the test exits when token does not split technically not checking anything and passing

validation.leeway = profile.clock_skew.as_secs();

let data = decode::<T>(token, &decoding_key, &validation)?;
let serialized = serde_json::to_value(&data.claims)

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 decode token into T, convert back with serde_json::to_value, and run the required-claims, iss, aud and time checks on that copy. This has two problems:

  • It relies on T’s Serialize round-tripping exactly (skipped None fields, renamed fields).
  • It does extra work on the hot path of native-token verification.

Decoding once into serde_json::Value, checking it, then from_value:: is simpler and checks what was actually signed.

/// not exceed 300 seconds, and registered time claims allow 60 seconds of
/// clock skew. Replay detection for the required `jti` remains the
/// caller's responsibility.
pub fn external_assertion(expected_issuers: &[String], token_endpoint_audience: &str) -> Self {

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.

The external_assertion profile look too strict for JWT-SVID. The SPIFFE SVID JWT only include sub, aud and exp, while you additionally require nbf, iat, jti. So we either need to have different profiles depending on which token is received or we reduce it to svid subset, which is what the docstring claim

///
/// The audience is the exact token-endpoint audience configured by the
/// caller. Issuance time may be at most 300 seconds old, `exp - iat` may
/// not exceed 300 seconds, and registered time claims allow 60 seconds of

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.

In the implementation clock_skew is not applied to max_age now.saturating_sub(iat) > max_age.as_secs() as i64 // max_age = 300, so while this is fine the comment is partially misleading.
Something like this can be used instead:
Issuance time may be at most 300 seconds old. This age limit is strict and does not include the clock skew. The 60-second skew applies only to the registered time claims (exp, nbf) and to rejecting future iat.

This branch has not been deployed

No deployments
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.

2 participants