use anyhow::Result;
use super::client_auth::AuthMethod;
use super::store::OAuthSession;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Revocation {
Revoked,
NoSession,
Failed(String),
}
pub fn token_to_revoke(session: &OAuthSession) -> (&str, &'static str) {
if session.refresh_token.is_empty() {
(&session.access_token, "access_token")
} else {
(&session.refresh_token, "refresh_token")
}
}
pub fn revoke_params(
method: AuthMethod,
client_id: &str,
assertion: Option<&str>,
token: &str,
) -> Result<Vec<(&'static str, String)>> {
let mut params = vec![("token", token.to_string())];
params.extend(super::client_auth::credential_params(
method, client_id, assertion,
)?);
Ok(params)
}
const REVOKE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5);
pub struct RevokeContext<'a> {
pub revocation_endpoint: Option<&'a str>,
pub client_id: &'a str,
pub auth_method: AuthMethod,
pub client_key: Option<&'a super::keys::SigningKey>,
pub deadline: std::time::Duration,
}
pub async fn sign_out(
pool: &sqlx::SqlitePool,
codec: &super::crypto::Codec,
http: &reqwest::Client,
ctx: &RevokeContext<'_>,
sub: &str,
now: i64,
) -> Revocation {
sign_out_with(pool, codec, sub, ctx.deadline, |session| async move {
revoke_tokens(pool, http, ctx, &session, now).await
})
.await
}
async fn sign_out_with<F, Fut>(
pool: &sqlx::SqlitePool,
codec: &super::crypto::Codec,
sub: &str,
deadline: std::time::Duration,
mut revoke: F,
) -> Revocation
where
F: FnMut(OAuthSession) -> Fut,
Fut: std::future::Future<Output = Revocation>,
{
let mut previous: Option<Revocation> = None;
for _ in 0..MAX_SIGN_OUT_ATTEMPTS {
let (session, version) = match super::store::get_session_versioned(pool, codec, sub).await {
Ok(Some(read)) => read,
Ok(None) => return previous.unwrap_or(Revocation::NoSession),
Err(err) => {
let _ = super::store::delete_session(pool, sub).await;
return Revocation::Failed(format!("reading the session: {err:#}"));
}
};
match bounded_then_delete(pool, sub, &version, deadline, revoke(session)).await {
Attempt::Done(outcome) => return outcome,
Attempt::Changed(outcome) => previous = Some(outcome),
}
}
Revocation::Failed(format!(
"the session kept changing while it was being signed out ({MAX_SIGN_OUT_ATTEMPTS} \
attempts, each overtaken by a refresh); its newest tokens were left in place"
))
}
pub(crate) async fn revoke_orphaned(
pool: &sqlx::SqlitePool,
http: &reqwest::Client,
ctx: &RevokeContext<'_>,
session: &OAuthSession,
now: i64,
) -> Revocation {
match tokio::time::timeout(ctx.deadline, revoke_tokens(pool, http, ctx, session, now)).await {
Ok(outcome) => outcome,
Err(_) => Revocation::Failed(format!(
"revocation did not finish within {:?}",
ctx.deadline
)),
}
}
pub(crate) const ORPHAN_REVOKE_DEADLINE: std::time::Duration = REVOKE_DEADLINE;
async fn revoke_tokens(
pool: &sqlx::SqlitePool,
http: &reqwest::Client,
ctx: &RevokeContext<'_>,
session: &OAuthSession,
now: i64,
) -> Revocation {
match try_revoke(pool, http, ctx, session, now).await {
Ok(()) => Revocation::Revoked,
Err(err) => Revocation::Failed(format!("{err:#}")),
}
}
async fn try_revoke(
pool: &sqlx::SqlitePool,
http: &reqwest::Client,
ctx: &RevokeContext<'_>,
session: &OAuthSession,
now: i64,
) -> Result<()> {
let endpoint = ctx.revocation_endpoint.ok_or_else(|| {
anyhow::anyhow!("the authorization server advertises no revocation endpoint")
})?;
let (token, _hint) = token_to_revoke(session);
let assertion = match ctx.auth_method {
AuthMethod::PrivateKeyJwt => {
let key = ctx.client_key.ok_or_else(|| {
anyhow::anyhow!("private_key_jwt requires the client signing key")
})?;
Some(super::client_auth::client_assertion(
key,
ctx.client_id,
&session.issuer,
now,
)?)
}
AuthMethod::None => None,
};
let params = revoke_params(ctx.auth_method, ctx.client_id, assertion.as_deref(), token)?;
let form: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
let key = super::keys::SigningKey::from_jwk_json(&session.dpop_key_jwk, "session")?;
let outcome = super::request::send_with_dpop(
http,
pool,
&super::request::DpopRequest {
endpoint: super::dpop::Endpoint::AuthorizationServer,
url: endpoint,
key: &key,
access_token: None,
body: super::request::DpopBody::Form(&form),
retry: super::request::Retry::Allowed,
},
)
.await?;
if !(200..300).contains(&outcome.status) {
anyhow::bail!("the revocation endpoint returned status {}", outcome.status);
}
Ok(())
}
pub async fn sign_out_discovering(
runtime: &super::runtime::OauthRuntime,
http: &reqwest::Client,
pool: &sqlx::SqlitePool,
sub: &str,
now: i64,
) -> Revocation {
let cache = EndpointCache::default();
let cache = &cache;
sign_out_with(
pool,
&runtime.codec,
sub,
2 * REVOKE_DEADLINE,
|session| async move {
revoke_at_own_issuer(runtime, http, pool, cache, &session, now).await
},
)
.await
}
type EndpointCache = std::sync::Mutex<Option<((String, String), Option<String>)>>;
async fn revoke_at_own_issuer(
runtime: &super::runtime::OauthRuntime,
http: &reqwest::Client,
pool: &sqlx::SqlitePool,
cache: &EndpointCache,
session: &OAuthSession,
now: i64,
) -> Revocation {
let key = (session.aud.clone(), session.issuer.clone());
let cached = cache
.lock()
.unwrap_or_else(|p| p.into_inner())
.as_ref()
.filter(|(for_pair, _)| *for_pair == key)
.map(|(_, endpoint)| endpoint.clone());
let endpoint = match cached {
Some(endpoint) => endpoint,
None => {
let endpoint = discover_revocation_endpoint(runtime, http, session).await;
*cache.lock().unwrap_or_else(|p| p.into_inner()) = Some((key, endpoint.clone()));
endpoint
}
};
revoke_tokens(
pool,
http,
&RevokeContext {
revocation_endpoint: endpoint.as_deref(),
client_id: &runtime.client_id,
auth_method: runtime.auth_method,
client_key: runtime.client_key.as_ref(),
deadline: REVOKE_DEADLINE,
},
session,
now,
)
.await
}
async fn discover_revocation_endpoint(
runtime: &super::runtime::OauthRuntime,
http: &reqwest::Client,
session: &OAuthSession,
) -> Option<String> {
let sub = &session.sub;
match tokio::time::timeout(
REVOKE_DEADLINE,
super::discovery::discover(
http,
&session.aud,
runtime.auth_method.as_str(),
Some(&session.issuer),
),
)
.await
{
Ok(Ok(server)) => server.revocation_endpoint,
Ok(Err(err)) => {
tracing::warn!(%err, %sub, "could not discover the revocation endpoint");
None
}
Err(_) => {
tracing::warn!(%sub, "discovering the revocation endpoint timed out");
None
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RevokeAllReport {
pub revoked: Vec<String>,
pub no_session: Vec<String>,
pub failed: Vec<(String, String)>,
pub late: Vec<String>,
}
pub async fn revoke_all(
runtime: &super::runtime::OauthRuntime,
http: &reqwest::Client,
pool: &sqlx::SqlitePool,
clock: impl FnMut() -> i64,
) -> Result<RevokeAllReport> {
revoke_all_with(pool, clock, |sub, now| async move {
sign_out_discovering(runtime, http, pool, &sub, now).await
})
.await
}
pub fn fit_to_revoke(runtime: &super::runtime::OauthRuntime) -> Result<()> {
let mut missing = Vec::new();
if runtime.auth_method != AuthMethod::PrivateKeyJwt {
missing.push(
"the confidential client (FEATHERREADER_PUBLIC_URL is loopback or unset, so this \
would revoke as the public dev client)",
);
}
if matches!(runtime.codec, super::crypto::Codec::Null) {
missing.push("an encryption key (FEATHERREADER_OAUTH_ENCRYPTION_KEY is unset)");
}
if runtime.client_key.is_none() {
missing.push("the signing key (FEATHERREADER_OAUTH_KEY_PATH)");
}
if missing.is_empty() {
Ok(())
} else {
anyhow::bail!(
"not the production OAuth client — missing {}. Run this inside the app's own \
environment",
missing.join("; ")
)
}
}
const OWN_JWKS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const OWN_JWKS_MAX_BYTES: usize = 64 * 1024;
async fn fetch_own_jwks(url: &str) -> Result<serde_json::Value> {
use anyhow::Context as _;
let parsed = url::Url::parse(url).with_context(|| format!("parsing {url}"))?;
if parsed.scheme() != "https" {
anyhow::bail!("the app's JWKS URL {url} must be https");
}
let builder = reqwest::Client::builder()
.user_agent(crate::USER_AGENT)
.redirect(reqwest::redirect::Policy::none())
.no_proxy()
.timeout(OWN_JWKS_TIMEOUT);
#[cfg(test)]
let builder = builder.add_root_certificate(
reqwest::Certificate::from_pem(crate::net::test_pki().ca_pem.as_bytes())
.context("parsing the test CA")?,
);
let client = builder.build().context("building the JWKS client")?;
let mut resp = client
.get(parsed)
.send()
.await
.with_context(|| format!("fetching {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("{url} answered {}", resp.status());
}
let mut body = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.with_context(|| format!("reading {url}"))?
{
if body.len() + chunk.len() > OWN_JWKS_MAX_BYTES {
anyhow::bail!("{url} is larger than {OWN_JWKS_MAX_BYTES} bytes");
}
body.extend_from_slice(&chunk);
}
serde_json::from_slice(&body).with_context(|| format!("{url} is not JSON"))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PreflightOptions {
pub sweep: bool,
pub accept_unreadable: bool,
}
pub async fn preflight(
runtime: &super::runtime::OauthRuntime,
pool: &sqlx::SqlitePool,
jwks_url: &str,
opts: PreflightOptions,
) -> Result<()> {
let PreflightOptions {
sweep,
accept_unreadable,
} = opts;
let subs = super::store::list_session_subs(pool).await?;
if subs.is_empty() {
return Ok(());
}
fit_to_revoke(runtime)?;
let mut readable = 0usize;
for sub in &subs {
if matches!(
super::store::get_session(pool, &runtime.codec, sub).await,
Ok(Some(_))
) {
readable += 1;
}
}
if readable == 0 {
if !accept_unreadable {
anyhow::bail!(
"none of the {} stored session(s) decrypts with this \
FEATHERREADER_OAUTH_ENCRYPTION_KEY — it is not the key the app wrote them \
with (wrong, or rotated). Signing out would delete every row unrevoked. \
If you KNOW every row is unreadable for a legitimate reason (only pre-AAD \
rows; the key was rotated with no logins since), re-run with \
--accept-unreadable: those tokens cannot be revoked by anyone and stay live \
until they expire",
subs.len()
);
}
tracing::warn!(
stored = subs.len(),
"--accept-unreadable: NO stored session decrypts. Every row will be deleted \
UNREVOKED and reported failed — those tokens stay live at their PDS until they \
expire. The client and signing-key checks still apply."
);
}
let key = runtime
.client_key
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no client signing key is loaded"))?;
let ours = key.thumbprint()?;
let served = match fetch_own_jwks(jwks_url).await {
Ok(doc) => doc,
Err(err) if sweep => {
tracing::warn!(
%err,
"sweep: the app's JWKS is unreachable (expected once the app is stopped); \
the signing key was checked on the main pass"
);
return Ok(());
}
Err(err) => {
return Err(err.context(format!(
"could not fetch the app's JWKS at {jwks_url} to confirm the signing key. \
The main pass runs while the app is serving, so this should be reachable; \
refusing rather than signing with a key no PDS may know"
)));
}
};
let matches = served
.get("keys")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.any(|jwk| {
jwk.get("kid").and_then(serde_json::Value::as_str) == Some(key.kid())
&& super::keys::SigningKey::public_thumbprint_of(&jwk.to_string()).ok()
== Some(ours.clone())
});
if !matches {
anyhow::bail!(
"the loaded signing key (FEATHERREADER_OAUTH_KEY_PATH, kid {:?}) is not the key \
the app serves at {jwks_url}. Every client assertion would be rejected and every \
row deleted unrevoked. Point FEATHERREADER_OAUTH_KEY_PATH at the app's own key",
key.kid()
);
}
Ok(())
}
async fn revoke_all_with<S, Fut>(
pool: &sqlx::SqlitePool,
mut clock: impl FnMut() -> i64,
mut sign_out: S,
) -> Result<RevokeAllReport>
where
S: FnMut(String, i64) -> Fut,
Fut: std::future::Future<Output = Revocation>,
{
let mut order: Vec<String> = Vec::new();
let mut outcomes: std::collections::HashMap<String, Revocation> = Default::default();
let mut late: Vec<String> = Vec::new();
let mut subs = super::store::list_session_subs(pool).await?;
let initial: std::collections::HashSet<String> = subs.iter().cloned().collect();
for pass in 0..=RE_LIST_PASSES {
for sub in subs {
if !outcomes.contains_key(&sub) {
order.push(sub.clone());
if !initial.contains(&sub) {
late.push(sub.clone());
}
}
let now = clock();
let outcome = sign_out(sub.clone(), now).await;
outcomes.insert(sub, outcome);
}
subs = super::store::list_session_subs(pool).await?;
if subs.is_empty() {
break;
}
if pass == RE_LIST_PASSES {
for sub in subs {
if !outcomes.contains_key(&sub) {
order.push(sub.clone());
}
let entry = outcomes.entry(sub).or_insert(Revocation::NoSession);
if !matches!(entry, Revocation::Failed(_)) {
*entry = Revocation::Failed(format!(
"still stored after {} passes (a login or refresh keeps \
re-creating it); not signed out",
RE_LIST_PASSES + 1
));
}
}
break;
}
}
let mut report = RevokeAllReport {
late,
..RevokeAllReport::default()
};
for sub in order {
match outcomes.remove(&sub) {
Some(Revocation::Revoked) => report.revoked.push(sub),
Some(Revocation::NoSession) | None => report.no_session.push(sub),
Some(Revocation::Failed(reason)) => report.failed.push((sub, reason)),
}
}
Ok(report)
}
const RE_LIST_PASSES: usize = 2;
const MAX_SIGN_OUT_ATTEMPTS: usize = 3;
#[derive(Debug)]
enum Attempt {
Done(Revocation),
Changed(Revocation),
}
async fn bounded_then_delete<F>(
pool: &sqlx::SqlitePool,
sub: &str,
version: &super::store::SessionVersion,
deadline: std::time::Duration,
attempt: F,
) -> Attempt
where
F: std::future::Future<Output = Revocation>,
{
let outcome = match tokio::time::timeout(deadline, attempt).await {
Ok(outcome) => outcome,
Err(_) => Revocation::Failed(format!(
"revocation did not finish within {deadline:?}; signing out locally anyway"
)),
};
match super::store::delete_session_if_unchanged(pool, sub, version).await {
Ok(true) => Attempt::Done(outcome),
Ok(false) => Attempt::Changed(outcome),
Err(err) => Attempt::Done(Revocation::Failed(format!(
"deleting the local session: {err:#}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session(access: &str, refresh: &str) -> OAuthSession {
OAuthSession {
sub: "did:plc:ewvi7nxzyoun6zhxrhs64oiz".into(),
issuer: "https://pds.example.com".into(),
aud: "https://pds.example.com".into(),
dpop_key_jwk: r#"{"kty":"EC"}"#.into(),
access_token: access.into(),
refresh_token: refresh.into(),
token_type: "DPoP".into(),
granted_scope: "atproto".into(),
expires_at: Some(1_700_000_000),
}
}
#[test]
fn the_refresh_token_is_preferred_over_the_access_token() {
let session = session("access-abc", "refresh-xyz");
let (token, hint) = token_to_revoke(&session);
assert_eq!(
token, "refresh-xyz",
"revoked the access token, leaving the refresh token live"
);
assert_eq!(hint, "refresh_token");
}
#[test]
fn an_absent_refresh_token_falls_back_to_the_access_token() {
let session = session("access-abc", "");
let (token, hint) = token_to_revoke(&session);
assert_eq!(token, "access-abc");
assert_eq!(hint, "access_token");
}
#[test]
fn a_public_client_sends_the_token_and_its_client_id() {
let params = revoke_params(AuthMethod::None, "http://localhost", None, "refresh-xyz")
.expect("a public client needs no assertion");
assert!(params.contains(&("token", "refresh-xyz".to_string())));
assert!(params.contains(&("client_id", "http://localhost".to_string())));
assert!(
!params
.iter()
.any(|(k, _)| k.starts_with("client_assertion")),
"a public client must not send an assertion it never registered: {params:?}"
);
}
#[test]
fn a_confidential_client_carries_its_assertion() {
let params = revoke_params(
AuthMethod::PrivateKeyJwt,
"https://feather-reader.com/oauth/client-metadata.json",
Some("the.assertion.jwt"),
"refresh-xyz",
)
.expect("an assertion was supplied");
assert!(params.contains(&("client_assertion", "the.assertion.jwt".to_string())));
}
#[test]
fn a_confidential_client_without_an_assertion_is_an_error() {
let err = revoke_params(AuthMethod::PrivateKeyJwt, "https://client", None, "tok")
.expect_err("must not send an unauthenticated revocation");
assert!(format!("{err:#}").contains("requires a client assertion"));
}
const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
const NOW: i64 = 1_700_000_000;
async fn db() -> (sqlx::SqlitePool, super::super::crypto::Codec) {
let pool = crate::store::init_url("sqlite::memory:").await.unwrap();
super::super::store::init_schema(&pool).await.unwrap();
(pool, super::super::crypto::Codec::new(Some(KEY)).unwrap())
}
async fn stored(pool: &sqlx::SqlitePool, codec: &super::super::crypto::Codec) -> OAuthSession {
let key = super::super::keys::SigningKey::generate("session");
let session = OAuthSession {
dpop_key_jwk: key.to_jwk_json().unwrap(),
..session("access-abc", "refresh-xyz")
};
super::super::store::put_session(pool, codec, &session)
.await
.unwrap();
session
}
const TEST_DEADLINE: std::time::Duration = std::time::Duration::from_millis(250);
fn ctx(endpoint: Option<&str>) -> RevokeContext<'_> {
RevokeContext {
revocation_endpoint: endpoint,
client_id: "http://localhost",
auth_method: AuthMethod::None,
client_key: None,
deadline: TEST_DEADLINE,
}
}
#[tokio::test]
async fn signing_out_deletes_the_local_session_even_when_revocation_fails() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let outcome = sign_out(
&pool,
&codec,
&reqwest::Client::new(),
&ctx(Some("http://127.0.0.1/oauth/revoke")),
DID,
NOW,
)
.await;
match &outcome {
Revocation::Failed(reason) => assert!(
reason.contains("forbidden (internal) address"),
"failed BEFORE reaching the network, so this proves nothing about a \
revocation failure: {reason}"
),
other => panic!("the loopback endpoint must not report success: {other:?}"),
}
assert!(
super::super::store::get_session(&pool, &codec, DID)
.await
.unwrap()
.is_none(),
"THE SESSION SURVIVED A FAILED REVOCATION — a signed-out user still has live credentials"
);
}
#[tokio::test]
async fn a_server_without_a_revocation_endpoint_still_signs_out_locally() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let outcome = sign_out(&pool, &codec, &reqwest::Client::new(), &ctx(None), DID, NOW).await;
match &outcome {
Revocation::Failed(reason) => assert!(
reason.contains("no revocation endpoint"),
"failed for the wrong reason: {reason}"
),
other => panic!("expected a failure, got {other:?}"),
}
assert!(super::super::store::get_session(&pool, &codec, DID)
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn a_hanging_attempt_does_not_hold_the_sign_out_open() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let (_, version) = super::super::store::get_session_versioned(&pool, &codec, DID)
.await
.unwrap()
.unwrap();
let started = std::time::Instant::now();
let attempt = super::bounded_then_delete(
&pool,
DID,
&version,
std::time::Duration::from_millis(50),
std::future::pending::<Revocation>(),
)
.await;
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"the bound did not fire"
);
let Attempt::Done(outcome) = attempt else {
panic!("the unchanged row was not deleted: {attempt:?}");
};
match &outcome {
Revocation::Failed(reason) => assert!(
reason.contains("did not finish within"),
"failed for the wrong reason: {reason}"
),
other => panic!("a never-resolving attempt must time out, got {other:?}"),
}
assert!(
super::super::store::get_session(&pool, &codec, DID)
.await
.unwrap()
.is_none(),
"the session survived a timed-out revocation"
);
}
#[tokio::test]
async fn an_unreadable_session_is_still_signed_out() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
.bind("https://evil.example")
.bind(DID)
.execute(&pool)
.await
.unwrap();
assert!(
super::super::store::get_session(&pool, &codec, DID)
.await
.is_err(),
"precondition: the row must be unreadable"
);
let outcome = sign_out(
&pool,
&codec,
&reqwest::Client::new(),
&ctx(Some("https://pds.example.com/oauth/revoke")),
DID,
NOW,
)
.await;
assert!(matches!(outcome, Revocation::Failed(_)), "got {outcome:?}");
let still_there: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session WHERE sub = ?")
.bind(DID)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
still_there, 0,
"an unreadable row survived a sign-out, so the account stays wedged"
);
}
fn runtime() -> super::super::runtime::OauthRuntime {
super::super::runtime::OauthRuntime::new(&crate::config::Config {
repo_backend: crate::metrics::Backend::Rust,
public_url: "http://127.0.0.1:8080".into(),
oauth: crate::config::OauthConfig {
encryption_key: Some(KEY.to_string()),
..crate::config::OauthConfig::default()
},
..crate::config::Config::default()
})
.expect("the test runtime must build")
}
#[tokio::test]
async fn the_production_sign_out_deletes_the_session() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let outcome =
sign_out_discovering(&runtime(), &reqwest::Client::new(), &pool, DID, NOW).await;
assert!(
matches!(outcome, Revocation::Failed(_)),
"an unreachable PDS must not report success: {outcome:?}"
);
assert!(
super::super::store::get_session(&pool, &codec, DID)
.await
.unwrap()
.is_none(),
"the production sign-out left the session behind"
);
}
#[tokio::test]
async fn the_production_sign_out_deletes_an_unreadable_session() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
.bind("https://evil.example")
.bind(DID)
.execute(&pool)
.await
.unwrap();
let outcome =
sign_out_discovering(&runtime(), &reqwest::Client::new(), &pool, DID, NOW).await;
assert!(matches!(outcome, Revocation::Failed(_)), "got {outcome:?}");
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session WHERE sub = ?")
.bind(DID)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows, 0, "account deletion would leave live tokens behind");
}
#[tokio::test]
async fn signing_out_without_a_session_is_idempotent() {
let (pool, codec) = db().await;
let outcome = sign_out(
&pool,
&codec,
&reqwest::Client::new(),
&ctx(Some("https://pds.example.com/oauth/revoke")),
DID,
NOW,
)
.await;
assert_eq!(outcome, Revocation::NoSession);
}
const SUBS: [&str; 3] = [
"did:plc:aaaaaaaaaaaaaaaaaaaaaaaa",
"did:plc:bbbbbbbbbbbbbbbbbbbbbbbb",
"did:plc:cccccccccccccccccccccccc",
];
async fn revoking_server(
revoke_replies: Vec<crate::net::TestResponse>,
) -> (
String,
String,
std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) {
let (addr, log) = crate::net::spawn_tls(move |addr| {
let port = addr.port();
let pds = format!("https://pds-e2e.test:{port}");
let issuer = format!("https://as-e2e.test:{port}");
let mut r = std::collections::HashMap::new();
r.insert(
"/.well-known/oauth-protected-resource".to_string(),
vec![crate::net::TestResponse::json(
200,
serde_json::json!({
"resource": pds,
"authorization_servers": [issuer],
})
.to_string(),
)],
);
r.insert(
"/.well-known/oauth-authorization-server".to_string(),
vec![crate::net::TestResponse::json(
200,
serde_json::json!({
"issuer": issuer,
"pushed_authorization_request_endpoint": format!("{issuer}/par"),
"authorization_endpoint": format!("{issuer}/authorize"),
"token_endpoint": format!("{issuer}/token"),
"revocation_endpoint": format!("{issuer}/revoke"),
"protected_resources": [pds],
"client_id_metadata_document_supported": true,
"require_pushed_authorization_requests": true,
"authorization_response_iss_parameter_supported": true,
"token_endpoint_auth_methods_supported": ["private_key_jwt", "none"],
"token_endpoint_auth_signing_alg_values_supported": ["ES256"],
"dpop_signing_alg_values_supported": ["ES256"],
"scopes_supported": ["atproto"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
})
.to_string(),
)],
);
r.insert("/revoke".to_string(), revoke_replies);
r
})
.await;
for h in ["pds-e2e.test", "as-e2e.test"] {
crate::net::test_host_override(h, addr);
}
let port = addr.port();
(
format!("https://pds-e2e.test:{port}"),
format!("https://as-e2e.test:{port}"),
log,
)
}
async fn store_sessions(
pool: &sqlx::SqlitePool,
codec: &super::super::crypto::Codec,
pds: &str,
issuer: &str,
) {
for sub in SUBS {
let key = super::super::keys::SigningKey::generate("session");
let session = OAuthSession {
sub: sub.into(),
issuer: issuer.into(),
aud: pds.into(),
dpop_key_jwk: key.to_jwk_json().unwrap(),
..session("access-abc", &format!("refresh-{sub}"))
};
super::super::store::put_session(pool, codec, &session)
.await
.unwrap();
}
}
async fn session_rows(pool: &sqlx::SqlitePool) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM oauth_session")
.fetch_one(pool)
.await
.unwrap()
}
fn revoke_requests(log: &std::sync::Mutex<Vec<String>>) -> Vec<String> {
log.lock()
.unwrap()
.iter()
.filter(|r| r.starts_with("POST /revoke"))
.cloned()
.collect()
}
#[tokio::test]
async fn revoke_all_revokes_every_session_at_its_server() {
let (pool, codec) = db().await;
let (pds, issuer, log) =
revoking_server(vec![crate::net::TestResponse::json(200, "{}")]).await;
store_sessions(&pool, &codec, &pds, &issuer).await;
let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
.await
.expect("listing the sessions");
let requests = revoke_requests(&log);
assert_eq!(
requests.len(),
3,
"one revocation request per session:\n{requests:#?}"
);
for sub in SUBS {
assert!(
requests
.iter()
.any(|r| r.contains(&format!("token=refresh-{}", sub.replace(':', "%3A")))),
"{sub}'s refresh token was never presented:\n{requests:#?}"
);
}
assert_eq!(report.revoked, SUBS.map(String::from).to_vec());
assert!(report.failed.is_empty(), "{:?}", report.failed);
assert!(report.no_session.is_empty());
assert_eq!(session_rows(&pool).await, 0, "rows survived a revoke-all");
}
#[tokio::test]
async fn one_failed_revocation_is_reported_and_the_rest_still_revoked() {
let (pool, codec) = db().await;
let (pds, issuer, log) = revoking_server(vec![
crate::net::TestResponse::json(200, "{}"),
crate::net::TestResponse::json(500, "{}"),
crate::net::TestResponse::json(200, "{}"),
])
.await;
store_sessions(&pool, &codec, &pds, &issuer).await;
let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
.await
.expect("listing the sessions");
assert_eq!(revoke_requests(&log).len(), 3, "a failure stopped the walk");
assert!(
report.late.is_empty(),
"the first walk missed sessions a re-list had to find: {:?}",
report.late
);
assert_eq!(
report.revoked,
vec![SUBS[0].to_string(), SUBS[2].to_string()]
);
assert_eq!(report.failed.len(), 1, "{:?}", report.failed);
assert_eq!(report.failed[0].0, SUBS[1]);
assert!(
report.failed[0].1.contains("status 500"),
"the reason was lost: {}",
report.failed[0].1
);
assert_eq!(
session_rows(&pool).await,
0,
"the failed session's row survived — the wipe would be the only thing removing it"
);
}
#[tokio::test]
async fn an_unreadable_row_fails_and_is_deleted_and_an_empty_store_is_empty() {
let (pool, codec) = db().await;
let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
.await
.unwrap();
assert_eq!(report, RevokeAllReport::default());
stored(&pool, &codec).await;
sqlx::query("UPDATE oauth_session SET issuer = ? WHERE sub = ?")
.bind("https://evil.example")
.bind(DID)
.execute(&pool)
.await
.unwrap();
let report = revoke_all(&runtime(), &reqwest::Client::new(), &pool, || NOW)
.await
.unwrap();
assert!(report.revoked.is_empty());
assert_eq!(report.failed.len(), 1);
assert_eq!(report.failed[0].0, DID);
assert!(
report.failed[0].1.contains("reading the session"),
"{}",
report.failed[0].1
);
assert_eq!(session_rows(&pool).await, 0, "the unreadable row survived");
}
fn confidential_runtime(tag: &str) -> super::super::runtime::OauthRuntime {
super::super::runtime::OauthRuntime::new(&crate::config::Config {
repo_backend: crate::metrics::Backend::Rust,
public_url: "https://feather-reader.com".into(),
oauth: crate::config::OauthConfig {
encryption_key: Some(KEY.to_string()),
key_path: std::env::temp_dir().join(format!(
"fr-revoke-test-key-{}-{tag}.json",
std::process::id()
)),
plc_directory: "https://plc.invalid".to_string(),
..crate::config::OauthConfig::default()
},
..crate::config::Config::default()
})
.expect("the confidential test runtime must build")
}
fn assertion_iat(raw: &str) -> i64 {
use base64::Engine as _;
let body = raw.split("\r\n\r\n").nth(1).expect("no request body");
let jwt = body
.split('&')
.find_map(|kv| kv.strip_prefix("client_assertion="))
.unwrap_or_else(|| panic!("no client_assertion in {body}"));
let payload = jwt.split('.').nth(1).expect("malformed assertion");
let json: serde_json::Value = serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.expect("assertion payload is not base64url"),
)
.expect("assertion payload is not JSON");
json["iat"].as_i64().expect("assertion has no iat")
}
#[tokio::test]
async fn each_revocation_takes_the_time_afresh() {
let (pool, codec) = db().await;
let (pds, issuer, log) =
revoking_server(vec![crate::net::TestResponse::json(200, "{}")]).await;
store_sessions(&pool, &codec, &pds, &issuer).await;
let mut tick = 0;
let clock = || {
let t = NOW + tick * 100;
tick += 1;
t
};
let report = revoke_all(
&confidential_runtime("clock"),
&reqwest::Client::new(),
&pool,
clock,
)
.await
.expect("listing the sessions");
assert_eq!(report.revoked.len(), 3, "{report:?}");
let iats: Vec<i64> = revoke_requests(&log)
.iter()
.map(|r| assertion_iat(r))
.collect();
assert_eq!(
iats,
vec![NOW, NOW + 100, NOW + 200],
"the assertions reused one timestamp — later ones would be expired on arrival"
);
}
async fn rotate_to(pool: &sqlx::SqlitePool, refresh: &str) {
let codec = super::super::crypto::Codec::new(Some(KEY)).unwrap();
let key = super::super::keys::SigningKey::generate("session");
let session = OAuthSession {
dpop_key_jwk: key.to_jwk_json().unwrap(),
..session("access-rotated", refresh)
};
super::super::store::put_session(pool, &codec, &session)
.await
.unwrap();
}
type Seen = std::sync::Arc<std::sync::Mutex<Vec<String>>>;
#[tokio::test]
async fn a_session_rotated_mid_sign_out_has_the_new_token_revoked_too() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let seen: Seen = Default::default();
let outcome = sign_out_with(&pool, &codec, DID, TEST_DEADLINE, |s| {
let (pool, seen) = (pool.clone(), seen.clone());
async move {
let first = {
let mut v = seen.lock().unwrap();
v.push(s.refresh_token.clone());
v.len() == 1
};
if first {
rotate_to(&pool, "refresh-R2").await;
}
Revocation::Revoked
}
})
.await;
assert_eq!(
*seen.lock().unwrap(),
vec!["refresh-xyz".to_string(), "refresh-R2".to_string()],
"the rotated token was never presented for revocation"
);
assert_eq!(outcome, Revocation::Revoked);
assert!(
super::super::store::get_session(&pool, &codec, DID)
.await
.unwrap()
.is_none(),
"the session survived a sign-out that revoked every version of it"
);
}
#[tokio::test]
async fn a_session_that_keeps_rotating_is_reported_and_left_in_place() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let seen: Seen = Default::default();
let outcome = sign_out_with(&pool, &codec, DID, TEST_DEADLINE, |s| {
let (pool, seen) = (pool.clone(), seen.clone());
async move {
let n = {
let mut v = seen.lock().unwrap();
v.push(s.refresh_token.clone());
v.len()
};
rotate_to(&pool, &format!("refresh-R{}", n + 1)).await;
Revocation::Revoked
}
})
.await;
let attempts = seen.lock().unwrap().len();
assert_eq!(attempts, 3, "the retries were not bounded at 3");
match &outcome {
Revocation::Failed(reason) => assert!(
reason.contains("kept changing"),
"failed for the wrong reason: {reason}"
),
other => panic!("a still-rotating session must not report success: {other:?}"),
}
let left = super::super::store::get_session(&pool, &codec, DID)
.await
.unwrap()
.expect("the newest token was deleted unrevoked");
assert_eq!(left.refresh_token, "refresh-R4");
}
async fn insert_raw(pool: &sqlx::SqlitePool, sub: &str) {
sqlx::query(
"INSERT OR REPLACE INTO oauth_session (sub, issuer, aud, dpop_key_jwk, \
access_token, refresh_token, token_type, granted_scope, expires_at) \
VALUES (?, 'https://as.invalid', 'https://pds.invalid', 'x', 'x', 'x', \
'DPoP', 'atproto', NULL)",
)
.bind(sub)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn a_session_created_during_the_walk_is_signed_out_by_a_re_list() {
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
let calls: Seen = Default::default();
let report = revoke_all_with(
&pool,
|| NOW,
|sub, _| {
let (pool, calls) = (pool.clone(), calls.clone());
async move {
let first = {
let mut c = calls.lock().unwrap();
c.push(sub.clone());
c.len() == 1
};
if first {
insert_raw(&pool, SUBS[1]).await;
}
super::super::store::delete_session(&pool, &sub)
.await
.unwrap();
Revocation::Revoked
}
},
)
.await
.unwrap();
assert_eq!(
*calls.lock().unwrap(),
vec![SUBS[0].to_string(), SUBS[1].to_string()],
"the session created mid-walk was never signed out"
);
assert_eq!(report.late, vec![SUBS[1].to_string()]);
assert_eq!(report.revoked.len(), 2);
assert_eq!(session_rows(&pool).await, 0);
}
#[tokio::test]
async fn the_re_list_is_bounded_and_reports_what_remains() {
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
let calls: Seen = Default::default();
let report = revoke_all_with(
&pool,
|| NOW,
|sub, _| {
let calls = calls.clone();
async move {
calls.lock().unwrap().push(sub);
Revocation::Revoked
}
},
)
.await
.unwrap();
assert_eq!(
calls.lock().unwrap().len(),
3,
"not bounded at 1 + 2 passes"
);
let still = report
.failed
.iter()
.find(|(sub, _)| sub == SUBS[0])
.expect("a session still stored after every pass was not reported");
assert!(still.1.contains("still stored"), "{}", still.1);
assert_eq!(
report.failed.len(),
1,
"one entry per DID: {:?}",
report.failed
);
assert!(
report.revoked.is_empty(),
"a DID still stored at the end was ALSO reported revoked: {:?}",
report.revoked
);
assert!(
report.late.is_empty(),
"a row present from the start is not one that appeared during the walk"
);
}
#[tokio::test]
async fn a_did_that_fails_then_succeeds_is_reported_revoked_only() {
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
let calls: Seen = Default::default();
let report = revoke_all_with(
&pool,
|| NOW,
|sub, _| {
let (pool, calls) = (pool.clone(), calls.clone());
async move {
let n = {
let mut c = calls.lock().unwrap();
c.push(sub.clone());
c.len()
};
if n == 1 {
return Revocation::Failed("kept changing".into());
}
super::super::store::delete_session(&pool, &sub)
.await
.unwrap();
Revocation::Revoked
}
},
)
.await
.unwrap();
assert_eq!(calls.lock().unwrap().len(), 2);
assert_eq!(report.revoked, vec![SUBS[0].to_string()]);
assert!(
report.failed.is_empty(),
"a DID revoked by the re-list is still reported failed: {:?}",
report.failed
);
assert!(
report.late.is_empty(),
"mislabelled as late: {:?}",
report.late
);
}
#[tokio::test]
async fn every_session_is_attempted_in_the_first_walk_despite_failures() {
let (pool, _) = db().await;
let subs = [
"did:plc:aaaaaaaaaaaaaaaaaaaaaaaa",
"did:plc:bbbbbbbbbbbbbbbbbbbbbbbb",
"did:plc:cccccccccccccccccccccccc",
"did:plc:dddddddddddddddddddddddd",
];
for sub in subs {
insert_raw(&pool, sub).await;
}
let report = revoke_all_with(
&pool,
|| NOW,
|sub, _| {
let pool = pool.clone();
async move {
super::super::store::delete_session(&pool, &sub)
.await
.unwrap();
Revocation::Failed("the PDS said no".into())
}
},
)
.await
.unwrap();
assert_eq!(report.failed.len(), 4, "{:?}", report.failed);
assert!(
report
.failed
.iter()
.all(|(_, reason)| reason == "the PDS said no"),
"a session was never attempted: {:?}",
report.failed
);
}
#[tokio::test]
async fn a_did_that_fails_every_pass_is_one_failed_entry() {
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
let report = revoke_all_with(
&pool,
|| NOW,
|_, _| async { Revocation::Failed("kept changing".into()) },
)
.await
.unwrap();
assert_eq!(
report.failed.len(),
1,
"the same DID was counted once per pass: {:?}",
report.failed
);
assert_eq!(report.failed[0].0, SUBS[0]);
assert!(report.revoked.is_empty() && report.late.is_empty());
}
#[tokio::test]
async fn a_session_deleted_mid_sign_out_reports_the_revocation() {
let (pool, codec) = db().await;
stored(&pool, &codec).await;
let seen: Seen = Default::default();
let outcome = sign_out_with(&pool, &codec, DID, TEST_DEADLINE, |s| {
let (pool, seen) = (pool.clone(), seen.clone());
async move {
seen.lock().unwrap().push(s.refresh_token.clone());
super::super::store::delete_session(&pool, DID)
.await
.unwrap();
Revocation::Revoked
}
})
.await;
assert_eq!(outcome, Revocation::Revoked);
assert_eq!(seen.lock().unwrap().len(), 1, "retried a row that was gone");
}
async fn jwks_server(doc: Option<String>) -> String {
let (addr, _log) = crate::net::spawn_tls(move |_| {
let mut r = std::collections::HashMap::new();
if let Some(doc) = doc {
r.insert(
"/oauth/jwks.json".to_string(),
vec![crate::net::TestResponse::json(200, doc)],
);
}
r
})
.await;
format!("https://localhost:{}/oauth/jwks.json", addr.port())
}
async fn pool_with_readable_session(codec_key: &str) -> sqlx::SqlitePool {
let (pool, _) = db().await;
let codec = super::super::crypto::Codec::new(Some(codec_key)).unwrap();
stored(&pool, &codec).await;
pool
}
#[tokio::test]
async fn a_wrong_encryption_key_is_refused_by_the_preflight() {
let rt = confidential_runtime("wrongenc");
let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let pool = pool_with_readable_session(other).await;
let url = jwks_server(Some(
rt.client_key
.as_ref()
.unwrap()
.jwks_document()
.unwrap()
.to_string(),
))
.await;
let err = preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: false,
accept_unreadable: false,
},
)
.await
.expect_err("a key that decrypts nothing was accepted");
assert!(format!("{err:#}").contains("ENCRYPTION_KEY"), "{err:#}");
assert_eq!(
session_rows(&pool).await,
1,
"the preflight deleted something"
);
}
#[tokio::test]
async fn some_unreadable_rows_do_not_fail_the_preflight() {
let rt = confidential_runtime("someunread");
let pool = pool_with_readable_session(KEY).await;
insert_raw(&pool, SUBS[1]).await;
let url = jwks_server(Some(
rt.client_key
.as_ref()
.unwrap()
.jwks_document()
.unwrap()
.to_string(),
))
.await;
preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: false,
accept_unreadable: false,
},
)
.await
.expect("one readable row proves the key");
}
#[tokio::test]
async fn a_signing_key_the_app_does_not_serve_is_refused() {
let rt = confidential_runtime("wrongsig");
let pool = pool_with_readable_session(KEY).await;
let stranger = super::super::keys::SigningKey::generate(super::super::runtime::CLIENT_KID);
let url = jwks_server(Some(stranger.jwks_document().unwrap().to_string())).await;
for sweep in [false, true] {
let err = preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep,
accept_unreadable: false,
},
)
.await
.expect_err("a signing key the PDSes have never seen was accepted");
assert!(
format!("{err:#}").contains("is not the key the app serves"),
"{err:#}"
);
}
assert_eq!(session_rows(&pool).await, 1);
}
#[tokio::test]
async fn the_served_signing_key_passes_the_preflight() {
let rt = confidential_runtime("rightsig");
let pool = pool_with_readable_session(KEY).await;
let url = jwks_server(Some(
rt.client_key
.as_ref()
.unwrap()
.jwks_document()
.unwrap()
.to_string(),
))
.await;
preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: false,
accept_unreadable: false,
},
)
.await
.expect("the served key was refused");
}
#[tokio::test]
async fn an_unreachable_jwks_refuses_the_main_pass_but_not_the_sweep() {
let rt = confidential_runtime("nojwks");
let pool = pool_with_readable_session(KEY).await;
let url = jwks_server(None).await;
let err = preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: false,
accept_unreadable: false,
},
)
.await
.expect_err("an unverifiable signing key was accepted on the main pass");
assert!(format!("{err:#}").contains("could not fetch"), "{err:#}");
preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: true,
accept_unreadable: false,
},
)
.await
.expect("the sweep cannot reach a stopped app's JWKS, and must not need to");
}
#[tokio::test]
async fn a_null_codec_runtime_is_refused_even_when_the_rest_passes() {
let rt = super::super::runtime::OauthRuntime::new(&crate::config::Config {
repo_backend: crate::metrics::Backend::Rust,
public_url: "https://feather-reader.com".into(),
oauth: crate::config::OauthConfig {
encryption_key: None,
key_path: std::env::temp_dir().join(format!(
"fr-revoke-test-key-{}-nullcodec.json",
std::process::id()
)),
plc_directory: "https://plc.invalid".to_string(),
..crate::config::OauthConfig::default()
},
..crate::config::Config::default()
})
.unwrap();
assert!(matches!(rt.codec, super::super::crypto::Codec::Null));
let (pool, _) = db().await;
stored(&pool, &rt.codec).await;
let url = jwks_server(Some(
rt.client_key
.as_ref()
.unwrap()
.jwks_document()
.unwrap()
.to_string(),
))
.await;
let err = preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: false,
accept_unreadable: false,
},
)
.await
.expect_err("the Null codec was accepted");
assert!(format!("{err:#}").contains("encryption key"), "{err:#}");
}
#[tokio::test]
async fn an_empty_store_needs_no_preflight() {
let rt = confidential_runtime("emptypre");
let (pool, _) = db().await;
preflight(
&rt,
&pool,
"https://unreachable.invalid/oauth/jwks.json",
PreflightOptions::default(),
)
.await
.expect("an empty store was refused");
}
#[tokio::test]
async fn a_session_that_moves_issuer_mid_sign_out_is_revoked_at_its_own_issuer() {
let (pool, codec) = db().await;
let ok = || vec![crate::net::TestResponse::json(200, "{}")];
let (pds_a, iss_a, log_a) = revoking_server(ok()).await;
let (pds_b, iss_b, log_b) = revoking_server(ok()).await;
assert_ne!(iss_a, iss_b, "precondition: two distinct issuers");
let at = |pds: &str, iss: &str, refresh: &str| OAuthSession {
sub: DID.into(),
issuer: iss.into(),
aud: pds.into(),
dpop_key_jwk: super::super::keys::SigningKey::generate("session")
.to_jwk_json()
.unwrap(),
..session("access", refresh)
};
super::super::store::put_session(&pool, &codec, &at(&pds_a, &iss_a, "refresh-A"))
.await
.unwrap();
let moved = at(&pds_b, &iss_b, "refresh-B");
let (rt, http, cache) = (runtime(), reqwest::Client::new(), EndpointCache::default());
let (rt, http, cache, pool_ref) = (&rt, &http, &cache, &pool);
let first = std::sync::atomic::AtomicBool::new(true);
let (first, moved) = (&first, &moved);
let outcome = sign_out_with(
&pool,
&codec,
DID,
std::time::Duration::from_secs(10),
|s| async move {
let r = revoke_at_own_issuer(rt, http, pool_ref, cache, &s, NOW).await;
if first.swap(false, std::sync::atomic::Ordering::SeqCst) {
let codec = super::super::crypto::Codec::new(Some(KEY)).unwrap();
super::super::store::put_session(pool_ref, &codec, moved)
.await
.unwrap();
}
r
},
)
.await;
let a = revoke_requests(&log_a).join("\n");
let b = revoke_requests(&log_b).join("\n");
assert!(
a.contains("token=refresh-A"),
"the first grant was never revoked:\n{a}"
);
assert!(
!a.contains("refresh-B"),
"the NEW grant's refresh token was sent to the OLD authorization server:\n{a}"
);
assert!(
b.contains("token=refresh-B"),
"the new grant was never revoked at its own issuer:\n{b}"
);
assert_eq!(outcome, Revocation::Revoked);
assert_eq!(session_rows(&pool).await, 0);
}
async fn local_jwks(doc: String) -> String {
let (addr, _log) = crate::net::spawn_tls(move |_| {
let mut r = std::collections::HashMap::new();
r.insert(
"/oauth/jwks.json".to_string(),
vec![crate::net::TestResponse::json(200, doc)],
);
r
})
.await;
format!("https://localhost:{}/oauth/jwks.json", addr.port())
}
#[tokio::test]
async fn a_jwks_on_a_loopback_address_passes_the_preflight() {
let rt = confidential_runtime("loopjwks");
let pool = pool_with_readable_session(KEY).await;
let url = local_jwks(
rt.client_key
.as_ref()
.unwrap()
.jwks_document()
.unwrap()
.to_string(),
)
.await;
preflight(&rt, &pool, &url, PreflightOptions::default())
.await
.expect("the app's own JWKS on loopback was refused");
}
#[tokio::test]
async fn a_plain_http_jwks_url_is_refused() {
let rt = confidential_runtime("httpjwks");
let pool = pool_with_readable_session(KEY).await;
let err = preflight(
&rt,
&pool,
"http://localhost:9/oauth/jwks.json",
PreflightOptions::default(),
)
.await
.expect_err("an http JWKS was accepted");
assert!(format!("{err:#}").contains("https"), "{err:#}");
}
#[tokio::test]
async fn accept_unreadable_passes_an_all_unreadable_store() {
let rt = confidential_runtime("acceptok");
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
insert_raw(&pool, SUBS[1]).await;
let url = local_jwks(
rt.client_key
.as_ref()
.unwrap()
.jwks_document()
.unwrap()
.to_string(),
)
.await;
let refused = preflight(&rt, &pool, &url, PreflightOptions::default())
.await
.expect_err("without the flag an all-unreadable store must be refused");
assert!(
format!("{refused:#}").contains("--accept-unreadable"),
"{refused:#}"
);
let accept = PreflightOptions {
sweep: false,
accept_unreadable: true,
};
preflight(&rt, &pool, &url, accept)
.await
.expect("the override did not override");
assert_eq!(
session_rows(&pool).await,
2,
"the preflight deleted something"
);
}
#[tokio::test]
async fn accept_unreadable_does_not_bypass_the_signing_key_check() {
let rt = confidential_runtime("acceptsig");
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
let stranger = super::super::keys::SigningKey::generate(super::super::runtime::CLIENT_KID);
let url = local_jwks(stranger.jwks_document().unwrap().to_string()).await;
let err = preflight(
&rt,
&pool,
&url,
PreflightOptions {
sweep: false,
accept_unreadable: true,
},
)
.await
.expect_err("--accept-unreadable bypassed the signing-key check");
assert!(
format!("{err:#}").contains("is not the key the app serves"),
"{err:#}"
);
}
#[tokio::test]
async fn accept_unreadable_does_not_bypass_the_client_check() {
let (pool, _) = db().await;
insert_raw(&pool, SUBS[0]).await;
let err = preflight(
&runtime(),
&pool,
"https://localhost:9/oauth/jwks.json",
PreflightOptions {
sweep: true,
accept_unreadable: true,
},
)
.await
.expect_err("--accept-unreadable bypassed the client check");
assert!(format!("{err:#}").contains("public dev client"), "{err:#}");
}
}