Repository navigation
feat(oauth2): Add shared OAuth2 subject-token validation - #1560
DomMurphy-git wants to merge 4 commits into
Conversation
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>
5529bf7 to
6241ebd
Compare
| exist. Only after every deployment that shares a database with Python | ||
| Keystone has moved trust creation off it. | ||
|
|
||
| ### Phase 1 implementation notes |
There was a problem hiding this comment.
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 |
| /// 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() { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
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
jtithat the JWT library does not require by itself.Delegation checks also recognize
ServiceGrantand enforce its delegatedproject 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 fornightly-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. Clippyemitted 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 profileconfiguration, 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.
instead of the authentication chain. This change verifies JWT claims and
enforces the existing delegation boundary. (I1/I2)
ScopeInfovariant or authentication method was added. (I5, Gate J)(I7, Gate I)
(I8a)
invalid time claims, bad signatures, algorithm mismatch, and
ServiceGrant scope drift.
ValidatedSecurityContext::new_for_scope()is not involved; this changedoes not build a security context or change scope handling.