use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::time::{Duration, SystemTime};
use crate::authorization::{AuthorizationCodeRecord, AuthorizationCodeState, CodeChallengeMethod};
use crate::client::{Client, ClientAuth, ClientId, DynamicRegistration, SecretHash};
use crate::device::{normalize_user_code, DeviceGrant, DeviceGrantState};
use crate::grant::GrantType;
use crate::scope::ScopeSet;
use crate::store::{Storage, StorageError, WriteOutcome};
use crate::token::{IssuedToken, RefreshTokenRecord, RefreshTokenState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub check: &'static str,
pub detail: String,
}
impl fmt::Display for Violation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.check, self.detail)
}
}
pub const CHECKS: &[&str] = &[
HARNESS_RACE_SETUP,
HARNESS_RACER_PANICKED,
ROUND_TRIP_CLIENT,
ROUND_TRIP_DEVICE_GRANT,
ROUND_TRIP_AUTHORIZATION_CODE,
ROUND_TRIP_TOKEN,
ROUND_TRIP_REFRESH_TOKEN,
ATOMIC_TAKE_DEVICE_GRANT,
SWAP_APPLIES_ON_MATCH,
SWAP_HONOURS_EXPECTED,
SWAP_NEVER_RESURRECTS,
SWAP_RETIRES_OLD_USER_CODE,
SWAP_REFUSES_DUPLICATE_USER_CODE,
ATOMIC_TAKE_REFRESH_TOKEN,
ATOMIC_TAKE_AUTHORIZATION_CODE,
INDEX_RETIRES_OLD_USER_CODE,
INDEX_REFUSES_DUPLICATE_USER_CODE,
INDEX_REFUSAL_WRITES_NOTHING,
INDEX_CLEARED_BY_TAKE,
INDEX_NO_NORMALIZATION,
SWEEP_REMOVES_DEAD,
SWEEP_KEEPS_LIVE,
SWEEP_COUNT,
SWEEP_EMPTY_IS_ZERO,
SWEEP_CONCURRENT_WRITES,
SWEEP_RECLAIMS_PUSHED_REQUESTS,
REVOKE_FAMILY_REMOVES,
REVOKE_FAMILY_SPARES_OTHERS,
REVOKE_FAMILY_COUNT,
DELETE_CLIENT_CASCADES,
DELETE_CLIENT_REPORTS,
DELETE_TOKEN_IDEMPOTENT,
ATOMIC_CLAIM_REPLAY_ID,
CLAIM_REPLAY_ID_REFUSES_SECOND,
SWEEP_RECLAIMS_REPLAY_IDS,
ATOMIC_TAKE_PUSHED_REQUEST,
ROUND_TRIP_PUSHED_REQUEST,
ROUND_TRIP_CONSENT,
CONSENTS_FOR_SUBJECT,
REVOKE_CONSENT_CASCADES,
REVOKE_CONSENT_SPARES_OTHERS,
REVOKE_CONSENT_COUNT,
BARRIER_REFUSES_TOKEN,
BARRIER_REFUSES_REFRESH,
BARRIER_REFUSES_PUSHED_REQUEST,
BARRIER_SPARES_UNRELATED,
BARRIER_ADMITS_A_LATER_GRANT,
BARRIER_REPEAT_REVOCATION_MOVES_IT,
BARRIER_SWEPT_AT_DEADLINE,
BARRIER_KEPT_BEFORE_DEADLINE,
REVOCATION_REFUSES_EMPTY_SCOPE,
CLIENT_SWAP_APPLIES,
CLIENT_SWAP_HONOURS_EXPECTED,
CLIENT_SWAP_NEVER_RESURRECTS,
CLIENT_SWAP_ATOMIC,
CODE_SWAP_APPLIES,
CODE_SWAP_HONOURS_EXPECTED,
CODE_SWAP_NEVER_RESURRECTS,
CODE_SWAP_ATOMIC,
CONSENT_SWAP_APPLIES,
CONSENT_SWAP_HONOURS_EXPECTED,
CONSENT_SWAP_NEVER_RESURRECTS,
CONSENT_SWAP_ATOMIC,
SWAP_ATOMIC,
];
const BARRIER_REFUSES_TOKEN: &str = "revocation_barrier/refuses_put_token";
const BARRIER_REFUSES_REFRESH: &str = "revocation_barrier/refuses_put_refresh_token";
const BARRIER_REFUSES_PUSHED_REQUEST: &str =
"revocation_barrier/refuses_put_pushed_authorization_request";
const BARRIER_SPARES_UNRELATED: &str = "revocation_barrier/spares_unrelated_records";
const BARRIER_ADMITS_A_LATER_GRANT: &str = "revocation_barrier/admits_a_later_grant";
const BARRIER_REPEAT_REVOCATION_MOVES_IT: &str = "revocation_barrier/repeat_revocation_moves_it";
const BARRIER_SWEPT_AT_DEADLINE: &str = "revocation_barrier/swept_at_its_deadline";
const BARRIER_KEPT_BEFORE_DEADLINE: &str = "revocation_barrier/kept_before_its_deadline";
const REVOCATION_REFUSES_EMPTY_SCOPE: &str = "revocation/refuses_an_empty_scope";
const CLIENT_SWAP_APPLIES: &str = "compare_and_swap_client/applies_when_it_matches";
const CLIENT_SWAP_HONOURS_EXPECTED: &str = "compare_and_swap_client/honours_expected";
const CLIENT_SWAP_NEVER_RESURRECTS: &str = "compare_and_swap_client/never_resurrects";
const CODE_SWAP_APPLIES: &str = "compare_and_swap_authorization_code/applies_when_it_matches";
const CODE_SWAP_HONOURS_EXPECTED: &str = "compare_and_swap_authorization_code/honours_expected";
const CODE_SWAP_NEVER_RESURRECTS: &str = "compare_and_swap_authorization_code/never_resurrects";
const CONSENT_SWAP_APPLIES: &str = "compare_and_swap_consent/applies_when_it_matches";
const CONSENT_SWAP_HONOURS_EXPECTED: &str = "compare_and_swap_consent/honours_expected";
const CONSENT_SWAP_NEVER_RESURRECTS: &str = "compare_and_swap_consent/never_resurrects";
const SWAP_ATOMIC: &str = "compare_and_swap_device_grant/atomic_under_a_race";
const CLIENT_SWAP_ATOMIC: &str = "compare_and_swap_client/atomic_under_a_race";
const CODE_SWAP_ATOMIC: &str = "compare_and_swap_authorization_code/atomic_under_a_race";
const CONSENT_SWAP_ATOMIC: &str = "compare_and_swap_consent/atomic_under_a_race";
const HARNESS_RACE_SETUP: &str = "harness/race_setup";
const HARNESS_RACER_PANICKED: &str = "harness/racer_panicked";
const ROUND_TRIP_CLIENT: &str = "round_trip/client";
const ROUND_TRIP_DEVICE_GRANT: &str = "round_trip/device_grant";
const ROUND_TRIP_AUTHORIZATION_CODE: &str = "round_trip/authorization_code";
const ROUND_TRIP_TOKEN: &str = "round_trip/token";
const ROUND_TRIP_REFRESH_TOKEN: &str = "round_trip/refresh_token";
const ATOMIC_TAKE_DEVICE_GRANT: &str = "atomic_take/take_device_grant";
const SWAP_APPLIES_ON_MATCH: &str = "compare_and_swap_device_grant/applies_when_the_state_matches";
const SWAP_HONOURS_EXPECTED: &str = "compare_and_swap_device_grant/honours_expected";
const SWAP_NEVER_RESURRECTS: &str = "compare_and_swap_device_grant/never_resurrects";
const SWAP_RETIRES_OLD_USER_CODE: &str = "compare_and_swap_device_grant/retires_the_old_user_code";
const SWAP_REFUSES_DUPLICATE_USER_CODE: &str =
"compare_and_swap_device_grant/refuses_a_duplicate_user_code";
const ATOMIC_TAKE_REFRESH_TOKEN: &str = "atomic_take/take_refresh_token";
const ATOMIC_TAKE_AUTHORIZATION_CODE: &str = "atomic_take/take_authorization_code";
const INDEX_RETIRES_OLD_USER_CODE: &str = "user_code_index/retires_old_entry";
const INDEX_REFUSES_DUPLICATE_USER_CODE: &str = "user_code_index/refuses_duplicate";
const INDEX_REFUSAL_WRITES_NOTHING: &str = "user_code_index/refusal_writes_nothing";
const INDEX_CLEARED_BY_TAKE: &str = "user_code_index/cleared_by_take";
const INDEX_NO_NORMALIZATION: &str = "user_code_index/store_does_not_normalize";
const SWEEP_REMOVES_DEAD: &str = "sweep_expired/removes_dead";
const SWEEP_KEEPS_LIVE: &str = "sweep_expired/keeps_live";
const SWEEP_COUNT: &str = "sweep_expired/count";
const SWEEP_EMPTY_IS_ZERO: &str = "sweep_expired/empty_is_zero";
const SWEEP_RECLAIMS_PUSHED_REQUESTS: &str = "sweep_expired/reclaims_pushed_requests";
const SWEEP_CONCURRENT_WRITES: &str = "sweep_expired/safe_under_concurrent_writes";
const REVOKE_FAMILY_REMOVES: &str = "revoke_token_family/removes_the_family";
const REVOKE_FAMILY_SPARES_OTHERS: &str = "revoke_token_family/spares_other_families";
const REVOKE_FAMILY_COUNT: &str = "revoke_token_family/count";
const DELETE_CLIENT_CASCADES: &str = "delete_client/cascades";
const DELETE_CLIENT_REPORTS: &str = "delete_client/reports_whether_it_removed";
const DELETE_TOKEN_IDEMPOTENT: &str = "delete_token/idempotent";
const ATOMIC_CLAIM_REPLAY_ID: &str = "atomic_claim/claim_replay_id";
const CLAIM_REPLAY_ID_REFUSES_SECOND: &str = "claim_replay_id/refuses_a_second_claim";
const SWEEP_RECLAIMS_REPLAY_IDS: &str = "sweep_expired/reclaims_replay_ids";
const ATOMIC_TAKE_PUSHED_REQUEST: &str = "atomic_take/take_pushed_authorization_request";
const ROUND_TRIP_PUSHED_REQUEST: &str = "round_trip/pushed_authorization_request";
const ROUND_TRIP_CONSENT: &str = "round_trip/consent";
const CONSENTS_FOR_SUBJECT: &str = "consents_for_subject/lists_that_subjects_consents";
const REVOKE_CONSENT_CASCADES: &str = "revoke_consent/cascades";
const REVOKE_CONSENT_SPARES_OTHERS: &str = "revoke_consent/spares_other_subjects";
const REVOKE_CONSENT_COUNT: &str = "revoke_consent/count";
pub type Task = Pin<Box<dyn Future<Output = ()> + Send>>;
type SpawnFn = Arc<dyn Fn(Task) + Send + Sync>;
type BoxTake<T> = Pin<Box<dyn Future<Output = Result<Option<T>, StorageError>> + Send>>;
type TakeResults<T> = Vec<Result<Option<T>, StorageError>>;
const DEFAULT_RACERS: usize = 8;
const GATE_POLL_BUDGET: u32 = 10_000;
pub struct StorageConformance<F> {
new_store: F,
spawn: Option<SpawnFn>,
racers: usize,
}
impl<F> StorageConformance<F> {
pub fn new(new_store: F) -> Self {
StorageConformance {
new_store,
spawn: None,
racers: DEFAULT_RACERS,
}
}
pub fn with_spawn(mut self, spawn: impl Fn(Task) + Send + Sync + 'static) -> Self {
self.spawn = Some(Arc::new(spawn));
self
}
pub fn racers(mut self, racers: usize) -> Self {
self.racers = racers.max(2);
self
}
}
impl<F, Fut, S> StorageConformance<F>
where
F: Fn() -> Fut,
Fut: Future<Output = S>,
S: Storage + 'static,
{
pub async fn run(&self) -> Vec<Violation> {
let mut report = Report::default();
self.round_trip_client(&mut report).await;
self.round_trip_device_grant(&mut report).await;
self.round_trip_authorization_code(&mut report).await;
self.round_trip_token(&mut report).await;
self.round_trip_refresh_token(&mut report).await;
self.atomic_take_device_grant(&mut report).await;
self.compare_and_swap_device_grant(&mut report).await;
self.compare_and_swap_device_grant_user_code_index(&mut report)
.await;
self.atomic_take_refresh_token(&mut report).await;
self.atomic_take_authorization_code(&mut report).await;
self.user_code_index(&mut report).await;
self.sweep(&mut report).await;
self.revoke_family(&mut report).await;
self.delete_client(&mut report).await;
self.delete_token(&mut report).await;
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
self.claim_replay_id(&mut report).await;
#[cfg(feature = "par")]
self.round_trip_pushed_request(&mut report).await;
#[cfg(feature = "par")]
self.atomic_take_pushed_request(&mut report).await;
#[cfg(feature = "consent")]
self.consent(&mut report).await;
self.revocation_barrier(&mut report).await;
self.barrier_admits_a_later_grant(&mut report).await;
self.compare_and_swap_client(&mut report).await;
self.compare_and_swap_authorization_code(&mut report).await;
#[cfg(feature = "consent")]
self.compare_and_swap_consent(&mut report).await;
self.compare_and_swap_device_grant_race(&mut report).await;
self.compare_and_swap_client_race(&mut report).await;
self.compare_and_swap_authorization_code_race(&mut report)
.await;
#[cfg(feature = "consent")]
self.compare_and_swap_consent_race(&mut report).await;
self.sweep_under_concurrent_writes(&mut report).await;
self.revocation_refuses_an_empty_scope(&mut report).await;
self.barrier_repeat_revocation_moves_it(&mut report).await;
report.violations
}
#[cfg(any(feature = "client-assertion", feature = "dpop"))]
async fn claim_replay_id(&self, report: &mut Report) {
let store = self.store().await;
let deadline = at(300);
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
Box::pin(async move {
gate.wait().await;
store
.claim_replay_id("jti-race", deadline)
.await
.map(|claimed| if claimed { Some(()) } else { None })
})
})
.await;
self.judge_race(
report,
ATOMIC_CLAIM_REPLAY_ID,
"claim on a single-use jti",
results,
);
let store = self.store().await;
let first = report.ok(
CLAIM_REPLAY_ID_REFUSES_SECOND,
"claim_replay_id",
store.claim_replay_id("jti-once", deadline).await,
);
if first == Some(false) {
report.fail(
CLAIM_REPLAY_ID_REFUSES_SECOND,
"the FIRST claim of an unseen id answered false, so every artifact carrying a jti \
is refused as a replay of itself",
);
}
if let Some(second) = report.ok(
CLAIM_REPLAY_ID_REFUSES_SECOND,
"claim_replay_id (again)",
store.claim_replay_id("jti-once", deadline).await,
) {
if second {
report.fail(
CLAIM_REPLAY_ID_REFUSES_SECOND,
"the SECOND claim of the same id also answered true: the id is not recorded, \
so a client assertion or DPoP proof can be replayed by anyone who observed \
one request",
);
}
}
if let Some(other) = report.ok(
CLAIM_REPLAY_ID_REFUSES_SECOND,
"claim_replay_id (a different id)",
store.claim_replay_id("jti-other", deadline).await,
) {
if !other {
report.fail(
CLAIM_REPLAY_ID_REFUSES_SECOND,
"a claim of an id that was never claimed answered false",
);
}
}
let store = self.store().await;
let now = at(0);
if report
.ok(
SWEEP_RECLAIMS_REPLAY_IDS,
"claim_replay_id",
store.claim_replay_id("jti-sweep", now).await,
)
.is_none()
{
return;
}
if let Some(removed) = report.ok(
SWEEP_RECLAIMS_REPLAY_IDS,
"sweep_expired",
store.sweep_expired(now).await,
) {
if removed != 1 {
report.fail(
SWEEP_RECLAIMS_REPLAY_IDS,
format!(
"sweep_expired reported {removed} removed with exactly one dead replay id \
in the store: claimed ids are records the sweep must reclaim, or the \
table grows once per authenticated request forever"
),
);
}
}
}
async fn store(&self) -> Arc<S> {
Arc::new((self.new_store)().await)
}
async fn barrier_admits_a_later_grant(&self, report: &mut Report) {
let store = self.store().await;
let client = ClientId::new("client-relifecycle");
if report
.ok(
BARRIER_ADMITS_A_LATER_GRANT,
"put_client",
store.put_client(sample_client(client.as_str())).await,
)
.is_none()
{
return;
}
if report
.ok(
BARRIER_ADMITS_A_LATER_GRANT,
"delete_client",
store
.delete_client(
&client,
crate::store::RevocationWindow {
recorded_at: at_before(0),
until: barrier_until(),
},
)
.await,
)
.is_none()
{
return;
}
let mut stale = sample_token("at-grant-before-deletion", client.as_str(), None);
stale.grant_established_at = at_before(60);
match store.put_token(stale).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
"put_token wrote a token whose grant was established BEFORE the client was \
deleted: that is the in-flight write the barrier exists to refuse, so the \
deletion is undone by a request that was already running when it ran",
),
Err(e) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
format!("put_token failed unexpectedly: {e}"),
),
}
let mut orphan = sample_token("at-grant-after-deletion-still-gone", client.as_str(), None);
orphan.grant_established_at = at(60);
match store.put_token(orphan).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
"put_token wrote a token for a client that was DELETED and not re-provisioned, on \
the strength of a grant instant later than the revocation. A concurrent write can \
stamp exactly that instant, so a deleted client's credentials come back: refuse a \
client-scope grant whenever the client no longer exists",
),
Err(e) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
format!("put_token failed unexpectedly: {e}"),
),
}
let mut bystander = sample_token(
"at-another-clients-grant-before-the-deletion",
"client-not-the-one-deleted",
None,
);
bystander.grant_established_at = at_before(60);
match store.put_token(bystander).await {
Ok(WriteOutcome::Applied) => {}
Ok(WriteOutcome::RefusedRevoked) => report.fail(
BARRIER_SPARES_UNRELATED,
"put_token refused a token belonging to a DIFFERENT client than the one deleted: \
the client barrier is not comparing its scope against the record's `client_id` at \
all, so one RFC 7592 s2.3 deletion has stopped every client this server has",
),
Err(e) => report.fail(
BARRIER_SPARES_UNRELATED,
format!("put_token failed unexpectedly: {e}"),
),
}
if report
.ok(
BARRIER_ADMITS_A_LATER_GRANT,
"put_client (re-provision)",
store.put_client(sample_client(client.as_str())).await,
)
.is_none()
{
return;
}
let mut fresh = sample_token("at-grant-after-reprovisioning", client.as_str(), None);
fresh.grant_established_at = at(60);
match store.put_token(fresh).await {
Ok(WriteOutcome::Applied) => {}
Ok(WriteOutcome::RefusedRevoked) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
"put_token refused a token whose grant was established AFTER the client was \
re-provisioned: a re-registered client is locked out until the barrier is swept, \
as long as the longest token this server mints. A client that EXISTS is judged by \
RevocationWindow::recorded_at alone",
),
Err(e) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
format!("put_token failed unexpectedly: {e}"),
),
}
#[cfg(feature = "par")]
{
let store2 = self.store().await;
let deleted = ClientId::new("client-par-deleted-and-gone");
if store2
.put_client(sample_client(deleted.as_str()))
.await
.is_ok()
&& store2
.delete_client(
&deleted,
crate::store::RevocationWindow {
recorded_at: at_before(0),
until: barrier_until(),
},
)
.await
.is_ok()
{
let mut pushed_orphan = sample_pushed_request(
"urn:ietf:params:oauth:request_uri:pushed-while-client-gone",
);
pushed_orphan.client_id = deleted.clone();
pushed_orphan.pushed_at = at(60);
match store2.put_pushed_authorization_request(pushed_orphan).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
"put_pushed_authorization_request wrote a request for a client that was \
deleted and not re-provisioned, on a `pushed_at` later than the \
revocation. A concurrent push stamps exactly that, so a deleted client's \
request_uri survives (RFC 9126 s2.2)",
),
Err(e) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
format!("put_pushed_authorization_request failed unexpectedly: {e}"),
),
}
}
let mut pushed_after = sample_pushed_request(
"urn:ietf:params:oauth:request_uri:pushed-after-reprovisioning",
);
pushed_after.client_id = client.clone();
pushed_after.pushed_at = at(60);
match store.put_pushed_authorization_request(pushed_after).await {
Ok(WriteOutcome::Applied) => {}
Ok(WriteOutcome::RefusedRevoked) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
"put_pushed_authorization_request refused a request pushed for a client the \
host RE-PROVISIONED: the RFC 9126 endpoint answers server_error for a live \
registration until the barrier is swept",
),
Err(e) => report.fail(
BARRIER_ADMITS_A_LATER_GRANT,
format!("put_pushed_authorization_request failed unexpectedly: {e}"),
),
}
}
}
async fn revocation_barrier(&self, report: &mut Report) {
let store = self.store().await;
if report
.ok(
BARRIER_REFUSES_TOKEN,
"revoke_token_family",
store
.revoke_token_family("fam-barrier", barrier_window())
.await,
)
.is_none()
{
return;
}
match store
.put_token(sample_token(
"at-after-revocation",
"client-conformance",
Some("fam-barrier"),
))
.await
{
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_TOKEN,
"put_token wrote an access token for a family that had just been revoked: an \
issuance already in flight when the revocation ran completes behind it, so RFC \
9700 s4.14.2 containment reports success and the token it was containing is live",
),
Err(e) => report.fail(
BARRIER_REFUSES_TOKEN,
format!("put_token failed unexpectedly: {e}"),
),
}
let mut later_in_the_family = sample_token(
"at-after-revocation-later-grant",
"client-conformance",
Some("fam-barrier"),
);
later_in_the_family.grant_established_at = at(60);
match store.put_token(later_in_the_family).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_TOKEN,
"put_token wrote a token for a REVOKED family because its grant instant was after \
the revocation. The family scope refuses UNCONDITIONALLY: a rotation carries the \
grant instant forward but mints its records at `now`, so comparing here readmits \
the rotation that completes behind the cascade, which is the whole of what RFC \
9700 s4.14.2 containment is for. Compare `recorded_at` for the client and consent \
scopes only",
),
Err(e) => report.fail(
BARRIER_REFUSES_TOKEN,
format!("put_token failed unexpectedly: {e}"),
),
}
match store
.put_refresh_token(sample_refresh(
"rt-after-revocation",
"client-conformance",
"fam-barrier",
))
.await
{
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_REFRESH,
"put_refresh_token restored a refresh record for a family that had just been \
revoked: the user was told the grant was revoked and the client still holds a \
rotatable chain",
),
Err(e) => report.fail(
BARRIER_REFUSES_REFRESH,
format!("put_refresh_token failed unexpectedly: {e}"),
),
}
match store
.put_token(sample_token(
"at-unrelated",
"client-conformance",
Some("fam-other"),
))
.await
{
Ok(WriteOutcome::Applied) => {}
Ok(WriteOutcome::RefusedRevoked) => report.fail(
BARRIER_SPARES_UNRELATED,
"put_token refused a token from a DIFFERENT family: the barrier is matching too \
widely, so one revocation has stopped this client issuing anything at all",
),
Err(e) => report.fail(
BARRIER_SPARES_UNRELATED,
format!("put_token failed unexpectedly: {e}"),
),
}
let never_registered = ClientId::new("client-never-registered");
if report
.ok(
BARRIER_REFUSES_TOKEN,
"delete_client (a client that was never stored)",
store
.delete_client(&never_registered, barrier_window())
.await,
)
.is_some()
{
match store
.put_token(sample_token(
"at-for-a-client-that-was-never-registered",
never_registered.as_str(),
None,
))
.await
{
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_TOKEN,
"delete_client recorded NO barrier because there was no registration to \
remove, so a write covered by that deletion was accepted. Deleting a client \
that is already gone answers Ok(false) and must still record the barrier: the \
issuance the deletion is racing is holding a registration it read earlier, \
and the empty result set says nothing about it",
),
Err(e) => report.fail(
BARRIER_REFUSES_TOKEN,
format!("put_token failed unexpectedly: {e}"),
),
}
}
let before = report.ok(
BARRIER_KEPT_BEFORE_DEADLINE,
"sweep_expired",
store
.sweep_expired(barrier_until() - Duration::from_secs(1))
.await,
);
if before.is_some() {
match store
.put_refresh_token(sample_refresh(
"rt-still-refused",
"client-conformance",
"fam-barrier",
))
.await
{
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_KEPT_BEFORE_DEADLINE,
"a sweep BEFORE the barrier deadline reclaimed it, so a write that the \
revocation should still be refusing was accepted: the window the barrier \
exists to close has been reopened early",
),
Err(e) => report.fail(
BARRIER_KEPT_BEFORE_DEADLINE,
format!("put_refresh_token failed unexpectedly: {e}"),
),
}
}
let Some(removed) = report.ok(
BARRIER_SWEPT_AT_DEADLINE,
"sweep_expired",
store.sweep_expired(barrier_until()).await,
) else {
return;
};
if removed == 0 {
report.fail(
BARRIER_SWEPT_AT_DEADLINE,
"sweep_expired reclaimed nothing at the barrier deadline: a barrier is a row \
nothing else ever removes, so a store that does not sweep them grows by one per \
revocation forever",
);
}
}
async fn barrier_repeat_revocation_moves_it(&self, report: &mut Report) {
let c = BARRIER_REPEAT_REVOCATION_MOVES_IT;
let store = self.store().await;
let client = ClientId::new("client-revoked-twice");
if report
.ok(
c,
"put_client",
store.put_client(sample_client(client.as_str())).await,
)
.is_none()
{
return;
}
let later = crate::store::RevocationWindow {
recorded_at: at(100),
until: barrier_until(),
};
let earlier = crate::store::RevocationWindow {
recorded_at: at_before(0),
until: at(200),
};
if report
.ok(
c,
"delete_client",
store.delete_client(&client, later).await,
)
.is_none()
{
return;
}
if report
.ok(
c,
"delete_client (a second revocation, with an EARLIER window)",
store.delete_client(&client, earlier).await,
)
.is_none()
{
return;
}
if report
.ok(
c,
"put_client (re-provision, so the merge is what is tested)",
store.put_client(sample_client(client.as_str())).await,
)
.is_none()
{
return;
}
let mut between = sample_token("at-between-two-revocations", client.as_str(), None);
between.grant_established_at = at(50);
match store.put_token(between).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
c,
"after two revocations of one client, a token whose grant was established BETWEEN \
them was written. The second revocation carried an EARLIER `recorded_at` and this \
store took it, so the repeat revocation moved the barrier BACKWARDS and admitted \
exactly the grant the first one covered. `recorded_at` must take the later of the \
two, or a store whose two nodes race loses whichever revocation commits first",
),
Err(e) => report.fail(c, format!("put_token failed unexpectedly: {e}")),
}
let mut fodder = sample_token("at-dead-sweep-fodder", "client-sweep-fodder", None);
fodder.expires_at = at_before(1);
if report
.ok(
c,
"put_token (a dead record for the sweep)",
store.put_token(fodder).await,
)
.is_none()
{
return;
}
if report
.ok(
c,
"sweep_expired (past the second window's deadline, short of the first's)",
store.sweep_expired(at(300)).await,
)
.is_none()
{
return;
}
let mut predating = sample_token("at-predating-both-revocations", client.as_str(), None);
predating.grant_established_at = at_before(60);
match store.put_token(predating).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
c,
"a second revocation of one client SHORTENED the first one's deadline: sweeping \
past the second window's `until`, which is far short of the first's, reclaimed \
the barrier, and a write the first revocation was still covering was accepted. A \
repeat revocation must never shrink the protection already recorded",
),
Err(e) => report.fail(c, format!("put_token failed unexpectedly: {e}")),
}
}
async fn revocation_refuses_an_empty_scope(&self, report: &mut Report) {
let c = REVOCATION_REFUSES_EMPTY_SCOPE;
let store = self.store().await;
if report
.ok(
c,
"put_token",
store
.put_token(sample_token(
"at-empty-scope",
"client-empty-scope",
Some("fam-empty-scope"),
))
.await,
)
.is_none()
{
return;
}
if let Ok(removed) = store
.delete_client(&ClientId::new(""), barrier_window())
.await
{
report.fail(
c,
format!(
"delete_client accepted an EMPTY client_id and answered Ok({removed}). The \
empty string names no registration, so there is nothing for the cascade to \
mean and nothing a later write can be compared against; a store that keys \
barriers by value must refuse it rather than record one for \"\""
),
);
}
if let Ok(removed) = store.revoke_token_family("", barrier_window()).await {
report.fail(
c,
format!(
"revoke_token_family accepted an EMPTY family_id and answered Ok({removed}). \
RFC 6749 section 4.4 tokens carry no family at all, so a store that treats \
\"\" as a family is one careless call away from a predicate that matches them"
),
);
}
if let Some(found) = report.ok(c, "get_token", store.get_token("at-empty-scope").await) {
if found.is_none() {
report.fail(
c,
"a revocation refused for naming an empty scope had already removed records \
by the time it refused: the caller is told the call failed and the store is \
the one the failed call left behind",
);
}
}
}
async fn compare_and_swap_client(&self, report: &mut Report) {
let store = self.store().await;
let original = sample_client("client-swap");
if report
.ok(
CLIENT_SWAP_APPLIES,
"put_client",
store.put_client(original.clone()).await,
)
.is_none()
{
return;
}
let Some(original) = report.ok(
CLIENT_SWAP_APPLIES,
"get_client",
store.get_client(&ClientId::new("client-swap")).await,
) else {
return;
};
let Some(original) = original.map(|a| (*a).clone()) else {
report.fail(
CLIENT_SWAP_APPLIES,
"the registration written a moment ago is not there to swap against",
);
return;
};
let mut renamed = original.clone();
renamed.name = Some("renamed by the swap".to_string());
match store
.compare_and_swap_client(&original, renamed.clone())
.await
{
Ok(true) => {}
Ok(false) => report.fail(
CLIENT_SWAP_APPLIES,
"a swap whose expected record is exactly what is stored reported that it did not \
apply, so no RFC 7592 update can ever be recorded",
),
Err(e) => report.fail(
CLIENT_SWAP_APPLIES,
format!("compare_and_swap_client failed unexpectedly: {e}"),
),
}
let mut clobber = original.clone();
clobber.name = Some("clobbered".to_string());
match store.compare_and_swap_client(&original, clobber).await {
Ok(false) => {}
Ok(true) => report.fail(
CLIENT_SWAP_HONOURS_EXPECTED,
"a swap applied against a registration that had already changed: two concurrent \
RFC 7592 updates silently lose one, and the loser is whichever landed first",
),
Err(e) => report.fail(
CLIENT_SWAP_HONOURS_EXPECTED,
format!("compare_and_swap_client failed unexpectedly: {e}"),
),
}
match store.get_client(&ClientId::new("client-swap")).await {
Ok(Some(live)) if live.name.as_deref() == Some("renamed by the swap") => {}
Ok(Some(_)) => report.fail(
CLIENT_SWAP_HONOURS_EXPECTED,
"a refused swap wrote anyway: `Ok(false)` must mean nothing changed",
),
Ok(None) => report.fail(
CLIENT_SWAP_HONOURS_EXPECTED,
"the registration vanished during a refused swap",
),
Err(e) => report.fail(
CLIENT_SWAP_HONOURS_EXPECTED,
format!("get_client failed unexpectedly: {e}"),
),
}
if report
.ok(
CLIENT_SWAP_NEVER_RESURRECTS,
"delete_client",
store
.delete_client(&ClientId::new("client-swap"), barrier_window())
.await,
)
.is_none()
{
return;
}
match store
.compare_and_swap_client(&renamed, renamed.clone())
.await
{
Ok(false) => {}
Ok(true) => report.fail(
CLIENT_SWAP_NEVER_RESURRECTS,
"a swap against a DELETED registration reported that it applied: `Ok(false)` is \
the only correct answer for a row that is not there",
),
Err(e) => report.fail(
CLIENT_SWAP_NEVER_RESURRECTS,
format!("compare_and_swap_client failed unexpectedly: {e}"),
),
}
match store.get_client(&ClientId::new("client-swap")).await {
Ok(None) => {}
Ok(Some(_)) => report.fail(
CLIENT_SWAP_NEVER_RESURRECTS,
"a swap brought back a deleted registration, with its old credential and its old \
registration access token hash: deleting a compromised client is defeatable by \
whoever holds the stolen token",
),
Err(e) => report.fail(
CLIENT_SWAP_NEVER_RESURRECTS,
format!("get_client failed unexpectedly: {e}"),
),
}
}
async fn compare_and_swap_authorization_code(&self, report: &mut Report) {
let store = self.store().await;
let issued = sample_authorization_code("code-swap");
if report
.ok(
CODE_SWAP_APPLIES,
"put_authorization_code",
store.put_authorization_code(issued.clone()).await,
)
.is_none()
{
return;
}
let Some(issued) = report.ok(
CODE_SWAP_APPLIES,
"take_authorization_code",
store.take_authorization_code("code-swap").await,
) else {
return;
};
let Some(issued) = issued else {
report.fail(
CODE_SWAP_APPLIES,
"the authorization code written a moment ago is not there to swap against",
);
return;
};
if report
.ok(
CODE_SWAP_APPLIES,
"put_authorization_code",
store.put_authorization_code(issued.clone()).await,
)
.is_none()
{
return;
}
let consumed_state = AuthorizationCodeState::Consumed {
access_token: Some("at-from-code".to_string()),
refresh_token: None,
};
let mut consumed = issued.clone();
consumed.state = consumed_state.clone();
match store
.compare_and_swap_authorization_code(&issued.state, consumed.clone())
.await
{
Ok(true) => {}
Ok(false) => report.fail(
CODE_SWAP_APPLIES,
"a swap whose expected state is exactly what is stored reported that it did not \
apply, so a redemption can never record what it minted",
),
Err(e) => report.fail(
CODE_SWAP_APPLIES,
format!("compare_and_swap_authorization_code failed unexpectedly: {e}"),
),
}
let mut replayed = issued.clone();
replayed.state = AuthorizationCodeState::Replayed {
access_token: Some("at-from-code".to_string()),
refresh_token: None,
};
if report
.ok(
CODE_SWAP_HONOURS_EXPECTED,
"put_authorization_code",
store.put_authorization_code(replayed).await,
)
.is_some()
{
match store
.compare_and_swap_authorization_code(&consumed_state, consumed.clone())
.await
{
Ok(false) => {}
Ok(true) => report.fail(
CODE_SWAP_HONOURS_EXPECTED,
"a swap applied over a state that had already moved on: a redemption \
suspended in the host's signer overwrites the trace a detected replay left \
for it, and hands out the very tokens the replay was containing",
),
Err(e) => report.fail(
CODE_SWAP_HONOURS_EXPECTED,
format!("compare_and_swap_authorization_code failed unexpectedly: {e}"),
),
}
}
if report
.ok(
CODE_SWAP_NEVER_RESURRECTS,
"take_authorization_code",
store.take_authorization_code("code-swap").await,
)
.is_none()
{
return;
}
match store
.compare_and_swap_authorization_code(&consumed_state, consumed)
.await
{
Ok(false) => {}
Ok(true) => report.fail(
CODE_SWAP_NEVER_RESURRECTS,
"a swap against an authorization code that is not there reported that it applied",
),
Err(e) => report.fail(
CODE_SWAP_NEVER_RESURRECTS,
format!("compare_and_swap_authorization_code failed unexpectedly: {e}"),
),
}
match store.take_authorization_code("code-swap").await {
Ok(None) => {}
Ok(Some(_)) => report.fail(
CODE_SWAP_NEVER_RESURRECTS,
"a swap reinstated an authorization code that had been removed: a code a \
withdrawal or a client deletion cascaded away is redeemable again",
),
Err(e) => report.fail(
CODE_SWAP_NEVER_RESURRECTS,
format!("take_authorization_code failed unexpectedly: {e}"),
),
}
}
#[cfg(feature = "consent")]
async fn compare_and_swap_consent(&self, report: &mut Report) {
let store = self.store().await;
let original = sample_consent("consent-swap", "subject-swap");
match store.compare_and_swap_consent(None, original.clone()).await {
Ok(true) => {}
Ok(false) => report.fail(
CONSENT_SWAP_APPLIES,
"a create against a (client, subject) pair that holds no consent reported that it \
did not apply, so a first approval can never be recorded",
),
Err(e) => report.fail(
CONSENT_SWAP_APPLIES,
format!("compare_and_swap_consent failed unexpectedly: {e}"),
),
}
let original = match store
.find_consent(&ClientId::new("client-conformance"), "subject-swap")
.await
{
Ok(Some(live)) => (*live).clone(),
Ok(None) => return,
Err(e) => {
report.fail(
CONSENT_SWAP_APPLIES,
format!("find_consent failed unexpectedly: {e}"),
);
return;
}
};
let mut widened = original.clone();
widened.extend(&scopes("read write admin"), &[]);
match store
.compare_and_swap_consent(Some(&original), widened.clone())
.await
{
Ok(true) => {}
Ok(false) => report.fail(
CONSENT_SWAP_APPLIES,
"a widen whose expected record is exactly what is stored reported that it did not \
apply, so a consent can never be broadened in place and the user is re-prompted \
on every authorization request that asks for a scope they have already approved",
),
Err(e) => report.fail(
CONSENT_SWAP_APPLIES,
format!("compare_and_swap_consent failed unexpectedly: {e}"),
),
}
let original = match store
.find_consent(&ClientId::new("client-conformance"), "subject-swap")
.await
{
Ok(Some(live)) if live.scope == widened.scope => (*live).clone(),
Ok(Some(live)) => {
report.fail(
CONSENT_SWAP_APPLIES,
format!(
"a widen that reported success did not change the stored record: the pair \
still holds scope {:?} rather than the widened {:?}",
live.scope, widened.scope
),
);
(*live).clone()
}
Ok(None) => return,
Err(e) => {
report.fail(
CONSENT_SWAP_APPLIES,
format!("find_consent failed unexpectedly: {e}"),
);
return;
}
};
let duplicate = sample_consent("consent-swap-duplicate", "subject-swap");
match store.compare_and_swap_consent(None, duplicate).await {
Ok(false) => {}
Ok(true) => report.fail(
CONSENT_SWAP_HONOURS_EXPECTED,
"a create applied against a pair that already holds a consent: the pair now has \
two, and a user withdrawing one is told they revoked an application that is \
still authorized by the other",
),
Err(e) => report.fail(
CONSENT_SWAP_HONOURS_EXPECTED,
format!("compare_and_swap_consent failed unexpectedly: {e}"),
),
}
if report
.ok(
CONSENT_SWAP_NEVER_RESURRECTS,
"revoke_consent",
store.revoke_consent("consent-swap", barrier_window()).await,
)
.is_none()
{
return;
}
let mut widened_again = original.clone();
widened_again.extend(
&scopes("read write admin"),
&["https://rs-three.example/".to_string()],
);
match store
.compare_and_swap_consent(Some(&original), widened_again)
.await
{
Ok(false) => {}
Ok(true) => report.fail(
CONSENT_SWAP_NEVER_RESURRECTS,
"a widen applied against a consent that had been WITHDRAWN: the user was told \
they revoked an application and every later authorization request is still \
answered from the record they destroyed",
),
Err(e) => report.fail(
CONSENT_SWAP_NEVER_RESURRECTS,
format!("compare_and_swap_consent failed unexpectedly: {e}"),
),
}
match store
.find_consent(&ClientId::new("client-conformance"), "subject-swap")
.await
{
Ok(None) => {}
Ok(Some(_)) => report.fail(
CONSENT_SWAP_NEVER_RESURRECTS,
"a withdrawn consent is live again after a swap",
),
Err(e) => report.fail(
CONSENT_SWAP_NEVER_RESURRECTS,
format!("find_consent failed unexpectedly: {e}"),
),
}
}
fn judge_swap_race(
&self,
report: &mut Report,
check: &'static str,
what: &str,
results: TakeResults<()>,
) {
let winners = results.iter().filter(|r| matches!(r, Ok(Some(_)))).count();
let errors = results.iter().filter(|r| r.is_err()).count();
if winners > 1 {
report.fail(
check,
format!(
"{winners} of {} concurrent swaps of {what}, every one of them naming the SAME \
expected value, were each told they applied. Only the first can be right: the \
first write moves the record off `expected`, so every comparison after it must \
fail. This store performs the comparison and the write as separate steps, and \
what is lost between them is whatever the previous writer decided",
results.len()
),
);
} else if winners == 0 {
report.fail(
check,
format!(
"none of {} concurrent swaps of {what} applied, though the record was stored \
with exactly the expected value beforehand: the write was lost rather than \
granted to one caller, so the decision the winner made is recorded nowhere",
results.len()
),
);
}
if errors > 0 {
report.fail(
check,
format!(
"{errors} of {} concurrent swaps of {what} failed with a StorageError. The \
server maps that to server_error, so an ordinary overlap between two writers \
fails a legitimate request; a store using optimistic concurrency must retry \
internally rather than surface the conflict, because `Ok(false)` already says \
\"somebody else got there first\" and the caller knows what to do with it. \
This is the `Storage` trait's rule that contention is the store's to resolve, \
not the caller's",
results.len()
),
);
}
}
async fn compare_and_swap_device_grant_race(&self, report: &mut Report) {
let store = self.store().await;
let pending = DeviceGrant {
state: DeviceGrantState::Pending,
..sample_device_grant("dc-swap-race", "SWPR-AAAA")
};
if report
.ok(
SWAP_ATOMIC,
"put_device_grant",
store.put_device_grant(pending.clone()).await,
)
.is_none()
{
return;
}
let seq = AtomicUsize::new(0);
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
let decided = DeviceGrant {
state: DeviceGrantState::Approved {
subject: format!("subject-racer-{}", seq.fetch_add(1, Ordering::SeqCst)),
},
..pending.clone()
};
Box::pin(async move {
gate.wait().await;
store
.compare_and_swap_device_grant(&DeviceGrantState::Pending, decided)
.await
.map(|applied| if applied { Some(()) } else { None })
})
})
.await;
self.judge_swap_race(report, SWAP_ATOMIC, "one Pending device grant", results);
}
async fn compare_and_swap_client_race(&self, report: &mut Report) {
let store = self.store().await;
let id = ClientId::new("client-swap-race");
if report
.ok(
CLIENT_SWAP_ATOMIC,
"put_client",
store.put_client(sample_client(id.as_str())).await,
)
.is_none()
{
return;
}
let Some(Some(original)) = report.ok(
CLIENT_SWAP_ATOMIC,
"get_client",
store.get_client(&id).await,
) else {
return;
};
let original = (*original).clone();
let seq = AtomicUsize::new(0);
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
let mut updated = original.clone();
updated.name = Some(format!(
"renamed by racer {}",
seq.fetch_add(1, Ordering::SeqCst)
));
let expected = original.clone();
Box::pin(async move {
gate.wait().await;
store
.compare_and_swap_client(&expected, updated)
.await
.map(|applied| if applied { Some(()) } else { None })
})
})
.await;
self.judge_swap_race(report, CLIENT_SWAP_ATOMIC, "one registration", results);
}
async fn compare_and_swap_authorization_code_race(&self, report: &mut Report) {
let store = self.store().await;
if report
.ok(
CODE_SWAP_ATOMIC,
"put_authorization_code",
store
.put_authorization_code(sample_authorization_code("code-swap-race"))
.await,
)
.is_none()
{
return;
}
let Some(Some(issued)) = report.ok(
CODE_SWAP_ATOMIC,
"take_authorization_code",
store.take_authorization_code("code-swap-race").await,
) else {
return;
};
if report
.ok(
CODE_SWAP_ATOMIC,
"put_authorization_code (put back for the race)",
store.put_authorization_code(issued.clone()).await,
)
.is_none()
{
return;
}
let seq = AtomicUsize::new(0);
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
let mut updated = issued.clone();
updated.state = AuthorizationCodeState::Replayed {
access_token: Some(format!("at-racer-{}", seq.fetch_add(1, Ordering::SeqCst))),
refresh_token: None,
};
let expected = issued.state.clone();
Box::pin(async move {
gate.wait().await;
store
.compare_and_swap_authorization_code(&expected, updated)
.await
.map(|applied| if applied { Some(()) } else { None })
})
})
.await;
self.judge_swap_race(
report,
CODE_SWAP_ATOMIC,
"one authorization code record",
results,
);
}
#[cfg(feature = "consent")]
async fn compare_and_swap_consent_race(&self, report: &mut Report) {
let store = self.store().await;
if report
.ok(
CONSENT_SWAP_ATOMIC,
"compare_and_swap_consent (create)",
store
.compare_and_swap_consent(
None,
sample_consent("consent-swap-race", "subject-swap-race"),
)
.await,
)
.is_none()
{
return;
}
let Some(Some(original)) = report.ok(
CONSENT_SWAP_ATOMIC,
"find_consent",
store
.find_consent(&ClientId::new("client-conformance"), "subject-swap-race")
.await,
) else {
return;
};
let original = (*original).clone();
let seq = AtomicUsize::new(0);
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
let mut updated = original.clone();
updated.granted_at = at(seq.fetch_add(1, Ordering::SeqCst) as u64);
let expected = original.clone();
Box::pin(async move {
gate.wait().await;
store
.compare_and_swap_consent(Some(&expected), updated)
.await
.map(|applied| if applied { Some(()) } else { None })
})
})
.await;
self.judge_swap_race(report, CONSENT_SWAP_ATOMIC, "one live consent", results);
}
async fn round_trip_client(&self, report: &mut Report) {
let store = self.store().await;
let want = sample_client("client-round-trip");
let superseded = Client {
name: Some("the registration this put must REPLACE".to_string()),
allowed_scopes: scopes("read"),
..want.clone()
};
if report
.ok(
ROUND_TRIP_CLIENT,
"put_client (the registration the next put must replace)",
store.put_client(superseded).await,
)
.is_none()
{
return;
}
if report
.ok(
ROUND_TRIP_CLIENT,
"put_client",
store.put_client(want.clone()).await,
)
.is_none()
{
return;
}
let Some(got) = report.ok(
ROUND_TRIP_CLIENT,
"get_client",
store.get_client(&want.client_id).await,
) else {
return;
};
let Some(got) = report.some(ROUND_TRIP_CLIENT, "get_client", got) else {
return;
};
let c = ROUND_TRIP_CLIENT;
report.same(c, "client_id", &want.client_id, &got.client_id);
report.same(c, "auth", &want.auth, &got.auth);
report.same(c, "grant_types", &want.grant_types, &got.grant_types);
report.same(c, "redirect_uris", &want.redirect_uris, &got.redirect_uris);
report.same(
c,
"allowed_scopes",
&want.allowed_scopes,
&got.allowed_scopes,
);
report.same(
c,
"default_scopes",
&want.default_scopes,
&got.default_scopes,
);
report.same(c, "name", &want.name, &got.name);
report.same(c, "registration", &want.registration, &got.registration);
}
async fn compare_and_swap_device_grant(&self, report: &mut Report) {
let store = self.store().await;
let pending = DeviceGrant {
state: DeviceGrantState::Pending,
..sample_device_grant("dc-swap", "SWAP-AAAA")
};
if report
.ok(
SWAP_APPLIES_ON_MATCH,
"put_device_grant",
store.put_device_grant(pending.clone()).await,
)
.is_none()
{
return;
}
let denied = DeviceGrant {
state: DeviceGrantState::Denied,
..pending.clone()
};
let Some(applied) = report.ok(
SWAP_APPLIES_ON_MATCH,
"compare_and_swap_device_grant",
store
.compare_and_swap_device_grant(&DeviceGrantState::Pending, denied.clone())
.await,
) else {
return;
};
if !applied {
report.fail(
SWAP_APPLIES_ON_MATCH,
"a swap whose expected state matched the stored state reported that it did not \
apply; the user's decision at the verification UI would never be recorded",
);
}
match store.get_device_grant(&pending.device_code).await {
Ok(Some(got)) if got.state == DeviceGrantState::Denied => {}
Ok(other) => report.fail(
SWAP_APPLIES_ON_MATCH,
format!(
"a swap that reported success did not change the stored state: read back \
{:?}",
other.map(|g| g.state)
),
),
Err(e) => report.fail(
SWAP_APPLIES_ON_MATCH,
format!("get_device_grant failed unexpectedly: {e}"),
),
}
let repending = DeviceGrant {
state: DeviceGrantState::Pending,
..pending.clone()
};
let Some(applied) = report.ok(
SWAP_HONOURS_EXPECTED,
"compare_and_swap_device_grant",
store
.compare_and_swap_device_grant(&DeviceGrantState::Pending, repending)
.await,
) else {
return;
};
if applied {
report.fail(
SWAP_HONOURS_EXPECTED,
"a swap whose expected state was STALE reported that it applied: the store is not \
comparing `expected` against the stored state at all, so the user's decision is \
reverted by whichever writer arrives last",
);
}
match store.get_device_grant(&pending.device_code).await {
Ok(Some(got)) if got.state == DeviceGrantState::Denied => {}
Ok(other) => report.fail(
SWAP_HONOURS_EXPECTED,
format!(
"a swap with a stale `expected` overwrote the stored state: the user denied \
this grant and it now reads {:?}",
other.map(|g| g.state)
),
),
Err(e) => report.fail(
SWAP_HONOURS_EXPECTED,
format!("get_device_grant failed unexpectedly: {e}"),
),
}
if report
.ok(
SWAP_NEVER_RESURRECTS,
"take_device_grant",
store.take_device_grant(&pending.device_code).await,
)
.is_none()
{
return;
}
let index_already_dirty = matches!(
store
.find_device_grant_by_user_code(&normalize_user_code(&pending.user_code))
.await,
Ok(Some(_))
);
let Some(applied) = report.ok(
SWAP_NEVER_RESURRECTS,
"compare_and_swap_device_grant",
store
.compare_and_swap_device_grant(&DeviceGrantState::Denied, denied)
.await,
) else {
return;
};
if applied {
report.fail(
SWAP_NEVER_RESURRECTS,
"a swap against a device_code that had already been redeemed reported that it \
applied: `Ok(false)` is the only correct answer for a row that is not there",
);
}
match store.get_device_grant(&pending.device_code).await {
Ok(None) => {}
Ok(Some(_)) => report.fail(
SWAP_NEVER_RESURRECTS,
"a swap brought back a device grant that had been redeemed: the store is writing \
through an insert-or-update, so an RFC 8628 single-use device code is now \
redeemable a second time",
),
Err(e) => report.fail(
SWAP_NEVER_RESURRECTS,
format!("get_device_grant failed unexpectedly: {e}"),
),
}
match store
.find_device_grant_by_user_code(&normalize_user_code(&pending.user_code))
.await
{
Ok(None) => {}
Ok(Some(_)) if index_already_dirty => {}
Ok(Some(_)) => report.fail(
SWAP_NEVER_RESURRECTS,
"a swap put a redeemed grant back into the user-code index: the code a human \
typed resolves to a grant that has already been exchanged for a token",
),
Err(e) => report.fail(
SWAP_NEVER_RESURRECTS,
format!("find_device_grant_by_user_code failed unexpectedly: {e}"),
),
}
}
async fn compare_and_swap_device_grant_user_code_index(&self, report: &mut Report) {
let store = self.store().await;
let pending = DeviceGrant {
state: DeviceGrantState::Pending,
..sample_device_grant("dc-swap-idx", "SWPA-AAAA")
};
if report
.ok(
SWAP_RETIRES_OLD_USER_CODE,
"put_device_grant",
store.put_device_grant(pending.clone()).await,
)
.is_none()
{
return;
}
let recoded = DeviceGrant {
state: DeviceGrantState::Denied,
user_code: "SWPB-BBBB".to_string(),
..pending.clone()
};
let Some(applied) = report.ok(
SWAP_RETIRES_OLD_USER_CODE,
"compare_and_swap_device_grant (same device_code, new user code)",
store
.compare_and_swap_device_grant(&DeviceGrantState::Pending, recoded)
.await,
) else {
return;
};
if applied {
if let Some(Some(got)) = report.ok(
SWAP_RETIRES_OLD_USER_CODE,
"get_device_grant after the re-coding swap",
store.get_device_grant(&pending.device_code).await,
) {
report.same(
SWAP_RETIRES_OLD_USER_CODE,
"state",
&DeviceGrantState::Denied,
&got.state,
);
}
if let Some(found) = report.ok(
SWAP_RETIRES_OLD_USER_CODE,
"find_device_grant_by_user_code(new)",
store.find_device_grant_by_user_code("SWPBBBBB").await,
) {
if found.is_none() {
report.fail(
SWAP_RETIRES_OLD_USER_CODE,
"after a swap changed the user code, the NEW code does not resolve: the \
swap wrote the grant and not the index, so the verification page cannot \
reach a device that is waiting",
);
}
}
if let Some(found) = report.ok(
SWAP_RETIRES_OLD_USER_CODE,
"find_device_grant_by_user_code(old)",
store.find_device_grant_by_user_code("SWPAAAAA").await,
) {
if found.is_some() {
report.fail(
SWAP_RETIRES_OLD_USER_CODE,
"the OLD user code still resolves after a swap changed it: a code the user \
was shown and that has been superseded can still be used to approve the \
grant, and the grant now answers to two codes at once",
);
}
}
}
let store = self.store().await;
let first = DeviceGrant {
state: DeviceGrantState::Pending,
..sample_device_grant("dc-swap-idx-first", "SWPC-CCCC")
};
let second = DeviceGrant {
state: DeviceGrantState::Pending,
..sample_device_grant("dc-swap-idx-second", "SWPD-DDDD")
};
for grant in [first.clone(), second.clone()] {
if report
.ok(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"put_device_grant",
store.put_device_grant(grant).await,
)
.is_none()
{
return;
}
}
let clash = DeviceGrant {
state: DeviceGrantState::Denied,
user_code: first.user_code.clone(),
..second.clone()
};
if store
.compare_and_swap_device_grant(&DeviceGrantState::Pending, clash)
.await
.is_ok()
{
report.fail(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"a swap onto a user code already indexed for another device_code did not fail: it \
must answer a StorageError, not Ok(_). Repointing the index gives two devices one \
identity and orphans the older grant, and the put refuses exactly this while the \
swap — which the verification UI and the polling device both reach — let it \
through",
);
}
if let Some(found) = report.ok(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"find_device_grant_by_user_code after the refused swap",
store.find_device_grant_by_user_code("SWPCCCCC").await,
) {
match found {
Some(g) if g.device_code == first.device_code => {}
Some(g) => report.fail(
SWAP_REFUSES_DUPLICATE_USER_CODE,
format!(
"the user code now resolves to device_code {:?}, not to the grant that \
owned it: the index was repointed by a swap that should have written \
nothing",
g.device_code
),
),
None => report.fail(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"the user code resolves to nothing after a clashing swap: the refused write \
removed the index entry belonging to the grant that already owned it",
),
}
}
if let Some(found) = report.ok(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"get_device_grant after the refused swap",
store.get_device_grant(&second.device_code).await,
) {
match found {
Some(g) if g.user_code == second.user_code => {}
Some(g) => report.fail(
SWAP_REFUSES_DUPLICATE_USER_CODE,
format!(
"the swapping grant was rewritten even though its new user code belonged \
to another device_code: it now carries {:?}",
g.user_code
),
),
None => report.fail(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"the swapping grant is gone after a refused swap: a refusal must leave the \
store exactly as it was",
),
}
}
if let Some(Some(g)) = report.ok(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"get_device_grant (state) after the refused swap",
store.get_device_grant(&second.device_code).await,
) {
report.same(
SWAP_REFUSES_DUPLICATE_USER_CODE,
"state of the grant a refused swap targeted",
&second.state,
&g.state,
);
}
}
async fn round_trip_device_grant(&self, report: &mut Report) {
let store = self.store().await;
let want = sample_device_grant("dc-round-trip", "RTRT-AAAA");
if report
.ok(
ROUND_TRIP_DEVICE_GRANT,
"put_device_grant",
store.put_device_grant(want.clone()).await,
)
.is_none()
{
return;
}
let Some(got) = report.ok(
ROUND_TRIP_DEVICE_GRANT,
"get_device_grant",
store.get_device_grant(&want.device_code).await,
) else {
return;
};
let Some(got) = report.some(ROUND_TRIP_DEVICE_GRANT, "get_device_grant", got) else {
return;
};
let c = ROUND_TRIP_DEVICE_GRANT;
report.same(c, "device_code", &want.device_code, &got.device_code);
report.same(c, "user_code", &want.user_code, &got.user_code);
report.same(c, "client_id", &want.client_id, &got.client_id);
report.same(c, "scope", &want.scope, &got.scope);
report.same(c, "state", &want.state, &got.state);
report.same(c, "created_at", &want.created_at, &got.created_at);
report.same(c, "expires_at", &want.expires_at, &got.expires_at);
report.same(c, "interval", &want.interval, &got.interval);
report.same(c, "last_poll_at", &want.last_poll_at, &got.last_poll_at);
let Some(found) = report.ok(
c,
"find_device_grant_by_user_code",
store
.find_device_grant_by_user_code(&normalize_user_code(&want.user_code))
.await,
) else {
return;
};
match found {
Some(found) => report.same(c, "by-user-code record", &want, &found),
None => report.fail(
c,
"a grant that was just put is not reachable by its normalized user code",
),
}
}
async fn round_trip_authorization_code(&self, report: &mut Report) {
let store = self.store().await;
let want = sample_authorization_code("code-round-trip");
if report
.ok(
ROUND_TRIP_AUTHORIZATION_CODE,
"put_authorization_code",
store.put_authorization_code(want.clone()).await,
)
.is_none()
{
return;
}
let Some(got) = report.ok(
ROUND_TRIP_AUTHORIZATION_CODE,
"take_authorization_code",
store.take_authorization_code(&want.code).await,
) else {
return;
};
let Some(got) = report.some(
ROUND_TRIP_AUTHORIZATION_CODE,
"take_authorization_code",
got,
) else {
return;
};
let c = ROUND_TRIP_AUTHORIZATION_CODE;
report.same(c, "code", &want.code, &got.code);
report.same(c, "client_id", &want.client_id, &got.client_id);
report.same(c, "redirect_uri", &want.redirect_uri, &got.redirect_uri);
report.same(
c,
"redirect_uri_was_explicit",
&want.redirect_uri_was_explicit,
&got.redirect_uri_was_explicit,
);
report.same(c, "scope", &want.scope, &got.scope);
report.same(c, "subject", &want.subject, &got.subject);
report.same(
c,
"code_challenge",
&want.code_challenge,
&got.code_challenge,
);
report.same(
c,
"code_challenge_method",
&want.code_challenge_method,
&got.code_challenge_method,
);
report.same(c, "resource", &want.resource, &got.resource);
report.same(c, "issued_at", &want.issued_at, &got.issued_at);
report.same(c, "expires_at", &want.expires_at, &got.expires_at);
report.same(c, "state", &want.state, &got.state);
#[cfg(feature = "rar")]
report.same(
c,
"authorization_details",
&want.authorization_details,
&got.authorization_details,
);
#[cfg(feature = "consent")]
report.same(
c,
"authentication",
&want.authentication,
&got.authentication,
);
}
async fn round_trip_token(&self, report: &mut Report) {
let store = self.store().await;
let want = sample_token("at-round-trip", "client-round-trip", Some("fam-round-trip"));
if report
.ok(
ROUND_TRIP_TOKEN,
"put_token",
store.put_token(want.clone()).await,
)
.is_none()
{
return;
}
let Some(got) = report.ok(
ROUND_TRIP_TOKEN,
"get_token",
store.get_token(&want.access_token).await,
) else {
return;
};
let Some(got) = report.some(ROUND_TRIP_TOKEN, "get_token", got) else {
return;
};
let c = ROUND_TRIP_TOKEN;
report.same(c, "access_token", &want.access_token, &got.access_token);
report.same(c, "client_id", &want.client_id, &got.client_id);
report.same(c, "subject", &want.subject, &got.subject);
report.same(c, "scope", &want.scope, &got.scope);
report.same(c, "resource", &want.resource, &got.resource);
report.same(c, "issued_at", &want.issued_at, &got.issued_at);
report.same(
c,
"grant_established_at",
&want.grant_established_at,
&got.grant_established_at,
);
report.same(c, "expires_at", &want.expires_at, &got.expires_at);
report.same(c, "family_id", &want.family_id, &got.family_id);
#[cfg(feature = "dpop")]
report.same(c, "jkt", &want.jkt, &got.jkt);
#[cfg(feature = "mtls")]
report.same(c, "x5t_s256", &want.x5t_s256, &got.x5t_s256);
#[cfg(feature = "rar")]
report.same(
c,
"authorization_details",
&want.authorization_details,
&got.authorization_details,
);
#[cfg(feature = "token-exchange")]
report.same(c, "act", &want.act, &got.act);
#[cfg(feature = "consent")]
report.same(
c,
"authentication",
&want.authentication,
&got.authentication,
);
match store.get_token(&want.access_token).await {
Ok(Some(_)) => {}
Ok(None) => report.fail(
c,
"a second get_token for the same access token found nothing: the read is \
DESTRUCTIVE, so every introspection or revocation that merely asked about a token \
has revoked it, and the client holding it is refused with no explanation anywhere",
),
Err(e) => report.fail(c, format!("get_token failed unexpectedly: {e}")),
}
}
async fn round_trip_refresh_token(&self, report: &mut Report) {
let store = self.store().await;
let want = sample_refresh("rt-round-trip", "client-round-trip", "fam-round-trip");
if report
.ok(
ROUND_TRIP_REFRESH_TOKEN,
"put_refresh_token",
store.put_refresh_token(want.clone()).await,
)
.is_none()
{
return;
}
let Some(got) = report.ok(
ROUND_TRIP_REFRESH_TOKEN,
"get_refresh_token",
store.get_refresh_token(&want.refresh_token).await,
) else {
return;
};
let Some(got) = report.some(ROUND_TRIP_REFRESH_TOKEN, "get_refresh_token", got) else {
return;
};
let c = ROUND_TRIP_REFRESH_TOKEN;
report.same(c, "refresh_token", &want.refresh_token, &got.refresh_token);
report.same(c, "client_id", &want.client_id, &got.client_id);
report.same(c, "subject", &want.subject, &got.subject);
report.same(c, "scope", &want.scope, &got.scope);
report.same(c, "resource", &want.resource, &got.resource);
report.same(
c,
"grant_established_at",
&want.grant_established_at,
&got.grant_established_at,
);
report.same(c, "expires_at", &want.expires_at, &got.expires_at);
report.same(c, "family_id", &want.family_id, &got.family_id);
report.same(c, "state", &want.state, &got.state);
#[cfg(feature = "dpop")]
report.same(c, "jkt", &want.jkt, &got.jkt);
#[cfg(feature = "mtls")]
report.same(c, "x5t_s256", &want.x5t_s256, &got.x5t_s256);
#[cfg(feature = "rar")]
report.same(
c,
"authorization_details",
&want.authorization_details,
&got.authorization_details,
);
#[cfg(feature = "consent")]
report.same(
c,
"authentication",
&want.authentication,
&got.authentication,
);
match store.get_refresh_token(&want.refresh_token).await {
Ok(Some(_)) => {}
Ok(None) => report.fail(
c,
"a second get_refresh_token for the same token found nothing: the read is \
DESTRUCTIVE, so a revocation request that merely verified the requesting client \
has ended a chain it was not entitled to touch, and the user is logged out by a \
request that reported success",
),
Err(e) => report.fail(c, format!("get_refresh_token failed unexpectedly: {e}")),
}
}
async fn atomic_take_device_grant(&self, report: &mut Report) {
let store = self.store().await;
let grant = sample_device_grant("dc-race", "RACE-AAAA");
if report
.ok(
ATOMIC_TAKE_DEVICE_GRANT,
"put_device_grant",
store.put_device_grant(grant).await,
)
.is_none()
{
return;
}
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
Box::pin(async move {
gate.wait().await;
store.take_device_grant("dc-race").await
})
})
.await;
self.judge_race(report, ATOMIC_TAKE_DEVICE_GRANT, "device grant", results);
if let Some(again) = report.ok(
ATOMIC_TAKE_DEVICE_GRANT,
"get_device_grant after take",
store.get_device_grant("dc-race").await,
) {
if again.is_some() {
report.fail(
ATOMIC_TAKE_DEVICE_GRANT,
"the grant is still readable after take_device_grant returned it",
);
}
}
}
async fn atomic_take_refresh_token(&self, report: &mut Report) {
let store = self.store().await;
let record = sample_refresh("rt-race", "client-race", "fam-race");
if report
.ok(
ATOMIC_TAKE_REFRESH_TOKEN,
"put_refresh_token",
store.put_refresh_token(record).await,
)
.is_none()
{
return;
}
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
Box::pin(async move {
gate.wait().await;
store.take_refresh_token("rt-race").await
})
})
.await;
self.judge_race(report, ATOMIC_TAKE_REFRESH_TOKEN, "refresh record", results);
if let Some(again) = report.ok(
ATOMIC_TAKE_REFRESH_TOKEN,
"get_refresh_token after take",
store.get_refresh_token("rt-race").await,
) {
if again.is_some() {
report.fail(
ATOMIC_TAKE_REFRESH_TOKEN,
"the record is still readable after take_refresh_token returned it",
);
}
}
}
async fn atomic_take_authorization_code(&self, report: &mut Report) {
let store = self.store().await;
let record = sample_authorization_code("code-race");
if report
.ok(
ATOMIC_TAKE_AUTHORIZATION_CODE,
"put_authorization_code",
store.put_authorization_code(record).await,
)
.is_none()
{
return;
}
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
Box::pin(async move {
gate.wait().await;
store.take_authorization_code("code-race").await
})
})
.await;
self.judge_race(
report,
ATOMIC_TAKE_AUTHORIZATION_CODE,
"authorization code record",
results,
);
if let Some(again) = report.ok(
ATOMIC_TAKE_AUTHORIZATION_CODE,
"take_authorization_code after take",
store.take_authorization_code("code-race").await,
) {
if again.is_some() {
report.fail(
ATOMIC_TAKE_AUTHORIZATION_CODE,
"a second take_authorization_code returned the record again",
);
}
}
}
#[cfg(feature = "par")]
async fn round_trip_pushed_request(&self, report: &mut Report) {
let store = self.store().await;
let want = sample_pushed_request("urn:ietf:params:oauth:request_uri:round-trip");
let superseded = crate::par::PushedAuthorizationRequest {
state: Some("the pushed request this put must REPLACE".to_string()),
..want.clone()
};
if report
.ok(
ROUND_TRIP_PUSHED_REQUEST,
"put_pushed_authorization_request (the record the next put must replace)",
store.put_pushed_authorization_request(superseded).await,
)
.is_none()
{
return;
}
if report
.ok(
ROUND_TRIP_PUSHED_REQUEST,
"put_pushed_authorization_request",
store.put_pushed_authorization_request(want.clone()).await,
)
.is_none()
{
return;
}
let Some(got) = report.ok(
ROUND_TRIP_PUSHED_REQUEST,
"take_pushed_authorization_request",
store
.take_pushed_authorization_request(&want.request_uri)
.await,
) else {
return;
};
let Some(got) = report.some(
ROUND_TRIP_PUSHED_REQUEST,
"take_pushed_authorization_request",
got,
) else {
return;
};
let c = ROUND_TRIP_PUSHED_REQUEST;
report.same(c, "request_uri", &want.request_uri, &got.request_uri);
report.same(c, "client_id", &want.client_id, &got.client_id);
report.same(c, "response_type", &want.response_type, &got.response_type);
report.same(c, "redirect_uri", &want.redirect_uri, &got.redirect_uri);
report.same(c, "scope", &want.scope, &got.scope);
report.same(c, "state", &want.state, &got.state);
report.same(
c,
"code_challenge",
&want.code_challenge,
&got.code_challenge,
);
report.same(
c,
"code_challenge_method",
&want.code_challenge_method,
&got.code_challenge_method,
);
report.same(c, "resource", &want.resource, &got.resource);
report.same(c, "expires_at", &want.expires_at, &got.expires_at);
report.same(c, "pushed_at", &want.pushed_at, &got.pushed_at);
#[cfg(feature = "rar")]
report.same(
c,
"authorization_details",
&want.authorization_details,
&got.authorization_details,
);
#[cfg(feature = "consent")]
report.same(c, "acr_values", &want.acr_values, &got.acr_values);
#[cfg(feature = "consent")]
report.same(c, "max_age", &want.max_age, &got.max_age);
}
#[cfg(feature = "par")]
async fn atomic_take_pushed_request(&self, report: &mut Report) {
let store = self.store().await;
let record = sample_pushed_request("urn:ietf:params:oauth:request_uri:race");
if report
.ok(
ATOMIC_TAKE_PUSHED_REQUEST,
"put_pushed_authorization_request",
store.put_pushed_authorization_request(record).await,
)
.is_none()
{
return;
}
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
Box::pin(async move {
gate.wait().await;
store
.take_pushed_authorization_request("urn:ietf:params:oauth:request_uri:race")
.await
})
})
.await;
self.judge_race(
report,
ATOMIC_TAKE_PUSHED_REQUEST,
"pushed authorization request",
results,
);
if let Some(again) = report.ok(
ATOMIC_TAKE_PUSHED_REQUEST,
"take_pushed_authorization_request after take",
store
.take_pushed_authorization_request("urn:ietf:params:oauth:request_uri:race")
.await,
) {
if again.is_some() {
report.fail(
ATOMIC_TAKE_PUSHED_REQUEST,
"a second take_pushed_authorization_request returned the handle again",
);
}
}
}
#[cfg(feature = "consent")]
async fn consent(&self, report: &mut Report) {
let store = self.store().await;
let mine = sample_consent("consent-mine", "subject-conformance");
let theirs = sample_consent("consent-theirs", "subject-other");
let superseded = crate::consent::ConsentRecord {
scope: scopes("read"),
resource: Vec::new(),
..mine.clone()
};
if report
.ok(
ROUND_TRIP_CONSENT,
"put_consent (the record the next put must replace)",
store.put_consent(superseded).await,
)
.is_none()
{
return;
}
if report
.ok(
ROUND_TRIP_CONSENT,
"put_consent",
store.put_consent(mine.clone()).await,
)
.is_none()
{
return;
}
if report
.ok(
ROUND_TRIP_CONSENT,
"put_consent (second subject)",
store.put_consent(theirs.clone()).await,
)
.is_none()
{
return;
}
if let Some(Some(back)) = report.ok(
ROUND_TRIP_CONSENT,
"get_consent",
store.get_consent("consent-mine").await,
) {
if *back != mine {
report.fail(
ROUND_TRIP_CONSENT,
"get_consent returned a record that differs from the one stored",
);
}
report.same(ROUND_TRIP_CONSENT, "scope", &mine.scope, &back.scope);
report.same(
ROUND_TRIP_CONSENT,
"resource",
&mine.resource,
&back.resource,
);
} else {
report.fail(ROUND_TRIP_CONSENT, "get_consent did not return the record");
}
if let Some(found) = report.ok(
ROUND_TRIP_CONSENT,
"find_consent",
store
.find_consent(&ClientId::new("client-conformance"), "subject-conformance")
.await,
) {
match found {
Some(f) if f.consent_id == mine.consent_id => {}
Some(_) => report.fail(
ROUND_TRIP_CONSENT,
"find_consent returned a different consent than the one for that subject",
),
None => report.fail(
ROUND_TRIP_CONSENT,
"find_consent did not find a consent that get_consent can read",
),
}
}
if let Some(listed) = report.ok(
CONSENTS_FOR_SUBJECT,
"consents_for_subject",
store.consents_for_subject("subject-conformance").await,
) {
let ids: Vec<&str> = listed.iter().map(|r| r.consent_id.as_ref()).collect();
if !ids.contains(&"consent-mine") {
report.fail(
CONSENTS_FOR_SUBJECT,
format!(
"consents_for_subject listed {ids:?} for a subject holding consent-mine: a \
user cannot withdraw what the host never shows them, so a listing that \
misses a live consent makes revocation unreachable from the UI"
),
);
}
if ids.contains(&"consent-theirs") {
report.fail(
CONSENTS_FOR_SUBJECT,
format!(
"consents_for_subject listed {ids:?}, which includes a DIFFERENT resource \
owner's consent for the same client: the predicate is on the client id \
rather than the subject, so one user is shown another user's grants and \
can withdraw them"
),
);
}
}
let seed = |subject: &str, tag: &str| {
let mut token = sample_token(&format!("at-{tag}"), "client-conformance", Some(tag));
token.subject = Some(subject.to_string());
let mut refresh = sample_refresh(&format!("rt-{tag}"), "client-conformance", tag);
refresh.subject = Some(subject.to_string());
let mut code = sample_authorization_code(&format!("code-{tag}"));
code.subject = subject.to_string();
(
token,
refresh,
code,
sample_approved_device_grant(&format!("dc-{tag}"), &format!("UC{tag}"), subject),
)
};
let (at_mine, rt_mine, code_mine, grant_mine) = seed("subject-conformance", "mine");
let (at_theirs, rt_theirs, code_theirs, grant_theirs) = seed("subject-other", "theirs");
for (t, r, c, g) in [
(&at_mine, &rt_mine, &code_mine, &grant_mine),
(&at_theirs, &rt_theirs, &code_theirs, &grant_theirs),
] {
let seeded = report
.ok(
REVOKE_CONSENT_CASCADES,
"seeding the records a withdrawal must reach: put_token",
store.put_token(t.clone()).await,
)
.and(report.ok(
REVOKE_CONSENT_CASCADES,
"seeding the records a withdrawal must reach: put_refresh_token",
store.put_refresh_token(r.clone()).await,
))
.and(report.ok(
REVOKE_CONSENT_CASCADES,
"seeding the records a withdrawal must reach: put_authorization_code",
store.put_authorization_code(c.clone()).await,
))
.and(report.ok(
REVOKE_CONSENT_CASCADES,
"seeding the records a withdrawal must reach: put_device_grant",
store.put_device_grant(g.clone()).await,
));
if seeded.is_none() {
return;
}
}
let pending = DeviceGrant {
state: DeviceGrantState::Pending,
..sample_device_grant("dc-pending-mine", "PEND-MINE")
};
if report
.ok(
REVOKE_CONSENT_SPARES_OTHERS,
"seeding a PENDING device grant the withdrawal must leave alone",
store.put_device_grant(pending).await,
)
.is_none()
{
return;
}
let removed = match report.ok(
REVOKE_CONSENT_CASCADES,
"revoke_consent",
store.revoke_consent("consent-mine", barrier_window()).await,
) {
Some(n) => n,
None => return,
};
for (what, gone) in [
(
"the access token",
matches!(store.get_token(&at_mine.access_token).await, Ok(None)),
),
(
"the refresh record",
matches!(
store.get_refresh_token(&rt_mine.refresh_token).await,
Ok(None)
),
),
(
"the unredeemed authorization code",
matches!(
store.take_authorization_code(&code_mine.code).await,
Ok(None)
),
),
(
"the approved device grant",
matches!(
store.get_device_grant(&grant_mine.device_code).await,
Ok(None)
),
),
(
"the user-code index entry of the approved device grant",
matches!(
store
.find_device_grant_by_user_code(&normalize_user_code(&grant_mine.user_code))
.await,
Ok(None)
),
),
(
"the consent record",
matches!(store.get_consent("consent-mine").await, Ok(None)),
),
] {
if !gone {
report.fail(
REVOKE_CONSENT_CASCADES,
format!(
"revoke_consent left {what} alive, so the user was told this application \
was stopped and it was not"
),
);
}
}
for (what, alive) in [
(
"access token",
matches!(store.get_token(&at_theirs.access_token).await, Ok(Some(_))),
),
(
"refresh record",
matches!(
store.get_refresh_token(&rt_theirs.refresh_token).await,
Ok(Some(_))
),
),
(
"device grant",
matches!(
store.get_device_grant(&grant_theirs.device_code).await,
Ok(Some(_))
),
),
(
"user-code index entry",
matches!(
store
.find_device_grant_by_user_code(&normalize_user_code(
&grant_theirs.user_code
))
.await,
Ok(Some(_))
),
),
(
"authorization code",
matches!(
store.take_authorization_code(&code_theirs.code).await,
Ok(Some(_))
),
),
(
"PENDING device grant of the same subject",
matches!(store.get_device_grant("dc-pending-mine").await, Ok(Some(_))),
),
(
"consent record",
matches!(store.get_consent("consent-theirs").await, Ok(Some(_))),
),
] {
if !alive {
report.fail(
REVOKE_CONSENT_SPARES_OTHERS,
format!(
"revoke_consent removed the {what} it was required to spare, ending a \
grant nobody withdrew"
),
);
}
}
let mut orphan = sample_token(
"at-issued-after-the-withdrawal",
"client-conformance",
Some("fam-after-withdrawal"),
);
orphan.subject = Some("subject-conformance".to_string());
match store.put_token(orphan).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_TOKEN,
"put_token wrote an access token for the (client, subject) pair whose consent had \
just been withdrawn. The cascade only reaches what is in the store when it runs; \
an authorization code redemption or a rotation already in flight for this pair \
completes behind it, and the user who was told the application was stopped is \
holding a live token issued after they stopped it",
),
Err(e) => report.fail(
BARRIER_REFUSES_TOKEN,
format!("put_token failed unexpectedly: {e}"),
),
}
let mut orphan_refresh = sample_refresh(
"rt-restored-after-the-withdrawal",
"client-conformance",
"fam-after-withdrawal",
);
orphan_refresh.subject = Some("subject-conformance".to_string());
match store.put_refresh_token(orphan_refresh).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_REFRESH,
"put_refresh_token restored a refresh record for a withdrawn consent. This is the \
write every refusal path of a rotation makes, on a record `take_refresh_token` \
has already removed, so absence proves nothing and the barrier is the only \
evidence there is: without it the withdrawal is undone by the rotation it raced",
),
Err(e) => report.fail(
BARRIER_REFUSES_REFRESH,
format!("put_refresh_token failed unexpectedly: {e}"),
),
}
let mut bystanders_token = sample_token(
"at-for-the-other-subject-after-the-withdrawal",
"client-conformance",
Some("fam-after-withdrawal"),
);
bystanders_token.subject = Some("subject-other".to_string());
match store.put_token(bystanders_token).await {
Ok(WriteOutcome::Applied) => {}
Ok(WriteOutcome::RefusedRevoked) => report.fail(
BARRIER_SPARES_UNRELATED,
"put_token refused a token for a DIFFERENT resource owner of the same client after \
one user withdrew their consent: the consent barrier is matching on the client \
alone, so one person clicking withdraw stops every other user of that application \
from obtaining a token until the barrier is swept",
),
Err(e) => report.fail(
BARRIER_SPARES_UNRELATED,
format!("put_token failed unexpectedly: {e}"),
),
}
if let Some(listed) = report.ok(
CONSENTS_FOR_SUBJECT,
"consents_for_subject after revoke_consent",
store.consents_for_subject("subject-conformance").await,
) {
let ids: Vec<&str> = listed.iter().map(|r| r.consent_id.as_ref()).collect();
if ids.contains(&"consent-mine") {
report.fail(
CONSENTS_FOR_SUBJECT,
format!(
"consents_for_subject still listed {ids:?} after that consent was \
withdrawn: the per-subject listing is a stale index, so the user is shown \
an application they have already stopped"
),
);
}
}
if removed != 4 {
report.fail(
REVOKE_CONSENT_COUNT,
format!(
"revoke_consent removed 4 credentials but reported {removed} (the consent \
record itself is not counted)"
),
);
}
if let Some(second) = report.ok(
REVOKE_CONSENT_COUNT,
"revoke_consent (second call)",
store.revoke_consent("consent-mine", barrier_window()).await,
) {
if second != 0 {
report.fail(
REVOKE_CONSENT_COUNT,
format!("withdrawing an already-withdrawn consent reported {second}, not 0"),
);
}
}
}
fn judge_race<T>(
&self,
report: &mut Report,
check: &'static str,
what: &str,
results: TakeResults<T>,
) {
let winners = results.iter().filter(|r| matches!(r, Ok(Some(_)))).count();
let errors = results.iter().filter(|r| r.is_err()).count();
judge_race_counts(report, check, what, winners, errors, results.len());
}
async fn race<T, M>(&self, report: &mut Report, make: M) -> TakeResults<T>
where
T: Send + Unpin + 'static,
M: Fn(Arc<Gate>) -> BoxTake<T>,
{
let n = self.racers;
let gate = Gate::new(n);
let futures: Vec<BoxTake<T>> = (0..n).map(|_| make(Arc::clone(&gate))).collect();
let abandoned = Arc::new(AtomicUsize::new(0));
let results = match &self.spawn {
Some(spawn) => {
let collected: Arc<Mutex<TakeResults<T>>> =
Arc::new(Mutex::new(Vec::with_capacity(n)));
let latch = Latch::new(n);
for fut in futures {
let collected = Arc::clone(&collected);
let latch = Arc::clone(&latch);
let abandoned = Arc::clone(&abandoned);
spawn(Box::pin(async move {
let mut guard = RacerGuard {
latch,
abandoned,
finished: false,
};
let outcome = fut.await;
collected
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(outcome);
guard.finished = true;
}));
}
latch.wait().await;
let mut guard = collected.lock().unwrap_or_else(|e| e.into_inner());
std::mem::take(&mut *guard)
}
None => JoinAll::new(futures).await,
};
race_setup_verdict(
report,
abandoned.load(Ordering::SeqCst),
n,
gate.unsatisfied(),
);
results
}
async fn user_code_index(&self, report: &mut Report) {
let store = self.store().await;
let ok_first = report
.ok(
INDEX_RETIRES_OLD_USER_CODE,
"put_device_grant",
store
.put_device_grant(sample_device_grant("dc-idx", "AAAA-AAAA"))
.await,
)
.is_some();
let ok_second = report
.ok(
INDEX_RETIRES_OLD_USER_CODE,
"put_device_grant (same device_code, new user code)",
store
.put_device_grant(sample_device_grant("dc-idx", "BBBB-BBBB"))
.await,
)
.is_some();
if ok_first && ok_second {
if let Some(found) = report.ok(
INDEX_RETIRES_OLD_USER_CODE,
"find_device_grant_by_user_code(new)",
store.find_device_grant_by_user_code("BBBBBBBB").await,
) {
if found.is_none() {
report.fail(
INDEX_RETIRES_OLD_USER_CODE,
"after a put changed the user code, the NEW code does not resolve",
);
}
}
if let Some(found) = report.ok(
INDEX_RETIRES_OLD_USER_CODE,
"find_device_grant_by_user_code(old)",
store.find_device_grant_by_user_code("AAAAAAAA").await,
) {
if found.is_some() {
report.fail(
INDEX_RETIRES_OLD_USER_CODE,
"the OLD user code still resolves after a put changed it: a code the user \
was shown and that has been superseded can still be used to approve the \
grant",
);
}
}
}
if report
.ok(
INDEX_CLEARED_BY_TAKE,
"take_device_grant",
store.take_device_grant("dc-idx").await,
)
.is_some()
{
if let Some(found) = report.ok(
INDEX_CLEARED_BY_TAKE,
"find_device_grant_by_user_code after take",
store.find_device_grant_by_user_code("BBBBBBBB").await,
) {
if found.is_some() {
report.fail(
INDEX_CLEARED_BY_TAKE,
"a taken grant is still reachable by its user code",
);
}
}
}
let store = self.store().await;
if report
.ok(
INDEX_REFUSES_DUPLICATE_USER_CODE,
"put_device_grant",
store
.put_device_grant(sample_device_grant("dc-first", "CCCC-CCCC"))
.await,
)
.is_none()
{
return;
}
let clash = store
.put_device_grant(sample_device_grant("dc-second", "CCCC-CCCC"))
.await;
if clash.is_ok() {
report.fail(
INDEX_REFUSES_DUPLICATE_USER_CODE,
"putting a second grant with a user code already indexed for another device_code \
succeeded; it must fail with a StorageError. Repointing the index gives two \
devices one identity and orphans the older grant, and it makes the server's \
user-code collision retry loop meaningless, since only the store can answer \
\"is this code taken\" without a race",
);
}
if let Some(found) = report.ok(
INDEX_REFUSAL_WRITES_NOTHING,
"find_device_grant_by_user_code after the refused put",
store.find_device_grant_by_user_code("CCCCCCCC").await,
) {
match found {
Some(g) if g.device_code == "dc-first" => {}
Some(g) => report.fail(
INDEX_REFUSAL_WRITES_NOTHING,
format!(
"the user code now resolves to device_code {:?}, not to the grant that \
owned it: the index was repointed by a put that should have written \
nothing",
g.device_code
),
),
None => report.fail(
INDEX_REFUSAL_WRITES_NOTHING,
"the user code resolves to nothing after a clashing put: the refused write \
removed the index entry belonging to the grant that already owned it",
),
}
}
if let Some(found) = report.ok(
INDEX_REFUSAL_WRITES_NOTHING,
"get_device_grant(dc-second)",
store.get_device_grant("dc-second").await,
) {
if found.is_some() {
report.fail(
INDEX_REFUSAL_WRITES_NOTHING,
"the clashing grant was persisted even though its user code belonged to \
another device_code",
);
}
}
let store = self.store().await;
if report
.ok(
INDEX_NO_NORMALIZATION,
"put_device_grant",
store
.put_device_grant(sample_device_grant("dc-norm", "WDJB-MJHT"))
.await,
)
.is_none()
{
return;
}
if let Some(found) = report.ok(
INDEX_NO_NORMALIZATION,
"find_device_grant_by_user_code(normalized)",
store.find_device_grant_by_user_code("WDJBMJHT").await,
) {
if found.is_none() {
report.fail(
INDEX_NO_NORMALIZATION,
"the normalized user code does not resolve, so the store is not indexing what \
it was given",
);
}
}
for probe in ["WDJB-MJHT", "wdjbmjht"] {
if let Some(found) = report.ok(
INDEX_NO_NORMALIZATION,
"find_device_grant_by_user_code(unnormalized)",
store.find_device_grant_by_user_code(probe).await,
) {
if found.is_some() {
report.fail(
INDEX_NO_NORMALIZATION,
format!(
"the store resolved {probe:?}, which is not the normalized key it was \
given: it normalizes on the caller's behalf, so two distinct index \
keys collide and a lookup the server never intended succeeds"
),
);
}
}
}
}
async fn sweep(&self, report: &mut Report) {
let store = self.store().await;
let now = at(0);
let mut dead_grant = sample_device_grant("dc-dead", "DEAD-AAAA");
dead_grant.expires_at = now;
let mut live_grant = sample_device_grant("dc-live", "LIVE-AAAA");
live_grant.expires_at = at(600);
let mut dead_code = sample_authorization_code("code-dead");
dead_code.expires_at = at_before(1);
dead_code.state = AuthorizationCodeState::Issued;
let mut live_code = sample_authorization_code("code-live");
live_code.expires_at = at(600);
let mut dead_token = sample_token("at-dead", "client-sweep", Some("fam-sweep"));
dead_token.expires_at = at_before(1);
let mut live_token = sample_token("at-live", "client-sweep", Some("fam-sweep"));
live_token.expires_at = at(600);
#[cfg(feature = "par")]
let mut dead_pushed = sample_pushed_request(PUSHED_SWEPT);
#[cfg(feature = "par")]
{
dead_pushed.expires_at = now;
}
#[cfg(feature = "par")]
let mut live_pushed = sample_pushed_request(PUSHED_KEPT);
#[cfg(feature = "par")]
{
live_pushed.expires_at = at(600);
}
let mut dead_refresh = sample_refresh("rt-dead", "client-sweep", "fam-sweep");
dead_refresh.expires_at = Some(now);
let mut live_refresh = sample_refresh("rt-live", "client-sweep", "fam-sweep");
live_refresh.expires_at = Some(at(600));
let mut endless_refresh = sample_refresh("rt-endless", "client-sweep", "fam-sweep");
endless_refresh.expires_at = None;
let c = SWEEP_REMOVES_DEAD;
let mut planted = true;
for grant in [dead_grant, live_grant] {
planted &= report
.ok(c, "put_device_grant", store.put_device_grant(grant).await)
.is_some();
}
for code in [dead_code, live_code] {
planted &= report
.ok(
c,
"put_authorization_code",
store.put_authorization_code(code).await,
)
.is_some();
}
for token in [dead_token, live_token] {
planted &= report
.ok(c, "put_token", store.put_token(token).await)
.is_some();
}
for record in [dead_refresh, live_refresh, endless_refresh] {
planted &= report
.ok(
c,
"put_refresh_token",
store.put_refresh_token(record).await,
)
.is_some();
}
#[cfg(feature = "par")]
for record in [dead_pushed, live_pushed] {
planted &= report
.ok(
c,
"put_pushed_authorization_request",
store.put_pushed_authorization_request(record).await,
)
.is_some();
}
if !planted {
return;
}
let Some(removed) = report.ok(c, "sweep_expired", store.sweep_expired(now).await) else {
return;
};
#[cfg(feature = "par")]
let (dead_records, planted_records) = (5u64, 11);
#[cfg(not(feature = "par"))]
let (dead_records, planted_records) = (4u64, 9);
if removed != dead_records {
report.fail(
SWEEP_COUNT,
format!(
"sweep_expired reported {removed} records removed, but exactly {dead_records} \
of the {planted_records} planted records were dead at `now`. The count is \
what a host schedules its sweep on, so a wrong one is a store that looks idle \
while it grows"
),
);
}
if let Some(found) = report.ok(
c,
"get_device_grant",
store.get_device_grant("dc-dead").await,
) {
if found.is_some() {
report.fail(c, "an expired device grant survived the sweep");
}
}
if let Some(found) = report.ok(
c,
"find_device_grant_by_user_code",
store.find_device_grant_by_user_code("DEADAAAA").await,
) {
if found.is_some() {
report.fail(
c,
"the user code of a swept grant still resolves: the index outlived the record \
it points at",
);
}
}
if let Some(found) = report.ok(
c,
"take_authorization_code",
store.take_authorization_code("code-dead").await,
) {
if found.is_some() {
report.fail(c, "an expired authorization code survived the sweep");
}
}
if let Some(found) = report.ok(c, "get_token", store.get_token("at-dead").await) {
if found.is_some() {
report.fail(c, "an expired access token survived the sweep");
}
}
if let Some(found) = report.ok(
c,
"get_refresh_token",
store.get_refresh_token("rt-dead").await,
) {
if found.is_some() {
report.fail(c, "an expired refresh record survived the sweep");
}
}
#[cfg(feature = "par")]
if let Some(found) = report.ok(
SWEEP_RECLAIMS_PUSHED_REQUESTS,
"take_pushed_authorization_request",
store.take_pushed_authorization_request(PUSHED_SWEPT).await,
) {
if found.is_some() {
report.fail(
SWEEP_RECLAIMS_PUSHED_REQUESTS,
"an expired pushed authorization request survived the sweep: nothing else in \
this crate ever reclaims one, so the table grows once per pushed request that \
was never redeemed, forever",
);
}
}
let k = SWEEP_KEEPS_LIVE;
if let Some(found) = report.ok(
k,
"get_device_grant",
store.get_device_grant("dc-live").await,
) {
if found.is_none() {
report.fail(k, "the sweep removed a device grant that had not expired");
}
}
if let Some(found) = report.ok(k, "get_token", store.get_token("at-live").await) {
if found.is_none() {
report.fail(k, "the sweep removed an access token that had not expired");
}
}
if let Some(found) = report.ok(
k,
"get_refresh_token",
store.get_refresh_token("rt-live").await,
) {
if found.is_none() {
report.fail(k, "the sweep removed a refresh record that had not expired");
}
}
if let Some(found) = report.ok(
k,
"get_refresh_token(no absolute expiry)",
store.get_refresh_token("rt-endless").await,
) {
if found.is_none() {
report.fail(
k,
"the sweep removed a refresh record whose expires_at is None. A chain with no \
absolute lifetime is not dead, and treating None as \"expired at the epoch\" \
silently logs every such client out",
);
}
}
if let Some(found) = report.ok(
k,
"take_authorization_code(live)",
store.take_authorization_code("code-live").await,
) {
if found.is_none() {
report.fail(
k,
"the sweep removed an authorization code that had not expired",
);
}
}
#[cfg(feature = "par")]
if let Some(found) = report.ok(
k,
"take_pushed_authorization_request(live)",
store.take_pushed_authorization_request(PUSHED_KEPT).await,
) {
if found.is_none() {
report.fail(
k,
"the sweep removed a pushed authorization request that had not expired",
);
}
}
let store = self.store().await;
if let Some(removed) = report.ok(
SWEEP_EMPTY_IS_ZERO,
"sweep_expired on an empty store",
store.sweep_expired(now).await,
) {
if removed != 0 {
report.fail(
SWEEP_EMPTY_IS_ZERO,
format!("sweep_expired on an empty store reported {removed} records removed"),
);
}
}
}
async fn sweep_under_concurrent_writes(&self, report: &mut Report) {
let c = SWEEP_CONCURRENT_WRITES;
let store = self.store().await;
let n = self.racers;
for i in 0..n {
let mut planted = sample_token(
&format!("at-sweep-race-planted-{i}"),
"client-sweep-race",
None,
);
planted.expires_at = at(600);
if report
.ok(
c,
"put_token (live, before the race)",
store.put_token(planted).await,
)
.is_none()
{
return;
}
}
let answered: Arc<Mutex<Option<Result<u64, StorageError>>>> = Arc::new(Mutex::new(None));
let seq = AtomicUsize::new(0);
let results = self
.race(report, |gate| {
let store = Arc::clone(&store);
let answered = Arc::clone(&answered);
let index = seq.fetch_add(1, Ordering::SeqCst);
Box::pin(async move {
if index == 0 {
gate.wait().await;
let swept = store.sweep_expired(at(0)).await;
*answered.lock().unwrap_or_else(|e| e.into_inner()) = Some(swept);
return Ok(None);
}
let mut token = sample_token(
&format!("at-sweep-race-written-{index}"),
"client-sweep-race",
None,
);
token.expires_at = at(600);
gate.wait().await;
let outcome = store.put_token(token).await?;
Ok(if outcome.is_applied() {
Some(index)
} else {
None
})
})
})
.await;
let mut errors = 0usize;
let mut applied = Vec::new();
for result in results {
match result {
Ok(Some(index)) => applied.push(index),
Ok(None) => {}
Err(_) => errors += 1,
}
}
if errors > 0 {
report.fail(
c,
format!(
"{errors} of the {} concurrent put_token calls failed with a StorageError \
while a sweep was in flight. An issuance may not fail because a maintenance \
job is running beside it: the server maps that to server_error, so the host's \
sweep schedule becomes a source of failed token requests",
n - 1
),
);
}
match &*answered.lock().unwrap_or_else(|e| e.into_inner()) {
Some(Ok(0)) => {}
Some(Ok(removed)) => report.fail(
c,
format!(
"the sweep removed {removed} records at an instant when every record in the \
store was live. It is reaping what the writes beside it were adding, so a \
token this store reported as written is already gone"
),
),
Some(Err(_)) => {}
None => report.fail(
c,
"the sweep never reported an answer, so the race never ran it".to_string(),
),
}
let mut lost = Vec::new();
for index in &applied {
let key = format!("at-sweep-race-written-{index}");
match report.ok(c, "get_token", store.get_token(&key).await) {
Some(None) => lost.push(key),
Some(Some(_)) => {}
None => return,
}
}
for i in 0..n {
let key = format!("at-sweep-race-planted-{i}");
match report.ok(c, "get_token", store.get_token(&key).await) {
Some(None) => lost.push(key),
Some(Some(_)) => {}
None => return,
}
}
if !lost.is_empty() {
report.fail(
c,
format!(
"{} live access tokens were gone after a sweep that ran alongside {} \
concurrent put_token calls: {:?}. Every one of them was either already \
stored or reported Applied, so this store loses writes that overlap a sweep. \
The usual cause is reading the table, deciding what to keep and writing the \
kept set back, with anything at all happening outside the lock in between",
lost.len(),
n - 1,
lost
),
);
}
}
async fn revoke_family(&self, report: &mut Report) {
let c = REVOKE_FAMILY_REMOVES;
let store = self.store().await;
let mut planted = true;
for (key, family) in [("at-a1", "fam-a"), ("at-a2", "fam-a"), ("at-b", "fam-b")] {
planted &= report
.ok(
c,
"put_token",
store
.put_token(sample_token(key, "client-fam", Some(family)))
.await,
)
.is_some();
}
planted &= report
.ok(
c,
"put_token(no family)",
store
.put_token(sample_token("at-nofam", "client-fam", None))
.await,
)
.is_some();
for (key, family) in [("rt-a1", "fam-a"), ("rt-a2", "fam-a"), ("rt-b", "fam-b")] {
planted &= report
.ok(
c,
"put_refresh_token",
store
.put_refresh_token(sample_refresh(key, "client-fam", family))
.await,
)
.is_some();
}
if !planted {
return;
}
let Some(removed) = report.ok(
c,
"revoke_token_family",
store.revoke_token_family("fam-a", barrier_window()).await,
) else {
return;
};
if removed != 4 {
report.fail(
REVOKE_FAMILY_COUNT,
format!(
"revoke_token_family reported {removed} removed, but the family held 4 \
records (2 access tokens and 2 refresh records)"
),
);
}
for key in ["at-a1", "at-a2"] {
if let Some(found) = report.ok(c, "get_token", store.get_token(key).await) {
if found.is_some() {
report.fail(
c,
format!(
"access token {key} carrying the revoked family_id survived. RFC 9700 \
section 4.14.2 requires revoking the tokens issued for that \
authorization grant, not just the refresh chain, so the thief's \
already-minted access tokens stay live"
),
);
}
}
}
for key in ["rt-a1", "rt-a2"] {
if let Some(found) =
report.ok(c, "get_refresh_token", store.get_refresh_token(key).await)
{
if found.is_some() {
report.fail(
c,
format!("refresh record {key} carrying the revoked family_id survived"),
);
}
}
}
let s = REVOKE_FAMILY_SPARES_OTHERS;
if let Some(found) = report.ok(s, "get_token", store.get_token("at-b").await) {
if found.is_none() {
report.fail(s, "revoking one family removed an access token of another");
}
}
if let Some(found) = report.ok(
s,
"get_refresh_token",
store.get_refresh_token("rt-b").await,
) {
if found.is_none() {
report.fail(s, "revoking one family removed a refresh record of another");
}
}
if let Some(found) = report.ok(s, "get_token(no family)", store.get_token("at-nofam").await)
{
if found.is_none() {
report.fail(
s,
"revoking a family removed an access token that carries no family_id at all",
);
}
}
match store.revoke_token_family("fam-a", barrier_window()).await {
Ok(0) => {}
Ok(n) => report.fail(
REVOKE_FAMILY_COUNT,
format!("revoking an already-revoked family reported {n} removed, expected 0"),
),
Err(e) => report.fail(
c,
format!(
"revoking an already-revoked family failed with {e}. Removing records that are \
already gone is success: this runs on evidence of compromise"
),
),
}
}
async fn delete_client(&self, report: &mut Report) {
let c = DELETE_CLIENT_CASCADES;
let store = self.store().await;
let doomed = ClientId::new("client-doomed");
let bystander = ClientId::new("client-bystander");
let mut planted = true;
for id in [&doomed, &bystander] {
planted &= report
.ok(
c,
"put_client",
store.put_client(sample_client(id.as_str())).await,
)
.is_some();
let mut grant = sample_device_grant(
&format!("dc-{}", id.as_str()),
if id == &doomed {
"DOOM-AAAA"
} else {
"BYST-AAAA"
},
);
grant.client_id = id.clone();
planted &= report
.ok(c, "put_device_grant", store.put_device_grant(grant).await)
.is_some();
let mut code = sample_authorization_code(&format!("code-{}", id.as_str()));
code.client_id = id.clone();
planted &= report
.ok(
c,
"put_authorization_code",
store.put_authorization_code(code).await,
)
.is_some();
let mut unredeemed =
sample_authorization_code(&format!("code-unredeemed-{}", id.as_str()));
unredeemed.client_id = id.clone();
unredeemed.state = AuthorizationCodeState::Issued;
planted &= report
.ok(
c,
"put_authorization_code (unredeemed)",
store.put_authorization_code(unredeemed).await,
)
.is_some();
planted &= report
.ok(
c,
"put_token",
store
.put_token(sample_token(
&format!("at-{}", id.as_str()),
id.as_str(),
Some("fam-cascade"),
))
.await,
)
.is_some();
planted &= report
.ok(
c,
"put_refresh_token",
store
.put_refresh_token(sample_refresh(
&format!("rt-{}", id.as_str()),
id.as_str(),
"fam-cascade",
))
.await,
)
.is_some();
#[cfg(feature = "par")]
{
let mut pushed = sample_pushed_request(&pushed_request_uri(id));
pushed.client_id = id.clone();
planted &= report
.ok(
c,
"put_pushed_authorization_request",
store.put_pushed_authorization_request(pushed).await,
)
.is_some();
}
#[cfg(feature = "consent")]
{
let mut consent =
sample_consent(&format!("consent-{}", id.as_str()), "subject-conformance");
consent.client_id = id.clone();
planted &= report
.ok(c, "put_consent", store.put_consent(consent).await)
.is_some();
}
}
if !planted {
return;
}
let Some(existed) = report.ok(
DELETE_CLIENT_REPORTS,
"delete_client",
store.delete_client(&doomed, barrier_window()).await,
) else {
return;
};
if !existed {
report.fail(
DELETE_CLIENT_REPORTS,
"delete_client answered false for a registration that was present",
);
}
if let Some(found) = report.ok(c, "get_client", store.get_client(&doomed).await) {
if found.is_some() {
report.fail(c, "the registration survived delete_client");
}
}
if let Some(found) = report.ok(c, "get_token", store.get_token("at-client-doomed").await) {
if found.is_some() {
report.fail(
c,
"an access token issued to the deleted client survived: a client that no \
longer exists can still call resource servers",
);
}
}
if let Some(found) = report.ok(
c,
"get_refresh_token",
store.get_refresh_token("rt-client-doomed").await,
) {
if found.is_some() {
report.fail(
c,
"a refresh chain of the deleted client survived, so the deleted client can \
mint fresh access tokens indefinitely",
);
}
}
if let Some(found) = report.ok(
c,
"take_authorization_code",
store.take_authorization_code("code-client-doomed").await,
) {
if found.is_some() {
report.fail(c, "an authorization code of the deleted client survived");
}
}
if let Some(found) = report.ok(
c,
"take_authorization_code (unredeemed)",
store
.take_authorization_code("code-unredeemed-client-doomed")
.await,
) {
if found.is_some() {
report.fail(
c,
"an UNREDEEMED authorization code of the deleted client survived, so the \
cascade is filtering on the code's state. That code is a live grant: the \
deleted registration redeems it and receives an access token and a refresh \
chain minutes after RFC 7592 section 2.3 said it no longer exists",
);
}
}
if let Some(found) = report.ok(
c,
"get_device_grant",
store.get_device_grant("dc-client-doomed").await,
) {
if found.is_some() {
report.fail(c, "a device grant of the deleted client survived");
}
}
if let Some(found) = report.ok(
c,
"find_device_grant_by_user_code",
store.find_device_grant_by_user_code("DOOMAAAA").await,
) {
if found.is_some() {
report.fail(
c,
"the user-code index entry of the deleted client's device grant survived",
);
}
}
#[cfg(feature = "par")]
if let Some(found) = report.ok(
c,
"take_pushed_authorization_request",
store
.take_pushed_authorization_request(&pushed_request_uri(&doomed))
.await,
) {
if found.is_some() {
report.fail(
c,
"a pushed authorization request of the deleted client survived: RFC 9126 \
section 2.2 binds the handle to the client that pushed it, so what is left is \
a live `request_uri` nobody may ever redeem, holding authorization parameters \
for a registration that no longer exists",
);
}
}
#[cfg(feature = "consent")]
if let Some(found) = report.ok(
c,
"get_consent",
store.get_consent("consent-client-doomed").await,
) {
if found.is_some() {
report.fail(
c,
"a consent record of the deleted client survived. The user is shown an \
application that no longer exists and cannot meaningfully withdraw it, and \
because `client_id` is chosen by the HOST, a client provisioned later under \
the same id inherits that standing approval — its scope and its resource set \
— without the user ever being asked",
);
}
}
#[cfg(feature = "par")]
{
let mut again = sample_pushed_request(&pushed_request_uri(&doomed));
again.client_id = doomed.clone();
match store.put_pushed_authorization_request(again).await {
Ok(WriteOutcome::RefusedRevoked) => {}
Ok(WriteOutcome::Applied) => report.fail(
BARRIER_REFUSES_PUSHED_REQUEST,
"put_pushed_authorization_request restored a handle for a client that had just \
been deleted. The record was pushed BEFORE the deletion, so the barrier covers \
it; a store that writes it anyway hands a deleted registration a live \
`request_uri`, and if the host re-provisions that `client_id` — which the \
trait explicitly permits — the handle resolves against the NEW registration \
carrying a `code_challenge` its owner never pushed",
),
Err(e) => report.fail(
BARRIER_REFUSES_PUSHED_REQUEST,
format!("put_pushed_authorization_request failed unexpectedly: {e}"),
),
}
}
if let Some(found) = report.ok(
c,
"get_client(bystander)",
store.get_client(&bystander).await,
) {
if found.is_none() {
report.fail(c, "delete_client removed a DIFFERENT client's registration");
}
}
if let Some(found) = report.ok(
c,
"get_token(bystander)",
store.get_token("at-client-bystander").await,
) {
if found.is_none() {
report.fail(c, "delete_client removed another client's access token");
}
}
if let Some(found) = report.ok(
c,
"get_refresh_token(bystander)",
store.get_refresh_token("rt-client-bystander").await,
) {
if found.is_none() {
report.fail(c, "delete_client removed another client's refresh record");
}
}
if let Some(found) = report.ok(
c,
"get_device_grant(bystander)",
store.get_device_grant("dc-client-bystander").await,
) {
if found.is_none() {
report.fail(c, "delete_client removed another client's device grant");
}
}
if let Some(found) = report.ok(
c,
"find_device_grant_by_user_code(bystander)",
store.find_device_grant_by_user_code("BYSTAAAA").await,
) {
if found.is_none() {
report.fail(
c,
"delete_client removed another client's user-code index entry: the grant is \
still there and the code the user was shown no longer reaches it, so the \
verification page answers \"no such code\" for a device that is waiting",
);
}
}
if let Some(found) = report.ok(
c,
"take_authorization_code(bystander)",
store.take_authorization_code("code-client-bystander").await,
) {
if found.is_none() {
report.fail(
c,
"delete_client removed another client's authorization code: a grant that was \
in flight for a client nobody deleted is gone, and the user sees a redemption \
fail as `invalid_grant` with nothing anywhere explaining it",
);
}
}
if let Some(found) = report.ok(
c,
"take_authorization_code(bystander, unredeemed)",
store
.take_authorization_code("code-unredeemed-client-bystander")
.await,
) {
if found.is_none() {
report.fail(
c,
"delete_client removed another client's UNREDEEMED authorization code: a user \
who is mid-authorization for a client nobody deleted has their redemption \
refused as `invalid_grant`",
);
}
}
#[cfg(feature = "par")]
if let Some(found) = report.ok(
c,
"take_pushed_authorization_request(bystander)",
store
.take_pushed_authorization_request(&pushed_request_uri(&bystander))
.await,
) {
if found.is_none() {
report.fail(
c,
"delete_client removed another client's pushed authorization request",
);
}
}
#[cfg(feature = "par")]
{
let mut bystanders_push = sample_pushed_request(
"urn:ietf:params:oauth:request_uri:pushed-by-a-client-nobody-deleted",
);
bystanders_push.client_id = bystander.clone();
match store
.put_pushed_authorization_request(bystanders_push)
.await
{
Ok(WriteOutcome::Applied) => {}
Ok(WriteOutcome::RefusedRevoked) => report.fail(
BARRIER_SPARES_UNRELATED,
"put_pushed_authorization_request refused a push from a DIFFERENT client than \
the one deleted: the client barrier is not comparing its scope against the \
record's `client_id`, so one deletion has stopped every client in this \
deployment pushing an authorization request until the barrier is swept",
),
Err(e) => report.fail(
BARRIER_SPARES_UNRELATED,
format!("put_pushed_authorization_request failed unexpectedly: {e}"),
),
}
}
#[cfg(feature = "consent")]
if let Some(found) = report.ok(
c,
"get_consent(bystander)",
store.get_consent("consent-client-bystander").await,
) {
if found.is_none() {
report.fail(
c,
"delete_client removed another client's consent record, so a user is shown \
that they never approved an application they did",
);
}
}
match store.delete_client(&doomed, barrier_window()).await {
Ok(true) => report.fail(
DELETE_CLIENT_REPORTS,
"delete_client answered true for a registration that was already gone",
),
Ok(false) => {}
Err(e) => report.fail(
DELETE_CLIENT_REPORTS,
format!("deleting an absent registration failed with {e}, expected Ok(false)"),
),
}
}
async fn delete_token(&self, report: &mut Report) {
let c = DELETE_TOKEN_IDEMPOTENT;
let store = self.store().await;
if report
.ok(
c,
"put_token",
store
.put_token(sample_token("at-del", "client-del", None))
.await,
)
.is_none()
{
return;
}
if report
.ok(c, "delete_token", store.delete_token("at-del").await)
.is_none()
{
return;
}
if let Some(found) = report.ok(c, "get_token", store.get_token("at-del").await) {
if found.is_some() {
report.fail(c, "the token is still readable after delete_token");
}
}
if let Err(e) = store.delete_token("at-del").await {
report.fail(
c,
format!("deleting an already-deleted token failed with {e}, expected Ok(())"),
);
}
if let Err(e) = store.delete_token("at-never-existed").await {
report.fail(
c,
format!("deleting a token that never existed failed with {e}, expected Ok(())"),
);
}
}
}
pub async fn check_storage<F, Fut, S>(new_store: F) -> Vec<Violation>
where
F: Fn() -> Fut,
Fut: Future<Output = S>,
S: Storage + 'static,
{
StorageConformance::new(new_store).run().await
}
#[derive(Default)]
struct Report {
violations: Vec<Violation>,
}
impl Report {
fn fail(&mut self, check: &'static str, detail: impl Into<String>) {
self.violations.push(Violation {
check,
detail: detail.into(),
});
}
fn ok<T>(&mut self, check: &'static str, what: &str, r: Result<T, StorageError>) -> Option<T> {
match r {
Ok(v) => Some(v),
Err(e) => {
self.fail(check, format!("{what} failed unexpectedly: {e}"));
None
}
}
}
fn some<T>(&mut self, check: &'static str, what: &str, v: Option<T>) -> Option<T> {
if v.is_none() {
self.fail(
check,
format!("{what} returned None for a record that was just stored"),
);
}
v
}
fn same<T: PartialEq + fmt::Debug>(
&mut self,
check: &'static str,
field: &str,
want: &T,
got: &T,
) {
if want != got {
self.fail(
check,
format!("field {field} did not survive the round trip: stored {want:?}, read back {got:?}"),
);
}
}
}
fn judge_race_counts(
report: &mut Report,
check: &'static str,
what: &str,
winners: usize,
errors: usize,
total: usize,
) {
if winners > 1 {
report.fail(
check,
format!(
"{winners} of {total} concurrent takes each received the {what}: the operation is \
not an atomic remove-and-return, so this store double-spends single-use \
credentials under concurrency"
),
);
} else if winners == 0 {
report.fail(
check,
format!(
"none of {total} concurrent takes received the {what}, though it was stored \
beforehand: the value was lost rather than handed to exactly one caller"
),
);
}
if errors > 0 {
report.fail(
check,
format!(
"{errors} of {total} concurrent takes failed with a StorageError. The server maps \
that to server_error, so a legitimate redemption fails under ordinary \
contention; a store using optimistic concurrency must retry internally \
rather than surface the conflict. This is the `Storage` trait's rule that \
contention is the store's to resolve, not the caller's: `Ok(None)` is how a \
take says the record was not there to take, and a StorageError is not"
),
);
}
}
fn race_setup_verdict(report: &mut Report, abandoned: usize, n: usize, gate_unsatisfied: bool) {
if abandoned > 0 {
report.fail(
HARNESS_RACER_PANICKED,
format!(
"{abandoned} of {n} racers never finished: the store's call panicked, or the \
spawner dropped the task before it completed. Whatever the results of this \
check say, a store that panics under concurrent access fails the request that \
hit it, and on a host that aborts on panic it takes the process with it. The \
panic message itself is on the spawner's own reporting path, not here"
),
);
}
if gate_unsatisfied {
report.fail(
HARNESS_RACE_SETUP,
format!(
"the {n} racers never overlapped: each gave up waiting for the others, which \
means they ran one after another and the atomicity results in this run prove \
nothing. A `with_spawn` that runs its task to completion inline does this; \
hand the futures to a real runtime instead"
),
);
}
}
pub(crate) struct Gate {
target: usize,
arrived: AtomicUsize,
open: AtomicBool,
unsatisfied: AtomicBool,
waiters: Mutex<Vec<Waker>>,
}
impl Gate {
pub(crate) fn new(target: usize) -> Arc<Self> {
Arc::new(Gate {
target,
arrived: AtomicUsize::new(0),
open: AtomicBool::new(false),
unsatisfied: AtomicBool::new(false),
waiters: Mutex::new(Vec::new()),
})
}
pub(crate) fn wait(self: &Arc<Self>) -> GateWait {
GateWait {
gate: Arc::clone(self),
counted: false,
budget: GATE_POLL_BUDGET,
}
}
pub(crate) fn unsatisfied(&self) -> bool {
self.unsatisfied.load(Ordering::SeqCst)
}
fn wake_all(&self) {
let mut waiters = self.waiters.lock().unwrap_or_else(|e| e.into_inner());
for waker in waiters.drain(..) {
waker.wake();
}
}
}
pub(crate) struct GateWait {
gate: Arc<Gate>,
counted: bool,
budget: u32,
}
impl Future for GateWait {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if !self.counted {
self.counted = true;
if self.gate.arrived.fetch_add(1, Ordering::SeqCst) + 1 >= self.gate.target {
self.gate.open.store(true, Ordering::SeqCst);
self.gate.wake_all();
return Poll::Ready(());
}
}
if self.gate.open.load(Ordering::SeqCst) {
return Poll::Ready(());
}
if self.budget == 0 {
self.gate.unsatisfied.store(true, Ordering::SeqCst);
return Poll::Ready(());
}
self.budget -= 1;
self.gate
.waiters
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(cx.waker().clone());
if self.gate.open.load(Ordering::SeqCst) {
return Poll::Ready(());
}
cx.waker().wake_by_ref();
Poll::Pending
}
}
pub(crate) struct Latch {
remaining: AtomicUsize,
waker: Mutex<Option<Waker>>,
}
impl Latch {
pub(crate) fn new(target: usize) -> Arc<Self> {
Arc::new(Latch {
remaining: AtomicUsize::new(target),
waker: Mutex::new(None),
})
}
pub(crate) fn done(&self) {
if self.remaining.fetch_sub(1, Ordering::SeqCst) == 1 {
if let Some(waker) = self.waker.lock().unwrap_or_else(|e| e.into_inner()).take() {
waker.wake();
}
}
}
pub(crate) fn wait(self: &Arc<Self>) -> LatchWait {
LatchWait {
latch: Arc::clone(self),
}
}
}
struct RacerGuard {
latch: Arc<Latch>,
abandoned: Arc<AtomicUsize>,
finished: bool,
}
impl Drop for RacerGuard {
fn drop(&mut self) {
if !self.finished {
self.abandoned.fetch_add(1, Ordering::SeqCst);
}
self.latch.done();
}
}
pub(crate) struct LatchWait {
latch: Arc<Latch>,
}
impl Future for LatchWait {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.latch.remaining.load(Ordering::SeqCst) == 0 {
return Poll::Ready(());
}
*self.latch.waker.lock().unwrap_or_else(|e| e.into_inner()) = Some(cx.waker().clone());
if self.latch.remaining.load(Ordering::SeqCst) == 0 {
return Poll::Ready(());
}
Poll::Pending
}
}
pub(crate) struct JoinAll<T> {
futures: Vec<Option<Pin<Box<dyn Future<Output = T> + Send>>>>,
done: Vec<Option<T>>,
}
impl<T> JoinAll<T> {
pub(crate) fn new(futures: Vec<Pin<Box<dyn Future<Output = T> + Send>>>) -> Self {
let mut done = Vec::with_capacity(futures.len());
done.resize_with(futures.len(), || None);
JoinAll {
futures: futures.into_iter().map(Some).collect(),
done,
}
}
}
impl<T: Unpin> Future for JoinAll<T> {
type Output = Vec<T>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Vec<T>> {
let JoinAll { futures, done } = self.get_mut();
let mut pending = false;
for (slot, out) in futures.iter_mut().zip(done.iter_mut()) {
if let Some(fut) = slot {
match fut.as_mut().poll(cx) {
Poll::Ready(v) => {
*out = Some(v);
*slot = None;
}
Poll::Pending => pending = true,
}
}
}
if pending {
return Poll::Pending;
}
Poll::Ready(done.iter_mut().filter_map(Option::take).collect())
}
}
const BASE_SECS: u64 = 1_800_000_000;
fn at(offset_secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(BASE_SECS + offset_secs)
}
fn at_before(offset_secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(BASE_SECS - offset_secs)
}
fn barrier_window() -> crate::store::RevocationWindow {
crate::store::RevocationWindow {
recorded_at: at_before(0),
until: barrier_until(),
}
}
fn barrier_until() -> SystemTime {
at(1_000_000)
}
fn scopes(s: &str) -> ScopeSet {
ScopeSet::parse(s).unwrap_or_else(|_| ScopeSet::empty())
}
#[cfg(feature = "rar")]
const AUTHORIZATION_DETAILS_JSON: &str = r#"[{"type":"conformance-fixture","locations":["https://rs-one.example/"],"actions":["read","write"],"identifier":"account-4711"}]"#;
#[cfg(feature = "rar")]
fn sample_authorization_details() -> crate::rar::AuthorizationDetails {
crate::rar::AuthorizationDetails::parse(AUTHORIZATION_DETAILS_JSON)
.unwrap_or_else(|_| crate::rar::AuthorizationDetails::none())
}
#[cfg(feature = "consent")]
fn sample_authentication() -> Option<Box<crate::consent::Authentication>> {
Some(Box::new(crate::consent::Authentication {
auth_time: at_before(120),
acr: Some("urn:conformance:acr:multi-factor".into()),
}))
}
fn sample_client(client_id: &str) -> Client {
Client {
client_id: ClientId::new(client_id),
auth: ClientAuth::ConfidentialSecretHash {
hash: SecretHash::sha256("conformance-secret"),
},
grant_types: vec![
GrantType::AuthorizationCode,
GrantType::RefreshToken,
GrantType::DeviceCode,
],
redirect_uris: vec![
"https://app.example/cb".to_string(),
"https://app.example/cb2".to_string(),
],
allowed_scopes: scopes("read write admin"),
default_scopes: scopes("read"),
name: Some("conformance client".to_string()),
registration: Some(Box::new(DynamicRegistration {
registration_access_token_hash: SecretHash::sha256("conformance-rat"),
client_id_issued_at: Some(BASE_SECS),
client_secret_expires_at: Some(0),
token_endpoint_auth_method: "client_secret_basic".to_string(),
})),
}
}
fn sample_device_grant(device_code: &str, user_code: &str) -> DeviceGrant {
DeviceGrant {
device_code: device_code.to_string(),
user_code: user_code.to_string(),
client_id: ClientId::new("client-conformance"),
scope: scopes("read write"),
state: DeviceGrantState::Approved {
subject: "subject-conformance".to_string(),
},
created_at: at_before(30),
expires_at: at(600),
interval: Duration::from_secs(7),
last_poll_at: Some(at_before(5)),
}
}
#[cfg(feature = "consent")]
fn sample_approved_device_grant(device_code: &str, user_code: &str, subject: &str) -> DeviceGrant {
DeviceGrant {
state: DeviceGrantState::Approved {
subject: subject.to_string(),
},
..sample_device_grant(device_code, user_code)
}
}
#[cfg(feature = "consent")]
fn sample_consent(consent_id: &str, subject: &str) -> crate::consent::ConsentRecord {
crate::consent::ConsentRecord {
consent_id: consent_id.into(),
client_id: ClientId::new("client-conformance"),
subject: subject.into(),
scope: scopes("read write"),
resource: vec!["https://rs-one.example/".to_string()],
granted_at: at_before(60),
authentication: sample_authentication(),
}
}
#[cfg(feature = "par")]
fn pushed_request_uri(client_id: &ClientId) -> String {
format!(
"urn:ietf:params:oauth:request_uri:cascade-{}",
client_id.as_str()
)
}
#[cfg(feature = "par")]
const PUSHED_SWEPT: &str = "urn:ietf:params:oauth:request_uri:sweep-dead";
#[cfg(feature = "par")]
const PUSHED_KEPT: &str = "urn:ietf:params:oauth:request_uri:sweep-live";
#[cfg(feature = "par")]
fn sample_pushed_request(request_uri: &str) -> crate::par::PushedAuthorizationRequest {
crate::par::PushedAuthorizationRequest {
pushed_at: at_before(10),
request_uri: request_uri.to_string(),
client_id: ClientId::new("client-conformance"),
response_type: Some("code".to_string()),
redirect_uri: Some("https://app.example/cb".to_string()),
scope: Some("read write".to_string()),
state: Some("state-conformance".to_string()),
code_challenge: Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_string()),
code_challenge_method: Some("S256".to_string()),
resource: vec!["https://rs-one.example/".to_string()],
#[cfg(feature = "rar")]
authorization_details: Some(AUTHORIZATION_DETAILS_JSON.to_string()),
#[cfg(feature = "consent")]
acr_values: Some("urn:acr:phr".to_string()),
#[cfg(feature = "consent")]
max_age: Some("300".to_string()),
expires_at: at(60),
}
}
fn sample_authorization_code(code: &str) -> AuthorizationCodeRecord {
AuthorizationCodeRecord {
code: code.to_string(),
client_id: ClientId::new("client-conformance"),
redirect_uri: "https://app.example/cb".to_string(),
redirect_uri_was_explicit: false,
scope: scopes("read write"),
subject: "subject-conformance".to_string(),
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_string(),
code_challenge_method: CodeChallengeMethod::S256,
resource: vec![
"https://rs-one.example/".to_string(),
"https://rs-two.example/".to_string(),
],
#[cfg(feature = "rar")]
authorization_details: sample_authorization_details(),
issued_at: at_before(10),
expires_at: at(60),
state: AuthorizationCodeState::Consumed {
access_token: Some("at-minted-by-this-code".to_string()),
refresh_token: Some("rt-minted-by-this-code".to_string()),
},
#[cfg(feature = "consent")]
authentication: sample_authentication(),
}
}
fn sample_token(access_token: &str, client_id: &str, family_id: Option<&str>) -> IssuedToken {
IssuedToken {
access_token: access_token.to_string(),
client_id: ClientId::new(client_id),
subject: Some("subject-conformance".to_string()),
scope: scopes("read write"),
resource: vec![
"https://rs-one.example/".to_string(),
"https://rs-two.example/".to_string(),
],
#[cfg(feature = "rar")]
authorization_details: sample_authorization_details(),
issued_at: at_before(10),
grant_established_at: at_before(20),
expires_at: at(3600),
family_id: family_id.map(str::to_string),
#[cfg(feature = "token-exchange")]
act: Some(Box::new(crate::token_exchange::ActClaim {
sub: "actor-conformance".to_string(),
client_id: Some("client-actor-conformance".to_string()),
act: Some(Box::new(crate::token_exchange::ActClaim {
sub: "actor-conformance-prior".to_string(),
client_id: None,
act: None,
})),
})),
#[cfg(feature = "dpop")]
jkt: Some("0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I".into()),
#[cfg(feature = "mtls")]
x5t_s256: Some(Box::new(crate::mtls::CertificateThumbprint::from_der(
b"conformance-fixture-certificate",
))),
#[cfg(feature = "consent")]
authentication: sample_authentication(),
}
}
fn sample_refresh(refresh_token: &str, client_id: &str, family_id: &str) -> RefreshTokenRecord {
RefreshTokenRecord {
refresh_token: refresh_token.to_string(),
client_id: ClientId::new(client_id),
subject: Some("subject-conformance".to_string()),
scope: scopes("read write"),
resource: vec![
"https://rs-one.example/".to_string(),
"https://rs-two.example/".to_string(),
],
#[cfg(feature = "rar")]
authorization_details: sample_authorization_details(),
grant_established_at: at_before(20),
expires_at: Some(at(86_400)),
family_id: family_id.to_string(),
state: RefreshTokenState::Spent,
#[cfg(feature = "dpop")]
jkt: Some("0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I".into()),
#[cfg(feature = "mtls")]
x5t_s256: Some(Box::new(crate::mtls::CertificateThumbprint::from_der(
b"conformance-fixture-certificate",
))),
#[cfg(feature = "consent")]
authentication: sample_authentication(),
}
}
#[cfg(test)]
#[path = "tests/storage_conformance.rs"]
mod tests;